From f61eb104c13dcf0ac3332efbca0c875d8f8617ba Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Tue, 21 Jul 2026 13:39:43 +0200 Subject: [PATCH] feat(editor): a song can hold several drum parts (#339) Includes review fix: persist the primary drum part's id so a promoted primary survives reload (drum_tab_id round-trip). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++ docs/USER-GUIDE.md | 15 +- routes.py | 282 ++++++++++++++++++++++- src/arrangement.js | 77 +++++-- src/audio.js | 56 +++-- src/create.js | 12 +- src/drum-arrangement.js | 202 ++++++++++++---- src/file-ops.js | 65 ++++-- src/main.js | 45 ++-- src/mixer-panel.js | 10 +- src/new-track.js | 25 +- src/parts-view.js | 47 ++-- src/tempo.js | 8 + src/track-session.js | 221 ++++++++++-------- tests/drum_arrangement.test.mjs | 70 ++++++ tests/drum_delete_undo.test.mjs | 72 +++++- tests/drum_primary_id_roundtrip.test.mjs | 99 ++++++++ tests/midi_playback.test.mjs | 28 ++- tests/mixer_panel.test.mjs | 32 ++- tests/new_track.test.mjs | 45 +++- tests/test_drum_parts.py | 100 ++++++++ tests/view_switcher.test.mjs | 2 +- 22 files changed, 1247 insertions(+), 280 deletions(-) create mode 100644 tests/drum_primary_id_roundtrip.test.mjs create mode 100644 tests/test_drum_parts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ec2ec959..8db27570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `editor_track_session` schema is bumped to v3 (purely additive β€” v2 trees carry no regions and need no migration). Rendering, playback, and build are unchanged and land in later steps. +- **A song can hold several drum parts.** The payoff of the drums-as-arrangement + arc below: with drums already present, **οΌ‹ Track β–Έ Drums** adds *another* drum + part (a second drummer, an aux-percussion layer), and a GP/MIDI drum import is + **added** as a new Drums track instead of replacing the one you have. Each part + is its own **πŸ₯ entry in the part dropdown** β€” pick one to open *its* grid β€” with + its own Tracks row, mixer strip, and multi-track playback channel (two parts + hitting the same drum at the same instant both sound). Deleting a part removes + just that part (undoable, exact); renaming follows the part. Saving persists + every part: the first as the song-level drum tab today's game plays (packs stay + fully backward-compatible β€” older readers simply see that one), the rest as + `type: drums` arrangement entries per feedpak-spec 1.17.0, and they all come + back on reload. Create-mode compose sessions keep the one-part rule for now + (their build path persists a single drum tab). + - **The drums track is now an ordinary mixer / Tracks channel.** Building on the drums-as-arrangement work below, the drum chart's mixer strip and Tracks mix now use the same per-arrangement channel address every other part does, instead of a diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index dc8bd6f2..2938122a 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -127,10 +127,10 @@ defaults** puts everything back. ## 3. Play and navigate - **Space** plays/stops from the playhead. -- **Follow playhead** (`Shift+L`) keeps the view with the playhead during - playback. By default the view jumps ahead a page when the playhead reaches - the edge; turn on **View β–Έ Scroll in Play** to pin the playhead and glide the - timeline under it instead (Logic's continuous-scroll manner). +- **Follow playhead** (`Shift+L`) keeps the view with the playhead during + playback. By default the view jumps ahead a page when the playhead reaches + the edge; turn on **View β–Έ Scroll in Play** to pin the playhead and glide the + timeline under it instead (Logic's continuous-scroll manner). - **Loop A/B** (`Alt+B`) compares the recording against your guide so you can hear whether the chart matches the take. - **Count-in** adds a bar of clicks before playback so you can catch the entry. @@ -376,6 +376,13 @@ piano roll, with each row labeled by its GM note number (the familiar DAW drum-roll layout). The **drum limb lint** flags hits that would need three hands β€” advisory only. +A song can hold **several drum parts** (a second drummer, an aux-percussion +layer): with drums already present, **οΌ‹ Track β–Έ Drums** adds another, and a +GP/MIDI drum import is added as a new Drums track instead of replacing. Each +part is its own πŸ₯ entry in the part dropdown β€” pick one to open *its* grid β€” +with its own Tracks row and mixer channel. All parts save with the song; the +first part is the one the game plays today. + --- ## 8. Structure β€” sections, phrases, anchors, handshapes, tones diff --git a/routes.py b/routes.py index 4a9611eb..307fa87a 100644 --- a/routes.py +++ b/routes.py @@ -211,6 +211,27 @@ def _plugin_version() -> str: _DRUM_TAB_ABSENT = object() +def _primary_drum_alias_id(raw): + """The id under which the PRIMARY drum part's song-level ``drum_tab`` + alias entry is persisted. + + The frontend sends the promoted primary's ACTUAL id (``parts[0].id``) as + ``drum_tab_id`` so that deleting the original primary and promoting a + survivor (e.g. ``drums-2``) round-trips the part's identity: the alias + entry keeps that id, so on reload the arrangement returns under the same + id its ``editor_stem_links`` / track-session tree rows are keyed by (they + would otherwise be dropped as orphans). Sanitized to the same filename-safe + charset as the extra parts; falls back to the legacy ``"drums"`` when the + field is absent/blank β€” a legacy single-drum pack (and an old client that + doesn't send the field) stays byte-identical, and old readers still find + the song-level ``drum_tab``. + + Module-level so pytest can reach it. + """ + pid = re.sub(r"[^a-z0-9_-]+", "-", str(raw or "").strip().lower()).strip("-_") + return pid or "drums" + + def _drum_arrs_to_drum_tab(drum_arrs, out_unmapped=None): """Fold drum arrangements into a drum_tab. @@ -262,6 +283,62 @@ def _take(entry): } +def _is_drum_pointer_entry(entry): + """A manifest `arrangements[]` entry that is a DRUM-PART POINTER β€” + feedpak-spec 1.17.0 "drums as arrangements": `type: drums` with a + per-arrangement `drum_tab` file and NO note `file`. Old readers (and + the core loader's own file/notation gate) skip these cleanly; the + editor manages them in the save path's drum block, so the pitched + arrangement pipeline must never pair with one. + + Module-level so pytest can reach it. + """ + return ( + isinstance(entry, dict) + and str(entry.get("type") or "").strip().lower() in ("drums", "drum") + and not (isinstance(entry.get("file"), str) and entry.get("file").strip()) + ) + + +def _sanitize_extra_drum_tab(tab): + """A compact save-side sanitation for an EXTRA drum part's tab (the + primary's inline pass in `_save_sloppak` is the heavyweight original): + drop malformed / duplicate (t, piece) hits, round timestamps to the + millisecond, and sort β€” so every persisted drum tab holds the same + invariants the frontend's binary-search and drag-snap depend on. + Schema-shape validation (`validate_drum_tab`) already ran at the + request boundary. Returns a NEW dict; never mutates the request body. + + Module-level so pytest can reach it. + """ + import math as _math + + seen: set = set() + clean_hits: list[dict] = [] + for h in (tab.get("hits") or []) if isinstance(tab.get("hits"), list) else []: + if not isinstance(h, dict): + continue + try: + t = float(h.get("t")) # type: ignore[arg-type] + p = str(h.get("p") or "") + except (TypeError, ValueError): + continue + if not _math.isfinite(t) or t < 0 or not p: + continue + t = round(t, 3) + if (t, p) in seen: + continue + seen.add((t, p)) + clean = dict(h) + clean["t"] = t + clean["p"] = p + clean_hits.append(clean) + clean_hits.sort(key=lambda h2: h2["t"]) + out = dict(tab) + out["hits"] = clean_hits + return out + + # Generic "field absent from request" sentinel used by the save endpoint # to distinguish "client didn't send this field" from "client explicitly # sent an empty list / null". The empty-list case is meaningful for @@ -4345,10 +4422,23 @@ def _load_sloppak(): sloppak_form = "dir" if filepath.is_dir() else "zip" # Build a per-arrangement id list from the manifest so we can map - # edits back to the correct JSON file on save. + # edits back to the correct JSON file on save. Mirror the core + # loader's per-entry gate (lib/sloppak: an entry with neither + # `file` nor `notation` is skipped β€” that's how the drum-part + # POINTER entries ride the manifest invisibly), or the id list + # would misalign with song.arrangements on a multi-drum pack and + # every edit after the skip would map back to the WRONG file. arrangement_ids = [] arrangement_types = [] for entry in (loaded.manifest.get("arrangements", []) or []): + if not isinstance(entry, dict): + continue + _rel_raw = entry.get("file") + _has_file = isinstance(_rel_raw, str) and bool(_rel_raw.strip()) + _not_raw = entry.get("notation") + _has_notation = isinstance(_not_raw, str) and bool(_not_raw.strip()) + if not _has_file and not _has_notation: + continue arrangement_ids.append(entry.get("id", "")) arrangement_types.append(entry.get("type", "")) @@ -4542,6 +4632,64 @@ def _safe_stem_path(stem_entry: dict) -> "Path | None": _loaded_drum_tab = getattr(loaded, "drum_tab", None) if _loaded_drum_tab is not None: result["drum_tab"] = _loaded_drum_tab + # EXTRA drum parts (feedpak-spec 1.17.0 "drums as arrangements"): + # type:"drums" manifest entries carrying per-arrangement + # `drum_tab` pointers. The core loader skips these (no `file`), + # so read their side files here. The entry that aliases the + # song-level `drum_tab:` file IS the primary β€” already loaded + # above; never load it twice. A malformed part file is skipped, + # never fatal (a reader must not crash on a bad side-file). + _primary_rel = loaded.manifest.get("drum_tab") + _primary_id = "" + _drum_parts = [] + _seen_part_rels: set = set() + for _entry in (loaded.manifest.get("arrangements", []) or []): + if not _is_drum_pointer_entry(_entry): + continue + _rel = _entry.get("drum_tab") + if not isinstance(_rel, str) or not _rel.strip(): + continue + _rel = _rel.strip() + if _rel == _primary_rel: + # The alias entry for the song-level drum_tab IS the + # primary β€” never load it as an extra. Surface its id so + # the frontend re-materializes the primary under the same + # id (a promoted, non-"drums" primary keeps its stem + # links / tree rows on reload). + if not _primary_id: + _primary_id = str(_entry.get("id") or "") + continue + if _rel in _seen_part_rels: + continue + _seen_part_rels.add(_rel) + _src = Path(loaded.source_dir).resolve() + _p_path = (_src / _rel).resolve() + try: + _p_path.relative_to(_src) # path traversal guard + except ValueError: + continue + try: + _p_tab = json.loads(_p_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + from lib.drums import validate_drum_tab as _validate_part_tab + _p_ok, _ = _validate_part_tab(_p_tab) if isinstance(_p_tab, dict) else (False, "") + if not _p_ok: + continue + _drum_parts.append({ + "id": str(_entry.get("id") or ""), + "name": str(_entry.get("name") or _p_tab.get("name") or "Drums")[:120], + "drum_tab": _p_tab, + }) + if _drum_parts: + result["drum_parts"] = _drum_parts + # The primary's persisted id (from its alias entry) so the + # frontend re-materializes it under the same id it saved β€” only + # when there IS a primary drum_tab and an explicit alias id. A + # legacy pack with no alias entry omits it β†’ the frontend keeps + # the default "drums", byte-identical. + if _loaded_drum_tab is not None and _primary_id: + result["drum_tab_id"] = _primary_id # Carry the manifest-derived arrangement id list onto each # arrangement so the frontend can round-trip it back to us. # Use a single `used_ids` set when generating fallback ids so two @@ -4787,6 +4935,45 @@ async def save_song(data: dict): status_code=400, ) + # EXTRA drum parts (a song can hold several β€” feedpak-spec 1.17.0 + # "drums as arrangements"): [{id, name, drum_tab}] persisted as + # type:"drums" manifest arrangement entries, each with its own + # per-arrangement drum_tab side file. Sentinel semantics mirror + # drum_tab: key absent β†’ preserve whatever pointer entries the + # manifest already carries; a list (possibly EMPTY β€” the removal + # wire for a deleted extra part) β†’ the authoritative set. Always + # ships beside drum_tab (the primary), never alone β€” that pairing + # keeps the alias entry's name and the extras rebuilt atomically. + drum_parts_payload = data.get("drum_parts", _DRUM_TAB_ABSENT) + if drum_parts_payload is not _DRUM_TAB_ABSENT: + if not isinstance(drum_parts_payload, list): + return JSONResponse( + {"error": "drum_parts must be a list"}, status_code=400, + ) + if session.get("format") != "sloppak": + return JSONResponse( + {"error": "drum_parts can only be saved to sloppak-format songs"}, + status_code=400, + ) + if drum_tab_payload is _DRUM_TAB_ABSENT: + return JSONResponse( + {"error": "drum_parts requires drum_tab in the same save"}, + status_code=400, + ) + from lib.drums import validate_drum_tab as _validate_part_tab + for _pi, _part in enumerate(drum_parts_payload): + if not isinstance(_part, dict) or not isinstance(_part.get("drum_tab"), dict): + return JSONResponse( + {"error": f"drum_parts[{_pi}] must be an object with a drum_tab object"}, + status_code=400, + ) + _p_ok, _p_reason = _validate_part_tab(_part["drum_tab"]) + if not _p_ok: + return JSONResponse( + {"error": f"invalid drum_parts[{_pi}].drum_tab: {_p_reason}"}, + status_code=400, + ) + # archive export (and its extended-range truncation path) has been # removed β€” sloppak preserves extended range natively, so no # string-peeling is needed on save. @@ -4853,7 +5040,15 @@ def _build_wire(arr_dict, is_first): # provided, it's the authoritative full snapshot (handles adds, # removes, reorders). Otherwise we update only the single # arrangement at arrangement_index from notes/chords/templates. - old_entries = list(manifest.get("arrangements", []) or []) + # + # Drum-part POINTER entries (type:"drums", no file β€” feedpak-spec + # 1.17.0) are managed by the drum block below: split them out so + # the pitched pipeline never pairs a wire arrangement with a + # file-less entry, and so the single-arrangement path's index + # math stays pitched-only (matching the frontend's indices). + _all_old_entries = list(manifest.get("arrangements", []) or []) + old_entries = [e for e in _all_old_entries if not _is_drum_pointer_entry(e)] + old_drum_entries = [e for e in _all_old_entries if _is_drum_pointer_entry(e)] if all_arrangements is None: if arrangement_index >= len(old_entries): @@ -5136,6 +5331,89 @@ def _build_wire(arr_dict, is_first): drum_tab_path.unlink(missing_ok=True) manifest.pop("drum_tab", None) + # Drum-part manifest entries (feedpak-spec 1.17.0 "drums as + # arrangements"). When the client shipped `drum_parts` it is the + # authoritative set: rebuild every pointer entry β€” the PRIMARY as + # an alias of the song-level drum_tab.json, each EXTRA part with + # its own drum_tab_.json side file β€” and drop orphaned side + # files. When absent, re-append the entries the manifest already + # carried (they were split out of the pitched rebuild above), so + # an old client's save can't silently strip another writer's + # drum parts. + if drum_parts_payload is not _DRUM_TAB_ABSENT: + new_drum_entries: list[dict] = [] + kept_drum_files: set = set() + used_part_ids: set = set() + if isinstance(drum_tab_payload, dict): + # The primary's alias entry β€” same file the song-level + # `drum_tab:` key names, so a reader that predates the + # spec stays with one drum and a spec reader must not + # load it twice. Its id FOLLOWS the incoming primary's id + # (the frontend's `drum_tab_id`, = parts[0].id) so a + # promoted primary round-trips its identity; legacy / + # old-client saves fall back to "drums". + _primary_id = _primary_drum_alias_id(data.get("drum_tab_id")) + used_part_ids.add(_primary_id) + new_drum_entries.append({ + "id": _primary_id, + "name": str(drum_tab_payload.get("name") or "Drums")[:120], + "type": "drums", + "drum_tab": "drum_tab.json", + }) + for _part in drum_parts_payload: + # Durable id β†’ stable side-file name. Sanitize to a safe + # filename charset; de-collide with a numeric suffix. + _pid_raw = str(_part.get("id") or "").strip().lower() + _pid = re.sub(r"[^a-z0-9_-]+", "-", _pid_raw).strip("-_") or "drums-2" + if _pid in used_part_ids: + _n = 2 + while f"{_pid}-{_n}" in used_part_ids: + _n += 1 + _pid = f"{_pid}-{_n}" + used_part_ids.add(_pid) + _p_name = str(_part.get("name") or _part["drum_tab"].get("name") or "Drums")[:120] + _p_rel = f"drum_tab_{_pid}.json" + _p_path = (source_dir / _p_rel).resolve() + try: + _p_path.relative_to(source_dir) + except ValueError: + raise RuntimeError("drum part path escapes sandbox") + _p_tab = _sanitize_extra_drum_tab(_part["drum_tab"]) + # The tab's own name field is what the part shows on + # reload β€” keep it in lockstep with the entry name. + _p_tab["name"] = _p_name + _p_path.write_text( + json.dumps(_p_tab, separators=(",", ":")), + encoding="utf-8", + ) + kept_drum_files.add(_p_path) + new_drum_entries.append({ + "id": _pid, + "name": _p_name, + "type": "drums", + "drum_tab": _p_rel, + }) + manifest["arrangements"] = ( + [e for e in (manifest.get("arrangements") or []) + if not _is_drum_pointer_entry(e)] + + new_drum_entries + ) + # Drop orphaned extra side files (a deleted part, a changed + # id). Only the drum_tab_*.json family β€” drum_tab.json (the + # primary) is owned by the block above. + for _f in source_dir.glob("drum_tab_*.json"): + if _f.resolve() not in kept_drum_files: + try: + _f.unlink() + except OSError: + pass + elif old_drum_entries: + manifest["arrangements"] = ( + [e for e in (manifest.get("arrangements") or []) + if not _is_drum_pointer_entry(e)] + + old_drum_entries + ) + # Apply edited top-level metadata (title/artist/album/year only β€” # don't let the editor overwrite stems/lyrics/cover paths). if metadata: diff --git a/src/arrangement.js b/src/arrangement.js index 5065148a..22417eee 100644 --- a/src/arrangement.js +++ b/src/arrangement.js @@ -12,7 +12,7 @@ import { _editorEscHtml, _editorPromptText, setStatus } from './ui.js'; import { flattenChords } from './chords.js'; import { KEYS_PATTERN } from './keys.js'; import { _arrTypeKind, _typeKind } from './instrument.js'; -import { clampAwayFromDrums, isDrumArrangement, pitchedArrangementCount, pitchedIndexOf, syncDrumArrangement } from './drum-arrangement.js'; +import { addDrumArrangement, clampAwayFromDrums, findDrumArrangement, isDrumArrangement, pitchedArrangementCount, pitchedIndexOf, syncDrumArrangement } from './drum-arrangement.js'; import { _recState } from './midi-record.js'; import { _maybeOfferMidiTempoMap, _showDrumImportUnmappedModal } from './import.js'; import { host } from './host.js'; @@ -301,14 +301,31 @@ export function editorShowAddDrumsModal() { document.getElementById('editor-add-drums-status').textContent = ''; const fileInput = document.getElementById('editor-add-drums-gp'); if (fileInput) fileInput.value = ''; - // Show the "will replace" notice only when a drum_tab already lives on - // the sloppak so the user knows what's about to happen. + // Notice when a drum tab already exists: in a saved sloppak session the + // import ADDS another Drums track (multiple drum parts); in a create-mode + // session (which can only persist one part) it still REPLACES. const existingEl = document.getElementById('editor-add-drums-existing'); if (existingEl) { existingEl.classList.toggle('hidden', !S.drumTab); + if (S.drumTab) { + existingEl.textContent = _canAddAnotherDrums() + ? 'This song already has drums β€” this import will be added as another Drums track.' + : 'This song already has a drum track β€” importing will replace it.'; + } } } +// Can a SECOND (third, …) drum part be added β€” i.e. does a drum import ADD +// another part (vs replace the existing tab)? True only when the primary part +// is materialized as a type:"drums" arrangement in a non-create sloppak +// session β€” the create flow's build (create_sloppak) persists a single +// drum_tab, so create mode keeps the legacy one-part replace semantics. +// (Exported for new-track.js's plan gate + the tests.) +export function _canAddAnotherDrums() { + return !!(S.drumTab && !S.createMode && S.format === 'sloppak' + && findDrumArrangement(S.arrangements)); +} + export function editorHideAddDrumsModal() { document.getElementById('editor-add-drums-modal').classList.add('hidden'); } @@ -317,23 +334,33 @@ export function editorHideAddDrumsModal() { // tab is pure client state until save (S.drumTab + S.drumTabDirty β€” the // same stash the GP/MIDI import path uses), and the create flow's // init_drums seeds the identical empty shape, so no backend call is -// needed. Refuses when a drum tab already exists: replacing goes through -// the import modal, which warns. +// needed. When drums already exist, ADDS another drum part (a song can +// hold several) β€” except in create mode, whose build persists one part. export function editorAddEmptyDrums() { if (!S.sessionId || S.format !== 'sloppak') return false; - if (S.drumTab) { - setStatus('This song already has a Drums track β€” open it with πŸ₯ Edit Drums, or import to replace it.'); + if (S.drumTab && !_canAddAnotherDrums()) { + // Create-mode (or an unmaterialized legacy tab): still one part max. + setStatus('This song already has a Drums track β€” open it with πŸ₯ Edit Drums. Save the song to add more drum parts.'); return false; } - S.drumTab = { version: 1, name: 'Drums', kit: [], hits: [] }; + const tab = { version: 1, name: 'Drums', kit: [], hits: [] }; + if (S.drumTab) { + // A second (third, …) part: append its own type:"drums" arrangement + // and make the fresh part the active grid target. + addDrumArrangement(S, tab); + } + S.drumTab = tab; S.drumTabDirty = true; S.drumSel = new Set(); - syncDrumArrangement(S); // materialize the type:"drums" arrangement + // Materialize beside a pitched part only β€” a drums-only session must not + // put a drums arrangement at index 0, where the default currentArr would + // land on it (the tab stays a legacy off-array singleton there). + if (pitchedArrangementCount(S.arrangements) > 0) syncDrumArrangement(S); markSessionDirty(); host.updateArrangementSelector(); host.updateStatus(); host.draw(); - setStatus('Added empty Drums track β€” πŸ₯ Edit Drums to add hits; save to commit.'); + setStatus(`Added empty ${tab.name} track β€” πŸ₯ Edit Drums to add hits; save to commit.`); return true; } @@ -451,28 +478,36 @@ export async function editorDoAddDrums() { return; } - // Stash on session state; the next save_song ships it as - // `drum_tab` and the backend writes drum_tab.json + manifest key. - // Normalize hits: ensure sorted by t so drum-editor hit-testing and - // dragging work correctly, and clear any stale selection so indices - // from the old tab don't point into the new hits array. - S.drumTab = data.drum_tab; - if (S.drumTab && Array.isArray(S.drumTab.hits)) { - S.drumTab.hits.sort((a, b) => (a.t || 0) - (b.t || 0)); + // Stash on session state; the next save_song ships it (primary as + // `drum_tab`, extra parts as `drum_parts`) and the backend writes the + // drum tab JSONs + manifest keys. Normalize hits: ensure sorted by t + // so drum-editor hit-testing and dragging work correctly, and clear + // any stale selection so indices from the old tab don't point into + // the new hits array. When drums already exist (saved sloppak), the + // import ADDS another drum part; create mode still replaces. + const tab = data.drum_tab; + if (tab && Array.isArray(tab.hits)) { + tab.hits.sort((a, b) => (a.t || 0) - (b.t || 0)); } + const added = _canAddAnotherDrums(); + if (added) addDrumArrangement(S, tab); // its own type:"drums" arrangement + S.drumTab = tab; // the imported part is now the grid target S.drumTabDirty = true; // user-imported β€” persist on next save S.drumSel = new Set(); - syncDrumArrangement(S); // reflect the imported tab in S.arrangements[] + // Reflect the imported tab in S.arrangements[] β€” beside a pitched + // part only (a drums-only session must not put drums at index 0). + if (pitchedArrangementCount(S.arrangements) > 0) syncDrumArrangement(S); editorHideAddDrumsModal(); const hitCount = Array.isArray(data.drum_tab.hits) ? data.drum_tab.hits.length : 0; const unmapped = Array.isArray(data.unmapped) ? data.unmapped : []; const droppedCount = unmapped.reduce((s, u) => s + Math.max(0, Number(u.count) || 0), 0); + const what = added ? `Added drum track β€œ${tab.name}”` : 'Drum tab imported'; if (droppedCount > 0) { - setStatus(`Drum tab imported (${hitCount} hits, ${droppedCount} unmapped β€” see dialog) β€” save to persist`); + setStatus(`${what} (${hitCount} hits, ${droppedCount} unmapped β€” see dialog) β€” save to persist`); } else { - setStatus(`Drum tab imported (${hitCount} hits) β€” save to persist`); + setStatus(`${what} (${hitCount} hits) β€” save to persist`); } // Refresh the toolbar drum button (text/colour) and canvas so the // user immediately sees the "⟳ Drums (N)" state without waiting for diff --git a/src/audio.js b/src/audio.js index 9db0191d..b8ffd898 100644 --- a/src/audio.js +++ b/src/audio.js @@ -31,7 +31,7 @@ import { host } from './host.js'; import { _pickOnsetsPure, _spectralFluxOnsetsPlan, _spectralFluxStep } from './onsets.js'; import { _tourNoteAction } from './tour.js'; import { _rollMidiForNote, _rollPitchCtx, _rollPitchCtxFor, midiToFreq } from './keys.js'; -import { drumArrangementIndex, isDrumArrangement } from './drum-arrangement.js'; +import { isDrumArrangement } from './drum-arrangement.js'; import { arrKind } from './instrument.js'; import { _recState } from './midi-record.js'; import { notes } from './notes.js'; @@ -1707,20 +1707,24 @@ function _guidePitchedEvents() { // the strips and the engine can never disagree about who is who. function _bandPartsPure(arrangements, drumTab) { const out = []; + let anyDrumArr = false; (arrangements || []).forEach((a, i) => { - // The drums arrangement is appended below with the drum tab as its - // payload (its own notes are empty) β€” skip the plain arr pass so it - // isn't added twice. - if (a && a.type === 'drums') return; - if (a) out.push({ key: 'arr:' + i, idx: i, name: a.name || ('Track ' + (i + 1)) }); + if (!a) return; + if (a.type === 'drums') { + // A drum PART (a song can hold several): each plays ITS OWN tab's + // kit through its own `arr:` channel β€” the SAME key its mixer + // strip uses. An empty part (no hits yet) schedules nothing. + anyDrumArr = true; + if (!(a.drumTab && Array.isArray(a.drumTab.hits) && a.drumTab.hits.length)) return; + out.push({ key: 'arr:' + i, idx: i, name: a.name || 'Drums' }); + return; + } + out.push({ key: 'arr:' + i, idx: i, name: a.name || ('Track ' + (i + 1)) }); }); - if (drumTab && Array.isArray(drumTab.hits) && drumTab.hits.length) { - // The drum part rides its arrangement's own `arr:` channel now - // (PR2b) β€” the SAME key the mixer strip uses, so the strip and the - // engine agree. `idx` points at the drums arrangement so the scheduler - // resolves it; fall back to the legacy key if it isn't materialized. - const di = drumArrangementIndex(arrangements); - out.push({ key: di >= 0 ? 'arr:' + di : 'drums', idx: di, name: 'Drums' }); + if (!anyDrumArr && drumTab && Array.isArray(drumTab.hits) && drumTab.hits.length) { + // Legacy unmaterialized tab (create-mode compose): the old singleton + // band entry, keyed by the legacy 'drums' strip key. + out.push({ key: 'drums', idx: -1, name: 'Drums' }); } return out; } @@ -2176,21 +2180,24 @@ function _bandPartPitchedEvents(idx) { }))); } -// Voice the drum-tab hits in [from, to) as the GM KIT through `target` +// Voice a drum tab's hits in [from, to) as the GM KIT through `target` // (null = the guide bus): each piece plays its one-shot (kick, snare, hats, // toms, cymbals β€” DRUM_PIECE_GM_NOTE), lazily loaded on first sight; a hit // whose sound isn't ready yet ticks instead (the never-silent rule). The -// dedupe key is piece-scoped: kick + snare on the same millisecond BOTH -// sound. Used by band mode (per-part gain target) and the drum-edit guide. -function _drumKitVoicesInWindow(from, to, target, scale) { - const hits = (S.drumTab && Array.isArray(S.drumTab.hits)) ? S.drumTab.hits : []; +// dedupe key is piece-scoped AND part-scoped (`keyPrefix`): kick + snare on +// the same millisecond BOTH sound, and TWO drum parts hitting the same piece +// on the same millisecond both sound too. Used by band mode (per-part gain +// target + each part's OWN tab) and the drum-edit guide (defaults: the +// active tab, the legacy 'drums' prefix). +function _drumKitVoicesInWindow(from, to, target, scale, tab = S.drumTab, keyPrefix = 'drums') { + const hits = (tab && Array.isArray(tab.hits)) ? tab.hits : []; if (!hits.length) return; const bus = _ensureMasterBus(); const tgt = target || (bus && bus.guideGain); if (!tgt) return; for (const h of hits) { if (!h || !Number.isFinite(h.t) || h.t < from || h.t >= to) continue; - const key = _bandFiredKeyPure('drums:' + (h.p || ''), h.t); + const key = _bandFiredKeyPure(keyPrefix + ':' + (h.p || ''), h.t); if (_bandFiredKeys.has(key)) continue; _bandFiredKeys.add(key); const note = DRUM_PIECE_GM_NOTE[h.p]; @@ -2343,12 +2350,15 @@ function _guideTick() { if (!target) continue; const arr = part.idx >= 0 ? S.arrangements[part.idx] : null; // The drum-grid arrangement (type:"drums") voices real GM percussion - // from the drum tab through this part's gain (review #282). Its own + // from ITS OWN drum tab through this part's gain (review #282) β€” + // with several drum parts, each voices its own hits, part-scoped + // dedupe so two parts hitting the same piece both sound. Its own // notes are empty, so it must be caught BEFORE the clap-notes path - // below. `part.key === 'drums'` is the defensive fallback for an - // un-materialized tab (di < 0 in the roster). + // below. `part.key === 'drums'` is the legacy fallback for an + // un-materialized tab (create-mode compose; idx -1 in the roster). if (part.key === 'drums' || (arr && isDrumArrangement(arr))) { - _drumKitVoicesInWindow(from, to, target, 1); + _drumKitVoicesInWindow(from, to, target, 1, + (arr && arr.drumTab) || S.drumTab, part.key); continue; } // A drum-ENCODED pitched part (a legacy "Drums"-named arrangement with diff --git a/src/create.js b/src/create.js index ad7e5335..bf4f9ef3 100644 --- a/src/create.js +++ b/src/create.js @@ -24,7 +24,7 @@ import { _updateTonesButtonVisibility, } from './annotation-lanes.js'; import { _handshapesAreDirty, flattenChords, reconstructChords } from './chords.js'; -import { isDrumArrangement, syncDrumArrangement } from './drum-arrangement.js'; +import { isDrumArrangement, pitchedArrangementCount, syncDrumArrangement } from './drum-arrangement.js'; import { EditHistory } from './history.js'; import { host } from './host.js'; import { isKeysMode, updatePianoRange } from './keys.js'; @@ -2416,9 +2416,13 @@ export async function editorApplyCreateResult(data) { S.drumTabDirty = !!S.drumTab; S.drumEditMode = false; S.drumSel = new Set(); - // Materialize the type:"drums" arrangement now (like loadCDLC / +Drums), so a - // freshly imported drum song has it immediately β€” not only after build+reopen. - syncDrumArrangement(S); + // Materialize the drums as a type:"drums" arrangement here too (the load + // path has done so since the drums-as-arrangements foundation) so the + // switcher's πŸ₯ option and the drums mixer strip exist in create-mode + // sessions as well β€” but ONLY beside a pitched part: a drums-only import + // must not put a drums arrangement at index 0, where the default + // currentArr would land on it (it stays a legacy off-array tab instead). + if (pitchedArrangementCount(S.arrangements) > 0) syncDrumArrangement(S); // The DAW track-session feature is stacked independently. Its host hook is // inert on this branch, but when present it receives every create-time // source immediately so stems do not appear only after a save/reopen. diff --git a/src/drum-arrangement.js b/src/drum-arrangement.js index 969e1312..f5a287cd 100644 --- a/src/drum-arrangement.js +++ b/src/drum-arrangement.js @@ -10,18 +10,25 @@ // the arrangement infrastructure can hold β€” the substrate multiple drum charts // will grow from. // -// FOUNDATION SCOPE (this PR): the drums arrangement is MATERIALIZED into -// `S.arrangements[]` and `S.drumTab` stays the live editing surface β€” the -// arrangement's `.drumTab` payload IS `S.drumTab`, the SAME object reference, -// so every existing `S.drumTab` reader/mutator and every drum undo command -// (which hold references into `S.drumTab.hits`) keep working byte-for-byte. -// The tracks/mixer/switcher still address drums through the legacy `'drums'` -// target/mix-key for now; promoting those to `arr:` and routing the drum -// grid off arrangement selection (instead of the global mode) are the -// follow-ups. Build/save still persists drums as the song-level `drum_tab` -// primary β€” the drums arrangement is DERIVED on load and never written into -// the manifest `arrangements[]` (a drums entry there would default to guitar -// and be fretted-graded as garbage until the core loader learns `type:drums`). +// N-DRUMS MODEL (this arc's third slice): a song can hold SEVERAL drum +// parts. Each `type:"drums"` arrangement OWNS its `.drumTab` payload; the +// single `S.drumTab` is now a POINTER to the ACTIVE part's tab β€” the one the +// drum grid edits β€” so every existing `S.drumTab` reader/mutator and every +// drum undo command (which hold references into a tab's `hits`) keep working +// unchanged: they always operate on "the drum tab being edited". +// +// - PRIMARY part = the FIRST drums arrangement in list order. Its tab +// persists as the song-level `drum_tab` manifest key (the back-compat +// alias current cores play); the EXTRA parts persist as `type:"drums"` +// manifest arrangement entries with per-arrangement `drum_tab` pointers +// (feedpak-spec 1.17.0) β€” file-less entries an old reader skips cleanly. +// - ACTIVE part = the drums arrangement whose `.drumTab === S.drumTab`. +// Selecting a πŸ₯ option re-points `S.drumTab`; `S.currentArr` still +// NEVER moves onto a drums arrangement (the #337 invariant). +// +// Create-mode compose sessions keep the legacy single off-array tab until +// the primary is materialized (see syncDrumArrangement's callers) β€” the +// second-part verbs require a saved sloppak session. // // Leaf module (imports only the instrument-identity leaf), so state/load/ // track-session can call it without closing a cycle. @@ -44,25 +51,42 @@ export function isDrumArrangement(arr) { return _arrTypeKind(arr) === DRUMS_ARR_TYPE; } -// The (first) drums arrangement in the list, or null. Single drum still today; -// this is the seam the multiple-drum-charts work extends. +// The PRIMARY (first) drums arrangement in the list, or null β€” the part whose +// tab persists as the song-level `drum_tab` back-compat alias. export function findDrumArrangement(arrangements) { return (Array.isArray(arrangements) ? arrangements : []).find(isDrumArrangement) || null; } -// The index of the drums arrangement in the list, or -1. The arrangement switcher -// uses it to DISPLAY the drums option as selected while drum-edit mode is on β€” -// even though S.currentArr itself stays on a pitched arrangement. +// Every drums arrangement, in list order (primary first). +export function drumArrangements(arrangements) { + return (Array.isArray(arrangements) ? arrangements : []).filter(isDrumArrangement); +} + +// The index of the PRIMARY drums arrangement in the list, or -1. export function drumArrangementIndex(arrangements) { return (Array.isArray(arrangements) ? arrangements : []).findIndex(isDrumArrangement); } -// Which arrangement index the switcher should DISPLAY as selected: the drums -// arrangement while drum-edit mode is on (its view is the drum grid), else the -// current pitched arrangement. currentArr itself never moves onto drums. -export function switcherShownIndex(arrangements, currentArr, drumEditMode) { +// The index of the ACTIVE drums arrangement β€” the part whose tab IS the one +// the drum grid edits (identity match on the payload), or -1. This is what +// the switcher displays and what the mixer's drum-mode clap gate keys on. +export function activeDrumArrangementIndex(arrangements, drumTab) { + if (!drumTab || typeof drumTab !== 'object') return -1; + return (Array.isArray(arrangements) ? arrangements : []) + .findIndex(a => isDrumArrangement(a) && a.drumTab === drumTab); +} + +// Which arrangement index the switcher should DISPLAY as selected: the ACTIVE +// drums arrangement while drum-edit mode is on (its view is the drum grid), +// else the current pitched arrangement. currentArr never moves onto drums. +// Falls back to the primary when the active tab isn't materialized (legacy +// create-mode), and to currentArr when there are no drums at all. +export function switcherShownIndex(arrangements, currentArr, drumEditMode, drumTab) { + if (!drumEditMode) return currentArr; + const ai = activeDrumArrangementIndex(arrangements, drumTab); + if (ai >= 0) return ai; const di = drumArrangementIndex(arrangements); - return (drumEditMode && di >= 0) ? di : currentArr; + return di >= 0 ? di : currentArr; } // The number of PITCHED (non-drums) arrangements β€” what "how many arrangements @@ -93,40 +117,118 @@ export function pitchedIndexOf(arrangements, idx) { .slice(0, Math.max(0, Number(idx) || 0)).filter(a => !isDrumArrangement(a)).length; } -// Reconcile `S.arrangements[]` with `S.drumTab`: materialize / update / remove -// the single drums arrangement so its `.drumTab` payload IS `S.drumTab` (same -// object reference β€” the live editing surface). Idempotent, so it is safe to -// call after EVERY `S.drumTab` (re)assignment (load, GP/MIDI import, empty-add, -// delete + its undo-restore). Appended at the END, so existing arrangement -// indices β€” and therefore every `arr:` mix key β€” are preserved. Returns -// the drums arrangement, or null when there are no drums. -export function syncDrumArrangement(S) { +const _tabName = (tab) => String((tab && tab.name) || 'Drums').slice(0, 120); + +// A drums arrangement shell around a tab payload (SAME object reference β€” +// the drum grid edits it in place). Drums carry no fretted/pitched content; +// empty arrays keep every arrangement iterator (band audio, draw guards) safe. +function _drumArrShell(id, tab, name) { + return { + id, + name: String(name || _tabName(tab)).slice(0, 120), + type: DRUMS_ARR_TYPE, + drumTab: tab, + notes: [], + chords: [], + }; +} + +// The lowest unused drums-arrangement id: 'drums' for the primary, then +// 'drums-2', 'drums-3', … β€” durable (it is the target/pairing key AND the +// persisted manifest entry id), so it never renumbers after creation. +function _nextDrumArrId(arrangements) { + const used = new Set((Array.isArray(arrangements) ? arrangements : []) + .map(a => (a && a.id !== undefined && a.id !== null) ? String(a.id) : '')); + if (!used.has(DRUMS_ARR_ID)) return DRUMS_ARR_ID; + for (let n = 2; ; n++) { + const id = DRUMS_ARR_ID + '-' + n; + if (!used.has(id)) return id; + } +} + +// Reconcile `S.arrangements[]` with `S.drumTab` for the SINGLE-part flows +// (load-time primary materialization, first empty-add, first import). +// Idempotent. Appended at the END, so existing arrangement indices β€” and +// therefore every `arr:` mix key β€” are preserved. Multi-part editing +// never routes through here: adding extra parts is `addDrumArrangement`, +// deleting a specific part is `DeleteDrumTabCmd` (which splices the +// arrangement itself), so with several parts this is a careful no-op that +// leaves the non-active parts alone. Returns the arrangement holding +// `S.drumTab`, or null when there are no drums. +export function syncDrumArrangement(S, primaryId) { if (!S || !Array.isArray(S.arrangements)) return null; - const existing = findDrumArrangement(S.arrangements); + const all = drumArrangements(S.arrangements); const tab = S.drumTab; if (!tab || typeof tab !== 'object') { - // Drums removed β†’ drop the arrangement (leave every other entry in place). - if (existing) S.arrangements.splice(S.arrangements.indexOf(existing), 1); + // No active tab. The legacy singleton contract: clearing S.drumTab + // drops THE drums arrangement β€” but only when exactly one exists. + // With several parts a null active tab is never a "delete everything" + // instruction, so leave them in place. + if (all.length === 1) S.arrangements.splice(S.arrangements.indexOf(all[0]), 1); return null; } - if (existing) { - // Re-point to the live payload (import replaces the object) and follow - // its name; the SAME object reference keeps drum-editor undo refs valid. - existing.drumTab = tab; - existing.name = String(tab.name || 'Drums').slice(0, 120); - return existing; + // Already materialized (identity match) β†’ just follow the tab's name. + const holder = all.find(a => a.drumTab === tab); + if (holder) { + holder.name = _tabName(tab); + return holder; } - const arr = { - id: DRUMS_ARR_ID, - name: String(tab.name || 'Drums').slice(0, 120), - type: DRUMS_ARR_TYPE, - // SAME object reference as S.drumTab β€” the drum grid edits this in place. - drumTab: tab, - // Drums carry no fretted/pitched content; empty arrays keep every - // arrangement iterator (band audio, draw guards) safe. - notes: [], - chords: [], - }; + if (all.length) { + // A drums arrangement exists but none holds this tab: the legacy + // replace-payload seam (an import swapped the tab object). Re-point + // the PRIMARY β€” with one part this is exactly the old behavior; the + // multi-part import path adds a new part instead of coming here. + all[0].drumTab = tab; + all[0].name = _tabName(tab); + return all[0]; + } + // Materialize the primary under its PERSISTED id when the loader supplied + // one (feedpak-spec 1.17.0 alias entry id) and it doesn't collide β€” so a + // promoted, non-"drums" primary keeps the id its stem links / tree rows are + // keyed by. No/blank/colliding id β†’ the legacy "drums"-first default. + const wantId = (primaryId !== undefined && primaryId !== null) ? String(primaryId).trim() : ''; + const usedNow = new Set(S.arrangements.map(a => (a && a.id !== undefined && a.id !== null) ? String(a.id) : '')); + const id = (wantId && !usedNow.has(wantId)) ? wantId : _nextDrumArrId(S.arrangements); + const arr = _drumArrShell(id, tab); S.arrangements.push(arr); return arr; } + +// Add ANOTHER drum part: append a new `type:"drums"` arrangement owning +// `tab` (unique id + de-duplicated display name). Does NOT touch S.drumTab β€” +// the caller decides whether the new part becomes the active grid target. +// Returns the new arrangement. +export function addDrumArrangement(S, tab) { + if (!S || !Array.isArray(S.arrangements) || !tab || typeof tab !== 'object') return null; + const names = new Set(S.arrangements.map(a => a && a.name)); + let name = _tabName(tab); + if (names.has(name)) { + let n = 2; + while (names.has(`${name} ${n}`)) n++; + name = `${name} ${n}`; + } + tab.name = name; // the tab's own name field is what persists in its JSON + const arr = _drumArrShell(_nextDrumArrId(S.arrangements), tab, name); + S.arrangements.push(arr); + return arr; +} + +// Load-time adoption of the EXTRA drum parts read back from the manifest's +// `type:"drums"` arrangement entries (the wire's `drum_parts`, primary +// excluded β€” that one came in as the song-level `drum_tab` and was +// materialized by syncDrumArrangement). Appends in wire order; keeps each +// part's persisted id when it doesn't collide. +export function adoptDrumParts(S, parts) { + if (!S || !Array.isArray(S.arrangements) || !Array.isArray(parts)) return; + for (const part of parts) { + const tab = part && part.drum_tab; + if (!tab || typeof tab !== 'object' || !Array.isArray(tab.hits)) continue; + tab.hits.sort((a, b) => (a.t || 0) - (b.t || 0)); + const wantedId = (part.id !== undefined && part.id !== null) ? String(part.id).trim() : ''; + const used = new Set(S.arrangements.map(a => (a && a.id !== undefined && a.id !== null) ? String(a.id) : '')); + const id = (wantedId && !used.has(wantedId)) ? wantedId : _nextDrumArrId(S.arrangements); + const name = String(part.name || tab.name || 'Drums').slice(0, 120); + tab.name = name; + S.arrangements.push(_drumArrShell(id, tab, name)); + } +} diff --git a/src/file-ops.js b/src/file-ops.js index c2beecd4..f4576231 100644 --- a/src/file-ops.js +++ b/src/file-ops.js @@ -8,7 +8,7 @@ import { _abDisarm, _guideAnalysisReset, _resetAuditionForNewSong, loadAudio, re import { _handshapesAreDirty, _normalizeHandshape, flattenChords, reconstructChords } from './chords.js'; import { _normalizeTuningToLanes } from './commands.js'; import { editorBuild } from './create.js'; -import { isDrumArrangement, syncDrumArrangement } from './drum-arrangement.js'; +import { adoptDrumParts, findDrumArrangement, isDrumArrangement, syncDrumArrangement } from './drum-arrangement.js'; import { EditHistory } from './history.js'; import { isKeysMode, rollResetLaneH, updatePianoRange } from './keys.js'; import { _seedExtendedStringsFromTuning, _stringCountFor } from './lanes.js'; @@ -222,7 +222,22 @@ export async function loadCDLC(filename, options = {}) { // Migrate the song-level drum tab into a `type:"drums"` arrangement // (S.arrangements comes from data.arrangements above; drums are appended // last so no arr: shifts). The arrangement's payload IS S.drumTab. - syncDrumArrangement(S); + // Pass the persisted primary id (the alias entry id) so a promoted, + // non-"drums" primary re-materializes under the same id its stem links + // / tree rows are keyed by; absent β†’ the legacy "drums" default. + syncDrumArrangement(S, data.drum_tab_id); + // EXTRA drum parts (a song can hold several): the backend reads them + // from the manifest's `type:"drums"` arrangement entries (per- + // arrangement `drum_tab` pointers, feedpak-spec 1.17.0) and ships + // them as `drum_parts` β€” adopt each as its own drums arrangement. + adoptDrumParts(S, data.drum_parts); + // A pack whose drums ride ONLY as pointer entries (another writer + // omitted the song-level alias): the grid still needs an active + // target β€” the first part. + if (!S.drumTab) { + const firstDrums = findDrumArrangement(S.arrangements); + if (firstDrums) S.drumTab = firstDrums.drumTab; + } // 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 @@ -715,19 +730,41 @@ export function _buildSaveBody(forceFullSnapshot) { && (arr.handshapes.length > 0 || _handshapesAreDirty(arr))) { body.handshapes = arr.handshapes; } - // Drum-tab payload β€” separate from arrangements (see sloppak-spec Β§5.3). - // S.drumTab is null while the sloppak has none; after +Drums it holds the - // parsed JSON dict. Only ship `drum_tab` when the user actually - // imported / edited / DELETED it this session (`S.drumTabDirty`) β€” a tab - // merely loaded from disk is left out so the backend's no-op path - // preserves the manifest entry unchanged instead of re-serialising the - // whole hit list on every unrelated save. A dirty null MUST ship: it is - // the explicit-removal wire (the Tracks column's drum delete) β€” the - // backend only unlinks drum_tab.json on a literal null, so omitting the - // field here hit the absentβ†’preserve path and the deleted drums - // resurrected on the next load. + // Drum payloads β€” separate from arrangements (see sloppak-spec Β§5.3). + // Only ship them when the user actually imported / edited / DELETED / + // renamed drums this session (`S.drumTabDirty`) β€” parts merely loaded + // from disk are left out so the backend's no-op path preserves the + // manifest entries unchanged instead of re-serialising every hit list + // on every unrelated save. + // + // drum_tab β€” the PRIMARY part's tab (the FIRST drums arrangement β€” + // NOT S.drumTab, which points at the ACTIVE part: the + // user may be editing a secondary when they save). A + // dirty null MUST ship: it is the explicit-removal wire β€” + // the backend only unlinks drum_tab.json on a literal + // null, so omitting the field hit the absentβ†’preserve + // path and deleted drums resurrected on the next load. + // drum_parts β€” the EXTRA parts [{id, name, drum_tab}], persisted as + // type:"drums" manifest arrangement entries with per- + // arrangement drum_tab pointers (feedpak-spec 1.17.0). + // An explicitly EMPTY list is the removal wire for a + // deleted extra part, so it always ships beside drum_tab. if (S.drumTabDirty && S.drumTab !== undefined) { - body.drum_tab = S.drumTab; + const parts = S.arrangements.filter(isDrumArrangement); + // Legacy unmaterialized tab (create-mode compose) has no drums + // arrangement β€” its S.drumTab IS the primary, as before. + body.drum_tab = parts.length ? (parts[0].drumTab ?? null) : S.drumTab; + // The primary's durable id (parts[0].id) β€” the backend persists the + // alias entry under it so a promoted primary (e.g. "drums-2" after the + // original "drums" was deleted) round-trips its identity and its stem + // links / tree rows survive reload. Blank for a legacy unmaterialized + // tab β†’ the backend defaults to "drums". + body.drum_tab_id = parts.length ? String(parts[0].id || '') : ''; + body.drum_parts = parts.slice(1).map(a => ({ + id: String(a.id || ''), + name: String(a.name || 'Drums').slice(0, 120), + drum_tab: a.drumTab, + })); } // Beat-primary: strip the runtime beat cache so the wire stays seconds-only. return _stripBeatsFromSaveBody(body); diff --git a/src/main.js b/src/main.js index 847cff74..3efa7bd9 100644 --- a/src/main.js +++ b/src/main.js @@ -590,9 +590,12 @@ setHostHooks({ // canvas lane. targetIds are chart-track keys (the stemLinks dialect); // _trackSessionTargetsPure maps them back to arrangement indices. selectTrackSessionTarget: (targetId) => { - if (targetId === 'drums') return; const target = _trackSessionTargetsPure(S.arrangements, S.drumTab).find(t => t.id === targetId); const index = target && target.mixKey.startsWith('arr:') ? Number(target.mixKey.slice(4)) : -1; + // Arming a drum part is a no-op (any of them β€” the grid opens via + // openTrackSessionTarget); currentArr never moves onto a drums arr. + if (index >= 0 && isDrumArrangement(S.arrangements[index])) return; + if (targetId === 'drums') return; // legacy unmaterialized row if (index >= 0 && index !== S.currentArr) window.editorSelectArrangement(String(index)); }, // Focus an audio source: its buffer becomes the waveform + onset source @@ -604,17 +607,24 @@ setHostHooks({ S.partsViewMode = false; S.tempoMapMode = false; S.tempoSel = -1; - // draw() checks tabViewMode first β€” drop the engraved-tab lens on any - // target switch (shared root cause with editorSwitcherSelect / the - // Edit-Drums button) or it paints the old part's tab over the new one. + // draw() checks tabViewMode first; a target switch must not leave an + // engraved view of the previous part painted over the new surface. S.tabViewMode = false; - if (targetId === 'drums' && S.drumTab && S.format === 'sloppak') { + const target = _trackSessionTargetsPure(S.arrangements, S.drumTab).find(t => t.id === targetId); + const index = target && target.mixKey.startsWith('arr:') ? Number(target.mixKey.slice(4)) : -1; + const arr = index >= 0 ? S.arrangements[index] : null; + if (arr && isDrumArrangement(arr) && arr.drumTab && S.format === 'sloppak') { + // A drum part's row opens ITS grid: the row's own tab becomes the + // active grid target (a song can hold several drum parts). + S.drumTab = arr.drumTab; + S.drumEditMode = true; + S.drumSel = new Set(); + } else if (targetId === 'drums' && S.drumTab && S.format === 'sloppak') { + // Legacy unmaterialized tab (create-mode compose). S.drumEditMode = true; S.drumSel = new Set(); } else { S.drumEditMode = false; - const target = _trackSessionTargetsPure(S.arrangements, S.drumTab).find(t => t.id === targetId); - const index = target && target.mixKey.startsWith('arr:') ? Number(target.mixKey.slice(4)) : -1; if (index >= 0) window.editorSelectArrangement(String(index)); } _refreshPartsViewButton(); @@ -1087,7 +1097,7 @@ function updateArrangementSelector() { // a move onto the drums arrangement). But while drum-edit mode is on, // DISPLAY the drums option as selected so the dropdown matches the canvas. S.currentArr = clampAwayFromDrums(S.arrangements, S.currentArr || 0); - sel.value = String(switcherShownIndex(S.arrangements, S.currentArr, S.drumEditMode)); + sel.value = String(switcherShownIndex(S.arrangements, S.currentArr, S.drumEditMode, S.drumTab)); } // "οΌ‹ Track" β€” the single New Track entry (the old + Drums / + Keys / @@ -1859,25 +1869,24 @@ window.editorSelectArrangement = (val) => { window.editorSwitcherSelect = (val) => { const idx = parseInt(val) || 0; if (isDrumArrangement(S.arrangements[idx])) { - // Drums: open the drum grid as a MODE (currentArr stays pitched). A drums - // arrangement only exists in a sloppak session that has a drum tab; guard - // anyway so a drums index can NEVER fall through to editorSelectArrangement - // (which would move currentArr onto the drums arrangement). - // Resync the even when the drums path is unavailable', () => { Object.assign(S, { - arrangements: [GTR('Lead'), DRUMS()], currentArr: 0, + arrangements: [GTR('Lead'), { ...DRUMS(), drumTab: null }], currentArr: 0, drumTab: null, format: 'sloppak', // no drum tab β†’ the guarded early return tabViewMode: false, drumEditMode: false, sel: new Set(), });