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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions docs/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/audio.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/host.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
/**
Expand Down
32 changes: 12 additions & 20 deletions src/mixer-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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:<id>' 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
Expand Down
6 changes: 3 additions & 3 deletions src/track-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
47 changes: 28 additions & 19 deletions tests/mixer_panel.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand All @@ -173,16 +180,18 @@ 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);
// Mute always wins: a muted master stays silent even while soloed.
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 ──────────────────────
Expand All @@ -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);
});
Expand Down
4 changes: 2 additions & 2 deletions tests/stem_engine.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading