@waveform-playlist/engine 7.1.3 → 8.1.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
@@ -171,7 +171,7 @@ declare function clampSeekPosition(time: number, duration: number): number;
171
171
  interface PlayoutAdapter {
172
172
  init(): Promise<void>;
173
173
  setTracks(tracks: ClipTrack[]): void;
174
- play(startTime: number, endTime?: number): Promise<void>;
174
+ play(startTime: number, endTime?: number): void;
175
175
  pause(): void;
176
176
  stop(): void;
177
177
  seek(time: number): void;
@@ -182,6 +182,7 @@ interface PlayoutAdapter {
182
182
  setTrackMute(trackId: string, muted: boolean): void;
183
183
  setTrackSolo(trackId: string, soloed: boolean): void;
184
184
  setTrackPan(trackId: string, pan: number): void;
185
+ setLoop(enabled: boolean, start: number, end: number): void;
185
186
  dispose(): void;
186
187
  }
187
188
  /**
@@ -189,6 +190,8 @@ interface PlayoutAdapter {
189
190
  */
190
191
  interface EngineState {
191
192
  tracks: ClipTrack[];
193
+ /** Monotonic counter incremented on any tracks mutation (setTracks, addTrack, removeTrack, moveClip, trimClip, splitClip). */
194
+ tracksVersion: number;
192
195
  duration: number;
193
196
  currentTime: number;
194
197
  isPlaying: boolean;
@@ -198,6 +201,18 @@ interface EngineState {
198
201
  zoomIndex: number;
199
202
  canZoomIn: boolean;
200
203
  canZoomOut: boolean;
204
+ /** Start of the audio selection range. Guaranteed: selectionStart <= selectionEnd. */
205
+ selectionStart: number;
206
+ /** End of the audio selection range. Guaranteed: selectionStart <= selectionEnd. */
207
+ selectionEnd: number;
208
+ /** Master output volume, 0.0–1.0. */
209
+ masterVolume: number;
210
+ /** Start of the loop region. Guaranteed: loopStart <= loopEnd. */
211
+ loopStart: number;
212
+ /** End of the loop region. Guaranteed: loopStart <= loopEnd. */
213
+ loopEnd: number;
214
+ /** Whether loop playback is active. */
215
+ isLoopEnabled: boolean;
201
216
  }
202
217
  /**
203
218
  * Configuration options for PlaylistEngine constructor.
@@ -230,11 +245,19 @@ type EventName = keyof EngineEvents;
230
245
  declare class PlaylistEngine {
231
246
  private _tracks;
232
247
  private _currentTime;
248
+ private _playStartPosition;
233
249
  private _isPlaying;
234
250
  private _selectedTrackId;
235
251
  private _sampleRate;
236
252
  private _zoomLevels;
237
253
  private _zoomIndex;
254
+ private _selectionStart;
255
+ private _selectionEnd;
256
+ private _masterVolume;
257
+ private _loopStart;
258
+ private _loopEnd;
259
+ private _isLoopEnabled;
260
+ private _tracksVersion;
238
261
  private _adapter;
239
262
  private _animFrameId;
240
263
  private _disposed;
@@ -248,11 +271,16 @@ declare class PlaylistEngine {
248
271
  moveClip(trackId: string, clipId: string, deltaSamples: number): void;
249
272
  splitClip(trackId: string, clipId: string, atSample: number): void;
250
273
  trimClip(trackId: string, clipId: string, boundary: 'left' | 'right', deltaSamples: number): void;
251
- play(startTime?: number, endTime?: number): Promise<void>;
274
+ init(): Promise<void>;
275
+ play(startTime?: number, endTime?: number): void;
252
276
  pause(): void;
253
277
  stop(): void;
254
278
  seek(time: number): void;
255
279
  setMasterVolume(volume: number): void;
280
+ getCurrentTime(): number;
281
+ setSelection(start: number, end: number): void;
282
+ setLoopRegion(start: number, end: number): void;
283
+ setLoopEnabled(enabled: boolean): void;
256
284
  setTrackVolume(trackId: string, volume: number): void;
257
285
  setTrackMute(trackId: string, muted: boolean): void;
258
286
  setTrackSolo(trackId: string, soloed: boolean): void;
@@ -265,6 +293,14 @@ declare class PlaylistEngine {
265
293
  dispose(): void;
266
294
  private _emit;
267
295
  private _emitStateChange;
296
+ /**
297
+ * Returns whether Transport loop should be active right now.
298
+ * When not playing, returns true unconditionally — the intent to loop
299
+ * should be honored when playback starts (play() has its own position
300
+ * check). During playback, checks if the live position is within the
301
+ * loop region [loopStart, loopEnd).
302
+ */
303
+ private _shouldActivateTransportLoop;
268
304
  private _startTimeUpdateLoop;
269
305
  private _stopTimeUpdateLoop;
270
306
  }
package/dist/index.d.ts CHANGED
@@ -171,7 +171,7 @@ declare function clampSeekPosition(time: number, duration: number): number;
171
171
  interface PlayoutAdapter {
172
172
  init(): Promise<void>;
173
173
  setTracks(tracks: ClipTrack[]): void;
174
- play(startTime: number, endTime?: number): Promise<void>;
174
+ play(startTime: number, endTime?: number): void;
175
175
  pause(): void;
176
176
  stop(): void;
177
177
  seek(time: number): void;
@@ -182,6 +182,7 @@ interface PlayoutAdapter {
182
182
  setTrackMute(trackId: string, muted: boolean): void;
183
183
  setTrackSolo(trackId: string, soloed: boolean): void;
184
184
  setTrackPan(trackId: string, pan: number): void;
185
+ setLoop(enabled: boolean, start: number, end: number): void;
185
186
  dispose(): void;
186
187
  }
187
188
  /**
@@ -189,6 +190,8 @@ interface PlayoutAdapter {
189
190
  */
190
191
  interface EngineState {
191
192
  tracks: ClipTrack[];
193
+ /** Monotonic counter incremented on any tracks mutation (setTracks, addTrack, removeTrack, moveClip, trimClip, splitClip). */
194
+ tracksVersion: number;
192
195
  duration: number;
193
196
  currentTime: number;
194
197
  isPlaying: boolean;
@@ -198,6 +201,18 @@ interface EngineState {
198
201
  zoomIndex: number;
199
202
  canZoomIn: boolean;
200
203
  canZoomOut: boolean;
204
+ /** Start of the audio selection range. Guaranteed: selectionStart <= selectionEnd. */
205
+ selectionStart: number;
206
+ /** End of the audio selection range. Guaranteed: selectionStart <= selectionEnd. */
207
+ selectionEnd: number;
208
+ /** Master output volume, 0.0–1.0. */
209
+ masterVolume: number;
210
+ /** Start of the loop region. Guaranteed: loopStart <= loopEnd. */
211
+ loopStart: number;
212
+ /** End of the loop region. Guaranteed: loopStart <= loopEnd. */
213
+ loopEnd: number;
214
+ /** Whether loop playback is active. */
215
+ isLoopEnabled: boolean;
201
216
  }
202
217
  /**
203
218
  * Configuration options for PlaylistEngine constructor.
@@ -230,11 +245,19 @@ type EventName = keyof EngineEvents;
230
245
  declare class PlaylistEngine {
231
246
  private _tracks;
232
247
  private _currentTime;
248
+ private _playStartPosition;
233
249
  private _isPlaying;
234
250
  private _selectedTrackId;
235
251
  private _sampleRate;
236
252
  private _zoomLevels;
237
253
  private _zoomIndex;
254
+ private _selectionStart;
255
+ private _selectionEnd;
256
+ private _masterVolume;
257
+ private _loopStart;
258
+ private _loopEnd;
259
+ private _isLoopEnabled;
260
+ private _tracksVersion;
238
261
  private _adapter;
239
262
  private _animFrameId;
240
263
  private _disposed;
@@ -248,11 +271,16 @@ declare class PlaylistEngine {
248
271
  moveClip(trackId: string, clipId: string, deltaSamples: number): void;
249
272
  splitClip(trackId: string, clipId: string, atSample: number): void;
250
273
  trimClip(trackId: string, clipId: string, boundary: 'left' | 'right', deltaSamples: number): void;
251
- play(startTime?: number, endTime?: number): Promise<void>;
274
+ init(): Promise<void>;
275
+ play(startTime?: number, endTime?: number): void;
252
276
  pause(): void;
253
277
  stop(): void;
254
278
  seek(time: number): void;
255
279
  setMasterVolume(volume: number): void;
280
+ getCurrentTime(): number;
281
+ setSelection(start: number, end: number): void;
282
+ setLoopRegion(start: number, end: number): void;
283
+ setLoopEnabled(enabled: boolean): void;
256
284
  setTrackVolume(trackId: string, volume: number): void;
257
285
  setTrackMute(trackId: string, muted: boolean): void;
258
286
  setTrackSolo(trackId: string, soloed: boolean): void;
@@ -265,6 +293,14 @@ declare class PlaylistEngine {
265
293
  dispose(): void;
266
294
  private _emit;
267
295
  private _emitStateChange;
296
+ /**
297
+ * Returns whether Transport loop should be active right now.
298
+ * When not playing, returns true unconditionally — the intent to loop
299
+ * should be honored when playback starts (play() has its own position
300
+ * check). During playback, checks if the live position is within the
301
+ * loop region [loopStart, loopEnd).
302
+ */
303
+ private _shouldActivateTransportLoop;
268
304
  private _startTimeUpdateLoop;
269
305
  private _stopTimeUpdateLoop;
270
306
  }
package/dist/index.js CHANGED
@@ -194,8 +194,16 @@ var PlaylistEngine = class {
194
194
  constructor(options = {}) {
195
195
  this._tracks = [];
196
196
  this._currentTime = 0;
197
+ this._playStartPosition = 0;
197
198
  this._isPlaying = false;
198
199
  this._selectedTrackId = null;
200
+ this._selectionStart = 0;
201
+ this._selectionEnd = 0;
202
+ this._masterVolume = 1;
203
+ this._loopStart = 0;
204
+ this._loopEnd = 0;
205
+ this._isLoopEnabled = false;
206
+ this._tracksVersion = 0;
199
207
  this._animFrameId = null;
200
208
  this._disposed = false;
201
209
  // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
@@ -215,6 +223,7 @@ var PlaylistEngine = class {
215
223
  getState() {
216
224
  return {
217
225
  tracks: this._tracks.map((t) => ({ ...t, clips: [...t.clips] })),
226
+ tracksVersion: this._tracksVersion,
218
227
  duration: calculateDuration(this._tracks),
219
228
  currentTime: this._currentTime,
220
229
  isPlaying: this._isPlaying,
@@ -223,7 +232,13 @@ var PlaylistEngine = class {
223
232
  selectedTrackId: this._selectedTrackId,
224
233
  zoomIndex: this._zoomIndex,
225
234
  canZoomIn: this._zoomIndex > 0,
226
- canZoomOut: this._zoomIndex < this._zoomLevels.length - 1
235
+ canZoomOut: this._zoomIndex < this._zoomLevels.length - 1,
236
+ selectionStart: this._selectionStart,
237
+ selectionEnd: this._selectionEnd,
238
+ masterVolume: this._masterVolume,
239
+ loopStart: this._loopStart,
240
+ loopEnd: this._loopEnd,
241
+ isLoopEnabled: this._isLoopEnabled
227
242
  };
228
243
  }
229
244
  // ---------------------------------------------------------------------------
@@ -231,17 +246,20 @@ var PlaylistEngine = class {
231
246
  // ---------------------------------------------------------------------------
232
247
  setTracks(tracks) {
233
248
  this._tracks = [...tracks];
249
+ this._tracksVersion++;
234
250
  this._adapter?.setTracks(this._tracks);
235
251
  this._emitStateChange();
236
252
  }
237
253
  addTrack(track) {
238
254
  this._tracks = [...this._tracks, track];
255
+ this._tracksVersion++;
239
256
  this._adapter?.setTracks(this._tracks);
240
257
  this._emitStateChange();
241
258
  }
242
259
  removeTrack(trackId) {
243
260
  if (!this._tracks.some((t) => t.id === trackId)) return;
244
261
  this._tracks = this._tracks.filter((t) => t.id !== trackId);
262
+ this._tracksVersion++;
245
263
  if (this._selectedTrackId === trackId) {
246
264
  this._selectedTrackId = null;
247
265
  }
@@ -249,6 +267,7 @@ var PlaylistEngine = class {
249
267
  this._emitStateChange();
250
268
  }
251
269
  selectTrack(trackId) {
270
+ if (trackId === this._selectedTrackId) return;
252
271
  this._selectedTrackId = trackId;
253
272
  this._emitStateChange();
254
273
  }
@@ -257,9 +276,17 @@ var PlaylistEngine = class {
257
276
  // ---------------------------------------------------------------------------
258
277
  moveClip(trackId, clipId, deltaSamples) {
259
278
  const track = this._tracks.find((t) => t.id === trackId);
260
- if (!track) return;
279
+ if (!track) {
280
+ console.warn(`[waveform-playlist/engine] moveClip: track "${trackId}" not found`);
281
+ return;
282
+ }
261
283
  const clipIndex = track.clips.findIndex((c) => c.id === clipId);
262
- if (clipIndex === -1) return;
284
+ if (clipIndex === -1) {
285
+ console.warn(
286
+ `[waveform-playlist/engine] moveClip: clip "${clipId}" not found in track "${trackId}"`
287
+ );
288
+ return;
289
+ }
263
290
  const clip = track.clips[clipIndex];
264
291
  const sortedClips = (0, import_core2.sortClipsByTime)(track.clips);
265
292
  const sortedIndex = sortedClips.findIndex((c) => c.id === clipId);
@@ -275,16 +302,31 @@ var PlaylistEngine = class {
275
302
  );
276
303
  return { ...t, clips: newClips };
277
304
  });
305
+ this._tracksVersion++;
306
+ this._adapter?.setTracks(this._tracks);
278
307
  this._emitStateChange();
279
308
  }
280
309
  splitClip(trackId, clipId, atSample) {
281
310
  const track = this._tracks.find((t) => t.id === trackId);
282
- if (!track) return;
311
+ if (!track) {
312
+ console.warn(`[waveform-playlist/engine] splitClip: track "${trackId}" not found`);
313
+ return;
314
+ }
283
315
  const clipIndex = track.clips.findIndex((c) => c.id === clipId);
284
- if (clipIndex === -1) return;
316
+ if (clipIndex === -1) {
317
+ console.warn(
318
+ `[waveform-playlist/engine] splitClip: clip "${clipId}" not found in track "${trackId}"`
319
+ );
320
+ return;
321
+ }
285
322
  const clip = track.clips[clipIndex];
286
323
  const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);
287
- if (!canSplitAt(clip, atSample, minDuration)) return;
324
+ if (!canSplitAt(clip, atSample, minDuration)) {
325
+ console.warn(
326
+ `[waveform-playlist/engine] splitClip: cannot split clip "${clipId}" at sample ${atSample} (clip range: ${clip.startSample}\u2013${clip.startSample + clip.durationSamples}, minDuration: ${minDuration})`
327
+ );
328
+ return;
329
+ }
288
330
  const { left, right } = splitClip(clip, atSample);
289
331
  this._tracks = this._tracks.map((t) => {
290
332
  if (t.id !== trackId) return t;
@@ -292,13 +334,23 @@ var PlaylistEngine = class {
292
334
  newClips.splice(clipIndex, 1, left, right);
293
335
  return { ...t, clips: newClips };
294
336
  });
337
+ this._tracksVersion++;
338
+ this._adapter?.setTracks(this._tracks);
295
339
  this._emitStateChange();
296
340
  }
297
341
  trimClip(trackId, clipId, boundary, deltaSamples) {
298
342
  const track = this._tracks.find((t) => t.id === trackId);
299
- if (!track) return;
343
+ if (!track) {
344
+ console.warn(`[waveform-playlist/engine] trimClip: track "${trackId}" not found`);
345
+ return;
346
+ }
300
347
  const clipIndex = track.clips.findIndex((c) => c.id === clipId);
301
- if (clipIndex === -1) return;
348
+ if (clipIndex === -1) {
349
+ console.warn(
350
+ `[waveform-playlist/engine] trimClip: clip "${clipId}" not found in track "${trackId}"`
351
+ );
352
+ return;
353
+ }
302
354
  const clip = track.clips[clipIndex];
303
355
  const sortedClips = (0, import_core2.sortClipsByTime)(track.clips);
304
356
  const sortedIndex = sortedClips.findIndex((c) => c.id === clipId);
@@ -329,21 +381,43 @@ var PlaylistEngine = class {
329
381
  });
330
382
  return { ...t, clips: newClips };
331
383
  });
384
+ this._tracksVersion++;
385
+ this._adapter?.setTracks(this._tracks);
332
386
  this._emitStateChange();
333
387
  }
334
388
  // ---------------------------------------------------------------------------
335
389
  // Playback (delegates to adapter, no-ops without adapter)
336
390
  // ---------------------------------------------------------------------------
337
- async play(startTime, endTime) {
391
+ async init() {
392
+ if (this._adapter) {
393
+ await this._adapter.init();
394
+ }
395
+ }
396
+ play(startTime, endTime) {
397
+ const prevCurrentTime = this._currentTime;
398
+ const prevPlayStartPosition = this._playStartPosition;
338
399
  if (startTime !== void 0) {
339
400
  const duration = calculateDuration(this._tracks);
340
401
  this._currentTime = clampSeekPosition(startTime, duration);
341
402
  }
403
+ this._playStartPosition = this._currentTime;
342
404
  if (this._adapter) {
343
- await this._adapter.play(this._currentTime, endTime);
344
- this._startTimeUpdateLoop();
405
+ if (endTime !== void 0) {
406
+ this._adapter.setLoop(false, this._loopStart, this._loopEnd);
407
+ } else if (this._isLoopEnabled) {
408
+ const inLoopRegion = this._currentTime >= this._loopStart && this._currentTime < this._loopEnd;
409
+ this._adapter.setLoop(inLoopRegion, this._loopStart, this._loopEnd);
410
+ }
411
+ try {
412
+ this._adapter.play(this._currentTime, endTime);
413
+ } catch (err) {
414
+ this._currentTime = prevCurrentTime;
415
+ this._playStartPosition = prevPlayStartPosition;
416
+ throw err;
417
+ }
345
418
  }
346
419
  this._isPlaying = true;
420
+ this._startTimeUpdateLoop();
347
421
  this._emit("play");
348
422
  this._emitStateChange();
349
423
  }
@@ -359,8 +433,9 @@ var PlaylistEngine = class {
359
433
  }
360
434
  stop() {
361
435
  this._isPlaying = false;
362
- this._currentTime = 0;
436
+ this._currentTime = this._playStartPosition;
363
437
  this._stopTimeUpdateLoop();
438
+ this._adapter?.setLoop(false, this._loopStart, this._loopEnd);
364
439
  this._adapter?.stop();
365
440
  this._emit("stop");
366
441
  this._emitStateChange();
@@ -372,21 +447,72 @@ var PlaylistEngine = class {
372
447
  this._emitStateChange();
373
448
  }
374
449
  setMasterVolume(volume) {
450
+ if (volume === this._masterVolume) return;
451
+ this._masterVolume = volume;
375
452
  this._adapter?.setMasterVolume(volume);
453
+ this._emitStateChange();
454
+ }
455
+ getCurrentTime() {
456
+ if (this._isPlaying && this._adapter) {
457
+ return this._adapter.getCurrentTime();
458
+ }
459
+ return this._currentTime;
460
+ }
461
+ // ---------------------------------------------------------------------------
462
+ // Selection & Loop
463
+ // ---------------------------------------------------------------------------
464
+ setSelection(start, end) {
465
+ const s = Math.min(start, end);
466
+ const e = Math.max(start, end);
467
+ if (s === this._selectionStart && e === this._selectionEnd) return;
468
+ this._selectionStart = s;
469
+ this._selectionEnd = e;
470
+ this._emitStateChange();
471
+ }
472
+ setLoopRegion(start, end) {
473
+ const s = Math.min(start, end);
474
+ const e = Math.max(start, end);
475
+ if (s === this._loopStart && e === this._loopEnd) return;
476
+ this._loopStart = s;
477
+ this._loopEnd = e;
478
+ this._adapter?.setLoop(
479
+ this._isLoopEnabled && this._shouldActivateTransportLoop(),
480
+ this._loopStart,
481
+ this._loopEnd
482
+ );
483
+ this._emitStateChange();
484
+ }
485
+ setLoopEnabled(enabled) {
486
+ if (enabled === this._isLoopEnabled) return;
487
+ this._isLoopEnabled = enabled;
488
+ this._adapter?.setLoop(
489
+ enabled && this._shouldActivateTransportLoop(),
490
+ this._loopStart,
491
+ this._loopEnd
492
+ );
493
+ this._emitStateChange();
376
494
  }
377
495
  // ---------------------------------------------------------------------------
378
496
  // Per-Track Audio (delegates to adapter)
379
497
  // ---------------------------------------------------------------------------
380
498
  setTrackVolume(trackId, volume) {
499
+ const track = this._tracks.find((t) => t.id === trackId);
500
+ if (track) track.volume = volume;
381
501
  this._adapter?.setTrackVolume(trackId, volume);
382
502
  }
383
503
  setTrackMute(trackId, muted) {
504
+ const track = this._tracks.find((t) => t.id === trackId);
505
+ if (track) track.muted = muted;
384
506
  this._adapter?.setTrackMute(trackId, muted);
385
507
  }
386
508
  setTrackSolo(trackId, soloed) {
509
+ const track = this._tracks.find((t) => t.id === trackId);
510
+ if (track) track.soloed = soloed;
387
511
  this._adapter?.setTrackSolo(trackId, soloed);
388
512
  }
389
513
  setTrackPan(trackId, pan) {
514
+ const track = this._tracks.find((t) => t.id === trackId);
515
+ if (track) track.pan = pan;
390
516
  this._adapter?.setTrackPan(trackId, pan);
391
517
  }
392
518
  // ---------------------------------------------------------------------------
@@ -429,7 +555,11 @@ var PlaylistEngine = class {
429
555
  if (this._disposed) return;
430
556
  this._disposed = true;
431
557
  this._stopTimeUpdateLoop();
432
- this._adapter?.dispose();
558
+ try {
559
+ this._adapter?.dispose();
560
+ } catch (err) {
561
+ console.warn("[waveform-playlist/engine] Error disposing adapter:", err);
562
+ }
433
563
  this._listeners.clear();
434
564
  }
435
565
  // ---------------------------------------------------------------------------
@@ -450,6 +580,18 @@ var PlaylistEngine = class {
450
580
  _emitStateChange() {
451
581
  this._emit("statechange", this.getState());
452
582
  }
583
+ /**
584
+ * Returns whether Transport loop should be active right now.
585
+ * When not playing, returns true unconditionally — the intent to loop
586
+ * should be honored when playback starts (play() has its own position
587
+ * check). During playback, checks if the live position is within the
588
+ * loop region [loopStart, loopEnd).
589
+ */
590
+ _shouldActivateTransportLoop() {
591
+ if (!this._isPlaying) return true;
592
+ const t = this._adapter?.getCurrentTime() ?? this._currentTime;
593
+ return t >= this._loopStart && t < this._loopEnd;
594
+ }
453
595
  _startTimeUpdateLoop() {
454
596
  if (typeof requestAnimationFrame === "undefined") return;
455
597
  this._stopTimeUpdateLoop();
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":"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","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 _playStartPosition = 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 init(): Promise<void> {\n if (this._adapter) {\n await this._adapter.init();\n }\n }\n\n play(startTime?: number, endTime?: number): void {\n const prevCurrentTime = this._currentTime;\n const prevPlayStartPosition = this._playStartPosition;\n\n if (startTime !== undefined) {\n const duration = calculateDuration(this._tracks);\n this._currentTime = clampSeekPosition(startTime, duration);\n }\n\n // Remember where playback started (Audacity-style: stop returns here)\n this._playStartPosition = this._currentTime;\n\n if (this._adapter) {\n // Configure loop state BEFORE play(). The adapter caches loopStart/\n // loopEnd/enabled from setLoop(), then TonePlayout.play() applies\n // them to the Transport before transport.start() and advances\n // Clock._lastUpdate to skip stale ghost ticks.\n if (endTime !== undefined) {\n // Disable Transport loop for duration-limited playback (selection/annotation)\n this._adapter.setLoop(false, this._loopStart, this._loopEnd);\n } else if (this._isLoopEnabled) {\n // Only enable Transport loop if starting within the loop region.\n // Starting outside plays normally to the end (DAW behavior).\n const inLoopRegion =\n this._currentTime >= this._loopStart && this._currentTime < this._loopEnd;\n this._adapter.setLoop(inLoopRegion, this._loopStart, this._loopEnd);\n }\n try {\n this._adapter.play(this._currentTime, endTime);\n } catch (err) {\n // Restore state so the engine isn't left with a moved playhead\n // but no audio. The throw propagates to the caller.\n this._currentTime = prevCurrentTime;\n this._playStartPosition = prevPlayStartPosition;\n throw err;\n }\n }\n\n this._isPlaying = true;\n this._startTimeUpdateLoop();\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 = this._playStartPosition;\n this._stopTimeUpdateLoop();\n this._adapter?.setLoop(false, this._loopStart, this._loopEnd);\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 getCurrentTime(): number {\n if (this._isPlaying && this._adapter) {\n return this._adapter.getCurrentTime();\n }\n return this._currentTime;\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._adapter?.setLoop(\n this._isLoopEnabled && this._shouldActivateTransportLoop(),\n this._loopStart,\n this._loopEnd\n );\n this._emitStateChange();\n }\n\n setLoopEnabled(enabled: boolean): void {\n if (enabled === this._isLoopEnabled) return;\n this._isLoopEnabled = enabled;\n this._adapter?.setLoop(\n enabled && this._shouldActivateTransportLoop(),\n this._loopStart,\n this._loopEnd\n );\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Per-Track Audio (delegates to adapter)\n // ---------------------------------------------------------------------------\n\n setTrackVolume(trackId: string, volume: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.volume = volume;\n this._adapter?.setTrackVolume(trackId, volume);\n }\n\n setTrackMute(trackId: string, muted: boolean): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.muted = muted;\n this._adapter?.setTrackMute(trackId, muted);\n }\n\n setTrackSolo(trackId: string, soloed: boolean): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.soloed = soloed;\n this._adapter?.setTrackSolo(trackId, soloed);\n }\n\n setTrackPan(trackId: string, pan: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.pan = pan;\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 try {\n this._adapter?.dispose();\n } catch (err) {\n console.warn('[waveform-playlist/engine] Error disposing adapter:', err);\n }\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 /**\n * Returns whether Transport loop should be active right now.\n * When not playing, returns true unconditionally — the intent to loop\n * should be honored when playback starts (play() has its own position\n * check). During playback, checks if the live position is within the\n * loop region [loopStart, loopEnd).\n */\n private _shouldActivateTransportLoop(): boolean {\n if (!this._isPlaying) return true;\n const t = this._adapter?.getCurrentTime() ?? this._currentTime;\n return t >= this._loopStart && t < this._loopEnd;\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
@@ -156,8 +156,16 @@ var PlaylistEngine = class {
156
156
  constructor(options = {}) {
157
157
  this._tracks = [];
158
158
  this._currentTime = 0;
159
+ this._playStartPosition = 0;
159
160
  this._isPlaying = false;
160
161
  this._selectedTrackId = null;
162
+ this._selectionStart = 0;
163
+ this._selectionEnd = 0;
164
+ this._masterVolume = 1;
165
+ this._loopStart = 0;
166
+ this._loopEnd = 0;
167
+ this._isLoopEnabled = false;
168
+ this._tracksVersion = 0;
161
169
  this._animFrameId = null;
162
170
  this._disposed = false;
163
171
  // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
@@ -177,6 +185,7 @@ var PlaylistEngine = class {
177
185
  getState() {
178
186
  return {
179
187
  tracks: this._tracks.map((t) => ({ ...t, clips: [...t.clips] })),
188
+ tracksVersion: this._tracksVersion,
180
189
  duration: calculateDuration(this._tracks),
181
190
  currentTime: this._currentTime,
182
191
  isPlaying: this._isPlaying,
@@ -185,7 +194,13 @@ var PlaylistEngine = class {
185
194
  selectedTrackId: this._selectedTrackId,
186
195
  zoomIndex: this._zoomIndex,
187
196
  canZoomIn: this._zoomIndex > 0,
188
- canZoomOut: this._zoomIndex < this._zoomLevels.length - 1
197
+ canZoomOut: this._zoomIndex < this._zoomLevels.length - 1,
198
+ selectionStart: this._selectionStart,
199
+ selectionEnd: this._selectionEnd,
200
+ masterVolume: this._masterVolume,
201
+ loopStart: this._loopStart,
202
+ loopEnd: this._loopEnd,
203
+ isLoopEnabled: this._isLoopEnabled
189
204
  };
190
205
  }
191
206
  // ---------------------------------------------------------------------------
@@ -193,17 +208,20 @@ var PlaylistEngine = class {
193
208
  // ---------------------------------------------------------------------------
194
209
  setTracks(tracks) {
195
210
  this._tracks = [...tracks];
211
+ this._tracksVersion++;
196
212
  this._adapter?.setTracks(this._tracks);
197
213
  this._emitStateChange();
198
214
  }
199
215
  addTrack(track) {
200
216
  this._tracks = [...this._tracks, track];
217
+ this._tracksVersion++;
201
218
  this._adapter?.setTracks(this._tracks);
202
219
  this._emitStateChange();
203
220
  }
204
221
  removeTrack(trackId) {
205
222
  if (!this._tracks.some((t) => t.id === trackId)) return;
206
223
  this._tracks = this._tracks.filter((t) => t.id !== trackId);
224
+ this._tracksVersion++;
207
225
  if (this._selectedTrackId === trackId) {
208
226
  this._selectedTrackId = null;
209
227
  }
@@ -211,6 +229,7 @@ var PlaylistEngine = class {
211
229
  this._emitStateChange();
212
230
  }
213
231
  selectTrack(trackId) {
232
+ if (trackId === this._selectedTrackId) return;
214
233
  this._selectedTrackId = trackId;
215
234
  this._emitStateChange();
216
235
  }
@@ -219,9 +238,17 @@ var PlaylistEngine = class {
219
238
  // ---------------------------------------------------------------------------
220
239
  moveClip(trackId, clipId, deltaSamples) {
221
240
  const track = this._tracks.find((t) => t.id === trackId);
222
- if (!track) return;
241
+ if (!track) {
242
+ console.warn(`[waveform-playlist/engine] moveClip: track "${trackId}" not found`);
243
+ return;
244
+ }
223
245
  const clipIndex = track.clips.findIndex((c) => c.id === clipId);
224
- if (clipIndex === -1) return;
246
+ if (clipIndex === -1) {
247
+ console.warn(
248
+ `[waveform-playlist/engine] moveClip: clip "${clipId}" not found in track "${trackId}"`
249
+ );
250
+ return;
251
+ }
225
252
  const clip = track.clips[clipIndex];
226
253
  const sortedClips = sortClipsByTime(track.clips);
227
254
  const sortedIndex = sortedClips.findIndex((c) => c.id === clipId);
@@ -237,16 +264,31 @@ var PlaylistEngine = class {
237
264
  );
238
265
  return { ...t, clips: newClips };
239
266
  });
267
+ this._tracksVersion++;
268
+ this._adapter?.setTracks(this._tracks);
240
269
  this._emitStateChange();
241
270
  }
242
271
  splitClip(trackId, clipId, atSample) {
243
272
  const track = this._tracks.find((t) => t.id === trackId);
244
- if (!track) return;
273
+ if (!track) {
274
+ console.warn(`[waveform-playlist/engine] splitClip: track "${trackId}" not found`);
275
+ return;
276
+ }
245
277
  const clipIndex = track.clips.findIndex((c) => c.id === clipId);
246
- if (clipIndex === -1) return;
278
+ if (clipIndex === -1) {
279
+ console.warn(
280
+ `[waveform-playlist/engine] splitClip: clip "${clipId}" not found in track "${trackId}"`
281
+ );
282
+ return;
283
+ }
247
284
  const clip = track.clips[clipIndex];
248
285
  const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);
249
- if (!canSplitAt(clip, atSample, minDuration)) return;
286
+ if (!canSplitAt(clip, atSample, minDuration)) {
287
+ console.warn(
288
+ `[waveform-playlist/engine] splitClip: cannot split clip "${clipId}" at sample ${atSample} (clip range: ${clip.startSample}\u2013${clip.startSample + clip.durationSamples}, minDuration: ${minDuration})`
289
+ );
290
+ return;
291
+ }
250
292
  const { left, right } = splitClip(clip, atSample);
251
293
  this._tracks = this._tracks.map((t) => {
252
294
  if (t.id !== trackId) return t;
@@ -254,13 +296,23 @@ var PlaylistEngine = class {
254
296
  newClips.splice(clipIndex, 1, left, right);
255
297
  return { ...t, clips: newClips };
256
298
  });
299
+ this._tracksVersion++;
300
+ this._adapter?.setTracks(this._tracks);
257
301
  this._emitStateChange();
258
302
  }
259
303
  trimClip(trackId, clipId, boundary, deltaSamples) {
260
304
  const track = this._tracks.find((t) => t.id === trackId);
261
- if (!track) return;
305
+ if (!track) {
306
+ console.warn(`[waveform-playlist/engine] trimClip: track "${trackId}" not found`);
307
+ return;
308
+ }
262
309
  const clipIndex = track.clips.findIndex((c) => c.id === clipId);
263
- if (clipIndex === -1) return;
310
+ if (clipIndex === -1) {
311
+ console.warn(
312
+ `[waveform-playlist/engine] trimClip: clip "${clipId}" not found in track "${trackId}"`
313
+ );
314
+ return;
315
+ }
264
316
  const clip = track.clips[clipIndex];
265
317
  const sortedClips = sortClipsByTime(track.clips);
266
318
  const sortedIndex = sortedClips.findIndex((c) => c.id === clipId);
@@ -291,21 +343,43 @@ var PlaylistEngine = class {
291
343
  });
292
344
  return { ...t, clips: newClips };
293
345
  });
346
+ this._tracksVersion++;
347
+ this._adapter?.setTracks(this._tracks);
294
348
  this._emitStateChange();
295
349
  }
296
350
  // ---------------------------------------------------------------------------
297
351
  // Playback (delegates to adapter, no-ops without adapter)
298
352
  // ---------------------------------------------------------------------------
299
- async play(startTime, endTime) {
353
+ async init() {
354
+ if (this._adapter) {
355
+ await this._adapter.init();
356
+ }
357
+ }
358
+ play(startTime, endTime) {
359
+ const prevCurrentTime = this._currentTime;
360
+ const prevPlayStartPosition = this._playStartPosition;
300
361
  if (startTime !== void 0) {
301
362
  const duration = calculateDuration(this._tracks);
302
363
  this._currentTime = clampSeekPosition(startTime, duration);
303
364
  }
365
+ this._playStartPosition = this._currentTime;
304
366
  if (this._adapter) {
305
- await this._adapter.play(this._currentTime, endTime);
306
- this._startTimeUpdateLoop();
367
+ if (endTime !== void 0) {
368
+ this._adapter.setLoop(false, this._loopStart, this._loopEnd);
369
+ } else if (this._isLoopEnabled) {
370
+ const inLoopRegion = this._currentTime >= this._loopStart && this._currentTime < this._loopEnd;
371
+ this._adapter.setLoop(inLoopRegion, this._loopStart, this._loopEnd);
372
+ }
373
+ try {
374
+ this._adapter.play(this._currentTime, endTime);
375
+ } catch (err) {
376
+ this._currentTime = prevCurrentTime;
377
+ this._playStartPosition = prevPlayStartPosition;
378
+ throw err;
379
+ }
307
380
  }
308
381
  this._isPlaying = true;
382
+ this._startTimeUpdateLoop();
309
383
  this._emit("play");
310
384
  this._emitStateChange();
311
385
  }
@@ -321,8 +395,9 @@ var PlaylistEngine = class {
321
395
  }
322
396
  stop() {
323
397
  this._isPlaying = false;
324
- this._currentTime = 0;
398
+ this._currentTime = this._playStartPosition;
325
399
  this._stopTimeUpdateLoop();
400
+ this._adapter?.setLoop(false, this._loopStart, this._loopEnd);
326
401
  this._adapter?.stop();
327
402
  this._emit("stop");
328
403
  this._emitStateChange();
@@ -334,21 +409,72 @@ var PlaylistEngine = class {
334
409
  this._emitStateChange();
335
410
  }
336
411
  setMasterVolume(volume) {
412
+ if (volume === this._masterVolume) return;
413
+ this._masterVolume = volume;
337
414
  this._adapter?.setMasterVolume(volume);
415
+ this._emitStateChange();
416
+ }
417
+ getCurrentTime() {
418
+ if (this._isPlaying && this._adapter) {
419
+ return this._adapter.getCurrentTime();
420
+ }
421
+ return this._currentTime;
422
+ }
423
+ // ---------------------------------------------------------------------------
424
+ // Selection & Loop
425
+ // ---------------------------------------------------------------------------
426
+ setSelection(start, end) {
427
+ const s = Math.min(start, end);
428
+ const e = Math.max(start, end);
429
+ if (s === this._selectionStart && e === this._selectionEnd) return;
430
+ this._selectionStart = s;
431
+ this._selectionEnd = e;
432
+ this._emitStateChange();
433
+ }
434
+ setLoopRegion(start, end) {
435
+ const s = Math.min(start, end);
436
+ const e = Math.max(start, end);
437
+ if (s === this._loopStart && e === this._loopEnd) return;
438
+ this._loopStart = s;
439
+ this._loopEnd = e;
440
+ this._adapter?.setLoop(
441
+ this._isLoopEnabled && this._shouldActivateTransportLoop(),
442
+ this._loopStart,
443
+ this._loopEnd
444
+ );
445
+ this._emitStateChange();
446
+ }
447
+ setLoopEnabled(enabled) {
448
+ if (enabled === this._isLoopEnabled) return;
449
+ this._isLoopEnabled = enabled;
450
+ this._adapter?.setLoop(
451
+ enabled && this._shouldActivateTransportLoop(),
452
+ this._loopStart,
453
+ this._loopEnd
454
+ );
455
+ this._emitStateChange();
338
456
  }
339
457
  // ---------------------------------------------------------------------------
340
458
  // Per-Track Audio (delegates to adapter)
341
459
  // ---------------------------------------------------------------------------
342
460
  setTrackVolume(trackId, volume) {
461
+ const track = this._tracks.find((t) => t.id === trackId);
462
+ if (track) track.volume = volume;
343
463
  this._adapter?.setTrackVolume(trackId, volume);
344
464
  }
345
465
  setTrackMute(trackId, muted) {
466
+ const track = this._tracks.find((t) => t.id === trackId);
467
+ if (track) track.muted = muted;
346
468
  this._adapter?.setTrackMute(trackId, muted);
347
469
  }
348
470
  setTrackSolo(trackId, soloed) {
471
+ const track = this._tracks.find((t) => t.id === trackId);
472
+ if (track) track.soloed = soloed;
349
473
  this._adapter?.setTrackSolo(trackId, soloed);
350
474
  }
351
475
  setTrackPan(trackId, pan) {
476
+ const track = this._tracks.find((t) => t.id === trackId);
477
+ if (track) track.pan = pan;
352
478
  this._adapter?.setTrackPan(trackId, pan);
353
479
  }
354
480
  // ---------------------------------------------------------------------------
@@ -391,7 +517,11 @@ var PlaylistEngine = class {
391
517
  if (this._disposed) return;
392
518
  this._disposed = true;
393
519
  this._stopTimeUpdateLoop();
394
- this._adapter?.dispose();
520
+ try {
521
+ this._adapter?.dispose();
522
+ } catch (err) {
523
+ console.warn("[waveform-playlist/engine] Error disposing adapter:", err);
524
+ }
395
525
  this._listeners.clear();
396
526
  }
397
527
  // ---------------------------------------------------------------------------
@@ -412,6 +542,18 @@ var PlaylistEngine = class {
412
542
  _emitStateChange() {
413
543
  this._emit("statechange", this.getState());
414
544
  }
545
+ /**
546
+ * Returns whether Transport loop should be active right now.
547
+ * When not playing, returns true unconditionally — the intent to loop
548
+ * should be honored when playback starts (play() has its own position
549
+ * check). During playback, checks if the live position is within the
550
+ * loop region [loopStart, loopEnd).
551
+ */
552
+ _shouldActivateTransportLoop() {
553
+ if (!this._isPlaying) return true;
554
+ const t = this._adapter?.getCurrentTime() ?? this._currentTime;
555
+ return t >= this._loopStart && t < this._loopEnd;
556
+ }
415
557
  _startTimeUpdateLoop() {
416
558
  if (typeof requestAnimationFrame === "undefined") return;
417
559
  this._stopTimeUpdateLoop();
@@ -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 _playStartPosition = 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 init(): Promise<void> {\n if (this._adapter) {\n await this._adapter.init();\n }\n }\n\n play(startTime?: number, endTime?: number): void {\n const prevCurrentTime = this._currentTime;\n const prevPlayStartPosition = this._playStartPosition;\n\n if (startTime !== undefined) {\n const duration = calculateDuration(this._tracks);\n this._currentTime = clampSeekPosition(startTime, duration);\n }\n\n // Remember where playback started (Audacity-style: stop returns here)\n this._playStartPosition = this._currentTime;\n\n if (this._adapter) {\n // Configure loop state BEFORE play(). The adapter caches loopStart/\n // loopEnd/enabled from setLoop(), then TonePlayout.play() applies\n // them to the Transport before transport.start() and advances\n // Clock._lastUpdate to skip stale ghost ticks.\n if (endTime !== undefined) {\n // Disable Transport loop for duration-limited playback (selection/annotation)\n this._adapter.setLoop(false, this._loopStart, this._loopEnd);\n } else if (this._isLoopEnabled) {\n // Only enable Transport loop if starting within the loop region.\n // Starting outside plays normally to the end (DAW behavior).\n const inLoopRegion =\n this._currentTime >= this._loopStart && this._currentTime < this._loopEnd;\n this._adapter.setLoop(inLoopRegion, this._loopStart, this._loopEnd);\n }\n try {\n this._adapter.play(this._currentTime, endTime);\n } catch (err) {\n // Restore state so the engine isn't left with a moved playhead\n // but no audio. The throw propagates to the caller.\n this._currentTime = prevCurrentTime;\n this._playStartPosition = prevPlayStartPosition;\n throw err;\n }\n }\n\n this._isPlaying = true;\n this._startTimeUpdateLoop();\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 = this._playStartPosition;\n this._stopTimeUpdateLoop();\n this._adapter?.setLoop(false, this._loopStart, this._loopEnd);\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 getCurrentTime(): number {\n if (this._isPlaying && this._adapter) {\n return this._adapter.getCurrentTime();\n }\n return this._currentTime;\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._adapter?.setLoop(\n this._isLoopEnabled && this._shouldActivateTransportLoop(),\n this._loopStart,\n this._loopEnd\n );\n this._emitStateChange();\n }\n\n setLoopEnabled(enabled: boolean): void {\n if (enabled === this._isLoopEnabled) return;\n this._isLoopEnabled = enabled;\n this._adapter?.setLoop(\n enabled && this._shouldActivateTransportLoop(),\n this._loopStart,\n this._loopEnd\n );\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Per-Track Audio (delegates to adapter)\n // ---------------------------------------------------------------------------\n\n setTrackVolume(trackId: string, volume: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.volume = volume;\n this._adapter?.setTrackVolume(trackId, volume);\n }\n\n setTrackMute(trackId: string, muted: boolean): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.muted = muted;\n this._adapter?.setTrackMute(trackId, muted);\n }\n\n setTrackSolo(trackId: string, soloed: boolean): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.soloed = soloed;\n this._adapter?.setTrackSolo(trackId, soloed);\n }\n\n setTrackPan(trackId: string, pan: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.pan = pan;\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 try {\n this._adapter?.dispose();\n } catch (err) {\n console.warn('[waveform-playlist/engine] Error disposing adapter:', err);\n }\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 /**\n * Returns whether Transport loop should be active right now.\n * When not playing, returns true unconditionally — the intent to loop\n * should be honored when playback starts (play() has its own position\n * check). During playback, checks if the live position is within the\n * loop region [loopStart, loopEnd).\n */\n private _shouldActivateTransportLoop(): boolean {\n if (!this._isPlaying) return true;\n const t = this._adapter?.getCurrentTime() ?? this._currentTime;\n return t >= this._loopStart && t < this._loopEnd;\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.1.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.1.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@waveform-playlist/core": ">=7.0.0"