@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.js CHANGED
@@ -42,6 +42,7 @@ __export(index_exports, {
42
42
  ClipPointerHandler: () => ClipPointerHandler,
43
43
  DawClipElement: () => DawClipElement,
44
44
  DawEditorElement: () => DawEditorElement,
45
+ DawKeyboardShortcutsElement: () => DawKeyboardShortcutsElement,
45
46
  DawPauseButtonElement: () => DawPauseButtonElement,
46
47
  DawPlayButtonElement: () => DawPlayButtonElement,
47
48
  DawPlayheadElement: () => DawPlayheadElement,
@@ -1133,6 +1134,13 @@ var PeakPipeline = class {
1133
1134
  this._baseScale = baseScale;
1134
1135
  this._bits = bits;
1135
1136
  }
1137
+ /**
1138
+ * Inject externally-loaded WaveformData (e.g., from a .dat file) into the cache.
1139
+ * Prevents worker generation for this AudioBuffer on all subsequent calls.
1140
+ */
1141
+ cacheWaveformData(audioBuffer, waveformData) {
1142
+ this._cache.set(audioBuffer, waveformData);
1143
+ }
1136
1144
  /**
1137
1145
  * Generate PeakData for a clip from its AudioBuffer.
1138
1146
  * Uses cached WaveformData when available; otherwise generates via worker.
@@ -1140,8 +1148,9 @@ var PeakPipeline = class {
1140
1148
  */
1141
1149
  async generatePeaks(audioBuffer, samplesPerPixel, isMono, offsetSamples, durationSamples) {
1142
1150
  const waveformData = await this._getWaveformData(audioBuffer);
1151
+ const effectiveScale = this._clampScale(waveformData, samplesPerPixel);
1143
1152
  try {
1144
- return extractPeaks(waveformData, samplesPerPixel, isMono, offsetSamples, durationSamples);
1153
+ return extractPeaks(waveformData, effectiveScale, isMono, offsetSamples, durationSamples);
1145
1154
  } catch (err) {
1146
1155
  console.warn("[dawcore] extractPeaks failed: " + String(err));
1147
1156
  throw err;
@@ -1149,23 +1158,29 @@ var PeakPipeline = class {
1149
1158
  }
1150
1159
  /**
1151
1160
  * Re-extract peaks for all clips at a new zoom level using cached WaveformData.
1152
- * Only works for zoom levels coarser than (or equal to) the cached base scale.
1153
- * Returns a new Map of clipId PeakData. Clips without cached data or where
1154
- * the target scale is finer than the cached base are skipped.
1161
+ * Returns a new Map of clipId PeakData. Clips without cached data are skipped.
1162
+ * When the requested scale is finer than cached data, peaks are clamped to the
1163
+ * cached scale and a single summary warning is logged.
1155
1164
  */
1156
1165
  reextractPeaks(clipBuffers, samplesPerPixel, isMono, clipOffsets) {
1157
1166
  const result = /* @__PURE__ */ new Map();
1167
+ let clampedCount = 0;
1168
+ let clampedScale = 0;
1158
1169
  for (const [clipId, audioBuffer] of clipBuffers) {
1159
1170
  const cached = this._cache.get(audioBuffer);
1160
1171
  if (cached) {
1161
- if (samplesPerPixel < cached.scale) continue;
1172
+ const effectiveScale = this._clampScale(cached, samplesPerPixel, false);
1173
+ if (effectiveScale !== samplesPerPixel) {
1174
+ clampedCount++;
1175
+ clampedScale = effectiveScale;
1176
+ }
1162
1177
  try {
1163
1178
  const offsets = clipOffsets?.get(clipId);
1164
1179
  result.set(
1165
1180
  clipId,
1166
1181
  extractPeaks(
1167
1182
  cached,
1168
- samplesPerPixel,
1183
+ effectiveScale,
1169
1184
  isMono,
1170
1185
  offsets?.offsetSamples,
1171
1186
  offsets?.durationSamples
@@ -1176,8 +1191,42 @@ var PeakPipeline = class {
1176
1191
  }
1177
1192
  }
1178
1193
  }
1194
+ if (clampedCount > 0) {
1195
+ console.warn(
1196
+ "[dawcore] Requested zoom " + samplesPerPixel + " spp is finer than pre-computed peaks (" + clampedScale + " spp) \u2014 " + clampedCount + " clip(s) using available resolution"
1197
+ );
1198
+ }
1179
1199
  return result;
1180
1200
  }
1201
+ /**
1202
+ * Clamp requested scale to cached WaveformData scale.
1203
+ * WaveformData.resample() can only go coarser — if the requested zoom is
1204
+ * finer than the cached data, use the cached scale. Set warn=true to log
1205
+ * (default); reextractPeaks passes false and logs a single summary instead.
1206
+ */
1207
+ _clampScale(waveformData, requestedScale, warn = true) {
1208
+ if (requestedScale < waveformData.scale) {
1209
+ if (warn) {
1210
+ console.warn(
1211
+ "[dawcore] Requested zoom " + requestedScale + " spp is finer than pre-computed peaks (" + waveformData.scale + " spp) \u2014 using available resolution"
1212
+ );
1213
+ }
1214
+ return waveformData.scale;
1215
+ }
1216
+ return requestedScale;
1217
+ }
1218
+ /**
1219
+ * Return the coarsest (largest) scale among cached WaveformData entries
1220
+ * that correspond to the given clip buffers. Returns 0 if none are cached.
1221
+ */
1222
+ getMaxCachedScale(clipBuffers) {
1223
+ let max = 0;
1224
+ for (const audioBuffer of clipBuffers.values()) {
1225
+ const cached = this._cache.get(audioBuffer);
1226
+ if (cached && cached.scale > max) max = cached.scale;
1227
+ }
1228
+ return max;
1229
+ }
1181
1230
  terminate() {
1182
1231
  this._worker?.terminate();
1183
1232
  this._worker = null;
@@ -2229,6 +2278,13 @@ var ClipPointerHandler = class {
2229
2278
  this._isDragging = false;
2230
2279
  this._lastDeltaPx = 0;
2231
2280
  this._cumulativeDeltaSamples = 0;
2281
+ if (this._host.engine) {
2282
+ this._host.engine.beginTransaction();
2283
+ } else {
2284
+ console.warn(
2285
+ "[dawcore] beginDrag: engine unavailable, drag mutations will not be grouped for undo"
2286
+ );
2287
+ }
2232
2288
  if (mode === "trim-left" || mode === "trim-right") {
2233
2289
  const container = this._host.shadowRoot?.querySelector(
2234
2290
  `.clip-container[data-clip-id="${clipId}"]`
@@ -2267,8 +2323,8 @@ var ClipPointerHandler = class {
2267
2323
  const incrementalDeltaPx = totalDeltaPx - this._lastDeltaPx;
2268
2324
  this._lastDeltaPx = totalDeltaPx;
2269
2325
  const incrementalDeltaSamples = Math.round(incrementalDeltaPx * this._host.samplesPerPixel);
2270
- this._cumulativeDeltaSamples += incrementalDeltaSamples;
2271
- engine.moveClip(this._trackId, this._clipId, incrementalDeltaSamples, true);
2326
+ const applied = engine.moveClip(this._trackId, this._clipId, incrementalDeltaSamples, true);
2327
+ this._cumulativeDeltaSamples += applied;
2272
2328
  } else {
2273
2329
  const boundary = this._mode === "trim-left" ? "left" : "right";
2274
2330
  const rawDeltaSamples = Math.round(totalDeltaPx * this._host.samplesPerPixel);
@@ -2357,9 +2413,18 @@ var ClipPointerHandler = class {
2357
2413
  }
2358
2414
  })
2359
2415
  );
2416
+ } else {
2417
+ console.warn(
2418
+ "[dawcore] engine unavailable at trim drop \u2014 trim not applied for clip " + this._clipId
2419
+ );
2360
2420
  }
2361
2421
  }
2362
2422
  } finally {
2423
+ if (this._isDragging && this._cumulativeDeltaSamples !== 0) {
2424
+ this._host.engine?.commitTransaction();
2425
+ } else {
2426
+ this._host.engine?.abortTransaction();
2427
+ }
2363
2428
  this._reset();
2364
2429
  }
2365
2430
  }
@@ -2470,6 +2535,7 @@ async function loadFiles(host, files) {
2470
2535
  clips: [
2471
2536
  {
2472
2537
  src: "",
2538
+ peaksSrc: "",
2473
2539
  start: 0,
2474
2540
  duration: audioBuffer.duration,
2475
2541
  offset: 0,
@@ -2555,6 +2621,7 @@ function addRecordedClip(host, trackId, buf, startSample, durSamples, offsetSamp
2555
2621
  const sr = host.effectiveSampleRate;
2556
2622
  const clipDesc = {
2557
2623
  src: "",
2624
+ peaksSrc: "",
2558
2625
  start: startSample / sr,
2559
2626
  duration: durSamples / sr,
2560
2627
  offset: 0,
@@ -2595,6 +2662,35 @@ function addRecordedClip(host, trackId, buf, startSample, durSamples, offsetSamp
2595
2662
 
2596
2663
  // src/interactions/split-handler.ts
2597
2664
  function splitAtPlayhead(host) {
2665
+ const wasPlaying = host.isPlaying;
2666
+ const time = host.currentTime;
2667
+ if (!canSplitAtTime(host, time)) return false;
2668
+ if (wasPlaying) {
2669
+ host.stop();
2670
+ }
2671
+ let result;
2672
+ try {
2673
+ result = performSplit(host, time);
2674
+ } catch (err) {
2675
+ console.warn("[dawcore] splitAtPlayhead failed: " + String(err));
2676
+ result = false;
2677
+ }
2678
+ if (wasPlaying) {
2679
+ host.play(time);
2680
+ }
2681
+ return result;
2682
+ }
2683
+ function canSplitAtTime(host, time) {
2684
+ const { engine } = host;
2685
+ if (!engine) return false;
2686
+ const state5 = engine.getState();
2687
+ if (!state5.selectedTrackId) return false;
2688
+ const track = state5.tracks.find((t) => t.id === state5.selectedTrackId);
2689
+ if (!track) return false;
2690
+ const atSample = Math.round(time * host.effectiveSampleRate);
2691
+ return !!findClipAtSample(track.clips, atSample);
2692
+ }
2693
+ function performSplit(host, time) {
2598
2694
  const { engine } = host;
2599
2695
  if (!engine) return false;
2600
2696
  const stateBefore = engine.getState();
@@ -2602,7 +2698,7 @@ function splitAtPlayhead(host) {
2602
2698
  if (!selectedTrackId) return false;
2603
2699
  const track = tracks.find((t) => t.id === selectedTrackId);
2604
2700
  if (!track) return false;
2605
- const atSample = Math.round(host.currentTime * host.effectiveSampleRate);
2701
+ const atSample = Math.round(time * host.effectiveSampleRate);
2606
2702
  const clip = findClipAtSample(track.clips, atSample);
2607
2703
  if (!clip) return false;
2608
2704
  const originalClipId = clip.id;
@@ -2727,11 +2823,29 @@ function findAudioBufferForClip(host, clip, track) {
2727
2823
  return null;
2728
2824
  }
2729
2825
 
2826
+ // src/interactions/peaks-loader.ts
2827
+ var import_waveform_data2 = __toESM(require("waveform-data"));
2828
+ async function loadWaveformDataFromUrl(src) {
2829
+ const response = await fetch(src);
2830
+ if (!response.ok) {
2831
+ throw new Error("[dawcore] Failed to fetch peaks data: " + response.statusText);
2832
+ }
2833
+ const { pathname } = new URL(src, globalThis.location?.href ?? "http://localhost");
2834
+ const isBinary = pathname.toLowerCase().endsWith(".dat");
2835
+ if (isBinary) {
2836
+ const arrayBuffer = await response.arrayBuffer();
2837
+ return import_waveform_data2.default.create(arrayBuffer);
2838
+ } else {
2839
+ const json = await response.json();
2840
+ return import_waveform_data2.default.create(json);
2841
+ }
2842
+ }
2843
+
2730
2844
  // src/elements/daw-editor.ts
2731
2845
  var DawEditorElement = class extends import_lit12.LitElement {
2732
2846
  constructor() {
2733
2847
  super(...arguments);
2734
- this.samplesPerPixel = 1024;
2848
+ this._samplesPerPixel = 1024;
2735
2849
  this.waveHeight = 128;
2736
2850
  this.timescale = false;
2737
2851
  this.mono = false;
@@ -2758,9 +2872,12 @@ var DawEditorElement = class extends import_lit12.LitElement {
2758
2872
  this._engine = null;
2759
2873
  this._enginePromise = null;
2760
2874
  this._audioCache = /* @__PURE__ */ new Map();
2875
+ this._peaksCache = /* @__PURE__ */ new Map();
2761
2876
  this._clipBuffers = /* @__PURE__ */ new Map();
2762
2877
  this._clipOffsets = /* @__PURE__ */ new Map();
2763
2878
  this._peakPipeline = new PeakPipeline();
2879
+ /** Coarsest scale from pre-computed peaks — zoom cannot go finer than this. 0 = no limit. */
2880
+ this._minSamplesPerPixel = 0;
2764
2881
  this._trackElements = /* @__PURE__ */ new Map();
2765
2882
  this._childObserver = null;
2766
2883
  this._audioResume = new AudioResumeController(this);
@@ -2809,6 +2926,19 @@ var DawEditorElement = class extends import_lit12.LitElement {
2809
2926
  this._onTrackControl = (e) => {
2810
2927
  const { trackId, prop, value } = e.detail ?? {};
2811
2928
  if (!trackId || !prop || !DawEditorElement._CONTROL_PROPS.has(prop)) return;
2929
+ if (this._selectedTrackId !== trackId) {
2930
+ this._setSelectedTrackId(trackId);
2931
+ if (this._engine) {
2932
+ this._engine.selectTrack(trackId);
2933
+ }
2934
+ this.dispatchEvent(
2935
+ new CustomEvent("daw-track-select", {
2936
+ bubbles: true,
2937
+ composed: true,
2938
+ detail: { trackId }
2939
+ })
2940
+ );
2941
+ }
2812
2942
  const oldDescriptor = this._tracks.get(trackId);
2813
2943
  if (oldDescriptor) {
2814
2944
  const descriptor = { ...oldDescriptor, [prop]: value };
@@ -2866,20 +2996,24 @@ var DawEditorElement = class extends import_lit12.LitElement {
2866
2996
  );
2867
2997
  }
2868
2998
  };
2869
- this._onKeyDown = (e) => {
2870
- if (!this.interactiveClips) return;
2871
- if (e.key === "s" || e.key === "S") {
2872
- if (e.ctrlKey || e.metaKey || e.altKey) return;
2873
- const tag = e.target?.tagName;
2874
- if (tag === "INPUT" || tag === "TEXTAREA") return;
2875
- if (e.target?.isContentEditable) return;
2876
- e.preventDefault();
2877
- this.splitAtPlayhead();
2878
- }
2879
- };
2880
2999
  // --- Recording ---
2881
3000
  this.recordingStream = null;
2882
3001
  }
3002
+ get samplesPerPixel() {
3003
+ return this._samplesPerPixel;
3004
+ }
3005
+ set samplesPerPixel(value) {
3006
+ const old = this._samplesPerPixel;
3007
+ if (!Number.isFinite(value) || value <= 0) return;
3008
+ const clamped = this._minSamplesPerPixel > 0 && value < this._minSamplesPerPixel ? this._minSamplesPerPixel : value;
3009
+ if (clamped !== value) {
3010
+ console.warn(
3011
+ "[dawcore] Zoom " + value + " spp rejected \u2014 pre-computed peaks limit is " + this._minSamplesPerPixel + " spp"
3012
+ );
3013
+ }
3014
+ this._samplesPerPixel = clamped;
3015
+ this.requestUpdate("samplesPerPixel", old);
3016
+ }
2883
3017
  get _clipHandler() {
2884
3018
  return this.interactiveClips ? this._clipPointer : null;
2885
3019
  }
@@ -2942,10 +3076,6 @@ var DawEditorElement = class extends import_lit12.LitElement {
2942
3076
  // --- Lifecycle ---
2943
3077
  connectedCallback() {
2944
3078
  super.connectedCallback();
2945
- if (!this.hasAttribute("tabindex")) {
2946
- this.setAttribute("tabindex", "0");
2947
- }
2948
- this.addEventListener("keydown", this._onKeyDown);
2949
3079
  this.addEventListener("daw-track-connected", this._onTrackConnected);
2950
3080
  this.addEventListener("daw-track-update", this._onTrackUpdate);
2951
3081
  this.addEventListener("daw-track-control", this._onTrackControl);
@@ -2971,7 +3101,6 @@ var DawEditorElement = class extends import_lit12.LitElement {
2971
3101
  }
2972
3102
  disconnectedCallback() {
2973
3103
  super.disconnectedCallback();
2974
- this.removeEventListener("keydown", this._onKeyDown);
2975
3104
  this.removeEventListener("daw-track-connected", this._onTrackConnected);
2976
3105
  this.removeEventListener("daw-track-update", this._onTrackUpdate);
2977
3106
  this.removeEventListener("daw-track-control", this._onTrackControl);
@@ -2980,9 +3109,11 @@ var DawEditorElement = class extends import_lit12.LitElement {
2980
3109
  this._childObserver = null;
2981
3110
  this._trackElements.clear();
2982
3111
  this._audioCache.clear();
3112
+ this._peaksCache.clear();
2983
3113
  this._clipBuffers.clear();
2984
3114
  this._clipOffsets.clear();
2985
3115
  this._peakPipeline.terminate();
3116
+ this._minSamplesPerPixel = 0;
2986
3117
  try {
2987
3118
  this._disposeEngine();
2988
3119
  } catch (err) {
@@ -3032,6 +3163,7 @@ var DawEditorElement = class extends import_lit12.LitElement {
3032
3163
  if (this._engine) {
3033
3164
  this._engine.removeTrack(trackId);
3034
3165
  }
3166
+ this._minSamplesPerPixel = this._peakPipeline.getMaxCachedScale(this._clipBuffers);
3035
3167
  if (nextEngine.size === 0) {
3036
3168
  this._currentTime = 0;
3037
3169
  this._stopPlayhead();
@@ -3043,6 +3175,7 @@ var DawEditorElement = class extends import_lit12.LitElement {
3043
3175
  if (clipEls.length === 0 && trackEl.src) {
3044
3176
  clips.push({
3045
3177
  src: trackEl.src,
3178
+ peaksSrc: "",
3046
3179
  start: 0,
3047
3180
  duration: 0,
3048
3181
  offset: 0,
@@ -3056,6 +3189,7 @@ var DawEditorElement = class extends import_lit12.LitElement {
3056
3189
  for (const clipEl of clipEls) {
3057
3190
  clips.push({
3058
3191
  src: clipEl.src,
3192
+ peaksSrc: clipEl.peaksSrc,
3059
3193
  start: clipEl.start,
3060
3194
  duration: clipEl.duration,
3061
3195
  offset: clipEl.offset,
@@ -3083,7 +3217,77 @@ var DawEditorElement = class extends import_lit12.LitElement {
3083
3217
  const clips = [];
3084
3218
  for (const clipDesc of descriptor.clips) {
3085
3219
  if (!clipDesc.src) continue;
3086
- const audioBuffer = await this._fetchAndDecode(clipDesc.src);
3220
+ const waveformDataPromise = clipDesc.peaksSrc ? this._fetchPeaks(clipDesc.peaksSrc) : null;
3221
+ const audioPromise = this._fetchAndDecode(clipDesc.src);
3222
+ let waveformData = null;
3223
+ if (waveformDataPromise) {
3224
+ try {
3225
+ waveformData = await waveformDataPromise;
3226
+ } catch (err) {
3227
+ console.warn(
3228
+ "[dawcore] Failed to load peaks from " + clipDesc.peaksSrc + ": " + String(err) + " \u2014 falling back to AudioBuffer generation"
3229
+ );
3230
+ }
3231
+ }
3232
+ if (waveformData) {
3233
+ const clip2 = (0, import_core4.createClipFromSeconds)({
3234
+ waveformData,
3235
+ startTime: clipDesc.start,
3236
+ duration: clipDesc.duration || waveformData.duration,
3237
+ offset: clipDesc.offset,
3238
+ gain: clipDesc.gain,
3239
+ name: clipDesc.name,
3240
+ sampleRate: waveformData.sample_rate,
3241
+ sourceDuration: waveformData.duration
3242
+ });
3243
+ const effectiveScale = Math.max(this.samplesPerPixel, waveformData.scale);
3244
+ const peakData2 = extractPeaks(
3245
+ waveformData,
3246
+ effectiveScale,
3247
+ this.mono,
3248
+ clip2.offsetSamples,
3249
+ clip2.durationSamples
3250
+ );
3251
+ this._clipOffsets.set(clip2.id, {
3252
+ offsetSamples: clip2.offsetSamples,
3253
+ durationSamples: clip2.durationSamples
3254
+ });
3255
+ this._peaksData = new Map(this._peaksData).set(clip2.id, peakData2);
3256
+ this._minSamplesPerPixel = Math.max(this._minSamplesPerPixel, waveformData.scale);
3257
+ const previewTrack = (0, import_core4.createTrack)({
3258
+ name: descriptor.name,
3259
+ clips: [clip2],
3260
+ volume: descriptor.volume,
3261
+ pan: descriptor.pan,
3262
+ muted: descriptor.muted,
3263
+ soloed: descriptor.soloed
3264
+ });
3265
+ previewTrack.id = trackId;
3266
+ this._engineTracks = new Map(this._engineTracks).set(trackId, previewTrack);
3267
+ this._recomputeDuration();
3268
+ let audioBuffer2;
3269
+ try {
3270
+ audioBuffer2 = await audioPromise;
3271
+ } catch (audioErr) {
3272
+ const nextPeaks = new Map(this._peaksData);
3273
+ nextPeaks.delete(clip2.id);
3274
+ this._peaksData = nextPeaks;
3275
+ this._clipOffsets.delete(clip2.id);
3276
+ const nextEngine = new Map(this._engineTracks);
3277
+ nextEngine.delete(trackId);
3278
+ this._engineTracks = nextEngine;
3279
+ this._minSamplesPerPixel = this._peakPipeline.getMaxCachedScale(this._clipBuffers);
3280
+ this._recomputeDuration();
3281
+ throw audioErr;
3282
+ }
3283
+ this._resolvedSampleRate = audioBuffer2.sampleRate;
3284
+ const updatedClip = { ...clip2, audioBuffer: audioBuffer2 };
3285
+ this._clipBuffers = new Map(this._clipBuffers).set(clip2.id, audioBuffer2);
3286
+ this._peakPipeline.cacheWaveformData(audioBuffer2, waveformData);
3287
+ clips.push(updatedClip);
3288
+ continue;
3289
+ }
3290
+ const audioBuffer = await audioPromise;
3087
3291
  this._resolvedSampleRate = audioBuffer.sampleRate;
3088
3292
  const clip = (0, import_core4.createClipFromSeconds)({
3089
3293
  audioBuffer,
@@ -3165,6 +3369,16 @@ var DawEditorElement = class extends import_lit12.LitElement {
3165
3369
  throw err;
3166
3370
  }
3167
3371
  }
3372
+ _fetchPeaks(src) {
3373
+ const cached = this._peaksCache.get(src);
3374
+ if (cached) return cached;
3375
+ const promise = loadWaveformDataFromUrl(src).catch((err) => {
3376
+ this._peaksCache.delete(src);
3377
+ throw err;
3378
+ });
3379
+ this._peaksCache.set(src, promise);
3380
+ return promise;
3381
+ }
3168
3382
  _recomputeDuration() {
3169
3383
  let maxSample = 0;
3170
3384
  for (const track of this._engineTracks.values()) {
@@ -3263,18 +3477,71 @@ var DawEditorElement = class extends import_lit12.LitElement {
3263
3477
  this._stopPlayhead();
3264
3478
  this.dispatchEvent(new CustomEvent("daw-stop", { bubbles: true, composed: true }));
3265
3479
  }
3480
+ /** Toggle between play and pause. */
3481
+ togglePlayPause() {
3482
+ if (this._isPlaying) {
3483
+ this.pause();
3484
+ } else {
3485
+ this.play();
3486
+ }
3487
+ }
3266
3488
  seekTo(time) {
3267
- if (!this._engine) return;
3268
- this._engine.seek(time);
3269
- this._currentTime = time;
3489
+ if (!this._engine) {
3490
+ console.warn("[dawcore] seekTo: engine not ready, call ignored");
3491
+ return;
3492
+ }
3493
+ if (this._isPlaying) {
3494
+ this.stop();
3495
+ this.play(time);
3496
+ } else {
3497
+ this._engine.seek(time);
3498
+ this._currentTime = time;
3499
+ this._stopPlayhead();
3500
+ }
3501
+ }
3502
+ /** Undo the last structural edit. */
3503
+ undo() {
3504
+ if (!this._engine) {
3505
+ console.warn("[dawcore] undo: engine not ready, call ignored");
3506
+ return;
3507
+ }
3508
+ this._engine.undo();
3509
+ }
3510
+ /** Redo the last undone edit. */
3511
+ redo() {
3512
+ if (!this._engine) {
3513
+ console.warn("[dawcore] redo: engine not ready, call ignored");
3514
+ return;
3515
+ }
3516
+ this._engine.redo();
3517
+ }
3518
+ /** Whether undo is available. */
3519
+ get canUndo() {
3520
+ return this._engine?.canUndo ?? false;
3521
+ }
3522
+ /** Whether redo is available. */
3523
+ get canRedo() {
3524
+ return this._engine?.canRedo ?? false;
3270
3525
  }
3271
3526
  /** Split the clip under the playhead on the selected track. */
3272
3527
  splitAtPlayhead() {
3273
3528
  return splitAtPlayhead({
3274
3529
  effectiveSampleRate: this.effectiveSampleRate,
3275
3530
  currentTime: this._currentTime,
3531
+ isPlaying: this._isPlaying,
3276
3532
  engine: this._engine,
3277
- dispatchEvent: (e) => this.dispatchEvent(e)
3533
+ dispatchEvent: (e) => this.dispatchEvent(e),
3534
+ stop: () => {
3535
+ this._engine?.stop();
3536
+ this._stopPlayhead();
3537
+ },
3538
+ // Call engine.play directly (synchronous) — not the async editor play()
3539
+ // which yields to microtask queue via await engine.init(). Engine is
3540
+ // already initialized at split time; the async gap causes audio desync.
3541
+ play: (time) => {
3542
+ this._engine?.play(time);
3543
+ this._startPlayhead();
3544
+ }
3278
3545
  });
3279
3546
  }
3280
3547
  get currentTime() {
@@ -3524,8 +3791,8 @@ DawEditorElement.styles = [
3524
3791
  ];
3525
3792
  DawEditorElement._CONTROL_PROPS = /* @__PURE__ */ new Set(["volume", "pan", "muted", "soloed"]);
3526
3793
  __decorateClass([
3527
- (0, import_decorators10.property)({ type: Number, attribute: "samples-per-pixel" })
3528
- ], DawEditorElement.prototype, "samplesPerPixel", 2);
3794
+ (0, import_decorators10.property)({ type: Number, attribute: "samples-per-pixel", noAccessor: true })
3795
+ ], DawEditorElement.prototype, "samplesPerPixel", 1);
3529
3796
  __decorateClass([
3530
3797
  (0, import_decorators10.property)({ type: Number, attribute: "wave-height" })
3531
3798
  ], DawEditorElement.prototype, "waveHeight", 2);
@@ -3874,12 +4141,183 @@ __decorateClass([
3874
4141
  DawRecordButtonElement = __decorateClass([
3875
4142
  (0, import_decorators13.customElement)("daw-record-button")
3876
4143
  ], DawRecordButtonElement);
4144
+
4145
+ // src/elements/daw-keyboard-shortcuts.ts
4146
+ var import_lit16 = require("lit");
4147
+ var import_decorators14 = require("lit/decorators.js");
4148
+ var import_core5 = require("@waveform-playlist/core");
4149
+ var DawKeyboardShortcutsElement = class extends import_lit16.LitElement {
4150
+ constructor() {
4151
+ super(...arguments);
4152
+ this.playback = false;
4153
+ this.splitting = false;
4154
+ this.undo = false;
4155
+ // --- JS properties for remapping ---
4156
+ this.playbackShortcuts = null;
4157
+ this.splittingShortcuts = null;
4158
+ this.undoShortcuts = null;
4159
+ /** Additional custom shortcuts. */
4160
+ this.customShortcuts = [];
4161
+ this._editor = null;
4162
+ this._cachedShortcuts = null;
4163
+ // --- Event handler ---
4164
+ this._onKeyDown = (e) => {
4165
+ const shortcuts = this.shortcuts;
4166
+ if (shortcuts.length === 0) return;
4167
+ try {
4168
+ (0, import_core5.handleKeyboardEvent)(e, shortcuts, true);
4169
+ } catch (err) {
4170
+ console.warn("[dawcore] Keyboard shortcut failed (key=" + e.key + "): " + String(err));
4171
+ const target = this._editor ?? this;
4172
+ target.dispatchEvent(
4173
+ new CustomEvent("daw-error", {
4174
+ bubbles: true,
4175
+ composed: true,
4176
+ detail: { operation: "keyboard-shortcut", key: e.key, error: err }
4177
+ })
4178
+ );
4179
+ }
4180
+ };
4181
+ }
4182
+ /** All active shortcuts (read-only, cached). */
4183
+ get shortcuts() {
4184
+ if (!this._cachedShortcuts) {
4185
+ this._cachedShortcuts = this._buildShortcuts();
4186
+ }
4187
+ return this._cachedShortcuts;
4188
+ }
4189
+ /** Invalidate cached shortcuts when Lit properties change. */
4190
+ updated() {
4191
+ this._cachedShortcuts = null;
4192
+ }
4193
+ // --- Lifecycle ---
4194
+ connectedCallback() {
4195
+ super.connectedCallback();
4196
+ this._editor = this.closest("daw-editor");
4197
+ if (!this._editor) {
4198
+ console.warn(
4199
+ "[dawcore] <daw-keyboard-shortcuts> must be placed inside a <daw-editor>. Preset shortcuts (playback, splitting, undo) will be inactive; only customShortcuts will fire."
4200
+ );
4201
+ }
4202
+ document.addEventListener("keydown", this._onKeyDown);
4203
+ }
4204
+ disconnectedCallback() {
4205
+ super.disconnectedCallback();
4206
+ document.removeEventListener("keydown", this._onKeyDown);
4207
+ this._editor = null;
4208
+ }
4209
+ // No shadow DOM — render-less element
4210
+ createRenderRoot() {
4211
+ return this;
4212
+ }
4213
+ // --- Shortcut building ---
4214
+ _buildShortcuts() {
4215
+ const editor = this._editor;
4216
+ if (!editor) return this.customShortcuts;
4217
+ const result = [];
4218
+ if (this.playback) {
4219
+ const map = this.playbackShortcuts;
4220
+ result.push(
4221
+ this._makeShortcut(
4222
+ map?.playPause ?? { key: " ", ctrlKey: false, metaKey: false },
4223
+ () => editor.togglePlayPause(),
4224
+ "Play/Pause"
4225
+ ),
4226
+ this._makeShortcut(
4227
+ map?.stop ?? { key: "Escape", ctrlKey: false, metaKey: false },
4228
+ () => editor.stop(),
4229
+ "Stop"
4230
+ ),
4231
+ this._makeShortcut(
4232
+ map?.rewindToStart ?? { key: "0", ctrlKey: false, metaKey: false },
4233
+ () => editor.seekTo(0),
4234
+ "Rewind to start"
4235
+ )
4236
+ );
4237
+ }
4238
+ if (this.splitting) {
4239
+ const map = this.splittingShortcuts;
4240
+ const binding = map?.splitAtPlayhead ?? {
4241
+ key: "s",
4242
+ ctrlKey: false,
4243
+ metaKey: false,
4244
+ altKey: false
4245
+ };
4246
+ result.push(this._makeShortcut(binding, () => editor.splitAtPlayhead(), "Split at playhead"));
4247
+ }
4248
+ if (this.undo) {
4249
+ const map = this.undoShortcuts;
4250
+ const undoBinding = map?.undo ?? { key: "z" };
4251
+ const redoBinding = map?.redo ?? { key: "z", shiftKey: true };
4252
+ if (undoBinding.ctrlKey === void 0 && undoBinding.metaKey === void 0) {
4253
+ const undoShift = undoBinding.shiftKey === void 0 ? { shiftKey: false } : {};
4254
+ result.push(
4255
+ this._makeShortcut(
4256
+ { ...undoBinding, ctrlKey: true, ...undoShift },
4257
+ () => editor.undo(),
4258
+ "Undo"
4259
+ ),
4260
+ this._makeShortcut(
4261
+ { ...undoBinding, metaKey: true, ...undoShift },
4262
+ () => editor.undo(),
4263
+ "Undo"
4264
+ )
4265
+ );
4266
+ } else {
4267
+ result.push(this._makeShortcut(undoBinding, () => editor.undo(), "Undo"));
4268
+ }
4269
+ if (redoBinding.ctrlKey === void 0 && redoBinding.metaKey === void 0) {
4270
+ const redoShift = redoBinding.shiftKey === void 0 ? { shiftKey: true } : {};
4271
+ result.push(
4272
+ this._makeShortcut(
4273
+ { ...redoBinding, ctrlKey: true, ...redoShift },
4274
+ () => editor.redo(),
4275
+ "Redo"
4276
+ ),
4277
+ this._makeShortcut(
4278
+ { ...redoBinding, metaKey: true, ...redoShift },
4279
+ () => editor.redo(),
4280
+ "Redo"
4281
+ )
4282
+ );
4283
+ } else {
4284
+ result.push(this._makeShortcut(redoBinding, () => editor.redo(), "Redo"));
4285
+ }
4286
+ }
4287
+ result.push(...this.customShortcuts);
4288
+ return result;
4289
+ }
4290
+ _makeShortcut(binding, action, description) {
4291
+ return {
4292
+ key: binding.key,
4293
+ ...binding.ctrlKey !== void 0 && { ctrlKey: binding.ctrlKey },
4294
+ ...binding.shiftKey !== void 0 && { shiftKey: binding.shiftKey },
4295
+ ...binding.metaKey !== void 0 && { metaKey: binding.metaKey },
4296
+ ...binding.altKey !== void 0 && { altKey: binding.altKey },
4297
+ action,
4298
+ description
4299
+ };
4300
+ }
4301
+ };
4302
+ __decorateClass([
4303
+ (0, import_decorators14.property)({ type: Boolean })
4304
+ ], DawKeyboardShortcutsElement.prototype, "playback", 2);
4305
+ __decorateClass([
4306
+ (0, import_decorators14.property)({ type: Boolean })
4307
+ ], DawKeyboardShortcutsElement.prototype, "splitting", 2);
4308
+ __decorateClass([
4309
+ (0, import_decorators14.property)({ type: Boolean })
4310
+ ], DawKeyboardShortcutsElement.prototype, "undo", 2);
4311
+ DawKeyboardShortcutsElement = __decorateClass([
4312
+ (0, import_decorators14.customElement)("daw-keyboard-shortcuts")
4313
+ ], DawKeyboardShortcutsElement);
3877
4314
  // Annotate the CommonJS export names for ESM import in node:
3878
4315
  0 && (module.exports = {
3879
4316
  AudioResumeController,
3880
4317
  ClipPointerHandler,
3881
4318
  DawClipElement,
3882
4319
  DawEditorElement,
4320
+ DawKeyboardShortcutsElement,
3883
4321
  DawPauseButtonElement,
3884
4322
  DawPlayButtonElement,
3885
4323
  DawPlayheadElement,