@waveform-playlist/engine 7.1.3 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -189,6 +189,8 @@ interface PlayoutAdapter {
189
189
  */
190
190
  interface EngineState {
191
191
  tracks: ClipTrack[];
192
+ /** Monotonic counter incremented on any tracks mutation (setTracks, addTrack, removeTrack, moveClip, trimClip, splitClip). */
193
+ tracksVersion: number;
192
194
  duration: number;
193
195
  currentTime: number;
194
196
  isPlaying: boolean;
@@ -198,6 +200,18 @@ interface EngineState {
198
200
  zoomIndex: number;
199
201
  canZoomIn: boolean;
200
202
  canZoomOut: boolean;
203
+ /** Start of the audio selection range. Guaranteed: selectionStart <= selectionEnd. */
204
+ selectionStart: number;
205
+ /** End of the audio selection range. Guaranteed: selectionStart <= selectionEnd. */
206
+ selectionEnd: number;
207
+ /** Master output volume, 0.0–1.0. */
208
+ masterVolume: number;
209
+ /** Start of the loop region. Guaranteed: loopStart <= loopEnd. */
210
+ loopStart: number;
211
+ /** End of the loop region. Guaranteed: loopStart <= loopEnd. */
212
+ loopEnd: number;
213
+ /** Whether loop playback is active. */
214
+ isLoopEnabled: boolean;
201
215
  }
202
216
  /**
203
217
  * Configuration options for PlaylistEngine constructor.
@@ -235,6 +249,13 @@ declare class PlaylistEngine {
235
249
  private _sampleRate;
236
250
  private _zoomLevels;
237
251
  private _zoomIndex;
252
+ private _selectionStart;
253
+ private _selectionEnd;
254
+ private _masterVolume;
255
+ private _loopStart;
256
+ private _loopEnd;
257
+ private _isLoopEnabled;
258
+ private _tracksVersion;
238
259
  private _adapter;
239
260
  private _animFrameId;
240
261
  private _disposed;
@@ -253,6 +274,9 @@ declare class PlaylistEngine {
253
274
  stop(): void;
254
275
  seek(time: number): void;
255
276
  setMasterVolume(volume: number): void;
277
+ setSelection(start: number, end: number): void;
278
+ setLoopRegion(start: number, end: number): void;
279
+ setLoopEnabled(enabled: boolean): void;
256
280
  setTrackVolume(trackId: string, volume: number): void;
257
281
  setTrackMute(trackId: string, muted: boolean): void;
258
282
  setTrackSolo(trackId: string, soloed: boolean): void;
package/dist/index.d.ts CHANGED
@@ -189,6 +189,8 @@ interface PlayoutAdapter {
189
189
  */
190
190
  interface EngineState {
191
191
  tracks: ClipTrack[];
192
+ /** Monotonic counter incremented on any tracks mutation (setTracks, addTrack, removeTrack, moveClip, trimClip, splitClip). */
193
+ tracksVersion: number;
192
194
  duration: number;
193
195
  currentTime: number;
194
196
  isPlaying: boolean;
@@ -198,6 +200,18 @@ interface EngineState {
198
200
  zoomIndex: number;
199
201
  canZoomIn: boolean;
200
202
  canZoomOut: boolean;
203
+ /** Start of the audio selection range. Guaranteed: selectionStart <= selectionEnd. */
204
+ selectionStart: number;
205
+ /** End of the audio selection range. Guaranteed: selectionStart <= selectionEnd. */
206
+ selectionEnd: number;
207
+ /** Master output volume, 0.0–1.0. */
208
+ masterVolume: number;
209
+ /** Start of the loop region. Guaranteed: loopStart <= loopEnd. */
210
+ loopStart: number;
211
+ /** End of the loop region. Guaranteed: loopStart <= loopEnd. */
212
+ loopEnd: number;
213
+ /** Whether loop playback is active. */
214
+ isLoopEnabled: boolean;
201
215
  }
202
216
  /**
203
217
  * Configuration options for PlaylistEngine constructor.
@@ -235,6 +249,13 @@ declare class PlaylistEngine {
235
249
  private _sampleRate;
236
250
  private _zoomLevels;
237
251
  private _zoomIndex;
252
+ private _selectionStart;
253
+ private _selectionEnd;
254
+ private _masterVolume;
255
+ private _loopStart;
256
+ private _loopEnd;
257
+ private _isLoopEnabled;
258
+ private _tracksVersion;
238
259
  private _adapter;
239
260
  private _animFrameId;
240
261
  private _disposed;
@@ -253,6 +274,9 @@ declare class PlaylistEngine {
253
274
  stop(): void;
254
275
  seek(time: number): void;
255
276
  setMasterVolume(volume: number): void;
277
+ setSelection(start: number, end: number): void;
278
+ setLoopRegion(start: number, end: number): void;
279
+ setLoopEnabled(enabled: boolean): void;
256
280
  setTrackVolume(trackId: string, volume: number): void;
257
281
  setTrackMute(trackId: string, muted: boolean): void;
258
282
  setTrackSolo(trackId: string, soloed: boolean): void;
package/dist/index.js CHANGED
@@ -196,6 +196,13 @@ var PlaylistEngine = class {
196
196
  this._currentTime = 0;
197
197
  this._isPlaying = false;
198
198
  this._selectedTrackId = null;
199
+ this._selectionStart = 0;
200
+ this._selectionEnd = 0;
201
+ this._masterVolume = 1;
202
+ this._loopStart = 0;
203
+ this._loopEnd = 0;
204
+ this._isLoopEnabled = false;
205
+ this._tracksVersion = 0;
199
206
  this._animFrameId = null;
200
207
  this._disposed = false;
201
208
  // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
@@ -215,6 +222,7 @@ var PlaylistEngine = class {
215
222
  getState() {
216
223
  return {
217
224
  tracks: this._tracks.map((t) => ({ ...t, clips: [...t.clips] })),
225
+ tracksVersion: this._tracksVersion,
218
226
  duration: calculateDuration(this._tracks),
219
227
  currentTime: this._currentTime,
220
228
  isPlaying: this._isPlaying,
@@ -223,7 +231,13 @@ var PlaylistEngine = class {
223
231
  selectedTrackId: this._selectedTrackId,
224
232
  zoomIndex: this._zoomIndex,
225
233
  canZoomIn: this._zoomIndex > 0,
226
- canZoomOut: this._zoomIndex < this._zoomLevels.length - 1
234
+ canZoomOut: this._zoomIndex < this._zoomLevels.length - 1,
235
+ selectionStart: this._selectionStart,
236
+ selectionEnd: this._selectionEnd,
237
+ masterVolume: this._masterVolume,
238
+ loopStart: this._loopStart,
239
+ loopEnd: this._loopEnd,
240
+ isLoopEnabled: this._isLoopEnabled
227
241
  };
228
242
  }
229
243
  // ---------------------------------------------------------------------------
@@ -231,17 +245,20 @@ var PlaylistEngine = class {
231
245
  // ---------------------------------------------------------------------------
232
246
  setTracks(tracks) {
233
247
  this._tracks = [...tracks];
248
+ this._tracksVersion++;
234
249
  this._adapter?.setTracks(this._tracks);
235
250
  this._emitStateChange();
236
251
  }
237
252
  addTrack(track) {
238
253
  this._tracks = [...this._tracks, track];
254
+ this._tracksVersion++;
239
255
  this._adapter?.setTracks(this._tracks);
240
256
  this._emitStateChange();
241
257
  }
242
258
  removeTrack(trackId) {
243
259
  if (!this._tracks.some((t) => t.id === trackId)) return;
244
260
  this._tracks = this._tracks.filter((t) => t.id !== trackId);
261
+ this._tracksVersion++;
245
262
  if (this._selectedTrackId === trackId) {
246
263
  this._selectedTrackId = null;
247
264
  }
@@ -249,6 +266,7 @@ var PlaylistEngine = class {
249
266
  this._emitStateChange();
250
267
  }
251
268
  selectTrack(trackId) {
269
+ if (trackId === this._selectedTrackId) return;
252
270
  this._selectedTrackId = trackId;
253
271
  this._emitStateChange();
254
272
  }
@@ -257,9 +275,17 @@ var PlaylistEngine = class {
257
275
  // ---------------------------------------------------------------------------
258
276
  moveClip(trackId, clipId, deltaSamples) {
259
277
  const track = this._tracks.find((t) => t.id === trackId);
260
- if (!track) return;
278
+ if (!track) {
279
+ console.warn(`[waveform-playlist/engine] moveClip: track "${trackId}" not found`);
280
+ return;
281
+ }
261
282
  const clipIndex = track.clips.findIndex((c) => c.id === clipId);
262
- if (clipIndex === -1) return;
283
+ if (clipIndex === -1) {
284
+ console.warn(
285
+ `[waveform-playlist/engine] moveClip: clip "${clipId}" not found in track "${trackId}"`
286
+ );
287
+ return;
288
+ }
263
289
  const clip = track.clips[clipIndex];
264
290
  const sortedClips = (0, import_core2.sortClipsByTime)(track.clips);
265
291
  const sortedIndex = sortedClips.findIndex((c) => c.id === clipId);
@@ -275,16 +301,31 @@ var PlaylistEngine = class {
275
301
  );
276
302
  return { ...t, clips: newClips };
277
303
  });
304
+ this._tracksVersion++;
305
+ this._adapter?.setTracks(this._tracks);
278
306
  this._emitStateChange();
279
307
  }
280
308
  splitClip(trackId, clipId, atSample) {
281
309
  const track = this._tracks.find((t) => t.id === trackId);
282
- if (!track) return;
310
+ if (!track) {
311
+ console.warn(`[waveform-playlist/engine] splitClip: track "${trackId}" not found`);
312
+ return;
313
+ }
283
314
  const clipIndex = track.clips.findIndex((c) => c.id === clipId);
284
- if (clipIndex === -1) return;
315
+ if (clipIndex === -1) {
316
+ console.warn(
317
+ `[waveform-playlist/engine] splitClip: clip "${clipId}" not found in track "${trackId}"`
318
+ );
319
+ return;
320
+ }
285
321
  const clip = track.clips[clipIndex];
286
322
  const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);
287
- if (!canSplitAt(clip, atSample, minDuration)) return;
323
+ if (!canSplitAt(clip, atSample, minDuration)) {
324
+ console.warn(
325
+ `[waveform-playlist/engine] splitClip: cannot split clip "${clipId}" at sample ${atSample} (clip range: ${clip.startSample}\u2013${clip.startSample + clip.durationSamples}, minDuration: ${minDuration})`
326
+ );
327
+ return;
328
+ }
288
329
  const { left, right } = splitClip(clip, atSample);
289
330
  this._tracks = this._tracks.map((t) => {
290
331
  if (t.id !== trackId) return t;
@@ -292,13 +333,23 @@ var PlaylistEngine = class {
292
333
  newClips.splice(clipIndex, 1, left, right);
293
334
  return { ...t, clips: newClips };
294
335
  });
336
+ this._tracksVersion++;
337
+ this._adapter?.setTracks(this._tracks);
295
338
  this._emitStateChange();
296
339
  }
297
340
  trimClip(trackId, clipId, boundary, deltaSamples) {
298
341
  const track = this._tracks.find((t) => t.id === trackId);
299
- if (!track) return;
342
+ if (!track) {
343
+ console.warn(`[waveform-playlist/engine] trimClip: track "${trackId}" not found`);
344
+ return;
345
+ }
300
346
  const clipIndex = track.clips.findIndex((c) => c.id === clipId);
301
- if (clipIndex === -1) return;
347
+ if (clipIndex === -1) {
348
+ console.warn(
349
+ `[waveform-playlist/engine] trimClip: clip "${clipId}" not found in track "${trackId}"`
350
+ );
351
+ return;
352
+ }
302
353
  const clip = track.clips[clipIndex];
303
354
  const sortedClips = (0, import_core2.sortClipsByTime)(track.clips);
304
355
  const sortedIndex = sortedClips.findIndex((c) => c.id === clipId);
@@ -329,6 +380,8 @@ var PlaylistEngine = class {
329
380
  });
330
381
  return { ...t, clips: newClips };
331
382
  });
383
+ this._tracksVersion++;
384
+ this._adapter?.setTracks(this._tracks);
332
385
  this._emitStateChange();
333
386
  }
334
387
  // ---------------------------------------------------------------------------
@@ -372,7 +425,34 @@ var PlaylistEngine = class {
372
425
  this._emitStateChange();
373
426
  }
374
427
  setMasterVolume(volume) {
428
+ if (volume === this._masterVolume) return;
429
+ this._masterVolume = volume;
375
430
  this._adapter?.setMasterVolume(volume);
431
+ this._emitStateChange();
432
+ }
433
+ // ---------------------------------------------------------------------------
434
+ // Selection & Loop
435
+ // ---------------------------------------------------------------------------
436
+ setSelection(start, end) {
437
+ const s = Math.min(start, end);
438
+ const e = Math.max(start, end);
439
+ if (s === this._selectionStart && e === this._selectionEnd) return;
440
+ this._selectionStart = s;
441
+ this._selectionEnd = e;
442
+ this._emitStateChange();
443
+ }
444
+ setLoopRegion(start, end) {
445
+ const s = Math.min(start, end);
446
+ const e = Math.max(start, end);
447
+ if (s === this._loopStart && e === this._loopEnd) return;
448
+ this._loopStart = s;
449
+ this._loopEnd = e;
450
+ this._emitStateChange();
451
+ }
452
+ setLoopEnabled(enabled) {
453
+ if (enabled === this._isLoopEnabled) return;
454
+ this._isLoopEnabled = enabled;
455
+ this._emitStateChange();
376
456
  }
377
457
  // ---------------------------------------------------------------------------
378
458
  // Per-Track Audio (delegates to adapter)
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/operations/clipOperations.ts","../src/operations/viewportOperations.ts","../src/operations/timelineOperations.ts","../src/PlaylistEngine.ts"],"sourcesContent":["// Operations (pure functions)\nexport * from './operations';\n\n// Engine class\nexport { PlaylistEngine } from './PlaylistEngine';\n\n// Engine types\nexport * from './types';\n","/**\n * Clip Operations\n *\n * Pure functions for constraining clip movement, boundary trimming,\n * and splitting clips on a timeline. All positions are in samples (integers).\n */\n\nimport type { AudioClip } from '@waveform-playlist/core';\nimport { createClip } from '@waveform-playlist/core';\n\n/**\n * Constrain clip movement delta to prevent overlaps with adjacent clips\n * and going before sample 0.\n *\n * @param clip - The clip being dragged\n * @param deltaSamples - Requested movement in samples (negative = left, positive = right)\n * @param sortedClips - All clips on the track, sorted by startSample\n * @param clipIndex - Index of the dragged clip in sortedClips\n * @returns Constrained delta that prevents overlaps\n */\nexport function constrainClipDrag(\n clip: AudioClip,\n deltaSamples: number,\n sortedClips: AudioClip[],\n clipIndex: number\n): number {\n let delta = deltaSamples;\n\n // Constraint 1: Cannot go before sample 0\n const minDelta = -clip.startSample;\n delta = Math.max(delta, minDelta);\n\n // Constraint 2: Cannot overlap previous clip\n if (clipIndex > 0) {\n const prevClip = sortedClips[clipIndex - 1];\n const prevClipEnd = prevClip.startSample + prevClip.durationSamples;\n // clip.startSample + delta >= prevClipEnd\n const minDeltaPrev = prevClipEnd - clip.startSample;\n delta = Math.max(delta, minDeltaPrev);\n }\n\n // Constraint 3: Cannot overlap next clip\n if (clipIndex < sortedClips.length - 1) {\n const nextClip = sortedClips[clipIndex + 1];\n // clip.startSample + clip.durationSamples + delta <= nextClip.startSample\n const maxDeltaNext = nextClip.startSample - (clip.startSample + clip.durationSamples);\n delta = Math.min(delta, maxDeltaNext);\n }\n\n return delta;\n}\n\n/**\n * Constrain boundary trim delta for left or right edge of a clip.\n *\n * LEFT boundary: delta moves the left edge (positive = shrink, negative = expand)\n * - startSample += delta, offsetSamples += delta, durationSamples -= delta\n *\n * RIGHT boundary: delta applied to durationSamples (positive = expand, negative = shrink)\n * - durationSamples += delta\n *\n * @param clip - The clip being trimmed\n * @param deltaSamples - Requested trim delta in samples\n * @param boundary - Which edge is being trimmed: 'left' or 'right'\n * @param sortedClips - All clips on the track, sorted by startSample\n * @param clipIndex - Index of the trimmed clip in sortedClips\n * @param minDurationSamples - Minimum allowed clip duration in samples\n * @returns Constrained delta\n */\nexport function constrainBoundaryTrim(\n clip: AudioClip,\n deltaSamples: number,\n boundary: 'left' | 'right',\n sortedClips: AudioClip[],\n clipIndex: number,\n minDurationSamples: number\n): number {\n let delta = deltaSamples;\n\n if (boundary === 'left') {\n // Constraint 1: startSample + delta >= 0\n delta = Math.max(delta, -clip.startSample);\n\n // Constraint 2: offsetSamples + delta >= 0\n delta = Math.max(delta, -clip.offsetSamples);\n\n // Constraint 3: Cannot overlap previous clip\n if (clipIndex > 0) {\n const prevClip = sortedClips[clipIndex - 1];\n const prevClipEnd = prevClip.startSample + prevClip.durationSamples;\n // startSample + delta >= prevClipEnd\n delta = Math.max(delta, prevClipEnd - clip.startSample);\n }\n\n // Constraint 4: durationSamples - delta >= minDurationSamples\n // delta <= durationSamples - minDurationSamples\n delta = Math.min(delta, clip.durationSamples - minDurationSamples);\n } else {\n // RIGHT boundary\n\n // Constraint 1: durationSamples + delta >= minDurationSamples\n // delta >= minDurationSamples - durationSamples\n delta = Math.max(delta, minDurationSamples - clip.durationSamples);\n\n // Constraint 2: offsetSamples + (durationSamples + delta) <= sourceDurationSamples\n // delta <= sourceDurationSamples - offsetSamples - durationSamples\n delta = Math.min(delta, clip.sourceDurationSamples - clip.offsetSamples - clip.durationSamples);\n\n // Constraint 3: startSample + (durationSamples + delta) <= nextClip.startSample\n if (clipIndex < sortedClips.length - 1) {\n const nextClip = sortedClips[clipIndex + 1];\n // delta <= nextClip.startSample - startSample - durationSamples\n delta = Math.min(delta, nextClip.startSample - clip.startSample - clip.durationSamples);\n }\n }\n\n return delta;\n}\n\n/**\n * Snap a split sample position to the nearest pixel boundary.\n *\n * @param splitSample - The sample position to snap\n * @param samplesPerPixel - Current zoom level (samples per pixel)\n * @returns Snapped sample position\n */\nexport function calculateSplitPoint(splitSample: number, samplesPerPixel: number): number {\n return Math.floor(splitSample / samplesPerPixel) * samplesPerPixel;\n}\n\n/**\n * Split a clip into two clips at the given sample position.\n *\n * The left clip retains the original fadeIn; the right clip retains the original fadeOut.\n * Both clips share the same waveformData reference.\n * If the clip has a name, suffixes \" (1)\" and \" (2)\" are appended.\n *\n * @param clip - The clip to split\n * @param splitSample - The timeline sample position where the split occurs\n * @returns Object with `left` and `right` AudioClip\n */\nexport function splitClip(\n clip: AudioClip,\n splitSample: number\n): { left: AudioClip; right: AudioClip } {\n const leftDuration = splitSample - clip.startSample;\n const rightDuration = clip.durationSamples - leftDuration;\n\n const leftName = clip.name ? `${clip.name} (1)` : undefined;\n const rightName = clip.name ? `${clip.name} (2)` : undefined;\n\n const left = createClip({\n startSample: clip.startSample,\n durationSamples: leftDuration,\n offsetSamples: clip.offsetSamples,\n sampleRate: clip.sampleRate,\n sourceDurationSamples: clip.sourceDurationSamples,\n gain: clip.gain,\n name: leftName,\n color: clip.color,\n fadeIn: clip.fadeIn,\n audioBuffer: clip.audioBuffer,\n waveformData: clip.waveformData,\n });\n\n const right = createClip({\n startSample: splitSample,\n durationSamples: rightDuration,\n offsetSamples: clip.offsetSamples + leftDuration,\n sampleRate: clip.sampleRate,\n sourceDurationSamples: clip.sourceDurationSamples,\n gain: clip.gain,\n name: rightName,\n color: clip.color,\n fadeOut: clip.fadeOut,\n audioBuffer: clip.audioBuffer,\n waveformData: clip.waveformData,\n });\n\n return { left, right };\n}\n\n/**\n * Check whether a clip can be split at the given sample position.\n *\n * The split point must be strictly inside the clip (not at start or end),\n * and both resulting clips must meet the minimum duration requirement.\n *\n * @param clip - The clip to check\n * @param sample - The timeline sample position to test\n * @param minDurationSamples - Minimum allowed clip duration in samples\n * @returns true if the split is valid\n */\nexport function canSplitAt(clip: AudioClip, sample: number, minDurationSamples: number): boolean {\n const clipEnd = clip.startSample + clip.durationSamples;\n\n // Must be strictly within clip bounds\n if (sample <= clip.startSample || sample >= clipEnd) {\n return false;\n }\n\n // Both resulting clips must meet minimum duration\n const leftDuration = sample - clip.startSample;\n const rightDuration = clipEnd - sample;\n\n return leftDuration >= minDurationSamples && rightDuration >= minDurationSamples;\n}\n","/**\n * Viewport operations for virtual scrolling.\n *\n * Pure math helpers that determine which portion of the timeline\n * is visible and which canvas chunks need to be mounted.\n */\n\n/**\n * Calculate the visible region with an overscan buffer for virtual scrolling.\n *\n * The buffer extends the visible range on both sides so that chunks are\n * mounted slightly before they scroll into view, preventing flicker.\n *\n * @param scrollLeft - Current horizontal scroll position in pixels\n * @param containerWidth - Width of the scroll container in pixels\n * @param bufferRatio - Multiplier for buffer size (default 1.5x container width)\n * @returns Object with visibleStart and visibleEnd in pixels\n */\nexport function calculateViewportBounds(\n scrollLeft: number,\n containerWidth: number,\n bufferRatio: number = 1.5\n): { visibleStart: number; visibleEnd: number } {\n const buffer = containerWidth * bufferRatio;\n return {\n visibleStart: Math.max(0, scrollLeft - buffer),\n visibleEnd: scrollLeft + containerWidth + buffer,\n };\n}\n\n/**\n * Get an array of chunk indices that overlap the visible viewport.\n *\n * Chunks are fixed-width segments of the total timeline width. Only chunks\n * that intersect [visibleStart, visibleEnd) are included. The last chunk\n * may be narrower than chunkWidth if totalWidth is not evenly divisible.\n *\n * @param totalWidth - Total width of the timeline in pixels\n * @param chunkWidth - Width of each chunk in pixels\n * @param visibleStart - Left edge of the visible region in pixels\n * @param visibleEnd - Right edge of the visible region in pixels\n * @returns Array of chunk indices (0-based) that are visible\n */\nexport function getVisibleChunkIndices(\n totalWidth: number,\n chunkWidth: number,\n visibleStart: number,\n visibleEnd: number\n): number[] {\n const totalChunks = Math.ceil(totalWidth / chunkWidth);\n const indices: number[] = [];\n\n for (let i = 0; i < totalChunks; i++) {\n const chunkLeft = i * chunkWidth;\n const thisChunkWidth = Math.min(totalWidth - chunkLeft, chunkWidth);\n const chunkEnd = chunkLeft + thisChunkWidth;\n\n if (chunkEnd <= visibleStart || chunkLeft >= visibleEnd) {\n continue;\n }\n\n indices.push(i);\n }\n\n return indices;\n}\n\n/**\n * Determine whether a scroll change is large enough to warrant\n * recalculating the viewport and re-rendering chunks.\n *\n * Small scroll movements are ignored to avoid excessive recomputation\n * during smooth scrolling.\n *\n * @param oldScrollLeft - Previous scroll position in pixels\n * @param newScrollLeft - Current scroll position in pixels\n * @param threshold - Minimum pixel delta to trigger an update (default 100)\n * @returns true if the scroll delta meets or exceeds the threshold\n */\nexport function shouldUpdateViewport(\n oldScrollLeft: number,\n newScrollLeft: number,\n threshold: number = 100\n): boolean {\n return Math.abs(oldScrollLeft - newScrollLeft) >= threshold;\n}\n","import type { ClipTrack } from '@waveform-playlist/core';\n\n/**\n * Calculate total timeline duration in seconds from all tracks/clips.\n * Iterates all clips, finds the furthest clip end (startSample + durationSamples),\n * converts to seconds using each clip's sampleRate.\n *\n * @param tracks - Array of clip tracks\n * @returns Duration in seconds\n */\nexport function calculateDuration(tracks: ClipTrack[]): number {\n let maxDuration = 0;\n for (const track of tracks) {\n for (const clip of track.clips) {\n const clipEndSample = clip.startSample + clip.durationSamples;\n const clipEnd = clipEndSample / clip.sampleRate;\n maxDuration = Math.max(maxDuration, clipEnd);\n }\n }\n return maxDuration;\n}\n\n/**\n * Find the zoom level index closest to a given samplesPerPixel.\n * Returns exact match if found, otherwise the index whose value is\n * nearest to the target (by absolute difference).\n *\n * @param targetSamplesPerPixel - The samplesPerPixel value to find\n * @param zoomLevels - Array of available zoom levels (samplesPerPixel values)\n * @returns Index into the zoomLevels array\n */\nexport function findClosestZoomIndex(targetSamplesPerPixel: number, zoomLevels: number[]): number {\n if (zoomLevels.length === 0) return 0;\n\n let bestIndex = 0;\n let bestDiff = Math.abs(zoomLevels[0] - targetSamplesPerPixel);\n\n for (let i = 1; i < zoomLevels.length; i++) {\n const diff = Math.abs(zoomLevels[i] - targetSamplesPerPixel);\n if (diff < bestDiff) {\n bestDiff = diff;\n bestIndex = i;\n }\n }\n\n return bestIndex;\n}\n\n/**\n * Keep viewport centered during zoom changes.\n * Calculates center time from old zoom, computes new pixel position at new zoom,\n * and returns new scrollLeft clamped to >= 0.\n *\n * @param oldSamplesPerPixel - Previous zoom level\n * @param newSamplesPerPixel - New zoom level\n * @param scrollLeft - Current horizontal scroll position\n * @param containerWidth - Viewport width in pixels\n * @param sampleRate - Audio sample rate\n * @param controlWidth - Width of track controls panel (defaults to 0)\n * @returns New scrollLeft value\n */\nexport function calculateZoomScrollPosition(\n oldSamplesPerPixel: number,\n newSamplesPerPixel: number,\n scrollLeft: number,\n containerWidth: number,\n sampleRate: number,\n controlWidth: number = 0\n): number {\n const centerPixel = scrollLeft + containerWidth / 2 - controlWidth;\n const centerTime = (centerPixel * oldSamplesPerPixel) / sampleRate;\n const newCenterPixel = (centerTime * sampleRate) / newSamplesPerPixel;\n const newScrollLeft = newCenterPixel + controlWidth - containerWidth / 2;\n return Math.max(0, newScrollLeft);\n}\n\n/**\n * Clamp a seek position to the valid range [0, duration].\n *\n * @param time - Requested seek time in seconds\n * @param duration - Maximum duration in seconds\n * @returns Clamped time value\n */\nexport function clampSeekPosition(time: number, duration: number): number {\n return Math.max(0, Math.min(time, duration));\n}\n","/**\n * PlaylistEngine — Stateful, framework-agnostic timeline engine.\n *\n * Composes pure operations from ./operations with an event emitter\n * and optional PlayoutAdapter for audio playback delegation.\n */\n\nimport type { AudioClip, ClipTrack } from '@waveform-playlist/core';\nimport { sortClipsByTime } from '@waveform-playlist/core';\nimport {\n constrainClipDrag,\n constrainBoundaryTrim,\n canSplitAt,\n splitClip as splitClipOp,\n} from './operations/clipOperations';\nimport {\n calculateDuration,\n clampSeekPosition,\n findClosestZoomIndex,\n} from './operations/timelineOperations';\nimport type { PlayoutAdapter, EngineState, EngineEvents, PlaylistEngineOptions } from './types';\n\nconst DEFAULT_SAMPLE_RATE = 44100;\nconst DEFAULT_SAMPLES_PER_PIXEL = 1000;\nconst DEFAULT_ZOOM_LEVELS = [256, 512, 1024, 2048, 4096, 8192];\nconst DEFAULT_MIN_DURATION_SECONDS = 0.1;\n\ntype EventName = keyof EngineEvents;\n\nexport class PlaylistEngine {\n private _tracks: ClipTrack[] = [];\n private _currentTime = 0;\n private _isPlaying = false;\n private _selectedTrackId: string | null = null;\n private _sampleRate: number;\n private _zoomLevels: number[];\n private _zoomIndex: number;\n private _adapter: PlayoutAdapter | null;\n private _animFrameId: number | null = null;\n private _disposed = false;\n // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type\n private _listeners: Map<string, Set<Function>> = new Map();\n\n constructor(options: PlaylistEngineOptions = {}) {\n this._sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;\n this._zoomLevels = [...(options.zoomLevels ?? DEFAULT_ZOOM_LEVELS)];\n this._adapter = options.adapter ?? null;\n\n if (this._zoomLevels.length === 0) {\n throw new Error('PlaylistEngine: zoomLevels must not be empty');\n }\n\n const initialSpp = options.samplesPerPixel ?? DEFAULT_SAMPLES_PER_PIXEL;\n this._zoomIndex = findClosestZoomIndex(initialSpp, this._zoomLevels);\n }\n\n // ---------------------------------------------------------------------------\n // State snapshot\n // ---------------------------------------------------------------------------\n\n getState(): EngineState {\n return {\n tracks: this._tracks.map((t) => ({ ...t, clips: [...t.clips] })),\n duration: calculateDuration(this._tracks),\n currentTime: this._currentTime,\n isPlaying: this._isPlaying,\n samplesPerPixel: this._zoomLevels[this._zoomIndex],\n sampleRate: this._sampleRate,\n selectedTrackId: this._selectedTrackId,\n zoomIndex: this._zoomIndex,\n canZoomIn: this._zoomIndex > 0,\n canZoomOut: this._zoomIndex < this._zoomLevels.length - 1,\n };\n }\n\n // ---------------------------------------------------------------------------\n // Track Management\n // ---------------------------------------------------------------------------\n\n setTracks(tracks: ClipTrack[]): void {\n this._tracks = [...tracks];\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n addTrack(track: ClipTrack): void {\n this._tracks = [...this._tracks, track];\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n removeTrack(trackId: string): void {\n if (!this._tracks.some((t) => t.id === trackId)) return;\n this._tracks = this._tracks.filter((t) => t.id !== trackId);\n if (this._selectedTrackId === trackId) {\n this._selectedTrackId = null;\n }\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n selectTrack(trackId: string | null): void {\n this._selectedTrackId = trackId;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Clip Editing (delegates to operations/)\n // ---------------------------------------------------------------------------\n\n moveClip(trackId: string, clipId: string, deltaSamples: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) return;\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) return;\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n\n const constrainedDelta = constrainClipDrag(clip, deltaSamples, sortedClips, sortedIndex);\n\n if (constrainedDelta === 0) return;\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = t.clips.map((c: AudioClip, i: number) =>\n i === clipIndex\n ? {\n ...c,\n startSample: Math.floor(c.startSample + constrainedDelta),\n }\n : c\n );\n return { ...t, clips: newClips };\n });\n\n this._emitStateChange();\n }\n\n splitClip(trackId: string, clipId: string, atSample: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) return;\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) return;\n\n const clip = track.clips[clipIndex];\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n if (!canSplitAt(clip, atSample, minDuration)) return;\n\n const { left, right } = splitClipOp(clip, atSample);\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = [...t.clips];\n newClips.splice(clipIndex, 1, left, right);\n return { ...t, clips: newClips };\n });\n\n this._emitStateChange();\n }\n\n trimClip(\n trackId: string,\n clipId: string,\n boundary: 'left' | 'right',\n deltaSamples: number\n ): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) return;\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) return;\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n const constrained = constrainBoundaryTrim(\n clip,\n deltaSamples,\n boundary,\n sortedClips,\n sortedIndex,\n minDuration\n );\n\n if (constrained === 0) return;\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = t.clips.map((c: AudioClip, i: number) => {\n if (i !== clipIndex) return c;\n if (boundary === 'left') {\n return {\n ...c,\n startSample: c.startSample + constrained,\n offsetSamples: c.offsetSamples + constrained,\n durationSamples: c.durationSamples - constrained,\n };\n } else {\n return { ...c, durationSamples: c.durationSamples + constrained };\n }\n });\n return { ...t, clips: newClips };\n });\n\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Playback (delegates to adapter, no-ops without adapter)\n // ---------------------------------------------------------------------------\n\n async play(startTime?: number, endTime?: number): Promise<void> {\n if (startTime !== undefined) {\n const duration = calculateDuration(this._tracks);\n this._currentTime = clampSeekPosition(startTime, duration);\n }\n\n if (this._adapter) {\n await this._adapter.play(this._currentTime, endTime);\n this._startTimeUpdateLoop();\n }\n\n this._isPlaying = true;\n this._emit('play');\n this._emitStateChange();\n }\n\n pause(): void {\n this._isPlaying = false;\n this._stopTimeUpdateLoop();\n this._adapter?.pause();\n if (this._adapter) {\n this._currentTime = this._adapter.getCurrentTime();\n }\n this._emit('pause');\n this._emitStateChange();\n }\n\n stop(): void {\n this._isPlaying = false;\n this._currentTime = 0;\n this._stopTimeUpdateLoop();\n this._adapter?.stop();\n this._emit('stop');\n this._emitStateChange();\n }\n\n seek(time: number): void {\n const duration = calculateDuration(this._tracks);\n this._currentTime = clampSeekPosition(time, duration);\n this._adapter?.seek(this._currentTime);\n this._emitStateChange();\n }\n\n setMasterVolume(volume: number): void {\n this._adapter?.setMasterVolume(volume);\n }\n\n // ---------------------------------------------------------------------------\n // Per-Track Audio (delegates to adapter)\n // ---------------------------------------------------------------------------\n\n setTrackVolume(trackId: string, volume: number): void {\n this._adapter?.setTrackVolume(trackId, volume);\n }\n\n setTrackMute(trackId: string, muted: boolean): void {\n this._adapter?.setTrackMute(trackId, muted);\n }\n\n setTrackSolo(trackId: string, soloed: boolean): void {\n this._adapter?.setTrackSolo(trackId, soloed);\n }\n\n setTrackPan(trackId: string, pan: number): void {\n this._adapter?.setTrackPan(trackId, pan);\n }\n\n // ---------------------------------------------------------------------------\n // Zoom\n // ---------------------------------------------------------------------------\n\n zoomIn(): void {\n if (this._zoomIndex > 0) {\n this._zoomIndex--;\n this._emitStateChange();\n }\n }\n\n zoomOut(): void {\n if (this._zoomIndex < this._zoomLevels.length - 1) {\n this._zoomIndex++;\n this._emitStateChange();\n }\n }\n\n setZoomLevel(samplesPerPixel: number): void {\n const newIndex = findClosestZoomIndex(samplesPerPixel, this._zoomLevels);\n if (newIndex === this._zoomIndex) return;\n this._zoomIndex = newIndex;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Events\n // ---------------------------------------------------------------------------\n\n on<K extends EventName>(event: K, listener: EngineEvents[K]): void {\n if (!this._listeners.has(event)) {\n this._listeners.set(event, new Set());\n }\n this._listeners.get(event)!.add(listener);\n }\n\n off<K extends EventName>(event: K, listener: EngineEvents[K]): void {\n this._listeners.get(event)?.delete(listener);\n }\n\n // ---------------------------------------------------------------------------\n // Lifecycle\n // ---------------------------------------------------------------------------\n\n dispose(): void {\n if (this._disposed) return;\n this._disposed = true;\n this._stopTimeUpdateLoop();\n this._adapter?.dispose();\n this._listeners.clear();\n }\n\n // ---------------------------------------------------------------------------\n // Private helpers\n // ---------------------------------------------------------------------------\n\n private _emit(event: string, ...args: unknown[]): void {\n const listeners = this._listeners.get(event);\n if (listeners) {\n for (const listener of listeners) {\n try {\n listener(...args);\n } catch (error) {\n console.warn('[waveform-playlist/engine] Error in event listener:', error);\n }\n }\n }\n }\n\n private _emitStateChange(): void {\n this._emit('statechange', this.getState());\n }\n\n private _startTimeUpdateLoop(): void {\n // Guard for Node.js / SSR environments where RAF is unavailable\n if (typeof requestAnimationFrame === 'undefined') return;\n\n this._stopTimeUpdateLoop();\n\n const tick = () => {\n if (this._disposed || !this._isPlaying) return;\n if (this._adapter) {\n this._currentTime = this._adapter.getCurrentTime();\n this._emit('timeupdate', this._currentTime);\n }\n this._animFrameId = requestAnimationFrame(tick);\n };\n\n this._animFrameId = requestAnimationFrame(tick);\n }\n\n private _stopTimeUpdateLoop(): void {\n if (this._animFrameId !== null && typeof cancelAnimationFrame !== 'undefined') {\n cancelAnimationFrame(this._animFrameId);\n this._animFrameId = null;\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACQA,kBAA2B;AAYpB,SAAS,kBACd,MACA,cACA,aACA,WACQ;AACR,MAAI,QAAQ;AAGZ,QAAM,WAAW,CAAC,KAAK;AACvB,UAAQ,KAAK,IAAI,OAAO,QAAQ;AAGhC,MAAI,YAAY,GAAG;AACjB,UAAM,WAAW,YAAY,YAAY,CAAC;AAC1C,UAAM,cAAc,SAAS,cAAc,SAAS;AAEpD,UAAM,eAAe,cAAc,KAAK;AACxC,YAAQ,KAAK,IAAI,OAAO,YAAY;AAAA,EACtC;AAGA,MAAI,YAAY,YAAY,SAAS,GAAG;AACtC,UAAM,WAAW,YAAY,YAAY,CAAC;AAE1C,UAAM,eAAe,SAAS,eAAe,KAAK,cAAc,KAAK;AACrE,YAAQ,KAAK,IAAI,OAAO,YAAY;AAAA,EACtC;AAEA,SAAO;AACT;AAmBO,SAAS,sBACd,MACA,cACA,UACA,aACA,WACA,oBACQ;AACR,MAAI,QAAQ;AAEZ,MAAI,aAAa,QAAQ;AAEvB,YAAQ,KAAK,IAAI,OAAO,CAAC,KAAK,WAAW;AAGzC,YAAQ,KAAK,IAAI,OAAO,CAAC,KAAK,aAAa;AAG3C,QAAI,YAAY,GAAG;AACjB,YAAM,WAAW,YAAY,YAAY,CAAC;AAC1C,YAAM,cAAc,SAAS,cAAc,SAAS;AAEpD,cAAQ,KAAK,IAAI,OAAO,cAAc,KAAK,WAAW;AAAA,IACxD;AAIA,YAAQ,KAAK,IAAI,OAAO,KAAK,kBAAkB,kBAAkB;AAAA,EACnE,OAAO;AAKL,YAAQ,KAAK,IAAI,OAAO,qBAAqB,KAAK,eAAe;AAIjE,YAAQ,KAAK,IAAI,OAAO,KAAK,wBAAwB,KAAK,gBAAgB,KAAK,eAAe;AAG9F,QAAI,YAAY,YAAY,SAAS,GAAG;AACtC,YAAM,WAAW,YAAY,YAAY,CAAC;AAE1C,cAAQ,KAAK,IAAI,OAAO,SAAS,cAAc,KAAK,cAAc,KAAK,eAAe;AAAA,IACxF;AAAA,EACF;AAEA,SAAO;AACT;AASO,SAAS,oBAAoB,aAAqB,iBAAiC;AACxF,SAAO,KAAK,MAAM,cAAc,eAAe,IAAI;AACrD;AAaO,SAAS,UACd,MACA,aACuC;AACvC,QAAM,eAAe,cAAc,KAAK;AACxC,QAAM,gBAAgB,KAAK,kBAAkB;AAE7C,QAAM,WAAW,KAAK,OAAO,GAAG,KAAK,IAAI,SAAS;AAClD,QAAM,YAAY,KAAK,OAAO,GAAG,KAAK,IAAI,SAAS;AAEnD,QAAM,WAAO,wBAAW;AAAA,IACtB,aAAa,KAAK;AAAA,IAClB,iBAAiB;AAAA,IACjB,eAAe,KAAK;AAAA,IACpB,YAAY,KAAK;AAAA,IACjB,uBAAuB,KAAK;AAAA,IAC5B,MAAM,KAAK;AAAA,IACX,MAAM;AAAA,IACN,OAAO,KAAK;AAAA,IACZ,QAAQ,KAAK;AAAA,IACb,aAAa,KAAK;AAAA,IAClB,cAAc,KAAK;AAAA,EACrB,CAAC;AAED,QAAM,YAAQ,wBAAW;AAAA,IACvB,aAAa;AAAA,IACb,iBAAiB;AAAA,IACjB,eAAe,KAAK,gBAAgB;AAAA,IACpC,YAAY,KAAK;AAAA,IACjB,uBAAuB,KAAK;AAAA,IAC5B,MAAM,KAAK;AAAA,IACX,MAAM;AAAA,IACN,OAAO,KAAK;AAAA,IACZ,SAAS,KAAK;AAAA,IACd,aAAa,KAAK;AAAA,IAClB,cAAc,KAAK;AAAA,EACrB,CAAC;AAED,SAAO,EAAE,MAAM,MAAM;AACvB;AAaO,SAAS,WAAW,MAAiB,QAAgB,oBAAqC;AAC/F,QAAM,UAAU,KAAK,cAAc,KAAK;AAGxC,MAAI,UAAU,KAAK,eAAe,UAAU,SAAS;AACnD,WAAO;AAAA,EACT;AAGA,QAAM,eAAe,SAAS,KAAK;AACnC,QAAM,gBAAgB,UAAU;AAEhC,SAAO,gBAAgB,sBAAsB,iBAAiB;AAChE;;;AC5LO,SAAS,wBACd,YACA,gBACA,cAAsB,KACwB;AAC9C,QAAM,SAAS,iBAAiB;AAChC,SAAO;AAAA,IACL,cAAc,KAAK,IAAI,GAAG,aAAa,MAAM;AAAA,IAC7C,YAAY,aAAa,iBAAiB;AAAA,EAC5C;AACF;AAeO,SAAS,uBACd,YACA,YACA,cACA,YACU;AACV,QAAM,cAAc,KAAK,KAAK,aAAa,UAAU;AACrD,QAAM,UAAoB,CAAC;AAE3B,WAAS,IAAI,GAAG,IAAI,aAAa,KAAK;AACpC,UAAM,YAAY,IAAI;AACtB,UAAM,iBAAiB,KAAK,IAAI,aAAa,WAAW,UAAU;AAClE,UAAM,WAAW,YAAY;AAE7B,QAAI,YAAY,gBAAgB,aAAa,YAAY;AACvD;AAAA,IACF;AAEA,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,SAAO;AACT;AAcO,SAAS,qBACd,eACA,eACA,YAAoB,KACX;AACT,SAAO,KAAK,IAAI,gBAAgB,aAAa,KAAK;AACpD;;;AC3EO,SAAS,kBAAkB,QAA6B;AAC7D,MAAI,cAAc;AAClB,aAAW,SAAS,QAAQ;AAC1B,eAAW,QAAQ,MAAM,OAAO;AAC9B,YAAM,gBAAgB,KAAK,cAAc,KAAK;AAC9C,YAAM,UAAU,gBAAgB,KAAK;AACrC,oBAAc,KAAK,IAAI,aAAa,OAAO;AAAA,IAC7C;AAAA,EACF;AACA,SAAO;AACT;AAWO,SAAS,qBAAqB,uBAA+B,YAA8B;AAChG,MAAI,WAAW,WAAW,EAAG,QAAO;AAEpC,MAAI,YAAY;AAChB,MAAI,WAAW,KAAK,IAAI,WAAW,CAAC,IAAI,qBAAqB;AAE7D,WAAS,IAAI,GAAG,IAAI,WAAW,QAAQ,KAAK;AAC1C,UAAM,OAAO,KAAK,IAAI,WAAW,CAAC,IAAI,qBAAqB;AAC3D,QAAI,OAAO,UAAU;AACnB,iBAAW;AACX,kBAAY;AAAA,IACd;AAAA,EACF;AAEA,SAAO;AACT;AAeO,SAAS,4BACd,oBACA,oBACA,YACA,gBACA,YACA,eAAuB,GACf;AACR,QAAM,cAAc,aAAa,iBAAiB,IAAI;AACtD,QAAM,aAAc,cAAc,qBAAsB;AACxD,QAAM,iBAAkB,aAAa,aAAc;AACnD,QAAM,gBAAgB,iBAAiB,eAAe,iBAAiB;AACvE,SAAO,KAAK,IAAI,GAAG,aAAa;AAClC;AASO,SAAS,kBAAkB,MAAc,UAA0B;AACxE,SAAO,KAAK,IAAI,GAAG,KAAK,IAAI,MAAM,QAAQ,CAAC;AAC7C;;;AC7EA,IAAAA,eAAgC;AAchC,IAAM,sBAAsB;AAC5B,IAAM,4BAA4B;AAClC,IAAM,sBAAsB,CAAC,KAAK,KAAK,MAAM,MAAM,MAAM,IAAI;AAC7D,IAAM,+BAA+B;AAI9B,IAAM,iBAAN,MAAqB;AAAA,EAc1B,YAAY,UAAiC,CAAC,GAAG;AAbjD,SAAQ,UAAuB,CAAC;AAChC,SAAQ,eAAe;AACvB,SAAQ,aAAa;AACrB,SAAQ,mBAAkC;AAK1C,SAAQ,eAA8B;AACtC,SAAQ,YAAY;AAEpB;AAAA,SAAQ,aAAyC,oBAAI,IAAI;AAGvD,SAAK,cAAc,QAAQ,cAAc;AACzC,SAAK,cAAc,CAAC,GAAI,QAAQ,cAAc,mBAAoB;AAClE,SAAK,WAAW,QAAQ,WAAW;AAEnC,QAAI,KAAK,YAAY,WAAW,GAAG;AACjC,YAAM,IAAI,MAAM,8CAA8C;AAAA,IAChE;AAEA,UAAM,aAAa,QAAQ,mBAAmB;AAC9C,SAAK,aAAa,qBAAqB,YAAY,KAAK,WAAW;AAAA,EACrE;AAAA;AAAA;AAAA;AAAA,EAMA,WAAwB;AACtB,WAAO;AAAA,MACL,QAAQ,KAAK,QAAQ,IAAI,CAAC,OAAO,EAAE,GAAG,GAAG,OAAO,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE;AAAA,MAC/D,UAAU,kBAAkB,KAAK,OAAO;AAAA,MACxC,aAAa,KAAK;AAAA,MAClB,WAAW,KAAK;AAAA,MAChB,iBAAiB,KAAK,YAAY,KAAK,UAAU;AAAA,MACjD,YAAY,KAAK;AAAA,MACjB,iBAAiB,KAAK;AAAA,MACtB,WAAW,KAAK;AAAA,MAChB,WAAW,KAAK,aAAa;AAAA,MAC7B,YAAY,KAAK,aAAa,KAAK,YAAY,SAAS;AAAA,IAC1D;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAMA,UAAU,QAA2B;AACnC,SAAK,UAAU,CAAC,GAAG,MAAM;AACzB,SAAK,UAAU,UAAU,KAAK,OAAO;AACrC,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,SAAS,OAAwB;AAC/B,SAAK,UAAU,CAAC,GAAG,KAAK,SAAS,KAAK;AACtC,SAAK,UAAU,UAAU,KAAK,OAAO;AACrC,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,YAAY,SAAuB;AACjC,QAAI,CAAC,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO,EAAG;AACjD,SAAK,UAAU,KAAK,QAAQ,OAAO,CAAC,MAAM,EAAE,OAAO,OAAO;AAC1D,QAAI,KAAK,qBAAqB,SAAS;AACrC,WAAK,mBAAmB;AAAA,IAC1B;AACA,SAAK,UAAU,UAAU,KAAK,OAAO;AACrC,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,YAAY,SAA8B;AACxC,SAAK,mBAAmB;AACxB,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMA,SAAS,SAAiB,QAAgB,cAA4B;AACpE,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,CAAC,MAAO;AAEZ,UAAM,YAAY,MAAM,MAAM,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AACzE,QAAI,cAAc,GAAI;AAEtB,UAAM,OAAO,MAAM,MAAM,SAAS;AAClC,UAAM,kBAAc,8BAAgB,MAAM,KAAK;AAC/C,UAAM,cAAc,YAAY,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AAE3E,UAAM,mBAAmB,kBAAkB,MAAM,cAAc,aAAa,WAAW;AAEvF,QAAI,qBAAqB,EAAG;AAE5B,SAAK,UAAU,KAAK,QAAQ,IAAI,CAAC,MAAM;AACrC,UAAI,EAAE,OAAO,QAAS,QAAO;AAC7B,YAAM,WAAW,EAAE,MAAM;AAAA,QAAI,CAAC,GAAc,MAC1C,MAAM,YACF;AAAA,UACE,GAAG;AAAA,UACH,aAAa,KAAK,MAAM,EAAE,cAAc,gBAAgB;AAAA,QAC1D,IACA;AAAA,MACN;AACA,aAAO,EAAE,GAAG,GAAG,OAAO,SAAS;AAAA,IACjC,CAAC;AAED,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,UAAU,SAAiB,QAAgB,UAAwB;AACjE,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,CAAC,MAAO;AAEZ,UAAM,YAAY,MAAM,MAAM,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AACzE,QAAI,cAAc,GAAI;AAEtB,UAAM,OAAO,MAAM,MAAM,SAAS;AAClC,UAAM,cAAc,KAAK,MAAM,+BAA+B,KAAK,WAAW;AAE9E,QAAI,CAAC,WAAW,MAAM,UAAU,WAAW,EAAG;AAE9C,UAAM,EAAE,MAAM,MAAM,IAAI,UAAY,MAAM,QAAQ;AAElD,SAAK,UAAU,KAAK,QAAQ,IAAI,CAAC,MAAM;AACrC,UAAI,EAAE,OAAO,QAAS,QAAO;AAC7B,YAAM,WAAW,CAAC,GAAG,EAAE,KAAK;AAC5B,eAAS,OAAO,WAAW,GAAG,MAAM,KAAK;AACzC,aAAO,EAAE,GAAG,GAAG,OAAO,SAAS;AAAA,IACjC,CAAC;AAED,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,SACE,SACA,QACA,UACA,cACM;AACN,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,CAAC,MAAO;AAEZ,UAAM,YAAY,MAAM,MAAM,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AACzE,QAAI,cAAc,GAAI;AAEtB,UAAM,OAAO,MAAM,MAAM,SAAS;AAClC,UAAM,kBAAc,8BAAgB,MAAM,KAAK;AAC/C,UAAM,cAAc,YAAY,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AAC3E,UAAM,cAAc,KAAK,MAAM,+BAA+B,KAAK,WAAW;AAE9E,UAAM,cAAc;AAAA,MAClB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAEA,QAAI,gBAAgB,EAAG;AAEvB,SAAK,UAAU,KAAK,QAAQ,IAAI,CAAC,MAAM;AACrC,UAAI,EAAE,OAAO,QAAS,QAAO;AAC7B,YAAM,WAAW,EAAE,MAAM,IAAI,CAAC,GAAc,MAAc;AACxD,YAAI,MAAM,UAAW,QAAO;AAC5B,YAAI,aAAa,QAAQ;AACvB,iBAAO;AAAA,YACL,GAAG;AAAA,YACH,aAAa,EAAE,cAAc;AAAA,YAC7B,eAAe,EAAE,gBAAgB;AAAA,YACjC,iBAAiB,EAAE,kBAAkB;AAAA,UACvC;AAAA,QACF,OAAO;AACL,iBAAO,EAAE,GAAG,GAAG,iBAAiB,EAAE,kBAAkB,YAAY;AAAA,QAClE;AAAA,MACF,CAAC;AACD,aAAO,EAAE,GAAG,GAAG,OAAO,SAAS;AAAA,IACjC,CAAC;AAED,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,KAAK,WAAoB,SAAiC;AAC9D,QAAI,cAAc,QAAW;AAC3B,YAAM,WAAW,kBAAkB,KAAK,OAAO;AAC/C,WAAK,eAAe,kBAAkB,WAAW,QAAQ;AAAA,IAC3D;AAEA,QAAI,KAAK,UAAU;AACjB,YAAM,KAAK,SAAS,KAAK,KAAK,cAAc,OAAO;AACnD,WAAK,qBAAqB;AAAA,IAC5B;AAEA,SAAK,aAAa;AAClB,SAAK,MAAM,MAAM;AACjB,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,QAAc;AACZ,SAAK,aAAa;AAClB,SAAK,oBAAoB;AACzB,SAAK,UAAU,MAAM;AACrB,QAAI,KAAK,UAAU;AACjB,WAAK,eAAe,KAAK,SAAS,eAAe;AAAA,IACnD;AACA,SAAK,MAAM,OAAO;AAClB,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,OAAa;AACX,SAAK,aAAa;AAClB,SAAK,eAAe;AACpB,SAAK,oBAAoB;AACzB,SAAK,UAAU,KAAK;AACpB,SAAK,MAAM,MAAM;AACjB,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,KAAK,MAAoB;AACvB,UAAM,WAAW,kBAAkB,KAAK,OAAO;AAC/C,SAAK,eAAe,kBAAkB,MAAM,QAAQ;AACpD,SAAK,UAAU,KAAK,KAAK,YAAY;AACrC,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,gBAAgB,QAAsB;AACpC,SAAK,UAAU,gBAAgB,MAAM;AAAA,EACvC;AAAA;AAAA;AAAA;AAAA,EAMA,eAAe,SAAiB,QAAsB;AACpD,SAAK,UAAU,eAAe,SAAS,MAAM;AAAA,EAC/C;AAAA,EAEA,aAAa,SAAiB,OAAsB;AAClD,SAAK,UAAU,aAAa,SAAS,KAAK;AAAA,EAC5C;AAAA,EAEA,aAAa,SAAiB,QAAuB;AACnD,SAAK,UAAU,aAAa,SAAS,MAAM;AAAA,EAC7C;AAAA,EAEA,YAAY,SAAiB,KAAmB;AAC9C,SAAK,UAAU,YAAY,SAAS,GAAG;AAAA,EACzC;AAAA;AAAA;AAAA;AAAA,EAMA,SAAe;AACb,QAAI,KAAK,aAAa,GAAG;AACvB,WAAK;AACL,WAAK,iBAAiB;AAAA,IACxB;AAAA,EACF;AAAA,EAEA,UAAgB;AACd,QAAI,KAAK,aAAa,KAAK,YAAY,SAAS,GAAG;AACjD,WAAK;AACL,WAAK,iBAAiB;AAAA,IACxB;AAAA,EACF;AAAA,EAEA,aAAa,iBAA+B;AAC1C,UAAM,WAAW,qBAAqB,iBAAiB,KAAK,WAAW;AACvE,QAAI,aAAa,KAAK,WAAY;AAClC,SAAK,aAAa;AAClB,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMA,GAAwB,OAAU,UAAiC;AACjE,QAAI,CAAC,KAAK,WAAW,IAAI,KAAK,GAAG;AAC/B,WAAK,WAAW,IAAI,OAAO,oBAAI,IAAI,CAAC;AAAA,IACtC;AACA,SAAK,WAAW,IAAI,KAAK,EAAG,IAAI,QAAQ;AAAA,EAC1C;AAAA,EAEA,IAAyB,OAAU,UAAiC;AAClE,SAAK,WAAW,IAAI,KAAK,GAAG,OAAO,QAAQ;AAAA,EAC7C;AAAA;AAAA;AAAA;AAAA,EAMA,UAAgB;AACd,QAAI,KAAK,UAAW;AACpB,SAAK,YAAY;AACjB,SAAK,oBAAoB;AACzB,SAAK,UAAU,QAAQ;AACvB,SAAK,WAAW,MAAM;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMQ,MAAM,UAAkB,MAAuB;AACrD,UAAM,YAAY,KAAK,WAAW,IAAI,KAAK;AAC3C,QAAI,WAAW;AACb,iBAAW,YAAY,WAAW;AAChC,YAAI;AACF,mBAAS,GAAG,IAAI;AAAA,QAClB,SAAS,OAAO;AACd,kBAAQ,KAAK,uDAAuD,KAAK;AAAA,QAC3E;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,mBAAyB;AAC/B,SAAK,MAAM,eAAe,KAAK,SAAS,CAAC;AAAA,EAC3C;AAAA,EAEQ,uBAA6B;AAEnC,QAAI,OAAO,0BAA0B,YAAa;AAElD,SAAK,oBAAoB;AAEzB,UAAM,OAAO,MAAM;AACjB,UAAI,KAAK,aAAa,CAAC,KAAK,WAAY;AACxC,UAAI,KAAK,UAAU;AACjB,aAAK,eAAe,KAAK,SAAS,eAAe;AACjD,aAAK,MAAM,cAAc,KAAK,YAAY;AAAA,MAC5C;AACA,WAAK,eAAe,sBAAsB,IAAI;AAAA,IAChD;AAEA,SAAK,eAAe,sBAAsB,IAAI;AAAA,EAChD;AAAA,EAEQ,sBAA4B;AAClC,QAAI,KAAK,iBAAiB,QAAQ,OAAO,yBAAyB,aAAa;AAC7E,2BAAqB,KAAK,YAAY;AACtC,WAAK,eAAe;AAAA,IACtB;AAAA,EACF;AACF;","names":["import_core"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/operations/clipOperations.ts","../src/operations/viewportOperations.ts","../src/operations/timelineOperations.ts","../src/PlaylistEngine.ts"],"sourcesContent":["// Operations (pure functions)\nexport * from './operations';\n\n// Engine class\nexport { PlaylistEngine } from './PlaylistEngine';\n\n// Engine types\nexport * from './types';\n","/**\n * Clip Operations\n *\n * Pure functions for constraining clip movement, boundary trimming,\n * and splitting clips on a timeline. All positions are in samples (integers).\n */\n\nimport type { AudioClip } from '@waveform-playlist/core';\nimport { createClip } from '@waveform-playlist/core';\n\n/**\n * Constrain clip movement delta to prevent overlaps with adjacent clips\n * and going before sample 0.\n *\n * @param clip - The clip being dragged\n * @param deltaSamples - Requested movement in samples (negative = left, positive = right)\n * @param sortedClips - All clips on the track, sorted by startSample\n * @param clipIndex - Index of the dragged clip in sortedClips\n * @returns Constrained delta that prevents overlaps\n */\nexport function constrainClipDrag(\n clip: AudioClip,\n deltaSamples: number,\n sortedClips: AudioClip[],\n clipIndex: number\n): number {\n let delta = deltaSamples;\n\n // Constraint 1: Cannot go before sample 0\n const minDelta = -clip.startSample;\n delta = Math.max(delta, minDelta);\n\n // Constraint 2: Cannot overlap previous clip\n if (clipIndex > 0) {\n const prevClip = sortedClips[clipIndex - 1];\n const prevClipEnd = prevClip.startSample + prevClip.durationSamples;\n // clip.startSample + delta >= prevClipEnd\n const minDeltaPrev = prevClipEnd - clip.startSample;\n delta = Math.max(delta, minDeltaPrev);\n }\n\n // Constraint 3: Cannot overlap next clip\n if (clipIndex < sortedClips.length - 1) {\n const nextClip = sortedClips[clipIndex + 1];\n // clip.startSample + clip.durationSamples + delta <= nextClip.startSample\n const maxDeltaNext = nextClip.startSample - (clip.startSample + clip.durationSamples);\n delta = Math.min(delta, maxDeltaNext);\n }\n\n return delta;\n}\n\n/**\n * Constrain boundary trim delta for left or right edge of a clip.\n *\n * LEFT boundary: delta moves the left edge (positive = shrink, negative = expand)\n * - startSample += delta, offsetSamples += delta, durationSamples -= delta\n *\n * RIGHT boundary: delta applied to durationSamples (positive = expand, negative = shrink)\n * - durationSamples += delta\n *\n * @param clip - The clip being trimmed\n * @param deltaSamples - Requested trim delta in samples\n * @param boundary - Which edge is being trimmed: 'left' or 'right'\n * @param sortedClips - All clips on the track, sorted by startSample\n * @param clipIndex - Index of the trimmed clip in sortedClips\n * @param minDurationSamples - Minimum allowed clip duration in samples\n * @returns Constrained delta\n */\nexport function constrainBoundaryTrim(\n clip: AudioClip,\n deltaSamples: number,\n boundary: 'left' | 'right',\n sortedClips: AudioClip[],\n clipIndex: number,\n minDurationSamples: number\n): number {\n let delta = deltaSamples;\n\n if (boundary === 'left') {\n // Constraint 1: startSample + delta >= 0\n delta = Math.max(delta, -clip.startSample);\n\n // Constraint 2: offsetSamples + delta >= 0\n delta = Math.max(delta, -clip.offsetSamples);\n\n // Constraint 3: Cannot overlap previous clip\n if (clipIndex > 0) {\n const prevClip = sortedClips[clipIndex - 1];\n const prevClipEnd = prevClip.startSample + prevClip.durationSamples;\n // startSample + delta >= prevClipEnd\n delta = Math.max(delta, prevClipEnd - clip.startSample);\n }\n\n // Constraint 4: durationSamples - delta >= minDurationSamples\n // delta <= durationSamples - minDurationSamples\n delta = Math.min(delta, clip.durationSamples - minDurationSamples);\n } else {\n // RIGHT boundary\n\n // Constraint 1: durationSamples + delta >= minDurationSamples\n // delta >= minDurationSamples - durationSamples\n delta = Math.max(delta, minDurationSamples - clip.durationSamples);\n\n // Constraint 2: offsetSamples + (durationSamples + delta) <= sourceDurationSamples\n // delta <= sourceDurationSamples - offsetSamples - durationSamples\n delta = Math.min(delta, clip.sourceDurationSamples - clip.offsetSamples - clip.durationSamples);\n\n // Constraint 3: startSample + (durationSamples + delta) <= nextClip.startSample\n if (clipIndex < sortedClips.length - 1) {\n const nextClip = sortedClips[clipIndex + 1];\n // delta <= nextClip.startSample - startSample - durationSamples\n delta = Math.min(delta, nextClip.startSample - clip.startSample - clip.durationSamples);\n }\n }\n\n return delta;\n}\n\n/**\n * Snap a split sample position to the nearest pixel boundary.\n *\n * @param splitSample - The sample position to snap\n * @param samplesPerPixel - Current zoom level (samples per pixel)\n * @returns Snapped sample position\n */\nexport function calculateSplitPoint(splitSample: number, samplesPerPixel: number): number {\n return Math.floor(splitSample / samplesPerPixel) * samplesPerPixel;\n}\n\n/**\n * Split a clip into two clips at the given sample position.\n *\n * The left clip retains the original fadeIn; the right clip retains the original fadeOut.\n * Both clips share the same waveformData reference.\n * If the clip has a name, suffixes \" (1)\" and \" (2)\" are appended.\n *\n * @param clip - The clip to split\n * @param splitSample - The timeline sample position where the split occurs\n * @returns Object with `left` and `right` AudioClip\n */\nexport function splitClip(\n clip: AudioClip,\n splitSample: number\n): { left: AudioClip; right: AudioClip } {\n const leftDuration = splitSample - clip.startSample;\n const rightDuration = clip.durationSamples - leftDuration;\n\n const leftName = clip.name ? `${clip.name} (1)` : undefined;\n const rightName = clip.name ? `${clip.name} (2)` : undefined;\n\n const left = createClip({\n startSample: clip.startSample,\n durationSamples: leftDuration,\n offsetSamples: clip.offsetSamples,\n sampleRate: clip.sampleRate,\n sourceDurationSamples: clip.sourceDurationSamples,\n gain: clip.gain,\n name: leftName,\n color: clip.color,\n fadeIn: clip.fadeIn,\n audioBuffer: clip.audioBuffer,\n waveformData: clip.waveformData,\n });\n\n const right = createClip({\n startSample: splitSample,\n durationSamples: rightDuration,\n offsetSamples: clip.offsetSamples + leftDuration,\n sampleRate: clip.sampleRate,\n sourceDurationSamples: clip.sourceDurationSamples,\n gain: clip.gain,\n name: rightName,\n color: clip.color,\n fadeOut: clip.fadeOut,\n audioBuffer: clip.audioBuffer,\n waveformData: clip.waveformData,\n });\n\n return { left, right };\n}\n\n/**\n * Check whether a clip can be split at the given sample position.\n *\n * The split point must be strictly inside the clip (not at start or end),\n * and both resulting clips must meet the minimum duration requirement.\n *\n * @param clip - The clip to check\n * @param sample - The timeline sample position to test\n * @param minDurationSamples - Minimum allowed clip duration in samples\n * @returns true if the split is valid\n */\nexport function canSplitAt(clip: AudioClip, sample: number, minDurationSamples: number): boolean {\n const clipEnd = clip.startSample + clip.durationSamples;\n\n // Must be strictly within clip bounds\n if (sample <= clip.startSample || sample >= clipEnd) {\n return false;\n }\n\n // Both resulting clips must meet minimum duration\n const leftDuration = sample - clip.startSample;\n const rightDuration = clipEnd - sample;\n\n return leftDuration >= minDurationSamples && rightDuration >= minDurationSamples;\n}\n","/**\n * Viewport operations for virtual scrolling.\n *\n * Pure math helpers that determine which portion of the timeline\n * is visible and which canvas chunks need to be mounted.\n */\n\n/**\n * Calculate the visible region with an overscan buffer for virtual scrolling.\n *\n * The buffer extends the visible range on both sides so that chunks are\n * mounted slightly before they scroll into view, preventing flicker.\n *\n * @param scrollLeft - Current horizontal scroll position in pixels\n * @param containerWidth - Width of the scroll container in pixels\n * @param bufferRatio - Multiplier for buffer size (default 1.5x container width)\n * @returns Object with visibleStart and visibleEnd in pixels\n */\nexport function calculateViewportBounds(\n scrollLeft: number,\n containerWidth: number,\n bufferRatio: number = 1.5\n): { visibleStart: number; visibleEnd: number } {\n const buffer = containerWidth * bufferRatio;\n return {\n visibleStart: Math.max(0, scrollLeft - buffer),\n visibleEnd: scrollLeft + containerWidth + buffer,\n };\n}\n\n/**\n * Get an array of chunk indices that overlap the visible viewport.\n *\n * Chunks are fixed-width segments of the total timeline width. Only chunks\n * that intersect [visibleStart, visibleEnd) are included. The last chunk\n * may be narrower than chunkWidth if totalWidth is not evenly divisible.\n *\n * @param totalWidth - Total width of the timeline in pixels\n * @param chunkWidth - Width of each chunk in pixels\n * @param visibleStart - Left edge of the visible region in pixels\n * @param visibleEnd - Right edge of the visible region in pixels\n * @returns Array of chunk indices (0-based) that are visible\n */\nexport function getVisibleChunkIndices(\n totalWidth: number,\n chunkWidth: number,\n visibleStart: number,\n visibleEnd: number\n): number[] {\n const totalChunks = Math.ceil(totalWidth / chunkWidth);\n const indices: number[] = [];\n\n for (let i = 0; i < totalChunks; i++) {\n const chunkLeft = i * chunkWidth;\n const thisChunkWidth = Math.min(totalWidth - chunkLeft, chunkWidth);\n const chunkEnd = chunkLeft + thisChunkWidth;\n\n if (chunkEnd <= visibleStart || chunkLeft >= visibleEnd) {\n continue;\n }\n\n indices.push(i);\n }\n\n return indices;\n}\n\n/**\n * Determine whether a scroll change is large enough to warrant\n * recalculating the viewport and re-rendering chunks.\n *\n * Small scroll movements are ignored to avoid excessive recomputation\n * during smooth scrolling.\n *\n * @param oldScrollLeft - Previous scroll position in pixels\n * @param newScrollLeft - Current scroll position in pixels\n * @param threshold - Minimum pixel delta to trigger an update (default 100)\n * @returns true if the scroll delta meets or exceeds the threshold\n */\nexport function shouldUpdateViewport(\n oldScrollLeft: number,\n newScrollLeft: number,\n threshold: number = 100\n): boolean {\n return Math.abs(oldScrollLeft - newScrollLeft) >= threshold;\n}\n","import type { ClipTrack } from '@waveform-playlist/core';\n\n/**\n * Calculate total timeline duration in seconds from all tracks/clips.\n * Iterates all clips, finds the furthest clip end (startSample + durationSamples),\n * converts to seconds using each clip's sampleRate.\n *\n * @param tracks - Array of clip tracks\n * @returns Duration in seconds\n */\nexport function calculateDuration(tracks: ClipTrack[]): number {\n let maxDuration = 0;\n for (const track of tracks) {\n for (const clip of track.clips) {\n const clipEndSample = clip.startSample + clip.durationSamples;\n const clipEnd = clipEndSample / clip.sampleRate;\n maxDuration = Math.max(maxDuration, clipEnd);\n }\n }\n return maxDuration;\n}\n\n/**\n * Find the zoom level index closest to a given samplesPerPixel.\n * Returns exact match if found, otherwise the index whose value is\n * nearest to the target (by absolute difference).\n *\n * @param targetSamplesPerPixel - The samplesPerPixel value to find\n * @param zoomLevels - Array of available zoom levels (samplesPerPixel values)\n * @returns Index into the zoomLevels array\n */\nexport function findClosestZoomIndex(targetSamplesPerPixel: number, zoomLevels: number[]): number {\n if (zoomLevels.length === 0) return 0;\n\n let bestIndex = 0;\n let bestDiff = Math.abs(zoomLevels[0] - targetSamplesPerPixel);\n\n for (let i = 1; i < zoomLevels.length; i++) {\n const diff = Math.abs(zoomLevels[i] - targetSamplesPerPixel);\n if (diff < bestDiff) {\n bestDiff = diff;\n bestIndex = i;\n }\n }\n\n return bestIndex;\n}\n\n/**\n * Keep viewport centered during zoom changes.\n * Calculates center time from old zoom, computes new pixel position at new zoom,\n * and returns new scrollLeft clamped to >= 0.\n *\n * @param oldSamplesPerPixel - Previous zoom level\n * @param newSamplesPerPixel - New zoom level\n * @param scrollLeft - Current horizontal scroll position\n * @param containerWidth - Viewport width in pixels\n * @param sampleRate - Audio sample rate\n * @param controlWidth - Width of track controls panel (defaults to 0)\n * @returns New scrollLeft value\n */\nexport function calculateZoomScrollPosition(\n oldSamplesPerPixel: number,\n newSamplesPerPixel: number,\n scrollLeft: number,\n containerWidth: number,\n sampleRate: number,\n controlWidth: number = 0\n): number {\n const centerPixel = scrollLeft + containerWidth / 2 - controlWidth;\n const centerTime = (centerPixel * oldSamplesPerPixel) / sampleRate;\n const newCenterPixel = (centerTime * sampleRate) / newSamplesPerPixel;\n const newScrollLeft = newCenterPixel + controlWidth - containerWidth / 2;\n return Math.max(0, newScrollLeft);\n}\n\n/**\n * Clamp a seek position to the valid range [0, duration].\n *\n * @param time - Requested seek time in seconds\n * @param duration - Maximum duration in seconds\n * @returns Clamped time value\n */\nexport function clampSeekPosition(time: number, duration: number): number {\n return Math.max(0, Math.min(time, duration));\n}\n","/**\n * PlaylistEngine — Stateful, framework-agnostic timeline engine.\n *\n * Composes pure operations from ./operations with an event emitter\n * and optional PlayoutAdapter for audio playback delegation.\n */\n\nimport type { AudioClip, ClipTrack } from '@waveform-playlist/core';\nimport { sortClipsByTime } from '@waveform-playlist/core';\nimport {\n constrainClipDrag,\n constrainBoundaryTrim,\n canSplitAt,\n splitClip as splitClipOp,\n} from './operations/clipOperations';\nimport {\n calculateDuration,\n clampSeekPosition,\n findClosestZoomIndex,\n} from './operations/timelineOperations';\nimport type { PlayoutAdapter, EngineState, EngineEvents, PlaylistEngineOptions } from './types';\n\nconst DEFAULT_SAMPLE_RATE = 44100;\nconst DEFAULT_SAMPLES_PER_PIXEL = 1000;\nconst DEFAULT_ZOOM_LEVELS = [256, 512, 1024, 2048, 4096, 8192];\nconst DEFAULT_MIN_DURATION_SECONDS = 0.1;\n\ntype EventName = keyof EngineEvents;\n\nexport class PlaylistEngine {\n private _tracks: ClipTrack[] = [];\n private _currentTime = 0;\n private _isPlaying = false;\n private _selectedTrackId: string | null = null;\n private _sampleRate: number;\n private _zoomLevels: number[];\n private _zoomIndex: number;\n private _selectionStart = 0;\n private _selectionEnd = 0;\n private _masterVolume = 1.0;\n private _loopStart = 0;\n private _loopEnd = 0;\n private _isLoopEnabled = false;\n private _tracksVersion = 0;\n private _adapter: PlayoutAdapter | null;\n private _animFrameId: number | null = null;\n private _disposed = false;\n // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type\n private _listeners: Map<string, Set<Function>> = new Map();\n\n constructor(options: PlaylistEngineOptions = {}) {\n this._sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;\n this._zoomLevels = [...(options.zoomLevels ?? DEFAULT_ZOOM_LEVELS)];\n this._adapter = options.adapter ?? null;\n\n if (this._zoomLevels.length === 0) {\n throw new Error('PlaylistEngine: zoomLevels must not be empty');\n }\n\n const initialSpp = options.samplesPerPixel ?? DEFAULT_SAMPLES_PER_PIXEL;\n this._zoomIndex = findClosestZoomIndex(initialSpp, this._zoomLevels);\n }\n\n // ---------------------------------------------------------------------------\n // State snapshot\n // ---------------------------------------------------------------------------\n\n getState(): EngineState {\n return {\n tracks: this._tracks.map((t) => ({ ...t, clips: [...t.clips] })),\n tracksVersion: this._tracksVersion,\n duration: calculateDuration(this._tracks),\n currentTime: this._currentTime,\n isPlaying: this._isPlaying,\n samplesPerPixel: this._zoomLevels[this._zoomIndex],\n sampleRate: this._sampleRate,\n selectedTrackId: this._selectedTrackId,\n zoomIndex: this._zoomIndex,\n canZoomIn: this._zoomIndex > 0,\n canZoomOut: this._zoomIndex < this._zoomLevels.length - 1,\n selectionStart: this._selectionStart,\n selectionEnd: this._selectionEnd,\n masterVolume: this._masterVolume,\n loopStart: this._loopStart,\n loopEnd: this._loopEnd,\n isLoopEnabled: this._isLoopEnabled,\n };\n }\n\n // ---------------------------------------------------------------------------\n // Track Management\n // ---------------------------------------------------------------------------\n\n setTracks(tracks: ClipTrack[]): void {\n this._tracks = [...tracks];\n this._tracksVersion++;\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n addTrack(track: ClipTrack): void {\n this._tracks = [...this._tracks, track];\n this._tracksVersion++;\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n removeTrack(trackId: string): void {\n if (!this._tracks.some((t) => t.id === trackId)) return;\n this._tracks = this._tracks.filter((t) => t.id !== trackId);\n this._tracksVersion++;\n if (this._selectedTrackId === trackId) {\n this._selectedTrackId = null;\n }\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n selectTrack(trackId: string | null): void {\n if (trackId === this._selectedTrackId) return;\n this._selectedTrackId = trackId;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Clip Editing (delegates to operations/)\n // ---------------------------------------------------------------------------\n\n moveClip(trackId: string, clipId: string, deltaSamples: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] moveClip: track \"${trackId}\" not found`);\n return;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] moveClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return;\n }\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n\n const constrainedDelta = constrainClipDrag(clip, deltaSamples, sortedClips, sortedIndex);\n\n if (constrainedDelta === 0) return;\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = t.clips.map((c: AudioClip, i: number) =>\n i === clipIndex\n ? {\n ...c,\n startSample: Math.floor(c.startSample + constrainedDelta),\n }\n : c\n );\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n splitClip(trackId: string, clipId: string, atSample: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] splitClip: track \"${trackId}\" not found`);\n return;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] splitClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return;\n }\n\n const clip = track.clips[clipIndex];\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n if (!canSplitAt(clip, atSample, minDuration)) {\n console.warn(\n `[waveform-playlist/engine] splitClip: cannot split clip \"${clipId}\" at sample ${atSample} ` +\n `(clip range: ${clip.startSample}–${clip.startSample + clip.durationSamples}, minDuration: ${minDuration})`\n );\n return;\n }\n\n const { left, right } = splitClipOp(clip, atSample);\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = [...t.clips];\n newClips.splice(clipIndex, 1, left, right);\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n trimClip(\n trackId: string,\n clipId: string,\n boundary: 'left' | 'right',\n deltaSamples: number\n ): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] trimClip: track \"${trackId}\" not found`);\n return;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] trimClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return;\n }\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n const constrained = constrainBoundaryTrim(\n clip,\n deltaSamples,\n boundary,\n sortedClips,\n sortedIndex,\n minDuration\n );\n\n if (constrained === 0) return;\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = t.clips.map((c: AudioClip, i: number) => {\n if (i !== clipIndex) return c;\n if (boundary === 'left') {\n return {\n ...c,\n startSample: c.startSample + constrained,\n offsetSamples: c.offsetSamples + constrained,\n durationSamples: c.durationSamples - constrained,\n };\n } else {\n return { ...c, durationSamples: c.durationSamples + constrained };\n }\n });\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Playback (delegates to adapter, no-ops without adapter)\n // ---------------------------------------------------------------------------\n\n async play(startTime?: number, endTime?: number): Promise<void> {\n if (startTime !== undefined) {\n const duration = calculateDuration(this._tracks);\n this._currentTime = clampSeekPosition(startTime, duration);\n }\n\n if (this._adapter) {\n await this._adapter.play(this._currentTime, endTime);\n this._startTimeUpdateLoop();\n }\n\n this._isPlaying = true;\n this._emit('play');\n this._emitStateChange();\n }\n\n pause(): void {\n this._isPlaying = false;\n this._stopTimeUpdateLoop();\n this._adapter?.pause();\n if (this._adapter) {\n this._currentTime = this._adapter.getCurrentTime();\n }\n this._emit('pause');\n this._emitStateChange();\n }\n\n stop(): void {\n this._isPlaying = false;\n this._currentTime = 0;\n this._stopTimeUpdateLoop();\n this._adapter?.stop();\n this._emit('stop');\n this._emitStateChange();\n }\n\n seek(time: number): void {\n const duration = calculateDuration(this._tracks);\n this._currentTime = clampSeekPosition(time, duration);\n this._adapter?.seek(this._currentTime);\n this._emitStateChange();\n }\n\n setMasterVolume(volume: number): void {\n if (volume === this._masterVolume) return;\n this._masterVolume = volume;\n this._adapter?.setMasterVolume(volume);\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Selection & Loop\n // ---------------------------------------------------------------------------\n\n setSelection(start: number, end: number): void {\n const s = Math.min(start, end);\n const e = Math.max(start, end);\n if (s === this._selectionStart && e === this._selectionEnd) return;\n this._selectionStart = s;\n this._selectionEnd = e;\n this._emitStateChange();\n }\n\n setLoopRegion(start: number, end: number): void {\n const s = Math.min(start, end);\n const e = Math.max(start, end);\n if (s === this._loopStart && e === this._loopEnd) return;\n this._loopStart = s;\n this._loopEnd = e;\n this._emitStateChange();\n }\n\n setLoopEnabled(enabled: boolean): void {\n if (enabled === this._isLoopEnabled) return;\n this._isLoopEnabled = enabled;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Per-Track Audio (delegates to adapter)\n // ---------------------------------------------------------------------------\n\n setTrackVolume(trackId: string, volume: number): void {\n this._adapter?.setTrackVolume(trackId, volume);\n }\n\n setTrackMute(trackId: string, muted: boolean): void {\n this._adapter?.setTrackMute(trackId, muted);\n }\n\n setTrackSolo(trackId: string, soloed: boolean): void {\n this._adapter?.setTrackSolo(trackId, soloed);\n }\n\n setTrackPan(trackId: string, pan: number): void {\n this._adapter?.setTrackPan(trackId, pan);\n }\n\n // ---------------------------------------------------------------------------\n // Zoom\n // ---------------------------------------------------------------------------\n\n zoomIn(): void {\n if (this._zoomIndex > 0) {\n this._zoomIndex--;\n this._emitStateChange();\n }\n }\n\n zoomOut(): void {\n if (this._zoomIndex < this._zoomLevels.length - 1) {\n this._zoomIndex++;\n this._emitStateChange();\n }\n }\n\n setZoomLevel(samplesPerPixel: number): void {\n const newIndex = findClosestZoomIndex(samplesPerPixel, this._zoomLevels);\n if (newIndex === this._zoomIndex) return;\n this._zoomIndex = newIndex;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Events\n // ---------------------------------------------------------------------------\n\n on<K extends EventName>(event: K, listener: EngineEvents[K]): void {\n if (!this._listeners.has(event)) {\n this._listeners.set(event, new Set());\n }\n this._listeners.get(event)!.add(listener);\n }\n\n off<K extends EventName>(event: K, listener: EngineEvents[K]): void {\n this._listeners.get(event)?.delete(listener);\n }\n\n // ---------------------------------------------------------------------------\n // Lifecycle\n // ---------------------------------------------------------------------------\n\n dispose(): void {\n if (this._disposed) return;\n this._disposed = true;\n this._stopTimeUpdateLoop();\n this._adapter?.dispose();\n this._listeners.clear();\n }\n\n // ---------------------------------------------------------------------------\n // Private helpers\n // ---------------------------------------------------------------------------\n\n private _emit(event: string, ...args: unknown[]): void {\n const listeners = this._listeners.get(event);\n if (listeners) {\n for (const listener of listeners) {\n try {\n listener(...args);\n } catch (error) {\n console.warn('[waveform-playlist/engine] Error in event listener:', error);\n }\n }\n }\n }\n\n private _emitStateChange(): void {\n this._emit('statechange', this.getState());\n }\n\n private _startTimeUpdateLoop(): void {\n // Guard for Node.js / SSR environments where RAF is unavailable\n if (typeof requestAnimationFrame === 'undefined') return;\n\n this._stopTimeUpdateLoop();\n\n const tick = () => {\n if (this._disposed || !this._isPlaying) return;\n if (this._adapter) {\n this._currentTime = this._adapter.getCurrentTime();\n this._emit('timeupdate', this._currentTime);\n }\n this._animFrameId = requestAnimationFrame(tick);\n };\n\n this._animFrameId = requestAnimationFrame(tick);\n }\n\n private _stopTimeUpdateLoop(): void {\n if (this._animFrameId !== null && typeof cancelAnimationFrame !== 'undefined') {\n cancelAnimationFrame(this._animFrameId);\n this._animFrameId = null;\n }\n }\n}\n"],"mappings":"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","names":["import_core"]}
package/dist/index.mjs CHANGED
@@ -158,6 +158,13 @@ var PlaylistEngine = class {
158
158
  this._currentTime = 0;
159
159
  this._isPlaying = false;
160
160
  this._selectedTrackId = null;
161
+ this._selectionStart = 0;
162
+ this._selectionEnd = 0;
163
+ this._masterVolume = 1;
164
+ this._loopStart = 0;
165
+ this._loopEnd = 0;
166
+ this._isLoopEnabled = false;
167
+ this._tracksVersion = 0;
161
168
  this._animFrameId = null;
162
169
  this._disposed = false;
163
170
  // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
@@ -177,6 +184,7 @@ var PlaylistEngine = class {
177
184
  getState() {
178
185
  return {
179
186
  tracks: this._tracks.map((t) => ({ ...t, clips: [...t.clips] })),
187
+ tracksVersion: this._tracksVersion,
180
188
  duration: calculateDuration(this._tracks),
181
189
  currentTime: this._currentTime,
182
190
  isPlaying: this._isPlaying,
@@ -185,7 +193,13 @@ var PlaylistEngine = class {
185
193
  selectedTrackId: this._selectedTrackId,
186
194
  zoomIndex: this._zoomIndex,
187
195
  canZoomIn: this._zoomIndex > 0,
188
- canZoomOut: this._zoomIndex < this._zoomLevels.length - 1
196
+ canZoomOut: this._zoomIndex < this._zoomLevels.length - 1,
197
+ selectionStart: this._selectionStart,
198
+ selectionEnd: this._selectionEnd,
199
+ masterVolume: this._masterVolume,
200
+ loopStart: this._loopStart,
201
+ loopEnd: this._loopEnd,
202
+ isLoopEnabled: this._isLoopEnabled
189
203
  };
190
204
  }
191
205
  // ---------------------------------------------------------------------------
@@ -193,17 +207,20 @@ var PlaylistEngine = class {
193
207
  // ---------------------------------------------------------------------------
194
208
  setTracks(tracks) {
195
209
  this._tracks = [...tracks];
210
+ this._tracksVersion++;
196
211
  this._adapter?.setTracks(this._tracks);
197
212
  this._emitStateChange();
198
213
  }
199
214
  addTrack(track) {
200
215
  this._tracks = [...this._tracks, track];
216
+ this._tracksVersion++;
201
217
  this._adapter?.setTracks(this._tracks);
202
218
  this._emitStateChange();
203
219
  }
204
220
  removeTrack(trackId) {
205
221
  if (!this._tracks.some((t) => t.id === trackId)) return;
206
222
  this._tracks = this._tracks.filter((t) => t.id !== trackId);
223
+ this._tracksVersion++;
207
224
  if (this._selectedTrackId === trackId) {
208
225
  this._selectedTrackId = null;
209
226
  }
@@ -211,6 +228,7 @@ var PlaylistEngine = class {
211
228
  this._emitStateChange();
212
229
  }
213
230
  selectTrack(trackId) {
231
+ if (trackId === this._selectedTrackId) return;
214
232
  this._selectedTrackId = trackId;
215
233
  this._emitStateChange();
216
234
  }
@@ -219,9 +237,17 @@ var PlaylistEngine = class {
219
237
  // ---------------------------------------------------------------------------
220
238
  moveClip(trackId, clipId, deltaSamples) {
221
239
  const track = this._tracks.find((t) => t.id === trackId);
222
- if (!track) return;
240
+ if (!track) {
241
+ console.warn(`[waveform-playlist/engine] moveClip: track "${trackId}" not found`);
242
+ return;
243
+ }
223
244
  const clipIndex = track.clips.findIndex((c) => c.id === clipId);
224
- if (clipIndex === -1) return;
245
+ if (clipIndex === -1) {
246
+ console.warn(
247
+ `[waveform-playlist/engine] moveClip: clip "${clipId}" not found in track "${trackId}"`
248
+ );
249
+ return;
250
+ }
225
251
  const clip = track.clips[clipIndex];
226
252
  const sortedClips = sortClipsByTime(track.clips);
227
253
  const sortedIndex = sortedClips.findIndex((c) => c.id === clipId);
@@ -237,16 +263,31 @@ var PlaylistEngine = class {
237
263
  );
238
264
  return { ...t, clips: newClips };
239
265
  });
266
+ this._tracksVersion++;
267
+ this._adapter?.setTracks(this._tracks);
240
268
  this._emitStateChange();
241
269
  }
242
270
  splitClip(trackId, clipId, atSample) {
243
271
  const track = this._tracks.find((t) => t.id === trackId);
244
- if (!track) return;
272
+ if (!track) {
273
+ console.warn(`[waveform-playlist/engine] splitClip: track "${trackId}" not found`);
274
+ return;
275
+ }
245
276
  const clipIndex = track.clips.findIndex((c) => c.id === clipId);
246
- if (clipIndex === -1) return;
277
+ if (clipIndex === -1) {
278
+ console.warn(
279
+ `[waveform-playlist/engine] splitClip: clip "${clipId}" not found in track "${trackId}"`
280
+ );
281
+ return;
282
+ }
247
283
  const clip = track.clips[clipIndex];
248
284
  const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);
249
- if (!canSplitAt(clip, atSample, minDuration)) return;
285
+ if (!canSplitAt(clip, atSample, minDuration)) {
286
+ console.warn(
287
+ `[waveform-playlist/engine] splitClip: cannot split clip "${clipId}" at sample ${atSample} (clip range: ${clip.startSample}\u2013${clip.startSample + clip.durationSamples}, minDuration: ${minDuration})`
288
+ );
289
+ return;
290
+ }
250
291
  const { left, right } = splitClip(clip, atSample);
251
292
  this._tracks = this._tracks.map((t) => {
252
293
  if (t.id !== trackId) return t;
@@ -254,13 +295,23 @@ var PlaylistEngine = class {
254
295
  newClips.splice(clipIndex, 1, left, right);
255
296
  return { ...t, clips: newClips };
256
297
  });
298
+ this._tracksVersion++;
299
+ this._adapter?.setTracks(this._tracks);
257
300
  this._emitStateChange();
258
301
  }
259
302
  trimClip(trackId, clipId, boundary, deltaSamples) {
260
303
  const track = this._tracks.find((t) => t.id === trackId);
261
- if (!track) return;
304
+ if (!track) {
305
+ console.warn(`[waveform-playlist/engine] trimClip: track "${trackId}" not found`);
306
+ return;
307
+ }
262
308
  const clipIndex = track.clips.findIndex((c) => c.id === clipId);
263
- if (clipIndex === -1) return;
309
+ if (clipIndex === -1) {
310
+ console.warn(
311
+ `[waveform-playlist/engine] trimClip: clip "${clipId}" not found in track "${trackId}"`
312
+ );
313
+ return;
314
+ }
264
315
  const clip = track.clips[clipIndex];
265
316
  const sortedClips = sortClipsByTime(track.clips);
266
317
  const sortedIndex = sortedClips.findIndex((c) => c.id === clipId);
@@ -291,6 +342,8 @@ var PlaylistEngine = class {
291
342
  });
292
343
  return { ...t, clips: newClips };
293
344
  });
345
+ this._tracksVersion++;
346
+ this._adapter?.setTracks(this._tracks);
294
347
  this._emitStateChange();
295
348
  }
296
349
  // ---------------------------------------------------------------------------
@@ -334,7 +387,34 @@ var PlaylistEngine = class {
334
387
  this._emitStateChange();
335
388
  }
336
389
  setMasterVolume(volume) {
390
+ if (volume === this._masterVolume) return;
391
+ this._masterVolume = volume;
337
392
  this._adapter?.setMasterVolume(volume);
393
+ this._emitStateChange();
394
+ }
395
+ // ---------------------------------------------------------------------------
396
+ // Selection & Loop
397
+ // ---------------------------------------------------------------------------
398
+ setSelection(start, end) {
399
+ const s = Math.min(start, end);
400
+ const e = Math.max(start, end);
401
+ if (s === this._selectionStart && e === this._selectionEnd) return;
402
+ this._selectionStart = s;
403
+ this._selectionEnd = e;
404
+ this._emitStateChange();
405
+ }
406
+ setLoopRegion(start, end) {
407
+ const s = Math.min(start, end);
408
+ const e = Math.max(start, end);
409
+ if (s === this._loopStart && e === this._loopEnd) return;
410
+ this._loopStart = s;
411
+ this._loopEnd = e;
412
+ this._emitStateChange();
413
+ }
414
+ setLoopEnabled(enabled) {
415
+ if (enabled === this._isLoopEnabled) return;
416
+ this._isLoopEnabled = enabled;
417
+ this._emitStateChange();
338
418
  }
339
419
  // ---------------------------------------------------------------------------
340
420
  // Per-Track Audio (delegates to adapter)
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/operations/clipOperations.ts","../src/operations/viewportOperations.ts","../src/operations/timelineOperations.ts","../src/PlaylistEngine.ts"],"sourcesContent":["/**\n * Clip Operations\n *\n * Pure functions for constraining clip movement, boundary trimming,\n * and splitting clips on a timeline. All positions are in samples (integers).\n */\n\nimport type { AudioClip } from '@waveform-playlist/core';\nimport { createClip } from '@waveform-playlist/core';\n\n/**\n * Constrain clip movement delta to prevent overlaps with adjacent clips\n * and going before sample 0.\n *\n * @param clip - The clip being dragged\n * @param deltaSamples - Requested movement in samples (negative = left, positive = right)\n * @param sortedClips - All clips on the track, sorted by startSample\n * @param clipIndex - Index of the dragged clip in sortedClips\n * @returns Constrained delta that prevents overlaps\n */\nexport function constrainClipDrag(\n clip: AudioClip,\n deltaSamples: number,\n sortedClips: AudioClip[],\n clipIndex: number\n): number {\n let delta = deltaSamples;\n\n // Constraint 1: Cannot go before sample 0\n const minDelta = -clip.startSample;\n delta = Math.max(delta, minDelta);\n\n // Constraint 2: Cannot overlap previous clip\n if (clipIndex > 0) {\n const prevClip = sortedClips[clipIndex - 1];\n const prevClipEnd = prevClip.startSample + prevClip.durationSamples;\n // clip.startSample + delta >= prevClipEnd\n const minDeltaPrev = prevClipEnd - clip.startSample;\n delta = Math.max(delta, minDeltaPrev);\n }\n\n // Constraint 3: Cannot overlap next clip\n if (clipIndex < sortedClips.length - 1) {\n const nextClip = sortedClips[clipIndex + 1];\n // clip.startSample + clip.durationSamples + delta <= nextClip.startSample\n const maxDeltaNext = nextClip.startSample - (clip.startSample + clip.durationSamples);\n delta = Math.min(delta, maxDeltaNext);\n }\n\n return delta;\n}\n\n/**\n * Constrain boundary trim delta for left or right edge of a clip.\n *\n * LEFT boundary: delta moves the left edge (positive = shrink, negative = expand)\n * - startSample += delta, offsetSamples += delta, durationSamples -= delta\n *\n * RIGHT boundary: delta applied to durationSamples (positive = expand, negative = shrink)\n * - durationSamples += delta\n *\n * @param clip - The clip being trimmed\n * @param deltaSamples - Requested trim delta in samples\n * @param boundary - Which edge is being trimmed: 'left' or 'right'\n * @param sortedClips - All clips on the track, sorted by startSample\n * @param clipIndex - Index of the trimmed clip in sortedClips\n * @param minDurationSamples - Minimum allowed clip duration in samples\n * @returns Constrained delta\n */\nexport function constrainBoundaryTrim(\n clip: AudioClip,\n deltaSamples: number,\n boundary: 'left' | 'right',\n sortedClips: AudioClip[],\n clipIndex: number,\n minDurationSamples: number\n): number {\n let delta = deltaSamples;\n\n if (boundary === 'left') {\n // Constraint 1: startSample + delta >= 0\n delta = Math.max(delta, -clip.startSample);\n\n // Constraint 2: offsetSamples + delta >= 0\n delta = Math.max(delta, -clip.offsetSamples);\n\n // Constraint 3: Cannot overlap previous clip\n if (clipIndex > 0) {\n const prevClip = sortedClips[clipIndex - 1];\n const prevClipEnd = prevClip.startSample + prevClip.durationSamples;\n // startSample + delta >= prevClipEnd\n delta = Math.max(delta, prevClipEnd - clip.startSample);\n }\n\n // Constraint 4: durationSamples - delta >= minDurationSamples\n // delta <= durationSamples - minDurationSamples\n delta = Math.min(delta, clip.durationSamples - minDurationSamples);\n } else {\n // RIGHT boundary\n\n // Constraint 1: durationSamples + delta >= minDurationSamples\n // delta >= minDurationSamples - durationSamples\n delta = Math.max(delta, minDurationSamples - clip.durationSamples);\n\n // Constraint 2: offsetSamples + (durationSamples + delta) <= sourceDurationSamples\n // delta <= sourceDurationSamples - offsetSamples - durationSamples\n delta = Math.min(delta, clip.sourceDurationSamples - clip.offsetSamples - clip.durationSamples);\n\n // Constraint 3: startSample + (durationSamples + delta) <= nextClip.startSample\n if (clipIndex < sortedClips.length - 1) {\n const nextClip = sortedClips[clipIndex + 1];\n // delta <= nextClip.startSample - startSample - durationSamples\n delta = Math.min(delta, nextClip.startSample - clip.startSample - clip.durationSamples);\n }\n }\n\n return delta;\n}\n\n/**\n * Snap a split sample position to the nearest pixel boundary.\n *\n * @param splitSample - The sample position to snap\n * @param samplesPerPixel - Current zoom level (samples per pixel)\n * @returns Snapped sample position\n */\nexport function calculateSplitPoint(splitSample: number, samplesPerPixel: number): number {\n return Math.floor(splitSample / samplesPerPixel) * samplesPerPixel;\n}\n\n/**\n * Split a clip into two clips at the given sample position.\n *\n * The left clip retains the original fadeIn; the right clip retains the original fadeOut.\n * Both clips share the same waveformData reference.\n * If the clip has a name, suffixes \" (1)\" and \" (2)\" are appended.\n *\n * @param clip - The clip to split\n * @param splitSample - The timeline sample position where the split occurs\n * @returns Object with `left` and `right` AudioClip\n */\nexport function splitClip(\n clip: AudioClip,\n splitSample: number\n): { left: AudioClip; right: AudioClip } {\n const leftDuration = splitSample - clip.startSample;\n const rightDuration = clip.durationSamples - leftDuration;\n\n const leftName = clip.name ? `${clip.name} (1)` : undefined;\n const rightName = clip.name ? `${clip.name} (2)` : undefined;\n\n const left = createClip({\n startSample: clip.startSample,\n durationSamples: leftDuration,\n offsetSamples: clip.offsetSamples,\n sampleRate: clip.sampleRate,\n sourceDurationSamples: clip.sourceDurationSamples,\n gain: clip.gain,\n name: leftName,\n color: clip.color,\n fadeIn: clip.fadeIn,\n audioBuffer: clip.audioBuffer,\n waveformData: clip.waveformData,\n });\n\n const right = createClip({\n startSample: splitSample,\n durationSamples: rightDuration,\n offsetSamples: clip.offsetSamples + leftDuration,\n sampleRate: clip.sampleRate,\n sourceDurationSamples: clip.sourceDurationSamples,\n gain: clip.gain,\n name: rightName,\n color: clip.color,\n fadeOut: clip.fadeOut,\n audioBuffer: clip.audioBuffer,\n waveformData: clip.waveformData,\n });\n\n return { left, right };\n}\n\n/**\n * Check whether a clip can be split at the given sample position.\n *\n * The split point must be strictly inside the clip (not at start or end),\n * and both resulting clips must meet the minimum duration requirement.\n *\n * @param clip - The clip to check\n * @param sample - The timeline sample position to test\n * @param minDurationSamples - Minimum allowed clip duration in samples\n * @returns true if the split is valid\n */\nexport function canSplitAt(clip: AudioClip, sample: number, minDurationSamples: number): boolean {\n const clipEnd = clip.startSample + clip.durationSamples;\n\n // Must be strictly within clip bounds\n if (sample <= clip.startSample || sample >= clipEnd) {\n return false;\n }\n\n // Both resulting clips must meet minimum duration\n const leftDuration = sample - clip.startSample;\n const rightDuration = clipEnd - sample;\n\n return leftDuration >= minDurationSamples && rightDuration >= minDurationSamples;\n}\n","/**\n * Viewport operations for virtual scrolling.\n *\n * Pure math helpers that determine which portion of the timeline\n * is visible and which canvas chunks need to be mounted.\n */\n\n/**\n * Calculate the visible region with an overscan buffer for virtual scrolling.\n *\n * The buffer extends the visible range on both sides so that chunks are\n * mounted slightly before they scroll into view, preventing flicker.\n *\n * @param scrollLeft - Current horizontal scroll position in pixels\n * @param containerWidth - Width of the scroll container in pixels\n * @param bufferRatio - Multiplier for buffer size (default 1.5x container width)\n * @returns Object with visibleStart and visibleEnd in pixels\n */\nexport function calculateViewportBounds(\n scrollLeft: number,\n containerWidth: number,\n bufferRatio: number = 1.5\n): { visibleStart: number; visibleEnd: number } {\n const buffer = containerWidth * bufferRatio;\n return {\n visibleStart: Math.max(0, scrollLeft - buffer),\n visibleEnd: scrollLeft + containerWidth + buffer,\n };\n}\n\n/**\n * Get an array of chunk indices that overlap the visible viewport.\n *\n * Chunks are fixed-width segments of the total timeline width. Only chunks\n * that intersect [visibleStart, visibleEnd) are included. The last chunk\n * may be narrower than chunkWidth if totalWidth is not evenly divisible.\n *\n * @param totalWidth - Total width of the timeline in pixels\n * @param chunkWidth - Width of each chunk in pixels\n * @param visibleStart - Left edge of the visible region in pixels\n * @param visibleEnd - Right edge of the visible region in pixels\n * @returns Array of chunk indices (0-based) that are visible\n */\nexport function getVisibleChunkIndices(\n totalWidth: number,\n chunkWidth: number,\n visibleStart: number,\n visibleEnd: number\n): number[] {\n const totalChunks = Math.ceil(totalWidth / chunkWidth);\n const indices: number[] = [];\n\n for (let i = 0; i < totalChunks; i++) {\n const chunkLeft = i * chunkWidth;\n const thisChunkWidth = Math.min(totalWidth - chunkLeft, chunkWidth);\n const chunkEnd = chunkLeft + thisChunkWidth;\n\n if (chunkEnd <= visibleStart || chunkLeft >= visibleEnd) {\n continue;\n }\n\n indices.push(i);\n }\n\n return indices;\n}\n\n/**\n * Determine whether a scroll change is large enough to warrant\n * recalculating the viewport and re-rendering chunks.\n *\n * Small scroll movements are ignored to avoid excessive recomputation\n * during smooth scrolling.\n *\n * @param oldScrollLeft - Previous scroll position in pixels\n * @param newScrollLeft - Current scroll position in pixels\n * @param threshold - Minimum pixel delta to trigger an update (default 100)\n * @returns true if the scroll delta meets or exceeds the threshold\n */\nexport function shouldUpdateViewport(\n oldScrollLeft: number,\n newScrollLeft: number,\n threshold: number = 100\n): boolean {\n return Math.abs(oldScrollLeft - newScrollLeft) >= threshold;\n}\n","import type { ClipTrack } from '@waveform-playlist/core';\n\n/**\n * Calculate total timeline duration in seconds from all tracks/clips.\n * Iterates all clips, finds the furthest clip end (startSample + durationSamples),\n * converts to seconds using each clip's sampleRate.\n *\n * @param tracks - Array of clip tracks\n * @returns Duration in seconds\n */\nexport function calculateDuration(tracks: ClipTrack[]): number {\n let maxDuration = 0;\n for (const track of tracks) {\n for (const clip of track.clips) {\n const clipEndSample = clip.startSample + clip.durationSamples;\n const clipEnd = clipEndSample / clip.sampleRate;\n maxDuration = Math.max(maxDuration, clipEnd);\n }\n }\n return maxDuration;\n}\n\n/**\n * Find the zoom level index closest to a given samplesPerPixel.\n * Returns exact match if found, otherwise the index whose value is\n * nearest to the target (by absolute difference).\n *\n * @param targetSamplesPerPixel - The samplesPerPixel value to find\n * @param zoomLevels - Array of available zoom levels (samplesPerPixel values)\n * @returns Index into the zoomLevels array\n */\nexport function findClosestZoomIndex(targetSamplesPerPixel: number, zoomLevels: number[]): number {\n if (zoomLevels.length === 0) return 0;\n\n let bestIndex = 0;\n let bestDiff = Math.abs(zoomLevels[0] - targetSamplesPerPixel);\n\n for (let i = 1; i < zoomLevels.length; i++) {\n const diff = Math.abs(zoomLevels[i] - targetSamplesPerPixel);\n if (diff < bestDiff) {\n bestDiff = diff;\n bestIndex = i;\n }\n }\n\n return bestIndex;\n}\n\n/**\n * Keep viewport centered during zoom changes.\n * Calculates center time from old zoom, computes new pixel position at new zoom,\n * and returns new scrollLeft clamped to >= 0.\n *\n * @param oldSamplesPerPixel - Previous zoom level\n * @param newSamplesPerPixel - New zoom level\n * @param scrollLeft - Current horizontal scroll position\n * @param containerWidth - Viewport width in pixels\n * @param sampleRate - Audio sample rate\n * @param controlWidth - Width of track controls panel (defaults to 0)\n * @returns New scrollLeft value\n */\nexport function calculateZoomScrollPosition(\n oldSamplesPerPixel: number,\n newSamplesPerPixel: number,\n scrollLeft: number,\n containerWidth: number,\n sampleRate: number,\n controlWidth: number = 0\n): number {\n const centerPixel = scrollLeft + containerWidth / 2 - controlWidth;\n const centerTime = (centerPixel * oldSamplesPerPixel) / sampleRate;\n const newCenterPixel = (centerTime * sampleRate) / newSamplesPerPixel;\n const newScrollLeft = newCenterPixel + controlWidth - containerWidth / 2;\n return Math.max(0, newScrollLeft);\n}\n\n/**\n * Clamp a seek position to the valid range [0, duration].\n *\n * @param time - Requested seek time in seconds\n * @param duration - Maximum duration in seconds\n * @returns Clamped time value\n */\nexport function clampSeekPosition(time: number, duration: number): number {\n return Math.max(0, Math.min(time, duration));\n}\n","/**\n * PlaylistEngine — Stateful, framework-agnostic timeline engine.\n *\n * Composes pure operations from ./operations with an event emitter\n * and optional PlayoutAdapter for audio playback delegation.\n */\n\nimport type { AudioClip, ClipTrack } from '@waveform-playlist/core';\nimport { sortClipsByTime } from '@waveform-playlist/core';\nimport {\n constrainClipDrag,\n constrainBoundaryTrim,\n canSplitAt,\n splitClip as splitClipOp,\n} from './operations/clipOperations';\nimport {\n calculateDuration,\n clampSeekPosition,\n findClosestZoomIndex,\n} from './operations/timelineOperations';\nimport type { PlayoutAdapter, EngineState, EngineEvents, PlaylistEngineOptions } from './types';\n\nconst DEFAULT_SAMPLE_RATE = 44100;\nconst DEFAULT_SAMPLES_PER_PIXEL = 1000;\nconst DEFAULT_ZOOM_LEVELS = [256, 512, 1024, 2048, 4096, 8192];\nconst DEFAULT_MIN_DURATION_SECONDS = 0.1;\n\ntype EventName = keyof EngineEvents;\n\nexport class PlaylistEngine {\n private _tracks: ClipTrack[] = [];\n private _currentTime = 0;\n private _isPlaying = false;\n private _selectedTrackId: string | null = null;\n private _sampleRate: number;\n private _zoomLevels: number[];\n private _zoomIndex: number;\n private _adapter: PlayoutAdapter | null;\n private _animFrameId: number | null = null;\n private _disposed = false;\n // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type\n private _listeners: Map<string, Set<Function>> = new Map();\n\n constructor(options: PlaylistEngineOptions = {}) {\n this._sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;\n this._zoomLevels = [...(options.zoomLevels ?? DEFAULT_ZOOM_LEVELS)];\n this._adapter = options.adapter ?? null;\n\n if (this._zoomLevels.length === 0) {\n throw new Error('PlaylistEngine: zoomLevels must not be empty');\n }\n\n const initialSpp = options.samplesPerPixel ?? DEFAULT_SAMPLES_PER_PIXEL;\n this._zoomIndex = findClosestZoomIndex(initialSpp, this._zoomLevels);\n }\n\n // ---------------------------------------------------------------------------\n // State snapshot\n // ---------------------------------------------------------------------------\n\n getState(): EngineState {\n return {\n tracks: this._tracks.map((t) => ({ ...t, clips: [...t.clips] })),\n duration: calculateDuration(this._tracks),\n currentTime: this._currentTime,\n isPlaying: this._isPlaying,\n samplesPerPixel: this._zoomLevels[this._zoomIndex],\n sampleRate: this._sampleRate,\n selectedTrackId: this._selectedTrackId,\n zoomIndex: this._zoomIndex,\n canZoomIn: this._zoomIndex > 0,\n canZoomOut: this._zoomIndex < this._zoomLevels.length - 1,\n };\n }\n\n // ---------------------------------------------------------------------------\n // Track Management\n // ---------------------------------------------------------------------------\n\n setTracks(tracks: ClipTrack[]): void {\n this._tracks = [...tracks];\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n addTrack(track: ClipTrack): void {\n this._tracks = [...this._tracks, track];\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n removeTrack(trackId: string): void {\n if (!this._tracks.some((t) => t.id === trackId)) return;\n this._tracks = this._tracks.filter((t) => t.id !== trackId);\n if (this._selectedTrackId === trackId) {\n this._selectedTrackId = null;\n }\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n selectTrack(trackId: string | null): void {\n this._selectedTrackId = trackId;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Clip Editing (delegates to operations/)\n // ---------------------------------------------------------------------------\n\n moveClip(trackId: string, clipId: string, deltaSamples: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) return;\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) return;\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n\n const constrainedDelta = constrainClipDrag(clip, deltaSamples, sortedClips, sortedIndex);\n\n if (constrainedDelta === 0) return;\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = t.clips.map((c: AudioClip, i: number) =>\n i === clipIndex\n ? {\n ...c,\n startSample: Math.floor(c.startSample + constrainedDelta),\n }\n : c\n );\n return { ...t, clips: newClips };\n });\n\n this._emitStateChange();\n }\n\n splitClip(trackId: string, clipId: string, atSample: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) return;\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) return;\n\n const clip = track.clips[clipIndex];\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n if (!canSplitAt(clip, atSample, minDuration)) return;\n\n const { left, right } = splitClipOp(clip, atSample);\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = [...t.clips];\n newClips.splice(clipIndex, 1, left, right);\n return { ...t, clips: newClips };\n });\n\n this._emitStateChange();\n }\n\n trimClip(\n trackId: string,\n clipId: string,\n boundary: 'left' | 'right',\n deltaSamples: number\n ): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) return;\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) return;\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n const constrained = constrainBoundaryTrim(\n clip,\n deltaSamples,\n boundary,\n sortedClips,\n sortedIndex,\n minDuration\n );\n\n if (constrained === 0) return;\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = t.clips.map((c: AudioClip, i: number) => {\n if (i !== clipIndex) return c;\n if (boundary === 'left') {\n return {\n ...c,\n startSample: c.startSample + constrained,\n offsetSamples: c.offsetSamples + constrained,\n durationSamples: c.durationSamples - constrained,\n };\n } else {\n return { ...c, durationSamples: c.durationSamples + constrained };\n }\n });\n return { ...t, clips: newClips };\n });\n\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Playback (delegates to adapter, no-ops without adapter)\n // ---------------------------------------------------------------------------\n\n async play(startTime?: number, endTime?: number): Promise<void> {\n if (startTime !== undefined) {\n const duration = calculateDuration(this._tracks);\n this._currentTime = clampSeekPosition(startTime, duration);\n }\n\n if (this._adapter) {\n await this._adapter.play(this._currentTime, endTime);\n this._startTimeUpdateLoop();\n }\n\n this._isPlaying = true;\n this._emit('play');\n this._emitStateChange();\n }\n\n pause(): void {\n this._isPlaying = false;\n this._stopTimeUpdateLoop();\n this._adapter?.pause();\n if (this._adapter) {\n this._currentTime = this._adapter.getCurrentTime();\n }\n this._emit('pause');\n this._emitStateChange();\n }\n\n stop(): void {\n this._isPlaying = false;\n this._currentTime = 0;\n this._stopTimeUpdateLoop();\n this._adapter?.stop();\n this._emit('stop');\n this._emitStateChange();\n }\n\n seek(time: number): void {\n const duration = calculateDuration(this._tracks);\n this._currentTime = clampSeekPosition(time, duration);\n this._adapter?.seek(this._currentTime);\n this._emitStateChange();\n }\n\n setMasterVolume(volume: number): void {\n this._adapter?.setMasterVolume(volume);\n }\n\n // ---------------------------------------------------------------------------\n // Per-Track Audio (delegates to adapter)\n // ---------------------------------------------------------------------------\n\n setTrackVolume(trackId: string, volume: number): void {\n this._adapter?.setTrackVolume(trackId, volume);\n }\n\n setTrackMute(trackId: string, muted: boolean): void {\n this._adapter?.setTrackMute(trackId, muted);\n }\n\n setTrackSolo(trackId: string, soloed: boolean): void {\n this._adapter?.setTrackSolo(trackId, soloed);\n }\n\n setTrackPan(trackId: string, pan: number): void {\n this._adapter?.setTrackPan(trackId, pan);\n }\n\n // ---------------------------------------------------------------------------\n // Zoom\n // ---------------------------------------------------------------------------\n\n zoomIn(): void {\n if (this._zoomIndex > 0) {\n this._zoomIndex--;\n this._emitStateChange();\n }\n }\n\n zoomOut(): void {\n if (this._zoomIndex < this._zoomLevels.length - 1) {\n this._zoomIndex++;\n this._emitStateChange();\n }\n }\n\n setZoomLevel(samplesPerPixel: number): void {\n const newIndex = findClosestZoomIndex(samplesPerPixel, this._zoomLevels);\n if (newIndex === this._zoomIndex) return;\n this._zoomIndex = newIndex;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Events\n // ---------------------------------------------------------------------------\n\n on<K extends EventName>(event: K, listener: EngineEvents[K]): void {\n if (!this._listeners.has(event)) {\n this._listeners.set(event, new Set());\n }\n this._listeners.get(event)!.add(listener);\n }\n\n off<K extends EventName>(event: K, listener: EngineEvents[K]): void {\n this._listeners.get(event)?.delete(listener);\n }\n\n // ---------------------------------------------------------------------------\n // Lifecycle\n // ---------------------------------------------------------------------------\n\n dispose(): void {\n if (this._disposed) return;\n this._disposed = true;\n this._stopTimeUpdateLoop();\n this._adapter?.dispose();\n this._listeners.clear();\n }\n\n // ---------------------------------------------------------------------------\n // Private helpers\n // ---------------------------------------------------------------------------\n\n private _emit(event: string, ...args: unknown[]): void {\n const listeners = this._listeners.get(event);\n if (listeners) {\n for (const listener of listeners) {\n try {\n listener(...args);\n } catch (error) {\n console.warn('[waveform-playlist/engine] Error in event listener:', error);\n }\n }\n }\n }\n\n private _emitStateChange(): void {\n this._emit('statechange', this.getState());\n }\n\n private _startTimeUpdateLoop(): void {\n // Guard for Node.js / SSR environments where RAF is unavailable\n if (typeof requestAnimationFrame === 'undefined') return;\n\n this._stopTimeUpdateLoop();\n\n const tick = () => {\n if (this._disposed || !this._isPlaying) return;\n if (this._adapter) {\n this._currentTime = this._adapter.getCurrentTime();\n this._emit('timeupdate', this._currentTime);\n }\n this._animFrameId = requestAnimationFrame(tick);\n };\n\n this._animFrameId = requestAnimationFrame(tick);\n }\n\n private _stopTimeUpdateLoop(): void {\n if (this._animFrameId !== null && typeof cancelAnimationFrame !== 'undefined') {\n cancelAnimationFrame(this._animFrameId);\n this._animFrameId = null;\n }\n }\n}\n"],"mappings":";AAQA,SAAS,kBAAkB;AAYpB,SAAS,kBACd,MACA,cACA,aACA,WACQ;AACR,MAAI,QAAQ;AAGZ,QAAM,WAAW,CAAC,KAAK;AACvB,UAAQ,KAAK,IAAI,OAAO,QAAQ;AAGhC,MAAI,YAAY,GAAG;AACjB,UAAM,WAAW,YAAY,YAAY,CAAC;AAC1C,UAAM,cAAc,SAAS,cAAc,SAAS;AAEpD,UAAM,eAAe,cAAc,KAAK;AACxC,YAAQ,KAAK,IAAI,OAAO,YAAY;AAAA,EACtC;AAGA,MAAI,YAAY,YAAY,SAAS,GAAG;AACtC,UAAM,WAAW,YAAY,YAAY,CAAC;AAE1C,UAAM,eAAe,SAAS,eAAe,KAAK,cAAc,KAAK;AACrE,YAAQ,KAAK,IAAI,OAAO,YAAY;AAAA,EACtC;AAEA,SAAO;AACT;AAmBO,SAAS,sBACd,MACA,cACA,UACA,aACA,WACA,oBACQ;AACR,MAAI,QAAQ;AAEZ,MAAI,aAAa,QAAQ;AAEvB,YAAQ,KAAK,IAAI,OAAO,CAAC,KAAK,WAAW;AAGzC,YAAQ,KAAK,IAAI,OAAO,CAAC,KAAK,aAAa;AAG3C,QAAI,YAAY,GAAG;AACjB,YAAM,WAAW,YAAY,YAAY,CAAC;AAC1C,YAAM,cAAc,SAAS,cAAc,SAAS;AAEpD,cAAQ,KAAK,IAAI,OAAO,cAAc,KAAK,WAAW;AAAA,IACxD;AAIA,YAAQ,KAAK,IAAI,OAAO,KAAK,kBAAkB,kBAAkB;AAAA,EACnE,OAAO;AAKL,YAAQ,KAAK,IAAI,OAAO,qBAAqB,KAAK,eAAe;AAIjE,YAAQ,KAAK,IAAI,OAAO,KAAK,wBAAwB,KAAK,gBAAgB,KAAK,eAAe;AAG9F,QAAI,YAAY,YAAY,SAAS,GAAG;AACtC,YAAM,WAAW,YAAY,YAAY,CAAC;AAE1C,cAAQ,KAAK,IAAI,OAAO,SAAS,cAAc,KAAK,cAAc,KAAK,eAAe;AAAA,IACxF;AAAA,EACF;AAEA,SAAO;AACT;AASO,SAAS,oBAAoB,aAAqB,iBAAiC;AACxF,SAAO,KAAK,MAAM,cAAc,eAAe,IAAI;AACrD;AAaO,SAAS,UACd,MACA,aACuC;AACvC,QAAM,eAAe,cAAc,KAAK;AACxC,QAAM,gBAAgB,KAAK,kBAAkB;AAE7C,QAAM,WAAW,KAAK,OAAO,GAAG,KAAK,IAAI,SAAS;AAClD,QAAM,YAAY,KAAK,OAAO,GAAG,KAAK,IAAI,SAAS;AAEnD,QAAM,OAAO,WAAW;AAAA,IACtB,aAAa,KAAK;AAAA,IAClB,iBAAiB;AAAA,IACjB,eAAe,KAAK;AAAA,IACpB,YAAY,KAAK;AAAA,IACjB,uBAAuB,KAAK;AAAA,IAC5B,MAAM,KAAK;AAAA,IACX,MAAM;AAAA,IACN,OAAO,KAAK;AAAA,IACZ,QAAQ,KAAK;AAAA,IACb,aAAa,KAAK;AAAA,IAClB,cAAc,KAAK;AAAA,EACrB,CAAC;AAED,QAAM,QAAQ,WAAW;AAAA,IACvB,aAAa;AAAA,IACb,iBAAiB;AAAA,IACjB,eAAe,KAAK,gBAAgB;AAAA,IACpC,YAAY,KAAK;AAAA,IACjB,uBAAuB,KAAK;AAAA,IAC5B,MAAM,KAAK;AAAA,IACX,MAAM;AAAA,IACN,OAAO,KAAK;AAAA,IACZ,SAAS,KAAK;AAAA,IACd,aAAa,KAAK;AAAA,IAClB,cAAc,KAAK;AAAA,EACrB,CAAC;AAED,SAAO,EAAE,MAAM,MAAM;AACvB;AAaO,SAAS,WAAW,MAAiB,QAAgB,oBAAqC;AAC/F,QAAM,UAAU,KAAK,cAAc,KAAK;AAGxC,MAAI,UAAU,KAAK,eAAe,UAAU,SAAS;AACnD,WAAO;AAAA,EACT;AAGA,QAAM,eAAe,SAAS,KAAK;AACnC,QAAM,gBAAgB,UAAU;AAEhC,SAAO,gBAAgB,sBAAsB,iBAAiB;AAChE;;;AC5LO,SAAS,wBACd,YACA,gBACA,cAAsB,KACwB;AAC9C,QAAM,SAAS,iBAAiB;AAChC,SAAO;AAAA,IACL,cAAc,KAAK,IAAI,GAAG,aAAa,MAAM;AAAA,IAC7C,YAAY,aAAa,iBAAiB;AAAA,EAC5C;AACF;AAeO,SAAS,uBACd,YACA,YACA,cACA,YACU;AACV,QAAM,cAAc,KAAK,KAAK,aAAa,UAAU;AACrD,QAAM,UAAoB,CAAC;AAE3B,WAAS,IAAI,GAAG,IAAI,aAAa,KAAK;AACpC,UAAM,YAAY,IAAI;AACtB,UAAM,iBAAiB,KAAK,IAAI,aAAa,WAAW,UAAU;AAClE,UAAM,WAAW,YAAY;AAE7B,QAAI,YAAY,gBAAgB,aAAa,YAAY;AACvD;AAAA,IACF;AAEA,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,SAAO;AACT;AAcO,SAAS,qBACd,eACA,eACA,YAAoB,KACX;AACT,SAAO,KAAK,IAAI,gBAAgB,aAAa,KAAK;AACpD;;;AC3EO,SAAS,kBAAkB,QAA6B;AAC7D,MAAI,cAAc;AAClB,aAAW,SAAS,QAAQ;AAC1B,eAAW,QAAQ,MAAM,OAAO;AAC9B,YAAM,gBAAgB,KAAK,cAAc,KAAK;AAC9C,YAAM,UAAU,gBAAgB,KAAK;AACrC,oBAAc,KAAK,IAAI,aAAa,OAAO;AAAA,IAC7C;AAAA,EACF;AACA,SAAO;AACT;AAWO,SAAS,qBAAqB,uBAA+B,YAA8B;AAChG,MAAI,WAAW,WAAW,EAAG,QAAO;AAEpC,MAAI,YAAY;AAChB,MAAI,WAAW,KAAK,IAAI,WAAW,CAAC,IAAI,qBAAqB;AAE7D,WAAS,IAAI,GAAG,IAAI,WAAW,QAAQ,KAAK;AAC1C,UAAM,OAAO,KAAK,IAAI,WAAW,CAAC,IAAI,qBAAqB;AAC3D,QAAI,OAAO,UAAU;AACnB,iBAAW;AACX,kBAAY;AAAA,IACd;AAAA,EACF;AAEA,SAAO;AACT;AAeO,SAAS,4BACd,oBACA,oBACA,YACA,gBACA,YACA,eAAuB,GACf;AACR,QAAM,cAAc,aAAa,iBAAiB,IAAI;AACtD,QAAM,aAAc,cAAc,qBAAsB;AACxD,QAAM,iBAAkB,aAAa,aAAc;AACnD,QAAM,gBAAgB,iBAAiB,eAAe,iBAAiB;AACvE,SAAO,KAAK,IAAI,GAAG,aAAa;AAClC;AASO,SAAS,kBAAkB,MAAc,UAA0B;AACxE,SAAO,KAAK,IAAI,GAAG,KAAK,IAAI,MAAM,QAAQ,CAAC;AAC7C;;;AC7EA,SAAS,uBAAuB;AAchC,IAAM,sBAAsB;AAC5B,IAAM,4BAA4B;AAClC,IAAM,sBAAsB,CAAC,KAAK,KAAK,MAAM,MAAM,MAAM,IAAI;AAC7D,IAAM,+BAA+B;AAI9B,IAAM,iBAAN,MAAqB;AAAA,EAc1B,YAAY,UAAiC,CAAC,GAAG;AAbjD,SAAQ,UAAuB,CAAC;AAChC,SAAQ,eAAe;AACvB,SAAQ,aAAa;AACrB,SAAQ,mBAAkC;AAK1C,SAAQ,eAA8B;AACtC,SAAQ,YAAY;AAEpB;AAAA,SAAQ,aAAyC,oBAAI,IAAI;AAGvD,SAAK,cAAc,QAAQ,cAAc;AACzC,SAAK,cAAc,CAAC,GAAI,QAAQ,cAAc,mBAAoB;AAClE,SAAK,WAAW,QAAQ,WAAW;AAEnC,QAAI,KAAK,YAAY,WAAW,GAAG;AACjC,YAAM,IAAI,MAAM,8CAA8C;AAAA,IAChE;AAEA,UAAM,aAAa,QAAQ,mBAAmB;AAC9C,SAAK,aAAa,qBAAqB,YAAY,KAAK,WAAW;AAAA,EACrE;AAAA;AAAA;AAAA;AAAA,EAMA,WAAwB;AACtB,WAAO;AAAA,MACL,QAAQ,KAAK,QAAQ,IAAI,CAAC,OAAO,EAAE,GAAG,GAAG,OAAO,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE;AAAA,MAC/D,UAAU,kBAAkB,KAAK,OAAO;AAAA,MACxC,aAAa,KAAK;AAAA,MAClB,WAAW,KAAK;AAAA,MAChB,iBAAiB,KAAK,YAAY,KAAK,UAAU;AAAA,MACjD,YAAY,KAAK;AAAA,MACjB,iBAAiB,KAAK;AAAA,MACtB,WAAW,KAAK;AAAA,MAChB,WAAW,KAAK,aAAa;AAAA,MAC7B,YAAY,KAAK,aAAa,KAAK,YAAY,SAAS;AAAA,IAC1D;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAMA,UAAU,QAA2B;AACnC,SAAK,UAAU,CAAC,GAAG,MAAM;AACzB,SAAK,UAAU,UAAU,KAAK,OAAO;AACrC,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,SAAS,OAAwB;AAC/B,SAAK,UAAU,CAAC,GAAG,KAAK,SAAS,KAAK;AACtC,SAAK,UAAU,UAAU,KAAK,OAAO;AACrC,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,YAAY,SAAuB;AACjC,QAAI,CAAC,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO,EAAG;AACjD,SAAK,UAAU,KAAK,QAAQ,OAAO,CAAC,MAAM,EAAE,OAAO,OAAO;AAC1D,QAAI,KAAK,qBAAqB,SAAS;AACrC,WAAK,mBAAmB;AAAA,IAC1B;AACA,SAAK,UAAU,UAAU,KAAK,OAAO;AACrC,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,YAAY,SAA8B;AACxC,SAAK,mBAAmB;AACxB,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMA,SAAS,SAAiB,QAAgB,cAA4B;AACpE,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,CAAC,MAAO;AAEZ,UAAM,YAAY,MAAM,MAAM,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AACzE,QAAI,cAAc,GAAI;AAEtB,UAAM,OAAO,MAAM,MAAM,SAAS;AAClC,UAAM,cAAc,gBAAgB,MAAM,KAAK;AAC/C,UAAM,cAAc,YAAY,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AAE3E,UAAM,mBAAmB,kBAAkB,MAAM,cAAc,aAAa,WAAW;AAEvF,QAAI,qBAAqB,EAAG;AAE5B,SAAK,UAAU,KAAK,QAAQ,IAAI,CAAC,MAAM;AACrC,UAAI,EAAE,OAAO,QAAS,QAAO;AAC7B,YAAM,WAAW,EAAE,MAAM;AAAA,QAAI,CAAC,GAAc,MAC1C,MAAM,YACF;AAAA,UACE,GAAG;AAAA,UACH,aAAa,KAAK,MAAM,EAAE,cAAc,gBAAgB;AAAA,QAC1D,IACA;AAAA,MACN;AACA,aAAO,EAAE,GAAG,GAAG,OAAO,SAAS;AAAA,IACjC,CAAC;AAED,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,UAAU,SAAiB,QAAgB,UAAwB;AACjE,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,CAAC,MAAO;AAEZ,UAAM,YAAY,MAAM,MAAM,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AACzE,QAAI,cAAc,GAAI;AAEtB,UAAM,OAAO,MAAM,MAAM,SAAS;AAClC,UAAM,cAAc,KAAK,MAAM,+BAA+B,KAAK,WAAW;AAE9E,QAAI,CAAC,WAAW,MAAM,UAAU,WAAW,EAAG;AAE9C,UAAM,EAAE,MAAM,MAAM,IAAI,UAAY,MAAM,QAAQ;AAElD,SAAK,UAAU,KAAK,QAAQ,IAAI,CAAC,MAAM;AACrC,UAAI,EAAE,OAAO,QAAS,QAAO;AAC7B,YAAM,WAAW,CAAC,GAAG,EAAE,KAAK;AAC5B,eAAS,OAAO,WAAW,GAAG,MAAM,KAAK;AACzC,aAAO,EAAE,GAAG,GAAG,OAAO,SAAS;AAAA,IACjC,CAAC;AAED,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,SACE,SACA,QACA,UACA,cACM;AACN,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,CAAC,MAAO;AAEZ,UAAM,YAAY,MAAM,MAAM,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AACzE,QAAI,cAAc,GAAI;AAEtB,UAAM,OAAO,MAAM,MAAM,SAAS;AAClC,UAAM,cAAc,gBAAgB,MAAM,KAAK;AAC/C,UAAM,cAAc,YAAY,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AAC3E,UAAM,cAAc,KAAK,MAAM,+BAA+B,KAAK,WAAW;AAE9E,UAAM,cAAc;AAAA,MAClB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAEA,QAAI,gBAAgB,EAAG;AAEvB,SAAK,UAAU,KAAK,QAAQ,IAAI,CAAC,MAAM;AACrC,UAAI,EAAE,OAAO,QAAS,QAAO;AAC7B,YAAM,WAAW,EAAE,MAAM,IAAI,CAAC,GAAc,MAAc;AACxD,YAAI,MAAM,UAAW,QAAO;AAC5B,YAAI,aAAa,QAAQ;AACvB,iBAAO;AAAA,YACL,GAAG;AAAA,YACH,aAAa,EAAE,cAAc;AAAA,YAC7B,eAAe,EAAE,gBAAgB;AAAA,YACjC,iBAAiB,EAAE,kBAAkB;AAAA,UACvC;AAAA,QACF,OAAO;AACL,iBAAO,EAAE,GAAG,GAAG,iBAAiB,EAAE,kBAAkB,YAAY;AAAA,QAClE;AAAA,MACF,CAAC;AACD,aAAO,EAAE,GAAG,GAAG,OAAO,SAAS;AAAA,IACjC,CAAC;AAED,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,KAAK,WAAoB,SAAiC;AAC9D,QAAI,cAAc,QAAW;AAC3B,YAAM,WAAW,kBAAkB,KAAK,OAAO;AAC/C,WAAK,eAAe,kBAAkB,WAAW,QAAQ;AAAA,IAC3D;AAEA,QAAI,KAAK,UAAU;AACjB,YAAM,KAAK,SAAS,KAAK,KAAK,cAAc,OAAO;AACnD,WAAK,qBAAqB;AAAA,IAC5B;AAEA,SAAK,aAAa;AAClB,SAAK,MAAM,MAAM;AACjB,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,QAAc;AACZ,SAAK,aAAa;AAClB,SAAK,oBAAoB;AACzB,SAAK,UAAU,MAAM;AACrB,QAAI,KAAK,UAAU;AACjB,WAAK,eAAe,KAAK,SAAS,eAAe;AAAA,IACnD;AACA,SAAK,MAAM,OAAO;AAClB,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,OAAa;AACX,SAAK,aAAa;AAClB,SAAK,eAAe;AACpB,SAAK,oBAAoB;AACzB,SAAK,UAAU,KAAK;AACpB,SAAK,MAAM,MAAM;AACjB,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,KAAK,MAAoB;AACvB,UAAM,WAAW,kBAAkB,KAAK,OAAO;AAC/C,SAAK,eAAe,kBAAkB,MAAM,QAAQ;AACpD,SAAK,UAAU,KAAK,KAAK,YAAY;AACrC,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,gBAAgB,QAAsB;AACpC,SAAK,UAAU,gBAAgB,MAAM;AAAA,EACvC;AAAA;AAAA;AAAA;AAAA,EAMA,eAAe,SAAiB,QAAsB;AACpD,SAAK,UAAU,eAAe,SAAS,MAAM;AAAA,EAC/C;AAAA,EAEA,aAAa,SAAiB,OAAsB;AAClD,SAAK,UAAU,aAAa,SAAS,KAAK;AAAA,EAC5C;AAAA,EAEA,aAAa,SAAiB,QAAuB;AACnD,SAAK,UAAU,aAAa,SAAS,MAAM;AAAA,EAC7C;AAAA,EAEA,YAAY,SAAiB,KAAmB;AAC9C,SAAK,UAAU,YAAY,SAAS,GAAG;AAAA,EACzC;AAAA;AAAA;AAAA;AAAA,EAMA,SAAe;AACb,QAAI,KAAK,aAAa,GAAG;AACvB,WAAK;AACL,WAAK,iBAAiB;AAAA,IACxB;AAAA,EACF;AAAA,EAEA,UAAgB;AACd,QAAI,KAAK,aAAa,KAAK,YAAY,SAAS,GAAG;AACjD,WAAK;AACL,WAAK,iBAAiB;AAAA,IACxB;AAAA,EACF;AAAA,EAEA,aAAa,iBAA+B;AAC1C,UAAM,WAAW,qBAAqB,iBAAiB,KAAK,WAAW;AACvE,QAAI,aAAa,KAAK,WAAY;AAClC,SAAK,aAAa;AAClB,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMA,GAAwB,OAAU,UAAiC;AACjE,QAAI,CAAC,KAAK,WAAW,IAAI,KAAK,GAAG;AAC/B,WAAK,WAAW,IAAI,OAAO,oBAAI,IAAI,CAAC;AAAA,IACtC;AACA,SAAK,WAAW,IAAI,KAAK,EAAG,IAAI,QAAQ;AAAA,EAC1C;AAAA,EAEA,IAAyB,OAAU,UAAiC;AAClE,SAAK,WAAW,IAAI,KAAK,GAAG,OAAO,QAAQ;AAAA,EAC7C;AAAA;AAAA;AAAA;AAAA,EAMA,UAAgB;AACd,QAAI,KAAK,UAAW;AACpB,SAAK,YAAY;AACjB,SAAK,oBAAoB;AACzB,SAAK,UAAU,QAAQ;AACvB,SAAK,WAAW,MAAM;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMQ,MAAM,UAAkB,MAAuB;AACrD,UAAM,YAAY,KAAK,WAAW,IAAI,KAAK;AAC3C,QAAI,WAAW;AACb,iBAAW,YAAY,WAAW;AAChC,YAAI;AACF,mBAAS,GAAG,IAAI;AAAA,QAClB,SAAS,OAAO;AACd,kBAAQ,KAAK,uDAAuD,KAAK;AAAA,QAC3E;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,mBAAyB;AAC/B,SAAK,MAAM,eAAe,KAAK,SAAS,CAAC;AAAA,EAC3C;AAAA,EAEQ,uBAA6B;AAEnC,QAAI,OAAO,0BAA0B,YAAa;AAElD,SAAK,oBAAoB;AAEzB,UAAM,OAAO,MAAM;AACjB,UAAI,KAAK,aAAa,CAAC,KAAK,WAAY;AACxC,UAAI,KAAK,UAAU;AACjB,aAAK,eAAe,KAAK,SAAS,eAAe;AACjD,aAAK,MAAM,cAAc,KAAK,YAAY;AAAA,MAC5C;AACA,WAAK,eAAe,sBAAsB,IAAI;AAAA,IAChD;AAEA,SAAK,eAAe,sBAAsB,IAAI;AAAA,EAChD;AAAA,EAEQ,sBAA4B;AAClC,QAAI,KAAK,iBAAiB,QAAQ,OAAO,yBAAyB,aAAa;AAC7E,2BAAqB,KAAK,YAAY;AACtC,WAAK,eAAe;AAAA,IACtB;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/operations/clipOperations.ts","../src/operations/viewportOperations.ts","../src/operations/timelineOperations.ts","../src/PlaylistEngine.ts"],"sourcesContent":["/**\n * Clip Operations\n *\n * Pure functions for constraining clip movement, boundary trimming,\n * and splitting clips on a timeline. All positions are in samples (integers).\n */\n\nimport type { AudioClip } from '@waveform-playlist/core';\nimport { createClip } from '@waveform-playlist/core';\n\n/**\n * Constrain clip movement delta to prevent overlaps with adjacent clips\n * and going before sample 0.\n *\n * @param clip - The clip being dragged\n * @param deltaSamples - Requested movement in samples (negative = left, positive = right)\n * @param sortedClips - All clips on the track, sorted by startSample\n * @param clipIndex - Index of the dragged clip in sortedClips\n * @returns Constrained delta that prevents overlaps\n */\nexport function constrainClipDrag(\n clip: AudioClip,\n deltaSamples: number,\n sortedClips: AudioClip[],\n clipIndex: number\n): number {\n let delta = deltaSamples;\n\n // Constraint 1: Cannot go before sample 0\n const minDelta = -clip.startSample;\n delta = Math.max(delta, minDelta);\n\n // Constraint 2: Cannot overlap previous clip\n if (clipIndex > 0) {\n const prevClip = sortedClips[clipIndex - 1];\n const prevClipEnd = prevClip.startSample + prevClip.durationSamples;\n // clip.startSample + delta >= prevClipEnd\n const minDeltaPrev = prevClipEnd - clip.startSample;\n delta = Math.max(delta, minDeltaPrev);\n }\n\n // Constraint 3: Cannot overlap next clip\n if (clipIndex < sortedClips.length - 1) {\n const nextClip = sortedClips[clipIndex + 1];\n // clip.startSample + clip.durationSamples + delta <= nextClip.startSample\n const maxDeltaNext = nextClip.startSample - (clip.startSample + clip.durationSamples);\n delta = Math.min(delta, maxDeltaNext);\n }\n\n return delta;\n}\n\n/**\n * Constrain boundary trim delta for left or right edge of a clip.\n *\n * LEFT boundary: delta moves the left edge (positive = shrink, negative = expand)\n * - startSample += delta, offsetSamples += delta, durationSamples -= delta\n *\n * RIGHT boundary: delta applied to durationSamples (positive = expand, negative = shrink)\n * - durationSamples += delta\n *\n * @param clip - The clip being trimmed\n * @param deltaSamples - Requested trim delta in samples\n * @param boundary - Which edge is being trimmed: 'left' or 'right'\n * @param sortedClips - All clips on the track, sorted by startSample\n * @param clipIndex - Index of the trimmed clip in sortedClips\n * @param minDurationSamples - Minimum allowed clip duration in samples\n * @returns Constrained delta\n */\nexport function constrainBoundaryTrim(\n clip: AudioClip,\n deltaSamples: number,\n boundary: 'left' | 'right',\n sortedClips: AudioClip[],\n clipIndex: number,\n minDurationSamples: number\n): number {\n let delta = deltaSamples;\n\n if (boundary === 'left') {\n // Constraint 1: startSample + delta >= 0\n delta = Math.max(delta, -clip.startSample);\n\n // Constraint 2: offsetSamples + delta >= 0\n delta = Math.max(delta, -clip.offsetSamples);\n\n // Constraint 3: Cannot overlap previous clip\n if (clipIndex > 0) {\n const prevClip = sortedClips[clipIndex - 1];\n const prevClipEnd = prevClip.startSample + prevClip.durationSamples;\n // startSample + delta >= prevClipEnd\n delta = Math.max(delta, prevClipEnd - clip.startSample);\n }\n\n // Constraint 4: durationSamples - delta >= minDurationSamples\n // delta <= durationSamples - minDurationSamples\n delta = Math.min(delta, clip.durationSamples - minDurationSamples);\n } else {\n // RIGHT boundary\n\n // Constraint 1: durationSamples + delta >= minDurationSamples\n // delta >= minDurationSamples - durationSamples\n delta = Math.max(delta, minDurationSamples - clip.durationSamples);\n\n // Constraint 2: offsetSamples + (durationSamples + delta) <= sourceDurationSamples\n // delta <= sourceDurationSamples - offsetSamples - durationSamples\n delta = Math.min(delta, clip.sourceDurationSamples - clip.offsetSamples - clip.durationSamples);\n\n // Constraint 3: startSample + (durationSamples + delta) <= nextClip.startSample\n if (clipIndex < sortedClips.length - 1) {\n const nextClip = sortedClips[clipIndex + 1];\n // delta <= nextClip.startSample - startSample - durationSamples\n delta = Math.min(delta, nextClip.startSample - clip.startSample - clip.durationSamples);\n }\n }\n\n return delta;\n}\n\n/**\n * Snap a split sample position to the nearest pixel boundary.\n *\n * @param splitSample - The sample position to snap\n * @param samplesPerPixel - Current zoom level (samples per pixel)\n * @returns Snapped sample position\n */\nexport function calculateSplitPoint(splitSample: number, samplesPerPixel: number): number {\n return Math.floor(splitSample / samplesPerPixel) * samplesPerPixel;\n}\n\n/**\n * Split a clip into two clips at the given sample position.\n *\n * The left clip retains the original fadeIn; the right clip retains the original fadeOut.\n * Both clips share the same waveformData reference.\n * If the clip has a name, suffixes \" (1)\" and \" (2)\" are appended.\n *\n * @param clip - The clip to split\n * @param splitSample - The timeline sample position where the split occurs\n * @returns Object with `left` and `right` AudioClip\n */\nexport function splitClip(\n clip: AudioClip,\n splitSample: number\n): { left: AudioClip; right: AudioClip } {\n const leftDuration = splitSample - clip.startSample;\n const rightDuration = clip.durationSamples - leftDuration;\n\n const leftName = clip.name ? `${clip.name} (1)` : undefined;\n const rightName = clip.name ? `${clip.name} (2)` : undefined;\n\n const left = createClip({\n startSample: clip.startSample,\n durationSamples: leftDuration,\n offsetSamples: clip.offsetSamples,\n sampleRate: clip.sampleRate,\n sourceDurationSamples: clip.sourceDurationSamples,\n gain: clip.gain,\n name: leftName,\n color: clip.color,\n fadeIn: clip.fadeIn,\n audioBuffer: clip.audioBuffer,\n waveformData: clip.waveformData,\n });\n\n const right = createClip({\n startSample: splitSample,\n durationSamples: rightDuration,\n offsetSamples: clip.offsetSamples + leftDuration,\n sampleRate: clip.sampleRate,\n sourceDurationSamples: clip.sourceDurationSamples,\n gain: clip.gain,\n name: rightName,\n color: clip.color,\n fadeOut: clip.fadeOut,\n audioBuffer: clip.audioBuffer,\n waveformData: clip.waveformData,\n });\n\n return { left, right };\n}\n\n/**\n * Check whether a clip can be split at the given sample position.\n *\n * The split point must be strictly inside the clip (not at start or end),\n * and both resulting clips must meet the minimum duration requirement.\n *\n * @param clip - The clip to check\n * @param sample - The timeline sample position to test\n * @param minDurationSamples - Minimum allowed clip duration in samples\n * @returns true if the split is valid\n */\nexport function canSplitAt(clip: AudioClip, sample: number, minDurationSamples: number): boolean {\n const clipEnd = clip.startSample + clip.durationSamples;\n\n // Must be strictly within clip bounds\n if (sample <= clip.startSample || sample >= clipEnd) {\n return false;\n }\n\n // Both resulting clips must meet minimum duration\n const leftDuration = sample - clip.startSample;\n const rightDuration = clipEnd - sample;\n\n return leftDuration >= minDurationSamples && rightDuration >= minDurationSamples;\n}\n","/**\n * Viewport operations for virtual scrolling.\n *\n * Pure math helpers that determine which portion of the timeline\n * is visible and which canvas chunks need to be mounted.\n */\n\n/**\n * Calculate the visible region with an overscan buffer for virtual scrolling.\n *\n * The buffer extends the visible range on both sides so that chunks are\n * mounted slightly before they scroll into view, preventing flicker.\n *\n * @param scrollLeft - Current horizontal scroll position in pixels\n * @param containerWidth - Width of the scroll container in pixels\n * @param bufferRatio - Multiplier for buffer size (default 1.5x container width)\n * @returns Object with visibleStart and visibleEnd in pixels\n */\nexport function calculateViewportBounds(\n scrollLeft: number,\n containerWidth: number,\n bufferRatio: number = 1.5\n): { visibleStart: number; visibleEnd: number } {\n const buffer = containerWidth * bufferRatio;\n return {\n visibleStart: Math.max(0, scrollLeft - buffer),\n visibleEnd: scrollLeft + containerWidth + buffer,\n };\n}\n\n/**\n * Get an array of chunk indices that overlap the visible viewport.\n *\n * Chunks are fixed-width segments of the total timeline width. Only chunks\n * that intersect [visibleStart, visibleEnd) are included. The last chunk\n * may be narrower than chunkWidth if totalWidth is not evenly divisible.\n *\n * @param totalWidth - Total width of the timeline in pixels\n * @param chunkWidth - Width of each chunk in pixels\n * @param visibleStart - Left edge of the visible region in pixels\n * @param visibleEnd - Right edge of the visible region in pixels\n * @returns Array of chunk indices (0-based) that are visible\n */\nexport function getVisibleChunkIndices(\n totalWidth: number,\n chunkWidth: number,\n visibleStart: number,\n visibleEnd: number\n): number[] {\n const totalChunks = Math.ceil(totalWidth / chunkWidth);\n const indices: number[] = [];\n\n for (let i = 0; i < totalChunks; i++) {\n const chunkLeft = i * chunkWidth;\n const thisChunkWidth = Math.min(totalWidth - chunkLeft, chunkWidth);\n const chunkEnd = chunkLeft + thisChunkWidth;\n\n if (chunkEnd <= visibleStart || chunkLeft >= visibleEnd) {\n continue;\n }\n\n indices.push(i);\n }\n\n return indices;\n}\n\n/**\n * Determine whether a scroll change is large enough to warrant\n * recalculating the viewport and re-rendering chunks.\n *\n * Small scroll movements are ignored to avoid excessive recomputation\n * during smooth scrolling.\n *\n * @param oldScrollLeft - Previous scroll position in pixels\n * @param newScrollLeft - Current scroll position in pixels\n * @param threshold - Minimum pixel delta to trigger an update (default 100)\n * @returns true if the scroll delta meets or exceeds the threshold\n */\nexport function shouldUpdateViewport(\n oldScrollLeft: number,\n newScrollLeft: number,\n threshold: number = 100\n): boolean {\n return Math.abs(oldScrollLeft - newScrollLeft) >= threshold;\n}\n","import type { ClipTrack } from '@waveform-playlist/core';\n\n/**\n * Calculate total timeline duration in seconds from all tracks/clips.\n * Iterates all clips, finds the furthest clip end (startSample + durationSamples),\n * converts to seconds using each clip's sampleRate.\n *\n * @param tracks - Array of clip tracks\n * @returns Duration in seconds\n */\nexport function calculateDuration(tracks: ClipTrack[]): number {\n let maxDuration = 0;\n for (const track of tracks) {\n for (const clip of track.clips) {\n const clipEndSample = clip.startSample + clip.durationSamples;\n const clipEnd = clipEndSample / clip.sampleRate;\n maxDuration = Math.max(maxDuration, clipEnd);\n }\n }\n return maxDuration;\n}\n\n/**\n * Find the zoom level index closest to a given samplesPerPixel.\n * Returns exact match if found, otherwise the index whose value is\n * nearest to the target (by absolute difference).\n *\n * @param targetSamplesPerPixel - The samplesPerPixel value to find\n * @param zoomLevels - Array of available zoom levels (samplesPerPixel values)\n * @returns Index into the zoomLevels array\n */\nexport function findClosestZoomIndex(targetSamplesPerPixel: number, zoomLevels: number[]): number {\n if (zoomLevels.length === 0) return 0;\n\n let bestIndex = 0;\n let bestDiff = Math.abs(zoomLevels[0] - targetSamplesPerPixel);\n\n for (let i = 1; i < zoomLevels.length; i++) {\n const diff = Math.abs(zoomLevels[i] - targetSamplesPerPixel);\n if (diff < bestDiff) {\n bestDiff = diff;\n bestIndex = i;\n }\n }\n\n return bestIndex;\n}\n\n/**\n * Keep viewport centered during zoom changes.\n * Calculates center time from old zoom, computes new pixel position at new zoom,\n * and returns new scrollLeft clamped to >= 0.\n *\n * @param oldSamplesPerPixel - Previous zoom level\n * @param newSamplesPerPixel - New zoom level\n * @param scrollLeft - Current horizontal scroll position\n * @param containerWidth - Viewport width in pixels\n * @param sampleRate - Audio sample rate\n * @param controlWidth - Width of track controls panel (defaults to 0)\n * @returns New scrollLeft value\n */\nexport function calculateZoomScrollPosition(\n oldSamplesPerPixel: number,\n newSamplesPerPixel: number,\n scrollLeft: number,\n containerWidth: number,\n sampleRate: number,\n controlWidth: number = 0\n): number {\n const centerPixel = scrollLeft + containerWidth / 2 - controlWidth;\n const centerTime = (centerPixel * oldSamplesPerPixel) / sampleRate;\n const newCenterPixel = (centerTime * sampleRate) / newSamplesPerPixel;\n const newScrollLeft = newCenterPixel + controlWidth - containerWidth / 2;\n return Math.max(0, newScrollLeft);\n}\n\n/**\n * Clamp a seek position to the valid range [0, duration].\n *\n * @param time - Requested seek time in seconds\n * @param duration - Maximum duration in seconds\n * @returns Clamped time value\n */\nexport function clampSeekPosition(time: number, duration: number): number {\n return Math.max(0, Math.min(time, duration));\n}\n","/**\n * PlaylistEngine — Stateful, framework-agnostic timeline engine.\n *\n * Composes pure operations from ./operations with an event emitter\n * and optional PlayoutAdapter for audio playback delegation.\n */\n\nimport type { AudioClip, ClipTrack } from '@waveform-playlist/core';\nimport { sortClipsByTime } from '@waveform-playlist/core';\nimport {\n constrainClipDrag,\n constrainBoundaryTrim,\n canSplitAt,\n splitClip as splitClipOp,\n} from './operations/clipOperations';\nimport {\n calculateDuration,\n clampSeekPosition,\n findClosestZoomIndex,\n} from './operations/timelineOperations';\nimport type { PlayoutAdapter, EngineState, EngineEvents, PlaylistEngineOptions } from './types';\n\nconst DEFAULT_SAMPLE_RATE = 44100;\nconst DEFAULT_SAMPLES_PER_PIXEL = 1000;\nconst DEFAULT_ZOOM_LEVELS = [256, 512, 1024, 2048, 4096, 8192];\nconst DEFAULT_MIN_DURATION_SECONDS = 0.1;\n\ntype EventName = keyof EngineEvents;\n\nexport class PlaylistEngine {\n private _tracks: ClipTrack[] = [];\n private _currentTime = 0;\n private _isPlaying = false;\n private _selectedTrackId: string | null = null;\n private _sampleRate: number;\n private _zoomLevels: number[];\n private _zoomIndex: number;\n private _selectionStart = 0;\n private _selectionEnd = 0;\n private _masterVolume = 1.0;\n private _loopStart = 0;\n private _loopEnd = 0;\n private _isLoopEnabled = false;\n private _tracksVersion = 0;\n private _adapter: PlayoutAdapter | null;\n private _animFrameId: number | null = null;\n private _disposed = false;\n // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type\n private _listeners: Map<string, Set<Function>> = new Map();\n\n constructor(options: PlaylistEngineOptions = {}) {\n this._sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;\n this._zoomLevels = [...(options.zoomLevels ?? DEFAULT_ZOOM_LEVELS)];\n this._adapter = options.adapter ?? null;\n\n if (this._zoomLevels.length === 0) {\n throw new Error('PlaylistEngine: zoomLevels must not be empty');\n }\n\n const initialSpp = options.samplesPerPixel ?? DEFAULT_SAMPLES_PER_PIXEL;\n this._zoomIndex = findClosestZoomIndex(initialSpp, this._zoomLevels);\n }\n\n // ---------------------------------------------------------------------------\n // State snapshot\n // ---------------------------------------------------------------------------\n\n getState(): EngineState {\n return {\n tracks: this._tracks.map((t) => ({ ...t, clips: [...t.clips] })),\n tracksVersion: this._tracksVersion,\n duration: calculateDuration(this._tracks),\n currentTime: this._currentTime,\n isPlaying: this._isPlaying,\n samplesPerPixel: this._zoomLevels[this._zoomIndex],\n sampleRate: this._sampleRate,\n selectedTrackId: this._selectedTrackId,\n zoomIndex: this._zoomIndex,\n canZoomIn: this._zoomIndex > 0,\n canZoomOut: this._zoomIndex < this._zoomLevels.length - 1,\n selectionStart: this._selectionStart,\n selectionEnd: this._selectionEnd,\n masterVolume: this._masterVolume,\n loopStart: this._loopStart,\n loopEnd: this._loopEnd,\n isLoopEnabled: this._isLoopEnabled,\n };\n }\n\n // ---------------------------------------------------------------------------\n // Track Management\n // ---------------------------------------------------------------------------\n\n setTracks(tracks: ClipTrack[]): void {\n this._tracks = [...tracks];\n this._tracksVersion++;\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n addTrack(track: ClipTrack): void {\n this._tracks = [...this._tracks, track];\n this._tracksVersion++;\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n removeTrack(trackId: string): void {\n if (!this._tracks.some((t) => t.id === trackId)) return;\n this._tracks = this._tracks.filter((t) => t.id !== trackId);\n this._tracksVersion++;\n if (this._selectedTrackId === trackId) {\n this._selectedTrackId = null;\n }\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n selectTrack(trackId: string | null): void {\n if (trackId === this._selectedTrackId) return;\n this._selectedTrackId = trackId;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Clip Editing (delegates to operations/)\n // ---------------------------------------------------------------------------\n\n moveClip(trackId: string, clipId: string, deltaSamples: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] moveClip: track \"${trackId}\" not found`);\n return;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] moveClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return;\n }\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n\n const constrainedDelta = constrainClipDrag(clip, deltaSamples, sortedClips, sortedIndex);\n\n if (constrainedDelta === 0) return;\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = t.clips.map((c: AudioClip, i: number) =>\n i === clipIndex\n ? {\n ...c,\n startSample: Math.floor(c.startSample + constrainedDelta),\n }\n : c\n );\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n splitClip(trackId: string, clipId: string, atSample: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] splitClip: track \"${trackId}\" not found`);\n return;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] splitClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return;\n }\n\n const clip = track.clips[clipIndex];\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n if (!canSplitAt(clip, atSample, minDuration)) {\n console.warn(\n `[waveform-playlist/engine] splitClip: cannot split clip \"${clipId}\" at sample ${atSample} ` +\n `(clip range: ${clip.startSample}–${clip.startSample + clip.durationSamples}, minDuration: ${minDuration})`\n );\n return;\n }\n\n const { left, right } = splitClipOp(clip, atSample);\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = [...t.clips];\n newClips.splice(clipIndex, 1, left, right);\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n trimClip(\n trackId: string,\n clipId: string,\n boundary: 'left' | 'right',\n deltaSamples: number\n ): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] trimClip: track \"${trackId}\" not found`);\n return;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] trimClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return;\n }\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n const constrained = constrainBoundaryTrim(\n clip,\n deltaSamples,\n boundary,\n sortedClips,\n sortedIndex,\n minDuration\n );\n\n if (constrained === 0) return;\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = t.clips.map((c: AudioClip, i: number) => {\n if (i !== clipIndex) return c;\n if (boundary === 'left') {\n return {\n ...c,\n startSample: c.startSample + constrained,\n offsetSamples: c.offsetSamples + constrained,\n durationSamples: c.durationSamples - constrained,\n };\n } else {\n return { ...c, durationSamples: c.durationSamples + constrained };\n }\n });\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Playback (delegates to adapter, no-ops without adapter)\n // ---------------------------------------------------------------------------\n\n async play(startTime?: number, endTime?: number): Promise<void> {\n if (startTime !== undefined) {\n const duration = calculateDuration(this._tracks);\n this._currentTime = clampSeekPosition(startTime, duration);\n }\n\n if (this._adapter) {\n await this._adapter.play(this._currentTime, endTime);\n this._startTimeUpdateLoop();\n }\n\n this._isPlaying = true;\n this._emit('play');\n this._emitStateChange();\n }\n\n pause(): void {\n this._isPlaying = false;\n this._stopTimeUpdateLoop();\n this._adapter?.pause();\n if (this._adapter) {\n this._currentTime = this._adapter.getCurrentTime();\n }\n this._emit('pause');\n this._emitStateChange();\n }\n\n stop(): void {\n this._isPlaying = false;\n this._currentTime = 0;\n this._stopTimeUpdateLoop();\n this._adapter?.stop();\n this._emit('stop');\n this._emitStateChange();\n }\n\n seek(time: number): void {\n const duration = calculateDuration(this._tracks);\n this._currentTime = clampSeekPosition(time, duration);\n this._adapter?.seek(this._currentTime);\n this._emitStateChange();\n }\n\n setMasterVolume(volume: number): void {\n if (volume === this._masterVolume) return;\n this._masterVolume = volume;\n this._adapter?.setMasterVolume(volume);\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Selection & Loop\n // ---------------------------------------------------------------------------\n\n setSelection(start: number, end: number): void {\n const s = Math.min(start, end);\n const e = Math.max(start, end);\n if (s === this._selectionStart && e === this._selectionEnd) return;\n this._selectionStart = s;\n this._selectionEnd = e;\n this._emitStateChange();\n }\n\n setLoopRegion(start: number, end: number): void {\n const s = Math.min(start, end);\n const e = Math.max(start, end);\n if (s === this._loopStart && e === this._loopEnd) return;\n this._loopStart = s;\n this._loopEnd = e;\n this._emitStateChange();\n }\n\n setLoopEnabled(enabled: boolean): void {\n if (enabled === this._isLoopEnabled) return;\n this._isLoopEnabled = enabled;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Per-Track Audio (delegates to adapter)\n // ---------------------------------------------------------------------------\n\n setTrackVolume(trackId: string, volume: number): void {\n this._adapter?.setTrackVolume(trackId, volume);\n }\n\n setTrackMute(trackId: string, muted: boolean): void {\n this._adapter?.setTrackMute(trackId, muted);\n }\n\n setTrackSolo(trackId: string, soloed: boolean): void {\n this._adapter?.setTrackSolo(trackId, soloed);\n }\n\n setTrackPan(trackId: string, pan: number): void {\n this._adapter?.setTrackPan(trackId, pan);\n }\n\n // ---------------------------------------------------------------------------\n // Zoom\n // ---------------------------------------------------------------------------\n\n zoomIn(): void {\n if (this._zoomIndex > 0) {\n this._zoomIndex--;\n this._emitStateChange();\n }\n }\n\n zoomOut(): void {\n if (this._zoomIndex < this._zoomLevels.length - 1) {\n this._zoomIndex++;\n this._emitStateChange();\n }\n }\n\n setZoomLevel(samplesPerPixel: number): void {\n const newIndex = findClosestZoomIndex(samplesPerPixel, this._zoomLevels);\n if (newIndex === this._zoomIndex) return;\n this._zoomIndex = newIndex;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Events\n // ---------------------------------------------------------------------------\n\n on<K extends EventName>(event: K, listener: EngineEvents[K]): void {\n if (!this._listeners.has(event)) {\n this._listeners.set(event, new Set());\n }\n this._listeners.get(event)!.add(listener);\n }\n\n off<K extends EventName>(event: K, listener: EngineEvents[K]): void {\n this._listeners.get(event)?.delete(listener);\n }\n\n // ---------------------------------------------------------------------------\n // Lifecycle\n // ---------------------------------------------------------------------------\n\n dispose(): void {\n if (this._disposed) return;\n this._disposed = true;\n this._stopTimeUpdateLoop();\n this._adapter?.dispose();\n this._listeners.clear();\n }\n\n // ---------------------------------------------------------------------------\n // Private helpers\n // ---------------------------------------------------------------------------\n\n private _emit(event: string, ...args: unknown[]): void {\n const listeners = this._listeners.get(event);\n if (listeners) {\n for (const listener of listeners) {\n try {\n listener(...args);\n } catch (error) {\n console.warn('[waveform-playlist/engine] Error in event listener:', error);\n }\n }\n }\n }\n\n private _emitStateChange(): void {\n this._emit('statechange', this.getState());\n }\n\n private _startTimeUpdateLoop(): void {\n // Guard for Node.js / SSR environments where RAF is unavailable\n if (typeof requestAnimationFrame === 'undefined') return;\n\n this._stopTimeUpdateLoop();\n\n const tick = () => {\n if (this._disposed || !this._isPlaying) return;\n if (this._adapter) {\n this._currentTime = this._adapter.getCurrentTime();\n this._emit('timeupdate', this._currentTime);\n }\n this._animFrameId = requestAnimationFrame(tick);\n };\n\n this._animFrameId = requestAnimationFrame(tick);\n }\n\n private _stopTimeUpdateLoop(): void {\n if (this._animFrameId !== null && typeof cancelAnimationFrame !== 'undefined') {\n cancelAnimationFrame(this._animFrameId);\n this._animFrameId = null;\n }\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waveform-playlist/engine",
3
- "version": "7.1.3",
3
+ "version": "8.0.0",
4
4
  "description": "Framework-agnostic engine for waveform-playlist — pure operations and stateful timeline management",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -39,7 +39,7 @@
39
39
  "tsup": "^8.0.1",
40
40
  "typescript": "^5.3.3",
41
41
  "vitest": "^3.0.0",
42
- "@waveform-playlist/core": "7.1.3"
42
+ "@waveform-playlist/core": "8.0.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@waveform-playlist/core": ">=7.0.0"