Skip to Content
raavl

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(), and createRegionFromSelection(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 scheduling

Range 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.

On this page