@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.js CHANGED
@@ -57,6 +57,7 @@ __export(index_exports, {
57
57
  DawTransportElement: () => DawTransportElement,
58
58
  DawWaveformElement: () => DawWaveformElement,
59
59
  RecordingController: () => RecordingController,
60
+ isDomClip: () => isDomClip,
60
61
  splitAtPlayhead: () => splitAtPlayhead
61
62
  });
62
63
  module.exports = __toCommonJS(index_exports);
@@ -79,11 +80,54 @@ var DawClipElement = class extends import_lit.LitElement {
79
80
  this.fadeOut = 0;
80
81
  this.fadeType = "linear";
81
82
  this.clipId = crypto.randomUUID();
83
+ // Removal is detected by the editor's MutationObserver — detached elements
84
+ // cannot bubble events to ancestors.
85
+ this._hasRendered = false;
82
86
  }
83
87
  // Light DOM — no visual rendering, just a data container
84
88
  createRenderRoot() {
85
89
  return this;
86
90
  }
91
+ connectedCallback() {
92
+ super.connectedCallback();
93
+ setTimeout(() => {
94
+ this.dispatchEvent(
95
+ new CustomEvent("daw-clip-connected", {
96
+ bubbles: true,
97
+ composed: true,
98
+ detail: { clipId: this.clipId, element: this }
99
+ })
100
+ );
101
+ }, 0);
102
+ }
103
+ updated(changed) {
104
+ if (!this._hasRendered) {
105
+ this._hasRendered = true;
106
+ return;
107
+ }
108
+ const clipProps = [
109
+ "src",
110
+ "peaksSrc",
111
+ "start",
112
+ "duration",
113
+ "offset",
114
+ "gain",
115
+ "name",
116
+ "fadeIn",
117
+ "fadeOut",
118
+ "fadeType"
119
+ ];
120
+ if (clipProps.some((p) => changed.has(p))) {
121
+ const trackEl = this.closest("daw-track");
122
+ this.dispatchEvent(
123
+ new CustomEvent("daw-clip-update", {
124
+ bubbles: true,
125
+ composed: true,
126
+ detail: { trackId: trackEl?.trackId ?? "", clipId: this.clipId }
127
+ })
128
+ );
129
+ }
130
+ }
87
131
  };
