From 99fc4e003d99a352e75e350b7986b03410a83e04 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 30 Sep 2026 07:23:58 +0100 Subject: [PATCH 1/7] fix(engine): keep chunk sources fed during slowed playback The lookahead gate compared _scheduledTo, in source seconds, against the output playhead. With SoundTouch stretching, sources play at 1x and the worklet buffers the surplus, so below 1x the output playhead lags what the sources have consumed. The gate overstated its margin by (1 - rate) seconds per second until the sources ran dry and the next chunk was scheduled late and skipped. At 0.75x that happened about fifty seconds in. The gate now measures against ctxTimeToSourceTime, which already follows the source rate, so it also holds in the tape-effect fallback. Reported and diagnosed by @goermezer in #701. Closes #722 --- static/js/chunkedAudioEngine.js | 8 +- tests/js/slowed-scheduling.test.mjs | 198 ++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 tests/js/slowed-scheduling.test.mjs diff --git a/static/js/chunkedAudioEngine.js b/static/js/chunkedAudioEngine.js index 4a03d45d..3438208f 100644 --- a/static/js/chunkedAudioEngine.js +++ b/static/js/chunkedAudioEngine.js @@ -603,7 +603,13 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { // loop.start when the playhead crosses it (bounded by one rAF frame). const limit = (loop.enabled && loop.end > loop.start) ? loop.end : _duration; if (_scheduledTo >= limit) return; - if (_scheduledTo - _getCurrentTime() < LOOKAHEAD_SEC) { + // Measured against the sources, not the output playhead. With SoundTouch + // stretching, sources play at 1x and the worklet buffers the surplus, so + // below 1x the output playhead falls behind what the sources have used up. + // Gating on it overstated the margin by (1 - rate) seconds every second + // until the sources ran dry and the next chunk landed late and skipped + // (#722). In the tape-effect fallback the two clocks agree. + if (_scheduledTo - ctxTimeToSourceTime(ctx.currentTime) < LOOKAHEAD_SEC) { _filling = true; _scheduleNext().finally(() => { _filling = false; }); } diff --git a/tests/js/slowed-scheduling.test.mjs b/tests/js/slowed-scheduling.test.mjs new file mode 100644 index 00000000..3dd6553c --- /dev/null +++ b/tests/js/slowed-scheduling.test.mjs @@ -0,0 +1,198 @@ +// Slowed playback must never let the chunk sources run dry (#722). +// +// With SoundTouch stretching, sources play at 1x and the worklet buffers what +// it has not yet released, so below 1x the sources are used up faster than the +// output playhead moves. The lookahead gate compared against the output +// playhead, overstated its margin by (1 - rate) seconds every second, and at +// 0.75x the sources ran dry about fifty seconds in. +// +// So this plays a two minute track against a fake clock, one tick every 100 ms, +// and checks at every tick that the scheduled sources still reach past "now". +// The bug needs minutes of playback to show, which the 6 second e2e fixture +// cannot provide, hence a node test with a synthetic WAV. + +let passed = 0; +let failed = 0; + +function check(name, condition, detail = '') { + if (condition) { + passed++; + console.log(`PASS ${name}`); + } else { + failed++; + console.log(`FAIL ${name}${detail ? ` -- ${detail}` : ''}`); + } +} + +const RATE = 8000; +const TRACK_SEC = 120; +const PLAY_SEC = 100; +const STEP_SEC = 0.1; + +class FakeParam { + constructor(value = 0) { this.value = value; } +} + +class FakeNode { + constructor(ctx, type) { + this.ctx = ctx; + this.type = type; + ctx.nodes.push(this); + } + + connect(destination) { return destination; } + disconnect() {} +} + +class FakeGain extends FakeNode { + constructor(ctx) { + super(ctx, 'gain'); + this.gain = { + value: 1, + setTargetAtTime: (value) => { this.gain.value = value; }, + }; + } +} + +class FakeAnalyser extends FakeNode { + constructor(ctx) { + super(ctx, 'analyser'); + this.fftSize = 1024; + } +} + +// Records where each source starts and how much of its buffer it plays, which +// is all the check below needs to know when the feed runs out. +class FakeSource extends FakeNode { + constructor(ctx) { + super(ctx, 'source'); + this.playbackRate = new FakeParam(1); + this.buffer = null; + } + + start(when, offset = 0) { + const played = (this.buffer.duration - offset) / this.playbackRate.value; + this.ctx.fedUntil = Math.max(this.ctx.fedUntil, when + played); + } + + stop() {} +} + +class FakeContext { + constructor({ worklet }) { + this.nodes = []; + this.currentTime = 0; + this.sampleRate = 44100; + this.state = 'running'; + this.fedUntil = 0; + this.destination = new FakeNode(this, 'destination'); + this.audioWorklet = worklet ? { addModule: async () => {} } : null; + } + + createGain() { return new FakeGain(this); } + createAnalyser() { return new FakeAnalyser(this); } + createBufferSource() { return new FakeSource(this); } + createBuffer(channels, length, sampleRate) { + const data = Array.from({ length: channels }, () => new Float32Array(length)); + return { duration: length / sampleRate, getChannelData: (channel) => data[channel] }; + } + async resume() {} + async close() {} + addEventListener() {} + removeEventListener() {} +} + +class FakeWorkletNode extends FakeNode { + constructor(ctx) { + super(ctx, 'worklet'); + this.parameters = new Map([['tempo', new FakeParam(1)]]); + this.port = { postMessage() {} }; + queueMicrotask(() => this.port.onmessage?.({ data: { type: 'latency', frames: 6541 } })); + } +} + +let pendingTick = null; +globalThis.window = { AudioContext: FakeContext }; +globalThis.AudioWorkletNode = FakeWorkletNode; +globalThis.requestAnimationFrame = (cb) => { pendingTick = cb; return 1; }; +globalThis.cancelAnimationFrame = () => { pendingTick = null; }; + +// Mono 16-bit silence. Only the length matters, and a low rate keeps it small. +function wavBytes(seconds) { + const dataBytes = seconds * RATE * 2; + const bytes = new ArrayBuffer(44 + dataBytes); + const view = new DataView(bytes); + const text = (offset, value) => { + for (let i = 0; i < value.length; i++) view.setUint8(offset + i, value.charCodeAt(i)); + }; + text(0, 'RIFF'); + view.setUint32(4, bytes.byteLength - 8, true); + text(8, 'WAVE'); + text(12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, RATE, true); + view.setUint32(28, RATE * 2, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + text(36, 'data'); + view.setUint32(40, dataBytes, true); + return new Uint8Array(bytes); +} + +const WAV = wavBytes(TRACK_SEC); +globalThis.fetch = async (_url, options = {}) => { + const match = /bytes=(\d+)-(\d+)/.exec(options.headers?.Range || ''); + const start = match ? Number(match[1]) : 0; + const end = match ? Math.min(Number(match[2]), WAV.length - 1) : WAV.length - 1; + const slice = WAV.slice(start, end + 1); + return { + ok: true, + status: match ? 206 : 200, + headers: { get: (name) => name === 'Content-Range' ? `bytes ${start}-${end}/${WAV.length}` : null }, + arrayBuffer: async () => slice.buffer, + }; +}; + +const { createChunkedAudioEngine } = await import('../../static/js/chunkedAudioEngine.js'); + +// Enough turns of the event loop for a chunk fetch and decode to settle. +const settle = async () => { + for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve)); +}; + +async function verify(label, { worklet, rate }) { + const ctx = new FakeContext({ worklet }); + const engine = createChunkedAudioEngine([{ name: 'vocals', url: '/vocals.wav' }], { context: ctx }); + check(`${label}: engine becomes ready`, await engine.ready); + check( + `${label}: runs on the expected path`, + engine.supportsPitchShift() === worklet, + ); + engine.setPlaybackRate(rate); + await engine.play(); + + let dryAt = null; + while (ctx.currentTime < PLAY_SEC) { + ctx.currentTime += STEP_SEC; + const tick = pendingTick; + pendingTick = null; + tick?.(); + await settle(); + if (dryAt === null && ctx.fedUntil < ctx.currentTime) dryAt = ctx.currentTime; + } + check( + `${label}: sources never run dry over ${PLAY_SEC} s`, + dryAt === null, + `ran dry at ${dryAt?.toFixed(1)} s`, + ); + engine.destroy(); +} + +await verify('SoundTouch at 1x', { worklet: true, rate: 1 }); +await verify('SoundTouch at 0.75x', { worklet: true, rate: 0.75 }); +await verify('tape effect at 0.75x', { worklet: false, rate: 0.75 }); + +console.log(`\n${passed}/${passed + failed} checks passed`); +process.exit(failed === 0 ? 0 : 1); From 8349df41db275b0e7c7b073863336932949ed7fe Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 30 Sep 2026 08:48:45 +0100 Subject: [PATCH 2/7] fix(engine): stop the tempo stage replaying drum attacks below 1x Below 1x each WSOLA sequence plays 70 ms of input but advances only 70 * tempo, so consecutive sequences overlap and an attack in the overlap is heard twice. At 0.75x kicks came out as a ~50 ms flam. The shared tempo stage now marks attacks on the sample-to-sample difference, and when the next sequence would replay one it carries straight on from where the last sequence ended instead. That splices identical samples, so it is seamless. The extra input is borrowed, at most 100 ms, and paid back by later sequences stretching slightly more. Only the shared tempo stage does this. The pitch stages are aligned sample for sample with the unpitched drums, and borrowing there would pull the band off the kit. Measured on a full mix at 0.75x: 93 attacks in, 104 out before, 93 out after. Closes #728 --- static/vendor/soundtouch-processor.js | 154 +++++++++++++++++++++++++- tests/js/tempo-attacks.test.mjs | 148 +++++++++++++++++++++++++ 2 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 tests/js/tempo-attacks.test.mjs diff --git a/static/vendor/soundtouch-processor.js b/static/vendor/soundtouch-processor.js index 52eb564a..689c2b21 100644 --- a/static/vendor/soundtouch-processor.js +++ b/static/vendor/soundtouch-processor.js @@ -173,6 +173,103 @@ class Biquad { // actually land, and four biquads on one bus cost nothing worth counting. const BUTTERWORTH_Q8 = [0.50979558, 0.60134489, 0.89997622, 2.56291545]; +// Attacks the tempo stage must not play twice (#728). +// +// Below 1x a sequence plays 70 ms of input but moves on by only 70 * tempo, so +// consecutive sequences overlap and whatever sits in the overlap is heard +// twice. Sustained sound hides that. A kick does not: at 0.75x it came out as a +// flam about 50 ms long. When the next sequence would replay an attack, the +// chain instead carries straight on from where the last sequence stopped, which +// splices identical samples and so is seamless, and borrows the time that costs. +// Later sequences stretch a little harder to pay it back. +// +// Nothing is split or filtered, so unlike the attack path this file once had, +// the output is still built only from whole grains of the input. +const ATTACK_FRAME_MS = 2; // energy frame the detector works in +const ATTACK_HISTORY_MS = 20; // what an attack is measured against +const ATTACK_RATIO = 4; // 6 dB above the recent level +const ATTACK_FLOOR = 1e-6; // ignore noise rising out of near silence +const ATTACK_GAP_MS = 30; // one attack per kick, not one per frame of it +// An attack is marked this far ahead of the frame that crossed the threshold, +// so the few milliseconds of rise before it count as part of the attack. +const ATTACK_LEAD_MS = 4; +// Borrowed input the chain may be ahead of its nominal clock by. The whole +// mix, click included, goes through this one stage, so borrowing moves +// everything together and the only effect is the playhead trailing the audio, +// by this at most, until it is paid back. +const MAX_BORROW_MS = 100; +// Share of a sequence's advance used to pay the loan back, so repayment is +// spread thinly rather than stretching one sequence hard. +const REPAY_SHARE = 0.25; + +/** + * Marks attacks in a sample stream, by absolute sample position. + * + * Measured on the sample-to-sample difference rather than on the signal: the + * difference is dominated by high frequencies, which is what an attack is made + * of, while a bass line or a pad barely moves it. Plain energy missed most of + * the attacks in a full mix and placed the rest 8 to 30 ms late. + * + * Positions are absolute so they survive the FIFO compacting under them. + */ +class AttackTracker { + constructor(sr) { + this.frameLen = Math.max(1, Math.round(ATTACK_FRAME_MS * sr / 1000)); + this.alpha = ATTACK_FRAME_MS / ATTACK_HISTORY_MS; + this.gap = Math.round(ATTACK_GAP_MS * sr / 1000); + this.lead = Math.round(ATTACK_LEAD_MS * sr / 1000); + this.clear(); + } + + clear() { + this.written = 0; // absolute position of the next sample pushed + this.frameAcc = 0; + this.frameFill = 0; + this.prevL = 0; + this.prevR = 0; + this.level = 0; + this.lastAttack = -Infinity; + this.attacks = []; + } + + push(l, r, n) { + for (let i = 0; i < n; i++) { + const dl = l[i] - this.prevL, dr = r[i] - this.prevR; + this.prevL = l[i]; + this.prevR = r[i]; + this.frameAcc += dl * dl + dr * dr; + if (++this.frameFill < this.frameLen) continue; + const e = this.frameAcc / this.frameLen; + const start = this.written + i + 1 - this.frameLen - this.lead; + if (e > ATTACK_RATIO * this.level && e > ATTACK_FLOOR && start - this.lastAttack >= this.gap) { + this.attacks.push(start); + this.lastAttack = start; + } + this.level += this.alpha * (e - this.level); + this.frameAcc = 0; + this.frameFill = 0; + } + this.written += n; + } + + /** True if an attack starts in [from, to), absolute positions. */ + any(from, to) { + // Sorted, and below 1x the unread backlog, and so this list, keeps growing. + for (const a of this.attacks) { + if (a >= to) return false; + if (a >= from) return true; + } + return false; + } + + /** Forget attacks the chain has consumed past. */ + drop(before) { + let k = 0; + while (k < this.attacks.length && this.attacks[k] < before) k++; + if (k) this.attacks.splice(0, k); + } +} + /** * One WSOLA time-stretch chain: stereo in, stereo out, its own buffers. * @@ -183,7 +280,13 @@ const BUTTERWORTH_Q8 = [0.50979558, 0.60134489, 0.89997622, 2.56291545]; * folding, only attenuate whatever the fold produced. */ class Wsola { - constructor(sr) { + /** + * `keepAttacks` must stay off for the pitch stages. Borrowing moves a chain's + * clock, and a pitch chain is aligned sample for sample with the unpitched + * drums beside it, so there it would pull the band away from the kit. It is + * safe on the shared tempo stage only because everything shares that clock. + */ + constructor(sr, { keepAttacks = false } = {}) { this.ovLen = Math.round(OVERLAP_MS * sr / 1000); this.seekLen = Math.round(SEEK_MS * sr / 1000); this.seqLen = Math.round(SEQUENCE_MS * sr / 1000); @@ -209,6 +312,12 @@ class Wsola { this._primed = false; this.postFilter = null; + + this._attacks = keepAttacks ? new AttackTracker(sr) : null; + this._maxBorrow = Math.round(MAX_BORROW_MS * sr / 1000); + this._head = 0; // absolute position of inL's read head + this._borrowed = 0; // input consumed ahead of the nominal clock + this._continue = false; // next sequence starts exactly where this one ended } clear() { @@ -217,11 +326,23 @@ class Wsola { this._carryL.fill(0); this._carryR.fill(0); this._advanceRem = 0; this._primed = false; + this._attacks?.clear(); + this._head = 0; + this._borrowed = 0; + this._continue = false; } push(l, r, n) { this.inL.push(l, 0, n); this.inR.push(r, 0, n); + this._attacks?.push(l, r, n); + } + + _consume(n) { + this.inL.consume(n); + this.inR.consume(n); + this._head += n; + this._attacks?.drop(this._head); } /** Run sequences until `want` output samples are queued, or input runs out. */ @@ -236,7 +357,10 @@ class Wsola { _sequence(tempo) { const ovLen = this.ovLen, midLen = this.midLen, seqLen = this.seqLen; const outLen = ovLen + midLen; - const bestOff = findBestOffset(this._carryL, ovLen, this.inL, this.seekLen); + // After carrying straight on, the carry is the very samples at the head, + // so offset 0 splices them onto themselves. A search could land elsewhere. + const bestOff = this._continue ? 0 : findBestOffset(this._carryL, ovLen, this.inL, this.seekLen); + this._continue = false; for (let i = 0; i < ovLen; i++) { const w = i / ovLen; @@ -257,14 +381,32 @@ class Wsola { this.outL.push(this._tmpL, 0, outLen); this.outR.push(this._tmpR, 0, outLen); - const exact = (seqLen - ovLen) * tempo + this._advanceRem; + const nominal = (seqLen - ovLen) * tempo; + const repay = this._attacks ? Math.min(this._borrowed, nominal * REPAY_SHARE) : 0; + const exact = nominal - repay + this._advanceRem; const whole = Math.floor(exact); + + // Input this sequence played in full ends at `played`; the carry after it + // is faded out under the next sequence's head. The next sequence starts no + // earlier than `whole`, so an attack in between would be heard twice. + if (this._attacks && tempo < 1) { + const played = bestOff + outLen; + const cost = played - nominal; + if (this._borrowed + cost <= this._maxBorrow + && this._attacks.any(this._head + whole, this._head + played + ovLen)) { + this._borrowed += cost; + this._continue = true; + this._consume(played); + return; + } + } + + this._borrowed -= repay; this._advanceRem = exact - whole; // The similarity search is local to this sequence. Folding bestOff into // the nominal advance makes content-dependent offsets accumulate into a // clock error, which is especially audible against unpitched drums. - this.inL.consume(whole); - this.inR.consume(whole); + this._consume(whole); } } @@ -459,7 +601,7 @@ class SoundTouchProcessor extends AudioWorkletProcessor { constructor() { super(); - this._tempo = new Wsola(sampleRate); + this._tempo = new Wsola(sampleRate, { keepAttacks: true }); // Silence every bus is primed with, before its own alignment pad, so they // share one clock and each chain can produce from its first block. this._commonPrime = this._tempo.needed + PRIME_CUSHION; diff --git a/tests/js/tempo-attacks.test.mjs b/tests/js/tempo-attacks.test.mjs new file mode 100644 index 00000000..cc6c1b14 --- /dev/null +++ b/tests/js/tempo-attacks.test.mjs @@ -0,0 +1,148 @@ +// Kicks must not come out doubled at slowed speeds (#728). +// +// Below 1x consecutive WSOLA sequences overlap, and an attack in the overlap is +// played twice. At 0.75x a kick came out as a flam roughly 50 ms long. The +// tempo stage now carries straight on past an attack instead of replaying it, +// and borrows the time that costs. +// +// Ground truth comes from the signal, not from a detector: a synthetic track of +// 40 kicks over a sustained chord, so the output must hold exactly 40 attacks. +// The processor is driven exactly as an AudioWorklet would drive it. +// +// Run: node tests/js/tempo-attacks.test.mjs + +import { readFileSync } from 'node:fs'; + +const SR = 44100; +const BLOCK = 128; +const TEMPO = 0.75; +const KICKS = 40; +const KICK_EVERY = 0.5; +const INPUT_COUNT = 13; +const ZERO_INPUT = 6; +// The processor's own MAX_BORROW_MS, in output time, plus a few milliseconds +// of measurement slack. +const MAX_SHIFT = 0.1 / TEMPO + 0.005; + +let passed = 0; +let failed = 0; + +function check(name, condition, detail = '') { + if (condition) { + passed++; + console.log(`PASS ${name}`); + } else { + failed++; + console.log(`FAIL ${name}${detail ? ` -- ${detail}` : ''}`); + } +} + +const SRC = readFileSync(new URL('../../static/vendor/soundtouch-processor.js', import.meta.url), 'utf8'); + +function load() { + let Cls = null; + new Function('sampleRate', 'AudioWorkletProcessor', 'registerProcessor', SRC)( + SR, + class { constructor() { this.port = { onmessage: null, postMessage() {} }; } }, + (_name, c) => { Cls = c; }, + ); + return Cls; +} + +/** A kick is a 3 kHz click and a 60 Hz thump, over an A minor chord that never stops. */ +function track() { + const n = Math.round((KICKS * KICK_EVERY + 0.5) * SR); + const x = new Float32Array(n); + for (let i = 0; i < n; i++) { + const t = i / SR; + x[i] = 0.08 * (Math.sin(2 * Math.PI * 220 * t) + Math.sin(2 * Math.PI * 261.63 * t) + Math.sin(2 * Math.PI * 329.63 * t)); + } + for (let k = 0; k < KICKS; k++) { + const at = Math.round((0.25 + k * KICK_EVERY) * SR); + for (let i = 0; i < 0.15 * SR && at + i < n; i++) { + const t = i / SR; + const click = t < 0.003 ? 0.5 * Math.sin(2 * Math.PI * 3000 * t) * (1 - t / 0.003) : 0; + x[at + i] += click + 0.6 * Math.sin(2 * Math.PI * 60 * t) * Math.exp(-t / 0.04); + } + } + return x; +} + +function stretch(x) { + const Processor = load(); + const p = new Processor(); + const total = Math.round(x.length / TEMPO) + SR; + const out = new Float32Array(Math.ceil(total / BLOCK) * BLOCK); + const params = { tempo: new Float32Array([TEMPO]) }; + for (let pos = 0; pos < out.length; pos += BLOCK) { + const block = new Float32Array(BLOCK); + if (pos < x.length) block.set(x.subarray(pos, Math.min(pos + BLOCK, x.length))); + const inputs = Array.from({ length: INPUT_COUNT }, (_, k) => (k === ZERO_INPUT ? [block, block] : [])); + const l = new Float32Array(BLOCK); + const r = new Float32Array(BLOCK); + p.process(inputs, [[l, r]], params); + out.set(l, pos); + } + return out; +} + +/** + * Attack times in seconds: peaks of the click band's 1 ms envelope above a + * third of the loudest, at least 10 ms apart. The chord is too low to reach + * it, so each peak is a click, whether first played or replayed. + */ +function attacks(y) { + const hop = Math.round(SR * 0.001); + const env = []; + for (let i = hop; i + hop <= y.length; i += hop) { + let e = 0; + for (let j = 0; j < hop; j++) { + const d = y[i + j] - y[i + j - 1]; + e += d * d; + } + env.push(e); + } + const top = Math.max(...env); + const out = []; + for (let f = 1; f + 1 < env.length; f++) { + if (env[f] < top / 3 || env[f] < env[f - 1] || env[f] < env[f + 1]) continue; + const t = (f + 1) * hop / SR; + if (out.length && t - out.at(-1) < 0.01) continue; + out.push(t); + } + return out; +} + +const input = track(); +check('the probe sees every kick in the input', attacks(input).length === KICKS, `saw ${attacks(input).length}`); + +const found = attacks(stretch(input)); +check( + `every kick comes out once at ${TEMPO}x`, + found.length === KICKS, + `heard ${found.length} attacks for ${KICKS} kicks`, +); + +// Borrowed time is paid back, so each kick lands within the loan of where an +// exact clock puts it. The first kick fixes the stage's latency. +const shifts = found.slice(0, KICKS).map((t, k) => t - found[0] - (k * KICK_EVERY) / TEMPO); +const worst = Math.max(...shifts.map(Math.abs)); +check( + 'no kick drifts further than the borrow limit', + worst <= MAX_SHIFT, + `worst ${(worst * 1000).toFixed(1)} ms`, +); +check( + 'the last kick is back on the clock', + Math.abs(shifts.at(-1)) <= MAX_SHIFT, + `${(shifts.at(-1) * 1000).toFixed(1)} ms`, +); + +// Borrowing moves a chain's clock. The pitch stages are aligned sample for +// sample with the unpitched drums, so only the shared tempo stage may do it. +const enabled = [...SRC.matchAll(/new Wsola\([^)]*keepAttacks: true/g)].length; +const tempoStage = /this\._tempo = new Wsola\(sampleRate, \{ keepAttacks: true \}\)/.test(SRC); +check('only the shared tempo stage keeps attacks', enabled === 1 && tempoStage, `enabled in ${enabled} places`); + +console.log(`\n${passed}/${passed + failed} checks passed`); +process.exit(failed === 0 ? 0 : 1); From 662f5cf83b26758feb9f79fb933188b6bbce1b04 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 30 Sep 2026 10:15:29 +0100 Subject: [PATCH 3/7] feat(engine): stretch slowed playback with Signalsmith Stretch WSOLA slows audio by repeating overlapping 82 ms fragments, heard as an echo on everything sustained. The shared tempo stage now uses the Signalsmith Stretch WASM core (MIT, vendored from signalsmith-stretch 1.3.2) at 40 ms blocks, chosen by ear against 30, 60 and 120 ms. - The core is its own worklet module, added before the processor. The processor instantiates it asynchronously and swaps it in at the next flush, never under audio already playing. If it does not load, WSOLA stays the tempo stage. - The processor reports which stage runs and its latency in two parts. Latency is the priming plus the core's input side, divided by the tempo, plus its output side; measured end to end it lands within 1 ms. Both engines take it from one shared module, tempoStage.js, instead of each hard-coding WSOLA's. - script-src gains 'wasm-unsafe-eval', in the server CSP and the Tauri CSP. It allows WebAssembly compilation only, not JS eval. The CSP test now pins script-src to exactly 'self' and 'wasm-unsafe-eval'. On 20 s of a full mix at 0.75x (55 attacks in): WSOLA 60 attacks out with 8 close pairs, Signalsmith 61 with 2. Closes #729 --- app/main.py | 6 +- desktop/src-tauri/tauri.conf.json | 2 +- static/js/audioEngine.js | 30 +++-- static/js/chunkedAudioEngine.js | 30 +++-- static/js/tempoStage.js | 58 +++++++++ static/vendor/signalsmith-stretch.js | 53 ++++++++ static/vendor/soundtouch-processor.js | 136 ++++++++++++++++++++- tests/e2e/tempo-stage.spec.mjs | 42 +++++++ tests/js/pitch-shift.test.mjs | 2 +- tests/js/signalsmith-stage.test.mjs | 166 ++++++++++++++++++++++++++ tests/js/tempo-attacks.test.mjs | 2 +- tests/test_csp.py | 10 +- 12 files changed, 499 insertions(+), 38 deletions(-) create mode 100644 static/js/tempoStage.js create mode 100644 static/vendor/signalsmith-stretch.js create mode 100644 tests/e2e/tempo-stage.spec.mjs create mode 100644 tests/js/signalsmith-stage.test.mjs diff --git a/app/main.py b/app/main.py index eb050740..2035d665 100644 --- a/app/main.py +++ b/app/main.py @@ -915,7 +915,9 @@ def download_logs_zip() -> StreamingResponse: # Content-Security-Policy. Defense-in-depth so an injected string in the webview # can't run script (and, in the desktop app, reach the exposed Tauri IPC) — #171. # script-src has no 'unsafe-inline'/'eval': all JS is same-origin modules and the -# inline scripts/onclick were moved out. 'unsafe-inline' is allowed for *styles* +# inline scripts/onclick were moved out. 'wasm-unsafe-eval' lets WebAssembly +# compile, which the Signalsmith tempo stage in the audio worklet needs (#729); +# it does not re-enable JS eval, new Function or string timers. 'unsafe-inline' is allowed for *styles* # only (the UI sets many style attributes). Allowances: # connect-src -> same-origin API/SSE, the GitHub update check, Tauri IPC, # Wikidata/Wikipedia for the artist box the now-playing card @@ -926,7 +928,7 @@ def download_logs_zip() -> StreamingResponse: # style/font -> the Google Fonts _CSP = ( "default-src 'self'; " - "script-src 'self'; " + "script-src 'self' 'wasm-unsafe-eval'; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " "font-src 'self' https://fonts.gstatic.com data:; " "img-src 'self' data: blob: https:; " diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 5604ffd7..8ad86950 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -24,7 +24,7 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" + "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" } }, "bundle": { diff --git a/static/js/audioEngine.js b/static/js/audioEngine.js index 0100ac06..d2616ed4 100644 --- a/static/js/audioEngine.js +++ b/static/js/audioEngine.js @@ -24,6 +24,7 @@ import { } from "./pitchBus.js"; import { createPlaybackContext } from "./audioContext.js"; import { createTickLoop } from "./tickLoop.js"; +import { loadStretchWorklet, pipelineLatencySeconds as stretchLatency } from "./tempoStage.js"; export function createAudioEngine(stems, { onTime, onEnded, context } = {}) { // Mobile/iOS only starts audio from a context resumed inside a user gesture. @@ -53,15 +54,22 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) { // Reported by the processor, because deriving it here would mean keeping a // copy of its buffering constants in sync by hand. let _workletLatencyFrames = 0; + // Which tempo stage the processor is running, and its latency (#729). + let _tempoStage = null; const _workletReady = (ctx.audioWorklet - ? ctx.audioWorklet.addModule('/vendor/soundtouch-processor.js').then(() => { + ? loadStretchWorklet(ctx.audioWorklet).then(() => { stNode = new AudioWorkletNode(ctx, 'soundtouch-processor', { numberOfInputs: INPUT_COUNT, numberOfOutputs: 1, outputChannelCount: [2], }); stNode.port.onmessage = (event) => { - if (event?.data?.type === 'latency') _workletLatencyFrames = event.data.frames || 0; + const data = event?.data; + if (data?.type === 'latency') _workletLatencyFrames = data.frames || 0; + else if (data?.type === 'tempoStage') _tempoStage = data; + else if (data?.type === 'tempoStageFailed') { + console.warn('[tempoStage] Signalsmith core failed to start, keeping WSOLA:', data.message); + } }; // The worklet loads asynchronously, so anything set before it arrived // would otherwise be dropped. Re-apply the current value now. @@ -187,12 +195,6 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) { // (startCtxTime > ctx.currentTime), which would otherwise make this go // negative -- the playhead must sit still at the start until the audio enters. // A no-op for a normal start, where startCtxTime == the moment play() ran. - const wsolaLatencySeconds = () => { - const needed = Math.round(0.012 * ctx.sampleRate) - + Math.round(0.028 * ctx.sampleRate) - + Math.round(0.082 * ctx.sampleRate); - return Math.floor(needed / 128) * 128 / ctx.sampleRate; - }; const anyLaneTransposed = () => { for (const t of tracks.values()) { if (t.visualOnly) continue; @@ -202,11 +204,13 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) { }; const pipelineLatencySeconds = () => { if (!stNode) return 0; - // The pitch buses only buffer once something is actually transposed. Until - // then the worklet hands its input straight back, with no delay to correct. - const pitchLatency = anyLaneTransposed() ? _workletLatencyFrames / ctx.sampleRate : 0; - const tempoStages = Math.abs(_playbackRate - 1) >= 1e-3 ? 1 : 0; - return pitchLatency + tempoStages * wsolaLatencySeconds(); + return stretchLatency({ + workletFrames: _workletLatencyFrames, + stage: _tempoStage, + rate: _playbackRate, + transposed: anyLaneTransposed(), + sampleRate: ctx.sampleRate, + }); }; const now = () => playing ? Math.max( diff --git a/static/js/chunkedAudioEngine.js b/static/js/chunkedAudioEngine.js index 3438208f..a85475b0 100644 --- a/static/js/chunkedAudioEngine.js +++ b/static/js/chunkedAudioEngine.js @@ -190,6 +190,7 @@ import { } from "./pitchBus.js"; import { createPlaybackContext } from "./audioContext.js"; import { createTickLoop } from "./tickLoop.js"; +import { loadStretchWorklet, pipelineLatencySeconds as stretchLatency } from "./tempoStage.js"; export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {}) { const AC = window.AudioContext || window.webkitAudioContext; @@ -226,15 +227,22 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { // Reported by the processor, because deriving it here would mean keeping a // copy of its buffering constants in sync by hand. let _workletLatencyFrames = 0; + // Which tempo stage the processor is running, and its latency (#729). + let _tempoStage = null; const _workletReady = (ctx.audioWorklet - ? ctx.audioWorklet.addModule('/vendor/soundtouch-processor.js').then(() => { + ? loadStretchWorklet(ctx.audioWorklet).then(() => { stNode = new AudioWorkletNode(ctx, 'soundtouch-processor', { numberOfInputs: INPUT_COUNT, numberOfOutputs: 1, outputChannelCount: [2], }); stNode.port.onmessage = (event) => { - if (event?.data?.type === 'latency') _workletLatencyFrames = event.data.frames || 0; + const data = event?.data; + if (data?.type === 'latency') _workletLatencyFrames = data.frames || 0; + else if (data?.type === 'tempoStage') _tempoStage = data; + else if (data?.type === 'tempoStageFailed') { + console.warn('[tempoStage] Signalsmith core failed to start, keeping WSOLA:', data.message); + } }; // The worklet loads asynchronously, so anything set before it arrived // would otherwise be dropped. Re-apply the current value now. @@ -335,12 +343,6 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { // schedule chunk 0 without an async await after ready() completes. const _cache = new Map(); - const _wsolaLatencySeconds = () => { - const needed = Math.round(0.012 * ctx.sampleRate) - + Math.round(0.028 * ctx.sampleRate) - + Math.round(0.082 * ctx.sampleRate); - return Math.floor(needed / 128) * 128 / ctx.sampleRate; - }; const _anyLaneTransposed = () => { for (const stem of stemMap.values()) { if (effectivePitch(stem.name, stem.pitch, stem.pitchable) !== 0) return true; @@ -349,11 +351,13 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { }; const _pipelineLatencySeconds = () => { if (!stNode) return 0; - // The pitch buses only buffer once something is actually transposed. Until - // then the worklet hands its input straight back, with no delay to correct. - const pitchLatency = _anyLaneTransposed() ? _workletLatencyFrames / ctx.sampleRate : 0; - const tempoStages = Math.abs(_playbackRate - 1) >= 1e-3 ? 1 : 0; - return pitchLatency + tempoStages * _wsolaLatencySeconds(); + return stretchLatency({ + workletFrames: _workletLatencyFrames, + stage: _tempoStage, + rate: _playbackRate, + transposed: _anyLaneTransposed(), + sampleRate: ctx.sampleRate, + }); }; // Ducking either side of a bus change. Both buses are delayed by the same diff --git a/static/js/tempoStage.js b/static/js/tempoStage.js new file mode 100644 index 00000000..43842988 --- /dev/null +++ b/static/js/tempoStage.js @@ -0,0 +1,58 @@ +// Loading the worklet, and how late its output runs, for both audio engines. +// +// Both engines drive the same processor and used to carry their own copy of +// its latency. Signalsmith Stretch (#729) made that a formula that depends on +// the tempo and on which stage loaded, and a third copy of it would drift. + +const CORE_URL = "/vendor/signalsmith-stretch.js"; +const PROCESSOR_URL = "/vendor/soundtouch-processor.js"; + +/** + * Add the Signalsmith core, then the processor. + * + * The core is optional. A browser without WebAssembly in worklets, or a CSP + * without 'wasm-unsafe-eval', fails here, and the processor then keeps WSOLA + * as its tempo stage. Only the processor failing fails the whole thing. + */ +export async function loadStretchWorklet(audioWorklet) { + try { + await audioWorklet.addModule(CORE_URL); + } catch (err) { + console.warn("[tempoStage] Signalsmith core did not load, keeping WSOLA:", err); + } + await audioWorklet.addModule(PROCESSOR_URL); +} + +// WSOLA's tempo stage, as the engines have always counted it. +function wsolaLatencySeconds(sampleRate) { + const needed = Math.round(0.012 * sampleRate) + + Math.round(0.028 * sampleRate) + + Math.round(0.082 * sampleRate); + return Math.floor(needed / 128) * 128 / sampleRate; +} + +/** + * Seconds between a source sample entering the worklet and it being heard. + * + * @param {object} p + * @param {number} p.workletFrames the processor's `latency` message: the + * priming every bus goes through once the worklet is doing anything + * @param {object|null} p.stage the processor's latest `tempoStage` message + * @param {number} p.rate playback rate + * @param {boolean} p.transposed whether any lane is off its own key + * @param {number} p.sampleRate + */ +export function pipelineLatencySeconds({ workletFrames, stage, rate, transposed, sampleRate }) { + const tempoActive = Math.abs(rate - 1) >= 1e-3; + // The pitch buses only buffer once something is actually transposed. Until + // then the worklet hands its input straight back, with no delay to correct. + const pitchSeconds = transposed ? workletFrames / sampleRate : 0; + if (!tempoActive) return pitchSeconds; + if (stage?.stage === "signalsmith") { + // The priming and the core's input side are counted in input samples, so + // they stretch with the tempo; its output side does not. Measured against + // the processor end to end, this lands within a millisecond. + return ((workletFrames + stage.inputFrames) / rate + stage.outputFrames) / sampleRate; + } + return pitchSeconds + wsolaLatencySeconds(sampleRate); +} diff --git a/static/vendor/signalsmith-stretch.js b/static/vendor/signalsmith-stretch.js new file mode 100644 index 00000000..d0460010 --- /dev/null +++ b/static/vendor/signalsmith-stretch.js @@ -0,0 +1,53 @@ +// Signalsmith Stretch 1.3.2, the WASM core only (#729). +// +// From the npm package signalsmith-stretch@1.3.2, file SignalsmithStretch.mjs, +// lines 3 to 16: the Emscripten loader with the WASM embedded. Nothing in it is +// changed. The package's own AudioWorkletNode wrapper is left out, because it +// stretches only buffers it owns and ignores the rate on live input, which is +// all StemDeck has. soundtouch-processor.js drives the core directly instead. +// +// Loaded as its own worklet module ahead of soundtouch-processor.js. Modules in +// one AudioWorkletGlobalScope share its global object, which is how the loader +// reaches the processor without an import (tests load the processor as a plain +// function body, where an import would not parse). +// +// Upstream: https://signalsmith-audio.co.uk/code/stretch/ +// +// MIT License +// +// Copyright (c) 2022 Geraint Luff / Signalsmith Audio Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +var SignalsmithStretch = (() => { + var _scriptName = typeof document != 'undefined' ? document.currentScript?.src : undefined; + + return ( +function(moduleArg = {}) { + var moduleRtn; + +var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof WorkerGlobalScope!="undefined";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";var ENVIRONMENT_IS_SHELL=!ENVIRONMENT_IS_WEB&&!ENVIRONMENT_IS_NODE&&!ENVIRONMENT_IS_WORKER;var crypto=globalThis?.crypto||{getRandomValues:array=>{for(var i=0;iDate.now()};var moduleOverrides=Object.assign({},Module);var arguments_=[];var quit_=(status,toThrow)=>{throw toThrow};var scriptDirectory="";var readAsync,readBinary;if(ENVIRONMENT_IS_SHELL){readBinary=f=>{if(typeof readbuffer=="function"){return new Uint8Array(readbuffer(f))}let data=read(f,"binary");assert(typeof data=="object");return data};readAsync=f=>new Promise((resolve,reject)=>{setTimeout(()=>resolve(readBinary(f)))});globalThis.clearTimeout??=id=>{};globalThis.setTimeout??=f=>f();arguments_=globalThis.arguments||globalThis.scriptArgs;if(typeof quit=="function"){quit_=(status,toThrow)=>{setTimeout(()=>{if(!(toThrow instanceof ExitStatus)){let toLog=toThrow;if(toThrow&&typeof toThrow=="object"&&toThrow.stack){toLog=[toThrow,toThrow.stack]}err(`exiting due to exception: ${toLog}`)}quit(status)});throw toThrow}}if(typeof print!="undefined"){globalThis.console??={};console.log=print;console.warn=console.error=globalThis.printErr??print}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}else{}var out=console.log.bind(console);var err=console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;var wasmBinary;if(typeof atob=="undefined"){if(typeof global!="undefined"&&typeof globalThis=="undefined"){globalThis=global}globalThis.atob=function(input){var keyStr="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";var output="";var chr1,chr2,chr3;var enc1,enc2,enc3,enc4;var i=0;input=input.replace(/[^A-Za-z0-9\+\/\=]/g,"");do{enc1=keyStr.indexOf(input.charAt(i++));enc2=keyStr.indexOf(input.charAt(i++));enc3=keyStr.indexOf(input.charAt(i++));enc4=keyStr.indexOf(input.charAt(i++));chr1=enc1<<2|enc2>>4;chr2=(enc2&15)<<4|enc3>>2;chr3=(enc3&3)<<6|enc4;output=output+String.fromCharCode(chr1);if(enc3!==64){output=output+String.fromCharCode(chr2)}if(enc4!==64){output=output+String.fromCharCode(chr3)}}while(ifilename.startsWith(dataURIPrefix);function findWasmBinary(){var f="data:application/octet-stream;base64,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fyAAKAIIIQMgBCgCACEFAkACQCABQQRPBEAgAyAFckEDcQ0BA0AgAygCACAFKAIARw0CIAVBBGohBSADQQRqIQMgAUEEayIBQQNLDQALCyABRQ0BCwNAIAMtAAAiCSAFLQAAIgpGBEAgBUEBaiEFIANBAWohAyABQQFrIgENAQwCCwsgCSAKawwBC0EACyIBDQBBACEBIAAoAgwiAyAEKAIEIgVGDQBBf0EBIAMgBUkbIQELIAZBEGokACABRSEDCyAEQRBqJAAgAEEgaiQAIANFBEAjAEEQayIFJABBuggQHCEEAn8gAi0AC0EHdgRAIAIoAgQMAQsgAi0AC0H/AHELIQMCfwJ/IwBBEGsiByQAIAhBBGohASADIARqIgBB9////wdNBEACQCAAQQtJBEAgAUIANwIAIAFBADYCCCABIAEtAAtBgAFxIABB/wBxcjoACyABIAEtAAtB/wBxOgALDAELIABBC08EfyAAQQhqQXhxIgYgBkEBayIGIAZBC0YbBUEKC0EBaiIGEAchCCABIAEoAghBgICAgHhxIAZB/////wdxcjYCCCABIAEoAghBgICAgHhyNgIIIAEgCDYCACABIAA2AgQLIAdBEGokACABDAELEA0ACyIALQALQQd2BEAgACgCAAwBCyAACyIAQboIIAQQGyAAIARqIgACfyACLQALQQd2BEAgAigCAAwBCyACCyADEBsgACADaiECIwBBEGsiACQAIABBADoAD0EBIQQDQCAEBEAgAiAALQAPOgAAIARBAWshBCACQQFqIQIMAQsLIABBEGokACAFQRBqJAACfyABLQALQQd2BEAgASgCAAwBC0EACxoQDgALIAhBEGokACAHLAAPQQBIBEAgBygCBBAECyMAQRBrIgIkACMAQRBrIgEkACACQQxqQQQQACIABH9B3DQgADYCAEF/BUEACyABKAIMGiABQRBqJAAEQEHcNCgCABoQDgALIAIoAgwhASACQRBqJABBkDRBASABQf////8HcCICIAJBAU0bNgIAQZw0QgA3AgBBlDRCADcCAEGoNEIANwMAQbA0QgA3AwAgB0EQaiQAQbg0QgA3AgBBwDRBADYCAEHENEIANwIAQcw0QQA2AgBB0DRCADcCAEHYNEEANgIAC+gBAQR/IwBBEGsiBiQAIwBBIGsiAyQAIwBBEGsiBCQAIAQg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";return f}var wasmBinaryFile;function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}var binary=tryParseAsDataURI(file);if(binary){return binary}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){return instantiateArrayBuffer(binaryFile,imports,callback)}function getWasmImports(){return{a:wasmImports}}function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["e"];updateMemoryViews();addOnInit(wasmExports["f"]);removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}var info=getWasmImports();wasmBinaryFile??=findWasmBinary();instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var __abort_js=()=>abort("");var __emscripten_memcpy_js=(dest,src,num)=>HEAPU8.copyWithin(dest,src,src+num);var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var abortOnCannotGrowMemory=requestedSize=>{abort("OOM")};var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){abortOnCannotGrowMemory(requestedSize)}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.5/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}abortOnCannotGrowMemory(requestedSize)};var initRandomFill=()=>{if(typeof crypto=="object"&&typeof crypto["getRandomValues"]=="function"){return view=>crypto.getRandomValues(view)}else abort("initRandomDevice")};var randomFill=view=>(randomFill=initRandomFill())(view);var _random_get=(buffer,size)=>{randomFill(HEAPU8.subarray(buffer,buffer+size));return 0};var keepRuntimeAlive=()=>true;var _proc_exit=code=>{EXITSTATUS=code;if(!keepRuntimeAlive()){ABORT=true}quit_(code,new ExitStatus(code))};var exitJS=(status,implicit)=>{EXITSTATUS=status;_proc_exit(status)};var handleException=e=>{if(e instanceof ExitStatus||e=="unwind"){return EXITSTATUS}quit_(1,e)};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):"";var wasmImports={d:__abort_js,c:__emscripten_memcpy_js,b:_emscripten_resize_heap,a:_random_get};var wasmExports=createWasm();var ___wasm_call_ctors=()=>(___wasm_call_ctors=wasmExports["f"])();var _setBuffers=Module["_setBuffers"]=(a0,a1)=>(_setBuffers=Module["_setBuffers"]=wasmExports["h"])(a0,a1);var _blockSamples=Module["_blockSamples"]=()=>(_blockSamples=Module["_blockSamples"]=wasmExports["i"])();var _intervalSamples=Module["_intervalSamples"]=()=>(_intervalSamples=Module["_intervalSamples"]=wasmExports["j"])();var _inputLatency=Module["_inputLatency"]=()=>(_inputLatency=Module["_inputLatency"]=wasmExports["k"])();var _outputLatency=Module["_outputLatency"]=()=>(_outputLatency=Module["_outputLatency"]=wasmExports["l"])();var _reset=Module["_reset"]=()=>(_reset=Module["_reset"]=wasmExports["m"])();var _presetDefault=Module["_presetDefault"]=(a0,a1)=>(_presetDefault=Module["_presetDefault"]=wasmExports["n"])(a0,a1);var _presetCheaper=Module["_presetCheaper"]=(a0,a1)=>(_presetCheaper=Module["_presetCheaper"]=wasmExports["o"])(a0,a1);var _configure=Module["_configure"]=(a0,a1,a2,a3)=>(_configure=Module["_configure"]=wasmExports["p"])(a0,a1,a2,a3);var _setTransposeFactor=Module["_setTransposeFactor"]=(a0,a1)=>(_setTransposeFactor=Module["_setTransposeFactor"]=wasmExports["q"])(a0,a1);var _setTransposeSemitones=Module["_setTransposeSemitones"]=(a0,a1)=>(_setTransposeSemitones=Module["_setTransposeSemitones"]=wasmExports["r"])(a0,a1);var _setFormantFactor=Module["_setFormantFactor"]=(a0,a1)=>(_setFormantFactor=Module["_setFormantFactor"]=wasmExports["s"])(a0,a1);var _setFormantSemitones=Module["_setFormantSemitones"]=(a0,a1)=>(_setFormantSemitones=Module["_setFormantSemitones"]=wasmExports["t"])(a0,a1);var _setFormantBase=Module["_setFormantBase"]=a0=>(_setFormantBase=Module["_setFormantBase"]=wasmExports["u"])(a0);var _seek=Module["_seek"]=(a0,a1)=>(_seek=Module["_seek"]=wasmExports["v"])(a0,a1);var _process=Module["_process"]=(a0,a1)=>(_process=Module["_process"]=wasmExports["w"])(a0,a1);var _flush=Module["_flush"]=a0=>(_flush=Module["_flush"]=wasmExports["x"])(a0);var _main=Module["_main"]=(a0,a1)=>(_main=Module["_main"]=wasmExports["y"])(a0,a1);Module["UTF8ToString"]=UTF8ToString;var calledRun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function callMain(){var entryFunction=_main;var argc=0;var argv=0;try{var ret=entryFunction(argc,argv);exitJS(ret,true);return ret}catch(e){return handleException(e)}}function run(){if(runDependencies>0){return}preRun();if(runDependencies>0){return}function doRun(){if(calledRun)return;calledRun=true;Module["calledRun"]=true;if(ABORT)return;initRuntime();preMain();readyPromiseResolve(Module);if(shouldRunNow)callMain();postRun()}{doRun()}}var shouldRunNow=true;run();moduleRtn=readyPromise; + + + return moduleRtn; +} +); +})(); + +globalThis.SignalsmithStretchCore = SignalsmithStretch; diff --git a/static/vendor/soundtouch-processor.js b/static/vendor/soundtouch-processor.js index 689c2b21..d5b4a510 100644 --- a/static/vendor/soundtouch-processor.js +++ b/static/vendor/soundtouch-processor.js @@ -410,6 +410,99 @@ class Wsola { } } +// The shared tempo stage, when the Signalsmith core loaded (#729). +// +// WSOLA slows audio down by repeating overlapping fragments of it, which is +// heard as an echo on anything sustained. Signalsmith Stretch works in the +// frequency domain and repeats nothing. 40 ms blocks were chosen by ear against +// 30, 60 and the library's 120 ms default: 120 softened a quarter of the drum +// attacks in a full mix, 30 blurred the low end. +const SIGNALSMITH_BLOCK_MS = 40; +const SIGNALSMITH_INTERVAL_MS = 10; +// Largest input one render quantum can ask for is 128 * the tempo maximum. +const SIGNALSMITH_MAX_BLOCK = 128 * 4; + +/** + * The same shape as a Wsola chain, as far as the processor uses one: push, + * fill, clear and an output FIFO pair. Anything else would mean a second code + * path through process(). + * + * The core stretches by whatever ratio it is handed, so each quantum feeds it + * 128 * tempo input samples for 128 output samples. The fraction carries over, + * so the rate is exact on average and nothing drifts against the click. + */ +class SignalsmithTempo { + constructor(core, sr) { + this._core = core; + core._main(); + core._configure( + 2, + Math.round(SIGNALSMITH_BLOCK_MS * sr / 1000), + Math.round(SIGNALSMITH_INTERVAL_MS * sr / 1000), + false, + ); + core._reset(); + this.inputLatency = core._inputLatency(); + this.outputLatency = core._outputLatency(); + this._len = SIGNALSMITH_MAX_BLOCK; + this._ptr = core._setBuffers(2, this._len); + this._heap = null; + this.inL = new FloatFifo(); + this.inR = new FloatFifo(); + this.outL = new FloatFifo(); + this.outR = new FloatFifo(); + this._carry = 0; + } + + // Views onto the core's buffers, rebuilt only if its memory was replaced. + _views() { + const buffer = this._core.HEAP8.buffer; + if (!this._heap || this._heap.buffer !== buffer) { + this._heap = new Float32Array(buffer, this._ptr, this._len * 4); + } + return this._heap; + } + + clear() { + this.inL.clear(); this.inR.clear(); + this.outL.clear(); this.outR.clear(); + this._core._reset(); + this._carry = 0; + } + + push(l, r, n) { + this.inL.push(l, 0, n); + this.inR.push(r, 0, n); + } + + fill(tempo, want) { + const block = 128; + while (this.outL.avail < want) { + const exact = block * tempo + this._carry; + const take = Math.min(Math.floor(exact), this._len); + if (this.inL.avail < take) return; + this._carry = exact - take; + const heap = this._views(); + const len = this._len; + for (let i = 0; i < take; i++) { + heap[i] = this.inL.peek(i); + heap[len + i] = this.inR.peek(i); + } + this.inL.consume(take); + this.inR.consume(take); + this._core._process(take, block); + const out = this._views(); + this.outL.push(out, 2 * len, block); + this.outR.push(out, 3 * len, block); + } + } + + /** Output samples between a sample entering and leaving, at this tempo. */ + latencyFrames(tempo) { + return this.inputLatency / tempo + this.outputLatency; + } +} + /** FIFO sample at `idx`, falling back to the carried history for idx < 0. */ function sampleAt(fifo, hist, idx) { if (idx >= 0) return fifo.peek(idx); @@ -601,10 +694,15 @@ class SoundTouchProcessor extends AudioWorkletProcessor { constructor() { super(); - this._tempo = new Wsola(sampleRate, { keepAttacks: true }); + // WSOLA stays as the tempo stage until the Signalsmith core is ready, and + // for good if it never is: a browser without WebAssembly in worklets, or a + // CSP that blocks it. Its sizes also set the priming below either way. + this._wsola = new Wsola(sampleRate, { keepAttacks: true }); + this._tempo = this._wsola; + this._signalsmith = null; // Silence every bus is primed with, before its own alignment pad, so they // share one clock and each chain can produce from its first block. - this._commonPrime = this._tempo.needed + PRIME_CUSHION; + this._commonPrime = this._wsola.needed + PRIME_CUSHION; this._chains = new Array(INPUT_COUNT).fill(null); this._idle = new Array(INPUT_COUNT).fill(0); this._unpitchedL = new FloatFifo(); @@ -629,13 +727,39 @@ class SoundTouchProcessor extends AudioWorkletProcessor { this.port.postMessage({ type: 'latency', frames: this._commonPrime - + alignmentPad(this._tempo.ovLen + this._tempo.midLen, 1), + + alignmentPad(this._wsola.ovLen + this._wsola.midLen, 1), + }); + } + this._reportTempoStage(); + + // Loaded as its own worklet module ahead of this one; see + // signalsmith-stretch.js. Instantiating is asynchronous, so the stage is + // only swapped in at the next flush, never under audio already playing. + const core = globalThis.SignalsmithStretchCore; + if (typeof core === 'function') { + core().then((module) => { + this._signalsmith = new SignalsmithTempo(module, sampleRate); + }).catch((err) => { + this.port?.postMessage({ type: 'tempoStageFailed', message: String(err?.message || err) }); }); } } + // The tempo stage's own latency, which the engine adds when tempo is not 1. + // Signalsmith's depends on the tempo, so it is sent as its two parts. + _reportTempoStage() { + if (!this.port) return; + const ss = this._tempo === this._signalsmith ? this._signalsmith : null; + this.port.postMessage({ + type: 'tempoStage', + stage: ss ? 'signalsmith' : 'wsola', + inputFrames: ss ? ss.inputLatency : 0, + outputFrames: ss ? ss.outputLatency : 0, + }); + } + _primeUnpitched() { - const outLen = this._tempo.ovLen + this._tempo.midLen; + const outLen = this._wsola.ovLen + this._wsola.midLen; const pad = new Float32Array(this._commonPrime + alignmentPad(outLen, 1)); this._unpitchedL.clear(); this._unpitchedR.clear(); @@ -644,6 +768,10 @@ class SoundTouchProcessor extends AudioWorkletProcessor { } _flush() { + if (this._signalsmith && this._tempo !== this._signalsmith) { + this._tempo = this._signalsmith; + this._reportTempoStage(); + } this._tempo.clear(); this._mixFifoL.clear(); this._mixFifoR.clear(); diff --git a/tests/e2e/tempo-stage.spec.mjs b/tests/e2e/tempo-stage.spec.mjs new file mode 100644 index 00000000..bb412935 --- /dev/null +++ b/tests/e2e/tempo-stage.spec.mjs @@ -0,0 +1,42 @@ +// The Signalsmith tempo stage loads in a real browser (#729). +// +// tests/js/signalsmith-stage.test.mjs checks the stage itself in node. What +// only a browser can show is that it gets that far at all: the core is a +// WebAssembly module compiled inside the audio worklet, which the page's CSP +// blocks unless script-src allows 'wasm-unsafe-eval'. Blocked, the processor +// quietly keeps WSOLA and every other test still passes, so this watches the +// processor say which stage it is running. + +import { test, expect } from "@playwright/test"; +import { openStudio } from "./helpers.mjs"; + +async function recordWorkletMessages(page) { + await page.addInitScript(() => { + window.__workletMessages = []; + const Native = window.AudioWorkletNode; + window.AudioWorkletNode = class extends Native { + constructor(...args) { + super(...args); + this.port.addEventListener("message", (event) => window.__workletMessages.push(event.data)); + } + }; + }); +} + +test("slowed playback runs on the Signalsmith stage", async ({ page }) => { + const failures = []; + page.on("console", (msg) => { + if (msg.type() === "warning" && msg.text().includes("[tempoStage]")) failures.push(msg.text()); + }); + await recordWorkletMessages(page); + await openStudio(page); + + await page.locator("#t-speed-075").click(); + await page.locator("#t-play").click(); + + await expect.poll( + () => page.evaluate(() => window.__workletMessages.filter((m) => m?.type === "tempoStage").at(-1)?.stage), + { timeout: 10000 }, + ).toBe("signalsmith"); + expect(failures).toEqual([]); +}); diff --git a/tests/js/pitch-shift.test.mjs b/tests/js/pitch-shift.test.mjs index 91d64423..ba47f468 100644 --- a/tests/js/pitch-shift.test.mjs +++ b/tests/js/pitch-shift.test.mjs @@ -619,7 +619,7 @@ const Proc = load(); const starved = load(SRC .replace('const PRIME_CUSHION = 512;', 'const PRIME_CUSHION = 0;') .replace('const OUTPUT_RESERVE = 512;', 'const OUTPUT_RESERVE = 0;') - .replace('this._commonPrime = this._tempo.needed + PRIME_CUSHION;', 'this._commonPrime = 0;')); + .replace('this._commonPrime = this._wsola.needed + PRIME_CUSHION;', 'this._commonPrime = 0;')); let starvedPartials = 0; for (const [pitch, tempo] of [[2, 1], [-2, 1], [5, 1], [-5, 1]]) { starvedPartials += artefacts(starved, pitch, tempo).partial; diff --git a/tests/js/signalsmith-stage.test.mjs b/tests/js/signalsmith-stage.test.mjs new file mode 100644 index 00000000..9be327dc --- /dev/null +++ b/tests/js/signalsmith-stage.test.mjs @@ -0,0 +1,166 @@ +// The Signalsmith tempo stage (#729). +// +// WSOLA slowed audio by repeating overlapping fragments, heard as an echo on +// everything sustained. The processor now stretches with Signalsmith Stretch +// when its core loads, and keeps WSOLA when it does not. Checked here, with the +// processor driven exactly as an AudioWorklet drives it: +// +// - the stage is swapped in at a flush, and reported, and only then +// - the latency the engines compute from that report is the latency measured +// - kicks come out once each and on time, so nothing drifts +// - with no core, WSOLA stays, so playback never depends on WebAssembly +// +// Run: node tests/js/signalsmith-stage.test.mjs + +import { readFileSync } from 'node:fs'; +import { pipelineLatencySeconds } from '../../static/js/tempoStage.js'; + +const BLOCK = 128; +const INPUT_COUNT = 13; +const ZERO_INPUT = 6; + +let passed = 0; +let failed = 0; + +function check(name, condition, detail = '') { + if (condition) { + passed++; + console.log(`PASS ${name}`); + } else { + failed++; + console.log(`FAIL ${name}${detail ? ` -- ${detail}` : ''}`); + } +} + +const vendor = (name) => readFileSync(new URL(`../../static/vendor/${name}`, import.meta.url), 'utf8'); +const PROCESSOR = vendor('soundtouch-processor.js'); +const CORE = vendor('signalsmith-stretch.js'); + +/** A processor, with or without the core in its global scope, and its messages. */ +async function create(sr, { core }) { + if (core) new Function(CORE)(); + else delete globalThis.SignalsmithStretchCore; + let Cls = null; + const messages = []; + new Function('sampleRate', 'AudioWorkletProcessor', 'registerProcessor', PROCESSOR)( + sr, + class { constructor() { this.port = { postMessage: (m) => messages.push(m) }; } }, + (_name, c) => { Cls = c; }, + ); + const p = new Cls(); + // Instantiating the core is asynchronous; the engine's play() resets the + // processor well after that, which is what this stands in for. + await new Promise((resolve) => setTimeout(resolve, 50)); + p.port.onmessage({ data: { type: 'reset' } }); + return { p, messages }; +} + +/** Run `input` through at `tempo` into the unpitched input; returns the left output. */ +function run(p, input, tempo, outLength) { + const out = new Float32Array(Math.ceil(outLength / BLOCK) * BLOCK); + const params = { tempo: new Float32Array([tempo]) }; + for (let pos = 0; pos < out.length; pos += BLOCK) { + const block = new Float32Array(BLOCK); + if (pos < input.length) block.set(input.subarray(pos, Math.min(pos + BLOCK, input.length))); + const inputs = Array.from({ length: INPUT_COUNT }, (_, k) => (k === ZERO_INPUT ? [block, block] : [])); + const l = new Float32Array(BLOCK); + p.process(inputs, [[l, new Float32Array(BLOCK)]], params); + out.set(l, pos); + } + return out; +} + +const lastOf = (messages, type) => messages.filter((m) => m.type === type).at(-1); + +// --- the stage is swapped in, and says so --- +{ + const { p, messages } = await create(44100, { core: true }); + run(p, new Float32Array(BLOCK), 0.75, BLOCK); + const stage = lastOf(messages, 'tempoStage'); + check('the core is swapped in at the first flush', stage?.stage === 'signalsmith', `stage ${stage?.stage}`); + check('its latency is reported in two parts', stage?.inputFrames > 0 && stage?.outputFrames > 0); +} + +// --- no core: WSOLA stays --- +{ + const { p, messages } = await create(44100, { core: false }); + run(p, new Float32Array(BLOCK), 0.75, BLOCK); + check('without the core, WSOLA stays the tempo stage', lastOf(messages, 'tempoStage')?.stage === 'wsola'); +} + +// --- latency: what the engines compute is what comes out --- +for (const sr of [44100, 48000]) { + for (const tempo of [0.75, 0.9]) { + const { p, messages } = await create(sr, { core: true }); + const at = Math.round(sr * 0.5); + const input = new Float32Array(sr); + input[at] = 1; + const out = run(p, input, tempo, sr * 2); + let peak = 0; + let peakAt = 0; + for (let i = 0; i < out.length; i++) if (Math.abs(out[i]) > peak) { peak = Math.abs(out[i]); peakAt = i; } + const measured = (peakAt - at / tempo) / sr; + const computed = pipelineLatencySeconds({ + workletFrames: lastOf(messages, 'latency').frames, + stage: lastOf(messages, 'tempoStage'), + rate: tempo, + transposed: false, + sampleRate: sr, + }); + const error = Math.abs(measured - computed) * 1000; + check( + `the computed latency matches at ${sr} Hz, ${tempo}x`, + error <= 1, + `measured ${(measured * 1000).toFixed(1)} ms, computed ${(computed * 1000).toFixed(1)} ms`, + ); + } +} + +// --- kicks come out once, and on time --- +{ + const sr = 44100; + const tempo = 0.75; + const kicks = 40; + const every = 0.5; + const n = Math.round((kicks * every + 0.5) * sr); + const x = new Float32Array(n); + for (let i = 0; i < n; i++) { + const t = i / sr; + x[i] = 0.08 * (Math.sin(2 * Math.PI * 220 * t) + Math.sin(2 * Math.PI * 261.63 * t) + Math.sin(2 * Math.PI * 329.63 * t)); + } + for (let k = 0; k < kicks; k++) { + const at = Math.round((0.25 + k * every) * sr); + for (let i = 0; i < 0.15 * sr && at + i < n; i++) { + const t = i / sr; + const click = t < 0.003 ? 0.5 * Math.sin(2 * Math.PI * 3000 * t) * (1 - t / 0.003) : 0; + x[at + i] += click + 0.6 * Math.sin(2 * Math.PI * 60 * t) * Math.exp(-t / 0.04); + } + } + const { p } = await create(sr, { core: true }); + const y = run(p, x, tempo, Math.round(n / tempo) + sr); + + // Peaks of the click band's 1 ms envelope above a third of the loudest. + const hop = Math.round(sr * 0.001); + const env = []; + for (let i = hop; i + hop <= y.length; i += hop) { + let e = 0; + for (let j = 0; j < hop; j++) e += (y[i + j] - y[i + j - 1]) ** 2; + env.push(e); + } + const top = Math.max(...env); + const found = []; + for (let f = 1; f + 1 < env.length; f++) { + if (env[f] < top / 3 || env[f] < env[f - 1] || env[f] < env[f + 1]) continue; + const t = (f + 1) * hop / sr; + if (found.length && t - found.at(-1) < 0.01) continue; + found.push(t); + } + check(`every kick comes out once at ${tempo}x`, found.length === kicks, `heard ${found.length} for ${kicks}`); + // The input advance carries its fraction, so the last kick lands where an + // exact clock puts it, not a few samples further each quantum. + const drift = found.at(-1) - found[0] - ((kicks - 1) * every) / tempo; + check('the last kick is on the clock', Math.abs(drift) <= 0.002, `${(drift * 1000).toFixed(2)} ms`); +} + +console.log(`\n${passed}/${passed + failed} checks passed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/tests/js/tempo-attacks.test.mjs b/tests/js/tempo-attacks.test.mjs index cc6c1b14..7ddcfcb2 100644 --- a/tests/js/tempo-attacks.test.mjs +++ b/tests/js/tempo-attacks.test.mjs @@ -141,7 +141,7 @@ check( // Borrowing moves a chain's clock. The pitch stages are aligned sample for // sample with the unpitched drums, so only the shared tempo stage may do it. const enabled = [...SRC.matchAll(/new Wsola\([^)]*keepAttacks: true/g)].length; -const tempoStage = /this\._tempo = new Wsola\(sampleRate, \{ keepAttacks: true \}\)/.test(SRC); +const tempoStage = /this\._wsola = new Wsola\(sampleRate, \{ keepAttacks: true \}\)/.test(SRC); check('only the shared tempo stage keeps attacks', enabled === 1 && tempoStage, `enabled in ${enabled} places`); console.log(`\n${passed}/${passed + failed} checks passed`); diff --git a/tests/test_csp.py b/tests/test_csp.py index 53833ff5..9b8e1a11 100644 --- a/tests/test_csp.py +++ b/tests/test_csp.py @@ -40,7 +40,11 @@ def test_connect_src_allows_the_artist_and_lyrics_hosts_and_nothing_wider(): def test_script_src_stays_locked(): # Lock #171's intent: loosening connect-src must not weaken the XSS defense. - script = _csp_directive("script-src") + script = _csp_directive("script-src").split()[1:] assert "'self'" in script - assert "unsafe-inline" not in script - assert "unsafe-eval" not in script + assert "'unsafe-inline'" not in script + assert "'unsafe-eval'" not in script + # 'wasm-unsafe-eval' compiles WebAssembly and nothing else: JS eval, + # new Function and string timers stay blocked. The Signalsmith tempo stage + # needs it (#729). Anything beyond it here is a loosening to justify. + assert set(script) == {"'self'", "'wasm-unsafe-eval'"} From d78fa34c69ab3e5b4e29602d55850a21bf87db59 Mon Sep 17 00:00:00 2001 From: "Tha.Les" <31625914+thcp@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:04:54 +0100 Subject: [PATCH 4/7] Fix the bugs reported against 0.19.0 (#737) * fix(backend): start on CPU when torch is installed but cannot load available_torch_devices() caught only ImportError. A torch whose DLLs fail to load raises OSError instead (WinError 127 from a CUDA DLL left behind on Windows, #723), which killed startup when the device setting was "auto" and made every GET /api/settings a 500 otherwise. The probe now catches any failure, falls back to ["cpu"], logs the cause once with its traceback, and remembers it. /api/settings carries it as torch_error, and Settings replaces the device description with a warning in red: separation cannot run on any device until torch is repaired, CPU included, so a quiet fallback would hide the real state. tests/test_torch_unloadable.py installs an import hook that makes `import torch` raise that OSError; the probe and settings tests fail on the old code. Closes #730 * fix(library): favourite a track from its library row The Now Playing heart was the only way to favourite, and daw.css hides that whole card below 1460 px. A maximised laptop window is under that, so there was no way to favourite at all, and no way to un-favourite from the Favorites view. - Library and Favorites rows get a heart next to the delete button. - Every heart goes through toggleFavorite(), which saves, repaints the Now Playing heart when it is the open track, and re-renders the list, so the hearts cannot disagree with each other or the store. - Row actions float over the row's right edge on hover or keyboard focus. In the flow the hidden delete button took about 29 px from every title at rest; a second button would have taken it again. Delete was also invisible under keyboard focus before. - A favourite shows a small heart in its subline at rest. tests/e2e/favorite-reach.spec.mjs runs at 1280 and 1600 px; all four fail on the old code. Closes #724 * fix(desktop): offer older NVIDIA cards a torch build with their kernels A CUDA 13 driver was offered cu128 alone. cu128 installs torch 2.8, whose builds start at sm_61 on Windows and sm_70 on Linux (TORCH_CUDA_ARCH_LIST in pytorch's release/2.8 .ci scripts), so a GTX 970 (sm_52) installed it, failed verification with "no kernel image is available", and dropped to CPU. - A card below compute capability 7 is never offered cu128; it gets cu124 then cu118, the torch 2.6 builds, which carry sm_50 on both platforms (release/2.6 scripts), and an sm_50 binary runs on 5.x. - A CUDA 13 driver on a newer card keeps cu128 first, with cu124 and cu118 behind it instead of CPU. New tests: a_pre_volta_card_is_never_offered_cu128 (the old code returned ["cu128"] for 5.2 under 13.0) and a_cuda_13_driver_falls_back_to_the_2_6_builds. The published-wheel check now covers caps 5.2 and 6.1. Closes #732 * fix(ui): show a track's key once, as one label that fits The analysis strip said the key three times: "D maj" on the Key card, "Major" under it, and "Major" again on a Scale card of its own, while the Key card was too narrow for a longer label. - keyLabel.js formatKey() turns the stored key and scale into one label, "B minor" or "B harmonic minor", from a translated template and mode names in all ten languages (plus the European Portuguese spelling). Note names stay letters everywhere, as DAWs write them. - The Scale card, the Major/Minor sub-line and an unused confidence label are gone; the confidence ring stays. - Key takes the Scale card's grid column, so the row keeps the seven edges the presence row lines up with. Measured at a 950 px window it still has 115 px for a label that can need 155, so the key wraps to a second line rather than clip; a short key stays on one line. - Display only: stored key, scale and key_confidence are unchanged, so nothing needs re-analysis. tests/js/key-label.test.mjs checks every language; the new tests/e2e/key-card.spec.mjs checks en, de and fr at 1366 and 950 px for the label and that it is not clipped, and fails on the old code. Closes #736 * fix(desktop): stop retrying CUDA setup on a GPU no build supports A CPU result born from a failure is re-probed on every launch (#247), which is right for a network drop. For a GPU that every offered CUDA build refuses with "no kernel image is available", it meant downloading and installing CUDA torch, failing verification and restoring CPU torch on every launch: six cycles in the #723 reporter's setup.log. - verify_cuda_torch() now says why it failed; the no-kernel case is matched on CUDA's exact wording, so driver, memory or install problems are never mistaken for it. - When every candidate installed and was refused for that reason, setup records cuda-unsupported-gpu. Any other mix keeps today's reasons. - The setup screen treats that reason as settled and says plainly why the GPU is not used. A new app version re-runs setup anyway, which is when different builds could be on offer. New tests: a_gpu_every_build_refuses_is_settled_as_unsupported and only_cudas_own_wording_counts_as_no_kernel_image. Closes #733 * fix(desktop): put the bundled torch back exactly when CUDA fails The CUDA install laid the CUDA wheels over the CPU ones, and the restore laid the CPU wheels back over those, both with --ignore-installed. Overlaying never removes files only the CUDA wheel has. On Windows torch loads every DLL in torch/lib, so the leftover c10_cuda.dll of one torch version failed to link against the restored CPU DLLs, and `import torch` raised WinError 127 for good while setup reported success (#723). New module torchswap: - Before the first real CUDA install the bundled torch is moved aside into .stemdeck-torch-backup next to site-packages. What belongs to torch is read from pip's RECORD files; on a real install that is only torch, functorch, torchgen, torchaudio, torio, torchvision and their dist-infos. - A manifest is written and synced before anything moves, so a crash at any point is recoverable; the next setup run restores it first. - Each candidate installs into clean directories; a failure deletes the CUDA build and renames the original back, with no network. Success deletes the backup. After any restore `import torch` is now checked. Without a backup (the move failed, or the install broke before this fix) the CPU wheels are reinstalled, and if torch still does not import, everything torch owns is deleted and they are installed once more into clean directories. That is what repairs an install already carrying the stray DLL. Whether a working CUDA build is already present is now asked once, before anything moves, instead of inside each install. Eight new tests on real temp directories, including the reporter's case (no c10_cuda.dll after rollback, c10.dll byte for byte) and a snapshot interrupted part way. Closes #731 * fix(analyze): stop reporting minor keys as their relative major Key detection multiplied each key's profile correlation by how loud its root was. In a minor song the relative major's root is the minor chord's third and sits in most of a typical loop's chords, so it is usually louder: every i-VI-III-VII loop came out as its relative major at up to 100% confidence ("Plug in Baby", B minor, read D major). Harmonic minor was never reported, and confidence changed with the input's level. - Keys are ranked by correlation alone, which is level-invariant. - The keys close to the best (within 0.1) and their relatives are then weighed against the song's edges: how well the first and, when the whole song was analysed, last 4 s of music fit each key's tonic triad and bass note. A song starts and ends on its tonic far more often than anywhere else, which is what a whole-song histogram cannot show. - A minor key is reported as harmonic minor when its raised seventh outweighs the flat seventh 1.3 to 1. - After separation the key is detected again from the stems: harmony stems without drums, the bass stem as the bass line, and the whole song. Any failure keeps the estimate from the mix. - Chroma uses 93 ms frames: 4.1 s instead of 8.6 s on a 9.5 min track's stems, same score. The stems pass takes about 1 s per minute of audio. Measured on tests/keyset.py, 33 synthetic songs with known keys (chord loops over a bass line, 11 progressions in 3 keys), key and scale both right: before 21 of 33 (27 keys right) after 30 of 33 from the mix, 30 of 33 from stems The three misses are vi-IV-I-V, which starts and ends on its minor chord. A real 9.5 min track (E minor) reads the same before and after. Already analysed tracks keep their stored key; nothing is re-analysed. tests/test_key_detection.py scores the set and the reporter's loop, and fails on the old detector. Closes #726 * fix(pipeline): count the stems key pass inside post A separate "key" stage timing broke the timings' order, which tests/test_identify_regressions.py holds as a contract: new entries may only follow the existing stages. The pass is short and already inside post, so it is counted there. Refs #726 * test(desktop): check the torch rollback on a real torch install An ignored test, run by hand: copies a real site-packages torch (1.2 GB here), moves it aside, lays a CUDA build with a stray c10_cuda.dll into its place, rolls back, and compares every file's SHA-256 with the original. Passed against the dev venv's torch 2.6.0+cpu, which then imported and ran a tensor op from the restored copy. Refs #731 --------- Co-authored-by: Thales <> --- app/core/config.py | 25 ++ app/main.py | 4 + app/pipeline/analyze.py | 352 +++++++++++++++++++---- app/pipeline/runner.py | 8 +- desktop/src-tauri/src/main.rs | 345 ++++++++++++++++++++-- desktop/src-tauri/src/torchswap.rs | 444 +++++++++++++++++++++++++++++ desktop/ui/setup.js | 15 +- static/css/daw.css | 51 +++- static/index.html | 9 - static/js/catalog.js | 89 ++++-- static/js/i18n.js | 101 ++++++- static/js/job.js | 9 +- static/js/keyLabel.js | 35 +++ static/js/player.js | 4 - tests/e2e/favorite-reach.spec.mjs | 74 +++++ tests/e2e/key-card.spec.mjs | 64 +++++ tests/js/key-label.test.mjs | 61 ++++ tests/keyset.py | 106 +++++++ tests/test_key_detection.py | 156 ++++++++++ tests/test_torch_unloadable.py | 87 ++++++ 20 files changed, 1896 insertions(+), 143 deletions(-) create mode 100644 desktop/src-tauri/src/torchswap.rs create mode 100644 static/js/keyLabel.js create mode 100644 tests/e2e/favorite-reach.spec.mjs create mode 100644 tests/e2e/key-card.spec.mjs create mode 100644 tests/js/key-label.test.mjs create mode 100644 tests/keyset.py create mode 100644 tests/test_key_detection.py create mode 100644 tests/test_torch_unloadable.py diff --git a/app/core/config.py b/app/core/config.py index 104f67cb..63ebf7c4 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -28,11 +28,23 @@ def _env_path_opt(name: str) -> Path | None: return Path(raw).expanduser().resolve() if raw else None +# Why torch could not be loaded at the last probe, or None if it loaded (#730). +# Refreshed on every probe rather than decided once, so a repaired install +# clears it without a restart. +_torch_load_error: str | None = None + + +def torch_load_error() -> str | None: + """The last probe's reason torch is installed but unusable, if any.""" + return _torch_load_error + + def available_torch_devices() -> list[str]: """Compute devices this machine can actually use, best-first. CPU is always present; cuda/mps depend on the hardware + installed torch build. The Settings UI uses this to disable options that aren't available/detected so a user can't pick an impossible device.""" + global _torch_load_error devices: list[str] = [] try: import torch @@ -41,8 +53,21 @@ def available_torch_devices() -> list[str]: devices.append("cuda") if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available(): devices.append("mps") + _torch_load_error = None except ImportError: pass + except Exception as exc: # noqa: BLE001 -- any failure here must not stop the server + # Installed but unloadable: on Windows a CUDA DLL left behind by a + # half-reverted install raises OSError (WinError 127) from torch's own + # DLL loader (#723). Catching only ImportError let that kill startup + # and every /api/settings call. The server has to come up so it can + # say what is wrong. Logged loudly once per distinct cause, because + # separation cannot run on any device until torch is repaired, CPU + # included, and a quiet fallback would hide exactly that. + reason = f"{type(exc).__name__}: {exc}" + if reason != _torch_load_error: + logger.error("torch is installed but could not be loaded: %s", reason, exc_info=True) + _torch_load_error = reason devices.append("cpu") return devices diff --git a/app/main.py b/app/main.py index 2035d665..4d5fde4f 100644 --- a/app/main.py +++ b/app/main.py @@ -39,6 +39,7 @@ available_torch_devices, configure_portable_environment, ensure_runtime_dirs, + torch_load_error, ) from app.core.logging_setup import configure_logging from app.core.process import process_exists as _process_exists @@ -393,6 +394,9 @@ def _settings_payload() -> dict[str, object]: "demucs_device": get_demucs_device_choice(), "demucs_device_resolved": get_demucs_device(), "demucs_devices_available": available_torch_devices(), + # Set when torch is installed but cannot load, which stops separation + # on every device (#730). Read after the probe above refreshed it. + "torch_error": torch_load_error(), } diff --git a/app/pipeline/analyze.py b/app/pipeline/analyze.py index 60d60c13..2acbb3f1 100644 --- a/app/pipeline/analyze.py +++ b/app/pipeline/analyze.py @@ -54,6 +54,34 @@ # and minor is the better default when the call is genuinely ambiguous. _MINOR_TIE_BREAK_FRAC = 0.05 +# How much the song's edges count against the correlation when choosing +# between close candidates, a key and its relative above all. Tuned on +# tests/keyset.py: the reporter's +# i-VI-III-VII leads its relative major by up to 0.22 in correlation and +# trails it by about 0.8 in edge fit, and in every song detected correctly the +# edges agree with the correlation, so weighing them in never flips those. +_EDGE_WEIGHT = 0.4 +# Keys this close to the best correlation, and their relatives, are all +# candidates when the edges are weighed in. A loop like i-VII-VI-VII puts the +# VII's own key ahead of the tonic's by a hair, and neither is the other's +# relative. +_FAMILY_WINDOW = 0.1 +# Without edges, a relative pair this close in correlation counts as a tie. +_RELATIVE_MARGIN = 0.15 +# Harmonic minor is reported when the raised seventh outweighs the flat one by +# this much and reaches this share of the loudest pitch class. On +# tests/keyset.py the ratio is at most 0.77 for natural minor and at least 1.54 +# for harmonic minor, on the mix and on stems; the floor only keeps two +# near-silent pitch classes from deciding it. +_HARMONIC_RATIO = 1.3 +_HARMONIC_FLOOR = 0.1 +# A correlation gap this wide over the nearest unrelated key is full +# confidence; an edge margin this wide settles a relative pair outright. +_CONFIDENCE_FULL_GAP = 0.25 +_EDGE_CONFIDENT_MARGIN = 0.3 +# How much of the start and end of the music counts as its edges. +_EDGE_SECONDS = 4.0 + def _correlate(profile: tuple[float, ...], chroma: list[float], shift: int) -> float: n = len(profile) @@ -68,66 +96,131 @@ def _correlate(profile: tuple[float, ...], chroma: list[float], shift: int) -> f return num / (denom_p * denom_c) -def _detect_key(chroma_mean: list[float]) -> tuple[str, str, int]: - """Find the best-matching key by combining profile correlation with - root prominence. The Pearson correlation alone is fooled by relative - keys whose diatonic notes happen to overlap with the song's loud - pitches but whose own tonic is weak (e.g. picking A minor for an - E-minor song because E is its 5th and D is its 4th). Weighting by - the candidate root's chroma value forces the algorithm to also - confirm 'is this proposed tonic actually loud in the audio?'. - Logs the chroma vector and top-5 candidates for diagnostics. +def _key_candidates(chroma_mean: list[float]) -> list[tuple[float, int, str]]: + """Every key's Pearson correlation with its profile, best first, as + (correlation, root, "maj" | "min").""" + out = [] + for shift in range(12): + out.append((_correlate(_MAJOR_PROFILE, chroma_mean, shift), shift, "maj")) + out.append((_correlate(_MINOR_PROFILE, chroma_mean, shift), shift, "min")) + out.sort(key=lambda c: c[0], reverse=True) + return out + + +def _relative(root: int, mode: str) -> tuple[int, str]: + return ((root + 9) % 12, "min") if mode == "maj" else ((root + 3) % 12, "maj") + + +def _tonic_fit(root: int, mode: str, edges: list[tuple[list[float], list[float]]]) -> float: + """How much the song's edges sound like this key's home chord. + + Each edge is (chroma, bass chroma) averaged over a few seconds at the start + or the end of the music, each scaled to a maximum of 1. A song starts and + ends on its tonic far more often than on any other chord, and that is the + one thing a whole-song histogram cannot show: a minor loop such as + i-VI-III-VII uses exactly its relative major's notes (#726). + """ + triad = {root, (root + (4 if mode == "maj" else 3)) % 12, (root + 7) % 12} + score = 0.0 + for harmony, bass in edges: + inside = sum(harmony[i] for i in triad) / 3 + outside = sum(harmony[i] for i in range(12) if i not in triad) / 9 + score += inside - outside + bass[root] + return score / len(edges) + + +def _detect_key( + chroma_mean: list[float], + edges: list[tuple[list[float], list[float]]] | None = None, +) -> tuple[str, str, int]: + """Find the key: profile correlation narrows it to a few close candidates, + and the song's edges decide between them, a key and its relative above all. + + It used to multiply each key's correlation by how loud its root was. In a + minor song the relative major's root is the minor chord's third, and is + also in two of the other three chords of a typical loop, so it is usually + the louder one: every i-VI-III-VII progression came out as its relative + major, at up to 100% confidence (#726). Correlation alone is scale + invariant and does not have that pull; what it cannot do is tell relative + keys apart, which is the edges' job. + + `edges` is optional: without it a near-tie between a key and its relative + falls back on the pop/rock minor prior. Returns (label, scale_name, confidence_pct). - - label: e.g. "G# maj" - - scale_name: "Major" or "Natural Minor" - - confidence_pct: 0-100, derived from the gap between the winning - candidate and the runner-up, normalized so a clear - win ranks high and a near-tie ranks low.""" - raw: list[tuple[float, float, str, int]] = [] # (weighted, pearson, label, root_idx) - for shift in range(12): - root_strength = chroma_mean[shift] - pearson_maj = _correlate(_MAJOR_PROFILE, chroma_mean, shift) - pearson_min = _correlate(_MINOR_PROFILE, chroma_mean, shift) - # Multiplicative root weighting. Pearson can be negative; when - # it is, a low-chroma root makes things less negative (closer to - # zero), which is actually the desired ordering. - raw.append((pearson_maj * root_strength, pearson_maj, f"{_PITCHES[shift]} maj", shift)) - raw.append((pearson_min * root_strength, pearson_min, f"{_PITCHES[shift]} min", shift)) - raw.sort(key=lambda x: x[0], reverse=True) - - # Diagnostic log: chroma profile + top 5 candidates with both raw - # and weighted scores. Lets us see what the algorithm is "hearing". + - label: e.g. "G# maj" + - scale_name: "Major", "Natural Minor" or "Harmonic Minor" + - confidence_pct: 0-100 + """ + candidates = _key_candidates(chroma_mean) + best = candidates[0] + rel_root, rel_mode = _relative(best[1], best[2]) + rel = next(c for c in candidates if c[1] == rel_root and c[2] == rel_mode) + chroma_str = ", ".join(f"{_PITCHES[i]}={chroma_mean[i]:.3f}" for i in range(12)) - top5_str = ", ".join( - f"{label}={weighted:+.3f}(p{pearson:+.2f}*r{chroma_mean[idx]:.2f})" - for weighted, pearson, label, idx in raw[:5] - ) + top5_str = ", ".join(f"{_PITCHES[r]} {m}={p:+.3f}" for p, r, m in candidates[:5]) logger.debug("chroma: %s", chroma_str) logger.debug("key candidates (top 5): %s", top5_str) - # Pick best major and best minor for the tie-break, both by the - # weighted score. - best_maj = next(c for c in raw if c[2].endswith("maj")) - best_min = next(c for c in raw if c[2].endswith("min")) - - gap = abs(best_maj[0] - best_min[0]) - threshold = max(abs(best_maj[0]), abs(best_min[0])) * _MINOR_TIE_BREAK_FRAC - # Near-tie -> prefer minor (pop/rock prior); clear winner -> use it. - winner = (best_maj if best_maj[0] > best_min[0] else best_min) if gap > threshold else best_min - - # Confidence: gap between the winner and the runner-up that *isn't* - # the relative major/minor of the winner (those will always be near- - # ties with the algorithm's profile-correlation approach, so they - # tell us nothing about real ambiguity). Normalize so a healthy 0.15 - # gap = 100% confident; tiny gap = 0%. - runner_up = next(c for c in raw if c[2] != winner[2]) - confidence_score = winner[0] - runner_up[0] - confidence_pct = max(0, min(100, round(confidence_score / 0.15 * 100))) - - label = winner[2] - scale_name = "Major" if label.endswith("maj") else "Natural Minor" - return label, scale_name, confidence_pct + winner = best + # How clearly the winner was separated from its closest rival, 0-1. + clarity = 1.0 + if edges: + by_key = {(c[1], c[2]): c for c in candidates} + pool = set() + for c in candidates: + if best[0] - c[0] <= _FAMILY_WINDOW: + pool.add((c[1], c[2])) + pool.add(_relative(c[1], c[2])) + scored = sorted( + ( + (by_key[k][0] + _EDGE_WEIGHT * _tonic_fit(k[0], k[1], edges), by_key[k]) + for k in pool + ), + key=lambda s: s[0], + reverse=True, + ) + logger.debug( + "with edges: %s", + ", ".join(f"{_PITCHES[c[1]]} {c[2]}={score:.3f}" for score, c in scored[:5]), + ) + winner = scored[0][1] + runner = scored[1][0] if len(scored) > 1 else scored[0][0] - _EDGE_CONFIDENT_MARGIN + clarity = min(1.0, (scored[0][0] - runner) / _EDGE_CONFIDENT_MARGIN) + elif best[0] - rel[0] < _RELATIVE_MARGIN: + # Near-tie with nothing else to go on: prefer minor, the pop/rock + # prior ("Come As You Are" hammers the open D of an E minor song). + threshold = max(abs(best[0]), abs(rel[0])) * _MINOR_TIE_BREAK_FRAC + if best[0] - rel[0] <= threshold and rel[2] == "min": + winner = rel + clarity = 0.5 + + root, mode = winner[1], winner[2] + label = f"{_PITCHES[root]} {mode}" + + scale_name = "Major" + if mode == "min": + # The raised seventh is the harmonic minor's signature (a major V); + # the natural minor's is the flat seventh. Only called when the raised + # one clearly dominates and is actually sounding. + raised = chroma_mean[(root + 11) % 12] + flat = chroma_mean[(root + 10) % 12] + peak = max(chroma_mean) or 1.0 + harmonic = raised > _HARMONIC_RATIO * flat and raised > _HARMONIC_FLOOR * peak + scale_name = "Harmonic Minor" if harmonic else "Natural Minor" + + # Confidence: how clearly the key family won, against the best candidate + # that is neither the winner nor its relative (relative keys correlate + # alike by construction, so they say nothing about the family), then + # scaled by how clearly the pair itself was separated. + # With edges, the margin over the runner-up already covers both, since + # every close candidate was in the running. + if edges: + return label, scale_name, round(clarity * 100) + others = [c for c in candidates if (c[1], c[2]) not in {(best[1], best[2]), (rel[1], rel[2])}] + family_gap = best[0] - others[0][0] + confidence = min(1.0, max(0.0, family_gap / _CONFIDENCE_FULL_GAP)) * clarity + return label, scale_name, round(confidence * 100) def _measure_loudness(y: object, sr: int) -> tuple[float | None, float | None]: @@ -261,12 +354,11 @@ def analyze(job: Job, source: Path) -> tuple[int | None, str | None]: # chroma_cqt is constant-Q based — better pitch resolution than # chroma_stft, especially in the bass register where the open # strings of a guitar live. - chroma = librosa.feature.chroma_cqt(y=y_harmonic, sr=sr) - chroma_mean = chroma.mean(axis=1).tolist() - if any(chroma_mean): - key, scale, key_confidence = _detect_key(chroma_mean) - else: - key, scale, key_confidence = None, None, None + # The decode stops at 180 s, so the song's last chord is only among + # the evidence when the whole song fitted. + whole_song = y.size < 179.5 * sr + detected = detect_key_from_audio(y_harmonic, None, sr, whole_song=whole_song) + key, scale, key_confidence = detected if detected else (None, None, None) # LUFS / peak. Computed on the same 22 kHz mono buffer; this # loses a few dB of accuracy vs full-sample-rate stereo, but @@ -310,3 +402,143 @@ def analyze(job: Job, source: Path) -> tuple[int | None, str | None]: logger.exception("analyze failed for job %s", job.id) _set(job, stage="Analysis skipped") return None, None + + +def _key_evidence( + harmony: object, + bass: object | None, + sr: int, + whole_song: bool, +) -> tuple[list[float], list[tuple[list[float], list[float]]]]: + """The whole-song chroma, and the chroma and bass chroma at the edges of + the music, for _detect_key. + + `bass` is the separated bass stem when there is one. Without it, the low + three octaves of `harmony` stand in for it. The end of the song is only an + edge when `whole_song`: a decode capped part way through ends mid-song. + """ + import librosa + import numpy as np + + # 93 ms frames. Key needs no finer: measured on a 9.5 min track's stems, + # 512 took 8.6 s for both chromas and 2048 took 4.1 s, with the same + # score on tests/keyset.py. + hop = 2048 + chroma = librosa.feature.chroma_cqt(y=harmony, sr=sr, hop_length=hop) + low = bass if bass is not None else harmony + bass_chroma = librosa.feature.chroma_cqt( + y=low, sr=sr, hop_length=hop, fmin=librosa.note_to_hz("C1"), n_octaves=3 + ) + level = librosa.feature.rms(y=harmony if bass is None else harmony + bass, hop_length=hop)[0] + frames = min(chroma.shape[1], bass_chroma.shape[1], level.shape[0]) + chroma, bass_chroma, level = chroma[:, :frames], bass_chroma[:, :frames], level[:frames] + + # Music, not the silence or count-in before it: frames within 20 dB of + # the loudest. + active = np.flatnonzero(level > 0.1 * (level.max() or 1.0)) + span = max(1, int(_EDGE_SECONDS * sr / hop)) + + def region(a: int, b: int) -> tuple[list[float], list[float]]: + h = chroma[:, a:b].mean(axis=1) + lo = bass_chroma[:, a:b].mean(axis=1) + return (h / (h.max() or 1.0)).tolist(), (lo / (lo.max() or 1.0)).tolist() + + edges = [] + if active.size: + start = int(active[0]) + edges.append(region(start, start + span)) + if whole_song: + end = int(active[-1]) + 1 + edges.append(region(max(0, end - span), end)) + return chroma.mean(axis=1).tolist(), edges + + +def detect_key_from_audio( + harmony: object, bass: object | None, sr: int, whole_song: bool = True +) -> tuple[str, str, int] | None: + """Key, scale and confidence from mono audio, or None for silence.""" + chroma_mean, edges = _key_evidence(harmony, bass, sr, whole_song) + if not any(chroma_mean): + return None + return _detect_key(chroma_mean, edges) + + +# Stems that carry harmony. Vocals count for half: a melody leans on the tonic +# at phrase ends, but it also dwells on passing notes. +_HARMONY_STEMS = {"guitar": 1.0, "piano": 1.0, "other": 1.0, "vocals": 0.5} +# The stems are decoded whole up to this long, so the end of the song counts. +_STEM_KEY_MAX_SECONDS = 600.0 + + +def refine_key_from_stems(job: Job, stems_dir: Path) -> None: + """Detect the key again from the separated stems, and keep it if it works. + + The first estimate comes from the full mix before separation, so the key + shows while separation runs. The stems are better evidence: the bass line + on its own says which note is home, drums no longer smear the chroma, and + the whole song, including its last chord, is available rather than the + first 180 s. Any failure keeps the first estimate. + """ + import numpy as np + + sr = 22050 + try: + harmony = None + heard = [] + longest = 0 + for name, weight in _HARMONY_STEMS.items(): + path = stems_dir / f"{name}.wav" + if not path.is_file(): + continue + loaded = _load_audio_ffmpeg(path, sr=sr, duration=_STEM_KEY_MAX_SECONDS) + if loaded is None: + continue + y = loaded[0] * weight + longest = max(longest, y.size) + harmony = y if harmony is None else _sum_padded(harmony, y) + heard.append(name) + if harmony is None: + logger.info("key from stems: no harmony stems for job %s, keeping %s", job.id, job.key) + return + bass = None + bass_path = stems_dir / "bass.wav" + if bass_path.is_file(): + loaded = _load_audio_ffmpeg(bass_path, sr=sr, duration=_STEM_KEY_MAX_SECONDS) + if loaded is not None: + bass = loaded[0] + harmony, bass = _pad_pair(harmony, bass) + if bass is None: + logger.info( + "key from stems: no bass stem for job %s, using the harmony's low end", job.id + ) + whole = longest < (_STEM_KEY_MAX_SECONDS - 1) * sr + result = detect_key_from_audio(harmony.astype(np.float32), bass, sr, whole_song=whole) + if result is None: + return + key, scale, confidence = result + if key != job.key or scale != job.scale: + logger.info( + "key from stems (%s%s): %s %s %s%% (mix said %s %s)", + "+".join(heard), + "+bass" if bass is not None else "", + key, + scale, + confidence, + job.key, + job.scale, + ) + _set(job, key=key, scale=scale, key_confidence=confidence) + except Exception: + logger.exception("key from stems failed for job %s, keeping %s", job.id, job.key) + + +def _sum_padded(a: object, b: object) -> object: + a, b = _pad_pair(a, b) + return a + b + + +def _pad_pair(a: object, b: object) -> tuple[object, object]: + import numpy as np + + n = max(a.size, b.size) # type: ignore[attr-defined] + return np.pad(a, (0, n - a.size)), np.pad(b, (0, n - b.size)) # type: ignore[attr-defined] diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py index 83a70b0f..ab484902 100644 --- a/app/pipeline/runner.py +++ b/app/pipeline/runner.py @@ -21,7 +21,7 @@ from app.core.redact import redact from app.core.registry import is_upload, set_proc from app.core.registry import persist as persist_registry -from app.pipeline.analyze import analyze +from app.pipeline.analyze import analyze, refine_key_from_stems from app.pipeline.beatgrid import compute_beat_grid from app.pipeline.collect import ( cleanup_source, @@ -222,6 +222,12 @@ def _run_common(job: Job, source: Path, job_dir: Path) -> None: cleanup_source(job_dir) job.stems = [{"name": name, "url": f"/api/jobs/{job.id}/stems/{name}.wav"} for name in found] _check_cancel(job) + # The key was first estimated from the mix, before separation. The stems + # are better evidence, the bass line above all (#726). Keeps the first + # estimate on any failure. Counted in "post", not as a stage of its own: + # the timings' order is a contract (tests/test_identify_regressions.py). + refine_key_from_stems(job, stems_dir) + _check_cancel(job) _set(job, stage="Mixing tracks...") original_path = make_original_track(job, job_dir, stems_dir) if original_path is not None: diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index b1ad729c..442e0c0f 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1,6 +1,8 @@ mod certs; mod dragout; mod dropin; +#[cfg(any(not(target_os = "macos"), test))] +mod torchswap; use flate2::read::GzDecoder; use serde::{Deserialize, Serialize}; @@ -1945,6 +1947,27 @@ fn ensure_torch_device( #[cfg(not(target_os = "macos"))] { + let site = torch_site_packages(&python); + if site.is_none() { + append_to_setup_log( + &data_dir, + "could not locate site-packages; torch swaps overlay", + ); + } + // A swap that was interrupted (crash, forced quit, a rollback that did + // not finish) is put back before anything else looks at torch. + if let Some(site) = site.as_deref().filter(|s| torchswap::has_backup(s)) { + match torchswap::restore(site) { + Ok(()) => append_to_setup_log( + &data_dir, + "restored the bundled torch left aside by an interrupted swap", + ), + Err(e) => append_to_setup_log( + &data_dir, + &format!("could not restore an interrupted torch swap ({e})"), + ), + } + } let setup = match detect_nvidia_gpu(&data_dir) { Some((gpu_name, cuda_version, compute_cap)) => { // Try each candidate wheel in turn. A build that installs but @@ -1959,7 +1982,20 @@ fn ensure_torch_device( // reads in a bug report and sends them after the wrong thing // (#502: the install had been killed mid-download). let mut cuda_installed = false; - for tag in &candidates { + // One entry per candidate tried: None when it did not install, + // otherwise what verification said. Decides the reason below. + let mut outcomes: Vec> = Vec::new(); + // Whether the bundled torch was moved aside, so a failure can + // put exactly it back (#731). + let mut swapped = false; + // A re-run (a new app version) on a CUDA build that already + // works has nothing to install. + let already = verify_cuda_torch(&python) == CudaVerify::Verified; + if already { + append_to_setup_log(&data_dir, "CUDA torch already installed and verified"); + cuda_verified = true; + } + for tag in candidates.iter().filter(|_| !already) { append_to_setup_log( &data_dir, &format!( @@ -1967,13 +2003,41 @@ fn ensure_torch_device( torch_version_for_tag(tag) ), ); + // Each build goes into clean directories. The first moves + // the bundled torch aside; later ones clear the build + // before them, so no build is laid over another. + if let Some(site) = &site { + if !swapped { + match torchswap::snapshot(site) { + Ok(n) => { + swapped = true; + append_to_setup_log( + &data_dir, + &format!("moved the bundled torch aside ({n} entries)"), + ); + } + Err(e) => append_to_setup_log( + &data_dir, + &format!("could not move the bundled torch aside ({e}); installing over it"), + ), + } + } else if let Err(e) = torchswap::discard_installed(site) { + append_to_setup_log( + &data_dir, + &format!("could not clear the previous CUDA build ({e})"), + ); + } + } let index_url = format!("https://download.pytorch.org/whl/{tag}"); if let Err(e) = install_cuda_torch(&python, &index_url, &state, &app) { append_to_setup_log(&data_dir, &format!("{tag} install failed: {e}")); + outcomes.push(None); continue; } cuda_installed = true; - if verify_cuda_torch(&python) { + let outcome = verify_cuda_torch(&python); + outcomes.push(Some(outcome)); + if outcome == CudaVerify::Verified { append_to_setup_log(&data_dir, &format!("{tag} verified")); cuda_verified = true; break; @@ -1981,19 +2045,32 @@ fn ensure_torch_device( append_to_setup_log(&data_dir, &format!("{tag} installed but did not verify")); } let reason = if cuda_verified { + if swapped { + if let Some(site) = &site { + if let Err(e) = torchswap::commit(site) { + append_to_setup_log( + &data_dir, + &format!("could not delete the torch backup ({e})"), + ); + } + } + } "verified" } else { // CUDA torch is installed but unusable. Falling back to the // "cpu" device is not enough — app/main.py imports torch at // module scope, so a wheel that cannot load keeps the // backend from starting at all. Put the CPU wheels back - // (#324). - let stage = if cuda_installed { - "cuda-verify-failed" - } else { - "cuda-install-failed" - }; - match restore_cpu_torch(&python, &state, &app) { + // (#324), and check they import (#731). + let stage = cuda_failure_stage(&outcomes); + match put_cpu_torch_back( + &python, + site.as_deref(), + swapped, + &state, + &app, + &data_dir, + ) { Ok(()) => stage, Err(e) => { append_to_setup_log( @@ -2325,13 +2402,23 @@ fn wheel_tag(compute_cap: Option<&str>, cuda_version: &str) -> &'static str { /// on the GPU and then fail at separation time. #[cfg(any(not(target_os = "macos"), test))] fn wheel_candidates(compute_cap: Option<&str>, cuda_version: &str) -> Vec<&'static str> { - let blackwell = compute_cap - .and_then(|cap| cap.split('.').next()?.parse::().ok()) - .is_some_and(|major| major >= 10); + let cap_major = compute_cap.and_then(|cap| cap.split('.').next()?.parse::().ok()); + let blackwell = cap_major.is_some_and(|major| major >= 10); if blackwell { return vec!["cu128"]; } + // torch 2.8 (cu128) dropped Maxwell and Pascal: its Windows builds start + // at sm_61 and its Linux builds at sm_70 (TORCH_CUDA_ARCH_LIST in + // pytorch's release/2.8 .ci scripts). The 2.6 line keeps sm_50 in both + // cu124 and cu118, and an sm_50 binary runs on any 5.x card. A GTX 970 + // under a CUDA 13 driver was offered cu128 alone, failed verification with + // "no kernel image is available", and dropped to CPU (#732). + let pre_volta = cap_major.is_some_and(|major| major < 7); match cuda_tag(cuda_version) { + "cu128" if pre_volta => vec!["cu124", "cu118"], + // A CUDA 13 driver runs the 2.6 builds too, so they are a second + // chance when cu128 installs but does not verify. + "cu128" => vec!["cu128", "cu124", "cu118"], // cu118 as a second chance for a CUDA 12 driver. cu124 is the right // first answer for all of them (see cuda_tag), but minor-version // compatibility is the thing being relied on there, and when it does @@ -2912,6 +2999,99 @@ fn restore_cpu_torch( ) } +/// The site-packages this Python installs into, asked of the interpreter +/// itself rather than assumed from the layout, which differs between the +/// Windows and Linux bundles and the downloaded runtime pack. +#[cfg(not(target_os = "macos"))] +fn torch_site_packages(python: &Path) -> Option { + let mut command = Command::new(python); + command + .args([ + "-c", + "import sysconfig; print(sysconfig.get_paths()['purelib'])", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + hide_console_window(&mut command); + let out = command_output_with_timeout(command, Duration::from_secs(60), "site-packages probe") + .ok() + .filter(|out| out.status.success())?; + let path = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim()); + path.is_dir().then_some(path) +} + +/// Whether `import torch` works. A restore that leaves torch unimportable is +/// a failed restore, whatever pip said (#731). +#[cfg(not(target_os = "macos"))] +fn verify_torch_imports(python: &Path) -> Result<(), String> { + let mut command = Command::new(python); + command + .args(["-c", "import torch"]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + hide_console_window(&mut command); + let out = command_output_with_timeout(command, GPU_VERIFY_TIMEOUT, "torch import check")?; + if out.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&out.stderr); + let tail: Vec<&str> = stderr.trim().lines().rev().take(3).collect(); + Err(tail.into_iter().rev().collect::>().join("\n")) +} + +/// Put CPU torch back after CUDA did not work out, and prove it imports. +/// +/// With the bundled torch moved aside, that is a rename back: exact, and no +/// network. Without one (the move failed, or the install broke before this +/// fix existed and has no backup), the CPU wheels are reinstalled, and if +/// torch still cannot import, everything torch owns is deleted and they are +/// installed once more into clean directories. That second pass is what +/// repairs an install already carrying a stray CUDA DLL (#723). +#[cfg(not(target_os = "macos"))] +fn put_cpu_torch_back( + python: &Path, + site: Option<&Path>, + swapped: bool, + state: &BackendState, + app: &tauri::AppHandle, + data_dir: &Path, +) -> Result<(), String> { + if let (true, Some(site)) = (swapped, site) { + match torchswap::restore(site) { + Ok(()) => match verify_torch_imports(python) { + Ok(()) => { + append_to_setup_log(data_dir, "put the bundled torch back"); + return Ok(()); + } + Err(e) => append_to_setup_log( + data_dir, + &format!("the restored torch does not import:\n{e}"), + ), + }, + Err(e) => append_to_setup_log( + data_dir, + &format!("could not put the bundled torch back ({e})"), + ), + } + } + restore_cpu_torch(python, state, app)?; + let Err(first) = verify_torch_imports(python) else { + return Ok(()); + }; + append_to_setup_log( + data_dir, + &format!("CPU torch reinstalled over the old files but does not import:\n{first}"), + ); + let site = site.ok_or("torch does not import and site-packages is unknown")?; + torchswap::discard_installed(site) + .map_err(|e| format!("could not clear the broken torch ({e})"))?; + restore_cpu_torch(python, state, app)?; + verify_torch_imports(python) + .map_err(|e| format!("CPU torch does not import after a clean reinstall:\n{e}"))?; + append_to_setup_log(data_dir, "CPU torch reinstalled into clean directories"); + Ok(()) +} + #[cfg(not(target_os = "macos"))] fn install_cuda_torch( python: &Path, @@ -2919,11 +3099,9 @@ fn install_cuda_torch( state: &BackendState, app: &tauri::AppHandle, ) -> Result<(), String> { - // Skip only when CUDA torch is already active — torch.version.cuda is - // None for CPU-only wheels, so this correctly re-installs when needed. - if verify_cuda_torch(python) { - return Ok(()); - } + // Whether a working CUDA build is already installed is decided once, by + // the caller, before any build is moved aside: asked here, after the + // bundled torch has been moved, it would always say no. // Fix the build machine's Python path baked into pyvenv.cfg before pip // runs — pip validates the `home` entry and fails if it doesn't exist. @@ -3015,8 +3193,49 @@ fn cuda_install_passes<'a>( /// an unbounded wait is an unbounded hang (#502). const GPU_VERIFY_TIMEOUT: Duration = Duration::from_secs(120); +/// What a CUDA verification run showed. +#[cfg(any(not(target_os = "macos"), test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CudaVerify { + Verified, + /// The build has no kernels for this GPU. Another build might, but trying + /// the same builds again on the next launch never will (#733). + NoKernelImage, + /// Anything else, including a timeout. Worth retrying next launch. + Failed, +} + +/// Whether a failed verification was the "this build has no kernels for your +/// GPU" error. Matched on CUDA's exact wording, because a looser match would +/// settle a driver or install problem as permanent. +#[cfg(any(not(target_os = "macos"), test))] +fn is_no_kernel_image(stderr: &str) -> bool { + stderr.contains("no kernel image is available for execution on the device") +} + +/// The reason recorded when CUDA setup does not end on the GPU. +/// +/// `cuda-unsupported-gpu` means every build that installed was refused for +/// having no kernels for this card, and nothing else went wrong. The setup +/// screen treats it as settled, so the same builds are not downloaded and +/// refused again on every launch (#733). A new app version re-runs setup +/// anyway, which is when different builds could be on offer. +#[cfg(any(not(target_os = "macos"), test))] +fn cuda_failure_stage(outcomes: &[Option]) -> &'static str { + let installed: Vec = outcomes.iter().flatten().copied().collect(); + if installed.is_empty() { + "cuda-install-failed" + } else if installed.len() == outcomes.len() + && installed.iter().all(|o| *o == CudaVerify::NoKernelImage) + { + "cuda-unsupported-gpu" + } else { + "cuda-verify-failed" + } +} + #[cfg(not(target_os = "macos"))] -fn verify_cuda_torch(python: &Path) -> bool { +fn verify_cuda_torch(python: &Path) -> CudaVerify { // Don't trust torch.cuda.is_available() alone: it returns True even when the // installed wheel has no kernels for the device (e.g. sm_120 on a cu124 // build), which then crashes mid-extraction with "no kernel image is @@ -3050,7 +3269,7 @@ fn verify_cuda_torch(python: &Path) -> bool { }; match command_output_with_timeout(command, GPU_VERIFY_TIMEOUT, "CUDA verify") { - Ok(out) if out.status.success() => true, + Ok(out) if out.status.success() => CudaVerify::Verified, Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr); if !stderr.trim().is_empty() { @@ -3060,7 +3279,11 @@ fn verify_cuda_torch(python: &Path) -> bool { // torch loaded but reported no usable device. log("CUDA verify failed: torch reported no usable CUDA device"); } - false + if is_no_kernel_image(&stderr) { + CudaVerify::NoKernelImage + } else { + CudaVerify::Failed + } } Err(e) => { // The timeout path lands here, and it is the one worth naming @@ -3071,7 +3294,7 @@ fn verify_cuda_torch(python: &Path) -> bool { and falling back to CPU. A driver that does not match the installed \ CUDA wheel is the usual cause." )); - false + CudaVerify::Failed } } } @@ -6211,6 +6434,84 @@ mod tests { assert_eq!(super::wheel_candidates(None, "11.8"), vec!["cu118"]); } + /// The reporter's GTX 970 (sm_52) under a CUDA 13 driver (#723, #732). + /// torch 2.8's cu128 builds have no kernels below sm_61 on Windows or + /// sm_70 on Linux, so every pre-Volta card goes to the 2.6 builds. + #[cfg(not(target_os = "macos"))] + #[test] + fn a_pre_volta_card_is_never_offered_cu128() { + for cap in ["5.0", "5.2", "6.0", "6.1", "6.2"] { + for driver in ["13.0", "12.8", "12.4", "11.8"] { + let tags = super::wheel_candidates(Some(cap), driver); + assert!( + !tags.is_empty(), + "cap {cap}, driver {driver}: nothing offered" + ); + assert!( + !tags.contains(&"cu128"), + "cap {cap}, driver {driver}: offered {tags:?}" + ); + } + } + assert_eq!( + super::wheel_candidates(Some("5.2"), "13.0"), + vec!["cu124", "cu118"] + ); + } + + /// The reporter's setup log: the GPU was refused by every build for having + /// no kernels, and setup repeated the whole install on each of six + /// launches (#733). That outcome, and only that one, is settled. + #[test] + fn a_gpu_every_build_refuses_is_settled_as_unsupported() { + use super::CudaVerify::{Failed, NoKernelImage, Verified}; + let stage = super::cuda_failure_stage; + assert_eq!(stage(&[Some(NoKernelImage)]), "cuda-unsupported-gpu"); + assert_eq!( + stage(&[Some(NoKernelImage), Some(NoKernelImage)]), + "cuda-unsupported-gpu" + ); + // Anything that might go differently next time is not settled. + assert_eq!( + stage(&[Some(NoKernelImage), Some(Failed)]), + "cuda-verify-failed" + ); + assert_eq!(stage(&[Some(NoKernelImage), None]), "cuda-verify-failed"); + assert_eq!(stage(&[Some(Failed)]), "cuda-verify-failed"); + assert_eq!(stage(&[None, None]), "cuda-install-failed"); + assert_eq!(stage(&[]), "cuda-install-failed"); + // Verified never reaches here, but must not read as unsupported. + assert_eq!(stage(&[Some(Verified)]), "cuda-verify-failed"); + } + + #[test] + fn only_cudas_own_wording_counts_as_no_kernel_image() { + // The line from the reporter's setup.log. + assert!(super::is_no_kernel_image( + "torch.AcceleratorError: CUDA error: no kernel image is available for execution on the device" + )); + assert!(!super::is_no_kernel_image("CUDA error: out of memory")); + assert!(!super::is_no_kernel_image( + "RuntimeError: CUDA kernel launch timed out" + )); + assert!(!super::is_no_kernel_image("")); + } + + /// A card cu128 does support still starts there, with the 2.6 builds + /// behind it instead of CPU. + #[cfg(not(target_os = "macos"))] + #[test] + fn a_cuda_13_driver_falls_back_to_the_2_6_builds() { + assert_eq!( + super::wheel_candidates(Some("8.6"), "13.0"), + vec!["cu128", "cu124", "cu118"] + ); + assert_eq!( + super::wheel_candidates(None, "13.0"), + vec!["cu128", "cu124", "cu118"] + ); + } + /// Every wheel tag setup can offer publishes the torch line it installs. /// /// The tag comes from the driver and the version from a table, and nothing @@ -6234,6 +6535,8 @@ mod tests { ]; let caps = [ None, + Some("5.2"), + Some("6.1"), Some("7.5"), Some("8.6"), Some("8.9"), diff --git a/desktop/src-tauri/src/torchswap.rs b/desktop/src-tauri/src/torchswap.rs new file mode 100644 index 00000000..522ca625 --- /dev/null +++ b/desktop/src-tauri/src/torchswap.rs @@ -0,0 +1,444 @@ +//! Swapping the bundled torch for a CUDA build, and back, without leftovers. +//! +//! The CUDA install used to lay the CUDA wheels over the CPU ones with +//! `pip install --ignore-installed`, and the restore laid the CPU wheels back +//! over those. Overlaying replaces shared files but never removes files only +//! the CUDA wheel has. On Windows torch loads every DLL in `torch/lib`, so a +//! leftover `c10_cuda.dll` from one torch version failed to link against the +//! restored CPU DLLs from another, and `import torch` raised WinError 127 for +//! good (#731, reported in #723). +//! +//! So the torch the bundle shipped is moved aside before the first CUDA +//! install, each candidate goes into clean directories, and a failed attempt +//! is rolled back by deleting what was installed and moving the originals +//! back: exact, and with no network. +//! +//! What belongs to torch is read from pip's own `RECORD` files, never guessed +//! from names, so nothing else in site-packages is ever touched. + +use std::collections::BTreeSet; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +/// The distributions a CUDA install replaces. +const PACKAGES: [&str; 3] = ["torch", "torchaudio", "torchvision"]; +/// Next to site-packages, so moving into it is a rename on the same volume. +const BACKUP_DIR: &str = ".stemdeck-torch-backup"; +/// Written, and flushed to disk, before anything is moved. +const MANIFEST: &str = "manifest.txt"; + +/// Where the backup for this site-packages lives. +pub fn backup_root(site: &Path) -> PathBuf { + site.parent().unwrap_or(site).join(BACKUP_DIR) +} + +/// Whether a swap was started and not finished: a crash, a forced quit, or a +/// rollback that did not complete. +pub fn has_backup(site: &Path) -> bool { + backup_root(site).join(MANIFEST).is_file() +} + +/// Whether a dist-info directory name belongs to one of the torch packages. +fn is_torch_dist_info(name: &str) -> bool { + let Some(stem) = name.strip_suffix(".dist-info") else { + return false; + }; + let Some((dist, _version)) = stem.split_once('-') else { + return false; + }; + PACKAGES.iter().any(|p| p.eq_ignore_ascii_case(dist)) +} + +/// The top-level names in `site` that the installed torch packages own: their +/// dist-info directories and the first path segment of every file listed in +/// their RECORD. Only names that exist are returned. +pub fn owned_entries(site: &Path) -> io::Result> { + let mut owned = BTreeSet::new(); + for entry in fs::read_dir(site)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + if !is_torch_dist_info(&name) { + continue; + } + owned.insert(name.clone()); + match fs::read_to_string(entry.path().join("RECORD")) { + Ok(record) => { + for line in record.lines() { + let path = line.split(',').next().unwrap_or("").trim(); + let first = path.split(['/', '\\']).next().unwrap_or(""); + // "../../Scripts/torchrun.exe" and the like live outside + // site-packages; they are replaced in place and harmless. + if first.is_empty() || first == "." || first == ".." { + continue; + } + owned.insert(first.to_string()); + } + } + // A damaged install with no RECORD still owns its package dir. + Err(_) => { + if let Some((dist, _)) = name.trim_end_matches(".dist-info").split_once('-') { + owned.insert(dist.to_lowercase()); + } + } + } + } + owned.retain(|name| name != BACKUP_DIR && site.join(name).exists()); + Ok(owned) +} + +fn remove_entry(path: &Path) -> io::Result<()> { + if path.is_dir() { + fs::remove_dir_all(path) + } else if path.exists() { + fs::remove_file(path) + } else { + Ok(()) + } +} + +/// Move the installed torch packages aside. Fails without moving anything if +/// there is no torch to move or a backup already exists. +pub fn snapshot(site: &Path) -> io::Result { + let backup = backup_root(site); + if has_backup(site) { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "a torch backup already exists; restore it first", + )); + } + let entries = owned_entries(site)?; + if entries.is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "no torch packages found to back up", + )); + } + fs::create_dir_all(&backup)?; + // The manifest goes first and reaches the disk before anything moves, so + // a crash part way through leaves a record of what to put back. + let mut manifest = fs::File::create(backup.join(MANIFEST))?; + for name in &entries { + writeln!(manifest, "{name}")?; + } + manifest.sync_all()?; + drop(manifest); + for name in &entries { + fs::rename(site.join(name), backup.join(name))?; + } + Ok(entries.len()) +} + +/// Delete whatever torch packages are installed now. Used between candidates, +/// so one CUDA build is never laid over another. +pub fn discard_installed(site: &Path) -> io::Result<()> { + for name in owned_entries(site)? { + remove_entry(&site.join(name))?; + } + Ok(()) +} + +/// Put the backed-up torch back exactly, removing anything installed since. +/// Safe to run after a crash at any point in `snapshot` or in itself. +pub fn restore(site: &Path) -> io::Result<()> { + let backup = backup_root(site); + let Ok(manifest) = fs::read_to_string(backup.join(MANIFEST)) else { + return Ok(()); + }; + let names: BTreeSet = manifest + .lines() + .map(str::trim) + .filter(|n| !n.is_empty() && *n != "." && *n != ".." && !n.contains(['/', '\\'])) + .map(str::to_string) + .collect(); + // Moved names come back, replacing whatever is there now. A name the + // manifest lists but the backup lacks was never moved, so it is still the + // original and stays. + for name in &names { + let saved = backup.join(name); + if !saved.exists() { + continue; + } + remove_entry(&site.join(name))?; + fs::rename(&saved, site.join(name))?; + } + // Then anything torch-owned that the original did not have: the CUDA + // build's own dist-info, its extra top-level packages. + for name in owned_entries(site)? { + if !names.contains(&name) { + remove_entry(&site.join(name))?; + } + } + fs::remove_dir_all(&backup) +} + +/// The swap worked: the backup is no longer needed. +pub fn commit(site: &Path) -> io::Result<()> { + let backup = backup_root(site); + if backup.exists() { + fs::remove_dir_all(backup)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tree_hashes(root: &Path) -> std::collections::BTreeMap> { + use sha2::{Digest, Sha256}; + let mut out = std::collections::BTreeMap::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + stack.push(path); + } else { + let digest = Sha256::digest(fs::read(&path).unwrap()).to_vec(); + out.insert(path.strip_prefix(root).unwrap().to_path_buf(), digest); + } + } + } + out + } + + fn copy_tree(from: &Path, to: &Path) { + if from.is_dir() { + fs::create_dir_all(to).unwrap(); + for entry in fs::read_dir(from).unwrap() { + let entry = entry.unwrap(); + copy_tree(&entry.path(), &to.join(entry.file_name())); + } + } else { + fs::copy(from, to).unwrap(); + } + } + + /// The rollback on a real torch install rather than a fake one: gigabytes, + /// thousands of files, real RECORDs. Run by hand, with a site-packages to + /// copy from and a folder to work in (left behind, so the result can be + /// imported afterwards): + /// + /// STEMDECK_REAL_TORCH_SITE= STEMDECK_REAL_TORCH_WORK= + /// cargo test real_torch -- --ignored + #[test] + #[ignore] + fn a_real_torch_comes_back_byte_for_byte() { + let (Ok(source), Ok(work)) = ( + std::env::var("STEMDECK_REAL_TORCH_SITE"), + std::env::var("STEMDECK_REAL_TORCH_WORK"), + ) else { + panic!("set STEMDECK_REAL_TORCH_SITE and STEMDECK_REAL_TORCH_WORK"); + }; + let source = PathBuf::from(source); + let site = PathBuf::from(work).join("Lib").join("site-packages"); + if site.exists() { + fs::remove_dir_all(&site).unwrap(); + } + let _ = fs::remove_dir_all(backup_root(&site)); + fs::create_dir_all(&site).unwrap(); + let owned = owned_entries(&source).unwrap(); + assert!(owned.contains("torch"), "no torch in {}", source.display()); + for name in &owned { + copy_tree(&source.join(name), &site.join(name)); + } + let before = tree_hashes(&site); + + let moved = snapshot(&site).unwrap(); + assert_eq!(moved, owned.len()); + // A CUDA build laid into the clean directories, with a DLL the CPU + // build does not have. + let lib = site.join("torch").join("lib"); + fs::create_dir_all(&lib).unwrap(); + fs::write(lib.join("c10_cuda.dll"), "cuda").unwrap(); + fs::write(lib.join("c10.dll"), "cuda c10").unwrap(); + let dist = site.join("torch-2.8.0+cu128.dist-info"); + fs::create_dir_all(&dist).unwrap(); + fs::write( + dist.join("RECORD"), + "torch/lib/c10.dll,,\ntorch/lib/c10_cuda.dll,,\ntorch-2.8.0+cu128.dist-info/RECORD,,\n", + ) + .unwrap(); + + restore(&site).unwrap(); + + assert_eq!( + tree_hashes(&site), + before, + "the restored torch differs from the original" + ); + assert!(!has_backup(&site)); + } + + /// A site-packages with torch as pip lays it out, plus a neighbour that + /// must never be touched. + fn fake_site(root: &Path, version: &str, extra_lib: Option<&str>) -> PathBuf { + let site = root.join("Lib").join("site-packages"); + let lib = site.join("torch").join("lib"); + fs::create_dir_all(&lib).unwrap(); + fs::write(lib.join("c10.dll"), format!("c10 {version}")).unwrap(); + let mut record = String::from("torch/lib/c10.dll,,\n"); + // A leftover from an earlier overlay: on disk, but in no RECORD, + // exactly as the reporter's c10_cuda.dll was. + if let Some(dll) = extra_lib { + fs::write(lib.join(dll), "cuda").unwrap(); + } + fs::create_dir_all(site.join("functorch")).unwrap(); + fs::write(site.join("functorch").join("__init__.py"), version).unwrap(); + record.push_str("functorch/__init__.py,,\n../../Scripts/torchrun.exe,,\n"); + let dist = site.join(format!("torch-{version}.dist-info")); + fs::create_dir_all(&dist).unwrap(); + record.push_str(&format!("torch-{version}.dist-info/RECORD,,\n")); + fs::write(dist.join("RECORD"), record).unwrap(); + fs::create_dir_all(site.join("numpy")).unwrap(); + fs::write(site.join("numpy").join("__init__.py"), "numpy").unwrap(); + site + } + + /// Lay a CUDA torch into `site` the way pip would into clean directories. + fn install_cuda(site: &Path) { + let lib = site.join("torch").join("lib"); + fs::create_dir_all(&lib).unwrap(); + fs::write(lib.join("c10.dll"), "c10 cuda").unwrap(); + fs::write(lib.join("c10_cuda.dll"), "cuda").unwrap(); + let dist = site.join("torch-2.6.0+cu124.dist-info"); + fs::create_dir_all(&dist).unwrap(); + fs::write( + dist.join("RECORD"), + "torch/lib/c10.dll,,\ntorch/lib/c10_cuda.dll,,\ntorch-2.6.0+cu124.dist-info/RECORD,,\n", + ) + .unwrap(); + } + + #[test] + fn owned_entries_come_from_record_and_leave_neighbours_alone() { + let tmp = tempfile::tempdir().unwrap(); + let site = fake_site(tmp.path(), "2.6.0+cpu", None); + let owned = owned_entries(&site).unwrap(); + assert!(owned.contains("torch")); + assert!(owned.contains("functorch")); + assert!(owned.contains("torch-2.6.0+cpu.dist-info")); + assert!(!owned.contains("numpy")); + assert!(!owned.contains("..")); + } + + /// The #723 install: rolling back must leave no c10_cuda.dll behind, and + /// the original c10.dll byte for byte. + #[test] + fn a_rollback_leaves_no_cuda_dll_behind() { + let tmp = tempfile::tempdir().unwrap(); + let site = fake_site(tmp.path(), "2.6.0+cpu", None); + snapshot(&site).unwrap(); + assert!( + !site.join("torch").exists(), + "the original was not moved aside" + ); + install_cuda(&site); + + restore(&site).unwrap(); + + let lib = site.join("torch").join("lib"); + assert!(!lib.join("c10_cuda.dll").exists()); + assert_eq!( + fs::read_to_string(lib.join("c10.dll")).unwrap(), + "c10 2.6.0+cpu" + ); + assert!(site.join("torch-2.6.0+cpu.dist-info").exists()); + assert!(!site.join("torch-2.6.0+cu124.dist-info").exists()); + assert_eq!( + fs::read_to_string(site.join("numpy").join("__init__.py")).unwrap(), + "numpy" + ); + assert!(!has_backup(&site)); + } + + #[test] + fn a_second_candidate_installs_into_clean_directories() { + let tmp = tempfile::tempdir().unwrap(); + let site = fake_site(tmp.path(), "2.6.0+cpu", None); + snapshot(&site).unwrap(); + install_cuda(&site); + discard_installed(&site).unwrap(); + assert!(!site.join("torch").exists()); + assert!(!site.join("torch-2.6.0+cu124.dist-info").exists()); + assert!(site.join("numpy").exists()); + assert!( + has_backup(&site), + "the original must survive between candidates" + ); + } + + #[test] + fn a_verified_swap_drops_the_backup() { + let tmp = tempfile::tempdir().unwrap(); + let site = fake_site(tmp.path(), "2.6.0+cpu", None); + snapshot(&site).unwrap(); + install_cuda(&site); + commit(&site).unwrap(); + assert!(!has_backup(&site)); + assert!(site.join("torch").join("lib").join("c10_cuda.dll").exists()); + } + + /// A crash part way through the snapshot: some entries moved, some not. + /// Restoring puts back what moved and keeps what never did. + #[test] + fn a_snapshot_interrupted_part_way_is_recovered() { + let tmp = tempfile::tempdir().unwrap(); + let site = fake_site(tmp.path(), "2.6.0+cpu", None); + let backup = backup_root(&site); + fs::create_dir_all(&backup).unwrap(); + let entries = owned_entries(&site).unwrap(); + let listed: Vec<&String> = entries.iter().collect(); + fs::write( + backup.join(MANIFEST), + listed.iter().map(|n| format!("{n}\n")).collect::(), + ) + .unwrap(); + fs::rename(site.join(listed[0]), backup.join(listed[0])).unwrap(); + + assert!(has_backup(&site)); + restore(&site).unwrap(); + + assert_eq!(owned_entries(&site).unwrap(), entries); + assert_eq!( + fs::read_to_string(site.join("torch").join("lib").join("c10.dll")).unwrap(), + "c10 2.6.0+cpu" + ); + assert!(!has_backup(&site)); + } + + #[test] + fn a_second_snapshot_refuses_rather_than_overwrite_the_first() { + let tmp = tempfile::tempdir().unwrap(); + let site = fake_site(tmp.path(), "2.6.0+cpu", None); + snapshot(&site).unwrap(); + install_cuda(&site); + assert!(snapshot(&site).is_err()); + restore(&site).unwrap(); + assert_eq!( + fs::read_to_string(site.join("torch").join("lib").join("c10.dll")).unwrap(), + "c10 2.6.0+cpu" + ); + } + + #[test] + fn nothing_to_restore_is_not_an_error() { + let tmp = tempfile::tempdir().unwrap(); + let site = fake_site(tmp.path(), "2.6.0+cpu", None); + restore(&site).unwrap(); + assert!(site.join("torch").exists()); + } + + /// An install that already has the leftover DLL (the reporter's state) + /// is cleared entirely by discard_installed, ahead of a clean reinstall. + #[test] + fn a_broken_install_is_cleared_before_reinstalling() { + let tmp = tempfile::tempdir().unwrap(); + let site = fake_site(tmp.path(), "2.6.0+cpu", Some("c10_cuda.dll")); + discard_installed(&site).unwrap(); + assert!(!site.join("torch").exists()); + assert!(!site.join("functorch").exists()); + assert!(site.join("numpy").exists()); + } +} diff --git a/desktop/ui/setup.js b/desktop/ui/setup.js index 3a0bb557..fed8146b 100644 --- a/desktop/ui/setup.js +++ b/desktop/ui/setup.js @@ -322,10 +322,17 @@ async function runSetup() { // that predates reason tracking -- re-runs the GPU step on this launch, so // a single bad first run can't pin the install to CPU forever (#247). // Cost when nothing changed: one fast nvidia-smi probe. + // + // A GPU that every offered CUDA build refused for having no kernels for it + // is settled too: trying the same builds again cannot go differently, and + // did cost about 45 s of download and install on every launch (#733). A + // new version re-runs setup through versionMismatch below, which is when + // different builds could be on offer. const torchDeviceSettled = runtime.torchDevice === "cuda" || runtime.torchDevice === "mps" || - (runtime.torchDevice === "cpu" && runtime.torchDeviceReason === "cpu-only-package"); + (runtime.torchDevice === "cpu" && runtime.torchDeviceReason === "cpu-only-package") || + (runtime.torchDevice === "cpu" && runtime.torchDeviceReason === "cuda-unsupported-gpu"); if (runtime.pythonReady && runtime.ffmpegReady && torchDeviceSettled && !versionMismatch) { for (const step of steps) { @@ -449,7 +456,11 @@ async function runSetup() { ? `${gpu.gpuName} acceleration enabled` : "MPS acceleration unavailable - stem separation will use CPU"; } else { - if (gpu.gpuDetected && !gpu.cudaVerified) { + if (gpu.gpuDetected && !gpu.cudaVerified && gpu.reason === "cuda-unsupported-gpu") { + showError( + `GPU detected (${gpu.gpuName}), but none of the CUDA builds StemDeck can install support it - stem separation will use CPU. StemDeck will not try again until the next update.` + ); + } else if (gpu.gpuDetected && !gpu.cudaVerified) { showError( `GPU detected (${gpu.gpuName}) but CUDA setup failed - stem separation will use CPU.\nCheck logs/setup.log in the StemDeck data folder for details.` ); diff --git a/static/css/daw.css b/static/css/daw.css index 8370923a..4ae32ca0 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -782,6 +782,7 @@ input, textarea { font-family: inherit; } /* cat-item (legacy catalog items JS creates) */ .cat-item { + position: relative; display: flex; gap: 9px; align-items: center; @@ -905,14 +906,36 @@ input, textarea { font-family: inherit; } .queue-row:hover .queue-top { opacity: 0.65; } .queue-top:hover { background: rgba(232, 168, 56, 0.16); color: var(--accent); opacity: 1; } .queue-top:disabled { opacity: 0.3; cursor: default; } -.cat-del { +/* A row's actions float over its right edge and show on hover or keyboard + focus (#724). In the flow they cost the title their width on every row at + rest, which is where titles were being cut short, and a second button in + the flow would have cost it again. The fade lets a long title run under + them instead of stopping at a hard edge. */ +.cat-actions { + position: absolute; top: 50%; right: 6px; transform: translateY(-50%); + display: flex; gap: 2px; padding-left: 16px; + background: linear-gradient(to right, transparent, var(--panel-2) 14px); + opacity: 0; pointer-events: none; + transition: opacity var(--t-fast); +} +.cat-item:hover .cat-actions, +.cat-item:focus-within .cat-actions { opacity: 1; pointer-events: auto; } +.cat-del, .cat-fav { width: 20px; height: 20px; border: 0; border-radius: 5px; background: transparent; color: var(--muted); cursor: pointer; - opacity: 0; display: flex; align-items: center; justify-content: center; - padding: 0; flex-shrink: 0; transition: opacity var(--t-fast), background var(--t-fast); + display: flex; align-items: center; justify-content: center; + padding: 0; flex-shrink: 0; transition: background var(--t-fast), color var(--t-fast); } -.cat-item:hover .cat-del { opacity: 1; } .cat-del:hover { background: rgba(21,31,39,0.8); color: var(--danger); } +.cat-fav:hover { background: rgba(21,31,39,0.8); color: var(--fg); } +.cat-fav.active, .cat-fav.active:hover { color: #e54e4e; } +/* A favourite says so at rest, in the subline, where it takes no title room. */ +.cat-item.is-fav .cat-sub::before { + content: ""; flex-shrink: 0; align-self: center; + width: 9px; height: 9px; background: #e54e4e; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z'/%3E%3C/svg%3E") center / contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z'/%3E%3C/svg%3E") center / contain no-repeat; +} /* Folder items (legacy catalog) */ .folder { display: flex; flex-direction: column; } @@ -996,6 +1019,8 @@ input, textarea { font-family: inherit; } .folder-editor-colors { display: flex; align-items: center; gap: 9px; } .folder-editor-colors .folder-color-dot { width: 22px; height: 22px; } .folder-editor-msg { color: var(--danger); font-size: 11px; margin-top: 8px; } +/* The separation engine cannot load at all (#730). */ +.set-demucs-desc.is-broken { color: var(--danger); } .folder-editor-msg:empty { display: none; } .folder-editor-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; } .folder-editor-actions button { min-height: 30px; border-radius: 7px; font-family: var(--font-mono); font-size: 11px; cursor: pointer; padding: 0 11px; } @@ -1363,6 +1388,20 @@ input, textarea { font-family: inherit; } grid-template-columns: repeat(7, minmax(0, 1fr)); border-bottom: 1px solid var(--border); } +/* Key takes the column the Scale card had (#736). "F# harmonisches Moll" has to + fit, and two columns keep the seven edges the presence row lines up with. */ +.daw-meta-card[data-meta="key"] { grid-column: span 2; } +/* Even two columns are 115 px of text at a 950 px window, and "F# harmonisches + Moll" needs 155. Clipping it would hide the mode, which is the part the + label exists to say, so the key wraps instead: a short key still sits on + one line at any width, a long one takes two. */ +.daw-meta-card[data-meta="key"] .meta-card-main { align-items: center; } +.daw-meta-card[data-meta="key"] .meta-card-value { + white-space: normal; + overflow-wrap: break-word; + line-height: 1.15; + text-wrap: balance; +} /* Track card */ .daw-track-card { @@ -5431,8 +5470,8 @@ input, textarea { font-family: inherit; } * So the two things that were moved up here give way, in the order they can be * done without: the card first, since the track is also named in the library * and the mixer, then the toggles drop to a line of their own, which is where - * they lived before. Nothing is lost at any width, it just stops being one - * line. + * they lived before. The card's favourite heart is also on every library row + * (#724). Its About this song button has no second home yet (#735). */ @media (max-width: 1460px) { .daw-composer > #nowPlayingPanel { display: none; } diff --git a/static/index.html b/static/index.html index 6c353e35..1a7182b1 100644 --- a/static/index.html +++ b/static/index.html @@ -452,8 +452,6 @@

Lyrics

— - -
@@ -479,13 +477,6 @@

Lyrics

-
- SCALE -
- — -
-
-
DYNAMIC RANGE
diff --git a/static/js/catalog.js b/static/js/catalog.js index bf2c0c70..ef8d6d42 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -5,6 +5,7 @@ import { initSections } from "./sections.js"; import { bpmChip, foregroundJobId, keyChip, saveSelectedStems, selectedStems, titleEl } from "./state.js"; import { refreshStemChoiceVisuals } from "./stemChoice.js"; import { trackFormat, formatIconSvg, paintNowPlayingArt } from "./formatIcon.js"; +import { formatKey } from "./keyLabel.js"; import { showError, importFromUrl, detachForegroundJob, runVocalSplitIfWanted } from "./job.js"; import { cancelQueuedJob, getQueueSnapshot, isPaused, onJobSettled, onQueueChange, @@ -856,18 +857,13 @@ function applyTrackInfoToPanel(track) { 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) summaryKey.textContent = track.key || "—"; + if (summaryKey) summaryKey.textContent = formatKey(track.key, track.scale, i18nT) || "—"; if (summaryBpm) summaryBpm.textContent = track.bpm ? String(track.bpm) : "—"; - if (summaryScale) summaryScale.textContent = track.scale || ""; - if (summaryScaleName) summaryScaleName.textContent = track.scale || "—"; if (summaryLufs) summaryLufs.textContent = track.lufs != null ? Number(track.lufs).toFixed(1) : "—"; if (summaryPeak) summaryPeak.textContent = track.peakDb != null ? i18nT("job.peakDb", { value: Number(track.peakDb).toFixed(1) }) : ""; if (summaryDuration) summaryDuration.textContent = track.duration ? fmtTime(track.duration) : "—"; @@ -894,16 +890,9 @@ function applyTrackInfoToPanel(track) { // The now-playing square shows the same format icon the library row does. paintNowPlayingArt(trackFormat(track)); if (favBtn) { - favBtn.classList.toggle("active", Boolean(track.favorite)); - favBtn.setAttribute("aria-pressed", String(Boolean(track.favorite))); + paintFavButton(favBtn, Boolean(track.favorite)); favBtn.onclick = () => { - if (!_currentTrackId) return; - const t = tracks[_currentTrackId]; - if (!t) return; - t.favorite = !t.favorite; - favBtn.classList.toggle("active", t.favorite); - favBtn.setAttribute("aria-pressed", String(t.favorite)); - saveState(); + if (_currentTrackId) toggleFavorite(_currentTrackId); }; } @@ -923,7 +912,6 @@ function applyTrackInfoToPanel(track) { summaryConfidence.textContent = ""; summaryConfidence.style.removeProperty("--confidence-pct"); summaryConfidence.classList.add("hidden"); - summaryConfidenceLabel?.classList.add("hidden"); if (track.keyConfidence != null) { const confidence = Math.max(0, Math.min(100, Number(track.keyConfidence))); const confSpan = document.createElement("span"); @@ -931,7 +919,6 @@ function applyTrackInfoToPanel(track) { summaryConfidence.appendChild(confSpan); summaryConfidence.style.setProperty("--confidence-pct", confidence); summaryConfidence.classList.remove("hidden"); - summaryConfidenceLabel?.classList.remove("hidden"); } } } @@ -1425,6 +1412,53 @@ function dropOnFolder(folderId, trackId) { render(); } +function paintFavButton(btn, on) { + btn.classList.toggle("active", on); + btn.setAttribute("aria-pressed", String(on)); +} + +/** + * Flip a track's favourite, from whichever heart was pressed. + * + * The Now Playing heart used to be the only one, and it disappears with its + * card below 1460 px (#724), which left no way to favourite at all. Library + * rows have one too now, and every heart goes through here so none of them can + * disagree with the store or with each other. + */ +export function toggleFavorite(trackId) { + const track = tracks[trackId]; + if (!track) return; + track.favorite = !track.favorite; + saveState(); + if (trackId === _currentTrackId) { + const favBtn = document.getElementById("fav-btn"); + if (favBtn) paintFavButton(favBtn, track.favorite); + } + // Rows show the state at rest, and the Favorites view lists by it. + render(); +} + +function favButtonHtml(track) { + const on = Boolean(track.favorite); + const label = i18nT(on ? "track.unfavoriteTitle" : "track.favoriteTitle", { + title: track.title ?? i18nT("track.unknown"), + }); + return ``; +} + +function wireFavButton(el, trackId) { + el.querySelector(".cat-fav")?.addEventListener("click", (e) => { + // The row itself loads the track on click. + e.stopPropagation(); + toggleFavorite(trackId); + }); +} + function wireTrackDragAndLoad(el, trackId) { el.draggable = true; el.addEventListener("dragstart", (e) => { @@ -1432,7 +1466,7 @@ function wireTrackDragAndLoad(el, trackId) { }); el.addEventListener("dragend", () => endDrag(el)); el.addEventListener("click", (e) => { - if (e.target.closest(".cat-del")) return; + if (e.target.closest(".cat-del, .cat-fav")) return; loadTrackIntoStudio(trackId); }); } @@ -1635,7 +1669,7 @@ function renderRecentItem(trackId) { if (!track) return null; const el = document.createElement("div"); const isUnavailable = track.status === "unavailable"; - el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}`; + el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}${track.favorite ? " is-fav" : ""}`; el.dataset.id = trackId; el.innerHTML = `
${thumbHtml(track)}
@@ -1644,7 +1678,9 @@ function renderRecentItem(trackId) {
${trackSublineHtml(track)}
+
${favButtonHtml(track)}
`; + wireFavButton(el, trackId); wireTrackDragAndLoad(el, trackId); return el; } @@ -1702,7 +1738,7 @@ function renderTrackItem(trackId, { inTrash = false } = {}) { const el = document.createElement("div"); const isUnavailable = track.status === "unavailable"; - el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}`; + el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}${!inTrash && track.favorite ? " is-fav" : ""}`; el.dataset.id = trackId; el.innerHTML = ` @@ -1712,12 +1748,12 @@ function renderTrackItem(trackId, { inTrash = false } = {}) {
${trackSublineHtml(track, { inTrash })}
- ${inTrash ? "" : ``} + `} `; el.querySelector(".cat-del")?.setAttribute("aria-label", i18nT("track.moveTitleToTrash", { title: track.title ?? i18nT("track.unknown") })); @@ -1725,6 +1761,7 @@ function renderTrackItem(trackId, { inTrash = false } = {}) { e.stopPropagation(); moveTrackToTrash(trackId); }); + wireFavButton(el, trackId); wireTrackDragAndLoad(el, trackId); @@ -4006,7 +4043,13 @@ async function wireGeneralSettings(overlay) { } if (deviceDesc) { const resolved = d.demucs_device_resolved ? i18nT("settings.device.currently", { device: d.demucs_device_resolved }) : ""; - deviceDesc.textContent = i18nT("settings.device.desc", { resolved }); + // torch installed but unloadable stops separation on every device, CPU + // included, so this replaces the usual line rather than sitting under a + // device list that looks healthy (#730). + deviceDesc.textContent = d.torch_error + ? i18nT("settings.device.torchBroken") + : i18nT("settings.device.desc", { resolved }); + deviceDesc.classList.toggle("is-broken", !!d.torch_error); } }; diff --git a/static/js/i18n.js b/static/js/i18n.js index 35f8785b..848b1b51 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -466,10 +466,13 @@ const en = { "trash.noSearchMatch": "No deleted tracks match your search", "meta.key": "Key", + "key.label": "{tonic} {mode}", + "key.mode.major": "major", + "key.mode.minor": "minor", + "key.mode.harmonicMinor": "harmonic minor", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "Duration", - "meta.scale": "Scale", "meta.dynamicRange": "Dynamic Range", "meta.tempoStability": "Tempo Stability", @@ -763,6 +766,7 @@ const en = { "settings.device.cpu": "CPU", "settings.device.currently": " (currently: {device})", "settings.device.notAvailable": " — not available", + "settings.device.torchBroken": "The separation engine could not start on this computer, so tracks cannot be separated on any device. Reinstalling StemDeck repairs it. The reason is in the log.", "settings.quality.title": "Separation quality", "settings.quality.desc": "Best runs the separator twice with randomized shifts and averages the result — cleaner stems, twice the time.", "settings.quality.standard": "Standard", @@ -802,6 +806,10 @@ const en = { "track.removed": "Removed", "track.moveToTrash": "Move to Trash", "track.moveTitleToTrash": "Move {title} to Trash", + "track.favorite": "Add to Favorites", + "track.unfavorite": "Remove from Favorites", + "track.favoriteTitle": "Add {title} to Favorites", + "track.unfavoriteTitle": "Remove {title} from Favorites", "folder.dragToReorder": "Drag to reorder", "folder.newSubfolder": "New subfolder", "folder.deleteFolder": "Delete folder", @@ -1264,10 +1272,13 @@ const pl = { "trash.noSearchMatch": "Żadne usunięte utwory nie pasują do wyszukiwania", "meta.key": "Tonacja", + "key.label": "{tonic} {mode}", + "key.mode.major": "dur", + "key.mode.minor": "moll", + "key.mode.harmonicMinor": "moll harmoniczna", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "Czas", - "meta.scale": "Skala", "meta.dynamicRange": "Zakres dynamiki", "meta.tempoStability": "Stabilność tempa", @@ -1560,6 +1571,7 @@ const pl = { "settings.device.cpu": "CPU", "settings.device.currently": " (obecnie: {device})", "settings.device.notAvailable": " — niedostępne", + "settings.device.torchBroken": "Silnik rozdzielania nie mógł się uruchomić na tym komputerze, więc utworów nie da się rozdzielić na żadnym urządzeniu. Ponowna instalacja StemDeck to naprawia. Przyczyna jest w logu.", "settings.quality.title": "Jakość rozdzielania", "settings.quality.desc": "Best uruchamia separator dwukrotnie z losowymi przesunięciami i uśrednia wynik — czystsze ścieżki, dwa razy dłużej.", "settings.quality.standard": "Standardowa", @@ -1602,6 +1614,10 @@ const pl = { "track.removed": "Usunięto", "track.moveToTrash": "Przenieś do kosza", "track.moveTitleToTrash": "Przenieś {title} do kosza", + "track.favorite": "Dodaj do ulubionych", + "track.unfavorite": "Usuń z ulubionych", + "track.favoriteTitle": "Dodaj {title} do ulubionych", + "track.unfavoriteTitle": "Usuń {title} z ulubionych", "folder.dragToReorder": "Przeciągnij, aby zmienić kolejność", "folder.newSubfolder": "Nowy podfolder", "folder.deleteFolder": "Usuń folder", @@ -2052,10 +2068,13 @@ const ja = { "trash.noSearchMatch": "検索に一致する削除済みトラックはありません", "meta.key": "キー", + "key.label": "{tonic} {mode}", + "key.mode.major": "メジャー", + "key.mode.minor": "マイナー", + "key.mode.harmonicMinor": "ハーモニック・マイナー", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "長さ", - "meta.scale": "スケール", "meta.dynamicRange": "ダイナミックレンジ", "meta.tempoStability": "テンポ安定性", @@ -2343,6 +2362,7 @@ const ja = { "settings.device.cpu": "CPU", "settings.device.currently": " (現在: {device})", "settings.device.notAvailable": " — 利用不可", + "settings.device.torchBroken": "分離エンジンがこのコンピューターで起動できなかったため、どのデバイスでも曲を分離できません。StemDeck を再インストールすると修復されます。原因はログに記録されています。", "settings.quality.title": "分離品質", "settings.quality.desc": "Bestはランダムなシフトで分離を2回実行し結果を平均化します — よりクリーンなパートになりますが時間は2倍。", "settings.quality.standard": "標準", @@ -2381,6 +2401,10 @@ const ja = { "track.removed": "削除済み", "track.moveToTrash": "ゴミ箱に移動", "track.moveTitleToTrash": "{title}をゴミ箱に移動", + "track.favorite": "お気に入りに追加", + "track.unfavorite": "お気に入りから削除", + "track.favoriteTitle": "{title}をお気に入りに追加", + "track.unfavoriteTitle": "{title}をお気に入りから削除", "folder.dragToReorder": "ドラッグして並べ替え", "folder.newSubfolder": "新しいサブフォルダ", "folder.deleteFolder": "フォルダを削除", @@ -2815,10 +2839,13 @@ const zhHans = { "trash.noSearchMatch": "没有已删除的曲目与你的搜索匹配", "meta.key": "调性", + "key.label": "{tonic} {mode}", + "key.mode.major": "大调", + "key.mode.minor": "小调", + "key.mode.harmonicMinor": "和声小调", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "时长", - "meta.scale": "音阶", "meta.dynamicRange": "动态范围", "meta.tempoStability": "速度稳定性", @@ -3106,6 +3133,7 @@ const zhHans = { "settings.device.cpu": "CPU", "settings.device.currently": "(当前:{device})", "settings.device.notAvailable": " — 不可用", + "settings.device.torchBroken": "分离引擎无法在这台电脑上启动,因此在任何设备上都无法分离曲目。重新安装 StemDeck 即可修复。原因已写入日志。", "settings.quality.title": "分离质量", "settings.quality.desc": "Best 会以随机偏移运行分离器两次并取平均结果 — 音轨更干净,但耗时翻倍。", "settings.quality.standard": "标准", @@ -3144,6 +3172,10 @@ const zhHans = { "track.removed": "已移除", "track.moveToTrash": "移到回收站", "track.moveTitleToTrash": "将 {title} 移到回收站", + "track.favorite": "添加到收藏", + "track.unfavorite": "从收藏中移除", + "track.favoriteTitle": "将 {title} 添加到收藏", + "track.unfavoriteTitle": "将 {title} 从收藏中移除", "folder.dragToReorder": "拖动以重新排序", "folder.newSubfolder": "新建子文件夹", "folder.deleteFolder": "删除文件夹", @@ -3578,10 +3610,13 @@ const de = { "trash.noSearchMatch": "Keine gelöschten Tracks entsprechen deiner Suche", "meta.key": "Tonart", + "key.label": "{tonic} {mode}", + "key.mode.major": "Dur", + "key.mode.minor": "Moll", + "key.mode.harmonicMinor": "harmonisches Moll", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "Dauer", - "meta.scale": "Tonleiter", "meta.dynamicRange": "Dynamikumfang", "meta.tempoStability": "Tempostabilität", @@ -3872,6 +3907,7 @@ const de = { "settings.device.cpu": "CPU", "settings.device.currently": " (aktuell: {device})", "settings.device.notAvailable": " — nicht verfügbar", + "settings.device.torchBroken": "Die Trenn-Engine konnte auf diesem Computer nicht starten, daher lassen sich Titel auf keinem Gerät trennen. Eine Neuinstallation von StemDeck behebt das. Der Grund steht im Protokoll.", "settings.quality.title": "Trennqualität", "settings.quality.desc": "„Best“ führt die Trennung zweimal mit zufälligen Verschiebungen aus und mittelt das Ergebnis — sauberere Stems, doppelte Zeit.", "settings.quality.standard": "Standard", @@ -3912,6 +3948,10 @@ const de = { "track.removed": "Entfernt", "track.moveToTrash": "In den Papierkorb verschieben", "track.moveTitleToTrash": "{title} in den Papierkorb verschieben", + "track.favorite": "Zu Favoriten hinzufügen", + "track.unfavorite": "Aus Favoriten entfernen", + "track.favoriteTitle": "{title} zu Favoriten hinzufügen", + "track.unfavoriteTitle": "{title} aus Favoriten entfernen", "folder.dragToReorder": "Ziehen, um die Reihenfolge zu ändern", "folder.newSubfolder": "Neuer Unterordner", "folder.deleteFolder": "Ordner löschen", @@ -4352,10 +4392,13 @@ const pt = { "trash.noSearchMatch": "Nenhuma faixa excluída corresponde à sua pesquisa", "meta.key": "Tom", + "key.label": "{tonic} {mode}", + "key.mode.major": "maior", + "key.mode.minor": "menor", + "key.mode.harmonicMinor": "menor harmônica", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "Duração", - "meta.scale": "Escala", "meta.dynamicRange": "Faixa dinâmica", "meta.tempoStability": "Estabilidade de tempo", @@ -4646,6 +4689,7 @@ const pt = { "settings.device.cpu": "CPU", "settings.device.currently": " (atualmente: {device})", "settings.device.notAvailable": " — indisponível", + "settings.device.torchBroken": "O mecanismo de separação não conseguiu iniciar neste computador, então nenhuma faixa pode ser separada em nenhum dispositivo. Reinstalar o StemDeck corrige isso. O motivo está no log.", "settings.quality.title": "Qualidade de separação", "settings.quality.desc": "\"Best\" executa o separador duas vezes com deslocamentos aleatórios e calcula a média do resultado — stems mais limpos, o dobro do tempo.", "settings.quality.standard": "Padrão", @@ -4686,6 +4730,10 @@ const pt = { "track.removed": "Removido", "track.moveToTrash": "Mover para a lixeira", "track.moveTitleToTrash": "Mover {title} para a lixeira", + "track.favorite": "Adicionar aos favoritos", + "track.unfavorite": "Remover dos favoritos", + "track.favoriteTitle": "Adicionar {title} aos favoritos", + "track.unfavoriteTitle": "Remover {title} dos favoritos", "folder.dragToReorder": "Arraste para reordenar", "folder.newSubfolder": "Nova subpasta", "folder.deleteFolder": "Excluir pasta", @@ -5128,10 +5176,13 @@ const id = { "trash.noSearchMatch": "Tidak ada trek terhapus yang cocok dengan pencarian Anda", "meta.key": "Nada Dasar", + "key.label": "{tonic} {mode}", + "key.mode.major": "mayor", + "key.mode.minor": "minor", + "key.mode.harmonicMinor": "minor harmonik", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "Durasi", - "meta.scale": "Tangga Nada", "meta.dynamicRange": "Rentang Dinamis", "meta.tempoStability": "Stabilitas Tempo", @@ -5419,6 +5470,7 @@ const id = { "settings.device.cpu": "CPU", "settings.device.currently": " (saat ini: {device})", "settings.device.notAvailable": " — tidak tersedia", + "settings.device.torchBroken": "Mesin pemisah tidak dapat berjalan di komputer ini, jadi lagu tidak dapat dipisahkan di perangkat mana pun. Memasang ulang StemDeck akan memperbaikinya. Penyebabnya ada di log.", "settings.quality.title": "Kualitas pemisahan", "settings.quality.desc": "Terbaik menjalankan pemisah dua kali dengan pergeseran acak dan merata-ratakan hasilnya — stem lebih bersih, dua kali lebih lama.", "settings.quality.standard": "Standar", @@ -5457,6 +5509,10 @@ const id = { "track.removed": "Dihapus", "track.moveToTrash": "Pindahkan ke Sampah", "track.moveTitleToTrash": "Pindahkan {title} ke Sampah", + "track.favorite": "Tambahkan ke favorit", + "track.unfavorite": "Hapus dari favorit", + "track.favoriteTitle": "Tambahkan {title} ke favorit", + "track.unfavoriteTitle": "Hapus {title} dari favorit", "folder.dragToReorder": "Seret untuk mengatur ulang", "folder.newSubfolder": "Subfolder baru", "folder.deleteFolder": "Hapus folder", @@ -5891,10 +5947,13 @@ const fr = { "trash.noSearchMatch": "Aucun morceau supprimé ne correspond à votre recherche", "meta.key": "Tonalité", + "key.label": "{tonic} {mode}", + "key.mode.major": "majeur", + "key.mode.minor": "mineur", + "key.mode.harmonicMinor": "mineur harmonique", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "Durée", - "meta.scale": "Gamme", "meta.dynamicRange": "Plage dynamique", "meta.tempoStability": "Stabilité du tempo", @@ -6186,6 +6245,7 @@ const fr = { "settings.device.cpu": "CPU", "settings.device.currently": " (actuellement : {device})", "settings.device.notAvailable": " — indisponible", + "settings.device.torchBroken": "Le moteur de séparation n’a pas pu démarrer sur cet ordinateur : aucun morceau ne peut être séparé, quel que soit l’appareil. Réinstaller StemDeck corrige le problème. La cause figure dans le journal.", "settings.quality.title": "Qualité de séparation", "settings.quality.desc": "« Meilleure » exécute le séparateur deux fois avec des décalages aléatoires et fait la moyenne du résultat — des pistes plus nettes, deux fois plus de temps.", "settings.quality.standard": "Standard", @@ -6225,6 +6285,10 @@ const fr = { "track.removed": "Supprimé", "track.moveToTrash": "Mettre à la corbeille", "track.moveTitleToTrash": "Mettre {title} à la corbeille", + "track.favorite": "Ajouter aux favoris", + "track.unfavorite": "Retirer des favoris", + "track.favoriteTitle": "Ajouter {title} aux favoris", + "track.unfavoriteTitle": "Retirer {title} des favoris", "folder.dragToReorder": "Glisser pour réorganiser", "folder.newSubfolder": "Nouveau sous-dossier", "folder.deleteFolder": "Supprimer le dossier", @@ -6422,6 +6486,7 @@ const fr = { // through pt (see FALLBACK), so the two variants cannot drift and a key // added to pt later is picked up here rather than reverting to English. const ptPT = { + "key.mode.harmonicMinor": "menor harmónica", "lyrics.align.hint": "Coloque o cursor de reprodução onde a primeira linha é cantada e prima Começar a letra aqui. Para usar outra linha, clique nela primeiro e depois leve o cursor até onde é cantada.", "lyrics.align.targetPicked": "Move a linha em que clicou: “{line}”", "lyrics.align.detecting": "A verificar a voz…", @@ -6837,10 +6902,13 @@ const es = { "trash.noSearchMatch": "Ninguna pista eliminada coincide con tu búsqueda", "meta.key": "Tonalidad", + "key.label": "{tonic} {mode}", + "key.mode.major": "mayor", + "key.mode.minor": "menor", + "key.mode.harmonicMinor": "menor armónica", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "Duración", - "meta.scale": "Escala", "meta.dynamicRange": "Rango dinámico", "meta.tempoStability": "Estabilidad del tempo", @@ -7132,6 +7200,7 @@ const es = { "settings.device.cpu": "CPU", "settings.device.currently": " (actualmente: {device})", "settings.device.notAvailable": " — no disponible", + "settings.device.torchBroken": "El motor de separación no pudo iniciarse en este equipo, así que no se pueden separar pistas en ningún dispositivo. Reinstalar StemDeck lo repara. El motivo está en el registro.", "settings.quality.title": "Calidad de la separación", "settings.quality.desc": "«Máxima» ejecuta el separador dos veces con desplazamientos aleatorios y promedia el resultado: stems más limpios, el doble de tiempo.", "settings.quality.standard": "Estándar", @@ -7172,6 +7241,10 @@ const es = { "track.removed": "Eliminada", "track.moveToTrash": "Mover a la papelera", "track.moveTitleToTrash": "Mover {title} a la papelera", + "track.favorite": "Añadir a favoritos", + "track.unfavorite": "Quitar de favoritos", + "track.favoriteTitle": "Añadir {title} a favoritos", + "track.unfavoriteTitle": "Quitar {title} de favoritos", "folder.dragToReorder": "Arrastra para reordenar", "folder.newSubfolder": "Nueva subcarpeta", "folder.deleteFolder": "Eliminar carpeta", @@ -7634,10 +7707,13 @@ const ko = { "trash.noSearchMatch": "검색과 맞는 삭제된 트랙이 없어요", "meta.key": "키", + "key.label": "{tonic} {mode}", + "key.mode.major": "메이저", + "key.mode.minor": "마이너", + "key.mode.harmonicMinor": "하모닉 마이너", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "길이", - "meta.scale": "음계", "meta.dynamicRange": "다이내믹 레인지", "meta.tempoStability": "템포 안정성", @@ -7925,6 +8001,7 @@ const ko = { "settings.device.cpu": "CPU", "settings.device.currently": " (현재: {device})", "settings.device.notAvailable": " (사용할 수 없음)", + "settings.device.torchBroken": "이 컴퓨터에서 분리 엔진을 시작할 수 없어 어떤 장치에서도 곡을 분리할 수 없습니다. StemDeck을 다시 설치하면 해결됩니다. 원인은 로그에 기록되어 있습니다.", "settings.quality.title": "분리 품질", "settings.quality.desc": "최고 품질은 분리기를 두 번 돌려 무작위로 어긋낸 결과를 평균해요. 스템이 더 깨끗해지지만 시간은 두 배 걸려요.", "settings.quality.standard": "표준", @@ -7963,6 +8040,10 @@ const ko = { "track.removed": "삭제됨", "track.moveToTrash": "휴지통으로 옮기기", "track.moveTitleToTrash": "{title}을(를) 휴지통으로 옮기기", + "track.favorite": "즐겨찾기에 추가", + "track.unfavorite": "즐겨찾기에서 제거", + "track.favoriteTitle": "{title}을(를) 즐겨찾기에 추가", + "track.unfavoriteTitle": "{title}을(를) 즐겨찾기에서 제거", "folder.dragToReorder": "끌어서 순서 바꾸기", "folder.newSubfolder": "새 하위 폴더", "folder.deleteFolder": "폴더 삭제", diff --git a/static/js/job.js b/static/js/job.js index a41b4cbb..547b39e7 100644 --- a/static/js/job.js +++ b/static/js/job.js @@ -13,6 +13,7 @@ import { addTrackToLibrary, setCurrentTrack, updateTrackStatus, applyStemPresenc import { initSections } from "./sections.js"; import { importPlaylist, looksLikePlaylist } from "./playlist.js"; import { t } from "./i18n.js"; +import { formatKey } from "./keyLabel.js"; // Playful stage label rotation (Claude-Code-style flair). The backend // emits truthful stage strings; we surface them in the small #job-detail @@ -329,17 +330,12 @@ export function detachForegroundJob() { function applyStudioSummary(state) { 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 (summaryKey && state.key) summaryKey.textContent = formatKey(state.key, state.scale, t); 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 = t("job.peakDb", { value: state.peak_db.toFixed(1) }); if (summaryDuration && state.duration) { @@ -355,7 +351,6 @@ function applyStudioSummary(state) { 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"); diff --git a/static/js/keyLabel.js b/static/js/keyLabel.js new file mode 100644 index 00000000..34c0114a --- /dev/null +++ b/static/js/keyLabel.js @@ -0,0 +1,35 @@ +// A track's key as one label, "B minor" or "B harmonic minor" (#736). +// +// Analysis stores the key as "B min" and the scale separately as "Natural +// Minor", and the analysis strip used to show both plus a Scale card of its +// own: "D maj", then "Major", then "Major" again. One label says it once. +// +// Note names stay as letters in every language, the convention DAWs and chord +// charts use, rather than H or Si. The mode is translated and placed by a +// template, because word order differs ("B minor", "B-Moll" style, "B 小调"). + +// The scale names analysis can produce, to the mode each is shown as. +const MODE_KEYS = { + "Major": "key.mode.major", + "Natural Minor": "key.mode.minor", + "Harmonic Minor": "key.mode.harmonicMinor", +}; + +/** + * @param {string|null|undefined} key as stored, e.g. "F# min" + * @param {string|null|undefined} scale as stored, e.g. "Harmonic Minor" + * @param {(key: string, vars?: object) => string} t the i18n lookup + * @returns {string} the label, or "" when there is no key to show + */ +export function formatKey(key, scale, t) { + const text = String(key ?? "").trim(); + if (!text) return ""; + const [tonic, suffix] = text.split(/\s+/); + // A scale this does not know (or none, from tracks analysed before it was + // stored) falls back on the key's own maj/min suffix. + let modeKey = MODE_KEYS[scale]; + if (!modeKey && suffix === "maj") modeKey = "key.mode.major"; + if (!modeKey && suffix === "min") modeKey = "key.mode.minor"; + if (!modeKey) return text; + return t("key.label", { tonic, mode: t(modeKey) }); +} diff --git a/static/js/player.js b/static/js/player.js index 4cbee674..e1c1c9db 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -223,19 +223,15 @@ function clearStemSelectionFilter() { function resetAnalysisCards() { const summaryKey = document.getElementById("summary-key"); const summaryBpm = document.getElementById("summary-bpm"); - const summaryScale = document.getElementById("summary-scale"); const summaryConfidence = document.getElementById("summary-confidence"); - const summaryConfidenceLabel = document.getElementById("summary-confidence-label"); const loudnessCard = document.getElementById("loudness-card"); if (summaryKey) summaryKey.textContent = "—"; if (summaryBpm) summaryBpm.innerHTML = "— BPM"; - if (summaryScale) summaryScale.textContent = ""; if (summaryConfidence) { summaryConfidence.textContent = ""; summaryConfidence.style.removeProperty("--confidence-pct"); summaryConfidence.classList.add("hidden"); } - if (summaryConfidenceLabel) summaryConfidenceLabel.classList.add("hidden"); if (loudnessCard) loudnessCard.classList.add("hidden"); } diff --git a/tests/e2e/favorite-reach.spec.mjs b/tests/e2e/favorite-reach.spec.mjs new file mode 100644 index 00000000..5cc37dfc --- /dev/null +++ b/tests/e2e/favorite-reach.spec.mjs @@ -0,0 +1,74 @@ +// A track can be favourited at any window width (#724). +// +// The only heart used to be the Now Playing card's, and daw.css hides that +// whole card below 1460 px. A maximised laptop window is under that, so there +// was no way to favourite at all. Library and Favorites rows have one now, and +// every heart goes through one toggle, so they cannot disagree. + +import { test, expect } from "@playwright/test"; +import { JOB_ID, SIBLING_JOB_ID, openStudio, readCatalogState } from "./helpers.mjs"; + +const row = (page, id) => page.locator(`#catalogList .cat-item[data-id="${id}"]`).first(); +const rowHeart = (page, id) => row(page, id).locator(".cat-fav"); +const favoriteIn = async (page, id) => Boolean((await readCatalogState(page))?.tracks?.[id]?.favorite); + +test.describe("favourites at a narrow window", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("a library row can favourite a track the Now Playing card cannot reach", async ({ page }) => { + await openStudio(page); + await expect(page.locator("#fav-btn")).toBeHidden(); + + await row(page, SIBLING_JOB_ID).hover(); + await expect(rowHeart(page, SIBLING_JOB_ID)).toBeVisible(); + await rowHeart(page, SIBLING_JOB_ID).click(); + + expect(await favoriteIn(page, SIBLING_JOB_ID)).toBe(true); + await expect(rowHeart(page, SIBLING_JOB_ID)).toHaveAttribute("aria-pressed", "true"); + await expect(row(page, SIBLING_JOB_ID)).toHaveClass(/\bis-fav\b/); + // The heart is a button on the row, not a way of opening the track. + await expect(row(page, JOB_ID)).toHaveClass(/\bactive\b/); + await expect(row(page, SIBLING_JOB_ID)).not.toHaveClass(/\bactive\b/); + }); + + test("the Favorites view can take a track back out", async ({ page }) => { + await openStudio(page); + await row(page, SIBLING_JOB_ID).hover(); + await rowHeart(page, SIBLING_JOB_ID).click(); + + await page.locator(".rail-favorites").click(); + await expect(row(page, SIBLING_JOB_ID)).toBeVisible(); + await row(page, SIBLING_JOB_ID).hover(); + await rowHeart(page, SIBLING_JOB_ID).click(); + + await expect(row(page, SIBLING_JOB_ID)).toHaveCount(0); + expect(await favoriteIn(page, SIBLING_JOB_ID)).toBe(false); + }); + + test("keyboard focus reveals the row heart", async ({ page }) => { + await openStudio(page); + await rowHeart(page, SIBLING_JOB_ID).focus(); + // Polled: the reveal fades in over a short transition. + await expect.poll(() => row(page, SIBLING_JOB_ID).locator(".cat-actions") + .evaluate((el) => Number(getComputedStyle(el).opacity))).toBeGreaterThan(0.9); + }); +}); + +test.describe("favourites at a wide window", () => { + test.use({ viewport: { width: 1600, height: 900 } }); + + test("the row heart and the Now Playing heart stay in step", async ({ page }) => { + await openStudio(page); + const npHeart = page.locator("#fav-btn"); + await expect(npHeart).toBeVisible(); + await expect(npHeart).toHaveAttribute("aria-pressed", "false"); + + await row(page, JOB_ID).hover(); + await rowHeart(page, JOB_ID).click(); + await expect(npHeart).toHaveAttribute("aria-pressed", "true"); + + await npHeart.click(); + await expect(rowHeart(page, JOB_ID)).toHaveAttribute("aria-pressed", "false"); + expect(await favoriteIn(page, JOB_ID)).toBe(false); + }); +}); diff --git a/tests/e2e/key-card.spec.mjs b/tests/e2e/key-card.spec.mjs new file mode 100644 index 00000000..56c6e7be --- /dev/null +++ b/tests/e2e/key-card.spec.mjs @@ -0,0 +1,64 @@ +// The key is one label on one card, and it fits (#736). +// +// It used to be "D maj" on the Key card, "Major" under it and "Major" again on +// a Scale card of its own, while the Key card was too narrow for anything +// longer. The longest label analysis can produce is a sharp harmonic minor, and +// German spells it longest, so that is what this measures. + +import { test, expect } from "@playwright/test"; +import { + JOB_ID, SIBLING_JOB_ID, TRACK_TITLE, SIBLING_TITLE, + fixtureTrack, seedCatalogState, stubExportEndpoints, stubUpdateCheck, +} from "./helpers.mjs"; + +async function openWithKey(page, { language }) { + await page.addInitScript((code) => window.localStorage.setItem("stemdeck.language", JSON.stringify(code)), language); + await seedCatalogState(page, { + folders: [ + { id: "f-unsorted", name: "Unsorted", items: [JOB_ID, SIBLING_JOB_ID], color: null }, + { id: "trash", name: "Trash", items: [], color: null }, + ], + tracks: { + [JOB_ID]: { ...fixtureTrack(JOB_ID, TRACK_TITLE), key: "F# min", scale: "Harmonic Minor", keyConfidence: 72 }, + [SIBLING_JOB_ID]: fixtureTrack(SIBLING_JOB_ID, SIBLING_TITLE), + }, + }); + // The server's own record wins over the store once the page syncs, and the + // fixture job says "C maj", so the answer is rewritten on the way in. + await page.route(`**/api/jobs**`, async (route) => { + const response = await route.fetch(); + let body = await response.text(); + if ((response.headers()["content-type"] || "").includes("json")) { + body = body.replaceAll('"key":"C maj"', '"key":"F# min"') + .replaceAll('"key": "C maj"', '"key": "F# min"') + .replaceAll('"scale":"Major"', '"scale":"Harmonic Minor"') + .replaceAll('"scale": "Major"', '"scale": "Harmonic Minor"'); + } + await route.fulfill({ response, body }); + }); + await stubExportEndpoints(page); + await stubUpdateCheck(page, { available: false }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().click(); +} + +// Background syncs are still in flight through the rewriter when a test ends. +test.afterEach(async ({ page }) => { + await page.unrouteAll({ behavior: "ignoreErrors" }); +}); + +const EXPECTED = { en: "F# harmonic minor", de: "F# harmonisches Moll", fr: "F# mineur harmonique" }; + +for (const [language, label] of Object.entries(EXPECTED)) { + for (const width of [1366, 950]) { + test(`${language} at ${width}px: the key is one label and it fits`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await openWithKey(page, { language }); + const key = page.locator("#summary-key"); + await expect(key).toHaveText(label); + await expect(page.locator('[data-meta="scale"]')).toHaveCount(0); + const clipped = await key.evaluate((el) => el.scrollWidth > el.clientWidth); + expect(clipped).toBe(false); + }); + } +} diff --git a/tests/js/key-label.test.mjs b/tests/js/key-label.test.mjs new file mode 100644 index 00000000..10afc7b2 --- /dev/null +++ b/tests/js/key-label.test.mjs @@ -0,0 +1,61 @@ +// The merged key label (#736): "B minor", not "B min" plus "Natural Minor" plus +// a Scale card saying it a third time. +// +// Run: node tests/js/key-label.test.mjs + +import { formatKey } from '../../static/js/keyLabel.js'; +import { TRANSLATIONS } from '../../static/js/i18n.js'; + +let passed = 0; +let failed = 0; + +function check(name, condition, detail = '') { + if (condition) { + passed++; + console.log(`PASS ${name}`); + } else { + failed++; + console.log(`FAIL ${name}${detail ? ` -- ${detail}` : ''}`); + } +} + +// The same lookup the app does: the language, then pt for pt-PT, then English. +const FALLBACK = { 'pt-PT': 'pt' }; +function tFor(code) { + return (key, vars = {}) => { + let text; + for (let c = code; c && text === undefined; c = FALLBACK[c]) text = TRANSLATIONS[c]?.[key]; + text ??= TRANSLATIONS.en[key] ?? key; + return text.replace(/\{(\w+)\}/g, (_, v) => String(vars[v] ?? `{${v}}`)); + }; +} +const en = tFor('en'); + +check('a major key reads as one label', formatKey('D maj', 'Major', en) === 'D major', formatKey('D maj', 'Major', en)); +check('a natural minor key drops "natural"', formatKey('B min', 'Natural Minor', en) === 'B minor'); +check('harmonic minor is named', formatKey('B min', 'Harmonic Minor', en) === 'B harmonic minor'); +check('sharps keep their letter', formatKey('F# min', 'Natural Minor', en) === 'F# minor'); +check('a track analysed before scale was stored still reads', formatKey('F# min', undefined, en) === 'F# minor'); +check('an unknown scale falls back on the key', formatKey('C maj', 'Dorian', en) === 'C major'); +check('an unreadable key is shown as stored', formatKey('weird', null, en) === 'weird'); +check('no key gives nothing', formatKey(null, 'Major', en) === '' && formatKey('', null, en) === ''); + +// Every language, including the regional variant, produces a complete label: +// the tonic letter, no raw key names, no unfilled placeholder. +for (const code of Object.keys(TRANSLATIONS)) { + const t = tFor(code); + for (const scale of ['Major', 'Natural Minor', 'Harmonic Minor']) { + const label = formatKey('F# min', scale, t); + check( + `${code}: ${scale} is a complete label`, + label.startsWith('F#') && !/key\.|\{|\}/.test(label) && label.length > 3, + label, + ); + } +} + +check('German reads naturally', formatKey('B min', 'Natural Minor', tFor('de')) === 'B Moll'); +check('European Portuguese keeps its spelling', formatKey('B min', 'Harmonic Minor', tFor('pt-PT')) === 'B menor harmónica'); + +console.log(`\n${passed}/${passed + failed} checks passed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/tests/keyset.py b/tests/keyset.py new file mode 100644 index 00000000..9b0736d6 --- /dev/null +++ b/tests/keyset.py @@ -0,0 +1,106 @@ +"""A labelled set of synthetic songs for measuring key detection (#726). + +Each song is a chord loop over a bass line, rendered to audio with numpy, so +the right answer is known and nothing is downloaded. The harmony and the bass +come back separately as well as mixed, which is what lets the full-mix path +and the stems path be scored on the same material. + +The progressions are the ones that make relative keys hard: a minor loop such +as i-VI-III-VII uses exactly the notes of its relative major. Like real songs, +each loop starts on its tonic chord and the song ends on it. +""" + +from __future__ import annotations + +import zlib +from dataclasses import dataclass + +import numpy as np + +SR = 22050 +PITCHES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") + +# Chords as (semitones above the tonic, quality). "M" major, "m" minor. +PROGRESSIONS: dict[str, tuple[str, list[tuple[int, str]]]] = { + # Major keys + "I-V-vi-IV": ("maj", [(0, "M"), (7, "M"), (9, "m"), (5, "M")]), + "I-IV-V-I": ("maj", [(0, "M"), (5, "M"), (7, "M"), (0, "M")]), + "I-vi-IV-V": ("maj", [(0, "M"), (9, "m"), (5, "M"), (7, "M")]), + "I-bVII-IV-I": ("maj", [(0, "M"), (10, "M"), (5, "M"), (0, "M")]), + "vi-IV-I-V": ("maj", [(9, "m"), (5, "M"), (0, "M"), (7, "M")]), + # Natural minor keys + "i-VI-III-VII": ("min", [(0, "m"), (8, "M"), (3, "M"), (10, "M")]), + "i-VII-VI-VII": ("min", [(0, "m"), (10, "M"), (8, "M"), (10, "M")]), + "i-iv-v-i": ("min", [(0, "m"), (5, "m"), (7, "m"), (0, "m")]), + "i-III-VII-iv": ("min", [(0, "m"), (3, "M"), (10, "M"), (5, "m")]), + # Harmonic minor: a major V carries the raised seventh + "i-iv-V-i": ("hmin", [(0, "m"), (5, "m"), (7, "M"), (0, "m")]), + "i-VI-iv-V": ("hmin", [(0, "m"), (8, "M"), (5, "m"), (7, "M")]), +} + +# Every progression in three keys, spread round the circle. +TONICS = (11, 4, 7) # B, E, G + + +@dataclass +class Song: + name: str + tonic: int + mode: str # "maj", "min" or "hmin" + harmony: np.ndarray + bass: np.ndarray + + @property + def mix(self) -> np.ndarray: + return self.harmony + self.bass + + @property + def label(self) -> str: + return f"{PITCHES[self.tonic]} {'maj' if self.mode == 'maj' else 'min'}" + + @property + def scale(self) -> str: + return {"maj": "Major", "min": "Natural Minor", "hmin": "Harmonic Minor"}[self.mode] + + +def _tone(freq: float, n: int, harmonics: int, rng: np.random.Generator) -> np.ndarray: + t = np.arange(n) / SR + phase = rng.uniform(0, 2 * np.pi) + out = np.zeros(n) + for h in range(1, harmonics + 1): + out += np.sin(2 * np.pi * freq * h * t + phase * h) / h + # A short attack and release, so chord changes are not clicks. + env = np.minimum(1.0, np.minimum(t / 0.02, (n / SR - t) / 0.05)) + return out * np.clip(env, 0, 1) + + +def _midi_freq(midi: int) -> float: + return 440.0 * 2 ** ((midi - 69) / 12) + + +def render(name: str, tonic: int, bars_per_chord: int = 1, loops: int = 4, bpm: int = 110) -> Song: + mode, chords = PROGRESSIONS[name] + rng = np.random.default_rng(zlib.crc32(f"{name}/{tonic}".encode())) + bar = int(SR * 4 * 60 / bpm) * bars_per_chord + harmony: list[np.ndarray] = [] + bass: list[np.ndarray] = [] + sequence = chords * loops + [chords[0]] # end on the tonic chord + for offset, quality in sequence: + root = tonic + offset + third = 4 if quality == "M" else 3 + chord = np.zeros(bar) + for interval in (0, third, 7): + chord += _tone(_midi_freq(60 + (root + interval) % 12), bar, 6, rng) + harmony.append(0.12 * chord) + # Root on the beat, fifth on the offbeats: a plain rock bass line. + beat = bar // (4 * bars_per_chord) + line = np.zeros(bar) + for b in range(4 * bars_per_chord): + note = root if b % 2 == 0 else root + 7 + line[b * beat : (b + 1) * beat] = _tone(_midi_freq(36 + note % 12), beat, 4, rng) + bass.append(0.25 * line) + return Song(name, tonic % 12, mode, np.concatenate(harmony), np.concatenate(bass)) + + +def songs() -> list[Song]: + return [render(name, tonic) for name in PROGRESSIONS for tonic in TONICS] diff --git a/tests/test_key_detection.py b/tests/test_key_detection.py new file mode 100644 index 00000000..617f44a1 --- /dev/null +++ b/tests/test_key_detection.py @@ -0,0 +1,156 @@ +"""Telling a minor key from its relative major (#726). + +A minor loop such as i-VI-III-VII uses exactly the notes of its relative +major, so a whole-song pitch histogram cannot separate them. The detector used +to break the tie by how loud each candidate's root was, and in a minor song +the relative major's root is usually louder: every i-VI-III-VII came out as +its relative major, at up to 100% confidence. "Plug in Baby", in B minor, +came out as D major. + +These score the detector on tests/keyset.py, synthetic songs whose keys are +known. On that set the old detector got 27 of 33 keys and 21 of 33 keys with +their scale; it never reported harmonic minor. +""" + +from __future__ import annotations + +import wave +from pathlib import Path + +import numpy as np +import pytest + +from app.core.models import Job +from app.pipeline import analyze as az +from tests import keyset +from tests.ffmpeg_probe import skip_without_ffmpeg + + +def _stems_key(song: keyset.Song) -> tuple[str, str, int]: + result = az.detect_key_from_audio( + song.harmony.astype(np.float32), song.bass.astype(np.float32), keyset.SR + ) + assert result is not None + return result + + +def _mix_key(song: keyset.Song) -> tuple[str, str, int]: + import librosa + + harmonic, _ = librosa.effects.hpss(song.mix.astype(np.float32)) + result = az.detect_key_from_audio(harmonic, None, keyset.SR) + assert result is not None + return result + + +def test_the_reported_loop_is_minor_from_the_mix(): + # B minor, i-VI-III-VII: the old detector said D major. + song = keyset.render("i-VI-III-VII", 11) + label, scale, _ = _mix_key(song) + assert (label, scale) == ("B min", "Natural Minor") + + +@pytest.mark.parametrize( + "name, tonic", + [("i-VI-III-VII", 11), ("i-VII-VI-VII", 7), ("I-V-vi-IV", 4), ("i-iv-v-i", 4)], +) +def test_keys_from_stems(name, tonic): + song = keyset.render(name, tonic) + label, scale, _ = _stems_key(song) + assert (label, scale) == (song.label, song.scale) + + +@pytest.mark.parametrize("name", ["i-iv-V-i", "i-VI-iv-V"]) +def test_a_major_five_reads_as_harmonic_minor(name): + song = keyset.render(name, 4) + label, scale, _ = _stems_key(song) + assert (label, scale) == ("E min", "Harmonic Minor") + + +def test_the_whole_labelled_set_from_stems(): + # 30 of 33 when this was written. The three misses are vi-IV-I-V, which + # starts and ends on its minor chord and reads as minor: ambiguous enough + # that the set's "major" label is arguable. + songs = keyset.songs() + right = sum(_stems_key(s)[:2] == (s.label, s.scale) for s in songs) + assert right >= 30, f"{right} of {len(songs)}" + + +def test_confidence_does_not_depend_on_level(): + # It did: the old score multiplied correlation by a raw chroma value, so + # the same vector read 100% raw and 21% normalised. + vec = [0.9, 0.05, 0.4, 0.05, 0.7, 0.4, 0.05, 0.8, 0.05, 0.5, 0.05, 0.4] + edges = [([1.0, 0, 0, 0, 0.8, 0, 0, 0.9, 0, 0, 0, 0], [1.0] + [0.0] * 11)] + for with_edges in (None, edges): + loud = az._detect_key([v * 5 for v in vec], with_edges) + quiet = az._detect_key([v / 5 for v in vec], with_edges) + assert loud == quiet + + +def test_edges_decide_between_a_key_and_its_relative(): + # The same histogram, opened on a C major chord or on an A minor one. + diatonic = [1.0, 0.05, 0.8, 0.05, 0.9, 0.8, 0.05, 0.9, 0.05, 0.9, 0.05, 0.7] + c_chord = [1.0, 0, 0, 0, 0.9, 0, 0, 0.9, 0, 0, 0, 0] + a_chord = [0.9, 0, 0, 0, 0.9, 0, 0, 0, 0, 1.0, 0, 0] + c_bass = [1.0] + [0.0] * 11 + a_bass = [0.0] * 9 + [1.0, 0.0, 0.0] + assert az._detect_key(diatonic, [(c_chord, c_bass)])[0] == "C maj" + assert az._detect_key(diatonic, [(a_chord, a_bass)])[0] == "A min" + + +# ── refine_key_from_stems ──────────────────────────────────────────── + + +def _write_wav(path: Path, samples: np.ndarray, sr: int) -> None: + pcm = (np.clip(samples, -1, 1) * 32767).astype(" Job: + job = Job(id="a1b2c3d4e5f6") + job.key, job.scale, job.key_confidence = key, scale, 90 + return job + + +def _stems_dir(job: Job) -> Path: + # The decoder only reads inside the jobs directory, which the test + # fixtures point at a temporary one. + d = az.JOBS_DIR / job.id / "stems" + d.mkdir(parents=True, exist_ok=True) + return d + + +def test_stems_replace_the_mix_estimate(): + skip_without_ffmpeg() + song = keyset.render("i-VI-III-VII", 11) + job = _job() + stems = _stems_dir(job) + _write_wav(stems / "other.wav", song.harmony, keyset.SR) + _write_wav(stems / "bass.wav", song.bass, keyset.SR) + az.refine_key_from_stems(job, stems) + assert (job.key, job.scale) == ("B min", "Natural Minor") + + +def test_no_stems_keeps_the_mix_estimate(): + job = _job() + az.refine_key_from_stems(job, _stems_dir(job)) + assert (job.key, job.scale, job.key_confidence) == ("D maj", "Major", 90) + + +def test_a_failure_keeps_the_mix_estimate(monkeypatch): + skip_without_ffmpeg() + song = keyset.render("i-VI-III-VII", 11) + job = _job() + stems = _stems_dir(job) + _write_wav(stems / "other.wav", song.harmony, keyset.SR) + + def boom(*_a, **_k): + raise RuntimeError("chroma exploded") + + monkeypatch.setattr(az, "detect_key_from_audio", boom) + az.refine_key_from_stems(job, stems) + assert (job.key, job.scale, job.key_confidence) == ("D maj", "Major", 90) diff --git a/tests/test_torch_unloadable.py b/tests/test_torch_unloadable.py new file mode 100644 index 00000000..f1d088ff --- /dev/null +++ b/tests/test_torch_unloadable.py @@ -0,0 +1,87 @@ +"""A torch that is installed but cannot load must not take the server down (#730). + +On Windows, a CUDA DLL left behind by a half-reverted CUDA install makes torch's +own DLL loader raise OSError (WinError 127) from `import torch` (#723). The +device probe caught only ImportError, so with the device on "auto" the backend +died at startup, and with it forced to CPU every /api/settings call was a 500. + +These install an import hook that makes `import torch` raise exactly that, so +they run whether or not a real torch is present. +""" + +from __future__ import annotations + +import importlib.abc +import importlib.machinery +import sys + +import pytest +from fastapi.testclient import TestClient + +from app.core import config + +REASON = "[WinError 127] The specified procedure could not be found. Error loading c10_cuda.dll" + + +class _BrokenTorchLoader(importlib.abc.Loader): + def create_module(self, spec): + return None + + def exec_module(self, module): + raise OSError(REASON) + + +class _BrokenTorchFinder(importlib.abc.MetaPathFinder): + def find_spec(self, name, path=None, target=None): + if name == "torch": + return importlib.machinery.ModuleSpec(name, _BrokenTorchLoader()) + return None + + +@pytest.fixture +def broken_torch(monkeypatch): + for name in [m for m in sys.modules if m == "torch" or m.startswith("torch.")]: + monkeypatch.delitem(sys.modules, name) + monkeypatch.setattr(sys, "meta_path", [_BrokenTorchFinder(), *sys.meta_path]) + monkeypatch.setattr(config, "_torch_load_error", None) + yield + + +def test_the_probe_falls_back_to_cpu(broken_torch): + assert config.available_torch_devices() == ["cpu"] + assert config.detect_torch_device() == "cpu" + + +def test_the_probe_says_why(broken_torch): + config.available_torch_devices() + assert config.torch_load_error() == f"OSError: {REASON}" + + +def test_the_reason_is_logged_once_per_cause(broken_torch, caplog): + with caplog.at_level("ERROR", logger="stemdeck.config"): + config.available_torch_devices() + config.available_torch_devices() + assert len([r for r in caplog.records if "could not be loaded" in r.getMessage()]) == 1 + + +def test_settings_still_answer_and_carry_the_error(broken_torch): + from app.main import app + + with TestClient(app) as c: + resp = c.get("/api/settings") + assert resp.status_code == 200 + body = resp.json() + assert body["demucs_devices_available"] == ["cpu"] + assert body["torch_error"] == f"OSError: {REASON}" + + +def test_a_working_torch_reports_no_error(monkeypatch): + # The healthy path: whatever this machine has, a torch that imports (or is + # simply absent) leaves no error behind. + monkeypatch.setattr(config, "_torch_load_error", "OSError: stale") + try: + import torch # noqa: F401 + except ImportError: + monkeypatch.setattr(config, "_torch_load_error", None) + config.available_torch_devices() + assert config.torch_load_error() is None From 094e0a912f39929ed074e0ba20e18bd8debcd88f Mon Sep 17 00:00:00 2001 From: "Tha.Les" <31625914+thcp@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:27:29 +0100 Subject: [PATCH 5/7] Fix the open issues after 0.19.0 (#740) * fix(desktop): lift rustls and quick-xml past their advisories rustls 0.23.40 -> 0.23.45 (RUSTSEC-2026-0285) is reached through reqwest at runtime: every download and the updater. plist 1.9 -> 1.10.1 moves quick-xml 0.39.3 -> 0.42.0 (RUSTSEC-2026-0194, -0195), build time only. Lockfile only: no Cargo.toml or uv.lock change, so the in-app update still applies. cargo audit now reports no vulnerabilities, only the existing unmaintained and unsound warnings. Closes #738 * feat(ui): stack the composer's actions, and keep the card at every width The top bar's right-hand end follows the layout suggested in #725: Detect structure sits over the main button, which now reads Extract stems to match the Extract row it acts on, in all ten languages, and the Collapse toggles stack in a column beside them. That end of the bar is about 50px narrower. The Now Playing card no longer leaves the bar below 1460px, which took About this song with it and left no way to open it (#735). It gives way in steps instead, by container query: the artwork and meta line first, then the title, down to the heart and the About button. The grid gives the card a 72px floor, so the Extract chips fold into their overflow button before the card can be squeezed out. Tests: about-reach.spec.mjs opens About this song at 1024, 1280, 1366 and 1600px, which fails on the old layout below 1460, and checks the stacking. favorite-reach.spec.mjs no longer expects the card's heart to be hidden. Closes #725 Closes #735 * feat(library): keep favourites on the server, so the phone can use them Favourites lived only in the studio's catalog store, which the phone cannot reach: it had no heart to press, and its Favorites chip set a filter nothing read, so it listed every track. Each job now carries favorite (null until a client says, then true or false), set by PUT /api/jobs/{id}/favorite with a strict bool. The studio sends every heart there, adopts the server's value on each sync and when its window is looked at again, and hands up a heart set before this change while the server still says null. The phone has a heart on every row and the chip filters for real. Tests: test_jobs_favorite.py covers the endpoint (shape, strict body, 404, crafted ids, the record round trip); favorite-sync.spec.mjs covers both directions and the phone. The e2e helpers stub the writes so no spec leaves a favourite on the shared backend. Closes #734 * refactor(lyrics): let the server alone decide which lyrics are a track's Lyrics were matched to a song in two places: the import's lookup on the server, and the Lyrics tab's own LRCLIB search in the browser, each with its own copy of which version is this song by this artist and which length fits. They were kept equal by hand, and when they drifted a track could get lyrics at import and none in the tab. The tab now asks POST /api/jobs/{id}/lyrics/lookup, which runs the import's lookup, keeps what it finds the way the import does, and answers as GET .../lyrics then would: 502 when LRCLIB is out of reach, 404 with nothing_known when the track says too little. The band saved in the artist box goes along, since it lives in the studio's store. LRCLIB is still only asked when the tab is opened or at import, and a phone or offline client still shows kept lyrics. The browser's search, ranking and artist matching are gone from lyricsLookup.js, and LRCLIB leaves the page's connect-src. sameSong stays, for lyrics the tab kept before the server held them to the song. Tests: test_lyrics_api.py covers the endpoint (kept, saved band, nothing known, another artist, offline, crafted ids); the lyrics e2e specs answer the lookup with stubLyricsLookup, which keeps lyrics the way the server does; acceptance E4 answers it as LRCLIB unreachable. Closes #719 * feat(transport): let the slow speed be set, from 0.50x to 0.99x Speed was a fixed 0.75x or 1x. Some fills want 0.75x and some only a nudge, which is what #701 asked for. The slow button now moves a hundredth at a time when scrolled over, or with the up and down arrow keys on it, between 0.50x and 0.99x, and plays at it. Its label is the speed, so the footer keeps its width, 1x stays one press away, and the slow speed is kept between sessions. The tooltip says how, in all ten languages. The 0.75x floor came from WSOLA, whose artefacts made slower speeds hard to follow (#433). Signalsmith Stretch (#729) holds up further down, so the floor is 0.5x. A turn of the wheel is applied once it stops, since each change of rate flushes the tempo stage. Tests: slow-speed.spec.mjs (wheel, arrow keys, both limits, a reload). Closes #701 --------- Co-authored-by: Thales <> --- app/api/jobs.py | 101 +++++++- app/core/models.py | 11 + app/core/registry.py | 10 + app/main.py | 12 +- desktop/src-tauri/Cargo.lock | 24 +- static/css/daw.css | 110 +++++---- static/css/variables.css | 2 +- static/index.html | 2 +- static/js/catalog.js | 56 ++++- static/js/i18n.js | 31 ++- static/js/job.js | 4 +- static/js/lyrics.js | 82 +++---- static/js/lyricsLookup.js | 316 +------------------------ static/js/shared/jobs.js | 3 + static/js/transport.js | 84 +++++-- static/js/utils.js | 2 +- static/mobile/app.js | 43 +++- static/mobile/styles.css | 13 + tests/acceptance/README.md | 2 +- tests/acceptance/acceptance.spec.mjs | 28 ++- tests/acceptance/helpers.mjs | 8 +- tests/e2e/about-reach.spec.mjs | 36 +++ tests/e2e/audio-tags-backfill.spec.mjs | 15 +- tests/e2e/composer-arming.spec.mjs | 2 +- tests/e2e/favorite-reach.spec.mjs | 12 +- tests/e2e/favorite-sync.spec.mjs | 57 +++++ tests/e2e/helpers.mjs | 89 ++++++- tests/e2e/lyrics-cjk.spec.mjs | 39 ++- tests/e2e/lyrics-from-server.spec.mjs | 10 +- tests/e2e/lyrics.spec.mjs | 71 +++--- tests/e2e/search.spec.mjs | 2 +- tests/e2e/slow-speed.spec.mjs | 50 ++++ tests/js/lyrics-lookup.test.mjs | 200 +--------------- tests/js/name-parity.test.mjs | 22 +- tests/js/source-arming.test.mjs | 2 +- tests/test_csp.py | 11 +- tests/test_jobs_favorite.py | 83 +++++++ tests/test_lyrics_api.py | 83 +++++++ 38 files changed, 963 insertions(+), 765 deletions(-) create mode 100644 tests/e2e/about-reach.spec.mjs create mode 100644 tests/e2e/favorite-sync.spec.mjs create mode 100644 tests/e2e/slow-speed.spec.mjs create mode 100644 tests/test_jobs_favorite.py diff --git a/app/api/jobs.py b/app/api/jobs.py index bb7a3ae3..eecca686 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -16,7 +16,7 @@ from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse, Response -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, StrictBool, field_validator from app.core.config import ( DISCOGS_LOOKUP_BUDGET_SEC, @@ -44,6 +44,7 @@ from app.core.registry import persist as registry_persist from app.core.registry import register_if_capacity as registry_register_if_capacity from app.core.registry import remove as registry_remove +from app.core.registry import set_favorite as registry_set_favorite from app.core.registry import set_trashed as registry_set_trashed from app.core.settings import ( get_acoustid_api_key, @@ -68,6 +69,7 @@ copy_lyrics, find_lyrics, keep_answer, + lookup_lyrics, lyrics_path, lyrics_settled, read_candidates, @@ -423,6 +425,25 @@ def restore_job(job_id: str) -> dict: return {"job_id": job.id, "trashed_at": job.trashed_at} +class FavoriteBody(BaseModel): + """Strict, so a string such as "false" is refused rather than read as true.""" + + favorite: StrictBool + + +@router.put("/{job_id}/favorite") +def set_favorite(job_id: str, body: FavoriteBody) -> dict: + """Mark a job as a favourite or take it back out (#734). Kept on the server + so the desktop and the phone share one answer.""" + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + job = registry_set_favorite(job_id, body.favorite) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + registry_persist(JOBS_DIR) + return {"job_id": job.id, "favorite": job.favorite} + + @router.get("/{job_id}") def get_job(job_id: str) -> dict: """Get the current state of a job by ID.""" @@ -1174,6 +1195,84 @@ def get_lyrics(job_id: str) -> Response: raise HTTPException(status_code=404, detail="no lyrics") +class LookupBand(BaseModel): + """The band saved on the track from the artist box, which lives in the + studio's own store and may not be on the job yet.""" + + id: str = Field(pattern=r"^Q\d{1,12}$") + name: str = Field(default="", max_length=300) + englishName: str = Field(default="", max_length=300) # noqa: N815 (the store's own key) + + +class LyricsLookupBody(BaseModel): + band: LookupBand | None = None + + +# Jobs whose lyrics the tab is looking up now, so a second request for the +# same track waits for the first rather than asking LRCLIB twice. +_LYRICS_LOOKUPS: set[str] = set() + + +@router.post("/{job_id}/lyrics/lookup") +async def lookup_lyrics_route(job_id: str, body: LyricsLookupBody | None = None) -> Response: + """Look the track's lyrics up on LRCLIB now, for the Lyrics tab (#719). + + The tab used to search LRCLIB itself, with its own copy of the rules that + decide which version is this song by this artist and which length fits. + The two copies had to be kept equal by hand, and when they drifted the + same track could get lyrics at import and none in the tab. This runs the + import's own lookup instead, keeps what it finds the way the import does, + and answers exactly as GET .../lyrics would afterwards. + + Only ever asked when the tab is opened on a track with no lyrics, or the + user presses Look up again: LRCLIB is never reached on its own. 502 when + LRCLIB cannot be reached, so the tab can offer to try again; 404 with + {"nothing_known": true} when the track says too little to look up. + """ + job, job_dir = _lyrics_job(job_id) + if await asyncio.to_thread(lyrics_path(job_dir).is_file): + return await asyncio.to_thread(get_lyrics, job_id) + if job_id in _LYRICS_LOOKUPS: + raise HTTPException(status_code=409, detail="already looking") + band = body.band.model_dump() if body and body.band else None + query = build_query(job, band=band) + if query is None: + return JSONResponse( + {"detail": "nothing to look up", "others": [], "nothing_known": True}, status_code=404 + ) + _LYRICS_LOOKUPS.add(job_id) + try: + answer = await asyncio.wait_for( + asyncio.to_thread( + lookup_lyrics, + query, + cancelled=lambda: registry_get(job_id) is not job, + fallback_title=job.title or "", + ), + timeout=LYRICS_LOOKUP_BUDGET_SEC + TIMEOUT_LYRICS_LOOKUP + 1, + ) + except asyncio.TimeoutError: + logger.info("[%s] lyrics lookup timed out", job_id) + raise HTTPException(status_code=502, detail="lyrics service unreachable") from None + except Exception: + logger.info("[%s] lyrics lookup failed", job_id, exc_info=True) + raise HTTPException(status_code=502, detail="lyrics service unreachable") from None + finally: + _LYRICS_LOOKUPS.discard(job_id) + if registry_get(job_id) is not job: + raise HTTPException(status_code=404, detail="job not found") + if ( + answer is not None + and not lyrics_path(job_dir).is_file() + and await asyncio.to_thread(keep_answer, job, job_dir, answer) + ): + registry_persist(JOBS_DIR) + try: + return await asyncio.to_thread(get_lyrics, job_id) + except HTTPException: + return JSONResponse({"detail": "no lyrics", "others": []}, status_code=404) + + class LyricsOffsetBody(BaseModel): """How many seconds later than their own timing the lyrics are shown: negative for earlier, 0 for their own timing again.""" diff --git a/app/core/models.py b/app/core/models.py index 77e4abc9..e51c84d2 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -306,6 +306,16 @@ class Job: # A timestamp rather than a bool so the Trash can say when, and so a future # auto-purge has something to work from. trashed_at: float | None = None + # Whether the user marked this track as a favourite, or None if no + # client has said either way yet (#734). + # + # Server-side for the same reason as trashed_at: favourites lived only in + # the desktop page's catalog store, so the phone, which builds its library + # from GET /api/jobs, had no way to set one or to filter by them. None + # rather than False lets the desktop tell "never recorded here" from + # "taken back out", so it can hand up favourites it set before this field + # existed without undoing one removed on the phone. + favorite: bool | None = None # Set by POST /api/jobs/{id}/cancel; consumed by pipeline stages. # Not surfaced via to_state() -- it's internal control state. cancel_requested: bool = False @@ -366,6 +376,7 @@ def to_state(self) -> dict[str, Any]: "stage_timings": self.stage_timings, "vocal_split": self.vocal_split, "trashed_at": self.trashed_at, + "favorite": self.favorite, "created_at": self.created_at, } diff --git a/app/core/registry.py b/app/core/registry.py index 1a732c3e..9b16793f 100644 --- a/app/core/registry.py +++ b/app/core/registry.py @@ -107,6 +107,16 @@ def set_trashed(job_id: str, trashed: bool) -> Job | None: return job +def set_favorite(job_id: str, favorite: bool) -> Job | None: + """Mark a job as a favourite, or take it back out (#734).""" + with _lock: + job = _jobs.get(job_id) + if job is None: + return None + job.favorite = favorite + return job + + def all_jobs() -> dict[str, Job]: """Return a snapshot of the registry for sweep / cleanup.""" with _lock: diff --git a/app/main.py b/app/main.py index 4d5fde4f..238d576b 100644 --- a/app/main.py +++ b/app/main.py @@ -924,10 +924,11 @@ def download_logs_zip() -> StreamingResponse: # it does not re-enable JS eval, new Function or string timers. 'unsafe-inline' is allowed for *styles* # only (the UI sets many style attributes). Allowances: # connect-src -> same-origin API/SSE, the GitHub update check, Tauri IPC, -# Wikidata/Wikipedia for the artist box the now-playing card -# opens, and LRCLIB for the Lyrics tab (#699). All four are -# read-only public APIs, asked only about the open track, and -# sent only an artist and a song name. +# and Wikidata/Wikipedia for the artist box the now-playing +# card opens. Read-only public APIs, asked only about the +# open track, and sent only an artist and a song name. Not +# LRCLIB: the Lyrics tab asks the server to look lyrics up +# (#719), so the page never reaches it. # img-src https: -> remote YouTube/SoundCloud thumbnails # style/font -> the Google Fonts _CSP = ( @@ -942,8 +943,7 @@ def download_logs_zip() -> StreamingResponse: # (#186). They are inline/same-origin schemes, not network endpoints, so # they add no exfiltration channel — script-src below stays locked. "connect-src 'self' https://api.github.com ipc: http://ipc.localhost data: blob: " - "https://www.wikidata.org https://query.wikidata.org https://*.wikipedia.org " - "https://lrclib.net; " + "https://www.wikidata.org https://query.wikidata.org https://*.wikipedia.org; " "object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" ) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index b6a073b8..0a8becdf 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -103,6 +103,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.8.0" @@ -2503,11 +2509,11 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "indexmap 2.14.0", "quick-xml", "serde", @@ -2635,9 +2641,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.3" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ "memchr", ] @@ -2990,9 +2996,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", @@ -3014,9 +3020,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", diff --git a/static/css/daw.css b/static/css/daw.css index 4ae32ca0..b4527f1f 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -292,7 +292,7 @@ input, textarea { font-family: inherit; } color: var(--color); } /* Automatic song-structure detection. It sits at the end of the composer next - to Split stems and matches its footprint, so the pair reads as two segments + to Extract stems and matches its footprint, so the pair reads as two segments of one bar rather than a chip loose beside a button. Colour still separates them: the timeline blue, never the amber the primary action owns. */ .structure-toggle { @@ -4840,19 +4840,27 @@ input, textarea { font-family: inherit; } and are therefore exactly the same width. As a flex row the search grew to whatever was left over, which is why it ran past the last chip. - Columns: content | detect | split | rule | card. Row one is the import - gesture, row two is what to extract. Split sits straight after the options - it acts on, and the loaded track comes after the rule, at the end: in the - middle of the row it read as Split's input, when it is only what is - playing (#699). */ + Columns: content | actions | panel toggles | rule | card. Row one is the + import gesture, row two is what to extract. The two actions stack in one + column, Detect structure over Extract stems, so Extract sits on the chips' + row, straight after the options it acts on, and the Collapse toggles stack + beside them (#725). Stacked rather than side by side, the right-hand end + is about 50px narrower, which is room the card keeps. The loaded track + comes after the rule, at the end: in the middle of the row it read as + Extract's input, when it is only what is playing (#699). */ height: auto; display: grid; /* minmax(0, 1fr), not 1fr: a grid track's automatic minimum is its min-content, so a plain 1fr refuses to shrink below the widest thing in - it. The card sat at its natural width and pushed Split stems off the right + it. The card sat at its natural width and pushed Extract stems off the right edge of its own container. Zero as the floor lets the column give way, and the card's own text clips with an ellipsis rather than forcing the row. */ - grid-template-columns: minmax(0, max-content) auto auto auto minmax(0, 1fr); + /* The card's floor is its compact form, the heart and the About this song + button and nothing else (#735). Below what the whole card needs, the + chips fold into their overflow button first (fitStemChips), so the card + is never squeezed out of the row the way display: none once took it. */ + --np-min: 72px; + grid-template-columns: minmax(0, max-content) auto auto auto minmax(var(--np-min), 1fr); align-items: center; row-gap: 8px; column-gap: 10px; @@ -5003,8 +5011,13 @@ input, textarea { font-family: inherit; } border-left: none; } .daw-composer > .daw-process-btn { - grid-column: 3; - grid-row: 1; + grid-column: 2; + grid-row: 2; + /* As wide as Detect structure above it, and as tall as the chips beside + it, so the bar keeps its height. */ + justify-self: stretch; + min-height: 30px; + height: 30px; } /* 2. Ten percent off the waveform: REMOVED. @@ -5024,31 +5037,30 @@ input, textarea { font-family: inherit; } and it would need the strip and the footer inset to match, or something given the freed space. */ -/* Row two, under the two actions: on the chips' line rather than below it. +/* The Collapse toggles, a column of their own beside the two actions (#725): + the label on top, then one toggle per panel, spanning both rows. Beside the + actions rather than under them, which is what lets Extract stems take the + chips' row. - Columns 2 to 4, the two action columns only, not the rule's: a spanning - item's minimum lands in the first track it covers, and the 1px rule column - would have been handed the toggles' whole width. The action columns are - already as wide as Detect structure and Split stems, which is more than the - toggles need, so spanning only those costs the row nothing. - - A grid item rather than absolutely positioned: out of flow it sat on the - right line and would have landed on top of the Extract chips the moment a - window was narrow enough for the two to meet. */ + A grid item rather than absolutely positioned: out of flow it would land on + top of the Extract chips the moment a window was narrow enough for the two + to meet. */ .daw-composer > .daw-panel-toggles { - grid-column: 2 / 4; - grid-row: 2; - justify-self: end; + grid-column: 3; + grid-row: 1 / 3; align-self: center; + flex-direction: column; + align-items: stretch; + justify-content: center; width: auto; min-width: 0; margin: 0; - /* The wrapper stops being a box of its own: each toggle is a chip now, so a + /* The wrapper stops being a box of its own: each toggle is a chip, so a frame around the group would be a box inside a box. */ padding: 0; background: none; border: none; - gap: 6px; + gap: 4px; } /* 4. The toggles become chips, the same size as the Extract ones. @@ -5057,7 +5069,10 @@ input, textarea { font-family: inherit; } The colours stay theirs, including the strike-through that means a panel is put away. */ .daw-composer-stack .daw-panel-toggle { - height: 30px; + /* 26, not the chips' 30: three to a column have to fit in the height of + the two rows beside them. */ + height: 26px; + justify-content: center; padding: 0 11px; border: 1px solid var(--border); border-radius: 6px; @@ -5075,7 +5090,8 @@ input, textarea { font-family: inherit; } font-size: 12px; letter-spacing: -0.005em; text-transform: none; - margin-right: 4px; + text-align: center; + margin: 0; padding: 0; border: none; } @@ -5130,7 +5146,7 @@ input, textarea { font-family: inherit; } align-self: stretch; /* .footer-track-slot is `width: var(--daw-col-w)`, which is the gutter width it was built to fill in the footer. Here the column decides the width, so - the fixed one has to go or the card never shrinks and pushes Split stems + the fixed one has to go or the card never shrinks and pushes Extract stems out of the composer on a narrow window. */ width: auto; min-width: 0; @@ -5459,25 +5475,25 @@ input, textarea { font-family: inherit; } display: none; } -/* Below about 1450px the two rows cannot hold everything. - * - * Measured rather than picked: the chips need 655px at their widest, the - * toggles 280, Detect structure 164 and Split stems 140. With gaps and the - * bar's own padding that is 1299px of composer before the card gets a pixel, - * which is a window of roughly 1450 once the rail and the margins are paid - * for. - * - * So the two things that were moved up here give way, in the order they can be - * done without: the card first, since the track is also named in the library - * and the mixer, then the toggles drop to a line of their own, which is where - * they lived before. The card's favourite heart is also on every library row - * (#724). Its About this song button has no second home yet (#735). - */ -@media (max-width: 1460px) { - .daw-composer > #nowPlayingPanel { display: none; } - /* The rule only divides the extraction from the card. Without the card it - would divide the row from nothing. */ - .daw-composer > .daw-composer-sep { display: none; } +/* A narrow card keeps what can be done from it (#735). + + It used to leave the bar entirely below a 1460px window, and with it the + only way to open About this song. Now it gives way in steps as its column + narrows, which happens after the Extract chips have folded what they can: + first the artwork and the meta line, then the title, until only the heart + and the About button are left, each still a full-height segment at its + edge. Container queries, because how wide the card ends up depends on the + language as much as on the window. */ +@container (max-width: 239px) { + #nowPlayingPanel .track-panel-cover, + #nowPlayingPanel .daw-track-meta-line, + #nowPlayingPanel .np-artist { display: none; } + #nowPlayingPanel .track-panel-head { padding-left: calc(var(--np-fav) + 2px); } + #nowPlayingPanel .np-legend { left: calc(var(--np-fav) + 8px); } +} +@container (max-width: 129px) { + #nowPlayingPanel .track-panel-info, + #nowPlayingPanel .np-legend { display: none; } } /* The click-track options with nothing to act on. diff --git a/static/css/variables.css b/static/css/variables.css index e81c5cad..b5ae03f3 100644 --- a/static/css/variables.css +++ b/static/css/variables.css @@ -26,7 +26,7 @@ --other: #9ca3af; /* Width shared by the composer's two end segments (Song structure and - Split stems) so they match. See .daw-process-btn / .structure-toggle. */ + Extract stems) so they match. See .daw-process-btn / .structure-toggle. */ --composer-action-w: 140px; /* Accent */ diff --git a/static/index.html b/static/index.html index 1a7182b1..a69fa64a 100644 --- a/static/index.html +++ b/static/index.html @@ -270,7 +270,7 @@ - Split stems + Extract stems diff --git a/static/js/catalog.js b/static/js/catalog.js index ef8d6d42..8fae4f6a 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -942,6 +942,41 @@ function syncTrashToServer(trackId, trashed) { .catch((e) => console.warn(`[catalog] could not ${action} ${trackId} on the server`, e)); } +/** + * Tell the server a track was favourited or taken back out (#734). + * + * The server holds the answer the phone reads, and the phone can change it, so + * this side adopts the server's value on every sync (applyServerFavorite) and + * only says something when the user presses a heart here. Not awaited, for the + * same reason as the Trash: a heart must answer at once. + */ +function syncFavoriteToServer(trackId, favorite) { + fetch(`/api/jobs/${encodeURIComponent(trackId)}/favorite`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ favorite }), + }).catch((e) => console.warn(`[catalog] could not save the favourite for ${trackId}`, e)); +} + +/** + * Take the server's favourite for a track this side already knows. Returns + * whether anything changed here. + * + * null on the server means no client has said either way, which is every + * track from before favourites moved there. A heart set here then is handed up + * rather than dropped; anything the server does know wins, because the phone + * may have changed it since. + */ +function applyServerFavorite(track, state) { + if (state.favorite == null) { + if (track.favorite) syncFavoriteToServer(state.job_id, true); + return false; + } + if (Boolean(track.favorite) === state.favorite) return false; + track.favorite = state.favorite; + return true; +} + function moveTrackToTrash(trackId) { if (!tracks[trackId]) return; removeTrackFromFolders(trackId); @@ -1056,7 +1091,7 @@ async function loadTrackIntoStudio(trackId) { applyStoredStemSelection(track); setCurrentTrack(trackId); - // The composer is an input the Split stems button submits, not a caption for + // The composer is an input the Extract stems button submits, not a caption for // the open track, so it may only ever hold something that can actually be // imported. A `local:` source is a file that was uploaded once and is not // reachable again; putting its bare filename here armed the button with a @@ -1079,7 +1114,7 @@ async function loadTrackIntoStudio(trackId) { // `required` is the guard for "the button has nothing to act on", and it // is a blunt one: the browser refuses the submit before any handler runs, // so it must not be set while the button has a track to re-split. Setting - // it there made pressing Split stems on an upload answer "please fill out + // it there made pressing Extract stems on an upload answer "please fill out // this field" instead of separating it. if (importable || resplittable) urlInput.removeAttribute("required"); else urlInput.setAttribute("required", ""); @@ -1098,7 +1133,7 @@ async function loadTrackIntoStudio(trackId) { } /** - * Aim the Split stems button at a track rather than at the composer. + * Aim the Extract stems button at a track rather than at the composer. * * Carried on the button itself rather than in a module variable so job.js can * read it at submit time without the two files having to agree on an import @@ -1429,6 +1464,7 @@ export function toggleFavorite(trackId) { const track = tracks[trackId]; if (!track) return; track.favorite = !track.favorite; + syncFavoriteToServer(trackId, track.favorite); saveState(); if (trackId === _currentTrackId) { const favBtn = document.getElementById("fav-btn"); @@ -3463,6 +3499,7 @@ async function syncWithServer() { for (const state of jobs) { const known = tracks[state.job_id]; if (known) { + if (applyServerFavorite(known, state)) backfilled = true; // Tracks saved before the server reported a format (#690) only learn // it when they are opened. Taking it from here instead means the // whole library shows its icons at startup. @@ -3492,11 +3529,15 @@ async function syncWithServer() { if (deletedIds.has(state.job_id)) continue; // hard-deleted, skip const track = stateMetadataToTrack(state, { id: state.job_id, status: state.status }); track.id = state.job_id; + if (state.favorite) track.favorite = true; addTrackToLibrary(track); } if (backfilled) { saveState(); render(); + const favBtn = document.getElementById("fav-btn"); + const current = tracks[_currentTrackId]; + if (favBtn && current) paintFavButton(favBtn, Boolean(current.favorite)); } reconcileAvailability(jobs); } catch (e) { console.warn("[catalog] failed to load jobs from backend:", e); } @@ -4956,4 +4997,13 @@ export async function initCatalog() { loadCurrentVersion().finally(checkForUpdate); syncWithServer(); + // A heart pressed on the phone (#734) shows here the next time this window + // is looked at, rather than only after a reload. At most every 15 seconds, + // since switching windows back and forth is not new information. + let lastFocusSync = Date.now(); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState !== "visible" || Date.now() - lastFocusSync < 15_000) return; + lastFocusSync = Date.now(); + syncWithServer(); + }); } diff --git a/static/js/i18n.js b/static/js/i18n.js index 848b1b51..174f2289 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -394,7 +394,7 @@ const en = { "lyrics.sync.laneHint": "Drag a line · ← → nudge", "extract.all": "All", "extract.moreAria": "Choose stems", - "process.splitStems": "Split stems", + "process.extractStems": "Extract stems", "stem.original": "Original", "stem.vocals": "Vocals", @@ -583,6 +583,7 @@ const en = { "pitch.downTitle": "Transpose down a semitone", "pitch.upTitle": "Transpose up a semitone", "speed.ariaLabel": "Playback speed", + "speed.slowTitle": "Slow practice speed. Scroll over it, or press the up and down arrow keys, to set it anywhere from 0.50x to 0.99x.", "click.group": "Click track", "click.toggleTitle": "Click track (K)", @@ -1200,7 +1201,7 @@ const pl = { "lyrics.sync.laneHint": "Przeciągnij wers · ← → przesuń", "extract.all": "Wszystko", "extract.moreAria": "Wybierz ścieżki", - "process.splitStems": "Rozdziel ścieżki", + "process.extractStems": "Wyodrębnij ścieżki", "stem.original": "Oryginał", "stem.vocals": "Wokal", @@ -1387,6 +1388,7 @@ const pl = { "pitch.downTitle": "Transponuj w dół o półton", "pitch.upTitle": "Transponuj w górę o półton", "speed.ariaLabel": "Prędkość odtwarzania", + "speed.slowTitle": "Wolne tempo do ćwiczeń. Przewiń nad nim kółkiem myszy albo naciśnij strzałki w górę i w dół, aby ustawić je od 0.50x do 0.99x.", "click.group": "Metronom", "click.toggleTitle": "Metronom (K)", @@ -1996,7 +1998,7 @@ const ja = { "lyrics.sync.laneHint": "行をドラッグ · ← → で微調整", "extract.all": "すべて", "extract.moreAria": "抽出するパートを選ぶ", - "process.splitStems": "パートを分離", + "process.extractStems": "パートを抽出", "stem.original": "オリジナル", "stem.vocals": "ボーカル", @@ -2182,6 +2184,7 @@ const ja = { "pitch.downTitle": "半音下げる", "pitch.upTitle": "半音上げる", "speed.ariaLabel": "再生速度", + "speed.slowTitle": "練習用のスロー速度。上でスクロールするか、上下の矢印キーを押すと、0.50x から 0.99x の間で設定できます。", "click.group": "クリックトラック", "click.toggleTitle": "クリックトラック (K)", @@ -2767,7 +2770,7 @@ const zhHans = { "lyrics.sync.laneHint": "拖动歌词行 · ← → 微调", "extract.all": "全部", "extract.moreAria": "选择要提取的音轨", - "process.splitStems": "分离音轨", + "process.extractStems": "提取音轨", "stem.original": "原始", "stem.vocals": "人声", @@ -2953,6 +2956,7 @@ const zhHans = { "pitch.downTitle": "降低半音", "pitch.upTitle": "升高半音", "speed.ariaLabel": "播放速度", + "speed.slowTitle": "慢速练习速度。在上面滚动鼠标滚轮,或按上下方向键,可设置为 0.50x 到 0.99x 之间的任意值。", "click.group": "节拍器", "click.toggleTitle": "节拍器 (K)", @@ -3538,7 +3542,7 @@ const de = { "lyrics.sync.laneHint": "Zeile ziehen · ← → verschieben", "extract.all": "Alle", "extract.moreAria": "Stems auswählen", - "process.splitStems": "Stems trennen", + "process.extractStems": "Stems extrahieren", "stem.original": "Original", "stem.vocals": "Gesang", @@ -3725,6 +3729,7 @@ const de = { "pitch.downTitle": "Einen Halbton tiefer transponieren", "pitch.upTitle": "Einen Halbton höher transponieren", "speed.ariaLabel": "Wiedergabegeschwindigkeit", + "speed.slowTitle": "Langsames Übungstempo. Darüber scrollen oder die Pfeiltasten nach oben und unten drücken, um es zwischen 0.50x und 0.99x einzustellen.", "click.group": "Click-Track", "click.toggleTitle": "Click-Track (K)", @@ -4320,7 +4325,7 @@ const pt = { "lyrics.sync.laneHint": "Arraste uma linha · ← → ajustar", "extract.all": "Todos", "extract.moreAria": "Escolher as faixas", - "process.splitStems": "Separar stems", + "process.extractStems": "Extrair stems", "stem.original": "Original", "stem.vocals": "Vocais", @@ -4507,6 +4512,7 @@ const pt = { "pitch.downTitle": "Transpor um semitom para baixo", "pitch.upTitle": "Transpor um semitom para cima", "speed.ariaLabel": "Velocidade de reprodução", + "speed.slowTitle": "Velocidade lenta para praticar. Role o mouse sobre ela ou pressione as setas para cima e para baixo para ajustá-la entre 0.50x e 0.99x.", "click.group": "Clique de referência", "click.toggleTitle": "Clique de referência (K)", @@ -5104,7 +5110,7 @@ const id = { "lyrics.sync.laneHint": "Seret baris · ← → geser", "extract.all": "Semua", "extract.moreAria": "Pilih stem", - "process.splitStems": "Pisahkan stem", + "process.extractStems": "Ekstrak stem", "stem.original": "Asli", "stem.vocals": "Vokal", @@ -5290,6 +5296,7 @@ const id = { "pitch.downTitle": "Turunkan satu semitone", "pitch.upTitle": "Naikkan satu semitone", "speed.ariaLabel": "Kecepatan putar", + "speed.slowTitle": "Kecepatan lambat untuk latihan. Gulir di atasnya, atau tekan tombol panah atas dan bawah, untuk mengaturnya antara 0.50x dan 0.99x.", "click.group": "Click Track", "click.toggleTitle": "Click track (K)", @@ -5875,7 +5882,7 @@ const fr = { "lyrics.sync.laneHint": "Glissez une ligne · ← → ajuster", "extract.all": "Tout", "extract.moreAria": "Choisir les pistes", - "process.splitStems": "Séparer les pistes", + "process.extractStems": "Extraire les pistes", "stem.original": "Original", "stem.vocals": "Voix", @@ -6062,6 +6069,7 @@ const fr = { "pitch.downTitle": "Transposer d'un demi-ton vers le bas", "pitch.upTitle": "Transposer d'un demi-ton vers le haut", "speed.ariaLabel": "Vitesse de lecture", + "speed.slowTitle": "Vitesse lente d’entraînement. Faites défiler dessus, ou appuyez sur les flèches haut et bas, pour la régler entre 0.50x et 0.99x.", "click.group": "Métronome", "click.toggleTitle": "Métronome (K)", @@ -6486,6 +6494,7 @@ const fr = { // through pt (see FALLBACK), so the two variants cannot drift and a key // added to pt later is picked up here rather than reverting to English. const ptPT = { + "speed.slowTitle": "Velocidade lenta para praticar. Desloque a roda do rato sobre ela ou prima as setas para cima e para baixo para a ajustar entre 0.50x e 0.99x.", "key.mode.harmonicMinor": "menor harmónica", "lyrics.align.hint": "Coloque o cursor de reprodução onde a primeira linha é cantada e prima Começar a letra aqui. Para usar outra linha, clique nela primeiro e depois leve o cursor até onde é cantada.", "lyrics.align.targetPicked": "Move a linha em que clicou: “{line}”", @@ -6830,7 +6839,7 @@ const es = { "lyrics.sync.laneHint": "Arrastra una línea · ← → mover", "extract.all": "Todo", "extract.moreAria": "Elegir las pistas", - "process.splitStems": "Separar stems", + "process.extractStems": "Extraer stems", "stem.original": "Original", "stem.vocals": "Voces", @@ -7017,6 +7026,7 @@ const es = { "pitch.downTitle": "Transponer un semitono hacia abajo", "pitch.upTitle": "Transponer un semitono hacia arriba", "speed.ariaLabel": "Velocidad", + "speed.slowTitle": "Velocidad lenta para practicar. Desplázate sobre ella, o pulsa las flechas arriba y abajo, para ajustarla entre 0.50x y 0.99x.", "click.group": "Click", "click.toggleTitle": "Click (K)", @@ -7635,7 +7645,7 @@ const ko = { "lyrics.sync.laneHint": "줄 끌기 · ← → 옮기기", "extract.all": "전체", "extract.moreAria": "추출할 스템 선택", - "process.splitStems": "스템 분리", + "process.extractStems": "스템 추출", "stem.original": "원본", "stem.vocals": "보컬", @@ -7821,6 +7831,7 @@ const ko = { "pitch.downTitle": "반음 내리기", "pitch.upTitle": "반음 올리기", "speed.ariaLabel": "재생 속도", + "speed.slowTitle": "연습용 느린 속도입니다. 위에서 스크롤하거나 위아래 화살표 키를 눌러 0.50x에서 0.99x 사이로 설정할 수 있습니다.", "click.group": "클릭 트랙", "click.toggleTitle": "클릭 트랙 (K)", diff --git a/static/js/job.js b/static/js/job.js index 547b39e7..e6bd9e53 100644 --- a/static/js/job.js +++ b/static/js/job.js @@ -113,11 +113,11 @@ function setSubmitProcessing(processing) { const label = submitBtn.querySelector("span"); // Back to the label the markup ships, not a second one that only exists // here. This restored t("job.process") -- "Process" -- while index.html - // starts the button as t("process.splitStems") -- "Split stems" -- so the + // starts the button as t("process.extractStems") -- "Extract stems" -- so the // first submit of a session renamed the button for good, on success as much // as on failure, and nothing put it back short of a reload or a language // switch (#635). - if (label) label.textContent = processing ? t("job.processing") : t("process.splitStems"); + if (label) label.textContent = processing ? t("job.processing") : t("process.extractStems"); } /** True when audio is loaded in the studio. Either engine counts: the Web Audio diff --git a/static/js/lyrics.js b/static/js/lyrics.js index 55395ca9..eb21275e 100644 --- a/static/js/lyrics.js +++ b/static/js/lyrics.js @@ -43,15 +43,11 @@ import { getCurrentTrackInfo, getCurrentTrackArtist } from "./catalog.js"; import { transport, setPlayheadTime } from "./transport.js"; import { totalDuration } from "./state.js"; import { - searchLyrics, - rankVersions, - otherNames, parseLrc, currentLineIndex, wordTimings, songFromTitle, fromServerLyrics, - belongsTo, sameSong, clampOffset, shiftLines, @@ -82,11 +78,6 @@ import { // change to any track. const storeKey = (trackId) => `stemdeck.lyrics.${trackId}`; -// Within this many seconds of the track's length, an automatic search keeps a -// version without asking. LRCLIB lengths come from real releases, so the same -// recording lands within a second or two; a live cut or a radio edit does not. -const SAME_RECORDING_SEC = 3; - const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); let statusEl = null; @@ -1308,56 +1299,51 @@ function lookAgainButton() { return again; } -// How many of the artist's other names are searched by when its own finds no -// version the track's length. -const OTHER_NAME_SEARCHES = 2; - -const sameLength = (match, info) => Boolean(match) && info.duration > 0 && match.duration > 0 - && Math.abs(match.duration - info.duration) <= SAME_RECORDING_SEC; - /** - * Look the open track up on LRCLIB by what it is known to be. Only versions of - * this song by this artist count (belongsTo): LRCLIB's search also answers with - * other artists' songs, and no lyrics beat another song's. `names` are the - * other names the artist goes by (the band's, in its own script and in - * English), which count as its own, and which are searched by too when the - * artist's name finds no version the track's length: LRCLIB files a song under - * whichever name its uploader wrote, 周杰倫 or Jay Chou. One the same length as - * the track is kept straight away; otherwise they are offered to pick from; - * with none, it says so. What it found is remembered for the session, so - * opening the tab again does not ask again. A failed connection is not, so the - * next opening tries again. + * Ask the server to look the open track up on LRCLIB (#719). + * + * The tab used to search LRCLIB itself, with its own copy of the rules that + * decide which version is this song by this artist and which length fits. + * The two copies were kept equal by hand, and a track could get lyrics at + * import and none here. Now there is one copy, the import's, and what it + * finds is kept on the server like the import's answer, so the phone sees it + * too. The band saved from the artist box goes along: it lives in this side's + * store, and the server may not have it yet. + * + * What came back without lyrics is remembered for the session, so opening the + * tab again does not ask again. A failed connection is not, so the next + * opening tries again. */ -async function lookUp(info, { artist, song, names = [] }) { +async function lookUp(info, { song }) { searchController?.abort(); const controller = new AbortController(); searchController = controller; setStatus(t("lyrics.loading", { song }), "loading"); try { - const known = { artist, song, names }; - const search = async (by) => (await searchLyrics({ artist: by, song, duration: info.duration }, { signal: controller.signal })) - .filter((m) => belongsTo(m, known)); - let matches = await search(artist); - for (const other of otherNames(artist, names).slice(0, OTHER_NAME_SEARCHES)) { - if (sameLength(matches[0], info) || controller.signal.aborted) break; - // A failure here keeps what the artist's own name found. - const more = await search(other).catch((err) => { - console.warn("lyrics lookup by another name failed", err); - return []; - }); - matches = rankVersions([...matches, ...more], info.duration); - } + const band = getCurrentTrackArtist(); + const res = await fetch(`/api/jobs/${encodeURIComponent(info.id)}/lyrics/lookup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(band ? { band } : {}), + signal: controller.signal, + }); if (controller.signal.aborted || getCurrentTrackInfo()?.id !== info.id) return; - const best = matches[0]; - if (sameLength(best, info)) { - await keep(best, matches.slice(1, 12)); + if (!res.ok && res.status !== 404) throw new Error(`lyrics lookup -> ${res.status}`); + const data = await res.json().catch(() => null); + if (res.status === 404 && data?.nothing_known) { + setStatus(t("lyrics.nothingKnown"), "muted"); + nothingKnownFor = info.id; + return; + } + const found = fromServerLyrics(data); + if (found?.entry) { + show(found.entry, found.others, "server"); return; } - answered.set(info.id, { song, matches: matches.slice(0, 12) }); + answered.set(info.id, { song, matches: found?.others || [] }); showAnswer(answered.get(info.id)); } catch (err) { - // Aborted only by another track being opened; a timeout aborts the - // lookup's own inner signal and lands below, as a failure. + // Aborted only by another track being opened. if (controller.signal.aborted) return; console.warn("lyrics lookup failed", err); setStatus(t("lyrics.offline"), "error"); @@ -1522,7 +1508,7 @@ async function loadForCurrentTrack() { showAnswer(answered.get(info.id)); return; } - lookUp(info, { artist, song, names }); + lookUp(info, { song }); } // While the tab is on screen: notice a different track being opened, mark the diff --git a/static/js/lyricsLookup.js b/static/js/lyricsLookup.js index 9f1a71c9..7fb477f9 100644 --- a/static/js/lyricsLookup.js +++ b/static/js/lyricsLookup.js @@ -1,48 +1,18 @@ -// Lyrics for the open track, from LRCLIB (lrclib.net), for the Lyrics tab in -// the sidebar (#699). +// Lyrics for the Lyrics tab (#699): what the server found, read into the +// tab's shape, and the helpers that parse and time them. // -// LRCLIB because it is free, open and needs no key, and because most of what -// it holds is time-synced: each line carries the moment it is sung, so the tab -// can follow playback and a line can be clicked to jump there, which is what -// practising against the stems wants. The lyrics themselves are copyright of -// their writers; they are fetched when the Lyrics tab is opened on a track -// that has none kept, and kept on this machine for that track, never sent on. +// Finding them is the server's job alone (POST /api/jobs/{id}/lyrics/lookup, +// app/pipeline/lyrics_lookup.py). The tab used to search LRCLIB itself with +// its own copy of the rules for which version is this song by this artist, +// and the two copies drifted (#719). What is left here is the reading and the +// timing, which the tab and Sync lines need either way, and sameSong, which +// checks lyrics this tab kept before that rule existed. // -// What is sent is the artist and song the track is known to be (its tags, or -// the saved band and the song's name from the title), nothing else. -// -// No DOM here, and fetch is passed in, so this runs under node for the unit -// tests (tests/js/lyrics-lookup.test.mjs). +// No DOM here, so this runs under node for the unit tests. import { artistNameKey } from "./artistLookup.js"; import { toSimplified } from "./zhVariants.js"; -const LRCLIB_SEARCH = "https://lrclib.net/api/search"; -const TIMEOUT_MS = 12000; - -// Among versions this far apart or closer, prefer the one with synced lyrics. -// LRCLIB holds the same song many times over (live cuts, remasters, a rip with -// a second of silence trimmed), and a synced copy a second off beats a plain -// one that matches to the frame. -const SAME_LENGTH_SEC = 3; - -/** - * One LRCLIB row, reduced to what the tab uses. Rows with neither kind of - * lyrics and no instrumental flag say nothing, and are dropped by the caller. - */ -function normalise(row) { - return { - id: Number(row?.id) || 0, - track: String(row?.trackName || ""), - artist: String(row?.artistName || ""), - album: String(row?.albumName || ""), - duration: Number(row?.duration) || 0, - instrumental: Boolean(row?.instrumental), - synced: typeof row?.syncedLyrics === "string" ? row.syncedLyrics : "", - plain: typeof row?.plainLyrics === "string" ? row.plainLyrics : "", - }; -} - const SERVER_SOURCES = new Set(["lrclib", "file", "whisper"]); /** One version from the server's lyrics.json in the tab's shape, or null. */ @@ -113,107 +83,6 @@ export function fromServerLyrics(data) { return others.length ? { entry: null, others } : null; } -/** - * Best first, for a track `duration` seconds long (0 when unknown): - * closest in length, then synced over plain among versions within - * SAME_LENGTH_SEC of each other, then intact over a copy stripped of its - * accents (strippedCopy), then whatever LRCLIB ranked first. - */ -export function rankMatches(rows, duration = 0) { - const matches = (Array.isArray(rows) ? rows : []) - .map(normalise) - .filter((m) => m.id && (m.synced || m.plain || m.instrumental)); - return rankVersions(matches, duration); -} - -/** rankMatches' order over versions it already gave, say from two searches. - * Each version once, by id. */ -export function rankVersions(versions, duration = 0) { - const seen = new Set(); - const matches = versions.filter((m) => m?.id && !seen.has(m.id) && seen.add(m.id)); - const off = (m) => (duration && m.duration ? Math.abs(m.duration - duration) : 0); - const stripped = strippedIndexes(matches); - return matches - .map((m, rank) => ({ m, rank })) - .sort((a, b) => { - const da = off(a.m); - const db = off(b.m); - if (Math.abs(da - db) > SAME_LENGTH_SEC) return da - db; - if (Boolean(a.m.synced) !== Boolean(b.m.synced)) return a.m.synced ? -1 : 1; - const sa = stripped.has(a.rank); - if (sa !== stripped.has(b.rank)) return sa ? 1 : -1; - return da - db || a.rank - b.rank; - }) - .map(({ m }) => m); -} - -// Stripped copies, as _Words in app/pipeline/lyrics_lookup.py. LRCLIB holds -// many songs more than once, and some copies lost every letter outside ASCII -// on their way in: "Niewinnoci biaym niegiem" for "Niewinnością białym -// śniegiem" (Kayah, lrclib 5470091 beside the intact 10910419). Each such -// letter was either dropped or folded to its base ("się" as "sie"). Such a copy -// cannot be told from a song written without accents on its own, only beside -// the copy it was stripped from. -const LRC_TAGS = /\[[^\]\n]*\]|<\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?>/g; -const NOT_ASCII = /[^\p{ASCII}]/gu; -const isAscii = (word) => !/[^\p{ASCII}]/u.test(word); -// The intact copy has at least this many words with a letter outside ASCII, -// so a stray "café" proves nothing; the stripped one keeps at most a quarter -// of them; at least 60% of the intact copy's accented words appear in it with -// those letters dropped or folded; at least 80% of its words are the intact -// copy's. -const STRIPPED_MIN_WORDS = 5; -const STRIPPED_KEPT_MAX = 0.25; -const STRIPPED_FOUND_MIN = 0.6; -const STRIPPED_SAME_MIN = 0.8; - -/** What an accented word becomes with its letters outside ASCII dropped - * ("każe" as "kae") or folded to their base first ("się" as "sie"). */ -function strippedForms(word) { - const forms = [word.replace(NOT_ASCII, ""), word.normalize("NFD").replace(NOT_ASCII, "")]; - return forms.filter(Boolean); -} - -/** A version's words, lowercased, time stamps left out, with what they would - * be stripped: worked out once per version, compared many times. */ -function wordsOf(match) { - const text = String(match?.synced || match?.plain || "").normalize("NFC").replace(LRC_TAGS, " "); - const words = text.toLowerCase().match(/[\p{L}\p{M}]+/gu) || []; - const accented = words.filter((w) => !isAscii(w)); - const forms = accented.map(strippedForms); - return { words, have: new Set(words), accented, forms, known: new Set([...words, ...forms.flat()]) }; -} - -function strippedFrom(copy, intact) { - const accented = intact.accented.length; - if (accented < STRIPPED_MIN_WORDS || !copy.words.length) return false; - if (copy.accented.length > accented * STRIPPED_KEPT_MAX) return false; - const found = intact.forms.filter((forms) => forms.some((f) => copy.have.has(f))).length; - if (found < accented * STRIPPED_FOUND_MIN) return false; - const same = copy.words.filter((w) => intact.known.has(w)).length; - return same >= copy.words.length * STRIPPED_SAME_MIN; -} - -/** - * Whether `match` is `other`'s lyrics with the letters outside ASCII lost, - * dropped or folded to their base letter. Both are versions as rankMatches - * gives them ({ synced, plain }). - */ -export function strippedCopy(match, other) { - return strippedFrom(wordsOf(match), wordsOf(other)); -} - -/** The indexes in `matches` of versions that are a stripped copy of another. */ -function strippedIndexes(matches) { - const words = matches.map(wordsOf); - const intact = words.map((w, j) => [j, w]).filter(([, w]) => w.accented.length >= STRIPPED_MIN_WORDS); - const out = new Set(); - words.forEach((w, i) => { - if (intact.some(([j, other]) => j !== i && strippedFrom(w, other))) out.add(i); - }); - return out; -} - /** * Timed lines from LRC text, earliest first: [{ time, text }], time in * seconds. A line sung more than once carries several stamps @@ -615,31 +484,10 @@ export function songFromTitle(title, artist = "") { .trim(); } -// Whose song a version is: belongs_to in app/pipeline/lyrics_lookup.py. -// -// LRCLIB's search is fuzzy: asked for one artist's song it also answers with -// other artists' songs of a similar name. Lyrics that may be another song's are -// worse than none, so a version is kept only when its song is the one asked for -// and its artist is one of the names the track is known by: the artist itself, -// one of the artists credited with it ("Keala Settle" of "Keala Settle & The -// Greatest Showman Ensemble"), the show a cast recording is filed under, or -// another name the band goes by (its name in the artist box). Names are -// compared folded (fold): full-width letters as half-width, traditional -// Chinese as simplified. -const NAME_LIST = /\s*(?:[&,+/;]|\band\b|\bwith\b|\bfeat\.?|\bft\.?|\bfeaturing\b|\bvs\.?)\s*/iu; -const LEADING_THE = /^\s*the\s+/iu; +// What sameSong compares: a song's name less brackets and featured artists. const SONG_FEATURING = /\s+(?:ft\.?|feat\.?|featuring)\s.*$/iu; const SONG_BRACKETS = /[([{【][^()[\]{}【】]*[)\]}】]/gu; const SONG_TAIL = /\s+\p{Pd}+\s+.*$/u; -// What stands between the names a name is written in at once: "周杰倫 Jay -// Chou", "IU (아이유)", "五月天 (Mayday)". -const SCRIPT_BREAK = /[\s()[\]{}【】「」『』〈〉《》]+/u; -// One credited artist matches only when its name weighs at least this much -// (nameWeight: an ideograph counts two), so an initial or a stray "DJ" names -// nobody while 王菲 does; a run of words inside a longer name ("The Greatest -// Showman" in "The Greatest Showman Cast") needs this many. -const PART_MIN_CHARS = 4; -const RUN_MIN_WORDS = 2; // Latin letters Unicode does not build from a base letter and an accent, as a // name typed without them has them: "Podsiadlo" for Podsiadło. @@ -653,85 +501,6 @@ export function fold(text) { return toSimplified(String(text || "").normalize("NFKC").replace(LATIN_LETTER, (ch) => LATIN_LETTERS[ch])); } -const isHan = (cp) => - (cp >= 0x3400 && cp <= 0x4dbf) || (cp >= 0x4e00 && cp <= 0x9fff) || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0x20000 && cp <= 0x3ffff); - -/** A Chinese, Japanese or Korean letter: an ideograph, kana or Hangul. */ -function isCjk(ch) { - const cp = ch.codePointAt(0); - return isHan(cp) - || (cp >= 0x3040 && cp <= 0x30ff) || (cp >= 0x31f0 && cp <= 0x31ff) || (cp >= 0xff66 && cp <= 0xff9f) - || (cp >= 0x1100 && cp <= 0x11ff) || (cp >= 0x3130 && cp <= 0x318f) || (cp >= 0xac00 && cp <= 0xd7af); -} - -/** How much of a name a name key is, in Latin letters: name_weight in - * name_aliases.py. An ideograph counts two, marks nothing. */ -function nameWeight(key) { - let weight = 0; - for (const ch of key) weight += isHan(ch.codePointAt(0)) ? 2 : /\p{M}/u.test(ch) ? 0 : 1; - return weight; -} - -const nameKey = (name) => artistNameKey(fold(name).replace(LEADING_THE, "")); -const nameWords = (name) => (fold(name).match(/[\p{L}\p{M}\p{N}]+/gu) || []).map(artistNameKey).filter(Boolean); -const nameParts = (name) => fold(name).split(NAME_LIST).map(nameKey).filter(Boolean); - -function scriptOf(token) { - const letters = token.match(/\p{L}/gu) || []; - if (!letters.length) return ""; - const cjk = letters.filter(isCjk).length; - return cjk === letters.length ? "cjk" : cjk ? "mixed" : "other"; -} - -/** - * The names a name gives in two scripts at once, each on its own: "周杰倫 Jay - * Chou" as 周杰倫 and "Jay Chou", "IU (아이유)" as IU and 아이유. [] for a name - * in one script, or with a word that mixes them ("Official髭男dism"). - * script_names in app/pipeline/lyrics_lookup.py. - */ -export function scriptNames(name) { - const tokens = fold(name).split(SCRIPT_BREAK).filter(Boolean); - const kinds = tokens.map(scriptOf); - if (kinds.includes("mixed") || new Set(kinds.filter(Boolean)).size < 2) return []; - const groups = []; - let last = ""; - tokens.forEach((token, i) => { - if (!kinds[i]) { - last = ""; - return; - } - if (kinds[i] !== last) groups.push([]); - groups.at(-1).push(token); - last = kinds[i]; - }); - return groups.map((group) => group.join(" ")); -} - -const wholeNames = (name) => [nameKey(name), ...scriptNames(name).map(nameKey)].filter(Boolean); - -function containsRun(words, run) { - if (run.length < RUN_MIN_WORDS || run.length > words.length) return false; - for (let i = 0; i + run.length <= words.length; i++) { - if (run.every((w, j) => words[i + j] === w)) return true; - } - return false; -} - -/** Whether `found`, a version's artist, is one of `names`. */ -export function sameArtist(found, names) { - if (!nameKey(found)) return false; - const theirs = new Set(wholeNames(found)); - const theirParts = nameParts(found); - const theirWords = nameWords(found); - return names.some((name) => { - if (!String(name || "").trim()) return false; - if (wholeNames(name).some((key) => theirs.has(key))) return true; - if (nameParts(name).some((p) => nameWeight(p) >= PART_MIN_CHARS && theirParts.includes(p))) return true; - const mine = nameWords(name); - return containsRun(mine, theirWords) || containsRun(theirWords, mine); - }); -} - function songKeys(name) { const text = fold(name); let full = text.replace(SONG_BRACKETS, " ").replace(SONG_FEATURING, ""); @@ -753,31 +522,6 @@ export function sameSong(found, song) { return a.full === b.full || a.head === b.full || a.full === b.head; } -/** Whether a version is `song` by `artist` or one of `names` (a show's, the - * band's other names). */ -export function belongsTo(match, { artist = "", song = "", names = [] } = {}) { - return sameSong(match?.track, song) && sameArtist(match?.artist, [artist, ...names]); -} - -/** - * Who else to search LRCLIB by when the artist's own name found no version - * the track's length: the names it gives at once ("周杰倫 Jay Chou"), then - * `names` (the band's in the artist box), each once and never the artist's - * own. An uploader files a song under whichever one they write. - */ -export function otherNames(artist, names = []) { - const asked = new Set([String(artist || "").trim().toLowerCase()]); - const out = []; - for (const name of [...scriptNames(artist), ...names]) { - const text = String(name || "").trim(); - if (text && !asked.has(text.toLowerCase())) { - asked.add(text.toLowerCase()); - out.push(text); - } - } - return out; -} - // How far the Align panel moves lyrics either way, as the server bounds it // (LYRICS_OFFSET_MAX_SEC in app/core/config.py). export const MAX_OFFSET_SEC = 600; @@ -834,43 +578,3 @@ export function currentLineIndex(lines, seconds) { } return found; } - -/** The request for a search, as a URL. The song is required, the artist not. */ -export function searchUrl({ artist = "", song = "" }) { - const url = new URL(LRCLIB_SEARCH); - const a = String(artist).trim(); - const s = String(song).trim(); - if (a) { - url.searchParams.set("track_name", s); - url.searchParams.set("artist_name", a); - } else { - // No artist: the free-text search, which matches the song name against - // titles and artists both, rather than an empty artist filter. - url.searchParams.set("q", s); - } - return url.toString(); -} - -async function defaultFetchJson(url, signal) { - const res = await fetch(url, { signal, headers: { Accept: "application/json" } }); - if (!res.ok) throw new Error(`HTTP ${res.status} from ${new URL(url).host}`); - return res.json(); -} - -/** - * Search LRCLIB and rank what comes back for a track `duration` seconds long. - * Resolves to [] when nothing matches, and rejects when LRCLIB cannot be - * reached, so the tab can tell the two apart. - */ -export async function searchLyrics({ artist = "", song = "", duration = 0 }, { fetchJson = defaultFetchJson, signal } = {}) { - if (!String(song).trim()) return []; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(new Error("timeout")), TIMEOUT_MS); - signal?.addEventListener("abort", () => controller.abort(signal.reason), { once: true }); - try { - const rows = await fetchJson(searchUrl({ artist, song }), controller.signal); - return rankMatches(rows, duration); - } finally { - clearTimeout(timer); - } -} diff --git a/static/js/shared/jobs.js b/static/js/shared/jobs.js index 7a4edd60..6b74247a 100644 --- a/static/js/shared/jobs.js +++ b/static/js/shared/jobs.js @@ -76,5 +76,8 @@ export function jobToCard(state) { // can be reimported without a second fetch for its own source URL/stems. sourceUrl: state.source_url || null, selectedStems: state.selected_stems || [], + // Kept on the server since #734, so a heart set on either screen shows on + // the other. null (never said) reads as not a favourite. + favorite: state.favorite === true, }; } diff --git a/static/js/transport.js b/static/js/transport.js index aa5b18c2..37a5393c 100644 --- a/static/js/transport.js +++ b/static/js/transport.js @@ -962,35 +962,67 @@ export function wireTransportButtons() { wirePitchControl(); } -// Fixed presets, not a continuous dial -- practice speeds for slowing a part -// down, not a general-purpose tempo control (issue #269 follow-up). -// 0.75x rather than 0.5x/0.25x (#433): below ~0.7x the time-stretch artefacts -// dominate and the part gets harder to follow, which is the opposite of what -// a practice speed is for. -const SPEED_PRESETS = [0.75, 1]; +// Two buttons, normal and slow, rather than a dial -- practice speeds for +// slowing a part down, not a general-purpose tempo control (#269 follow-up). +// +// The slow one is set by the player (#701): scroll over it, or press the up +// and down arrow keys on it, to move it a hundredth at a time between +// SLOW_MIN and SLOW_MAX. Some fills want 0.75x, some only a nudge to 0.9x. +// It was fixed at 0.75x when the stretch was WSOLA, whose artefacts made +// anything slower hard to follow (#433); Signalsmith Stretch (#729) holds up +// further down, so the floor is 0.5x. Kept between sessions, and the footer +// keeps its width: the button only ever reads "0.xxx". +const SLOW_MIN = 0.5; +const SLOW_MAX = 0.99; +const SLOW_DEFAULT = 0.75; +const _SLOW_SPEED_KEY = "stemdeck:slow-speed"; +// A turn of the wheel fires many events; the rate itself is applied once +// they stop, since every change of rate flushes the tempo stage. +const SLOW_APPLY_DELAY_MS = 150; + +const clampSlow = (rate) => Math.round(Math.min(SLOW_MAX, Math.max(SLOW_MIN, rate)) * 100) / 100; +let _slowRate = SLOW_DEFAULT; +let _slowApplyTimer = 0; +const slowBtn = () => speedBtns[0]; + +function paintSlowButton() { + const btn = slowBtn(); + if (!btn) return; + btn.dataset.speed = String(_slowRate); + btn.textContent = `${_slowRate.toFixed(2)}x`; + btn.title = t("speed.slowTitle"); +} function applySpeed(rate) { - // Snap to the nearest preset rather than clamping continuously: every - // caller (button click, resetSpeed on track load) already passes one of - // SPEED_PRESETS, but snapping keeps this correct even if that changes. - const clamped = SPEED_PRESETS.reduce((best, p) => - Math.abs(p - rate) < Math.abs(best - rate) ? p : best - ); - setPlaybackSpeed(clamped); + // Normal or the slow speed: nothing in between is offered, so anything + // under 1 is the slow button's. + const applied = rate >= 1 ? 1 : _slowRate; + setPlaybackSpeed(applied); for (const btn of speedBtns) { if (!btn) continue; - const on = parseFloat(btn.dataset.speed) === clamped; + const on = parseFloat(btn.dataset.speed) === applied; btn.classList.toggle("active", on); btn.setAttribute("aria-checked", on ? "true" : "false"); } - audioEngine?.setPlaybackRate?.(clamped); + audioEngine?.setPlaybackRate?.(applied); if (multitrack) { for (const a of (multitrack.audios ?? [])) { - try { a.playbackRate = clamped; } catch { /* noop */ } + try { a.playbackRate = applied; } catch { /* noop */ } } } } +/** Move the slow speed by `steps` hundredths, and play at it. */ +function nudgeSlowSpeed(steps) { + const next = clampSlow(_slowRate + steps / 100); + if (next === _slowRate) return; + _slowRate = next; + paintSlowButton(); + storeSet(_SLOW_SPEED_KEY, _slowRate).catch((e) => console.warn("[transport] failed to save the slow speed:", e)); + clearTimeout(_slowApplyTimer); + _slowApplyTimer = setTimeout(() => applySpeed(_slowRate), SLOW_APPLY_DELAY_MS); +} + // ── Transpose (#245) ──────────────────────────────────────────────────────── // @@ -1092,6 +1124,26 @@ function wireSpeedControl() { for (const btn of speedBtns) { btn?.addEventListener("click", () => applySpeed(parseFloat(btn.dataset.speed))); } + const slow = slowBtn(); + if (!slow) return; + paintSlowButton(); + onLanguageChange(paintSlowButton); + slow.addEventListener("wheel", (e) => { + e.preventDefault(); + if (e.deltaY) nudgeSlowSpeed(e.deltaY < 0 ? 1 : -1); + }, { passive: false }); + slow.addEventListener("keydown", (e) => { + if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; + e.preventDefault(); + nudgeSlowSpeed(e.key === "ArrowUp" ? 1 : -1); + }); + storeGet(_SLOW_SPEED_KEY, null).then((saved) => { + if (typeof saved !== "number" || !Number.isFinite(saved)) return; + const wasSlow = slow.classList.contains("active"); + _slowRate = clampSlow(saved); + paintSlowButton(); + if (wasSlow) applySpeed(_slowRate); + }).catch(() => {}); } // ─── Click track ──────────────────────────────────────────── diff --git a/static/js/utils.js b/static/js/utils.js index 166b2cad..918b7dd1 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -139,7 +139,7 @@ export function parseTimecode(str) { // Everything else is a link the importer can act on. // // One predicate rather than three spellings of it. The composer arming the -// Split stems button and the re-import of an unavailable track are asking the +// Extract stems button and the re-import of an unavailable track are asking the // same question, and the answer stopped agreeing between them: the composer // showed an uploaded file's bare filename as though it were a link, and the // button then submitted it (#635). diff --git a/static/mobile/app.js b/static/mobile/app.js index d645fa5a..7bcf4f1e 100644 --- a/static/mobile/app.js +++ b/static/mobile/app.js @@ -66,6 +66,7 @@ const ICON = { search: '', link: '', upload: '', + heart: (on) => ``, check: '', scissors: '', tabLib: '', @@ -541,8 +542,15 @@ function libraryBody() { if (state.libState === "empty") { return `
No tracks yet. Head to Extract to split your first song.
`; } - return `
RECENT
- ${state.tracks.map((t) => { + // The Favorites chip used to set a filter nothing read, so it listed every + // track (#734). Favourites are on the server now, so it can filter for real. + const favOnly = state.filter === "Favorites"; + const shown = favOnly ? state.tracks.filter((t) => t.favorite) : state.tracks; + if (favOnly && !shown.length) { + return `
No favourites yet. Tap the heart on a track to add it here.
`; + } + return `
${favOnly ? "FAVORITES" : "RECENT"}
+ ${shown.map((t) => { const unavailable = t.status === "unavailable"; const canReimport = unavailable && t.sourceUrl && !t.sourceUrl.startsWith("local:"); const infoHtml = unavailable @@ -556,6 +564,7 @@ function libraryBody() {
${artLabel(t)}
${infoHtml}
+ `; @@ -620,7 +629,7 @@ function extractScreen() { - + ${extractProgressCard()} `; @@ -841,6 +850,29 @@ function closeSwipe() { closeOtherSwipes(null); } +// The same flag the desktop's hearts set (#734). The row changes at once and +// goes back if the server refuses, so a tap never shows a state that was not +// kept. +async function toggleFavorite(id) { + const track = state.tracks.find((t) => t.id === id); + if (!track) return; + track.favorite = !track.favorite; + render(); + try { + const res = await fetch(`/api/jobs/${encodeURIComponent(id)}/favorite`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ favorite: track.favorite }), + }); + if (!res.ok) throw new Error(`PUT favorite -> ${res.status}`); + } catch (e) { + console.warn("[mobile] could not save the favourite:", e); + track.favorite = !track.favorite; + toast("Could not save the favourite"); + render(); + } +} + async function deleteTrack(id) { state.swipedTrackId = null; try { @@ -962,6 +994,11 @@ app.addEventListener("click", (e) => { case "delete": deleteTrack(t.dataset.id); return; + case "favorite": + // The row underneath opens the track; the heart is its own button. + e.stopPropagation(); + toggleFavorite(t.dataset.id); + return; case "tab": state.tab = t.dataset.tab; break; diff --git a/static/mobile/styles.css b/static/mobile/styles.css index 7cae237f..817fe836 100644 --- a/static/mobile/styles.css +++ b/static/mobile/styles.css @@ -778,6 +778,19 @@ button { opacity: 1; transform: translate(-50%, 0); } +.track-fav { + flex-shrink: 0; + display: grid; + place-items: center; + width: 40px; + height: 40px; + margin-right: 2px; + border: none; + background: none; + color: #7a7a82; + cursor: pointer; +} +.track-fav.on { color: #e54e4e; } .track-load { flex-shrink: 0; border: 1px solid rgba(255, 255, 255, 0.12); diff --git a/tests/acceptance/README.md b/tests/acceptance/README.md index 65a226de..4571f3fb 100644 --- a/tests/acceptance/README.md +++ b/tests/acceptance/README.md @@ -113,7 +113,7 @@ window are added, so the karaoke checks do not depend on the window being on top. The window is resized with the Win32 API, because the now-playing card only shows in a window wider than 1460 px. -E4 blocks `lrclib.net` for the page alone. E6 starts the app a second time +E4 answers the page's lyrics lookup with the server's "LRCLIB unreachable" (502), for the page alone. E6 starts the app a second time with every outside request sent to a proxy that nobody listens on (`HTTP(S)_PROXY` for the backend, `--proxy-server` for WebView2), loopback excepted: offline for this app, while the machine keeps its network. diff --git a/tests/acceptance/acceptance.spec.mjs b/tests/acceptance/acceptance.spec.mjs index 628ed8f2..3cd85f3e 100644 --- a/tests/acceptance/acceptance.spec.mjs +++ b/tests/acceptance/acceptance.spec.mjs @@ -103,10 +103,9 @@ test.afterEach(async ({ app }, testInfo) => { if (testInfo.status === testInfo.expectedStatus) return; await shot(app.page, testInfo, "failure").catch(() => {}); const message = testInfo.error?.message || ""; - const evidence = serviceEvidence(app.page, checkStartedAt, { - // Blocked on purpose by E4. - ignoreHosts: testInfo.title.startsWith("E4") ? ["lrclib.net"] : [], - }); + // E4's refused lookup is a request to the app's own server, which this does + // not count as an outside service. + const evidence = serviceEvidence(app.page, checkStartedAt); // An outage only explains a failure it could have caused: an import that // YouTube refused, or a request from the page itself that failed. A service // in the backend log alone is noted, not blamed. @@ -342,7 +341,7 @@ test(title("A3"), async ({ app }, testInfo) => { // ─── Now playing and song details ─────────────────────────────────────────── // Steps: import any song and open it. Expect, left to right: the link field, -// the Extract options, Split stems, then the Now playing card with the +// the Extract options, Extract stems, then the Now playing card with the // artwork, the heart on its left and the (i) on its right. test(title("N1"), async ({ app }, testInfo) => { const { page } = app; @@ -367,8 +366,8 @@ test(title("N1"), async ({ app }, testInfo) => { // chips under the field; everything else follows them to the right. const besideOrUnder = url.right <= extract.left || (Math.abs(extract.left - url.left) < 40 && extract.top >= url.bottom - 2); expect(besideOrUnder, "the Extract options follow the link field").toBe(true); - expect(Math.max(url.right, extract.right) <= split.left, "Split stems follows them").toBe(true); - expect(split.right <= card.left, "the card comes after Split stems").toBe(true); + expect(Math.max(url.right, extract.right) <= split.left, "Extract stems follows them").toBe(true); + expect(split.right <= card.left, "the card comes after Extract stems").toBe(true); // One row: every one of them overlaps the card's height. for (const b of [url, extract, split]) expect(b.top < card.bottom && b.bottom > card.top, "one row").toBe(true); await expect(page.locator("#nowPlayingPanel .np-legend")).toHaveText("Now playing"); @@ -1021,16 +1020,19 @@ test(title("V2"), async ({ app }, testInfo) => { // Expect: "Could not reach LRCLIB. Check your connection and try again." in // the Lyrics panel; nothing floats over the top bar. // -// LRCLIB is blocked for the page alone (page.route over CDP): the rest of the -// machine keeps its network. The page only asks LRCLIB itself for a track the -// server kept no lyrics for, which the tagged tone file is. +// The page asks the server to look lyrics up (#719), and the server answers +// 502 when LRCLIB cannot be reached. That answer is given for the page alone +// (page.route over CDP), so the rest of the machine keeps its network. The +// lookup is only asked for a track the server kept no lyrics for, which the +// tagged tone file is. test(title("E4"), async ({ app }, testInfo) => { const { page } = app; await wideWindow(page); const id = await trackFor(page, "localMp3"); const kept = await fetch(`${readState().baseURL}/api/jobs/${id}/lyrics`); - test.skip(kept.status !== 404, `The server kept lyrics for the tone file (HTTP ${kept.status}), so the page would not ask LRCLIB.`); - await page.route(/^https:\/\/lrclib\.net\//, (route) => route.abort("internetdisconnected")); + test.skip(kept.status !== 404, `The server kept lyrics for the tone file (HTTP ${kept.status}), so the page would not ask for a lookup.`); + const LOOKUP = /\/api\/jobs\/[^/]+\/lyrics\/lookup$/; + await page.route(LOOKUP, (route) => route.fulfill({ status: 502, json: { detail: "lyrics service unreachable" } })); try { await openTrack(page, id); await openLyrics(page); @@ -1050,7 +1052,7 @@ test(title("E4"), async ({ app }, testInfo) => { expect(floating).toBe(0); await shot(page, testInfo, "offline"); } finally { - await page.unroute(/^https:\/\/lrclib\.net\//); + await page.unroute(LOOKUP); } }); diff --git a/tests/acceptance/helpers.mjs b/tests/acceptance/helpers.mjs index bda75cf7..c52bfdee 100644 --- a/tests/acceptance/helpers.mjs +++ b/tests/acceptance/helpers.mjs @@ -233,7 +233,7 @@ export async function showLibrary(page) { await expect(lib).toHaveAttribute("aria-pressed", "true"); } -/** Paste a link in the top bar's field and press Split stems. Returns the job id. */ +/** Paste a link in the top bar's field and press Extract stems. Returns the job id. */ export async function importLink(page, url) { const field = page.locator("#url"); await field.fill(url); @@ -242,7 +242,7 @@ export async function importLink(page, url) { page.locator("#submit").click(), ]); const body = await res.json().catch(() => ({})); - if (!res.ok()) throw new Error(`Split stems refused ${url}: ${res.status()} ${body.detail || ""}`); + if (!res.ok()) throw new Error(`Extract stems refused ${url}: ${res.status()} ${body.detail || ""}`); return body.job_id; } @@ -255,7 +255,7 @@ export async function importFile(page, file) { page.locator("#submit").click(), ]); const body = await res.json().catch(() => ({})); - if (!res.ok()) throw new Error(`Split stems refused ${path.basename(file)}: ${res.status()} ${body.detail || ""}`); + if (!res.ok()) throw new Error(`Extract stems refused ${path.basename(file)}: ${res.status()} ${body.detail || ""}`); return body.job_id; } @@ -327,7 +327,7 @@ export async function queueImports(page) { /** * The job id of an import, waiting for it to finish. A link YouTube refused * (a 403 on the media now and then) is split once more, as a person would - * press Split stems again; the retry is recorded in the state file so the + * press Extract stems again; the retry is recorded in the state file so the * check can say so. */ export async function songJob(page, key) { diff --git a/tests/e2e/about-reach.spec.mjs b/tests/e2e/about-reach.spec.mjs new file mode 100644 index 00000000..c762974c --- /dev/null +++ b/tests/e2e/about-reach.spec.mjs @@ -0,0 +1,36 @@ +// About this song can be opened at any window width (#735). +// +// Its button lived only on the Now Playing card, and daw.css hid that whole +// card below 1460 px, so a maximised laptop window had no way to open it. The +// card now gives way in steps instead: the artwork and the meta line go first, +// then the title, and the heart and the About button stay. + +import { test, expect } from "@playwright/test"; +import { openStudio } from "./helpers.mjs"; + +for (const width of [1024, 1280, 1366, 1600]) { + test(`About this song opens from the card at ${width} px`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await openStudio(page); + const button = page.locator("#np-details-btn"); + await expect(button).toBeVisible(); + await expect(page.locator("#fav-btn")).toBeVisible(); + // On screen and inside the bar, not pushed past its edge. + const box = await button.boundingBox(); + const bar = await page.locator(".daw-composer").boundingBox(); + expect(box.width).toBeGreaterThan(20); + expect(box.x + box.width).toBeLessThanOrEqual(bar.x + bar.width + 0.5); + await button.click(); + await expect(page.locator("#artistDialog")).toBeVisible(); + }); +} + +test("Extract stems sits under Detect structure and the composer keeps one line of chips", async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 800 }); + await openStudio(page); + const detect = await page.locator("#autoSectionsBtn").boundingBox(); + const extract = await page.locator("#submit").boundingBox(); + await expect(page.locator("#submit")).toHaveText(/Extract stems/); + expect(Math.abs(detect.x - extract.x)).toBeLessThan(1); + expect(extract.y).toBeGreaterThan(detect.y + detect.height - 1); +}); diff --git a/tests/e2e/audio-tags-backfill.spec.mjs b/tests/e2e/audio-tags-backfill.spec.mjs index 2a9c1d69..74f8b7c2 100644 --- a/tests/e2e/audio-tags-backfill.spec.mjs +++ b/tests/e2e/audio-tags-backfill.spec.mjs @@ -16,13 +16,13 @@ import { stubAudioTags, stubExportEndpoints, stubUpdateCheck, + LYRICS_LOOKUP, } from "./helpers.mjs"; // Wide enough for the now-playing card, which the bar hides below 1460px. test.use({ viewport: { width: 1600, height: 900 } }); const WIKIMEDIA = /^https:\/\/([a-z]+\.wikipedia\.org|www\.wikidata\.org|query\.wikidata\.org)\//; -const LRCLIB = /^https:\/\/lrclib\.net\//; // Longer than artistInfo.js's wait after a track opens. const PAST_THE_WAIT_MS = 2000; @@ -266,15 +266,12 @@ test.describe("tags for tracks imported before they were read", () => { test("the Lyrics tab, open while the tags arrive, looks them up", async ({ page }) => { await stubWikimedia(page); + // The tab asks the server to look them up (#719), which has the tags the + // read just stored; it is answered here with nothing found. const lrclib = []; - await page.route(LRCLIB, (route) => { + await page.route(LYRICS_LOOKUP, (route) => { lrclib.push(route.request().url()); - return route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: "[]", - }); + return route.fulfill({ status: 404, json: { detail: "no lyrics", others: [] } }); }); let release; const hold = new Promise((resolve) => { release = resolve; }); @@ -287,6 +284,6 @@ test.describe("tags for tracks imported before they were read", () => { release(); await expect.poll(() => lrclib.length, { timeout: 15000 }).toBeGreaterThan(0); - expect(decodeURIComponent(lrclib[0]).replace(/\+/g, " ")).toContain("Dream Theater"); + await expect(page.locator("#lyricsStatus")).toContainText("No lyrics found"); }); }); diff --git a/tests/e2e/composer-arming.spec.mjs b/tests/e2e/composer-arming.spec.mjs index a57bbfe5..88b90001 100644 --- a/tests/e2e/composer-arming.spec.mjs +++ b/tests/e2e/composer-arming.spec.mjs @@ -1,4 +1,4 @@ -// What the Split stems button is allowed to be pointed at (#635). +// What the Extract stems button is allowed to be pointed at (#635). // // The composer is an input the button submits, not a caption for whatever // track is open. Opening a track writes its source back into it, and for an diff --git a/tests/e2e/favorite-reach.spec.mjs b/tests/e2e/favorite-reach.spec.mjs index 5cc37dfc..4c1c02d6 100644 --- a/tests/e2e/favorite-reach.spec.mjs +++ b/tests/e2e/favorite-reach.spec.mjs @@ -1,9 +1,10 @@ // A track can be favourited at any window width (#724). // -// The only heart used to be the Now Playing card's, and daw.css hides that -// whole card below 1460 px. A maximised laptop window is under that, so there -// was no way to favourite at all. Library and Favorites rows have one now, and -// every heart goes through one toggle, so they cannot disagree. +// The only heart used to be the Now Playing card's, and daw.css hid that whole +// card below 1460 px. A maximised laptop window is under that, so there was no +// way to favourite at all. Library and Favorites rows have one now, and every +// heart goes through one toggle, so they cannot disagree. The card stays in a +// compact form at narrow widths too since #735, so both hearts are on screen. import { test, expect } from "@playwright/test"; import { JOB_ID, SIBLING_JOB_ID, openStudio, readCatalogState } from "./helpers.mjs"; @@ -15,9 +16,8 @@ const favoriteIn = async (page, id) => Boolean((await readCatalogState(page))?.t test.describe("favourites at a narrow window", () => { test.use({ viewport: { width: 1280, height: 800 } }); - test("a library row can favourite a track the Now Playing card cannot reach", async ({ page }) => { + test("a library row can favourite a track that is not the one playing", async ({ page }) => { await openStudio(page); - await expect(page.locator("#fav-btn")).toBeHidden(); await row(page, SIBLING_JOB_ID).hover(); await expect(rowHeart(page, SIBLING_JOB_ID)).toBeVisible(); diff --git a/tests/e2e/favorite-sync.spec.mjs b/tests/e2e/favorite-sync.spec.mjs new file mode 100644 index 00000000..0b340592 --- /dev/null +++ b/tests/e2e/favorite-sync.spec.mjs @@ -0,0 +1,57 @@ +// Favourites are kept on the server, so the phone and the studio agree (#734). +// +// They lived only in the studio's own store, so the phone had no heart to +// press and its Favorites chip listed every track. The server is stubbed here +// (stubFavorites) so no spec leaves a favourite behind on the shared backend; +// the endpoint itself is covered by tests/test_jobs_favorite.py. + +import { test, expect } from "@playwright/test"; +import { JOB_ID, SIBLING_JOB_ID, openStudio, readCatalogState, stubFavorites } from "./helpers.mjs"; + +const row = (page, id) => page.locator(`#catalogList .cat-item[data-id="${id}"]`).first(); + +test.describe("studio", () => { + test.use({ viewport: { width: 1600, height: 900 } }); + + test("a heart pressed in the studio is saved on the server", async ({ page }) => { + const { favoriteWrites } = await openStudio(page); + await row(page, SIBLING_JOB_ID).hover(); + await row(page, SIBLING_JOB_ID).locator(".cat-fav").click(); + await expect.poll(() => favoriteWrites).toContainEqual({ id: SIBLING_JOB_ID, favorite: true }); + }); + + test("a favourite set on the phone shows in the studio", async ({ page }) => { + await openStudio(page, { serverFavorites: { [SIBLING_JOB_ID]: true } }); + await expect(row(page, SIBLING_JOB_ID)).toHaveClass(/\bis-fav\b/); + expect((await readCatalogState(page))?.tracks?.[SIBLING_JOB_ID]?.favorite).toBe(true); + }); +}); + +test.describe("phone", () => { + test.use({ viewport: { width: 390, height: 844 } }); + + const openPhone = async (page, serverFavorites) => { + const writes = await stubFavorites(page, serverFavorites); + await page.goto("/?ui=mobile", { waitUntil: "domcontentloaded" }); + await page.locator('[data-action="tab"][data-tab="library"]').first().click(); + await page.locator(`.track[data-id="${JOB_ID}"]`).first().waitFor({ timeout: 20000 }); + return writes; + }; + + test("the Favorites chip lists only favourites", async ({ page }) => { + await openPhone(page, { [JOB_ID]: true }); + await page.locator('[data-action="filter"][data-filter="Favorites"]').click(); + await expect(page.locator(`.track[data-id="${JOB_ID}"]`)).toBeVisible(); + await expect(page.locator(`.track[data-id="${SIBLING_JOB_ID}"]`)).toHaveCount(0); + }); + + test("a heart on the phone is saved and does not open the track", async ({ page }) => { + const writes = await openPhone(page, {}); + const heart = page.locator(`.track-fav[data-id="${SIBLING_JOB_ID}"]`); + await heart.click(); + await expect(heart).toHaveAttribute("aria-pressed", "true"); + await expect.poll(() => writes).toContainEqual({ id: SIBLING_JOB_ID, favorite: true }); + // Still on the library, not taken to the player. + await expect(page.locator(`.track[data-id="${SIBLING_JOB_ID}"]`)).toBeVisible(); + }); +}); diff --git a/tests/e2e/helpers.mjs b/tests/e2e/helpers.mjs index 13e19b14..56eb46f3 100644 --- a/tests/e2e/helpers.mjs +++ b/tests/e2e/helpers.mjs @@ -253,8 +253,37 @@ export async function stubUpdateCheck(page, { available = false } = {}) { } /** Open the fixture track in the studio and wait until the transport is live. */ -export async function openStudio(page, { tauri = false, updateAvailable = false } = {}) { +/** + * Keep favourites off the shared e2e backend (#734). + * + * A heart now writes to the server, and every spec shares one server, so a + * favourite set by one test would come back through GET /api/jobs into the + * next one's library. Writes are answered here instead, and the server's + * value stays null, which the desktop reads as "never said". A test about the + * sync itself passes `serverFavorites` to stand in for what the server knows. + */ +export async function stubFavorites(page, serverFavorites = {}) { + const writes = []; + await page.route("**/api/jobs/*/favorite", async (route) => { + const id = new URL(route.request().url()).pathname.split("/")[3]; + const { favorite } = JSON.parse(route.request().postData() || "{}"); + writes.push({ id, favorite }); + serverFavorites[id] = favorite; + await route.fulfill({ json: { job_id: id, favorite } }); + }); + await page.route(/\/api\/jobs(\?.*)?$/, async (route) => { + if (route.request().method() !== "GET") return route.continue(); + const res = await route.fetch(); + const jobs = await res.json(); + for (const j of jobs) j.favorite = serverFavorites[j.job_id] ?? null; + await route.fulfill({ response: res, json: jobs }); + }); + return writes; +} + +export async function openStudio(page, { tauri = false, updateAvailable = false, serverFavorites = {} } = {}) { await seedLibrary(page); + const favoriteWrites = await stubFavorites(page, serverFavorites); if (tauri) await stubTauri(page); await stubExportEndpoints(page); await stubUpdateCheck(page, { available: updateAvailable }); @@ -268,6 +297,7 @@ export async function openStudio(page, { tauri = false, updateAvailable = false null, { timeout: 20000 }, ); + return { favoriteWrites }; } /** @@ -322,3 +352,60 @@ export const exportUi = (page) => ({ await page.locator("#t-export-panel:not(.hidden)").waitFor({ timeout: 5000 }); }, }); + +/** The server's lyrics lookup, which the Lyrics tab asks rather than LRCLIB (#719). */ +export const LYRICS_LOOKUP = /\/api\/jobs\/[a-f0-9]{12}\/lyrics\/lookup$/; + +/** + * Stand in for the server's lyrics lookup, so the lyrics specs run offline and + * never reach LRCLIB through the shared backend. + * + * `rows` are LRCLIB rows. Like the server, it keeps only versions of `song` by + * `artist` (brackets and a " - ..." tail aside), keeps the first within 3 s of + * the track's `duration` and offers the others with it, or offers them all + * when none is that length. What it kept or offered is then what GET + * .../lyrics answers, as the server's lyrics.json would. The rule itself is + * tested against the real server in tests/test_lyrics_lookup.py; this only + * has to answer the way it does. Resolves to the requests the page made: + * [{ id, body }]. + */ +export async function stubLyricsLookup(page, { rows = [], artist = "", song = "", duration = 6, offline = false, nothingKnown = false } = {}) { + const asked = []; + const kept = {}; + const offered = {}; + const jobOf = (route) => new URL(route.request().url()).pathname.split("/")[3]; + const version = (r) => ({ + source: "lrclib", + lrclib_id: r.id, + track: r.trackName, + artist: r.artistName, + album: r.albumName || "", + duration: r.duration, + instrumental: Boolean(r.instrumental), + synced: r.syncedLyrics || "", + plain: r.plainLyrics || "", + }); + const songKey = (s) => String(s || "").replace(/[([{][^)\]}]*[)\]}]/g, "").replace(/\s+-\s+.*$/, "").trim().toLowerCase(); + await page.route(/\/api\/jobs\/[a-f0-9]{12}\/lyrics$/, (route) => { + if (route.request().method() !== "GET") return route.fallback(); + const id = jobOf(route); + if (kept[id]) return route.fulfill({ json: kept[id], headers: { "cache-control": "no-cache" } }); + if (offered[id]?.length) return route.fulfill({ status: 404, json: { detail: "no lyrics", others: offered[id] } }); + return route.fallback(); + }); + await page.route(LYRICS_LOOKUP, (route) => { + const id = jobOf(route); + asked.push({ id, body: JSON.parse(route.request().postData() || "{}") }); + if (offline) return route.fulfill({ status: 502, json: { detail: "lyrics service unreachable" } }); + if (nothingKnown) return route.fulfill({ status: 404, json: { detail: "nothing to look up", others: [], nothing_known: true } }); + const mine = rows.filter((r) => r.artistName === artist && songKey(r.trackName) === songKey(song)).map(version); + const best = mine.find((v) => Math.abs(v.duration - duration) <= 3); + if (best) { + kept[id] = { v: 1, ...best, timing: "exact", others: mine.filter((v) => v !== best) }; + return route.fulfill({ json: kept[id] }); + } + offered[id] = mine; + return route.fulfill({ status: 404, json: { detail: "no lyrics", others: mine } }); + }); + return asked; +} diff --git a/tests/e2e/lyrics-cjk.spec.mjs b/tests/e2e/lyrics-cjk.spec.mjs index c56746bc..9efd5be4 100644 --- a/tests/e2e/lyrics-cjk.spec.mjs +++ b/tests/e2e/lyrics-cjk.spec.mjs @@ -13,8 +13,8 @@ // lyrics-from-server.spec.mjs gives them, and the vocals envelope is absent, so // the timing is the line's own and does not depend on the fixture's audio. // -// Last, the tab's own lookup (no lyrics from the server) finding a song LRCLIB -// files only under the band's name in its own script. +// Last, the tab asking the server to look a track up (no lyrics kept yet), +// with the band saved in both scripts. import { test, expect } from "@playwright/test"; import { JOB_ID, @@ -24,9 +24,10 @@ import { stubAudioTags, stubExportEndpoints, stubUpdateCheck, + stubLyricsLookup, + LYRICS_LOOKUP, } from "./helpers.mjs"; -const LRCLIB = /^https:\/\/lrclib\.net\//; const WIKIMEDIA = /^https:\/\/((www|query)\.wikidata\.org|[a-z-]+\.wikipedia\.org)\//; const LINES = { @@ -58,18 +59,12 @@ const SERVER = { others: [], }; -async function setUp(page, { server = SERVER, lrclibRows = null, extra = {} } = {}) { +async function setUp(page, { server = SERVER, extra = {} } = {}) { + // Lookups the tab asked the server for (#719). Watched, not answered: a test + // that wants an answer stubs the lookup itself. const lrclib = []; - await page.route(LRCLIB, (route) => { - const url = new URL(route.request().url()); - lrclib.push(url.searchParams.get("artist_name") || `q:${url.searchParams.get("q")}`); - if (!lrclibRows) return route.abort("internetdisconnected"); - return route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify(lrclibRows(url.searchParams.get("artist_name"))), - }); + page.on("request", (req) => { + if (LYRICS_LOOKUP.test(new URL(req.url()).pathname)) lrclib.push(req.url()); }); await page.route(`**/api/jobs/${JOB_ID}/lyrics`, (route) => (server ? route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(server) }) @@ -183,19 +178,19 @@ test.describe("karaoke in Chinese, Japanese and Korean", () => { expect(await ko.evaluate((el) => getComputedStyle(el).wordBreak)).toBe("keep-all"); }); - test("the tab finds a song LRCLIB files under the band's name in its own script", async ({ page }) => { + test("the tab asks the server with the band's names, and shows the song it found", async ({ page }) => { // Tagged "Jay Chou"; LRCLIB has the song only as 周杰倫's, and the artist - // box saved the band with both names. Another singer's 晴天 is never taken. - const rows = (artist) => (artist === "周杰倫" - ? [{ id: 401, trackName: "晴天", artistName: "周杰倫", albumName: "葉惠美", duration: 6, instrumental: false, syncedLyrics: SYNCED, plainLyrics: "" }] - : [{ id: 402, trackName: "晴天", artistName: "张信哲", albumName: "", duration: 6, instrumental: false, syncedLyrics: "[00:00.50]not his", plainLyrics: "" }]); - const { lrclib } = await setUp(page, { + // box saved the band with both names. Searching by the band's other names + // is the server's job now (#719, tests/test_lyrics_lookup.py); the tab + // sends the band it saved and shows what came back. + const rows = [{ id: 401, trackName: "晴天", artistName: "周杰倫", albumName: "葉惠美", duration: 6, instrumental: false, syncedLyrics: SYNCED, plainLyrics: "" }]; + const asked = await stubLyricsLookup(page, { rows, artist: "周杰倫", song: "晴天" }); + await setUp(page, { server: null, - lrclibRows: rows, extra: { artist: { id: "Q238819", name: "周杰倫", englishName: "Jay Chou" } }, }); await expect(page.locator(".lyrics-line")).toHaveCount(4); await expect(page.locator(".lyrics-match-meta")).toHaveText("周杰倫 · 葉惠美"); - expect(lrclib).toEqual(["Jay Chou", "周杰倫"]); + expect(asked.map((a) => a.body.band)).toEqual([{ id: "Q238819", name: "周杰倫", englishName: "Jay Chou" }]); }); }); diff --git a/tests/e2e/lyrics-from-server.spec.mjs b/tests/e2e/lyrics-from-server.spec.mjs index 2c902c9e..58f1cce6 100644 --- a/tests/e2e/lyrics-from-server.spec.mjs +++ b/tests/e2e/lyrics-from-server.spec.mjs @@ -19,9 +19,9 @@ import { stubAudioTags, stubExportEndpoints, stubUpdateCheck, + LYRICS_LOOKUP, } from "./helpers.mjs"; -const LRCLIB = /^https:\/\/lrclib\.net\//; const WIKIMEDIA = /^https:\/\/((www|query)\.wikidata\.org|[a-z-]+\.wikipedia\.org)\//; const version = (id, extra = {}) => ({ @@ -51,10 +51,12 @@ const SERVER = { }; async function setUp(page, { server = SERVER, saved = null } = {}) { + // Lookups the tab asks the server for (#719), answered as LRCLIB being + // out of reach. const lrclib = []; - await page.route(LRCLIB, (route) => { + await page.route(LYRICS_LOOKUP, (route) => { lrclib.push(route.request().url()); - return route.abort("internetdisconnected"); + return route.fulfill({ status: 502, json: { detail: "lyrics service unreachable" } }); }); const served = []; await page.route(`**/api/jobs/${JOB_ID}/lyrics`, (route) => { @@ -208,7 +210,7 @@ test.describe("lyrics from the server", () => { expect(lrclib).toEqual([]); }); - test("with none on the server, the tab looks them up itself as before", async ({ page }) => { + test("with none on the server, the tab asks the server to look them up", async ({ page }) => { const { lrclib, served } = await setUp(page, { server: null }); await expect.poll(() => lrclib.length).toBeGreaterThan(0); expect(served.length).toBeGreaterThan(0); diff --git a/tests/e2e/lyrics.spec.mjs b/tests/e2e/lyrics.spec.mjs index e574a0dd..802a67a5 100644 --- a/tests/e2e/lyrics.spec.mjs +++ b/tests/e2e/lyrics.spec.mjs @@ -3,11 +3,13 @@ // them for the track, and shows them in time with playback, karaoke style. // There is no search box. // -// LRCLIB is answered here, so the tests run offline. The fixture track is six -// seconds long, which is why the synced lines below sit inside it and why the -// first row below counts as the same recording. Ranking, LRC parsing, word -// timing and title cleaning are covered in tests/js/lyrics-lookup.test.mjs; -// this is the page: what is looked up and when, what is kept, what is shown. +// The tab asks the server to look lyrics up (#719), and the server's answer is +// given here (stubLyricsLookup), so the tests run offline. The fixture track +// is six seconds long, which is why the synced lines below sit inside it and +// why the first row below counts as the same recording. Which version the +// server keeps is tested against the server in tests/test_lyrics_lookup.py, +// LRC parsing and word timing in tests/js/lyrics-lookup.test.mjs; this is the +// page: what is asked and when, what is kept, what is shown. import { test, expect } from "@playwright/test"; import { JOB_ID, @@ -19,9 +21,11 @@ import { stubAudioTags, stubExportEndpoints, stubUpdateCheck, + stubLyricsLookup, + LYRICS_LOOKUP, } from "./helpers.mjs"; -const LRCLIB = /^https:\/\/lrclib\.net\//; +const LRCLIB = LYRICS_LOOKUP; const ROWS = [ { @@ -51,20 +55,16 @@ const TAGS = { audioTags: { artist: "Fixture Band", title: "Fixture Song" } }; // The first row as another artist's or another song's, for a search that asks for those. const rowAs = (artistName, trackName) => [{ ...ROWS[0], artistName, trackName }]; -async function stubLrclib(page, { rows = ROWS, offline = false } = {}) { - const asked = []; - await page.route(LRCLIB, async (route) => { - asked.push(route.request().url()); - if (offline) return route.abort("internetdisconnected"); - return route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify(rows), - }); - }); - return asked; -} +// The tagged artist and song unless a test says otherwise. +const stubLrclib = (page, options = {}) => + stubLyricsLookup(page, { rows: ROWS, artist: "Fixture Band", song: "Fixture Song", ...options }); + +/** What the server kept for the fixture track, as GET .../lyrics answers. */ +const serverKept = (page) => + page.evaluate(async (id) => { + const res = await fetch(`/api/jobs/${id}/lyrics`); + return res.ok ? res.json() : null; + }, JOB_ID); /** Both fixture tracks, the first with `extra` fields (tags, a saved band). */ async function seedWith(page, extra) { @@ -162,15 +162,12 @@ test.describe("lyrics tab", () => { await expect(page.locator(".lyrics-line")).toHaveText(["First line", "Second line", "Third line"]); await expect(page.locator(".lyrics-match-title")).toHaveText("Fixture Song"); - // What went out: the tagged artist and song, to LRCLIB, nothing else. - expect(asked).toHaveLength(1); - const url = new URL(asked[0]); - expect(url.searchParams.get("artist_name")).toBe("Fixture Band"); - expect(url.searchParams.get("track_name")).toBe("Fixture Song"); - expect(asked[0]).not.toContain("E2E"); + // One request, to the server, which looks the tags up itself (#719). + expect(asked).toEqual([{ id: JOB_ID, body: {} }]); - // Kept on this machine, for this track. - await expect.poll(async () => (await stored(page))?.entry?.id ?? null).toBe(101); + // Kept by the server, for this track, the way an import keeps them. + expect((await serverKept(page))?.lrclib_id).toBe(101); + expect(await stored(page)).toBeNull(); // The other version is folded away until asked for, then can be taken. const toggle = page.locator(".lyrics-tools .lyrics-link").first(); @@ -183,15 +180,14 @@ test.describe("lyrics tab", () => { }); test("a saved band is looked up with the song's name taken from the title", async ({ page }) => { - const asked = await stubLrclib(page, { rows: rowAs("Dream Theater", "E2E Fixture Track") }); + const asked = await stubLrclib(page, { rows: rowAs("Dream Theater", "E2E Fixture Track"), artist: "Dream Theater", song: "E2E Fixture Track" }); await seedWith(page, { artist: { id: "Q162586", name: "Dream Theater", englishName: "Dream Theater" } }); await open(page); await openTrack(page); await showLyricsTab(page); await expect(page.locator(".lyrics-line")).toHaveCount(3); - const url = new URL(asked[0]); - expect(url.searchParams.get("artist_name")).toBe("Dream Theater"); - expect(url.searchParams.get("track_name")).toBe("E2E Fixture Track"); + // The band lives in the studio's store, so it goes along with the request. + expect(asked[0].body.band).toEqual({ id: "Q162586", name: "Dream Theater", englishName: "Dream Theater" }); // Kept, so coming back to the tab asks nothing more. await page.locator(".rail-library").click(); @@ -363,9 +359,10 @@ test.describe("lyrics tab", () => { await expect(page.locator("#lyricsStatus")).toHaveText("Lyrics removed for this track."); expect(asked).toHaveLength(1); + // Looking up again finds what the server kept, with nothing more asked. await page.locator(".lyrics-link", { hasText: "Look up again" }).click(); await expect(page.locator(".lyrics-line")).toHaveCount(3); - expect(asked).toHaveLength(2); + expect(asked).toHaveLength(1); }); test("nothing found says so", async ({ page }) => { @@ -422,15 +419,15 @@ test.describe("lyrics tab", () => { }); test("a tagged artist with no tagged title is looked up with the song from the title", async ({ page }) => { - const asked = await stubLrclib(page, { rows: rowAs("Fixture Band", "E2E Fixture Track") }); + // The server takes the song from the title (build_query); the page only + // has to ask, which it does even with no tagged title. + const asked = await stubLrclib(page, { rows: rowAs("Fixture Band", "E2E Fixture Track"), song: "E2E Fixture Track" }); await seedWith(page, { audioTags: { artist: "Fixture Band" } }); await open(page); await openTrack(page); await showLyricsTab(page); await expect(page.locator(".lyrics-line")).toHaveCount(3); - const url = new URL(asked[0]); - expect(url.searchParams.get("artist_name")).toBe("Fixture Band"); - expect(url.searchParams.get("track_name")).toBe("E2E Fixture Track"); + expect(asked).toHaveLength(1); }); test("another artist's song of the same name is not this track's lyrics", async ({ page }) => { diff --git a/tests/e2e/search.spec.mjs b/tests/e2e/search.spec.mjs index 7c01c51d..6ea0f0ff 100644 --- a/tests/e2e/search.spec.mjs +++ b/tests/e2e/search.spec.mjs @@ -150,7 +150,7 @@ test.describe("topbar search", () => { await expect(page.locator("#url")).toHaveValue(/youtube\.com\/watch\?v=/); await expect(panel(page)).toBeHidden(); - // Extraction is minutes of work. It waits for Split stems. + // Extraction is minutes of work. It waits for Extract stems. expect(jobPosts).toHaveLength(0); }); diff --git a/tests/e2e/slow-speed.spec.mjs b/tests/e2e/slow-speed.spec.mjs new file mode 100644 index 00000000..c56f8d0b --- /dev/null +++ b/tests/e2e/slow-speed.spec.mjs @@ -0,0 +1,50 @@ +// The slow speed is the player's to set (#701). +// +// Speed was a fixed 0.75x or 1x. Some fills want 0.75x and some only a nudge, +// so the slow button can be moved a hundredth at a time, by the wheel or the +// up and down arrow keys, between 0.50x and 0.99x, and it is remembered. + +import { test, expect } from "@playwright/test"; +import { openStudio } from "./helpers.mjs"; + +const slow = (page) => page.locator("#t-speed-075"); +const rate = (page) => page.evaluate(async () => (await import("/js/state.js")).playbackSpeed); + +test("scrolling over the slow button moves it a hundredth at a time and plays at it", async ({ page }) => { + await openStudio(page); + await expect(slow(page)).toHaveText("0.75x"); + await slow(page).hover(); + await page.mouse.wheel(0, -100); + await page.mouse.wheel(0, -100); + await expect(slow(page)).toHaveText("0.77x"); + await expect(slow(page)).toHaveAttribute("aria-checked", "true"); + await expect.poll(() => rate(page)).toBe(0.77); + + // 1x is still one press away, and the slow speed is kept for next time. + await page.locator("#t-speed-1").click(); + await expect.poll(() => rate(page)).toBe(1); + await slow(page).click(); + await expect.poll(() => rate(page)).toBe(0.77); +}); + +test("the arrow keys move it too, and it stops at 0.50x and 0.99x", async ({ page }) => { + await openStudio(page); + await slow(page).focus(); + for (let i = 0; i < 40; i++) await page.keyboard.press("ArrowDown"); + await expect(slow(page)).toHaveText("0.50x"); + for (let i = 0; i < 60; i++) await page.keyboard.press("ArrowUp"); + await expect(slow(page)).toHaveText("0.99x"); + await expect.poll(() => rate(page)).toBe(0.99); +}); + +test("the slow speed survives a reload", async ({ page }) => { + await openStudio(page); + await slow(page).hover(); + await page.mouse.wheel(0, 100); + await expect(slow(page)).toHaveText("0.74x"); + await page.waitForTimeout(400); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(slow(page)).toHaveText("0.74x"); + // A track opens at normal speed, as it always has. + await expect(page.locator("#t-speed-1")).toHaveAttribute("aria-checked", "true"); +}); diff --git a/tests/js/lyrics-lookup.test.mjs b/tests/js/lyrics-lookup.test.mjs index 31173f7c..cb7eb74f 100644 --- a/tests/js/lyrics-lookup.test.mjs +++ b/tests/js/lyrics-lookup.test.mjs @@ -1,30 +1,20 @@ -// The Lyrics tab's lookup (#699): how LRC text becomes timed lines, which line -// is being sung at a given moment, which of LRCLIB's many versions of a song -// is taken as the one, and what is sent to search for it. -// -// No network. searchLyrics takes its fetch as an argument. +// The Lyrics tab's reading of lyrics (#699): how LRC text becomes timed lines, +// which line is being sung at a given moment, and what the server's answer +// becomes. Which of LRCLIB's versions is the track's is the server's to decide +// (#719), and is tested in tests/test_lyrics_lookup.py. // // Run: node tests/js/lyrics-lookup.test.mjs import { parseLrc, currentLineIndex, - rankMatches, - searchUrl, - searchLyrics, wordTimings, voicedPhrases, syllables, songFromTitle, fromServerLyrics, - strippedCopy, - belongsTo, - sameArtist, sameSong, - scriptNames, - otherNames, fold as foldName, - rankVersions, clampOffset, shiftLines, firstSungIndex, @@ -208,66 +198,6 @@ check("between lines, the one before", currentLineIndex(lines, 25) === 1); check("after the last", currentLineIndex(lines, 99) === 2); check("no lines", currentLineIndex([], 5) === -1); -// ── rankMatches ── -const row = (id, duration, kind) => ({ - id, - trackName: `T${id}`, - artistName: "A", - albumName: "", - duration, - instrumental: kind === "instrumental", - syncedLyrics: kind === "synced" ? "[00:01.00]x" : null, - plainLyrics: kind === "synced" || kind === "plain" ? "x" : null, -}); -const ranked = rankMatches( - [row(1, 769, "synced"), row(2, 571, "plain"), row(3, 573, "synced"), row(4, 572, "none"), row(5, 590, "synced")], - 572, -); -check( - "closest length first, synced preferred within a few seconds, empty rows dropped", - same(ranked.map((m) => m.id), [3, 2, 5, 1]), - JSON.stringify(ranked.map((m) => m.id)), -); -check("an instrumental row is kept", rankMatches([row(9, 100, "instrumental")], 100).length === 1); -check( - "with no length known, LRCLIB's order, synced first", - same(rankMatches([row(1, 10, "plain"), row(2, 500, "synced")], 0).map((m) => m.id), [2, 1]), -); -check("not an array gives nothing", same(rankMatches(null), []) && same(rankMatches({ error: 1 }), [])); - -// ── searchUrl ── -const withArtist = new URL(searchUrl({ artist: " Dream Theater ", song: " Metropolis " })); -check("LRCLIB's search", withArtist.origin + withArtist.pathname === "https://lrclib.net/api/search"); -check( - "artist and song as their own fields, trimmed", - withArtist.searchParams.get("artist_name") === "Dream Theater" && withArtist.searchParams.get("track_name") === "Metropolis", -); -const songOnly = new URL(searchUrl({ song: "Metropolis" })); -check( - "no artist: the free-text search, not an empty artist filter", - songOnly.searchParams.get("q") === "Metropolis" && !songOnly.searchParams.has("artist_name"), -); - -// ── searchLyrics ── -const asked = []; -const found = await searchLyrics( - { artist: "Dream Theater", song: "Metropolis", duration: 572 }, - { fetchJson: async (url) => { asked.push(url); return [row(1, 769, "synced"), row(3, 573, "synced")]; } }, -); -check("ranked for the track's length", same(found.map((m) => m.id), [3, 1])); -check("one request, to LRCLIB only", asked.length === 1 && asked[0].startsWith("https://lrclib.net/")); -check( - "no song asks nothing", - same(await searchLyrics({ artist: "X", song: " " }, { fetchJson: () => { throw new Error("asked"); } }), []), -); -let rejected = false; -try { - await searchLyrics({ song: "x" }, { fetchJson: async () => { throw new Error("offline"); } }); -} catch { - rejected = true; -} -check("no connection rejects, rather than reading as nothing found", rejected); - // ── fromServerLyrics: lyrics.json from GET /api/jobs/{id}/lyrics ── const version = (id, source = "lrclib", extra = {}) => ({ v: 1, @@ -319,70 +249,6 @@ check( ); // ── letters outside ASCII ── -// LRCLIB holds some songs as copies that lost those letters on the way in -// (Kayah's "Nie ma, nie ma ciebie": eleven copies read "Niewinnoci biaym -// niegiem", one "Niewinnością białym śniegiem"). The anthem stands in for a -// song, being in the public domain. The same cases as tests/test_lyrics_lookup.py. -const ANTHEM = [ - "[00:01.00]Jeszcze Polska nie zginęła,", - "[00:04.00]Kiedy my żyjemy.", - "[00:07.00]Co nam obca przemoc wzięła,", - "[00:10.00]Szablą odbierzemy.", - "[00:13.00]Marsz, marsz, Dąbrowski,", - "[00:16.00]Z ziemi włoskiej do Polski.", - "[00:19.00]Za twoim przewodem", - "[00:22.00]Złączym się z narodem.", -].join("\n"); -const drop = (s) => s.replace(/[^\p{ASCII}]/gu, ""); -const fold = (s) => drop(s.normalize("NFD")); -const asLrclibDropsThem = (s) => drop(s.replaceAll("ę", "e")); -const textRow = (id, duration, text, synced = true) => ({ - id, - trackName: `T${id}`, - artistName: "A", - albumName: "", - duration, - instrumental: false, - syncedLyrics: synced ? text : null, - plainLyrics: text.replace(/\[[^\]\n]*\]/g, ""), -}); -const lyricsOf = (text) => ({ synced: text, plain: "" }); - -for (const [name, strip] of [["dropped", drop], ["folded", fold], ["as LRCLIB drops them", asLrclibDropsThem]]) { - check(`a copy whose Polish letters were ${name} is told from its source`, strippedCopy(lyricsOf(strip(ANTHEM)), lyricsOf(ANTHEM))); -} -check("the intact copy is not a stripped one", !strippedCopy(lyricsOf(ANTHEM), lyricsOf(asLrclibDropsThem(ANTHEM)))); -check("a copy is not a stripped copy of itself", !strippedCopy(lyricsOf(ANTHEM), lyricsOf(ANTHEM))); -for (const [intact, other] of [ - ["[00:01.00]Группа крови на рукаве, мой порядковый номер на рукаве", "[00:01.00]Gruppa krovi na rukave, moy poryadkovyy nomer na rukave"], - ["[00:01.00]夢ならばどれほどよかったでしょう 未だにあなたのことを夢にみる", "[00:01.00]Yume naraba dore hodo yokatta deshou imada ni anata no koto wo yume ni miru"], - ["[00:01.00]A café, a naïve smile, and the rest in plain English words", "[00:01.00]A cafe, a naive smile, and the rest in plain English words"], - [ANTHEM, "[00:01.00]Jeszcze nic nie jest stracone, moja mila, gdy jestem z toba"], -]) { - check( - `not a stripped copy: ${other.slice(10, 30)}`, - !strippedCopy(lyricsOf(other), lyricsOf(intact)) && !strippedCopy(lyricsOf(intact), lyricsOf(other)), - ); -} -{ - const rows = [ - textRow(5470091, 230, asLrclibDropsThem(ANTHEM)), - textRow(28700266, 230, asLrclibDropsThem(ANTHEM)), - textRow(10910419, 229.93, ANTHEM), - textRow(4291789, 230, ANTHEM, false), - ]; - const want = [10910419, 5470091, 28700266, 4291789]; - check("the intact copy ranks before stripped ones LRCLIB listed first", same(rankMatches(rows, 230).map((m) => m.id), want)); - check("and with the length unknown", same(rankMatches(rows).map((m) => m.id), want)); - const stripped = textRow(1, 230, asLrclibDropsThem(ANTHEM)); - check( - "synced still beats plain, and length beats both", - same(rankMatches([stripped, textRow(2, 230, ANTHEM, false)], 230).map((m) => m.id), [1, 2]) - && same(rankMatches([stripped, textRow(3, 250, ANTHEM)], 230).map((m) => m.id), [1, 3]), - ); -} - -// Polish through parsing and the wipe: nothing dropped, nothing split. { const polish = parseLrc("[00:31.72]<00:31.72>Śpiewałem <00:32.40>głośno <00:32.90>pod <00:33.10>prysznicem\n[00:33.97]Ten mój małomiasteczkowy hit"); check("Polish lines keep every letter", polish[0].text === "Śpiewałem głośno pod prysznicem" && polish[1].text === "Ten mój małomiasteczkowy hit"); @@ -403,52 +269,17 @@ for (const [intact, other] of [ check("Cyrillic words stay whole", same(cyrillic.map((w) => w.text), ["Группа ", "крови ", "на ", "рукаве"])); } -// Whose song a version is: only this song by this artist is ever kept. +// Whose song kept lyrics are: the check on what the tab kept before the +// server held versions to the track's song (#719). The rest of the rule is +// the server's alone (tests/test_lyrics_lookup.py). { - const ask = { artist: "Keala Settle & The Greatest Showman Ensemble", song: "This Is Me" }; - const row = (artist, track) => ({ artist, track }); - check("the same artist and song belong", belongsTo(row("Keala Settle & The Greatest Showman Ensemble", "This Is Me"), ask)); - check("one credited artist belongs", belongsTo(row("Keala Settle", "This Is Me"), ask)); - check("the cast filed as a run of the name belongs", belongsTo(row("The Greatest Showman Ensemble", "This Is Me"), ask)); - check("a song tail belongs", belongsTo(row("Keala Settle", "This Is Me - From The Greatest Showman"), ask)); check("brackets and featuring are ignored", sameSong("This Is Me (feat. Someone) [Live]", "This Is Me")); - check("another artist's song of that name does not", !belongsTo(row("Kesha", "This Is Me"), ask)); - check("the artist's other song does not", !belongsTo(row("Keala Settle", "This Is Not Me"), ask)); + check("a song tail belongs", sameSong("This Is Me - From The Greatest Showman", "This Is Me")); + check("another song is not this one", !sameSong("This Is Not Me", "This Is Me")); check("two different tails are two songs", !sameSong("Part I - Dawn", "Part I - Dusk")); - check("no artist known, nothing belongs", !belongsTo(row("Keala Settle", "This Is Me"), { artist: "", song: "This Is Me" })); - check("a show's name counts when given", belongsTo(row("Wicked", "Popular"), { artist: "Kristin Chenoweth", song: "Popular", names: ["Wicked"] })); - check("a show's name does not count unless given", !belongsTo(row("Wicked", "Popular"), { artist: "Kristin Chenoweth", song: "Popular" })); - check("case, accents and a leading The do not matter", sameArtist("the beatles", ["The Beatles"]) && sameArtist("Beyonce", ["Beyoncé"]) && sameArtist("Beatles", ["The Beatles"])); - check("Polish names compare letter for letter", sameArtist("Dawid Podsiadło", ["Dawid Podsiadło"]) && !sameArtist("Dawid Podsiadło", ["Dawid Kwiatkowski"])); - check("a two-letter credit names nobody", !sameArtist("DJ", ["DJ & Someone Else"])); - check("one shared word is not a shared name", !sameArtist("Pink", ["Pink Floyd"])); } -// Chinese, Japanese and Korean names: the cases in tests/test_lyrics_names.py. { - const artists = [ - ["周杰伦", ["周杰倫"], true], - ["邓丽君", ["鄧麗君"], true], - ["YOASOBI", ["YOASOBI"], true], - ["キングヌー", ["キングヌー"], true], - ["Dawid Podsiadlo", ["Dawid Podsiadło"], true], - ["鄧麗君 (Teresa Teng)", ["Teresa Teng"], true], - ["五月天 (Mayday)", ["五月天"], true], - ["IU", ["IU (아이유)"], true], - ["周杰倫", ["周杰倫 Jay Chou"], true], - ["Jay Chou", ["周杰倫 Jay Chou"], true], - ["周杰倫 & 費玉清", ["周杰倫"], true], - ["Jay Chou", ["周杰倫"], false], - ["아이유", ["IU"], false], - ["张信哲", ["周杰倫"], false], - ["五月天 阿信", ["五月天"], false], - ["告五人", ["五月天"], false], - ["Official", ["Official髭男dism"], false], - ["林", ["林 & 周杰倫"], false], - ]; - for (const [found, names, want] of artists) { - check(`sameArtist(${found}, ${names}) is ${want}`, sameArtist(found, names) === want); - } const songs = [ ["红豆", "紅豆", true], ["晴天 (Sunny Day)", "晴天", true], @@ -465,20 +296,7 @@ for (const [intact, other] of [ for (const [found, song, want] of songs) { check(`sameSong(${found}, ${song}) is ${want}`, sameSong(found, song) === want); } - check("a name in two scripts gives both", same(scriptNames("周杰倫 Jay Chou"), ["周杰伦", "Jay Chou"]) && same(scriptNames("IU(아이유)"), ["IU", "아이유"])); - check("a name mixing scripts in one word is one name", same(scriptNames("Official髭男dism"), []) && same(scriptNames("五月天 阿信"), [])); check("folding: traditional, width, plain Latin", foldName("鄧麗君 IU Podsiadło") === "邓丽君 IU Podsiadlo"); - check("the band's names count as the artist's", belongsTo({ artist: "周杰倫", track: "晴天" }, { artist: "Jay Chou", song: "晴天", names: ["周杰倫", "Jay Chou"] })); - check("and never make another artist's song the track's", !belongsTo({ artist: "张信哲", track: "晴天" }, { artist: "Jay Chou", song: "晴天", names: ["周杰倫"] })); - check( - "other names to search by: a name's own scripts, then the band's, each once", - same(otherNames("周杰倫 Jay Chou", ["周杰倫", "Jay Chou", "周杰倫 Jay Chou"]), ["周杰伦", "Jay Chou", "周杰倫"]), - JSON.stringify(otherNames("周杰倫 Jay Chou", ["周杰倫", "Jay Chou"])), - ); - check("the artist's own name is not searched again", same(otherNames("IU", ["IU", "아이유"]), ["아이유"])); - const a = rankMatches([row(1, 300, "synced"), row(2, 269, "synced")], 269); - const b = rankMatches([row(3, 270, "synced"), row(2, 269, "synced")], 269); - check("versions from two searches rank together, each once", same(rankVersions([...a, ...b], 269).map((m) => m.id), [2, 3, 1])); } // Karaoke in Chinese, Japanese and Korean. diff --git a/tests/js/name-parity.test.mjs b/tests/js/name-parity.test.mjs index 854a0a05..47a9694e 100644 --- a/tests/js/name-parity.test.mjs +++ b/tests/js/name-parity.test.mjs @@ -1,6 +1,8 @@ -// JS half of the name parity gate: the page's lyrics matching -// (static/js/lyricsLookup.js, zhVariants.js) held to what the server's -// (app/pipeline/lyrics_lookup.py, name_aliases.py, zh_variants.py) says. +// JS half of the name parity gate: what is left of the page's name +// handling (static/js/lyricsLookup.js fold and sameSong, zhVariants.js) held +// to what the server's (app/pipeline/lyrics_lookup.py, name_aliases.py, +// zh_variants.py) says. The page no longer decides whose song a version is +// (#719); sameSong only checks lyrics it kept before that rule existed. // // tests/fixtures/name_parity.json is generated from Python and asserted by // tests/test_name_parity.py; this file asserts the page gives the same @@ -14,7 +16,7 @@ // Run: node tests/js/name-parity.test.mjs import { readFileSync } from 'node:fs'; -import { fold, scriptNames, sameArtist, sameSong } from '../../static/js/lyricsLookup.js'; +import { fold, sameSong } from '../../static/js/lyricsLookup.js'; import { PAIRS, toSimplified } from '../../static/js/zhVariants.js'; const fixture = JSON.parse(readFileSync(new URL('../fixtures/name_parity.json', import.meta.url), 'utf8')); @@ -31,22 +33,12 @@ function check(name, condition, detail = '') { } } -const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); - -check('the fixture is not empty', fixture.fold.length >= 10 && fixture.sameArtist.length >= 20 && fixture.sameSong.length >= 10); +check('the fixture is not empty', fixture.fold.length >= 10 && fixture.sameSong.length >= 10); for (const { in: text, out } of fixture.fold) { const got = fold(text); check(`fold(${JSON.stringify(text)})`, got === out, `page ${JSON.stringify(got)}, server ${JSON.stringify(out)}`); } -for (const { in: name, out } of fixture.scriptNames) { - const got = scriptNames(name); - check(`scriptNames(${JSON.stringify(name)})`, same(got, out), `page ${JSON.stringify(got)}, server ${JSON.stringify(out)}`); -} -for (const { found, names, same: want } of fixture.sameArtist) { - const got = sameArtist(found, names); - check(`sameArtist(${JSON.stringify(found)}, ${JSON.stringify(names)})`, got === want, `page ${got}, server ${want}`); -} for (const { found, song, same: want } of fixture.sameSong) { const got = sameSong(found, song); check(`sameSong(${JSON.stringify(found)}, ${JSON.stringify(song)})`, got === want, `page ${got}, server ${want}`); diff --git a/tests/js/source-arming.test.mjs b/tests/js/source-arming.test.mjs index 42ac1c24..81497239 100644 --- a/tests/js/source-arming.test.mjs +++ b/tests/js/source-arming.test.mjs @@ -1,4 +1,4 @@ -// Which track sources the Split stems button may be pointed at (#635). +// Which track sources the Extract stems button may be pointed at (#635). // // The composer is an input that button submits, not a caption for the open // track. An uploaded file's source is the synthetic "local:my song.mp3", and diff --git a/tests/test_csp.py b/tests/test_csp.py index 9b8e1a11..6a0ecb20 100644 --- a/tests/test_csp.py +++ b/tests/test_csp.py @@ -22,18 +22,19 @@ def test_connect_src_permits_data_and_blob(): assert "blob:" in connect -def test_connect_src_allows_the_artist_and_lyrics_hosts_and_nothing_wider(): - # The artist box and the Lyrics tab (#699) read Wikidata, Wikipedia and - # LRCLIB straight from the page. Those hosts by name, never a bare https: - # that would let an injected script send anything anywhere. +def test_connect_src_allows_the_artist_hosts_and_nothing_wider(): + # The artist box (#699) reads Wikidata and Wikipedia straight from the + # page. Those hosts by name, never a bare https: that would let an + # injected script send anything anywhere. LRCLIB is not among them: the + # Lyrics tab asks the server to look lyrics up (#719). sources = _csp_directive("connect-src").split()[1:] for host in ( "https://www.wikidata.org", "https://query.wikidata.org", "https://*.wikipedia.org", - "https://lrclib.net", ): assert host in sources + assert "https://lrclib.net" not in sources assert "https:" not in sources assert "*" not in sources diff --git a/tests/test_jobs_favorite.py b/tests/test_jobs_favorite.py new file mode 100644 index 00000000..f388cc50 --- /dev/null +++ b/tests/test_jobs_favorite.py @@ -0,0 +1,83 @@ +"""Favourites live on the server, so the phone can set them and filter by them (#734). + +They used to live only in the desktop page's catalog store. The phone builds its +library from GET /api/jobs and had no way to reach that store, so its Favorites +chip listed every track and there was no heart to press. + +`favorite` starts as None, meaning no client has said either way. The desktop +uses that to hand up favourites it set before the field existed, without +undoing one taken back out on the phone. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.core.models import Job +from app.core.registry import _jobs, register +from app.main import app + + +@pytest.fixture(autouse=True) +def _isolate_registry(): + _jobs.clear() + yield + _jobs.clear() + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def _done(job_id: str) -> Job: + job = Job(id=job_id, status="done", title="Song") + register(job) + return job + + +def test_a_new_job_has_said_nothing_about_favourites(client: TestClient) -> None: + _done("aaaaaaaaaaaa") + assert client.get("/api/jobs/aaaaaaaaaaaa").json()["favorite"] is None + + +def test_favourite_and_back_out(client: TestClient) -> None: + _done("aaaaaaaaaaaa") + resp = client.put("/api/jobs/aaaaaaaaaaaa/favorite", json={"favorite": True}) + assert resp.status_code == 200 + assert resp.json() == {"job_id": "aaaaaaaaaaaa", "favorite": True} + assert client.get("/api/jobs").json()[0]["favorite"] is True + + resp = client.put("/api/jobs/aaaaaaaaaaaa/favorite", json={"favorite": False}) + assert resp.json()["favorite"] is False + # False, not None: taken back out is an answer the desktop must respect. + assert client.get("/api/jobs/aaaaaaaaaaaa").json()["favorite"] is False + + +def test_the_flag_survives_a_restart() -> None: + job = _done("aaaaaaaaaaaa") + job.favorite = True + assert Job.from_record(job.to_record()).favorite is True + + +@pytest.mark.parametrize("body", [{}, {"favorite": "false"}, {"favorite": 1}, {"favorite": None}]) +def test_anything_but_a_bool_is_refused(client: TestClient, body: dict) -> None: + _done("aaaaaaaaaaaa") + resp = client.put("/api/jobs/aaaaaaaaaaaa/favorite", json=body) + assert resp.status_code == 422 + assert client.get("/api/jobs/aaaaaaaaaaaa").json()["favorite"] is None + + +def test_unknown_job_is_404(client: TestClient) -> None: + assert client.put("/api/jobs/999999999999/favorite", json={"favorite": True}).status_code == 404 + + +@pytest.mark.parametrize( + "job_id", ["../../etc/passwd", "..%2F..%2Fetc", "not-a-job-id", "AAAAAAAAAAAA"] +) +def test_crafted_ids_are_refused_not_500(client: TestClient, job_id: str) -> None: + """Some are collapsed before routing and nothing answers PUT there (405); + the rest arrive as a job id and are refused (404). Never a 500.""" + resp = client.put(f"/api/jobs/{job_id}/favorite", json={"favorite": True}) + assert resp.status_code in (404, 405) diff --git a/tests/test_lyrics_api.py b/tests/test_lyrics_api.py index 3aff16c2..d45056aa 100644 --- a/tests/test_lyrics_api.py +++ b/tests/test_lyrics_api.py @@ -648,3 +648,86 @@ def test_other_versions_of_another_song_are_not_offered(client): _lyrics_file(job).write_text(json.dumps({**LYRICS, "others": others}), encoding="utf-8") got = client.get(f"/api/jobs/{job.id}/lyrics").json() assert [o["lrclib_id"] for o in got["others"]] == [8] + + +# ── POST .../lyrics/lookup: the tab asks the server, not LRCLIB (#719) ── + + +def test_lookup_keeps_lyrics_the_way_the_import_does(client): + job = _done_job(audio_tags=TAGS) + lrclib = Lrclib(ROWS) + with patch.object(ll, "_fetch_json", lrclib): + r = client.post(f"/api/jobs/{job.id}/lyrics/lookup", json={}) + assert r.status_code == 200 + assert r.json()["synced"] == "[00:01.00]The smile of dawn" + assert _lyrics_file(job).is_file() + assert job.has_lyrics is True + # And GET answers the same from now on, with no second request. + assert client.get(f"/api/jobs/{job.id}/lyrics").json()["synced"] == r.json()["synced"] + + +def test_lookup_uses_the_band_the_studio_saved(client): + """No tags: only the band chosen in the artist box, which the studio + keeps in its own store, says whose song this is.""" + job = _done_job() + lrclib = Lrclib(ROWS) + with patch.object(ll, "_fetch_json", lrclib): + r = client.post(f"/api/jobs/{job.id}/lyrics/lookup", json={"band": DT}) + assert r.status_code == 200 + assert lrclib.asked + + +def test_lookup_with_nothing_to_go_on_asks_nobody(client): + job = _done_job() + job.title = "" + lrclib = Lrclib(ROWS) + with patch.object(ll, "_fetch_json", lrclib): + r = client.post(f"/api/jobs/{job.id}/lyrics/lookup", json={}) + assert r.status_code == 404 + assert r.json()["nothing_known"] is True + assert lrclib.asked == [] + + +def test_lookup_offers_other_artists_nothing(client): + """Another artist's song of the same name is not this track's lyrics: + the server's rule, now the only one.""" + job = _done_job(audio_tags=TAGS) + other = [{**ROWS[0], "artistName": "Someone Else"}] + with patch.object(ll, "_fetch_json", Lrclib(other)): + r = client.post(f"/api/jobs/{job.id}/lyrics/lookup", json={}) + assert r.status_code == 404 + assert r.json().get("others", []) == [] + assert not _lyrics_file(job).is_file() + + +def test_lookup_offline_is_502_so_the_tab_can_retry(client): + job = _done_job(audio_tags=TAGS) + # conftest's stand-in answers every request as offline. + r = client.post(f"/api/jobs/{job.id}/lyrics/lookup", json={}) + assert r.status_code == 502 + assert r.json() == {"detail": "lyrics service unreachable"} + + +def test_lookup_answers_kept_lyrics_without_asking(client): + job = _done_job(has_lyrics=True, audio_tags=TAGS) + _lyrics_file(job).write_text(json.dumps(LYRICS), encoding="utf-8") + lrclib = Lrclib(ROWS) + with patch.object(ll, "_fetch_json", lrclib): + r = client.post(f"/api/jobs/{job.id}/lyrics/lookup", json={}) + assert r.status_code == 200 + assert lrclib.asked == [] + + +@pytest.mark.parametrize("band", [{"id": "../etc", "name": "x"}, {"id": "Q1", "name": "x" * 301}]) +def test_lookup_refuses_a_malformed_band(client, band): + job = _done_job() + r = client.post(f"/api/jobs/{job.id}/lyrics/lookup", json={"band": band}) + assert r.status_code == 422 + + +@pytest.mark.parametrize( + "job_id", ["../../etc/passwd", "..%2F..%2Fetc", "not-a-job", "bbbbbbbbbbbb"] +) +def test_lookup_crafted_or_unknown_ids_are_refused_not_500(client, job_id): + r = client.post(f"/api/jobs/{job_id}/lyrics/lookup", json={}) + assert r.status_code in (404, 405) From ffa6e93e58d632aa9ce12d0e8d6494bbc61c276c Mon Sep 17 00:00:00 2001 From: "Tha.Les" <31625914+thcp@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:00:59 +0100 Subject: [PATCH 6/7] ci(trivy): ignore the two urllib3 CVEs until the lock next moves (#741) urllib3 2.7.0 -> 2.8.0 changes uv.lock, which sends every desktop install to a full download instead of the one-click update, two days after 0.19.0 already did. StemDeck's own requests use the standard library's urllib; urllib3 only serves model and data downloads from fixed hosts through requests. The entry says when to drop it. Co-authored-by: Thales <> --- .trivyignore | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.trivyignore b/.trivyignore index 17b6d25b..81f03eac 100644 --- a/.trivyignore +++ b/.trivyignore @@ -35,3 +35,20 @@ DS-0002 # update. Take the bump the next time the lock moves for another reason, and # drop this entry then. CVE-2026-63374 + +# CVE-2026-97687 (traffic interception through an HTTPS proxy's TLS +# configuration) and CVE-2026-97689 (denial of service through unbounded +# memory): urllib3, fixed in 2.8.0; the lock pins 2.7.0. +# +# StemDeck's own requests all go through the standard library's urllib. +# urllib3 is here only through requests, which audio-separator, librosa's +# pooch and Whisper's tiktoken use to download models and data from fixed +# hosts (GitHub, Hugging Face, OpenAI's CDN). The first needs an HTTPS proxy +# configured, the second a hostile server on one of those downloads. +# +# Not bumped now for the same reason as CVE-2026-63374: any uv.lock change +# sends every existing desktop install to a full download instead of an +# in-app update, and 0.19.0 already did that two days before 0.19.1. Take the +# bump the next time the lock moves for another reason, and drop these then. +CVE-2026-97687 +CVE-2026-97689 From dffb4f629e842ef079cb6f3df629c45e5bfcf624 Mon Sep 17 00:00:00 2001 From: "Tha.Les" <31625914+thcp@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:24:08 +0100 Subject: [PATCH 7/7] feat(transport): a half-speed button beside the slow speed (#743) 0.5x is now one press, next to the slow speed the player sets and 1x, instead of 25 notches down from 0.75x. The button pressed is the one lit, so half speed and a slow speed brought down to 0.50x never light together. Tests: slow-speed.spec.mjs presses half speed and checks only one button is lit when the slow speed is also at 0.50x. Closes #742 Co-authored-by: Thales <> --- static/index.html | 1 + static/js/state.js | 2 +- static/js/transport.js | 38 +++++++++++++++++++++-------------- tests/e2e/slow-speed.spec.mjs | 20 ++++++++++++++++++ 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/static/index.html b/static/index.html index a69fa64a..7baf25c0 100644 --- a/static/index.html +++ b/static/index.html @@ -871,6 +871,7 @@

Lyrics

Speed