Skip to content

feat(editor): stem mixer — isolate any instrument while editing - #275

Closed
Sabissimo wants to merge 4 commits into
got-feedBack:mainfrom
Sabissimo:stem-mixer
Closed

feat(editor): stem mixer — isolate any instrument while editing#275
Sabissimo wants to merge 4 commits into
got-feedBack:mainfrom
Sabissimo:stem-mixer

Conversation

@Sabissimo

@Sabissimo Sabissimo commented Jul 14, 2026

Copy link
Copy Markdown

What

Adds a Stems section to the editor's docked Mixer panel (Shift+C): one strip per audio stem (guitar / bass / drums / vocals / …) with volume / mute / solo, so you can isolate any instrument's audio while editing — e.g. solo the guitar to hear exactly the note you're tuning.

The backend has been serving per-instrument stem URLs from /load (result["stems"], gated on ≥2 stems) with a comment saying it exists "so the editor's stem mixer can load and balance them live" — this PR is the frontend half that finally consumes it. No backend changes.

How

  • Engine (src/audio.js): new @pure:stem-mixer block (the DAW rule — mute wins, any solo isolates — folded into per-stem gains; unity detection; the source-path predicate) plus a lazy stem playback path:
    • Stems fetch + decode in parallel on the first strip gesture (generation-guarded like cancelAudioLoad), so stem-less sessions and untouched mixers pay nothing.
    • Playback is N sample-synced BufferSources (identical (when, offset) on the same context clock) → per-stem GainNode → one stem bus → _refGain, so the Recording fader, A/B guide-pass mute, first-play hearing-safety fade, count-in and loop wraps all govern the stem mix exactly as they governed the single recording.
    • Unity fallback: while no strip is muted/soloed and every volume is 100, the editor keeps playing the pack's own mixdown — bit-identical to before (a sum of stems is not the artist's mixdown).
    • Gain moves ramp live (setTargetAtTime, ~20 ms); only unity-boundary crossings restart via the existing _restartPlaybackAt idiom. Every source stop/create path now routes through _stopAllAudioSources.
    • Audition < 100% bypasses stems (the pitch-preserving slow path is a single media element; N elements can't be sample-synced) — the strips gray out and say so.
  • Panel (src/mixer-panel.js, screen.html): Stems section above Tracks in the parts-strip idiom, on its own data-stem-* attributes (a stem id like drums is also a valid part key) and its own session map S.stemMix, reset on song load like S.partMix. Loading / unavailable / slow-bypass notes come from the engine.
  • Wiring (src/host.js, src/main.js): host.stemMixChanged / stemUiState / stemUiChangedaudio.js still never imports the panel module.
  • Song switch mid-decode aborts cleanly; replace-audio and create-mode clear stems; teardownAudio drops the graph.

Tests

  • New tests/stem_mixer.test.js — gain math, mute-beats-solo, stale-solo inertness, the unity boundary, the path predicate (10 cases).
  • tests/mixer_panel.test.mjs — stem strip rendering/visibility, click/input wiring + engine pokes, memo invalidation on mix and engine-state changes (7 new cases).
  • tests/audition_clock.test.mjs — stub list extended for the new engine names.
  • npm test: 158/159 pass — the one failure is tests/song_fit.test.mjs, which also fails on a clean checkout of main (pre-existing). pytest: 254 passed. npm run lint: 0 errors (3 pre-existing warnings).

Notes for reviewers

  • With the stem path sounding, S.audioSource is intentionally null; the MIDI-record onended end-of-song fallback (guarded by if (S.audioSource)) is skipped on that path and the rAF EOF clamp still finalizes — only a backgrounded-tab punch-in while stem-mixing loses the fallback.
  • Stem mute/solo/volume is deliberately session-only (like S.partMix): a persisted solo silently isolating one instrument next session would read as a playback bug.

🤖 Generated with Claude Code

https://claude.ai/code/session_01STL2MctqAyEw5J5Myz3rPr

Summary by CodeRabbit

  • New Features
    • Added a Stems section to the Mixer for stem-capable packs, with per-stem volume, mute, and solo controls.
    • Stems are fetched and decoded lazily on first interaction, and mix behavior aligns with existing editing playback controls (recording, A/B passes, count-in, looping).
  • Bug Fixes
    • Stem mix state now resets correctly when loading or replacing songs.
    • Stems are bypassed during slow-practice audition speeds below 100%.
  • UI/Style
    • Mixer button active styling now reflects mute/solo state for both parts and stems.
  • Tests
    • Added/expanded automated coverage for stem mixer logic and panel behavior.

…nstrument while editing

The /load payload has advertised per-instrument stem URLs (result["stems"])
since the backend landed them "so the editor's stem mixer can load and
balance them live" — the frontend just never consumed them. Now it does:

- Engine (src/audio.js): a @pure:stem-mixer block (DAW rule as per-stem
  gains, unity detection, source-path predicate) plus a lazy stem path —
  stems fetch+decode in parallel on the first strip gesture (generation-
  guarded like cancelAudioLoad), then play as N sample-synced BufferSources
  through per-stem GainNodes summing into _refGain, so the Recording fader,
  A/B guide-pass mute and first-play fade govern the stem mix unchanged.
  While every strip sits at unity the shipped mixdown keeps playing,
  bit-identical to before; gain moves ramp live, only unity-boundary
  crossings restart via _restartPlaybackAt. Audition < 100% bypasses stems
  (single-media-element slow path). All source stop/create paths route
  through _stopAllAudioSources.
- Panel (src/mixer-panel.js + screen.html): a Stems section above Tracks,
  one strip per stem (volume / M / S) in the parts-strip idiom on its own
  data-stem-* attributes and its own session map S.stemMix (reset on song
  load like S.partMix); loading / unavailable / slow-bypass notes from the
  engine. Panel ↔ engine cross via host.stemMixChanged / stemUiState /
  stemUiChanged — audio.js still never imports the panel.
- Stem-less packs (< 2 stems) hide the section and behave byte-identically.

Tests: tests/stem_mixer.test.js (gain math, unity boundary, path
predicate), mixer_panel.test.mjs stem strips/wiring/memo, audition_clock
stubs extended for the new engine names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STL2MctqAyEw5J5Myz3rPr
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5e3b8440-d20d-4066-b019-ba96b7c74958

📥 Commits

Reviewing files that changed from the base of the PR and between 8955bce and e4994c9.

📒 Files selected for processing (7)
  • src/audio.js
  • src/create.js
  • src/file-ops.js
  • src/mixer-panel.js
  • src/replace-audio.js
  • src/state.js
  • tests/stem_mixer.test.mjs
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/state.js
  • src/replace-audio.js
  • src/file-ops.js
  • src/create.js
  • tests/stem_mixer.test.mjs
  • src/mixer-panel.js
  • src/audio.js

📝 Walkthrough

Walkthrough

The editor adds per-instrument stem mixer controls with lazy decoding, mute/solo/volume processing, synchronized playback, slow-audition bypass, lifecycle resets, host wiring, tests, and documentation.

Changes

Stem mixer

Layer / File(s) Summary
Stem state and song lifecycle
src/state.js, src/host.js, src/create.js, src/file-ops.js, src/replace-audio.js, src/audio.js
Adds session stem state, host callbacks, and reset behavior for loaded, created, replaced, and torn-down songs.
Stem playback engine
src/audio.js
Adds pure gain and playback predicates, lazy decoding, stem gain graphs, synchronized sources, slow-path bypass, and unified source stopping.
Mixer UI and host wiring
screen.html, src/mixer-panel.js, src/main.js, assets/v3-theme.css, CHANGELOG.md, README.md
Adds the Stems section, per-stem controls, delegated interactions, render invalidation, host hooks, status messaging, styling, and feature documentation.
Stem mixer validation
tests/audition_clock.test.mjs, tests/mixer_panel.test.mjs, tests/stem_mixer.test.mjs
Updates audition test wiring and adds stem math, parsing, rendering, interaction, and refresh coverage.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MixerPanel
  participant HostHooks
  participant AudioEngine
  participant StemSources
  MixerPanel->>HostHooks: update stem volume, mute, or solo
  HostHooks->>AudioEngine: stemMixChanged()
  AudioEngine->>StemSources: lazily decode and start synchronized stems
  StemSources->>AudioEngine: route stem gains into reference output
  AudioEngine->>MixerPanel: stemUiChanged()
Loading

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a stem mixer to isolate individual instruments while editing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

The M/S pressed-state colors (mute amber, solo sky) were keyed on
data-mix-act only, so the Stems section's buttons — which use the
disjoint data-stem-act attribute set — never lit up: stem mute/solo
worked audibly but showed no visual state. Extend both aria-pressed
rules to cover data-stem-act.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
tests/stem_mixer.test.js (1)

15-36: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use real ESM imports instead of extracting and evaluating source.

This new suite bypasses the module-loading contract and depends on @pure comments and source layout. Rename it to .test.mjs and import the exported helpers directly, consistent with the repository’s documented convention for new tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/stem_mixer.test.js` around lines 15 - 36, The test suite currently
extracts and evaluates helpers from source text instead of exercising the
module-loading contract. Rename the test file to use the .test.mjs convention,
replace the fs/path extraction and Function-based setup around extract with real
ESM imports of the exported stem helper functions, and update references to use
those imported symbols directly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/mixer-panel.js`:
- Around line 141-143: Update _stemMsBtn and the related stem-row rendering
paths to HTML-escape every interpolated attribute value, including stem IDs,
actions, pressed state, and titles, before building markup. Replace any
percentage-element querySelector that constructs a selector from p.key with a
lookup scoped to the rendered row, so IDs containing selector metacharacters are
handled safely. Apply the same changes to the additional stem-button and
percentage-rendering paths noted in the review.
- Around line 98-106: Update _mixerStemsPure so it filters invalid stems before
applying the minimum-length check. Return an empty list when fewer than two
valid entries remain, then map the filtered stems into the existing key and name
objects.

In `@tests/mixer_panel.test.mjs`:
- Around line 246-258: Update the _mixerStemsPure tests to cover inputs
containing two raw entries but only one valid stem after malformed entries are
filtered, including [{ id: 'guitar' }, null]. Assert that the result is [] to
preserve the “fewer than two valid stems is not a mixer” invariant.

---

Nitpick comments:
In `@tests/stem_mixer.test.js`:
- Around line 15-36: The test suite currently extracts and evaluates helpers
from source text instead of exercising the module-loading contract. Rename the
test file to use the .test.mjs convention, replace the fs/path extraction and
Function-based setup around extract with real ESM imports of the exported stem
helper functions, and update references to use those imported symbols directly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 621d7c14-d084-4e82-bca6-ce0fcd4d3d08

📥 Commits

Reviewing files that changed from the base of the PR and between d7c5e3c and 4941a96.

📒 Files selected for processing (15)
  • CHANGELOG.md
  • README.md
  • assets/v3-theme.css
  • screen.html
  • src/audio.js
  • src/create.js
  • src/file-ops.js
  • src/host.js
  • src/main.js
  • src/mixer-panel.js
  • src/replace-audio.js
  • src/state.js
  • tests/audition_clock.test.mjs
  • tests/mixer_panel.test.mjs
  • tests/stem_mixer.test.js

Comment thread src/mixer-panel.js
Comment thread src/mixer-panel.js
Comment thread tests/mixer_panel.test.mjs
…ring

CodeRabbit review on got-feedBack#275:
- _mixerStemsPure now filters invalid entries BEFORE the <2 gate, so a
  two-entry payload with one junk stem can't render a lone unusable strip
  (+ regression case in tests/mixer_panel.test.mjs).
- Stem ids are /load payload data — escape them in every attribute they
  reach (data-stem-row, data-stem-part), and find the volume readout by
  row-scoped [data-stem-val] lookup instead of building a selector from
  the id (metacharacters would throw; markup can't inject).
- tests/stem_mixer.test.js → .test.mjs: the five stem pures are exported
  from src/audio.js and imported for real, retiring the @pure:stem-mixer
  slice-and-eval harness per the repo's test convention.
- JSDoc on the new stem functions in src/audio.js and src/mixer-panel.js
  (docstring-coverage pre-merge check).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/audio.js (1)

1304-1332: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Use null-prototype stem-state maps.
S.stemMix is still initialized with {} in the song-reset paths, and _stemGainsPure also allocates {}. A stem id like __proto__ can hit the special accessor instead of a normal key, so stem lookups and writes should use Object.create(null).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/audio.js` around lines 1304 - 1332, Use null-prototype objects for all
stem-state maps: change the song-reset initializations of S.stemMix and the
gains accumulator created in _stemGainsPure from {} to Object.create(null).
Preserve the existing stem lookup and gain behavior while ensuring ids such as
__proto__ are treated as ordinary keys.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/audio.js`:
- Around line 1304-1332: Use null-prototype objects for all stem-state maps:
change the song-reset initializations of S.stemMix and the gains accumulator
created in _stemGainsPure from {} to Object.create(null). Preserve the existing
stem lookup and gain behavior while ensuring ids such as __proto__ are treated
as ordinary keys.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: aecd892b-de03-42f4-96f0-e4477e4a736f

📥 Commits

Reviewing files that changed from the base of the PR and between 4941a96 and 8955bce.

📒 Files selected for processing (4)
  • src/audio.js
  • src/mixer-panel.js
  • tests/mixer_panel.test.mjs
  • tests/stem_mixer.test.mjs
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/mixer_panel.test.mjs
  • src/mixer-panel.js

…oto__ are ordinary keys

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
byrongamatos pushed a commit that referenced this pull request Jul 15, 2026
…air studio tracks with chart tracks

Christian's ask: more than demucs's five — take N tracks from an actual
studio session, ingest, rename, rearrange, and transcribe/chart against
each as the user wishes.

Backend (routes.py):
- POST /import-stems (multipart, any count): filename-derived ids
  (_stem_safe_id: sanitized, deduped, 60-char cap), files into the
  sloppak session's stems/ + manifest stems list (zip-form persists on
  Save — the replace-audio rule) or, for create-mode GP/MIDI imports,
  into the session's stem_files which Build packs into the new pak
  (staging/stems/ + manifest entries in _write_sloppak_pak).
- POST /stem-op rename|reorder|delete: pure list ops
  (_stems_rename_pure refuses collisions; _stems_reorder_pure demands a
  full permutation — partial orders get no authority to drop entries;
  _stems_manifest_list preserves unknown entry fields so another tool's
  stem metadata survives our edits); renames MOVE the file and follow
  the links; deletes drop their links.
- editor_stem_links manifest extension key (chart-track key → stem id,
  the audio_shift absent/empty/garbage contract) at all three save
  paths + /load; /load's stems gate relaxed >=2 → >=1 (a lone imported
  track must reach the manager; the mixer strips keep their own gate).

