From dfe935c55ba0c170f4550ca9317c7777a7a92052 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:53:56 +0100 Subject: [PATCH 1/2] test(e2e): a song's key after switching to another song and back Checks the counter, every lane's stepper, the audio graph's routing and the saved record agree in Practice, Advanced and Karaoke, after switching back and after a reload. Also a record saved by 0.20, a reopen inside the save debounce, a record read after the engine is up, speed and mutes, and that switching leaves no extra AudioContext or pitch chain. The transpose spec's reopen test asserted the key went back to 0, which only held because the reopen beat the debounced save. --- tests/e2e/spec-map.json | 6 +- tests/e2e/transpose-switch-song.spec.mjs | 374 +++++++++++++++++++++++ tests/e2e/transpose.spec.mjs | 11 +- 3 files changed, 385 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/transpose-switch-song.spec.mjs diff --git a/tests/e2e/spec-map.json b/tests/e2e/spec-map.json index 511623fe..ee18bac1 100644 --- a/tests/e2e/spec-map.json +++ b/tests/e2e/spec-map.json @@ -86,7 +86,7 @@ "static/js/modeUi.js", "static/js/mixMode.js", "static/js/modeStems.js", "static/js/partChoice.js", "static/js/stemChoice.js", "static/js/stemsControl.js", "static/js/playbackStems.js", "static/css/appbar.css" ], - "specs": ["modes", "help-mode", "mode-stems", "partial-stems", "extract-row", "stem-chip-overflow", "karaoke", "streamer", "streamer-media", "panel-toggles"] + "specs": ["modes", "help-mode", "mode-stems", "partial-stems", "extract-row", "stem-chip-overflow", "karaoke", "streamer", "streamer-media", "panel-toggles", "transpose-switch-song"] }, { "why": "The import form: URL box, search, file drop, playlist import, and the stem choice submitted with it", @@ -141,7 +141,7 @@ "static/js/pitchBus.js", "static/js/tempoStage.js", "static/js/tickLoop.js", "static/js/keyLabel.js", "static/vendor/signalsmith-stretch.js", "static/vendor/soundtouch-processor.js" ], - "specs": ["tempo-stage", "slow-speed", "transpose", "mobile-transpose", "network-transpose-warning", "click-track", "count-in-replay", "key-card", "help-mode"] + "specs": ["tempo-stage", "slow-speed", "transpose", "transpose-switch-song", "mobile-transpose", "network-transpose-warning", "click-track", "count-in-replay", "key-card", "help-mode"] }, { "why": "Footer and transport layout", @@ -180,7 +180,7 @@ { "why": "Karaoke mode", "paths": ["static/js/karaoke/**", "static/css/karaoke.css", "app/api/backing.py", "app/pipeline/backing.py"], - "specs": ["karaoke", "streamer", "modes", "help-mode", "mode-stems", "lyrics", "playback-memory", "track-header", "video-sync", "zoom"] + "specs": ["karaoke", "streamer", "modes", "help-mode", "mode-stems", "lyrics", "playback-memory", "track-header", "video-sync", "zoom", "transpose-switch-song"] }, { "why": "Performance (streamer) mode", diff --git a/tests/e2e/transpose-switch-song.spec.mjs b/tests/e2e/transpose-switch-song.spec.mjs new file mode 100644 index 00000000..26c7c58f --- /dev/null +++ b/tests/e2e/transpose-switch-song.spec.mjs @@ -0,0 +1,374 @@ +// A song's key, and the rest of its saved state, after switching to another +// song and back (#781). +// +// Three things have to agree once a song is open again: the number on the +// transport's key counter (and on each lane's key stepper), what the audio +// graph is actually doing, and what the song's saved record holds, which is +// what the next reload will show. 0.20 put the lanes back at -2 from the saved +// record but always showed 0 on the counter, so the song still played two +// semitones down under a counter claiming the original key. +// +// What the audio is doing is read from the graph itself, not only from a value +// the engine reports about itself. A lane's transpose is a connection: lane +// audio is wired into the worklet input for its shift (input 6 + n, +// pitchBus.js), and a pitch input is wired into the worklet only while a lane +// sits on it. So the recorder below keeps, for the newest worklet, which input +// every lane is on right now and which pitch inputs are wired. Lanes at -2 are +// on input 4, and input 4 is the only pitch input wired: that is a track +// playing two semitones down, with no pitch chain running for a key nothing is +// in. + +import { test, expect } from "@playwright/test"; +import { JOB_ID, SIBLING_JOB_ID, openStudio, chooseMode, stubTauri } from "./helpers.mjs"; + +const ZERO_INPUT = 6; + +const down = (page) => page.locator("#t-pitch-down"); +const up = (page) => page.locator("#t-pitch-up"); +const laneDown = (page, stem) => page.locator(`.lane-key[data-stem="${stem}"] .lane-key-step.down`); +// Karaoke's own Key panel: the same global key, through setGlobalPitch. +const kkDown = (page) => page.locator("#karaoke .kk-key .kk-btn.kk-sq").first(); +const kkUp = (page) => page.locator("#karaoke .kk-key .kk-btn.kk-sq").nth(1); +const kkBadge = (page) => page.locator("#karaoke .kk-key .kk-badge"); +// The badge signs with a true minus sign (karaokeModel.semitoneLabel). +const KK_MINUS_TWO = `${String.fromCharCode(0x2212)}2`; +const ev = (page, fn, arg) => page.evaluate(fn, arg); + +/** Watch the audio graph: lanes onto pitch inputs, and pitch inputs onto the worklet. */ +async function recordGraph(page) { + await page.addInitScript(() => { + const g = { contextsOpen: 0, wired: new Set(), lanes: new Map() }; + window.__graph = g; + let worklet = null; + const busInput = new Map(); + + const OrigCtx = window.AudioContext; + if (OrigCtx) { + window.AudioContext = class extends OrigCtx { + constructor(...args) { + super(...args); + g.contextsOpen += 1; + let closed = false; + const close = this.close.bind(this); + this.close = () => { + if (!closed) { closed = true; g.contextsOpen -= 1; } + return close(); + }; + } + }; + } + const OrigNode = window.AudioWorkletNode; + if (OrigNode) { + window.AudioWorkletNode = class extends OrigNode { + constructor(...args) { + super(...args); + if (args[1] === "soundtouch-processor") { + // A new engine: what the previous one was wired to went with it. + worklet = this; + g.wired = new Set(); + g.lanes = new Map(); + busInput.clear(); + } + } + }; + } + const connect = AudioNode.prototype.connect; + AudioNode.prototype.connect = function (dest, output, input) { + const r = connect.apply(this, arguments); + if (worklet && dest === worklet) { + busInput.set(this, input || 0); + g.wired.add(input || 0); + } else if (busInput.has(dest)) { + g.lanes.set(this, busInput.get(dest)); + } + return r; + }; + const disconnect = AudioNode.prototype.disconnect; + AudioNode.prototype.disconnect = function (dest, output, input) { + const r = disconnect.apply(this, arguments); + if (worklet && dest === worklet && busInput.has(this)) g.wired.delete(input ?? busInput.get(this)); + else if (dest === undefined) g.lanes.delete(this); + return r; + }; + }); +} + +const uniq = (xs) => [...new Set(xs)].sort((a, b) => (a > b ? 1 : a < b ? -1 : 0)); + +/** + * Everything that has to agree about the open song's key, in one read: for + * each lane the song has, the key the engine routes it at, the number on its + * stepper and the key in the saved record; the counter; and what the graph is + * wired to. Reduced to the distinct values over the pitched lanes, so "every + * pitched lane at -2" reads as [-2]. + */ +async function summary(page) { + const raw = await ev(page, async () => { + const { audioEngine, currentJobId, playbackSpeed, mixerState, trackIndex } = await import("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/js/state.js"); + const { getMixMode } = await import("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/js/mixer.js"); + const g = window.__graph; + const rec = JSON.parse(localStorage.getItem(`stemdeck:mix:${currentJobId}:${getMixMode()}`) || "null"); + const lanes = Object.keys(trackIndex || {}).map((n) => ({ + name: n, + pitchable: audioEngine?.isStemPitchable?.(n) === true, + engine: audioEngine?.getStemPitch?.(n), + dom: document.querySelector(`.lane-key[data-stem="${n}"] .lane-key-num`)?.textContent ?? null, + saved: rec?.[n]?.pitch ?? null, + })); + return { + counter: document.querySelector("#t-pitch-value")?.textContent, + lanes, + inputs: [...new Set(g.lanes.values())], + wiredPitchInputs: [...g.wired].filter((k) => k !== 6), + saved: rec?.__global?.pitch ?? null, + speed: playbackSpeed, + bassMuted: mixerState.bass?.muted, + }; + }); + const pitched = raw.lanes.filter((l) => l.pitchable); + return { + counter: raw.counter, + engine: uniq(pitched.map((l) => l.engine)), + steppers: uniq(pitched.filter((l) => l.dom !== null).map((l) => l.dom)), + savedLanes: uniq(pitched.map((l) => l.saved)), + byLane: Object.fromEntries(raw.lanes.map((l) => [l.name, l.engine])), + inputs: uniq(raw.inputs), + wiredPitchInputs: uniq(raw.wiredPitchInputs), + saved: raw.saved, + speed: raw.speed, + bassMuted: raw.bassMuted, + }; +} + +/** The song is open, its engine is up and the key control is live. */ +async function settled(page, id) { + await expect.poll(() => ev(page, async () => (await import("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/js/state.js")).currentJobId)).toBe(id); + await page.waitForFunction( + () => !/\/\s*00:00\s*$/.test(document.querySelector("#t-time")?.textContent || "00:00 / 00:00"), + null, { timeout: 20000 }, + ); + await expect.poll(() => ev(page, async () => (await import("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/js/transport.js")).getGlobalPitch().available)).toBe(true); + // Lanes are routed as stems land; wait for the graph to stop moving. + let prev = ""; + await expect.poll(async () => { + const s = JSON.stringify(await ev(page, () => [[...window.__graph.lanes.values()], [...window.__graph.wired]])); + const same = s === prev; + prev = s; + return same; + }, { timeout: 20000, intervals: [250] }).toBe(true); +} + +const openSong = async (page, id) => { + await page.locator(`.cat-item[data-id="${id}"]`).first().click(); + await settled(page, id); +}; + +/** + * The open song is in key `n` everywhere: the counter, every pitched lane's + * stepper, the routing (pitched lanes on input 6 + n, drums on the unpitched + * input, and input 6 + n the only pitch input wired), and the saved record. + */ +async function expectGlobalKey(page, n) { + const label = n > 0 ? `+${n}` : String(n); + await expect.poll(() => summary(page)).toMatchObject({ + counter: label, + engine: [n], + steppers: [label], + inputs: uniq([ZERO_INPUT + n, ZERO_INPUT]), + wiredPitchInputs: n === 0 ? [] : [ZERO_INPUT + n], + // A song never moved has no record yet; one that was has both. + ...(n === 0 ? {} : { saved: n, savedLanes: [n] }), + }); +} + +// The modes whose screen has a key control. Performance (streamer) has none: +// its view covers the transport and offers no key of its own. Karaoke covers +// the transport too, and has its own Key panel. +const MODES = { + practice: { base: "practice" }, + daw: { base: "daw" }, + karaoke: { base: "daw", view: "karaoke" }, +}; + +async function startIn(page, mode) { + await page.addInitScript((m) => { + if (!sessionStorage.getItem("mode-set")) { + sessionStorage.setItem("mode-set", "1"); + localStorage.setItem("stemdeck.mode", m); + } + }, MODES[mode].base); + await recordGraph(page); + await openStudio(page); + await settled(page, JOB_ID); + await enterView(page, mode); +} + +async function enterView(page, mode) { + const view = MODES[mode].view; + if (!view) return; + await chooseMode(page, view); + await expect(page.locator(`#${view}`)).toBeVisible(); + await expect(page.locator(`#modeMenu .mode-item[data-mode="${view}"]`)).toHaveAttribute("aria-checked", "true"); + await settled(page, JOB_ID); +} + +/** Step the global key with whichever control the mode shows. */ +async function stepKey(page, mode, delta) { + const karaoke = MODES[mode].view === "karaoke"; + const btn = delta < 0 ? (karaoke ? kkDown(page) : down(page)) : (karaoke ? kkUp(page) : up(page)); + for (let i = 0; i < Math.abs(delta); i++) await btn.click(); +} + +test.describe("a song's key after switching songs (#781)", () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1800, height: 1020 }); + }); + + for (const mode of Object.keys(MODES)) { + test(`${mode}: the global key agrees on screen, in the audio and in the save, after switching back and after a reload`, async ({ page }) => { + await startIn(page, mode); + await stepKey(page, mode, -2); + await expectGlobalKey(page, -2); + if (mode === "karaoke") await expect(kkBadge(page)).toHaveText(KK_MINUS_TWO); + + // The other song, left in a different key. + await openSong(page, SIBLING_JOB_ID); + await expectGlobalKey(page, 0); + await stepKey(page, mode, 1); + await expectGlobalKey(page, 1); + + // Back to the first: the reporter's steps. + await openSong(page, JOB_ID); + await expectGlobalKey(page, -2); + if (mode === "karaoke") await expect(kkBadge(page)).toHaveText(KK_MINUS_TWO); + + // And after a full reload. + await page.reload({ waitUntil: "domcontentloaded" }); + await openSong(page, JOB_ID); + await enterView(page, mode); + await expectGlobalKey(page, -2); + }); + } + + for (const mode of ["practice", "daw"]) { + test(`${mode}: one lane's own key comes back with the song`, async ({ page }) => { + await startIn(page, mode); + await laneDown(page, "vocals").click(); + await laneDown(page, "vocals").click(); + const vocalsOnly = { + counter: "0", + byLane: { vocals: -2, bass: 0, other: 0, drums: 0 }, + inputs: [ZERO_INPUT - 2, ZERO_INPUT], + wiredPitchInputs: [ZERO_INPUT - 2], + }; + await expect.poll(() => summary(page)).toMatchObject(vocalsOnly); + + await openSong(page, SIBLING_JOB_ID); + await expectGlobalKey(page, 0); + + await openSong(page, JOB_ID); + await expect(page.locator('.lane-key[data-stem="vocals"] .lane-key-num')).toHaveText("-2"); + await expect.poll(() => summary(page)).toMatchObject(vocalsOnly); + + await page.reload({ waitUntil: "domcontentloaded" }); + await openSong(page, JOB_ID); + await expect.poll(() => summary(page)).toMatchObject(vocalsOnly); + }); + } + + test("a song saved by 0.20 opens at the key its lanes play in", async ({ page }) => { + // 0.20 saved one record per song (no mode) holding each lane's absolute + // key, and never the counter. That is the reporter's saved state after + // the upgrade: opening it must show the key the lanes play in, not 0 over + // a track playing at -2. + await page.addInitScript((id) => { + if (sessionStorage.getItem("seeded-020")) return; + sessionStorage.setItem("seeded-020", "1"); + localStorage.setItem("stemdeck.mode", "practice"); + const lane = (pitch) => ({ volume: 1, muted: false, soloed: false, pitch }); + localStorage.setItem(`stemdeck:mix:${id}`, JSON.stringify({ + vocals: lane(-2), bass: lane(-2), other: lane(-2), guitar: lane(-2), piano: lane(-2), drums: lane(0), + })); + }, JOB_ID); + await recordGraph(page); + await openStudio(page); + await settled(page, JOB_ID); + await expect.poll(() => summary(page)).toMatchObject({ + counter: "-2", engine: [-2], steppers: ["-2"], + inputs: [ZERO_INPUT - 2, ZERO_INPUT], wiredPitchInputs: [ZERO_INPUT - 2], + }); + // Stepping from there moves to -1, every lane with it, and saves both. + await up(page).click(); + await expectGlobalKey(page, -1); + }); + + test("reopening a song at once does not lose the key it was just given", async ({ page }) => { + // The save is debounced. Opening the song again inside that window read + // the record from before the change, and the late write then put the key + // back in the store, behind a counter and an engine at 0. + await recordGraph(page); + await openStudio(page); + await settled(page, JOB_ID); + await down(page).click(); + await down(page).click(); + await page.locator(`.cat-item[data-id="${SIBLING_JOB_ID}"]`).first().click(); + await openSong(page, JOB_ID); + await expectGlobalKey(page, -2); + }); + + test("a saved record that arrives after the engine is up still sets the key", async ({ page }) => { + // Desktop reads the record over IPC. However that is timed against the + // engine coming up, the engine and the counter end on the record's key. + await stubTauri(page); + await page.addInitScript((id) => { + const lane = (pitch) => ({ volume: 1, muted: false, soloed: false, pitch, fx: [] }); + localStorage.setItem(`stemdeck:mix:${id}:daw`, JSON.stringify({ + vocals: lane(-2), bass: lane(-2), other: lane(-2), guitar: lane(-2), piano: lane(-2), drums: lane(0), + __global: { pitch: -2, speed: 1 }, + })); + const invoke = window.__TAURI__.core.invoke; + window.__TAURI__.core.invoke = (cmd, args) => { + if (cmd === "store_get" && String(args?.key).startsWith("stemdeck:mix:")) { + return new Promise((r) => setTimeout(r, 2500)).then(() => invoke(cmd, args)); + } + return invoke(cmd, args); + }; + }, JOB_ID); + await recordGraph(page); + await openStudio(page); + await settled(page, JOB_ID); + await expectGlobalKey(page, -2); + }); + + test("speed and mutes come back with the song too", async ({ page }) => { + await recordGraph(page); + await openStudio(page); + await settled(page, JOB_ID); + await page.locator("#t-speed-075").click(); + await page.locator('.lane-header[data-stem="bass"] .lane-icon-toggle').click(); + await expect.poll(() => summary(page)).toMatchObject({ speed: 0.75, bassMuted: true }); + + await openSong(page, SIBLING_JOB_ID); + await expect.poll(() => summary(page)).toMatchObject({ speed: 1, bassMuted: false }); + + await openSong(page, JOB_ID); + await expect.poll(() => summary(page)).toMatchObject({ speed: 0.75, bassMuted: true }); + await expect(page.locator("#t-speed-075")).toHaveClass(/active/); + }); + + test("switching back and forth keeps one engine and no pitch chain for a key nothing is in", async ({ page }) => { + await recordGraph(page); + await openStudio(page); + await settled(page, JOB_ID); + await down(page).click(); + await down(page).click(); + await expectGlobalKey(page, -2); + const before = await ev(page, () => window.__graph.contextsOpen); + for (let i = 0; i < 3; i++) { + await openSong(page, SIBLING_JOB_ID); + await openSong(page, JOB_ID); + } + await expectGlobalKey(page, -2); + expect(await ev(page, () => window.__graph.contextsOpen)).toBe(before); + }); +}); diff --git a/tests/e2e/transpose.spec.mjs b/tests/e2e/transpose.spec.mjs index 62e6f08f..2e55676e 100644 --- a/tests/e2e/transpose.spec.mjs +++ b/tests/e2e/transpose.spec.mjs @@ -176,16 +176,21 @@ test.describe("transpose", () => { await expect(page.locator("#t-speed-1")).not.toHaveClass(/active/); }); - test("reopening a track resets its displayed and applied key", async ({ page }) => { + test("reopening a track keeps its displayed and applied key", async ({ page }) => { + // The key is saved with the song (#781). This used to read 0 here only + // because the reopen beat the debounced save, while the lanes and the + // store went on to hold +2. await recordRouting(page); await openStudio(page); await up(page).click(); await up(page).click(); await expect(value(page)).toHaveText("+2"); + await clearRoutes(page); await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().click(); - await expect(value(page)).toHaveText("0"); - await expect(value(page)).not.toHaveClass(/active/); + await expect(value(page)).toHaveText("+2"); + await expect(value(page)).toHaveClass(/active/); + await expect.poll(() => recentInputs(page)).toContain(ZERO_INPUT + 2); }); test("disables transpose when AudioWorklet setup is unavailable", async ({ page }) => { From be1f4177c154e4e53a85235b41d483be8df8be3d Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:53:56 +0100 Subject: [PATCH 2/2] fix(transpose): open a song at the key its saved record holds The counter, the engine and the saved record could disagree when a song was opened again: - A record saved by 0.20 has each lane's absolute key but no global key, so the counter opened at 0 over lanes still playing transposed. The global key is now read back from the lanes when the record has none (pitchBus.savedGlobalPitch). - Opening a song inside the 300 ms save debounce read the record from before the change; the late write then stored it behind a screen and an engine at 0. The pending write is flushed before the read. - The engine's ready handler pushed whatever was in memory when the record had not been read yet. Gains, lane keys, EQs, speed and the global key now wait for the record, and the key controls stay off until it is in. Closes #781 --- static/js/mixer.js | 12 +++++-- static/js/pitchBus.js | 36 +++++++++++++++++++ static/js/player.js | 27 ++++++++------ tests/js/saved-global-pitch.test.mjs | 54 ++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 tests/js/saved-global-pitch.test.mjs diff --git a/static/js/mixer.js b/static/js/mixer.js index 13fcbef6..68e8db62 100644 --- a/static/js/mixer.js +++ b/static/js/mixer.js @@ -21,7 +21,7 @@ import { storeGet, storeSetDebounced, storeFlushPending } from "./utils.js"; import { getMode } from "./mode.js"; import { t } from "./i18n.js"; import { stemFileExt } from "./playbackStems.js"; -import { PITCH_MAX, PITCH_MIN, clampPitch, pitchBlockedKey } from "./pitchBus.js"; +import { PITCH_MAX, PITCH_MIN, clampPitch, pitchBlockedKey, savedGlobalPitch } from "./pitchBus.js"; function defaultMixerEntry() { // `pitch` is the key this lane is actually in, in semitones from the @@ -98,6 +98,11 @@ export async function loadMixIntoState(jobId, loadedStemNames = STEM_NAMES) { const mode = _activeMode; stored = null; try { + // A change made a moment ago may still be waiting in the debounce: + // reading past it would open the song as it was before that change, + // and the late write would then put the change back in the store + // behind a screen and an engine that never had it (#781). + await storeFlushPending(mixKey(jobId, mode)); stored = await storeGet(mixKey(jobId, mode), null); if (!stored && LEGACY_MODES.has(mode)) stored = await storeGet(legacyMixKey(jobId), null); } catch (e) { console.warn("[mixer] failed to load mix state:", e); } @@ -110,7 +115,10 @@ export async function loadMixIntoState(jobId, loadedStemNames = STEM_NAMES) { if (!Array.isArray(mixerState[name].fx)) mixerState[name].fx = []; } mixerState[MASTER_SLOT] = masterEntry(stored[MASTER_SLOT]); - Object.assign(mixGlobals, cleanGlobals(stored.__global)); + // The global key comes from the same record as the lanes it moved, so the + // counter can never disagree with what they play (a record without one is + // read from its lanes: savedGlobalPitch). + Object.assign(mixGlobals, cleanGlobals({ ...stored.__global, pitch: savedGlobalPitch(stored, loadedStemNames) })); // If all loaded stems are muted the session is unplayable -- unmute as recovery. const loadedStems = loadedStemNames.filter((n) => mixerState[n]); if (loadedStems.length > 0 && loadedStems.every((n) => mixerState[n].muted)) { diff --git a/static/js/pitchBus.js b/static/js/pitchBus.js index 0ea01c86..f8bfc0a9 100644 --- a/static/js/pitchBus.js +++ b/static/js/pitchBus.js @@ -59,3 +59,39 @@ export function inputForPitch(semitones) { export function pitchBlockedKey() { return window.isSecureContext === false ? "pitch.insecureOrigin" : "pitch.unavailable"; } + +/** + * The global key a song's saved record was left at. + * + * The record holds it under `__global.pitch`. A record from before the global + * key was saved (0.20 and earlier, `stemdeck:mix:`) has only the lanes, + * which are absolute: the global control moved every lane but drums by the + * same amount. Opening one showed 0 on the counter over lanes still playing + * two semitones down (#781), so the key is read back from the lanes: the one + * most of the song's pitched lanes are in, the one nearer the original on a + * tie, and 0 when no lane says anything. + * + * `laneNames` are the lanes the song actually has; a record also holds every + * lane the app knows, at whatever the control last pushed them to. + */ +export function savedGlobalPitch(record, laneNames) { + const stored = record?.__global?.pitch; + if (Number.isFinite(stored)) return clampPitch(stored); + const counts = new Map(); + for (const name of laneNames || []) { + if (name === "drums") continue; + const p = record?.[name]?.pitch; + if (!Number.isFinite(p)) continue; + const k = clampPitch(p); + counts.set(k, (counts.get(k) || 0) + 1); + } + let best = 0; + let bestCount = 0; + for (const [k, n] of counts) { + if (n > bestCount || (n === bestCount && Math.abs(k) < Math.abs(best))) { + best = k; + bestCount = n; + } + } + return best; +} diff --git a/static/js/player.js b/static/js/player.js index 08cc7ce0..47ab927f 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -1843,16 +1843,23 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti // "playback failed" notification if one was sitting there (#401). resolvePlaybackSuccess(jobId); eng.setLoop(loopEnabled, loopStart, loopEnd); - updatePitchAvailability(eng.supportsPitchShift?.() === true); - applyMix(); // push per-stem gains (incl. >1.0 boost) into the engine - // Lane keys ride on the same engine, so they are restored from the - // per-track store in the same breath as the gains. - setLaneKeysAvailable(eng.supportsPitchShift?.() === true); - applyAllLanePitches(); - // Saved EQs ride on the same engine and are restored with the keys. - applyAllLaneEq(); - // And the speed and global key the song was left at, in this mode. - restoreGlobals(); + // The song's saved record is the one source of what the engine and + // the screen show: gains, lane keys, EQs, and the speed and global + // key it was left at. The engine can come up before the record has + // been read (desktop reads it over IPC), and pushing what was in + // memory then left the engine and the counter on the defaults, or + // on the previous song, under the record's lanes (#781). So all of + // it waits for the record, and the key controls stay off until it + // is in, so a step cannot land on a key about to be replaced. + mixReady.then(() => { + if (token !== visualRenderToken || multitrack !== mt || audioEngine !== eng) return; + updatePitchAvailability(eng.supportsPitchShift?.() === true); + applyMix(); // push per-stem gains (incl. >1.0 boost) into the engine + setLaneKeysAvailable(eng.supportsPitchShift?.() === true); + applyAllLanePitches(); + applyAllLaneEq(); + restoreGlobals(); + }); // Click track. Bound to this engine instance, so it is rebuilt on // every engine bring-up (including the chunked -> fulldecode diff --git a/tests/js/saved-global-pitch.test.mjs b/tests/js/saved-global-pitch.test.mjs new file mode 100644 index 00000000..3c4a3715 --- /dev/null +++ b/tests/js/saved-global-pitch.test.mjs @@ -0,0 +1,54 @@ +// The global key a song opens at, read from its saved record (#781). +// +// A record saved since the key was remembered says so itself. One saved by +// 0.20 has only the lanes, which the global control moved together, so the +// key is read back from them rather than shown as 0 over a transposed track. +// +// Run: node tests/js/saved-global-pitch.test.mjs + +import { savedGlobalPitch } from '../../static/js/pitchBus.js'; + +let passed = 0; +let failed = 0; + +function check(name, actual, expected) { + if (Object.is(actual, expected)) { + passed++; + console.log(`PASS ${name}`); + } else { + failed++; + console.log(`FAIL ${name} -- expected ${expected}, got ${actual}`); + } +} + +const lane = (pitch) => ({ volume: 1, muted: false, soloed: false, pitch }); +const LANES = ['vocals', 'drums', 'bass', 'other']; + +check('a record that holds the key is read as it is', + savedGlobalPitch({ __global: { pitch: -2, speed: 1 }, vocals: lane(-2), bass: lane(-2) }, LANES), -2); +check('the saved key wins over the lanes, which may be moved on their own', + savedGlobalPitch({ __global: { pitch: 1 }, vocals: lane(4), bass: lane(1), other: lane(1) }, LANES), 1); +check('a saved key out of range is clamped', + savedGlobalPitch({ __global: { pitch: -11 } }, LANES), -6); +check('a saved key of 0 is 0, whatever the lanes say', + savedGlobalPitch({ __global: { pitch: 0 }, vocals: lane(3), bass: lane(3) }, LANES), 0); + +check('a 0.20 record: the key the lanes were moved to', + savedGlobalPitch({ vocals: lane(-2), drums: lane(0), bass: lane(-2), other: lane(-2) }, LANES), -2); +check('drums never count, they are never moved', + savedGlobalPitch({ vocals: lane(3), drums: lane(0), bass: lane(3), other: lane(3) }, LANES), 3); +check('one lane moved on its own does not outvote the rest', + savedGlobalPitch({ vocals: lane(1), bass: lane(-2), other: lane(-2) }, LANES), -2); +check('a tie goes to the key nearer the original', + savedGlobalPitch({ vocals: lane(3), bass: lane(-1) }, ['vocals', 'bass']), -1); +check('lanes the song does not have are not counted', + savedGlobalPitch({ vocals: lane(-2), guitar: lane(5), piano: lane(5) }, LANES), -2); +check('no record is the original key', savedGlobalPitch(null, LANES), 0); +check('an empty record is the original key', savedGlobalPitch({}, LANES), 0); +check('a record whose lanes carry no key is the original key', + savedGlobalPitch({ vocals: { volume: 1, muted: true } }, LANES), 0); +check('no lane names is the original key', + savedGlobalPitch({ vocals: lane(-2) }, undefined), 0); + +console.log(`\n${passed}/${passed + failed} checks passed`); +process.exit(failed === 0 ? 0 : 1);