Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions static/css/daw.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,12 @@
<span class="footer-time-sep">/</span>
<span class="num footer-total" id="footer-time-total">0:00</span>
</div>
<div class="tempo-bar" id="t-speed-wrap" title="Playback speed (double-click to reset)">
<span class="tempo-bar-label">TEMPO</span>
<input type="range" id="t-speed" min="0" max="2" step="0.25" value="1" aria-label="Playback speed">
<span class="tempo-bar-divider" aria-hidden="true"></span>
<span class="tempo-bar-val" id="t-speed-label">1.0x</span>
</div>
</div>

<div class="footer-chips">
Expand Down
46 changes: 39 additions & 7 deletions static/js/audioEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<string,{buffer:AudioBuffer,gain:GainNode,analyser:AnalyserNode,source:AudioBufferSourceNode|null}>} */
const tracks = new Map();
Expand All @@ -37,11 +50,14 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
let rafId = null;
let destroyed = false;
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);
Expand All @@ -59,11 +75,11 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
console.warn(`[audioEngine] decode failed for ${s.name}:`, e);
}
}),
);
]);
return tracks.size > 0;
})();

const now = () => (playing ? ctx.currentTime - startCtxTime + startOffset : startOffset);
const now = () => (playing ? (ctx.currentTime - startCtxTime) * _playbackRate + startOffset : startOffset);

function stopSources() {
for (const t of tracks.values()) {
Expand All @@ -80,6 +96,9 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
for (const t of tracks.values()) {
const src = ctx.createBufferSource();
src.buffer = t.buffer;
// 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;
Expand Down Expand Up @@ -150,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(() => {});
}

Expand All @@ -163,6 +183,18 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
getCurrentTime: now,
getDuration: () => duration,
setLoop: (enabled, start, end) => { loop = { enabled, start, end }; },
setPlaybackRate(rate) {
_playbackRate = 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,
setMasterGain,
getAnalyser: (name) => tracks.get(name)?.analyser ?? null,
Expand Down
3 changes: 2 additions & 1 deletion static/js/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
import {
buildRuler, updatePlayheadMarker, updateLoopRegionVisual,
applyWaveZoom, buildPresenceRuler, updateFooterTimes,
updatePresencePlayhead,
updatePresencePlayhead, resetSpeed,
} from "./transport.js";
import { stopVuLoop } from "./audio.js";
import { destroySections } from "./sections.js";
Expand Down Expand Up @@ -583,6 +583,7 @@ export function destroyPlayer() {
destroySections();
stopVuLoop();
stopStemVuLoop();
resetSpeed();
if (audioEngine) {
audioEngine.destroy();
setAudioEngine(null);
Expand Down
4 changes: 4 additions & 0 deletions static/js/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export const keyChip = $("t-key");
export const stemsChip = $("t-stems-chip");
export const timeEl = $("t-time");
export const masterFader = $("t-master");
export const speedEl = $("t-speed");
export const speedLabelEl = $("t-speed-label");
export const npArt = $("np-art");
export const npThumb = $("np-thumb");

Expand Down Expand Up @@ -134,6 +136,8 @@ export function setLoopStart(v) { loopStart = v; }
export function setLoopEnd(v) { loopEnd = v; }
export function setAudioContext(v) { audioContext = v; }
export function setMasterVolume(v) { masterVolume = v; }
export let playbackSpeed = 1.0;
export function setPlaybackSpeed(v) { playbackSpeed = v; }
export function setVuRafId(v) { vuRafId = v; }
export function setMasterBusGain(v) { masterBusGain = v; }
export function setMasterLimiter(v) { masterLimiter = v; }
Expand Down
37 changes: 36 additions & 1 deletion static/js/transport.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { fmtTime, fmtTickLabel } from "./utils.js";
import {
playBtn, playMiniBtn, stopBtn, loopBtn, timeEl, masterFader,
speedEl, speedLabelEl,
rulerTime, wavesGrid, loopRegionEl, playheadMarker,
multitrack, audioEngine, totalDuration, loopEnabled, loopStart, loopEnd, masterVolume,
waveScroll, waveCanvas, multitrackContainer,
presenceRulerEl, presencePlayheadEl,
footerTimeElapsed, footerTimeTotal, npScrubFill, footerWaveDrawFn,
setLoopEnabled, setLoopStart, setLoopEnd, setMasterVolume,
setLoopEnabled, setLoopStart, setLoopEnd, setMasterVolume, setPlaybackSpeed,
} from "./state.js";
import { applyMix } from "./mixer.js";

Expand Down Expand Up @@ -430,4 +431,38 @@ export function wireTransportButtons() {
setMasterVolume(0.5);
applyMix();
});
wireSpeedControl();
}

function applySpeed(rate) {
const clamped = Math.max(0.25, Math.min(2, rate));
setPlaybackSpeed(clamped);
if (speedEl) {
speedEl.value = String(clamped);
// 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`;
audioEngine?.setPlaybackRate?.(clamped);
if (multitrack) {
for (const a of (multitrack.audios ?? [])) {
try { a.playbackRate = clamped; } catch { /* noop */ }
}
}
}

export function resetSpeed() {
applySpeed(1.0);
}

function wireSpeedControl() {
if (!speedEl) return;
speedEl.addEventListener("input", () => applySpeed(parseFloat(speedEl.value)));
speedEl.addEventListener("dblclick", () => applySpeed(1.0));
speedEl.addEventListener("wheel", (e) => {
e.preventDefault();
const delta = e.deltaY < 0 ? 0.25 : -0.25;
applySpeed(parseFloat(speedEl.value) + delta);
}, { passive: false });
}
18 changes: 18 additions & 0 deletions static/mobile/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const state = {
vols: {}, // per-stem gain 0..1, keyed by backend stem name
muted: {},
solo: {},
speed: 1.0,
selected: { vocals: true, drums: true, bass: true, guitar: true, piano: true, other: true },
quality: "High",
filter: "All",
Expand Down Expand Up @@ -208,6 +209,7 @@ async function openTrack(card, { autoplay = false } = {}) {
state.current = { ...card, detail: null, loading: true, error: null };
state.playing = false;
state.progress = 0;
state.speed = 1.0;
render();

if (engine) { engine.destroy(); engine = null; engineTrackId = null; }
Expand Down Expand Up @@ -466,6 +468,11 @@ function mixerScreen() {
<button class="t-play" data-action="play" data-playing="${state.playing}" ${canPlay ? "" : "disabled style=opacity:.45"}>${state.playing ? ICON.pause(26, "#1a1206") : ICON.play(28, "#1a1206")}</button>
<button class="t-step" data-action="next" ${hasNext ? "" : "disabled"}>${ICON.next}</button>
</div>
<div class="speed-row">
<span class="speed-row-label">Speed</span>
<input type="range" class="speed-slider" data-speed min="0" max="2" step="0.25" value="${state.speed}">
<span class="speed-row-val">${state.speed % 1 === 0 ? state.speed.toFixed(1) : state.speed}x</span>
</div>
${preparing ? '<div class="mx-prep">Preparing audio…</div>' : ""}
<div class="segmented">
<button class="${state.mixerView === "stems" ? "on" : ""}" data-action="mixview" data-view="stems">Stems</button>
Expand Down Expand Up @@ -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) => {
Expand Down
29 changes: 29 additions & 0 deletions static/mobile/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading