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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions static/js/mixer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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); }
Expand All @@ -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)) {
Expand Down
36 changes: 36 additions & 0 deletions static/js/pitchBus.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:<jobId>`) 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;
}
27 changes: 17 additions & 10 deletions static/js/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions tests/e2e/spec-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading