diff --git a/CHANGELOG.md b/CHANGELOG.md index 64af6600..ee997eb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Studio stems now play.** Multitrack stems sound *alongside* the master + recording, sample-aligned through seeks, loops, and audio-shift, each + with its own mute / solo / volume on its Tracks-column row (and in the + mixer panel). It's one mixer: a stem and a synth part obey the same solo + rule — soloing one silences the others, while the master recording always + stays audible. **Solo my source track** now works: it isolates the stem + the current part was charted against. Each stem's lane draws its own + waveform. (Known limits: while audition speed is slowed below 100%, stems + stay silent — they resume at full speed; and the transport ends with the + master recording, so a stem that runs longer than the master — or is pushed + past the master's end by a positive offset — is cut off there.) + - **The Tracks area — a DAW-style track column.** The persistent track tree is now a surface you can see and arrange: a resizable header column beside the timeline lists every track (the master mix, studio stems, and each diff --git a/src/audio.js b/src/audio.js index 7ae293c7..5ca8de6e 100644 --- a/src/audio.js +++ b/src/audio.js @@ -682,6 +682,11 @@ export function _startAudioSourceAtCursor(preRoll = 0) { _stopRefMedia(); S.audioSource = null; } + // Studio stems ride alongside the master on the sample-accurate path — + // same anchor, so they stay aligned. Not on the audition-slow path (the + // BufferSource is silenced there); a slow-then-fast toggle restarts them. + if (slow) _stopStemSources(); + else _startStemSources(preRoll); _anchorTransportAtCursor(preRoll); } @@ -761,6 +766,7 @@ export function _restartPlaybackAt(t) { try { S.audioSource.stop(); } catch (_) {} S.audioSource = null; } + _stopStemSources(); // re-scheduled by _startAudioSourceAtCursor below S.cursorTime = Math.max(0, Math.min(_audioTimelineDuration() || Infinity, t)); // Compose mode re-anchors the clock without a BufferSource — the guide/ // click scheduler is the only sound (charrette §1.7). @@ -831,6 +837,7 @@ export function stopPlayback() { try { S.audioSource.stop(); } catch (_) {} S.audioSource = null; } + _stopStemSources(); _stopRefMedia(); S.playing = false; updatePlayIcon(); @@ -1556,6 +1563,216 @@ function _partGainsReset() { _partGains = null; } +// ════════════════════════════════════════════════════════════════════ +// Stem playback engine. +// +// Studio stems (S.stems) play ALONGSIDE the master recording, sample- +// aligned: each is decoded once into stemAudioCache, then scheduled at the +// SAME transport anchor as the master so they can't drift. The master keeps +// its own path (S.audioSource → _refGain, the audition MediaElement, A/B) — +// stems are purely ADDITIVE, so a stem-engine fault can never take the +// recording down with it. +// +// Mix routing reuses the band-mode mixer: a stem's gain reads +// host.partStripState('audio:') — the SAME S.partMix store and whole-map +// solo rule the synth parts use — and connects to _refGain (the transparent +// path, never the guide limiter). Volume ceiling is unity, matching the +// current per-part contract; meters and +6 dB headroom are a later polish. +// +// Known limitation: at audition speed < 1 the master reroutes to a +// pitch-preserving MediaElement and the sample-accurate BufferSource path is +// silenced — so stems do not sound while slowed (they resume at 100%). +// ════════════════════════════════════════════════════════════════════ +const stemAudioCache = new Map(); // sourceId → { url, buffer, peaks } +const playingStemSources = new Map(); // sourceId → live AudioBufferSourceNode +const stemGainNodes = new Map(); // sourceId → GainNode +let stemDecodeGeneration = 0; + +const stemKey = (sourceId) => 'audio:' + sourceId; + +// The live stems to play: S.stems minus the track session's non-destructive +// removals. The master is NOT here — it keeps its own S.audioSource path. +function _liveStemSources() { + const removed = new Set((S.trackSession && S.trackSession.removedSourceIds) || []); + const seen = new Set(); + const out = []; + for (const raw of (Array.isArray(S.stems) ? S.stems : [])) { + const id = raw && typeof raw.id === 'string' ? raw.id : ''; + const url = raw && typeof raw.url === 'string' ? raw.url : ''; + if (!id || !url || removed.has(id) || seen.has(id)) continue; + seen.add(id); + out.push({ id, url, offset: Number(raw.offset) || 0 }); + } + return out; +} + +// Decode every live stem into the cache (parallel, generation-guarded, one +// failure never blocks the rest). Drops cache entries for stems that are +// gone. Safe to call repeatedly — already-cached URLs are skipped. +export async function syncStemAudio() { + const sources = _liveStemSources(); + const liveIds = new Set(sources.map(s => s.id)); + // Retire stems that left the roster BEFORE any await — a removed/renamed + // stem must stop sounding now, not seconds later when the new decodes land. + _pruneStaleStems(liveIds); + if (typeof fetch !== 'function') return; + _ensureAudioCtx(); + if (!S.audioCtx) return; + const generation = ++stemDecodeGeneration; + for (const id of [...stemAudioCache.keys()]) if (!liveIds.has(id)) stemAudioCache.delete(id); + await Promise.all(sources.map(async (source) => { + const cached = stemAudioCache.get(source.id); + if (cached && cached.url === source.url && cached.buffer) return; + try { + const resp = await fetch(source.url); + if (!resp.ok) return; + const raw = await resp.arrayBuffer(); + const buffer = await S.audioCtx.decodeAudioData(raw); + if (generation !== stemDecodeGeneration) return; // superseded + stemAudioCache.set(source.id, { url: source.url, buffer, peaks: null }); + } catch (_) { /* one unavailable stem must not block the session */ } + })); + if (generation === stemDecodeGeneration + && _stemCatchupAllowedPure(S.playing, _auditionActive())) { + const catchup = _stemCatchupPure( + S.playStartTime, S.playStartWall, S.audioCtx.currentTime, _auditionRate()); + _startStemSources(catchup.preRoll, catchup.cursorTime); + } +} + +export function _stemCatchupAllowedPure(playing, auditionActive) { + return !!playing && !auditionActive; +} + +export function _stemCatchupPure(playStartTime, playStartWall, currentTime, rate = 1) { + const remaining = Math.max(0, (Number(playStartWall) || 0) - (Number(currentTime) || 0)); + return { + preRoll: remaining, + cursorTime: remaining > 0 + ? Math.max(0, Number(playStartTime) || 0) + : _transportChartTimePure(playStartTime, playStartWall, currentTime, rate), + }; +} + +// Retire every stem no longer in `liveIds` (a set of live source ids): stop its +// playing node, drop its gain node, and — the one that bites — delete its +// 'audio:' entry from S.partMix. That entry is counted by the whole-map +// solo rule, so a stale SOLO left behind by a removed stem would silence every +// live track. Mirrors the drum-delete path (delete S.partMix.drums). +export function _pruneStaleStems(liveIds) { + for (const id of [...playingStemSources.keys()]) { + if (liveIds.has(id)) continue; + try { playingStemSources.get(id).stop(); } catch (_) { /* already stopped */ } + playingStemSources.delete(id); + } + for (const id of [...stemGainNodes.keys()]) { + if (liveIds.has(id)) continue; + try { stemGainNodes.get(id).disconnect(); } catch (_) { /* context gone */ } + stemGainNodes.delete(id); + } + let removedSolo = false; + if (S.partMix && typeof S.partMix === 'object') { + for (const key of Object.keys(S.partMix)) { + if (key.startsWith('audio:') && !liveIds.has(key.slice('audio:'.length))) { + removedSolo = removedSolo || !!(S.partMix[key] && S.partMix[key].solo); + delete S.partMix[key]; + } + } + } + // If the removed stem was the soloed one, deleting its key fixes the solo + // RULE — but every live gain node (surviving stems AND synth parts) still + // sits at its solo'd-away zero until re-ramped. A delayed fetch/catch-up + // won't touch the part gains, so reapply the whole mix now (the same pair + // partMixChanged uses — both bands read partStripState). Returns whether a + // solo was pruned so callers/tests can observe the re-apply decision. + if (removedSolo) { _partGainsApply(false); applyStemMix(false); } + return removedSolo; +} + +// New song boundary: orphan in-flight decodes and drop every buffer. +export function resetStemAudioCache() { + stemDecodeGeneration++; + stemAudioCache.clear(); + _stopStemSources(); + _stemGainsReset(); +} + +// A stem's cached min/max waveform peaks for its lane (lazy — built on first +// request from the decoded buffer). Feeds host.trackWaveform. +export function audioStemWaveform(sourceId) { + const cached = stemAudioCache.get(sourceId); + if (!cached || !cached.buffer) return null; + if (!cached.peaks) cached.peaks = _buildWaveformPeaks(cached.buffer, 512); + return { peaks: cached.peaks, duration: cached.buffer.duration }; +} + +function _ensureStemGain(sourceId) { + if (stemGainNodes.has(sourceId)) return stemGainNodes.get(sourceId); + if (!S.audioCtx) return null; + const gain = S.audioCtx.createGain(); + const st = host.partStripState(stemKey(sourceId)); + // Seed at the strip's current state BEFORE connecting, so a muted stem + // never leaks its first sample (mirrors _ensurePartGain). + gain.gain.value = st && st.audible !== false ? Math.max(0, Number(st.vol) || 0) : 0; + gain.connect(_ensureRefGain() || S.audioCtx.destination); + stemGainNodes.set(sourceId, gain); + return gain; +} + +// Ramp every stem gain to its strip state (mute/solo/fader). ~20 ms house +// ramp, or immediate when seating at (re)start. +export function applyStemMix(immediate = false) { + if (!S.audioCtx) return; + const now = S.audioCtx.currentTime; + for (const [sourceId, gain] of stemGainNodes) { + const st = host.partStripState(stemKey(sourceId)); + const target = st && st.audible !== false ? Math.max(0, Number(st.vol) || 0) : 0; + if (immediate) { gain.gain.cancelScheduledValues(now); gain.gain.setValueAtTime(target, now); } + else gain.gain.setTargetAtTime(target, now, 0.02); + } +} + +// Schedule every cached stem at the current cursor, sample-aligned with the +// master: each computes its own placement from S.audioShift + its own offset +// and starts at the SAME preRoll-shifted anchor. Called from the master's +// rate-1 start path (never the audition-slow path). +function _startStemSources(preRoll = 0, cursorTime = S.cursorTime) { + _stopStemSources(); + if (!S.audioCtx) return 0; + let started = 0; + for (const source of _liveStemSources()) { + const cached = stemAudioCache.get(source.id); + if (!cached || !cached.buffer) continue; // not decoded yet — syncStemAudio catches up + const placement = _audioBufferStartPure( + cursorTime, (Number(S.audioShift) || 0) + source.offset, cached.buffer.duration); + if (!placement.play) continue; + const node = S.audioCtx.createBufferSource(); + node.buffer = cached.buffer; + node.connect(_ensureStemGain(source.id) || _ensureRefGain() || S.audioCtx.destination); + const when = (preRoll > 0 || placement.delay > 0) + ? S.audioCtx.currentTime + preRoll + placement.delay : 0; + node.start(when, placement.offset); + playingStemSources.set(source.id, node); + started++; + } + applyStemMix(true); + return started; +} + +function _stopStemSources() { + for (const node of playingStemSources.values()) { + try { node.stop(); } catch (_) { /* already stopped */ } + } + playingStemSources.clear(); +} + +function _stemGainsReset() { + for (const gain of stemGainNodes.values()) { + try { gain.disconnect(); } catch (_) { /* context gone */ } + } + stemGainNodes.clear(); +} + // Per-part pitched events (chart truth: the same converter the roll uses, // per arrangement) — drum parts return [] here; their hits clap instead. function _bandPartPitchedEvents(idx) { @@ -2155,6 +2372,8 @@ export function teardownAudio() { cancelAudioLoad(); _cancelOnsetJob(); _partGainsReset(); + _stemGainsReset(); + _stopStemSources(); try { if (S.audioSource) { S.audioSource.stop(); S.audioSource = null; } } catch (_) { /* already stopped */ } _stopRefMedia(); try { if (rafId) { cancelAnimationFrame(rafId); rafId = null; } } catch (_) { /* no frame queued */ } diff --git a/src/create.js b/src/create.js index 76a34524..a410ed79 100644 --- a/src/create.js +++ b/src/create.js @@ -31,7 +31,7 @@ import { _seedExtendedStringsFromTuning } from './lanes.js'; import { S, markSessionDirty } from './state.js'; import { _marksSanitizePure } from './tempo-marks.js'; import { disposeBackendSession, stopSessionProcesses } from './session-lifecycle.js'; -import { _ensureOnsetsShifted, _guideAnalysisReset } from './audio.js'; +import { _ensureOnsetsShifted, _guideAnalysisReset, resetStemAudioCache, syncStemAudio } from './audio.js'; import { _firstDownbeatTimePure, _importBar1NudgePure, _liftAllBeats, _restoreBeatLocks, _syncAppliedMessagePure } from './tempo.js'; import { seedSurfacePreset, surfacePersistFor } from './toolbars.js'; import { trackSessionSavePayload } from './track-session.js'; @@ -2326,6 +2326,8 @@ export async function editorApplyCreateResult(data) { // source immediately so stems do not appear only after a save/reopen. host.installCreatedTrackSession(data.track_session, data.audio_sources || []); _guideAnalysisReset(); // fresh import — no stale guide onsets may survive + resetStemAudioCache(); // …nor stale stem buffers + void syncStemAudio().finally(() => host.draw()); // decode stems, then repaint their lanes const _importHasDrums = !!(S.drumTab && (S.drumTab.hits || []).length); const _importHasKeys = (S.arrangements || []).some( a => KEYS_PATTERN.test(a.name || '')); diff --git a/src/file-ops.js b/src/file-ops.js index 19f1d4c7..740c8bee 100644 --- a/src/file-ops.js +++ b/src/file-ops.js @@ -4,7 +4,7 @@ // triggers stay in main.js and are reached through host. import { _anchorsAreDirty, _stripToneInternals, _tonesAreDirty, _updateTonesButtonVisibility } from './annotation-lanes.js'; -import { _abDisarm, _guideAnalysisReset, _resetAuditionForNewSong, loadAudio } from './audio.js'; +import { _abDisarm, _guideAnalysisReset, _resetAuditionForNewSong, loadAudio, resetStemAudioCache, syncStemAudio } from './audio.js'; import { _handshapesAreDirty, _normalizeHandshape, flattenChords, reconstructChords } from './chords.js'; import { _normalizeTuningToLanes } from './commands.js'; import { EditHistory } from './history.js'; @@ -190,6 +190,11 @@ export async function loadCDLC(filename, options = {}) { // New song ⇒ the previous song's guide analysis (a decoded stem + // its onsets) is stale; also orphans any in-flight guide decode. _guideAnalysisReset(); + // Decode this song's stems for playback (drops the old song's first). + // Fire-and-forget: the decode lands before play, or catches up on the + // next transport (re)start if the user is very quick. + resetStemAudioCache(); + void syncStemAudio().finally(() => host.draw()); // repaint stem lanes once decoded // Exit drum-edit mode on song change so we don't carry a stale // selection into a sloppak whose hits[] is different. S.drumEditMode = false; diff --git a/src/main.js b/src/main.js index d72bde42..f53c856c 100644 --- a/src/main.js +++ b/src/main.js @@ -47,7 +47,7 @@ import { editorSetEditBlip, editorSetMixLevel, editorSetAudioShift, editorNudgeAudioShift, initAudio, loadAudio, startPlayback, stopPlayback, teardownAudio, editorSetCountIn, editorSetAuditionRate, editorToggleAuditionTrainer, editorPlayAllTracksEnabled, editorTogglePlayAllTracks, - _partGainsApply, + _partGainsApply, applyStemMix, audioStemWaveform, syncStemAudio, } from './audio.js'; import { _mixerClapState, _mixerPanelRefresh, _mixerPartStripState, editorToggleMixerPanel, initMixerPanel } from './mixer-panel.js'; import { @@ -524,9 +524,23 @@ setHostHooks({ mixUiState: () => ({ pcts: _mixLoadPct(), blip: editorEditBlipEnabled() }), // Band mode (multi-track MIDI playback): the strips are the mixer. partStripState: (key) => _mixerPartStripState(key), - partMixChanged: () => { _partGainsApply(false); refreshTrackSession(); }, + // A strip changed: ramp the synth part gains AND the stem gains (both + // read partStripState), and refresh the Tracks header. + partMixChanged: () => { _partGainsApply(false); applyStemMix(false); refreshTrackSession(); }, + // A source was removed/restored/imported/renamed: rebuild the decoded + // roster and repaint every surface that derives rows/strips from it. + audioSourcesChanged: () => { + void syncStemAudio().finally(() => draw()); // repaint once late waveforms decode + _mixerPanelRefresh(); + refreshTrackSession(); + draw(); + }, playAllTracksEnabled: () => editorPlayAllTracksEnabled(), stripUiChanged: () => _mixerPanelRefresh(), + // The stem-mixer capability signal: its PRESENCE flips stemMixerAvailable() + // true (lighting up Solo-my-source and the audio-row strips). Re-ramps the + // stem gains off S.partMix and repaints the surfaces. + stemMixChanged: () => { applyStemMix(false); _mixerPanelRefresh(); refreshTrackSession(); }, // Persistent track tree: create/import hands every create-time source to // the installer so the tree exists from the first moment, not only after // a save/reopen (the seam #286 reserved). @@ -563,9 +577,10 @@ setHostHooks({ // Vertical wheel over the Tracks area scrolls the shared lane stack. scrollTrackArea: (deltaY) => scrollTrackSessionBy(deltaY), // Lane waveforms: the master mix draws from the session's decoded peaks; - // stems light up when the engine slice caches theirs. - trackWaveform: (sourceId) => (sourceId === 'master' && S.waveformPeaks && S.duration > 0 - ? { peaks: S.waveformPeaks, duration: S.duration } : null), + // a stem draws from its own decoded buffer in the stem-audio cache. + trackWaveform: (sourceId) => (sourceId === 'master' + ? (S.waveformPeaks && S.duration > 0 ? { peaks: S.waveformPeaks, duration: S.duration } : null) + : audioStemWaveform(sourceId)), }); // Re-attach the song-import modal handlers (import.js owns the logic; the HTML diff --git a/src/mixer-panel.js b/src/mixer-panel.js index 731f16a7..1c1435c2 100644 --- a/src/mixer-panel.js +++ b/src/mixer-panel.js @@ -33,8 +33,19 @@ import { _editorEscHtml, setStatus } from './ui.js'; // One strip per part: every arrangement, plus the drum tab as its own strip // (drums are a song-level sidecar, not an arrangement) — the same list shape // as the Parts view, keyed the way S.currentArr addresses parts (by index). -export function _mixerPartsPure(arrangements, drumTab) { +export function _mixerPartsPure(arrangements, drumTab, stems, removedSourceIds) { const parts = []; + // Studio stems first (they're the audio band), then the transcription + // parts. Stem strips key by 'audio:' — the same S.partMix store and + // whole-map solo rule the synth parts use, so one mixer drives both. + const removed = new Set(Array.isArray(removedSourceIds) ? removedSourceIds : []); + const seen = new Set(); + for (const stem of (Array.isArray(stems) ? stems : [])) { + const id = stem && typeof stem.id === 'string' ? stem.id : ''; + if (!id || removed.has(id) || seen.has(id)) continue; + seen.add(id); + parts.push({ key: 'audio:' + id, name: stem.name || id, kind: 'audio' }); + } (arrangements || []).forEach((arr, i) => { parts.push({ key: 'arr:' + i, @@ -111,7 +122,7 @@ function _msBtn(key, act, pressed, label, title) { } function _renderParts(container) { - const parts = _mixerPartsPure(S.arrangements, S.drumTab); + const parts = _mixerPartsPure(S.arrangements, S.drumTab, S.stems, S.trackSession && S.trackSession.removedSourceIds); if (!parts.length) { container.innerHTML = '

No tracks yet — strips appear as tracks are added.

'; return; @@ -232,7 +243,7 @@ export function _mixerPanelRefresh() { if (!panel || panel.classList.contains('hidden')) { _lastKey = ''; return; } const container = document.getElementById('editor-mixer-parts'); if (!container) return; - const parts = _mixerPartsPure(S.arrangements, S.drumTab); + const parts = _mixerPartsPure(S.arrangements, S.drumTab, S.stems, S.trackSession && S.trackSession.removedSourceIds); const key = editGen + '|' + JSON.stringify(S.partMix) + '|' + (host.playAllTracksEnabled() ? '1' : '0') + '|' + parts.map(p => p.key + ':' + p.name).join(','); diff --git a/src/stem-tracks.js b/src/stem-tracks.js index 62764b4c..921c5b25 100644 --- a/src/stem-tracks.js +++ b/src/stem-tracks.js @@ -92,7 +92,9 @@ function _adopt(data) { // (persisted=true, the replace-audio rule) — already durable, no mark. if (!data.persisted) markSessionDirty(); _render(); - if (host.stemUiChanged) host.stemUiChanged(); + // A stem was imported / renamed / reordered / removed — re-decode so the + // engine's buffer cache matches the new stem set (URLs may be new). + host.audioSourcesChanged(); } function _render() { @@ -254,10 +256,12 @@ export function stemMixerAvailable() { return typeof host.stemMixChanged === 'function'; } -// The transcription move: solo the CURRENT track's paired source stem via -// the stem mixer's S.stemMix rule. EXCLUSIVE isolate: enabling clears every -// other stem's solo (Guitar after Bass must not stack into Guitar+Bass); -// toggling off restores the no-solo state — all solos cleared. +// The transcription move: solo the CURRENT track's paired source stem. The +// stem plays through the SAME S.partMix mixer as everything else (keyed +// 'audio:'), 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). export function editorSoloMyStem() { if (!stemMixerAvailable()) { setStatus('Solo my source track needs the stem mixer — not available in this build yet.'); @@ -270,16 +274,19 @@ export function editorSoloMyStem() { setStatus(`"${arr.name}" has no paired source track — pair one in File › Audio tracks…`); return true; } - if (!S.stemMix || typeof S.stemMix !== 'object') S.stemMix = {}; - const cur = S.stemMix[sid] || {}; + if (!S.partMix || typeof S.partMix !== 'object') S.partMix = {}; + const key = 'audio:' + sid; + const cur = S.partMix[key] || {}; const on = !cur.solo; - for (const [k, v] of Object.entries(S.stemMix)) { - if (k !== sid && v && v.solo) S.stemMix[k] = { ...v, solo: false }; + // Clear every other stem's solo so the isolate is exclusive (leave the + // synth parts' own solos alone — this verb owns the audio band only). + for (const [k, v] of Object.entries(S.partMix)) { + if (k !== key && k.startsWith('audio:') && v && v.solo) S.partMix[k] = { ...v, solo: false }; } - S.stemMix[sid] = { vol: Number.isFinite(cur.vol) ? cur.vol : 100, mute: false, solo: on }; + 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.` + ? `Soloing ${sid} — the source track "${arr.name}" transcribes against; the recording stays audible.` : `${sid} solo off.`); return true; } diff --git a/src/track-session.js b/src/track-session.js index d7ce9b2b..ea6d4478 100644 --- a/src/track-session.js +++ b/src/track-session.js @@ -601,10 +601,14 @@ function render() { .concat(stems.map(source => ``)).join(''); const guide = sources.find(source => source.id === model.tempoGuideSourceId) || sources[0] || { name: 'No guide' }; // Per-part M/S/fader — the SAME canonical partMix the mixer panel owns - // (band-mode gains ramp off it live). Audio rows carry no strips yet: - // stem playback is the engine slice; strips arrive with it. + // (band-mode gains ramp off it live). Transcription parts AND stem audio + // rows get strips — both route through the same S.partMix mixer. The + // MASTER mix has no strip: it's the reference (always audible, never + // gated by solo), so a mute/solo there would be a lie. const mixControls = row => { - if (!row.mixKey || row.type !== 'transcription') return ''; + const stripped = row.type === 'transcription' + || (row.type === 'audio' && row.sourceKind !== 'master'); + if (!row.mixKey || !stripped) return ''; const key = _editorEscHtml(row.mixKey); const st = _mixerPartStatePure(S.partMix, row.mixKey); return `` @@ -625,7 +629,7 @@ function render() { const style = `--track-indent:${indent}px;--track-row-height:${height}px`; const selected = row.id === S.selectedTrackId ? ' editor-track-selected' : ''; if (row.type === 'folder') return `
${trackName(row, `${name}`)}${resizeGrip(row)}
`; - if (row.type === 'audio') return `
${row.sourceKind === 'master' ? 'MIX' : 'AUD'}${trackName(row, `${name}`)}${resizeGrip(row)}
`; + if (row.type === 'audio') return `
${row.sourceKind === 'master' ? 'MIX' : 'AUD'}${trackName(row, `${name}`)}${mixControls(row)}${resizeGrip(row)}
`; return `
${row.targetId === DRUM_TARGET_ID ? 'DRM' : 'MIDI'}${trackName(row, ``)}${mixControls(row)}${resizeGrip(row)}
`; }).join('')}`; const list = el.querySelector('.editor-track-session-list'); diff --git a/tests/audition_clock.test.mjs b/tests/audition_clock.test.mjs index 323f934f..346e1061 100644 --- a/tests/audition_clock.test.mjs +++ b/tests/audition_clock.test.mjs @@ -208,13 +208,14 @@ t('a failed slow path DEMOTES to 100% and plays the buffer — never silence und }; const run = new Function('S', '_audioBufferStartPure', '_auditionActive', '_startRefMediaAt', '_mixApplyFirstPlayFade', '_stopRefMedia', '_auditionRefreshUi', 'setStatus', - '_ensureRefGain', '_anchorTransportAtCursor', + '_ensureRefGain', '_anchorTransportAtCursor', '_stopStemSources', '_startStemSources', extractFn('_startAudioSourceAtCursor') + '\nreturn _startAudioSourceAtCursor;' )(S, () => ({ play: true, offset: 5, delay: 0 }), () => Number(S.auditionRate) < 1, () => false, // the slow path is unavailable - () => {}, () => {}, () => {}, (m) => status.push(m), () => null, () => {}); + () => {}, () => {}, () => {}, (m) => status.push(m), () => null, () => {}, + () => {}, () => 0); // stem scheduler stubs (no stems here) run(0); assert.strictEqual(S.auditionRate, 1, 'demoted to full speed so the clock matches the audio'); diff --git a/tests/stem_engine.test.mjs b/tests/stem_engine.test.mjs new file mode 100644 index 00000000..30b6238f --- /dev/null +++ b/tests/stem_engine.test.mjs @@ -0,0 +1,123 @@ +/* + * Stem playback engine (src/audio.js): the mixer routing that makes stems + * audible with mute/solo/fader, and the sample-alignment placement math. + * + * The Web Audio graph itself isn't unit-testable in node; these pin the + * PURE decisions the engine is built on — the audio-band mixer parts, the + * whole-map solo rule over 'audio:' keys, and the per-source buffer + * placement (shared with the master, so stems stay aligned). + * + * Run: node tests/stem_engine.test.mjs + */ +import assert from 'node:assert'; + +const { _mixerPartsPure, _mixerPartStripState, _mixerPartAudiblePure } = await import('../src/mixer-panel.js'); +const { _audioBufferStartPure, _stemCatchupPure, _stemCatchupAllowedPure, _pruneStaleStems } = await import('../src/audio.js'); +const { S } = await import('../src/state.js'); + +let pass = 0, fail = 0; +const tests = []; +const t = (name, fn) => tests.push([name, fn]); + +t('the mixer lists stem strips (audio band first), honoring removals', () => { + const parts = _mixerPartsPure( + [{ name: 'Lead' }], { hits: [{}] }, + [{ id: 'Guitar_L', name: 'Gtr L' }, { id: 'Bass_DI', name: 'Bass' }, { id: 'gone' }], + ['gone']); + assert.deepStrictEqual(parts.map(p => p.key), + ['audio:Guitar_L', 'audio:Bass_DI', 'arr:0', 'drums'], + 'stems first (removed dropped), then parts, then drums'); + assert.strictEqual(parts[0].name, 'Gtr L'); + assert.strictEqual(parts[0].kind, 'audio'); +}); + +t('a stem strip reads state from S.partMix under its audio: key', () => { + S.partMix = { 'audio:Guitar_L': { vol: 80, mute: false, solo: false } }; + const st = _mixerPartStripState('audio:Guitar_L'); + assert.deepStrictEqual(st, { audible: true, vol: 0.8 }, 'vol 0..1 for the gain node'); +}); + +t('the whole-map solo rule spans stems AND synth parts', () => { + // A soloed stem silences an unsoloed synth part, and vice-versa — one + // audio band, one rule. + const mix = { 'audio:Guitar_L': { solo: true }, 'arr:0': {}, 'audio:Bass_DI': {} }; + 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'); + // Mute always wins, even over its own solo. + assert.strictEqual(_mixerPartAudiblePure({ 'audio:x': { solo: true, mute: true } }, 'audio:x'), false); +}); + +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, + // so they start sample-aligned relative to their own placement. + const cursor = 10, shift = 0.2, dur = 60; + const a = _audioBufferStartPure(cursor, shift + 0, dur); // stem A, offset 0 + const b = _audioBufferStartPure(cursor, shift + 0.5, dur); // stem B, offset +0.5 + assert.deepStrictEqual(a, { play: true, offset: 9.8, delay: 0 }, 'cursor - (shift+offset)'); + assert.deepStrictEqual(b, { play: true, offset: 9.3, delay: 0 }, 'its own offset shifts the read point'); + // A stem whose (shifted) audio has already ended does not play — the + // scheduler skips it, the transport still runs. + assert.strictEqual(_audioBufferStartPure(70, 0, 60).play, false); + // Negative net placement delays the start instead of clipping. + assert.deepStrictEqual(_audioBufferStartPure(0, 0.3, 60), { play: true, offset: 0, delay: 0.3 }); +}); + +t('a stem decoded during playback catches up to the transport without skipping count-in', () => { + assert.deepStrictEqual(_stemCatchupPure(8, 12, 10.5, 1), + { preRoll: 1.5, cursorTime: 8 }, 'before the anchor it waits with the master at the start cursor'); + assert.deepStrictEqual(_stemCatchupPure(8, 12, 14.5, 1), + { preRoll: 0, cursorTime: 10.5 }, 'after the anchor it seeks to the live transport time'); + assert.deepStrictEqual(_stemCatchupPure(8, 12, 14, 0.5), + { preRoll: 0, cursorTime: 9 }, 'audition rate is part of the authoritative chart clock'); +}); + +t('late stems stay suppressed on the pitch-preserving slow path', () => { + assert.strictEqual(_stemCatchupAllowedPure(true, false), true); + assert.strictEqual(_stemCatchupAllowedPure(true, true), false, + 'a normal-speed BufferSource must not join slowed reference audio'); + assert.strictEqual(_stemCatchupAllowedPure(false, false), false); +}); + +t('a removed stem\'s phantom solo no longer silences the live band', () => { + // Bass was soloed, then removed from the roster. Its 'audio:Bass_DI' solo + // entry lingers in S.partMix, and the whole-map solo rule counts it — so + // every LIVE track (the surviving stem and the synth part) goes silent + // even though the soloed source is gone. Pre-prune, that's the bug. + S.partMix = { 'audio:Bass_DI': { solo: true }, 'audio:Guitar_L': {}, 'arr:0': {} }; + assert.strictEqual(_mixerPartAudiblePure(S.partMix, 'audio:Guitar_L'), false, + 'the stale solo silences the live stem (the bug)'); + assert.strictEqual(_mixerPartAudiblePure(S.partMix, 'arr:0'), false, + 'and the live synth part too'); + // Roster shrinks to just Guitar_L (Bass removed). Prune retires the ghost. + _pruneStaleStems(new Set(['Guitar_L'])); + assert.strictEqual(S.partMix['audio:Bass_DI'], undefined, 'the removed stem\'s mix entry is gone'); + assert.deepStrictEqual(S.partMix['audio:Guitar_L'], {}, 'the live stem\'s entry is untouched'); + assert.strictEqual(_mixerPartAudiblePure(S.partMix, 'audio:Guitar_L'), true, 'the live band sounds again'); + assert.strictEqual(_mixerPartAudiblePure(S.partMix, 'arr:0'), true); +}); + +t('pruning a soloed removed stem signals a live-gain re-apply', () => { + // Deleting the removed stem's 'audio:' key fixes the whole-map solo RULE, + // but the surviving gain nodes (live stem AND synth part) still sit at the + // solo'd-away zero the removed solo forced. A delayed fetch/catch-up never + // re-ramps the PART gains, so prune must signal the mix be re-applied — it + // returns true when it removes a soloed entry. (Pre-fix it returned + // undefined and the live band stayed silent.) + S.partMix = { 'audio:Bass_DI': { solo: true }, 'audio:Guitar_L': {}, 'arr:0': {} }; + assert.strictEqual(_pruneStaleStems(new Set(['Guitar_L'])), true, + 'removing the soloed stem signals a re-ramp'); + // Removing an unsoloed stem changes no live gains — no re-apply needed. + S.partMix = { 'audio:Bass_DI': {}, 'audio:Guitar_L': {} }; + assert.strictEqual(_pruneStaleStems(new Set(['Guitar_L'])), false, + 'removing an unsoloed stem needs no re-ramp'); +}); + +for (const [name, fn] of tests) { + try { await fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/tests/stem_tracks.test.mjs b/tests/stem_tracks.test.mjs index bac543ee..3ef24e2a 100644 --- a/tests/stem_tracks.test.mjs +++ b/tests/stem_tracks.test.mjs @@ -121,7 +121,7 @@ function seedSession(links = {}) { sessionId: 'sess1', sessionDirty: false, arrangements: [{ id: 'a1', name: 'Lead' }], currentArr: 0, stems: [{ id: 'Guitar_L' }, { id: 'Bass_DI' }], - stemLinks: { ...links }, stemMix: {}, + stemLinks: { ...links }, stemMix: {}, partMix: {}, }); statusEl.textContent = ''; fetchLog.length = 0; @@ -152,12 +152,15 @@ t('a chart track pairs with ONE stem; re-pairing replaces; empty unlinks', () => }); // ── Item 17: capability gate — the verb must not claim to change audio ── -t('soloMyStem is honest when no stem mixer consumes S.stemMix', () => { +// Solo-my-source now routes through the SAME S.partMix mixer the engine +// consumes (keyed 'audio:'), so the stem plays isolated through the real +// gain nodes. The capability gate (host.stemMixChanged present) is unchanged. +t('soloMyStem is honest when no stem mixer consumes the mix state', () => { delete host.stemMixChanged; // the branch's reality: no consumer wired seedSession({ a1: 'Guitar_L' }); assert.strictEqual(stemMixerAvailable(), false); editorSoloMyStem(); - assert.deepStrictEqual(S.stemMix, {}, 'no consumer → no state flip nothing reads'); + assert.deepStrictEqual(S.partMix, {}, 'no consumer → no state flip nothing reads'); assert.match(statusEl.textContent, /mixer/i, 'status names the missing capability'); assert.doesNotMatch(statusEl.textContent, /^Soloing/, 'must not claim the audio changed'); // …and springs to life the moment a real consumer is wired. @@ -165,7 +168,7 @@ t('soloMyStem is honest when no stem mixer consumes S.stemMix', () => { setHostHooks({ stemMixChanged: () => { mixCalls++; } }); assert.strictEqual(stemMixerAvailable(), true); editorSoloMyStem(); - assert.strictEqual(S.stemMix.Guitar_L.solo, true, 'wired hook → the solo lands'); + assert.strictEqual(S.partMix['audio:Guitar_L'].solo, true, 'wired hook → the solo lands'); assert.strictEqual(mixCalls, 1, 'the consumer hears the change'); }); @@ -173,15 +176,15 @@ t('soloMyStem solos the paired stem, leaves unsoloed stems alone, toggles off', setHostHooks({ stemMixChanged: () => {} }); // a real (fake) consumer seedSession({ a1: 'Guitar_L' }); editorSoloMyStem(); - assert.strictEqual(S.stemMix.Guitar_L.solo, true); - assert.strictEqual(S.stemMix.Guitar_L.mute, false, 'solo clears any mute'); - assert.strictEqual(S.stemMix.Bass_DI, undefined, 'an unsoloed stem needs no entry'); + assert.strictEqual(S.partMix['audio:Guitar_L'].solo, true); + assert.strictEqual(S.partMix['audio:Guitar_L'].mute, false, 'solo clears any mute'); + assert.strictEqual(S.partMix['audio:Bass_DI'], undefined, 'an unsoloed stem needs no entry'); editorSoloMyStem(); - assert.strictEqual(S.stemMix.Guitar_L.solo, false, 'second press releases'); + assert.strictEqual(S.partMix['audio:Guitar_L'].solo, false, 'second press releases'); // No pairing = a status nudge, never a throw or a wrong solo. S.stemLinks = {}; editorSoloMyStem(); - assert.strictEqual(S.stemMix.Bass_DI, undefined); + assert.strictEqual(S.partMix['audio:Bass_DI'], undefined); }); // ── Item 18: exclusive isolate — Guitar after Bass must not stack ────── @@ -189,18 +192,20 @@ t('soloMyStem clears other stem solos on enable; toggle-off = no solos at all', setHostHooks({ stemMixChanged: () => {} }); seedSession({ a1: 'Guitar_L' }); S.stems = [{ id: 'Guitar_L' }, { id: 'Bass_DI' }, { id: 'Kick' }]; - S.stemMix = { - Bass_DI: { vol: 100, mute: false, solo: true }, // e.g. Bass was soloed first - Kick: { vol: 80, mute: false, solo: false }, + S.partMix = { + 'audio:Bass_DI': { vol: 100, mute: false, solo: true }, // e.g. Bass was soloed first + 'audio:Kick': { vol: 80, mute: false, solo: false }, + 'arr:0': { vol: 100, mute: false, solo: true }, // a synth-part solo is NOT ours to touch }; editorSoloMyStem(); - assert.strictEqual(S.stemMix.Guitar_L.solo, true, 'my source is soloed'); - assert.strictEqual(S.stemMix.Bass_DI.solo, false, 'the previous solo is cleared — isolate, not stack'); - assert.strictEqual(S.stemMix.Kick.solo, false); - assert.strictEqual(S.stemMix.Kick.vol, 80, 'non-solo mix fields survive'); + assert.strictEqual(S.partMix['audio:Guitar_L'].solo, true, 'my source is soloed'); + assert.strictEqual(S.partMix['audio:Bass_DI'].solo, false, 'the previous STEM solo is cleared — isolate, not stack'); + assert.strictEqual(S.partMix['audio:Kick'].solo, false); + assert.strictEqual(S.partMix['audio:Kick'].vol, 80, 'non-solo mix fields survive'); + assert.strictEqual(S.partMix['arr:0'].solo, true, 'a synth-part solo is left alone — this verb owns the audio band only'); editorSoloMyStem(); - assert.ok(Object.values(S.stemMix).every((m) => !m.solo), - 'toggle-off restores the no-solo state (all solos cleared)'); + assert.ok(Object.entries(S.partMix).filter(([k]) => k.startsWith('audio:')).every(([, m]) => !m.solo), + 'toggle-off restores the no-solo state across the audio band'); }); // ── Item 17 surface: the menu greys the verb until the capability exists ──