@dawcore/components 0.0.1 → 0.0.2

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
@@ -551,7 +551,7 @@ DawTransportElement = __decorateClass([
551
551
 
552
552
  // src/elements/daw-play-button.ts
553
553
  import { html as html3 } from "lit";
554
- import { customElement as customElement6 } from "lit/decorators.js";
554
+ import { customElement as customElement6, state } from "lit/decorators.js";
555
555
 
556
556
  // src/elements/daw-transport-button.ts
557
557
  import { LitElement as LitElement6, css as css3 } from "lit";
@@ -581,9 +581,40 @@ DawTransportButton.styles = css3`
581
581
 
582
582
  // src/elements/daw-play-button.ts
583
583
  var DawPlayButtonElement = class extends DawTransportButton {
584
+ constructor() {
585
+ super(...arguments);
586
+ this._isRecording = false;
587
+ this._targetRef = null;
588
+ this._onRecStart = () => {
589
+ this._isRecording = true;
590
+ };
591
+ this._onRecEnd = () => {
592
+ this._isRecording = false;
593
+ };
594
+ }
595
+ connectedCallback() {
596
+ super.connectedCallback();
597
+ requestAnimationFrame(() => {
598
+ const target = this.target;
599
+ if (!target) return;
600
+ this._targetRef = target;
601
+ target.addEventListener("daw-recording-start", this._onRecStart);
602
+ target.addEventListener("daw-recording-complete", this._onRecEnd);
603
+ target.addEventListener("daw-recording-error", this._onRecEnd);
604
+ });
605
+ }
606
+ disconnectedCallback() {
607
+ super.disconnectedCallback();
608
+ if (this._targetRef) {
609
+ this._targetRef.removeEventListener("daw-recording-start", this._onRecStart);
610
+ this._targetRef.removeEventListener("daw-recording-complete", this._onRecEnd);
611
+ this._targetRef.removeEventListener("daw-recording-error", this._onRecEnd);
612
+ this._targetRef = null;
613
+ }
614
+ }
584
615
  render() {
585
616
  return html3`
586
- <button part="button" @click=${this._onClick}>
617
+ <button part="button" ?disabled=${this._isRecording} @click=${this._onClick}>
587
618
  <slot>Play</slot>
588
619
  </button>
589
620
  `;
@@ -599,17 +630,53 @@ var DawPlayButtonElement = class extends DawTransportButton {
599
630
  target.play();
600
631
  }
601
632
  };
633
+ __decorateClass([
634
+ state()
635
+ ], DawPlayButtonElement.prototype, "_isRecording", 2);
602
636
  DawPlayButtonElement = __decorateClass([
603
637
  customElement6("daw-play-button")
604
638
  ], DawPlayButtonElement);
605
639
 
606
640
  // src/elements/daw-pause-button.ts
607
- import { html as html4 } from "lit";
608
- import { customElement as customElement7 } from "lit/decorators.js";
641
+ import { html as html4, css as css4 } from "lit";
642
+ import { customElement as customElement7, state as state2 } from "lit/decorators.js";
609
643
  var DawPauseButtonElement = class extends DawTransportButton {
644
+ constructor() {
645
+ super(...arguments);
646
+ this._isPaused = false;
647
+ this._isRecording = false;
648
+ this._targetRef = null;
649
+ this._onRecStart = () => {
650
+ this._isRecording = true;
651
+ };
652
+ this._onRecEnd = () => {
653
+ this._isRecording = false;
654
+ this._isPaused = false;
655
+ };
656
+ }
657
+ connectedCallback() {
658
+ super.connectedCallback();
659
+ requestAnimationFrame(() => {
660
+ const target = this.target;
661
+ if (!target) return;
662
+ this._targetRef = target;
663
+ target.addEventListener("daw-recording-start", this._onRecStart);
664
+ target.addEventListener("daw-recording-complete", this._onRecEnd);
665
+ target.addEventListener("daw-recording-error", this._onRecEnd);
666
+ });
667
+ }
668
+ disconnectedCallback() {
669
+ super.disconnectedCallback();
670
+ if (this._targetRef) {
671
+ this._targetRef.removeEventListener("daw-recording-start", this._onRecStart);
672
+ this._targetRef.removeEventListener("daw-recording-complete", this._onRecEnd);
673
+ this._targetRef.removeEventListener("daw-recording-error", this._onRecEnd);
674
+ this._targetRef = null;
675
+ }
676
+ }
610
677
  render() {
611
678
  return html4`
612
- <button part="button" @click=${this._onClick}>
679
+ <button part="button" ?data-paused=${this._isPaused} @click=${this._onClick}>
613
680
  <slot>Pause</slot>
614
681
  </button>
615
682
  `;
@@ -622,9 +689,36 @@ var DawPauseButtonElement = class extends DawTransportButton {
622
689
  );
623
690
  return;
624
691
  }
625
- target.pause();
692
+ if (this._isRecording) {
693
+ if (this._isPaused) {
694
+ target.resumeRecording();
695
+ target.play(target.currentTime);
696
+ this._isPaused = false;
697
+ } else {
698
+ target.pauseRecording();
699
+ target.pause();
700
+ this._isPaused = true;
701
+ }
702
+ } else {
703
+ target.pause();
704
+ }
626
705
  }
627
706
  };
707
+ DawPauseButtonElement.styles = [
708
+ DawTransportButton.styles,
709
+ css4`
710
+ button[data-paused] {
711
+ background: rgba(255, 255, 255, 0.1);
712
+ border-color: var(--daw-controls-text, #e0d4c8);
713
+ }
714
+ `
715
+ ];
716
+ __decorateClass([
717
+ state2()
718
+ ], DawPauseButtonElement.prototype, "_isPaused", 2);
719
+ __decorateClass([
720
+ state2()
721
+ ], DawPauseButtonElement.prototype, "_isRecording", 2);
628
722
  DawPauseButtonElement = __decorateClass([
629
723
  customElement7("daw-pause-button")
630
724
  ], DawPauseButtonElement);
@@ -648,6 +742,9 @@ var DawStopButtonElement = class extends DawTransportButton {
648
742
  );
649
743
  return;
650
744
  }
745
+ if (target.isRecording) {
746
+ target.stopRecording();
747
+ }
651
748
  target.stop();
652
749
  }
653
750
  };
@@ -656,8 +753,8 @@ DawStopButtonElement = __decorateClass([
656
753
  ], DawStopButtonElement);
657
754
 
658
755
  // src/elements/daw-editor.ts
659
- import { LitElement as LitElement8, html as html7, css as css6 } from "lit";
660
- import { customElement as customElement10, property as property6, state } from "lit/decorators.js";
756
+ import { LitElement as LitElement8, html as html7, css as css7 } from "lit";
757
+ import { customElement as customElement10, property as property6, state as state3 } from "lit/decorators.js";
661
758
  import { createClipFromSeconds as createClipFromSeconds2, createTrack as createTrack2, clipPixelWidth } from "@waveform-playlist/core";
662
759
 
663
760
  // src/workers/peaksWorker.ts
@@ -979,20 +1076,22 @@ function extractPeaks(waveformData, samplesPerPixel, isMono, offsetSamples, dura
979
1076
 
980
1077
  // src/workers/peakPipeline.ts
981
1078
  var PeakPipeline = class {
982
- constructor() {
1079
+ constructor(baseScale = 128, bits = 16) {
983
1080
  this._worker = null;
984
1081
  this._cache = /* @__PURE__ */ new WeakMap();
985
1082
  this._inflight = /* @__PURE__ */ new WeakMap();
1083
+ this._baseScale = baseScale;
1084
+ this._bits = bits;
986
1085
  }
987
1086
  /**
988
1087
  * Generate PeakData for a clip from its AudioBuffer.
989
1088
  * Uses cached WaveformData when available; otherwise generates via worker.
990
- * The worker generates at `scale` (= samplesPerPixel) for exact rendering.
1089
+ * Worker generates at baseScale (default 128); extractPeaks resamples to the requested zoom.
991
1090
  */
992
- async generatePeaks(audioBuffer, samplesPerPixel, isMono) {
993
- const waveformData = await this._getWaveformData(audioBuffer, samplesPerPixel);
1091
+ async generatePeaks(audioBuffer, samplesPerPixel, isMono, offsetSamples, durationSamples) {
1092
+ const waveformData = await this._getWaveformData(audioBuffer);
994
1093
  try {
995
- return extractPeaks(waveformData, samplesPerPixel, isMono);
1094
+ return extractPeaks(waveformData, samplesPerPixel, isMono, offsetSamples, durationSamples);
996
1095
  } catch (err) {
997
1096
  console.warn("[dawcore] extractPeaks failed: " + String(err));
998
1097
  throw err;
@@ -1004,14 +1103,24 @@ var PeakPipeline = class {
1004
1103
  * Returns a new Map of clipId → PeakData. Clips without cached data or where
1005
1104
  * the target scale is finer than the cached base are skipped.
1006
1105
  */
1007
- reextractPeaks(clipBuffers, samplesPerPixel, isMono) {
1106
+ reextractPeaks(clipBuffers, samplesPerPixel, isMono, clipOffsets) {
1008
1107
  const result = /* @__PURE__ */ new Map();
1009
1108
  for (const [clipId, audioBuffer] of clipBuffers) {
1010
1109
  const cached = this._cache.get(audioBuffer);
1011
1110
  if (cached) {
1012
1111
  if (samplesPerPixel < cached.scale) continue;
1013
1112
  try {
1014
- result.set(clipId, extractPeaks(cached, samplesPerPixel, isMono));
1113
+ const offsets = clipOffsets?.get(clipId);
1114
+ result.set(
1115
+ clipId,
1116
+ extractPeaks(
1117
+ cached,
1118
+ samplesPerPixel,
1119
+ isMono,
1120
+ offsets?.offsetSamples,
1121
+ offsets?.durationSamples
1122
+ )
1123
+ );
1015
1124
  } catch (err) {
1016
1125
  console.warn("[dawcore] reextractPeaks failed for clip " + clipId + ": " + String(err));
1017
1126
  }
@@ -1023,9 +1132,9 @@ var PeakPipeline = class {
1023
1132
  this._worker?.terminate();
1024
1133
  this._worker = null;
1025
1134
  }
1026
- async _getWaveformData(audioBuffer, samplesPerPixel) {
1135
+ async _getWaveformData(audioBuffer) {
1027
1136
  const cached = this._cache.get(audioBuffer);
1028
- if (cached && cached.scale <= samplesPerPixel) return cached;
1137
+ if (cached) return cached;
1029
1138
  const inflight = this._inflight.get(audioBuffer);
1030
1139
  if (inflight) return inflight;
1031
1140
  if (!this._worker) {
@@ -1039,8 +1148,8 @@ var PeakPipeline = class {
1039
1148
  channels,
1040
1149
  length: audioBuffer.length,
1041
1150
  sampleRate: audioBuffer.sampleRate,
1042
- scale: samplesPerPixel,
1043
- bits: 16,
1151
+ scale: this._baseScale,
1152
+ bits: this._bits,
1044
1153
  splitChannels: true
1045
1154
  }).then((waveformData) => {
1046
1155
  this._cache.set(audioBuffer, waveformData);
@@ -1057,7 +1166,7 @@ var PeakPipeline = class {
1057
1166
  };
1058
1167
 
1059
1168
  // src/elements/daw-track-controls.ts
1060
- import { LitElement as LitElement7, html as html6, css as css4 } from "lit";
1169
+ import { LitElement as LitElement7, html as html6, css as css5 } from "lit";
1061
1170
  import { customElement as customElement9, property as property5 } from "lit/decorators.js";
1062
1171
  var DawTrackControlsElement = class extends LitElement7 {
1063
1172
  constructor() {
@@ -1157,11 +1266,11 @@ var DawTrackControlsElement = class extends LitElement7 {
1157
1266
  `;
1158
1267
  }
1159
1268
  };
1160
- DawTrackControlsElement.styles = css4`
1269
+ DawTrackControlsElement.styles = css5`
1161
1270
  :host {
1162
1271
  display: flex;
1163
1272
  flex-direction: column;
1164
- justify-content: center;
1273
+ justify-content: flex-start;
1165
1274
  box-sizing: border-box;
1166
1275
  padding: 6px 8px;
1167
1276
  background: var(--daw-controls-background, #0f0f1a);
@@ -1169,13 +1278,14 @@ DawTrackControlsElement.styles = css4`
1169
1278
  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
1170
1279
  font-family: system-ui, sans-serif;
1171
1280
  font-size: 11px;
1281
+ overflow: hidden;
1172
1282
  }
1173
1283
  .header {
1174
1284
  display: flex;
1175
1285
  align-items: center;
1176
1286
  justify-content: space-between;
1177
1287
  gap: 4px;
1178
- margin-bottom: 6px;
1288
+ margin-bottom: 3px;
1179
1289
  }
1180
1290
  .name {
1181
1291
  flex: 1;
@@ -1202,7 +1312,7 @@ DawTrackControlsElement.styles = css4`
1202
1312
  .buttons {
1203
1313
  display: flex;
1204
1314
  gap: 3px;
1205
- margin-bottom: 6px;
1315
+ margin-bottom: 3px;
1206
1316
  }
1207
1317
  .btn {
1208
1318
  background: rgba(255, 255, 255, 0.06);
@@ -1232,7 +1342,7 @@ DawTrackControlsElement.styles = css4`
1232
1342
  display: flex;
1233
1343
  align-items: center;
1234
1344
  gap: 4px;
1235
- height: 20px;
1345
+ height: 16px;
1236
1346
  }
1237
1347
  .slider-label {
1238
1348
  width: 50px;
@@ -1312,8 +1422,8 @@ DawTrackControlsElement = __decorateClass([
1312
1422
  ], DawTrackControlsElement);
1313
1423
 
1314
1424
  // src/styles/theme.ts
1315
- import { css as css5 } from "lit";
1316
- var hostStyles = css5`
1425
+ import { css as css6 } from "lit";
1426
+ var hostStyles = css6`
1317
1427
  :host {
1318
1428
  --daw-wave-color: #c49a6c;
1319
1429
  --daw-progress-color: #63c75f;
@@ -1329,6 +1439,77 @@ var hostStyles = css5`
1329
1439
  --daw-clip-header-text: #e0d4c8;
1330
1440
  }
1331
1441
  `;
1442
+ var clipStyles = css6`
1443
+ .clip-container {
1444
+ position: absolute;
1445
+ overflow: hidden;
1446
+ }
1447
+ .clip-header {
1448
+ position: relative;
1449
+ z-index: 1;
1450
+ height: 20px;
1451
+ background: var(--daw-clip-header-background, rgba(0, 0, 0, 0.4));
1452
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
1453
+ display: flex;
1454
+ align-items: center;
1455
+ padding: 0 6px;
1456
+ user-select: none;
1457
+ -webkit-user-drag: none;
1458
+ }
1459
+ .clip-header span {
1460
+ font-size: 10px;
1461
+ font-weight: 500;
1462
+ letter-spacing: 0.02em;
1463
+ font-family: system-ui, sans-serif;
1464
+ color: var(--daw-clip-header-text, #e0d4c8);
1465
+ white-space: nowrap;
1466
+ overflow: hidden;
1467
+ text-overflow: ellipsis;
1468
+ opacity: 0.8;
1469
+ }
1470
+ .clip-boundary {
1471
+ position: absolute;
1472
+ top: 0;
1473
+ width: 8px;
1474
+ height: 100%;
1475
+ z-index: 2;
1476
+ cursor: col-resize;
1477
+ background: transparent;
1478
+ border: none;
1479
+ touch-action: none;
1480
+ user-select: none;
1481
+ -webkit-user-drag: none;
1482
+ transition: background 0.1s, border-color 0.1s;
1483
+ }
1484
+ .clip-boundary[data-boundary-edge='left'] {
1485
+ left: 0;
1486
+ }
1487
+ .clip-boundary[data-boundary-edge='right'] {
1488
+ right: 0;
1489
+ }
1490
+ .clip-boundary[data-boundary-edge='left']:hover {
1491
+ background: rgba(255, 255, 255, 0.2);
1492
+ border-left: 2px solid rgba(255, 255, 255, 0.5);
1493
+ }
1494
+ .clip-boundary[data-boundary-edge='right']:hover {
1495
+ background: rgba(255, 255, 255, 0.2);
1496
+ border-right: 2px solid rgba(255, 255, 255, 0.5);
1497
+ }
1498
+ .clip-boundary[data-boundary-edge='left'].dragging {
1499
+ background: rgba(255, 255, 255, 0.4);
1500
+ border-left: 2px solid rgba(255, 255, 255, 0.8);
1501
+ }
1502
+ .clip-boundary[data-boundary-edge='right'].dragging {
1503
+ background: rgba(255, 255, 255, 0.4);
1504
+ border-right: 2px solid rgba(255, 255, 255, 0.8);
1505
+ }
1506
+ .clip-header[data-interactive] {
1507
+ cursor: grab;
1508
+ }
1509
+ .clip-header[data-interactive]:active {
1510
+ cursor: grabbing;
1511
+ }
1512
+ `;
1332
1513
 
1333
1514
  // src/controllers/viewport-controller.ts
1334
1515
  var OVERSCAN_MULTIPLIER = 1.5;
@@ -1511,6 +1692,9 @@ var RecordingController = class {
1511
1692
  }
1512
1693
  const channelCount = stream.getAudioTracks()[0]?.getSettings()?.channelCount ?? 1;
1513
1694
  const startSample = options.startSample ?? Math.floor(this._host._currentTime * this._host.effectiveSampleRate);
1695
+ const outputLatency = rawCtx.outputLatency ?? 0;
1696
+ const lookAhead = context.lookAhead ?? 0;
1697
+ const latencySamples = Math.floor((outputLatency + lookAhead) * rawCtx.sampleRate);
1514
1698
  const source = context.createMediaStreamSource(stream);
1515
1699
  const workletNode = context.createAudioWorkletNode("recording-processor", {
1516
1700
  channelCount,
@@ -1537,6 +1721,8 @@ var RecordingController = class {
1537
1721
  channelCount,
1538
1722
  bits,
1539
1723
  isFirstMessage: true,
1724
+ latencySamples,
1725
+ wasOverdub: options.overdub ?? false,
1540
1726
  _onTrackEnded: onTrackEnded,
1541
1727
  _audioTrack: audioTrack
1542
1728
  };
@@ -1557,6 +1743,9 @@ var RecordingController = class {
1557
1743
  })
1558
1744
  );
1559
1745
  this._host.requestUpdate();
1746
+ if (options.overdub && typeof this._host.play === "function") {
1747
+ await this._host.play(this._host._currentTime);
1748
+ }
1560
1749
  } catch (err) {
1561
1750
  this._cleanupSession(trackId);
1562
1751
  console.warn("[dawcore] RecordingController: Failed to start recording: " + String(err));
@@ -1569,11 +1758,28 @@ var RecordingController = class {
1569
1758
  );
1570
1759
  }
1571
1760
  }
1761
+ pauseRecording(trackId) {
1762
+ const id = trackId ?? [...this._sessions.keys()][0];
1763
+ if (!id) return;
1764
+ const session = this._sessions.get(id);
1765
+ if (!session) return;
1766
+ session.workletNode.port.postMessage({ command: "pause" });
1767
+ }
1768
+ resumeRecording(trackId) {
1769
+ const id = trackId ?? [...this._sessions.keys()][0];
1770
+ if (!id) return;
1771
+ const session = this._sessions.get(id);
1772
+ if (!session) return;
1773
+ session.workletNode.port.postMessage({ command: "resume" });
1774
+ }
1572
1775
  stopRecording(trackId) {
1573
1776
  const id = trackId ?? [...this._sessions.keys()][0];
1574
1777
  if (!id) return;
1575
1778
  const session = this._sessions.get(id);
1576
1779
  if (!session) return;
1780
+ if (session.wasOverdub && typeof this._host.stop === "function") {
1781
+ this._host.stop();
1782
+ }
1577
1783
  session.workletNode.port.postMessage({ command: "stop" });
1578
1784
  session.source.disconnect();
1579
1785
  session.workletNode.disconnect();
@@ -1591,7 +1797,8 @@ var RecordingController = class {
1591
1797
  );
1592
1798
  return;
1593
1799
  }
1594
- const stopCtx = getGlobalContext().rawContext;
1800
+ const context = getGlobalContext();
1801
+ const stopCtx = context.rawContext;
1595
1802
  const channelData = session.chunks.map((chunkArr) => concatenateAudioData(chunkArr));
1596
1803
  const audioBuffer = createAudioBuffer(
1597
1804
  stopCtx,
@@ -1599,7 +1806,21 @@ var RecordingController = class {
1599
1806
  this._host.effectiveSampleRate,
1600
1807
  session.channelCount
1601
1808
  );
1602
- const durationSamples = audioBuffer.length;
1809
+ const latencyOffsetSamples = session.latencySamples;
1810
+ const effectiveDuration = Math.max(0, audioBuffer.length - latencyOffsetSamples);
1811
+ if (effectiveDuration === 0) {
1812
+ console.warn("[dawcore] RecordingController: Recording too short for latency compensation");
1813
+ this._sessions.delete(id);
1814
+ this._host.requestUpdate();
1815
+ this._host.dispatchEvent(
1816
+ new CustomEvent("daw-recording-error", {
1817
+ bubbles: true,
1818
+ composed: true,
1819
+ detail: { trackId: id, error: new Error("Recording too short to save") }
1820
+ })
1821
+ );
1822
+ return;
1823
+ }
1603
1824
  const event = new CustomEvent("daw-recording-complete", {
1604
1825
  bubbles: true,
1605
1826
  composed: true,
@@ -1608,14 +1829,21 @@ var RecordingController = class {
1608
1829
  trackId: id,
1609
1830
  audioBuffer,
1610
1831
  startSample: session.startSample,
1611
- durationSamples
1832
+ durationSamples: effectiveDuration,
1833
+ offsetSamples: latencyOffsetSamples
1612
1834
  }
1613
1835
  });
1614
1836
  const notPrevented = this._host.dispatchEvent(event);
1615
1837
  this._sessions.delete(id);
1616
1838
  this._host.requestUpdate();
1617
1839
  if (notPrevented) {
1618
- this._createClipFromRecording(id, audioBuffer, session.startSample, durationSamples);
1840
+ this._createClipFromRecording(
1841
+ id,
1842
+ audioBuffer,
1843
+ session.startSample,
1844
+ effectiveDuration,
1845
+ latencyOffsetSamples
1846
+ );
1619
1847
  }
1620
1848
  }
1621
1849
  // Session fields are mutated in place on the hot path (~60fps worklet messages).
@@ -1646,7 +1874,9 @@ var RecordingController = class {
1646
1874
  );
1647
1875
  const newPeakCount = Math.floor(session.peaks[ch].length / 2);
1648
1876
  const waveformSelector = `daw-waveform[data-recording-track="${trackId}"][data-recording-channel="${ch}"]`;
1649
- const waveformEl = this._host.shadowRoot?.querySelector(waveformSelector);
1877
+ const waveformEl = this._host.shadowRoot?.querySelector(
1878
+ waveformSelector
1879
+ );
1650
1880
  if (waveformEl) {
1651
1881
  if (session.isFirstMessage) {
1652
1882
  waveformEl.peaks = session.peaks[ch];
@@ -1665,9 +1895,15 @@ var RecordingController = class {
1665
1895
  this._host.requestUpdate();
1666
1896
  }
1667
1897
  }
1668
- _createClipFromRecording(trackId, audioBuffer, startSample, durationSamples) {
1898
+ _createClipFromRecording(trackId, audioBuffer, startSample, durationSamples, offsetSamples = 0) {
1669
1899
  if (typeof this._host._addRecordedClip === "function") {
1670
- this._host._addRecordedClip(trackId, audioBuffer, startSample, durationSamples);
1900
+ this._host._addRecordedClip(
1901
+ trackId,
1902
+ audioBuffer,
1903
+ startSample,
1904
+ durationSamples,
1905
+ offsetSamples
1906
+ );
1671
1907
  } else {
1672
1908
  console.warn(
1673
1909
  '[dawcore] RecordingController: host does not implement _addRecordedClip \u2014 clip not created for track "' + trackId + '"'
@@ -1698,6 +1934,11 @@ var RecordingController = class {
1698
1934
 
1699
1935
  // src/interactions/pointer-handler.ts
1700
1936
  import { pixelsToSeconds } from "@waveform-playlist/core";
1937
+
1938
+ // src/interactions/constants.ts
1939
+ var DRAG_THRESHOLD = 3;
1940
+
1941
+ // src/interactions/pointer-handler.ts
1701
1942
  var PointerHandler = class {
1702
1943
  constructor(host) {
1703
1944
  this._isDragging = false;
@@ -1706,6 +1947,34 @@ var PointerHandler = class {
1706
1947
  // Cached from onPointerDown to avoid forced layout reflows at 60fps during drag
1707
1948
  this._timelineRect = null;
1708
1949
  this.onPointerDown = (e) => {
1950
+ const clipHandler = this._host._clipHandler;
1951
+ if (clipHandler) {
1952
+ const target = e.composedPath()[0];
1953
+ if (target && clipHandler.tryHandle(target, e)) {
1954
+ e.preventDefault();
1955
+ this._timeline = this._host.shadowRoot?.querySelector(".timeline");
1956
+ if (this._timeline) {
1957
+ this._timeline.setPointerCapture(e.pointerId);
1958
+ const onMove = (me) => clipHandler.onPointerMove(me);
1959
+ const onUp = (ue) => {
1960
+ clipHandler.onPointerUp(ue);
1961
+ this._timeline?.removeEventListener("pointermove", onMove);
1962
+ this._timeline?.removeEventListener("pointerup", onUp);
1963
+ try {
1964
+ this._timeline?.releasePointerCapture(ue.pointerId);
1965
+ } catch (err) {
1966
+ console.warn(
1967
+ "[dawcore] releasePointerCapture failed (may already be released): " + String(err)
1968
+ );
1969
+ }
1970
+ this._timeline = null;
1971
+ };
1972
+ this._timeline.addEventListener("pointermove", onMove);
1973
+ this._timeline.addEventListener("pointerup", onUp);
1974
+ }
1975
+ return;
1976
+ }
1977
+ }
1709
1978
  this._timeline = this._host.shadowRoot?.querySelector(".timeline");
1710
1979
  if (!this._timeline) return;
1711
1980
  this._timelineRect = this._timeline.getBoundingClientRect();
@@ -1718,7 +1987,7 @@ var PointerHandler = class {
1718
1987
  this._onPointerMove = (e) => {
1719
1988
  if (!this._timeline) return;
1720
1989
  const currentPx = this._pxFromPointer(e);
1721
- if (!this._isDragging && Math.abs(currentPx - this._dragStartPx) > 3) {
1990
+ if (!this._isDragging && Math.abs(currentPx - this._dragStartPx) > DRAG_THRESHOLD) {
1722
1991
  this._isDragging = true;
1723
1992
  }
1724
1993
  if (this._isDragging) {
@@ -1853,6 +2122,245 @@ var PointerHandler = class {
1853
2122
  }
1854
2123
  };
1855
2124
 
2125
+ // src/interactions/clip-pointer-handler.ts
2126
+ var ClipPointerHandler = class {
2127
+ constructor(host) {
2128
+ this._mode = null;
2129
+ this._clipId = "";
2130
+ this._trackId = "";
2131
+ this._startPx = 0;
2132
+ this._isDragging = false;
2133
+ this._lastDeltaPx = 0;
2134
+ this._cumulativeDeltaSamples = 0;
2135
+ // Trim visual feedback: snapshot of original clip state
2136
+ this._clipContainer = null;
2137
+ this._boundaryEl = null;
2138
+ this._originalLeft = 0;
2139
+ this._originalWidth = 0;
2140
+ this._originalOffsetSamples = 0;
2141
+ this._originalDurationSamples = 0;
2142
+ this._host = host;
2143
+ }
2144
+ /** Returns true if a drag interaction is currently in progress. */
2145
+ get isActive() {
2146
+ return this._mode !== null;
2147
+ }
2148
+ /**
2149
+ * Attempts to handle a pointerdown event on the given target element.
2150
+ * Returns true if the target is a recognized clip interaction element.
2151
+ */
2152
+ tryHandle(target, e) {
2153
+ if (!this._host.interactiveClips) return false;
2154
+ const boundary = target.closest?.(".clip-boundary");
2155
+ const header = target.closest?.(".clip-header");
2156
+ if (boundary && boundary.dataset.boundaryEdge !== void 0) {
2157
+ const clipId = boundary.dataset.clipId;
2158
+ const trackId = boundary.dataset.trackId;
2159
+ const edge = boundary.dataset.boundaryEdge;
2160
+ if (!clipId || !trackId || edge !== "left" && edge !== "right") return false;
2161
+ this._beginDrag(edge === "left" ? "trim-left" : "trim-right", clipId, trackId, e);
2162
+ this._boundaryEl = boundary;
2163
+ return true;
2164
+ }
2165
+ if (header && header.dataset.interactive !== void 0) {
2166
+ const clipId = header.dataset.clipId;
2167
+ const trackId = header.dataset.trackId;
2168
+ if (!clipId || !trackId) return false;
2169
+ this._beginDrag("move", clipId, trackId, e);
2170
+ return true;
2171
+ }
2172
+ return false;
2173
+ }
2174
+ _beginDrag(mode, clipId, trackId, e) {
2175
+ this._mode = mode;
2176
+ this._clipId = clipId;
2177
+ this._trackId = trackId;
2178
+ this._startPx = e.clientX;
2179
+ this._isDragging = false;
2180
+ this._lastDeltaPx = 0;
2181
+ this._cumulativeDeltaSamples = 0;
2182
+ if (mode === "trim-left" || mode === "trim-right") {
2183
+ const container = this._host.shadowRoot?.querySelector(
2184
+ `.clip-container[data-clip-id="${clipId}"]`
2185
+ );
2186
+ if (container) {
2187
+ this._clipContainer = container;
2188
+ this._originalLeft = parseFloat(container.style.left) || 0;
2189
+ this._originalWidth = parseFloat(container.style.width) || 0;
2190
+ } else {
2191
+ console.warn("[dawcore] clip container not found for trim visual feedback: " + clipId);
2192
+ }
2193
+ const engine = this._host.engine;
2194
+ if (engine) {
2195
+ const bounds = engine.getClipBounds(trackId, clipId);
2196
+ if (bounds) {
2197
+ this._originalOffsetSamples = bounds.offsetSamples;
2198
+ this._originalDurationSamples = bounds.durationSamples;
2199
+ }
2200
+ }
2201
+ }
2202
+ }
2203
+ /** Processes pointermove events during an active drag. */
2204
+ onPointerMove(e) {
2205
+ if (this._mode === null) return;
2206
+ const totalDeltaPx = e.clientX - this._startPx;
2207
+ if (!this._isDragging && Math.abs(totalDeltaPx) > DRAG_THRESHOLD) {
2208
+ this._isDragging = true;
2209
+ if (this._boundaryEl) {
2210
+ this._boundaryEl.classList.add("dragging");
2211
+ }
2212
+ }
2213
+ if (!this._isDragging) return;
2214
+ const engine = this._host.engine;
2215
+ if (!engine) return;
2216
+ if (this._mode === "move") {
2217
+ const incrementalDeltaPx = totalDeltaPx - this._lastDeltaPx;
2218
+ this._lastDeltaPx = totalDeltaPx;
2219
+ const incrementalDeltaSamples = Math.round(incrementalDeltaPx * this._host.samplesPerPixel);
2220
+ this._cumulativeDeltaSamples += incrementalDeltaSamples;
2221
+ engine.moveClip(this._trackId, this._clipId, incrementalDeltaSamples, true);
2222
+ } else {
2223
+ const boundary = this._mode === "trim-left" ? "left" : "right";
2224
+ const rawDeltaSamples = Math.round(totalDeltaPx * this._host.samplesPerPixel);
2225
+ const deltaSamples = engine.constrainTrimDelta(
2226
+ this._trackId,
2227
+ this._clipId,
2228
+ boundary,
2229
+ rawDeltaSamples
2230
+ );
2231
+ const deltaPx = Math.round(deltaSamples / this._host.samplesPerPixel);
2232
+ this._cumulativeDeltaSamples = deltaSamples;
2233
+ if (this._clipContainer) {
2234
+ if (this._mode === "trim-left") {
2235
+ const newLeft = this._originalLeft + deltaPx;
2236
+ const newWidth = this._originalWidth - deltaPx;
2237
+ if (newWidth > 0) {
2238
+ this._clipContainer.style.left = newLeft + "px";
2239
+ this._clipContainer.style.width = newWidth + "px";
2240
+ const newOffset = this._originalOffsetSamples + deltaSamples;
2241
+ const newDuration = this._originalDurationSamples - deltaSamples;
2242
+ if (this._updateWaveformPeaks(newOffset, newDuration)) {
2243
+ const waveforms = this._clipContainer.querySelectorAll("daw-waveform");
2244
+ for (const wf of waveforms) {
2245
+ wf.style.left = "0px";
2246
+ }
2247
+ } else {
2248
+ const waveforms = this._clipContainer.querySelectorAll("daw-waveform");
2249
+ for (const wf of waveforms) {
2250
+ wf.style.left = -deltaPx + "px";
2251
+ }
2252
+ }
2253
+ }
2254
+ } else {
2255
+ const newWidth = this._originalWidth + deltaPx;
2256
+ if (newWidth > 0) {
2257
+ this._clipContainer.style.width = newWidth + "px";
2258
+ const newDuration = this._originalDurationSamples + deltaSamples;
2259
+ this._updateWaveformPeaks(this._originalOffsetSamples, newDuration);
2260
+ }
2261
+ }
2262
+ }
2263
+ }
2264
+ }
2265
+ /** Processes pointerup events to finalize and dispatch result events. */
2266
+ onPointerUp(_e) {
2267
+ if (this._mode === null) return;
2268
+ try {
2269
+ if (!this._isDragging || this._cumulativeDeltaSamples === 0) {
2270
+ this._restoreTrimVisual();
2271
+ return;
2272
+ }
2273
+ const engine = this._host.engine;
2274
+ if (this._mode === "move") {
2275
+ if (engine) {
2276
+ engine.updateTrack(this._trackId);
2277
+ this._host.dispatchEvent(
2278
+ new CustomEvent("daw-clip-move", {
2279
+ bubbles: true,
2280
+ composed: true,
2281
+ detail: {
2282
+ trackId: this._trackId,
2283
+ clipId: this._clipId,
2284
+ deltaSamples: this._cumulativeDeltaSamples
2285
+ }
2286
+ })
2287
+ );
2288
+ } else {
2289
+ console.warn(
2290
+ "[dawcore] engine unavailable at move drop \u2014 audio may be out of sync for track " + this._trackId
2291
+ );
2292
+ }
2293
+ } else {
2294
+ this._restoreTrimVisual();
2295
+ const boundary = this._mode === "trim-left" ? "left" : "right";
2296
+ if (engine) {
2297
+ engine.trimClip(this._trackId, this._clipId, boundary, this._cumulativeDeltaSamples);
2298
+ this._host.dispatchEvent(
2299
+ new CustomEvent("daw-clip-trim", {
2300
+ bubbles: true,
2301
+ composed: true,
2302
+ detail: {
2303
+ trackId: this._trackId,
2304
+ clipId: this._clipId,
2305
+ boundary,
2306
+ deltaSamples: this._cumulativeDeltaSamples
2307
+ }
2308
+ })
2309
+ );
2310
+ }
2311
+ }
2312
+ } finally {
2313
+ this._reset();
2314
+ }
2315
+ }
2316
+ /** Re-extract peaks from cache and set on waveform elements during trim drag.
2317
+ * Returns true if peaks were successfully updated. */
2318
+ _updateWaveformPeaks(offsetSamples, durationSamples) {
2319
+ if (!this._clipContainer || durationSamples <= 0) return false;
2320
+ const peakSlice = this._host.reextractClipPeaks(this._clipId, offsetSamples, durationSamples);
2321
+ if (!peakSlice) return false;
2322
+ const waveforms = this._clipContainer.querySelectorAll("daw-waveform");
2323
+ for (let i = 0; i < waveforms.length; i++) {
2324
+ const wf = waveforms[i];
2325
+ const channelPeaks = peakSlice.data[i];
2326
+ if (channelPeaks) {
2327
+ wf.peaks = channelPeaks;
2328
+ wf.length = peakSlice.length;
2329
+ }
2330
+ }
2331
+ return true;
2332
+ }
2333
+ /** Restore clip container CSS to original values after trim visual preview. */
2334
+ _restoreTrimVisual() {
2335
+ if (this._clipContainer) {
2336
+ this._clipContainer.style.left = this._originalLeft + "px";
2337
+ this._clipContainer.style.width = this._originalWidth + "px";
2338
+ const waveforms = this._clipContainer.querySelectorAll("daw-waveform");
2339
+ for (const wf of waveforms) {
2340
+ wf.style.left = "0px";
2341
+ }
2342
+ }
2343
+ }
2344
+ _reset() {
2345
+ if (this._boundaryEl) {
2346
+ this._boundaryEl.classList.remove("dragging");
2347
+ this._boundaryEl = null;
2348
+ }
2349
+ this._mode = null;
2350
+ this._clipId = "";
2351
+ this._trackId = "";
2352
+ this._startPx = 0;
2353
+ this._isDragging = false;
2354
+ this._lastDeltaPx = 0;
2355
+ this._cumulativeDeltaSamples = 0;
2356
+ this._clipContainer = null;
2357
+ this._originalLeft = 0;
2358
+ this._originalWidth = 0;
2359
+ this._originalOffsetSamples = 0;
2360
+ this._originalDurationSamples = 0;
2361
+ }
2362
+ };
2363
+
1856
2364
  // src/interactions/file-loader.ts
1857
2365
  import { createClipFromSeconds, createTrack } from "@waveform-playlist/core";
1858
2366
  async function loadFiles(host, files) {
@@ -1887,10 +2395,16 @@ async function loadFiles(host, files) {
1887
2395
  sourceDuration: audioBuffer.duration
1888
2396
  });
1889
2397
  host._clipBuffers = new Map(host._clipBuffers).set(clip.id, audioBuffer);
2398
+ host._clipOffsets.set(clip.id, {
2399
+ offsetSamples: clip.offsetSamples,
2400
+ durationSamples: clip.durationSamples
2401
+ });
1890
2402
  const peakData = await host._peakPipeline.generatePeaks(
1891
2403
  audioBuffer,
1892
2404
  host.samplesPerPixel,
1893
- host.mono
2405
+ host.mono,
2406
+ clip.offsetSamples,
2407
+ clip.durationSamples
1894
2408
  );
1895
2409
  host._peaksData = new Map(host._peaksData).set(clip.id, peakData);
1896
2410
  const trackId = crypto.randomUUID();
@@ -1949,17 +2463,31 @@ async function loadFiles(host, files) {
1949
2463
 
1950
2464
  // src/interactions/recording-clip.ts
1951
2465
  import { createClip } from "@waveform-playlist/core";
1952
- function addRecordedClip(host, trackId, buf, startSample, durSamples) {
2466
+ function addRecordedClip(host, trackId, buf, startSample, durSamples, offsetSamples = 0) {
2467
+ let trimmedBuf = buf;
2468
+ if (offsetSamples > 0 && offsetSamples < buf.length) {
2469
+ const trimmed = new AudioBuffer({
2470
+ numberOfChannels: buf.numberOfChannels,
2471
+ length: durSamples,
2472
+ sampleRate: buf.sampleRate
2473
+ });
2474
+ for (let ch = 0; ch < buf.numberOfChannels; ch++) {
2475
+ const source = buf.getChannelData(ch);
2476
+ trimmed.copyToChannel(source.subarray(offsetSamples, offsetSamples + durSamples), ch);
2477
+ }
2478
+ trimmedBuf = trimmed;
2479
+ }
1953
2480
  const clip = createClip({
1954
- audioBuffer: buf,
2481
+ audioBuffer: trimmedBuf,
1955
2482
  startSample,
1956
2483
  durationSamples: durSamples,
1957
2484
  offsetSamples: 0,
2485
+ // offset already applied by slicing
1958
2486
  gain: 1,
1959
2487
  name: "Recording"
1960
2488
  });
1961
- host._clipBuffers = new Map(host._clipBuffers).set(clip.id, buf);
1962
- host._peakPipeline.generatePeaks(buf, host.samplesPerPixel, host.mono).then((pd) => {
2489
+ host._clipBuffers = new Map(host._clipBuffers).set(clip.id, trimmedBuf);
2490
+ host._peakPipeline.generatePeaks(trimmedBuf, host.samplesPerPixel, host.mono).then((pd) => {
1963
2491
  host._peaksData = new Map(host._peaksData).set(clip.id, pd);
1964
2492
  const t = host._engineTracks.get(trackId);
1965
2493
  if (!t) {
@@ -1992,7 +2520,12 @@ function addRecordedClip(host, trackId, buf, startSample, durSamples) {
1992
2520
  });
1993
2521
  }
1994
2522
  host._recomputeDuration();
1995
- host._engine?.setTracks([...host._engineTracks.values()]);
2523
+ const updatedTrack = host._engineTracks.get(trackId);
2524
+ if (host._engine?.updateTrack && updatedTrack) {
2525
+ host._engine.updateTrack(trackId, updatedTrack);
2526
+ } else {
2527
+ host._engine?.setTracks([...host._engineTracks.values()]);
2528
+ }
1996
2529
  }).catch((err) => {
1997
2530
  console.warn("[dawcore] Failed to generate peaks for recorded clip: " + String(err));
1998
2531
  const next = new Map(host._clipBuffers);
@@ -2010,6 +2543,140 @@ function addRecordedClip(host, trackId, buf, startSample, durSamples) {
2010
2543
  });
2011
2544
  }
2012
2545
 
2546
+ // src/interactions/split-handler.ts
2547
+ function splitAtPlayhead(host) {
2548
+ const { engine } = host;
2549
+ if (!engine) return false;
2550
+ const stateBefore = engine.getState();
2551
+ const { selectedTrackId, tracks } = stateBefore;
2552
+ if (!selectedTrackId) return false;
2553
+ const track = tracks.find((t) => t.id === selectedTrackId);
2554
+ if (!track) return false;
2555
+ const atSample = Math.round(host.currentTime * host.effectiveSampleRate);
2556
+ const clip = findClipAtSample(track.clips, atSample);
2557
+ if (!clip) return false;
2558
+ const originalClipId = clip.id;
2559
+ const clipIdsBefore = new Set(track.clips.map((c) => c.id));
2560
+ engine.splitClip(selectedTrackId, originalClipId, atSample);
2561
+ const stateAfter = engine.getState();
2562
+ const trackAfter = stateAfter.tracks.find((t) => t.id === selectedTrackId);
2563
+ if (!trackAfter) {
2564
+ console.warn(
2565
+ '[dawcore] splitAtPlayhead: track "' + selectedTrackId + '" disappeared after split'
2566
+ );
2567
+ return false;
2568
+ }
2569
+ const newClips = trackAfter.clips.filter((c) => !clipIdsBefore.has(c.id));
2570
+ if (newClips.length !== 2) {
2571
+ if (newClips.length > 0) {
2572
+ console.warn(
2573
+ "[dawcore] splitAtPlayhead: expected 2 new clips after split but got " + newClips.length
2574
+ );
2575
+ }
2576
+ return false;
2577
+ }
2578
+ const sorted = [...newClips].sort((a, b) => a.startSample - b.startSample);
2579
+ const leftClipId = sorted[0].id;
2580
+ const rightClipId = sorted[1].id;
2581
+ host.dispatchEvent(
2582
+ new CustomEvent("daw-clip-split", {
2583
+ bubbles: true,
2584
+ composed: true,
2585
+ detail: {
2586
+ trackId: selectedTrackId,
2587
+ originalClipId,
2588
+ leftClipId,
2589
+ rightClipId
2590
+ }
2591
+ })
2592
+ );
2593
+ return true;
2594
+ }
2595
+ function findClipAtSample(clips, atSample) {
2596
+ return clips.find(
2597
+ (c) => atSample > c.startSample && atSample < c.startSample + c.durationSamples
2598
+ );
2599
+ }
2600
+
2601
+ // src/interactions/clip-peak-sync.ts
2602
+ function syncPeaksForChangedClips(host, tracks) {
2603
+ const currentClipIds = /* @__PURE__ */ new Set();
2604
+ for (const track of tracks) {
2605
+ for (const clip of track.clips) {
2606
+ currentClipIds.add(clip.id);
2607
+ const cached = host._clipOffsets.get(clip.id);
2608
+ const needsPeaks = !host._peaksData.has(clip.id) || !cached || cached.offsetSamples !== clip.offsetSamples || cached.durationSamples !== clip.durationSamples;
2609
+ if (!needsPeaks) continue;
2610
+ const audioBuffer = clip.audioBuffer ?? host._clipBuffers.get(clip.id) ?? findAudioBufferForClip(host, clip, track);
2611
+ if (!audioBuffer) {
2612
+ console.warn(
2613
+ "[dawcore] syncPeaksForChangedClips: no AudioBuffer for clip " + clip.id + " \u2014 waveform will be blank"
2614
+ );
2615
+ continue;
2616
+ }
2617
+ host._clipBuffers = new Map(host._clipBuffers).set(clip.id, audioBuffer);
2618
+ host._clipOffsets.set(clip.id, {
2619
+ offsetSamples: clip.offsetSamples,
2620
+ durationSamples: clip.durationSamples
2621
+ });
2622
+ host._peakPipeline.generatePeaks(
2623
+ audioBuffer,
2624
+ host.samplesPerPixel,
2625
+ host.mono,
2626
+ clip.offsetSamples,
2627
+ clip.durationSamples
2628
+ ).then((peakData) => {
2629
+ host._peaksData = new Map(host._peaksData).set(clip.id, peakData);
2630
+ }).catch((err) => {
2631
+ console.warn(
2632
+ "[dawcore] Failed to generate peaks for clip " + clip.id + ": " + String(err)
2633
+ );
2634
+ });
2635
+ }
2636
+ }
2637
+ cleanupOrphanedClipData(host, currentClipIds);
2638
+ }
2639
+ function cleanupOrphanedClipData(host, currentClipIds) {
2640
+ let buffersChanged = false;
2641
+ let peaksChanged = false;
2642
+ for (const id of host._clipBuffers.keys()) {
2643
+ if (!currentClipIds.has(id)) {
2644
+ host._clipBuffers.delete(id);
2645
+ buffersChanged = true;
2646
+ }
2647
+ }
2648
+ let offsetsChanged = false;
2649
+ for (const id of host._clipOffsets.keys()) {
2650
+ if (!currentClipIds.has(id)) {
2651
+ host._clipOffsets.delete(id);
2652
+ offsetsChanged = true;
2653
+ }
2654
+ }
2655
+ for (const id of host._peaksData.keys()) {
2656
+ if (!currentClipIds.has(id)) {
2657
+ host._peaksData.delete(id);
2658
+ peaksChanged = true;
2659
+ }
2660
+ }
2661
+ if (buffersChanged) {
2662
+ host._clipBuffers = new Map(host._clipBuffers);
2663
+ }
2664
+ if (offsetsChanged) {
2665
+ host._clipOffsets = new Map(host._clipOffsets);
2666
+ }
2667
+ if (peaksChanged) {
2668
+ host._peaksData = new Map(host._peaksData);
2669
+ }
2670
+ }
2671
+ function findAudioBufferForClip(host, clip, track) {
2672
+ for (const sibling of track.clips) {
2673
+ if (sibling.id === clip.id) continue;
2674
+ const buf = host._clipBuffers.get(sibling.id);
2675
+ if (buf) return buf;
2676
+ }
2677
+ return null;
2678
+ }
2679
+
2013
2680
  // src/elements/daw-editor.ts
2014
2681
  var DawEditorElement = class extends LitElement8 {
2015
2682
  constructor() {
@@ -2021,6 +2688,9 @@ var DawEditorElement = class extends LitElement8 {
2021
2688
  this.barWidth = 1;
2022
2689
  this.barGap = 0;
2023
2690
  this.fileDrop = false;
2691
+ this.clipHeaders = false;
2692
+ this.clipHeaderHeight = 20;
2693
+ this.interactiveClips = false;
2024
2694
  this.sampleRate = 48e3;
2025
2695
  /** Resolved sample rate — falls back to sampleRate property until first audio decode. */
2026
2696
  this._resolvedSampleRate = null;
@@ -2037,14 +2707,15 @@ var DawEditorElement = class extends LitElement8 {
2037
2707
  this._currentTime = 0;
2038
2708
  this._engine = null;
2039
2709
  this._enginePromise = null;
2040
- this._audioInitialized = false;
2041
2710
  this._audioCache = /* @__PURE__ */ new Map();
2042
2711
  this._clipBuffers = /* @__PURE__ */ new Map();
2712
+ this._clipOffsets = /* @__PURE__ */ new Map();
2043
2713
  this._peakPipeline = new PeakPipeline();
2044
2714
  this._trackElements = /* @__PURE__ */ new Map();
2045
2715
  this._childObserver = null;
2046
2716
  this._audioResume = new AudioResumeController(this);
2047
2717
  this._recordingController = new RecordingController(this);
2718
+ this._clipPointer = new ClipPointerHandler(this);
2048
2719
  this._pointer = new PointerHandler(this);
2049
2720
  this._viewport = (() => {
2050
2721
  const v = new ViewportController(this);
@@ -2145,9 +2816,42 @@ var DawEditorElement = class extends LitElement8 {
2145
2816
  );
2146
2817
  }
2147
2818
  };
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
+ };
2148
2830
  // --- Recording ---
2149
2831
  this.recordingStream = null;
2150
2832
  }
2833
+ get _clipHandler() {
2834
+ return this.interactiveClips ? this._clipPointer : null;
2835
+ }
2836
+ get engine() {
2837
+ return this._engine;
2838
+ }
2839
+ /** Re-extract peaks for a clip at new offset/duration from cached WaveformData. */
2840
+ reextractClipPeaks(clipId, offsetSamples, durationSamples) {
2841
+ const buf = this._clipBuffers.get(clipId);
2842
+ if (!buf) return null;
2843
+ const singleClipBuffers = /* @__PURE__ */ new Map([[clipId, buf]]);
2844
+ const singleClipOffsets = /* @__PURE__ */ new Map([[clipId, { offsetSamples, durationSamples }]]);
2845
+ const result = this._peakPipeline.reextractPeaks(
2846
+ singleClipBuffers,
2847
+ this.samplesPerPixel,
2848
+ this.mono,
2849
+ singleClipOffsets
2850
+ );
2851
+ const peakData = result.get(clipId);
2852
+ if (!peakData) return null;
2853
+ return { data: peakData.data, length: peakData.length };
2854
+ }
2151
2855
  get effectiveSampleRate() {
2152
2856
  return this._resolvedSampleRate ?? this.sampleRate;
2153
2857
  }
@@ -2188,6 +2892,10 @@ var DawEditorElement = class extends LitElement8 {
2188
2892
  // --- Lifecycle ---
2189
2893
  connectedCallback() {
2190
2894
  super.connectedCallback();
2895
+ if (!this.hasAttribute("tabindex")) {
2896
+ this.setAttribute("tabindex", "0");
2897
+ }
2898
+ this.addEventListener("keydown", this._onKeyDown);
2191
2899
  this.addEventListener("daw-track-connected", this._onTrackConnected);
2192
2900
  this.addEventListener("daw-track-update", this._onTrackUpdate);
2193
2901
  this.addEventListener("daw-track-control", this._onTrackControl);
@@ -2213,6 +2921,7 @@ var DawEditorElement = class extends LitElement8 {
2213
2921
  }
2214
2922
  disconnectedCallback() {
2215
2923
  super.disconnectedCallback();
2924
+ this.removeEventListener("keydown", this._onKeyDown);
2216
2925
  this.removeEventListener("daw-track-connected", this._onTrackConnected);
2217
2926
  this.removeEventListener("daw-track-update", this._onTrackUpdate);
2218
2927
  this.removeEventListener("daw-track-control", this._onTrackControl);
@@ -2222,6 +2931,7 @@ var DawEditorElement = class extends LitElement8 {
2222
2931
  this._trackElements.clear();
2223
2932
  this._audioCache.clear();
2224
2933
  this._clipBuffers.clear();
2934
+ this._clipOffsets.clear();
2225
2935
  this._peakPipeline.terminate();
2226
2936
  try {
2227
2937
  this._disposeEngine();
@@ -2233,17 +2943,19 @@ var DawEditorElement = class extends LitElement8 {
2233
2943
  if (changedProperties.has("eagerResume")) {
2234
2944
  this._audioResume.target = this.eagerResume;
2235
2945
  }
2946
+ if (changedProperties.has("samplesPerPixel") && this._isPlaying) {
2947
+ this._startPlayhead();
2948
+ }
2236
2949
  if (changedProperties.has("samplesPerPixel") && this._clipBuffers.size > 0) {
2237
- const reextracted = this._peakPipeline.reextractPeaks(
2950
+ const re = this._peakPipeline.reextractPeaks(
2238
2951
  this._clipBuffers,
2239
2952
  this.samplesPerPixel,
2240
- this.mono
2953
+ this.mono,
2954
+ this._clipOffsets
2241
2955
  );
2242
- if (reextracted.size > 0) {
2956
+ if (re.size > 0) {
2243
2957
  const next = new Map(this._peaksData);
2244
- for (const [clipId, peakData] of reextracted) {
2245
- next.set(clipId, peakData);
2246
- }
2958
+ for (const [id, pd] of re) next.set(id, pd);
2247
2959
  this._peaksData = next;
2248
2960
  }
2249
2961
  }
@@ -2255,6 +2967,7 @@ var DawEditorElement = class extends LitElement8 {
2255
2967
  const nextPeaks = new Map(this._peaksData);
2256
2968
  for (const clip of removedTrack.clips) {
2257
2969
  this._clipBuffers.delete(clip.id);
2970
+ this._clipOffsets.delete(clip.id);
2258
2971
  nextPeaks.delete(clip.id);
2259
2972
  }
2260
2973
  this._peaksData = nextPeaks;
@@ -2333,10 +3046,16 @@ var DawEditorElement = class extends LitElement8 {
2333
3046
  sourceDuration: audioBuffer.duration
2334
3047
  });
2335
3048
  this._clipBuffers = new Map(this._clipBuffers).set(clip.id, audioBuffer);
3049
+ this._clipOffsets.set(clip.id, {
3050
+ offsetSamples: clip.offsetSamples,
3051
+ durationSamples: clip.durationSamples
3052
+ });
2336
3053
  const peakData = await this._peakPipeline.generatePeaks(
2337
3054
  audioBuffer,
2338
3055
  this.samplesPerPixel,
2339
- this.mono
3056
+ this.mono,
3057
+ clip.offsetSamples,
3058
+ clip.durationSamples
2340
3059
  );
2341
3060
  this._peaksData = new Map(this._peaksData).set(clip.id, peakData);
2342
3061
  clips.push(clip);
@@ -2428,10 +3147,20 @@ var DawEditorElement = class extends LitElement8 {
2428
3147
  samplesPerPixel: this.samplesPerPixel,
2429
3148
  zoomLevels: [256, 512, 1024, 2048, 4096, 8192, this.samplesPerPixel].filter((v, i, a) => a.indexOf(v) === i).sort((a, b) => a - b)
2430
3149
  });
3150
+ let lastTracksVersion = -1;
2431
3151
  engine.on("statechange", (engineState) => {
2432
3152
  this._isPlaying = engineState.isPlaying;
2433
3153
  this._duration = engineState.duration;
2434
3154
  this._selectedTrackId = engineState.selectedTrackId;
3155
+ if (engineState.tracksVersion !== lastTracksVersion) {
3156
+ lastTracksVersion = engineState.tracksVersion;
3157
+ const nextTracks = /* @__PURE__ */ new Map();
3158
+ for (const track of engineState.tracks) {
3159
+ nextTracks.set(track.id, track);
3160
+ }
3161
+ this._engineTracks = nextTracks;
3162
+ syncPeaksForChangedClips(this, engineState.tracks);
3163
+ }
2435
3164
  });
2436
3165
  engine.on("timeupdate", (time) => {
2437
3166
  this._currentTime = time;
@@ -2454,14 +3183,11 @@ var DawEditorElement = class extends LitElement8 {
2454
3183
  return loadFiles(this, files);
2455
3184
  }
2456
3185
  // --- Playback ---
2457
- async play() {
3186
+ async play(startTime) {
2458
3187
  try {
2459
3188
  const engine = await this._ensureEngine();
2460
- if (!this._audioInitialized) {
2461
- await engine.init();
2462
- this._audioInitialized = true;
2463
- }
2464
- engine.play();
3189
+ await engine.init();
3190
+ engine.play(startTime);
2465
3191
  this._startPlayhead();
2466
3192
  this.dispatchEvent(new CustomEvent("daw-play", { bubbles: true, composed: true }));
2467
3193
  } catch (err) {
@@ -2492,14 +3218,32 @@ var DawEditorElement = class extends LitElement8 {
2492
3218
  this._engine.seek(time);
2493
3219
  this._currentTime = time;
2494
3220
  }
3221
+ /** Split the clip under the playhead on the selected track. */
3222
+ splitAtPlayhead() {
3223
+ return splitAtPlayhead({
3224
+ effectiveSampleRate: this.effectiveSampleRate,
3225
+ currentTime: this._currentTime,
3226
+ engine: this._engine,
3227
+ dispatchEvent: (e) => this.dispatchEvent(e)
3228
+ });
3229
+ }
3230
+ get currentTime() {
3231
+ return this._currentTime;
3232
+ }
2495
3233
  get isRecording() {
2496
3234
  return this._recordingController.isRecording;
2497
3235
  }
3236
+ pauseRecording() {
3237
+ this._recordingController.pauseRecording();
3238
+ }
3239
+ resumeRecording() {
3240
+ this._recordingController.resumeRecording();
3241
+ }
2498
3242
  stopRecording() {
2499
3243
  this._recordingController.stopRecording();
2500
3244
  }
2501
- _addRecordedClip(trackId, buf, startSample, durSamples) {
2502
- addRecordedClip(this, trackId, buf, startSample, durSamples);
3245
+ _addRecordedClip(trackId, buf, startSample, durSamples, offsetSamples = 0) {
3246
+ addRecordedClip(this, trackId, buf, startSample, durSamples, offsetSamples);
2503
3247
  }
2504
3248
  async startRecording(stream, options) {
2505
3249
  const s = stream ?? this.recordingStream;
@@ -2512,15 +3256,19 @@ var DawEditorElement = class extends LitElement8 {
2512
3256
  _renderRecordingPreview(trackId, chH) {
2513
3257
  const rs = this._recordingController.getSession(trackId);
2514
3258
  if (!rs) return "";
3259
+ const audibleSamples = Math.max(0, rs.totalSamples - rs.latencySamples);
3260
+ if (audibleSamples === 0) return "";
3261
+ const latencyPixels = Math.floor(rs.latencySamples / this.samplesPerPixel);
2515
3262
  const left = Math.floor(rs.startSample / this.samplesPerPixel);
2516
- const w = Math.floor(rs.totalSamples / this.samplesPerPixel);
2517
- return rs.peaks.map(
2518
- (chPeaks, ch) => html7`
3263
+ const w = Math.floor(audibleSamples / this.samplesPerPixel);
3264
+ return rs.peaks.map((chPeaks, ch) => {
3265
+ const slicedPeaks = latencyPixels > 0 ? chPeaks.slice(latencyPixels * 2) : chPeaks;
3266
+ return html7`
2519
3267
  <daw-waveform
2520
3268
  data-recording-track=${trackId}
2521
3269
  data-recording-channel=${ch}
2522
3270
  style="position:absolute;left:${left}px;top:${ch * chH}px;"
2523
- .peaks=${chPeaks}
3271
+ .peaks=${slicedPeaks}
2524
3272
  .length=${w}
2525
3273
  .waveHeight=${chH}
2526
3274
  .barWidth=${this.barWidth}
@@ -2529,8 +3277,8 @@ var DawEditorElement = class extends LitElement8 {
2529
3277
  .visibleEnd=${this._viewport.visibleEnd}
2530
3278
  .originX=${left}
2531
3279
  ></daw-waveform>
2532
- `
2533
- );
3280
+ `;
3281
+ });
2534
3282
  }
2535
3283
  // --- Playhead ---
2536
3284
  _startPlayhead() {
@@ -2572,13 +3320,14 @@ var DawEditorElement = class extends LitElement8 {
2572
3320
  const orderedTracks = this._getOrderedTracks().map(([trackId, track]) => {
2573
3321
  const descriptor = this._tracks.get(trackId);
2574
3322
  const firstPeaks = track.clips.map((c) => this._peaksData.get(c.id)).find((p) => p && p.data.length > 0);
2575
- const numChannels = firstPeaks ? firstPeaks.data.length : 1;
3323
+ const recSession = this._recordingController.getSession(trackId);
3324
+ const numChannels = firstPeaks ? firstPeaks.data.length : recSession ? recSession.channelCount : 1;
2576
3325
  return {
2577
3326
  trackId,
2578
3327
  track,
2579
3328
  descriptor,
2580
3329
  numChannels,
2581
- trackHeight: this.waveHeight * numChannels
3330
+ trackHeight: this.waveHeight * numChannels + (this.clipHeaders ? this.clipHeaderHeight : 0)
2582
3331
  };
2583
3332
  });
2584
3333
  return html7`
@@ -2632,21 +3381,47 @@ var DawEditorElement = class extends LitElement8 {
2632
3381
  );
2633
3382
  const clipLeft = Math.floor(clip.startSample / this.samplesPerPixel);
2634
3383
  const channels = peakData?.data ?? [new Int16Array(0)];
2635
- return channels.map(
2636
- (channelPeaks, chIdx) => html7`
2637
- <daw-waveform
2638
- style="position: absolute; left: ${clipLeft}px; top: ${chIdx * channelHeight}px;"
2639
- .peaks=${channelPeaks}
2640
- .length=${peakData?.length ?? width}
2641
- .waveHeight=${channelHeight}
2642
- .barWidth=${this.barWidth}
2643
- .barGap=${this.barGap}
2644
- .visibleStart=${this._viewport.visibleStart}
2645
- .visibleEnd=${this._viewport.visibleEnd}
2646
- .originX=${clipLeft}
2647
- ></daw-waveform>
2648
- `
2649
- );
3384
+ const hdrH = this.clipHeaders ? this.clipHeaderHeight : 0;
3385
+ const chH = this.waveHeight;
3386
+ return html7` <div
3387
+ class="clip-container"
3388
+ style="left:${clipLeft}px;top:0;width:${width}px;height:${t.trackHeight}px;"
3389
+ data-clip-id=${clip.id}
3390
+ >
3391
+ ${hdrH > 0 ? html7`<div
3392
+ class="clip-header"
3393
+ data-clip-id=${clip.id}
3394
+ data-track-id=${t.trackId}
3395
+ ?data-interactive=${this.interactiveClips}
3396
+ >
3397
+ <span>${clip.name || t.descriptor?.name || ""}</span>
3398
+ </div>` : ""}
3399
+ ${channels.map(
3400
+ (chPeaks, chIdx) => html7` <daw-waveform
3401
+ style="position:absolute;left:0;top:${hdrH + chIdx * chH}px;"
3402
+ .peaks=${chPeaks}
3403
+ .length=${peakData?.length ?? width}
3404
+ .waveHeight=${chH}
3405
+ .barWidth=${this.barWidth}
3406
+ .barGap=${this.barGap}
3407
+ .visibleStart=${this._viewport.visibleStart}
3408
+ .visibleEnd=${this._viewport.visibleEnd}
3409
+ .originX=${clipLeft}
3410
+ ></daw-waveform>`
3411
+ )}
3412
+ ${this.interactiveClips ? html7` <div
3413
+ class="clip-boundary"
3414
+ data-boundary-edge="left"
3415
+ data-clip-id=${clip.id}
3416
+ data-track-id=${t.trackId}
3417
+ ></div>
3418
+ <div
3419
+ class="clip-boundary"
3420
+ data-boundary-edge="right"
3421
+ data-clip-id=${clip.id}
3422
+ data-track-id=${t.trackId}
3423
+ ></div>` : ""}
3424
+ </div>`;
2650
3425
  })}
2651
3426
  ${this._renderRecordingPreview(t.trackId, channelHeight)}
2652
3427
  </div>
@@ -2660,7 +3435,7 @@ var DawEditorElement = class extends LitElement8 {
2660
3435
  };
2661
3436
  DawEditorElement.styles = [
2662
3437
  hostStyles,
2663
- css6`
3438
+ css7`
2664
3439
  :host {
2665
3440
  display: flex;
2666
3441
  position: relative;
@@ -2694,7 +3469,8 @@ DawEditorElement.styles = [
2694
3469
  outline: 2px dashed var(--daw-selection-color, rgba(99, 199, 95, 0.3));
2695
3470
  outline-offset: -2px;
2696
3471
  }
2697
- `
3472
+ `,
3473
+ clipStyles
2698
3474
  ];
2699
3475
  DawEditorElement._CONTROL_PROPS = /* @__PURE__ */ new Set(["volume", "pan", "muted", "soloed"]);
2700
3476
  __decorateClass([
@@ -2718,29 +3494,38 @@ __decorateClass([
2718
3494
  __decorateClass([
2719
3495
  property6({ type: Boolean, attribute: "file-drop" })
2720
3496
  ], DawEditorElement.prototype, "fileDrop", 2);
3497
+ __decorateClass([
3498
+ property6({ type: Boolean, attribute: "clip-headers" })
3499
+ ], DawEditorElement.prototype, "clipHeaders", 2);
3500
+ __decorateClass([
3501
+ property6({ type: Number, attribute: "clip-header-height" })
3502
+ ], DawEditorElement.prototype, "clipHeaderHeight", 2);
3503
+ __decorateClass([
3504
+ property6({ type: Boolean, attribute: "interactive-clips" })
3505
+ ], DawEditorElement.prototype, "interactiveClips", 2);
2721
3506
  __decorateClass([
2722
3507
  property6({ type: Number, attribute: "sample-rate" })
2723
3508
  ], DawEditorElement.prototype, "sampleRate", 2);
2724
3509
  __decorateClass([
2725
- state()
3510
+ state3()
2726
3511
  ], DawEditorElement.prototype, "_tracks", 2);
2727
3512
  __decorateClass([
2728
- state()
3513
+ state3()
2729
3514
  ], DawEditorElement.prototype, "_engineTracks", 2);
2730
3515
  __decorateClass([
2731
- state()
3516
+ state3()
2732
3517
  ], DawEditorElement.prototype, "_peaksData", 2);
2733
3518
  __decorateClass([
2734
- state()
3519
+ state3()
2735
3520
  ], DawEditorElement.prototype, "_isPlaying", 2);
2736
3521
  __decorateClass([
2737
- state()
3522
+ state3()
2738
3523
  ], DawEditorElement.prototype, "_duration", 2);
2739
3524
  __decorateClass([
2740
- state()
3525
+ state3()
2741
3526
  ], DawEditorElement.prototype, "_selectedTrackId", 2);
2742
3527
  __decorateClass([
2743
- state()
3528
+ state3()
2744
3529
  ], DawEditorElement.prototype, "_dragOver", 2);
2745
3530
  __decorateClass([
2746
3531
  property6({ attribute: "eager-resume" })
@@ -2750,7 +3535,7 @@ DawEditorElement = __decorateClass([
2750
3535
  ], DawEditorElement);
2751
3536
 
2752
3537
  // src/elements/daw-ruler.ts
2753
- import { LitElement as LitElement9, html as html8, css as css7 } from "lit";
3538
+ import { LitElement as LitElement9, html as html8, css as css8 } from "lit";
2754
3539
  import { customElement as customElement11, property as property7 } from "lit/decorators.js";
2755
3540
 
2756
3541
  // src/utils/time-format.ts
@@ -2883,7 +3668,7 @@ var DawRulerElement = class extends LitElement9 {
2883
3668
  }
2884
3669
  }
2885
3670
  };
2886
- DawRulerElement.styles = css7`
3671
+ DawRulerElement.styles = css8`
2887
3672
  :host {
2888
3673
  display: block;
2889
3674
  position: relative;
@@ -2921,7 +3706,7 @@ DawRulerElement = __decorateClass([
2921
3706
  ], DawRulerElement);
2922
3707
 
2923
3708
  // src/elements/daw-selection.ts
2924
- import { LitElement as LitElement10, html as html9, css as css8 } from "lit";
3709
+ import { LitElement as LitElement10, html as html9, css as css9 } from "lit";
2925
3710
  import { customElement as customElement12, property as property8 } from "lit/decorators.js";
2926
3711
  var DawSelectionElement = class extends LitElement10 {
2927
3712
  constructor() {
@@ -2936,7 +3721,7 @@ var DawSelectionElement = class extends LitElement10 {
2936
3721
  return html9`<div style="left: ${left}px; width: ${width}px;"></div>`;
2937
3722
  }
2938
3723
  };
2939
- DawSelectionElement.styles = css8`
3724
+ DawSelectionElement.styles = css9`
2940
3725
  :host {
2941
3726
  position: absolute;
2942
3727
  top: 0;
@@ -2963,8 +3748,8 @@ DawSelectionElement = __decorateClass([
2963
3748
  ], DawSelectionElement);
2964
3749
 
2965
3750
  // src/elements/daw-record-button.ts
2966
- import { html as html10, css as css9 } from "lit";
2967
- import { customElement as customElement13, state as state2 } from "lit/decorators.js";
3751
+ import { html as html10, css as css10 } from "lit";
3752
+ import { customElement as customElement13, state as state4 } from "lit/decorators.js";
2968
3753
  var DawRecordButtonElement = class extends DawTransportButton {
2969
3754
  constructor() {
2970
3755
  super(...arguments);
@@ -2982,7 +3767,7 @@ var DawRecordButtonElement = class extends DawTransportButton {
2982
3767
  }
2983
3768
  connectedCallback() {
2984
3769
  super.connectedCallback();
2985
- this._listenToTarget();
3770
+ requestAnimationFrame(() => this._listenToTarget());
2986
3771
  }
2987
3772
  disconnectedCallback() {
2988
3773
  super.disconnectedCallback();
@@ -3007,11 +3792,12 @@ var DawRecordButtonElement = class extends DawTransportButton {
3007
3792
  render() {
3008
3793
  return html10`
3009
3794
  <button part="button" ?data-recording=${this._isRecording} @click=${this._onClick}>
3010
- <slot>${this._isRecording ? "Stop Rec" : "Record"}</slot>
3795
+ <slot>Record</slot>
3011
3796
  </button>
3012
3797
  `;
3013
3798
  }
3014
3799
  _onClick() {
3800
+ if (this._isRecording) return;
3015
3801
  const target = this.target;
3016
3802
  if (!target) {
3017
3803
  console.warn(
@@ -3019,30 +3805,28 @@ var DawRecordButtonElement = class extends DawTransportButton {
3019
3805
  );
3020
3806
  return;
3021
3807
  }
3022
- if (this._isRecording) {
3023
- target.stopRecording();
3024
- } else {
3025
- target.startRecording(target.recordingStream);
3026
- }
3808
+ target.startRecording(target.recordingStream);
3027
3809
  }
3028
3810
  };
3029
3811
  DawRecordButtonElement.styles = [
3030
3812
  DawTransportButton.styles,
3031
- css9`
3813
+ css10`
3032
3814
  button[data-recording] {
3033
3815
  color: #d08070;
3034
3816
  border-color: #d08070;
3817
+ background: rgba(208, 128, 112, 0.15);
3035
3818
  }
3036
3819
  `
3037
3820
  ];
3038
3821
  __decorateClass([
3039
- state2()
3822
+ state4()
3040
3823
  ], DawRecordButtonElement.prototype, "_isRecording", 2);
3041
3824
  DawRecordButtonElement = __decorateClass([
3042
3825
  customElement13("daw-record-button")
3043
3826
  ], DawRecordButtonElement);
3044
3827
  export {
3045
3828
  AudioResumeController,
3829
+ ClipPointerHandler,
3046
3830
  DawClipElement,
3047
3831
  DawEditorElement,
3048
3832
  DawPauseButtonElement,
@@ -3057,6 +3841,7 @@ export {
3057
3841
  DawTransportButton,
3058
3842
  DawTransportElement,
3059
3843
  DawWaveformElement,
3060
- RecordingController
3844
+ RecordingController,
3845
+ splitAtPlayhead
3061
3846
  };
3062
3847
  //# sourceMappingURL=index.mjs.map