diff --git a/static/css/daw.css b/static/css/daw.css
index 25c253c..69488dd 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -1958,6 +1958,45 @@ input, textarea { font-family: inherit; }
color: #4a8cff;
}
+/* Tempo bar */
+.tempo-bar {
+ display: flex; align-items: center; gap: 12px;
+ width: 100%; padding: 8px 4px 0;
+ border-top: 1px solid var(--border-strong);
+ margin-top: 6px; cursor: default; user-select: none;
+}
+.tempo-bar-label {
+ font-size: 10px; font-weight: 700; letter-spacing: 0.08em;
+ text-transform: uppercase; color: var(--fg-2); flex-shrink: 0;
+ white-space: nowrap;
+}
+.tempo-bar-divider {
+ width: 1px; height: 16px; background: var(--border-strong); flex-shrink: 0;
+}
+.tempo-bar-val {
+ font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums;
+ color: var(--fg-2); white-space: nowrap; flex-shrink: 0; min-width: 34px;
+ text-align: right;
+}
+#t-speed {
+ -webkit-appearance: none; appearance: none; flex: 1;
+ height: 4px; border-radius: 999px; outline: none; cursor: pointer;
+ background: linear-gradient(
+ 90deg,
+ var(--gold) 0 var(--speed-pct, 50%),
+ rgba(148,163,184,0.18) var(--speed-pct, 50%) 100%
+ );
+}
+#t-speed::-webkit-slider-thumb {
+ -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
+ background: var(--gold-bright); cursor: pointer;
+ box-shadow: 0 2px 8px rgba(216,168,74,0.35);
+}
+#t-speed::-moz-range-thumb {
+ width: 16px; height: 16px; border-radius: 50%; border: none;
+ background: var(--gold-bright); cursor: pointer;
+}
+
/* Speed + export chip buttons */
.footer-chip-wrap {
position: relative; flex-shrink: 0;
diff --git a/static/index.html b/static/index.html
index 38116f4..7c912ee 100644
--- a/static/index.html
+++ b/static/index.html
@@ -581,6 +581,12 @@
+
+ TEMPO
+
+
+ 1.0x
+
+
+ Speed
+
+ ${state.speed % 1 === 0 ? state.speed.toFixed(1) : state.speed}x
+
${preparing ? 'Preparing audio…
' : ""}
@@ -788,6 +795,17 @@ function wireFaders() {
});
});
+ const speedSlider = app.querySelector("[data-speed]");
+ if (speedSlider) {
+ speedSlider.addEventListener("input", () => {
+ const rate = parseFloat(speedSlider.value);
+ state.speed = rate;
+ const valEl = speedSlider.parentElement?.querySelector(".speed-row-val");
+ if (valEl) valEl.textContent = `${rate % 1 === 0 ? rate.toFixed(1) : rate}x`;
+ if (engine) engine.setPlaybackRate(rate);
+ });
+ }
+
const bars = app.querySelector("[data-seek]");
if (bars) {
bars.addEventListener("pointerdown", (e) => {
diff --git a/static/mobile/styles.css b/static/mobile/styles.css
index 4e41695..81d9ef9 100644
--- a/static/mobile/styles.css
+++ b/static/mobile/styles.css
@@ -282,6 +282,35 @@ button {
box-shadow: 0 12px 30px -6px rgba(63, 207, 110, 0.65);
}
+/* ── speed control ── */
+.speed-row {
+ display: flex; align-items: center; gap: 10px;
+ margin-top: 12px; padding: 0 4px;
+}
+.speed-row-label {
+ font-size: 12px; font-weight: 600; color: #9899a6;
+ text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap;
+ width: 42px;
+}
+.speed-row-val {
+ font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums;
+ color: #e8c96a; width: 38px; text-align: right; white-space: nowrap;
+}
+.speed-slider {
+ -webkit-appearance: none; appearance: none; flex: 1;
+ height: 4px; border-radius: 999px; outline: none; cursor: pointer;
+ background: rgba(148,163,184,0.2);
+}
+.speed-slider::-webkit-slider-thumb {
+ -webkit-appearance: none; width: 20px; height: 20px; border-radius: 50%;
+ background: #e8c96a; cursor: pointer;
+ box-shadow: 0 2px 8px rgba(232,201,106,0.4);
+}
+.speed-slider::-moz-range-thumb {
+ width: 20px; height: 20px; border-radius: 50%; border: none;
+ background: #e8c96a; cursor: pointer;
+}
+
.mx-prep {
text-align: center;
margin-top: 10px;
diff --git a/static/vendor/soundtouch-processor.js b/static/vendor/soundtouch-processor.js
new file mode 100644
index 0000000..031494f
--- /dev/null
+++ b/static/vendor/soundtouch-processor.js
@@ -0,0 +1,160 @@
+'use strict';
+// SoundTouch WSOLA time-stretcher - AudioWorkletProcessor
+// Adapted from SoundTouch C++ by Olli Parviainen. MIT License.
+// Self-contained; no imports required.
+
+const SEQUENCE_MS = 82;
+const SEEK_MS = 28;
+const OVERLAP_MS = 12;
+
+class FloatFifo {
+ constructor() {
+ this._d = new Float32Array(65536);
+ this._r = 0;
+ this._w = 0;
+ }
+ get avail() { return this._w - this._r; }
+ clear() { this._r = this._w = 0; }
+ peek(offset) { return this._d[this._r + offset]; }
+ consume(n) { this._r = Math.min(this._r + n, this._w); }
+ shift(dst, dstOff, n) {
+ n = Math.min(n, this.avail);
+ for (let i = 0; i < n; i++) dst[dstOff + i] = this._d[this._r++];
+ return n;
+ }
+ push(src, srcOff, n) {
+ this._ensureRoom(n);
+ for (let i = 0; i < n; i++) this._d[this._w++] = src[srcOff + i];
+ }
+ _compact() {
+ const av = this.avail;
+ this._d.copyWithin(0, this._r, this._w);
+ this._r = 0;
+ this._w = av;
+ }
+ _ensureRoom(n) {
+ if (this._r > 32768) this._compact();
+ if (this._w + n > this._d.length) {
+ this._compact();
+ if (this._w + n > this._d.length) {
+ const nd = new Float32Array(Math.max(this._d.length * 2, this._w + n + 4096));
+ nd.set(this._d.subarray(0, this._w));
+ this._d = nd;
+ }
+ }
+ }
+}
+
+function findBestOffset(ref, refLen, fifo, seekLen) {
+ let bestOff = 0, bestCorr = -Infinity;
+ for (let off = 0; off < seekLen; off++) {
+ let corr = 0;
+ for (let i = 0; i < refLen; i++) corr += ref[i] * fifo.peek(off + i);
+ if (corr > bestCorr) { bestCorr = corr; bestOff = off; }
+ }
+ return bestOff;
+}
+
+class SoundTouchProcessor extends AudioWorkletProcessor {
+ static get parameterDescriptors() {
+ return [{
+ name: 'tempo',
+ defaultValue: 1.0,
+ minValue: 0.25,
+ maxValue: 4.0,
+ automationRate: 'k-rate',
+ }];
+ }
+
+ constructor() {
+ super();
+ const sr = sampleRate;
+ this._ovLen = Math.round(OVERLAP_MS * sr / 1000);
+ this._seekLen = Math.round(SEEK_MS * sr / 1000);
+ this._seqLen = Math.round(SEQUENCE_MS * sr / 1000);
+ this._midLen = this._seqLen - 2 * this._ovLen;
+
+ this._inL = new FloatFifo();
+ this._inR = new FloatFifo();
+ this._outL = new FloatFifo();
+ this._outR = new FloatFifo();
+
+ // Overlap carry-over buffers (crossfade region saved from previous sequence)
+ this._carryL = new Float32Array(this._ovLen);
+ this._carryR = new Float32Array(this._ovLen);
+
+ // Pre-allocated temp output (seqLen - ovLen samples per sequence max)
+ const outPerSeq = this._ovLen + this._midLen;
+ this._tmpL = new Float32Array(outPerSeq);
+ this._tmpR = new Float32Array(outPerSeq);
+ }
+
+ process(inputs, outputs, parameters) {
+ const inp = inputs[0];
+ const outp = outputs[0];
+ const frames = 128;
+ const tempo = parameters.tempo[0];
+
+ const inL = inp?.[0] || new Float32Array(frames);
+ const inR = inp?.[1] || inL;
+ const outL = outp[0];
+ const outR = outp[1] || outL;
+
+ // Passthrough at 1.0 - zero DSP cost
+ if (Math.abs(tempo - 1.0) < 0.001) {
+ outL.set(inL);
+ if (outR !== outL) outR.set(inR);
+ return true;
+ }
+
+ this._inL.push(inL, 0, frames);
+ this._inR.push(inR, 0, frames);
+
+ const needed = this._ovLen + this._seekLen + this._seqLen;
+ while (this._inL.avail >= needed) this._processSeq(tempo);
+
+ const got = this._outL.shift(outL, 0, frames);
+ this._outR.shift(outR, 0, frames);
+ for (let i = got; i < frames; i++) { outL[i] = 0; if (outR !== outL) outR[i] = 0; }
+
+ return true;
+ }
+
+ _processSeq(tempo) {
+ const ovLen = this._ovLen;
+ const midLen = this._midLen;
+ const seqLen = this._seqLen;
+ const outLen = ovLen + midLen;
+
+ const bestOff = findBestOffset(this._carryL, ovLen, this._inL, this._seekLen);
+
+ // Crossfade carry-over with the new sequence start
+ for (let i = 0; i < ovLen; i++) {
+ const w = i / ovLen;
+ this._tmpL[i] = this._carryL[i] * (1 - w) + this._inL.peek(bestOff + i) * w;
+ this._tmpR[i] = this._carryR[i] * (1 - w) + this._inR.peek(bestOff + i) * w;
+ }
+
+ // Copy middle section verbatim
+ for (let i = 0; i < midLen; i++) {
+ this._tmpL[ovLen + i] = this._inL.peek(bestOff + ovLen + i);
+ this._tmpR[ovLen + i] = this._inR.peek(bestOff + ovLen + i);
+ }
+
+ // Save last ovLen samples as new carry-over for next crossfade
+ for (let i = 0; i < ovLen; i++) {
+ this._carryL[i] = this._inL.peek(bestOff + ovLen + midLen + i);
+ this._carryR[i] = this._inR.peek(bestOff + ovLen + midLen + i);
+ }
+
+ this._outL.push(this._tmpL, 0, outLen);
+ this._outR.push(this._tmpR, 0, outLen);
+
+ // Advance input: tempo controls how many input samples map to one output sequence
+ const advance = Math.round((seqLen - ovLen) * tempo) + bestOff;
+ this._inL.consume(advance);
+ this._inR.consume(advance);
+ }
+}
+
+registerProcessor('soundtouch-processor', SoundTouchProcessor);