@dawcore/components 0.0.8 → 0.0.10

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
@@ -193,7 +193,7 @@ function getVisibleChunkIndices(totalWidth, chunkWidth, visibleStart, visibleEnd
193
193
 
194
194
  // src/elements/daw-waveform.ts
195
195
  var MAX_CANVAS_WIDTH = 1e3;
196
- var LAYOUT_PROPS = /* @__PURE__ */ new Set(["length", "waveHeight", "barWidth", "barGap"]);
196
+ var LAYOUT_PROPS = /* @__PURE__ */ new Set(["length", "waveHeight", "barWidth", "barGap", "segments"]);
197
197
  function groupDirtyByChunk(dirtyPixels, step) {
198
198
  const dirtyByChunk = /* @__PURE__ */ new Map();
199
199
  for (const peakIdx of dirtyPixels) {
@@ -300,14 +300,22 @@ var DawWaveformElement = class extends LitElement3 {
300
300
  const halfHeight = this.waveHeight / 2;
301
301
  const bits = this.bits;
302
302
  const waveColor = getComputedStyle(this).getPropertyValue("--daw-wave-color").trim() || "#c49a6c";
303
- const dirtyByChunk = groupDirtyByChunk(this._dirtyPixels, step);
304
303
  this._drawnChunks.clear();
305
- for (const canvas of canvases) {
306
- const chunkIdx = Number(canvas.dataset.index);
307
- this._drawnChunks.add(chunkIdx);
308
- const range = dirtyByChunk.get(chunkIdx);
309
- if (!range) continue;
310
- this._drawChunk(canvas, chunkIdx, range, step, dpr, halfHeight, bits, waveColor);
304
+ if (this.segments) {
305
+ for (const canvas of canvases) {
306
+ const chunkIdx = Number(canvas.dataset.index);
307
+ this._drawnChunks.add(chunkIdx);
308
+ this._drawSegments(canvas, chunkIdx, dpr, halfHeight, bits, waveColor);
309
+ }
310
+ } else {
311
+ const dirtyByChunk = groupDirtyByChunk(this._dirtyPixels, step);
312
+ for (const canvas of canvases) {
313
+ const chunkIdx = Number(canvas.dataset.index);
314
+ this._drawnChunks.add(chunkIdx);
315
+ const range = dirtyByChunk.get(chunkIdx);
316
+ if (!range) continue;
317
+ this._drawChunk(canvas, chunkIdx, range, step, dpr, halfHeight, bits, waveColor);
318
+ }
311
319
  }
312
320
  this._dirtyPixels.clear();
313
321
  }
@@ -341,6 +349,45 @@ var DawWaveformElement = class extends LitElement3 {
341
349
  }
342
350
  }
343
351
  }
352
+ _drawSegments(canvas, chunkIdx, dpr, halfHeight, bits, waveColor) {
353
+ if (!this.segments) return;
354
+ const ctx = canvas.getContext("2d");
355
+ if (!ctx) return;
356
+ const globalOffset = chunkIdx * MAX_CANVAS_WIDTH;
357
+ const canvasWidth = Math.min(MAX_CANVAS_WIDTH, this.length - globalOffset);
358
+ ctx.resetTransform();
359
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
360
+ ctx.scale(dpr, dpr);
361
+ ctx.fillStyle = waveColor;
362
+ const step = Math.max(1, Math.round(this.barWidth + this.barGap));
363
+ for (const seg of this.segments) {
364
+ if (seg.pixelEnd <= globalOffset || seg.pixelStart >= globalOffset + canvasWidth) continue;
365
+ const localStart = Math.max(0, seg.pixelStart - globalOffset);
366
+ const localEnd = Math.min(canvasWidth, seg.pixelEnd - globalOffset);
367
+ const segPixelWidth = seg.pixelEnd - seg.pixelStart;
368
+ const segPeakWidth = seg.peakEnd - seg.peakStart;
369
+ if (segPixelWidth <= 0 || segPeakWidth <= 0) continue;
370
+ const peaksPerPixel = segPeakWidth / segPixelWidth;
371
+ for (let px = Math.floor(localStart); px < Math.ceil(localEnd); px += step) {
372
+ const pxInSeg = px + globalOffset - seg.pixelStart;
373
+ const peakPos = seg.peakStart + pxInSeg * peaksPerPixel;
374
+ const peakEnd = peakPos + step * peaksPerPixel;
375
+ const peak = aggregatePeaks(this._peaks, bits, Math.floor(peakPos), Math.ceil(peakEnd));
376
+ if (!peak) continue;
377
+ const rects = calculateBarRects(
378
+ px,
379
+ this.barWidth,
380
+ halfHeight,
381
+ peak.min,
382
+ peak.max,
383
+ "normal"
384
+ );
385
+ for (const r of rects) {
386
+ ctx.fillRect(r.x, r.y, r.width, r.height);
387
+ }
388
+ }
389
+ }
390
+ }
344
391
  connectedCallback() {
345
392
  super.connectedCallback();
346
393
  if (this._dirtyPixels.size > 0) {
@@ -435,6 +482,9 @@ __decorateClass([
435
482
  __decorateClass([
436
483
  property3({ type: Number, attribute: false })
437
484
  ], DawWaveformElement.prototype, "originX", 2);
485
+ __decorateClass([
486
+ property3({ attribute: false })
487
+ ], DawWaveformElement.prototype, "segments", 2);
438
488
  DawWaveformElement = __decorateClass([
439
489
  customElement3("daw-waveform")
440
490
  ], DawWaveformElement);
@@ -502,6 +552,40 @@ var DawPlayheadElement = class extends LitElement4 {
502
552
  this._line.style.transform = `translate3d(${px}px, 0, 0)`;
503
553
  }
504
554
  }
555
+ startBeatsAnimation(getTime, bpm, ppqn, ticksPerPixel) {
556
+ const ticksPerSecond = bpm * ppqn / 60;
557
+ this._animation.start(() => {
558
+ const time = getTime();
559
+ const px = time * ticksPerSecond / ticksPerPixel;
560
+ if (this._line) {
561
+ this._line.style.transform = `translate3d(${px}px, 0, 0)`;
562
+ }
563
+ });
564
+ }
565
+ stopBeatsAnimation(time, bpm, ppqn, ticksPerPixel) {
566
+ this._animation.stop();
567
+ const px = time * bpm * ppqn / (60 * ticksPerPixel);
568
+ if (this._line) {
569
+ this._line.style.transform = `translate3d(${px}px, 0, 0)`;
570
+ }
571
+ }
572
+ startBeatsAnimationWithMap(getTime, secondsToTicks, ticksPerPixel) {
573
+ this._animation.start(() => {
574
+ const time = getTime();
575
+ const tick = secondsToTicks(time);
576
+ const px = tick / ticksPerPixel;
577
+ if (this._line) {
578
+ this._line.style.transform = `translate3d(${px}px, 0, 0)`;
579
+ }
580
+ });
581
+ }
582
+ stopBeatsAnimationWithMap(time, secondsToTicks, ticksPerPixel) {
583
+ this._animation.stop();
584
+ const px = secondsToTicks(time) / ticksPerPixel;
585
+ if (this._line) {
586
+ this._line.style.transform = `translate3d(${px}px, 0, 0)`;
587
+ }
588
+ }
505
589
  };
506
590
  DawPlayheadElement.styles = css2`
507
591
  :host {
@@ -753,11 +837,11 @@ DawStopButtonElement = __decorateClass([
753
837
  ], DawStopButtonElement);
754
838
 
755
839
  // src/elements/daw-editor.ts
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";
840
+ import { LitElement as LitElement9, html as html8, css as css8 } from "lit";
841
+ import { customElement as customElement11, property as property7, state as state3 } from "lit/decorators.js";
758
842
  import {
759
- createClip as createClip2,
760
- createClipFromSeconds as createClipFromSeconds2,
843
+ createClip as createClip3,
844
+ createClipFromSeconds,
761
845
  createTrack as createTrack2,
762
846
  clipPixelWidth
763
847
  } from "@waveform-playlist/core";
@@ -1169,6 +1253,19 @@ var PeakPipeline = class {
1169
1253
  }
1170
1254
  return requestedScale;
1171
1255
  }
1256
+ /**
1257
+ * Extract peaks at the base scale from cached WaveformData.
1258
+ * Returns null if no cached data exists for this buffer.
1259
+ * Used by variable-tempo segments which handle stretching themselves.
1260
+ */
1261
+ getBaseScalePeaks(audioBuffer, isMono, offsetSamples, durationSamples) {
1262
+ const cached = this._cache.get(audioBuffer);
1263
+ if (!cached) return null;
1264
+ return {
1265
+ peaks: extractPeaks(cached, cached.scale, isMono, offsetSamples, durationSamples),
1266
+ scale: cached.scale
1267
+ };
1268
+ }
1172
1269
  /**
1173
1270
  * Return the coarsest (largest) scale among cached WaveformData entries
1174
1271
  * that correspond to the given clip buffers. Returns 0 if none are cached.
@@ -1474,9 +1571,187 @@ DawTrackControlsElement = __decorateClass([
1474
1571
  customElement9("daw-track-controls")
1475
1572
  ], DawTrackControlsElement);
1476
1573
 
1574
+ // src/elements/daw-grid.ts
1575
+ import { LitElement as LitElement8, html as html7, css as css6 } from "lit";
1576
+ import { customElement as customElement10, property as property6 } from "lit/decorators.js";
1577
+ import { MIN_PIXELS_PER_UNIT } from "@waveform-playlist/core";
1578
+
1579
+ // src/utils/musical-tick-cache.ts
1580
+ import { computeMusicalTicks } from "@waveform-playlist/core";
1581
+ var cachedParams = null;
1582
+ var cachedResult = null;
1583
+ function meterEntriesMatch(a, b) {
1584
+ if (a.length !== b.length) return false;
1585
+ for (let i = 0; i < a.length; i++) {
1586
+ if (a[i].tick !== b[i].tick || a[i].numerator !== b[i].numerator || a[i].denominator !== b[i].denominator)
1587
+ return false;
1588
+ }
1589
+ return true;
1590
+ }
1591
+ function paramsMatch(a, b) {
1592
+ return a.ticksPerPixel === b.ticksPerPixel && a.startPixel === b.startPixel && a.endPixel === b.endPixel && meterEntriesMatch(a.meterEntries, b.meterEntries) && (a.ppqn ?? 960) === (b.ppqn ?? 960);
1593
+ }
1594
+ function getCachedMusicalTicks(params) {
1595
+ if (cachedParams && cachedResult && paramsMatch(cachedParams, params)) {
1596
+ return cachedResult;
1597
+ }
1598
+ cachedResult = computeMusicalTicks(params);
1599
+ cachedParams = {
1600
+ ...params,
1601
+ meterEntries: params.meterEntries.map((e) => ({ ...e }))
1602
+ };
1603
+ return cachedResult;
1604
+ }
1605
+
1606
+ // src/elements/daw-grid.ts
1607
+ var MAX_CANVAS_WIDTH2 = 1e3;
1608
+ var DawGridElement = class extends LitElement8 {
1609
+ constructor() {
1610
+ super(...arguments);
1611
+ this.ticksPerPixel = 24;
1612
+ this.meterEntries = [
1613
+ { tick: 0, numerator: 4, denominator: 4 }
1614
+ ];
1615
+ this.ppqn = 960;
1616
+ this.visibleStart = -Infinity;
1617
+ this.visibleEnd = Infinity;
1618
+ this.length = 0;
1619
+ this.height = 200;
1620
+ this._tickData = null;
1621
+ }
1622
+ willUpdate() {
1623
+ if (this.length > 0) {
1624
+ this._tickData = getCachedMusicalTicks({
1625
+ ticksPerPixel: this.ticksPerPixel,
1626
+ meterEntries: this.meterEntries,
1627
+ ppqn: this.ppqn,
1628
+ startPixel: 0,
1629
+ endPixel: this.length
1630
+ });
1631
+ } else {
1632
+ this._tickData = null;
1633
+ }
1634
+ }
1635
+ render() {
1636
+ if (!this._tickData) return html7``;
1637
+ const totalWidth = this.length;
1638
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
1639
+ const indices = getVisibleChunkIndices(
1640
+ totalWidth,
1641
+ MAX_CANVAS_WIDTH2,
1642
+ this.visibleStart,
1643
+ this.visibleEnd
1644
+ );
1645
+ return html7`
1646
+ <div class="container" style="width: ${totalWidth}px; height: ${this.height}px;">
1647
+ ${indices.map((i) => {
1648
+ const width = Math.min(MAX_CANVAS_WIDTH2, totalWidth - i * MAX_CANVAS_WIDTH2);
1649
+ return html7`
1650
+ <canvas
1651
+ data-index=${i}
1652
+ width=${width * dpr}
1653
+ height=${this.height * dpr}
1654
+ style="left: ${i * MAX_CANVAS_WIDTH2}px; width: ${width}px; height: ${this.height}px;"
1655
+ ></canvas>
1656
+ `;
1657
+ })}
1658
+ </div>
1659
+ `;
1660
+ }
1661
+ updated() {
1662
+ this._drawGrid();
1663
+ }
1664
+ _drawGrid() {
1665
+ if (!this._tickData) return;
1666
+ const canvases = this.shadowRoot?.querySelectorAll("canvas");
1667
+ if (!canvases) return;
1668
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
1669
+ const style = getComputedStyle(this);
1670
+ const barHighlight = style.getPropertyValue("--daw-grid-bar-highlight").trim() || "rgba(255,255,255,0.02)";
1671
+ const majorLine = style.getPropertyValue("--daw-grid-major-line").trim() || "rgba(255,255,255,0.1)";
1672
+ const minorLine = style.getPropertyValue("--daw-grid-minor-line").trim() || "rgba(255,255,255,0.06)";
1673
+ const { ticks, pixelsPerQuarterNote } = this._tickData;
1674
+ for (const canvas of canvases) {
1675
+ const idx = Number(canvas.dataset.index);
1676
+ const ctx = canvas.getContext("2d");
1677
+ if (!ctx) continue;
1678
+ const chunkLeft = idx * MAX_CANVAS_WIDTH2;
1679
+ const canvasWidth = Math.min(MAX_CANVAS_WIDTH2, this.length - chunkLeft);
1680
+ ctx.resetTransform();
1681
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1682
+ ctx.scale(dpr, dpr);
1683
+ if (pixelsPerQuarterNote * 4 >= MIN_PIXELS_PER_UNIT) {
1684
+ ctx.fillStyle = barHighlight;
1685
+ const majorTicks = ticks.filter((t) => t.type === "major");
1686
+ for (let i = 0; i < majorTicks.length; i++) {
1687
+ if (majorTicks[i].barIndex % 2 === 1) {
1688
+ const x = majorTicks[i].pixel - chunkLeft;
1689
+ const lastMeter = this.meterEntries[this.meterEntries.length - 1];
1690
+ const lastBarWidth = pixelsPerQuarterNote * lastMeter.numerator * (4 / lastMeter.denominator);
1691
+ const nextX = i + 1 < majorTicks.length ? majorTicks[i + 1].pixel - chunkLeft : x + lastBarWidth;
1692
+ ctx.fillRect(x, 0, nextX - x, this.height);
1693
+ }
1694
+ }
1695
+ }
1696
+ ctx.lineWidth = 1;
1697
+ for (const tick of ticks) {
1698
+ if (tick.type === "minorMinor") continue;
1699
+ const localX = tick.pixel - chunkLeft;
1700
+ if (localX < 0 || localX >= canvasWidth) continue;
1701
+ ctx.strokeStyle = tick.type === "major" ? majorLine : minorLine;
1702
+ ctx.beginPath();
1703
+ ctx.moveTo(localX + 0.5, 0);
1704
+ ctx.lineTo(localX + 0.5, this.height);
1705
+ ctx.stroke();
1706
+ }
1707
+ }
1708
+ }
1709
+ };
1710
+ DawGridElement.styles = css6`
1711
+ :host {
1712
+ display: block;
1713
+ position: absolute;
1714
+ top: 0;
1715
+ left: 0;
1716
+ pointer-events: none;
1717
+ z-index: 0;
1718
+ }
1719
+ .container {
1720
+ position: relative;
1721
+ }
1722
+ canvas {
1723
+ position: absolute;
1724
+ top: 0;
1725
+ }
1726
+ `;
1727
+ __decorateClass([
1728
+ property6({ type: Number, attribute: false })
1729
+ ], DawGridElement.prototype, "ticksPerPixel", 2);
1730
+ __decorateClass([
1731
+ property6({ attribute: false })
1732
+ ], DawGridElement.prototype, "meterEntries", 2);
1733
+ __decorateClass([
1734
+ property6({ type: Number, attribute: false })
1735
+ ], DawGridElement.prototype, "ppqn", 2);
1736
+ __decorateClass([
1737
+ property6({ type: Number, attribute: false })
1738
+ ], DawGridElement.prototype, "visibleStart", 2);
1739
+ __decorateClass([
1740
+ property6({ type: Number, attribute: false })
1741
+ ], DawGridElement.prototype, "visibleEnd", 2);
1742
+ __decorateClass([
1743
+ property6({ type: Number, attribute: false })
1744
+ ], DawGridElement.prototype, "length", 2);
1745
+ __decorateClass([
1746
+ property6({ type: Number, attribute: false })
1747
+ ], DawGridElement.prototype, "height", 2);
1748
+ DawGridElement = __decorateClass([
1749
+ customElement10("daw-grid")
1750
+ ], DawGridElement);
1751
+
1477
1752
  // src/styles/theme.ts
1478
- import { css as css6 } from "lit";
1479
- var hostStyles = css6`
1753
+ import { css as css7 } from "lit";
1754
+ var hostStyles = css7`
1480
1755
  :host {
1481
1756
  --daw-wave-color: #c49a6c;
1482
1757
  --daw-progress-color: #63c75f;
@@ -1492,7 +1767,7 @@ var hostStyles = css6`
1492
1767
  --daw-clip-header-text: #e0d4c8;
1493
1768
  }
1494
1769
  `;
1495
- var clipStyles = css6`
1770
+ var clipStyles = css7`
1496
1771
  .clip-container {
1497
1772
  position: absolute;
1498
1773
  overflow: hidden;
@@ -1705,8 +1980,7 @@ var AudioResumeController = class {
1705
1980
  };
1706
1981
 
1707
1982
  // src/controllers/recording-controller.ts
1708
- import { recordingProcessorUrl } from "@waveform-playlist/worklets";
1709
- import { appendPeaks, concatenateAudioData, createAudioBuffer } from "@waveform-playlist/recording";
1983
+ import { appendPeaks, concatenateAudioData, createAudioBuffer } from "@waveform-playlist/core";
1710
1984
  var RecordingController = class {
1711
1985
  constructor(host) {
1712
1986
  this._sessions = /* @__PURE__ */ new Map();
@@ -1743,6 +2017,14 @@ var RecordingController = class {
1743
2017
  const rawCtx = this._host.audioContext;
1744
2018
  this._host.resolveAudioContextSampleRate(rawCtx.sampleRate);
1745
2019
  if (!this._workletLoadedCtx || this._workletLoadedCtx !== rawCtx) {
2020
+ let recordingProcessorUrl;
2021
+ try {
2022
+ ({ recordingProcessorUrl } = await import("@waveform-playlist/worklets"));
2023
+ } catch {
2024
+ throw new Error(
2025
+ "Recording requires @waveform-playlist/worklets. Install it: npm install @waveform-playlist/worklets"
2026
+ );
2027
+ }
1746
2028
  await rawCtx.audioWorklet.addModule(recordingProcessorUrl);
1747
2029
  this._workletLoadedCtx = rawCtx;
1748
2030
  }
@@ -1993,7 +2275,7 @@ var RecordingController = class {
1993
2275
  };
1994
2276
 
1995
2277
  // src/interactions/pointer-handler.ts
1996
- import { pixelsToSeconds } from "@waveform-playlist/core";
2278
+ import { pixelsToSeconds, snapTickToGrid } from "@waveform-playlist/core";
1997
2279
 
1998
2280
  // src/interactions/constants.ts
1999
2281
  var DRAG_THRESHOLD = 3;
@@ -2003,6 +2285,7 @@ var PointerHandler = class {
2003
2285
  constructor(host) {
2004
2286
  this._isDragging = false;
2005
2287
  this._dragStartPx = 0;
2288
+ this._dragStartTime = 0;
2006
2289
  this._timeline = null;
2007
2290
  // Cached from onPointerDown to avoid forced layout reflows at 60fps during drag
2008
2291
  this._timelineRect = null;
@@ -2049,21 +2332,18 @@ var PointerHandler = class {
2049
2332
  const currentPx = this._pxFromPointer(e);
2050
2333
  if (!this._isDragging && Math.abs(currentPx - this._dragStartPx) > DRAG_THRESHOLD) {
2051
2334
  this._isDragging = true;
2335
+ this._dragStartTime = this._pxToTime(this._dragStartPx);
2052
2336
  }
2053
2337
  if (this._isDragging) {
2054
2338
  const h = this._host;
2055
- const startTime = pixelsToSeconds(
2056
- this._dragStartPx,
2057
- h.samplesPerPixel,
2058
- h.effectiveSampleRate
2059
- );
2060
- const endTime = pixelsToSeconds(currentPx, h.samplesPerPixel, h.effectiveSampleRate);
2339
+ const startTime = this._dragStartTime;
2340
+ const endTime = this._pxToTime(currentPx);
2061
2341
  h._selectionStartTime = Math.min(startTime, endTime);
2062
2342
  h._selectionEndTime = Math.max(startTime, endTime);
2063
2343
  const sel = h.shadowRoot?.querySelector("daw-selection");
2064
2344
  if (sel) {
2065
- sel.startPx = h._selectionStartTime * h.effectiveSampleRate / h.samplesPerPixel;
2066
- sel.endPx = h._selectionEndTime * h.effectiveSampleRate / h.samplesPerPixel;
2345
+ sel.startPx = this._timeToPx(h._selectionStartTime);
2346
+ sel.endPx = this._timeToPx(h._selectionEndTime);
2067
2347
  }
2068
2348
  }
2069
2349
  };
@@ -2101,6 +2381,23 @@ var PointerHandler = class {
2101
2381
  }
2102
2382
  return e.clientX - this._timelineRect.left;
2103
2383
  }
2384
+ _pxToTime(px) {
2385
+ const h = this._host;
2386
+ if (h.scaleMode === "beats") {
2387
+ let tick = px * h.ticksPerPixel;
2388
+ tick = snapTickToGrid(tick, h.snapTo, h._meterEntries, h.ppqn);
2389
+ return h._ticksToSeconds(tick);
2390
+ }
2391
+ return pixelsToSeconds(px, h.samplesPerPixel, h.effectiveSampleRate);
2392
+ }
2393
+ _timeToPx(time) {
2394
+ const h = this._host;
2395
+ if (h.scaleMode === "beats") {
2396
+ const tick = h._secondsToTicks(time);
2397
+ return tick / h.ticksPerPixel;
2398
+ }
2399
+ return time * h.effectiveSampleRate / h.samplesPerPixel;
2400
+ }
2104
2401
  _finalizeSelection() {
2105
2402
  const h = this._host;
2106
2403
  if (h._engine) {
@@ -2118,7 +2415,7 @@ var PointerHandler = class {
2118
2415
  _handleSeekClick(e) {
2119
2416
  const h = this._host;
2120
2417
  const px = this._pxFromPointer(e);
2121
- const time = pixelsToSeconds(px, h.samplesPerPixel, h.effectiveSampleRate);
2418
+ const time = this._pxToTime(px);
2122
2419
  h._selectionStartTime = 0;
2123
2420
  h._selectionEndTime = 0;
2124
2421
  if (this._timeline) {
@@ -2183,6 +2480,7 @@ var PointerHandler = class {
2183
2480
  };
2184
2481
 
2185
2482
  // src/interactions/clip-pointer-handler.ts
2483
+ import { snapTickToGrid as snapTickToGrid2 } from "@waveform-playlist/core";
2186
2484
  var ClipPointerHandler = class {
2187
2485
  constructor(host) {
2188
2486
  this._mode = null;
@@ -2190,7 +2488,6 @@ var ClipPointerHandler = class {
2190
2488
  this._trackId = "";
2191
2489
  this._startPx = 0;
2192
2490
  this._isDragging = false;
2193
- this._lastDeltaPx = 0;
2194
2491
  this._cumulativeDeltaSamples = 0;
2195
2492
  // Trim visual feedback: snapshot of original clip state
2196
2493
  this._clipContainer = null;
@@ -2199,8 +2496,31 @@ var ClipPointerHandler = class {
2199
2496
  this._originalWidth = 0;
2200
2497
  this._originalOffsetSamples = 0;
2201
2498
  this._originalDurationSamples = 0;
2499
+ this._originalStartSample = 0;
2202
2500
  this._host = host;
2203
2501
  }
2502
+ /**
2503
+ * Convert a pixel delta to samples, snapping in tick space when in beats mode.
2504
+ *
2505
+ * The anchor is the absolute sample position being moved (e.g., clip start
2506
+ * for move/left-trim, clip end for right-trim). Snapping the absolute
2507
+ * position — not just the delta — ensures clips land exactly on grid lines
2508
+ * even if they started off-grid.
2509
+ */
2510
+ _snapDeltaToSamples(totalDeltaPx, anchorSample) {
2511
+ const h = this._host;
2512
+ if (h.scaleMode === "beats") {
2513
+ const anchorSeconds = anchorSample / h.effectiveSampleRate;
2514
+ const anchorTick = h._secondsToTicks(anchorSeconds);
2515
+ const deltaTicks = totalDeltaPx * h.ticksPerPixel;
2516
+ const targetTick = anchorTick + deltaTicks;
2517
+ const snappedTick = h.snapTo !== "off" ? snapTickToGrid2(targetTick, h.snapTo, h._meterEntries, h.ppqn) : targetTick;
2518
+ const snappedSeconds = h._ticksToSeconds(snappedTick);
2519
+ const snappedSample = Math.round(snappedSeconds * h.effectiveSampleRate);
2520
+ return snappedSample - anchorSample;
2521
+ }
2522
+ return Math.round(totalDeltaPx * h.renderSamplesPerPixel);
2523
+ }
2204
2524
  /** Returns true if a drag interaction is currently in progress. */
2205
2525
  get isActive() {
2206
2526
  return this._mode !== null;
@@ -2237,10 +2557,16 @@ var ClipPointerHandler = class {
2237
2557
  this._trackId = trackId;
2238
2558
  this._startPx = e.clientX;
2239
2559
  this._isDragging = false;
2240
- this._lastDeltaPx = 0;
2241
2560
  this._cumulativeDeltaSamples = 0;
2242
- if (this._host.engine) {
2243
- this._host.engine.beginTransaction();
2561
+ const engine = this._host.engine;
2562
+ if (engine) {
2563
+ const bounds = engine.getClipBounds(trackId, clipId);
2564
+ if (bounds) {
2565
+ this._originalStartSample = bounds.startSample;
2566
+ }
2567
+ }
2568
+ if (engine) {
2569
+ engine.beginTransaction();
2244
2570
  } else {
2245
2571
  console.warn(
2246
2572
  "[dawcore] beginDrag: engine unavailable, drag mutations will not be grouped for undo"
@@ -2257,9 +2583,9 @@ var ClipPointerHandler = class {
2257
2583
  } else {
2258
2584
  console.warn("[dawcore] clip container not found for trim visual feedback: " + clipId);
2259
2585
  }
2260
- const engine = this._host.engine;
2261
- if (engine) {
2262
- const bounds = engine.getClipBounds(trackId, clipId);
2586
+ const engine2 = this._host.engine;
2587
+ if (engine2) {
2588
+ const bounds = engine2.getClipBounds(trackId, clipId);
2263
2589
  if (bounds) {
2264
2590
  this._originalOffsetSamples = bounds.offsetSamples;
2265
2591
  this._originalDurationSamples = bounds.durationSamples;
@@ -2281,21 +2607,33 @@ var ClipPointerHandler = class {
2281
2607
  const engine = this._host.engine;
2282
2608
  if (!engine) return;
2283
2609
  if (this._mode === "move") {
2284
- const incrementalDeltaPx = totalDeltaPx - this._lastDeltaPx;
2285
- this._lastDeltaPx = totalDeltaPx;
2286
- const incrementalDeltaSamples = Math.round(incrementalDeltaPx * this._host.samplesPerPixel);
2287
- const applied = engine.moveClip(this._trackId, this._clipId, incrementalDeltaSamples, true);
2288
- this._cumulativeDeltaSamples += applied;
2610
+ const totalSnappedDelta = this._snapDeltaToSamples(totalDeltaPx, this._originalStartSample);
2611
+ const incrementalDeltaSamples = totalSnappedDelta - this._cumulativeDeltaSamples;
2612
+ if (incrementalDeltaSamples !== 0) {
2613
+ const applied = engine.moveClip(this._trackId, this._clipId, incrementalDeltaSamples, true);
2614
+ this._cumulativeDeltaSamples += applied;
2615
+ }
2289
2616
  } else {
2290
2617
  const boundary = this._mode === "trim-left" ? "left" : "right";
2291
- const rawDeltaSamples = Math.round(totalDeltaPx * this._host.samplesPerPixel);
2618
+ const anchor = boundary === "left" ? this._originalStartSample : this._originalStartSample + this._originalDurationSamples;
2619
+ const rawDeltaSamples = this._snapDeltaToSamples(totalDeltaPx, anchor);
2292
2620
  const deltaSamples = engine.constrainTrimDelta(
2293
2621
  this._trackId,
2294
2622
  this._clipId,
2295
2623
  boundary,
2296
2624
  rawDeltaSamples
2297
2625
  );
2298
- const deltaPx = Math.round(deltaSamples / this._host.samplesPerPixel);
2626
+ let deltaPx;
2627
+ if (this._host.scaleMode === "beats") {
2628
+ const h = this._host;
2629
+ const anchorSec = anchor / h.effectiveSampleRate;
2630
+ const anchorTick = h._secondsToTicks(anchorSec);
2631
+ const newSec = anchorSec + deltaSamples / h.effectiveSampleRate;
2632
+ const newTick = h._secondsToTicks(newSec);
2633
+ deltaPx = Math.round((newTick - anchorTick) / h.ticksPerPixel);
2634
+ } else {
2635
+ deltaPx = Math.round(deltaSamples / this._host.renderSamplesPerPixel);
2636
+ }
2299
2637
  this._cumulativeDeltaSamples = deltaSamples;
2300
2638
  if (this._clipContainer) {
2301
2639
  if (this._mode === "trim-left") {
@@ -2427,18 +2765,18 @@ var ClipPointerHandler = class {
2427
2765
  this._trackId = "";
2428
2766
  this._startPx = 0;
2429
2767
  this._isDragging = false;
2430
- this._lastDeltaPx = 0;
2431
2768
  this._cumulativeDeltaSamples = 0;
2432
2769
  this._clipContainer = null;
2433
2770
  this._originalLeft = 0;
2434
2771
  this._originalWidth = 0;
2435
2772
  this._originalOffsetSamples = 0;
2436
2773
  this._originalDurationSamples = 0;
2774
+ this._originalStartSample = 0;
2437
2775
  }
2438
2776
  };
2439
2777
 
2440
2778
  // src/interactions/file-loader.ts
2441
- import { createClipFromSeconds, createTrack } from "@waveform-playlist/core";
2779
+ import { createClip, createTrack } from "@waveform-playlist/core";
2442
2780
  async function loadFiles(host, files) {
2443
2781
  if (!files) {
2444
2782
  console.warn("[dawcore] loadFiles called with null/undefined");
@@ -2460,24 +2798,24 @@ async function loadFiles(host, files) {
2460
2798
  host._audioCache.delete(blobUrl);
2461
2799
  host._resolvedSampleRate = audioBuffer.sampleRate;
2462
2800
  const name = file.name.replace(/\.\w+$/, "");
2463
- const clip = createClipFromSeconds({
2801
+ const clip = createClip({
2464
2802
  audioBuffer,
2465
- startTime: 0,
2466
- duration: audioBuffer.duration,
2467
- offset: 0,
2803
+ startSample: 0,
2804
+ durationSamples: audioBuffer.length,
2805
+ offsetSamples: 0,
2468
2806
  gain: 1,
2469
2807
  name,
2470
2808
  sampleRate: audioBuffer.sampleRate,
2471
- sourceDuration: audioBuffer.duration
2809
+ sourceDurationSamples: audioBuffer.length
2472
2810
  });
2473
2811
  host._clipBuffers = new Map(host._clipBuffers).set(clip.id, audioBuffer);
2474
- host._clipOffsets.set(clip.id, {
2812
+ host._clipOffsets = new Map(host._clipOffsets).set(clip.id, {
2475
2813
  offsetSamples: clip.offsetSamples,
2476
2814
  durationSamples: clip.durationSamples
2477
2815
  });
2478
2816
  const peakData = await host._peakPipeline.generatePeaks(
2479
2817
  audioBuffer,
2480
- host.samplesPerPixel,
2818
+ host.renderSamplesPerPixel,
2481
2819
  host.mono,
2482
2820
  clip.offsetSamples,
2483
2821
  clip.durationSamples
@@ -2539,7 +2877,7 @@ async function loadFiles(host, files) {
2539
2877
  }
2540
2878
 
2541
2879
  // src/interactions/recording-clip.ts
2542
- import { createClip } from "@waveform-playlist/core";
2880
+ import { createClip as createClip2 } from "@waveform-playlist/core";
2543
2881
  function addRecordedClip(host, trackId, buf, startSample, durSamples, offsetSamples = 0) {
2544
2882
  let trimmedBuf = buf;
2545
2883
  if (offsetSamples > 0 && offsetSamples < buf.length) {
@@ -2554,7 +2892,7 @@ function addRecordedClip(host, trackId, buf, startSample, durSamples, offsetSamp
2554
2892
  }
2555
2893
  trimmedBuf = trimmed;
2556
2894
  }
2557
- const clip = createClip({
2895
+ const clip = createClip2({
2558
2896
  audioBuffer: trimmedBuf,
2559
2897
  startSample,
2560
2898
  durationSamples: durSamples,
@@ -2722,13 +3060,13 @@ function syncPeaksForChangedClips(host, tracks) {
2722
3060
  continue;
2723
3061
  }
2724
3062
  host._clipBuffers = new Map(host._clipBuffers).set(clip.id, audioBuffer);
2725
- host._clipOffsets.set(clip.id, {
3063
+ host._clipOffsets = new Map(host._clipOffsets).set(clip.id, {
2726
3064
  offsetSamples: clip.offsetSamples,
2727
3065
  durationSamples: clip.durationSamples
2728
3066
  });
2729
3067
  host._peakPipeline.generatePeaks(
2730
3068
  audioBuffer,
2731
- host.samplesPerPixel,
3069
+ host.renderSamplesPerPixel,
2732
3070
  host.mono,
2733
3071
  clip.offsetSamples,
2734
3072
  clip.durationSamples
@@ -2803,7 +3141,7 @@ async function loadWaveformDataFromUrl(src) {
2803
3141
  }
2804
3142
 
2805
3143
  // src/elements/daw-editor.ts
2806
- var DawEditorElement = class extends LitElement8 {
3144
+ var DawEditorElement = class extends LitElement9 {
2807
3145
  constructor() {
2808
3146
  super(...arguments);
2809
3147
  this._samplesPerPixel = 1024;
@@ -2816,6 +3154,12 @@ var DawEditorElement = class extends LitElement8 {
2816
3154
  this.clipHeaders = false;
2817
3155
  this.clipHeaderHeight = 20;
2818
3156
  this.interactiveClips = false;
3157
+ this.scaleMode = "temporal";
3158
+ this._ticksPerPixel = 24;
3159
+ this._bpm = 120;
3160
+ this.timeSignature = [4, 4];
3161
+ this._ppqn = 960;
3162
+ this.snapTo = "off";
2819
3163
  this.sampleRate = 48e3;
2820
3164
  /** Resolved sample rate — falls back to sampleRate property until first audio decode. */
2821
3165
  this._resolvedSampleRate = null;
@@ -2834,6 +3178,9 @@ var DawEditorElement = class extends LitElement8 {
2834
3178
  this._externalAudioContext = null;
2835
3179
  this._ownedAudioContext = null;
2836
3180
  this._engine = null;
3181
+ this._adapter = null;
3182
+ this._warnedMissingTicksToSeconds = false;
3183
+ this._warnedMissingSecondsToTicks = false;
2837
3184
  this._enginePromise = null;
2838
3185
  this._audioCache = /* @__PURE__ */ new Map();
2839
3186
  this._peaksCache = /* @__PURE__ */ new Map();
@@ -2978,6 +3325,41 @@ var DawEditorElement = class extends LitElement8 {
2978
3325
  this._samplesPerPixel = clamped;
2979
3326
  this.requestUpdate("samplesPerPixel", old);
2980
3327
  }
3328
+ get ticksPerPixel() {
3329
+ return this._ticksPerPixel;
3330
+ }
3331
+ set ticksPerPixel(value) {
3332
+ const old = this._ticksPerPixel;
3333
+ if (!Number.isFinite(value) || value <= 0) return;
3334
+ this._ticksPerPixel = value;
3335
+ this.requestUpdate("ticksPerPixel", old);
3336
+ }
3337
+ get bpm() {
3338
+ return this._bpm;
3339
+ }
3340
+ set bpm(value) {
3341
+ const old = this._bpm;
3342
+ if (!Number.isFinite(value) || value <= 0) return;
3343
+ this._bpm = value;
3344
+ if (this._engine) {
3345
+ this._engine.setTempo(value);
3346
+ }
3347
+ this.requestUpdate("bpm", old);
3348
+ }
3349
+ /** MeterEntries for grid/ruler: explicit meterEntries if set, otherwise derived from timeSignature. */
3350
+ get _meterEntries() {
3351
+ if (this.meterEntries && this.meterEntries.length > 0) return this.meterEntries;
3352
+ return [{ tick: 0, numerator: this.timeSignature[0], denominator: this.timeSignature[1] }];
3353
+ }
3354
+ get ppqn() {
3355
+ return this._ppqn;
3356
+ }
3357
+ set ppqn(value) {
3358
+ const old = this._ppqn;
3359
+ if (!Number.isFinite(value) || value <= 0) return;
3360
+ this._ppqn = value;
3361
+ this.requestUpdate("ppqn", old);
3362
+ }
2981
3363
  /** Set an AudioContext to use for all audio operations. Must be set before tracks load. */
2982
3364
  set audioContext(ctx) {
2983
3365
  if (ctx && ctx.state === "closed") {
@@ -3009,6 +3391,13 @@ var DawEditorElement = class extends LitElement8 {
3009
3391
  get engine() {
3010
3392
  return this._engine;
3011
3393
  }
3394
+ /** The adapter's Transport — use for tempo, metronome, and effects. */
3395
+ get transport() {
3396
+ return this._adapter?.transport ?? null;
3397
+ }
3398
+ get renderSamplesPerPixel() {
3399
+ return this._renderSpp;
3400
+ }
3012
3401
  /** Re-extract peaks for a clip at new offset/duration from cached WaveformData. */
3013
3402
  reextractClipPeaks(clipId, offsetSamples, durationSamples) {
3014
3403
  const buf = this._clipBuffers.get(clipId);
@@ -3017,7 +3406,7 @@ var DawEditorElement = class extends LitElement8 {
3017
3406
  const singleClipOffsets = /* @__PURE__ */ new Map([[clipId, { offsetSamples, durationSamples }]]);
3018
3407
  const result = this._peakPipeline.reextractPeaks(
3019
3408
  singleClipBuffers,
3020
- this.samplesPerPixel,
3409
+ this._renderSpp,
3021
3410
  this.mono,
3022
3411
  singleClipOffsets
3023
3412
  );
@@ -3031,9 +3420,59 @@ var DawEditorElement = class extends LitElement8 {
3031
3420
  resolveAudioContextSampleRate(rate) {
3032
3421
  if (!this._resolvedSampleRate) this._resolvedSampleRate = rate;
3033
3422
  }
3423
+ /**
3424
+ * In beats mode, derive samplesPerPixel from ticksPerPixel so that
3425
+ * clip positions, waveforms, and the tick-space grid all align.
3426
+ */
3427
+ get _renderSpp() {
3428
+ if (this.scaleMode === "beats") {
3429
+ const spp = Math.ceil(
3430
+ 60 * this.effectiveSampleRate * this.ticksPerPixel / (this.ppqn * this.bpm)
3431
+ );
3432
+ return this._minSamplesPerPixel > 0 ? Math.max(spp, this._minSamplesPerPixel) : spp;
3433
+ }
3434
+ return this.samplesPerPixel;
3435
+ }
3436
+ /** Convert seconds to ticks — uses callback if provided, otherwise single-BPM fallback. */
3437
+ _secondsToTicks(seconds) {
3438
+ if (this.secondsToTicks) {
3439
+ if (!this.ticksToSeconds && !this._warnedMissingTicksToSeconds) {
3440
+ this._warnedMissingTicksToSeconds = true;
3441
+ console.warn(
3442
+ "[waveform-playlist] daw-editor: secondsToTicks is set but ticksToSeconds is missing. Both callbacks are required for variable tempo."
3443
+ );
3444
+ }
3445
+ return this.secondsToTicks(seconds);
3446
+ }
3447
+ return seconds * this.bpm * this.ppqn / 60;
3448
+ }
3449
+ /** Convert ticks to seconds — uses callback if provided, otherwise single-BPM fallback. */
3450
+ _ticksToSeconds(ticks) {
3451
+ if (this.ticksToSeconds) {
3452
+ if (!this.secondsToTicks && !this._warnedMissingSecondsToTicks) {
3453
+ this._warnedMissingSecondsToTicks = true;
3454
+ console.warn(
3455
+ "[waveform-playlist] daw-editor: ticksToSeconds is set but secondsToTicks is missing. Both callbacks are required for variable tempo."
3456
+ );
3457
+ }
3458
+ return this.ticksToSeconds(ticks);
3459
+ }
3460
+ return ticks * 60 / (this.bpm * this.ppqn);
3461
+ }
3034
3462
  get _totalWidth() {
3463
+ if (this.scaleMode === "beats") {
3464
+ const contentTicks = this._secondsToTicks(this._duration);
3465
+ const [num] = this.timeSignature;
3466
+ const minTicks = 32 * num * this.ppqn;
3467
+ return Math.ceil(Math.max(contentTicks, minTicks) / this.ticksPerPixel);
3468
+ }
3035
3469
  return Math.ceil(this._duration * this.effectiveSampleRate / this.samplesPerPixel);
3036
3470
  }
3471
+ /** Grid height when no tracks exist — matches scroll area's rendered height. */
3472
+ get _emptyGridHeight() {
3473
+ const scrollArea = this.shadowRoot?.querySelector(".scroll-area");
3474
+ return scrollArea?.clientHeight ?? 200;
3475
+ }
3037
3476
  _setSelectedTrackId(trackId) {
3038
3477
  this._selectedTrackId = trackId;
3039
3478
  }
@@ -3119,13 +3558,14 @@ var DawEditorElement = class extends LitElement8 {
3119
3558
  if (changedProperties.has("eagerResume")) {
3120
3559
  this._audioResume.target = this.eagerResume;
3121
3560
  }
3122
- if (changedProperties.has("samplesPerPixel") && this._isPlaying) {
3561
+ if ((changedProperties.has("samplesPerPixel") || changedProperties.has("ticksPerPixel") || changedProperties.has("bpm") || changedProperties.has("secondsToTicks")) && this._isPlaying) {
3123
3562
  this._startPlayhead();
3124
3563
  }
3125
- if (changedProperties.has("samplesPerPixel") && this._clipBuffers.size > 0) {
3564
+ const zoomChanged = changedProperties.has("samplesPerPixel") || changedProperties.has("ticksPerPixel") || changedProperties.has("bpm") || changedProperties.has("scaleMode") || changedProperties.has("secondsToTicks");
3565
+ if (zoomChanged && this._clipBuffers.size > 0) {
3126
3566
  const re = this._peakPipeline.reextractPeaks(
3127
3567
  this._clipBuffers,
3128
- this.samplesPerPixel,
3568
+ this._renderSpp,
3129
3569
  this.mono,
3130
3570
  this._clipOffsets
3131
3571
  );
@@ -3234,7 +3674,7 @@ var DawEditorElement = class extends LitElement8 {
3234
3674
  }
3235
3675
  if (waveformData) {
3236
3676
  const wdRate = waveformData.sample_rate;
3237
- const clip2 = createClip2({
3677
+ const clip2 = createClip3({
3238
3678
  waveformData,
3239
3679
  startSample: Math.round(clipDesc.start * wdRate),
3240
3680
  durationSamples: Math.round((clipDesc.duration || waveformData.duration) * wdRate),
@@ -3244,7 +3684,7 @@ var DawEditorElement = class extends LitElement8 {
3244
3684
  sampleRate: wdRate,
3245
3685
  sourceDurationSamples: Math.ceil(waveformData.duration * wdRate)
3246
3686
  });
3247
- const effectiveScale = Math.max(this.samplesPerPixel, waveformData.scale);
3687
+ const effectiveScale = Math.max(this._renderSpp, waveformData.scale);
3248
3688
  const peakData2 = extractPeaks(
3249
3689
  waveformData,
3250
3690
  effectiveScale,
@@ -3293,7 +3733,7 @@ var DawEditorElement = class extends LitElement8 {
3293
3733
  }
3294
3734
  const audioBuffer = await audioPromise;
3295
3735
  this._resolvedSampleRate = audioBuffer.sampleRate;
3296
- const clip = createClipFromSeconds2({
3736
+ const clip = createClipFromSeconds({
3297
3737
  audioBuffer,
3298
3738
  startTime: clipDesc.start,
3299
3739
  duration: clipDesc.duration || audioBuffer.duration,
@@ -3310,7 +3750,7 @@ var DawEditorElement = class extends LitElement8 {
3310
3750
  });
3311
3751
  const peakData = await this._peakPipeline.generatePeaks(
3312
3752
  audioBuffer,
3313
- this.samplesPerPixel,
3753
+ this._renderSpp,
3314
3754
  this.mono,
3315
3755
  clip.offsetSamples,
3316
3756
  clip.durationSamples
@@ -3408,10 +3848,14 @@ var DawEditorElement = class extends LitElement8 {
3408
3848
  import("@dawcore/transport")
3409
3849
  ]);
3410
3850
  const adapter = new NativePlayoutAdapter(this.audioContext);
3851
+ this._adapter = adapter;
3852
+ adapter.setTempo(this._bpm);
3411
3853
  const engine = new PlaylistEngine({
3412
3854
  adapter,
3413
3855
  sampleRate: this.effectiveSampleRate,
3414
3856
  samplesPerPixel: this.samplesPerPixel,
3857
+ bpm: this._bpm,
3858
+ ppqn: this._ppqn,
3415
3859
  zoomLevels: [256, 512, 1024, 2048, 4096, 8192, this.samplesPerPixel].filter((v, i, a) => a.indexOf(v) === i).sort((a, b) => a - b)
3416
3860
  });
3417
3861
  let lastTracksVersion = -1;
@@ -3429,8 +3873,8 @@ var DawEditorElement = class extends LitElement8 {
3429
3873
  syncPeaksForChangedClips(this, engineState.tracks);
3430
3874
  }
3431
3875
  });
3432
- engine.on("timeupdate", (time) => {
3433
- this._currentTime = time;
3876
+ engine.on("pause", () => {
3877
+ this._currentTime = engine.getCurrentTime();
3434
3878
  });
3435
3879
  engine.on("stop", () => {
3436
3880
  this._currentTime = engine.getCurrentTime();
@@ -3444,6 +3888,7 @@ var DawEditorElement = class extends LitElement8 {
3444
3888
  this._engine.dispose();
3445
3889
  this._engine = null;
3446
3890
  }
3891
+ this._adapter = null;
3447
3892
  this._enginePromise = null;
3448
3893
  }
3449
3894
  async loadFiles(files) {
@@ -3530,7 +3975,7 @@ var DawEditorElement = class extends LitElement8 {
3530
3975
  splitAtPlayhead() {
3531
3976
  return splitAtPlayhead({
3532
3977
  effectiveSampleRate: this.effectiveSampleRate,
3533
- currentTime: this._currentTime,
3978
+ currentTime: this.currentTime,
3534
3979
  isPlaying: this._isPlaying,
3535
3980
  engine: this._engine,
3536
3981
  dispatchEvent: (e) => this.dispatchEvent(e),
@@ -3548,6 +3993,9 @@ var DawEditorElement = class extends LitElement8 {
3548
3993
  });
3549
3994
  }
3550
3995
  get currentTime() {
3996
+ if (this._isPlaying && this._engine) {
3997
+ return this._engine.getCurrentTime();
3998
+ }
3551
3999
  return this._currentTime;
3552
4000
  }
3553
4001
  get isRecording() {
@@ -3578,12 +4026,13 @@ var DawEditorElement = class extends LitElement8 {
3578
4026
  if (!rs) return "";
3579
4027
  const audibleSamples = Math.max(0, rs.totalSamples - rs.latencySamples);
3580
4028
  if (audibleSamples === 0) return "";
3581
- const latencyPixels = Math.floor(rs.latencySamples / this.samplesPerPixel);
3582
- const left = Math.floor(rs.startSample / this.samplesPerPixel);
3583
- const w = Math.floor(audibleSamples / this.samplesPerPixel);
4029
+ const renderSpp = this._renderSpp;
4030
+ const latencyPixels = Math.floor(rs.latencySamples / renderSpp);
4031
+ const left = Math.floor(rs.startSample / renderSpp);
4032
+ const w = Math.floor(audibleSamples / renderSpp);
3584
4033
  return rs.peaks.map((chPeaks, ch) => {
3585
4034
  const slicedPeaks = latencyPixels > 0 ? chPeaks.slice(latencyPixels * 2) : chPeaks;
3586
- return html7`
4035
+ return html8`
3587
4036
  <daw-waveform
3588
4037
  data-recording-track=${trackId}
3589
4038
  data-recording-channel=${ch}
@@ -3606,19 +4055,39 @@ var DawEditorElement = class extends LitElement8 {
3606
4055
  if (!playhead || !this._engine) return;
3607
4056
  const engine = this._engine;
3608
4057
  const ctx = this.audioContext;
3609
- playhead.startAnimation(
3610
- () => {
3611
- const latency = "outputLatency" in ctx ? ctx.outputLatency : 0;
3612
- return Math.max(0, engine.getCurrentTime() - latency);
3613
- },
3614
- this.effectiveSampleRate,
3615
- this.samplesPerPixel
3616
- );
4058
+ if (this.scaleMode === "beats") {
4059
+ const secondsToTicksFn = (s) => this._secondsToTicks(s);
4060
+ playhead.startBeatsAnimationWithMap(
4061
+ () => {
4062
+ const latency = "outputLatency" in ctx ? ctx.outputLatency : 0;
4063
+ return Math.max(0, engine.getCurrentTime() - latency);
4064
+ },
4065
+ secondsToTicksFn,
4066
+ this.ticksPerPixel
4067
+ );
4068
+ } else {
4069
+ playhead.startAnimation(
4070
+ () => {
4071
+ const latency = "outputLatency" in ctx ? ctx.outputLatency : 0;
4072
+ return Math.max(0, engine.getCurrentTime() - latency);
4073
+ },
4074
+ this.effectiveSampleRate,
4075
+ this.samplesPerPixel
4076
+ );
4077
+ }
3617
4078
  }
3618
4079
  _stopPlayhead() {
3619
4080
  const playhead = this._getPlayhead();
3620
4081
  if (!playhead) return;
3621
- playhead.stopAnimation(this._currentTime, this.effectiveSampleRate, this.samplesPerPixel);
4082
+ if (this.scaleMode === "beats") {
4083
+ playhead.stopBeatsAnimationWithMap(
4084
+ this._currentTime,
4085
+ (s) => this._secondsToTicks(s),
4086
+ this.ticksPerPixel
4087
+ );
4088
+ } else {
4089
+ playhead.stopAnimation(this._currentTime, this.effectiveSampleRate, this.samplesPerPixel);
4090
+ }
3622
4091
  }
3623
4092
  _getPlayhead() {
3624
4093
  return this.shadowRoot?.querySelector("daw-playhead");
@@ -3639,8 +4108,18 @@ var DawEditorElement = class extends LitElement8 {
3639
4108
  // --- Render ---
3640
4109
  render() {
3641
4110
  const sr = this.effectiveSampleRate;
3642
- const selStartPx = this._selectionStartTime * sr / this.samplesPerPixel;
3643
- const selEndPx = this._selectionEndTime * sr / this.samplesPerPixel;
4111
+ const spp = this._renderSpp;
4112
+ let selStartPx;
4113
+ let selEndPx;
4114
+ if (this.scaleMode === "beats") {
4115
+ const startTick = this._secondsToTicks(this._selectionStartTime);
4116
+ const endTick = this._secondsToTicks(this._selectionEndTime);
4117
+ selStartPx = startTick / this.ticksPerPixel;
4118
+ selEndPx = endTick / this.ticksPerPixel;
4119
+ } else {
4120
+ selStartPx = this._selectionStartTime * sr / spp;
4121
+ selEndPx = this._selectionEndTime * sr / spp;
4122
+ }
3644
4123
  const orderedTracks = this._getOrderedTracks().map(([trackId, track]) => {
3645
4124
  const descriptor = this._tracks.get(trackId);
3646
4125
  const firstPeaks = track.clips.map((c) => this._peaksData.get(c.id)).find((p) => p && p.data.length > 0);
@@ -3654,11 +4133,11 @@ var DawEditorElement = class extends LitElement8 {
3654
4133
  trackHeight: this.waveHeight * numChannels + (this.clipHeaders ? this.clipHeaderHeight : 0)
3655
4134
  };
3656
4135
  });
3657
- return html7`
3658
- ${orderedTracks.length > 0 ? html7`<div class="controls-column">
3659
- ${this.timescale ? html7`<div style="height: 30px;"></div>` : ""}
4136
+ return html8`
4137
+ ${orderedTracks.length > 0 ? html8`<div class="controls-column">
4138
+ ${this.timescale ? html8`<div style="height: 30px;"></div>` : ""}
3660
4139
  ${orderedTracks.map(
3661
- (t) => html7`
4140
+ (t) => html8`
3662
4141
  <daw-track-controls
3663
4142
  style="height: ${t.trackHeight}px;"
3664
4143
  .trackId=${t.trackId}
@@ -3681,16 +4160,31 @@ var DawEditorElement = class extends LitElement8 {
3681
4160
  @dragleave=${this._onDragLeave}
3682
4161
  @drop=${this._onDrop}
3683
4162
  >
3684
- ${orderedTracks.length > 0 && this.timescale ? html7`<daw-ruler
3685
- .samplesPerPixel=${this.samplesPerPixel}
4163
+ ${(orderedTracks.length > 0 || this.scaleMode === "beats") && this.timescale ? html8`<daw-ruler
4164
+ .samplesPerPixel=${spp}
3686
4165
  .sampleRate=${this.effectiveSampleRate}
3687
4166
  .duration=${this._duration}
4167
+ .scaleMode=${this.scaleMode}
4168
+ .ticksPerPixel=${this.ticksPerPixel}
4169
+ .meterEntries=${this._meterEntries}
4170
+ .ppqn=${this.ppqn}
4171
+ .totalWidth=${this._totalWidth}
3688
4172
  ></daw-ruler>` : ""}
3689
- ${orderedTracks.length > 0 ? html7`<daw-selection .startPx=${selStartPx} .endPx=${selEndPx}></daw-selection>
4173
+ ${this.scaleMode === "beats" ? html8`<daw-grid
4174
+ style="top: ${this.timescale ? 30 : 0}px;"
4175
+ .ticksPerPixel=${this.ticksPerPixel}
4176
+ .meterEntries=${this._meterEntries}
4177
+ .ppqn=${this.ppqn}
4178
+ .visibleStart=${this._viewport.visibleStart}
4179
+ .visibleEnd=${this._viewport.visibleEnd}
4180
+ .length=${this._totalWidth}
4181
+ .height=${orderedTracks.length > 0 ? orderedTracks.reduce((sum, t) => sum + t.trackHeight + 1, 0) : this._emptyGridHeight}
4182
+ ></daw-grid>` : ""}
4183
+ ${orderedTracks.length > 0 || this.scaleMode === "beats" ? html8`<daw-selection .startPx=${selStartPx} .endPx=${selEndPx}></daw-selection>
3690
4184
  <daw-playhead></daw-playhead>` : ""}
3691
4185
  ${orderedTracks.map((t) => {
3692
4186
  const channelHeight = this.waveHeight;
3693
- return html7`
4187
+ return html8`
3694
4188
  <div
3695
4189
  class="track-row ${t.trackId === this._selectedTrackId ? "selected" : ""}"
3696
4190
  style="height: ${t.trackHeight}px;"
@@ -3698,21 +4192,67 @@ var DawEditorElement = class extends LitElement8 {
3698
4192
  >
3699
4193
  ${t.track.clips.map((clip) => {
3700
4194
  const peakData = this._peaksData.get(clip.id);
3701
- const width = clipPixelWidth(
3702
- clip.startSample,
3703
- clip.durationSamples,
3704
- this.samplesPerPixel
3705
- );
3706
- const clipLeft = Math.floor(clip.startSample / this.samplesPerPixel);
3707
- const channels = peakData?.data ?? [new Int16Array(0)];
4195
+ let clipLeft;
4196
+ let width;
4197
+ if (this.scaleMode === "beats") {
4198
+ const startTick = clip.startTick !== void 0 ? clip.startTick : this._secondsToTicks(clip.startSample / sr);
4199
+ const durSec = clip.durationSamples / sr;
4200
+ const startSec = clip.startTick !== void 0 ? this._ticksToSeconds(clip.startTick) : clip.startSample / sr;
4201
+ const endTick = this._secondsToTicks(startSec + durSec);
4202
+ clipLeft = Math.round(startTick / this.ticksPerPixel);
4203
+ width = Math.round(endTick / this.ticksPerPixel) - clipLeft;
4204
+ } else {
4205
+ clipLeft = Math.floor(clip.startSample / spp);
4206
+ width = clipPixelWidth(clip.startSample, clip.durationSamples, spp);
4207
+ }
4208
+ let clipSegments;
4209
+ let segmentChannels;
4210
+ if (this.scaleMode === "beats" && this.secondsToTicks) {
4211
+ const audioBuffer = this._clipBuffers.get(clip.id);
4212
+ const basePeaks = audioBuffer ? this._peakPipeline.getBaseScalePeaks(
4213
+ audioBuffer,
4214
+ this.mono,
4215
+ clip.offsetSamples,
4216
+ clip.durationSamples
4217
+ ) : null;
4218
+ if (basePeaks) {
4219
+ const baseScale = basePeaks.scale;
4220
+ segmentChannels = basePeaks.peaks.data;
4221
+ const MIN_RENDER_STEP = 80;
4222
+ const stepTicks = Math.max(MIN_RENDER_STEP, Math.ceil(this.ticksPerPixel));
4223
+ const startSec = clip.startTick !== void 0 ? this._ticksToSeconds(clip.startTick) : clip.startSample / sr;
4224
+ const clipOffsetSec = clip.offsetSamples / sr;
4225
+ const segStartTick = clip.startTick !== void 0 ? clip.startTick : this._secondsToTicks(startSec);
4226
+ const endTick = this._secondsToTicks(startSec + clip.durationSamples / sr);
4227
+ clipSegments = [];
4228
+ for (let tick = segStartTick; tick < endTick; tick += stepTicks) {
4229
+ const segEndTick = Math.min(tick + stepTicks, endTick);
4230
+ const segStartAudioSec = this._ticksToSeconds(tick) - startSec + clipOffsetSec;
4231
+ const segEndAudioSec = this._ticksToSeconds(segEndTick) - startSec + clipOffsetSec;
4232
+ const segStartSample = Math.round(segStartAudioSec * sr);
4233
+ const segEndSample = Math.round(segEndAudioSec * sr);
4234
+ const totalPeaks = clip.durationSamples / baseScale;
4235
+ clipSegments.push({
4236
+ peakStart: Math.max(0, (segStartSample - clip.offsetSamples) / baseScale),
4237
+ peakEnd: Math.min(
4238
+ totalPeaks,
4239
+ (segEndSample - clip.offsetSamples) / baseScale
4240
+ ),
4241
+ pixelStart: (tick - segStartTick) / this.ticksPerPixel,
4242
+ pixelEnd: (segEndTick - segStartTick) / this.ticksPerPixel
4243
+ });
4244
+ }
4245
+ }
4246
+ }
4247
+ const channels = segmentChannels ?? peakData?.data ?? [new Int16Array(0)];
3708
4248
  const hdrH = this.clipHeaders ? this.clipHeaderHeight : 0;
3709
4249
  const chH = this.waveHeight;
3710
- return html7` <div
4250
+ return html8` <div
3711
4251
  class="clip-container"
3712
4252
  style="left:${clipLeft}px;top:0;width:${width}px;height:${t.trackHeight}px;"
3713
4253
  data-clip-id=${clip.id}
3714
4254
  >
3715
- ${hdrH > 0 ? html7`<div
4255
+ ${hdrH > 0 ? html8`<div
3716
4256
  class="clip-header"
3717
4257
  data-clip-id=${clip.id}
3718
4258
  data-track-id=${t.trackId}
@@ -3721,7 +4261,7 @@ var DawEditorElement = class extends LitElement8 {
3721
4261
  <span>${clip.name || t.descriptor?.name || ""}</span>
3722
4262
  </div>` : ""}
3723
4263
  ${channels.map(
3724
- (chPeaks, chIdx) => html7` <daw-waveform
4264
+ (chPeaks, chIdx) => html8` <daw-waveform
3725
4265
  style="position:absolute;left:0;top:${hdrH + chIdx * chH}px;"
3726
4266
  .peaks=${chPeaks}
3727
4267
  .length=${peakData?.length ?? width}
@@ -3731,9 +4271,10 @@ var DawEditorElement = class extends LitElement8 {
3731
4271
  .visibleStart=${this._viewport.visibleStart}
3732
4272
  .visibleEnd=${this._viewport.visibleEnd}
3733
4273
  .originX=${clipLeft}
4274
+ .segments=${clipSegments}
3734
4275
  ></daw-waveform>`
3735
4276
  )}
3736
- ${this.interactiveClips ? html7` <div
4277
+ ${this.interactiveClips ? html8` <div
3737
4278
  class="clip-boundary"
3738
4279
  data-boundary-edge="left"
3739
4280
  data-clip-id=${clip.id}
@@ -3759,7 +4300,7 @@ var DawEditorElement = class extends LitElement8 {
3759
4300
  };
3760
4301
  DawEditorElement.styles = [
3761
4302
  hostStyles,
3762
- css7`
4303
+ css8`
3763
4304
  :host {
3764
4305
  display: flex;
3765
4306
  position: relative;
@@ -3789,6 +4330,15 @@ DawEditorElement.styles = [
3789
4330
  .track-row.selected {
3790
4331
  background: rgba(99, 199, 95, 0.08);
3791
4332
  }
4333
+ :host([scale-mode='beats']) .track-row {
4334
+ background: transparent;
4335
+ }
4336
+ :host([scale-mode='beats']) .clip-container {
4337
+ background: var(--daw-track-background, #16213e);
4338
+ }
4339
+ :host([scale-mode='beats']) .track-row.selected .clip-container {
4340
+ box-shadow: inset 0 0 0 1000px rgba(99, 199, 95, 0.06);
4341
+ }
3792
4342
  .timeline.drag-over {
3793
4343
  outline: 2px dashed var(--daw-selection-color, rgba(99, 199, 95, 0.3));
3794
4344
  outline-offset: -2px;
@@ -3798,37 +4348,64 @@ DawEditorElement.styles = [
3798
4348
  ];
3799
4349
  DawEditorElement._CONTROL_PROPS = /* @__PURE__ */ new Set(["volume", "pan", "muted", "soloed"]);
3800
4350
  __decorateClass([
3801
- property6({ type: Number, attribute: "samples-per-pixel", noAccessor: true })
4351
+ property7({ type: Number, attribute: "samples-per-pixel", noAccessor: true })
3802
4352
  ], DawEditorElement.prototype, "samplesPerPixel", 1);
3803
4353
  __decorateClass([
3804
- property6({ type: Number, attribute: "wave-height" })
4354
+ property7({ type: Number, attribute: "wave-height" })
3805
4355
  ], DawEditorElement.prototype, "waveHeight", 2);
3806
4356
  __decorateClass([
3807
- property6({ type: Boolean })
4357
+ property7({ type: Boolean })
3808
4358
  ], DawEditorElement.prototype, "timescale", 2);
3809
4359
  __decorateClass([
3810
- property6({ type: Boolean })
4360
+ property7({ type: Boolean })
3811
4361
  ], DawEditorElement.prototype, "mono", 2);
3812
4362
  __decorateClass([
3813
- property6({ type: Number, attribute: "bar-width" })
4363
+ property7({ type: Number, attribute: "bar-width" })
3814
4364
  ], DawEditorElement.prototype, "barWidth", 2);
3815
4365
  __decorateClass([
3816
- property6({ type: Number, attribute: "bar-gap" })
4366
+ property7({ type: Number, attribute: "bar-gap" })
3817
4367
  ], DawEditorElement.prototype, "barGap", 2);
3818
4368
  __decorateClass([
3819
- property6({ type: Boolean, attribute: "file-drop" })
4369
+ property7({ type: Boolean, attribute: "file-drop" })
3820
4370
  ], DawEditorElement.prototype, "fileDrop", 2);
3821
4371
  __decorateClass([
3822
- property6({ type: Boolean, attribute: "clip-headers" })
4372
+ property7({ type: Boolean, attribute: "clip-headers" })
3823
4373
  ], DawEditorElement.prototype, "clipHeaders", 2);
3824
4374
  __decorateClass([
3825
- property6({ type: Number, attribute: "clip-header-height" })
4375
+ property7({ type: Number, attribute: "clip-header-height" })
3826
4376
  ], DawEditorElement.prototype, "clipHeaderHeight", 2);
3827
4377
  __decorateClass([
3828
- property6({ type: Boolean, attribute: "interactive-clips" })
4378
+ property7({ type: Boolean, attribute: "interactive-clips" })
3829
4379
  ], DawEditorElement.prototype, "interactiveClips", 2);
3830
4380
  __decorateClass([
3831
- property6({ type: Number, attribute: "sample-rate" })
4381
+ property7({ type: String, attribute: "scale-mode" })
4382
+ ], DawEditorElement.prototype, "scaleMode", 2);
4383
+ __decorateClass([
4384
+ property7({ type: Number, attribute: "ticks-per-pixel", noAccessor: true })
4385
+ ], DawEditorElement.prototype, "ticksPerPixel", 1);
4386
+ __decorateClass([
4387
+ property7({ type: Number, noAccessor: true })
4388
+ ], DawEditorElement.prototype, "bpm", 1);
4389
+ __decorateClass([
4390
+ property7({ attribute: false })
4391
+ ], DawEditorElement.prototype, "timeSignature", 2);
4392
+ __decorateClass([
4393
+ property7({ attribute: false })
4394
+ ], DawEditorElement.prototype, "meterEntries", 2);
4395
+ __decorateClass([
4396
+ property7({ type: Number, noAccessor: true })
4397
+ ], DawEditorElement.prototype, "ppqn", 1);
4398
+ __decorateClass([
4399
+ property7({ type: String, attribute: "snap-to" })
4400
+ ], DawEditorElement.prototype, "snapTo", 2);
4401
+ __decorateClass([
4402
+ property7({ attribute: false })
4403
+ ], DawEditorElement.prototype, "secondsToTicks", 2);
4404
+ __decorateClass([
4405
+ property7({ attribute: false })
4406
+ ], DawEditorElement.prototype, "ticksToSeconds", 2);
4407
+ __decorateClass([
4408
+ property7({ type: Number, attribute: "sample-rate" })
3832
4409
  ], DawEditorElement.prototype, "sampleRate", 2);
3833
4410
  __decorateClass([
3834
4411
  state3()
@@ -3852,15 +4429,15 @@ __decorateClass([
3852
4429
  state3()
3853
4430
  ], DawEditorElement.prototype, "_dragOver", 2);
3854
4431
  __decorateClass([
3855
- property6({ attribute: "eager-resume" })
4432
+ property7({ attribute: "eager-resume" })
3856
4433
  ], DawEditorElement.prototype, "eagerResume", 2);
3857
4434
  DawEditorElement = __decorateClass([
3858
- customElement10("daw-editor")
4435
+ customElement11("daw-editor")
3859
4436
  ], DawEditorElement);
3860
4437
 
3861
4438
  // src/elements/daw-ruler.ts
3862
- import { LitElement as LitElement9, html as html8, css as css8 } from "lit";
3863
- import { customElement as customElement11, property as property7 } from "lit/decorators.js";
4439
+ import { LitElement as LitElement10, html as html9, css as css9 } from "lit";
4440
+ import { customElement as customElement12, property as property8 } from "lit/decorators.js";
3864
4441
 
3865
4442
  // src/utils/time-format.ts
3866
4443
  function formatTime(milliseconds) {
@@ -3911,18 +4488,36 @@ function computeTemporalTicks(samplesPerPixel, sampleRate, duration, rulerHeight
3911
4488
  }
3912
4489
 
3913
4490
  // src/elements/daw-ruler.ts
3914
- var MAX_CANVAS_WIDTH2 = 1e3;
3915
- var DawRulerElement = class extends LitElement9 {
4491
+ var MAX_CANVAS_WIDTH3 = 1e3;
4492
+ var DawRulerElement = class extends LitElement10 {
3916
4493
  constructor() {
3917
4494
  super(...arguments);
3918
4495
  this.samplesPerPixel = 1024;
3919
4496
  this.sampleRate = 48e3;
3920
4497
  this.duration = 0;
3921
4498
  this.rulerHeight = 30;
4499
+ this.scaleMode = "temporal";
4500
+ this.ticksPerPixel = 4;
4501
+ this.meterEntries = [
4502
+ { tick: 0, numerator: 4, denominator: 4 }
4503
+ ];
4504
+ this.ppqn = 960;
4505
+ this.totalWidth = 0;
3922
4506
  this._tickData = null;
4507
+ this._musicalTickData = null;
3923
4508
  }
3924
4509
  willUpdate() {
3925
- if (this.duration > 0) {
4510
+ if (this.scaleMode === "beats" && this.totalWidth > 0) {
4511
+ this._musicalTickData = getCachedMusicalTicks({
4512
+ meterEntries: this.meterEntries,
4513
+ ticksPerPixel: this.ticksPerPixel,
4514
+ startPixel: 0,
4515
+ endPixel: this.totalWidth,
4516
+ ppqn: this.ppqn
4517
+ });
4518
+ this._tickData = null;
4519
+ } else if (this.duration > 0) {
4520
+ this._musicalTickData = null;
3926
4521
  this._tickData = computeTemporalTicks(
3927
4522
  this.samplesPerPixel,
3928
4523
  this.sampleRate,
@@ -3930,30 +4525,39 @@ var DawRulerElement = class extends LitElement9 {
3930
4525
  this.rulerHeight
3931
4526
  );
3932
4527
  } else {
4528
+ this._musicalTickData = null;
3933
4529
  this._tickData = null;
3934
4530
  }
3935
4531
  }
3936
4532
  render() {
3937
- if (!this._tickData) return html8``;
3938
- const { widthX, labels } = this._tickData;
3939
- const totalChunks = Math.ceil(widthX / MAX_CANVAS_WIDTH2);
4533
+ const widthX = this.scaleMode === "beats" ? this.totalWidth : this._tickData?.widthX ?? 0;
4534
+ if (widthX <= 0) return html9``;
4535
+ const totalChunks = Math.ceil(widthX / MAX_CANVAS_WIDTH3);
3940
4536
  const indices = Array.from({ length: totalChunks }, (_, i) => i);
3941
4537
  const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
3942
- return html8`
4538
+ const beatsLabels = this.scaleMode === "beats" ? this._musicalTickData?.ticks.filter((t) => t.label) ?? [] : [];
4539
+ const temporalLabels = this.scaleMode !== "beats" ? this._tickData?.labels ?? [] : [];
4540
+ return html9`
3943
4541
  <div class="container" style="width: ${widthX}px; height: ${this.rulerHeight}px;">
3944
4542
  ${indices.map((i) => {
3945
- const width = Math.min(MAX_CANVAS_WIDTH2, widthX - i * MAX_CANVAS_WIDTH2);
3946
- return html8`
4543
+ const width = Math.min(MAX_CANVAS_WIDTH3, widthX - i * MAX_CANVAS_WIDTH3);
4544
+ return html9`
3947
4545
  <canvas
3948
4546
  data-index=${i}
3949
4547
  width=${width * dpr}
3950
4548
  height=${this.rulerHeight * dpr}
3951
- style="left: ${i * MAX_CANVAS_WIDTH2}px; width: ${width}px; height: ${this.rulerHeight}px;"
4549
+ style="left: ${i * MAX_CANVAS_WIDTH3}px; width: ${width}px; height: ${this.rulerHeight}px;"
3952
4550
  ></canvas>
3953
4551
  `;
3954
4552
  })}
3955
- ${labels.map(
3956
- ({ pix, text }) => html8`<span class="label" style="left: ${pix + 4}px;">${text}</span>`
4553
+ ${this.scaleMode === "beats" ? beatsLabels.map(
4554
+ (t) => html9`<span
4555
+ class="label ${t.pixel > 0 ? "centered" : ""}"
4556
+ style="left: ${t.pixel > 0 ? t.pixel : t.pixel + 4}px;"
4557
+ >${t.label}</span
4558
+ >`
4559
+ ) : temporalLabels.map(
4560
+ ({ pix, text }) => html9`<span class="label" style="left: ${pix + 4}px;">${text}</span>`
3957
4561
  )}
3958
4562
  </div>
3959
4563
  `;
@@ -3962,37 +4566,49 @@ var DawRulerElement = class extends LitElement9 {
3962
4566
  this._drawTicks();
3963
4567
  }
3964
4568
  _drawTicks() {
3965
- if (!this._tickData) return;
3966
4569
  const canvases = this.shadowRoot?.querySelectorAll("canvas");
3967
4570
  if (!canvases) return;
3968
4571
  const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
3969
4572
  const rulerColor = getComputedStyle(this).getPropertyValue("--daw-ruler-color").trim() || "#c49a6c";
4573
+ const widthX = this.scaleMode === "beats" ? this.totalWidth : this._tickData?.widthX ?? 0;
3970
4574
  for (const canvas of canvases) {
3971
4575
  const idx = Number(canvas.dataset.index);
3972
4576
  const ctx = canvas.getContext("2d");
3973
4577
  if (!ctx) continue;
3974
- const canvasWidth = Math.min(
3975
- MAX_CANVAS_WIDTH2,
3976
- this._tickData.widthX - idx * MAX_CANVAS_WIDTH2
3977
- );
3978
- const globalOffset = idx * MAX_CANVAS_WIDTH2;
4578
+ const canvasWidth = Math.min(MAX_CANVAS_WIDTH3, widthX - idx * MAX_CANVAS_WIDTH3);
4579
+ const globalOffset = idx * MAX_CANVAS_WIDTH3;
3979
4580
  ctx.resetTransform();
3980
4581
  ctx.clearRect(0, 0, canvas.width, canvas.height);
3981
4582
  ctx.scale(dpr, dpr);
3982
4583
  ctx.strokeStyle = rulerColor;
3983
4584
  ctx.lineWidth = 1;
3984
- for (const [pix, height] of this._tickData.canvasInfo) {
3985
- const localX = pix - globalOffset;
3986
- if (localX < 0 || localX >= canvasWidth) continue;
3987
- ctx.beginPath();
3988
- ctx.moveTo(localX + 0.5, this.rulerHeight);
3989
- ctx.lineTo(localX + 0.5, this.rulerHeight - height);
3990
- ctx.stroke();
4585
+ if (this.scaleMode === "beats" && this._musicalTickData) {
4586
+ const h = this.rulerHeight;
4587
+ for (const tick of this._musicalTickData.ticks) {
4588
+ const localX = tick.pixel - globalOffset;
4589
+ if (localX < 0 || localX >= canvasWidth) continue;
4590
+ const tickH = tick.type === "major" ? h * 0.6 : tick.type === "minor" ? h * 0.35 : h * 0.15;
4591
+ ctx.globalAlpha = tick.type === "major" ? 1 : 0.5;
4592
+ ctx.beginPath();
4593
+ ctx.moveTo(localX + 0.5, h);
4594
+ ctx.lineTo(localX + 0.5, h - tickH);
4595
+ ctx.stroke();
4596
+ }
4597
+ ctx.globalAlpha = 1;
4598
+ } else if (this._tickData) {
4599
+ for (const [pix, height] of this._tickData.canvasInfo) {
4600
+ const localX = pix - globalOffset;
4601
+ if (localX < 0 || localX >= canvasWidth) continue;
4602
+ ctx.beginPath();
4603
+ ctx.moveTo(localX + 0.5, this.rulerHeight);
4604
+ ctx.lineTo(localX + 0.5, this.rulerHeight - height);
4605
+ ctx.stroke();
4606
+ }
3991
4607
  }
3992
4608
  }
3993
4609
  }
3994
4610
  };
3995
- DawRulerElement.styles = css8`
4611
+ DawRulerElement.styles = css9`
3996
4612
  :host {
3997
4613
  display: block;
3998
4614
  position: relative;
@@ -4008,31 +4624,50 @@ DawRulerElement.styles = css8`
4008
4624
  .label {
4009
4625
  position: absolute;
4010
4626
  font-size: 0.7rem;
4627
+ line-height: 1;
4011
4628
  white-space: nowrap;
4012
4629
  color: var(--daw-ruler-color, #c49a6c);
4013
- top: 2px;
4630
+ top: 1px;
4631
+ }
4632
+ .label.centered {
4633
+ transform: translateX(-50%);
4014
4634
  }
4015
4635
  `;
4016
4636
  __decorateClass([
4017
- property7({ type: Number, attribute: false })
4637
+ property8({ type: Number, attribute: false })
4018
4638
  ], DawRulerElement.prototype, "samplesPerPixel", 2);
4019
4639
  __decorateClass([
4020
- property7({ type: Number, attribute: false })
4640
+ property8({ type: Number, attribute: false })
4021
4641
  ], DawRulerElement.prototype, "sampleRate", 2);
4022
4642
  __decorateClass([
4023
- property7({ type: Number, attribute: false })
4643
+ property8({ type: Number, attribute: false })
4024
4644
  ], DawRulerElement.prototype, "duration", 2);
4025
4645
  __decorateClass([
4026
- property7({ type: Number, attribute: false })
4646
+ property8({ type: Number, attribute: false })
4027
4647
  ], DawRulerElement.prototype, "rulerHeight", 2);
4648
+ __decorateClass([
4649
+ property8({ type: String, attribute: false })
4650
+ ], DawRulerElement.prototype, "scaleMode", 2);
4651
+ __decorateClass([
4652
+ property8({ type: Number, attribute: false })
4653
+ ], DawRulerElement.prototype, "ticksPerPixel", 2);
4654
+ __decorateClass([
4655
+ property8({ attribute: false })
4656
+ ], DawRulerElement.prototype, "meterEntries", 2);
4657
+ __decorateClass([
4658
+ property8({ type: Number, attribute: false })
4659
+ ], DawRulerElement.prototype, "ppqn", 2);
4660
+ __decorateClass([
4661
+ property8({ type: Number, attribute: false })
4662
+ ], DawRulerElement.prototype, "totalWidth", 2);
4028
4663
  DawRulerElement = __decorateClass([
4029
- customElement11("daw-ruler")
4664
+ customElement12("daw-ruler")
4030
4665
  ], DawRulerElement);
4031
4666
 
4032
4667
  // src/elements/daw-selection.ts
4033
- import { LitElement as LitElement10, html as html9, css as css9 } from "lit";
4034
- import { customElement as customElement12, property as property8 } from "lit/decorators.js";
4035
- var DawSelectionElement = class extends LitElement10 {
4668
+ import { LitElement as LitElement11, html as html10, css as css10 } from "lit";
4669
+ import { customElement as customElement13, property as property9 } from "lit/decorators.js";
4670
+ var DawSelectionElement = class extends LitElement11 {
4036
4671
  constructor() {
4037
4672
  super(...arguments);
4038
4673
  this.startPx = 0;
@@ -4041,11 +4676,11 @@ var DawSelectionElement = class extends LitElement10 {
4041
4676
  render() {
4042
4677
  const left = Math.min(this.startPx, this.endPx);
4043
4678
  const width = Math.abs(this.endPx - this.startPx);
4044
- if (width === 0) return html9``;
4045
- return html9`<div style="left: ${left}px; width: ${width}px;"></div>`;
4679
+ if (width === 0) return html10``;
4680
+ return html10`<div style="left: ${left}px; width: ${width}px;"></div>`;
4046
4681
  }
4047
4682
  };
4048
- DawSelectionElement.styles = css9`
4683
+ DawSelectionElement.styles = css10`
4049
4684
  :host {
4050
4685
  position: absolute;
4051
4686
  top: 0;
@@ -4062,18 +4697,18 @@ DawSelectionElement.styles = css9`
4062
4697
  }
4063
4698
  `;
4064
4699
  __decorateClass([
4065
- property8({ type: Number, attribute: false })
4700
+ property9({ type: Number, attribute: false })
4066
4701
  ], DawSelectionElement.prototype, "startPx", 2);
4067
4702
  __decorateClass([
4068
- property8({ type: Number, attribute: false })
4703
+ property9({ type: Number, attribute: false })
4069
4704
  ], DawSelectionElement.prototype, "endPx", 2);
4070
4705
  DawSelectionElement = __decorateClass([
4071
- customElement12("daw-selection")
4706
+ customElement13("daw-selection")
4072
4707
  ], DawSelectionElement);
4073
4708
 
4074
4709
  // src/elements/daw-record-button.ts
4075
- import { html as html10, css as css10 } from "lit";
4076
- import { customElement as customElement13, state as state4 } from "lit/decorators.js";
4710
+ import { html as html11, css as css11 } from "lit";
4711
+ import { customElement as customElement14, state as state4 } from "lit/decorators.js";
4077
4712
  var DawRecordButtonElement = class extends DawTransportButton {
4078
4713
  constructor() {
4079
4714
  super(...arguments);
@@ -4114,7 +4749,7 @@ var DawRecordButtonElement = class extends DawTransportButton {
4114
4749
  }
4115
4750
  }
4116
4751
  render() {
4117
- return html10`
4752
+ return html11`
4118
4753
  <button part="button" ?data-recording=${this._isRecording} @click=${this._onClick}>
4119
4754
  <slot>Record</slot>
4120
4755
  </button>
@@ -4134,7 +4769,7 @@ var DawRecordButtonElement = class extends DawTransportButton {
4134
4769
  };
4135
4770
  DawRecordButtonElement.styles = [
4136
4771
  DawTransportButton.styles,
4137
- css10`
4772
+ css11`
4138
4773
  button[data-recording] {
4139
4774
  color: #d08070;
4140
4775
  border-color: #d08070;
@@ -4146,14 +4781,14 @@ __decorateClass([
4146
4781
  state4()
4147
4782
  ], DawRecordButtonElement.prototype, "_isRecording", 2);
4148
4783
  DawRecordButtonElement = __decorateClass([
4149
- customElement13("daw-record-button")
4784
+ customElement14("daw-record-button")
4150
4785
  ], DawRecordButtonElement);
4151
4786
 
4152
4787
  // src/elements/daw-keyboard-shortcuts.ts
4153
- import { LitElement as LitElement11 } from "lit";
4154
- import { customElement as customElement14, property as property9 } from "lit/decorators.js";
4788
+ import { LitElement as LitElement12 } from "lit";
4789
+ import { customElement as customElement15, property as property10 } from "lit/decorators.js";
4155
4790
  import { handleKeyboardEvent } from "@waveform-playlist/core";
4156
- var DawKeyboardShortcutsElement = class extends LitElement11 {
4791
+ var DawKeyboardShortcutsElement = class extends LitElement12 {
4157
4792
  constructor() {
4158
4793
  super(...arguments);
4159
4794
  this.playback = false;
@@ -4307,22 +4942,23 @@ var DawKeyboardShortcutsElement = class extends LitElement11 {
4307
4942
  }
4308
4943
  };
4309
4944
  __decorateClass([
4310
- property9({ type: Boolean })
4945
+ property10({ type: Boolean })
4311
4946
  ], DawKeyboardShortcutsElement.prototype, "playback", 2);
4312
4947
  __decorateClass([
4313
- property9({ type: Boolean })
4948
+ property10({ type: Boolean })
4314
4949
  ], DawKeyboardShortcutsElement.prototype, "splitting", 2);
4315
4950
  __decorateClass([
4316
- property9({ type: Boolean })
4951
+ property10({ type: Boolean })
4317
4952
  ], DawKeyboardShortcutsElement.prototype, "undo", 2);
4318
4953
  DawKeyboardShortcutsElement = __decorateClass([
4319
- customElement14("daw-keyboard-shortcuts")
4954
+ customElement15("daw-keyboard-shortcuts")
4320
4955
  ], DawKeyboardShortcutsElement);
4321
4956
  export {
4322
4957
  AudioResumeController,
4323
4958
  ClipPointerHandler,
4324
4959
  DawClipElement,
4325
4960
  DawEditorElement,
4961
+ DawGridElement,
4326
4962
  DawKeyboardShortcutsElement,
4327
4963
  DawPauseButtonElement,
4328
4964
  DawPlayButtonElement,