@dawcore/components 0.0.2 → 0.0.4

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
@@ -1083,6 +1083,13 @@ var PeakPipeline = class {
1083
1083
  this._baseScale = baseScale;
1084
1084
  this._bits = bits;
1085
1085
  }
1086
+ /**
1087
+ * Inject externally-loaded WaveformData (e.g., from a .dat file) into the cache.
1088
+ * Prevents worker generation for this AudioBuffer on all subsequent calls.
1089
+ */
1090
+ cacheWaveformData(audioBuffer, waveformData) {
1091
+ this._cache.set(audioBuffer, waveformData);
1092
+ }
1086
1093
  /**
1087
1094
  * Generate PeakData for a clip from its AudioBuffer.
1088
1095
  * Uses cached WaveformData when available; otherwise generates via worker.
@@ -1090,8 +1097,9 @@ var PeakPipeline = class {
1090
1097
  */
1091
1098
  async generatePeaks(audioBuffer, samplesPerPixel, isMono, offsetSamples, durationSamples) {
1092
1099
  const waveformData = await this._getWaveformData(audioBuffer);
1100
+ const effectiveScale = this._clampScale(waveformData, samplesPerPixel);
1093
1101
  try {
1094
- return extractPeaks(waveformData, samplesPerPixel, isMono, offsetSamples, durationSamples);
1102
+ return extractPeaks(waveformData, effectiveScale, isMono, offsetSamples, durationSamples);
1095
1103
  } catch (err) {
1096
1104
  console.warn("[dawcore] extractPeaks failed: " + String(err));
1097
1105
  throw err;
@@ -1099,23 +1107,29 @@ var PeakPipeline = class {
1099
1107
  }
1100
1108
  /**
1101
1109
  * Re-extract peaks for all clips at a new zoom level using cached WaveformData.
1102
- * Only works for zoom levels coarser than (or equal to) the cached base scale.
1103
- * Returns a new Map of clipId PeakData. Clips without cached data or where
1104
- * the target scale is finer than the cached base are skipped.
1110
+ * Returns a new Map of clipId PeakData. Clips without cached data are skipped.
1111
+ * When the requested scale is finer than cached data, peaks are clamped to the
1112
+ * cached scale and a single summary warning is logged.
1105
1113
  */
1106
1114
  reextractPeaks(clipBuffers, samplesPerPixel, isMono, clipOffsets) {
1107
1115
  const result = /* @__PURE__ */ new Map();
1116
+ let clampedCount = 0;
1117
+ let clampedScale = 0;
1108
1118
  for (const [clipId, audioBuffer] of clipBuffers) {
1109
1119
  const cached = this._cache.get(audioBuffer);
1110
1120
  if (cached) {
1111
- if (samplesPerPixel < cached.scale) continue;
1121
+ const effectiveScale = this._clampScale(cached, samplesPerPixel, false);
1122
+ if (effectiveScale !== samplesPerPixel) {
1123
+ clampedCount++;
1124
+ clampedScale = effectiveScale;
1125
+ }
1112
1126
  try {
1113
1127
  const offsets = clipOffsets?.get(clipId);
1114
1128
  result.set(
1115
1129
  clipId,
1116
1130
  extractPeaks(
1117
1131
  cached,
1118
- samplesPerPixel,
1132
+ effectiveScale,
1119
1133
  isMono,
1120
1134
  offsets?.offsetSamples,
1121
1135
  offsets?.durationSamples
@@ -1126,8 +1140,42 @@ var PeakPipeline = class {
1126
1140
  }
1127
1141
  }
1128
1142
  }
1143
+ if (clampedCount > 0) {
1144
+ console.warn(
1145
+ "[dawcore] Requested zoom " + samplesPerPixel + " spp is finer than pre-computed peaks (" + clampedScale + " spp) \u2014 " + clampedCount + " clip(s) using available resolution"
1146
+ );
1147
+ }
1129
1148
  return result;
1130
1149
  }
1150
+ /**
1151
+ * Clamp requested scale to cached WaveformData scale.
1152
+ * WaveformData.resample() can only go coarser — if the requested zoom is
1153
+ * finer than the cached data, use the cached scale. Set warn=true to log
1154
+ * (default); reextractPeaks passes false and logs a single summary instead.
1155
+ */
1156
+ _clampScale(waveformData, requestedScale, warn = true) {
1157
+ if (requestedScale < waveformData.scale) {
1158
+ if (warn) {
1159
+ console.warn(
1160
+ "[dawcore] Requested zoom " + requestedScale + " spp is finer than pre-computed peaks (" + waveformData.scale + " spp) \u2014 using available resolution"
1161
+ );
1162
+ }
1163
+ return waveformData.scale;
1164
+ }
1165
+ return requestedScale;
1166
+ }
1167
+ /**
1168
+ * Return the coarsest (largest) scale among cached WaveformData entries
1169
+ * that correspond to the given clip buffers. Returns 0 if none are cached.
1170
+ */
1171
+ getMaxCachedScale(clipBuffers) {
1172
+ let max = 0;
1173
+ for (const audioBuffer of clipBuffers.values()) {
1174
+ const cached = this._cache.get(audioBuffer);
1175
+ if (cached && cached.scale > max) max = cached.scale;
1176
+ }
1177
+ return max;
1178
+ }
1131
1179
  terminate() {
1132
1180
  this._worker?.terminate();
1133
1181
  this._worker = null;
@@ -2179,6 +2227,13 @@ var ClipPointerHandler = class {
2179
2227
  this._isDragging = false;
2180
2228
  this._lastDeltaPx = 0;
2181
2229
  this._cumulativeDeltaSamples = 0;
2230
+ if (this._host.engine) {
2231
+ this._host.engine.beginTransaction();
2232
+ } else {
2233
+ console.warn(
2234
+ "[dawcore] beginDrag: engine unavailable, drag mutations will not be grouped for undo"
2235
+ );
2236
+ }
2182
2237
  if (mode === "trim-left" || mode === "trim-right") {
2183
2238
  const container = this._host.shadowRoot?.querySelector(
2184
2239
  `.clip-container[data-clip-id="${clipId}"]`
@@ -2217,8 +2272,8 @@ var ClipPointerHandler = class {
2217
2272
  const incrementalDeltaPx = totalDeltaPx - this._lastDeltaPx;
2218
2273
  this._lastDeltaPx = totalDeltaPx;
2219
2274
  const incrementalDeltaSamples = Math.round(incrementalDeltaPx * this._host.samplesPerPixel);
2220
- this._cumulativeDeltaSamples += incrementalDeltaSamples;
2221
- engine.moveClip(this._trackId, this._clipId, incrementalDeltaSamples, true);
2275
+ const applied = engine.moveClip(this._trackId, this._clipId, incrementalDeltaSamples, true);
2276
+ this._cumulativeDeltaSamples += applied;
2222
2277
  } else {
2223
2278
  const boundary = this._mode === "trim-left" ? "left" : "right";
2224
2279
  const rawDeltaSamples = Math.round(totalDeltaPx * this._host.samplesPerPixel);
@@ -2307,9 +2362,18 @@ var ClipPointerHandler = class {
2307
2362
  }
2308
2363
  })
2309
2364
  );
2365
+ } else {
2366
+ console.warn(
2367
+ "[dawcore] engine unavailable at trim drop \u2014 trim not applied for clip " + this._clipId
2368
+ );
2310
2369
  }
2311
2370
  }
2312
2371
  } finally {
2372
+ if (this._isDragging && this._cumulativeDeltaSamples !== 0) {
2373
+ this._host.engine?.commitTransaction();
2374
+ } else {
2375
+ this._host.engine?.abortTransaction();
2376
+ }
2313
2377
  this._reset();
2314
2378
  }
2315
2379
  }
@@ -2420,6 +2484,7 @@ async function loadFiles(host, files) {
2420
2484
  clips: [
2421
2485
  {
2422
2486
  src: "",
2487
+ peaksSrc: "",
2423
2488
  start: 0,
2424
2489
  duration: audioBuffer.duration,
2425
2490
  offset: 0,
@@ -2505,6 +2570,7 @@ function addRecordedClip(host, trackId, buf, startSample, durSamples, offsetSamp
2505
2570
  const sr = host.effectiveSampleRate;
2506
2571
  const clipDesc = {
2507
2572
  src: "",
2573
+ peaksSrc: "",
2508
2574
  start: startSample / sr,
2509
2575
  duration: durSamples / sr,
2510
2576
  offset: 0,
@@ -2545,6 +2611,35 @@ function addRecordedClip(host, trackId, buf, startSample, durSamples, offsetSamp
2545
2611
 
2546
2612
  // src/interactions/split-handler.ts
2547
2613
  function splitAtPlayhead(host) {
2614
+ const wasPlaying = host.isPlaying;
2615
+ const time = host.currentTime;
2616
+ if (!canSplitAtTime(host, time)) return false;
2617
+ if (wasPlaying) {
2618
+ host.stop();
2619
+ }
2620
+ let result;
2621
+ try {
2622
+ result = performSplit(host, time);
2623
+ } catch (err) {
2624
+ console.warn("[dawcore] splitAtPlayhead failed: " + String(err));
2625
+ result = false;
2626
+ }
2627
+ if (wasPlaying) {
2628
+ host.play(time);
2629
+ }
2630
+ return result;
2631
+ }
2632
+ function canSplitAtTime(host, time) {
2633
+ const { engine } = host;
2634
+ if (!engine) return false;
2635
+ const state5 = engine.getState();
2636
+ if (!state5.selectedTrackId) return false;
2637
+ const track = state5.tracks.find((t) => t.id === state5.selectedTrackId);
2638
+ if (!track) return false;
2639
+ const atSample = Math.round(time * host.effectiveSampleRate);
2640
+ return !!findClipAtSample(track.clips, atSample);
2641
+ }
2642
+ function performSplit(host, time) {
2548
2643
  const { engine } = host;
2549
2644
  if (!engine) return false;
2550
2645
  const stateBefore = engine.getState();
@@ -2552,7 +2647,7 @@ function splitAtPlayhead(host) {
2552
2647
  if (!selectedTrackId) return false;
2553
2648
  const track = tracks.find((t) => t.id === selectedTrackId);
2554
2649
  if (!track) return false;
2555
- const atSample = Math.round(host.currentTime * host.effectiveSampleRate);
2650
+ const atSample = Math.round(time * host.effectiveSampleRate);
2556
2651
  const clip = findClipAtSample(track.clips, atSample);
2557
2652
  if (!clip) return false;
2558
2653
  const originalClipId = clip.id;
@@ -2677,11 +2772,29 @@ function findAudioBufferForClip(host, clip, track) {
2677
2772
  return null;
2678
2773
  }
2679
2774
 
2775
+ // src/interactions/peaks-loader.ts
2776
+ import WaveformData2 from "waveform-data";
2777
+ async function loadWaveformDataFromUrl(src) {
2778
+ const response = await fetch(src);
2779
+ if (!response.ok) {
2780
+ throw new Error("[dawcore] Failed to fetch peaks data: " + response.statusText);
2781
+ }
2782
+ const { pathname } = new URL(src, globalThis.location?.href ?? "http://localhost");
2783
+ const isBinary = pathname.toLowerCase().endsWith(".dat");
2784
+ if (isBinary) {
2785
+ const arrayBuffer = await response.arrayBuffer();
2786
+ return WaveformData2.create(arrayBuffer);
2787
+ } else {
2788
+ const json = await response.json();
2789
+ return WaveformData2.create(json);
2790
+ }
2791
+ }
2792
+
2680
2793
  // src/elements/daw-editor.ts
2681
2794
  var DawEditorElement = class extends LitElement8 {
2682
2795
  constructor() {
2683
2796
  super(...arguments);
2684
- this.samplesPerPixel = 1024;
2797
+ this._samplesPerPixel = 1024;
2685
2798
  this.waveHeight = 128;
2686
2799
  this.timescale = false;
2687
2800
  this.mono = false;
@@ -2708,9 +2821,12 @@ var DawEditorElement = class extends LitElement8 {
2708
2821
  this._engine = null;
2709
2822
  this._enginePromise = null;
2710
2823
  this._audioCache = /* @__PURE__ */ new Map();
2824
+ this._peaksCache = /* @__PURE__ */ new Map();
2711
2825
  this._clipBuffers = /* @__PURE__ */ new Map();
2712
2826
  this._clipOffsets = /* @__PURE__ */ new Map();
2713
2827
  this._peakPipeline = new PeakPipeline();
2828
+ /** Coarsest scale from pre-computed peaks — zoom cannot go finer than this. 0 = no limit. */
2829
+ this._minSamplesPerPixel = 0;
2714
2830
  this._trackElements = /* @__PURE__ */ new Map();
2715
2831
  this._childObserver = null;
2716
2832
  this._audioResume = new AudioResumeController(this);
@@ -2759,6 +2875,19 @@ var DawEditorElement = class extends LitElement8 {
2759
2875
  this._onTrackControl = (e) => {
2760
2876
  const { trackId, prop, value } = e.detail ?? {};
2761
2877
  if (!trackId || !prop || !DawEditorElement._CONTROL_PROPS.has(prop)) return;
2878
+ if (this._selectedTrackId !== trackId) {
2879
+ this._setSelectedTrackId(trackId);
2880
+ if (this._engine) {
2881
+ this._engine.selectTrack(trackId);
2882
+ }
2883
+ this.dispatchEvent(
2884
+ new CustomEvent("daw-track-select", {
2885
+ bubbles: true,
2886
+ composed: true,
2887
+ detail: { trackId }
2888
+ })
2889
+ );
2890
+ }
2762
2891
  const oldDescriptor = this._tracks.get(trackId);
2763
2892
  if (oldDescriptor) {
2764
2893
  const descriptor = { ...oldDescriptor, [prop]: value };
@@ -2816,20 +2945,24 @@ var DawEditorElement = class extends LitElement8 {
2816
2945
  );
2817
2946
  }
2818
2947
  };
2819
- this._onKeyDown = (e) => {
2820
- if (!this.interactiveClips) return;
2821
- if (e.key === "s" || e.key === "S") {
2822
- if (e.ctrlKey || e.metaKey || e.altKey) return;
2823
- const tag = e.target?.tagName;
2824
- if (tag === "INPUT" || tag === "TEXTAREA") return;
2825
- if (e.target?.isContentEditable) return;
2826
- e.preventDefault();
2827
- this.splitAtPlayhead();
2828
- }
2829
- };
2830
2948
  // --- Recording ---
2831
2949
  this.recordingStream = null;
2832
2950
  }
2951
+ get samplesPerPixel() {
2952
+ return this._samplesPerPixel;
2953
+ }
2954
+ set samplesPerPixel(value) {
2955
+ const old = this._samplesPerPixel;
2956
+ if (!Number.isFinite(value) || value <= 0) return;
2957
+ const clamped = this._minSamplesPerPixel > 0 && value < this._minSamplesPerPixel ? this._minSamplesPerPixel : value;
2958
+ if (clamped !== value) {
2959
+ console.warn(
2960
+ "[dawcore] Zoom " + value + " spp rejected \u2014 pre-computed peaks limit is " + this._minSamplesPerPixel + " spp"
2961
+ );
2962
+ }
2963
+ this._samplesPerPixel = clamped;
2964
+ this.requestUpdate("samplesPerPixel", old);
2965
+ }
2833
2966
  get _clipHandler() {
2834
2967
  return this.interactiveClips ? this._clipPointer : null;
2835
2968
  }
@@ -2892,10 +3025,6 @@ var DawEditorElement = class extends LitElement8 {
2892
3025
  // --- Lifecycle ---
2893
3026
  connectedCallback() {
2894
3027
  super.connectedCallback();
2895
- if (!this.hasAttribute("tabindex")) {
2896
- this.setAttribute("tabindex", "0");
2897
- }
2898
- this.addEventListener("keydown", this._onKeyDown);
2899
3028
  this.addEventListener("daw-track-connected", this._onTrackConnected);
2900
3029
  this.addEventListener("daw-track-update", this._onTrackUpdate);
2901
3030
  this.addEventListener("daw-track-control", this._onTrackControl);
@@ -2921,7 +3050,6 @@ var DawEditorElement = class extends LitElement8 {
2921
3050
  }
2922
3051
  disconnectedCallback() {
2923
3052
  super.disconnectedCallback();
2924
- this.removeEventListener("keydown", this._onKeyDown);
2925
3053
  this.removeEventListener("daw-track-connected", this._onTrackConnected);
2926
3054
  this.removeEventListener("daw-track-update", this._onTrackUpdate);
2927
3055
  this.removeEventListener("daw-track-control", this._onTrackControl);
@@ -2930,9 +3058,11 @@ var DawEditorElement = class extends LitElement8 {
2930
3058
  this._childObserver = null;
2931
3059
  this._trackElements.clear();
2932
3060
  this._audioCache.clear();
3061
+ this._peaksCache.clear();
2933
3062
  this._clipBuffers.clear();
2934
3063
  this._clipOffsets.clear();
2935
3064
  this._peakPipeline.terminate();
3065
+ this._minSamplesPerPixel = 0;
2936
3066
  try {
2937
3067
  this._disposeEngine();
2938
3068
  } catch (err) {
@@ -2982,6 +3112,7 @@ var DawEditorElement = class extends LitElement8 {
2982
3112
  if (this._engine) {
2983
3113
  this._engine.removeTrack(trackId);
2984
3114
  }
3115
+ this._minSamplesPerPixel = this._peakPipeline.getMaxCachedScale(this._clipBuffers);
2985
3116
  if (nextEngine.size === 0) {
2986
3117
  this._currentTime = 0;
2987
3118
  this._stopPlayhead();
@@ -2993,6 +3124,7 @@ var DawEditorElement = class extends LitElement8 {
2993
3124
  if (clipEls.length === 0 && trackEl.src) {
2994
3125
  clips.push({
2995
3126
  src: trackEl.src,
3127
+ peaksSrc: "",
2996
3128
  start: 0,
2997
3129
  duration: 0,
2998
3130
  offset: 0,
@@ -3006,6 +3138,7 @@ var DawEditorElement = class extends LitElement8 {
3006
3138
  for (const clipEl of clipEls) {
3007
3139
  clips.push({
3008
3140
  src: clipEl.src,
3141
+ peaksSrc: clipEl.peaksSrc,
3009
3142
  start: clipEl.start,
3010
3143
  duration: clipEl.duration,
3011
3144
  offset: clipEl.offset,
@@ -3033,7 +3166,77 @@ var DawEditorElement = class extends LitElement8 {
3033
3166
  const clips = [];
3034
3167
  for (const clipDesc of descriptor.clips) {
3035
3168
  if (!clipDesc.src) continue;
3036
- const audioBuffer = await this._fetchAndDecode(clipDesc.src);
3169
+ const waveformDataPromise = clipDesc.peaksSrc ? this._fetchPeaks(clipDesc.peaksSrc) : null;
3170
+ const audioPromise = this._fetchAndDecode(clipDesc.src);
3171
+ let waveformData = null;
3172
+ if (waveformDataPromise) {
3173
+ try {
3174
+ waveformData = await waveformDataPromise;
3175
+ } catch (err) {
3176
+ console.warn(
3177
+ "[dawcore] Failed to load peaks from " + clipDesc.peaksSrc + ": " + String(err) + " \u2014 falling back to AudioBuffer generation"
3178
+ );
3179
+ }
3180
+ }
3181
+ if (waveformData) {
3182
+ const clip2 = createClipFromSeconds2({
3183
+ waveformData,
3184
+ startTime: clipDesc.start,
3185
+ duration: clipDesc.duration || waveformData.duration,
3186
+ offset: clipDesc.offset,
3187
+ gain: clipDesc.gain,
3188
+ name: clipDesc.name,
3189
+ sampleRate: waveformData.sample_rate,
3190
+ sourceDuration: waveformData.duration
3191
+ });
3192
+ const effectiveScale = Math.max(this.samplesPerPixel, waveformData.scale);
3193
+ const peakData2 = extractPeaks(
3194
+ waveformData,
3195
+ effectiveScale,
3196
+ this.mono,
3197
+ clip2.offsetSamples,
3198
+ clip2.durationSamples
3199
+ );
3200
+ this._clipOffsets.set(clip2.id, {
3201
+ offsetSamples: clip2.offsetSamples,
3202
+ durationSamples: clip2.durationSamples
3203
+ });
3204
+ this._peaksData = new Map(this._peaksData).set(clip2.id, peakData2);
3205
+ this._minSamplesPerPixel = Math.max(this._minSamplesPerPixel, waveformData.scale);
3206
+ const previewTrack = createTrack2({
3207
+ name: descriptor.name,
3208
+ clips: [clip2],
3209
+ volume: descriptor.volume,
3210
+ pan: descriptor.pan,
3211
+ muted: descriptor.muted,
3212
+ soloed: descriptor.soloed
3213
+ });
3214
+ previewTrack.id = trackId;
3215
+ this._engineTracks = new Map(this._engineTracks).set(trackId, previewTrack);
3216
+ this._recomputeDuration();
3217
+ let audioBuffer2;
3218
+ try {
3219
+ audioBuffer2 = await audioPromise;
3220
+ } catch (audioErr) {
3221
+ const nextPeaks = new Map(this._peaksData);
3222
+ nextPeaks.delete(clip2.id);
3223
+ this._peaksData = nextPeaks;
3224
+ this._clipOffsets.delete(clip2.id);
3225
+ const nextEngine = new Map(this._engineTracks);
3226
+ nextEngine.delete(trackId);
3227
+ this._engineTracks = nextEngine;
3228
+ this._minSamplesPerPixel = this._peakPipeline.getMaxCachedScale(this._clipBuffers);
3229
+ this._recomputeDuration();
3230
+ throw audioErr;
3231
+ }
3232
+ this._resolvedSampleRate = audioBuffer2.sampleRate;
3233
+ const updatedClip = { ...clip2, audioBuffer: audioBuffer2 };
3234
+ this._clipBuffers = new Map(this._clipBuffers).set(clip2.id, audioBuffer2);
3235
+ this._peakPipeline.cacheWaveformData(audioBuffer2, waveformData);
3236
+ clips.push(updatedClip);
3237
+ continue;
3238
+ }
3239
+ const audioBuffer = await audioPromise;
3037
3240
  this._resolvedSampleRate = audioBuffer.sampleRate;
3038
3241
  const clip = createClipFromSeconds2({
3039
3242
  audioBuffer,
@@ -3115,6 +3318,16 @@ var DawEditorElement = class extends LitElement8 {
3115
3318
  throw err;
3116
3319
  }
3117
3320
  }
3321
+ _fetchPeaks(src) {
3322
+ const cached = this._peaksCache.get(src);
3323
+ if (cached) return cached;
3324
+ const promise = loadWaveformDataFromUrl(src).catch((err) => {
3325
+ this._peaksCache.delete(src);
3326
+ throw err;
3327
+ });
3328
+ this._peaksCache.set(src, promise);
3329
+ return promise;
3330
+ }
3118
3331
  _recomputeDuration() {
3119
3332
  let maxSample = 0;
3120
3333
  for (const track of this._engineTracks.values()) {
@@ -3213,18 +3426,71 @@ var DawEditorElement = class extends LitElement8 {
3213
3426
  this._stopPlayhead();
3214
3427
  this.dispatchEvent(new CustomEvent("daw-stop", { bubbles: true, composed: true }));
3215
3428
  }
3429
+ /** Toggle between play and pause. */
3430
+ togglePlayPause() {
3431
+ if (this._isPlaying) {
3432
+ this.pause();
3433
+ } else {
3434
+ this.play();
3435
+ }
3436
+ }
3216
3437
  seekTo(time) {
3217
- if (!this._engine) return;
3218
- this._engine.seek(time);
3219
- this._currentTime = time;
3438
+ if (!this._engine) {
3439
+ console.warn("[dawcore] seekTo: engine not ready, call ignored");
3440
+ return;
3441
+ }
3442
+ if (this._isPlaying) {
3443
+ this.stop();
3444
+ this.play(time);
3445
+ } else {
3446
+ this._engine.seek(time);
3447
+ this._currentTime = time;
3448
+ this._stopPlayhead();
3449
+ }
3450
+ }
3451
+ /** Undo the last structural edit. */
3452
+ undo() {
3453
+ if (!this._engine) {
3454
+ console.warn("[dawcore] undo: engine not ready, call ignored");
3455
+ return;
3456
+ }
3457
+ this._engine.undo();
3458
+ }
3459
+ /** Redo the last undone edit. */
3460
+ redo() {
3461
+ if (!this._engine) {
3462
+ console.warn("[dawcore] redo: engine not ready, call ignored");
3463
+ return;
3464
+ }
3465
+ this._engine.redo();
3466
+ }
3467
+ /** Whether undo is available. */
3468
+ get canUndo() {
3469
+ return this._engine?.canUndo ?? false;
3470
+ }
3471
+ /** Whether redo is available. */
3472
+ get canRedo() {
3473
+ return this._engine?.canRedo ?? false;
3220
3474
  }
3221
3475
  /** Split the clip under the playhead on the selected track. */
3222
3476
  splitAtPlayhead() {
3223
3477
  return splitAtPlayhead({
3224
3478
  effectiveSampleRate: this.effectiveSampleRate,
3225
3479
  currentTime: this._currentTime,
3480
+ isPlaying: this._isPlaying,
3226
3481
  engine: this._engine,
3227
- dispatchEvent: (e) => this.dispatchEvent(e)
3482
+ dispatchEvent: (e) => this.dispatchEvent(e),
3483
+ stop: () => {
3484
+ this._engine?.stop();
3485
+ this._stopPlayhead();
3486
+ },
3487
+ // Call engine.play directly (synchronous) — not the async editor play()
3488
+ // which yields to microtask queue via await engine.init(). Engine is
3489
+ // already initialized at split time; the async gap causes audio desync.
3490
+ play: (time) => {
3491
+ this._engine?.play(time);
3492
+ this._startPlayhead();
3493
+ }
3228
3494
  });
3229
3495
  }
3230
3496
  get currentTime() {
@@ -3474,8 +3740,8 @@ DawEditorElement.styles = [
3474
3740
  ];
3475
3741
  DawEditorElement._CONTROL_PROPS = /* @__PURE__ */ new Set(["volume", "pan", "muted", "soloed"]);
3476
3742
  __decorateClass([
3477
- property6({ type: Number, attribute: "samples-per-pixel" })
3478
- ], DawEditorElement.prototype, "samplesPerPixel", 2);
3743
+ property6({ type: Number, attribute: "samples-per-pixel", noAccessor: true })
3744
+ ], DawEditorElement.prototype, "samplesPerPixel", 1);
3479
3745
  __decorateClass([
3480
3746
  property6({ type: Number, attribute: "wave-height" })
3481
3747
  ], DawEditorElement.prototype, "waveHeight", 2);
@@ -3824,11 +4090,182 @@ __decorateClass([
3824
4090
  DawRecordButtonElement = __decorateClass([
3825
4091
  customElement13("daw-record-button")
3826
4092
  ], DawRecordButtonElement);
4093
+
4094
+ // src/elements/daw-keyboard-shortcuts.ts
4095
+ import { LitElement as LitElement11 } from "lit";
4096
+ import { customElement as customElement14, property as property9 } from "lit/decorators.js";
4097
+ import { handleKeyboardEvent } from "@waveform-playlist/core";
4098
+ var DawKeyboardShortcutsElement = class extends LitElement11 {
4099
+ constructor() {
4100
+ super(...arguments);
4101
+ this.playback = false;
4102
+ this.splitting = false;
4103
+ this.undo = false;
4104
+ // --- JS properties for remapping ---
4105
+ this.playbackShortcuts = null;
4106
+ this.splittingShortcuts = null;
4107
+ this.undoShortcuts = null;
4108
+ /** Additional custom shortcuts. */
4109
+ this.customShortcuts = [];
4110
+ this._editor = null;
4111
+ this._cachedShortcuts = null;
4112
+ // --- Event handler ---
4113
+ this._onKeyDown = (e) => {
4114
+ const shortcuts = this.shortcuts;
4115
+ if (shortcuts.length === 0) return;
4116
+ try {
4117
+ handleKeyboardEvent(e, shortcuts, true);
4118
+ } catch (err) {
4119
+ console.warn("[dawcore] Keyboard shortcut failed (key=" + e.key + "): " + String(err));
4120
+ const target = this._editor ?? this;
4121
+ target.dispatchEvent(
4122
+ new CustomEvent("daw-error", {
4123
+ bubbles: true,
4124
+ composed: true,
4125
+ detail: { operation: "keyboard-shortcut", key: e.key, error: err }
4126
+ })
4127
+ );
4128
+ }
4129
+ };
4130
+ }
4131
+ /** All active shortcuts (read-only, cached). */
4132
+ get shortcuts() {
4133
+ if (!this._cachedShortcuts) {
4134
+ this._cachedShortcuts = this._buildShortcuts();
4135
+ }
4136
+ return this._cachedShortcuts;
4137
+ }
4138
+ /** Invalidate cached shortcuts when Lit properties change. */
4139
+ updated() {
4140
+ this._cachedShortcuts = null;
4141
+ }
4142
+ // --- Lifecycle ---
4143
+ connectedCallback() {
4144
+ super.connectedCallback();
4145
+ this._editor = this.closest("daw-editor");
4146
+ if (!this._editor) {
4147
+ console.warn(
4148
+ "[dawcore] <daw-keyboard-shortcuts> must be placed inside a <daw-editor>. Preset shortcuts (playback, splitting, undo) will be inactive; only customShortcuts will fire."
4149
+ );
4150
+ }
4151
+ document.addEventListener("keydown", this._onKeyDown);
4152
+ }
4153
+ disconnectedCallback() {
4154
+ super.disconnectedCallback();
4155
+ document.removeEventListener("keydown", this._onKeyDown);
4156
+ this._editor = null;
4157
+ }
4158
+ // No shadow DOM — render-less element
4159
+ createRenderRoot() {
4160
+ return this;
4161
+ }
4162
+ // --- Shortcut building ---
4163
+ _buildShortcuts() {
4164
+ const editor = this._editor;
4165
+ if (!editor) return this.customShortcuts;
4166
+ const result = [];
4167
+ if (this.playback) {
4168
+ const map = this.playbackShortcuts;
4169
+ result.push(
4170
+ this._makeShortcut(
4171
+ map?.playPause ?? { key: " ", ctrlKey: false, metaKey: false },
4172
+ () => editor.togglePlayPause(),
4173
+ "Play/Pause"
4174
+ ),
4175
+ this._makeShortcut(
4176
+ map?.stop ?? { key: "Escape", ctrlKey: false, metaKey: false },
4177
+ () => editor.stop(),
4178
+ "Stop"
4179
+ ),
4180
+ this._makeShortcut(
4181
+ map?.rewindToStart ?? { key: "0", ctrlKey: false, metaKey: false },
4182
+ () => editor.seekTo(0),
4183
+ "Rewind to start"
4184
+ )
4185
+ );
4186
+ }
4187
+ if (this.splitting) {
4188
+ const map = this.splittingShortcuts;
4189
+ const binding = map?.splitAtPlayhead ?? {
4190
+ key: "s",
4191
+ ctrlKey: false,
4192
+ metaKey: false,
4193
+ altKey: false
4194
+ };
4195
+ result.push(this._makeShortcut(binding, () => editor.splitAtPlayhead(), "Split at playhead"));
4196
+ }
4197
+ if (this.undo) {
4198
+ const map = this.undoShortcuts;
4199
+ const undoBinding = map?.undo ?? { key: "z" };
4200
+ const redoBinding = map?.redo ?? { key: "z", shiftKey: true };
4201
+ if (undoBinding.ctrlKey === void 0 && undoBinding.metaKey === void 0) {
4202
+ const undoShift = undoBinding.shiftKey === void 0 ? { shiftKey: false } : {};
4203
+ result.push(
4204
+ this._makeShortcut(
4205
+ { ...undoBinding, ctrlKey: true, ...undoShift },
4206
+ () => editor.undo(),
4207
+ "Undo"
4208
+ ),
4209
+ this._makeShortcut(
4210
+ { ...undoBinding, metaKey: true, ...undoShift },
4211
+ () => editor.undo(),
4212
+ "Undo"
4213
+ )
4214
+ );
4215
+ } else {
4216
+ result.push(this._makeShortcut(undoBinding, () => editor.undo(), "Undo"));
4217
+ }
4218
+ if (redoBinding.ctrlKey === void 0 && redoBinding.metaKey === void 0) {
4219
+ const redoShift = redoBinding.shiftKey === void 0 ? { shiftKey: true } : {};
4220
+ result.push(
4221
+ this._makeShortcut(
4222
+ { ...redoBinding, ctrlKey: true, ...redoShift },
4223
+ () => editor.redo(),
4224
+ "Redo"
4225
+ ),
4226
+ this._makeShortcut(
4227
+ { ...redoBinding, metaKey: true, ...redoShift },
4228
+ () => editor.redo(),
4229
+ "Redo"
4230
+ )
4231
+ );
4232
+ } else {
4233
+ result.push(this._makeShortcut(redoBinding, () => editor.redo(), "Redo"));
4234
+ }
4235
+ }
4236
+ result.push(...this.customShortcuts);
4237
+ return result;
4238
+ }
4239
+ _makeShortcut(binding, action, description) {
4240
+ return {
4241
+ key: binding.key,
4242
+ ...binding.ctrlKey !== void 0 && { ctrlKey: binding.ctrlKey },
4243
+ ...binding.shiftKey !== void 0 && { shiftKey: binding.shiftKey },
4244
+ ...binding.metaKey !== void 0 && { metaKey: binding.metaKey },
4245
+ ...binding.altKey !== void 0 && { altKey: binding.altKey },
4246
+ action,
4247
+ description
4248
+ };
4249
+ }
4250
+ };
4251
+ __decorateClass([
4252
+ property9({ type: Boolean })
4253
+ ], DawKeyboardShortcutsElement.prototype, "playback", 2);
4254
+ __decorateClass([
4255
+ property9({ type: Boolean })
4256
+ ], DawKeyboardShortcutsElement.prototype, "splitting", 2);
4257
+ __decorateClass([
4258
+ property9({ type: Boolean })
4259
+ ], DawKeyboardShortcutsElement.prototype, "undo", 2);
4260
+ DawKeyboardShortcutsElement = __decorateClass([
4261
+ customElement14("daw-keyboard-shortcuts")
4262
+ ], DawKeyboardShortcutsElement);
3827
4263
  export {
3828
4264
  AudioResumeController,
3829
4265
  ClipPointerHandler,
3830
4266
  DawClipElement,
3831
4267
  DawEditorElement,
4268
+ DawKeyboardShortcutsElement,
3832
4269
  DawPauseButtonElement,
3833
4270
  DawPlayButtonElement,
3834
4271
  DawPlayheadElement,