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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ─── 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)}`;
|
||||
}
|
||||
@@ -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 : "",
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user