Optional modules
Compose a timeline, minimap, undo/redo history, and magnetic snapping.
These modules have independent ESM entry points and no registration system or mandatory runtime dependencies. Import only what you use:
import { mountTimeline } from 'raavl/timeline'
import { mountMinimap } from 'raavl/minimap'
import { createHistory, recordHistory, undoHistory, redoHistory } from 'raavl/history'
import { createSnapper } from 'raavl/snapping'The main raavl and raavl/react entries do not import these modules. All can be
imported during SSR; mounting DOM modules must happen in the browser.
Viewport contract
Every WaveformView now provides:
getViewport(): cached, frozen{ sourceId, duration, start, end, width, pixelsPerSecond, disposed }. Times use seconds; width uses CSS pixels.subscribeViewport(callback): returns an unsubscribe function. Geometry is published after the first draw and when viewport state changes. Unchanged snapshots retain identity. The subscription does not immediately invoke you.scrollTo(seconds): positions the left edge, clamped to the last complete visible window. Requires finite, nonnegative seconds. Does not seek playback.
Initial geometry is zero until layout. Disposal publishes disposed: true and
clears listeners; commands and new subscriptions then throw DISPOSED. Set
viewport.follow: 'off' when application navigation should control scrolling
while audio plays. Native scrolling, resizing, and zoom updates synchronize on
the next waveform draw; scrollTo also publishes immediately.
React exposes onView(view | null), alongside onPlayer. It reports the committed
view and null at teardown. Keep the callback stable, such as a state setter, and
mount accessories from an effect depending on that view. Appearance changes do
not recreate it. The existing forwarded ref still exposes the player.
Timeline
const timeline = mountTimeline(timelineHost, {
view,
minSpacing: 80,
formatTime: seconds => `${seconds.toFixed(1)}s`,
})
// Later: timeline.dispose()minSpacing defaults to 80 CSS pixels (allowed range 16–1,000). Ticks adapt to
zoom with 1/2/5 intervals and only cover the visible time range. The default
exported formatTime formats minutes, hours, and fractional seconds. The ruler
is visual and aria-hidden; the waveform/minimap already expose time accessibly.
Use a container aligned to the waveform with the same width. To change options,
dispose and remount. No full-recording DOM strip is allocated.
Style .raavl-timeline, .raavl-timeline-tick, and --raavl-timeline-color.
Long custom labels may overlap; choose an appropriate spacing.
Minimap
const minimap = mountMinimap(minimapHost, {
view,
player, // or data for a data-only view, never both
height: 64,
label: 'Recording viewport',
})
// minimap.refreshTheme() after changing CSS canvas colors
// minimap.dispose() during teardownIt reuses the existing fit-width waveform renderer and highlights the main view's visible window. It borrows the player and never fetches or decodes another copy of the audio. Match its source to the main view. Data-only minimaps retain their supplied immutable data; remount when replacing it.
Drag the highlighted window, click/tap elsewhere to center it, or focus the slider and use Left/Right (10% of a window), Shift+Left/Right (one window), Home or End. Navigation changes the view only. The slider disables for unknown, mismatched, or disposed main views. Disposal releases captures and subscriptions. The underlying visual waveform is inert and hidden from accessibility APIs so only one interactive viewport slider is exposed.
Dispose minimap and timeline before disposing the main view. They do not own that view, its player, or annotation stores. Main-view disposal disables existing accessories, but you still dispose them to remove their resources and DOM.
Style .raavl-minimap, .raavl-minimap-control, .raavl-minimap-window,
--raavl-minimap-window-color, and --raavl-focus-color. Canvas colors use the
existing waveform CSS tokens. The supplied stylesheet is optional.
Undo/redo
History is deliberately independent of a player, store, or framework:
let history = createHistory(initialDocument, { limit: 100 })
history = recordHistory(history, acceptedDocument)
const undone = undoHistory(history)
// Apply undone.present to your application/store, then adopt undone on success.
const redone = redoHistory(undone)The functions validate/copy recorded documents, preserve immutable old states,
and return { past, present, future, limit }. No mutation, automatic store
subscription, keyboard interception, or audio resource ownership is involved.
Empty undo/redo and identical recordings return the same history object. A new
edit after undo clears redo. A different sourceId starts a new history; use
createHistory explicitly to reset for a new revision of the same source.
Record only accepted documents. In controlled React, atomically check
change.baseRevision against current state before recording the document and
incrementing the controlled revision. Undo/redo also changes controlled data, so
increment the revision for those operations too. A rejected proposal must not
enter history. The browser example's React snippet demonstrates this pattern.
For an uncontrolled store, subscribe with onCommit. For restoration:
const next = undoHistory(history)
if (next !== history) {
restoring = true // your onCommit listener skips recordHistory while true
try {
annotations.replaceDocument(next.present) // may reject current bounds/rules
history = next // advance only after success
} finally {
restoring = false
}
}Undo restores full documents, including locks/labels, using replacement semantics. It does not bypass store validation. A gesture already emits one commit on release, so it creates one history step. Keyboard repeats and API calls each create a step; transaction grouping/coalescing is not implemented. The default limit is 100 undo entries, configurable to a positive integer. These are full document snapshots, not patches; use smaller limits for dense annotation documents. No audio is stored.
Magnetic snapping
const snapper = createSnapper({
tolerancePixels: 8,
markers: true,
regionBoundaries: true,
grid: 0.1,
playhead: () => player.getCurrentTime(),
targets: () => [1.25, 3.75],
})
view.update({ snapAnnotation: snapper })
// React: <Waveform snapAnnotation={snapper} ... /> (memoize it)Defaults: 8 CSS pixels, markers and region boundaries enabled, grid disabled. Callbacks supply current playhead/custom times; invalid or out-of-duration targets are ignored. The nearest target inside tolerance wins; ties choose the earlier time. The annotation being edited cannot snap to itself. Tolerance converts to seconds using current zoom. Outside tolerance the pointer coordinate is unchanged.
The callback replaces grid snapping for pointer edits only, including range
selection. Keyboard retains annotationRules.snap and arrow-key semantics so
magnetism cannot trap keyboard movement. Region movement snaps its start and
preserves length; resizing snaps the edited boundary; marker movement snaps its
time. Length and duration constraints take precedence over snapping. No target
highlight, hysteresis, automatic zero-crossing, or beat analysis is implemented.
For a custom policy, pass any pure AnnotationSnapper(time, context) returning a
finite time. Context contains document, excludeId, duration, and
pixelsPerSecond. A nonfinite result falls back to the raw time. A custom
callback should not mutate stores, allocate resources, or throw during gestures.
Try the modules together
Open the React composition or the JavaScript composition to try the timeline, minimap, undo/redo history, and magnetic snapping together.
Rename a region, undo and redo an edit, resize a region near the cue at 8 seconds, pan with the minimap, and switch audio sources. Switching between React and JavaScript restarts the demo with the selected implementation and resets your edits.
Source: examples/live/ModulesEditor.tsx.