Using the Engine Directly
The @waveform-playlist/engine package is framework-agnostic — no React, Vue, or Svelte dependencies. It provides:
- A stateful
PlaylistEngineclass with event-driven state management - A pluggable
PlayoutAdapterinterface for any audio backend - Pure functions for clip/timeline operations
Use the engine directly when building with Svelte, Vue, vanilla JS, or when you need headless timeline editing (e.g., server-side or testing).
Installation​
npm install @waveform-playlist/engine @waveform-playlist/core
The engine's only peer dependency is @waveform-playlist/core (data model types).
Quick Start​
import { PlaylistEngine } from '@waveform-playlist/engine';
import { createTrack, createClipFromSeconds } from '@waveform-playlist/core';
// 1. Create an engine (use AudioContext.sampleRate for the hardware rate)
const engine = new PlaylistEngine({
sampleRate: 48000, // Match your AudioContext.sampleRate
samplesPerPixel: 1000,
});
// 2. Subscribe to state changes
engine.on('statechange', (state) => {
console.log('Tracks:', state.tracks.length);
console.log('Duration:', state.duration, 'seconds');
console.log('Playing:', state.isPlaying);
});
// 3. Add tracks
const track = createTrack({
name: 'Vocals',
clips: [
createClipFromSeconds({
audioBuffer: myAudioBuffer,
startTime: 0,
name: 'Intro',
}),
],
});
engine.setTracks([track]);
// 4. Control playback (requires a PlayoutAdapter — see below)
engine.play();
engine.pause();
engine.stop();
// 5. Clean up
engine.dispose();
:::tip Headless editing
The adapter option is optional. Without an adapter, the engine handles all state management (tracks, selection, zoom, etc.) but playback methods are no-ops. This is useful for headless editing, testing, or building a custom audio layer.
:::
PlaylistEngine​
Constructor Options​
const engine = new PlaylistEngine(options?: PlaylistEngineOptions);
| Option | Type | Default | Description |
|---|---|---|---|
adapter | PlayoutAdapter | null | Audio playback backend |
sampleRate | number | 48000 | Audio sample rate (use AudioContext.sampleRate for hardware rate) |
samplesPerPixel | number | 1000 | Initial zoom level |
zoomLevels | number[] | [256, 512, 1024, 2048, 4096, 8192] | Available zoom steps (samples per pixel) |
State Snapshot​
Call getState() to get a read-only snapshot of the entire engine state:
const state = engine.getState();
The returned EngineState contains:
Tracks
| Field | Type | Description |
|---|---|---|
tracks | ClipTrack[] | Defensive copy of all tracks and clips |
tracksVersion | number | Monotonic counter — increments on structural track mutations only |
mixerVersion | number | Monotonic counter — increments on per-track mixer edits (volume/mute/solo/pan) |
duration | number | Total timeline duration in seconds |
Playback
| Field | Type | Description |
|---|---|---|
currentTime | number | Current playhead position in seconds |
isPlaying | boolean | Whether playback is active |
Zoom
| Field | Type | Description |
|---|---|---|
samplesPerPixel | number | Current zoom level |
sampleRate | number | Audio sample rate |
zoomIndex | number | Index into the zoomLevels array |
canZoomIn | boolean | Whether further zoom in is possible |
canZoomOut | boolean | Whether further zoom out is possible |
Selection & Loop
| Field | Type | Description |
|---|---|---|
selectedTrackId | string | null | Currently selected track |
selectionStart | number | Start of selection range (guaranteed <= selectionEnd) |
selectionEnd | number | End of selection range |
loopStart | number | Start of loop region (guaranteed <= loopEnd) |
loopEnd | number | End of loop region |
isLoopEnabled | boolean | Whether loop playback is active |
Volume
| Field | Type | Description |
|---|---|---|
masterVolume | number | Master output volume (0.0–1.0) |
:::info Defensive copies
getState() returns a defensive copy — modifying the returned object does not affect engine state. Similarly, setTracks() copies its input. This prevents accidental mutations.
:::
Track Management​
// Replace all tracks
engine.setTracks(tracks: ClipTrack[]): void;
// Append a track
engine.addTrack(track: ClipTrack): void;
// Remove by ID (no-op if not found)
engine.removeTrack(trackId: string): void;
// Select a track (no-op if already selected)
engine.selectTrack(trackId: string | null): void;
Clip Editing​
All clip operations use sample-based positions (integers, not seconds):
// Move a clip by deltaSamples (constrained to prevent overlaps)
engine.moveClip(trackId: string, clipId: string, deltaSamples: number): void;
// Trim a clip boundary
engine.trimClip(
trackId: string,
clipId: string,
boundary: 'left' | 'right',
deltaSamples: number
): void;
// Split a clip at a sample position
engine.splitClip(trackId: string, clipId: string, atSample: number): void;
All three methods:
- Validate track and clip IDs (warn to console if not found)
- Apply collision constraints automatically (no overlaps)
- Enforce minimum clip duration (0.1 seconds)
- Increment
tracksVersionand emitstatechange
Playback​
// Start playback (optionally from a time position)
engine.play(startTime?: number, endTime?: number): void;
// Pause at current position
engine.pause(): void;
// Stop and reset to beginning
engine.stop(): void;
// Seek to a time position (any non-negative value — plays silence beyond audio)
engine.seek(time: number): void;
:::note Adapter required
Playback methods delegate to the PlayoutAdapter. Without an adapter, play() still updates isPlaying state and emits events, but no audio plays.
:::
Selection & Loop​
// Set selection range (automatically normalizes start <= end)
engine.setSelection(start: number, end: number): void;
// Set loop region (automatically normalizes start <= end)
engine.setLoopRegion(start: number, end: number): void;
// Toggle loop mode
engine.setLoopEnabled(enabled: boolean): void;
Zoom​
// Step zoom in (lower samplesPerPixel = more detail)
engine.zoomIn(): void;
// Step zoom out
engine.zoomOut(): void;
// Jump to a specific zoom level (snaps to closest available level)
engine.setZoomLevel(samplesPerPixel: number): void;
Volume​
// Master output volume (0.0–1.0)
engine.setMasterVolume(volume: number): void;
// Per-track controls (delegate directly to adapter)
engine.setTrackVolume(trackId: string, volume: number): void;
engine.setTrackMute(trackId: string, muted: boolean): void;
engine.setTrackSolo(trackId: string, soloed: boolean): void;
engine.setTrackPan(trackId: string, pan: number): void;
Events​
engine.on(event, listener): void;
engine.off(event, listener): void;
| Event | Listener Signature | When |
|---|---|---|
statechange | (state: EngineState) => void | Any state mutation |
play | () => void | Playback starts |
pause | () => void | Playback pauses |
stop | () => void | Playback stops |
The statechange event fires on every mutation (tracks, selection, zoom, volume, playback state). Use the tracksVersion field to distinguish structural track changes, and mixerVersion to distinguish per-track mixer edits (volume/mute/solo/pan), from other state updates.
Cleanup​
engine.dispose(): void;
Stops playback, disposes the adapter, and clears all event listeners. Safe to call multiple times.
Implementing a PlayoutAdapter​
The PlayoutAdapter interface connects the engine to any audio backend. The engine calls these methods; your adapter translates them into audio API calls.
import type { PlayoutAdapter } from '@waveform-playlist/engine';
import type { ClipTrack } from '@waveform-playlist/core';
class MyAudioAdapter implements PlayoutAdapter {
async init(): Promise<void> {
// Initialize your audio context
}
setTracks(tracks: ClipTrack[]): void {
// Rebuild audio graph from tracks
}
play(startTime: number, endTime?: number): void {
// Start audio playback from startTime
}
pause(): void {
// Pause audio playback
}
stop(): void {
// Stop audio and reset
}
seek(time: number): void {
// Move playhead to time position
}
getCurrentTime(): number {
// Return current playback position in seconds
return 0;
}
isPlaying(): boolean {
// Return whether audio is currently playing
return false;
}
setMasterVolume(volume: number): void {
// Set master output level
}
setTrackVolume(trackId: string, volume: number): void { /* ... */ }
setTrackMute(trackId: string, muted: boolean): void { /* ... */ }
setTrackSolo(trackId: string, soloed: boolean): void { /* ... */ }
setTrackPan(trackId: string, pan: number): void { /* ... */ }
setLoop(enabled: boolean, start: number, end: number): void {
// Configure loop region
}
dispose(): void {
// Clean up audio resources
}
}
// Use it:
const engine = new PlaylistEngine({
adapter: new MyAudioAdapter(),
});
:::tip Built-in adapter
The @waveform-playlist/playout package provides createToneAdapter() — a production PlayoutAdapter built on Tone.js. The React provider uses this internally.
:::
Pure Operations​
The engine also exports pure functions used internally. These are useful for custom UIs or headless processing:
Clip Operations​
import {
constrainClipDrag,
constrainBoundaryTrim,
splitClip,
canSplitAt,
calculateSplitPoint,
} from '@waveform-playlist/engine';
// Constrain a drag delta to prevent overlaps
const safeDelta = constrainClipDrag(clip, deltaSamples, sortedClips, clipIndex);
// Constrain a boundary trim
const safeTrim = constrainBoundaryTrim(clip, delta, 'left', sortedClips, index, minDuration);
// Check if a split is valid, then split
if (canSplitAt(clip, samplePosition, minDuration)) {
const { left, right } = splitClip(clip, samplePosition);
}
// Snap split point to pixel boundary
const snapped = calculateSplitPoint(samplePosition, samplesPerPixel);
Timeline Operations​
import {
calculateDuration,
findClosestZoomIndex,
calculateZoomScrollPosition,
clampSeekPosition,
} from '@waveform-playlist/engine';
// Total duration across all tracks
const duration = calculateDuration(tracks);
// Find nearest zoom level
const index = findClosestZoomIndex(targetSpp, zoomLevels);
// Keep viewport centered during zoom
const newScrollLeft = calculateZoomScrollPosition(
oldSpp, newSpp, scrollLeft, containerWidth, sampleRate
);
// Clamp negative seek position to 0
const time = Math.max(0, requestedTime);
Viewport Operations​
import {
calculateViewportBounds,
getVisibleChunkIndices,
shouldUpdateViewport,
} from '@waveform-playlist/engine';
// Calculate visible region with overscan buffer
const { visibleStart, visibleEnd } = calculateViewportBounds(scrollLeft, containerWidth);
// Which canvas chunks are visible?
const chunks = getVisibleChunkIndices(totalWidth, chunkWidth, visibleStart, visibleEnd);
// Should we recalculate? (debounce small scroll movements)
if (shouldUpdateViewport(oldScroll, newScroll, threshold)) {
// re-render
}
Key Concepts​
Sample-Based Architecture​
All timing is stored as integer sample counts, not floating-point seconds. This eliminates precision errors that cause pixel gaps between clips.
// Positions are in samples
clip.startSample; // 44100 (= 1 second at 44.1kHz)
clip.durationSamples; // 88200 (= 2 seconds)
clip.offsetSamples; // 0 (start of source audio)
// Convert to seconds when needed
const seconds = clip.startSample / clip.sampleRate;
Use createClipFromSeconds() for convenience — it handles the conversion internally.
No-Op Emission Guards​
The engine avoids unnecessary listener calls. Mutating methods bail early when:
- A constrained drag/trim delta resolves to zero
- A track/clip ID is not found
- A zoom level is already at the requested value
- Selection or volume values haven't changed
This prevents wasted re-renders in your UI framework.
Defensive Copying​
setTracks()copies the input array — your original array is not retainedgetState()returns copies of tracks — modifying the snapshot doesn't affect engine state
Invariant Normalization​
The engine normalizes ordering automatically:
setSelection(10, 5)→selectionStart = 5, selectionEnd = 10setLoopRegion(20, 10)→loopStart = 10, loopEnd = 20
Consumers can trust that EngineState.selectionStart <= selectionEnd and loopStart <= loopEnd.
tracksVersion Counter​
The tracksVersion field in EngineState is a monotonic counter that increments only on structural track mutations (setTracks, addTrack, removeTrack, moveClip, trimClip, splitClip). It does not increment on selection, zoom, master volume, or loop changes, nor on per-track mixer edits. Use it to skip expensive operations (like audio graph rebuilds or peak regeneration) when only non-structural state changed.
Per-track mixer edits (setTrackVolume, setTrackMute, setTrackSolo, setTrackPan) instead bump a separate mixerVersion counter (and emit statechange). This lets a consumer that caches a track snapshot refresh that cache — so live mixer state survives a later setTracks rebuild — without re-running the structural work gated on tracksVersion. The split matters because mixer setters fire continuously during a volume/pan slider drag.
Example: Vanilla JavaScript​
A plain DOM UI that reacts to engine state — no framework required:
<div id="app">
<p id="status">Stopped</p>
<p id="info">Duration: 0s | Tracks: 0</p>
<button id="play">Play</button>
<button id="pause">Pause</button>
<button id="stop">Stop</button>
<button id="zoom-in">Zoom In</button>
<button id="zoom-out">Zoom Out</button>
</div>
<script type="module">
import { PlaylistEngine } from '@waveform-playlist/engine';
import { createTrack, createClipFromSeconds } from '@waveform-playlist/core';
const engine = new PlaylistEngine({ sampleRate: 48000 });
// Update DOM on every state change
engine.on('statechange', (state) => {
document.getElementById('status').textContent =
state.isPlaying ? 'Playing' : 'Stopped';
document.getElementById('info').textContent =
`Duration: ${state.duration.toFixed(2)}s | Tracks: ${state.tracks.length}`;
document.getElementById('zoom-in').disabled = !state.canZoomIn;
document.getElementById('zoom-out').disabled = !state.canZoomOut;
});
// Wire up buttons
document.getElementById('play').onclick = () => engine.play();
document.getElementById('pause').onclick = () => engine.pause();
document.getElementById('stop').onclick = () => engine.stop();
document.getElementById('zoom-in').onclick = () => engine.zoomIn();
document.getElementById('zoom-out').onclick = () => engine.zoomOut();
</script>
Example: Svelte Integration​
A minimal Svelte store that subscribes to engine state:
<script>
import { onMount, onDestroy } from 'svelte';
import { writable } from 'svelte/store';
import { PlaylistEngine } from '@waveform-playlist/engine';
const engine = new PlaylistEngine({ sampleRate: 48000 });
const state = writable(engine.getState());
function handleStateChange(newState) {
state.set(newState);
}
onMount(() => {
engine.on('statechange', handleStateChange);
});
onDestroy(() => {
engine.dispose();
});
</script>
<div>
<p>Duration: {$state.duration.toFixed(2)}s</p>
<p>Playing: {$state.isPlaying}</p>
<p>Tracks: {$state.tracks.length}</p>
<button on:click={() => engine.play()}>Play</button>
<button on:click={() => engine.pause()}>Pause</button>
<button on:click={() => engine.stop()}>Stop</button>
<button on:click={() => engine.zoomIn()} disabled={!$state.canZoomIn}>Zoom In</button>
<button on:click={() => engine.zoomOut()} disabled={!$state.canZoomOut}>Zoom Out</button>
</div>
TypeScript​
All types are exported from @waveform-playlist/engine:
import type {
EngineState,
EngineEvents,
PlaylistEngineOptions,
PlayoutAdapter,
} from '@waveform-playlist/engine';
Data model types come from @waveform-playlist/core:
import type { AudioClip, ClipTrack } from '@waveform-playlist/core';
EngineState​
interface EngineState {
tracks: ClipTrack[];
tracksVersion: number;
mixerVersion: number;
duration: number;
currentTime: number;
isPlaying: boolean;
samplesPerPixel: number;
sampleRate: number;
selectedTrackId: string | null;
zoomIndex: number;
canZoomIn: boolean;
canZoomOut: boolean;
selectionStart: number;
selectionEnd: number;
masterVolume: number;
loopStart: number;
loopEnd: number;
isLoopEnabled: boolean;
}
PlaylistEngineOptions​
interface PlaylistEngineOptions {
adapter?: PlayoutAdapter;
sampleRate?: number;
samplesPerPixel?: number;
zoomLevels?: number[];
}
PlayoutAdapter​
interface PlayoutAdapter {
init(): Promise<void>;
setTracks(tracks: ClipTrack[]): void;
play(startTime: number, endTime?: number): void;
pause(): void;
stop(): void;
seek(time: number): void;
getCurrentTime(): number;
isPlaying(): boolean;
setMasterVolume(volume: number): void;
setTrackVolume(trackId: string, volume: number): void;
setTrackMute(trackId: string, muted: boolean): void;
setTrackSolo(trackId: string, soloed: boolean): void;
setTrackPan(trackId: string, pan: number): void;
setLoop(enabled: boolean, start: number, end: number): void;
dispose(): void;
}
EngineEvents​
interface EngineEvents {
statechange: (state: EngineState) => void;
play: () => void;
pause: () => void;
stop: () => void;
}
See Also​
- Hooks Reference — React hooks that wrap the engine
- Loading Audio — Creating tracks with
useAudioTracks - Track Management — Adding and removing tracks