Regions and markers
Source-bound annotations, controlled editing, keyboard interactions, and validated JSON.
Data and validation
import { createAnnotations, parseAnnotations, stringifyAnnotations } from 'raavl/regions'
const initialDocument = {
version: 1 as const,
sourceId: 'episode-1',
regions: [{ id: 'intro', start: 0.5, end: 3, label: 'Introduction' }],
markers: [{ id: 'cue', time: 8, label: 'Cue' }],
}
const annotations = createAnnotations({ document: initialDocument, rules: { minLength: 0.1 } })Regions require finite 0 <= start < end in seconds. Markers are explicit point
annotations with a finite nonnegative time. IDs are unique across both arrays.
Optional fields: label, locked, and JSON metadata; regions additionally
accept a zero-based channel (omitted means all channels).
Validation copies and deeply freezes documents, including metadata. DOM nodes, functions, class instances, cycles, nonfinite values, and unsupported versions are rejected. This format is annotation JSON, not audio or waveform peaks.
Annotations can exist before audio metadata. Once attached to a view, the store
receives duration. Out-of-bounds items are retained, reported in invalidIds,
and excluded from overlays. Known-invalid channel indices are also excluded by
the view. Correct or remove invalid items explicitly: coordinates are not silently
clamped in saved data. Editing gestures are constrained to recording bounds.
A document belongs to sourceId. Views hide annotations when the player's source
differs, and restore them when that source returns. Switching audio never deletes
the document. The caller must supply the correct document for each source; a view
does not maintain a global document cache.
Vanilla: library-owned committed data
const view = mountWaveform(container, {
player,
annotations,
interaction: { drag: 'select' },
annotationRules: { snap: 0.1, minLength: 0.1 },
renderRegionLabel(region) {
const label = document.createElement('strong') // browser document, not annotation data
label.textContent = region.label ?? region.id
return label
},
onSelectionChange(selection) { console.log('Temporary range', selection) },
onAnnotationSelect(id) { console.log('Selected annotation', id) },
onAnnotationError(error) { console.error(error) },
})
const stopSaving = annotations.onCommit((next, change) => {
// Application code owns persistence and handles storage failures.
console.log(stringifyAnnotations(next), change)
})
annotations.addRegion({ id: 'quote', start: 4, end: 6, label: 'Quote' })
annotations.updateRegion('quote', { end: 6.5 })
annotations.addMarker({ id: 'beat', time: 9 })
annotations.updateMarker('beat', { time: 9.5 })
annotations.remove('beat')
// Teardown: detach the view before disposing a borrowed store/player.
// view.dispose(); stopSaving(); annotations.dispose(); player.dispose()The browser document global above must not be shadowed by a variable containing
annotation data. A label renderer may alternatively return { element, dispose }
for custom cleanup. Label content is presentation-only and is not serialized.
Label slots are intended for noninteractive text/visual content inside the region
button; put editable inputs in your application's inspector.
getSnapshot() returns { document, revision, duration, invalidIds }.
subscribe(callback) observes committed snapshots and validation-context changes.
onCommit(callback) observes committed data changes in an uncontrolled store.
Both return unsubscribe functions. setDuration(number | null) can supply or
clear duration without a mounted view. dispose() is idempotent.
Store rules enforce minLength/maxLength on edited regions. View
annotationRules constrain pointer and keyboard gestures, and optionally snap to
a time grid. Pass matching length rules to both when using a borrowed store.
Programmatic updates/imported coordinates are never implicitly snapped. Overlaps
are allowed. Channel-specific styling follows supplied waveform channel data.
Controlled annotations
let value = initialDocument
let revision = 0
function onCommit(next, change) {
if (change.baseRevision !== revision) return
value = next
revision += 1
view.update({ annotations: { value, revision, onCommit } })
}
view.update({ annotations: { value, revision, onCommit } })In this mode onCommit is a proposal, and the application owns acceptance.
Publish a new document/revision to accept it. Ignoring the callback rejects the
edit; transient geometry reverts to the committed document. A changed document
requires a changed revision. External revisions cancel an active gesture.
During a drag, only overlay geometry changes. One proposal is emitted on release.
Pointer cancellation or Escape produces no commit. Change metadata contains
operation, origin, affected ids, and baseRevision. An external controlled
store is also available through createAnnotations({ value, revision, onCommit })
and setControlled({ value, revision, onCommit }).
React
const [state, setState] = useState({ value: initialDocument, revision: 0 })
<Waveform
source={source}
annotations={state.value}
annotationsRevision={state.revision}
onAnnotationsCommit={(next, change) => {
if (change.baseRevision !== state.revision) return
setState({ value: next, revision: state.revision + 1 })
}}
interaction={{ drag: 'select' }}
annotationRules={{ snap: 0.1, minLength: 0.1 }}
renderRegionLabel={region => <strong>{region.label}</strong>}
onSelectionChange={setSelection}
onAnnotationSelect={setSelectedId}
/>For internally owned state, use defaultAnnotations={initialDocument} and
onAnnotationsCommit. Defaults initialize once; subsequent changes use a
controlled document or a store. To borrow an existing store, use
annotationStore={annotations}. These three ownership modes are mutually
exclusive. useAnnotations(store) subscribes to a store snapshot.
React renders custom label content through portals. The library owns the overlay wrapper and geometry; React owns the portal children. Content can unmount when an annotation changes or disappears. Keep durable editor state in the document or application, not inside a transient label. React DOM is an optional peer alongside React for the React entry point; vanilla entry points import neither.
Selection, editing, and playback
interaction.drag: 'select'creates a temporary range over empty waveform space. It does not create a region automatically or seek playback on release.- Vanilla views expose
getSelection(),clearSelection(), andcreateRegionFromSelection(id, label?). React applications use the selection callback to add a region to their controlled document/store. - Clicking an annotation selects it, without starting audio. Its body moves it; its handles resize it. Markers can be moved.
- Arrow keys move the focused annotation or handle. The default keyboard step is 0.1 seconds; Shift makes it 1 second. A configured snap interval replaces the base step, with Shift using ten intervals. Delete/Backspace removes an unlocked annotation. Escape cancels the current gesture/selection.
- Locked items can be selected but cannot be moved, resized, or removed. Unlock through the store/inspector. The accessible list below the waveform selects annotations and focuses their overlay controls.
await player.playRange({ start: 2, end: 5 })
await player.playRange({ start: 2, end: 5, loop: true })
player.pause() // also clears range schedulingRange playback is a media-element convenience, not sample-accurate scheduling. It disables full-recording looping. Pause, explicit seek, source replacement, and disposal clear range scheduling. Browser timer throttling and media seeking can introduce gaps or overshoot, especially in background tabs. The promise resolves when playback starts, not when the range finishes.
Persistence and customization
stringifyAnnotations(data) emits validated JSON. parseAnnotations(text, expectedSourceId?) validates imported JSON and optionally checks asset identity.
Use store.replaceDocument(parsed) or a new controlled revision to publish it.
Parsing does not know recording duration; committing to a duration-aware store
also validates bounds. Store persistence in your application, not in the library.
The annotation editor includes localStorage save/load, JSON editing/import, and JSON download.
It saves no audio. Its storage key is raavl-annotations-demo.
Style ordinary DOM classes: .raavl-region, .raavl-region-body,
.raavl-region-label, .raavl-region-handle-start, .raavl-region-handle-end,
.raavl-marker, .raavl-selection, and .raavl-annotation-list. Tokens include
--raavl-region-color, --raavl-handle-color, --raavl-marker-color,
--raavl-selection-color, and --raavl-label-color. Selected overlays expose
data-selected="true"; annotation wrappers/buttons expose data-raavl-annotation.
Boundaries
The annotation store does not add overlap policies, multitrack editing, automatic
label collision avoidance, or edge auto-scroll during dragging. The renderer culls
overlays to the visible time range and paginates the list in groups of 100.
Focused and dragged overlays remain mounted. Custom label slots unmount offscreen;
keep durable state outside those slots. Style .raavl-annotation-pagination and
set --raavl-annotation-list-height to customize the list (default 12rem).
See scale measurements and browser limits. Undo/redo and magnetic
snapping are available as separately imported modules.