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: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
keybind-profile suite now treats intercepted keys as claimed, so this class
of display drift fails tests instead of shipping. Also: Cableton gains Live's
Ctrl+E Split for the existing split-at-playhead command.
- **Soloing an audio track actually isolates it now.** The Master Mix strip used
to be immune to every solo, so soloing a stem (or several) left the full-mix
recording playing over it — the solo never isolated anything, and the mute
buttons then seemed inconsistent because the master ignored a solo that
silenced its unmuted neighbours. The master now joins the DAW solo rule
*within the audio band*: soloing a stem mutes the master like any peer track,
and soloing the master isolates the recording. Soloing a **transcription
part** still keeps the recording audible (it is the reference you chart
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.

- **Corrected inaccurate and inconsistent UI labels.** The canvas status footer
said "Scroll: zoom" although the wheel pans (Ctrl+wheel zooms); it now reads
"Wheel/middle-drag: pan | Ctrl+wheel: zoom". In the in-app User Guide, Tempo Map
Expand Down
3 changes: 3 additions & 0 deletions docs/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ 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.

Press **`?`** at any time for the searchable shortcut panel, or **`Ctrl+K`** for
the command palette.
Expand Down
5 changes: 3 additions & 2 deletions src/audio.js
Original file line number Diff line number Diff line change
Expand Up @@ -2329,8 +2329,9 @@ function _guideTick() {
const from = Math.max(_guideScheduledUntil, nowChart - 0.005);
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 recording is a bus,
// not a part, and stays audible under any solo, D5). Gated at the
// 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
// 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
36 changes: 27 additions & 9 deletions src/mixer-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,18 +122,27 @@ 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.
export function _mixerPartAudiblePure(partMix, key) {
const st = _mixerPartStatePure(partMix, key);
if (st.mute) return false;
// The master mix is the OUTPUT bus — the final destination downstream of the
// sum, not a peer channel. Another track's mute/solo removes only that
// track's contribution and must never silence the output; only master's OWN
// mute (the output fader, handled above) does. So master is immune to the
// whole-map solo rule.
if (key === 'audio:master') return true;
// 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 Expand Up @@ -239,7 +248,9 @@ function _renderParts(container) {
+ `<span class="editor-mixer-strip-type">${p.kind === 'audio' ? 'AUDIO' : 'MIDI'}</span>`
+ `<div class="editor-mixer-ms-row">`
+ _msBtn(p.key, 'mute', st.mute, 'M', 'Mute track')
+ _msBtn(p.key, 'solo', st.solo, 'S', 'Solo track — the recording stays audible')
+ _msBtn(p.key, 'solo', st.solo, 'S', p.kind === 'audio'
? 'Solo track — isolates it among the audio tracks'
: 'Solo track — the recording stays audible')
+ `</div>`
+ `<div class="editor-mixer-channel">`
+ _meterMarkup(p.key)
Expand Down Expand Up @@ -410,9 +421,16 @@ function _wire(panel) {
_lastKey = '';
_mixerPanelRefresh();
const now = _mixerPartStatePure(S.partMix, key);
const isAudio = typeof key === 'string' && key.startsWith('audio:');
setStatus(act === 'mute'
? (now.mute ? 'Track muted — its guide voice is silent' : 'Track unmuted')
: (now.solo ? 'Track soloed — other tracks’ guide voices are silent; the recording stays audible' : 'Solo off'));
? (now.mute
? (isAudio ? 'Track muted' : 'Track muted — its guide voice is silent')
: 'Track unmuted')
: (now.solo
? (isAudio
? 'Track soloed — unsoloed audio tracks and unsoloed guide voices are silent'
: 'Track soloed — other tracks’ guide voices are silent; the recording stays audible')
: 'Solo off'));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
panel.addEventListener('input', (e) => {
const el = e.target;
Expand Down
5 changes: 3 additions & 2 deletions src/stem-tracks.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,8 @@ export function stemMixerAvailable() {
// 'audio:<id>'), so this is an EXCLUSIVE isolate over the audio band —
// enabling clears every OTHER stem's solo (Guitar after Bass must not stack
// into Guitar+Bass); toggling off clears the paired stem's solo too. The
// master recording stays audible (the reference is never gated by solo).
// isolate is real now: the master mix mutes with the rest of the audio band
// (solo the master strip alongside to hear both).
export function editorSoloMyStem() {
if (!stemMixerAvailable()) {
setStatus('Solo my source track needs the stem mixer — not available in this build yet.');
Expand All @@ -292,7 +293,7 @@ export function editorSoloMyStem() {
S.partMix[key] = { vol: Number.isFinite(cur.vol) ? cur.vol : 100, mute: false, solo: on };
host.stemMixChanged();
setStatus(on
? `Soloing ${sid} — the source track "${arr.name}" transcribes against; the recording stays audible.`
? `Soloing ${sid} — the source track "${arr.name}" transcribes against; the other audio tracks are muted.`
: `${sid} solo off.`);
return true;
}
Expand Down
5 changes: 3 additions & 2 deletions src/track-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -724,8 +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 output-bus semantics: its own mute silences it, other tracks'
// solo never does (_mixerPartAudiblePure's 'audio:master' carve-out).
// 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).
const mixControls = row => {
if (!_trackRowShowsStripPure(row)) return '';
const key = _editorEscHtml(row.mixKey);
Expand Down
39 changes: 30 additions & 9 deletions tests/mixer_panel.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ globalThis.localStorage = globalThis.localStorage || {
globalThis.window = globalThis.window || globalThis;

const {
_mixerPartsPure, _mixerPartStatePure, _mixerAnySoloPure, _mixerPartAudiblePure,
_mixerPartsPure, _mixerPartStatePure, _mixerAnySoloPure, _mixerAnyAudioSoloPure,
_mixerPartAudiblePure,
_mixerClapStatePure, _mixerActivePartKeyPure, _mixerOpenFromStoredPure, _mixerClapState,
_mixerGainForFaderPure, _mixerFaderLabelPure, _mixerOrderedPartsPure,
_mixerPanelRefresh, editorToggleMixerPanel, initMixerPanel,
Expand Down Expand Up @@ -153,15 +154,35 @@ t('audibility: any solo isolates the soloed strips', () => {
assert.strictEqual(_mixerPartAudiblePure(mix, 'drums'), false);
});

t('master is the OUTPUT bus: others solo/mute never silence it, its own mute does', () => {
// A stem soloed AND a different track muted — master must stay audible: it's
// the final destination downstream of the sum, not a peer channel.
const mix = { 'audio:gtr': { solo: true }, 'arr:0': { mute: true } };
assert.strictEqual(_mixerPartAudiblePure(mix, 'audio:master'), true);
// The non-soloed non-master track is still isolated out (rule unchanged).
assert.strictEqual(_mixerPartAudiblePure(mix, 'audio:bass'), false);
// Over-correction guard: master's OWN mute (the output fader) still mutes it.
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).
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.
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');
assert.strictEqual(_mixerPartAudiblePure(stemSolo, 'arr:1'), false, 'an audio solo still silences unsoloed parts');
// The master's OWN solo isolates the recording the same way.
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).
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);
});

// ── The clap state the guide scheduler consumes ──────────────────────
Expand Down
23 changes: 23 additions & 0 deletions tests/stem_engine.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,33 @@ t('the whole-map solo rule spans stems AND synth parts', () => {
assert.strictEqual(_mixerPartAudiblePure(mix, 'audio:Guitar_L'), true, 'the soloed stem sounds');
assert.strictEqual(_mixerPartAudiblePure(mix, 'arr:0'), false, 'an unsoloed synth part is silenced by a stem solo');
assert.strictEqual(_mixerPartAudiblePure(mix, 'audio:Bass_DI'), false, 'and so is an unsoloed stem');
assert.strictEqual(_mixerPartAudiblePure(mix, 'audio:master'), false,
'the master (full-mix recording) is a peer: a stem solo isolates against it too');
// Mute always wins, even over its own solo.
assert.strictEqual(_mixerPartAudiblePure({ 'audio:x': { solo: true, mute: true } }, 'audio:x'), false);
});

t('a stem solo gates the master through the LIVE strip hook the engine ramps from', () => {
// _mixerPartStripState is what main.js wires into host.partStripState —
// the exact value applyStemMix / _ensureStemGain seed and ramp the per-
// source gain nodes to. Drive it through the real S, both directions.
const savedMix = S.partMix;
try {
S.partMix = { 'audio:Guitar_L': { solo: true } };
assert.deepStrictEqual(_mixerPartStripState('audio:master'), { audible: false, vol: 1 },
'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)');
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');
assert.deepStrictEqual(_mixerPartStripState('audio:Guitar_L'), { audible: false, vol: 1 },
'and isolates the recording against the stems');
} finally { S.partMix = savedMix; }
});

t('each stem places its buffer from its OWN shift+offset — the alignment contract', () => {
// Two stems at the same cursor: one un-offset, one nudged +0.5s. Both
// compute against the SAME cursor with the SAME formula the master uses,
Expand Down
Loading