From f60be624690f42bc42ab377b3058da722fc507dc Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Thu, 16 Jul 2026 20:37:55 +0200 Subject: [PATCH] PR #296 squashed (active-source switching) --- CHANGELOG.md | 42 +++++++ routes.py | 51 +++++++- src/arrangement.js | 10 +- src/audio.js | 209 ++++++++++++++++++++++++++----- src/file-ops.js | 12 +- src/host.js | 4 + src/main.js | 14 ++- src/mixer-panel.js | 48 +++++++- src/parts-view.js | 14 ++- src/shortcuts.js | 2 +- src/state.js | 12 ++ src/track-session.js | 102 +++++++++++++--- src/waveform.js | 8 +- tests/audition_clock.test.mjs | 14 +-- tests/mixer_panel.test.mjs | 30 ++++- tests/stem_engine.test.mjs | 210 +++++++++++++++++++++++++++++++- tests/test_editor_stem_cache.py | 14 +++ tests/track_session.test.mjs | 57 +++++++-- 18 files changed, 760 insertions(+), 93 deletions(-) create mode 100644 tests/test_editor_stem_cache.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7325269f..f0d4cce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **The master mix is a channel strip in the mixer.** Every audio source now has + a vertical strip in the mixer drawer — the master mix leads the audio band + (matching the DAW console), followed by the stems, then the MIDI parts and the + SOURCE/GUIDE/CLICK/MASTER buses. Its fader, mute, and solo are real: the active + source's reference playback now routes through its own per-source gain before + the SOURCE submix, so riding the master strip actually changes its level. The + Tracks pane still lists the master as a selectable source row (click it to + chart against the full mix), but its strip **controls** — fader, mute, solo — + live only in the mixer, not the pane. +- **Click a track to chart against it.** Selecting an audio track (the master + mix or any stem) in the Tracks column now makes it the **active source**: + the main waveform shows that track and the onset tools (Suggest, snapping) + analyze it — so you can line the grid up against an isolated stem. Playback + is unaffected: every source keeps playing together; only what you *see* and + *analyze* follows the click. + +### Fixed +- **Audio stem lanes now draw their waveforms.** The per-stem waveform builder + was handed the decoded `AudioBuffer` instead of its channel `Float32Array`, so + every sample read `undefined` and the peaks collapsed to ±Infinity — the lane + painted off-canvas and looked empty. It now reads `getChannelData(0)` at the + master's ~3 ms/bin resolution, so each stem lane shows its shape like the mix. +- **Parity pass against the DAW track-session design.** The backend now sends + `audio_sources` with the master's pack-authored name (from the manifest + `full` mix) and per-stem display names, and the stem cache filename carries + a per-source index so two stem ids that sanitize alike can't overwrite each + other's audio. Deleting a transcription track from the Tracks context menu + now finishes its cleanup (its `editorRemoveArrangement` reports success + again); clicking an audio LANE on the canvas focuses that source like its + header row does; cycling the tempo guide respects the 🔒 lock and resets a + new guide to plain audio analysis; the header-strip fader regained its + +6 dB range; a removed audio track drops its stale mixer state. +- **Tempo guide + Tracks column reliability.** The Master row and the tempo + guide could vanish (guide read "No guide") because audio sources were + derived from `S.audioUrl`, which active-source switching reassigns to a + focused stem and which isn't set yet at load — the master now rides the + stable `S.masterAudioUrl`. The master track defaults to the **song name** + (not the generic "Master Mix"), and the guide label follows a track's + inline rename. The mixer channel strips now **reorder to match a drag in + the Tracks column** (and rename with it). + ### Changed - **The mixer is now a proper DAW console.** The docked side panel is diff --git a/routes.py b/routes.py index ab889bb6..e28a1d23 100644 --- a/routes.py +++ b/routes.py @@ -509,6 +509,18 @@ def _parse_track_session(data: dict): return _coerce_track_session(data.get("track_session"), invalid=_FIELD_ABSENT) +def _editor_stem_cache_basename(audio_id, index, sid): + """Cached-stem filename stem that stays unique per source. + + Distinct stem ids can sanitize to the same string (``drums/kit`` and + ``drums:kit`` both become ``drums_kit``), so the sanitized id alone would + let one copied stem overwrite another and every URL would then play the + last-copied file. The per-source ``index`` keeps the paths distinct. + """ + safe = re.sub(r"[^a-zA-Z0-9_-]", "_", str(sid)) + return f"editor_stem_{audio_id}_{index}_{safe}" + + def _apply_track_session(manifest: dict, session) -> None: """Write/remove the `editor_track_session` manifest extension key.""" if session is _FIELD_ABSENT: @@ -4123,7 +4135,7 @@ def _safe_stem_path(stem_entry: dict) -> "Path | None": # mixer can load and balance them live. Only for genuine # multi-stem sloppaks — a single-`full` sloppak has nothing to mix. _stem_urls = [] - for _s in loaded.stems: + for _index, _s in enumerate(loaded.stems): _sid = (_s.get("id") or "").strip() if not _sid or _sid == "full": continue @@ -4131,15 +4143,28 @@ def _safe_stem_path(stem_entry: dict) -> "Path | None": if _sp is None or not _sp.exists(): continue _sext = _sp.suffix or ".ogg" - _safe_sid = re.sub(r"[^a-zA-Z0-9_-]", "_", _sid) - _sdest = STORAGE_DIR / f"editor_stem_{audio_id}_{_safe_sid}{_sext}" + # Per-source index keeps the cached filename unique even when two + # distinct ids sanitize to the same string ('drums/kit' vs + # 'drums:kit') — otherwise one stem overwrites another and every + # URL plays the last-copied file. + _base = _editor_stem_cache_basename(audio_id, _index, _sid) + _sdest = STORAGE_DIR / f"{_base}{_sext}" try: shutil.copy2(_sp, _sdest) except OSError: continue + try: + _source_offset = float(_s.get("offset", 0) or 0) + except (TypeError, ValueError): + _source_offset = 0.0 + if not math.isfinite(_source_offset): + _source_offset = 0.0 _stem_urls.append({ "id": _sid, - "url": f"{STORAGE_URL}/editor_stem_{audio_id}_{_safe_sid}{_sext}", + "name": (str(_s.get("name")).strip()[:160] + if isinstance(_s.get("name"), str) and _s.get("name").strip() else _sid), + "url": f"{STORAGE_URL}/{_base}{_sext}", + "offset": _source_offset, }) result = _song_to_dict(song, audio_url) @@ -4163,6 +4188,24 @@ def _safe_stem_path(stem_entry: dict) -> "Path | None": loaded.manifest.get("editor_track_session"), invalid=None) if _tree: result["track_session"] = _tree + # Editor audio sources (master + stems) with display names. The + # master's name comes from the manifest's `full` mix entry when the + # pack authored one; absent that, the frontend falls back to the + # song name. The Tracks column, the tempo-guide label, and the mixer + # strips all read these names. (Ported from the DAW track-session + # backend; ids stay the bare manifest ids this editor already uses.) + _master_manifest = next((s for s in loaded.stems + if isinstance(s, dict) and str(s.get("id") or "") == "full"), {}) + _master_name = _master_manifest.get("name") + _master_name = (_master_name.strip()[:160] + if isinstance(_master_name, str) and _master_name.strip() else "") + _audio_sources = [{"id": "master", "name": _master_name, + "kind": "master", "url": audio_url or ""}] + for _su in _stem_urls: + _audio_sources.append({"id": _su["id"], "name": _su.get("name") or _su["id"], + "kind": "stem", "url": _su.get("url") or "", + "offset": _su.get("offset", 0)}) + result["audio_sources"] = _audio_sources # `lib/sloppak.load_song()` doesn't restore song.offset (the # sloppak format doesn't carry an explicit offset field today), # so song.offset is 0 here. If the manifest happens to surface diff --git a/src/arrangement.js b/src/arrangement.js index 68ab1899..bbaf7b58 100644 --- a/src/arrangement.js +++ b/src/arrangement.js @@ -158,12 +158,12 @@ export async function editorRenameArrangement() { export async function editorRemoveArrangement() { if (_recState !== 'idle') { setStatus('Cannot remove an arrangement while recording. Stop the take first.'); - return; + return false; } - if (S.arrangements.length <= 1) return; + if (S.arrangements.length <= 1) return false; const removeIdx = S.currentArr; const arr = S.arrangements[removeIdx]; - if (!confirm(`Remove "${arr.name}" arrangement?`)) return; + if (!confirm(`Remove "${arr.name}" arrangement?`)) return false; // Remove from backend first if (S.sessionId) { @@ -179,11 +179,11 @@ export async function editorRemoveArrangement() { const result = await resp.json(); if (result.error) { setStatus('Remove failed: ' + result.error); - return; + return false; } } catch (e) { setStatus('Remove failed: ' + e.message); - return; + return false; } } diff --git a/src/audio.js b/src/audio.js index 3354b3fa..beb32cf5 100644 --- a/src/audio.js +++ b/src/audio.js @@ -46,6 +46,7 @@ import { setStatus } from './ui.js'; let rafId = null; let audioLoadController = null; let audioLoadGeneration = 0; +let activeSourceGeneration = 0; // Lazily create the shared AudioContext. Compose mode never decodes a // recording (loadAudio is the only other creation site), yet the transport @@ -74,9 +75,13 @@ export async function loadAudio(url) { if (generation !== audioLoadGeneration) return false; S.audioBuffer = decoded; S.duration = S.audioBuffer.duration; + S.masterAudioDuration = S.audioBuffer.duration; // Keep the playable URL for the pitch-preserving audition path (the // MediaElement needs a src; the decoded buffer feeds waveform + onsets). S.audioUrl = url; + S.masterAudioUrl = url; + S.activeAudioSourceId = 'master'; + S.activeAudioSourceOffset = 0; _resetAuditionForNewSong(); // a per-song pref never carries across loads // A new recording is loaded — re-arm the hearing-safety fade so it // applies to this recording too, not just the session's first one. @@ -253,7 +258,7 @@ export function _ensureOnsets() { // burning ticks first. _cancelOnsetJob(); _onsetCache = null; _onsetCacheKey = null; _onsetDetector = null; - const dur = S.duration || 0; + const dur = (S.audioBuffer && S.audioBuffer.duration) || S.duration || 0; if (!key || dur <= 0) return null; // Return the cheap RMS-envelope onsets IMMEDIATELY (zero delay for the strip / // snap), and upgrade to banded spectral-flux (P2-2) in the BACKGROUND — the @@ -324,7 +329,7 @@ function _cancelOnsetJob() { // so it always tracks the live S.audioShift. export function _ensureOnsetsShifted() { const raw = _ensureOnsets(); - const sh = Number(S.audioShift) || 0; + const sh = (Number(S.audioShift) || 0) + (Number(S.activeAudioSourceOffset) || 0); if (!raw || !sh) return raw; return raw.map(o => ({ ...o, t: o.t + sh })); // carry s + per-band strengths } @@ -498,7 +503,7 @@ export function _audioTimelineDurationPure(timelineDuration, audioShift, bufferD /* @pure:audio-shift:end */ function _audioTimelineDuration() { - return _audioTimelineDurationPure(S.duration, S.audioShift, S.audioBuffer && S.audioBuffer.duration); + return _audioTimelineDurationPure(S.duration, S.audioShift, S.masterAudioDuration || S.duration); } // ── Audition speed (design slice 5): pitch-preserving slow practice ────────── @@ -558,8 +563,7 @@ function _ensureRefMedia() { if (!_refMediaNode) { try { _refMediaNode = S.audioCtx.createMediaElementSource(_refMediaEl); } catch (_) { _refMediaNode = null; return null; } - const refGain = _ensureRefGain(); - _refMediaNode.connect(refGain || S.audioCtx.destination); + _refMediaNode.connect(_activeRefTarget() || S.audioCtx.destination); } return _refMediaEl; } @@ -576,6 +580,12 @@ function _stopRefMedia() { function _startRefMediaAt(st, preRoll = 0) { const el = _ensureRefMedia(); if (!el) return false; + // The active source may have changed since the media node was wired — re-point + // it at the current active source's per-source gain so its strip fader applies. + if (_refMediaNode) { + try { _refMediaNode.disconnect(); } catch (_) { /* not connected yet */ } + _refMediaNode.connect(_activeRefTarget() || S.audioCtx.destination); + } if (_refMediaPlayTimer) { clearTimeout(_refMediaPlayTimer); _refMediaPlayTimer = null; } const r = _auditionRate(); el.preservesPitch = true; @@ -596,7 +606,9 @@ function _startRefMediaAt(st, preRoll = 0) { // from where the ear lands in the stretched signal. function _auditionResyncMedia() { if (!_auditionActive() || !_refMediaEl || _refMediaEl.paused) return; - const st = _audioBufferStartPure(S.cursorTime, S.audioShift, S.audioBuffer && S.audioBuffer.duration); + const st = _audioBufferStartPure(S.cursorTime, + (Number(S.audioShift) || 0) + (Number(S.activeAudioSourceOffset) || 0), + S.audioBuffer && S.audioBuffer.duration); if (!st.play) return; if (Math.abs(_refMediaEl.currentTime - st.offset) > 0.03) { try { _refMediaEl.currentTime = Math.max(0, st.offset); } catch (_) { /* seeking */ } @@ -645,7 +657,9 @@ export function _startAudioSourceAtCursor(preRoll = 0) { // push the audio start into the future (delay) or, near the end, past the // buffer entirely (no source; the transport still runs so the cursor and // guide advance over the trailing silence). - const st = _audioBufferStartPure(S.cursorTime, S.audioShift, S.audioBuffer && S.audioBuffer.duration); + const st = _audioBufferStartPure(S.cursorTime, + (Number(S.audioShift) || 0) + (Number(S.activeAudioSourceOffset) || 0), + S.audioBuffer && S.audioBuffer.duration); let slow = _auditionActive(); if (slow && st.play) { // Pitch-preserving slow path: the reference rides the MediaElement, so @@ -672,8 +686,8 @@ export function _startAudioSourceAtCursor(preRoll = 0) { // mixer fader is a plain gain (unity by default): the guide-clap limiter // must never color the recording, even when claps are off. Only the // guide/click voices sum through the limiter (see _ensureMasterBus). - const refGain = _ensureRefGain(); - if (refGain) S.audioSource.connect(refGain); + const target = _activeRefTarget(); + if (target) S.audioSource.connect(target); else S.audioSource.connect(S.audioCtx.destination); _mixApplyFirstPlayFade(); const when = (preRoll > 0 || st.delay > 0) ? S.audioCtx.currentTime + preRoll + st.delay : 0; @@ -1692,15 +1706,22 @@ const playingStemSources = new Map(); // sourceId → live AudioBufferSourceNode const stemGainNodes = new Map(); // sourceId → GainNode let stemDecodeGeneration = 0; +const MASTER_SOURCE_ID = 'master'; 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) || []); +// Every live audio source — the master recording PLUS the stems — minus the +// track session's non-destructive removals. The master's URL comes from +// S.masterAudioUrl so it survives while a STEM is the active buffer (at +// which point S.audioUrl points at the stem). +export function _liveAudioSourcesPure(masterUrl, stems, removedSourceIds) { + const removed = new Set(Array.isArray(removedSourceIds) ? removedSourceIds : []); const seen = new Set(); const out = []; - for (const raw of (Array.isArray(S.stems) ? S.stems : [])) { + if (masterUrl && !removed.has(MASTER_SOURCE_ID)) { + out.push({ id: MASTER_SOURCE_ID, url: masterUrl, offset: 0 }); + seen.add(MASTER_SOURCE_ID); + } + for (const raw of (Array.isArray(stems) ? 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; @@ -1710,20 +1731,51 @@ function _liveStemSources() { 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. +function _liveAudioSources() { + const masterUrl = S.masterAudioUrl + || (S.activeAudioSourceId === MASTER_SOURCE_ID ? S.audioUrl : '') || ''; + return _liveAudioSourcesPure(masterUrl, S.stems, + S.trackSession && S.trackSession.removedSourceIds); +} + +// The sources the multi-source scheduler plays: every live source EXCEPT the +// active one, which plays via the S.audioSource reference path (its buffer is +// what the waveform shows and onset tools analyze). Pure, for the tests. +export function _scheduledSourceIdsPure(sources, activeId) { + return (Array.isArray(sources) ? sources : []) + .map(s => s && s.id).filter(id => id && id !== activeId); +} + +export function _staleAudioSourceIdsPure(existingIds, liveIds) { + const live = liveIds instanceof Set ? liveIds : new Set(liveIds || []); + return [...(existingIds || [])].filter(id => !live.has(id)); +} + +// Decode every live source into the cache (parallel, generation-guarded, one +// failure never blocks the rest). The master's buffer is usually already +// decoded as S.audioBuffer — adopt it directly rather than re-fetching. Drops +// cache entries for sources that are gone. Safe to call repeatedly. export async function syncStemAudio() { - const sources = _liveStemSources(); + const sources = _liveAudioSources(); 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. + // stem must stop sounding now, and a stale SOLO it left in partMix would + // otherwise silence every live track (see _pruneStaleStems). Runs before the + // fetch/ctx guards so the solo cleanup happens even headless. _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); + for (const id of _staleAudioSourceIdsPure(stemAudioCache.keys(), liveIds)) stemAudioCache.delete(id); + // Adopt the already-decoded active buffer (usually the master) for free. + if (S.audioBuffer && S.activeAudioSourceId && liveIds.has(S.activeAudioSourceId)) { + const src = sources.find(s => s.id === S.activeAudioSourceId); + const cached = stemAudioCache.get(S.activeAudioSourceId); + if (src && S.audioUrl === src.url && (!cached || cached.url !== src.url)) { + stemAudioCache.set(S.activeAudioSourceId, { url: src.url, buffer: S.audioBuffer, peaks: null }); + } + } await Promise.all(sources.map(async (source) => { const cached = stemAudioCache.get(source.id); if (cached && cached.url === source.url && cached.buffer) return; @@ -1734,10 +1786,38 @@ export async function syncStemAudio() { 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 */ } + } catch (_) { /* one unavailable source must not block the session */ } })); + if (generation !== stemDecodeGeneration) return; + let activeRepaired = false; + if (!liveIds.has(S.activeAudioSourceId)) { + // Prefer the master, then any other live source — a failed decode of + // one candidate must not strand the removed id as active, so keep + // trying until one activates. + const candidates = [ + ...sources.filter(source => source.id === MASTER_SOURCE_ID), + ...sources.filter(source => source.id !== MASTER_SOURCE_ID), + ]; + for (const source of candidates) { + if (await activateTrackAudioSource(source.id)) { activeRepaired = true; break; } + } + if (!activeRepaired) { + // Nothing decoded — clear the stale reference and reset to the + // no-source state so playback doesn't keep the removed buffer. + activeSourceGeneration++; + cancelAudioLoad(); + S.audioBuffer = null; + S.waveformPeaks = null; + S.audioUrl = null; + S.activeAudioSourceId = MASTER_SOURCE_ID; + S.activeAudioSourceOffset = 0; + if (S.playing) _restartPlaybackAt(S.cursorTime); + host.draw(); + activeRepaired = true; + } + } if (generation === stemDecodeGeneration - && _stemCatchupAllowedPure(S.playing, _auditionActive())) { + && !activeRepaired && _stemCatchupAllowedPure(S.playing, _auditionActive())) { const catchup = _stemCatchupPure( S.playStartTime, S.playStartWall, S.audioCtx.currentTime, _auditionRate()); _startStemSources(catchup.preRoll, catchup.cursorTime); @@ -1793,12 +1873,64 @@ export function _pruneStaleStems(liveIds) { return removedSolo; } +// Make `sourceId` the active reference: its decoded buffer becomes what the +// main waveform shows and what onset tools (Suggest, snap) analyze. Playback +// is NOT rerouted — the newly-active source plays via the reference path and +// every other live source keeps playing through the scheduler, so what you +// hear is unchanged; only what you SEE and analyze follows the click. +export async function activateTrackAudioSource(sourceId) { + if (!sourceId) return false; + const generation = ++activeSourceGeneration; + const source = _liveAudioSources().find(item => item.id === sourceId); + if (!source || !source.url) { setStatus('That audio source is unavailable in this song.'); return false; } + if (sourceId === S.activeAudioSourceId && S.audioUrl === source.url + && (Number(S.activeAudioSourceOffset) || 0) === source.offset) return true; + let cached = stemAudioCache.get(sourceId); + if (!cached || cached.url !== source.url || !cached.buffer) { + if (typeof fetch !== 'function' || !S.audioCtx) return false; + try { + const resp = await fetch(source.url); + if (!resp.ok) throw new Error('fetch'); + const buffer = await S.audioCtx.decodeAudioData(await resp.arrayBuffer()); + if (generation !== activeSourceGeneration) return false; + cached = { url: source.url, buffer, peaks: null }; + stemAudioCache.set(sourceId, cached); + } catch (_) { + if (generation === activeSourceGeneration) setStatus('That audio source could not be loaded.'); + return false; + } + } + if (generation !== activeSourceGeneration) return false; + // A master load started before this selection must not land afterward and + // silently replace the chosen reference buffer. + cancelAudioLoad(); + // The active source becomes the reference buffer. The timeline length is + // the master's — a stem shares it — so don't let a slightly-different stem + // duration move the chart's end. + S.audioBuffer = cached.buffer; + S.audioUrl = source.url; + S.activeAudioSourceId = sourceId; + S.activeAudioSourceOffset = Number(source.offset) || 0; + if (sourceId === MASTER_SOURCE_ID) { + S.duration = cached.buffer.duration; + S.masterAudioDuration = cached.buffer.duration; + } + host.editorApplyScrollBounds(); + computeWaveform(); // waveform now shows this source + if (S.playing) _restartPlaybackAt(S.cursorTime); // re-split active vs scheduled + host.draw(); + return true; +} + // New song boundary: orphan in-flight decodes and drop every buffer. export function resetStemAudioCache() { + activeSourceGeneration++; stemDecodeGeneration++; stemAudioCache.clear(); _stopStemSources(); _stemGainsReset(); // detaches each stem's meter tap with its gain + S.activeAudioSourceId = MASTER_SOURCE_ID; + S.activeAudioSourceOffset = 0; } // A stem's cached min/max waveform peaks for its lane (lazy — built on first @@ -1806,7 +1938,15 @@ export function resetStemAudioCache() { export function audioStemWaveform(sourceId) { const cached = stemAudioCache.get(sourceId); if (!cached || !cached.buffer) return null; - if (!cached.peaks) cached.peaks = _buildWaveformPeaks(cached.buffer, 512); + if (!cached.peaks) { + // _buildWaveformPeaks wants a channel Float32Array, NOT the AudioBuffer — + // passing the buffer made every data[s] read `undefined`, collapsing the + // peaks to ±Infinity so the lane drew off-canvas (invisible stems). Match + // computeWaveform's ~3 ms/bin resolution so a stem lane looks like the master. + const channel = cached.buffer.getChannelData(0); + const binSamples = Math.max(64, Math.round(cached.buffer.sampleRate * 0.003)); + cached.peaks = _buildWaveformPeaks(channel, binSamples); + } return { peaks: cached.peaks, duration: cached.buffer.duration }; } @@ -1824,6 +1964,18 @@ function _ensureStemGain(sourceId) { return gain; } +// The graph node the ACTIVE source's reference playback (the rate-1 BufferSource +// AND the audition MediaElement alike) feeds into: its OWN per-source gain, so +// the active source's channel strip — the master mix included — governs its +// level/mute/solo, then on into _refGain (the SOURCE submix). Non-active sources +// already route this way via _startStemSources. Falls back to _refGain, then the +// destination, before any per-source gain can exist. +function _activeRefTarget() { + return _ensureStemGain(S.activeAudioSourceId) + || _ensureRefGain() + || (S.audioCtx ? S.audioCtx.destination : null); +} + // 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) { @@ -1837,15 +1989,16 @@ export function applyStemMix(immediate = false) { } } -// 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). +// Schedule every live source EXCEPT the active one (which plays via the +// S.audioSource reference path), sample-aligned: each computes its placement +// from S.audioShift + its own offset and starts at the SAME preRoll-shifted +// anchor. Called from the reference's rate-1 start path (never audition-slow). function _startStemSources(preRoll = 0, cursorTime = S.cursorTime) { _stopStemSources(); if (!S.audioCtx) return 0; let started = 0; - for (const source of _liveStemSources()) { + for (const source of _liveAudioSources()) { + if (source.id === S.activeAudioSourceId) continue; // plays via S.audioSource const cached = stemAudioCache.get(source.id); if (!cached || !cached.buffer) continue; // not decoded yet — syncStemAudio catches up const placement = _audioBufferStartPure( diff --git a/src/file-ops.js b/src/file-ops.js index 740c8bee..df2ed501 100644 --- a/src/file-ops.js +++ b/src/file-ops.js @@ -182,6 +182,16 @@ export async function loadCDLC(filename, options = {}) { } // Freshly loaded from disk — not dirty until the user edits it. S.drumTabDirty = false; + // The master recording's URL is the active source's anchor — held + // separately so it survives while a stem is focused as the reference. + // Set BEFORE installTrackSession: its render reads the master name via + // _liveSources, so a late assignment would flash the previous song's + // master/guide label on the first paint. + S.masterAudioUrl = data.audio_url || null; + S.masterAudioDuration = data.audio_url ? (Number(S.duration) || 0) : 0; + // The pack-authored master audio name (backend audio_sources), if any. + S.masterAudioName = (Array.isArray(data.audio_sources) + ? (data.audio_sources.find(s => s && s.id === 'master') || {}).name : '') || ''; // Persistent track tree — adopted after arrangements/stems/drumTab so // normalization sees the full loaded song. data.audio_url rides in // explicitly: S.audioUrl still points at the PREVIOUS song here @@ -190,7 +200,7 @@ 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). + // Decode this song's sources 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(); diff --git a/src/host.js b/src/host.js index 8f67df9f..901276a4 100644 --- a/src/host.js +++ b/src/host.js @@ -73,6 +73,8 @@ export const host = { partsViewOnDblClick: () => {}, /** Arm a transcription target (arrangement / drums) from the Tracks area. */ selectTrackSessionTarget: () => {}, + /** Focus an audio source as the active waveform/onset reference. */ + selectTrackSessionSource: () => {}, /** Leave the Tracks overview and open a transcription's native editor. */ openTrackSessionTarget: () => {}, /** Vertically scroll the shared track-header/canvas lane stack. */ @@ -203,6 +205,8 @@ export const host = { mixUiState: () => ({ pcts: { ref: 100, guide: 35, click: 25, master: 100 }, blip: true }), /** Live post-fader meter levels + peak dB per bus and stem track. */ mixerMeterLevels: () => ({ ref: 0, guide: 0, click: 0, master: 0, tracks: {}, peaks: {}, trackPeaks: {} }), + /** Mixer strip keys in Tracks-column row order (mixer follows a reorder). */ + mixerTrackOrder: () => [], /** * Per-part strip state BY KEY ('arr:' / 'drums') for band-mode * MIDI playback: {audible, vol 0..1} with the whole-map solo rule. diff --git a/src/main.js b/src/main.js index 68b4b836..de3a85d4 100644 --- a/src/main.js +++ b/src/main.js @@ -48,6 +48,7 @@ import { startPlayback, stopPlayback, teardownAudio, editorSetCountIn, editorSetAuditionRate, editorToggleAuditionTrainer, editorPlayAllTracksEnabled, editorTogglePlayAllTracks, _partGainsApply, applyStemMix, audioStemWaveform, syncStemAudio, audioMixerMeterLevels, + activateTrackAudioSource, } from './audio.js'; import { _mixerClapState, _mixerPanelRefresh, _mixerPartStripState, editorToggleMixerPanel, initMixerPanel } from './mixer-panel.js'; import { @@ -124,7 +125,7 @@ import { EDITOR_MENUS, initMenuBar } from './menu-bar.js'; import { _tabViewHideIfShown, _tabViewPing, editorToggleTabView, teardownTabView } from './tab-view-live.js'; import { initToolbars } from './toolbars.js'; import { editorStartTour, editorTourEscape, editorTourSkip, _tourAdvance, _tourNoteAction } from './tour.js'; -import { _trackSessionTargetsPure, initTrackSession, installCreatedTrackSession, refreshTrackSession, scrollTrackSessionBy } from './track-session.js'; +import { _trackSessionTargetsPure, initTrackSession, installCreatedTrackSession, refreshTrackSession, scrollTrackSessionBy, trackSessionOrderedMixKeys } from './track-session.js'; import { editorDismissSignpost } from './signposts.js'; import { _editorSongFit } from './song-fit.js'; import { _transportBarTick, initTransportBar } from './transport-bar.js'; @@ -555,6 +556,10 @@ setHostHooks({ const index = target && target.mixKey.startsWith('arr:') ? Number(target.mixKey.slice(4)) : -1; if (index >= 0 && index !== S.currentArr) window.editorSelectArrangement(String(index)); }, + // Focus an audio source: its buffer becomes the waveform + onset source + // (playback keeps all sources — see activateTrackAudioSource). Async; + // fire-and-forget from the click. + selectTrackSessionSource: (sourceId) => { activateTrackAudioSource(sourceId); }, openTrackSessionTarget: (targetId) => { _finalizeActiveDrag(); S.partsViewMode = false; @@ -577,11 +582,12 @@ setHostHooks({ }, // Vertical wheel over the Tracks area scrolls the shared lane stack. scrollTrackArea: (deltaY) => scrollTrackSessionBy(deltaY), + mixerTrackOrder: () => trackSessionOrderedMixKeys(), // Lane waveforms: the master mix draws from the session's decoded peaks; // 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)), + trackWaveform: (sourceId) => audioStemWaveform(sourceId) + || (sourceId === S.activeAudioSourceId && S.waveformPeaks && S.audioBuffer + ? { peaks: S.waveformPeaks, duration: S.audioBuffer.duration } : null), }); // 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 b19469b7..b988d215 100644 --- a/src/mixer-panel.js +++ b/src/mixer-panel.js @@ -33,13 +33,21 @@ 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, stems, removedSourceIds) { +export function _mixerPartsPure(arrangements, drumTab, stems, removedSourceIds, master) { 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. + // The master mix leads the audio band as its own channel strip (keyed + // 'audio:master', same store/solo rule as the stems) so every audio source + // — master included — has a strip, matching the DAW track-session mixer. + // `master` is null in compose mode (no recording) so no phantom strip shows. const removed = new Set(Array.isArray(removedSourceIds) ? removedSourceIds : []); const seen = new Set(); + if (master && !removed.has('master')) { + parts.push({ key: 'audio:master', name: master.name || 'Master Mix', kind: 'audio' }); + seen.add('master'); + } + // Then the studio stems (the rest of the audio band), then 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. 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; @@ -90,6 +98,18 @@ export function _mixerDbLabelPure(db) { if (!Number.isFinite(value) || value <= -60) return '−∞'; return (value > -10 ? value.toFixed(1) : Math.round(value).toString()) + ' dB'; } +// Reorder the strips to match the Tracks-column row order (orderedKeys), +// so a drag-reorder in the left pane moves the mixer strip too. Keys not in +// the order list keep their original relative position at the tail — a +// stable sort by (index in orderedKeys, original index). +export function _mixerOrderedPartsPure(parts, orderedKeys) { + const rank = new Map((Array.isArray(orderedKeys) ? orderedKeys : []).map((k, i) => [k, i])); + const TAIL = Number.MAX_SAFE_INTEGER; + return (Array.isArray(parts) ? parts : []) + .map((p, i) => [p, rank.has(p.key) ? rank.get(p.key) : TAIL, i]) + .sort((a, b) => (a[1] - b[1]) || (a[2] - b[2])) + .map(entry => entry[0]); +} // Meter ballistics: instant attack (peaks show at once), gravity decay // (~full-scale over 700 ms) so a transient doesn't strobe. export function _mixerMeterNextPure(previous, input, elapsedMs) { @@ -108,6 +128,12 @@ export function _mixerAnySoloPure(partMix) { 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; return _mixerAnySoloPure(partMix) ? st.solo : true; } // What the guide-clap scheduler needs for the ACTIVE editing surface: claps @@ -177,10 +203,19 @@ function _selectedStripKeyPure() { return ''; } +// The master-mix strip descriptor, or null in compose mode (no recording). +// Named from the pack's authored master name, else the song title. +function _mixerMaster() { + return (S.masterAudioUrl || S.audioUrl) + ? { name: S.masterAudioName || S.title || 'Master Mix' } : null; +} + // A vertical channel strip per part: type badge, M/S, the meter+fader // channel, a dB value, and the name — faithful to the #285 console. function _renderParts(container) { - const parts = _mixerPartsPure(S.arrangements, S.drumTab, S.stems, S.trackSession && S.trackSession.removedSourceIds); + const parts = _mixerOrderedPartsPure( + _mixerPartsPure(S.arrangements, S.drumTab, S.stems, S.trackSession && S.trackSession.removedSourceIds, _mixerMaster()), + host.mixerTrackOrder()); if (!parts.length) { container.innerHTML = '

