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 @@ +
+ + +
+
+ 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 @@ - +
+ +
+ + +
+
-
- - -
+
+ 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; })();