chore: import upstream snapshot with attribution
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:32:09 +08:00
commit 8f10353f0c
135 changed files with 37786 additions and 0 deletions
+149
View File
@@ -0,0 +1,149 @@
import { TRACK_NAMES } from "./constants.js";
import {
multitrack, mixerEl, audioContext, trackAnalysers,
vuRafId, trackIndex,
setAudioContext, setVuRafId,
} from "./state.js";
export function attachAnalysers() {
if (!multitrack) return;
if (trackAnalysers.length) return;
const wsArr = multitrack.wavesurfers || multitrack._wavesurfers;
const audios = multitrack.audios;
if (!wsArr?.length || !audios?.length) return;
const ctx = multitrack.audioContext;
if (!ctx) return;
if (ctx.state === "suspended") ctx.resume().catch(() => {});
setAudioContext(ctx);
for (const stemName of TRACK_NAMES) {
const idx = trackIndex[stemName];
if (idx === undefined) continue;
const audioEl = audios[idx];
if (!audioEl) continue;
const isHtmlMedia = audioEl instanceof HTMLMediaElement;
const isWebAudio = typeof audioEl.getGainNode === "function";
if (!isHtmlMedia && !isWebAudio) continue;
if (isHtmlMedia && !audioEl.src) continue;
let analyser;
try {
analyser = ctx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.5;
if (isWebAudio) {
// wavesurfer's audio wrapper exposes its internal gain node;
// analyser taps the post-gain signal.
audioEl.getGainNode().connect(analyser);
} else {
// Bare HTMLAudioElement: route through Web Audio so we can tap
// the post-gain signal. applyMix() may have already created the
// MediaElementSource + GainNode chain; re-use it if so (the spec
// only allows createMediaElementSource once per element).
if (!audioEl._stMediaSource) {
audioEl._stMediaSource = ctx.createMediaElementSource(audioEl);
audioEl._stGainNode = ctx.createGain();
audioEl._stMediaSource.connect(audioEl._stGainNode);
audioEl._stGainNode.connect(ctx.destination);
audioEl.volume = 1;
} else if (!audioEl._stGainNode) {
audioEl._stGainNode = ctx.createGain();
audioEl._stMediaSource.connect(audioEl._stGainNode);
audioEl._stGainNode.connect(ctx.destination);
audioEl.volume = 1;
}
audioEl._stGainNode.connect(analyser);
}
} catch (err) {
console.warn("VU analyser hookup failed for stem", stemName, err);
continue;
}
// Time-domain buffer must be sized to fftSize (not frequencyBinCount,
// which is fftSize/2). With a too-small buffer, getByteTimeDomainData
// only writes the first N samples and trailing entries keep stale
// values, biasing RMS toward whatever was last left there.
const data = new Uint8Array(analyser.fftSize);
const vuEl = mixerEl.querySelector(`.lane-vu[data-stem="${stemName}"]`);
const miniMeterEl = document.querySelector(`.stem-list .${stemName} .mini-meter`);
trackAnalysers.push({ analyser, data, vuEl, miniMeterEl, peak: 0 });
}
const tick = () => {
for (const t of trackAnalysers) {
t.analyser.getByteTimeDomainData(t.data);
let sum = 0;
for (let i = 0; i < t.data.length; i++) {
const v = (t.data[i] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / t.data.length);
// Linear RMS with 2.5x gain. Compensates for the 0.5 master
// attenuation the analyser sees post-gain via createMediaElementSource,
// so typical -20 dBFS music lands in the 25-50% bar range, drum
// hits push toward 80-100%, silence falls to 0. Linear (not dB)
// gives the meter punch on transients that dB scaling smooths out.
const level = Math.min(1, rms * 2.5);
// Peak hold: bar follows level on the way up, falls slowly on
// the way down. That's what makes a VU look alive -- it lifts
// sharply on each drum hit / vocal phrase and drifts down in
// between rather than tracking instantaneous RMS jitter.
const prevPeak = t.peak || 0;
const nextPeak = level > prevPeak ? level : Math.max(0, prevPeak - 0.012);
t.peak = nextPeak;
// Separate peak-hold marker that holds the recent max for
// ~600 ms before falling, so a thin tick visually marks
// "loudest moment in the last second" above the bar.
const prevHold = t.peakHold || 0;
let nextHold = prevHold;
let holdFrames = t.holdFrames || 0;
if (level > prevHold) {
nextHold = level;
holdFrames = 36;
} else if (holdFrames > 0) {
holdFrames -= 1;
} else {
nextHold = Math.max(0, prevHold - 0.02);
}
t.peakHold = nextHold;
t.holdFrames = holdFrames;
const lvlPct = Math.round(level * 100);
const peakPct = Math.round(nextPeak * 100);
const holdPct = Math.round(nextHold * 100);
if (t.vuEl) {
if (lvlPct !== t.lastLevelPct) {
t.vuEl.style.setProperty("--vu-level", `${lvlPct}%`);
}
if (holdPct !== t.lastHoldPct) {
t.vuEl.style.setProperty("--vu-peak", `${holdPct}%`);
}
}
if (t.miniMeterEl) {
if (peakPct !== t.lastPeakPct) {
t.miniMeterEl.style.setProperty("--vu-scale", nextPeak.toFixed(3));
}
if (holdPct !== t.lastHoldPct) {
t.miniMeterEl.style.setProperty("--vu-peak-pct", String(holdPct));
t.miniMeterEl.style.setProperty(
"--vu-peak-opacity",
nextHold > 0.04 ? "1" : "0",
);
}
}
t.lastLevelPct = lvlPct;
t.lastPeakPct = peakPct;
t.lastHoldPct = holdPct;
}
setVuRafId(requestAnimationFrame(tick));
};
setVuRafId(requestAnimationFrame(tick));
}
export function stopVuLoop() {
if (vuRafId) {
cancelAnimationFrame(vuRafId);
setVuRafId(null);
}
}
+218
View File
@@ -0,0 +1,218 @@
// Web Audio decode-and-mix playback engine.
//
// Safari/WKWebView goes choppy when playing N streaming <audio> elements (one per
// stem) over HTTP/1.1: the 6-connection-per-origin cap + small media buffers + the
// multitrack's per-element currentTime nudging cause underruns. This engine instead
// decodes each active stem once into an AudioBuffer and plays them all from a single
// AudioContext clock — sample-accurate, zero streaming connections during playback,
// no drift. Works identically on WKWebView, Safari, and Chrome.
//
// Graph: per-stem AudioBufferSourceNode -> GainNode (vol/mute/solo) -> AnalyserNode (VU)
// -> 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.
const AudioCtx = window.AudioContext || window.webkitAudioContext;
/**
* @param {{name:string,url:string}[]} stems Active stems only (caller filters).
* @param {{onTime?:(t:number)=>void, onEnded?:()=>void}} cbs
*/
export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
// Mobile/iOS only starts audio from a context resumed inside a user gesture.
// Callers can pass a shared, gesture-unlocked `context` (the mobile UI does);
// desktop passes none and we own a fresh one. We only close contexts we own.
const ctx = context || new AudioCtx();
const ownsCtx = !context;
const master = ctx.createGain();
// 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();
let duration = 0;
let playing = false;
let startCtxTime = 0; // ctx.currentTime at playback start
let startOffset = 0; // media offset at that moment
let rafId = null;
let destroyed = false;
let loop = { enabled: false, start: 0, end: 0 };
let _playbackRate = 1.0;
// 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([
_workletReady,
...stems.map(async (s) => {
if (!s?.url) return;
try {
const res = await fetch(s.url);
if (!res.ok) throw new Error(`fetch ${res.status}`);
const buffer = await ctx.decodeAudioData(await res.arrayBuffer());
if (destroyed) return;
const gain = ctx.createGain();
const analyser = ctx.createAnalyser();
analyser.fftSize = 1024;
gain.connect(analyser);
analyser.connect(master);
tracks.set(s.name, { buffer, gain, analyser, source: null });
duration = Math.max(duration, buffer.duration);
} catch (e) {
console.warn(`[audioEngine] decode failed for ${s.name}:`, e);
}
}),
]);
return tracks.size > 0;
})();
const now = () => (playing ? (ctx.currentTime - startCtxTime) * _playbackRate + startOffset : startOffset);
function stopSources() {
for (const t of tracks.values()) {
if (t.source) {
try { t.source.stop(); } catch { /* already stopped */ }
try { t.source.disconnect(); } catch { /* noop */ }
t.source = null;
}
}
}
function startSources(offset) {
const when = ctx.currentTime;
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;
}
startCtxTime = when;
startOffset = offset;
}
function tick() {
if (!playing) return;
let t = now();
if (loop.enabled && loop.end > loop.start && t >= loop.end) {
seek(loop.start);
t = loop.start;
} else if (t >= duration) {
pause();
startOffset = duration;
onTime?.(duration);
onEnded?.();
return;
}
onTime?.(t);
rafId = requestAnimationFrame(tick);
}
function play() {
if (playing || destroyed || !tracks.size) return;
// Safari: resume the context fire-and-forget within the user-gesture tick.
if (ctx.state === "suspended") ctx.resume().catch(() => {});
let off = startOffset;
if (off >= duration) off = 0;
startSources(off);
playing = true;
rafId = requestAnimationFrame(tick);
}
function pause() {
if (!playing) return;
const t = now();
stopSources();
playing = false;
startOffset = Math.max(0, Math.min(t, duration));
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
}
function seek(t) {
const clamped = Math.max(0, Math.min(t, duration || 0));
if (playing) {
stopSources();
startSources(clamped);
} else {
startOffset = clamped;
}
onTime?.(clamped);
}
function setGain(name, v) {
const t = tracks.get(name);
if (t) t.gain.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
}
function setMasterGain(v) {
master.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
}
function destroy() {
destroyed = true;
stopSources();
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
tracks.clear();
if (stNode) { try { stNode.disconnect(); } catch { /* noop */ } }
if (ownsCtx) ctx.close().catch(() => {});
}
return {
ready,
play,
pause,
seek,
setTime: seek, // alias to match the multitrack interface used by transport.js
isPlaying: () => playing,
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,
// Decoded AudioBuffers keyed by stem name — reused by the visuals (overview
// waveforms, mini-waves, VU envelopes, energy bars) so they don't need the
// multitrack to also decode the audio. Map<name, AudioBuffer>.
getBuffers: () => {
const m = new Map();
for (const [name, t] of tracks) m.set(name, t.buffer);
return m;
},
destroy,
audioContext: ctx,
};
}
// Rough decoded-PCM memory estimate (Float32 = 4 bytes/sample/channel) used by the
// caller's guard to fall back to streaming for very long / many-stem tracks.
export function estimateDecodedBytes(durationSec, stemCount, channels = 2, sampleRate = 44100) {
return Math.round(durationSec * stemCount * channels * sampleRate * 4);
}
+2194
View File
File diff suppressed because it is too large Load Diff
+442
View File
@@ -0,0 +1,442 @@
// Chunked audio engine for the mobile player.
//
// Fetches WAV stems in fixed-size windows via HTTP Range requests and chains
// AudioBufferSourceNodes back-to-back for gapless playback. Compared to the
// full-decode engine (audioEngine.js):
// - First audio after ~7 MB download (one 10-second chunk per 4 stems on WiFi)
// instead of waiting for the complete file
// - Peak RAM ~28 MB vs ~420 MB for a 5-minute 4-stem track
// - No track-length cap
// - Same glitch-free behavior on Safari/WKWebView: AudioBufferSourceNode,
// no streaming elements, no HTTP/1.1 connection-cap underruns
//
// The backend's FileResponse already handles Range requests natively (Starlette
// 1.3.x), so no server-side changes are needed.
//
// Graph: per-stem AudioBufferSourceNode -> GainNode -> masterGain -> SoundTouchNode -> destination
const CHUNK_SEC = 5; // seconds of audio per chunk
const LOOKAHEAD_SEC = 12; // schedule next chunk this far ahead of playhead
// ---------------------------------------------------------------------------
// WAV parsing
// ---------------------------------------------------------------------------
function _parseWavHeader(buf) {
const view = new DataView(buf);
const tag = (off) => String.fromCharCode(...new Uint8Array(buf, off, 4));
if (tag(0) !== "RIFF" || tag(8) !== "WAVE") return null;
let audioFormat = 1, channels = 2, sampleRate = 44100, bitsPerSample = 16;
let dataOffset = -1, dataSize = 0;
let off = 12;
while (off + 8 <= buf.byteLength) {
const id = tag(off);
const size = view.getUint32(off + 4, true);
if (id === "fmt ") {
audioFormat = view.getUint16(off + 8, true);
channels = view.getUint16(off + 10, true);
sampleRate = view.getUint32(off + 12, true);
bitsPerSample = view.getUint16(off + 22, true);
} else if (id === "data") {
dataOffset = off + 8;
dataSize = size;
break;
}
off += 8 + size + (size & 1); // chunks are word-aligned
}
if (dataOffset < 0) return null;
const bytesPerFrame = channels * (bitsPerSample >> 3);
return {
audioFormat, channels, sampleRate, bitsPerSample,
dataOffset, dataSize, bytesPerFrame,
duration: dataSize / (bytesPerFrame * sampleRate),
};
}
// Convert raw interleaved PCM bytes to an AudioBuffer.
// Fast paths for the common cases (stereo 16-bit, stereo float32).
function _pcmToAudioBuffer(ctx, pcmData, header) {
const { channels, sampleRate, bitsPerSample, audioFormat } = header;
const totalSamples = Math.floor(pcmData.byteLength / (channels * (bitsPerSample >> 3)));
if (totalSamples === 0) return null;
const ab = ctx.createBuffer(channels, totalSamples, sampleRate);
if (bitsPerSample === 16) {
const src = new Int16Array(pcmData);
const scale = 1 / 32768;
if (channels === 2) {
const ch0 = ab.getChannelData(0);
const ch1 = ab.getChannelData(1);
for (let i = 0, j = 0; i < totalSamples; i++, j += 2) {
ch0[i] = src[j] * scale;
ch1[i] = src[j + 1] * scale;
}
} else {
for (let ch = 0; ch < channels; ch++) {
const out = ab.getChannelData(ch);
for (let i = 0; i < totalSamples; i++) out[i] = src[i * channels + ch] * scale;
}
}
} else if (audioFormat === 3 && bitsPerSample === 32) {
const src = new Float32Array(pcmData);
if (channels === 2) {
const ch0 = ab.getChannelData(0);
const ch1 = ab.getChannelData(1);
for (let i = 0, j = 0; i < totalSamples; i++, j += 2) {
ch0[i] = src[j];
ch1[i] = src[j + 1];
}
} else {
for (let ch = 0; ch < channels; ch++) {
const out = ab.getChannelData(ch);
for (let i = 0; i < totalSamples; i++) out[i] = src[i * channels + ch];
}
}
} else {
return null; // unsupported format
}
return ab;
}
// ---------------------------------------------------------------------------
// Engine factory
// ---------------------------------------------------------------------------
/**
* @param {{name:string,url:string}[]} stems Active stems (WAV URLs).
* @param {{onTime?:(t:number)=>void, onEnded?:()=>void, context?:AudioContext}} opts
*/
export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {}) {
const AC = window.AudioContext || window.webkitAudioContext;
const ctx = context || new AC();
const ownsCtx = !context;
const master = ctx.createGain();
let stNode = null;
let _playbackRate = 1.0;
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('[chunkedEngine] SoundTouch worklet failed, tape-effect fallback:', err);
master.connect(ctx.destination);
})
: Promise.resolve().then(() => { master.connect(ctx.destination); }));
// Per-stem state: url, parsed WAV header, gain node, currently playing nodes
const stemMap = new Map();
for (const s of stems) {
if (!s?.url) continue;
const gain = ctx.createGain();
gain.connect(master);
stemMap.set(s.name, { url: s.url, header: null, gain, activeNodes: [] });
}
let _duration = 0;
let playing = false;
let destroyed = false;
let rafId = null;
// Playback clock: getCurrentTime = ctx.currentTime - _startCtxTime + _startOffset
let _startCtxTime = 0;
let _startOffset = 0;
// _scheduledTo: track position (seconds) up to which AudioBufferSourceNodes
// have already been scheduled. Always sits at a chunk boundary after play().
let _scheduledTo = 0;
// True once the first AudioBufferSourceNode is actually queued; guards
// getCurrentTime() from advancing during an async chunk fetch.
let _audioStarted = false;
let _filling = false; // prevents concurrent _scheduleNext() calls
// Chunk cache: chunkIdx -> { promise: Promise<Map>, result: Map|null }
// result is set synchronously once the promise resolves so play() can
// schedule chunk 0 without an async await after ready() completes.
const _cache = new Map();
function _getCurrentTime() {
if (!playing || !_audioStarted) return _startOffset;
return Math.min((ctx.currentTime - _startCtxTime) * _playbackRate + _startOffset, _duration);
}
// --- fetch helpers ---
async function _fetchHeader(url) {
const res = await fetch(url, { headers: { Range: "bytes=0-1023" } });
const buf = await res.arrayBuffer();
return _parseWavHeader(buf);
}
async function _fetchPcm(stem, chunkIdx) {
const { url, header } = stem;
const { dataOffset, dataSize, bytesPerFrame, sampleRate } = header;
const chunkBytes = Math.floor(CHUNK_SEC * sampleRate) * bytesPerFrame;
const byteStart = dataOffset + chunkIdx * chunkBytes;
if (byteStart >= dataOffset + dataSize) return null; // past end of file
const byteEnd = Math.min(byteStart + chunkBytes, dataOffset + dataSize) - 1;
const res = await fetch(url, { headers: { Range: `bytes=${byteStart}-${byteEnd}` } });
if (!res.ok && res.status !== 206) throw new Error(`Range fetch ${res.status}`);
return res.arrayBuffer();
}
// Returns a Promise<Map<name, AudioBuffer>>. Deduplicates: if a fetch for
// chunkIdx is already in flight, returns the same promise.
function _fetchChunk(chunkIdx) {
const hit = _cache.get(chunkIdx);
if (hit) return hit.promise;
const entry = { promise: null, result: null };
entry.promise = (async () => {
const pairs = await Promise.all(
[...stemMap.entries()]
.filter(([, s]) => s.header)
.map(async ([name, stem]) => {
try {
const pcm = await _fetchPcm(stem, chunkIdx);
if (!pcm) return [name, null];
return [name, _pcmToAudioBuffer(ctx, pcm, stem.header)];
} catch (e) {
console.warn(`[chunked] chunk ${chunkIdx} stem ${name}:`, e);
return [name, null];
}
})
);
const map = new Map(pairs.filter(([, b]) => b));
entry.result = map;
// Keep at most the previous chunk + current in cache to bound memory.
for (const k of _cache.keys()) {
if (k < chunkIdx - 1) _cache.delete(k);
}
return map;
})();
_cache.set(chunkIdx, entry);
return entry.promise;
}
// --- node lifecycle ---
function _stopNodes() {
for (const stem of stemMap.values()) {
for (const node of stem.activeNodes) {
try { node.stop(); } catch { /* already stopped */ }
try { node.disconnect(); } catch { /* noop */ }
}
stem.activeNodes = [];
}
}
// Schedule all stems' AudioBufferSourceNodes to start at `when` (AudioContext
// time), beginning `startSecs` into each buffer. Returns the duration of audio
// that will play (max over stems of buffer.duration - startSecs).
function _scheduleChunk(buffers, when, startSecs) {
let playDur = 0;
for (const [name, stem] of stemMap) {
const buf = buffers.get(name);
if (!buf) continue;
const node = ctx.createBufferSource();
node.buffer = buf;
if (!stNode) node.playbackRate.value = _playbackRate; // tape-effect fallback
node.connect(stem.gain);
const offset = Math.max(0, Math.min(startSecs, buf.duration - 0.001));
node.start(when, offset);
stem.activeNodes.push(node);
playDur = Math.max(playDur, buf.duration - offset);
}
return playDur;
}
// --- lookahead scheduler ---
async function _scheduleNext() {
const chunkIdx = Math.floor(_scheduledTo / CHUNK_SEC);
_fetchChunk(chunkIdx + 1); // fire-and-forget pre-fetch of the chunk after
// Use the synchronous result if already decoded, otherwise await.
const hit = _cache.get(chunkIdx);
const buffers = hit?.result ?? await _fetchChunk(chunkIdx);
if (!playing || destroyed) return;
if (!buffers || buffers.size === 0) return; // past end; tick() handles onEnded
// With SoundTouch, sources play at 1.0x so scheduled wall-clock time equals
// audio time. With tape-effect (_playbackRate != 1, no stNode), sources play
// faster/slower so wall-clock time = audio seconds / _playbackRate.
const playFactor = stNode ? 1.0 : _playbackRate;
const idealWhen = _startCtxTime + (_scheduledTo - _startOffset) / playFactor;
const when = Math.max(idealWhen, ctx.currentTime + 0.01);
// If we're late (slow network), skip the audio portion that already "passed".
const firstBuf = buffers.values().next().value;
const maxSkip = firstBuf ? Math.max(0, firstBuf.duration - 0.001) : 0;
const bufOffset = Math.min(Math.max(0, when - idealWhen) * playFactor, maxSkip);
const dur = _scheduleChunk(buffers, when, bufOffset);
_scheduledTo += bufOffset + dur; // always advances by ~CHUNK_SEC
}
function _maybeSchedule() {
if (_filling || !playing || destroyed) return;
if (_scheduledTo >= _duration) return;
if (_scheduledTo - _getCurrentTime() < LOOKAHEAD_SEC) {
_filling = true;
_scheduleNext().finally(() => { _filling = false; });
}
}
function _tick() {
if (!playing) return;
const t = _getCurrentTime();
if (t >= _duration) {
playing = false;
_audioStarted = false;
_startOffset = _duration;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
onTime?.(_duration);
onEnded?.();
return;
}
_maybeSchedule();
onTime?.(t);
rafId = requestAnimationFrame(_tick);
}
// --- public API ---
function play() {
if (playing || destroyed) return;
if (ctx.state === "suspended") ctx.resume().catch(() => {});
playing = true;
const chunkIdx = Math.floor(_startOffset / CHUNK_SEC);
const offsetWithin = _startOffset - chunkIdx * CHUNK_SEC;
const startWith = (buffers) => {
if (!playing || destroyed) return;
const when = ctx.currentTime + 0.05;
_startCtxTime = when;
const dur = _scheduleChunk(buffers, when, offsetWithin);
_scheduledTo = _startOffset + dur;
_audioStarted = true;
_fetchChunk(chunkIdx + 1); // pre-fetch next chunk
rafId = requestAnimationFrame(_tick);
};
// chunk 0 is pre-decoded during ready(), so the sync path is the hot path.
const hit = _cache.get(chunkIdx);
if (hit?.result) {
startWith(hit.result);
} else {
_fetchChunk(chunkIdx)
.then(startWith)
.catch((e) => {
console.warn("[chunked] play fetch failed:", e);
playing = false;
_audioStarted = false;
});
}
}
function pause() {
if (!playing) return;
_startOffset = _getCurrentTime();
_stopNodes();
playing = false;
_audioStarted = false;
_scheduledTo = _startOffset;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
}
function seek(t) {
const clamped = Math.max(0, Math.min(t, _duration || 0));
const wasPlaying = playing;
if (wasPlaying) {
_stopNodes();
playing = false;
_audioStarted = false;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
}
_startOffset = clamped;
_scheduledTo = clamped;
// Evict cache for chunks before the new position.
const newIdx = Math.floor(clamped / CHUNK_SEC);
for (const k of _cache.keys()) {
if (k < newIdx) _cache.delete(k);
}
onTime?.(clamped);
if (wasPlaying) play();
}
// Initialize: fetch all WAV headers in parallel and load the SoundTouch worklet.
// Chunk 0 is kicked off in the background so ready() resolves quickly (headers
// only, ~6 x 1 KB) instead of blocking on the full first-chunk download (~5 MB).
// play() handles the case where chunk 0 is not yet cached.
const ready = (async () => {
if (!stemMap.size) return false;
await Promise.all([
_workletReady,
...[...stemMap.values()].map(async (stem) => {
try { stem.header = await _fetchHeader(stem.url); }
catch (e) { console.warn("[chunked] header fetch failed:", e); }
}),
]);
for (const stem of stemMap.values()) {
if (stem.header) _duration = Math.max(_duration, stem.header.duration);
}
if (!_duration) return false;
// Kick off chunk 0 and 1 in the background; play() picks up the cached result.
_fetchChunk(0);
_fetchChunk(1);
return true;
})();
return {
ready,
play,
pause,
seek,
setTime: seek,
isPlaying: () => playing,
getCurrentTime: _getCurrentTime,
getDuration: () => _duration,
setLoop: () => {},
setGain(name, v) {
const stem = stemMap.get(name);
if (stem) stem.gain.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
},
setMasterGain(v) {
master.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
},
setPlaybackRate(rate) {
const t = _getCurrentTime(); // capture before updating rate
_playbackRate = rate;
if (stNode) {
stNode.parameters.get('tempo').value = rate;
} else if (playing) {
// Tape-effect fallback: seek to current position so new source nodes
// are created with the updated playbackRate and scheduling math resets.
seek(t);
}
},
getAnalyser: () => null,
getBuffers: () => new Map(),
destroy() {
destroyed = true;
if (playing) pause();
if (stNode) { try { stNode.disconnect(); } catch { /* noop */ } }
_cache.clear();
stemMap.clear();
if (ownsCtx) ctx.close().catch(() => {});
},
audioContext: ctx,
};
}
+47
View File
@@ -0,0 +1,47 @@
// Fallback list used before /api/config responds. Kept in sync with
// STEM_NAMES in app/core/config.py — the API is the canonical source.
export let STEM_NAMES = ["vocals", "drums", "bass", "guitar", "piano", "other"];
export let TRACK_NAMES = ["original", ...STEM_NAMES];
export async function syncStemNamesFromAPI() {
try {
const res = await fetch("/api/config");
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data.stem_names) && data.stem_names.length > 0) {
STEM_NAMES = data.stem_names;
TRACK_NAMES = ["original", ...STEM_NAMES];
}
} catch (e) {
console.warn("[constants] failed to sync stem names from API:", e);
}
}
export const STEM_DISPLAY = {
vocals: "Vocals",
drums: "Drums",
bass: "Bass",
guitar: "Guitar",
piano: "Piano",
other: "Other",
original: "Original",
};
// FL Studio-style channel palette: saturated but slightly dusty, designed
// to read well on a dark background.
export const STEM_COLORS = {
vocals: "#e85f6f",
drums: "#e89048",
bass: "#e8b848",
guitar: "#88d878",
piano: "#b88fe8",
other: "#88a8c8",
original: "#a8b0bd",
};
export const PROGRESS_COLOR = "#3a3a3a";
export const LOOP_DEFAULT_START_FRAC = 0.25;
export const LOOP_DEFAULT_END_FRAC = 0.5;
export const LANE_VOLUME_MAX = 2;
+511
View File
@@ -0,0 +1,511 @@
import {
form, urlInput, submitBtn, errorEl, jobBox, jobTitleEl, jobStageEl,
jobDetailEl, jobCancelBtn, progressEl, titleEl, bpmChip, keyChip,
eventSource, setEventSource, setCurrentJobId, currentJobId,
selectedStems,
} from "./state.js";
import { destroyPlayer, wireUpAudio, setWaveformLoading, updateFooterTrack } from "./player.js";
import { stagePhrases } from "./phrases.js";
import { addTrackToLibrary, setCurrentTrack, updateTrackStatus, applyStemPresenceCards } from "./catalog.js";
import { initSections } from "./sections.js";
// Playful stage label rotation (Claude-Code-style flair). The backend
// emits truthful stage strings; we surface them in the small #job-detail
// line so progress is debuggable, while #job-stage rotates whimsy.
const ROTATION_MS = 2500;
let phraseTimerId = null;
let lastStatus = null;
let jobPollTimerId = null;
const renderedJobs = new Set();
const jobSources = new Map();
const TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]);
function setSubmitProcessing(processing) {
submitBtn.disabled = processing;
submitBtn.classList.toggle("loading", processing);
document.querySelector(".strip-sq-process")?.classList.toggle("loading", processing);
const label = submitBtn.querySelector("span");
if (label) label.textContent = processing ? "Processing" : "Process";
}
function pickPhrase(status) {
const pool = stagePhrases[status] || stagePhrases.default;
return pool[Math.floor(Math.random() * pool.length)];
}
function setOverlayPhrase(text) {
const el = document.getElementById("waveLoadingPhrase");
if (el) el.textContent = text;
}
function startPhraseRotation(status) {
stopPhraseRotation();
const phrase = pickPhrase(status);
jobStageEl.textContent = phrase;
setOverlayPhrase(phrase);
phraseTimerId = setInterval(() => {
const p = pickPhrase(status);
jobStageEl.textContent = p;
setOverlayPhrase(p);
}, ROTATION_MS);
}
function stopPhraseRotation() {
if (phraseTimerId) {
clearInterval(phraseTimerId);
phraseTimerId = null;
}
jobStageEl.textContent = "";
}
function stopJobPolling() {
if (jobPollTimerId) {
clearInterval(jobPollTimerId);
jobPollTimerId = null;
}
}
export function showError(message) {
errorEl.textContent = "";
const msg = document.createElement("div");
msg.className = "error-msg";
msg.textContent = message;
const retry = document.createElement("button");
retry.className = "retry-btn";
retry.type = "button";
retry.textContent = "Try again";
retry.addEventListener("click", () => {
errorEl.classList.add("hidden");
urlInput.focus();
urlInput.select();
});
errorEl.append(msg, retry);
errorEl.classList.remove("hidden");
}
export function reset() {
if (eventSource) {
eventSource.close();
setEventSource(null);
}
stopJobPolling();
stopPhraseRotation();
lastStatus = null;
destroyPlayer();
errorEl.classList.add("hidden");
errorEl.textContent = "";
jobBox.classList.add("hidden");
jobCancelBtn.classList.add("hidden");
jobTitleEl.textContent = "";
jobStageEl.textContent = "";
jobDetailEl.textContent = "";
progressEl.value = 0;
setSubmitProcessing(false);
setCurrentJobId(null);
}
function applyState(state) {
if (state.job_id) {
addTrackToLibrary({
id: state.job_id,
title: state.title || urlInput.value || "Processing track",
channel: state.status === "done" ? "Extracted" : "Processing",
thumb: state.thumbnail,
stems: state.selected_stems || state.stems?.map((stem) => stem.name) || [...selectedStems],
selectedStems: state.selected_stems || [...selectedStems],
audioStems: state.stems || [],
status: state.status,
duration: state.duration,
bpm: state.bpm,
key: state.key,
scale: state.scale,
keyConfidence: state.key_confidence,
lufs: state.lufs,
peakDb: state.peak_db,
stemPresence: state.stem_presence,
sourceUrl: jobSources.get(state.job_id) || urlInput.value,
createdAt: state.created_at,
});
setCurrentTrack(state.job_id);
}
if (state.title) {
jobTitleEl.textContent = state.title;
titleEl.textContent = state.title;
}
if (state.bpm) bpmChip.textContent = `${state.bpm} BPM`;
if (state.key) keyChip.textContent = state.key;
if (state.title || state.bpm || state.key || state.thumbnail) {
updateFooterTrack({
title: state.title,
thumbnail: state.thumbnail,
key: state.key,
bpm: state.bpm,
stemCount: state.stems ? state.stems.filter((s) => s.name !== "original").length : null,
});
}
const summaryKey = document.getElementById("summary-key");
const summaryBpm = document.getElementById("summary-bpm");
const summaryScale = document.getElementById("summary-scale");
const summaryScaleName = document.getElementById("summary-scale-name");
const summaryConfidence = document.getElementById("summary-confidence");
const summaryConfidenceLabel = document.getElementById("summary-confidence-label");
const summaryLufs = document.getElementById("summary-lufs");
const summaryPeak = document.getElementById("summary-peak");
const summaryDuration = document.getElementById("summary-duration");
if (summaryKey && state.key) summaryKey.textContent = state.key;
if (summaryBpm && state.bpm) summaryBpm.textContent = String(state.bpm);
if (summaryScale && state.scale) summaryScale.textContent = state.scale;
if (summaryScaleName && state.scale) summaryScaleName.textContent = state.scale;
if (summaryLufs && state.lufs != null) summaryLufs.textContent = state.lufs.toFixed(1);
if (summaryPeak && state.peak_db != null) summaryPeak.textContent = `Peak ${state.peak_db.toFixed(1)} dB`;
if (summaryDuration && state.duration) {
const m = Math.floor(state.duration / 60);
const s = Math.floor(state.duration % 60).toString().padStart(2, "0");
summaryDuration.textContent = `${m.toString().padStart(2, "0")}:${s}`;
}
if (summaryConfidence && state.key_confidence != null) {
const confidence = Math.max(0, Math.min(100, Number(state.key_confidence)));
const confSpan = document.createElement("span");
confSpan.textContent = `${confidence}%`;
summaryConfidence.textContent = "";
summaryConfidence.appendChild(confSpan);
summaryConfidence.style.setProperty("--confidence-pct", confidence);
summaryConfidence.classList.remove("hidden");
summaryConfidenceLabel?.classList.remove("hidden");
}
const summaryDr = document.getElementById("summary-dr");
const summaryDrLabel = document.getElementById("summary-dr-label");
const summaryStability = document.getElementById("summary-stability");
const summaryStabilityLabel = document.getElementById("summary-stability-label");
if (summaryDr && state.dynamic_range != null) summaryDr.textContent = String(state.dynamic_range);
if (summaryDrLabel && state.dynamic_range != null) {
const dr = state.dynamic_range;
summaryDrLabel.textContent = dr < 7 ? "Compressed" : dr < 10 ? "Moderate" : dr < 14 ? "High" : "Wide";
}
if (summaryStability && state.tempo_stability != null) {
summaryStability.textContent = `${state.tempo_stability}%`;
summaryStability.className = "meta-card-value" + (state.tempo_stability >= 80 ? " stability-high" : "");
}
if (summaryStabilityLabel && state.tempo_stability != null) {
const s = state.tempo_stability;
summaryStabilityLabel.textContent = s >= 90 ? "Very Stable" : s >= 70 ? "Stable" : s >= 50 ? "Moderate" : "Variable";
}
if (state.stem_presence != null) {
applyStemPresenceCards(state.stem_presence);
}
// Stage label is owned by the phrase-rotation timer below; we don't
// overwrite it from each SSE tick. The truthful backend stage goes
// to the small detail line instead.
jobDetailEl.textContent = state.stage || "";
progressEl.value = Math.round((state.progress || 0) * 100);
// Cancel button is visible exactly while the job is in a non-terminal state.
const terminal = TERMINAL_STATUSES.has(state.status);
jobCancelBtn.classList.toggle("hidden", terminal);
if (state.status !== lastStatus) {
if (terminal) stopPhraseRotation();
else startPhraseRotation(state.status);
lastStatus = state.status;
}
if (state.status === "error") {
stopJobPolling();
updateTrackStatus(state.job_id, "error");
setWaveformLoading(false);
showError(state.error || "Unknown error");
setSubmitProcessing(false);
} else if (state.status === "cancelled") {
stopJobPolling();
updateTrackStatus(state.job_id, "cancelled");
setWaveformLoading(false);
jobBox.classList.add("hidden");
setSubmitProcessing(false);
} else if (state.status === "done") {
stopJobPolling();
updateTrackStatus(state.job_id, "done");
jobBox.classList.add("hidden");
if (!renderedJobs.has(state.job_id)) {
renderedJobs.add(state.job_id);
wireUpAudio(
state.job_id,
state.stems || [],
state.duration || 0,
state.thumbnail,
state.mix_url ?? null,
state.title || "",
null,
state.has_video ?? false,
);
initSections(state.job_id, state.sections, state.duration || 0);
}
setSubmitProcessing(false);
}
}
async function probeJob(jobId) {
const r = await fetch(`/api/jobs/${jobId}`);
if (!r.ok) {
if (r.status === 404) throw new Error("Job no longer exists on the server");
throw new Error(`Job probe failed: ${r.status}`);
}
const s = await r.json();
applyState(s);
return s;
}
function startJobPolling(jobId) {
stopJobPolling();
const tick = async () => {
try {
const s = await probeJob(jobId);
if (TERMINAL_STATUSES.has(s.status)) stopJobPolling();
} catch (err) {
console.warn("[job] REST fallback failed:", err);
}
};
tick();
jobPollTimerId = setInterval(tick, 1000);
}
// Connect (or reconnect) to the SSE stream for a job. On unexpected
// disconnect we probe /api/jobs/{id} to decide: if the job is already
// terminal, accept its final state; otherwise reconnect with backoff.
// Falls back to REST polling only after SSE exhausts its retry budget.
function connectEvents(jobId) {
let attempt = 0;
let stopped = false;
const open = () => {
const es = new EventSource(`/api/jobs/${jobId}/events`);
setEventSource(es);
es.onmessage = (ev) => {
attempt = 0; // any successful frame resets backoff
let s;
try { s = JSON.parse(ev.data); } catch { return; }
// Defer by one tick so synchronous user event handlers (clicks,
// input events) always complete before SSE state is applied.
setTimeout(() => {
applyState(s);
if (TERMINAL_STATUSES.has(s.status)) {
stopped = true;
es.close();
setEventSource(null);
}
}, 0);
};
es.onerror = async () => {
if (stopped) return;
es.close();
setEventSource(null);
// Probe REST once before declaring failure -- handles dev-server
// reloads and brief network blips where the job is actually fine.
try {
const s = await probeJob(jobId);
if (TERMINAL_STATUSES.has(s.status)) {
stopped = true;
return;
}
} catch (err) {
if (err.message === "Job no longer exists on the server") {
stopped = true;
showError(err.message);
setSubmitProcessing(false);
return;
}
// Network down -- fall through to backoff.
}
attempt += 1;
if (attempt > 6) {
// SSE gave up — activate REST polling as the fallback.
startJobPolling(jobId);
return;
}
// 0.5s, 1s, 2s, 4s, 8s, 16s
const delay = 500 * Math.pow(2, attempt - 1);
setTimeout(() => { if (!stopped) open(); }, delay);
};
};
open();
}
async function cancelCurrentJob() {
const id = currentJobId;
if (!id) return;
jobCancelBtn.disabled = true;
jobCancelBtn.textContent = "Cancelling…";
try {
await fetch(`/api/jobs/${id}/cancel`, { method: "POST" });
// The next SSE frame (or the REST probe in connectEvents) will
// surface the cancelled state and hide the button via applyState.
} catch {
/* SSE will reflect the result regardless */
} finally {
jobCancelBtn.disabled = false;
jobCancelBtn.textContent = "Cancel";
}
}
function sanitizeFilename(name) {
// Strip extension, collapse whitespace, cap at 120 chars — mirrors the
// backend _sanitize_title() so title and sourceUrl match on both sides.
return name
.replace(/\.[^.]+$/, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120);
}
// Programmatic URL import — re-uses the full studio/SSE pipeline (same as the
// import form's URL path). Used by the library "Sync again" auto-restore to
// re-download + re-separate a track whose backend audio was swept. Takes over
// the studio like a normal import. Returns the new job id, or null on failure.
export async function importFromUrl(url, { title, stems } = {}) {
if (!url || url.startsWith("local:")) return null; // local files can't auto-restore
reset();
setSubmitProcessing(true);
setWaveformLoading(true, "");
const stemSel = stems?.length ? stems : [...selectedStems];
let jobId;
try {
const res = await fetch("/api/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, stems: stemSel }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.statusText);
jobId = data.job_id;
} catch (err) {
showError(`Failed to restore track: ${err.message}`);
setSubmitProcessing(false);
return null;
}
setCurrentJobId(jobId);
jobSources.set(jobId, url);
// Merges into the existing library entry by sourceUrl (replaceTrackId),
// preserving its folder placement; status updates as SSE frames arrive.
addTrackToLibrary({
id: jobId,
title: title || url || "Processing track",
channel: "Processing",
thumb: "",
stems: stemSel,
selectedStems: stemSel,
audioStems: [],
status: "processing",
bpm: null,
key: null,
scale: null,
keyConfidence: null,
lufs: null,
peakDb: null,
sourceUrl: url,
});
setCurrentTrack(jobId);
jobBox.classList.add("hidden");
jobCancelBtn.classList.add("hidden");
startPhraseRotation("queued");
lastStatus = "queued";
connectEvents(jobId);
return jobId;
}
export function wireJobForm() {
jobCancelBtn.addEventListener("click", cancelCurrentJob);
form.addEventListener("submit", async (e) => {
e.preventDefault();
reset();
setSubmitProcessing(true);
const fileInput = document.getElementById("fileInput");
// Prefer _file cache: browsers (WKWebView, Chromium) silently clear
// fileInput.files after a fetch() submission, breaking re-submits.
const file = fileInput?._file ?? fileInput?.files?.[0] ?? null;
const sanitized = file ? sanitizeFilename(file.name) : null;
const sourceUrl = file ? `local:${sanitized}` : urlInput.value;
const displayTitle = sanitized ?? (urlInput.value || "Processing track");
const postUrlText = document.getElementById("post-url-text");
if (postUrlText) postUrlText.textContent = displayTitle;
// Show overlay immediately for both paths. File uploads show "Uploading…"
// in the overlay phrase until the fetch completes and SSE takes over.
setWaveformLoading(true, file ? "Uploading…" : "");
if (file) {
lastStatus = "queued";
}
let fetchInit;
if (file) {
const fd = new FormData();
fd.append("file", file);
fd.append("stems", JSON.stringify([...selectedStems]));
fetchInit = { method: "POST", body: fd };
} else {
fetchInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: urlInput.value,
// Backend uses this to decide whether to ffmpeg-amix a
// "selected stems" track (mix.wav) at the end of the pipeline.
stems: [...selectedStems],
}),
};
}
let jobId;
try {
const res = await fetch("/api/jobs", fetchInit);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.statusText);
jobId = data.job_id;
} catch (err) {
if (file) jobBox.classList.add("hidden");
showError(`Failed to start job: ${err.message}`);
setSubmitProcessing(false);
return;
}
setCurrentJobId(jobId);
jobSources.set(jobId, sourceUrl);
addTrackToLibrary({
id: jobId,
title: displayTitle,
channel: "Processing",
thumb: "",
stems: [...selectedStems],
selectedStems: [...selectedStems],
audioStems: [],
status: "processing",
bpm: null,
key: null,
scale: null,
keyConfidence: null,
lufs: null,
peakDb: null,
sourceUrl,
});
setCurrentTrack(jobId);
// Both paths: keep job box hidden, overlay drives the UI.
// Start phrase rotation now that the job exists on the server.
jobBox.classList.add("hidden");
jobCancelBtn.classList.add("hidden");
startPhraseRotation("queued");
lastStatus = "queued";
connectEvents(jobId);
});
}
+443
View File
@@ -0,0 +1,443 @@
import {
playBtn, loopBtn, multitrack, totalDuration, loopEnabled, loopStart, loopEnd,
setLoopStart, setLoopEnd, selectedStems, saveSelectedStems, stemSelectionReady,
} from "./state.js";
import { STEM_NAMES, syncStemNamesFromAPI } from "./constants.js";
import { renderEmptyShell, buildStripStems, downloadCurrentMix, downloadCurrentVideo, downloadAllStemsZip, downloadRegionMix, drawFooterPlaceholder } from "./player.js";
import { wireJobForm, showError } from "./job.js";
import { wireTransportButtons } from "./transport.js";
import { togglePlayPause, updateLoopRegionVisual } from "./transport.js";
import { wireStemListControls, wireMixerToolbar } from "./mixer.js";
import { initCatalog } from "./catalog.js";
import { runStoreMigrationIfNeeded } from "./utils.js";
// ─── Stem choice toggles on the import page ───
//
// Filter-chip semantics (Spotify-style). The natural mental model when
// a user sees all 6 stems lit up is "everything is extracted"; when
// they then click ONE chip, they expect "now only this one". A plain
// toggle inverts the clicked chip and leaves the others on, which
// reads as "I just deselected the one I wanted" -- exactly the user
// confusion that prompted this fix.
//
// Algorithm:
// - "All selected" is the implicit default (no filter applied).
// - First click on a chip while in default state switches to
// "only this stem" (clears all others).
// - Subsequent clicks on inactive chips ADD them to the filter.
// - Clicks on the only-selected chip clear it; if that empties the
// selection, we revert to "all selected" (wraparound).
//
// Persisted across reloads so the next song honors the user's last
// chosen subset, but a 0-selection state is normalized to all 6.
function refreshStemChoiceVisuals() {
for (const btn of document.querySelectorAll(".stem-choice[data-stem]")) {
btn.setAttribute(
"aria-pressed",
String(selectedStems.has(btn.dataset.stem)),
);
}
}
function handleStemChoiceClick(stem) {
const allSelected = selectedStems.size === STEM_NAMES.length;
if (allSelected) {
// Default state -> switch to "only this stem".
selectedStems.clear();
selectedStems.add(stem);
} else if (selectedStems.has(stem)) {
selectedStems.delete(stem);
if (selectedStems.size === 0) {
// Empty out wraps back to "all" so the user is never stuck.
for (const n of STEM_NAMES) selectedStems.add(n);
}
} else {
selectedStems.add(stem);
}
saveSelectedStems();
refreshStemChoiceVisuals();
buildStripStems();
}
function wireStemChoiceButtons() {
refreshStemChoiceVisuals();
for (const btn of document.querySelectorAll(".stem-choice[data-stem]")) {
btn.addEventListener("click", () => handleStemChoiceClick(btn.dataset.stem));
}
}
function wireAllButton() {
const allBtn = document.getElementById("stemAllBtn");
if (!allBtn) return;
function syncAllBtn() {
allBtn.setAttribute("aria-pressed", String(selectedStems.size === STEM_NAMES.length));
}
allBtn.addEventListener("click", () => {
const allSelected = selectedStems.size === STEM_NAMES.length;
if (allSelected) {
selectedStems.clear();
} else {
for (const n of STEM_NAMES) selectedStems.add(n);
}
saveSelectedStems();
refreshStemChoiceVisuals();
buildStripStems();
syncAllBtn();
});
/* Keep All in sync when individual stems are toggled */
for (const btn of document.querySelectorAll(".stem-choice[data-stem]")) {
btn.addEventListener("click", syncAllBtn);
}
syncAllBtn();
}
// ─── Wire everything up ───
syncStemNamesFromAPI().then(() => buildStripStems());
wireJobForm();
wireTransportButtons();
wireFooterControls();
requestAnimationFrame(drawFooterPlaceholder);
wireStemListControls();
wireMixerToolbar();
wireStemChoiceButtons();
wireAllButton();
wireFileDrop();
wireAppShellControls();
(async () => {
await runStoreMigrationIfNeeded();
await stemSelectionReady;
refreshStemChoiceVisuals();
await initCatalog();
})().catch(console.error);
// ─── Footer: speed dropdown, export dropdown, scrub seek ───
function wireFooterControls() {
// ── Export split-button dropdown ──
// The full button toggles the export menu. Export actions live inside
// the dropdown so the hit target is predictable.
// The menu offers Mix / All Stems / Current Region, with a WAV/MP3 toggle in
// the header. All exports reuse the backend-served download helpers.
const exportBtn = document.getElementById("t-export-btn");
const exportPanel = document.getElementById("t-export-panel");
const exportLabel = document.getElementById("t-export-label");
const fmtWav = document.getElementById("t-fmt-wav");
const fmtMp3 = document.getElementById("t-fmt-mp3");
const fmtFlac = document.getElementById("t-fmt-flac");
const fmtMp4 = document.getElementById("t-fmt-mp4");
const exportWrap = document.getElementById("footer-export-wrap");
const itemMix = document.getElementById("t-export-mix");
const itemStems = document.getElementById("t-export-stems");
const itemRegion = document.getElementById("t-export-region");
const mixDescEl = itemMix?.querySelector(".chip-item-desc");
// Only the rows actually visible in the current format mode (MP4 hides the
// audio-only Stems/Region rows).
const actionItems = () =>
[itemMix, itemStems, itemRegion].filter((it) => it && it.offsetParent !== null);
// MP4 is a format choice, shown only for jobs with a preserved video track.
// It applies to the mix only — stems/region are audio-only.
const videoAvailable = () => !!exportWrap?.classList.contains("has-video");
let format = "wav";
let busy = false;
const panelOpen = () => exportPanel && !exportPanel.classList.contains("hidden");
function openPanel() {
closeAllChipPanels();
// A previous (video) job may have left MP4 selected; revert if unavailable now.
if (format === "mp4" && !videoAvailable()) setFormat("wav");
exportPanel?.classList.remove("hidden");
exportBtn?.setAttribute("aria-expanded", "true");
}
function closePanel() {
exportPanel?.classList.add("hidden");
exportBtn?.setAttribute("aria-expanded", "false");
}
function setFormat(f) {
format = f;
for (const [btn, val] of [[fmtWav, "wav"], [fmtMp3, "mp3"], [fmtFlac, "flac"], [fmtMp4, "mp4"]]) {
btn?.classList.toggle("active", f === val);
btn?.setAttribute("aria-checked", String(f === val));
}
applyFormatState();
}
fmtWav?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("wav"); });
fmtMp3?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("mp3"); });
fmtFlac?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("flac"); });
fmtMp4?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("mp4"); });
// MP4 exports the mix muxed with the source video. Stems and region have no
// video equivalent, so they're hidden (via .fmt-mp4) while MP4 is selected,
// leaving just "Export Mix".
function applyFormatState() {
const video = format === "mp4";
exportPanel?.classList.toggle("fmt-mp4", video);
if (mixDescEl) {
mixDescEl.textContent = video ? "Export mix with the original video" : "Export the mixed audio";
}
if (!video) updateLoopRegionVisual(); // restores the region item's disabled state
}
function resetBusy() {
busy = false;
exportBtn?.classList.remove("is-busy");
if (exportLabel) exportLabel.textContent = "Export Mix";
itemMix?.removeAttribute("aria-disabled");
applyFormatState(); // restores stems/region per the active format
}
// Single-file (mix/region) downloads give no JS-observable byte progress, so
// show a brief indeterminate "Exporting…" state, then reset.
function flashBusy() {
busy = true;
exportBtn?.classList.add("is-busy");
if (exportLabel) exportLabel.textContent = "Exporting…";
actionItems().forEach((it) => it?.setAttribute("aria-disabled", "true"));
closePanel();
window.setTimeout(resetBusy, 1200);
}
exportBtn?.addEventListener("click", (e) => {
e.stopPropagation();
if (busy) return;
panelOpen() ? closePanel() : openPanel();
});
// Export Mix: MP4 produces the video; any other format an audio mix.
itemMix?.addEventListener("click", (e) => {
e.stopPropagation();
if (busy) return;
const ok = format === "mp4" ? downloadCurrentVideo() : downloadCurrentMix(format);
if (!ok) { showError("All stems are muted - nothing to export."); return; }
flashBusy();
});
itemRegion?.addEventListener("click", (e) => {
e.stopPropagation();
if (busy || itemRegion.getAttribute("aria-disabled") === "true") return;
const ok = downloadRegionMix(format);
if (!ok) { showError("All stems are muted - nothing to export."); return; }
flashBusy();
});
// All Stems = a single backend-built ZIP, named after the song. Audio-only,
// so it's disabled (and inert) while MP4 is the selected format.
itemStems?.addEventListener("click", (e) => {
e.stopPropagation();
if (busy || itemStems.getAttribute("aria-disabled") === "true") return;
downloadAllStemsZip(format);
flashBusy();
});
// Keyboard: ↓ opens/moves into the menu, ↑/↓ cycle rows, Esc closes + restores focus.
exportBtn?.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!panelOpen()) openPanel();
actionItems().find((it) => it?.getAttribute("aria-disabled") !== "true")?.focus();
}
});
exportPanel?.addEventListener("keydown", (e) => {
const focusable = actionItems().filter((it) => it && it.getAttribute("aria-disabled") !== "true");
const idx = focusable.indexOf(document.activeElement);
if (e.key === "Escape") { closePanel(); exportBtn?.focus(); }
else if (e.key === "ArrowDown") { e.preventDefault(); focusable[(idx + 1) % focusable.length]?.focus(); }
else if (e.key === "ArrowUp") { e.preventDefault(); focusable[(idx - 1 + focusable.length) % focusable.length]?.focus(); }
});
// ── Scrub bar seek ──
const scrub = document.getElementById("footer-scrub");
if (scrub) {
function seekToX(clientX) {
if (!multitrack || !totalDuration) return;
const rect = scrub.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
multitrack.setTime(frac * totalDuration);
}
let _scrubbing = false;
scrub.addEventListener("mousedown", (e) => {
_scrubbing = true;
seekToX(e.clientX);
});
document.addEventListener("mousemove", (e) => { if (_scrubbing) seekToX(e.clientX); });
document.addEventListener("mouseup", () => { _scrubbing = false; });
}
// ── Close panels on outside click ──
document.addEventListener("click", closeAllChipPanels);
}
function closeAllChipPanels() {
document.querySelectorAll(".footer-chip-panel:not(.hidden)").forEach((p) => {
p.classList.add("hidden");
p.previousElementSibling?.setAttribute("aria-expanded", "false");
});
}
// ─── File drop on URL input ───
function wireFileDrop() {
const urlWrap = document.querySelector(".url-wrap");
const urlInput = document.getElementById("url");
const fileInput = document.getElementById("fileInput");
const filePill = document.getElementById("filePill");
const fileName = document.getElementById("fileName");
const fileSize = document.getElementById("fileSize");
const fileClear = document.getElementById("fileClear");
if (!urlWrap || !urlInput || !fileInput || !filePill) return;
function formatBytes(n) {
return n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
}
const MAX_UPLOAD_BYTES = 400 * 1024 * 1024; // must match server _MAX_UPLOAD_BYTES
function applyFile(file) {
if (!file) return;
const lower = file.name.toLowerCase();
if (!lower.endsWith(".mp3") && !lower.endsWith(".wav") && !lower.endsWith(".flac") &&
!lower.endsWith(".mp4") && !lower.endsWith(".m4a")) {
showError("Only MP3, WAV, FLAC, MP4, and M4A files are supported.");
return;
}
if (file.size > MAX_UPLOAD_BYTES) {
showError(`File is too large (${formatBytes(file.size)}). Maximum is 400 MB.`);
return;
}
if (fileName) fileName.textContent = file.name;
if (fileSize) fileSize.textContent = formatBytes(file.size);
filePill.classList.remove("hidden");
urlWrap.classList.add("has-file");
// Cache the File object directly on the element so job.js can always
// retrieve it even after the browser clears fileInput.files following
// a fetch() submission (known WKWebView / Chromium behaviour).
fileInput._file = file;
const dt = new DataTransfer();
dt.items.add(file);
fileInput.files = dt.files;
urlInput.value = "";
urlInput.removeAttribute("required");
}
function clearFile() {
filePill.classList.add("hidden");
urlWrap.classList.remove("has-file");
fileInput._file = null;
fileInput.value = "";
urlInput.setAttribute("required", "");
}
fileClear?.addEventListener("click", clearFile);
urlWrap.addEventListener("dragover", (e) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
urlWrap.classList.add("drag-over");
});
urlWrap.addEventListener("dragleave", (e) => {
if (!urlWrap.contains(e.relatedTarget)) urlWrap.classList.remove("drag-over");
});
urlWrap.addEventListener("drop", (e) => {
e.preventDefault();
urlWrap.classList.remove("drag-over");
const file = e.dataTransfer.files[0];
if (file) applyFile(file);
});
fileInput.addEventListener("change", () => {
if (fileInput.files[0]) applyFile(fileInput.files[0]);
});
}
// ─── App shell controls ───
function wireAppShellControls() {
document.getElementById("appMenuBtn")?.addEventListener("click", (e) => {
e.stopPropagation();
const app = document.querySelector(".app");
// In trash view: switch back to library.
if (document.querySelector(".sidebar.trash-view, .sidebar.favorites-view")) {
document.querySelector(".rail-library")?.click();
}
// If collapsed: open. Never collapse from the library button.
if (app?.classList.contains("cat-collapsed")) {
document.getElementById("catalogToggle")?.click();
}
});
}
// ─── Keyboard shortcuts ───
document.addEventListener("keydown", (e) => {
if (!multitrack) return;
if (e.target instanceof HTMLInputElement) return;
if (e.code === "Space") {
e.preventDefault();
togglePlayPause();
} else if (e.code === "BracketLeft") {
e.preventDefault();
multitrack.setTime(Math.max(0, multitrack.getCurrentTime() - 5));
} else if (e.code === "BracketRight") {
e.preventDefault();
multitrack.setTime(
Math.min(multitrack.getDuration(), multitrack.getCurrentTime() + 5),
);
} else if (e.code === "KeyL") {
e.preventDefault();
loopBtn.click();
} else if (e.code === "KeyI" && loopEnabled && multitrack) {
e.preventDefault();
setLoopStart(Math.min(multitrack.getCurrentTime(), loopEnd - 0.5));
updateLoopRegionVisual();
} else if (e.code === "KeyO" && loopEnabled && multitrack) {
e.preventDefault();
setLoopEnd(Math.max(multitrack.getCurrentTime(), loopStart + 0.5));
updateLoopRegionVisual();
}
});
// ─── External links ───
document.addEventListener("click", (e) => {
const dl = e.target.closest("a.lane-dl");
if (dl?.href) {
const openUrl = window.__TAURI__?.core?.invoke;
if (openUrl) {
e.preventDefault();
openUrl("open_url", { url: dl.href });
}
return;
}
const anchor = e.target.closest('a[target="_blank"]');
if (anchor?.href) {
const openUrl = window.__TAURI__?.core?.invoke;
if (openUrl) {
e.preventDefault();
openUrl("open_url", { url: anchor.href });
}
}
});
// ─── Global error logging ───
window.addEventListener("error", (e) => {
console.error("[app:error]", e.message, "\n", e.filename, ":", e.lineno, "\n", e.error?.stack ?? "");
});
window.addEventListener("unhandledrejection", (e) => {
console.error("[app:unhandledrejection]", e.reason?.message ?? e.reason, "\n", e.reason?.stack ?? "");
});
// ─── Bootstrap ───
buildStripStems();
renderEmptyShell();
+500
View File
@@ -0,0 +1,500 @@
import {
STEM_NAMES, TRACK_NAMES, STEM_COLORS, STEM_DISPLAY, LANE_VOLUME_MAX,
} from "./constants.js";
import {
mixerState, mixerEl, stemListEl, currentJobId, multitrack, trackIndex,
masterVolume, audioEngine,
} from "./state.js";
import { storeGet, storeSetDebounced } from "./utils.js";
function defaultMixerEntry() {
return { volume: 1, muted: false, soloed: false };
}
export function ensureMixerStateDefaults() {
for (const name of TRACK_NAMES) {
if (!mixerState[name]) mixerState[name] = defaultMixerEntry();
}
}
export async function loadMixIntoState(jobId, loadedStemNames = STEM_NAMES) {
let stored = {};
try {
const data = await storeGet(`stemdeck:mix:${jobId}`, {});
if (data && typeof data === "object") stored = data;
} catch (e) { console.warn("[mixer] failed to load mix state:", e); }
for (const name of TRACK_NAMES) {
Object.assign(mixerState[name], defaultMixerEntry(), stored[name] || {});
}
// If all loaded stems are muted the session is unplayable -- unmute as recovery.
const loadedStems = loadedStemNames.filter((n) => mixerState[n]);
if (loadedStems.length > 0 && loadedStems.every((n) => mixerState[n].muted)) {
for (const name of loadedStems) mixerState[name].muted = false;
}
}
export function resetMixerState() {
for (const name of TRACK_NAMES) {
Object.assign(mixerState[name], defaultMixerEntry());
}
}
function saveMix() {
if (!currentJobId) return;
storeSetDebounced(`stemdeck:mix:${currentJobId}`, mixerState);
}
export function applyMix() {
if (!multitrack) return;
const anySolo = TRACK_NAMES.some((name) => trackIndex[name] !== undefined && mixerState[name]?.soloed);
for (const name of TRACK_NAMES) {
const s = mixerState[name];
if (!s) continue;
let effective = s.volume;
if (s.muted) effective = 0;
else if (anySolo && !s.soloed) effective = 0;
const idx = trackIndex[name];
if (idx === undefined) continue;
const targetGain = effective * masterVolume;
if (audioEngine) {
// Web Audio engine owns playback: set the per-stem gain directly (no 1.0
// cap, so >1.0 lane boost works), and skip the streaming volume path —
// the multitrack is mounted for visuals only and never plays.
audioEngine.setGain(name, targetGain);
continue;
}
const audioEl = multitrack.audios?.[idx];
if (audioEl instanceof HTMLMediaElement) {
// WKWebView does not pass audio through MediaElementSource → GainNode →
// destination. Use the native HTMLAudioElement volume path instead and
// cap at 1.0 (the spec limit). Boost above unity is not supported on
// this platform but basic volume/mute works reliably.
audioEl.volume = Math.max(0, Math.min(1, targetGain));
} else {
multitrack.setTrackVolume(idx, targetGain);
}
}
}
export function updateLaneKnobVisual(knobEl, v) {
const frac = Math.max(0, Math.min(1, v / LANE_VOLUME_MAX));
knobEl.style.setProperty("--lane-pos", frac.toFixed(3));
knobEl.setAttribute("aria-valuenow", v.toFixed(2));
const input = knobEl.querySelector(".mx-fader-input");
if (input) {
input.value = String(v);
// Set --lane-pos directly on the input so ::webkit-slider-runnable-track
// can see it — WebKit shadow DOM pseudo-elements don't inherit vars from ancestors.
input.style.setProperty("--lane-pos", frac.toFixed(3));
}
const val = knobEl.closest(".lane-header")?.querySelector(".mx-val");
if (val) {
const db = v <= 0 ? "-∞" : (20 * Math.log10(v)).toFixed(1);
val.textContent = db === "-∞" ? "-∞" : `${parseFloat(db) > 0 ? "+" : ""}${db}`;
}
}
export function setLaneVolume(name, v) {
const state = mixerState[name];
if (!state) return;
state.volume = Math.max(0, Math.min(LANE_VOLUME_MAX, v));
const knob = mixerEl.querySelector(`.lane-knob[data-stem="${name}"]`);
if (knob) updateLaneKnobVisual(knob, state.volume);
applyMix();
saveMix();
}
export function refreshMixerVisuals() {
for (const name of TRACK_NAMES) {
const state = mixerState[name];
if (!state) continue;
// Mixer-column lane header
const row = mixerEl.querySelector(`.lane-header[data-stem="${name}"]`);
if (row) {
const muteBtn = row.querySelector(".mute");
const soloBtn = row.querySelector(".solo");
if (soloBtn) soloBtn.classList.toggle("active", state.soloed);
const iconToggle = row.querySelector(".lane-icon-toggle");
if (iconToggle) {
iconToggle.classList.toggle("active", !state.muted);
iconToggle.setAttribute("aria-pressed", String(!state.muted));
}
row.classList.toggle("muted", state.muted);
const knob = row.querySelector(".lane-knob");
if (knob) updateLaneKnobVisual(knob, state.volume);
}
// Stems-list panel row (mirrors the mixer column buttons)
if (stemListEl) {
const slRow = stemListEl.querySelector(`span[data-stem="${name}"]`);
if (slRow) {
const m = slRow.querySelector(".stem-mute");
const s = slRow.querySelector(".stem-solo");
const mon = slRow.querySelector(".stem-monitor");
if (m) {
m.classList.toggle("active", state.muted);
m.setAttribute("aria-pressed", String(state.muted));
}
if (s) {
s.classList.toggle("active", state.soloed);
s.setAttribute("aria-pressed", String(state.soloed));
}
if (mon) {
// Active when this stem is THE lone solo (the "monitor" target).
const others = TRACK_NAMES.filter((n) => n !== name);
const lone = state.soloed
&& others.every((n) => !mixerState[n]?.soloed);
mon.classList.toggle("active", lone);
}
slRow.classList.toggle("muted", state.muted);
}
}
}
}
export function setLaneControlsEnabled(enabled) {
for (const b of mixerEl.querySelectorAll(".ms-btn")) b.disabled = !enabled;
for (const b of mixerEl.querySelectorAll(".lane-icon-toggle")) b.disabled = !enabled;
for (const a of mixerEl.querySelectorAll(".lane-dl")) {
a.classList.toggle("disabled", !enabled);
if (!enabled) {
a.setAttribute("aria-disabled", "true");
a.setAttribute("tabindex", "-1");
} else {
a.removeAttribute("aria-disabled");
a.removeAttribute("tabindex");
}
}
for (const k of mixerEl.querySelectorAll(".lane-knob")) {
k.classList.toggle("disabled", !enabled);
k.setAttribute("aria-disabled", String(!enabled));
k.setAttribute("tabindex", enabled ? "0" : "-1");
}
}
const MINI_WAVE_BARS = 40;
const MINI_WAVE_VIEWBOX_H = 26;
function emptyMiniWaveSvg(stemName) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "lane-mini-wave");
svg.dataset.stem = stemName;
svg.setAttribute("preserveAspectRatio", "none");
svg.setAttribute("viewBox", `0 0 ${MINI_WAVE_BARS * 2} ${MINI_WAVE_VIEWBOX_H}`);
return svg;
}
function makeMiniWaveSvg(stemName, color) {
// Seeded placeholder bars used while real peaks haven't loaded yet.
let s = 0;
for (const c of stemName) s = (s * 31 + c.charCodeAt(0)) >>> 0;
const rng = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
const svg = emptyMiniWaveSvg(stemName);
for (let i = 0; i < MINI_WAVE_BARS; i++) {
const env = Math.sin((i / MINI_WAVE_BARS) * Math.PI) * 0.7 + 0.3;
const h = Math.max(2, env * (rng() * 0.6 + 0.25) * MINI_WAVE_VIEWBOX_H);
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("x", `${i * 2}`);
rect.setAttribute("y", `${(MINI_WAVE_VIEWBOX_H - h) / 2}`);
rect.setAttribute("width", "1");
rect.setAttribute("height", `${h}`);
rect.setAttribute("fill", color);
rect.setAttribute("opacity", "0.6");
svg.appendChild(rect);
}
return svg;
}
export function renderRealMiniWave(stemName, audioBuffer, color) {
const svg = mixerEl.querySelector(`.lane-mini-wave[data-stem="${stemName}"]`);
if (!svg || !audioBuffer || typeof audioBuffer.getChannelData !== "function") return;
const ch = audioBuffer.getChannelData(0);
if (!ch || !ch.length) return;
const binSize = Math.max(1, Math.floor(ch.length / MINI_WAVE_BARS));
const peaks = new Array(MINI_WAVE_BARS);
let max = 0;
for (let i = 0; i < MINI_WAVE_BARS; i++) {
const start = i * binSize;
const end = i === MINI_WAVE_BARS - 1 ? ch.length : start + binSize;
let p = 0;
for (let j = start; j < end; j++) {
const v = Math.abs(ch[j]);
if (v > p) p = v;
}
peaks[i] = p;
if (p > max) max = p;
}
const norm = max > 0 ? 1 / max : 0;
while (svg.firstChild) svg.removeChild(svg.firstChild);
for (let i = 0; i < MINI_WAVE_BARS; i++) {
const h = Math.max(1.5, peaks[i] * norm * MINI_WAVE_VIEWBOX_H);
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("x", `${i * 2}`);
rect.setAttribute("y", `${(MINI_WAVE_VIEWBOX_H - h) / 2}`);
rect.setAttribute("width", "1");
rect.setAttribute("height", `${h}`);
rect.setAttribute("fill", color);
rect.setAttribute("opacity", "0.95");
svg.appendChild(rect);
}
}
function downloadIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("width", "18");
svg.setAttribute("height", "18");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
svg.setAttribute("aria-hidden", "true");
svg.innerHTML =
'<path d="M12 3v11"></path>' +
'<path d="m7.5 9.5 4.5 4.5 4.5-4.5"></path>' +
'<rect x="5" y="17" width="14" height="4" rx="1.5"></rect>';
return svg;
}
function makeVolumeKnob(stemName, color) {
const wrap = document.createElement("div");
wrap.className = "lane-knob mx-fader";
wrap.dataset.stem = stemName;
const input = document.createElement("input");
input.type = "range";
input.className = "mx-fader-input";
input.setAttribute("min", "0");
input.setAttribute("max", String(LANE_VOLUME_MAX));
input.setAttribute("step", "0.01");
input.setAttribute("value", "1");
input.style.setProperty("--fader-color", color);
input.style.setProperty("--lane-pos", "0.5");
input.setAttribute("aria-label", `${STEM_DISPLAY[stemName] || stemName} volume`);
input.addEventListener("input", () => setLaneVolume(stemName, parseFloat(input.value)));
input.addEventListener("dblclick", (e) => { e.stopPropagation(); setLaneVolume(stemName, 1); });
wrap.appendChild(input);
wrap.addEventListener("dblclick", () => setLaneVolume(stemName, 1));
wrap.addEventListener("wheel", (e) => {
e.preventDefault();
const cur = mixerState[stemName]?.volume ?? 1;
setLaneVolume(stemName, cur - Math.sign(e.deltaY) * (e.shiftKey ? 0.2 : 0.04));
}, { passive: false });
return wrap;
}
function stemIconMarkup(stemName) {
const common = 'class="lane-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"';
const icons = {
vocals: `<svg ${common}><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><path d="M12 19v3"></path></svg>`,
drums: `<svg ${common}><path d="M7 13.5a5 5 0 0 0 10 0"></path><path d="M7 13.5h10"></path><circle cx="9" cy="10" r="2.5"></circle><circle cx="15" cy="10" r="2.5"></circle><path d="M4 6.5h5"></path><path d="M15 6.5h5"></path><path d="M6.5 6.5v5"></path><path d="M17.5 6.5v5"></path><path d="M10 18l-2 3"></path><path d="M14 18l2 3"></path><path d="M4 18l16-8"></path></svg>`,
bass: `<svg ${common}><path d="M16.5 3h4v5h-3"></path><path d="M17.5 5.5 9.8 13.2"></path><path d="M10 13c1.6 2.2 1.1 5.1-1.2 6.5-2.1 1.3-5 .5-6-1.6-.9-1.9-.1-4.1 1.8-5 .9-.4 1.8-.4 2.8-.1.1-1.1.6-2.1 1.6-2.6 1.2-.6 2.6-.1 3.2 1.1"></path><path d="M6.7 16.4h.01"></path><path d="M13.5 9.5l3 3"></path><path d="M18.2 3v4.6"></path><path d="M20.5 3v4"></path></svg>`,
guitar: `<svg ${common}><path d="M16 4.5 20 2l2 2-2.5 4"></path><path d="M18.2 5.8 10.2 13.8"></path><path d="M10.5 13.5c1.1 1.7.5 4.2-1.5 5.5-2.2 1.5-5.3.8-6.3-1.3-.8-1.7.1-3.6 1.9-4.2 1-.3 1.8-.1 2.7.5.1-1.1.6-2.1 1.6-2.6 1.4-.7 2.7.2 1.6 2.1Z"></path><path d="M6.5 15.1c1.3.6 2.2 1.5 2.9 2.8"></path><circle cx="7" cy="16.4" r="1.4"></circle><path d="M14 8l3 3"></path></svg>`,
piano: `<svg ${common}><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="M7 5v14"></path><path d="M12 5v14"></path><path d="M17 5v14"></path><path d="M9.5 5v7"></path><path d="M14.5 5v7"></path></svg>`,
other: `<svg ${common}><path d="M4 13v-2"></path><path d="M8 17V7"></path><path d="M12 21V3"></path><path d="M16 17V7"></path><path d="M20 13v-2"></path></svg>`,
original: `<svg ${common}><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>`,
};
return icons[stemName] || icons.other;
}
export function renderMixerRow(stem) {
const state = mixerState[stem.name];
const color = STEM_COLORS[stem.name] || "#a0a0a0";
const display = STEM_DISPLAY[stem.name] || stem.name;
const row = document.createElement("div");
row.className = "lane-header mx-row";
row.dataset.stem = stem.name;
// Col 1: stem icon
const iconCell = document.createElement("div");
iconCell.className = "mx-icon";
iconCell.style.color = color;
iconCell.innerHTML = stemIconMarkup(stem.name);
// Hidden lane-stripe kept for CSS compat
const stripe = document.createElement("div");
stripe.className = "lane-stripe";
stripe.style.background = color;
row.appendChild(stripe);
// Col 2: name
const nameEl = document.createElement("span");
nameEl.className = "mx-name";
nameEl.style.color = color;
nameEl.textContent = display;
// Col 3: horizontal fader
const fader = makeVolumeKnob(stem.name, color);
// Col 4: VU meter
const vu = document.createElement("div");
vu.className = "lane-vu mx-meter";
vu.dataset.stem = stem.name;
vu.innerHTML = '<div class="lane-vu-bar mx-meter-fill"></div><div class="lane-vu-bar"></div>';
// Col 5: value label
const val = document.createElement("span");
val.className = "mx-val";
const initFrac = Math.max(0, Math.min(1, (state?.volume ?? 1) / LANE_VOLUME_MAX));
val.textContent = String(Math.round(initFrac * 100));
// Col 6: M button
const muteBtn = document.createElement("button");
muteBtn.type = "button";
muteBtn.className = "lane-icon-toggle mx-btn mute";
muteBtn.textContent = "M";
muteBtn.setAttribute("aria-label", `Mute ${display}`);
muteBtn.setAttribute("aria-pressed", String(state?.muted ?? false));
if (!state?.muted) muteBtn.classList.add("active");
// Col 7: S button
const soloBtn = document.createElement("button");
soloBtn.type = "button";
soloBtn.className = "solo ms-btn mx-btn";
soloBtn.textContent = "S";
soloBtn.setAttribute("aria-label", `Solo ${display}`);
soloBtn.setAttribute("aria-pressed", String(state?.soloed ?? false));
if (state?.soloed) soloBtn.classList.add("active");
// Col 8: download
const dl = document.createElement("a");
dl.className = "lane-dl mx-btn";
dl.href = stem.url;
dl.download = `${stem.name}.wav`;
dl.title = `Download ${display}`;
dl.appendChild(downloadIcon());
// Wrap name + VU in a column so VU appears below the name
const nameVuCol = document.createElement("div");
nameVuCol.className = "lane-name-vu";
nameVuCol.append(nameEl, vu);
row.append(iconCell, nameVuCol, fader, val, muteBtn, soloBtn, dl);
muteBtn.addEventListener("click", () => toggleStemMute(stem.name));
soloBtn.addEventListener("click", () => toggleStemSolo(stem.name));
row.classList.toggle("muted", state?.muted ?? false);
if (state) updateLaneKnobVisual(fader, state.volume);
return { row, vuEl: vu };
}
// ─── Stem-list panel (Stems sidebar) ───
//
// The stems-list panel renders a parallel set of M / S / Monitor controls
// that share state with the mixer-column lane-header buttons. Both UIs
// drive `mixerState`; either one updates the audio mix and both visuals
// re-render via refreshMixerVisuals().
export function toggleStemMute(name) {
const state = mixerState[name];
if (!state) return;
state.muted = !state.muted;
refreshMixerVisuals();
applyMix();
saveMix();
}
export function toggleStemSolo(name) {
const state = mixerState[name];
if (!state) return;
state.soloed = !state.soloed;
refreshMixerVisuals();
applyMix();
saveMix();
}
// "Monitor" = solo only this stem. If already the lone solo, clear all
// solos (toggle-style behavior, like Logic's "Solo Safe" / Reaper's
// solo-exclusive). Also clears mute on the target so it's audible.
export function soloOnlyStem(name) {
const state = mixerState[name];
if (!state) return;
const others = TRACK_NAMES.filter((n) => n !== name);
const isAlreadyAlone = state.soloed && others.every((n) => !mixerState[n]?.soloed);
if (isAlreadyAlone) {
state.soloed = false;
} else {
for (const n of TRACK_NAMES) {
if (!mixerState[n]) continue;
mixerState[n].soloed = (n === name);
}
state.muted = false;
}
refreshMixerVisuals();
applyMix();
saveMix();
}
export function resetMixer() {
for (const name of TRACK_NAMES) {
const s = mixerState[name];
if (!s) continue;
s.volume = 1;
s.muted = false;
s.soloed = false;
}
refreshMixerVisuals();
applyMix();
saveMix();
}
export function muteAll() {
// Toggle: if every stem is muted, un-mute all; otherwise mute all.
const allMuted = STEM_NAMES.every((n) => mixerState[n]?.muted);
for (const name of TRACK_NAMES) {
const s = mixerState[name];
if (!s) continue;
s.muted = !allMuted;
}
refreshMixerVisuals();
applyMix();
saveMix();
}
export function clearAllSolos() {
for (const name of TRACK_NAMES) {
const s = mixerState[name];
if (!s) continue;
s.soloed = false;
}
refreshMixerVisuals();
applyMix();
saveMix();
}
export function wireMixerToolbar() {
document.getElementById("mixer-reset")?.addEventListener("click", resetMixer);
document.getElementById("mixer-mute-all")?.addEventListener("click", muteAll);
document.getElementById("mixer-clear-solo")?.addEventListener("click", clearAllSolos);
}
export function wireStemListControls() {
if (!stemListEl) return;
for (const btn of stemListEl.querySelectorAll(".stem-mute")) {
btn.addEventListener("click", () => toggleStemMute(btn.dataset.stem));
btn.addEventListener("keydown", (e) => {
if (e.code === "Space" || e.code === "Enter") {
e.preventDefault();
toggleStemMute(btn.dataset.stem);
}
});
}
for (const btn of stemListEl.querySelectorAll(".stem-solo")) {
btn.addEventListener("click", () => toggleStemSolo(btn.dataset.stem));
btn.addEventListener("keydown", (e) => {
if (e.code === "Space" || e.code === "Enter") {
e.preventDefault();
toggleStemSolo(btn.dataset.stem);
}
});
}
for (const btn of stemListEl.querySelectorAll(".stem-monitor")) {
btn.addEventListener("click", () => soloOnlyStem(btn.dataset.stem));
}
}
+54
View File
@@ -0,0 +1,54 @@
// Playful labels rotated by job.js every ROTATION_MS while a job is in
// progress. Keyed by Job.status values from the backend. The progress
// bar carries the real percentage; this is purely UI personality.
//
// Edit freely — additions/removals don't require any code changes.
export const stagePhrases = {
queued: [
"Lacing up…",
"Tuning forks…",
"Spinning up…",
"Warming the tubes…",
],
// Downloading is load-in: gear arriving, getting set up, before the
// sound check starts.
downloading: [
"Load-in…",
"Rolling in flight cases…",
"Setting up the kit…",
"Snaking the cables…",
"Plugging into the patch bay…",
],
// Separating is the long stage — keep the user company with band /
// sound-check vignettes. Short observational sentences, the kind of
// thing you'd overhear at a rehearsal.
separating: [
"Tuning the bass…",
"Guitarist checking himself in the mirror…",
"Pick fell on the floor…",
"In-ear check…",
"Mic check, one two…",
"\"More me in the monitor\"…",
"Drummer adjusting the snare…",
"Singer warming up… lalala",
"Capo on, capo off…",
"Tightening the lugs…",
"Tuning the floor tom…",
"Coiling a cable…",
"Bassist plugged in backwards…",
"Pedalboard wiggling…",
"Tech swapping a 9V battery…",
"Roadie taping down the setlist…",
"\"Is this thing on?\"…",
"Stepping on a fuzz pedal…",
"Tuning the high E…",
"Drummer twirling sticks…",
"Singer sipping tea…",
"Quick bathroom break…",
"\"Can I get more vocals?\"…",
"Snare too snappy…",
"Levels look good…",
],
default: ["Working on it…"],
};
+1414
View File
File diff suppressed because it is too large Load Diff
+382
View File
@@ -0,0 +1,382 @@
// sections.js — interactive sections bar above the waveform
const SECTION_COLORS = [
"#4a7fff",
"#2ab8e8",
"#9a4aff",
"#ff8a20",
"#00c8a0",
"#ff4a90",
"#e8c840",
"#00d4d4",
];
const MIN_SEC = 0.5; // minimum section duration in seconds
const DEFAULT_WIDTH_FRAC = 0.12; // default new section = 12% of track
let _trackId = null;
let _duration = 0;
let _sections = [];
let _container = null;
let _saveTimer = null;
// ─── Public API ───────────────────────────────────────────
export function initSections(trackId, sections, duration) {
_trackId = trackId;
_duration = Math.max(1, duration || 0);
_sections = (sections || []).map((s) => ({ ...s }));
_container = document.getElementById("daw-sections");
if (!_container) return;
// Wire the static "Add" button in the label area (may already be wired)
const addBtn = document.getElementById("sectionsAddBtn");
if (addBtn && !addBtn.dataset.sectionsWired) {
addBtn.dataset.sectionsWired = "1";
addBtn.addEventListener("click", () => _addSection());
}
_render();
}
export function destroySections() {
// Flush any pending debounced save before clearing state so switching tracks
// never drops unsaved sections. _save() serializes _sections synchronously
// (JSON.stringify runs before the first await) so it's safe to clear state
// after calling it.
if (_saveTimer !== null) {
clearTimeout(_saveTimer);
_saveTimer = null;
_save();
}
_hideSaveIndicator();
_trackId = null;
_sections = [];
_duration = 0;
if (_container) _container.innerHTML = "";
_container = null;
}
// ─── Rendering ────────────────────────────────────────────
function _render() {
if (!_container) return;
_container.innerHTML = "";
const sorted = [..._sections].sort((a, b) => a.start - b.start);
for (const section of sorted) {
_container.appendChild(_makeSectionEl(section));
}
}
function _makeSectionEl(section) {
const pctStart = (section.start / _duration) * 100;
const pctWidth = ((section.end - section.start) / _duration) * 100;
const el = document.createElement("div");
el.className = "section-block";
el.dataset.id = section.id;
el.style.cssText = `left:${pctStart.toFixed(4)}%;width:${pctWidth.toFixed(4)}%;--sc:${section.color}`;
el.innerHTML = `
<div class="section-handle section-handle-l" data-edge="left"></div>
<span class="section-label">${_esc(section.name)}</span>
<button class="section-del" type="button" aria-label="Delete section" tabindex="-1">×</button>
<div class="section-handle section-handle-r" data-edge="right"></div>
`;
el.querySelector(".section-del").addEventListener("click", (e) => {
e.stopPropagation();
_deleteSection(section.id);
});
el.querySelector(".section-label").addEventListener("dblclick", (e) => {
e.stopPropagation();
_openRename(section.id, el.querySelector(".section-label"));
});
_wireDrag(el, section);
for (const h of el.querySelectorAll(".section-handle")) {
_wireResize(h, el, section);
}
return el;
}
function _esc(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// ─── Drag to move ─────────────────────────────────────────
function _wireDrag(el, section) {
let active = false;
let startX = 0;
let origStart = 0;
el.addEventListener("pointerdown", (e) => {
if (e.target.closest(".section-handle,.section-del")) return;
active = true;
startX = e.clientX;
origStart = section.start;
el.setPointerCapture(e.pointerId);
el.classList.add("sec-dragging");
e.preventDefault();
});
el.addEventListener("pointermove", (e) => {
if (!active) return;
const cw = _container.getBoundingClientRect().width;
if (!cw) return;
const dt = ((e.clientX - startX) / cw) * _duration;
const w = section.end - section.start;
section.start = _clampMove(section.id, origStart + dt, w);
section.end = section.start + w;
el.style.left = `${(section.start / _duration) * 100}%`;
});
el.addEventListener("pointerup", () => {
if (!active) return;
active = false;
el.classList.remove("sec-dragging");
_scheduleSave();
});
el.addEventListener("pointercancel", () => {
active = false;
el.classList.remove("sec-dragging");
});
}
// ─── Resize handles ───────────────────────────────────────
function _wireResize(handle, el, section) {
const edge = handle.dataset.edge;
let active = false;
let startX = 0;
let origTime = 0;
handle.addEventListener("pointerdown", (e) => {
active = true;
startX = e.clientX;
origTime = edge === "left" ? section.start : section.end;
handle.setPointerCapture(e.pointerId);
el.classList.add("sec-resizing");
e.preventDefault();
e.stopPropagation();
});
handle.addEventListener("pointermove", (e) => {
if (!active) return;
const cw = _container.getBoundingClientRect().width;
if (!cw) return;
const dt = ((e.clientX - startX) / cw) * _duration;
const desired = origTime + dt;
if (edge === "left") {
const lbound = _leftNeighborEnd(section.id);
const max = section.end - MIN_SEC;
section.start = Math.max(lbound, Math.min(max, desired));
} else {
const rbound = _rightNeighborStart(section.id);
const min = section.start + MIN_SEC;
section.end = Math.min(rbound, Math.max(min, desired));
}
const ps = (section.start / _duration) * 100;
const pw = ((section.end - section.start) / _duration) * 100;
el.style.left = `${ps}%`;
el.style.width = `${pw}%`;
});
handle.addEventListener("pointerup", () => {
if (!active) return;
active = false;
el.classList.remove("sec-resizing");
_scheduleSave();
});
handle.addEventListener("pointercancel", () => {
active = false;
el.classList.remove("sec-resizing");
});
}
// ─── Collision helpers ────────────────────────────────────
function _clampMove(id, desiredStart, width) {
let start = Math.max(0, Math.min(_duration - width, desiredStart));
const end = () => start + width;
const others = _sections.filter((s) => s.id !== id);
for (const o of others) {
if (start < o.end && end() > o.start) {
// Snap to whichever edge is closer to desired
const snapRight = o.end;
const snapLeft = o.start - width;
const dr = Math.abs(desiredStart - snapRight);
const dl = Math.abs(desiredStart - snapLeft);
start = dl < dr ? Math.max(0, snapLeft) : Math.min(_duration - width, snapRight);
}
}
return start;
}
function _leftNeighborEnd(id) {
const s = _sections.find((x) => x.id === id);
let bound = 0;
for (const o of _sections) {
if (o.id === id) continue;
if (o.end <= s.end) bound = Math.max(bound, o.end);
}
return bound;
}
function _rightNeighborStart(id) {
const s = _sections.find((x) => x.id === id);
let bound = _duration;
for (const o of _sections) {
if (o.id === id) continue;
if (o.start >= s.start) bound = Math.min(bound, o.start);
}
return bound;
}
// ─── CRUD ─────────────────────────────────────────────────
function _addSection() {
const defW = _duration * DEFAULT_WIDTH_FRAC;
const sorted = [..._sections].sort((a, b) => a.start - b.start);
// Find first gap ≥ defW
let start = 0;
for (const s of sorted) {
if (s.start - start >= defW) break;
start = Math.max(start, s.end);
}
// Clamp and verify room
start = Math.min(start, _duration - MIN_SEC);
if (start < 0) return;
const end = Math.min(start + defW, _duration);
if (end - start < MIN_SEC) return;
// Verify no overlap
if (_sections.some((s) => start < s.end && end > s.start)) return;
const color = _nextColor();
const section = { id: _nextId(), name: "Section", start, end, color };
_sections.push(section);
_render();
_scheduleSave();
// Open rename immediately
const el = _container?.querySelector(`[data-id="${section.id}"]`);
if (el) _openRename(section.id, el.querySelector(".section-label"));
}
function _deleteSection(id) {
_sections = _sections.filter((s) => s.id !== id);
_render();
_scheduleSave();
}
function _openRename(id, labelEl) {
if (!labelEl) return;
const section = _sections.find((s) => s.id === id);
if (!section) return;
const input = document.createElement("input");
input.className = "section-rename-input";
input.type = "text";
input.value = section.name;
input.style.setProperty("--sc", section.color);
labelEl.replaceWith(input);
input.focus();
input.select();
const commit = () => {
const n = input.value.trim();
if (n) section.name = n;
_render();
_scheduleSave();
};
input.addEventListener("blur", commit, { once: true });
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); input.blur(); }
if (e.key === "Escape") { input.value = section.name; input.removeEventListener("blur", commit); input.blur(); }
});
}
// ─── Persistence ──────────────────────────────────────────
let _savedTimer = null;
function _showSaving() {
const el = document.getElementById("sectionsSaveIndicator");
if (!el) return;
clearTimeout(_savedTimer);
el.textContent = "Saving";
el.className = "sections-save-indicator";
}
function _showSaved() {
const el = document.getElementById("sectionsSaveIndicator");
if (!el) return;
el.textContent = "Saved";
el.className = "sections-save-indicator saved";
_savedTimer = setTimeout(() => {
el.className = "sections-save-indicator hidden";
}, 1800);
}
function _hideSaveIndicator() {
const el = document.getElementById("sectionsSaveIndicator");
if (el) el.className = "sections-save-indicator hidden";
clearTimeout(_savedTimer);
}
function _scheduleSave() {
clearTimeout(_saveTimer);
_showSaving();
_saveTimer = setTimeout(_save, 600);
}
async function _save() {
if (!_trackId) return;
const id = _trackId;
const body = JSON.stringify({ sections: _sections });
try {
const res = await fetch(`/api/jobs/${id}/sections`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body,
});
if (!res.ok) {
const detail = await res.text().catch(() => String(res.status));
console.warn("[sections] save failed:", res.status, detail);
if (id === _trackId) _hideSaveIndicator();
return;
}
if (id === _trackId) _showSaved();
} catch (e) {
console.warn("[sections] save failed:", e);
if (id === _trackId) _hideSaveIndicator();
}
}
// ─── Utilities ────────────────────────────────────────────
function _nextColor() {
const used = new Set(_sections.map((s) => s.color));
return SECTION_COLORS.find((c) => !used.has(c)) ?? SECTION_COLORS[_sections.length % SECTION_COLORS.length];
}
function _nextId() {
return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
+76
View File
@@ -0,0 +1,76 @@
// Shared, DOM-free job/library helpers used by both the mobile UI and
// (incrementally) the desktop catalog. Anything in here must stay free of
// document/window-element access so it can be imported from either entry
// point. The API (`GET /api/jobs`) is the canonical data source.
import { fmtTime } from "../utils.js";
// Mirror of catalog.js PROCESSING_STATUSES — keep in sync with the backend
// job lifecycle. These map to the amber "working" dot.
const PROCESSING_STATUSES = new Set(["queued", "downloading", "analyzing", "separating", "processing"]);
export async function fetchJobs() {
const res = await fetch("/api/jobs", { cache: "no-store" });
if (!res.ok) throw new Error(`GET /api/jobs -> ${res.status}`);
return res.json();
}
export function deriveSource(sourceUrl) {
if (!sourceUrl) return "Local file";
if (sourceUrl.startsWith("local:")) return "Local file";
if (sourceUrl.includes("youtube.com") || sourceUrl.includes("youtu.be")) return "YouTube";
if (sourceUrl.includes("soundcloud.com")) return "SoundCloud";
return "Web";
}
// One of: "done" (green), "processing" (amber), "unavailable" (grey/red).
export function statusKind(status) {
if (PROCESSING_STATUSES.has(status)) return "processing";
if (status === "done") return "done";
return "unavailable"; // unavailable / error / failed / unknown
}
export function coverInitial(title) {
const t = (title || "").trim();
return t ? t[0].toUpperCase() : "♪";
}
// Deterministic cover gradient: the same track always gets the same color.
// Palette matches the mobile design's aesthetic (design/mobile prototype).
const GRADIENTS = [
"linear-gradient(150deg,#7b46f0,#3f6ef0 55%,#1b245f)",
"linear-gradient(140deg,#2bd4c4,#1a6d9e)",
"linear-gradient(150deg,#c44ad0,#5a2b9e)",
"linear-gradient(150deg,#f06a6a,#7a2b4e)",
"linear-gradient(150deg,#4a9bf5,#2b3a7a)",
"linear-gradient(150deg,#f5a516,#d2541f)",
"linear-gradient(150deg,#3fcf6e,#1a6d4e)",
];
export function coverGradient(seed) {
const s = String(seed || "");
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return GRADIENTS[h % GRADIENTS.length];
}
// Normalize an /api/jobs state object into the shape the mobile UI renders.
export function jobToCard(state) {
const id = state.job_id;
const title = state.title || "Untitled";
const stemCount = (state.stems || state.selected_stems || []).length;
const parts = [];
if (state.duration) parts.push(fmtTime(state.duration));
if (stemCount) parts.push(`${stemCount} stem${stemCount === 1 ? "" : "s"}`);
return {
id,
title,
sub: deriveSource(state.source_url),
meta: parts.join(" · "),
stemCount,
status: statusKind(state.status),
createdAt: state.created_at || 0,
initial: coverInitial(title),
gradient: coverGradient(id || title),
thumb: typeof state.thumbnail === "string" ? state.thumbnail : "",
};
}
+149
View File
@@ -0,0 +1,149 @@
import { $, storeGet, storeSet } from "./utils.js";
import { STEM_NAMES } from "./constants.js";
// ─── DOM refs ───
export const form = $("job-form");
export const urlInput = $("url");
export const submitBtn = $("submit");
export const playBtn = $("t-play");
export const playMiniBtn = $("t-play-mini");
export const stopBtn = $("t-stop");
export const loopBtn = $("t-loop");
export const titleEl = $("title");
export const bpmChip = $("t-bpm");
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");
export const jobBox = $("job");
export const jobTitleEl = $("job-title");
export const jobStageEl = $("job-stage");
export const jobDetailEl = $("job-detail");
export const jobCancelBtn = $("job-cancel");
export const progressEl = $("progress");
export const errorEl = $("error");
export const lanesEl = $("lanes");
export const mixerEl = $("mixer");
export const multitrackContainer = $("multitrack-container");
export const wavesGrid = $("waves-grid");
export const rulerTime = $("ruler-time");
export const loopRegionEl = $("loop-region");
export const playheadMarker = document.querySelector(".playhead-marker");
export const waveScroll = $("wave-scroll");
export const waveCanvas = $("wave-canvas");
export const presenceRulerEl = $("presence-ruler");
export const presencePlayheadEl = $("presence-playhead");
export const footerTimeElapsed = $("footer-time-elapsed");
export const footerTimeTotal = $("footer-time-total");
export const loopStartInput = $("t-loop-start");
export const loopEndInput = $("t-loop-end");
export const stemListEl = document.querySelector(".stem-list");
export const npScrubEl = document.querySelector(".np-scrub");
export const npScrubFill = $("footer-scrub-fill");
export const footerTitle = $("footer-title");
export const footerMeta = $("footer-meta");
export const footerThumb = $("footer-thumb");
// ─── Mutable state ───
export let eventSource = null;
export let multitrack = null;
// Web Audio decode-and-mix engine (Safari-safe playback). Null = legacy streaming path.
export let audioEngine = null;
export let currentJobId = null;
// `mixerState` is mutated in place (never reassigned). renderMixerRow's
// closures capture each entry by reference, so on a new job we merge
// localStorage values into the existing objects rather than replacing them.
export const mixerState = {};
export let trackIndex = {};
export let totalDuration = 0;
export let loopEnabled = false;
export let loopStart = 0;
export let loopEnd = 0;
// Selected stems for extraction. The set determines (a) which stem
// rows render in the studio dashboard after a job completes and (b)
// which stem audio gets loaded into the multitrack. Backend always
// runs Demucs on all 6 stems regardless -- filtering happens entirely
// client-side at render time. Persisted across reloads in localStorage
// so a user who turns off "Vocals" stays set up that way for the
// next song.
const _STEM_SEL_KEY = "stemdeck:selected-stems";
// Start with all stems selected (safe default). The async store load below
// updates this binding once the store is available; ES module live bindings
// ensure all importers see the updated value on next read.
export let selectedStems = new Set(STEM_NAMES);
// Resolves when the persisted stem selection has been loaded from the store.
// Consumers that need the exact stored selection (e.g. the stem-choice UI)
// should await this before reading selectedStems.
export const stemSelectionReady = (async () => {
try {
const arr = await storeGet(_STEM_SEL_KEY, null);
if (Array.isArray(arr) && arr.length > 0) {
const valid = arr.filter((n) => STEM_NAMES.includes(n));
if (valid.length > 0) {
selectedStems = new Set(valid);
return;
}
}
} catch (e) { console.warn("[state] failed to load stem selection:", e); }
// Keep the all-stems default.
})();
export function saveSelectedStems() {
storeSet(_STEM_SEL_KEY, [...selectedStems]).catch((e) =>
console.warn("[state] failed to save stem selection:", e)
);
}
export function setStemSelected(name, selected) {
if (selected) selectedStems.add(name);
else selectedStems.delete(name);
saveSelectedStems();
}
// Web Audio analysers for live VU meters.
export let audioContext = null;
export let masterVolume = 0.5; // mirrored from masterFader.value
export const trackAnalysers = []; // index → { analyser, data, vuEl }
export let vuRafId = null;
// Master bus nodes — created once in audio.js, shared across mixer.js.
// masterBusGain is driven by the master fader; masterLimiter is a
// transparent brickwall limiter that prevents inter-stem summing clipping.
export let masterBusGain = null;
export let masterLimiter = null;
// ─── Setter helpers for mutable state (so other modules can update) ───
export function setEventSource(v) { eventSource = v; }
export function setMultitrack(v) { multitrack = v; }
export function setAudioEngine(v) { audioEngine = v; }
export function setCurrentJobId(v) { currentJobId = v; }
export function setTrackIndex(v) { trackIndex = v; }
export function setTotalDuration(v) { totalDuration = v; }
export function setLoopEnabled(v) { loopEnabled = v; }
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; }
// Footer waveform draw callback — set by player.js, called by transport.js
export let footerWaveDrawFn = null;
export function setFooterWaveDrawFn(fn) { footerWaveDrawFn = fn; }
+536
View File
@@ -0,0 +1,536 @@
import { fmtTime, fmtTickLabel, fmtTimeMs, parseTimecode } 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,
loopStartInput, loopEndInput,
setLoopEnabled, setLoopStart, setLoopEnd, setMasterVolume, setPlaybackSpeed,
} from "./state.js";
import { applyMix } from "./mixer.js";
const MIN_LOOP_SEC = 0.2;
// Below this visible width the waveform stops compressing to fit and instead
// keeps a minimum size, overflowing horizontally so .wave-scroll can scroll.
const WAVE_MIN_WIDTH = 720;
// rulerTime is the canonical timeline reference for both click->time
// and time->pixel mapping. The wave-editor lays the ruler and the
// waveform body out so they should be horizontally aligned (both gutter
// 48 px on the left in studio mode), but using one element for both
// halves of the round-trip eliminates any subtle CSS drift -- clicking
// "1:00" on the ruler always lands a marker exactly under that tick,
// regardless of how the waves layer below happens to size itself.
function rulerRect() {
return rulerTime?.getBoundingClientRect() || { left: 0, width: 1 };
}
function loopOverlayParent() {
return document.querySelector(".waves-column") || rulerTime;
}
function ensureLoopRegionParent() {
const parent = loopOverlayParent();
if (parent && loopRegionEl.parentElement !== parent) {
parent.appendChild(loopRegionEl);
}
}
function timeFromClientX(clientX) {
if (!totalDuration) return null;
const rect = rulerRect();
const x = clientX - rect.left;
const frac = Math.max(0, Math.min(1, x / Math.max(1, rect.width)));
return frac * totalDuration;
}
function setPlayheadTime(sec) {
const tx = audioEngine ?? multitrack;
if (!tx || !totalDuration) return;
const next = Math.max(0, Math.min(totalDuration, sec));
tx.setTime(next);
updatePlayheadMarker(next);
updateFooterTimes(next);
updatePresencePlayhead(next);
}
export function buildRuler(durationSec) {
rulerTime.innerHTML = "";
wavesGrid.innerHTML = "";
const marker = document.createElement("div");
marker.className = "playhead-marker";
marker.setAttribute("aria-hidden", "true");
marker.innerHTML =
'<svg viewBox="0 0 10 10" width="10" height="10"><polygon points="0,0 10,0 5,8" fill="#e54e4e"></polygon></svg>';
rulerTime.appendChild(marker);
if (!durationSec || durationSec <= 0) return;
const step = durationSec < 90 ? 15 : durationSec < 300 ? 30 : 60;
for (let t = 0; t <= durationSec; t += step) {
const leftPct = (t / durationSec) * 100;
const tick = document.createElement("div");
tick.className = "tick";
tick.style.left = `${leftPct}%`;
tick.innerHTML = `<span class="tick-label">${fmtTickLabel(t)}</span>`;
rulerTime.appendChild(tick);
const grid = document.createElement("div");
grid.className = "grid-line";
grid.style.left = `${leftPct}%`;
wavesGrid.appendChild(grid);
}
}
export function updatePlayheadMarker(currentSec) {
if (!playheadMarker || !totalDuration) return;
const m = rulerTime.querySelector(".playhead-marker");
if (m) {
// Position relative to the ruler itself (the marker is a ruler
// child) so the playhead always sits exactly under the tick at
// the matching time. Use percent instead of px: app-level CSS
// zoom scales getBoundingClientRect() values, while left/width
// styles are interpreted in unzoomed layout pixels.
const pct = Math.max(0, Math.min(100, (currentSec / totalDuration) * 100));
m.style.left = `${pct}%`;
}
}
// Mirror the elapsed/total time into the transport-footer's two side
// labels (which used to show hardcoded "00:00.000" / "03:38.000") and
// drive the small scrub bar in the now-playing card. Driven from the
// same wavesurfer "timeupdate" event that already updates #t-time, so
// every label stays in sync without extra event plumbing.
export function updateFooterTimes(currentSec) {
if (!totalDuration) return;
if (footerTimeElapsed) footerTimeElapsed.textContent = fmtTime(currentSec);
if (footerTimeTotal) footerTimeTotal.textContent = fmtTime(totalDuration);
const pct = Math.max(0, Math.min(100, (currentSec / totalDuration) * 100));
if (npScrubFill) npScrubFill.style.width = `${pct}%`;
footerWaveDrawFn?.(pct / 100);
}
// Build the presence-panel ruler labels from the actual track duration.
// The HTML ships 8 placeholder <b> tags ("0:00 ... 3:38"); we replace
// each label's text with a tick at evenly-spaced fractions of the song.
export function buildPresenceRuler(durationSec) {
if (!presenceRulerEl) return;
const ticks = presenceRulerEl.querySelectorAll("b");
if (!ticks.length) return;
if (!durationSec || durationSec <= 0) {
for (const t of ticks) t.textContent = "0:00";
return;
}
// 8 ticks -- evenly distribute from 0 to duration.
const n = ticks.length;
for (let i = 0; i < n; i++) {
const frac = i / (n - 1);
ticks[i].textContent = fmtTickLabel(frac * durationSec);
}
}
// Move the gold playhead line that overlays the presence-bars panel.
// Uses left% within the .presence-bars container, which spans the full
// duration -- matches the ruler ticks above it.
export function updatePresencePlayhead(currentSec) {
if (!presencePlayheadEl) return;
if (!totalDuration || totalDuration <= 0) {
presencePlayheadEl.classList.add("hidden");
return;
}
const pct = Math.max(0, Math.min(100, (currentSec / totalDuration) * 100));
presencePlayheadEl.style.left = `${pct}%`;
presencePlayheadEl.classList.remove("hidden");
}
export function updateLoopRegionVisual() {
const regionItem = document.getElementById("t-export-region");
const hasRegion = loopEnabled && totalDuration > 0 && loopEnd > loopStart;
if (regionItem) regionItem.setAttribute("aria-disabled", String(!hasRegion));
// Keep the engine's loop bounds in sync with every loop change (toggle/drag);
// the engine wraps playback itself off these values. No-op on streaming path.
audioEngine?.setLoop(loopEnabled, loopStart, loopEnd);
// Mirror the bounds into the exact-loop text fields (skips fields being edited).
syncLoopInputs();
if (!loopEnabled || !totalDuration) {
loopRegionEl.classList.add("hidden");
return;
}
ensureLoopRegionParent();
// Keep the loop overlay in the same normalized timeline coordinate
// system as the ruler ticks. Percentages avoid CSS zoom mismatch:
// pointer coordinates and getBoundingClientRect() are visual pixels,
// but style.left/style.width in px are unzoomed layout pixels.
const startPct = Math.max(0, Math.min(100, (loopStart / totalDuration) * 100));
const endPct = Math.max(0, Math.min(100, (loopEnd / totalDuration) * 100));
loopRegionEl.style.left = `${startPct}%`;
loopRegionEl.style.width = `${Math.max(0, endPct - startPct)}%`;
loopRegionEl.classList.remove("hidden");
}
// Keep the exact-loop text fields in sync with loopStart/loopEnd after any
// programmatic change (drag, toggle). Never overwrite a field the user is
// actively editing, and disable both when no track is loaded.
function syncLoopInputs() {
const enabled = totalDuration > 0;
for (const [input, value] of [
[loopStartInput, loopStart],
[loopEndInput, loopEnd],
]) {
if (!input) continue;
input.disabled = !enabled;
if (document.activeElement !== input) input.value = fmtTimeMs(value);
}
}
// Commit a typed loop time. Invalid/out-of-range input reverts the field to the
// current stored value (self-evident rejection) rather than raising an error;
// showError lives in the import form and would surface in the wrong place.
function commitLoopInput(which) {
const input = which === "start" ? loopStartInput : loopEndInput;
if (!input) return;
const revert = () => {
input.value = fmtTimeMs(which === "start" ? loopStart : loopEnd);
};
const parsed = parseTimecode(input.value);
if (parsed === null || totalDuration <= 0) {
revert();
return;
}
const v = Math.max(0, Math.min(totalDuration, parsed));
const start = which === "start" ? v : loopStart;
const end = which === "end" ? v : loopEnd;
if (end - start < MIN_LOOP_SEC) {
revert();
return;
}
setLoopStart(start);
setLoopEnd(end);
setLoopEnabled(true);
loopBtn.classList.add("active");
updateLoopRegionVisual();
}
function wireLoopInputs() {
for (const [input, which] of [
[loopStartInput, "start"],
[loopEndInput, "end"],
]) {
if (!input) continue;
input.addEventListener("blur", () => commitLoopInput(which));
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
input.blur();
} else if (e.key === "Escape") {
e.preventDefault();
input.value = fmtTimeMs(which === "start" ? loopStart : loopEnd);
input.blur();
}
});
}
syncLoopInputs();
}
// Standard DAW transport state machine:
// [stopped] (paused at start) ─Play→ [playing]
// ↑ ↓ Play
// Stop [paused] (paused mid-track)
// Stop ↓
// [stopped]
//
// Play button is a Play/Pause toggle. Stop both pauses and returns the
// playhead to 0 (or loopStart if loop is on). Visual state is driven
// from the multitrack lifecycle events in player.js (mt.on play/pause/
// timeupdate) — click handlers only mutate the transport, never the
// button's CSS class. That way manual seeks (e.g. clicking the ruler)
// keep the button states in sync without extra plumbing.
// WKWebView (Tauri desktop) has small audio buffers. After a seek, all
// audio elements drop their buffers and issue new range requests simultaneously.
// Calling play() before they reach HAVE_FUTURE_DATA (readyState >= 3) causes
// choppiness. Wait for all elements to be ready, with a hard 1.5 s fallback so
// the user is never stuck. Desktop browsers buffer aggressively enough that
// this wait is skipped entirely (readyState is already >= 3 by the time play
// is pressed after a seek).
function _playWhenReady() {
if (!multitrack) return;
const inTauri = Boolean(window.__TAURI__?.core?.invoke);
if (!inTauri) { multitrack.play(); return; }
const audios = (multitrack.audios ?? [])
.filter((a) => a instanceof HTMLMediaElement && a.src);
const notReady = audios.filter((a) => a.readyState < 3);
if (!notReady.length) { multitrack.play(); return; }
let fired = false;
const fire = () => { if (!fired && multitrack && !multitrack.isPlaying()) { fired = true; multitrack.play(); } };
const waits = notReady.map((a) => new Promise((res) => {
if (a.readyState >= 3) { res(); return; }
const onReady = () => { a.removeEventListener("canplay", onReady); res(); };
a.addEventListener("canplay", onReady);
}));
Promise.all(waits).then(fire);
window.setTimeout(fire, 1500);
}
export function togglePlayPause() {
const eng = audioEngine;
const tx = eng ?? multitrack;
if (!tx) return;
if (tx.isPlaying()) {
tx.pause();
// The engine emits no play/pause events (the multitrack stays silent), so
// the play-button visual that the ws "pause" handler normally toggles must
// be driven here directly.
if (eng) playBtn.classList.remove("playing");
return;
}
const ctx = tx.audioContext;
// Safari requires play() to be called synchronously within the user-gesture
// handler. Resume the AudioContext fire-and-forget so the context becomes
// live, then call play() immediately on the same tick.
if (ctx && ctx.state === "suspended") {
ctx.resume().catch(() => {});
}
// Snap playhead to loopStart on play (DAW convention).
if (loopEnabled && totalDuration > 0) {
tx.setTime(loopStart);
}
if (eng) {
eng.play();
playBtn.classList.add("playing");
stopBtn.classList.remove("stopped");
} else {
_playWhenReady();
}
}
export function stopTransport() {
const eng = audioEngine;
const tx = eng ?? multitrack;
if (!tx) return;
tx.pause();
tx.setTime(loopEnabled ? loopStart : 0); // engine: setTime → onTime → stop visual
if (eng) playBtn.classList.remove("playing");
}
export function toggleLoop() {
setLoopEnabled(!loopEnabled);
loopBtn.classList.toggle("active", loopEnabled);
updateLoopRegionVisual();
}
// Click-drag on the timeline ruler or waveform body to define the loop
// region. Drag direction doesn't matter -- start and end get sorted.
// Tiny drags are treated as clicks and seek the playhead instead.
function wireLoopDrag() {
let dragging = false;
let dragStartTime = 0;
let activePointerId = null;
let moved = false;
const startDrag = (e, surface) => {
if (e.button !== 0 || e.target.closest(".loop-region")) return;
const t = timeFromClientX(e.clientX);
if (t === null) return;
dragging = true;
activePointerId = e.pointerId;
moved = false;
dragStartTime = t;
setLoopStart(t);
setLoopEnd(t);
setLoopEnabled(true);
loopBtn.classList.add("active");
updateLoopRegionVisual();
surface.setPointerCapture(e.pointerId);
e.preventDefault();
};
const moveDrag = (e) => {
if (!dragging || e.pointerId !== activePointerId) return;
const t = timeFromClientX(e.clientX);
if (t === null) return;
if (Math.abs(t - dragStartTime) >= MIN_LOOP_SEC) moved = true;
if (t < dragStartTime) {
setLoopStart(t);
setLoopEnd(dragStartTime);
} else {
setLoopStart(dragStartTime);
setLoopEnd(t);
}
updateLoopRegionVisual();
};
const finishDrag = (e) => {
if (!dragging || e.pointerId !== activePointerId) return;
dragging = false;
activePointerId = null;
const clicked = !moved || loopEnd - loopStart < MIN_LOOP_SEC;
if (clicked) {
setLoopEnabled(false);
loopBtn.classList.remove("active");
updateLoopRegionVisual();
setPlayheadTime(dragStartTime);
}
};
const wavesColumn = document.querySelector(".waves-column");
const surfaces = [rulerTime, wavesColumn].filter(Boolean);
for (const surface of surfaces) {
surface.addEventListener("pointerdown", (e) => {
if (surface === rulerTime && e.target !== rulerTime) return;
startDrag(e, surface);
});
surface.addEventListener("pointermove", moveDrag);
surface.addEventListener("pointerup", finishDrag);
surface.addEventListener("pointercancel", finishDrag);
}
}
// ─── Zoom ───
//
// Single CSS variable `--zoom` on .wave-canvas drives the visual width
// (canvas = 100% * zoom). Multitrack's pxPerSec is set to match so its
// internal canvases stay the exact same pixel width as the canvas; that
// way the bundle never adds its own internal horizontal scroll, which
// historically broke alignment with our ruler/loop overlay.
//
// All percentage-positioned children (ruler ticks, playhead, grid lines,
// loop region) automatically stretch with the canvas, so the loop drag
// math stays correct without any per-element width logic.
// Keep the header ruler horizontally aligned with the (possibly wider, scrolled)
// waveform body. The ruler lives outside .wave-scroll, so translate it by the
// same scrollLeft; .daw-ruler-area uses overflow-x: clip to hide the spill while
// leaving the vertical playhead line (overflow-y: visible) intact.
export function syncRulerScroll() {
if (rulerTime && waveScroll) {
rulerTime.style.transform = `translateX(${-waveScroll.scrollLeft}px)`;
}
}
export function applyWaveZoom() {
const lanes = document.getElementById("lanes") || waveCanvas;
const wavesColumn = document.querySelector(".waves-column");
if (wavesColumn) {
lanes?.style.setProperty("--wave-playhead-h", `${wavesColumn.clientHeight}px`);
}
if (multitrack && totalDuration > 0 && waveScroll) {
const baseWidth = waveScroll.clientWidth;
if (baseWidth > 0) {
// Fit to the visible width, but never compress below WAVE_MIN_WIDTH.
const contentWidth = Math.max(baseWidth, WAVE_MIN_WIDTH);
const zoom = contentWidth / baseWidth;
// Widen the container via --zoom FIRST. Then, after the browser has
// reflowed it, zoom WaveSurfer to fit the container's *actual* width.
// Measuring post-reflow avoids the resize race where WaveSurfer renders
// wider than its container and exposes its own (unstyled, light) internal
// horizontal scrollbar — the only horizontal scroll must come from the
// outer .wave-scroll, which also keeps the ruler/playhead aligned.
lanes?.style.setProperty("--zoom", String(zoom));
requestAnimationFrame(() => {
if (!multitrack || totalDuration <= 0) return;
const w = multitrackContainer?.clientWidth || contentWidth;
try { multitrack.zoom(w / totalDuration); } catch { /* ignore -- pre-canplay */ }
syncRulerScroll();
});
}
}
}
function wireZoomButtons() {
if (waveScroll) {
let rafId = null;
const ro = new ResizeObserver(() => {
if (!multitrack || totalDuration <= 0) return;
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => { rafId = null; applyWaveZoom(); });
});
ro.observe(waveScroll);
}
if (waveScroll) {
waveScroll.addEventListener("wheel", (e) => {
if (waveScroll.scrollWidth <= waveScroll.clientWidth) return;
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault();
waveScroll.scrollLeft += e.deltaY;
}
}, { passive: false });
waveScroll.addEventListener("scroll", syncRulerScroll, { passive: true });
}
applyWaveZoom();
}
// Keep the mixer column and the waveform area scrolled in lockstep so stem
// controls stay aligned with their lanes when the stack overflows (#159).
function wireLaneScrollSync() {
const mixer = document.getElementById("mixer");
if (!mixer || !waveScroll) return;
// Mirror scrollTop between the two panes by assigning only when the values
// differ. The echo stops on its own (once equal, the partner's handler is a
// no-op), so no reentrancy guard / rAF is needed — that frame-delayed guard
// was what made inertial scrolling stutter (#163).
const link = (src, dst) =>
src.addEventListener("scroll", () => {
if (dst.scrollTop !== src.scrollTop) dst.scrollTop = src.scrollTop;
}, { passive: true });
link(mixer, waveScroll);
link(waveScroll, mixer);
}
// ─── Wire transport buttons ───
export function wireTransportButtons() {
playBtn.addEventListener("click", togglePlayPause);
playMiniBtn?.addEventListener("click", togglePlayPause);
stopBtn.addEventListener("click", stopTransport);
loopBtn.addEventListener("click", toggleLoop);
wireLoopDrag();
wireLoopInputs();
wireZoomButtons();
wireLaneScrollSync();
masterFader?.addEventListener("input", () => {
setMasterVolume(parseFloat(masterFader.value));
applyMix();
});
masterFader?.addEventListener("dblclick", () => {
masterFader.value = "0.5";
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 });
}
+31
View File
@@ -0,0 +1,31 @@
// Small UI-chrome handlers extracted from inline index.html scripts / onclick
// attributes so the Content-Security-Policy can forbid inline script (#171).
// Loaded as a module (deferred), so the DOM is parsed before this runs.
// Upload button → trigger the hidden file input.
document.getElementById("uploadFileBtn")?.addEventListener("click", () => {
document.getElementById("fileInput")?.click();
});
// Notification panel: toggle / close / close-on-outside-click.
const notifBtn = document.getElementById("notifBtn");
const notifWrap = notifBtn?.closest(".daw-notif-wrap");
function setNotifOpen(open) {
notifWrap?.classList.toggle("open", open);
notifBtn?.setAttribute("aria-expanded", String(open));
}
notifBtn?.addEventListener("click", () => {
setNotifOpen(!notifWrap?.classList.contains("open"));
});
document
.querySelector(".daw-notif-close")
?.addEventListener("click", () => setNotifOpen(false));
document.addEventListener("click", (e) => {
if (notifWrap?.classList.contains("open") && !notifWrap.contains(e.target)) {
setNotifOpen(false);
}
});
+135
View File
@@ -0,0 +1,135 @@
// ─── Persistent store (tauri-plugin-store via custom commands) ───
//
// Falls back to localStorage when running outside Tauri (browser dev mode).
// The store is backed by ~/Library/Application Support/app.stemdeck.desktop/user-data.json
// on macOS — outside WebKit's reach, so WebView resets can never destroy user data.
export async function storeGet(key, fallback = null) {
if (window.__TAURI__?.core?.invoke) {
try {
const val = await window.__TAURI__.core.invoke("store_get", { key });
return val ?? fallback;
} catch (e) { console.warn("[store] get failed for", key, e); return fallback; }
}
try {
const raw = localStorage.getItem(key);
return raw !== null ? JSON.parse(raw) : fallback;
} catch (e) { console.warn("[store] localStorage get failed for", key, e); return fallback; }
}
export async function storeSet(key, value) {
if (window.__TAURI__?.core?.invoke) {
try {
await window.__TAURI__.core.invoke("store_set", { key, value });
} catch (e) { console.warn("[store] set failed for", key, e); }
return;
}
try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { console.warn("[store] localStorage set failed", e); }
}
// Debounced variant — coalesces rapid writes (e.g. mixer slider moves) into
// a single store write ~300ms after the last call. Each call snapshots the
// value so no mutation races occur.
const _storePending = new Map();
export function storeSetDebounced(key, value, delayMs = 300) {
if (_storePending.has(key)) clearTimeout(_storePending.get(key));
const snapshot = structuredClone(value);
_storePending.set(key, setTimeout(() => {
_storePending.delete(key);
storeSet(key, snapshot);
}, delayMs));
}
// Keys that hold critical user data and must be migrated from localStorage
// to the store on first launch after this feature ships.
const _MIGRATE_KEYS = [
"stemdeck.folders",
"stemdeck.deleted_jobs",
"stemdeck:selected-stems",
];
// One-time bootstrap: copy localStorage → store for existing users.
// On fresh installs (no localStorage data) this is a no-op that just writes
// the migration flag so future upgrades can safely clear WebKit.
export async function runStoreMigrationIfNeeded() {
if (!window.__TAURI__?.core?.invoke) return;
try {
// Check if any critical key is absent from the store but present in localStorage.
const needs = await Promise.all(
_MIGRATE_KEYS.map(async (k) => {
const inStore = await window.__TAURI__.core.invoke("store_get", { key: k });
return inStore === null && localStorage.getItem(k) !== null;
})
).then((r) => r.some(Boolean));
if (needs) {
// Migrate fixed keys.
for (const k of _MIGRATE_KEYS) {
try {
const raw = localStorage.getItem(k);
if (raw !== null) {
await window.__TAURI__.core.invoke("store_set", { key: k, value: JSON.parse(raw) });
}
} catch (e) { console.warn("[store] migration failed for key", k, e); }
}
// Migrate per-job mix keys (stemdeck:mix:<jobId>).
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k?.startsWith("stemdeck:mix:")) {
try {
const raw = localStorage.getItem(k);
if (raw !== null) {
await window.__TAURI__.core.invoke("store_set", { key: k, value: JSON.parse(raw) });
}
} catch (e) { console.warn("[store] migration failed for key", k, e); }
}
}
}
// Write the flag regardless of whether migration was needed. This covers
// fresh installs (no localStorage data) so future upgrades clear WebKit.
await window.__TAURI__.core.invoke("mark_store_migration_done").catch((e) =>
console.warn("[store] mark_store_migration_done failed:", e)
);
} catch (e) { console.warn("[store] migration error:", e); }
}
export function fmtTime(s) {
if (!isFinite(s) || s < 0) return "00:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60).toString().padStart(2, "0");
return `${m.toString().padStart(2, "0")}:${sec}`;
}
// Ruler tick: M:SS with no leading zero on the minutes digit ("0:30", "1:00", "12:30").
export function fmtTickLabel(s) {
if (!isFinite(s) || s < 0) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60).toString().padStart(2, "0");
return `${m}:${sec}`;
}
// Millisecond-precise timecode "mm:ss.mmm" for the exact-loop inputs. Integer-ms
// math avoids a rounding carry bug (e.g. 0.9999s -> "00:01.000", not "00:00.1000").
export function fmtTimeMs(s) {
if (!isFinite(s) || s < 0) return "00:00.000";
const totalMs = Math.round(s * 1000);
const m = Math.floor(totalMs / 60000);
const sec = Math.floor((totalMs % 60000) / 1000);
const ms = totalMs % 1000;
return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}.${ms
.toString()
.padStart(3, "0")}`;
}
// Parse a user-typed loop time. Accepts "mm:ss(.mmm)" (seconds field 0-59) or a
// plain decimal-seconds value ("12.48"). Returns seconds, or null if unparseable.
export function parseTimecode(str) {
const t = String(str ?? "").trim();
const colon = /^(\d+):([0-5]?\d(?:\.\d{1,3})?)$/.exec(t);
if (colon) return parseInt(colon[1], 10) * 60 + parseFloat(colon[2]);
if (/^\d+(?:\.\d+)?$/.test(t)) return parseFloat(t);
return null;
}
export const $ = (id) => document.getElementById(id);