Frontend (src/stem-tracks.js + modal):
- File ▸ Audio tracks…: import (multi-file), rename (click the name),
  reorder (▴▾ — the manifest is order-authoritative), delete, and a
  per-track pairing select over the chart tracks. Chart keys use the
  _partViewKeyPure id-or-name rule — never a bare index, so links
  survive part reordering.
- 'Solo my source track' (registry + Transport menu): solos the current
  track's paired stem via S.stemMix — the transcription move; audible
  once the stem-mixer strips (#275) land, honest status either way.

tests/stem_tracks.test.mjs (4, fails on main): row model + key rule,
one-stem-per-track pairing, solo verb exactness, persistence wire.
tests/test_stem_tracks.py (5): id sanitize/dedupe, rename/reorder pure
hard-edges, unknown-field preservation, the links contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q
byrongamatos added a commit that referenced this pull request Jul 15, 2026
…air studio tracks with chart tracks (#283)

* feat(editor): multitrack stem ingest — import, rename, reorder, and pair studio tracks with chart tracks

Christian's ask: more than demucs's five — take N tracks from an actual
studio session, ingest, rename, rearrange, and transcribe/chart against
each as the user wishes.

Backend (routes.py):
- POST /import-stems (multipart, any count): filename-derived ids
  (_stem_safe_id: sanitized, deduped, 60-char cap), files into the
  sloppak session's stems/ + manifest stems list (zip-form persists on
  Save — the replace-audio rule) or, for create-mode GP/MIDI imports,
  into the session's stem_files which Build packs into the new pak
  (staging/stems/ + manifest entries in _write_sloppak_pak).
- POST /stem-op rename|reorder|delete: pure list ops
  (_stems_rename_pure refuses collisions; _stems_reorder_pure demands a
  full permutation — partial orders get no authority to drop entries;
  _stems_manifest_list preserves unknown entry fields so another tool's
  stem metadata survives our edits); renames MOVE the file and follow
  the links; deletes drop their links.
- editor_stem_links manifest extension key (chart-track key → stem id,
  the audio_shift absent/empty/garbage contract) at all three save
  paths + /load; /load's stems gate relaxed >=2 → >=1 (a lone imported
  track must reach the manager; the mixer strips keep their own gate).

