Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
219 changes: 219 additions & 0 deletions src/audio.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -831,6 +837,7 @@ export function stopPlayback() {
try { S.audioSource.stop(); } catch (_) {}
S.audioSource = null;
}
_stopStemSources();
_stopRefMedia();
S.playing = false;
updatePlayIcon();
Expand Down Expand Up @@ -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:<id>') — 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 */ }
Comment thread
byrongamatos marked this conversation as resolved.
}));
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:<id>' 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];
}
}
}
Comment thread
byrongamatos marked this conversation as resolved.
// 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) {
Expand Down Expand Up @@ -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 */ }
Expand Down
4 changes: 3 additions & 1 deletion src/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
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';
Expand Down Expand Up @@ -1605,7 +1605,7 @@
// Left in place rather than deleted, because deleting them is a separate change
// from the bug fix that made them redundant. They arrived with the same
// half-wired Create-New redesign (977ec65, #45).
function _populateCreateArrButtons() {

Check warning on line 1608 in src/create.js

View workflow job for this annotation

GitHub Actions / lint

'_populateCreateArrButtons' is defined but never used
const wrap = document.getElementById('editor-create-arr-buttons');
if (!wrap) return;
wrap.replaceChildren();
Expand Down Expand Up @@ -1806,7 +1806,7 @@
createState.lastSync = { ...createState.lastSync, ...data };
}
return data;
} catch (e) {

Check warning on line 1809 in src/create.js

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used. Allowed unused caught errors must match /^_/u
return null;
}
}
Expand Down Expand Up @@ -2326,6 +2326,8 @@
// 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 || ''));
Expand Down
7 changes: 6 additions & 1 deletion src/file-ops.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 20 additions & 5 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
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 {
Expand Down Expand Up @@ -524,9 +524,23 @@
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();
Comment thread
byrongamatos marked this conversation as resolved.
},
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).
Expand Down Expand Up @@ -563,9 +577,10 @@
// 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
Expand Down Expand Up @@ -1799,7 +1814,7 @@
// the same save path as the Save button (in-place sloppak write, not the
// heavy create-mode build).
if (S.sessionId) {
try { await saveCDLC(); } catch (e) { /* surfaced via setStatus */ }

Check warning on line 1817 in src/main.js

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used. Allowed unused caught errors must match /^_/u
}
// Capture where we are so the return trip lands on the same spot.
const returnCtx = {
Expand Down
Loading
Loading