No tracks yet — strips appear as tracks are added.

'; return; @@ -399,9 +434,10 @@ export function _mixerPanelRefresh() { } const container = document.getElementById('editor-mixer-parts'); if (!container) return; - const parts = _mixerPartsPure(S.arrangements, S.drumTab, S.stems, S.trackSession && S.trackSession.removedSourceIds); + const parts = _mixerPartsPure(S.arrangements, S.drumTab, S.stems, S.trackSession && S.trackSession.removedSourceIds, _mixerMaster()); const key = editGen + '|' + S.selectedTrackId + '|' + JSON.stringify(S.partMix) + '|' + (host.playAllTracksEnabled() ? '1' : '0') + '|' + + host.mixerTrackOrder().join(',') + '|' + parts.map(p => p.key + ':' + p.name).join(','); if (key === _lastKey) return; _lastKey = key; diff --git a/src/parts-view.js b/src/parts-view.js index 2e8571d6..f2d57ca1 100644 --- a/src/parts-view.js +++ b/src/parts-view.js @@ -16,8 +16,8 @@ import { getMousePos } from './mouse.js'; import { S } from './state.js'; import { _refreshTempoMapButton } from './tempo.js'; import { - _trackSessionFittedHeightsPure, _trackSessionLaneLayoutPure, _trackSessionRowsPure, - _trackSessionSourcesPure, _trackSessionTargetsPure, refreshTrackSessionSelection, + _liveSources, _trackSessionFittedHeightsPure, _trackSessionLaneLayoutPure, _trackSessionRowsPure, + _trackSessionTargetsPure, refreshTrackSessionSelection, } from './track-session.js'; import { setStatus } from './ui.js'; import { host } from './host.js'; @@ -84,7 +84,7 @@ const PARTS_GUTTER = LABEL_W; // same pure, same inputs, so geometry can never diverge between surfaces. function _unifiedRows() { return _trackSessionRowsPure(S.trackSession, - _trackSessionSourcesPure(S.audioUrl, S.stems), S.arrangements, S.drumTab, S.stemLinks).rows; + _liveSources(), S.arrangements, S.drumTab, S.stemLinks).rows; } // Map a transcription targetId back to its arrangement index ('drums' → -1). function _arrIndexForTarget(targetId) { @@ -101,7 +101,7 @@ function _drawTrackAudioWaveform(row, y0, laneH, w) { const data = host.trackWaveform(row.sourceId); if (!data || !data.peaks || !data.peaks.bins || !(data.duration > 0)) return; const pk = data.peaks; - const shift = Number(S.audioShift) || 0; + const shift = (Number(S.audioShift) || 0) + (Number(row.sourceOffset) || 0); const xLo = Math.max(PARTS_GUTTER, Math.floor(timeToX(shift))); const xHi = Math.min(w, Math.ceil(timeToX(data.duration + shift))); const mid = y0 + laneH / 2; @@ -253,7 +253,13 @@ export function _partsViewOnMouseDown(e, x, y) { refreshTrackSessionSelection(); if (row.type === 'audio') { S.focusedSourceId = row.sourceId; + // Set the generic status BEFORE activation: activateTrackAudioSource + // sets a specific error synchronously when the source is unavailable, + // and that message must survive rather than be overwritten here. setStatus(`Audio track: ${row.name}`); + // Match the header row: focus this source as the active reference so + // the waveform + onset tools follow the clicked lane too. + host.selectTrackSessionSource(row.sourceId); } else if (row.targetId === 'drums') { setStatus('Drum transcription selected — double-click to open the drum editor'); } else { diff --git a/src/shortcuts.js b/src/shortcuts.js index 9b857ead..33d6fbfe 100644 --- a/src/shortcuts.js +++ b/src/shortcuts.js @@ -146,7 +146,7 @@ const EDITOR_SHORTCUT_COMMANDS = Object.freeze([ { id: 'tempoAcceptWholeFit', label: 'Accept whole tempo fit (all suggestions)', group: 'Tempo map', status: 'ready', keys: { feedback: '', eof: '' } }, { id: 'tempoInsertSync', label: 'Mark barline at cursor', group: 'Tempo map', status: 'ready', keys: { feedback: 'I (Tempo Map)', eof: 'I / Insert (Tempo Map)' } }, { id: 'tempoDeleteSync', label: 'Delete selected barline', group: 'Tempo map', status: 'ready', keys: { feedback: 'Del (Tempo Map)', eof: 'Del (Tempo Map)' } }, - { id: 'tempoToggleSyncLock', label: 'Lock/unlock selected barline', group: 'Tempo map', status: 'ready', keys: { feedback: 'S (Tempo Map)', eof: 'S (Tempo Map)' } }, + { id: 'tempoToggleSyncLock', label: 'Lock/unlock selected barlines', group: 'Tempo map', status: 'ready', keys: { feedback: 'S (Tempo Map)', eof: 'S (Tempo Map)' } }, { id: 'tempoSetPickup', label: 'Set pickup (partial first bar)', group: 'Tempo map', status: 'ready', keys: { feedback: '', eof: '' } }, { id: 'tempoFullDialog', label: 'Open full tempo dialog', group: 'Tempo map', status: 'planned', keys: { feedback: 'Alt+T (Tempo Map)', eof: 'Alt+T (Tempo Map)' } }, { id: 'tempoRebuildGrid', label: 'Rebuild visible beat grid', group: 'Tempo map', status: 'planned', keys: { feedback: 'Ctrl+Shift+T (Tempo Map)', eof: 'Ctrl+Shift+T (Tempo Map)' } }, diff --git a/src/state.js b/src/state.js index ad4f1c74..955b691c 100644 --- a/src/state.js +++ b/src/state.js @@ -136,6 +136,18 @@ export const S = { // header/canvas scroll, and the header column's width/viewport. selectedTrackId: '', focusedSourceId: 'master', + // The source shown as the main waveform and analyzed for onsets (Suggest, + // snap). Playback is unaffected — every OTHER live source plays alongside. + // The master recording's URL is held separately so it survives while a + // stem is the active buffer. + activeAudioSourceId: 'master', + activeAudioSourceOffset: 0, + masterAudioUrl: null, + masterAudioDuration: 0, + // The master recording's authored display name (from the pack manifest's + // `full` mix entry, via the backend's audio_sources). Empty when the pack + // didn't name it — the Tracks column then falls back to the song name. + masterAudioName: '', trackHeights: {}, trackScrollY: 0, trackHeaderWidth: 320, diff --git a/src/track-session.js b/src/track-session.js index ea6d4478..537ed624 100644 --- a/src/track-session.js +++ b/src/track-session.js @@ -52,9 +52,9 @@ const transcriptionTrackId = (targetId) => 'transcription:' + targetId; // plus every stem, in manifest order. Stem ids are the BARE manifest ids — // the same identity `manifest["stems"]`, the mixer strips, and stemLinks // values share. -export function _trackSessionSourcesPure(audioUrl, stems) { +export function _trackSessionSourcesPure(audioUrl, stems, masterName) { const out = []; - if (audioUrl) out.push({ id: MASTER_ID, name: 'Master Mix', kind: 'master', url: String(audioUrl) }); + if (audioUrl) out.push({ id: MASTER_ID, name: String(masterName || 'Master Mix').slice(0, 120), kind: 'master', url: String(audioUrl) }); const seen = new Set([MASTER_ID]); for (const raw of (Array.isArray(stems) ? stems : [])) { const id = idOf(raw && raw.id); @@ -211,7 +211,7 @@ export function _trackSessionNormalizePure(raw, sources, arrangements, drumTab) // their branch). Pairing is PROJECTED from stemLinks — never stored on the // row's tree entry — and a link that points at a removed or unknown source // projects as unpaired rather than resurrecting it. -export function _trackSessionRowsPure(session, sources, arrangements, drumTab, stemLinks) { +export function _trackSessionRowsPure(session, sources, arrangements, drumTab, stemLinks, includeCollapsed = false) { const model = _trackSessionNormalizePure(session, sources, arrangements, drumTab); const removedSources = new Set(model.removedSourceIds); const sourceMap = new Map((Array.isArray(sources) ? sources : []).filter(source => !removedSources.has(source.id)).map(s => [s.id, s])); @@ -233,10 +233,11 @@ export function _trackSessionRowsPure(session, sources, arrangements, drumTab, s depth, name: track.name || (source || target || {}).name || 'Track', sourceKind: source ? source.kind : '', + sourceOffset: source ? (Number(source.offset) || 0) : 0, mixKey: source ? audioTrackId(source.id) : (target && target.mixKey) || '', pairedSourceId: linked && sourceMap.has(linked) ? linked : '', }); - if (track.type !== 'folder' || !track.collapsed) visit(track.id, depth + 1); + if (includeCollapsed || track.type !== 'folder' || !track.collapsed) visit(track.id, depth + 1); } }; visit('', 0); @@ -431,14 +432,27 @@ export function _trackSessionIsDefaultPure(session, sources, arrangements, drumT } /* @pure:track-session:end */ -function _liveSources() { return _trackSessionSourcesPure(S.audioUrl, S.stems); } +// The master source rides S.masterAudioUrl — the ORIGINAL recording — not +// S.audioUrl, which active-source switching reassigns to whichever stem is +// focused. Deriving the master from S.audioUrl would rebuild it with a +// stem's URL (and drop it entirely at load, before loadAudio has run), +// which is what made the Master row and the tempo guide vanish. +// The master's display name: the pack-authored audio name (from the backend +// audio_sources / manifest) wins; otherwise the SONG name; "Master Mix" only +// as a last resort. An inline rename still overrides it on the row. +export function _liveSources() { + return _trackSessionSourcesPure(S.masterAudioUrl || S.audioUrl, S.stems, + S.masterAudioName || S.title || 'Master Mix'); +} // Load-boundary install (loadCDLC): adopt the persisted tree against the // freshly-loaded song. Never dirties the session — loading is not an edit. // `audioUrl` rides in explicitly at load time because S.audioUrl still -// points at the previous song until loadAudio runs. +// points at the previous song until loadAudio runs — and it pins +// S.masterAudioUrl so every later derivation sees a stable master. export function installTrackSession(raw, audioUrl) { - const sources = _trackSessionSourcesPure(audioUrl !== undefined ? audioUrl : S.audioUrl, S.stems); + if (audioUrl !== undefined) S.masterAudioUrl = audioUrl || null; + const sources = _trackSessionSourcesPure(S.masterAudioUrl || S.audioUrl, S.stems); // A persisted LOCKED guide whose stem id is gone must UNLOCK at load — not // silently repoint onto the first surviving source (usually the master): // normalize preserves the lock while replacing the missing id, and @@ -474,6 +488,14 @@ export function installCreatedTrackSession(raw, audioSources) { .filter(source => source && source.kind === 'stem' && typeof source.id === 'string' && source.id.startsWith('stem:')) .map(source => ({ id: source.id.slice('stem:'.length), name: source.name, url: source.url })); const master = list.find(source => source && source.kind === 'master' && source.url); + // The active-source anchor: the master's URL, held separately so it + // survives while a stem is focused as the reference. + S.masterAudioUrl = master ? master.url : (S.audioUrl || null); + S.masterAudioName = (master && master.name) || ''; + S.activeAudioSourceId = 'master'; + // The master anchors at offset 0 — clear any focused stem's placement so + // it can't shift this session's waveform and onset analysis. + S.activeAudioSourceOffset = 0; installTrackSession(raw, master ? master.url : ''); } @@ -573,6 +595,18 @@ function _rowsLive() { return _trackSessionRowsPure(S.trackSession, _liveSources(), S.arrangements, S.drumTab, S.stemLinks); } +// The mixer's strip keys in Tracks-column ROW order — so dragging a track in +// the left pane re-orders its mixer strip too. The master mix leads (it's the +// top audio row) so its mixer strip sorts first, matching the DAW console; +// folders have no strip and are skipped. Traverses the FULL tree (collapsed +// folders included) so collapsing a folder can't drop its stems' keys and +// silently reshuffle the mixer. Read via host.mixerTrackOrder. +export function trackSessionOrderedMixKeys() { + return _trackSessionRowsPure(S.trackSession, _liveSources(), S.arrangements, S.drumTab, S.stemLinks, true).rows + .filter(row => row.mixKey) + .map(row => row.mixKey); +} + export function applyTrackHeaderWidth(width, persist = false) { const value = Math.max(176, Math.min(576, Math.round(Number(width) || 320))); S.trackHeaderWidth = value; @@ -599,12 +633,20 @@ function render() { const stems = sources.filter(source => source.kind === 'stem'); const sourceOptions = selected => [''] .concat(stems.map(source => ``)).join(''); - const guide = sources.find(source => source.id === model.tempoGuideSourceId) || sources[0] || { name: 'No guide' }; + // The guide label follows the track's DISPLAY name (an inline rename + // wins over the source's default) — so a renamed recording never reverts + // to the generic "Master Mix" on the guide button. + const guideRow = rows.find(r => r.type === 'audio' && r.sourceId === model.tempoGuideSourceId) + || rows.find(r => r.type === 'audio'); + const guideName = guideRow ? guideRow.name + : ((sources.find(s => s.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). 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. + // rows get strips inline here. The MASTER mix is DELIBERATELY excluded from + // the left Tracks pane: it lives as a channel strip in the mixer drawer (its + // fader/mute/solo are real there — reference playback routes through a + // per-source gain, audio.js), and Christian wants the left pane kept to the + // tracks themselves, not the master-out or bus mixes. const mixControls = row => { const stripped = row.type === 'transcription' || (row.type === 'audio' && row.sourceKind !== 'master'); @@ -613,7 +655,7 @@ function render() { const st = _mixerPartStatePure(S.partMix, row.mixKey); return `` + `` - + ``; + + ``; }; const resizeGrip = row => ``; const trackName = (row, markup) => renamingTrackId === row.id @@ -623,7 +665,7 @@ function render() { const restore = removedSources.length ? `` : ''; - el.innerHTML = `
Tracks${restore}Guide
${rows.map(row => { + el.innerHTML = `
Tracks${restore}Guide
${rows.map(row => { const trackId = _editorEscHtml(row.id); const name = _editorEscHtml(row.name); const indent = Math.min(5, row.depth) * 14; const height = fittedHeights[row.id]; const style = `--track-indent:${indent}px;--track-row-height:${height}px`; @@ -660,6 +702,9 @@ function refreshTrackSelectionClass() { function commit(next, status) { S.trackSession = _trackSessionNormalizePure(next, _liveSources(), S.arrangements, S.drumTab); markSessionDirty(); lastRender = ''; refreshTrackSession(); + // Track order / names feed the mixer strips too — keep it in step so a + // drag-reorder (or rename) in the Tracks column moves/renames its strip. + _mixerPanelRefresh(); if (status) setStatus(status); host.draw(); } @@ -725,7 +770,12 @@ function selectTrack(trackId, openEditor = false) { S.selectedTrackId = row.id; if (row.type === 'audio') { S.focusedSourceId = row.sourceId; - setStatus(`Audio track selected: ${row.name}`); + // Focus this source as the active reference: the main waveform and + // onset tools follow it (playback still plays every source). + host.selectTrackSessionSource(row.sourceId); + setStatus(row.sourceKind === 'master' + ? `Source: ${row.name} (the master mix)` + : `Source: ${row.name} — waveform and Suggest now read this track`); } else if (row.type === 'transcription') { S.focusedSourceId = _trackFocusSourcePure(row); host.selectTrackSessionTarget(row.targetId); @@ -749,6 +799,7 @@ async function deleteTrack(trackId) { } else if (row.type === 'audio') { if (!confirm(`Remove audio track “${row.name}” from this session? The media stays in the pack and can come back.`)) return false; // Non-destructive: a tombstone in removedSourceIds, never a file op. + if (S.partMix) delete S.partMix['audio:' + row.sourceId]; // drop its stale strip state commit(_trackSessionDeletePure(S.trackSession, row.id, _liveSources(), S.arrangements, S.drumTab), `Removed audio track “${row.name}” — the media stays inside the project.`); host.partMixChanged(); @@ -869,8 +920,11 @@ export function initTrackSession() { } el.addEventListener('click', event => { const clickedRow = event.target && event.target.closest ? event.target.closest('.editor-track-row[data-track-id]') : null; - if (clickedRow && !event.target.closest('[data-track-rename-input]')) selectTrack(clickedRow.getAttribute('data-track-id') || ''); const control = event.target && event.target.closest ? event.target.closest('[data-track-action]') : null; + // Activate a source only on a DIRECT row/name click — never when the + // click lands on a strip control (M/S, fader, guide) or the rename + // input, which would also switch the main waveform as a side effect. + if (clickedRow && !control && !event.target.closest('[data-track-rename-input]')) selectTrack(clickedRow.getAttribute('data-track-id') || ''); if (!control) return; const action = control.getAttribute('data-track-action'); if (action === 'resize') return; @@ -930,14 +984,30 @@ export function initTrackSession() { : 'Tempo guide unlocked — assisted mapping analyzes the session recording.'); } else if (action === 'guide-cycle' || action === 'guide-set') { const next = _trackSessionNormalizePure(S.trackSession, _liveSources(), S.arrangements, S.drumTab); + const wantId = action === 'guide-set' + ? (control.getAttribute('data-source-id') || MASTER_ID) : null; + // A LOCKED guide is protected — changing it needs an explicit + // unlock (the 🔒 button), so a stray cycle can't drop a verified + // reference silently. + if (next.tempoGuideLocked && wantId !== next.tempoGuideSourceId) { + setStatus('Tempo guide is locked — unlock it (🔒) before choosing another.'); + return; + } const sources = _liveSources().filter(source => !next.removedSourceIds.includes(source.id)); if (!sources.length) return; + const prevGuideId = next.tempoGuideSourceId; if (action === 'guide-set') { - next.tempoGuideSourceId = control.getAttribute('data-source-id') || MASTER_ID; + next.tempoGuideSourceId = wantId; } else { const at = sources.findIndex(source => source.id === next.tempoGuideSourceId); next.tempoGuideSourceId = sources[(at + 1) % sources.length].id; } + // Choosing a DIFFERENT guide source resets it to plain audio + // analysis — the metronome (click-track) mode is opted into per + // source via the row menu, never inherited from the previous guide. + // Reselecting the SAME source (e.g. a locked guide's own guide-set) + // keeps its mode so the metronome isn't silently cleared. + if (next.tempoGuideSourceId !== prevGuideId) next.tempoGuideMode = 'audio'; const chosen = sources.find(source => source.id === next.tempoGuideSourceId); commit(next, `Tempo guide: ${chosen ? chosen.name : next.tempoGuideSourceId}.`); } diff --git a/src/waveform.js b/src/waveform.js index 44e1d080..41431f7f 100644 --- a/src/waveform.js +++ b/src/waveform.js @@ -25,7 +25,7 @@ export function drawWaveform(w) { return; } const pk = S.waveformPeaks; - const dur = S.duration || 0; + const dur = (S.audioBuffer && S.audioBuffer.duration) || S.duration || 0; if (!pk || !pk.bins || dur <= 0) { drawOnsets(); return; } const N = pk.bins; @@ -33,7 +33,7 @@ export function drawWaveform(w) { const amp = WAVEFORM_H / 2 - 4; // Audio placement shift: buffer-time B renders at timeToX(B + sh), so the // waveform slides with the recording while the grid/notes stay put. - const sh = Number(S.audioShift) || 0; + const sh = (Number(S.audioShift) || 0) + (Number(S.activeAudioSourceOffset) || 0); // Visible pixel span of the (shifted) audio, clamped to the waveform lane. const xLo = Math.max(LABEL_W, Math.floor(timeToX(sh))); const xHi = Math.min(w, Math.ceil(timeToX(dur + sh))); @@ -93,10 +93,10 @@ function _drawOnsetStrip(w) { if (!_onsetStripEnabled()) return; const onsets = _ensureOnsets(); if (!onsets || !onsets.length) return; - const dur = S.duration || 0; + const dur = (S.audioBuffer && S.audioBuffer.duration) || S.duration || 0; if (dur <= 0) return; // Onsets are buffer-time; they render shifted with the audio (timeToX(t+sh)). - const sh = Number(S.audioShift) || 0; + const sh = (Number(S.audioShift) || 0) + (Number(S.activeAudioSourceOffset) || 0); const xLo = Math.max(LABEL_W, Math.floor(timeToX(sh))); const xHi = Math.min(w, Math.ceil(timeToX(dur + sh))); // onsets are time-sorted and timeToX is monotonic, so the on-screen pixel diff --git a/tests/audition_clock.test.mjs b/tests/audition_clock.test.mjs index 346e1061..bf821642 100644 --- a/tests/audition_clock.test.mjs +++ b/tests/audition_clock.test.mjs @@ -150,11 +150,11 @@ function fakeEl() { t('_stopRefMedia cancels a deferred start — a stop/teardown can never resume audio', () => { const el = fakeEl(); const T = fakeTimers(); - const m = new Function('_el', '_ensureRefMedia', '_auditionRate', 'setTimeout', 'clearTimeout', - 'let _refMediaEl = _el;\nlet _refMediaPlayTimer = null;\n' + const m = new Function('_el', '_ensureRefMedia', '_auditionRate', '_activeRefTarget', 'setTimeout', 'clearTimeout', + 'let _refMediaEl = _el;\nlet _refMediaNode = null;\nlet _refMediaPlayTimer = null;\n' + extractFn('_stopRefMedia') + '\n' + extractFn('_startRefMediaAt') + '\nreturn { start: _startRefMediaAt, stop: _stopRefMedia };' - )(el, () => el, () => 0.5, T.setTimeout, T.clearTimeout); + )(el, () => el, () => 0.5, () => null, T.setTimeout, T.clearTimeout); // Count-in (preRoll 2s) defers the play() — ordinary usage, not an edge case. assert.strictEqual(m.start({ play: true, offset: 5, delay: 0 }, 2), true); @@ -178,7 +178,7 @@ t('_stopRefMedia cancels a deferred start — a stop/teardown can never resume a t('_ensureRefMedia memoises the ASSIGNED src — a relative url must not reload the element', () => { const el = fakeEl(); const S = { audioCtx: { createMediaElementSource: () => ({ connect() {} }) }, audioUrl: '/api/audio/x.wav' }; - const m = new Function('S', '_el', 'Audio', '_ensureRefGain', + const m = new Function('S', '_el', 'Audio', '_activeRefTarget', 'let _refMediaEl = null;\nlet _refMediaNode = null;\nlet _refMediaSrc = null;\n' + extractFn('_ensureRefMedia') + '\nreturn _ensureRefMedia;' )(S, el, function () { return el; }, () => null); @@ -208,14 +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', '_stopStemSources', '_startStemSources', + '_ensureRefGain', '_activeRefTarget', '_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, () => {}, - () => {}, () => 0); // stem scheduler stubs (no stems here) + () => {}, () => {}, () => {}, (m) => status.push(m), () => null, () => ({ connect() {} }), + () => {}, () => {}, () => 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/mixer_panel.test.mjs b/tests/mixer_panel.test.mjs index 453cf8a8..b92be5a8 100644 --- a/tests/mixer_panel.test.mjs +++ b/tests/mixer_panel.test.mjs @@ -67,7 +67,7 @@ globalThis.window = globalThis.window || globalThis; const { _mixerPartsPure, _mixerPartStatePure, _mixerAnySoloPure, _mixerPartAudiblePure, _mixerClapStatePure, _mixerOpenFromStoredPure, _mixerClapState, - _mixerGainForFaderPure, _mixerFaderLabelPure, + _mixerGainForFaderPure, _mixerFaderLabelPure, _mixerOrderedPartsPure, _mixerPanelRefresh, editorToggleMixerPanel, initMixerPanel, } = await import('../src/mixer-panel.js'); const { S } = await import('../src/state.js'); @@ -116,6 +116,23 @@ t('fader: unity detent at 0 dB, +10 dB of headroom at the ceiling', () => { assert.strictEqual(_mixerFaderLabelPure(0), '−∞ dB'); }); +t('strips reorder to match the Tracks-column row order (drag reorder follows)', () => { + const parts = [ + { key: 'audio:Guitar_L', name: 'Gtr' }, + { key: 'arr:0', name: 'Lead' }, + { key: 'arr:1', name: 'Bass' }, + { key: 'drums', name: 'Drums' }, + ]; + // Tracks column dragged into: Bass, Drums, Gtr, Lead. + const ordered = _mixerOrderedPartsPure(parts, ['arr:1', 'drums', 'audio:Guitar_L', 'arr:0']); + assert.deepStrictEqual(ordered.map(p => p.key), ['arr:1', 'drums', 'audio:Guitar_L', 'arr:0']); + // Keys absent from the order list keep their original relative order at the tail. + const partial = _mixerOrderedPartsPure(parts, ['arr:1']); + assert.deepStrictEqual(partial.map(p => p.key), ['arr:1', 'audio:Guitar_L', 'arr:0', 'drums']); + assert.deepStrictEqual(_mixerOrderedPartsPure(parts, []).map(p => p.key), + parts.map(p => p.key), 'no order → unchanged'); +}); + t('audibility: no solo → everything unmuted sounds; mute always wins', () => { assert.strictEqual(_mixerPartAudiblePure({}, 'arr:0'), true); assert.strictEqual(_mixerPartAudiblePure({ 'arr:0': { mute: true } }, 'arr:0'), false); @@ -131,6 +148,17 @@ 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. + assert.strictEqual(_mixerPartAudiblePure({ 'audio:master': { mute: true } }, 'audio:master'), false); +}); + // ── The clap state the guide scheduler consumes ────────────────────── t('clap state follows the active surface: drums in drum mode, else the current arrangement', () => { diff --git a/tests/stem_engine.test.mjs b/tests/stem_engine.test.mjs index 30b6238f..59f88be6 100644 --- a/tests/stem_engine.test.mjs +++ b/tests/stem_engine.test.mjs @@ -12,7 +12,11 @@ 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 { + _audioBufferStartPure, _stemCatchupPure, _stemCatchupAllowedPure, _pruneStaleStems, + _scheduledSourceIdsPure, syncStemAudio, audioStemWaveform, activateTrackAudioSource, + resetStemAudioCache, _liveAudioSourcesPure, _staleAudioSourceIdsPure, +} = await import('../src/audio.js'); const { S } = await import('../src/state.js'); let pass = 0, fail = 0; @@ -31,6 +35,21 @@ t('the mixer lists stem strips (audio band first), honoring removals', () => { assert.strictEqual(parts[0].kind, 'audio'); }); +t('the master mix leads the audio band as its own strip when a recording exists', () => { + const parts = _mixerPartsPure([{ name: 'Lead' }], null, + [{ id: 'gtr', name: 'Gtr' }], [], { name: 'Song Master' }); + assert.deepStrictEqual(parts.map(p => p.key), ['audio:master', 'audio:gtr', 'arr:0'], + 'master strip first, then stems, then parts'); + assert.strictEqual(parts[0].name, 'Song Master'); + assert.strictEqual(parts[0].kind, 'audio'); + // Compose mode (no recording) passes no master descriptor — no phantom strip. + const none = _mixerPartsPure([{ name: 'Lead' }], null, [{ id: 'gtr' }], []); + assert.ok(!none.some(p => p.key === 'audio:master'), 'no master strip without a recording'); + // A removed master is honored (tombstoned like any source). + const removed = _mixerPartsPure([], null, [], ['master'], { name: 'X' }); + assert.ok(!removed.some(p => p.key === 'audio:master'), 'a removed master shows no strip'); +}); + 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'); @@ -114,6 +133,195 @@ t('pruning a soloed removed stem signals a live-gain re-apply', () => { 'removing an unsoloed stem needs no re-ramp'); }); +t('the scheduler plays every live source EXCEPT the active one', () => { + const sources = [{ id: 'master' }, { id: 'Guitar_L' }, { id: 'Bass_DI' }]; + // Master active (default): the scheduler plays only the stems. + assert.deepStrictEqual(_scheduledSourceIdsPure(sources, 'master'), ['Guitar_L', 'Bass_DI']); + // Focus a stem: it moves to the reference path, the master joins the + // scheduler — everything still plays, only the active one is excluded here. + assert.deepStrictEqual(_scheduledSourceIdsPure(sources, 'Guitar_L'), ['master', 'Bass_DI']); + assert.deepStrictEqual(_scheduledSourceIdsPure([], 'master'), []); + assert.deepStrictEqual(_scheduledSourceIdsPure(sources, 'nope'), ['master', 'Guitar_L', 'Bass_DI'], + 'an unknown active id excludes nothing'); +}); + +t('the live roster honors master and stem tombstones', () => { + const stems = [{ id: 'gtr', url: '/gtr.ogg' }, { id: 'bass', url: '/bass.ogg' }]; + assert.deepStrictEqual(_liveAudioSourcesPure('/master.ogg', stems, ['master', 'bass']) + .map(source => source.id), ['gtr']); + assert.deepStrictEqual(_staleAudioSourceIdsPure(['master', 'gtr', 'bass'], new Set(['gtr'])), + ['master', 'bass'], 'removed source caches/gains are identified for teardown'); +}); + +t('removing the active source repairs the reference to a live fallback', async () => { + const saved = { + audioCtx: S.audioCtx, stems: S.stems, playing: S.playing, audioBuffer: S.audioBuffer, + audioUrl: S.audioUrl, masterAudioUrl: S.masterAudioUrl, trackSession: S.trackSession, + activeAudioSourceId: S.activeAudioSourceId, activeAudioSourceOffset: S.activeAudioSourceOffset, + duration: S.duration, masterAudioDuration: S.masterAudioDuration, + }; + const savedFetch = globalThis.fetch; + const samples = new Float32Array(128); + const oldStem = { sampleRate: 44100, duration: 1, getChannelData: () => samples }; + const master = { sampleRate: 44100, duration: 1, getChannelData: () => samples }; + try { + resetStemAudioCache(); + Object.assign(S, { playing: false, audioBuffer: oldStem, audioUrl: '/stem.ogg', + masterAudioUrl: '/master.ogg', activeAudioSourceId: 'stem', activeAudioSourceOffset: 0, + stems: [{ id: 'stem', url: '/stem.ogg' }], + trackSession: { removedSourceIds: ['stem'] }, + audioCtx: { decodeAudioData: async () => master, currentTime: 0 } }); + globalThis.fetch = async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }); + await syncStemAudio(); + assert.strictEqual(S.activeAudioSourceId, 'master'); + assert.strictEqual(S.audioBuffer, master, 'the removed source buffer no longer owns playback/waveform'); + } finally { + resetStemAudioCache(); + Object.assign(S, saved); + if (savedFetch === undefined) delete globalThis.fetch; else globalThis.fetch = savedFetch; + } +}); + +t('a failed fallback decode clears the removed source instead of leaving it active', async () => { + const saved = { + audioCtx: S.audioCtx, stems: S.stems, playing: S.playing, audioBuffer: S.audioBuffer, + audioUrl: S.audioUrl, masterAudioUrl: S.masterAudioUrl, trackSession: S.trackSession, + activeAudioSourceId: S.activeAudioSourceId, activeAudioSourceOffset: S.activeAudioSourceOffset, + waveformPeaks: S.waveformPeaks, + }; + const savedFetch = globalThis.fetch; + const samples = new Float32Array(128); + const oldStem = { sampleRate: 44100, duration: 1, getChannelData: () => samples }; + try { + resetStemAudioCache(); + Object.assign(S, { playing: false, audioBuffer: oldStem, audioUrl: '/stem.ogg', + masterAudioUrl: '/master.ogg', activeAudioSourceId: 'stem', activeAudioSourceOffset: 0, + stems: [{ id: 'stem', url: '/stem.ogg' }], + trackSession: { removedSourceIds: ['stem'] }, + audioCtx: { decodeAudioData: async () => oldStem, currentTime: 0 } }); + // Every source fails to load: the master fallback cannot decode either. + globalThis.fetch = async () => ({ ok: false }); + await syncStemAudio(); + assert.strictEqual(S.audioBuffer, null, 'the removed source buffer no longer owns playback/waveform'); + assert.strictEqual(S.audioUrl, null, 'and its url is cleared'); + assert.strictEqual(S.activeAudioSourceId, 'master', 'the active id resets to the no-source master anchor'); + } finally { + resetStemAudioCache(); + Object.assign(S, saved); + if (savedFetch === undefined) delete globalThis.fetch; else globalThis.fetch = savedFetch; + } +}); + +t('a decoded stem yields FINITE, non-flat lane peaks (channel data, not the AudioBuffer)', async () => { + // Regression: audioStemWaveform once passed the AudioBuffer straight to the + // peak builder, which indexes it like a Float32Array — every sample read + // `undefined`, collapsing min/max to ±Infinity so the lane drew off-canvas + // (invisible stems). It must read getChannelData(0) first. + const sine = new Float32Array(44100); + for (let i = 0; i < sine.length; i++) sine[i] = Math.sin(i * 0.1) * 0.8; + const fakeBuf = { sampleRate: 44100, duration: 1, length: sine.length, getChannelData: () => sine }; + const savedCtx = S.audioCtx, savedStems = S.stems, savedPlaying = S.playing, savedFetch = globalThis.fetch, savedBuffer = S.audioBuffer; + const savedMasterUrl = S.masterAudioUrl, savedAudioUrl = S.audioUrl; + const savedActiveId = S.activeAudioSourceId, savedActiveOffset = S.activeAudioSourceOffset; + try { + S.audioBuffer = null; + S.playing = false; + S.masterAudioUrl = ''; + S.stems = [{ id: 'gtr', url: 'blob:gtr' }]; + S.audioCtx = { decodeAudioData: async () => fakeBuf, currentTime: 0 }; + globalThis.fetch = async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }); + await syncStemAudio(); + const wf = audioStemWaveform('gtr'); + assert.ok(wf && wf.peaks && wf.peaks.bins > 0, 'peaks build for a decoded stem'); + let maxHi = -Infinity; + for (const v of wf.peaks.max) if (v > maxHi) maxHi = v; + assert.ok(Number.isFinite(maxHi), 'peak extremes are finite (channel data was read, not the buffer)'); + assert.ok(maxHi > 0.1, 'a loud sine produces a visibly non-flat lane, not a zero/Infinity line'); + } finally { + resetStemAudioCache(); + S.audioCtx = savedCtx; S.stems = savedStems; S.playing = savedPlaying; + S.audioBuffer = savedBuffer; + S.masterAudioUrl = savedMasterUrl; S.audioUrl = savedAudioUrl; + S.activeAudioSourceId = savedActiveId; S.activeAudioSourceOffset = savedActiveOffset; + if (savedFetch === undefined) delete globalThis.fetch; else globalThis.fetch = savedFetch; + } +}); + +t('a slower source selection cannot overwrite a newer active source', async () => { + const saved = { + audioCtx: S.audioCtx, stems: S.stems, playing: S.playing, audioBuffer: S.audioBuffer, + audioUrl: S.audioUrl, masterAudioUrl: S.masterAudioUrl, + activeAudioSourceId: S.activeAudioSourceId, activeAudioSourceOffset: S.activeAudioSourceOffset, + }; + const savedFetch = globalThis.fetch; + const samples = new Float32Array(128); + const buffer = (name) => ({ name, sampleRate: 44100, duration: 1, + getChannelData: () => samples }); + let resolveA; let resolveB; + try { + resetStemAudioCache(); + S.playing = false; + S.audioBuffer = buffer('master'); + S.audioUrl = '/master.ogg'; + S.masterAudioUrl = '/master.ogg'; + S.activeAudioSourceId = 'master'; + S.stems = [{ id: 'a', url: '/a.ogg' }, { id: 'b', url: '/b.ogg', offset: 0.2 }]; + S.audioCtx = { decodeAudioData: async raw => raw, currentTime: 0 }; + globalThis.fetch = (url) => new Promise(resolve => { + const finish = (decoded) => resolve({ ok: true, arrayBuffer: async () => decoded }); + if (url === '/a.ogg') resolveA = finish; + else resolveB = finish; + }); + const first = activateTrackAudioSource('a'); + const second = activateTrackAudioSource('b'); + const b = buffer('b'); + resolveB(b); + assert.strictEqual(await second, true); + const a = buffer('a'); + resolveA(a); + assert.strictEqual(await first, false, 'the superseded request reports that it did not activate'); + assert.strictEqual(S.activeAudioSourceId, 'b'); + assert.strictEqual(S.audioBuffer, b, 'late A decode cannot replace B'); + assert.strictEqual(S.activeAudioSourceOffset, 0.2, 'the active reference carries its own placement'); + } finally { + resetStemAudioCache(); + Object.assign(S, saved); + if (savedFetch === undefined) delete globalThis.fetch; else globalThis.fetch = savedFetch; + } +}); + +t('reselecting the current source cancels an in-flight switch', async () => { + const saved = { + audioCtx: S.audioCtx, stems: S.stems, playing: S.playing, audioBuffer: S.audioBuffer, + audioUrl: S.audioUrl, masterAudioUrl: S.masterAudioUrl, + activeAudioSourceId: S.activeAudioSourceId, activeAudioSourceOffset: S.activeAudioSourceOffset, + }; + const savedFetch = globalThis.fetch; + const samples = new Float32Array(128); + const master = { sampleRate: 44100, duration: 1, getChannelData: () => samples }; + let resolveStem; + try { + resetStemAudioCache(); + Object.assign(S, { playing: false, audioBuffer: master, audioUrl: '/master.ogg', + masterAudioUrl: '/master.ogg', activeAudioSourceId: 'master', activeAudioSourceOffset: 0, + stems: [{ id: 'stem', url: '/stem.ogg' }], + audioCtx: { decodeAudioData: async raw => raw, currentTime: 0 } }); + globalThis.fetch = () => new Promise(resolve => { resolveStem = resolve; }); + const pending = activateTrackAudioSource('stem'); + assert.strictEqual(await activateTrackAudioSource('master'), true, + 'reselecting the current source is an intentional newest request'); + resolveStem({ ok: true, arrayBuffer: async () => ({ sampleRate: 44100, duration: 1, + getChannelData: () => samples }) }); + assert.strictEqual(await pending, false); + assert.strictEqual(S.activeAudioSourceId, 'master'); + assert.strictEqual(S.audioBuffer, master); + } finally { + resetStemAudioCache(); + Object.assign(S, saved); + if (savedFetch === undefined) delete globalThis.fetch; else globalThis.fetch = savedFetch; + } +}); + for (const [name, fn] of tests) { try { await fn(); pass++; console.log(' ok ' + name); } catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } diff --git a/tests/test_editor_stem_cache.py b/tests/test_editor_stem_cache.py new file mode 100644 index 00000000..595633a5 --- /dev/null +++ b/tests/test_editor_stem_cache.py @@ -0,0 +1,14 @@ +"""Regression coverage for editor stem-cache filename collisions.""" + +from routes import _editor_stem_cache_basename + + +def test_distinct_stem_ids_that_sanitize_alike_get_distinct_cache_names(): + # `drums/kit` and `drums:kit` are distinct sources but both sanitize to + # `drums_kit`; without the per-source index they would share one cached + # file and every URL would play the last-copied stem. + a = _editor_stem_cache_basename("song123", 0, "drums/kit") + b = _editor_stem_cache_basename("song123", 1, "drums:kit") + assert a != b + assert a == "editor_stem_song123_0_drums_kit" + assert b == "editor_stem_song123_1_drums_kit" diff --git a/tests/track_session.test.mjs b/tests/track_session.test.mjs index a5258abe..e81e07ba 100644 --- a/tests/track_session.test.mjs +++ b/tests/track_session.test.mjs @@ -44,6 +44,7 @@ const { _partMixDropArrangementPure, installCreatedTrackSession, trackSessionSavePayload, + trackSessionOrderedMixKeys, } = await import('../src/track-session.js'); const { S } = await import('../src/state.js'); @@ -211,17 +212,51 @@ t('a fully-default tree is default; ANY customization is not', () => { }); t('the create/import seam adopts audio_sources wholesale — bare ids, unconditional stems reset', () => { - Object.assign(S, { arrangements: [{ name: 'Lead' }], drumTab: null, stems: [{ id: 'Stale', url: '/old.ogg' }], audioUrl: '/old.ogg' }); - installCreatedTrackSession(null, [ - { id: 'master', name: 'Master Mix', kind: 'master', url: '/new.ogg' }, - { id: 'stem:Kick_In', name: 'Kick In', kind: 'stem', url: '/k.ogg' }, - ]); - assert.deepStrictEqual(S.stems, [{ id: 'Kick_In', name: 'Kick In', url: '/k.ogg' }], - 'bare manifest ids; the previous song\'s stems are gone'); - assert.deepStrictEqual(S.trackSession.tracks.map(track => track.id), - ['audio:master', 'audio:Kick_In', 'transcription:Lead']); - installCreatedTrackSession(null, [{ id: 'master', name: 'M', kind: 'master', url: '/solo.ogg' }]); - assert.deepStrictEqual(S.stems, [], 'a stemless import resets stems too'); + const saved = { trackSession: S.trackSession, arrangements: S.arrangements, drumTab: S.drumTab, + stems: S.stems, audioUrl: S.audioUrl, masterAudioUrl: S.masterAudioUrl, stemLinks: S.stemLinks, + activeAudioSourceId: S.activeAudioSourceId, activeAudioSourceOffset: S.activeAudioSourceOffset }; + try { + Object.assign(S, { arrangements: [{ name: 'Lead' }], drumTab: null, stems: [{ id: 'Stale', url: '/old.ogg' }], audioUrl: '/old.ogg' }); + installCreatedTrackSession(null, [ + { id: 'master', name: 'Master Mix', kind: 'master', url: '/new.ogg' }, + { id: 'stem:Kick_In', name: 'Kick In', kind: 'stem', url: '/k.ogg' }, + ]); + assert.deepStrictEqual(S.stems, [{ id: 'Kick_In', name: 'Kick In', url: '/k.ogg' }], + 'bare manifest ids; the previous song\'s stems are gone'); + assert.deepStrictEqual(S.trackSession.tracks.map(track => track.id), + ['audio:master', 'audio:Kick_In', 'transcription:Lead']); + installCreatedTrackSession(null, [{ id: 'master', name: 'M', kind: 'master', url: '/solo.ogg' }]); + assert.deepStrictEqual(S.stems, [], 'a stemless import resets stems too'); + } finally { Object.assign(S, saved); } +}); + +t('the create/import seam anchors the master at offset 0, dropping a focused stem placement', () => { + const saved = { trackSession: S.trackSession, arrangements: S.arrangements, drumTab: S.drumTab, + stems: S.stems, audioUrl: S.audioUrl, masterAudioUrl: S.masterAudioUrl, stemLinks: S.stemLinks, + activeAudioSourceId: S.activeAudioSourceId, activeAudioSourceOffset: S.activeAudioSourceOffset }; + try { + Object.assign(S, { arrangements: [{ name: 'Lead' }], drumTab: null, stems: [], audioUrl: '/old.ogg', + activeAudioSourceId: 'Kick_In', activeAudioSourceOffset: 0.42 }); + installCreatedTrackSession(null, [{ id: 'master', name: 'M', kind: 'master', url: '/new.ogg' }]); + assert.strictEqual(S.activeAudioSourceId, 'master'); + assert.strictEqual(S.activeAudioSourceOffset, 0, + 'a previous stem offset cannot carry into the fresh session and shift its analysis'); + } finally { Object.assign(S, saved); } +}); + +t('collapsing a folder does not drop its stems from the mixer order', () => { + const saved = { trackSession: S.trackSession, arrangements: S.arrangements, drumTab: S.drumTab, + stems: S.stems, audioUrl: S.audioUrl, masterAudioUrl: S.masterAudioUrl, stemLinks: S.stemLinks }; + try { + let model = _trackSessionCreateFolderPure(empty, sources, arrangements, null, 'Band'); + model = _trackSessionMovePure(model, 'audio:Guitar_L', 'folder:1', 'inside', sources, arrangements, null); + Object.assign(S, { trackSession: model, arrangements, drumTab: null, + stems: [{ id: 'Guitar_L', url: '/s1.ogg' }], audioUrl: '/a.ogg', masterAudioUrl: '/a.ogg', stemLinks: {} }); + const expandedKeys = trackSessionOrderedMixKeys(); + model.tracks.find(track => track.id === 'folder:1').collapsed = true; + assert.deepStrictEqual(trackSessionOrderedMixKeys(), expandedKeys, + 'collapsing a folder must preserve mixer ordering'); + } finally { Object.assign(S, saved); } }); t('savePayload is null for a default tree and the normalized tree otherwise', () => {