import { AudioClip, ClipTrack } from '@waveform-playlist/core'; /** * Clip Operations * * Pure functions for constraining clip movement, boundary trimming, * and splitting clips on a timeline. All positions are in samples (integers). */ /** * Constrain clip movement delta to prevent overlaps with adjacent clips * and going before sample 0. * * @param clip - The clip being dragged * @param deltaSamples - Requested movement in samples (negative = left, positive = right) * @param sortedClips - All clips on the track, sorted by startSample * @param clipIndex - Index of the dragged clip in sortedClips * @returns Constrained delta that prevents overlaps */ declare function constrainClipDrag(clip: AudioClip, deltaSamples: number, sortedClips: AudioClip[], clipIndex: number): number; /** * Constrain boundary trim delta for left or right edge of a clip. * * LEFT boundary: delta moves the left edge (positive = shrink, negative = expand) * - startSample += delta, offsetSamples += delta, durationSamples -= delta * * RIGHT boundary: delta applied to durationSamples (positive = expand, negative = shrink) * - durationSamples += delta * * @param clip - The clip being trimmed * @param deltaSamples - Requested trim delta in samples * @param boundary - Which edge is being trimmed: 'left' or 'right' * @param sortedClips - All clips on the track, sorted by startSample * @param clipIndex - Index of the trimmed clip in sortedClips * @param minDurationSamples - Minimum allowed clip duration in samples * @returns Constrained delta */ declare function constrainBoundaryTrim(clip: AudioClip, deltaSamples: number, boundary: 'left' | 'right', sortedClips: AudioClip[], clipIndex: number, minDurationSamples: number): number; /** * Snap a split sample position to the nearest pixel boundary. * * @param splitSample - The sample position to snap * @param samplesPerPixel - Current zoom level (samples per pixel) * @returns Snapped sample position */ declare function calculateSplitPoint(splitSample: number, samplesPerPixel: number): number; /** * Split a clip into two clips at the given sample position. * * The left clip retains the original fadeIn; the right clip retains the original fadeOut. * Both clips share the same waveformData reference. * If the clip has a name, suffixes " (1)" and " (2)" are appended. * * @param clip - The clip to split * @param splitSample - The timeline sample position where the split occurs * @returns Object with `left` and `right` AudioClip */ declare function splitClip(clip: AudioClip, splitSample: number): { left: AudioClip; right: AudioClip; }; /** * Check whether a clip can be split at the given sample position. * * The split point must be strictly inside the clip (not at start or end), * and both resulting clips must meet the minimum duration requirement. * * @param clip - The clip to check * @param sample - The timeline sample position to test * @param minDurationSamples - Minimum allowed clip duration in samples * @returns true if the split is valid */ declare function canSplitAt(clip: AudioClip, sample: number, minDurationSamples: number): boolean; /** * Viewport operations for virtual scrolling. * * Pure math helpers that determine which portion of the timeline * is visible and which canvas chunks need to be mounted. */ /** * Calculate the visible region with an overscan buffer for virtual scrolling. * * The buffer extends the visible range on both sides so that chunks are * mounted slightly before they scroll into view, preventing flicker. * * @param scrollLeft - Current horizontal scroll position in pixels * @param containerWidth - Width of the scroll container in pixels * @param bufferRatio - Multiplier for buffer size (default 1.5x container width) * @returns Object with visibleStart and visibleEnd in pixels */ declare function calculateViewportBounds(scrollLeft: number, containerWidth: number, bufferRatio?: number): { visibleStart: number; visibleEnd: number; }; /** * Get an array of chunk indices that overlap the visible viewport. * * Chunks are fixed-width segments of the total timeline width. Only chunks * that intersect [visibleStart, visibleEnd) are included. The last chunk * may be narrower than chunkWidth if totalWidth is not evenly divisible. * * @param totalWidth - Total width of the timeline in pixels * @param chunkWidth - Width of each chunk in pixels * @param visibleStart - Left edge of the visible region in pixels * @param visibleEnd - Right edge of the visible region in pixels * @returns Array of chunk indices (0-based) that are visible */ declare function getVisibleChunkIndices(totalWidth: number, chunkWidth: number, visibleStart: number, visibleEnd: number): number[]; /** * Determine whether a scroll change is large enough to warrant * recalculating the viewport and re-rendering chunks. * * Small scroll movements are ignored to avoid excessive recomputation * during smooth scrolling. * * @param oldScrollLeft - Previous scroll position in pixels * @param newScrollLeft - Current scroll position in pixels * @param threshold - Minimum pixel delta to trigger an update (default 100) * @returns true if the scroll delta meets or exceeds the threshold */ declare function shouldUpdateViewport(oldScrollLeft: number, newScrollLeft: number, threshold?: number): boolean; /** * Calculate total timeline duration in seconds from all tracks/clips. * Iterates all clips, finds the furthest clip end (startSample + durationSamples), * converts to seconds using each clip's sampleRate. * * @param tracks - Array of clip tracks * @returns Duration in seconds */ declare function calculateDuration(tracks: ClipTrack[]): number; /** * Find the zoom level index closest to a given samplesPerPixel. * Returns exact match if found, otherwise the index whose value is * nearest to the target (by absolute difference). * * @param targetSamplesPerPixel - The samplesPerPixel value to find * @param zoomLevels - Array of available zoom levels (samplesPerPixel values) * @returns Index into the zoomLevels array */ declare function findClosestZoomIndex(targetSamplesPerPixel: number, zoomLevels: number[]): number; /** * Keep viewport centered during zoom changes. * Calculates center time from old zoom, computes new pixel position at new zoom, * and returns new scrollLeft clamped to >= 0. * * @param oldSamplesPerPixel - Previous zoom level * @param newSamplesPerPixel - New zoom level * @param scrollLeft - Current horizontal scroll position * @param containerWidth - Viewport width in pixels * @param sampleRate - Audio sample rate * @param controlWidth - Width of track controls panel (defaults to 0) * @returns New scrollLeft value */ declare function calculateZoomScrollPosition(oldSamplesPerPixel: number, newSamplesPerPixel: number, scrollLeft: number, containerWidth: number, sampleRate: number, controlWidth?: number): number; /** * Clamp a seek position to the valid range [0, duration]. * * @param time - Requested seek time in seconds * @param duration - Maximum duration in seconds * @returns Clamped time value */ declare function clampSeekPosition(time: number, duration: number): number; /** * Interface for pluggable audio playback adapters. * Implement this to connect PlaylistEngine to any audio backend * (Tone.js, openDAW, HTMLAudioElement, etc.) */ interface PlayoutAdapter { readonly audioContext: AudioContext; readonly ppqn: number; /** Set the adapter's tick resolution. Optional — adapters with fixed PPQN ignore this. */ setPpqn?(ppqn: number): void; init(): Promise; setTracks(tracks: ClipTrack[]): void; /** Incrementally add a single track without rebuilding the entire playout. */ addTrack?(track: ClipTrack): void; /** Incrementally remove a single track without rebuilding the entire playout. */ removeTrack?(trackId: string): void; /** Update a single track's clips (removes old, adds new). */ updateTrack?(trackId: string, track: ClipTrack): void; play(startTime: number, endTime?: number): void; pause(): void; stop(): void; seek(time: number): void; getCurrentTime(): number; isPlaying(): boolean; /** Subscribe to adapter-initiated playback completion (duration-limited * play(start, end) reaching its end). NOT fired for consumer-initiated * stop()/pause(). Pass null to unsubscribe. Optional — adapters without * self-terminating playback (or whose consumers poll) omit it. */ onPlaybackEnded?(callback: (() => void) | null): void; 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; /** Set tempo at a tick position. A defaulted (no atTick) call sets the base * tempo on a single-entry map; adapters MAY refuse it when their tempo map * has multiple entries (return `false`) so a "display BPM" write can't * clobber a consumer-installed tempo curve (#407). Pass an explicit atTick * to modify a multi-entry map. A `void` return counts as accepted. */ setTempo?(bpm: number, atTick?: number): boolean | void; /** Set time signature at a tick position. */ setMeter?(numerator: number, denominator: number, atTick?: number): void; /** Convert ticks to seconds using the adapter's tempo map. */ ticksToSeconds?(tick: number): number; /** Convert seconds to ticks using the adapter's tempo map. */ secondsToTicks?(seconds: number): number; /** Register a worklet module URL on this adapter's AudioContext. * Abstracts native vs standardized-audio-context differences. */ addWorkletModule?(url: string): Promise; /** Create an AudioWorkletNode on this adapter's context. * Required for standardized-audio-context (Tone.js) compatibility. */ createAudioWorkletNode?(name: string, options?: AudioWorkletNodeOptions): AudioWorkletNode; /** Create a MediaStreamSource on this adapter's context. */ createMediaStreamSource?(stream: MediaStream): MediaStreamAudioSourceNode; /** The master output AudioNode. Connect your own nodes (analyzers, recorders, etc.) * in parallel or series. The adapter already routes this to audioContext.destination. */ readonly masterOutputNode?: AudioNode; /** Audio scheduling lookahead in seconds — `getCurrentTime()` is this far ahead of * what the listener actually hears. Tone.js Transport reports a position that's * `lookAhead` ahead of audible (default 0.1s); native AudioContext-based adapters * have no lookahead. Consumers visualizing playback position should use * `PlaylistEngine.getAudibleTime()`, which applies this (plus * `audioContext.outputLatency`) only while playing. Returns 0 or undefined when * there's no lookahead. */ readonly lookAhead?: number; dispose(): void; } /** * Snapshot of playlist engine state, emitted on every state change. */ interface EngineState { tracks: ClipTrack[]; /** Monotonic counter incremented on any structural tracks mutation (setTracks, addTrack, removeTrack, moveClip, trimClip, splitClip). Does NOT change on per-track mixer edits — see `mixerVersion`. */ tracksVersion: number; /** Monotonic counter incremented on per-track mixer edits (setTrackVolume, setTrackMute, setTrackSolo, setTrackPan). Lets consumers refresh a cached track snapshot to keep mixer state without triggering the structural resync work (audio-graph rewire, peak regeneration) gated on `tracksVersion`. */ mixerVersion: number; duration: number; currentTime: number; isPlaying: boolean; samplesPerPixel: number; sampleRate: number; selectedTrackId: string | null; zoomIndex: number; canZoomIn: boolean; canZoomOut: boolean; /** Start of the audio selection range. Guaranteed: selectionStart <= selectionEnd. */ selectionStart: number; /** End of the audio selection range. Guaranteed: selectionStart <= selectionEnd. */ selectionEnd: number; /** Master output volume, 0.0–1.0. */ masterVolume: number; /** Start of the loop region. Guaranteed: loopStart <= loopEnd. */ loopStart: number; /** End of the loop region. Guaranteed: loopStart <= loopEnd. */ loopEnd: number; /** Whether loop playback is active. */ isLoopEnabled: boolean; /** Current base tempo in BPM. */ bpm: number; /** Pulses per quarter note. */ ppqn: number; /** Whether undo is available. */ canUndo: boolean; /** Whether redo is available. */ canRedo: boolean; } /** * Configuration options for PlaylistEngine constructor. */ interface PlaylistEngineOptions { adapter?: PlayoutAdapter; sampleRate?: number; samplesPerPixel?: number; zoomLevels?: number[]; /** Maximum number of undo steps (default 100). */ undoLimit?: number; /** Initial tempo in BPM (default 120). */ bpm?: number; /** Pulses per quarter note for headless mode (no adapter). When adapter is provided, adapter.ppqn is used instead. */ ppqn?: number; } /** * Events emitted by PlaylistEngine. */ interface EngineEvents { statechange: (state: EngineState) => void; play: () => void; pause: () => void; stop: () => void; } /** * PlaylistEngine — Stateful, framework-agnostic timeline engine. * * Composes pure operations from ./operations with an event emitter * and optional PlayoutAdapter for audio playback delegation. */ type EventName = keyof EngineEvents; declare class PlaylistEngine { private _tracks; private _currentTime; private _playStartPosition; private _isPlaying; private _selectedTrackId; private _sampleRate; private _zoomLevels; private _zoomIndex; private _selectionStart; private _selectionEnd; private _masterVolume; private _loopStart; private _loopEnd; private _isLoopEnabled; private _bpm; private _ppqn; private _tracksVersion; private _mixerVersion; private _adapter; private _disposed; private _listeners; private _undoStack; private _redoStack; private _inTransaction; private _transactionSnapshot; private _transactionMutated; readonly undoLimit: number; constructor(options?: PlaylistEngineOptions); get canUndo(): boolean; get canRedo(): boolean; undo(): void; redo(): void; clearHistory(): void; beginTransaction(): void; commitTransaction(): void; abortTransaction(): void; getState(): EngineState; setTracks(tracks: ClipTrack[]): void; addTrack(track: ClipTrack): void; removeTrack(trackId: string): void; /** * Move a track to a new position in the track order. Purely organizational — * track order is not audible (adapter nodes are keyed by track id), so the * adapter is deliberately NOT called: a setTracks() here would rebuild * playout and interrupt playback for a visual-only change. */ reorderTrack(trackId: string, toIndex: number): void; /** Update a single track's clips on the adapter (no full rebuild). */ updateTrack(trackId: string, track?: ClipTrack): void; /** Internal: update adapter after modifying this._tracks in place. */ private _updateTrackOnAdapter; selectTrack(trackId: string | null): void; /** Get a clip's full bounds for trim constraint computation. Returns null if not found. */ getClipBounds(trackId: string, clipId: string): { offsetSamples: number; durationSamples: number; startSample: number; sourceDurationSamples: number; } | null; /** Constrain a trim delta using the engine's collision/bounds logic. * Uses the clip's current state and neighboring clips for constraints. */ constrainTrimDelta(trackId: string, clipId: string, boundary: 'left' | 'right', deltaSamples: number): number; /** Move a clip by deltaSamples. Returns the constrained delta actually applied (0 if no-op). */ moveClip(trackId: string, clipId: string, deltaSamples: number, skipAdapter?: boolean): number; splitClip(trackId: string, clipId: string, atSample: number): void; trimClip(trackId: string, clipId: string, boundary: 'left' | 'right', deltaSamples: number, skipAdapter?: boolean): void; init(): Promise; play(startTime?: number, endTime?: number): void; pause(): void; stop(): void; seek(time: number): void; setMasterVolume(volume: number): void; setTempo(bpm: number, atTick?: number): void; getCurrentTime(): number; /** * Audio scheduling lookahead in seconds — `getCurrentTime()` is this far ahead of * what the listener actually hears. Tone.js Transport reports a position that's * `lookAhead` ahead of audible (default 0.1s); native AudioContext-based adapters * have no lookahead and return 0. Visual consumers should use `getAudibleTime()`, * which applies this compensation only while playing. */ get lookAhead(): number; /** * Playback position aligned with what the listener actually hears, for * visual consumers (playhead, progress overlays, auto-scroll). * * While playing: `getCurrentTime() − outputLatency − lookAhead`, held at * the play-start position during the pre-roll window (audio at the start * position isn't audible until ~outputLatency + lookAhead after `play()`, * so the cursor waits there instead of jumping backward). * * While not playing: the raw resting position. A stationary cursor has no * audible counterpart — seek/stop/pause positions display exactly. * * Storage stays raw: never feed this value back into `play()`/`seek()`, * which compounds the subtraction on every cycle. Use `getCurrentTime()` * for storage and resume positions. */ getAudibleTime(): number; setSelection(start: number, end: number): void; setLoopRegion(start: number, end: number): void; setLoopEnabled(enabled: boolean): 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; zoomIn(): void; zoomOut(): void; setZoomLevel(samplesPerPixel: number): void; on(event: K, listener: EngineEvents[K]): void; off(event: K, listener: EngineEvents[K]): void; dispose(): void; private _snapshotTracks; private _pushUndoSnapshot; private _restoreTracks; private _emit; /** * Returns whether the current playback position is before loopEnd. * Used by setLoopEnabled/setLoopRegion during playback — if past loopEnd, * Transport loop stays off so playback continues to the end. * Note: play() uses an inline check instead — _isPlaying is still false * when play() runs, and this method returns true unconditionally when * not playing. */ private _isBeforeLoopEnd; private _ticksToSeconds; private _secondsToTicks; private _recomputeStartSamples; private _emitStateChange; } export { type EngineEvents, type EngineState, PlaylistEngine, type PlaylistEngineOptions, type PlayoutAdapter, calculateDuration, calculateSplitPoint, calculateViewportBounds, calculateZoomScrollPosition, canSplitAt, clampSeekPosition, constrainBoundaryTrim, constrainClipDrag, findClosestZoomIndex, getVisibleChunkIndices, shouldUpdateViewport, splitClip };