diff --git a/CHANGELOG.md b/CHANGELOG.md index 70462d6..bfc56ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 against — charrette D5), and mute still always wins. "Solo my source track" now truly isolates the paired stem; solo the master strip alongside it to compare the two. +- **Soloing a transcription track now isolates it too — solo is one rule + across both bands.** The audio-band scoping above kept the Master Mix immune + to transcription-part solos, so soloing a part left the full-mix recording + (and with it, the song) playing over the isolated part. Solo now means + *isolate* everywhere: any solo silences every unsoloed strip, the master + included — matching what the stems already did. To keep the recording as a + reference while soloing a part, solo the Master Mix alongside it; mute still + always wins. - **Replace Audio no longer crosses song boundaries.** If an upload or download finishes after you switch songs, close the dialog, or start another Replace Audio request, the stale result is discarded instead of being applied to the diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 6234730..2429a6c 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -104,9 +104,10 @@ A few sources arrive through their own doors, any time after creation: **drum pad strip** for drum tracks. - **Mixer** (`Shift+C`) — per-track volume / mute / solo. Audio and transcription tracks are live together by default; mute or solo the channels you want to hear. - Soloing an **audio** track isolates it — the Master Mix mutes with the rest of - the audio band (solo the master too to hear both). Soloing a **transcription** - track keeps the recording audible as your reference. + Solo means *isolate*: soloing any track — audio or transcription — silences + everything unsoloed, the Master Mix included. Solo several tracks to hear them + together (for example a transcription track plus the Master Mix to keep the + recording as your reference). Mute always wins over solo. Press **`?`** at any time for the searchable shortcut panel, or **`Ctrl+K`** for the command palette. diff --git a/src/audio.js b/src/audio.js index 57c2520..28963b0 100644 --- a/src/audio.js +++ b/src/audio.js @@ -2330,8 +2330,8 @@ function _guideTick() { if (to <= from) return; // Per-part mute/solo (mixer panel, B6): the active surface's part gates // its own guide here (only the guide — the reference audio rides its own - // per-source strips: part solos never gate it (D5), and audio-band solos - // reach it through applyStemMix, never this path). Gated at the + // per-source strips: any solo reaches it through applyStemMix and the + // whole-map strip rule, never this path). Gated at the // scheduler, not in _guideSourceTimes, so it never touches song duration. // The pitched GM voice IS this part's guide voice, so it sits inside the // same gate as the clap fallback. diff --git a/src/host.js b/src/host.js index 4c44d7a..d3297f0 100644 --- a/src/host.js +++ b/src/host.js @@ -203,8 +203,8 @@ export const host = { * schedules voices. Lives in src/mixer-panel.js (the canonical * `S.partMix` owner); audio.js must not import that module, so it * crosses here. Inert default: audible at unity — exactly the - * pre-panel behavior. Part solo NEVER gates the reference recording - * (D5), which rides its own transparent gain path. + * pre-panel behavior. The reference recording never passes through this + * hook — its gate is its own 'audio:master' strip (partStripState). */ partClapState: () => ({ audible: true, vol: 1 }), /** diff --git a/src/mixer-panel.js b/src/mixer-panel.js index 9c0cb0e..f55d752 100644 --- a/src/mixer-panel.js +++ b/src/mixer-panel.js @@ -14,9 +14,11 @@ // per-part instrument voices (GM slice) read the SAME state when they arrive — // this panel is the source of truth, not a mirror. // -// The solo rule is the DAW one: any solo anywhere → only soloed parts sound; -// mute always wins. The recording / guide / click BUSES are not parts and are -// never gated by part solo — solo keeps the reference audible (charrette D5). +// The solo rule is the DAW one: any solo anywhere → only soloed strips sound; +// mute always wins. It spans BOTH bands — the master-mix strip is a peer audio +// track, so a transcription-part solo silences it like anything else (solo the +// master too to hear both). Only the recording / guide / click BUSES sit +// outside the rule: buses are not parts and are never gated by solo. // Audio consumes the state through `host.partClapState` (inert default: // audible at unity), so src/audio.js never imports this module. // @@ -122,27 +124,17 @@ export function _mixerAnySoloPure(partMix) { if (!partMix || typeof partMix !== 'object') return false; return Object.keys(partMix).some(k => partMix[k] && partMix[k].solo); } -// Any solo within the AUDIO band ('audio:' strips, the master included)? -// The master's solo immunity is scoped to this: it ignores transcription-part -// solos (D5 — the reference stays audible while charting) but joins the rule -// when the solo lives in its own band, so soloing a stem actually isolates it. -export function _mixerAnyAudioSoloPure(partMix) { - if (!partMix || typeof partMix !== 'object') return false; - return Object.keys(partMix).some(k => k.startsWith('audio:') && partMix[k] && partMix[k].solo); -} // The DAW audibility rule over PART keys only: mute always wins; any solo -// anywhere means only soloed parts sound. Buses (recording/guide/click) are -// not parts and never pass through here — solo keeps the reference audible. +// anywhere means only soloed strips sound. One rule for BOTH bands — the +// master mix strip is a peer AUDIO TRACK (the full-mix recording; the actual +// output fader is the mixer's master BUS, not this strip), so a transcription- +// part solo silences it exactly like a stem solo does. Soloing a track means +// "isolate this" — hear the reference alongside a soloed part by soloing the +// master too. Buses (recording/guide/click) are not parts and never pass +// through here. export function _mixerPartAudiblePure(partMix, key) { const st = _mixerPartStatePure(partMix, key); if (st.mute) return false; - // The master mix strip is a peer AUDIO TRACK (the full-mix recording) — - // the actual output fader is the mixer's master BUS, not this strip. But - // it is also the default reference every part is charted against, so a - // TRANSCRIPTION part's solo never silences it (D5); only a solo within - // the audio band does — soloing a stem mutes the full mix like any DAW - // console, and the master's own solo isolates the recording. - if (key === 'audio:master') return _mixerAnyAudioSoloPure(partMix) ? st.solo : true; return _mixerAnySoloPure(partMix) ? st.solo : true; } // The mix key of the ACTIVE editing surface: the drums arrangement's channel diff --git a/src/track-session.js b/src/track-session.js index 0d61c9e..d26e961 100644 --- a/src/track-session.js +++ b/src/track-session.js @@ -724,9 +724,9 @@ function render() { // master from the pane is a real workflow — dogfooding sessions kept // reaching for it — so the pane now mirrors the drawer. Same keys, same // handlers (mix-mute/mix-solo/mix-vol are key-generic), and the master - // keeps its reference semantics: a transcription part's solo never - // silences it (D5), while a solo within the audio band — a stem's or its - // own — gates it like any peer track (_mixerPartAudiblePure). + // follows the one whole-map solo rule like any peer track: any solo — + // a transcription part's, a stem's, or its own — gates it + // (_mixerPartAudiblePure). const mixControls = row => { if (!_trackRowShowsStripPure(row)) return ''; const key = _editorEscHtml(row.mixKey); diff --git a/tests/mixer_panel.test.mjs b/tests/mixer_panel.test.mjs index 7b5785a..f31f261 100644 --- a/tests/mixer_panel.test.mjs +++ b/tests/mixer_panel.test.mjs @@ -4,9 +4,10 @@ * canonical per-part mix state `S.partMix`. * * Pinned here: the pure strip-state model (defaults, clamps, the DAW - * mute-wins/solo-isolates audibility rule), the clap-state the guide - * scheduler consumes (drums vs current-arrangement key, volume scaling, - * and — D5 — that it is a PART gate: the host-hook default leaves audio + * mute-wins/solo-isolates audibility rule — ONE rule across both bands, + * the master strip included), the clap-state the guide scheduler consumes + * (drums vs current-arrangement key, volume scaling, and that it is a + * PART gate: the host-hook default leaves audio * untouched and no bus is ever a part), the panel open-state pref * round-trip, the memoized render, and that a double init can never stack * delegated listeners (re-inject safety). @@ -65,7 +66,7 @@ globalThis.localStorage = globalThis.localStorage || { globalThis.window = globalThis.window || globalThis; const { - _mixerPartsPure, _mixerPartStatePure, _mixerAnySoloPure, _mixerAnyAudioSoloPure, + _mixerPartsPure, _mixerPartStatePure, _mixerAnySoloPure, _mixerPartAudiblePure, _mixerClapStatePure, _mixerActivePartKeyPure, _mixerOpenFromStoredPure, _mixerClapState, _mixerGainForFaderPure, _mixerFaderLabelPure, _mixerOrderedPartsPure, @@ -154,17 +155,23 @@ t('audibility: any solo isolates the soloed strips', () => { assert.strictEqual(_mixerPartAudiblePure(mix, 'drums'), false); }); -t('master honors D5 for part solos but joins the solo rule inside the audio band', () => { - // A transcription-part solo (plus an unrelated mute) — the master stays - // audible: it is the reference the parts are charted against (D5). +t('one solo rule spans both bands: the master is a peer strip, never immune', () => { + // A TRANSCRIPTION-part solo silences the master like anything else — + // pre-fix the 'audio:master' carve-out kept the full mix playing over a + // soloed part, so soloing a transcription track never isolated it. const partSolo = { 'arr:0': { solo: true }, 'arr:1': { mute: true } }; - assert.strictEqual(_mixerAnyAudioSoloPure(partSolo), false, 'part solos are not audio-band solos'); - assert.strictEqual(_mixerPartAudiblePure(partSolo, 'audio:master'), true); - // A soloed STEM must actually isolate: the master (the full-mix recording, - // a peer audio track) mutes with the rest — pre-fix it kept playing over - // every solo, so soloing an audio track never isolated it. + assert.strictEqual(_mixerPartAudiblePure(partSolo, 'audio:master'), false, + 'a transcription-part solo silences the master'); + assert.strictEqual(_mixerPartAudiblePure(partSolo, 'audio:gtr'), false, + 'and the stems'); + assert.strictEqual(_mixerPartAudiblePure(partSolo, 'arr:0'), true, 'the soloed part sounds'); + // Hear the reference alongside a soloed part: solo the master too. + const partPlusMaster = { 'arr:0': { solo: true }, 'audio:master': { solo: true } }; + assert.strictEqual(_mixerPartAudiblePure(partPlusMaster, 'audio:master'), true); + assert.strictEqual(_mixerPartAudiblePure(partPlusMaster, 'arr:0'), true); + assert.strictEqual(_mixerPartAudiblePure(partPlusMaster, 'arr:1'), false); + // A soloed STEM isolates the same way (the #348 behavior, kept). const stemSolo = { 'audio:gtr': { solo: true }, 'arr:0': { mute: true } }; - assert.strictEqual(_mixerAnyAudioSoloPure(stemSolo), true); assert.strictEqual(_mixerPartAudiblePure(stemSolo, 'audio:master'), false, 'a stem solo silences the master'); assert.strictEqual(_mixerPartAudiblePure(stemSolo, 'audio:gtr'), true, 'the soloed stem sounds'); assert.strictEqual(_mixerPartAudiblePure(stemSolo, 'audio:bass'), false, 'an unsoloed stem is still isolated out'); @@ -173,7 +180,9 @@ t('master honors D5 for part solos but joins the solo rule inside the audio band const masterSolo = { 'audio:master': { solo: true } }; assert.strictEqual(_mixerPartAudiblePure(masterSolo, 'audio:master'), true); assert.strictEqual(_mixerPartAudiblePure(masterSolo, 'audio:gtr'), false); - // Master + stem both soloed → both sound (peers in one band). + assert.strictEqual(_mixerPartAudiblePure(masterSolo, 'arr:0'), false, + 'the master\'s solo silences unsoloed parts too — one rule, both directions'); + // Master + stem both soloed → both sound. const both = { 'audio:master': { solo: true }, 'audio:gtr': { solo: true } }; assert.strictEqual(_mixerPartAudiblePure(both, 'audio:master'), true); assert.strictEqual(_mixerPartAudiblePure(both, 'audio:gtr'), true); @@ -181,8 +190,8 @@ t('master honors D5 for part solos but joins the solo rule inside the audio band assert.strictEqual(_mixerPartAudiblePure({ 'audio:master': { mute: true, solo: true } }, 'audio:master'), false); assert.strictEqual(_mixerPartAudiblePure({ 'audio:master': { mute: true } }, 'audio:master'), false); // Malformed maps never throw and never phantom-solo. - assert.strictEqual(_mixerAnyAudioSoloPure(null), false); - assert.strictEqual(_mixerAnyAudioSoloPure({ 'audio:gtr': null }), false); + assert.strictEqual(_mixerAnySoloPure(null), false); + assert.strictEqual(_mixerPartAudiblePure({ 'audio:gtr': null }, 'audio:master'), true); }); // ── The clap state the guide scheduler consumes ────────────────────── @@ -202,14 +211,14 @@ t('clap state follows the active surface: the drums arrangement in drum mode, el 'drum mode with no drums arrangement → currentArr'); }); -t('solo keeps the reference audible (D5): the gate is per-PART, and the host default leaves audio untouched', () => { +t('the clap gate is per-PART, and the host default leaves audio untouched', () => { // Soloing a part silences other PARTS' claps… const mix = { 'arr:0': { solo: true } }; assert.strictEqual(_mixerClapStatePure(mix, false, 1).audible, false); // …but the hook audio.js consults defaults to audible-at-unity, so with // no panel wired (or nothing muted/soloed) nothing is gated — and the - // reference bus never passes through this gate at all: buses are not - // part keys, and audio.js only consults the hook on the CLAP path. + // recording/guide/click BUSES never pass through this gate at all: buses + // are not part keys, and audio.js only consults the hook on the CLAP path. assert.deepStrictEqual(host.partClapState(), { audible: true, vol: 1 }); assert.strictEqual(_mixerClapStatePure(mix, false, 0).audible, true); }); diff --git a/tests/stem_engine.test.mjs b/tests/stem_engine.test.mjs index ac6657d..af8aa39 100644 --- a/tests/stem_engine.test.mjs +++ b/tests/stem_engine.test.mjs @@ -81,8 +81,8 @@ t('a stem solo gates the master through the LIVE strip hook the engine ramps fro 'while a stem is soloed the master gain ramps to 0 (pre-fix: stayed at unity)'); assert.deepStrictEqual(_mixerPartStripState('audio:Guitar_L'), { audible: true, vol: 1 }); S.partMix = { 'arr:0': { solo: true } }; - assert.deepStrictEqual(_mixerPartStripState('audio:master'), { audible: true, vol: 1 }, - 'a transcription-part solo leaves the master reference audible (D5)'); + assert.deepStrictEqual(_mixerPartStripState('audio:master'), { audible: false, vol: 1 }, + 'a transcription-part solo gates the master too — one solo rule across bands'); S.partMix = { 'audio:master': { solo: true }, 'audio:Guitar_L': {} }; assert.deepStrictEqual(_mixerPartStripState('audio:master'), { audible: true, vol: 1 }, 'the master\'s own solo keeps it audible');