From 11d98f16f7ea2b1155e62bb66852308aa76e0d55 Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Tue, 30 Jun 2026 09:38:46 +0100
Subject: [PATCH 1/6] feat(player): playback speed control (0.5x to 2.0x)
Adds a speed slider to the transport footer (desktop) and mixer tab
(mobile) so users can slow down or speed up tracks for practice.
- audioEngine: store _playbackRate, apply to new AudioBufferSourceNodes
on startSources(), and fix getCurrentTime() to account for rate so
the waveform playhead and loop detection stay accurate at non-1x speeds
- transport: applySpeed() propagates rate to both engine and streaming
paths; scroll-wheel support (+-0.25 per tick); double-click resets to 1x
- state: playbackSpeed variable + setter; speedEl/speedLabelEl DOM refs
- player: resetSpeed() called in destroyPlayer() so a new track always
starts at 1x
- mobile: speed slider in mixer transport section, state.speed reset on
track open
---
static/css/daw.css | 31 +++++++++++++++++++++++++++++++
static/index.html | 4 ++++
static/js/audioEngine.js | 10 +++++++++-
static/js/player.js | 3 ++-
static/js/state.js | 4 ++++
static/js/transport.js | 37 ++++++++++++++++++++++++++++++++++++-
static/mobile/app.js | 18 ++++++++++++++++++
static/mobile/styles.css | 29 +++++++++++++++++++++++++++++
8 files changed, 133 insertions(+), 3 deletions(-)
diff --git a/static/css/daw.css b/static/css/daw.css
index 25c253c..ff14dcb 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -1958,6 +1958,37 @@ input, textarea { font-family: inherit; }
color: #4a8cff;
}
+/* Speed control */
+.speed-control {
+ display: flex; flex-direction: column; align-items: center; gap: 2px;
+ background: var(--panel-2); border: 1px solid var(--border-strong);
+ border-radius: 8px; padding: 4px 10px; cursor: default; user-select: none;
+ transition: border-color var(--t-fast);
+}
+.speed-control:hover { border-color: var(--line-strong); }
+.speed-label {
+ font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums;
+ color: var(--fg-2); line-height: 1; white-space: nowrap;
+}
+#t-speed {
+ -webkit-appearance: none; appearance: none;
+ width: 56px; height: 3px; border-radius: 999px; outline: none; cursor: pointer;
+ background: linear-gradient(
+ 90deg,
+ var(--gold) 0 calc((var(--speed-pct, 33%) )),
+ rgba(148,163,184,0.22) calc((var(--speed-pct, 33%))) 100%
+ );
+}
+#t-speed::-webkit-slider-thumb {
+ -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%;
+ background: var(--gold-bright); cursor: pointer;
+ box-shadow: 0 0 0 2px rgba(216,168,74,0.25);
+}
+#t-speed::-moz-range-thumb {
+ width: 12px; height: 12px; 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..e01a80c 100644
--- a/static/index.html
+++ b/static/index.html
@@ -575,6 +575,10 @@
+
+ 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;
From 0f1782f15864d41c4eeb08bc39df9ca88618cc47 Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Tue, 30 Jun 2026 09:58:47 +0100
Subject: [PATCH 2/6] fix(player): move speed control below play button,
centered
---
static/css/daw.css | 8 ++++----
static/index.html | 26 ++++++++++++++------------
2 files changed, 18 insertions(+), 16 deletions(-)
diff --git a/static/css/daw.css b/static/css/daw.css
index ff14dcb..9572f53 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -1959,13 +1959,13 @@ input, textarea { font-family: inherit; }
}
/* Speed control */
+.play-speed-stack {
+ display: flex; flex-direction: column; align-items: center; gap: 6px;
+}
.speed-control {
display: flex; flex-direction: column; align-items: center; gap: 2px;
- background: var(--panel-2); border: 1px solid var(--border-strong);
- border-radius: 8px; padding: 4px 10px; cursor: default; user-select: none;
- transition: border-color var(--t-fast);
+ cursor: default; user-select: none;
}
-.speed-control:hover { border-color: var(--line-strong); }
.speed-label {
font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums;
color: var(--fg-2); line-height: 1; white-space: nowrap;
diff --git a/static/index.html b/static/index.html
index e01a80c..6af8816 100644
--- a/static/index.html
+++ b/static/index.html
@@ -562,23 +562,25 @@
-
+
+
+
+ 1.0x
+
+
+
-
- 1.0x
-
-
+
+ TEMPO
+
+
+ 1.0x
+
TEMPO
-
+
1.0x
diff --git a/static/js/transport.js b/static/js/transport.js
index 248fcb5..bd05c75 100644
--- a/static/js/transport.js
+++ b/static/js/transport.js
@@ -435,12 +435,12 @@ export function wireTransportButtons() {
}
function applySpeed(rate) {
- const clamped = Math.max(0.5, Math.min(2, rate));
+ const clamped = Math.max(0.25, Math.min(2, rate));
setPlaybackSpeed(clamped);
if (speedEl) {
speedEl.value = String(clamped);
- // 0.5=0%, 1.0=33%, 1.25=50%, 2.0=100% -- map [0.5,2] to [0%,100%]
- const pct = ((clamped - 0.5) / 1.5) * 100;
+ // range is 0-2; 1.0 sits at exactly 50%
+ const pct = (clamped / 2) * 100;
speedEl.style.setProperty("--speed-pct", `${pct.toFixed(1)}%`);
}
if (speedLabelEl) speedLabelEl.textContent = `${clamped % 1 === 0 ? clamped.toFixed(1) : clamped}x`;
diff --git a/static/mobile/app.js b/static/mobile/app.js
index 1d7d06f..01e1f7f 100644
--- a/static/mobile/app.js
+++ b/static/mobile/app.js
@@ -470,7 +470,7 @@ function mixerScreen() {
Speed
-
+
${state.speed % 1 === 0 ? state.speed.toFixed(1) : state.speed}x
${preparing ? 'Preparing audio…
' : ""}
From 59f5675054d03abde3cd5c21d66a428c4ee3268a Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Tue, 30 Jun 2026 10:18:49 +0100
Subject: [PATCH 5/6] feat(audio): pitch-preserving tempo via SoundTouch
AudioWorklet
Voices and instruments no longer pitch-shift when changing playback speed.
A WSOLA time-stretcher runs as an AudioWorkletProcessor on the master bus
so a single node handles all stems. Falls back to tape-effect if the worklet
API is unavailable.
---
static/js/audioEngine.js | 40 +++++--
static/vendor/soundtouch-processor.js | 160 ++++++++++++++++++++++++++
2 files changed, 192 insertions(+), 8 deletions(-)
create mode 100644 static/vendor/soundtouch-processor.js
diff --git a/static/js/audioEngine.js b/static/js/audioEngine.js
index e02159a..a70683f 100644
--- a/static/js/audioEngine.js
+++ b/static/js/audioEngine.js
@@ -8,7 +8,7 @@
// no drift. Works identically on WKWebView, Safari, and Chrome.
//
// Graph: per-stem AudioBufferSourceNode -> GainNode (vol/mute/solo) -> AnalyserNode (VU)
-// -> masterGain -> destination
+// -> masterGain -> SoundTouchNode -> destination
//
// Used behind a feature flag (see player.js) so it can be A/B'd against the legacy
// streaming path before cutover.
@@ -26,7 +26,20 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
const ctx = context || new AudioCtx();
const ownsCtx = !context;
const master = ctx.createGain();
- master.connect(ctx.destination);
+
+ // SoundTouch pitch-preserving time-stretch on the master bus.
+ // Falls back to tape-effect (playbackRate) if AudioWorklet is unavailable.
+ let stNode = null;
+ const _workletReady = (ctx.audioWorklet
+ ? ctx.audioWorklet.addModule('/vendor/soundtouch-processor.js').then(() => {
+ stNode = new AudioWorkletNode(ctx, 'soundtouch-processor');
+ master.connect(stNode);
+ stNode.connect(ctx.destination);
+ }).catch((err) => {
+ console.warn('[audioEngine] SoundTouch worklet load failed, using tape-effect fallback:', err);
+ master.connect(ctx.destination);
+ })
+ : Promise.resolve().then(() => { master.connect(ctx.destination); }));
/** @type {Map} */
const tracks = new Map();
@@ -39,10 +52,12 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
let loop = { enabled: false, start: 0, end: 0 };
let _playbackRate = 1.0;
- // Decode all stems up front. Resolves true once at least one stem is ready.
+ // Decode all stems up front AND load the SoundTouch worklet in parallel.
+ // Resolves true once at least one stem is ready (worklet load is best-effort).
const ready = (async () => {
- await Promise.all(
- stems.map(async (s) => {
+ await Promise.all([
+ _workletReady,
+ ...stems.map(async (s) => {
if (!s?.url) return;
try {
const res = await fetch(s.url);
@@ -81,7 +96,9 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
for (const t of tracks.values()) {
const src = ctx.createBufferSource();
src.buffer = t.buffer;
- src.playbackRate.value = _playbackRate;
+ // SoundTouch handles time-stretch; playbackRate stays 1.0.
+ // Falls back to tape-effect only when the worklet is unavailable.
+ if (!stNode) src.playbackRate.value = _playbackRate;
src.connect(t.gain);
src.start(when, Math.max(0, Math.min(offset, t.buffer.duration)));
t.source = src;
@@ -152,6 +169,7 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
stopSources();
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
tracks.clear();
+ if (stNode) { try { stNode.disconnect(); } catch { /* noop */ } }
if (ownsCtx) ctx.close().catch(() => {});
}
@@ -167,8 +185,14 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
setLoop: (enabled, start, end) => { loop = { enabled, start, end }; },
setPlaybackRate(rate) {
_playbackRate = rate;
- for (const t of tracks.values()) {
- if (t.source) t.source.playbackRate.value = rate;
+ if (stNode) {
+ // Pitch-preserving: update SoundTouch tempo parameter
+ stNode.parameters.get('tempo').value = rate;
+ } else {
+ // Tape-effect fallback
+ for (const t of tracks.values()) {
+ if (t.source) t.source.playbackRate.value = rate;
+ }
}
},
setGain,
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);
From 4740f6116e311bdc6bbbe366bba6c03c914627c2 Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Tue, 30 Jun 2026 11:37:43 +0100
Subject: [PATCH 6/6] fix(audio): close array literal in Promise.all ([]) was
missing ]
---
static/js/audioEngine.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/static/js/audioEngine.js b/static/js/audioEngine.js
index a70683f..deebd91 100644
--- a/static/js/audioEngine.js
+++ b/static/js/audioEngine.js
@@ -75,7 +75,7 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
console.warn(`[audioEngine] decode failed for ${s.name}:`, e);
}
}),
- );
+ ]);
return tracks.size > 0;
})();