Frontend (src/stem-tracks.js + modal):
- File ▸ Audio tracks…: import (multi-file), rename (click the name),
  reorder (▴▾ — the manifest is order-authoritative), delete, and a
  per-track pairing select over the chart tracks. Chart keys use the
  _partViewKeyPure id-or-name rule — never a bare index, so links
  survive part reordering.
- 'Solo my source track' (registry + Transport menu): solos the current
  track's paired stem via S.stemMix — the transcription move; audible
  once the stem-mixer strips (#275) land, honest status either way.

tests/stem_tracks.test.mjs (4, fails on main): row model + key rule,
one-stem-per-track pairing, solo verb exactness, persistence wire.
tests/test_stem_tracks.py (5): id sanitize/dedupe, rename/reorder pure
hard-edges, unknown-field preservation, the links contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q

* fix(editor): atomic stem-link submission, session dirty marks, solo-source gating + isolation (review #283 items 15-18)

Item 15: every stem op (pairing sync via new op 'links', rename, reorder,
delete, import) ships the CURRENT S.stemLinks, the backend session seeds
from the manifest on load and honors the submitted snapshot, and pairings
ride the same manifest write as the op — so the authoritative response can
never resurrect stale links over an unsaved pairing. Failed POSTs
half-apply nothing.

Item 16: stem responses carry a persisted verdict (dir-form sloppak =
durable now; zip/create = Save/Build) and _adopt marks the session dirty
on non-durable changes so the lifecycle guard can't silently discard them.
Create-mode Build now ships stem_links too (the third save path).

Item 17: editorSoloMyStem is gated on a real host.stemMixChanged consumer
(stemMixerAvailable); with none wired it reports unavailable instead of
flipping state nothing reads, and the menu greys it via needs:'stemMixer'.

Item 18: solo-my-source is an exclusive isolate — enabling clears every
other stem's solo, toggle-off restores the no-solo state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q

* fix(editor): stem reorder rejected 400 when manifest carries a `full` mix

The stem manager hides the `full` combined-mix entry (both /load and
_stem_state_payload skip id=="full"), so S.stems — and the `order` the UI
sends on a reorder — never contains it. But the stem-op reorder validated
that order against the WHOLE manifest stems list via _stems_reorder_pure,
which still includes `full`, so `sorted(order) != sorted(ids)` and every
reorder on a real (stem-separated / replace-audio'd) sloppak returned 400
"order must be a full permutation".

Root cause: the permutation was checked against the full manifest list
instead of the surfaced subset. Fix: _reorder_with_hidden reorders only the
entries the manager surfaced (the ids `order` names), keeps hidden entries
(`full`, plus any payload-dropped missing-file/path-escape stems) at the
front, and still fails closed on a foreign or duplicate id. Wired into both
the sloppak and create-mode reorder branches.

Regression: test_reorder_with_hidden_keeps_full_and_reorders_managed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
@byrongamatos

Copy link
Copy Markdown
Collaborator

Superseded by the DAW track-session / mixer stack (#293 stem engine, #295 mixer console, #296 active-source switching), now merged to main. Closing per the merge plan; the stem-mixer capability it introduced is delivered by that stack. Reopen if a distinct need remains.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants