@dawcore/components 0.0.12 → 0.0.14

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.mjs CHANGED
@@ -27,11 +27,54 @@ var DawClipElement = class extends LitElement {
27
27
  this.fadeOut = 0;
28
28
  this.fadeType = "linear";
29
29
  this.clipId = crypto.randomUUID();
30
+ // Removal is detected by the editor's MutationObserver — detached elements
31
+ // cannot bubble events to ancestors.
32
+ this._hasRendered = false;
30
33
  }
31
34
  // Light DOM — no visual rendering, just a data container
32
35
  createRenderRoot() {
33
36
  return this;
34
37
  }
38
+ connectedCallback() {
39
+ super.connectedCallback();
40
+ setTimeout(() => {
41
+ this.dispatchEvent(
42
+ new CustomEvent("daw-clip-connected", {
43
+ bubbles: true,
44
+ composed: true,
45
+ detail: { clipId: this.clipId, element: this }
46
+ })
47
+ );
48
+ }, 0);
49
+ }
50
+ updated(changed) {
51
+ if (!this._hasRendered) {
52
+ this._hasRendered = true;
53
+ return;
54
+ }
55
+ const clipProps = [
56
+ "src",
57
+ "peaksSrc",
58
+ "start",
59
+ "duration",
60
+ "offset",
61
+ "gain",
62
+ "name",
63
+ "fadeIn",
64
+ "fadeOut",
65
+ "fadeType"
66
+ ];
67
+ if (clipProps.some((p) => changed.has(p))) {
68
+ const trackEl = this.closest("daw-track");
69
+ this.dispatchEvent(
70
+ new CustomEvent("daw-clip-update", {
71
+ bubbles: true,
72
+ composed: true,
73
+ detail: { trackId: trackEl?.trackId ?? "", clipId: this.clipId }
74
+ })
75
+ );
76
+ }
77
+ }
35
78
  };