88
132
  __decorateClass([
89
133
  (0, import_decorators.property)()
@@ -891,6 +935,13 @@ DawStopButtonElement = __decorateClass([
891
935
  // src/elements/daw-editor.ts
892
936
  var import_lit13 = require("lit");
893
937
  var import_decorators11 = require("lit/decorators.js");
938
+
939
+ // src/types.ts
940
+ function isDomClip(desc) {
941
+ return desc.kind === "dom";
942
+ }
943
+
944
+ // src/elements/daw-editor.ts
894
945
  var import_core8 = require("@waveform-playlist/core");
895
946
 
896
947
  // src/workers/peaksWorker.ts
@@ -1893,6 +1944,7 @@ var ViewportController = class {
1893
1944
  constructor(host) {
1894
1945
  this._scrollContainer = null;
1895
1946
  this._lastScrollLeft = 0;
1947
+ this._resizeObserver = null;
1896
1948
  // Permissive defaults: render everything until scroll container is attached
1897
1949
  this.visibleStart = -Infinity;
1898
1950
  this.visibleEnd = Infinity;
@@ -1925,13 +1977,26 @@ var ViewportController = class {
1925
1977
  }
1926
1978
  hostDisconnected() {
1927
1979
  this._scrollContainer?.removeEventListener("scroll", this._onScroll);
1980
+ this._resizeObserver?.disconnect();
1981
+ this._resizeObserver = null;
1928
1982
  this._scrollContainer = null;
1929
1983
  }
1930
1984
  _attachScrollContainer(container) {
1931
1985
  this._scrollContainer?.removeEventListener("scroll", this._onScroll);
1986
+ this._resizeObserver?.disconnect();
1932
1987
  this._scrollContainer = container;
1933
1988
  container.addEventListener("scroll", this._onScroll, { passive: true });
1934
1989
  this._update(container.scrollLeft, container.clientWidth);
1990
+ if (typeof ResizeObserver !== "undefined") {
1991
+ this._resizeObserver = new ResizeObserver(() => {
1992
+ if (!this._scrollContainer) return;
1993
+ const next = this._scrollContainer.clientWidth;
1994
+ if (next === this.containerWidth) return;
1995
+ this._update(this._scrollContainer.scrollLeft, next);
1996
+ this._host.requestUpdate();
1997
+ });
1998
+ this._resizeObserver.observe(container);
1999
+ }
1935
2000
  this._host.requestUpdate();
1936
2001
  }
1937
2002
  _update(scrollLeft, containerWidth) {
@@ -2897,6 +2962,7 @@ async function loadFiles(host, files) {
2897
2962
  soloed: false,
2898
2963
  clips: [
2899
2964
  {
2965
+ kind: "drop",
2900
2966
  src: "",
2901
2967
  peaksSrc: "",
2902
2968
  start: 0,
@@ -2983,6 +3049,7 @@ function addRecordedClip(host, trackId, buf, startSample, durSamples, offsetSamp
2983
3049
  if (desc) {
2984
3050
  const sr = host.effectiveSampleRate;
2985
3051
  const clipDesc = {
3052
+ kind: "drop",
2986
3053
  src: "",
2987
3054
  peaksSrc: "",
2988
3055
  start: startSample / sr,
@@ -3219,6 +3286,7 @@ var DawEditorElement = class extends import_lit13.LitElement {
3219
3286
  this.clipHeaders = false;
3220
3287
  this.clipHeaderHeight = 20;
3221
3288
  this.interactiveClips = false;
3289
+ this.indefinitePlayback = false;
3222
3290
  this.scaleMode = "temporal";
3223
3291
  this._ticksPerPixel = 24;
3224
3292
  this._bpm = 120;
@@ -3335,6 +3403,51 @@ var DawEditorElement = class extends import_lit13.LitElement {
3335
3403
  this._onTrackRemoved(trackId);
3336
3404
  }
3337
3405
  };
3406
+ // --- Clip lifecycle ---
3407
+ this._onClipConnected = (e) => {
3408
+ const detail = e.detail;
3409
+ const clipEl = detail.element;
3410
+ if (!(clipEl instanceof HTMLElement)) return;
3411
+ const trackEl = clipEl.closest("daw-track");
3412
+ if (!trackEl) return;
3413
+ const trackId = trackEl.trackId;
3414
+ if (!this._engineTracks.has(trackId)) {
3415
+ const desc = this._tracks.get(trackId);
3416
+ if (desc && !desc.clips.some((c) => isDomClip(c) && c.clipId === clipEl.clipId)) {
3417
+ console.warn(
3418
+ '[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.'
3419
+ );
3420
+ }
3421
+ return;
3422
+ }
3423
+ const clipDesc = {
3424
+ kind: "dom",
3425
+ clipId: clipEl.clipId,
3426
+ src: clipEl.src,
3427
+ peaksSrc: clipEl.peaksSrc,
3428
+ start: clipEl.start,
3429
+ duration: clipEl.duration,
3430
+ offset: clipEl.offset,
3431
+ gain: clipEl.gain,
3432
+ name: clipEl.name,
3433
+ fadeIn: clipEl.fadeIn,
3434
+ fadeOut: clipEl.fadeOut,
3435
+ fadeType: clipEl.fadeType
3436
+ };
3437
+ this._loadAndAppendClip(trackId, clipDesc);
3438
+ };
3439
+ this._onClipUpdate = (e) => {
3440
+ const clipEl = e.target;
3441
+ if (!(clipEl instanceof HTMLElement) || clipEl.tagName !== "DAW-CLIP") return;
3442
+ const detail = e.detail;
3443
+ if (!detail.trackId) {
3444
+ console.warn(
3445
+ "[dawcore] daw-clip-update fired from a <daw-clip> not nested in a <daw-track> \u2014 ignored"
3446
+ );
3447
+ return;
3448
+ }
3449
+ this._applyClipUpdate(detail.trackId, detail.clipId, clipEl);
3450
+ };
3338
3451
  // --- File Drop ---
3339
3452
  this._onDragOver = (e) => {
3340
3453
  if (!this.fileDrop) return;
@@ -3467,9 +3580,7 @@ var DawEditorElement = class extends import_lit13.LitElement {
3467
3580
  this.mono,
3468
3581
  singleClipOffsets
3469
3582
  );
3470
- const peakData = result.get(clipId);
3471
- if (!peakData) return null;
3472
- return { data: peakData.data, length: peakData.length };
3583
+ return result.get(clipId) ?? null;
3473
3584
  }
3474
3585
  get effectiveSampleRate() {
3475
3586
  return this._resolvedSampleRate ?? this.sampleRate;
@@ -3523,7 +3634,13 @@ var DawEditorElement = class extends import_lit13.LitElement {
3523
3634
  const minTicks = 32 * num * this.ppqn;
3524
3635
  return Math.ceil(Math.max(contentTicks, minTicks) / this.ticksPerPixel);
3525
3636
  }
3526
- return Math.ceil(this._duration * this.effectiveSampleRate / this.samplesPerPixel);
3637
+ const naturalWidth = Math.ceil(
3638
+ this._duration * this.effectiveSampleRate / this.samplesPerPixel
3639
+ );
3640
+ if (this.indefinitePlayback) {
3641
+ return Math.max(naturalWidth, this._viewport.containerWidth);
3642
+ }
3643
+ return naturalWidth;
3527
3644
  }
3528
3645
  /** Grid height when no tracks exist — matches scroll area's rendered height. */
3529
3646
  get _emptyGridHeight() {
@@ -3565,19 +3682,29 @@ var DawEditorElement = class extends import_lit13.LitElement {
3565
3682
  this.addEventListener("daw-track-update", this._onTrackUpdate);
3566
3683
  this.addEventListener("daw-track-control", this._onTrackControl);
3567
3684
  this.addEventListener("daw-track-remove", this._onTrackRemoveRequest);
3685
+ this.addEventListener("daw-clip-connected", this._onClipConnected);
3686
+ this.addEventListener("daw-clip-update", this._onClipUpdate);
3568
3687
  this._childObserver = new MutationObserver((mutations) => {
3569
3688
  for (const mutation of mutations) {
3570
3689
  for (const node of mutation.removedNodes) {
3571
3690
  if (node instanceof HTMLElement) {
3572
3691
  if (node.tagName === "DAW-TRACK") {
3573
3692
  this._onTrackRemoved(node.trackId);
3693
+ } else if (node.tagName === "DAW-CLIP") {
3694
+ this._onClipRemovedFromDom(node);
3574
3695
  }
3575
- const nested = node.querySelectorAll?.("daw-track");
3576
- if (nested) {
3577
- for (const track of nested) {
3696
+ const nestedTracks = node.querySelectorAll?.("daw-track");
3697
+ if (nestedTracks) {
3698
+ for (const track of nestedTracks) {
3578
3699
  this._onTrackRemoved(track.trackId);
3579
3700
  }
3580
3701
  }
3702
+ const nestedClips = node.querySelectorAll?.("daw-clip");
3703
+ if (nestedClips) {
3704
+ for (const clip of nestedClips) {
3705
+ this._onClipRemovedFromDom(clip);
3706
+ }
3707
+ }
3581
3708
  }
3582
3709
  }
3583
3710
  }
@@ -3590,6 +3717,8 @@ var DawEditorElement = class extends import_lit13.LitElement {
3590
3717
  this.removeEventListener("daw-track-update", this._onTrackUpdate);
3591
3718
  this.removeEventListener("daw-track-control", this._onTrackControl);
3592
3719
  this.removeEventListener("daw-track-remove", this._onTrackRemoveRequest);
3720
+ this.removeEventListener("daw-clip-connected", this._onClipConnected);
3721
+ this.removeEventListener("daw-clip-update", this._onClipUpdate);
3593
3722
  this._childObserver?.disconnect();
3594
3723
  this._childObserver = null;
3595
3724
  this._trackElements.clear();
@@ -3655,11 +3784,257 @@ var DawEditorElement = class extends import_lit13.LitElement {
3655
3784
  this._stopPlayhead();
3656
3785
  }
3657
3786
  }
3787
+ _onClipRemovedFromDom(clipEl) {
3788
+ const clipId = clipEl.clipId;
3789
+ for (const [trackId, t] of this._engineTracks.entries()) {
3790
+ if (t.clips.some((c) => c.id === clipId)) {
3791
+ this._removeClipFromTrack(trackId, clipId);
3792
+ return;
3793
+ }
3794
+ }
3795
+ if (this._clipBuffers.has(clipId) || this._clipOffsets.has(clipId) || this._peaksData.has(clipId)) {
3796
+ console.warn(
3797
+ '[dawcore] _onClipRemovedFromDom: orphaned cache entries for clip "' + clipId + '" \u2014 purging (DOM/engine id mismatch?)'
3798
+ );
3799
+ this._purgeClipCaches(clipId);
3800
+ }
3801
+ }
3802
+ async _loadAndAppendClip(trackId, clipDesc) {
3803
+ if (!clipDesc.src) return;
3804
+ const clipId = clipDesc.clipId;
3805
+ let insertedClipId = null;
3806
+ try {
3807
+ const waveformDataPromise = clipDesc.peaksSrc ? this._resolvePeaks(clipDesc.peaksSrc) : Promise.resolve(null);
3808
+ const audioPromise = this._fetchAndDecode(clipDesc.src);
3809
+ const [waveformData, audioBuffer] = await Promise.all([waveformDataPromise, audioPromise]);
3810
+ this._resolvedSampleRate = audioBuffer.sampleRate;
3811
+ const clip = await this._finalizeAudioClip(clipDesc, audioBuffer, waveformData);
3812
+ insertedClipId = clip.id;
3813
+ const t = this._engineTracks.get(trackId);
3814
+ if (!t) {
3815
+ this._purgeClipCaches(clip.id);
3816
+ return;
3817
+ }
3818
+ const updatedTrack = { ...t, clips: [...t.clips, clip] };
3819
+ this._engineTracks = new Map(this._engineTracks).set(trackId, updatedTrack);
3820
+ const desc = this._tracks.get(trackId);
3821
+ if (desc) {
3822
+ this._tracks = new Map(this._tracks).set(trackId, {
3823
+ ...desc,
3824
+ clips: [...desc.clips, clipDesc]
3825
+ });
3826
+ }
3827
+ this._commitTrackChange(trackId, updatedTrack);
3828
+ this.dispatchEvent(
3829
+ new CustomEvent("daw-clip-ready", {
3830
+ bubbles: true,
3831
+ composed: true,
3832
+ detail: { trackId, clipId: clip.id }
3833
+ })
3834
+ );
3835
+ } catch (err) {
3836
+ console.warn("[dawcore] _loadAndAppendClip failed: " + String(err));
3837
+ if (insertedClipId) this._purgeClipCaches(insertedClipId);
3838
+ this.dispatchEvent(
3839
+ new CustomEvent("daw-clip-error", {
3840
+ bubbles: true,
3841
+ composed: true,
3842
+ detail: { trackId, clipId: insertedClipId ?? clipId, error: err }
3843
+ })
3844
+ );
3845
+ }
3846
+ }
3847
+ /**
3848
+ * Resolve pre-computed peaks for a clip: fetch the .dat/.json, validate the
3849
+ * sample rate matches the AudioContext, return the WaveformData or null.
3850
+ * Warns on fetch failure and on sample-rate mismatch — never silent.
3851
+ *
3852
+ * Shared between `_loadTrack` (peaks-first preview path) and
3853
+ * `_loadAndAppendClip` (incremental late-append).
3854
+ */
3855
+ async _resolvePeaks(peaksSrc) {
3856
+ try {
3857
+ const wd = await this._fetchPeaks(peaksSrc);
3858
+ const contextRate = this.audioContext.sampleRate;
3859
+ if (wd.sample_rate === contextRate) return wd;
3860
+ console.warn(
3861
+ "[dawcore] Pre-computed peaks at " + wd.sample_rate + " Hz do not match AudioContext at " + contextRate + " Hz \u2014 ignoring " + peaksSrc + ", generating from audio"
3862
+ );
3863
+ return null;
3864
+ } catch (err) {
3865
+ console.warn(
3866
+ "[dawcore] Failed to load peaks from " + peaksSrc + ": " + String(err) + " \u2014 falling back to AudioBuffer generation"
3867
+ );
3868
+ return null;
3869
+ }
3870
+ }
3871
+ /**
3872
+ * Construct an AudioClip from a decoded buffer (and optional WaveformData),
3873
+ * align its id with the source `<daw-clip>.clipId` when present, populate
3874
+ * `_clipBuffers` / `_clipOffsets`, generate peaks via the worker pipeline,
3875
+ * and populate `_peaksData`. Returns the finished AudioClip.
3876
+ *
3877
+ * Shared between `_loadTrack`'s standard path and `_loadAndAppendClip`.
3878
+ * Not used by `_loadTrack`'s peaks-first preview path because that path
3879
+ * uses sync `extractPeaks` and inserts a preview track BEFORE audio decode.
3880
+ */
3881
+ async _finalizeAudioClip(clipDesc, audioBuffer, waveformData) {
3882
+ let clip;
3883
+ if (waveformData) {
3884
+ const wdRate = waveformData.sample_rate;
3885
+ clip = (0, import_core8.createClip)({
3886
+ audioBuffer,
3887
+ waveformData,
3888
+ startSample: Math.round(clipDesc.start * wdRate),
3889
+ durationSamples: Math.round((clipDesc.duration || waveformData.duration) * wdRate),
3890
+ offsetSamples: Math.round(clipDesc.offset * wdRate),
3891
+ gain: clipDesc.gain,
3892
+ name: clipDesc.name,
3893
+ sampleRate: wdRate,
3894
+ sourceDurationSamples: Math.ceil(waveformData.duration * wdRate)
3895
+ });
3896
+ this._peakPipeline.cacheWaveformData(audioBuffer, waveformData);
3897
+ } else {
3898
+ clip = (0, import_core8.createClipFromSeconds)({
3899
+ audioBuffer,
3900
+ startTime: clipDesc.start,
3901
+ duration: clipDesc.duration || audioBuffer.duration,
3902
+ offset: clipDesc.offset,
3903
+ gain: clipDesc.gain,
3904
+ name: clipDesc.name,
3905
+ sampleRate: audioBuffer.sampleRate,
3906
+ sourceDuration: audioBuffer.duration
3907
+ });
3908
+ }
3909
+ if (isDomClip(clipDesc)) clip.id = clipDesc.clipId;
3910
+ this._clipBuffers = new Map(this._clipBuffers).set(clip.id, audioBuffer);
3911
+ this._clipOffsets.set(clip.id, {
3912
+ offsetSamples: clip.offsetSamples,
3913
+ durationSamples: clip.durationSamples
3914
+ });
3915
+ let peakData;
3916
+ try {
3917
+ peakData = await this._peakPipeline.generatePeaks(
3918
+ audioBuffer,
3919
+ this._renderSpp,
3920
+ this.mono,
3921
+ clip.offsetSamples,
3922
+ clip.durationSamples
3923
+ );
3924
+ } catch (err) {
3925
+ this._purgeClipCaches(clip.id);
3926
+ throw err;
3927
+ }
3928
+ this._peaksData = new Map(this._peaksData).set(clip.id, peakData);
3929
+ if (waveformData) {
3930
+ this._minSamplesPerPixel = Math.max(this._minSamplesPerPixel, waveformData.scale);
3931
+ }
3932
+ return clip;
3933
+ }
3934
+ /** Remove a single clip from all per-clip caches. Used by error rollbacks. */
3935
+ _purgeClipCaches(clipId) {
3936
+ const nextBuffers = new Map(this._clipBuffers);
3937
+ nextBuffers.delete(clipId);
3938
+ this._clipBuffers = nextBuffers;
3939
+ const nextPeaks = new Map(this._peaksData);
3940
+ nextPeaks.delete(clipId);
3941
+ this._peaksData = nextPeaks;
3942
+ this._clipOffsets.delete(clipId);
3943
+ }
3944
+ /**
3945
+ * Recompute duration and forward an updated track to the engine. Single
3946
+ * source of truth for the incremental-vs-full-rebuild policy used by every
3947
+ * clip-level mutation (addClip, updateClip, removeClip, _applyClipUpdate).
3948
+ * Use the engine's incremental updateTrack when available; otherwise fall
3949
+ * back to full setTracks (legacy adapters).
3950
+ */
3951
+ _commitTrackChange(trackId, updatedTrack) {
3952
+ this._recomputeDuration();
3953
+ if (this._engine?.updateTrack) this._engine.updateTrack(trackId, updatedTrack);
3954
+ else if (this._engine) this._engine.setTracks([...this._engineTracks.values()]);
3955
+ }
3956
+ _applyClipUpdate(trackId, clipId, clipEl) {
3957
+ const t = this._engineTracks.get(trackId);
3958
+ if (!t) {
3959
+ console.warn('[dawcore] _applyClipUpdate: no engine track for id "' + trackId + '"');
3960
+ return;
3961
+ }
3962
+ const idx = t.clips.findIndex((c) => c.id === clipId);
3963
+ if (idx === -1) {
3964
+ console.warn(
3965
+ '[dawcore] _applyClipUpdate: clip "' + clipId + '" not found in track "' + trackId + '" (DOM/engine clip-id misalignment?)'
3966
+ );
3967
+ return;
3968
+ }
3969
+ const oldClip = t.clips[idx];
3970
+ const sr = oldClip.sampleRate ?? this.effectiveSampleRate;
3971
+ const newStartSample = Math.round(clipEl.start * sr);
3972
+ const newDurationSamples = clipEl.duration > 0 ? Math.round(clipEl.duration * sr) : oldClip.durationSamples;
3973
+ const newOffsetSamples = Math.round(clipEl.offset * sr);
3974
+ const updatedClip = {
3975
+ ...oldClip,
3976
+ startSample: newStartSample,
3977
+ durationSamples: newDurationSamples,
3978
+ offsetSamples: newOffsetSamples,
3979
+ gain: clipEl.gain,
3980
+ name: clipEl.name || oldClip.name
3981
+ };
3982
+ const updatedClips = [...t.clips];
3983
+ updatedClips[idx] = updatedClip;
3984
+ const updatedTrack = { ...t, clips: updatedClips };
3985
+ this._engineTracks = new Map(this._engineTracks).set(trackId, updatedTrack);
3986
+ const boundsChanged = oldClip.offsetSamples !== newOffsetSamples || oldClip.durationSamples !== newDurationSamples;
3987
+ if (boundsChanged) {
3988
+ this._clipOffsets.set(clipId, {
3989
+ offsetSamples: newOffsetSamples,
3990
+ durationSamples: newDurationSamples
3991
+ });
3992
+ const peaks = this.reextractClipPeaks(clipId, newOffsetSamples, newDurationSamples);
3993
+ if (peaks) {
3994
+ this._peaksData = new Map(this._peaksData).set(clipId, peaks);
3995
+ }
3996
+ }
3997
+ this._commitTrackChange(trackId, updatedTrack);
3998
+ }
3999
+ _removeClipFromTrack(trackId, clipId) {
4000
+ const t = this._engineTracks.get(trackId);
4001
+ if (!t) {
4002
+ console.warn('[dawcore] _removeClipFromTrack: no engine track for id "' + trackId + '"');
4003
+ return;
4004
+ }
4005
+ const updatedClips = t.clips.filter((c) => c.id !== clipId);
4006
+ if (updatedClips.length === t.clips.length) {
4007
+ console.warn(
4008
+ '[dawcore] _removeClipFromTrack: clip "' + clipId + '" not found in track "' + trackId + '"'
4009
+ );
4010
+ return;
4011
+ }
4012
+ const updatedTrack = { ...t, clips: updatedClips };
4013
+ this._engineTracks = new Map(this._engineTracks).set(trackId, updatedTrack);
4014
+ const nextBuffers = new Map(this._clipBuffers);
4015
+ nextBuffers.delete(clipId);
4016
+ this._clipBuffers = nextBuffers;
4017
+ this._clipOffsets.delete(clipId);
4018
+ const nextPeaks = new Map(this._peaksData);
4019
+ nextPeaks.delete(clipId);
4020
+ this._peaksData = nextPeaks;
4021
+ const desc = this._tracks.get(trackId);
4022
+ if (desc) {
4023
+ this._tracks = new Map(this._tracks).set(trackId, {
4024
+ ...desc,
4025
+ // Only DOM-sourced clips have an id to match; drop-sourced clips are
4026
+ // filtered through unchanged (their identity is the descriptor itself).
4027
+ clips: desc.clips.filter((c) => !(isDomClip(c) && c.clipId === clipId))
4028
+ });
4029
+ }
4030
+ this._commitTrackChange(trackId, updatedTrack);
4031
+ }
3658
4032
  _readTrackDescriptor(trackEl) {
3659
4033
  const clipEls = trackEl.querySelectorAll("daw-clip");
3660
4034
  const clips = [];
3661
4035
  if (clipEls.length === 0 && trackEl.src) {
3662
4036
  clips.push({
4037
+ kind: "drop",
3663
4038
  src: trackEl.src,
3664
4039
  peaksSrc: "",
3665
4040
  start: 0,
@@ -3674,6 +4049,8 @@ var DawEditorElement = class extends import_lit13.LitElement {
3674
4049
  } else {
3675
4050
  for (const clipEl of clipEls) {
3676
4051
  clips.push({
4052
+ kind: "dom",
4053
+ clipId: clipEl.clipId,
3677
4054
  src: clipEl.src,
3678
4055
  peaksSrc: clipEl.peaksSrc,
3679
4056
  start: clipEl.start,
@@ -3703,111 +4080,92 @@ var DawEditorElement = class extends import_lit13.LitElement {
3703
4080
  const clips = [];
3704
4081
  for (const clipDesc of descriptor.clips) {
3705
4082
  if (!clipDesc.src) continue;
3706
- const waveformDataPromise = clipDesc.peaksSrc ? this._fetchPeaks(clipDesc.peaksSrc) : null;
3707
- const audioPromise = this._fetchAndDecode(clipDesc.src);
3708
- let waveformData = null;
3709
- if (waveformDataPromise) {
3710
- try {
3711
- const wd = await waveformDataPromise;
3712
- const contextRate = this.audioContext.sampleRate;
3713
- if (wd.sample_rate === contextRate) {
3714
- waveformData = wd;
3715
- } else {
3716
- console.warn(
3717
- "[dawcore] Pre-computed peaks at " + wd.sample_rate + " Hz do not match AudioContext at " + contextRate + " Hz \u2014 ignoring " + clipDesc.peaksSrc + ", generating from audio"
3718
- );
3719
- }
3720
- } catch (err) {
3721
- console.warn(
3722
- "[dawcore] Failed to load peaks from " + clipDesc.peaksSrc + ": " + String(err) + " \u2014 falling back to AudioBuffer generation"
4083
+ try {
4084
+ const waveformDataPromise = clipDesc.peaksSrc ? this._resolvePeaks(clipDesc.peaksSrc) : Promise.resolve(null);
4085
+ const audioPromise = this._fetchAndDecode(clipDesc.src);
4086
+ const waveformData = await waveformDataPromise;
4087
+ if (waveformData) {
4088
+ const wdRate = waveformData.sample_rate;
4089
+ const clip2 = (0, import_core8.createClip)({
4090
+ waveformData,
4091
+ startSample: Math.round(clipDesc.start * wdRate),
4092
+ durationSamples: Math.round((clipDesc.duration || waveformData.duration) * wdRate),
4093
+ offsetSamples: Math.round(clipDesc.offset * wdRate),
4094
+ gain: clipDesc.gain,
4095
+ name: clipDesc.name,
4096
+ sampleRate: wdRate,
4097
+ sourceDurationSamples: Math.ceil(waveformData.duration * wdRate)
4098
+ });
4099
+ if (isDomClip(clipDesc)) clip2.id = clipDesc.clipId;
4100
+ const effectiveScale = Math.max(this._renderSpp, waveformData.scale);
4101
+ const peakData = extractPeaks(
4102
+ waveformData,
4103
+ effectiveScale,
4104
+ this.mono,
4105
+ clip2.offsetSamples,
4106
+ clip2.durationSamples
3723
4107
  );
4108
+ this._clipOffsets.set(clip2.id, {
4109
+ offsetSamples: clip2.offsetSamples,
4110
+ durationSamples: clip2.durationSamples
4111
+ });
4112
+ this._peaksData = new Map(this._peaksData).set(clip2.id, peakData);
4113
+ this._minSamplesPerPixel = Math.max(this._minSamplesPerPixel, waveformData.scale);
4114
+ const previewTrack = (0, import_core8.createTrack)({
4115
+ name: descriptor.name,
4116
+ clips: [clip2],
4117
+ volume: descriptor.volume,
4118
+ pan: descriptor.pan,
4119
+ muted: descriptor.muted,
4120
+ soloed: descriptor.soloed
4121
+ });
4122
+ previewTrack.id = trackId;
4123
+ this._engineTracks = new Map(this._engineTracks).set(trackId, previewTrack);
4124
+ this._recomputeDuration();
4125
+ let audioBuffer2;
4126
+ try {
4127
+ audioBuffer2 = await audioPromise;
4128
+ } catch (audioErr) {
4129
+ const nextPeaks = new Map(this._peaksData);
4130
+ nextPeaks.delete(clip2.id);
4131
+ this._peaksData = nextPeaks;
4132
+ this._clipOffsets.delete(clip2.id);
4133
+ const nextEngine = new Map(this._engineTracks);
4134
+ nextEngine.delete(trackId);
4135
+ this._engineTracks = nextEngine;
4136
+ this._minSamplesPerPixel = this._peakPipeline.getMaxCachedScale(this._clipBuffers);
4137
+ this._recomputeDuration();
4138
+ throw audioErr;
4139
+ }
4140
+ this._resolvedSampleRate = audioBuffer2.sampleRate;
4141
+ const updatedClip = { ...clip2, audioBuffer: audioBuffer2 };
4142
+ this._clipBuffers = new Map(this._clipBuffers).set(clip2.id, audioBuffer2);
4143
+ this._peakPipeline.cacheWaveformData(audioBuffer2, waveformData);
4144
+ clips.push(updatedClip);
4145
+ continue;
3724
4146
  }
3725
- }
3726
- if (waveformData) {
3727
- const wdRate = waveformData.sample_rate;
3728
- const clip2 = (0, import_core8.createClip)({
3729
- waveformData,
3730
- startSample: Math.round(clipDesc.start * wdRate),
3731
- durationSamples: Math.round((clipDesc.duration || waveformData.duration) * wdRate),
3732
- offsetSamples: Math.round(clipDesc.offset * wdRate),
3733
- gain: clipDesc.gain,
3734
- name: clipDesc.name,
3735
- sampleRate: wdRate,
3736
- sourceDurationSamples: Math.ceil(waveformData.duration * wdRate)
3737
- });
3738
- const effectiveScale = Math.max(this._renderSpp, waveformData.scale);
3739
- const peakData2 = extractPeaks(
3740
- waveformData,
3741
- effectiveScale,
3742
- this.mono,
3743
- clip2.offsetSamples,
3744
- clip2.durationSamples
4147
+ const audioBuffer = await audioPromise;
4148
+ this._resolvedSampleRate = audioBuffer.sampleRate;
4149
+ const clip = await this._finalizeAudioClip(clipDesc, audioBuffer, null);
4150
+ clips.push(clip);
4151
+ } catch (clipErr) {
4152
+ console.warn(
4153
+ '[dawcore] _loadTrack: clip "' + clipDesc.src + '" failed: ' + String(clipErr)
3745
4154
  );
3746
- this._clipOffsets.set(clip2.id, {
3747
- offsetSamples: clip2.offsetSamples,
3748
- durationSamples: clip2.durationSamples
3749
- });
3750
- this._peaksData = new Map(this._peaksData).set(clip2.id, peakData2);
3751
- this._minSamplesPerPixel = Math.max(this._minSamplesPerPixel, waveformData.scale);
3752
- const previewTrack = (0, import_core8.createTrack)({
3753
- name: descriptor.name,
3754
- clips: [clip2],
3755
- volume: descriptor.volume,
3756
- pan: descriptor.pan,
3757
- muted: descriptor.muted,
3758
- soloed: descriptor.soloed
3759
- });
3760
- previewTrack.id = trackId;
3761
- this._engineTracks = new Map(this._engineTracks).set(trackId, previewTrack);
3762
- this._recomputeDuration();
3763
- let audioBuffer2;
3764
- try {
3765
- audioBuffer2 = await audioPromise;
3766
- } catch (audioErr) {
3767
- const nextPeaks = new Map(this._peaksData);
3768
- nextPeaks.delete(clip2.id);
3769
- this._peaksData = nextPeaks;
3770
- this._clipOffsets.delete(clip2.id);
3771
- const nextEngine = new Map(this._engineTracks);
3772
- nextEngine.delete(trackId);
3773
- this._engineTracks = nextEngine;
3774
- this._minSamplesPerPixel = this._peakPipeline.getMaxCachedScale(this._clipBuffers);
3775
- this._recomputeDuration();
3776
- throw audioErr;
4155
+ if (this.isConnected) {
4156
+ this.dispatchEvent(
4157
+ new CustomEvent("daw-clip-error", {
4158
+ bubbles: true,
4159
+ composed: true,
4160
+ detail: {
4161
+ trackId,
4162
+ clipId: isDomClip(clipDesc) ? clipDesc.clipId : "",
4163
+ error: clipErr
4164
+ }
4165
+ })
4166
+ );
3777
4167
  }
3778
- this._resolvedSampleRate = audioBuffer2.sampleRate;
3779
- const updatedClip = { ...clip2, audioBuffer: audioBuffer2 };
3780
- this._clipBuffers = new Map(this._clipBuffers).set(clip2.id, audioBuffer2);
3781
- this._peakPipeline.cacheWaveformData(audioBuffer2, waveformData);
3782
- clips.push(updatedClip);
3783
- continue;
3784
4168
  }
3785
- const audioBuffer = await audioPromise;
3786
- this._resolvedSampleRate = audioBuffer.sampleRate;
3787
- const clip = (0, import_core8.createClipFromSeconds)({
3788
- audioBuffer,
3789
- startTime: clipDesc.start,
3790
- duration: clipDesc.duration || audioBuffer.duration,
3791
- offset: clipDesc.offset,
3792
- gain: clipDesc.gain,
3793
- name: clipDesc.name,
3794
- sampleRate: audioBuffer.sampleRate,
3795
- sourceDuration: audioBuffer.duration
3796
- });
3797
- this._clipBuffers = new Map(this._clipBuffers).set(clip.id, audioBuffer);
3798
- this._clipOffsets.set(clip.id, {
3799
- offsetSamples: clip.offsetSamples,
3800
- durationSamples: clip.durationSamples
3801
- });
3802
- const peakData = await this._peakPipeline.generatePeaks(
3803
- audioBuffer,
3804
- this._renderSpp,
3805
- this.mono,
3806
- clip.offsetSamples,
3807
- clip.durationSamples
3808
- );
3809
- this._peaksData = new Map(this._peaksData).set(clip.id, peakData);
3810
- clips.push(clip);
3811
4169
  }
3812
4170
  const track = (0, import_core8.createTrack)({
3813
4171
  name: descriptor.name,
@@ -3817,6 +4175,12 @@ var DawEditorElement = class extends import_lit13.LitElement {
3817
4175
  muted: descriptor.muted,
3818
4176
  soloed: descriptor.soloed
3819
4177
  });
4178
+ const requestedClips = descriptor.clips.filter((c) => c.src).length;
4179
+ if (requestedClips > 0 && clips.length === 0) {
4180
+ throw new Error(
4181
+ "all " + requestedClips + " clip(s) failed to load \u2014 see prior daw-clip-error events"
4182
+ );
4183
+ }
3820
4184
  track.id = trackId;
3821
4185
  this._engineTracks = new Map(this._engineTracks).set(trackId, track);
3822
4186
  this._recomputeDuration();
@@ -3950,6 +4314,251 @@ var DawEditorElement = class extends import_lit13.LitElement {
3950
4314
  async loadFiles(files) {
3951
4315
  return loadFiles(this, files);
3952
4316
  }
4317
+ // --- Programmatic Track API ---
4318
+ /**
4319
+ * Build the engine if it hasn't been built yet. Lets consumers obtain a
4320
+ * non-null `editor.engine` before any track has been loaded — useful for
4321
+ * wiring analyzers, effects, or master taps before content arrives.
4322
+ */
4323
+ async ready() {
4324
+ return this._ensureEngine();
4325
+ }
4326
+ /**
4327
+ * Wait for either `readyEvent` or `errorEvent` to fire on this editor for
4328
+ * the entity matching `matchesId`. Listeners are wired synchronously, then
4329
+ * `setup` is called (typical: appendChild). Resolves with `resolveValue`
4330
+ * on ready; rejects with a normalized Error on error. Used by addTrack and
4331
+ * addClip to share their Promise-with-listener-cleanup machinery.
4332
+ */
4333
+ _awaitId(readyEvent, errorEvent, matchesId, resolveValue, setup) {
4334
+ return new Promise((resolve, reject) => {
4335
+ const onReady = (e) => {
4336
+ if (!matchesId(e.detail)) return;
4337
+ cleanup();
4338
+ resolve(resolveValue);
4339
+ };
4340
+ const onError = (e) => {
4341
+ const detail = e.detail;
4342
+ if (!matchesId(e.detail)) return;
4343
+ cleanup();
4344
+ const err = detail.error;
4345
+ reject(err instanceof Error ? err : new Error(String(err)));
4346
+ };
4347
+ const cleanup = () => {
4348
+ this.removeEventListener(readyEvent, onReady);
4349
+ this.removeEventListener(errorEvent, onError);
4350
+ };
4351
+ this.addEventListener(readyEvent, onReady);
4352
+ this.addEventListener(errorEvent, onError);
4353
+ setup();
4354
+ });
4355
+ }
4356
+ /**
4357
+ * Append a `<daw-track>` element built from `config` and resolve once the
4358
+ * track finishes loading (or reject on `daw-track-error`). Goes through
4359
+ * the same `_loadTrack` pipeline as declarative tracks, so descriptors,
4360
+ * peaks, and clip buffers are populated correctly.
4361
+ */
4362
+ addTrack(config = {}) {
4363
+ const trackEl = document.createElement("daw-track");
4364
+ if (config.name !== void 0) trackEl.setAttribute("name", config.name);
4365
+ if (config.volume !== void 0) trackEl.volume = config.volume;
4366
+ if (config.pan !== void 0) trackEl.pan = config.pan;
4367
+ if (config.muted) trackEl.setAttribute("muted", "");
4368
+ if (config.soloed) trackEl.setAttribute("soloed", "");
4369
+ for (const clipConfig of config.clips ?? []) {
4370
+ trackEl.appendChild(this._buildClipElement(clipConfig));
4371
+ }
4372
+ return this._awaitId(
4373
+ "daw-track-ready",
4374
+ "daw-track-error",
4375
+ (d) => d.trackId === trackEl.trackId,
4376
+ trackEl,
4377
+ () => this.appendChild(trackEl)
4378
+ );
4379
+ }
4380
+ /**
4381
+ * Remove a track by id. Equivalent to `trackElement.remove()` —
4382
+ * the editor's MutationObserver handles engine and cache cleanup.
4383
+ * No-op if no matching track exists.
4384
+ */
4385
+ removeTrack(trackId) {
4386
+ const trackEl = this._trackElements.get(trackId);
4387
+ if (trackEl) {
4388
+ trackEl.remove();
4389
+ } else if (this._engineTracks.has(trackId)) {
4390
+ this._onTrackRemoved(trackId);
4391
+ } else {
4392
+ console.warn('[dawcore] removeTrack: no track found for id "' + trackId + '"');
4393
+ }
4394
+ }
4395
+ /**
4396
+ * Update reflected attributes on a track. For DOM-element tracks the changes
4397
+ * are written to the `<daw-track>` element (which fires `daw-track-update`);
4398
+ * for tracks without a DOM element (file drops) the descriptor and engine
4399
+ * state are updated in place.
4400
+ */
4401
+ updateTrack(trackId, partial) {
4402
+ const trackEl = this._trackElements.get(trackId);
4403
+ if (trackEl) {
4404
+ if (partial.name !== void 0) trackEl.setAttribute("name", partial.name);
4405
+ if (partial.volume !== void 0) trackEl.volume = partial.volume;
4406
+ if (partial.pan !== void 0) trackEl.pan = partial.pan;
4407
+ if (partial.muted !== void 0) {
4408
+ if (partial.muted) trackEl.setAttribute("muted", "");
4409
+ else trackEl.removeAttribute("muted");
4410
+ }
4411
+ if (partial.soloed !== void 0) {
4412
+ if (partial.soloed) trackEl.setAttribute("soloed", "");
4413
+ else trackEl.removeAttribute("soloed");
4414
+ }
4415
+ return;
4416
+ }
4417
+ const oldDesc = this._tracks.get(trackId);
4418
+ if (!oldDesc) return;
4419
+ const newDesc = {
4420
+ ...oldDesc,
4421
+ ...partial.name !== void 0 && { name: partial.name },
4422
+ ...partial.volume !== void 0 && { volume: partial.volume },
4423
+ ...partial.pan !== void 0 && { pan: partial.pan },
4424
+ ...partial.muted !== void 0 && { muted: partial.muted },
4425
+ ...partial.soloed !== void 0 && { soloed: partial.soloed }
4426
+ };
4427
+ this._tracks = new Map(this._tracks).set(trackId, newDesc);
4428
+ if (this._engine) {
4429
+ if (partial.volume !== void 0) this._engine.setTrackVolume(trackId, partial.volume);
4430
+ if (partial.pan !== void 0) this._engine.setTrackPan(trackId, partial.pan);
4431
+ if (partial.muted !== void 0) this._engine.setTrackMute(trackId, partial.muted);
4432
+ if (partial.soloed !== void 0) this._engine.setTrackSolo(trackId, partial.soloed);
4433
+ }
4434
+ }
4435
+ /**
4436
+ * Append a clip to an existing track. Builds a `<daw-clip>` from `config`
4437
+ * and appends it to the track's DOM element when one exists; resolves with
4438
+ * the new clip's id once the audio decode + peak generation finish.
4439
+ */
4440
+ addClip(trackId, config) {
4441
+ if (!config.src) {
4442
+ return Promise.reject(
4443
+ new Error(
4444
+ "addClip: config.src is required \u2014 pass a URL to load. Empty/recording clips are not yet supported via addClip."
4445
+ )
4446
+ );
4447
+ }
4448
+ const trackEl = this._trackElements.get(trackId);
4449
+ if (!trackEl) {
4450
+ return Promise.reject(
4451
+ new Error(
4452
+ 'addClip: no <daw-track> element for trackId "' + trackId + '" \u2014 addClip currently requires a DOM-backed track. Use editor.addTrack(config) first.'
4453
+ )
4454
+ );
4455
+ }
4456
+ const clipEl = this._buildClipElement(config);
4457
+ return this._awaitId(
4458
+ "daw-clip-ready",
4459
+ "daw-clip-error",
4460
+ (d) => d.clipId === clipEl.clipId,
4461
+ clipEl.clipId,
4462
+ () => trackEl.appendChild(clipEl)
4463
+ );
4464
+ }
4465
+ /**
4466
+ * Remove a clip by id. Removes the matching `<daw-clip>` DOM element when
4467
+ * present (MutationObserver handles cleanup); otherwise updates engine
4468
+ * state directly. No-op if no matching clip exists.
4469
+ */
4470
+ removeClip(trackId, clipId) {
4471
+ const trackEl = this._trackElements.get(trackId);
4472
+ if (trackEl) {
4473
+ const clipEl = [...trackEl.querySelectorAll("daw-clip")].find(
4474
+ (c) => c.clipId === clipId
4475
+ );
4476
+ if (clipEl) {
4477
+ clipEl.remove();
4478
+ return;
4479
+ }
4480
+ }
4481
+ if (this._engineTracks.has(trackId)) {
4482
+ this._removeClipFromTrack(trackId, clipId);
4483
+ return;
4484
+ }
4485
+ console.warn(
4486
+ '[dawcore] removeClip: no track found for id "' + trackId + '" (clipId "' + clipId + '")'
4487
+ );
4488
+ }
4489
+ /**
4490
+ * Update a clip's position (start/duration/offset) or properties (gain/name).
4491
+ * For DOM-element clips, writes properties on the `<daw-clip>` element which
4492
+ * fires `daw-clip-update`; otherwise applies directly via `_applyClipUpdate`.
4493
+ *
4494
+ * Re-decoding (changing `src`) is not supported via this method — remove and
4495
+ * re-add the clip instead.
4496
+ *
4497
+ * Note: `fadeIn` / `fadeOut` / `fadeType` on the partial are written to the
4498
+ * `<daw-clip>` element (so they round-trip in the descriptor), but engine-side
4499
+ * fade application from `<daw-clip>` properties is not yet implemented — see
4500
+ * the broader fade-engine integration tracked separately.
4501
+ */
4502
+ updateClip(trackId, clipId, partial) {
4503
+ const trackEl = this._trackElements.get(trackId);
4504
+ if (trackEl) {
4505
+ const clipEl = [...trackEl.querySelectorAll("daw-clip")].find(
4506
+ (c) => c.clipId === clipId
4507
+ );
4508
+ if (clipEl) {
4509
+ if (partial.start !== void 0) clipEl.start = partial.start;
4510
+ if (partial.duration !== void 0) clipEl.duration = partial.duration;
4511
+ if (partial.offset !== void 0) clipEl.offset = partial.offset;
4512
+ if (partial.gain !== void 0) clipEl.gain = partial.gain;
4513
+ if (partial.name !== void 0) clipEl.setAttribute("name", partial.name);
4514
+ if (partial.fadeIn !== void 0) clipEl.fadeIn = partial.fadeIn;
4515
+ if (partial.fadeOut !== void 0) clipEl.fadeOut = partial.fadeOut;
4516
+ if (partial.fadeType !== void 0) clipEl.setAttribute("fade-type", partial.fadeType);
4517
+ return;
4518
+ }
4519
+ }
4520
+ const t = this._engineTracks.get(trackId);
4521
+ if (!t) {
4522
+ console.warn('[dawcore] updateClip: no track found for id "' + trackId + '"');
4523
+ return;
4524
+ }
4525
+ const idx = t.clips.findIndex((c) => c.id === clipId);
4526
+ if (idx === -1) {
4527
+ console.warn(
4528
+ '[dawcore] updateClip: clip "' + clipId + '" not found in track "' + trackId + '"'
4529
+ );
4530
+ return;
4531
+ }
4532
+ const oldClip = t.clips[idx];
4533
+ const sr = oldClip.sampleRate ?? this.effectiveSampleRate;
4534
+ const updatedClip = {
4535
+ ...oldClip,
4536
+ ...partial.start !== void 0 && { startSample: Math.round(partial.start * sr) },
4537
+ ...partial.duration !== void 0 && partial.duration > 0 && { durationSamples: Math.round(partial.duration * sr) },
4538
+ ...partial.offset !== void 0 && { offsetSamples: Math.round(partial.offset * sr) },
4539
+ ...partial.gain !== void 0 && { gain: partial.gain },
4540
+ ...partial.name !== void 0 && { name: partial.name }
4541
+ };
4542
+ const updatedClips = [...t.clips];
4543
+ updatedClips[idx] = updatedClip;
4544
+ const updatedTrack = { ...t, clips: updatedClips };
4545
+ this._engineTracks = new Map(this._engineTracks).set(trackId, updatedTrack);
4546
+ this._commitTrackChange(trackId, updatedTrack);
4547
+ }
4548
+ _buildClipElement(config) {
4549
+ const clipEl = document.createElement("daw-clip");
4550
+ if (config.src !== void 0) clipEl.setAttribute("src", config.src);
4551
+ if (config.peaksSrc !== void 0) clipEl.setAttribute("peaks-src", config.peaksSrc);
4552
+ if (config.start !== void 0) clipEl.start = config.start;
4553
+ if (config.duration !== void 0) clipEl.duration = config.duration;
4554
+ if (config.offset !== void 0) clipEl.offset = config.offset;
4555
+ if (config.gain !== void 0) clipEl.gain = config.gain;
4556
+ if (config.name !== void 0) clipEl.setAttribute("name", config.name);
4557
+ if (config.fadeIn !== void 0) clipEl.fadeIn = config.fadeIn;
4558
+ if (config.fadeOut !== void 0) clipEl.fadeOut = config.fadeOut;
4559
+ if (config.fadeType !== void 0) clipEl.setAttribute("fade-type", config.fadeType);
4560
+ return clipEl;
4561
+ }
3953
4562
  // --- Playback ---
3954
4563
  async play(startTime) {
3955
4564
  try {
@@ -4123,38 +4732,34 @@ var DawEditorElement = class extends import_lit13.LitElement {
4123
4732
  if (!playhead || !this._engine) return;
4124
4733
  const engine = this._engine;
4125
4734
  const ctx = this.audioContext;
4735
+ const audibleTime = () => {
4736
+ const outputLatency = "outputLatency" in ctx ? ctx.outputLatency : 0;
4737
+ const t = engine.getCurrentTime() - outputLatency - engine.lookAhead;
4738
+ return Number.isFinite(t) ? Math.max(0, t) : 0;
4739
+ };
4126
4740
  if (this.scaleMode === "beats") {
4127
4741
  const secondsToTicksFn = (s) => this._secondsToTicks(s);
4128
- playhead.startBeatsAnimationWithMap(
4129
- () => {
4130
- const latency = "outputLatency" in ctx ? ctx.outputLatency : 0;
4131
- return Math.max(0, engine.getCurrentTime() - latency);
4132
- },
4133
- secondsToTicksFn,
4134
- this.ticksPerPixel
4135
- );
4742
+ playhead.startBeatsAnimationWithMap(audibleTime, secondsToTicksFn, this.ticksPerPixel);
4136
4743
  } else {
4137
- playhead.startAnimation(
4138
- () => {
4139
- const latency = "outputLatency" in ctx ? ctx.outputLatency : 0;
4140
- return Math.max(0, engine.getCurrentTime() - latency);
4141
- },
4142
- this.effectiveSampleRate,
4143
- this.samplesPerPixel
4144
- );
4744
+ playhead.startAnimation(audibleTime, this.effectiveSampleRate, this.samplesPerPixel);
4145
4745
  }
4146
4746
  }
4147
4747
  _stopPlayhead() {
4148
4748
  const playhead = this._getPlayhead();
4149
4749
  if (!playhead) return;
4750
+ const ctx = this.audioContext;
4751
+ const outputLatency = "outputLatency" in ctx ? ctx.outputLatency : 0;
4752
+ const lookAhead = this._engine?.lookAhead ?? 0;
4753
+ const t = this._currentTime - outputLatency - lookAhead;
4754
+ const visualTime = Number.isFinite(t) ? Math.max(0, t) : 0;
4150
4755
  if (this.scaleMode === "beats") {
4151
4756
  playhead.stopBeatsAnimationWithMap(
4152
- this._currentTime,
4757
+ visualTime,
4153
4758
  (s) => this._secondsToTicks(s),
4154
4759
  this.ticksPerPixel
4155
4760
  );
4156
4761
  } else {
4157
- playhead.stopAnimation(this._currentTime, this.effectiveSampleRate, this.samplesPerPixel);
4762
+ playhead.stopAnimation(visualTime, this.effectiveSampleRate, this.samplesPerPixel);
4158
4763
  }
4159
4764
  }
4160
4765
  _getPlayhead() {
@@ -4202,7 +4807,7 @@ var DawEditorElement = class extends import_lit13.LitElement {
4202
4807
  };
4203
4808
  });
4204
4809
  return import_lit13.html`
4205
- ${orderedTracks.length > 0 ? import_lit13.html`<div class="controls-column">
4810
+ ${orderedTracks.length > 0 || this.indefinitePlayback ? import_lit13.html`<div class="controls-column">
4206
4811
  ${this.timescale ? import_lit13.html`<div style="height: 30px;"></div>` : ""}
4207
4812
  ${orderedTracks.map(
4208
4813
  (t) => import_lit13.html`
@@ -4228,7 +4833,7 @@ var DawEditorElement = class extends import_lit13.LitElement {
4228
4833
  @dragleave=${this._onDragLeave}
4229
4834
  @drop=${this._onDrop}
4230
4835
  >
4231
- ${(orderedTracks.length > 0 || this.scaleMode === "beats") && this.timescale ? import_lit13.html`<daw-ruler
4836
+ ${(orderedTracks.length > 0 || this.scaleMode === "beats" || this.indefinitePlayback) && this.timescale ? import_lit13.html`<daw-ruler
4232
4837
  .samplesPerPixel=${spp}
4233
4838
  .sampleRate=${this.effectiveSampleRate}
4234
4839
  .duration=${this._duration}
@@ -4248,7 +4853,7 @@ var DawEditorElement = class extends import_lit13.LitElement {
4248
4853
  .length=${this._totalWidth}
4249
4854
  .height=${orderedTracks.length > 0 ? orderedTracks.reduce((sum, t) => sum + t.trackHeight + 1, 0) : this._emptyGridHeight}
4250
4855
  ></daw-grid>` : ""}
4251
- ${orderedTracks.length > 0 || this.scaleMode === "beats" ? import_lit13.html`<daw-selection .startPx=${selStartPx} .endPx=${selEndPx}></daw-selection>
4856
+ ${orderedTracks.length > 0 || this.scaleMode === "beats" || this.indefinitePlayback ? import_lit13.html`<daw-selection .startPx=${selStartPx} .endPx=${selEndPx}></daw-selection>
4252
4857
  <daw-playhead></daw-playhead>` : ""}
4253
4858
  ${orderedTracks.map((t) => {
4254
4859
  const channelHeight = this.waveHeight;
@@ -4445,6 +5050,9 @@ __decorateClass([
4445
5050
  __decorateClass([
4446
5051
  (0, import_decorators11.property)({ type: Boolean, attribute: "interactive-clips" })
4447
5052
  ], DawEditorElement.prototype, "interactiveClips", 2);
5053
+ __decorateClass([
5054
+ (0, import_decorators11.property)({ type: Boolean, attribute: "indefinite-playback" })
5055
+ ], DawEditorElement.prototype, "indefinitePlayback", 2);
4448
5056
  __decorateClass([
4449
5057
  (0, import_decorators11.property)({ type: String, attribute: "scale-mode" })
4450
5058
  ], DawEditorElement.prototype, "scaleMode", 2);
@@ -4584,12 +5192,14 @@ var DawRulerElement = class extends import_lit14.LitElement {
4584
5192
  ppqn: this.ppqn
4585
5193
  });
4586
5194
  this._tickData = null;
4587
- } else if (this.duration > 0) {
5195
+ } else if (this.duration > 0 || this.totalWidth > 0) {
5196
+ const widthDerivedDuration = this.totalWidth * this.samplesPerPixel / this.sampleRate;
5197
+ const effectiveDuration = Math.max(this.duration, widthDerivedDuration);
4588
5198
  this._musicalTickData = null;
4589
5199
  this._tickData = computeTemporalTicks(
4590
5200
  this.samplesPerPixel,
4591
5201
  this.sampleRate,
4592
- this.duration,
5202
+ effectiveDuration,
4593
5203
  this.rulerHeight
4594
5204
  );
4595
5205
  } else {
@@ -5042,6 +5652,7 @@ DawKeyboardShortcutsElement = __decorateClass([
5042
5652
  DawTransportElement,
5043
5653
  DawWaveformElement,
5044
5654
  RecordingController,
5655
+ isDomClip,
5045
5656
  splitAtPlayhead
5046
5657
  });
5047
5658
  //# sourceMappingURL=index.js.map