36
79
  __decorateClass([
37
80
  property()
@@ -839,6 +882,13 @@ DawStopButtonElement = __decorateClass([
839
882
  // src/elements/daw-editor.ts
840
883
  import { LitElement as LitElement9, html as html8, css as css8 } from "lit";
841
884
  import { customElement as customElement11, property as property7, state as state3 } from "lit/decorators.js";
885
+
886
+ // src/types.ts
887
+ function isDomClip(desc) {
888
+ return desc.kind === "dom";
889
+ }
890
+
891
+ // src/elements/daw-editor.ts
842
892
  import {
843
893
  createClip as createClip3,
844
894
  createClipFromSeconds,
@@ -1846,6 +1896,7 @@ var ViewportController = class {
1846
1896
  constructor(host) {
1847
1897
  this._scrollContainer = null;
1848
1898
  this._lastScrollLeft = 0;
1899
+ this._resizeObserver = null;
1849
1900
  // Permissive defaults: render everything until scroll container is attached
1850
1901
  this.visibleStart = -Infinity;
1851
1902
  this.visibleEnd = Infinity;
@@ -1878,13 +1929,26 @@ var ViewportController = class {
1878
1929
  }
1879
1930
  hostDisconnected() {
1880
1931
  this._scrollContainer?.removeEventListener("scroll", this._onScroll);
1932
+ this._resizeObserver?.disconnect();
1933
+ this._resizeObserver = null;
1881
1934
  this._scrollContainer = null;
1882
1935
  }
1883
1936
  _attachScrollContainer(container) {
1884
1937
  this._scrollContainer?.removeEventListener("scroll", this._onScroll);
1938
+ this._resizeObserver?.disconnect();
1885
1939
  this._scrollContainer = container;
1886
1940
  container.addEventListener("scroll", this._onScroll, { passive: true });
1887
1941
  this._update(container.scrollLeft, container.clientWidth);
1942
+ if (typeof ResizeObserver !== "undefined") {
1943
+ this._resizeObserver = new ResizeObserver(() => {
1944
+ if (!this._scrollContainer) return;
1945
+ const next = this._scrollContainer.clientWidth;
1946
+ if (next === this.containerWidth) return;
1947
+ this._update(this._scrollContainer.scrollLeft, next);
1948
+ this._host.requestUpdate();
1949
+ });
1950
+ this._resizeObserver.observe(container);
1951
+ }
1888
1952
  this._host.requestUpdate();
1889
1953
  }
1890
1954
  _update(scrollLeft, containerWidth) {
@@ -2850,6 +2914,7 @@ async function loadFiles(host, files) {
2850
2914
  soloed: false,
2851
2915
  clips: [
2852
2916
  {
2917
+ kind: "drop",
2853
2918
  src: "",
2854
2919
  peaksSrc: "",
2855
2920
  start: 0,
@@ -2936,6 +3001,7 @@ function addRecordedClip(host, trackId, buf, startSample, durSamples, offsetSamp
2936
3001
  if (desc) {
2937
3002
  const sr = host.effectiveSampleRate;
2938
3003
  const clipDesc = {
3004
+ kind: "drop",
2939
3005
  src: "",
2940
3006
  peaksSrc: "",
2941
3007
  start: startSample / sr,
@@ -3172,6 +3238,7 @@ var DawEditorElement = class extends LitElement9 {
3172
3238
  this.clipHeaders = false;
3173
3239
  this.clipHeaderHeight = 20;
3174
3240
  this.interactiveClips = false;
3241
+ this.indefinitePlayback = false;
3175
3242
  this.scaleMode = "temporal";
3176
3243
  this._ticksPerPixel = 24;
3177
3244
  this._bpm = 120;
@@ -3288,6 +3355,51 @@ var DawEditorElement = class extends LitElement9 {
3288
3355
  this._onTrackRemoved(trackId);
3289
3356
  }
3290
3357
  };
3358
+ // --- Clip lifecycle ---
3359
+ this._onClipConnected = (e) => {
3360
+ const detail = e.detail;
3361
+ const clipEl = detail.element;
3362
+ if (!(clipEl instanceof HTMLElement)) return;
3363
+ const trackEl = clipEl.closest("daw-track");
3364
+ if (!trackEl) return;
3365
+ const trackId = trackEl.trackId;
3366
+ if (!this._engineTracks.has(trackId)) {
3367
+ const desc = this._tracks.get(trackId);
3368
+ if (desc && !desc.clips.some((c) => isDomClip(c) && c.clipId === clipEl.clipId)) {
3369
+ console.warn(
3370
+ '[dawcore] daw-clip-connected fired while parent track "' + trackId + '" is still loading \u2014 late-appended clip may be missed. Wait for daw-track-ready before appending more <daw-clip> children, or use editor.addClip(trackId, config) after the track finishes loading.'
3371
+ );
3372
+ }
3373
+ return;
3374
+ }
3375
+ const clipDesc = {
3376
+ kind: "dom",
3377
+ clipId: clipEl.clipId,
3378
+ src: clipEl.src,
3379
+ peaksSrc: clipEl.peaksSrc,
3380
+ start: clipEl.start,
3381
+ duration: clipEl.duration,
3382
+ offset: clipEl.offset,
3383
+ gain: clipEl.gain,
3384
+ name: clipEl.name,
3385
+ fadeIn: clipEl.fadeIn,
3386
+ fadeOut: clipEl.fadeOut,
3387
+ fadeType: clipEl.fadeType
3388
+ };
3389
+ this._loadAndAppendClip(trackId, clipDesc);
3390
+ };
3391
+ this._onClipUpdate = (e) => {
3392
+ const clipEl = e.target;
3393
+ if (!(clipEl instanceof HTMLElement) || clipEl.tagName !== "DAW-CLIP") return;
3394
+ const detail = e.detail;
3395
+ if (!detail.trackId) {
3396
+ console.warn(
3397
+ "[dawcore] daw-clip-update fired from a <daw-clip> not nested in a <daw-track> \u2014 ignored"
3398
+ );
3399
+ return;
3400
+ }
3401
+ this._applyClipUpdate(detail.trackId, detail.clipId, clipEl);
3402
+ };
3291
3403
  // --- File Drop ---
3292
3404
  this._onDragOver = (e) => {
3293
3405
  if (!this.fileDrop) return;
@@ -3420,9 +3532,7 @@ var DawEditorElement = class extends LitElement9 {
3420
3532
  this.mono,
3421
3533
  singleClipOffsets
3422
3534
  );
3423
- const peakData = result.get(clipId);
3424
- if (!peakData) return null;
3425
- return { data: peakData.data, length: peakData.length };
3535
+ return result.get(clipId) ?? null;
3426
3536
  }
3427
3537
  get effectiveSampleRate() {
3428
3538
  return this._resolvedSampleRate ?? this.sampleRate;
@@ -3476,7 +3586,13 @@ var DawEditorElement = class extends LitElement9 {
3476
3586
  const minTicks = 32 * num * this.ppqn;
3477
3587
  return Math.ceil(Math.max(contentTicks, minTicks) / this.ticksPerPixel);
3478
3588
  }
3479
- return Math.ceil(this._duration * this.effectiveSampleRate / this.samplesPerPixel);
3589
+ const naturalWidth = Math.ceil(
3590
+ this._duration * this.effectiveSampleRate / this.samplesPerPixel
3591
+ );
3592
+ if (this.indefinitePlayback) {
3593
+ return Math.max(naturalWidth, this._viewport.containerWidth);
3594
+ }
3595
+ return naturalWidth;
3480
3596
  }
3481
3597
  /** Grid height when no tracks exist — matches scroll area's rendered height. */
3482
3598
  get _emptyGridHeight() {
@@ -3518,19 +3634,29 @@ var DawEditorElement = class extends LitElement9 {
3518
3634
  this.addEventListener("daw-track-update", this._onTrackUpdate);
3519
3635
  this.addEventListener("daw-track-control", this._onTrackControl);
3520
3636
  this.addEventListener("daw-track-remove", this._onTrackRemoveRequest);
3637
+ this.addEventListener("daw-clip-connected", this._onClipConnected);
3638
+ this.addEventListener("daw-clip-update", this._onClipUpdate);
3521
3639
  this._childObserver = new MutationObserver((mutations) => {
3522
3640
  for (const mutation of mutations) {
3523
3641
  for (const node of mutation.removedNodes) {
3524
3642
  if (node instanceof HTMLElement) {
3525
3643
  if (node.tagName === "DAW-TRACK") {
3526
3644
  this._onTrackRemoved(node.trackId);
3645
+ } else if (node.tagName === "DAW-CLIP") {
3646
+ this._onClipRemovedFromDom(node);
3527
3647
  }
3528
- const nested = node.querySelectorAll?.("daw-track");
3529
- if (nested) {
3530
- for (const track of nested) {
3648
+ const nestedTracks = node.querySelectorAll?.("daw-track");
3649
+ if (nestedTracks) {
3650
+ for (const track of nestedTracks) {
3531
3651
  this._onTrackRemoved(track.trackId);
3532
3652
  }
3533
3653
  }
3654
+ const nestedClips = node.querySelectorAll?.("daw-clip");
3655
+ if (nestedClips) {
3656
+ for (const clip of nestedClips) {
3657
+ this._onClipRemovedFromDom(clip);
3658
+ }
3659
+ }
3534
3660
  }
3535
3661
  }
3536
3662
  }
@@ -3543,6 +3669,8 @@ var DawEditorElement = class extends LitElement9 {
3543
3669
  this.removeEventListener("daw-track-update", this._onTrackUpdate);
3544
3670
  this.removeEventListener("daw-track-control", this._onTrackControl);
3545
3671
  this.removeEventListener("daw-track-remove", this._onTrackRemoveRequest);
3672
+ this.removeEventListener("daw-clip-connected", this._onClipConnected);
3673
+ this.removeEventListener("daw-clip-update", this._onClipUpdate);
3546
3674
  this._childObserver?.disconnect();
3547
3675
  this._childObserver = null;
3548
3676
  this._trackElements.clear();
@@ -3608,11 +3736,257 @@ var DawEditorElement = class extends LitElement9 {
3608
3736
  this._stopPlayhead();
3609
3737
  }
3610
3738
  }
3739
+ _onClipRemovedFromDom(clipEl) {
3740
+ const clipId = clipEl.clipId;
3741
+ for (const [trackId, t] of this._engineTracks.entries()) {
3742
+ if (t.clips.some((c) => c.id === clipId)) {
3743
+ this._removeClipFromTrack(trackId, clipId);
3744
+ return;
3745
+ }
3746
+ }
3747
+ if (this._clipBuffers.has(clipId) || this._clipOffsets.has(clipId) || this._peaksData.has(clipId)) {
3748
+ console.warn(
3749
+ '[dawcore] _onClipRemovedFromDom: orphaned cache entries for clip "' + clipId + '" \u2014 purging (DOM/engine id mismatch?)'
3750
+ );
3751
+ this._purgeClipCaches(clipId);
3752
+ }
3753
+ }
3754
+ async _loadAndAppendClip(trackId, clipDesc) {
3755
+ if (!clipDesc.src) return;
3756
+ const clipId = clipDesc.clipId;
3757
+ let insertedClipId = null;
3758
+ try {
3759
+ const waveformDataPromise = clipDesc.peaksSrc ? this._resolvePeaks(clipDesc.peaksSrc) : Promise.resolve(null);
3760
+ const audioPromise = this._fetchAndDecode(clipDesc.src);
3761
+ const [waveformData, audioBuffer] = await Promise.all([waveformDataPromise, audioPromise]);
3762
+ this._resolvedSampleRate = audioBuffer.sampleRate;
3763
+ const clip = await this._finalizeAudioClip(clipDesc, audioBuffer, waveformData);
3764
+ insertedClipId = clip.id;
3765
+ const t = this._engineTracks.get(trackId);
3766
+ if (!t) {
3767
+ this._purgeClipCaches(clip.id);
3768
+ return;
3769
+ }
3770
+ const updatedTrack = { ...t, clips: [...t.clips, clip] };
3771
+ this._engineTracks = new Map(this._engineTracks).set(trackId, updatedTrack);
3772
+ const desc = this._tracks.get(trackId);
3773
+ if (desc) {
3774
+ this._tracks = new Map(this._tracks).set(trackId, {
3775
+ ...desc,
3776
+ clips: [...desc.clips, clipDesc]
3777
+ });
3778
+ }
3779
+ this._commitTrackChange(trackId, updatedTrack);
3780
+ this.dispatchEvent(
3781
+ new CustomEvent("daw-clip-ready", {
3782
+ bubbles: true,
3783
+ composed: true,
3784
+ detail: { trackId, clipId: clip.id }
3785
+ })
3786
+ );
3787
+ } catch (err) {
3788
+ console.warn("[dawcore] _loadAndAppendClip failed: " + String(err));
3789
+ if (insertedClipId) this._purgeClipCaches(insertedClipId);
3790
+ this.dispatchEvent(
3791
+ new CustomEvent("daw-clip-error", {
3792
+ bubbles: true,
3793
+ composed: true,
3794
+ detail: { trackId, clipId: insertedClipId ?? clipId, error: err }
3795
+ })
3796
+ );
3797
+ }
3798
+ }
3799
+ /**
3800
+ * Resolve pre-computed peaks for a clip: fetch the .dat/.json, validate the
3801
+ * sample rate matches the AudioContext, return the WaveformData or null.
3802
+ * Warns on fetch failure and on sample-rate mismatch — never silent.
3803
+ *
3804
+ * Shared between `_loadTrack` (peaks-first preview path) and
3805
+ * `_loadAndAppendClip` (incremental late-append).
3806
+ */
3807
+ async _resolvePeaks(peaksSrc) {
3808
+ try {
3809
+ const wd = await this._fetchPeaks(peaksSrc);
3810
+ const contextRate = this.audioContext.sampleRate;
3811
+ if (wd.sample_rate === contextRate) return wd;
3812
+ console.warn(
3813
+ "[dawcore] Pre-computed peaks at " + wd.sample_rate + " Hz do not match AudioContext at " + contextRate + " Hz \u2014 ignoring " + peaksSrc + ", generating from audio"
3814
+ );
3815
+ return null;
3816
+ } catch (err) {
3817
+ console.warn(
3818
+ "[dawcore] Failed to load peaks from " + peaksSrc + ": " + String(err) + " \u2014 falling back to AudioBuffer generation"
3819
+ );
3820
+ return null;
3821
+ }
3822
+ }
3823
+ /**
3824
+ * Construct an AudioClip from a decoded buffer (and optional WaveformData),
3825
+ * align its id with the source `<daw-clip>.clipId` when present, populate
3826
+ * `_clipBuffers` / `_clipOffsets`, generate peaks via the worker pipeline,
3827
+ * and populate `_peaksData`. Returns the finished AudioClip.
3828
+ *
3829
+ * Shared between `_loadTrack`'s standard path and `_loadAndAppendClip`.
3830
+ * Not used by `_loadTrack`'s peaks-first preview path because that path
3831
+ * uses sync `extractPeaks` and inserts a preview track BEFORE audio decode.
3832
+ */
3833
+ async _finalizeAudioClip(clipDesc, audioBuffer, waveformData) {
3834
+ let clip;
3835
+ if (waveformData) {
3836
+ const wdRate = waveformData.sample_rate;
3837
+ clip = createClip3({
3838
+ audioBuffer,
3839
+ waveformData,
3840
+ startSample: Math.round(clipDesc.start * wdRate),
3841
+ durationSamples: Math.round((clipDesc.duration || waveformData.duration) * wdRate),
3842
+ offsetSamples: Math.round(clipDesc.offset * wdRate),
3843
+ gain: clipDesc.gain,
3844
+ name: clipDesc.name,
3845
+ sampleRate: wdRate,
3846
+ sourceDurationSamples: Math.ceil(waveformData.duration * wdRate)
3847
+ });
3848
+ this._peakPipeline.cacheWaveformData(audioBuffer, waveformData);
3849
+ } else {
3850
+ clip = createClipFromSeconds({
3851
+ audioBuffer,
3852
+ startTime: clipDesc.start,
3853
+ duration: clipDesc.duration || audioBuffer.duration,
3854
+ offset: clipDesc.offset,
3855
+ gain: clipDesc.gain,
3856
+ name: clipDesc.name,
3857
+ sampleRate: audioBuffer.sampleRate,
3858
+ sourceDuration: audioBuffer.duration
3859
+ });
3860
+ }
3861
+ if (isDomClip(clipDesc)) clip.id = clipDesc.clipId;
3862
+ this._clipBuffers = new Map(this._clipBuffers).set(clip.id, audioBuffer);
3863
+ this._clipOffsets.set(clip.id, {
3864
+ offsetSamples: clip.offsetSamples,
3865
+ durationSamples: clip.durationSamples
3866
+ });
3867
+ let peakData;
3868
+ try {
3869
+ peakData = await this._peakPipeline.generatePeaks(
3870
+ audioBuffer,
3871
+ this._renderSpp,
3872
+ this.mono,
3873
+ clip.offsetSamples,
3874
+ clip.durationSamples
3875
+ );
3876
+ } catch (err) {
3877
+ this._purgeClipCaches(clip.id);
3878
+ throw err;
3879
+ }
3880
+ this._peaksData = new Map(this._peaksData).set(clip.id, peakData);
3881
+ if (waveformData) {
3882
+ this._minSamplesPerPixel = Math.max(this._minSamplesPerPixel, waveformData.scale);
3883
+ }
3884
+ return clip;
3885
+ }
3886
+ /** Remove a single clip from all per-clip caches. Used by error rollbacks. */
3887
+ _purgeClipCaches(clipId) {
3888
+ const nextBuffers = new Map(this._clipBuffers);
3889
+ nextBuffers.delete(clipId);
3890
+ this._clipBuffers = nextBuffers;
3891
+ const nextPeaks = new Map(this._peaksData);
3892
+ nextPeaks.delete(clipId);
3893
+ this._peaksData = nextPeaks;
3894
+ this._clipOffsets.delete(clipId);
3895
+ }
3896
+ /**
3897
+ * Recompute duration and forward an updated track to the engine. Single
3898
+ * source of truth for the incremental-vs-full-rebuild policy used by every
3899
+ * clip-level mutation (addClip, updateClip, removeClip, _applyClipUpdate).
3900
+ * Use the engine's incremental updateTrack when available; otherwise fall
3901
+ * back to full setTracks (legacy adapters).
3902
+ */
3903
+ _commitTrackChange(trackId, updatedTrack) {
3904
+ this._recomputeDuration();
3905
+ if (this._engine?.updateTrack) this._engine.updateTrack(trackId, updatedTrack);
3906
+ else if (this._engine) this._engine.setTracks([...this._engineTracks.values()]);
3907
+ }
3908
+ _applyClipUpdate(trackId, clipId, clipEl) {
3909
+ const t = this._engineTracks.get(trackId);
3910
+ if (!t) {
3911
+ console.warn('[dawcore] _applyClipUpdate: no engine track for id "' + trackId + '"');
3912
+ return;
3913
+ }
3914
+ const idx = t.clips.findIndex((c) => c.id === clipId);
3915
+ if (idx === -1) {
3916
+ console.warn(
3917
+ '[dawcore] _applyClipUpdate: clip "' + clipId + '" not found in track "' + trackId + '" (DOM/engine clip-id misalignment?)'
3918
+ );
3919
+ return;
3920
+ }
3921
+ const oldClip = t.clips[idx];
3922
+ const sr = oldClip.sampleRate ?? this.effectiveSampleRate;
3923
+ const newStartSample = Math.round(clipEl.start * sr);
3924
+ const newDurationSamples = clipEl.duration > 0 ? Math.round(clipEl.duration * sr) : oldClip.durationSamples;
3925
+ const newOffsetSamples = Math.round(clipEl.offset * sr);
3926
+ const updatedClip = {
3927
+ ...oldClip,
3928
+ startSample: newStartSample,
3929
+ durationSamples: newDurationSamples,
3930
+ offsetSamples: newOffsetSamples,
3931
+ gain: clipEl.gain,
3932
+ name: clipEl.name || oldClip.name
3933
+ };
3934
+ const updatedClips = [...t.clips];
3935
+ updatedClips[idx] = updatedClip;
3936
+ const updatedTrack = { ...t, clips: updatedClips };
3937
+ this._engineTracks = new Map(this._engineTracks).set(trackId, updatedTrack);
3938
+ const boundsChanged = oldClip.offsetSamples !== newOffsetSamples || oldClip.durationSamples !== newDurationSamples;
3939
+ if (boundsChanged) {
3940
+ this._clipOffsets.set(clipId, {
3941
+ offsetSamples: newOffsetSamples,
3942
+ durationSamples: newDurationSamples
3943
+ });
3944
+ const peaks = this.reextractClipPeaks(clipId, newOffsetSamples, newDurationSamples);
3945
+ if (peaks) {
3946
+ this._peaksData = new Map(this._peaksData).set(clipId, peaks);
3947
+ }
3948
+ }
3949
+ this._commitTrackChange(trackId, updatedTrack);
3950
+ }
3951
+ _removeClipFromTrack(trackId, clipId) {
3952
+ const t = this._engineTracks.get(trackId);
3953
+ if (!t) {
3954
+ console.warn('[dawcore] _removeClipFromTrack: no engine track for id "' + trackId + '"');
3955
+ return;
3956
+ }
3957
+ const updatedClips = t.clips.filter((c) => c.id !== clipId);
3958
+ if (updatedClips.length === t.clips.length) {
3959
+ console.warn(
3960
+ '[dawcore] _removeClipFromTrack: clip "' + clipId + '" not found in track "' + trackId + '"'
3961
+ );
3962
+ return;
3963
+ }
3964
+ const updatedTrack = { ...t, clips: updatedClips };
3965
+ this._engineTracks = new Map(this._engineTracks).set(trackId, updatedTrack);
3966
+ const nextBuffers = new Map(this._clipBuffers);
3967
+ nextBuffers.delete(clipId);
3968
+ this._clipBuffers = nextBuffers;
3969
+ this._clipOffsets.delete(clipId);
3970
+ const nextPeaks = new Map(this._peaksData);
3971
+ nextPeaks.delete(clipId);
3972
+ this._peaksData = nextPeaks;
3973
+ const desc = this._tracks.get(trackId);
3974
+ if (desc) {
3975
+ this._tracks = new Map(this._tracks).set(trackId, {
3976
+ ...desc,
3977
+ // Only DOM-sourced clips have an id to match; drop-sourced clips are
3978
+ // filtered through unchanged (their identity is the descriptor itself).
3979
+ clips: desc.clips.filter((c) => !(isDomClip(c) && c.clipId === clipId))
3980
+ });
3981
+ }
3982
+ this._commitTrackChange(trackId, updatedTrack);
3983
+ }
3611
3984
  _readTrackDescriptor(trackEl) {
3612
3985
  const clipEls = trackEl.querySelectorAll("daw-clip");
3613
3986
  const clips = [];
3614
3987
  if (clipEls.length === 0 && trackEl.src) {
3615
3988
  clips.push({
3989
+ kind: "drop",
3616
3990
  src: trackEl.src,
3617
3991
  peaksSrc: "",
3618
3992
  start: 0,
@@ -3627,6 +4001,8 @@ var DawEditorElement = class extends LitElement9 {
3627
4001
  } else {
3628
4002
  for (const clipEl of clipEls) {
3629
4003
  clips.push({
4004
+ kind: "dom",
4005
+ clipId: clipEl.clipId,
3630
4006
  src: clipEl.src,
3631
4007
  peaksSrc: clipEl.peaksSrc,
3632
4008
  start: clipEl.start,
@@ -3656,111 +4032,92 @@ var DawEditorElement = class extends LitElement9 {
3656
4032
  const clips = [];
3657
4033
  for (const clipDesc of descriptor.clips) {
3658
4034
  if (!clipDesc.src) continue;
3659
- const waveformDataPromise = clipDesc.peaksSrc ? this._fetchPeaks(clipDesc.peaksSrc) : null;
3660
- const audioPromise = this._fetchAndDecode(clipDesc.src);
3661
- let waveformData = null;
3662
- if (waveformDataPromise) {
3663
- try {
3664
- const wd = await waveformDataPromise;
3665
- const contextRate = this.audioContext.sampleRate;
3666
- if (wd.sample_rate === contextRate) {
3667
- waveformData = wd;
3668
- } else {
3669
- console.warn(
3670
- "[dawcore] Pre-computed peaks at " + wd.sample_rate + " Hz do not match AudioContext at " + contextRate + " Hz \u2014 ignoring " + clipDesc.peaksSrc + ", generating from audio"
3671
- );
3672
- }
3673
- } catch (err) {
3674
- console.warn(
3675
- "[dawcore] Failed to load peaks from " + clipDesc.peaksSrc + ": " + String(err) + " \u2014 falling back to AudioBuffer generation"
4035
+ try {
4036
+ const waveformDataPromise = clipDesc.peaksSrc ? this._resolvePeaks(clipDesc.peaksSrc) : Promise.resolve(null);
4037
+ const audioPromise = this._fetchAndDecode(clipDesc.src);
4038
+ const waveformData = await waveformDataPromise;
4039
+ if (waveformData) {
4040
+ const wdRate = waveformData.sample_rate;
4041
+ const clip2 = createClip3({
4042
+ waveformData,
4043
+ startSample: Math.round(clipDesc.start * wdRate),
4044
+ durationSamples: Math.round((clipDesc.duration || waveformData.duration) * wdRate),
4045
+ offsetSamples: Math.round(clipDesc.offset * wdRate),
4046
+ gain: clipDesc.gain,
4047
+ name: clipDesc.name,
4048
+ sampleRate: wdRate,
4049
+ sourceDurationSamples: Math.ceil(waveformData.duration * wdRate)
4050
+ });
4051
+ if (isDomClip(clipDesc)) clip2.id = clipDesc.clipId;
4052
+ const effectiveScale = Math.max(this._renderSpp, waveformData.scale);
4053
+ const peakData = extractPeaks(
4054
+ waveformData,
4055
+ effectiveScale,
4056
+ this.mono,
4057
+ clip2.offsetSamples,
4058
+ clip2.durationSamples
3676
4059
  );
4060
+ this._clipOffsets.set(clip2.id, {
4061
+ offsetSamples: clip2.offsetSamples,
4062
+ durationSamples: clip2.durationSamples
4063
+ });
4064
+ this._peaksData = new Map(this._peaksData).set(clip2.id, peakData);
4065
+ this._minSamplesPerPixel = Math.max(this._minSamplesPerPixel, waveformData.scale);
4066
+ const previewTrack = createTrack2({
4067
+ name: descriptor.name,
4068
+ clips: [clip2],
4069
+ volume: descriptor.volume,
4070
+ pan: descriptor.pan,
4071
+ muted: descriptor.muted,
4072
+ soloed: descriptor.soloed
4073
+ });
4074
+ previewTrack.id = trackId;
4075
+ this._engineTracks = new Map(this._engineTracks).set(trackId, previewTrack);
4076
+ this._recomputeDuration();
4077
+ let audioBuffer2;
4078
+ try {
4079
+ audioBuffer2 = await audioPromise;
4080
+ } catch (audioErr) {
4081
+ const nextPeaks = new Map(this._peaksData);
4082
+ nextPeaks.delete(clip2.id);
4083
+ this._peaksData = nextPeaks;
4084
+ this._clipOffsets.delete(clip2.id);
4085
+ const nextEngine = new Map(this._engineTracks);
4086
+ nextEngine.delete(trackId);
4087
+ this._engineTracks = nextEngine;
4088
+ this._minSamplesPerPixel = this._peakPipeline.getMaxCachedScale(this._clipBuffers);
4089
+ this._recomputeDuration();
4090
+ throw audioErr;
4091
+ }
4092
+ this._resolvedSampleRate = audioBuffer2.sampleRate;
4093
+ const updatedClip = { ...clip2, audioBuffer: audioBuffer2 };
4094
+ this._clipBuffers = new Map(this._clipBuffers).set(clip2.id, audioBuffer2);
4095
+ this._peakPipeline.cacheWaveformData(audioBuffer2, waveformData);
4096
+ clips.push(updatedClip);
4097
+ continue;
3677
4098
  }
3678
- }
3679
- if (waveformData) {
3680
- const wdRate = waveformData.sample_rate;
3681
- const clip2 = createClip3({
3682
- waveformData,
3683
- startSample: Math.round(clipDesc.start * wdRate),
3684
- durationSamples: Math.round((clipDesc.duration || waveformData.duration) * wdRate),
3685
- offsetSamples: Math.round(clipDesc.offset * wdRate),
3686
- gain: clipDesc.gain,
3687
- name: clipDesc.name,
3688
- sampleRate: wdRate,
3689
- sourceDurationSamples: Math.ceil(waveformData.duration * wdRate)
3690
- });
3691
- const effectiveScale = Math.max(this._renderSpp, waveformData.scale);
3692
- const peakData2 = extractPeaks(
3693
- waveformData,
3694
- effectiveScale,
3695
- this.mono,
3696
- clip2.offsetSamples,
3697
- clip2.durationSamples
4099
+ const audioBuffer = await audioPromise;
4100
+ this._resolvedSampleRate = audioBuffer.sampleRate;
4101
+ const clip = await this._finalizeAudioClip(clipDesc, audioBuffer, null);
4102
+ clips.push(clip);
4103
+ } catch (clipErr) {
4104
+ console.warn(
4105
+ '[dawcore] _loadTrack: clip "' + clipDesc.src + '" failed: ' + String(clipErr)
3698
4106
  );
3699
- this._clipOffsets.set(clip2.id, {
3700
- offsetSamples: clip2.offsetSamples,
3701
- durationSamples: clip2.durationSamples
3702
- });
3703
- this._peaksData = new Map(this._peaksData).set(clip2.id, peakData2);
3704
- this._minSamplesPerPixel = Math.max(this._minSamplesPerPixel, waveformData.scale);
3705
- const previewTrack = createTrack2({
3706
- name: descriptor.name,
3707
- clips: [clip2],
3708
- volume: descriptor.volume,
3709
- pan: descriptor.pan,
3710
- muted: descriptor.muted,
3711
- soloed: descriptor.soloed
3712
- });
3713
- previewTrack.id = trackId;
3714
- this._engineTracks = new Map(this._engineTracks).set(trackId, previewTrack);
3715
- this._recomputeDuration();
3716
- let audioBuffer2;
3717
- try {
3718
- audioBuffer2 = await audioPromise;
3719
- } catch (audioErr) {
3720
- const nextPeaks = new Map(this._peaksData);
3721
- nextPeaks.delete(clip2.id);
3722
- this._peaksData = nextPeaks;
3723
- this._clipOffsets.delete(clip2.id);
3724
- const nextEngine = new Map(this._engineTracks);
3725
- nextEngine.delete(trackId);
3726
- this._engineTracks = nextEngine;
3727
- this._minSamplesPerPixel = this._peakPipeline.getMaxCachedScale(this._clipBuffers);
3728
- this._recomputeDuration();
3729
- throw audioErr;
4107
+ if (this.isConnected) {
4108
+ this.dispatchEvent(
4109
+ new CustomEvent("daw-clip-error", {
4110
+ bubbles: true,
4111
+ composed: true,
4112
+ detail: {
4113
+ trackId,
4114
+ clipId: isDomClip(clipDesc) ? clipDesc.clipId : "",
4115
+ error: clipErr
4116
+ }
4117
+ })
4118
+ );
3730
4119
  }
3731
- this._resolvedSampleRate = audioBuffer2.sampleRate;
3732
- const updatedClip = { ...clip2, audioBuffer: audioBuffer2 };
3733
- this._clipBuffers = new Map(this._clipBuffers).set(clip2.id, audioBuffer2);
3734
- this._peakPipeline.cacheWaveformData(audioBuffer2, waveformData);
3735
- clips.push(updatedClip);
3736
- continue;
3737
4120
  }
3738
- const audioBuffer = await audioPromise;
3739
- this._resolvedSampleRate = audioBuffer.sampleRate;
3740
- const clip = createClipFromSeconds({
3741
- audioBuffer,
3742
- startTime: clipDesc.start,
3743
- duration: clipDesc.duration || audioBuffer.duration,
3744
- offset: clipDesc.offset,
3745
- gain: clipDesc.gain,
3746
- name: clipDesc.name,
3747
- sampleRate: audioBuffer.sampleRate,
3748
- sourceDuration: audioBuffer.duration
3749
- });
3750
- this._clipBuffers = new Map(this._clipBuffers).set(clip.id, audioBuffer);
3751
- this._clipOffsets.set(clip.id, {
3752
- offsetSamples: clip.offsetSamples,
3753
- durationSamples: clip.durationSamples
3754
- });
3755
- const peakData = await this._peakPipeline.generatePeaks(
3756
- audioBuffer,
3757
- this._renderSpp,
3758
- this.mono,
3759
- clip.offsetSamples,
3760
- clip.durationSamples
3761
- );
3762
- this._peaksData = new Map(this._peaksData).set(clip.id, peakData);
3763
- clips.push(clip);
3764
4121
  }
3765
4122
  const track = createTrack2({
3766
4123
  name: descriptor.name,
@@ -3770,6 +4127,12 @@ var DawEditorElement = class extends LitElement9 {
3770
4127
  muted: descriptor.muted,
3771
4128
  soloed: descriptor.soloed
3772
4129
  });
4130
+ const requestedClips = descriptor.clips.filter((c) => c.src).length;
4131
+ if (requestedClips > 0 && clips.length === 0) {
4132
+ throw new Error(
4133
+ "all " + requestedClips + " clip(s) failed to load \u2014 see prior daw-clip-error events"
4134
+ );
4135
+ }
3773
4136
  track.id = trackId;
3774
4137
  this._engineTracks = new Map(this._engineTracks).set(trackId, track);
3775
4138
  this._recomputeDuration();
@@ -3903,6 +4266,251 @@ var DawEditorElement = class extends LitElement9 {
3903
4266
  async loadFiles(files) {
3904
4267
  return loadFiles(this, files);
3905
4268
  }
4269
+ // --- Programmatic Track API ---
4270
+ /**
4271
+ * Build the engine if it hasn't been built yet. Lets consumers obtain a
4272
+ * non-null `editor.engine` before any track has been loaded — useful for
4273
+ * wiring analyzers, effects, or master taps before content arrives.
4274
+ */
4275
+ async ready() {
4276
+ return this._ensureEngine();
4277
+ }
4278
+ /**
4279
+ * Wait for either `readyEvent` or `errorEvent` to fire on this editor for
4280
+ * the entity matching `matchesId`. Listeners are wired synchronously, then
4281
+ * `setup` is called (typical: appendChild). Resolves with `resolveValue`
4282
+ * on ready; rejects with a normalized Error on error. Used by addTrack and
4283
+ * addClip to share their Promise-with-listener-cleanup machinery.
4284
+ */
4285
+ _awaitId(readyEvent, errorEvent, matchesId, resolveValue, setup) {
4286
+ return new Promise((resolve, reject) => {
4287
+ const onReady = (e) => {
4288
+ if (!matchesId(e.detail)) return;
4289
+ cleanup();
4290
+ resolve(resolveValue);
4291
+ };
4292
+ const onError = (e) => {
4293
+ const detail = e.detail;
4294
+ if (!matchesId(e.detail)) return;
4295
+ cleanup();
4296
+ const err = detail.error;
4297
+ reject(err instanceof Error ? err : new Error(String(err)));
4298
+ };
4299
+ const cleanup = () => {
4300
+ this.removeEventListener(readyEvent, onReady);
4301
+ this.removeEventListener(errorEvent, onError);
4302
+ };
4303
+ this.addEventListener(readyEvent, onReady);
4304
+ this.addEventListener(errorEvent, onError);
4305
+ setup();
4306
+ });
4307
+ }
4308
+ /**
4309
+ * Append a `<daw-track>` element built from `config` and resolve once the
4310
+ * track finishes loading (or reject on `daw-track-error`). Goes through
4311
+ * the same `_loadTrack` pipeline as declarative tracks, so descriptors,
4312
+ * peaks, and clip buffers are populated correctly.
4313
+ */
4314
+ addTrack(config = {}) {
4315
+ const trackEl = document.createElement("daw-track");
4316
+ if (config.name !== void 0) trackEl.setAttribute("name", config.name);
4317
+ if (config.volume !== void 0) trackEl.volume = config.volume;
4318
+ if (config.pan !== void 0) trackEl.pan = config.pan;
4319
+ if (config.muted) trackEl.setAttribute("muted", "");
4320
+ if (config.soloed) trackEl.setAttribute("soloed", "");
4321
+ for (const clipConfig of config.clips ?? []) {
4322
+ trackEl.appendChild(this._buildClipElement(clipConfig));
4323
+ }
4324
+ return this._awaitId(
4325
+ "daw-track-ready",
4326
+ "daw-track-error",
4327
+ (d) => d.trackId === trackEl.trackId,
4328
+ trackEl,
4329
+ () => this.appendChild(trackEl)
4330
+ );
4331
+ }
4332
+ /**
4333
+ * Remove a track by id. Equivalent to `trackElement.remove()` —
4334
+ * the editor's MutationObserver handles engine and cache cleanup.
4335
+ * No-op if no matching track exists.
4336
+ */
4337
+ removeTrack(trackId) {
4338
+ const trackEl = this._trackElements.get(trackId);
4339
+ if (trackEl) {
4340
+ trackEl.remove();
4341
+ } else if (this._engineTracks.has(trackId)) {
4342
+ this._onTrackRemoved(trackId);
4343
+ } else {
4344
+ console.warn('[dawcore] removeTrack: no track found for id "' + trackId + '"');
4345
+ }
4346
+ }
4347
+ /**
4348
+ * Update reflected attributes on a track. For DOM-element tracks the changes
4349
+ * are written to the `<daw-track>` element (which fires `daw-track-update`);
4350
+ * for tracks without a DOM element (file drops) the descriptor and engine
4351
+ * state are updated in place.
4352
+ */
4353
+ updateTrack(trackId, partial) {
4354
+ const trackEl = this._trackElements.get(trackId);
4355
+ if (trackEl) {
4356
+ if (partial.name !== void 0) trackEl.setAttribute("name", partial.name);
4357
+ if (partial.volume !== void 0) trackEl.volume = partial.volume;
4358
+ if (partial.pan !== void 0) trackEl.pan = partial.pan;
4359
+ if (partial.muted !== void 0) {
4360
+ if (partial.muted) trackEl.setAttribute("muted", "");
4361
+ else trackEl.removeAttribute("muted");
4362
+ }
4363
+ if (partial.soloed !== void 0) {
4364
+ if (partial.soloed) trackEl.setAttribute("soloed", "");
4365
+ else trackEl.removeAttribute("soloed");
4366
+ }
4367
+ return;
4368
+ }
4369
+ const oldDesc = this._tracks.get(trackId);
4370
+ if (!oldDesc) return;
4371
+ const newDesc = {
4372
+ ...oldDesc,
4373
+ ...partial.name !== void 0 && { name: partial.name },
4374
+ ...partial.volume !== void 0 && { volume: partial.volume },
4375
+ ...partial.pan !== void 0 && { pan: partial.pan },
4376
+ ...partial.muted !== void 0 && { muted: partial.muted },
4377
+ ...partial.soloed !== void 0 && { soloed: partial.soloed }
4378
+ };
4379
+ this._tracks = new Map(this._tracks).set(trackId, newDesc);
4380
+ if (this._engine) {
4381
+ if (partial.volume !== void 0) this._engine.setTrackVolume(trackId, partial.volume);
4382
+ if (partial.pan !== void 0) this._engine.setTrackPan(trackId, partial.pan);
4383
+ if (partial.muted !== void 0) this._engine.setTrackMute(trackId, partial.muted);
4384
+ if (partial.soloed !== void 0) this._engine.setTrackSolo(trackId, partial.soloed);
4385
+ }
4386
+ }
4387
+ /**
4388
+ * Append a clip to an existing track. Builds a `<daw-clip>` from `config`
4389
+ * and appends it to the track's DOM element when one exists; resolves with
4390
+ * the new clip's id once the audio decode + peak generation finish.
4391
+ */
4392
+ addClip(trackId, config) {
4393
+ if (!config.src) {
4394
+ return Promise.reject(
4395
+ new Error(
4396
+ "addClip: config.src is required \u2014 pass a URL to load. Empty/recording clips are not yet supported via addClip."
4397
+ )
4398
+ );
4399
+ }
4400
+ const trackEl = this._trackElements.get(trackId);
4401
+ if (!trackEl) {
4402
+ return Promise.reject(
4403
+ new Error(
4404
+ 'addClip: no <daw-track> element for trackId "' + trackId + '" \u2014 addClip currently requires a DOM-backed track. Use editor.addTrack(config) first.'
4405
+ )
4406
+ );
4407
+ }
4408
+ const clipEl = this._buildClipElement(config);
4409
+ return this._awaitId(
4410
+ "daw-clip-ready",
4411
+ "daw-clip-error",
4412
+ (d) => d.clipId === clipEl.clipId,
4413
+ clipEl.clipId,
4414
+ () => trackEl.appendChild(clipEl)
4415
+ );
4416
+ }
4417
+ /**
4418
+ * Remove a clip by id. Removes the matching `<daw-clip>` DOM element when
4419
+ * present (MutationObserver handles cleanup); otherwise updates engine
4420
+ * state directly. No-op if no matching clip exists.
4421
+ */
4422
+ removeClip(trackId, clipId) {
4423
+ const trackEl = this._trackElements.get(trackId);
4424
+ if (trackEl) {
4425
+ const clipEl = [...trackEl.querySelectorAll("daw-clip")].find(
4426
+ (c) => c.clipId === clipId
4427
+ );
4428
+ if (clipEl) {
4429
+ clipEl.remove();
4430
+ return;
4431
+ }
4432
+ }
4433
+ if (this._engineTracks.has(trackId)) {
4434
+ this._removeClipFromTrack(trackId, clipId);
4435
+ return;
4436
+ }
4437
+ console.warn(
4438
+ '[dawcore] removeClip: no track found for id "' + trackId + '" (clipId "' + clipId + '")'
4439
+ );
4440
+ }
4441
+ /**
4442
+ * Update a clip's position (start/duration/offset) or properties (gain/name).
4443
+ * For DOM-element clips, writes properties on the `<daw-clip>` element which
4444
+ * fires `daw-clip-update`; otherwise applies directly via `_applyClipUpdate`.
4445
+ *
4446
+ * Re-decoding (changing `src`) is not supported via this method — remove and
4447
+ * re-add the clip instead.
4448
+ *
4449
+ * Note: `fadeIn` / `fadeOut` / `fadeType` on the partial are written to the
4450
+ * `<daw-clip>` element (so they round-trip in the descriptor), but engine-side
4451
+ * fade application from `<daw-clip>` properties is not yet implemented — see
4452
+ * the broader fade-engine integration tracked separately.
4453
+ */
4454
+ updateClip(trackId, clipId, partial) {
4455
+ const trackEl = this._trackElements.get(trackId);
4456
+ if (trackEl) {
4457
+ const clipEl = [...trackEl.querySelectorAll("daw-clip")].find(
4458
+ (c) => c.clipId === clipId
4459
+ );
4460
+ if (clipEl) {
4461
+ if (partial.start !== void 0) clipEl.start = partial.start;
4462
+ if (partial.duration !== void 0) clipEl.duration = partial.duration;
4463
+ if (partial.offset !== void 0) clipEl.offset = partial.offset;
4464
+ if (partial.gain !== void 0) clipEl.gain = partial.gain;
4465
+ if (partial.name !== void 0) clipEl.setAttribute("name", partial.name);
4466
+ if (partial.fadeIn !== void 0) clipEl.fadeIn = partial.fadeIn;
4467
+ if (partial.fadeOut !== void 0) clipEl.fadeOut = partial.fadeOut;
4468
+ if (partial.fadeType !== void 0) clipEl.setAttribute("fade-type", partial.fadeType);
4469
+ return;
4470
+ }
4471
+ }
4472
+ const t = this._engineTracks.get(trackId);
4473
+ if (!t) {
4474
+ console.warn('[dawcore] updateClip: no track found for id "' + trackId + '"');
4475
+ return;
4476
+ }
4477
+ const idx = t.clips.findIndex((c) => c.id === clipId);
4478
+ if (idx === -1) {
4479
+ console.warn(
4480
+ '[dawcore] updateClip: clip "' + clipId + '" not found in track "' + trackId + '"'
4481
+ );
4482
+ return;
4483
+ }
4484
+ const oldClip = t.clips[idx];
4485
+ const sr = oldClip.sampleRate ?? this.effectiveSampleRate;
4486
+ const updatedClip = {
4487
+ ...oldClip,
4488
+ ...partial.start !== void 0 && { startSample: Math.round(partial.start * sr) },
4489
+ ...partial.duration !== void 0 && partial.duration > 0 && { durationSamples: Math.round(partial.duration * sr) },
4490
+ ...partial.offset !== void 0 && { offsetSamples: Math.round(partial.offset * sr) },
4491
+ ...partial.gain !== void 0 && { gain: partial.gain },
4492
+ ...partial.name !== void 0 && { name: partial.name }
4493
+ };
4494
+ const updatedClips = [...t.clips];
4495
+ updatedClips[idx] = updatedClip;
4496
+ const updatedTrack = { ...t, clips: updatedClips };
4497
+ this._engineTracks = new Map(this._engineTracks).set(trackId, updatedTrack);
4498
+ this._commitTrackChange(trackId, updatedTrack);
4499
+ }
4500
+ _buildClipElement(config) {
4501
+ const clipEl = document.createElement("daw-clip");
4502
+ if (config.src !== void 0) clipEl.setAttribute("src", config.src);
4503
+ if (config.peaksSrc !== void 0) clipEl.setAttribute("peaks-src", config.peaksSrc);
4504
+ if (config.start !== void 0) clipEl.start = config.start;
4505
+ if (config.duration !== void 0) clipEl.duration = config.duration;
4506
+ if (config.offset !== void 0) clipEl.offset = config.offset;
4507
+ if (config.gain !== void 0) clipEl.gain = config.gain;
4508
+ if (config.name !== void 0) clipEl.setAttribute("name", config.name);
4509
+ if (config.fadeIn !== void 0) clipEl.fadeIn = config.fadeIn;
4510
+ if (config.fadeOut !== void 0) clipEl.fadeOut = config.fadeOut;
4511
+ if (config.fadeType !== void 0) clipEl.setAttribute("fade-type", config.fadeType);
4512
+ return clipEl;
4513
+ }
3906
4514
  // --- Playback ---
3907
4515
  async play(startTime) {
3908
4516
  try {
@@ -4076,38 +4684,34 @@ var DawEditorElement = class extends LitElement9 {
4076
4684
  if (!playhead || !this._engine) return;
4077
4685
  const engine = this._engine;
4078
4686
  const ctx = this.audioContext;
4687
+ const audibleTime = () => {
4688
+ const outputLatency = "outputLatency" in ctx ? ctx.outputLatency : 0;
4689
+ const t = engine.getCurrentTime() - outputLatency - engine.lookAhead;
4690
+ return Number.isFinite(t) ? Math.max(0, t) : 0;
4691
+ };
4079
4692
  if (this.scaleMode === "beats") {
4080
4693
  const secondsToTicksFn = (s) => this._secondsToTicks(s);
4081
- playhead.startBeatsAnimationWithMap(
4082
- () => {
4083
- const latency = "outputLatency" in ctx ? ctx.outputLatency : 0;
4084
- return Math.max(0, engine.getCurrentTime() - latency);
4085
- },
4086
- secondsToTicksFn,
4087
- this.ticksPerPixel
4088
- );
4694
+ playhead.startBeatsAnimationWithMap(audibleTime, secondsToTicksFn, this.ticksPerPixel);
4089
4695
  } else {
4090
- playhead.startAnimation(
4091
- () => {
4092
- const latency = "outputLatency" in ctx ? ctx.outputLatency : 0;
4093
- return Math.max(0, engine.getCurrentTime() - latency);
4094
- },
4095
- this.effectiveSampleRate,
4096
- this.samplesPerPixel
4097
- );
4696
+ playhead.startAnimation(audibleTime, this.effectiveSampleRate, this.samplesPerPixel);
4098
4697
  }
4099
4698
  }
4100
4699
  _stopPlayhead() {
4101
4700
  const playhead = this._getPlayhead();
4102
4701
  if (!playhead) return;
4702
+ const ctx = this.audioContext;
4703
+ const outputLatency = "outputLatency" in ctx ? ctx.outputLatency : 0;
4704
+ const lookAhead = this._engine?.lookAhead ?? 0;
4705
+ const t = this._currentTime - outputLatency - lookAhead;
4706
+ const visualTime = Number.isFinite(t) ? Math.max(0, t) : 0;
4103
4707
  if (this.scaleMode === "beats") {
4104
4708
  playhead.stopBeatsAnimationWithMap(
4105
- this._currentTime,
4709
+ visualTime,
4106
4710
  (s) => this._secondsToTicks(s),
4107
4711
  this.ticksPerPixel
4108
4712
  );
4109
4713
  } else {
4110
- playhead.stopAnimation(this._currentTime, this.effectiveSampleRate, this.samplesPerPixel);
4714
+ playhead.stopAnimation(visualTime, this.effectiveSampleRate, this.samplesPerPixel);
4111
4715
  }
4112
4716
  }
4113
4717
  _getPlayhead() {
@@ -4155,7 +4759,7 @@ var DawEditorElement = class extends LitElement9 {
4155
4759
  };
4156
4760
  });
4157
4761
  return html8`
4158
- ${orderedTracks.length > 0 ? html8`<div class="controls-column">
4762
+ ${orderedTracks.length > 0 || this.indefinitePlayback ? html8`<div class="controls-column">
4159
4763
  ${this.timescale ? html8`<div style="height: 30px;"></div>` : ""}
4160
4764
  ${orderedTracks.map(
4161
4765
  (t) => html8`
@@ -4181,7 +4785,7 @@ var DawEditorElement = class extends LitElement9 {
4181
4785
  @dragleave=${this._onDragLeave}
4182
4786
  @drop=${this._onDrop}
4183
4787
  >
4184
- ${(orderedTracks.length > 0 || this.scaleMode === "beats") && this.timescale ? html8`<daw-ruler
4788
+ ${(orderedTracks.length > 0 || this.scaleMode === "beats" || this.indefinitePlayback) && this.timescale ? html8`<daw-ruler
4185
4789
  .samplesPerPixel=${spp}
4186
4790
  .sampleRate=${this.effectiveSampleRate}
4187
4791
  .duration=${this._duration}
@@ -4201,7 +4805,7 @@ var DawEditorElement = class extends LitElement9 {
4201
4805
  .length=${this._totalWidth}
4202
4806
  .height=${orderedTracks.length > 0 ? orderedTracks.reduce((sum, t) => sum + t.trackHeight + 1, 0) : this._emptyGridHeight}
4203
4807
  ></daw-grid>` : ""}
4204
- ${orderedTracks.length > 0 || this.scaleMode === "beats" ? html8`<daw-selection .startPx=${selStartPx} .endPx=${selEndPx}></daw-selection>
4808
+ ${orderedTracks.length > 0 || this.scaleMode === "beats" || this.indefinitePlayback ? html8`<daw-selection .startPx=${selStartPx} .endPx=${selEndPx}></daw-selection>
4205
4809
  <daw-playhead></daw-playhead>` : ""}
4206
4810
  ${orderedTracks.map((t) => {
4207
4811
  const channelHeight = this.waveHeight;
@@ -4398,6 +5002,9 @@ __decorateClass([
4398
5002
  __decorateClass([
4399
5003
  property7({ type: Boolean, attribute: "interactive-clips" })
4400
5004
  ], DawEditorElement.prototype, "interactiveClips", 2);
5005
+ __decorateClass([
5006
+ property7({ type: Boolean, attribute: "indefinite-playback" })
5007
+ ], DawEditorElement.prototype, "indefinitePlayback", 2);
4401
5008
  __decorateClass([
4402
5009
  property7({ type: String, attribute: "scale-mode" })
4403
5010
  ], DawEditorElement.prototype, "scaleMode", 2);
@@ -4537,12 +5144,14 @@ var DawRulerElement = class extends LitElement10 {
4537
5144
  ppqn: this.ppqn
4538
5145
  });
4539
5146
  this._tickData = null;
4540
- } else if (this.duration > 0) {
5147
+ } else if (this.duration > 0 || this.totalWidth > 0) {
5148
+ const widthDerivedDuration = this.totalWidth * this.samplesPerPixel / this.sampleRate;
5149
+ const effectiveDuration = Math.max(this.duration, widthDerivedDuration);
4541
5150
  this._musicalTickData = null;
4542
5151
  this._tickData = computeTemporalTicks(
4543
5152
  this.samplesPerPixel,
4544
5153
  this.sampleRate,
4545
- this.duration,
5154
+ effectiveDuration,
4546
5155
  this.rulerHeight
4547
5156
  );
4548
5157
  } else {
@@ -4994,6 +5603,7 @@ export {
4994
5603
  DawTransportElement,
4995
5604
  DawWaveformElement,
4996
5605
  RecordingController,
5606
+ isDomClip,
4997
5607
  splitAtPlayhead
4998
5608
  };
4999
5609
  //# sourceMappingURL=index.mjs.map