diff --git a/CHANGELOG.md b/CHANGELOG.md index 723ae71f..9e313c19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Multiple drum parts now work when creating a new song too.** The several- + drum-parts feature previously covered only songs you re-open and Save; a + *create-mode* session (New Song from a Guitar Pro / MIDI import) was capped at + one drum chart. Now, once a melodic track exists, **οΌ‹ Track β–Έ Drums** adds + another drum part in a create session just like an existing one, and Building + the song persists every part (the first as the song-level drum tab today's + game plays, the rest as `type: drums` arrangement entries per feedpak-spec + 1.17.0). A drums-only draft still holds one part until you add a melodic + track (drums are never the primary chart). Packs stay backward-compatible. + +- **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 + one-off "drums" slot. Mute / solo / volume on the drums strip behave exactly like + a pitched track's β€” including in multi-track ("play all") playback, where the drum + kit now follows its own strip β€” and the drum grid's guide claps follow that strip + too. With a single drum chart you won't see a difference (its durable track + identity is unchanged, so delete/undo, rename, and pairing all work as before); + this is the wiring that lets *several* drum charts each get their own strip. + +- **Drums are now a selectable part in the arrangement switcher.** Pick + **"πŸ₯ Drums"** from the part dropdown to open the drum editor β€” exactly like + switching to Lead, Rhythm, or Bass. The drum chart is no longer a mode tucked + off to the side; it's a first-class part listed alongside the pitched ones (and + the πŸ₯ Edit Drums button and the Tracks row now keep the dropdown in sync). + Selecting a pitched part returns you to it as before β€” under the hood the drum + grid stays a view *over* the current pitched part, so nothing else about editing + changes. This is the visible step of the drums-as-arrangement work below, and the + set-up for having *more than one* drum chart to choose between. + +- **Drums are now a first-class arrangement (groundwork for multiple drum charts).** + The single drum tab has always lived *outside* the arrangement list as a lone + off-array singleton β€” the one instrument that wasn't an ordinary track. It now + also lives *in* the arrangement list as a `type:"drums"` arrangement, migrated + in automatically when a song loads. Nothing changes for you yet: the drum grid, + the Tracks row, the mixer, and saving all behave exactly as before (a saved pack + is byte-for-byte identical β€” drums still persist as the song-level drum tab), and + the drum editor's undo history is untouched. This is the load-bearing model + change that lets a song hold *more than one* drum chart (two drummers, an aux- + percussion layer) in a following release, built on the new authored-instrument + `type` so a drums track is identified by what it *is*, never its name. + +- **A track's instrument is now DATA, not a guess from its name.** The editor used + to infer whether a part was keys / bass / guitar from its *name*, in a dozen + places with two subtly disagreeing rules β€” the reason renaming a track could flip + its lane layout, and why the rename dialog has to refuse some names. The pack + format already records an authored instrument `type` per arrangement; the editor + now **reads it** (carrying it through load and save) and lets it drive *every* + identity decision β€” the keys/bass view, string counts, the guide-instrument + voice, the fretted-only Tab preview and Guitar-Pro export guards, and the + Parts-view silhouettes β€” falling back to the old name inference only when a + track is untyped. Existing songs open exactly as before β€” but a part named + against its instrument (a guitar called "Grand Piano") finally reads as, sounds + as, and exports as what it *is*. **The Tracks list now badges each transcription + track with its instrument** (GTR / BAS / KEY / DRM / VOX) instead of a generic + "MIDI", read from that same authoritative identity. And **renaming a typed track + is now free**: its identity is data, so the name is a pure display label and the + rename dialog no longer refuses a name that merely *looks* like another + instrument (untyped/legacy tracks keep the old kind-change guard, which protects + them from silently re-laning notes). Foundation for first-class drum tracks and + vocals. + +- **A per-track instrument-type control (next to the arrangement selector).** + Now that the editor honors an authored instrument `type` above the name for the + first time, an already-saved pack whose fretted (string+fret) chart has a keys + word *mid-name* β€” "Electric Piano", "Lead Synth" β€” can open **piano-locked**: + string buttons hidden, view stuck on the roll, with previously no way to + override it. The new **Guitar / Bass / Keys** dropdown sets the track's type + directly, which wins over the name everywhere β€” so a mis-named fretted part can + be corrected back to Guitar and reopens in string view immediately. The change + is undoable (Ctrl+Z) and persists with the pack on save; switching type rebuilds + the chart in place with no note loss. + - **The piano roll stretches, compacts and scrolls.** Its lane height used to be derived and untouchable β€” the whole pitch range packed into about 350px β€” so a wide range collapsed to four pixels per semitone: passable for reading, useless 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 1f4a36fb..e31330fe 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,111 @@ 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 + + +def _create_build_drum_entries(staging, drum_tab, drum_parts): + """The `type: drums` manifest arrangement entries for a CREATE-MODE build + with multiple drum parts (feedpak 1.17.0 "drums as arrangements"), and the + side effect of writing each EXTRA part's `drum_tab_.json` into + `staging`. The PRIMARY entry aliases the song-level `drum_tab.json` (already + written by the caller) β€” it contributes id/name only; each extra part gets + its own side file. Ids are sanitized to a safe filename charset and + de-collided. Returns the entries to append AFTER the pitched arrangements. + + Twin of /save_song's inline drum-parts block; shares + `_sanitize_extra_drum_tab` so both wire the same on-disk shape. Extracted + to module scope (out of `_write_sloppak_pak`'s closure) so pytest can drive + the write directly. Pure of the core `lib` (like `_sanitize_extra_drum_tab`): + each part's tab is schema-validated at the request boundary + (`build_song_endpoint`), so here junk hits are merely sanitized away. + """ + from pathlib import Path as _Path + + staging = _Path(staging) + entries = [{ + "id": "drums", + "name": str(drum_tab.get("name") or "Drums")[:120], + "type": "drums", + "drum_tab": "drum_tab.json", + }] + used_ids = {"drums"} + for part in (drum_parts or []): + if not isinstance(part, dict) or not isinstance(part.get("drum_tab"), dict): + continue + 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_ids: + n = 2 + while f"{pid}-{n}" in used_ids: + n += 1 + pid = f"{pid}-{n}" + used_ids.add(pid) + name = str(part.get("name") or part["drum_tab"].get("name") or "Drums")[:120] + tab = _sanitize_extra_drum_tab(part["drum_tab"]) + tab["name"] = name + (staging / f"drum_tab_{pid}.json").write_text( + json.dumps(tab, separators=(",", ":")), encoding="utf-8") + entries.append({ + "id": pid, "name": name, "type": "drums", + "drum_tab": f"drum_tab_{pid}.json", + }) + return entries + + # 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 @@ -4271,10 +4397,25 @@ 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", "")) # Pick the audio for editor playback. A freshly-converted # sloppak has one `full` stem β€” use it directly. A stem-split @@ -4466,6 +4607,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 @@ -4476,6 +4675,14 @@ def _safe_stem_path(stem_entry: dict) -> "Path | None": if not aid: aid = _arrangement_id(arr_data["name"], used_ids) arr_data["id"] = aid + # Carry the manifest `type` facet (feedpak-spec Β§5.2) onto the + # arrangement so the frontend reads instrument identity as DATA, + # not from the name. Only when authored/non-empty β€” an untyped + # entry stays untyped and the frontend falls back to name + # inference, byte-identical. + atype = arrangement_types[i] if i < len(arrangement_types) else "" + if isinstance(atype, str) and atype.strip(): + arr_data["type"] = atype.strip() # Round-trip load: populate the piano-roll from notation for any # notation-only arrangement (see _populate_notation_notes). @@ -4703,6 +4910,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. @@ -4769,7 +5015,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): @@ -4860,13 +5114,22 @@ def _build_wire(arr_dict, is_first): aid = _arrangement_id(ad.get("name", "arr"), used_ids) used_ids.add(aid) wire = _build_wire(ad, i == 0) - _entry = _merge_manifest_entry(_old_by_id.get(aid), { + _rebuilt = { "id": aid, "name": ad.get("name", "arr"), "file": f"arrangements/{aid}.json", "tuning": list(ad.get("tuning", [0]*6)), "capo": int(ad.get("capo", 0)), - }) + } + # Carry an editor-authored instrument `type` into the entry + # so a type SET in the editor persists β€” and a new typed + # arrangement (e.g. a drums-as-arrangement) isn't re-inferred + # from its name by the infer-once pass below. Only a non-empty + # string; the merge preserves an existing type when absent. + _atype = ad.get("type") + if isinstance(_atype, str) and _atype.strip(): + _rebuilt["type"] = _atype.strip() + _entry = _merge_manifest_entry(_old_by_id.get(aid), _rebuilt) # Carry any GP-import notation alongside the entry (NOT on # it β€” keeping it off the manifest entry means it can never # leak into manifest.yaml); the sidecar writer consumes it. @@ -5043,6 +5306,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: @@ -8262,6 +8608,32 @@ async def build_song_endpoint(data: dict): {"error": "drum_tab must be a JSON object with a 'hits' array"}, 400, ) + # EXTRA drum parts (feedpak 1.17.0 "drums as arrangements"): the + # create-mode /build twin of /save_song's `drum_parts`. Absent β†’ an + # old frontend, pack keeps just the song-level drum_tab (byte- + # identical). A list (possibly empty β€” the new editorBuild always + # ships one) β†’ the authoritative extras, written beside the primary. + drum_parts = data.get("drum_parts") + if drum_parts is not None: + if not isinstance(drum_parts, list): + return JSONResponse({"error": "drum_parts must be a list"}, 400) + if drum_tab is None: + return JSONResponse( + {"error": "drum_parts requires drum_tab in the same build"}, 400) + from lib.drums import validate_drum_tab as _validate_part_tab + for _pi, _part in enumerate(drum_parts): + 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"}, + 400, + ) + # Schema-validate at the boundary (like /save_song), so a bad + # tab fails fast with 400 rather than being sanitized to empty + # deeper in _create_build_drum_entries. + _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}"}, 400) def _build_sloppak(): """Build a `.sloppak` for the create-mode session. @@ -8299,6 +8671,7 @@ def _build_sloppak(): meta=meta, output_path=output, drum_tab=drum_tab if isinstance(drum_tab, dict) else None, + drum_parts=drum_parts, audio_tracks=extra_audio_tracks, audio_guide_name=(build_audio_tracks[0]["name"] if build_audio_tracks else ""), @@ -8409,6 +8782,7 @@ def _write_sloppak_pak(*, audio_file: str, art_path: str, arrangements_data: list, beats: list, sections: list, meta: dict, output_path: Path, drum_tab: dict | None = None, + drum_parts: list | None = None, lyrics: list | None = None, preview_path: str = "", fail_if_exists: bool = False, @@ -8699,6 +9073,20 @@ def _write_sloppak_pak(*, audio_file: str, art_path: str, ) manifest["drum_tab"] = "drum_tab.json" + # DRUM-PART entries (feedpak 1.17.0 "drums as arrangements"): + # a create-mode session can hold several drum charts. Written + # only when the client OPTED IN by shipping `drum_parts` (a + # list, possibly empty) β€” an old frontend omits it and the pack + # stays byte-identical with just the song-level key. The write + # lives in the module-level _create_build_drum_entries so pytest + # exercises it directly (this closure isn't reachable). + if drum_parts is not None: + # Append after the pitched arrangements (drums are never + # index 0 β€” that slot is the played chart). + manifest["arrangements"] = ( + list(manifest.get("arrangements") or []) + + _create_build_drum_entries(staging, drum_tab, drum_parts)) + # Vocals seed: an empty (or authored) lyrics track. feedpak Β§7.1 # lyrics.json is a flat array of syllables β€” an empty array is a # valid, empty track the author fills in later. diff --git a/screen.html b/screen.html index 0e4db38c..c9ff139d 100644 --- a/screen.html +++ b/screen.html @@ -36,7 +36,14 @@ - + + + diff --git a/src/arrangement.js b/src/arrangement.js index 95cf47cd..2d69211c 100644 --- a/src/arrangement.js +++ b/src/arrangement.js @@ -11,6 +11,8 @@ import { S, markSessionDirty } from './state.js'; 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 { 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'; @@ -22,12 +24,16 @@ import { host } from './host.js'; // ── Part rename (EDITOR-VIEW-MODALITY / DAW-workspace 2.2b) ────────── // Unblocked by the manifest `type` stamping + merge-not-rebuild save // (#101): a rename no longer strips the entry's `type`/unknown keys, and -// sloppak sessions carry a stable `id` the view prefs key off. One hard -// limit stays, enforced honestly: the NAME still drives kind inference -// (keys β†’ piano roll + notation sidecar, /bass/i β†’ 4-lane layout, -// /^drums/i β†’ drum routing), so a rename that would CHANGE the inferred -// kind is refused β€” silently re-laning a 6-string chart as a 4-string -// bass would strand notes on invisible strings. +// sloppak sessions carry a stable `id` the view prefs key off. +// +// For a TYPED part the rename is FREE: its instrument identity is DATA (the +// authored `type`, which every identity reader now honors), so the name is a +// pure display label and can change to anything. The kind-change refusal only +// applies to UNTYPED / legacy packs, where the NAME still drives kind inference +// (keys β†’ piano roll + notation sidecar, /bass/i β†’ 4-lane layout, /^drums/i β†’ +// drum routing) β€” there, a rename that would CHANGE the inferred kind is +// refused, since silently re-laning a 6-string chart as a 4-string bass would +// strand notes on invisible strings. /* @pure:rename-arr:start */ // A part name feeds TWO independent interpreters, and a rename must not shift @@ -72,11 +78,13 @@ function _arrKindLabelPure(name) { if (/bass/i.test(n)) return 'bass'; return 'guitar'; } -// Validate a rename: trimmed non-empty, bounded, unique among the OTHER -// parts (case-insensitive β€” the save-side name discipline), and never a -// kind change under EITHER interpreter. Returns {ok, reason, name} with the -// trimmed name. -function _renameGuardPure(oldName, rawNewName, otherNames) { +// Validate a rename: trimmed non-empty, bounded, and unique among the OTHER +// parts (case-insensitive β€” the save-side name discipline). For an UNTYPED +// part it is additionally never a kind change under EITHER interpreter; a +// TYPED part (`typed` true) skips that check β€” its identity is the authored +// `type`, not the name, so a rename can't re-lane it. Returns {ok, reason, +// name} with the trimmed name. +function _renameGuardPure(oldName, rawNewName, otherNames, typed) { const name = String(rawNewName || '').trim(); if (!name) return { ok: false, reason: 'Name can’t be empty.', name }; if (name.length > 60) return { ok: false, reason: 'Name too long (max 60 characters).', name }; @@ -85,23 +93,27 @@ function _renameGuardPure(oldName, rawNewName, otherNames) { if (taken.has(name.toLowerCase())) { return { ok: false, reason: `Another track is already named β€œ${name}”.`, name }; } - const runtimeMoved = _arrKindPure(oldName) !== _arrKindPure(name); - const saveMoved = _arrSaveKindPure(oldName) !== _arrSaveKindPure(name); - if (runtimeMoved || saveMoved) { - const oldLabel = _arrKindLabelPure(oldName); - const newLabel = _arrKindLabelPure(name); - const reason = (oldLabel !== newLabel) - // A clean instrument change (e.g. guitar β†’ bass, guitar β†’ keys). - ? `That name would change the track’s instrument (${oldLabel} β†’ ${newLabel}) β€” ` - + 'lane layout and notation still key off the name. Add a new track instead.' - // Same label, but the two interpreters disagree on this exact name - // (e.g. "Piano" β†’ "Electric Piano": the editor keys off the first - // word and would drop to guitar lanes, while the save still writes - // keys). Re-laning either way strands notes, so refuse. - : 'That name is read differently by the editor and the saved file, so it would ' - + 'change the track’s layout. The editor keys off the FIRST word, the save off ' - + 'any keys word β€” pick a name both agree on.'; - return { ok: false, reason, name }; + // Identity is DATA for a typed part β€” the name is a free display label, so + // the kind-change refusal below (a NAME-inference guard) does not apply. + if (!typed) { + const runtimeMoved = _arrKindPure(oldName) !== _arrKindPure(name); + const saveMoved = _arrSaveKindPure(oldName) !== _arrSaveKindPure(name); + if (runtimeMoved || saveMoved) { + const oldLabel = _arrKindLabelPure(oldName); + const newLabel = _arrKindLabelPure(name); + const reason = (oldLabel !== newLabel) + // A clean instrument change (e.g. guitar β†’ bass, guitar β†’ keys). + ? `That name would change the track’s instrument (${oldLabel} β†’ ${newLabel}) β€” ` + + 'lane layout and notation still key off the name. Add a new track instead.' + // Same label, but the two interpreters disagree on this exact name + // (e.g. "Piano" β†’ "Electric Piano": the editor keys off the first + // word and would drop to guitar lanes, while the save still writes + // keys). Re-laning either way strands notes, so refuse. + : 'That name is read differently by the editor and the saved file, so it would ' + + 'change the track’s layout. The editor keys off the FIRST word, the save off ' + + 'any keys word β€” pick a name both agree on.'; + return { ok: false, reason, name }; + } } return { ok: true, reason: '', name }; } @@ -144,7 +156,7 @@ export async function editorRenameArrangement() { const others = S.arrangements .filter((_, i) => i !== S.currentArr) .map(a => a && a.name); - const guard = _renameGuardPure(arr.name, val, others); + const guard = _renameGuardPure(arr.name, val, others, !!_arrTypeKind(arr)); if (!guard.ok) { if (guard.reason) setStatus(guard.reason); return; @@ -155,14 +167,75 @@ export async function editorRenameArrangement() { setStatus(`Renamed to β€œ${guard.name}”`); } +// Undoable instrument-type set. Instrument identity is DATA (the feedpak-spec +// Β§5.2 `type` facet): an authored type WINS over name inference in every reader +// β€” arrKind / isKeysArr / the 4-vs-6 string baseline / view routing β€” so this is +// the escape hatch for a pack whose NAME loaded it into the wrong instrument +// (e.g. a fretted chart named "Electric Piano" opening keys-locked with no way +// to override). Rebuilds in place (Principle VI): only arr.type moves, notes and +// strings are untouched. Refreshes the selector + lane metrics on exec AND +// rollback so the view flips immediately in both directions (undo/redo call +// host.draw()/updateStatus() themselves). +class SetArrangementTypeCmd { + constructor(arrIdx, newType) { + this.arrIdx = arrIdx; + this.newType = newType; + const arr = S.arrangements[arrIdx]; + this.oldType = arr ? arr.type : undefined; // undefined = was untyped + // Metadata-only β€” writes arr.type, never a note. Opts out of the + // read-only-roll note lock so the escape hatch works even for a fretted + // part currently shown read-only in the roll (see history.js _locked). + this.metadataScope = true; + } + _set(type) { + const arr = S.arrangements[this.arrIdx]; + if (!arr) return; + if (type) arr.type = type; else delete arr.type; + host.updateArrangementSelector(); + host.resizeForLaneChange(this.arrIdx); + } + exec() { this._set(this.newType); } + rollback() { this._set(this.oldType); } +} + +// Set the active arrangement's instrument type from the toolbar selector. +// Only guitar / bass / keys are authorable here β€” drums-as-arrangement authoring +// lands in a later stacked PR. A no-op only when the type is ALREADY AUTHORED to +// this kind β€” NOT merely name-inferred: stamping an untyped part's inferred kind +// is a real, useful op (it makes identity DATA, which frees the rename guard's +// name-inference lock so the part can be renamed to a neutral display label). +export function editorSetArrangementType(value) { + if (_recState !== 'idle') { + setStatus('Cannot change the instrument type while recording. Stop the take first.'); + return; + } + const arr = S.arrangements[S.currentArr]; + if (!arr) return; + const kind = _typeKind(value); + if (kind !== 'guitar' && kind !== 'bass' && kind !== 'keys') return; + if (kind === _arrTypeKind(arr)) return; // already AUTHORED to this kind β€” nothing to do + // WRITE the canonical feedpak-spec Β§5.2 spelling: the keys family serializes + // as "piano" ("keys" is only a READ alias _typeKind folds in). The backend + // persists arr.type verbatim, so authoring "keys" would leave a non-canonical + // manifest value other consumers keyed on "piano" wouldn't recognize. + const canonType = kind === 'keys' ? 'piano' : kind; + S.history.exec(new SetArrangementTypeCmd(S.currentArr, canonType)); + host.draw(); + host.updateStatus(); + setStatus(`Instrument type set to ${kind}`); +} + export async function editorRemoveArrangement() { if (_recState !== 'idle') { setStatus('Cannot remove an arrangement while recording. Stop the take first.'); return false; } - if (S.arrangements.length <= 1) return false; + // Count PITCHED arrangements β€” the derived drums arrangement doesn't count + // (removing the last pitched part would leave a drums-only, invalid song). + if (pitchedArrangementCount(S.arrangements) <= 1) return false; const removeIdx = S.currentArr; const arr = S.arrangements[removeIdx]; + if (!arr || isDrumArrangement(arr)) return false; // never the drums arrangement if (!confirm(`Remove "${arr.name}" arrangement?`)) return false; // Remove from backend first @@ -173,7 +246,9 @@ export async function editorRemoveArrangement() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: S.sessionId, - arrangement_index: removeIdx, + // The backend's arrangements[] has no drums arrangement β€” map + // the frontend index to its pitched-only position. + arrangement_index: pitchedIndexOf(S.arrangements, removeIdx), }), }); const result = await resp.json(); @@ -196,7 +271,9 @@ export async function editorRemoveArrangement() { // reconstructChords() reset (#18): drop the stack when the model shifts // under it. if (S.history) S.history.reset(); - S.currentArr = Math.min(removeIdx, S.arrangements.length - 1); + // Clamp to a PITCHED index β€” never leave the selection on the drums + // arrangement (which the removed slot may now expose). + S.currentArr = clampAwayFromDrums(S.arrangements, removeIdx); S.sel.clear(); flattenChords(); host.updateArrangementSelector(); @@ -224,14 +301,32 @@ 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 once the primary part is +// materialized as a type:"drums" arrangement in a sloppak session β€” both the +// /save_song and create-mode /build paths persist the extras now. A part +// only materializes beside a pitched track (drums are never index 0), so a +// drums-only compose session β€” its lone off-array tab β€” can't add a second +// until a melodic track exists. (Exported for new-track.js's plan gate + tests.) +export function _canAddAnotherDrums() { + return !!(S.drumTab && S.format === 'sloppak' + && findDrumArrangement(S.arrangements)); +} + export function editorHideAddDrumsModal() { document.getElementById('editor-add-drums-modal').classList.add('hidden'); } @@ -240,22 +335,34 @@ 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 β€” create and edit sessions alike, once a melodic track exists). 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()) { + // The only remaining one-part case: a drums-only session whose tab + // isn't materialized as an arrangement (no melodic track to sit beside). + setStatus('This song already has a Drums track β€” open it with πŸ₯ Edit Drums. Add a melodic track first to hold more than one drum part.'); 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(); + // 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; } @@ -373,27 +480,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(); + // 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 4e8f028c..b8ffd898 100644 --- a/src/audio.js +++ b/src/audio.js @@ -31,6 +31,8 @@ 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 { isDrumArrangement } from './drum-arrangement.js'; +import { arrKind } from './instrument.js'; import { _recState } from './midi-record.js'; import { notes } from './notes.js'; import { S } from './state.js'; @@ -1675,7 +1677,7 @@ function _guideGmProgram() { if (S.drumEditMode || !S.arrangements.length) return null; const arr = S.arrangements[S.currentArr]; if (!arr) return null; - return editorGmVoiceFor(_gmKindPure(arr.name)); + return editorGmVoiceFor(_gmKindPure(arrKind(arr))); } // Pitched events for the current arrangement: the same charted times the @@ -1701,14 +1703,27 @@ function _guidePitchedEvents() { // identical. Drum parts clap (GM percussion is a follow-up). // // The band roster: one entry per mixable part, in strip order β€” the SAME -// keys the mixer panel uses ('arr:' / 'drums'), so the strips and the -// engine can never disagree about who is who. +// `arr:` keys the mixer panel uses (the drums arrangement included), so +// 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) => { - 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) { + 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; @@ -1961,7 +1976,8 @@ export function _stemCatchupPure(playStartTime, playStartWall, currentTime, rate // playing node, drop its gain node, and β€” the one that bites β€” delete its // 'audio:' entry from S.partMix. That entry is counted by the whole-map // solo rule, so a stale SOLO left behind by a removed stem would silence every -// live track. Mirrors the drum-delete path (delete S.partMix.drums). +// live track. Same hazard the arrangement-delete path guards against by +// renumbering the arr: keys (see _partMixDropArrangementPure). export function _pruneStaleStems(liveIds) { for (const id of [...playingStemSources.keys()]) { if (liveIds.has(id)) continue; @@ -2155,7 +2171,7 @@ function _stemGainsReset() { // per arrangement) β€” drum parts return [] here; their hits clap instead. function _bandPartPitchedEvents(idx) { const arr = S.arrangements[idx]; - if (!arr || /^drums/i.test(arr.name || '')) return []; + if (!arr || arrKind(arr) === 'drums') return []; const rctx = _rollPitchCtxFor(arr); return _gmSanitizeEventsPure((arr.notes || []).map(n => ({ t: n.time, @@ -2164,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]; @@ -2330,11 +2349,23 @@ function _guideTick() { const target = _ensurePartGain(part.key); if (!target) continue; const arr = part.idx >= 0 ? S.arrangements[part.idx] : null; - // A drum-ENCODED arrangement (created/imported/legacy "Drums" part β€” - // no pitch, so _bandPartPitchedEvents returns []) claps its rhythm - // through this part's gain, else it voices neither GM nor clap and - // goes silent (review #280 follow-up; GM percussion here is a follow-up). - if (arr && /^drums/i.test(arr.name || '')) { + // The drum-grid arrangement (type:"drums") voices real GM percussion + // 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 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, + (arr && arr.drumTab) || S.drumTab, part.key); + continue; + } + // A drum-ENCODED pitched part (a legacy "Drums"-named arrangement with + // real notes, not type:"drums") claps its rhythm through this part's + // gain, else it voices neither GM nor clap and goes silent (review + // #280 follow-up; GM percussion here is a follow-up). + if (arr && arrKind(arr) === 'drums') { const times = _guideSanitizeTimesPure((arr.notes || []).map(n => n.time)); for (const t of _guideClapTimesInWindowPure(times, from, to)) { const k = _bandFiredKeyPure(part.key, t); @@ -2344,12 +2375,7 @@ function _guideTick() { } continue; } - // The drum-grid sidecar plays real GM percussion (review #282). - if (part.key === 'drums') { - _drumKitVoicesInWindow(from, to, target, 1); - continue; - } - const gm = editorGmVoiceFor(_gmKindPure(arr && arr.name)); + const gm = editorGmVoiceFor(_gmKindPure(arrKind(arr))); const ready = gm !== null && gmPresetReady(gm); if (gm !== null && !ready) ensureGmPreset(gm, S.audioCtx); // clap while it loads const groups = _gmEventsInWindowPure(_bandPartPitchedEvents(part.idx), from, to, 4); diff --git a/src/create.js b/src/create.js index 65380063..f26192f3 100644 --- a/src/create.js +++ b/src/create.js @@ -24,9 +24,11 @@ import { _updateTonesButtonVisibility, } from './annotation-lanes.js'; import { _handshapesAreDirty, flattenChords, reconstructChords } from './chords.js'; +import { isDrumArrangement, pitchedArrangementCount, syncDrumArrangement } from './drum-arrangement.js'; import { EditHistory } from './history.js'; import { host } from './host.js'; -import { KEYS_PATTERN, isKeysMode, updatePianoRange } from './keys.js'; +import { isKeysMode, updatePianoRange } from './keys.js'; +import { arrKind } from './instrument.js'; import { _seedExtendedStringsFromTuning } from './lanes.js'; import { S, markSessionDirty, markSessionSaved } from './state.js'; import { _marksSanitizePure } from './tempo-marks.js'; @@ -799,8 +801,27 @@ function _createAudioPayloadPure(audioTracks, guideId) { })), }; } +// The drum payload the create-mode /build ships (feedpak 1.17.0 N drums). +// The PRIMARY drum_tab is the FIRST drums arrangement's payload β€” NOT the +// active `drumTab`, which tracks whichever part is open in the grid (the user +// may be editing a secondary at build time). `drum_parts` (the extras) is only +// present when drums are materialized as arrangements, so a legacy single-tab +// compose build (no pitched part β†’ no drums arrangement) stays byte-identical. +function _drumBuildPayloadPure(arrangements, drumTab) { + const drumArrs = (Array.isArray(arrangements) ? arrangements : []).filter(isDrumArrangement); + const primaryTab = drumArrs.length ? drumArrs[0].drumTab : drumTab; + const out = { drum_tab: (primaryTab && Array.isArray(primaryTab.hits)) ? primaryTab : null }; + if (drumArrs.length) { + out.drum_parts = drumArrs.slice(1).map(a => ({ + id: String(a.id || ''), + name: String(a.name || 'Drums').slice(0, 120), + drum_tab: a.drumTab, + })); + } + return out; +} /* @pure:create-track-table:end */ -export { _createAudioPayloadPure, _createGuideIdPure, _createTrackRowsPure }; +export { _createAudioPayloadPure, _createGuideIdPure, _createTrackRowsPure, _drumBuildPayloadPure }; // Community arrangement XML and MusicXML share the .xml extension. Sniff the // document root before choosing an importer; declarations, DOCTYPEs, comments, @@ -2414,6 +2435,13 @@ export async function editorApplyCreateResult(data) { S.drumTabDirty = !!S.drumTab; S.drumEditMode = false; S.drumSel = new Set(); + // 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. @@ -2422,8 +2450,7 @@ export async function editorApplyCreateResult(data) { 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 || '')); + const _importHasKeys = (S.arrangements || []).some(a => arrKind(a) === 'keys'); if (_importHasDrums || _importHasKeys) S.format = 'sloppak'; // Reset offset UI so _effectiveAudioOffset() doesn't carry over a @@ -2568,6 +2595,9 @@ export async function editorBuild() { const savedArr = S.currentArr; const allArrangements = []; for (let i = 0; i < S.arrangements.length; i++) { + // Drums ship via `drum_tab`, never as a manifest arrangement (a session + // may hold a materialized type:"drums" arrangement). + if (isDrumArrangement(S.arrangements[i])) continue; S.currentArr = i; flattenChords(); reconstructChords(); @@ -2601,6 +2631,13 @@ export async function editorBuild() { chords: arr.chords, chord_templates: arr.chord_templates, }; + // Ship an authored instrument `type` (feedpak-spec Β§5.2) into the build + // payload β€” without it the backend re-infers the type from the NAME on + // /build, so a create-mode part re-typed via the toolbar selector (e.g. a + // fretted chart named "Electric Piano" corrected to guitar) would lose the + // correction on its very first build. The save path already ships it via + // the full-arrangement spread in _buildSaveBody; the build whitelist must too. + if (arr.type) arrEntry.type = arr.type; if (buildTones) arrEntry.tones = buildTones; if (arr._gp_notation) arrEntry._gp_notation = arr._gp_notation; // PR3d: include authored anchors too β€” same dirty-gate as @@ -2656,9 +2693,12 @@ export async function editorBuild() { // sloppak. editorDoCreate sets S.format='sloppak' when the GP // import brought either; forward that as the build target so // the server writes a sloppak (not a archive that silently drops - // them), and ship the imported drum_tab so it's persisted. + // them), and ship the drum parts so they're persisted. target_format: S.format === 'sloppak' ? 'sloppak' : '', - drum_tab: (S.drumTab && Array.isArray(S.drumTab.hits)) ? S.drumTab : null, + // The drum payload (primary tab + extras) β€” see + // _drumBuildPayloadPure: the primary is the FIRST drums + // arrangement's tab, NOT the active S.drumTab. + ..._drumBuildPayloadPure(S.arrangements, S.drumTab), // Audio placement shift ("Shift Audio…") β€” persisted into the // built pack's manifest (read back on load via data.audio_shift) // so the alignment survives the first build, not just re-saves. diff --git a/src/drum-arrangement.js b/src/drum-arrangement.js new file mode 100644 index 00000000..f5a287cd --- /dev/null +++ b/src/drum-arrangement.js @@ -0,0 +1,234 @@ +// ════════════════════════════════════════════════════════════════════ +// Drums as a first-class arrangement β€” the drums-as-arrangements foundation. +// +// Historically the single drum tab (`S.drumTab`) lived ENTIRELY OUTSIDE +// `S.arrangements[]`, as a lone off-array singleton edited through a global +// mode (`S.drumEditMode`) β€” the one instrument that wasn't an ordinary +// arrangement. This leaf gives it a home IN the arrangement list as a +// `type:"drums"` entry, so drums are an ordinary typed arrangement +// (`arrKind(arr) === 'drums'`, via the #335 instrument-type-as-data seam) that +// the arrangement infrastructure can hold β€” the substrate multiple drum charts +// will grow from. +// +// 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. +// ════════════════════════════════════════════════════════════════════ + +import { _arrTypeKind } from './instrument.js'; + +export const DRUMS_ARR_TYPE = 'drums'; +// Stable synthetic id for the derived drums arrangement. Never persisted (the +// arrangement is excluded from the save body); it exists only so track-session +// rows/targets that key off an arrangement id have a value that won't collide. +const DRUMS_ARR_ID = 'drums'; + +// Is this arrangement a drums arrangement? Keyed on the authored `type` ALONE +// (normalized: "drum"/"Drums" β†’ drums), NOT on `arrKind` β€” `arrKind` would name- +// infer, wrongly catching a pitched arrangement a user literally named "Drums" +// and then hiding/dropping it. The materialized drums arrangement always carries +// `type:"drums"`, so a type-only test is exact and safe. +export function isDrumArrangement(arr) { + return _arrTypeKind(arr) === DRUMS_ARR_TYPE; +} + +// 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; +} + +// 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); +} + +// 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 di >= 0 ? di : currentArr; +} + +// The number of PITCHED (non-drums) arrangements β€” what "how many arrangements +// are there" means everywhere the derived drums arrangement must not be counted +// (the remove-last-arrangement guard, single-arrangement checks). +export function pitchedArrangementCount(arrangements) { + return (Array.isArray(arrangements) ? arrangements : []).filter(a => !isDrumArrangement(a)).length; +} + +// Clamp an arrangement index so `S.currentArr` never lands on the drums +// arrangement β€” the "current" arrangement is always a pitched one (drums are +// edited through the drum grid, not selected as the pitched arrangement). Walks +// DOWN to the nearest pitched index; falls back to 0. +export function clampAwayFromDrums(arrangements, idx) { + const arrs = Array.isArray(arrangements) ? arrangements : []; + let i = Math.max(0, Math.min(Number(idx) || 0, arrs.length - 1)); + while (i > 0 && isDrumArrangement(arrs[i])) i--; + return i; +} + +// The BACKEND arrangement index for a frontend index: the count of pitched +// arrangements before it. The backend's `arrangements[]` never contains the +// session-only drums arrangement, so a frontend index (which may sit after an +// interspersed drums entry) must be mapped to its pitched-only position before +// it is sent to /remove-arrangement. +export function pitchedIndexOf(arrangements, idx) { + return (Array.isArray(arrangements) ? arrangements : []) + .slice(0, Math.max(0, Number(idx) || 0)).filter(a => !isDrumArrangement(a)).length; +} + +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 all = drumArrangements(S.arrangements); + const tab = S.drumTab; + if (!tab || typeof tab !== 'object') { + // 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; + } + // 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; + } + 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/drum.js b/src/drum.js index e8679c43..228b12d9 100644 --- a/src/drum.js +++ b/src/drum.js @@ -1228,6 +1228,7 @@ function _ensureDrumEditButton() { host.refreshTempoMapButton(); host.refreshPartsViewButton(); host.refreshDrumPadStrip(); + host.updateArrangementSelector(); // reflect drum-edit mode in the switcher host.draw(); }; drumsBtn.parentNode.insertBefore(btn, drumsBtn.nextSibling); diff --git a/src/file-ops.js b/src/file-ops.js index 64b88fd3..f4576231 100644 --- a/src/file-ops.js +++ b/src/file-ops.js @@ -8,9 +8,11 @@ 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 { 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'; +import { _isBassArr } from './instrument.js'; import { _updateLoopRegionControls } from './loop.js'; import { _marksSanitizePure } from './tempo-marks.js'; import { _recState } from './midi-record.js'; @@ -217,6 +219,25 @@ export async function loadCDLC(filename, options = {}) { if (S.drumTab && Array.isArray(S.drumTab.hits)) { S.drumTab.hits.sort((a, b) => (a.t || 0) - (b.t || 0)); } + // 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. + // 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 @@ -563,7 +584,7 @@ export function filterSongs(q) { export function _activeArrangementExceedsArchiveLimit() { const a = S.arrangements[S.currentArr]; if (!a) return false; - const isBass = /bass/i.test(a.name || ''); + const isBass = _isBassArr(a); const roleLimit = isBass ? 4 : 6; return _stringCountFor(a) > roleLimit; } @@ -649,7 +670,12 @@ export function _buildSaveBody(forceFullSnapshot) { // overwrite the on-disk `tones: null` sentinel with an // empty `{base, slots, changes, definitions}` dict on the // next sloppak save. - body.arrangements = S.arrangements.map(a => { + // Drums persist as the song-level `drum_tab` (body.drum_tab below), not + // as a manifest arrangement β€” the type:"drums" arrangement is a session- + // only derivation, so exclude it here to keep the built pack byte- + // identical (and to keep a drums entry out of arrangements[], where an + // old core would fretted-grade it). + body.arrangements = S.arrangements.filter(a => !isDrumArrangement(a)).map(a => { if (!a) return a; // Strip `_anchorEditCount` / `_handshapeEditCount` from every // arrangement so the dirty counters never leak to the backend's @@ -704,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/gm-guide.js b/src/gm-guide.js index 4f1bd92f..539104d5 100644 --- a/src/gm-guide.js +++ b/src/gm-guide.js @@ -24,8 +24,9 @@ * * Voice choice (1.5) is per part KIND β€” keys / bass / guitar (drums keep * their clap; the drum strip owns drum sounds) β€” stored as editor prefs - * (`editorGmVoice:`), never the pack. Kind inference mirrors the - * repo rule: KEYS_PATTERN (start-anchored) > /bass/i > guitar. + * (`editorGmVoice:`), never the pack. The part's instrument kind is + * resolved by the caller (arrKind β€” an authored `type` wins over the name) + * and collapsed here to the three voice families. * * This module is deliberately a leaf over state/keys: the audio scheduler * (src/audio.js) passes in the AudioContext and the guide bus, so no @@ -33,7 +34,6 @@ * node (that's how the unit tests run). */ -import { KEYS_PATTERN } from './keys.js'; import { setStatus } from './ui.js'; /* @pure:gm-guide:start */ @@ -69,13 +69,15 @@ export const GM_VOICE_CHOICES = Object.freeze({ ]), }); -// Part kind for guide-voice purposes. Mirrors the repo's kind-inference -// rule (keys > drums > bass > guitar) minus drums: the drum grid is a -// separate edit mode (S.drumEditMode) that never reaches this path. -export function _gmKindPure(arrName) { - const name = typeof arrName === 'string' ? arrName : ''; - if (KEYS_PATTERN.test(name)) return 'keys'; - if (/bass/i.test(name)) return 'bass'; +// The guide-voice family for a resolved instrument kind (arrKind β€” an +// authored `type` wins over the name). The GM voices come in three families, +// keys / bass / guitar; drums and vocals fall to the guitar voice β€” a drums +// arrangement is edited through the drum grid (S.drumEditMode) and never +// reaches this pitched path. Callers pass arrKind(arr), so a keys part named +// like a guitar takes the keys voice (identity is DATA, not the name). +export function _gmKindPure(kind) { + if (kind === 'keys') return 'keys'; + if (kind === 'bass') return 'bass'; return 'guitar'; } diff --git a/src/gp5-export.js b/src/gp5-export.js index da17357f..72a44272 100644 --- a/src/gp5-export.js +++ b/src/gp5-export.js @@ -15,18 +15,19 @@ import { S } from './state.js'; import { setStatus } from './ui.js'; import { guardSessionTransition } from './session-lifecycle.js'; import { _tabPreviewUrlPure } from './tab-preview.js'; +import { arrKind } from './instrument.js'; /* @pure:gp5-export:start */ // Which parts can export, with the exact user-facing reason when one can't. // Mirrors the Tab preview guard: fretted-only (keys/drums pack as pitch, not // stringΒ·fret, so a GP conversion would engrave nonsense), and a SAVED pack is -// required because the converter reads the last-saved pack. Regexes inlined so -// this @pure block stays self-contained and slice-testable (the tab-preview -// block inlines the same fretted test for the same reason). -function _gp5ExportGuardPure(filename, arrName, hasArrangements) { +// required because the converter reads the last-saved pack. The caller passes +// the RESOLVED instrument kind (arrKind β€” an authored `type` wins over the +// name), so keys/drums are refused by identity, not by a name guess; taking a +// kind keeps this @pure block self-contained and slice-testable. +function _gp5ExportGuardPure(filename, kind, hasArrangements) { if (!hasArrangements) return { ok: false, reason: 'Load a song first.' }; - const nm = String(arrName || ''); - if (/^(keys|piano|keyboard|synth)/i.test(nm) || /^drums/i.test(nm)) { + if (kind === 'keys' || kind === 'drums') { return { ok: false, reason: 'Guitar Pro export is for fretted tracks β€” keys and drums tracks have no tab.' }; } if (!filename) { @@ -79,7 +80,7 @@ function _downloadBytes(bytes, name) { export async function editorExportGp5() { const cur = () => (S.arrangements.length ? S.arrangements[S.currentArr] : null); let arr = cur(); - let guard = _gp5ExportGuardPure(S.filename, arr && arr.name, !!S.arrangements.length); + let guard = _gp5ExportGuardPure(S.filename, arrKind(arr), !!S.arrangements.length); if (!guard.ok) { setStatus(guard.reason); return; } // The converter reads the SAVED pack and indexes it by S.currentArr β€” and // it CLAMPS that index into the saved track list. So an unsaved session @@ -92,7 +93,7 @@ export async function editorExportGp5() { } // The prompt awaited: the song (and the current part) may have moved. arr = cur(); - guard = _gp5ExportGuardPure(S.filename, arr && arr.name, !!S.arrangements.length); + guard = _gp5ExportGuardPure(S.filename, arrKind(arr), !!S.arrangements.length); if (!guard.ok) { setStatus(guard.reason); return; } const name = _gp5ExportNamePure(S.filename, arr && arr.name); setStatus('Exporting ' + name + '…'); diff --git a/src/history.js b/src/history.js index e6cdfc60..a9618e7f 100644 --- a/src/history.js +++ b/src/history.js @@ -36,9 +36,15 @@ export const MAX_UNDO = 500; // suggestResolved β€” the VA.3 suggest-position writer (resolved adds + // Accept) IS the sanctioned string/fret write path the // lock was holding the door for. It marks, never guesses. +// metadataScope β€” arrangement metadata (its `type`), never a single note. +// The lock guards silent string/fret WRITES; a type set +// touches no note, and the type control is the escape +// hatch a fretted part shown read-only in the roll needs +// most (re-type it out of keys). Unlike songScope it keeps +// _arrIdx, so undo still switches to the retyped part. // Nothing else opts out. Returns true when the command must not run. function _locked(cmd) { - if (cmd.songScope === true || cmd.pitchPreserving === true || cmd.suggestResolved === true) return false; + if (cmd.songScope === true || cmd.pitchPreserving === true || cmd.suggestResolved === true || cmd.metadataScope === true) return false; if (!_rollReadOnly()) return false; _rollLockNotice(); return true; diff --git a/src/host.js b/src/host.js index 901276a4..f541c181 100644 --- a/src/host.js +++ b/src/host.js @@ -208,8 +208,8 @@ export const host = { /** Mixer strip keys in Tracks-column row order (mixer follows a reorder). */ mixerTrackOrder: () => [], /** - * Per-part strip state BY KEY ('arr:' / 'drums') for band-mode - * MIDI playback: {audible, vol 0..1} with the whole-map solo rule. + * Per-part strip state BY KEY ('arr:', the drums arrangement included) + * for band-mode MIDI playback: {audible, vol 0..1} with the whole-map solo rule. * Owned by src/mixer-panel.js; inert default = every part at unity. */ partStripState: () => ({ audible: true, vol: 1 }), diff --git a/src/import.js b/src/import.js index 8ffb6487..ab7792cf 100644 --- a/src/import.js +++ b/src/import.js @@ -11,7 +11,8 @@ import { _editorEscHtml, setStatus } from './ui.js'; import { ReplaceArrangementChartCmd } from './commands.js'; import { TempoGridCmd, _tempoRemapMarksByTime } from './tempo.js'; import { DRUM_PIECE_META, DRUM_PIECE_ORDER, _drumImportHitPure } from './drum.js'; -import { KEYS_PATTERN, _uniqueKeysName, updatePianoRange } from './keys.js'; +import { _uniqueKeysName, updatePianoRange } from './keys.js'; +import { arrKind, _isBassArr } from './instrument.js'; import { flattenChords } from './chords.js'; import { host } from './host.js'; @@ -593,7 +594,7 @@ async function _addEmptyArrangement(arrangement, statusElId, okStatus) { if (sel) sel.value = S.currentArr; flattenChords(); - if (KEYS_PATTERN.test(arrangement.name || '') && typeof updatePianoRange === 'function') { + if (arrKind(arrangement) === 'keys' && typeof updatePianoRange === 'function') { updatePianoRange(); } host.updateArrangementSelector(); @@ -729,9 +730,8 @@ export function editorImportGuitarRefreshReplaceTargets() { const eligible = S.arrangements .map((a, i) => ({ a, i })) .filter(({ a }) => { - const nm = a.name || ''; - if (KEYS_PATTERN.test(nm) || /^drums/i.test(nm)) return false; - return /bass/i.test(nm) === wantBass; + if (arrKind(a) === 'keys' || arrKind(a) === 'drums') return false; + return _isBassArr(a) === wantBass; }); if (replaceSel) { replaceSel.innerHTML = eligible.map(({ a, i }) => diff --git a/src/instrument.js b/src/instrument.js new file mode 100644 index 00000000..bc2638f8 --- /dev/null +++ b/src/instrument.js @@ -0,0 +1,90 @@ +// ════════════════════════════════════════════════════════════════════ +// Instrument identity β€” a track's INSTRUMENT TYPE as first-class DATA. +// +// Historically the editor inferred a part's instrument from its NAME, in a +// dozen places with subtly different rules β€” the runtime prefix test +// (`/^(keys|piano|…)/`) vs the save-side word-boundary test β€” the very +// disagreement the rename guard exists to band-aid ("Electric Piano" reads +// runtime-guitar but save-keys). This module is the seam that makes identity +// first-class: when an arrangement carries an authored `type` (the feedpak-spec +// Β§5.2 manifest facet, which the backend already persists and never clobbers), +// it WINS; name inference stays only as the fallback for untyped/legacy packs. +// +// The format writes "piano" for the keys family and (today) leaves drums/vocals +// as side-files (type ""); this canonicalizes the manifest vocabulary to the +// editor's runtime kinds and accepts the plural set the multitrack work +// (drums-as-arrangements, vocals) grows into. Leaf module (imports nothing from +// src/) so keys.js / lanes.js / arrangement.js consult it without closing a cycle. +// +// Deliberately NOT touched yet: the ~30 other name-inference sites and the +// rename guard still key off the name. Relaxing them is only safe once every +// identity reader consults `type`; this PR converts the load-bearing keys/bass +// DATA + view predicates and leaves the rest to follow behind the same seam. +// ════════════════════════════════════════════════════════════════════ + +// Manifest `type` value (any case/whitespace) β†’ the editor's runtime instrument +// kind, or null when the type is absent / blank / unrecognized (the caller then +// falls back to its own name inference, so an untyped pack is byte-identical). +// piano/keyboard/synth fold into keys; lead/rhythm are guitar; drum & vocal +// synonyms are accepted ahead of their arrangement-native support landing. +const _TYPE_KIND = { + keys: 'keys', piano: 'keys', keyboard: 'keys', synth: 'keys', + bass: 'bass', + guitar: 'guitar', lead: 'guitar', rhythm: 'guitar', + drums: 'drums', drum: 'drums', + vocals: 'vocals', vocal: 'vocals', voice: 'vocals', +}; + +export function _typeKind(rawType) { + if (typeof rawType !== 'string') return null; + const t = rawType.trim().toLowerCase(); + return (t && Object.prototype.hasOwnProperty.call(_TYPE_KIND, t)) ? _TYPE_KIND[t] : null; +} + +// The authored instrument kind of an arrangement, or null when it is untyped +// (caller falls back to name inference). The one place a `type` facet is read. +export function _arrTypeKind(arr) { + return _typeKind(arr && arr.type); +} + +// The keys-family name matcher (prefix-anchored): arrangements whose name STARTS +// with keys/piano/keyboard/synth open as piano-roll charts. Its canonical home +// is this leaf so the whole nameβ†’kind fallback lives in one place; keys.js +// re-exports it for the many sites that import KEYS_PATTERN from there. +export const KEYS_PATTERN = /^(keys|piano|keyboard|synth)/i; + +// The runtime instrument kind inferred from a NAME β€” the legacy fallback for an +// untyped track. Prefix-anchored keys/drums, then /bass/ anywhere, else guitar; +// keys is tested before bass (so "Synth Bass" reads keys). This is now the ONE +// name-inference implementation β€” arrangement.js `_arrKindPure` delegates here. +export function _arrKindFromName(name) { + const n = String(name || ''); + if (KEYS_PATTERN.test(n)) return 'keys'; + if (/^drums/i.test(n)) return 'drums'; + if (/bass/i.test(n)) return 'bass'; + return 'guitar'; +} + +// The canonical instrument kind of an arrangement: an authored `type` WINS +// (identity is DATA), else name inference. The type-authoritative resolver the +// Tracks-view badge, the remaining view/routing sites, and drums-as-arrangements +// read β€” so identity is decided in exactly one place. +export function arrKind(arr) { + return _arrTypeKind(arr) || _arrKindFromName(arr && arr.name); +} + +// True only for instrument kinds that have strings/tuning to edit. +export function _isFrettedKind(kind) { + return kind === 'guitar' || kind === 'bass'; +} + +// Bass predicate β€” type-authoritative, but its NAME fallback stays the INDEPENDENT +// `/bass/` test, NOT `arrKind === 'bass'`. Bass and keys are independent facets in +// the legacy inference ("Synth Bass" is bass for string-count AND keys for the +// view), so the single-kind `arrKind` (keys wins) would wrongly flip a "Synth +// Bass" from a 4-string baseline to 6. Every string-count / open-tuning site that +// used `/bass/i.test(arr.name)` must route through here to stay byte-identical. +export function _isBassArr(arr) { + const k = _arrTypeKind(arr); + return k ? k === 'bass' : /bass/i.test((arr && arr.name) || ''); +} diff --git a/src/key-view.js b/src/key-view.js index 939abe9c..8e949537 100644 --- a/src/key-view.js +++ b/src/key-view.js @@ -6,7 +6,8 @@ import { hideAddNote } from './add-note.js'; import { hideContextMenu } from './context-menu.js'; import { _loadEditorKeyIfNeeded, _persistEditorKey, editorKeyHighlightEnabled } from './draw.js'; -import { KEYS_PATTERN, _partViewKeyPure, _rollMidiForNote, _rollPitchCtx, _viewPrefs, _viewPrefsSave, updatePianoRange, viewFor } from './keys.js'; +import { _partViewKeyPure, _rollMidiForNote, _rollPitchCtx, _viewPrefs, _viewPrefsSave, updatePianoRange, viewFor } from './keys.js'; +import { arrKind } from './instrument.js'; import { notes } from './notes.js'; import { S } from './state.js'; import { PIANO_NOTE_NAMES, SCALE_INTERVALS, SCALE_LABELS, _detectKeyPure, _noteNamesForKeyPure } from './theory.js'; @@ -103,8 +104,8 @@ export function _refreshViewSwitch() { const el = document.getElementById('editor-view-switch'); if (!el) return; const arr = S.arrangements.length ? S.arrangements[S.currentArr] : null; - const fretted = !!arr && !KEYS_PATTERN.test(arr.name || ''); - const isDrums = !!arr && /^drums/i.test(arr.name || ''); + const fretted = !!arr && arrKind(arr) !== 'keys'; + const isDrums = !!arr && arrKind(arr) === 'drums'; // The DRUM editor gets its own two options (grid / percussion staff) β€” // its mode flag stays on under the drum lens, so it is the one signal. const drumMode = !!S.drumEditMode && !!S.drumTab; @@ -172,7 +173,7 @@ export const editorSetViewMode = (mode) => { if (mode !== 'string' && mode !== 'piano') return; const arr = S.arrangements.length ? S.arrangements[S.currentArr] : null; if (!arr) return; - if (KEYS_PATTERN.test(arr.name || '')) { + if (arrKind(arr) === 'keys') { setStatus('Keys tracks always use the piano roll'); return; } @@ -203,7 +204,7 @@ export const editorSetViewMode = (mode) => { export function _editorCycleViewMode() { const arr = S.arrangements.length ? S.arrangements[S.currentArr] : null; if (!arr) { setStatus('Load a song first'); return true; } - if (KEYS_PATTERN.test(arr.name || '')) { + if (arrKind(arr) === 'keys') { setStatus('Keys tracks always use the piano roll'); return true; } @@ -218,7 +219,7 @@ export function _editorCycleViewMode() { // Drums have no tab (same refusal as the lens's own guard) β€” the // toggle would refuse WITHOUT changing mode and the cycle would // stick on the roll. Skip the Tab stop and wrap to String view. - if (/^drums/i.test(arr.name || '')) { + if (arrKind(arr) === 'drums') { window.editorSetViewMode('string'); return true; } diff --git a/src/keys.js b/src/keys.js index 48a45801..c5e5b9b3 100644 --- a/src/keys.js +++ b/src/keys.js @@ -19,6 +19,10 @@ import { _openMidiForArr, _soundingPitchPure, _stringCountFor } from './lanes.js import { notes } from './notes.js'; import { S } from './state.js'; import { PIANO_NOTE_NAMES, _noteNamesForKeyPure } from './theory.js'; +import { KEYS_PATTERN, _arrTypeKind, arrKind } from './instrument.js'; +// Re-export so the many sites that import KEYS_PATTERN from keys.js keep working; +// its canonical definition now lives in the instrument-identity leaf. +export { KEYS_PATTERN }; // ── Piano roll constants ──────────────────────────────────────────── export const PIANO_OCTAVE_COLORS = [ @@ -27,10 +31,6 @@ export const PIANO_OCTAVE_COLORS = [ ]; export let PIANO_LANE_H = 10; // pixels per MIDI semitone export let pianoRange = { lo: 36, hi: 96 }; // MIDI range, updated per arrangement -// Names that should open in keys (piano-roll) editor mode. Arrangements -// named "Piano", "Keyboard", or "Synth" render as piano-roll charts rather -// than 6-string guitar charts. -export const KEYS_PATTERN = /^(keys|piano|keyboard|synth)/i; // Per-part editing-view choice (V2/V9 of EDITOR-VIEW-MODALITY-DESIGN): // 'string' (fretted lanes) or 'piano' (the roll). Keys-DATA arrangements @@ -82,6 +82,10 @@ export function _viewPrefsSave() { } catch (_) { /* ignore */ } } export function viewFor(arr) { + // An authored keys `type` piano-locks the part regardless of its name; + // otherwise the legacy name test drives the piano-lock + stored preference. + const k = _arrTypeKind(arr); + if (k) return k === 'keys' ? 'piano' : (_viewPrefs()[_partViewKeyPure(arr)] === 'piano' ? 'piano' : 'string'); return _viewForPure(arr && arr.name, _viewPrefs()[_partViewKeyPure(arr)]); } @@ -92,7 +96,11 @@ export function viewFor(arr) { export function isKeysArr() { if (!S.arrangements.length) return false; const arr = S.arrangements[S.currentArr]; - return !!(arr && KEYS_PATTERN.test(arr.name || '')); + if (!arr) return false; + // An authored `type` is authoritative (instrument identity is DATA); the + // legacy prefix name test is the fallback for untyped/legacy packs. + const k = _arrTypeKind(arr); + return k ? k === 'keys' : KEYS_PATTERN.test(arr.name || ''); } // Piano SURFACE predicate: the piano-roll view is active for the current @@ -125,7 +133,7 @@ export function _rollPitchCtx() { // The per-arrangement form (multi-track MIDI playback schedules EVERY part, // not just the current one): same rules, arr injected. export function _rollPitchCtxFor(arr) { - if (!arr || KEYS_PATTERN.test(arr.name || '')) return null; + if (!arr || arrKind(arr) === 'keys') return null; const laneCount = _stringCountFor(arr); const tuning = (Array.isArray(arr.tuning) ? arr.tuning : []).slice(0, laneCount); while (tuning.length < laneCount) tuning.push(0); diff --git a/src/lanes.js b/src/lanes.js index 27c39c6d..6403c884 100644 --- a/src/lanes.js +++ b/src/lanes.js @@ -7,6 +7,7 @@ */ import { S } from './state.js'; +import { _isBassArr } from './instrument.js'; export const MAX_LANES = 8; @@ -65,7 +66,8 @@ export function colorForLane(l) { function isBassArr() { if (!S.arrangements.length) return false; const arr = S.arrangements[S.currentArr]; - return !!arr && /bass/i.test(arr.name || ''); + if (!arr) return false; + return _isBassArr(arr); } // Active arrangement string count. Mirrors lib/song.py:arrangement_string_count @@ -95,7 +97,7 @@ function isBassArr() { export function _seedExtendedStringsFromTuning(arrangements, authoritativeLength) { for (const arr of arrangements || []) { if (typeof arr._extendedStrings === 'number') continue; // already set - const isBass = /bass/i.test(arr.name || ''); + const isBass = _isBassArr(arr); // type-authoritative; independent /bass/ fallback const baseline = isBass ? 4 : 6; const tuningLen = Array.isArray(arr.tuning) ? arr.tuning.length : baseline; if (tuningLen > 6) { @@ -116,7 +118,7 @@ export function _seedExtendedStringsFromTuning(arrangements, authoritativeLength export function _stringCountFor(arr) { if (!arr) return 6; - const isBass = /bass/i.test(arr.name || ''); + const isBass = _isBassArr(arr); const baseline = isBass ? 4 : 6; // User-added strings via the Strings modal β€” authoritative even // when tuning happens to be ambiguous length 6 (the standard RS-XML @@ -256,7 +258,7 @@ const _BASS_OPEN_MIDI = [28, 33, 38, 43]; // 4-string standard // extended/trimmed to `laneCount` strings. Extended-range strings // are prepended at the low end (matching how AddStringCmd works). export function _openMidiForArr(arr, laneCount) { - const isBass = /bass/i.test(arr.name || ''); + const isBass = _isBassArr(arr); const base = isBass ? _BASS_OPEN_MIDI.slice() : _GUITAR_OPEN_MIDI.slice(); // Extend low end: each additional low string is 5 semitones below // the current lowest (perfect 4th), matching standard guitar/bass diff --git a/src/main.js b/src/main.js index 42064c92..b80df6d2 100644 --- a/src/main.js +++ b/src/main.js @@ -69,7 +69,7 @@ import { import { editorDoAddDrums, editorDrumsFileSelected, editorDrumsGPSelected, editorHideAddDrumsModal, editorRemoveArrangement, editorRenameArrangement, - editorShowAddDrumsModal + editorSetArrangementType, editorShowAddDrumsModal } from './arrangement.js'; import { _activeArrangementExceedsArchiveLimit, _editorLoadsInFlight, _resetOffsetUI, @@ -186,8 +186,10 @@ import { LABEL_W, TIMELINE_TOP, clampZoom, setLaneMetrics } from './geometry.js'; import { _laneClipActive, applyLaneScrollBounds, drawLaneScrollbar, laneBandTop } from './lane-scroll.js'; import { - KEYS_PATTERN, _rollLockNotice, + _rollLockNotice, _rollMidiForNote, _rollPitchCtx, _rollReadOnly, editorKeyNoteNames, isKeysMode, midiToNote, updatePianoRange } from './keys.js'; +import { _isFrettedKind, arrKind } from './instrument.js'; +import { clampAwayFromDrums, isDrumArrangement, pitchedArrangementCount, switcherShownIndex } from './drum-arrangement.js'; import { _restoreSuggestedMarks, _saveSuggestedMarks, _suggestedCount, chords, notes @@ -586,9 +588,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 @@ -600,18 +605,30 @@ setHostHooks({ S.partsViewMode = false; S.tempoMapMode = false; S.tempoSel = -1; - if (targetId === 'drums' && S.drumTab && S.format === 'sloppak') { + // 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; + 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(); _refreshDrumEditButton(); _refreshTempoMapButton(); + updateArrangementSelector(); // reflect drums/pitched selection in the switcher draw(); updateStatus(); }, @@ -642,6 +659,7 @@ window.editorDoImportGuitar = editorDoImportGuitar; // Arrangement management (rename / remove / add-drums import) β€” arrangement.js. window.editorRenameArrangement = editorRenameArrangement; +window.editorSetArrangementType = editorSetArrangementType; window.editorRemoveArrangement = editorRemoveArrangement; window.editorShowAddDrumsModal = editorShowAddDrumsModal; window.editorHideAddDrumsModal = editorHideAddDrumsModal; @@ -1034,25 +1052,50 @@ function updateTimeDisplay() { // File operations // ════════════════════════════════════════════════════════════════════ +/* @pure:arr-affordances:start */ +// The Remove / Reorder button gates, counted over PITCHED parts only. The +// derived drums arrangement (type:"drums", appended last) must never make a +// single-pitched song look removable (a silent no-op) or let the last pitched +// part move DOWN past drums (breaks append-last, shifts arr: mix keys). +// `currentArr` is always a pitched index (clamped away from drums). +// Not exported β€” main.js is IIFE-wrapped; the test slices this @pure block out. +function _arrAffordancePure(pitchedCount, currentArr, sessionId, format) { + const canRemove = pitchedCount > 1; + // Reorder persists only through the full-snapshot sloppak save. + const canReorder = canRemove && !!sessionId && format === 'sloppak'; + return { + canRemove, + canReorder, + upDisabled: !canReorder || currentArr <= 0, + downDisabled: !canReorder || currentArr >= pitchedCount - 1, + }; +} +/* @pure:arr-affordances:end */ + function updateArrangementSelector() { const sel = document.getElementById('editor-arrangement'); sel.innerHTML = ''; S.arrangements.forEach((arr, i) => { + if (!arr) return; const opt = document.createElement('option'); opt.value = i; - opt.textContent = arr.name; + // Drums are a selectable part whose view is the drum grid β€” mark the + // option with πŸ₯ so it reads as the drum editor, not a pitched chart. + opt.textContent = (arr.type === 'drums') ? ('πŸ₯ ' + (arr.name || 'Drums')) : arr.name; sel.appendChild(opt); }); - sel.style.display = S.arrangements.length > 1 ? '' : 'none'; + sel.style.display = sel.options.length > 1 ? '' : 'none'; // Re-apply the active arrangement after the rebuild so callers that // changed S.currentArr (e.g. + Keys / + Drums append, remove-arr) // don't end up with a ` onchange. A DRUMS option opens the drum grid β€” a MODE +// over the current pitched arrangement, so currentArr does NOT move onto the +// drums arrangement (the invariant the rest of the editor relies on). Mirrors the +// Tracks 'drums' row (openTrackSessionTarget). A pitched option leaves drum-edit +// mode and selects it. editorSelectArrangement stays drums-unaware so its other +// callers β€” undo replay, the Tracks row β€” are unchanged. +window.editorSwitcherSelect = (val) => { + const idx = parseInt(val) || 0; + if (isDrumArrangement(S.arrangements[idx])) { + // A drum part: open the drum grid as a MODE (currentArr stays pitched) + // on THAT part's tab β€” a song can hold several, and each πŸ₯ option + // targets its own arrangement's payload. Guard so a drums index can + // NEVER fall through to editorSelectArrangement (which would move + // currentArr onto the drums arrangement). + const tab = S.arrangements[idx].drumTab; + if (!(tab && S.format === 'sloppak')) { updateArrangementSelector(); return; } + _finalizeActiveDrag(); + S.partsViewMode = false; + S.tempoMapMode = false; + S.tempoSel = -1; + S.drumTab = tab; // the selected part becomes the grid target + // draw() checks tabViewMode FIRST, so drop the engraved-tab lens on the + // switch (mirrors the Edit-Drums button) or it keeps painting the old + // part's tab over the drum grid. + S.tabViewMode = false; + S.drumEditMode = true; + S.drumSel = new Set(); // indices from another part's hits are stale + _refreshPartsViewButton(); + _refreshDrumEditButton(); + _refreshTempoMapButton(); + updateArrangementSelector(); + draw(); + updateStatus(); + return; + } + // A pitched part (guaranteed non-drums): leave EVERY mode-lens and select it. + // editorSelectArrangement moves currentArr (which stays off the drums slot) + // but clears none of the lens flags, and draw() renders whichever lens is + // still set instead of the arrangement (tabViewMode is even checked first). + // So drop them all here β€” parts/tempo included, not just drum/tab β€” mirroring + // the drums branch and openTrackSessionTarget; otherwise switching to a part + // while in Parts or Tempo Map view silently moves currentArr but keeps + // painting the old lens over it. + S.drumEditMode = false; + S.tabViewMode = false; + S.partsViewMode = false; + S.tempoMapMode = false; + S.tempoSel = -1; + window.editorSelectArrangement(String(idx)); + _refreshDrumEditButton(); + _refreshPartsViewButton(); + _refreshTempoMapButton(); + updateArrangementSelector(); +}; window.editorToggleTech = (idx, tech) => { // Read-only roll (V4): the context-menu technique toggle mutates // n.techniques directly (no EditHistory), so it escapes the exec lock. @@ -2283,7 +2401,10 @@ function _editorMovePart(dir) { return true; } const from = S.currentArr; - const to = _movePartTargetPure(from, dir, S.arrangements.length); + // Bound on PITCHED count, not arrangements.length: the drums arrangement is + // appended last, so a pitched part must never move past it (would break the + // append-last invariant and shift arr: mix keys onto the wrong parts). + const to = _movePartTargetPure(from, dir, pitchedArrangementCount(S.arrangements)); if (to < 0) return true; // at an end / nothing to do const [moved] = S.arrangements.splice(from, 1); S.arrangements.splice(to, 0, moved); diff --git a/src/menu-bar.js b/src/menu-bar.js index fa8d343c..7fe2da43 100644 --- a/src/menu-bar.js +++ b/src/menu-bar.js @@ -35,6 +35,7 @@ import { _editorSetGuideVoiceMode, editorFollowEnabled, editorGuideVoiceMode, editorScrollInPlayEnabled } from './audio.js'; import { GM_VOICE_CHOICES, _gmKindPure, editorGmVoiceFor, editorSetGmVoice } from './gm-guide.js'; +import { arrKind } from './instrument.js'; import { _editorRunEofCommand } from './input.js'; import { _editorShortcutRowsPure, editorShortcutProfile } from './shortcuts.js'; import { S } from './state.js'; @@ -451,7 +452,7 @@ function windowFns() { // model to render no instrument rows. function _gmGuideMenuCtx() { const arr = (S.arrangements && S.arrangements[S.currentArr]) || null; - const kind = (!arr || S.drumEditMode) ? null : _gmKindPure(arr.name); + const kind = (!arr || S.drumEditMode) ? null : _gmKindPure(arrKind(arr)); return { mode: editorGuideVoiceMode(), kind, diff --git a/src/mixer-panel.js b/src/mixer-panel.js index b988d215..d0b636af 100644 --- a/src/mixer-panel.js +++ b/src/mixer-panel.js @@ -7,7 +7,7 @@ // (recording / guide / click) and the edit blip. // // This module owns the CANONICAL per-part mix state, `S.partMix` β€” a map from -// part key ('arr:' for arrangements, 'drums' for the drum tab) to +// part key ('arr:' for arrangements, the drums arrangement included) to // { vol, mute, solo }. Today the only per-part sound is the guide voice (claps // follow the active editing surface), so mute/solo/volume gate and scale the // guide claps for the part being edited; the Parts-gutter M/S/A (Β§2.5) and @@ -25,14 +25,17 @@ // Part mute/solo/volume is SESSION state β€” it resets with the loaded song // (create.js / file-ops.js clear `S.partMix` when they install arrangements). // ════════════════════════════════════════════════════════════════════ +import { activeDrumArrangementIndex } from './drum-arrangement.js'; import { host } from './host.js'; import { S, editGen } from './state.js'; import { _editorEscHtml, setStatus } from './ui.js'; /* @pure:mixer-panel:start */ -// One strip per part: every arrangement, plus the drum tab as its own strip -// (drums are a song-level sidecar, not an arrangement) β€” the same list shape -// as the Parts view, keyed the way S.currentArr addresses parts (by index). +// One strip per part, keyed the way S.currentArr addresses parts (by index). +// The drums arrangement is an ordinary `type:"drums"` entry in `arrangements` +// now (its strip is `arr:` like any other part β€” no `'drums'` singleton), +// so a single pass over the arrangements covers it. `drumTab` is unused here; +// it stays in the signature so callers match the sibling roster builders. export function _mixerPartsPure(arrangements, drumTab, stems, removedSourceIds, master) { const parts = []; // The master mix leads the audio band as its own channel strip (keyed @@ -60,9 +63,6 @@ export function _mixerPartsPure(arrangements, drumTab, stems, removedSourceIds, name: (arr && arr.name) || 'Track ' + (i + 1), }); }); - if (drumTab && Array.isArray(drumTab.hits) && drumTab.hits.length) { - parts.push({ key: 'drums', name: 'Drums' }); - } return parts; } // Fader positions run 0..110: 0..100 is linear to unity, 101..110 adds @@ -136,11 +136,19 @@ export function _mixerPartAudiblePure(partMix, key) { if (key === 'audio:master') return true; return _mixerAnySoloPure(partMix) ? st.solo : true; } +// The mix key of the ACTIVE editing surface: the drums arrangement's channel +// while the drum grid is open (`arr:` β€” currentArr itself stays on a +// pitched arrangement, #337), else the current pitched arrangement. +export function _mixerActivePartKeyPure(drumEditMode, currentArr, drumIdx) { + return (drumEditMode && Number(drumIdx) >= 0) + ? 'arr:' + drumIdx + : 'arr:' + (Number(currentArr) || 0); +} // What the guide-clap scheduler needs for the ACTIVE editing surface: claps // follow the drum grid in drum mode, the current arrangement otherwise, so // that surface's part decides whether (and how loud) the claps sound. -export function _mixerClapStatePure(partMix, drumEditMode, currentArr) { - const key = drumEditMode ? 'drums' : 'arr:' + (Number(currentArr) || 0); +export function _mixerClapStatePure(partMix, drumEditMode, currentArr, drumIdx) { + const key = _mixerActivePartKeyPure(drumEditMode, currentArr, drumIdx); return { audible: _mixerPartAudiblePure(partMix, key), vol: _mixerGainForFaderPure(_mixerPartStatePure(partMix, key).vol), @@ -154,7 +162,9 @@ export function _mixerOpenFromStoredPure(raw) { // The host-hook target audio.js consults per scheduled clap voice. export function _mixerClapState() { - return _mixerClapStatePure(S.partMix, S.drumEditMode, S.currentArr); + // The ACTIVE drum part's channel (a song can hold several) β€” its strip + // gates the grid's guide claps, whichever part is open. + return _mixerClapStatePure(S.partMix, S.drumEditMode, S.currentArr, activeDrumArrangementIndex(S.arrangements, S.drumTab)); } // Band mode's per-KEY twin (host.partStripState): {audible, vol 0..1} for @@ -195,7 +205,8 @@ function _selectedStripKeyPure() { if (!selected) return ''; if (selected.type === 'audio') return 'audio:' + selected.sourceId; if (selected.type === 'transcription') { - if (selected.targetId === 'drums') return 'drums'; + // The drums arrangement resolves through the same idβ†’index path as any + // other part (its id is 'drums', so targetId 'drums' β†’ its arr:). const idx = (S.arrangements || []) .findIndex((arr, i) => String((arr && arr.id) || ('arr:' + i)) === selected.targetId); return idx >= 0 ? 'arr:' + idx : ''; @@ -259,7 +270,7 @@ export function _mixerMeterPeakPure(key, levels, activeAudioId, activePart) { } function _meterPeakForKey(key, levels) { - const activePart = S.drumEditMode ? 'drums' : 'arr:' + (Number(S.currentArr) || 0); + const activePart = _mixerActivePartKeyPure(S.drumEditMode, S.currentArr, activeDrumArrangementIndex(S.arrangements, S.drumTab)); return _mixerMeterPeakPure(key, levels, S.activeAudioSourceId, activePart); } @@ -274,7 +285,7 @@ export function _mixerMeterInputPure(key, levels, activeAudioId, activePart, pla } function _meterInputForKey(key, levels) { - const activePart = S.drumEditMode ? 'drums' : 'arr:' + (Number(S.currentArr) || 0); + const activePart = _mixerActivePartKeyPure(S.drumEditMode, S.currentArr, activeDrumArrangementIndex(S.arrangements, S.drumTab)); return _mixerMeterInputPure(key, levels, S.activeAudioSourceId, activePart, host.playAllTracksEnabled()); } diff --git a/src/new-track.js b/src/new-track.js index 8a10d561..550f8093 100644 --- a/src/new-track.js +++ b/src/new-track.js @@ -19,7 +19,7 @@ import { S } from './state.js'; import { setStatus } from './ui.js'; import { editorAddEmptyFretted, editorAddEmptyKeys, editorShowAddKeysModal, editorShowImportGuitarModal } from './import.js'; -import { editorAddEmptyDrums, editorShowAddDrumsModal } from './arrangement.js'; +import { _canAddAnotherDrums, editorAddEmptyDrums, editorShowAddDrumsModal } from './arrangement.js'; import { editorToggleStemTracks } from './stem-tracks.js'; /* @pure:new-track:start */ @@ -43,7 +43,12 @@ export function _newTrackPlanPure(sel, ctx) { } if (inst === 'Keys') return { action: 'empty-keys' }; if (inst === 'Drums') { - return c.hasDrumTab ? { action: 'blocked', reason: 'drums-exist' } : { action: 'empty-drums' }; + // A song can hold several drum parts now β€” an existing drum tab only + // blocks the empty start when another part CANNOT be added (create + // mode, whose build persists a single drum_tab). + return (c.hasDrumTab && !c.drumsCanAdd) + ? { action: 'blocked', reason: 'drums-exist' } + : { action: 'empty-drums' }; } return { action: 'empty-fretted', role: inst }; } @@ -73,19 +78,23 @@ function _renderNewTrackModal() { for (const r of modal.querySelectorAll('input[name="new-track-source"]')) { r.checked = r.value === _sel.source; } - // Drums with an existing drum tab: empty-start is blocked (one drum tab - // per song) β€” say so inline instead of failing at Create. + // Drums with an existing drum tab: once a drum part is materialized the + // empty start ADDS another (create + edit sessions alike) β€” say so. The + // only blocked case left is a drums-only session (no melodic track to sit + // beside), phrased inline instead of failing at Create. const note = _byId('editor-new-track-note'); if (note) { - const drumsBlocked = isTrans && _sel.instrument === 'Drums' && _sel.source === 'empty' && !!S.drumTab; - note.textContent = drumsBlocked - ? 'This song already has a Drums track β€” choose "Import from a file" to replace it.' - : ''; + const drumsEmpty = isTrans && _sel.instrument === 'Drums' && _sel.source === 'empty' && !!S.drumTab; + note.textContent = !drumsEmpty ? '' + : _canAddAnotherDrums() + ? 'This song already has drums β€” this will add another Drums track.' + : 'This song already has a Drums track β€” add a melodic track first to hold more than one drum part, or choose "Import from a file" to replace it.'; } const create = _byId('editor-new-track-create'); if (create) { create.disabled = _newTrackPlanPure(_sel, { hasSession: !!S.sessionId, format: S.format, hasDrumTab: !!S.drumTab, + drumsCanAdd: _canAddAnotherDrums(), }).action === 'blocked'; } } @@ -121,6 +130,7 @@ export function editorNewTrackSetSource(source) { export async function editorNewTrackCreate() { const plan = _newTrackPlanPure(_sel, { hasSession: !!S.sessionId, format: S.format, hasDrumTab: !!S.drumTab, + drumsCanAdd: _canAddAnotherDrums(), }); switch (plan.action) { case 'audio-picker': { diff --git a/src/parts-view.js b/src/parts-view.js index b1c57a99..4b0fe5e1 100644 --- a/src/parts-view.js +++ b/src/parts-view.js @@ -9,7 +9,8 @@ import { ctx } from './canvas.js'; import { hideContextMenu } from './context-menu.js'; import { DRUM_PIECE_META, _refreshDrumEditButton } from './drum.js'; import { LABEL_W, TIMELINE_TOP, timeToX, xToTime } from './geometry.js'; -import { KEYS_PATTERN } from './keys.js'; +import { isDrumArrangement } from './drum-arrangement.js'; +import { arrKind } from './instrument.js'; import { _stringCountFor } from './lanes.js'; import { _downbeatTimes } from './loop.js'; import { _recState } from './midi-record.js'; @@ -24,11 +25,19 @@ import { setStatus } from './ui.js'; import { host } from './host.js'; /* @pure:parts-view:start */ -// One entry per part: every arrangement, plus the drum tab as its own lane -// (drums are a song-level sidecar, not an arrangement). +// One entry per part: every arrangement β€” each drum part (a song can hold +// several) gets its own drum lane drawn from its OWN tab. The idx -1 lane is +// the legacy unmaterialized tab (create-mode compose). export function _partsListPure(arrangements, drumTab) { const parts = []; + let anyDrumArr = false; (arrangements || []).forEach((arr, i) => { + if (arr && arr.type === 'drums') { + anyDrumArr = true; + const hits = (arr.drumTab && Array.isArray(arr.drumTab.hits)) ? arr.drumTab.hits : []; + parts.push({ kind: 'drums', idx: i, name: arr.name || 'Drums', count: hits.length }); + return; + } parts.push({ kind: 'arr', idx: i, @@ -37,7 +46,7 @@ export function _partsListPure(arrangements, drumTab) { + ((arr && Array.isArray(arr.chords)) ? arr.chords.length : 0), }); }); - if (drumTab && Array.isArray(drumTab.hits) && drumTab.hits.length) { + if (!anyDrumArr && drumTab && Array.isArray(drumTab.hits) && drumTab.hits.length) { parts.push({ kind: 'drums', idx: -1, name: 'Drums', count: drumTab.hits.length }); } return parts; @@ -58,15 +67,14 @@ export function _partsLaneAtYPure(y, waveformH, laneH, count) { const i = Math.floor((y - waveformH) / laneH); return i >= 0 && i < count ? i : -1; } -// Instrument tag for a fretted/keyed arrangement, inferred from its NAME -// alone so every Parts-view lane reflects its OWN part rather than the armed -// arrangement. Self-contained (regexes inlined) so it stays unit-testable -// inside this @pure block. Mirrors KEYS_PATTERN (/^(keys|piano|keyboard| -// synth)/i) and isBassArr's /bass/i test, keys taking precedence. -function _partsArrKindPure(name) { - const s = String(name || ''); - if (/^(keys|piano|keyboard|synth)/i.test(s)) return 'Keys'; - if (/bass/i.test(s)) return 'Bass'; +// Instrument tag for a fretted/keyed arrangement lane, from its RESOLVED +// instrument kind (the caller passes arrKind β€” an authored `type` wins over +// the name), so every Parts-view lane reflects its OWN part's identity rather +// than the armed arrangement OR a misleading name. Self-contained (a plain +// kindβ†’tag map) so it stays unit-testable inside this @pure block. +export function _partsArrKindPure(kind) { + if (kind === 'keys') return 'Keys'; + if (kind === 'bass') return 'Bass'; return 'Guitar'; } // Unified-row hit test over the EXACT layout the header column shares β€” @@ -125,8 +133,13 @@ function _partsDrawSilhouette(part, y0, laneH, w) { const pad = 3; const innerH = laneH - pad * 2; if (part.kind === 'drums') { + // Each drum part paints ITS OWN tab (idx β‰₯ 0 names the arrangement); + // idx -1 is the legacy unmaterialized tab (create-mode compose). + const arr = part.idx >= 0 ? S.arrangements[part.idx] : null; + const tab = (arr && arr.drumTab) || S.drumTab; + if (!tab || !Array.isArray(tab.hits)) return; const bandH = innerH / 3; - for (const hit of S.drumTab.hits) { + for (const hit of tab.hits) { const x = timeToX(hit.t); if (x < PARTS_GUTTER || x > w) continue; const meta = DRUM_PIECE_META[hit.p]; @@ -148,7 +161,7 @@ function _partsDrawSilhouette(part, y0, laneH, w) { } } if (!events.length) return; - if (KEYS_PATTERN.test(arr.name || '')) { + if (arrKind(arr) === 'keys') { // Keys: pitch-mapped mini roll, range auto-fit to the part. let lo = Infinity, hi = -Infinity; for (const n of events) { @@ -169,9 +182,10 @@ function _partsDrawSilhouette(part, y0, laneH, w) { // Fretted: string-ribbon rows, low strings at the bottom to match the // focus editor's orientation. const strings = Math.max(1, _stringCountFor(arr)); - // Per-lane bass detection: isBassArr(arr) ignores its arg and tests the - // armed part, which would paint every lane the armed part's colour. - ctx.fillStyle = _partsArrKindPure(arr.name) === 'Bass' ? 'rgba(255,170,90,0.8)' : 'rgba(150,220,150,0.8)'; + // Per-lane bass detection via the resolved kind (keys already returned + // above): an authored `type` wins over the name, so a bass lane paints + // orange regardless of what part is armed or how it's named. + ctx.fillStyle = _partsArrKindPure(arrKind(arr)) === 'Bass' ? 'rgba(255,170,90,0.8)' : 'rgba(150,220,150,0.8)'; for (const n of events) { const x = timeToX(n.time); if (x < PARTS_GUTTER || x > w) continue; @@ -211,7 +225,11 @@ export function _partsViewDraw(w, h) { ctx.stroke(); } if (row.type === 'audio') _drawTrackAudioWaveform(row, y0, laneH, w); - else if (row.type === 'transcription' && row.targetId === 'drums') { + else if (row.type === 'transcription' && arrIdx >= 0 && isDrumArrangement(S.arrangements[arrIdx])) { + // A drum part's lane paints its OWN tab (any of several parts). + _partsDrawSilhouette({ kind: 'drums', idx: arrIdx }, y0, laneH, w); + } else if (row.type === 'transcription' && row.targetId === 'drums') { + // Legacy unmaterialized tab (create-mode compose). if (S.drumTab && Array.isArray(S.drumTab.hits)) _partsDrawSilhouette({ kind: 'drums', idx: -1 }, y0, laneH, w); } else if (row.type === 'transcription' && arrIdx >= 0) { _partsDrawSilhouette({ kind: 'arr', idx: arrIdx }, y0, laneH, w); @@ -261,14 +279,18 @@ export function _partsViewOnMouseDown(e, x, y) { // Match the header row: focus this source as the active reference so // the waveform + onset tools follow the clicked lane too. host.selectTrackSessionSource(row.sourceId); - } else if (row.targetId === 'drums') { - setStatus('Drum transcription selected β€” double-click to open the drum editor'); } else { const idx = _arrIndexForTarget(row.targetId); - if (idx >= 0 && idx !== S.currentArr) window.editorSelectArrangement(idx); - const sel = document.getElementById('editor-arrangement'); - if (sel && idx >= 0) sel.value = String(idx); - setStatus(`Transcription track: ${row.name} β€” double-click to open it`); + if ((idx >= 0 && isDrumArrangement(S.arrangements[idx])) || row.targetId === 'drums') { + // A drum part (any of them) β€” arming is a no-op (currentArr never + // moves onto a drums arrangement); double-click opens its grid. + setStatus('Drum transcription selected β€” double-click to open the drum editor'); + } else { + if (idx >= 0 && idx !== S.currentArr) window.editorSelectArrangement(idx); + const sel = document.getElementById('editor-arrangement'); + if (sel && idx >= 0) sel.value = String(idx); + setStatus(`Transcription track: ${row.name} β€” double-click to open it`); + } } host.draw(); } diff --git a/src/state.js b/src/state.js index 8dc5f8d4..e0eebbed 100644 --- a/src/state.js +++ b/src/state.js @@ -59,8 +59,9 @@ export const S = { partsViewMode: false, drumSel: new Set(), - // Per-part mix state (mixer panel, B6) β€” 'arr:' / 'drums' β†’ - // { vol, mute, solo }. Session-scoped UI state (never the pack): the + // Per-part mix state (mixer panel, B6) β€” 'arr:' (the drums + // arrangement included) β†’ { vol, mute, solo }. Session-scoped UI state + // (never the pack): the // canonical source for part mute/solo/volume that the mixer strips, // the guide-clap gate (via host.partClapState) and the future // Parts-gutter M/S/A all read. Reset when a song is installed. diff --git a/src/stem-tracks.js b/src/stem-tracks.js index 921c5b25..3dff1463 100644 --- a/src/stem-tracks.js +++ b/src/stem-tracks.js @@ -26,6 +26,7 @@ import { S, markSessionDirty } from './state.js'; import { host } from './host.js'; import { setStatus, _editorPromptText } from './ui.js'; import { _partViewKeyPure } from './keys.js'; +import { isDrumArrangement } from './drum-arrangement.js'; import { editorTempoGuideState, editorToggleTempoGuide, reconcileTempoGuideToStems } from './track-session.js'; /* @pure:stem-tracks:start */ @@ -47,6 +48,11 @@ function _stemRowsPure(stems, stemLinks, arrangements) { pairedWith: linkedTo(s2.id), })); } +// Chart tracks a stem can pair with: PITCHED arrangements only β€” the derived +// drums arrangement is a song-level sidecar, never a stem-transcription target. +function _stemPairArrsPure(arrangements) { + return (arrangements || []).filter(a => !isDrumArrangement(a)); +} // Toggle a pairing IMMUTABLY: linking a stem to a track drops that track's // previous link (one source track per chart track); picking '' unlinks. function _stemLinkSetPure(links, arrKey, stemId) { @@ -58,7 +64,7 @@ function _stemLinkSetPure(links, arrKey, stemId) { return out; } /* @pure:stem-tracks:end */ -export { _stemLinkSetPure, _stemRowsPure }; +export { _stemLinkSetPure, _stemRowsPure, _stemPairArrsPure }; const $modal = () => document.getElementById('editor-stem-tracks-modal'); const $list = () => document.getElementById('editor-stem-tracks-list'); @@ -102,7 +108,7 @@ function _render() { if (!list) return; const rows = _stemRowsPure(S.stems, S.stemLinks, S.arrangements); const arrOptions = (paired) => [''] - .concat((S.arrangements || []).map((a) => { + .concat(_stemPairArrsPure(S.arrangements).map((a) => { const k = _partViewKeyPure(a); const sel = paired && paired.key === k ? ' selected' : ''; return ``; diff --git a/src/strings.js b/src/strings.js index 4f19f6de..99af4382 100644 --- a/src/strings.js +++ b/src/strings.js @@ -4,7 +4,8 @@ import { AddStringCmd, RemoveStringCmd, RemoveStringWithNotesCmd } from './commands.js'; import { LANE_H, TIMELINE_TOP, WAVEFORM_H } from './geometry.js'; -import { isKeysMode, KEYS_PATTERN } from './keys.js'; +import { isKeysMode } from './keys.js'; +import { arrKind, _isBassArr } from './instrument.js'; import { _stringCountFor, laneLabels } from './lanes.js'; import { S, editGen } from './state.js'; import { host } from './host.js'; @@ -98,11 +99,12 @@ class SetStringTuningCmd { // Every lens that owns the timeline instead (piano roll, drum editor, // Tempo Map, Parts, Tab view) hides them, as do the non-fretted // arrangement kinds the Strings modal already refuses. -export function _stringButtonsVisiblePure(arrName, flags) { +export function _stringButtonsVisiblePure(kind, flags) { const f = flags || {}; if (f.keysMode || f.drumEdit || f.tempoMap || f.partsView || f.tabView) return false; - const name = arrName || ''; - return !KEYS_PATTERN.test(name) && !/^drums/i.test(name); + // `kind` is the resolved instrument (arrKind β€” type-authoritative); the + // string controls are for fretted parts only, so keys/drums hide them. + return kind !== 'keys' && kind !== 'drums'; } // Tooltip copy for the buttons. Count-centric: they always grow/shrink @@ -132,7 +134,7 @@ export function _stringRemoveLabelPure(isBass, cur) { function _stringsRangeForActive() { const arr = S.arrangements[S.currentArr]; - const isBass = arr && /bass/i.test(arr.name || ''); + const isBass = arr && _isBassArr(arr); return _stringsRangePure(!!isBass); } @@ -154,7 +156,7 @@ function _renderStringsModal() { const tuning = (arr.tuning || []).slice(0, labels.length); while (tuning.length < labels.length) tuning.push(0); const { min, max } = _stringsRangeForActive(); - const isBass = /bass/i.test(arr.name || ''); + const isBass = _isBassArr(arr); const summary = document.getElementById('editor-strings-summary'); if (summary) { @@ -244,7 +246,7 @@ function _renderStringsModal() { export const editorShowStringsModal = () => { const arr = S.arrangements[S.currentArr]; if (!arr) return; - if (KEYS_PATTERN.test(arr.name || '') || /^drums/i.test(arr.name || '')) return; + if (arrKind(arr) === 'keys' || arrKind(arr) === 'drums') return; document.getElementById('editor-strings-modal').classList.remove('hidden'); _renderStringsModal(); }; @@ -256,7 +258,7 @@ export const editorHideStringsModal = () => { export const editorAddString = (pos) => { const arr = S.arrangements[S.currentArr]; if (!arr) return; - const isBass = /bass/i.test(arr.name || ''); + const isBass = _isBassArr(arr); // Compute the count directly from the active arrangement rather // than going through `lanes()` β€” the latter consults a per-draw // cache and our intent here is explicitly "what is this @@ -279,7 +281,7 @@ export const editorAddString = (pos) => { export const editorRemoveString = (pos) => { const arr = S.arrangements[S.currentArr]; if (!arr) return; - const isBass = /bass/i.test(arr.name || ''); + const isBass = _isBassArr(arr); // Same reasoning as editorAddString β€” anchor on `arr` directly // rather than the cached `lanes()`. const cur = _stringCountFor(arr); @@ -311,7 +313,7 @@ export function editorStringButtonsRefresh() { const box = document.getElementById('editor-string-btns'); if (!box) return; const arr = S.arrangements[S.currentArr]; - const show = !!arr && _stringButtonsVisiblePure(arr.name, { + const show = !!arr && _stringButtonsVisiblePure(arrKind(arr), { keysMode: isKeysMode(), drumEdit: !!S.drumEditMode, tempoMap: !!S.tempoMapMode, partsView: !!S.partsViewMode, tabView: !!S.tabViewMode, }); @@ -319,7 +321,7 @@ export function editorStringButtonsRefresh() { if (_stringBtnsKey !== 'hidden') { box.classList.add('hidden'); _stringBtnsKey = 'hidden'; } return; } - const isBass = /bass/i.test(arr.name || ''); + const isBass = _isBassArr(arr); // _stringCountFor walks every note, so memo it on the edit generation // (the repo's standard dirty key β€” in-place moves keep array identity) // rather than paying O(notes) on every rAF flush. @@ -354,14 +356,14 @@ export function editorStringButtonsRefresh() { export const editorCanvasStringAdd = () => { const arr = S.arrangements[S.currentArr]; if (!arr) return; - const pos = _addPositionPure(/bass/i.test(arr.name || ''), _stringCountFor(arr)); + const pos = _addPositionPure(_isBassArr(arr), _stringCountFor(arr)); if (pos) editorAddString(pos); }; export const editorCanvasStringRemove = () => { const arr = S.arrangements[S.currentArr]; if (!arr) return; - const isBass = /bass/i.test(arr.name || ''); + const isBass = _isBassArr(arr); const cur = _stringCountFor(arr); const pos = _removePositionPure(isBass, cur); if (!pos) return; diff --git a/src/tab-preview.js b/src/tab-preview.js index 52008081..d28ac108 100644 --- a/src/tab-preview.js +++ b/src/tab-preview.js @@ -7,20 +7,20 @@ // exported explicitly. import { S } from './state.js'; +import { arrKind } from './instrument.js'; /* @pure:tab-preview:start */ // Guard: which parts can preview, with the exact user-facing reason when // one can't. NON-FRETTED parts (keys AND drums) are excluded β€” their wire // packing isn't fret/string, so a GP conversion of it would engrave -// nonsense tab. The non-fretted test mirrors the editor-wide one -// (KEYS_PATTERN /^(keys|piano|keyboard|synth)/i plus /^drums/i, e.g. the -// Strings modal's gate) but is INLINED so this @pure block stays -// self-contained and extractable β€” no reference to the outer KEYS_PATTERN -// global, matching the parts-view block's "regexes inlined" convention. -function _tabPreviewGuardPure(filename, arrName, hasArrangements) { +// nonsense tab. The caller passes the RESOLVED instrument kind (arrKind β€” an +// authored `type` wins over the name), so a keys part named like a guitar is +// still refused, and a guitar part named "Piano" now correctly previews. +// keys/drums are the two non-fretted runtime kinds; taking a kind (not a +// name) also keeps this @pure block self-contained β€” no regex, no outer ref. +function _tabPreviewGuardPure(filename, kind, hasArrangements) { if (!hasArrangements) return { ok: false, reason: 'Load a song first.' }; - const nm = String(arrName || ''); - if (/^(keys|piano|keyboard|synth)/i.test(nm) || /^drums/i.test(nm)) { + if (kind === 'keys' || kind === 'drums') { return { ok: false, reason: 'Tab preview is for fretted tracks β€” keys and drums tracks have no tab.' }; } if (!filename) { @@ -61,7 +61,7 @@ function _tabPreviewHttpMessagePure(status, bodyText) { // _tabPreviewUrlPure is the tabview-plugin GP5 conversion endpoint β€” the shared // contract. File β–Έ Export β–Έ Guitar Pro (src/gp5-export.js) downloads the bytes // from the same URL, so the endpoint format lives here only, never duplicated. -export { _tabPreviewKeyPolicyPure, _tabPreviewUrlPure }; +export { _tabPreviewGuardPure, _tabPreviewKeyPolicyPure, _tabPreviewUrlPure }; // Same pinned version + memoized loader idiom as the Tab View plugin β€” // pinning insulates the preview from CDN latest-tag churn (V12: alphaTab @@ -113,7 +113,7 @@ async function _tabPreviewRender() { if (!mount) return; const arr = S.arrangements.length ? S.arrangements[S.currentArr] : null; const guard = _tabPreviewGuardPure( - S.filename, arr && arr.name, !!S.arrangements.length); + S.filename, arrKind(arr), !!S.arrangements.length); if (!guard.ok) { _tabPreviewDestroyApi(); _tabPreviewStatus(guard.reason); diff --git a/src/tab-view-live.js b/src/tab-view-live.js index 9a673fdf..79cb507e 100644 --- a/src/tab-view-live.js +++ b/src/tab-view-live.js @@ -27,7 +27,7 @@ import { setStatus } from './ui.js'; import { notes } from './notes.js'; import { beatOf } from './beats.js'; import { _openMidiForArr, _stringCountFor } from './lanes.js'; -import { KEYS_PATTERN } from './keys.js'; +import { arrKind } from './instrument.js'; import { _alphaTexFromDrumHitsPure, _alphaTexFromNotesPure } from './alphatex.js'; import { TAB_RENDERER_FONT_DIR, _tabPreviewLoadScript } from './tab-preview.js'; @@ -231,7 +231,7 @@ export function _tabViewPing() { } } else { const arr = S.arrangements && S.arrangements[S.currentArr]; - if (!arr || KEYS_PATTERN.test(arr.name || '') || /^drums/i.test(arr.name || '')) { + if (!arr || arrKind(arr) === 'keys' || arrKind(arr) === 'drums') { S.tabViewMode = false; _tabViewHideIfShown(); setStatus('Tab view is for fretted tracks β€” switched back to this track’s normal view.'); @@ -285,7 +285,7 @@ export function editorToggleTabView(force) { } const arr = S.arrangements && S.arrangements[S.currentArr]; if (!arr) { setStatus('Load a song first.'); return true; } - if (KEYS_PATTERN.test(arr.name || '') || /^drums/i.test(arr.name || '')) { + if (arrKind(arr) === 'keys' || arrKind(arr) === 'drums') { setStatus('Tab view is for fretted tracks β€” keys and drums have no tab.'); return true; } diff --git a/src/tempo.js b/src/tempo.js index e54c0ece..aec3fcbe 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -3769,6 +3769,14 @@ export function _stripBeatsFromSaveBody(body) { if (body.drum_tab && Array.isArray(body.drum_tab.hits)) { body.drum_tab = { ...body.drum_tab, hits: _stripBeatsList(body.drum_tab.hits) }; } + // Extra drum parts (multiple drum charts) β€” same seconds-only wire rule + // as the primary drum_tab above. + if (Array.isArray(body.drum_parts)) { + body.drum_parts = body.drum_parts.map(p => + (p && p.drum_tab && Array.isArray(p.drum_tab.hits)) + ? { ...p, drum_tab: { ...p.drum_tab, hits: _stripBeatsList(p.drum_tab.hits) } } + : p); + } return body; } diff --git a/src/track-session.js b/src/track-session.js index 3af6c14d..91f9d774 100644 --- a/src/track-session.js +++ b/src/track-session.js @@ -32,7 +32,9 @@ // ════════════════════════════════════════════════════════════════════ import { host } from './host.js'; import { _renameGuardPure } from './arrangement.js'; +import { drumArrangementIndex, findDrumArrangement, isDrumArrangement, pitchedArrangementCount, syncDrumArrangement } from './drum-arrangement.js'; import { _partViewKeyPure } from './keys.js'; +import { arrKind, _arrTypeKind } from './instrument.js'; import { _mixerPanelRefresh, _mixerPartStatePure, mixerSetPart, mixerTogglePart } from './mixer-panel.js'; import { S, markSessionDirty } from './state.js'; import { _editorEscHtml, setStatus } from './ui.js'; @@ -41,6 +43,24 @@ const MASTER_ID = 'master'; const DRUM_TARGET_ID = 'drums'; const VERSION = 2; const TRACK_LANE_DEFAULT = 56; + +// The Tracks-view kind badge [abbr, tooltip]. An audio row shows its LAYER +// (Mix / Audio); a transcription row shows its INSTRUMENT, read +// type-authoritatively via `arrKind` (so a mis-named part badges by what it IS, +// not its name) β€” the drum target is drums by identity. Pure: takes the row + +// arrangements, so it unit-tests without the DOM. +const _KIND_ABBR = { + guitar: ['GTR', 'Guitar'], bass: ['BAS', 'Bass'], keys: ['KEY', 'Keys'], + drums: ['DRM', 'Drums'], vocals: ['VOX', 'Vocals'], +}; +export function _trackKindBadgePure(row, arrangements) { + if (!row) return ['', '']; + if (row.type === 'audio') return row.sourceKind === 'master' ? ['MIX', 'Master mix'] : ['AUD', 'Audio']; + if (row.targetId === DRUM_TARGET_ID) return _KIND_ABBR.drums; + const idx = (row.mixKey && row.mixKey.startsWith('arr:')) ? Number(row.mixKey.slice(4)) : -1; + const arr = (idx >= 0 && Array.isArray(arrangements)) ? arrangements[idx] : null; + return _KIND_ABBR[arrKind(arr)] || _KIND_ABBR.guitar; +} const TRACK_LANE_MIN = 28; const TRACK_LANE_MAX = 160; const idOf = (value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 160 ? value.trim() : ''; @@ -83,9 +103,14 @@ export function _clickSourcePure(sources) { && (CLICK_NAME.test(String(source.name || '')) || CLICK_NAME.test(String(source.id || '')))) || null; } -// The transcription targets: every arrangement plus the drum tab. targetId -// is the durable chart-track key (shared with stemLinks); mixKey is the -// session address partMix / band mode speak ('arr:' / 'drums'). +// The transcription targets: every arrangement β€” drums arrangements included, +// each drum part its own row (a song can hold several). targetId is the +// durable chart-track key (shared with stemLinks): a drums arrangement's id +// ('drums' for the primary, 'drums-2'…) doubles as its target id, so the +// primary's hooks are unchanged. mixKey is the session address partMix / band +// mode speak β€” always 'arr:'. The one legacy case: a drum tab that was +// never materialized as an arrangement (create-mode compose) still gets the +// synthesized 'drums' row so its track doesn't vanish there. export function _trackSessionTargetsPure(arrangements, drumTab) { const out = []; const seen = new Set(); @@ -98,7 +123,7 @@ export function _trackSessionTargetsPure(arrangements, drumTab) { seen.add(id); out.push({ id, name: String(arr.name || ('Track ' + (index + 1))).slice(0, 120), mixKey: 'arr:' + index }); }); - if (drumTab && Array.isArray(drumTab.hits)) { + if (drumTab && Array.isArray(drumTab.hits) && drumArrangementIndex(arrangements) < 0) { out.push({ id: DRUM_TARGET_ID, name: String(drumTab.name || 'Drums').slice(0, 120), mixKey: 'drums' }); } return out; @@ -118,6 +143,22 @@ export function _partMixDropArrangementPure(partMix, index) { return out; } +// Inverse of _partMixDropArrangementPure: open a slot at `index` (renumber every +// arr: with n >= index UP one) and drop `strip` in at arr:. Renumbers +// the CURRENT map, so any live mute/solo/volume edits made while the slot was +// gone ride along to their restored key instead of being clobbered. +export function _partMixInsertArrangementPure(partMix, index, strip) { + const out = {}; + for (const [key, value] of Object.entries(partMix && typeof partMix === 'object' ? partMix : {})) { + if (!key.startsWith('arr:')) { out[key] = value; continue; } + const n = Number(key.slice(4)); + if (!Number.isInteger(n)) continue; + out[n >= index ? 'arr:' + (n + 1) : key] = value; + } + if (strip !== undefined) out['arr:' + index] = strip; + return out; +} + // Normalize any persisted/half-trusted tree against the loaded song: drop // rows whose source/target no longer exists, append rows for anything new, // repair parent cycles, and default the tempo guide. Idempotent β€” this is @@ -432,8 +473,8 @@ export function _trackFocusSourcePure(row) { return row.pairedSourceId || MASTER_ID; } -export function _trackTranscriptionRenameGuardPure(oldName, requested, otherNames) { - return _renameGuardPure(oldName, requested, otherNames); +export function _trackTranscriptionRenameGuardPure(oldName, requested, otherNames, typed) { + return _renameGuardPure(oldName, requested, otherNames, typed); } // True when the tree carries nothing the canonical song doesn't already @@ -697,8 +738,10 @@ function render() { const style = `--track-indent:${indent}px;--track-row-height:${height}px`; const selected = row.id === S.selectedTrackId ? ' editor-track-selected' : ''; if (row.type === 'folder') return `
${trackName(row, `${name}`)}${resizeGrip(row)}
`; - if (row.type === 'audio') return `
${row.sourceKind === 'master' ? 'MIX' : 'AUD'}${trackName(row, `${name}`)}${mixControls(row)}${resizeGrip(row)}
`; - return `
${row.targetId === DRUM_TARGET_ID ? 'DRM' : 'MIDI'}${trackName(row, ``)}${mixControls(row)}${resizeGrip(row)}
`; + const [kindAbbr, kindTitle] = _trackKindBadgePure(row, S.arrangements); + const kindBadge = `${kindAbbr}`; + if (row.type === 'audio') return `
${kindBadge}${trackName(row, `${name}`)}${mixControls(row)}${resizeGrip(row)}
`; + return `
${kindBadge}${trackName(row, ``)}${mixControls(row)}${resizeGrip(row)}
`; }).join('')}`; const list = el.querySelector('.editor-track-session-list'); if (list) list.scrollTop = Math.max(0, Number(S.trackScrollY) || 0); @@ -743,7 +786,11 @@ function applyTrackRename(trackId, requested) { if (!requested || !requested.trim()) return false; const current = _rowsLive().rows.find(row => row.id === trackId); let clean = requested.trim().slice(0, 120); - if (current?.type === 'transcription' && current.targetId !== DRUM_TARGET_ID) { + if (current?.type === 'transcription') { + // Drum parts flow the same arrangement-name guard as pitched parts + // now (typed β†’ the kind-change refusal is skipped, but empty / + // too-long / duplicate names still refuse). The legacy unmaterialized + // 'drums' row (create mode) has no index and skips the guard, as before. const target = _trackSessionTargetsPure(S.arrangements, S.drumTab) .find(item => item.id === current.targetId); const index = target && target.mixKey.startsWith('arr:') ? Number(target.mixKey.slice(4)) : -1; @@ -751,7 +798,7 @@ function applyTrackRename(trackId, requested) { const otherNames = S.arrangements .filter((_, i) => i !== index).map(arr => arr && arr.name); const guard = _trackTranscriptionRenameGuardPure( - S.arrangements[index].name, requested, otherNames); + S.arrangements[index].name, requested, otherNames, !!_arrTypeKind(S.arrangements[index])); if (!guard.ok) { if (guard.reason) setStatus(guard.reason); return false; } clean = guard.name; } @@ -760,13 +807,26 @@ function applyTrackRename(trackId, requested) { let renamed = next.tracks.find(track => track.id === trackId); if (!renamed) return false; if (renamed.type === 'transcription') { - if (renamed.targetId === DRUM_TARGET_ID && S.drumTab) S.drumTab.name = clean; const targets = _trackSessionTargetsPure(S.arrangements, S.drumTab); const target = targets.find(item => item.id === renamed.targetId); const index = target && target.mixKey.startsWith('arr:') ? Number(target.mixKey.slice(4)) : -1; + if (index < 0 && renamed.targetId === DRUM_TARGET_ID && S.drumTab) { + // Legacy unmaterialized drum tab (create mode): the tab's own name + // field is the persisted name. + S.drumTab.name = clean; + S.drumTabDirty = true; + } if (index >= 0 && S.arrangements[index]) { const oldTargetId = renamed.targetId; S.arrangements[index].name = clean; + if (isDrumArrangement(S.arrangements[index])) { + // A drum part's PERSISTED name lives inside its tab JSON β€” + // follow it (this part's own tab, NOT S.drumTab: the row being + // renamed need not be the part open in the drum grid). + const tab = S.arrangements[index].drumTab; + if (tab && typeof tab === 'object') tab.name = clean; + S.drumTabDirty = true; + } const newTargetId = idOf(_partViewKeyPure(S.arrangements[index])) || oldTargetId; if (newTargetId !== oldTargetId) { next = _trackSessionRetargetPure(next, oldTargetId, newTargetId); @@ -816,61 +876,111 @@ function selectTrack(trackId, openEditor = false) { return true; } -// Deleting the drum transcription, as ONE undoable command. This used to -// blank S.drumTab in place and RESET the whole undo stack β€” losing not just -// the delete but every prior edit's undo (the shortcut for "commands in the +// Deleting ONE drum part, as ONE undoable command. This used to blank +// S.drumTab in place and RESET the whole undo stack β€” losing not just the +// delete but every prior edit's undo (the shortcut for "commands in the // stack hold references into the tab"). As a command, stack ORDER gives the // same guarantee for free: no older drum edit can be undone until this // rollback has put the very same tab object back. // +// `arrIndex` names the part (its position in S.arrangements); it defaults to +// the PRIMARY so legacy single-part callers are unchanged, and -1 (no +// materialized part β€” a create-mode tab) keeps the old singleton semantics. +// Deleting the ACTIVE part hands the grid to the next remaining part, or +// exits drum-edit mode when it was the last one; deleting the PRIMARY +// promotes the next part (its tab ships as the song-level drum_tab on the +// next save β€” the back-compat alias always names the first part). +// // The capture set is everything the delete touches: -// - the tab REFERENCE (identity matters β€” older drum commands hold -// references into its hits; restoring the same object keeps them valid); +// - the ARRANGEMENT + tab REFERENCE (identity matters β€” older drum +// commands hold references into its hits; restoring the same objects +// keeps them valid) and its position (rollback re-inserts, not appends); // - the drumTabDirty flag (a tab loaded from disk and deleted must return // to clean on undo, so an unrelated later save doesn't re-serialize it); -// - the drums mixer strip (session mix state, dropped by the delete); +// - the WHOLE partMix map (the splice renumbers every higher arr: +// strip β€” restoring the exact map is the only correct inverse); +// - drum-edit mode / active tab / currentArr (the splice may shift it); // - the pairing map (replaced immutably by _trackLinksRetargetPure, so the // captured reference IS the restore); // - the tree (normalize drops the drum row on exec; committing the // captured tree back restores its folder placement and display rename). export class DeleteDrumTabCmd { - constructor(rowName) { + constructor(rowName, arrIndex = drumArrangementIndex(S.arrangements)) { // Song-level data (like tempo-grid commands): the read-only-roll lock // must not block deleting/undeleting drums while a fretted part is // shown in the piano roll. this.songScope = true; this._name = rowName; - this._tab = S.drumTab; + this._index = Number.isInteger(arrIndex) ? arrIndex : -1; + this._arr = this._index >= 0 ? (S.arrangements[this._index] || null) : null; + this._targetId = this._arr ? String(this._arr.id || DRUM_TARGET_ID) : DRUM_TARGET_ID; + this._tab = this._arr ? this._arr.drumTab : S.drumTab; + this._wasActive = !!this._tab && S.drumTab === this._tab; + this._mode = !!S.drumEditMode; + this._currentArr = S.currentArr; this._dirty = !!S.drumTabDirty; - this._hadMixStrip = !!(S.partMix && ('drums' in S.partMix)); - this._mixStrip = S.partMix ? S.partMix.drums : undefined; + this._drumStrip = (S.partMix && this._index >= 0) ? S.partMix['arr:' + this._index] : undefined; this._links = S.stemLinks; this._tree = S.trackSession; this._selectedTrackId = S.selectedTrackId; } exec() { - S.drumTab = null; - // Dirty is what ships the explicit `drum_tab: null` removal on the - // next save (see _buildSaveBody) β€” without it the backend's - // absentβ†’preserve path would resurrect drum_tab.json on reload. + if (this._arr) { + const at = S.arrangements.indexOf(this._arr); + if (at >= 0) { + S.arrangements.splice(at, 1); + // A splice below the current pitched part shifts its index. + if (at < S.currentArr) S.currentArr -= 1; + // Renumber the higher arr: strips (drop this part's own). + S.partMix = _partMixDropArrangementPure(S.partMix, at); + } + if (this._wasActive) { + // The grid moves to the next remaining part, or closes. + const next = findDrumArrangement(S.arrangements); + S.drumTab = next ? next.drumTab : null; + S.drumSel = new Set(); + if (!next) S.drumEditMode = false; + } + } else { + // Legacy unmaterialized tab (create-mode compose). + S.drumTab = null; + syncDrumArrangement(S); + } + // Dirty is what ships the removal on the next save (see + // _buildSaveBody: the primary as an explicit `drum_tab` null / a new + // promoted primary, the extras as the rebuilt `drum_parts` list) β€” + // without it the backend's absentβ†’preserve path would resurrect the + // deleted part on reload. S.drumTabDirty = true; - if (S.partMix) delete S.partMix.drums; - S.stemLinks = _trackLinksRetargetPure(S.stemLinks, DRUM_TARGET_ID); - if (S.selectedTrackId === transcriptionTrackId(DRUM_TARGET_ID)) { + S.stemLinks = _trackLinksRetargetPure(S.stemLinks, this._targetId); + if (S.selectedTrackId === transcriptionTrackId(this._targetId)) { S.selectedTrackId = ''; } commit(S.trackSession, `Deleted drum transcription β€œ${this._name}”.`); host.partMixChanged(); } rollback() { - S.drumTab = this._tab; - S.drumTabDirty = this._dirty; - if (this._hadMixStrip) { - if (!S.partMix) S.partMix = {}; - S.partMix.drums = this._mixStrip; + if (this._arr) { + const at = Math.max(0, Math.min(this._index, S.arrangements.length)); + S.arrangements.splice(at, 0, this._arr); + if (this._wasActive) { + S.drumTab = this._tab; + S.drumSel = new Set(); + } + S.drumEditMode = this._mode; + S.currentArr = this._currentArr; + } else { + S.drumTab = this._tab; + syncDrumArrangement(S); // restore the derived type:"drums" arrangement } + // Re-open the restored slot in the CURRENT map so live mix edits made + // while this drum part was absent survive the undo. + if (this._arr && S.partMix) { + S.partMix = _partMixInsertArrangementPure(S.partMix, this._index, this._drumStrip); + } + S.drumTabDirty = this._dirty; S.stemLinks = this._links; - if (this._selectedTrackId === transcriptionTrackId(DRUM_TARGET_ID) + if (this._selectedTrackId === transcriptionTrackId(this._targetId) && !S.selectedTrackId) { S.selectedTrackId = this._selectedTrackId; } @@ -893,29 +1003,33 @@ async function deleteTrack(trackId) { `Removed audio track β€œ${row.name}” β€” the media stays inside the project.`); host.partMixChanged(); host.audioSourcesChanged(); - } else if (row.targetId === DRUM_TARGET_ID) { - if (!confirm(`Delete drum transcription β€œ${row.name}”?`)) return false; - const cmd = new DeleteDrumTabCmd(row.name); - if (S.history) S.history.exec(cmd); else cmd.exec(); } else { const targets = _trackSessionTargetsPure(S.arrangements, S.drumTab); const target = targets.find(item => item.id === row.targetId); const index = target && target.mixKey.startsWith('arr:') ? Number(target.mixKey.slice(4)) : -1; - if (index < 0) return false; - if (S.arrangements.length <= 1) { - setStatus('A feedpak requires at least one transcription arrangement; add another before deleting this track.'); - return false; + const arr = index >= 0 ? S.arrangements[index] : null; + if ((arr && isDrumArrangement(arr)) || (!arr && row.targetId === DRUM_TARGET_ID)) { + // A drum part (any of them), or the legacy unmaterialized tab. + if (!confirm(`Delete drum transcription β€œ${row.name}”?`)) return false; + const cmd = new DeleteDrumTabCmd(row.name, arr ? index : -1); + if (S.history) S.history.exec(cmd); else cmd.exec(); + } else { + if (index < 0) return false; + if (pitchedArrangementCount(S.arrangements) <= 1) { + setStatus('A feedpak requires at least one transcription arrangement; add another before deleting this track.'); + return false; + } + S.currentArr = index; + const removed = await window.editorRemoveArrangement(); + if (!removed) return false; + S.stemLinks = _trackLinksRetargetPure(S.stemLinks, row.targetId); + // Preserve every surviving strip's mute/solo/volume β€” the arrangement + // splice renumbers the higher indices, so shift the keys, don't wipe. + S.partMix = _partMixDropArrangementPure(S.partMix, index); + S.trackSession = _trackSessionNormalizePure(S.trackSession, _liveSources(), S.arrangements, S.drumTab); + lastRender = ''; + refreshTrackSession(); } - S.currentArr = index; - const removed = await window.editorRemoveArrangement(); - if (!removed) return false; - S.stemLinks = _trackLinksRetargetPure(S.stemLinks, row.targetId); - // Preserve every surviving strip's mute/solo/volume β€” the arrangement - // splice renumbers the higher indices, so shift the keys, don't wipe. - S.partMix = _partMixDropArrangementPure(S.partMix, index); - S.trackSession = _trackSessionNormalizePure(S.trackSession, _liveSources(), S.arrangements, S.drumTab); - lastRender = ''; - refreshTrackSession(); } if (S.selectedTrackId === row.id) S.selectedTrackId = ''; _mixerPanelRefresh(); diff --git a/tests/arrangement_type_control.test.mjs b/tests/arrangement_type_control.test.mjs new file mode 100644 index 00000000..cfcec89a --- /dev/null +++ b/tests/arrangement_type_control.test.mjs @@ -0,0 +1,168 @@ +/* + * Instrument-type control β€” the escape hatch that AUTHORS an arrangement's + * `type` so a fretted (string+fret) chart whose NAME contains a keys word + * ("Electric Piano", "Lead Synth") is no longer piano-locked. The authored + * type WINS over name inference in every reader (arrKind / _isBassArr / + * viewFor / isKeysArr), and the set is undoable (Principle IV). + * + * Fails on unfixed code: SetArrangementTypeCmd does not exist in + * src/arrangement.js, so there is no way to override the name inference and a + * keys-named fretted part stays keys-locked. + * + * Run: node tests/arrangement_type_control.test.mjs + */ +import assert from 'node:assert'; +import fs from 'node:fs'; +import { EditHistory } from '../src/history.js'; +import { arrKind, _isBassArr } from '../src/instrument.js'; +import { isKeysArr, viewFor } from '../src/keys.js'; +import { seedState, setRollView, trackHooks } from './_history_env.mjs'; +import { editorSetArrangementType } from '../src/arrangement.js'; + +const src = fs.readFileSync(new URL('../src/arrangement.js', import.meta.url), 'utf8'); +function extractClass(name) { + const start = src.indexOf('class ' + name); + assert.ok(start >= 0, `class ${name} must exist in src/arrangement.js`); + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1); + } + throw new Error(`unbalanced braces extracting ${name}`); +} + +let pass = 0, fail = 0; +const t = (name, fn) => { + try { fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +}; + +// EditHistory closes over the REAL S; the sliced command must share it (same +// pattern as rename_part.test.mjs). Inject the host stubs the command calls. +function makeEnv(arrangements) { + const S = seedState({ arrangements }); + const calls = { selector: 0, lane: 0 }; + const env = new Function( + 'S', 'host', + '"use strict";' + extractClass('SetArrangementTypeCmd') + + '\nreturn { SetArrangementTypeCmd };', + )(S, { + updateArrangementSelector: () => { calls.selector++; }, + resizeForLaneChange: () => { calls.lane++; }, + }); + trackHooks(); + return { ...env, S, calls, history: new EditHistory() }; +} + +t('authoring type=guitar rescues a fretted chart stamped type=piano (arrKind/isKeysArr/viewFor β†’ guitar/string)', () => { + // The real bug: an older save word-boundary-inferred "Electric Piano" and + // stamped `type: piano` into the manifest. This PR honors that stamped type, + // so the fretted chart now loads piano-locked with no way out β€” until this + // control overrides it. + const { S, history, SetArrangementTypeCmd, calls } = makeEnv( + [{ id: 'a1', name: 'Electric Piano', type: 'piano', notes: [], tuning: [40, 45, 50, 55, 59, 64] }]); + assert.strictEqual(arrKind(S.arrangements[0]), 'keys', 'stamped type=piano loads it keys'); + assert.strictEqual(viewFor(S.arrangements[0]), 'piano', 'so it opens piano-locked'); + history.exec(new SetArrangementTypeCmd(0, 'guitar')); + assert.strictEqual(S.arrangements[0].type, 'guitar'); + assert.strictEqual(arrKind(S.arrangements[0]), 'guitar', 'authored type wins over the keys name'); + assert.strictEqual(isKeysArr(), false, 'no longer keys-locked'); + assert.strictEqual(viewFor(S.arrangements[0]), 'string', 'now opens in string view'); + assert.ok(calls.selector >= 1, 'selector refreshed so the control follows'); + assert.ok(calls.lane >= 1, 'lane metrics rebuilt in place'); +}); + +t('authoring type=bass wins over a keys-word name for the bass predicate', () => { + const { S, history, SetArrangementTypeCmd } = makeEnv( + [{ id: 'a1', name: 'Grand Piano', notes: [], tuning: [28, 33, 38, 43] }]); + assert.strictEqual(_isBassArr(S.arrangements[0]), false, 'name is not /bass/'); + history.exec(new SetArrangementTypeCmd(0, 'bass')); + assert.strictEqual(_isBassArr(S.arrangements[0]), true, 'authored bass wins'); + assert.strictEqual(arrKind(S.arrangements[0]), 'bass'); +}); + +t('the type set is undoable β€” undo clears an authored-onto-untyped type, redo re-applies', () => { + // Untyped, keys-PREFIX name β†’ keys by name inference (the fallback path). + const { S, history, SetArrangementTypeCmd } = makeEnv( + [{ id: 'a1', name: 'Piano', notes: [] }]); + assert.strictEqual(S.arrangements[0].type, undefined, 'starts untyped'); + history.exec(new SetArrangementTypeCmd(0, 'guitar')); + assert.strictEqual(arrKind(S.arrangements[0]), 'guitar'); + history.doUndo(); + assert.strictEqual(S.arrangements[0].type, undefined, 'undo removes the authored type'); + assert.strictEqual(arrKind(S.arrangements[0]), 'keys', 'back to name inference'); + history.doRedo(); + assert.strictEqual(S.arrangements[0].type, 'guitar', 'redo re-applies'); +}); + +t('undo restores a PRIOR authored type (keys β†’ guitar β†’ undo β†’ keys), never deletes it', () => { + const { S, history, SetArrangementTypeCmd } = makeEnv( + [{ id: 'a1', name: 'Lead', type: 'keys', notes: [] }]); + assert.strictEqual(arrKind(S.arrangements[0]), 'keys'); + history.exec(new SetArrangementTypeCmd(0, 'guitar')); + assert.strictEqual(arrKind(S.arrangements[0]), 'guitar'); + history.doUndo(); + assert.strictEqual(S.arrangements[0].type, 'keys', 'prior authored type restored, not deleted'); +}); + +t('the escape hatch works even when the fretted part is shown READ-ONLY in the roll (lock opt-out)', () => { + // A fretted part with a non-keys name, manually flipped into the piano roll, + // is read-only (_rollReadOnly). The type control is still shown there β€” and + // is exactly the escape hatch that state needs. A type set writes no note, + // so the note-write lock must not swallow it. Pre-fix: SetArrangementTypeCmd + // carried no lock opt-out, so _locked() blocked it and arr.type never moved. + const { S, history, SetArrangementTypeCmd } = makeEnv( + [{ id: 'a1', name: 'Rhythm', notes: [], tuning: [40, 45, 50, 55, 59, 64] }]); + setRollView(true); // fretted part β†’ piano roll β†’ _rollReadOnly() === true + assert.strictEqual(arrKind(S.arrangements[0]), 'guitar', 'starts fretted'); + history.exec(new SetArrangementTypeCmd(0, 'bass')); + assert.strictEqual(S.arrangements[0].type, 'bass', 'type set applied despite the read-only roll'); + assert.strictEqual(arrKind(S.arrangements[0]), 'bass', 'reader honors it'); + history.doUndo(); + assert.strictEqual(S.arrangements[0].type, undefined, 'undo also passes the lock'); +}); + +t('picking Keys authors the CANONICAL spec type "piano", not the read-only alias "keys"', () => { + // Spec Β§5.2 spells the keyboard type "piano"; "keys" is only a READ alias. + // The backend persists arr.type verbatim, so the control must WRITE "piano" + // or a corrected keys track lands in the manifest with a spelling other + // consumers (Keys Highway 3D, Staff View) keyed on "piano" won't recognize. + // Pre-fix: editorSetArrangementType passed the raw "keys" option value through. + const S = seedState({ + arrangements: [{ id: 'a1', name: 'Rhythm', notes: [], tuning: [40, 45, 50, 55, 59, 64] }], + }); + S.history = new EditHistory(); + trackHooks(); + editorSetArrangementType('keys'); + assert.strictEqual(S.arrangements[0].type, 'piano', 'writes the canonical spec spelling'); + assert.strictEqual(arrKind(S.arrangements[0]), 'keys', 'still resolves to the keys kind'); + // guitar/bass are already canonical spellings β€” verify they pass through as-is. + editorSetArrangementType('bass'); + assert.strictEqual(S.arrangements[0].type, 'bass', 'bass is its own canonical spelling'); +}); + +t('picking the currently-INFERRED kind AUTHORS it (frees the rename guard), not a no-op', () => { + // An untyped "Bass" track infers bass by NAME. The rename guard refuses to + // rename an untyped track to a name that changes its inferred kind + // ("Bass" β†’ "Low End" would read guitar), and only a typed track escapes + // that. So stamping the inferred kind is the intended unlock β€” the old + // guard (kind === arrKind, effective) wrongly no-op'd it because the name + // already inferred bass, trapping the workflow. + const S = seedState({ + arrangements: [{ id: 'a1', name: 'Bass', notes: [], tuning: [28, 33, 38, 43] }], + }); + S.history = new EditHistory(); + trackHooks(); + assert.strictEqual(S.arrangements[0].type, undefined, 'starts untyped (name-inferred bass)'); + assert.strictEqual(arrKind(S.arrangements[0]), 'bass', 'effective kind already bass by name'); + editorSetArrangementType('bass'); + assert.strictEqual(S.arrangements[0].type, 'bass', 'authored the inferred kind so identity is now DATA'); + // Re-picking the SAME authored kind is a genuine no-op (no second history entry). + const undoLen = S.history.undo.length; + editorSetArrangementType('bass'); + assert.strictEqual(S.history.undo.length, undoLen, 're-picking an already-authored kind costs nothing'); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +if (fail) process.exit(1); diff --git a/tests/canvas_string_buttons.test.mjs b/tests/canvas_string_buttons.test.mjs index fba5e73b..185e2565 100644 --- a/tests/canvas_string_buttons.test.mjs +++ b/tests/canvas_string_buttons.test.mjs @@ -126,14 +126,14 @@ t('remove-with-notes undo and redo stay bound to their original arrangement', () assert.deepStrictEqual(snap(other), otherBefore, 'redo leaves the selected arrangement untouched'); }); -t('visibility: fretted String view only', () => { +t('visibility: fretted parts only (now takes the resolved kind, not a name)', () => { const none = {}; - assert.strictEqual(_stringButtonsVisiblePure('Lead', none), true); - assert.strictEqual(_stringButtonsVisiblePure('Bass', none), true); - assert.strictEqual(_stringButtonsVisiblePure('Keys', none), false); - assert.strictEqual(_stringButtonsVisiblePure('Drums', none), false); + assert.strictEqual(_stringButtonsVisiblePure('guitar', none), true); + assert.strictEqual(_stringButtonsVisiblePure('bass', none), true); + assert.strictEqual(_stringButtonsVisiblePure('keys', none), false); + assert.strictEqual(_stringButtonsVisiblePure('drums', none), false); for (const flag of ['keysMode', 'drumEdit', 'tempoMap', 'partsView', 'tabView']) { - assert.strictEqual(_stringButtonsVisiblePure('Lead', { [flag]: true }), false, flag); + assert.strictEqual(_stringButtonsVisiblePure('guitar', { [flag]: true }), false, flag); } }); diff --git a/tests/create_drum_materialize.test.js b/tests/create_drum_materialize.test.js new file mode 100644 index 00000000..74760453 --- /dev/null +++ b/tests/create_drum_materialize.test.js @@ -0,0 +1,58 @@ +'use strict'; +/* + * #336 regression: the create/import completion path (editorApplyCreateResult) + * must MATERIALIZE the drums arrangement. It installs S.arrangements and then + * assigns S.drumTab from the import's `drum_tab`, but pre-fix it never called + * syncDrumArrangement(S) β€” so a freshly imported drum song had NO type:"drums" + * arrangement until it was built and reopened (loadCDLC / +Drums / drum-delete + * undo all sync; this path was the odd one out). + * + * editorApplyCreateResult is async, DOM-heavy, and fires session teardown + + * host hooks + an async stem decode, so it is pinned structurally (the + * reorder_part.test.js precedent reads src to test an otherwise-unmountable + * seam): the fix is a single call, in the right order, on the real function. + * Fails pre-fix, where the call is absent. + * + * Run: node tests/create_drum_materialize.test.js + */ +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'create.js'), 'utf8'); + +function extractFn(name) { + const start = src.indexOf('function ' + name); + assert.ok(start >= 0, `function ${name} must exist`); + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1); + } + throw new Error(`unbalanced braces extracting ${name}`); +} + +let pass = 0, fail = 0; +function t(name, fn) { + try { fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +t('create.js imports syncDrumArrangement from the drum-arrangement leaf', () => { + assert.ok(/import\s*\{[^}]*\bsyncDrumArrangement\b[^}]*\}\s*from\s*['"]\.\/drum-arrangement\.js['"]/.test(src), + 'syncDrumArrangement must be imported to materialize the drums arrangement'); +}); + +t('editorApplyCreateResult calls syncDrumArrangement AFTER assigning S.drumTab', () => { + const body = extractFn('editorApplyCreateResult'); + const assignAt = body.indexOf('S.drumTab = data.drum_tab'); + const syncAt = body.indexOf('syncDrumArrangement(S)'); + assert.ok(assignAt >= 0, 'the path assigns S.drumTab from the import'); + assert.ok(syncAt >= 0, 'the path materializes the drums arrangement (syncDrumArrangement(S))'); + assert.ok(syncAt > assignAt, + 'sync must run AFTER the drumTab assignment, so it materializes the live payload'); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +if (fail) process.exit(1); diff --git a/tests/create_save_routing.test.mjs b/tests/create_save_routing.test.mjs index 86401027..bcc8f347 100644 --- a/tests/create_save_routing.test.mjs +++ b/tests/create_save_routing.test.mjs @@ -26,6 +26,7 @@ globalThis.document = globalThis.document || { getElementById: () => null }; const { saveCDLC, _removeEmptyPickedFile, _suggestedSaveNamePure } = await import('../src/file-ops.js'); +const { _drumBuildPayloadPure } = await import('../src/create.js'); const { S } = await import('../src/state.js'); let pass = 0, fail = 0; @@ -71,6 +72,24 @@ await t('create-mode saveCDLC routes to /build (never /save) and reports durable 'a build IS the durable save β€” the dirty flag clears so the close guard stays quiet'); }); +await t('create-mode build ships an authored arr.type (a re-typed track is not re-inferred from its name)', async () => { + seedCreateSession(); + // A fretted part the user re-typed to guitar via the new selector, whose NAME + // still reads keys. Build must carry the authored type or /build re-infers + // "piano" from the name and the correction is lost on the first save. + S.arrangements[0].name = 'Electric Piano'; + S.arrangements[0].type = 'guitar'; + let body = null; + globalThis.fetch = async (url, opts) => { + body = JSON.parse(opts.body); + return { json: async () => ({ success: true, filename: 'x.feedpak' }) }; + }; + await saveCDLC(); + assert.ok(Array.isArray(body.arrangements), 'build ships the arrangement snapshot'); + assert.strictEqual(body.arrangements[0].type, 'guitar', + 'the authored type rides the build payload (else /build re-infers keys from the name)'); +}); + await t('a failed build reports save failure and keeps the session dirty', async () => { seedCreateSession(); globalThis.fetch = async () => ({ json: async () => ({ error: 'DLC folder not configured' }) }); @@ -140,5 +159,67 @@ await t('_suggestedSaveNamePure: create mode derives the name the build will wri assert.strictEqual(_suggestedSaveNamePure(null, null, null), 'song.feedpak'); }); +// ── create-mode N drums: the /build drum payload (feedpak 1.17.0) ───────── +const dtab = (name, hits = [{ t: 1, p: 'kick' }]) => ({ version: 1, name, kit: [], hits }); + +await t('_drumBuildPayloadPure: primary = first drums arr (NOT the active tab); extras follow', () => { + const kit = dtab('Drums'); + const live = dtab('Drums (Live)', [{ t: 2, p: 'snare' }]); + const arrs = [ + { id: 'lead', name: 'Lead', notes: [] }, + { id: 'drums', name: 'Drums', type: 'drums', drumTab: kit }, + { id: 'drums-2', name: 'Drums (Live)', type: 'drums', drumTab: live }, + ]; + // The user is editing the SECOND part (active tab = live) β€” the primary + // shipped must still be the FIRST part's tab, not the active one. + const payload = _drumBuildPayloadPure(arrs, live); + assert.strictEqual(payload.drum_tab, kit, 'primary is the first drums arrangement, not the active tab'); + assert.deepStrictEqual(payload.drum_parts, [ + { id: 'drums-2', name: 'Drums (Live)', drum_tab: live }, + ]); +}); + +await t('_drumBuildPayloadPure: one drum part ships an EMPTY extras list (writes the alias entry)', () => { + const kit = dtab('Drums'); + const payload = _drumBuildPayloadPure( + [{ id: 'lead', name: 'Lead' }, { id: 'drums', name: 'Drums', type: 'drums', drumTab: kit }], kit); + assert.strictEqual(payload.drum_tab, kit); + assert.deepStrictEqual(payload.drum_parts, [], 'materialized single drum β†’ empty extras (not absent)'); +}); + +await t('_drumBuildPayloadPure: a legacy unmaterialized tab stays byte-identical (no drum_parts key)', () => { + const kit = dtab('Drums'); + // Drums-only compose (no pitched part β†’ no drums arrangement): the tab + // rides S.drumTab and there is NO drum_parts key at all. + const payload = _drumBuildPayloadPure([], kit); + assert.strictEqual(payload.drum_tab, kit); + assert.strictEqual('drum_parts' in payload, false); + // No drums at all β†’ drum_tab null, still no drum_parts. + assert.deepStrictEqual(_drumBuildPayloadPure([{ id: 'lead' }], null), { drum_tab: null }); +}); + +await t('create-mode build ships the drum parts on the /build wire (real editorBuild through saveCDLC)', async () => { + seedCreateSession(); + const kit = dtab('Drums'); + const live = dtab('Drums (Live)', [{ t: 2, p: 'snare' }]); + // Two materialized drum parts; the user is on the secondary in the grid. + S.arrangements.push({ id: 'drums', name: 'Drums', type: 'drums', drumTab: kit }); + S.arrangements.push({ id: 'drums-2', name: 'Drums (Live)', type: 'drums', drumTab: live }); + S.drumTab = live; + let body = null; + globalThis.fetch = async (url, opts) => { + body = JSON.parse(opts.body); + return { json: async () => ({ success: true, filename: 'x.feedpak' }) }; + }; + const ok = await saveCDLC(); + assert.strictEqual(ok, true); + assert.deepStrictEqual(body.drum_tab, kit, 'the PRIMARY tab ships as drum_tab, not the active secondary'); + assert.strictEqual(body.drum_parts.length, 1); + assert.strictEqual(body.drum_parts[0].id, 'drums-2'); + assert.strictEqual(body.drum_parts[0].name, 'Drums (Live)'); + assert.deepStrictEqual(body.drum_parts[0].drum_tab, live); + S.arrangements = []; S.drumTab = null; +}); + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); diff --git a/tests/drum_arrangement.test.mjs b/tests/drum_arrangement.test.mjs new file mode 100644 index 00000000..2b54e431 --- /dev/null +++ b/tests/drum_arrangement.test.mjs @@ -0,0 +1,293 @@ +/* + * Drums as a first-class arrangement β€” the drums-as-arrangements foundation + * (src/drum-arrangement.js + the migration/save/target wiring it drives). + * + * The single drum tab (S.drumTab) now lives IN S.arrangements[] as a derived + * `type:"drums"` arrangement whose `.drumTab` payload IS S.drumTab (same object + * reference). Pinned here β€” the STATEFUL wiring, not just the pure predicate: + * - syncDrumArrangement materializes / updates / removes that arrangement and + * keeps the SAME payload reference (so drum-editor undo refs never dangle); + * - it APPENDS (never inserts), so existing arr indices / arr: keys hold; + * - identity is TYPE-only: a pitched arrangement a user named "Drums" is NOT a + * drums arrangement, so it is never hidden, filtered, or dropped from a save; + * - the save body EXCLUDES the drums arrangement (drums persist as the song- + * level drum_tab), so the built pack stays byte-identical; + * - the tracks target list and the band roster still address drums through the + * legacy 'drums' key β€” the drums arrangement does NOT get a duplicate row. + * + * These fail on main, where drums never enter S.arrangements[]. + * + * Run: node tests/drum_arrangement.test.mjs + */ +import assert from 'node:assert'; + +import { + DRUMS_ARR_TYPE, isDrumArrangement, findDrumArrangement, syncDrumArrangement, + pitchedArrangementCount, clampAwayFromDrums, pitchedIndexOf, + drumArrangementIndex, switcherShownIndex, + activeDrumArrangementIndex, addDrumArrangement, adoptDrumParts, drumArrangements, +} from '../src/drum-arrangement.js'; +import { _trackSessionTargetsPure } from '../src/track-session.js'; + +let pass = 0, fail = 0; +function t(name, fn) { + try { fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +const tab = (over = {}) => ({ version: 1, name: 'Drums', kit: [], hits: [{ t: 0, p: 'kick' }], ...over }); +const gtr = (name = 'Lead') => ({ id: name.toLowerCase(), name, notes: [], chords: [] }); + +// ── identity is TYPE-only (the safety fix) ──────────────────────────── +t('isDrumArrangement keys on the authored type, normalized β€” never the name', () => { + assert.strictEqual(isDrumArrangement({ type: 'drums' }), true); + assert.strictEqual(isDrumArrangement({ type: 'Drums' }), true, 'case-insensitive'); + assert.strictEqual(isDrumArrangement({ type: 'drum' }), true, 'singular synonym'); + // The load-bearing safety case: a pitched part a user NAMED "Drums" (no + // authored type) is NOT a drums arrangement β€” else it would be hidden from + // the switcher/tracks and dropped from the save. + assert.strictEqual(isDrumArrangement({ name: 'Drums' }), false); + assert.strictEqual(isDrumArrangement({ name: 'Drum & Bass Lead' }), false); + assert.strictEqual(isDrumArrangement({ name: 'Lead', type: 'guitar' }), false); + assert.strictEqual(isDrumArrangement(null), false); +}); + +t('findDrumArrangement returns the drums entry or null', () => { + const arrs = [gtr('Lead'), { id: 'drums', name: 'Drums', type: DRUMS_ARR_TYPE, drumTab: tab() }]; + assert.strictEqual(findDrumArrangement(arrs), arrs[1]); + assert.strictEqual(findDrumArrangement([gtr('Lead'), gtr('Bass')]), null); + assert.strictEqual(findDrumArrangement(null), null); +}); + +// ── syncDrumArrangement β€” the state machine ─────────────────────────── +t('materialize: a drum tab becomes a type:"drums" arrangement holding the SAME payload, appended last', () => { + const payload = tab(); + const S = { arrangements: [gtr('Lead'), gtr('Bass')], drumTab: payload }; + const arr = syncDrumArrangement(S); + assert.strictEqual(S.arrangements.length, 3, 'appended, not replaced'); + assert.strictEqual(S.arrangements[2], arr, 'appended at the END (indices 0/1 unchanged)'); + assert.strictEqual(arr.type, DRUMS_ARR_TYPE); + assert.strictEqual(arr.drumTab, payload, 'payload is the SAME object as S.drumTab (live editing surface)'); + assert.strictEqual(arr.name, 'Drums'); + assert.strictEqual(S.arrangements[0].name, 'Lead', 'other parts untouched'); + assert.strictEqual(S.arrangements[1].name, 'Bass'); +}); + +t('idempotent: syncing again does not add a second drums arrangement', () => { + const S = { arrangements: [gtr('Lead')], drumTab: tab() }; + syncDrumArrangement(S); + syncDrumArrangement(S); + syncDrumArrangement(S); + assert.strictEqual(S.arrangements.filter(isDrumArrangement).length, 1); + assert.strictEqual(S.arrangements.length, 2); +}); + +t('update: replacing S.drumTab re-points the SAME arrangement to the new payload + name', () => { + const S = { arrangements: [gtr('Lead')], drumTab: tab() }; + const first = syncDrumArrangement(S); + const replacement = tab({ name: 'Kit 2', hits: [{ t: 1, p: 'snare' }] }); + S.drumTab = replacement; + const again = syncDrumArrangement(S); + assert.strictEqual(again, first, 'same arrangement object re-used'); + assert.strictEqual(again.drumTab, replacement, 're-pointed to the new payload'); + assert.strictEqual(again.name, 'Kit 2', 'follows the tab name'); + assert.strictEqual(S.arrangements.filter(isDrumArrangement).length, 1); +}); + +t('remove: clearing S.drumTab drops the drums arrangement, leaving the rest in order', () => { + const S = { arrangements: [gtr('Lead'), gtr('Bass')], drumTab: tab() }; + syncDrumArrangement(S); + assert.strictEqual(S.arrangements.length, 3); + S.drumTab = null; + const arr = syncDrumArrangement(S); + assert.strictEqual(arr, null); + assert.deepStrictEqual(S.arrangements.map(a => a.name), ['Lead', 'Bass'], 'only drums removed, order kept'); +}); + +t('remove β†’ restore round-trips (the drum-delete undo path)', () => { + const payload = tab(); + const S = { arrangements: [gtr('Lead')], drumTab: payload }; + syncDrumArrangement(S); + // exec (delete): + S.drumTab = null; syncDrumArrangement(S); + assert.strictEqual(findDrumArrangement(S.arrangements), null); + // rollback (undo restores the exact payload): + S.drumTab = payload; const restored = syncDrumArrangement(S); + assert.strictEqual(restored.drumTab, payload, 'the original payload object is back'); + assert.strictEqual(S.arrangements.filter(isDrumArrangement).length, 1); +}); + +t('degrades safely on a malformed state (no throw)', () => { + assert.strictEqual(syncDrumArrangement(null), null); + assert.strictEqual(syncDrumArrangement({}), null, 'no arrangements array'); + assert.strictEqual(syncDrumArrangement({ arrangements: [gtr('Lead')], drumTab: null }), null); + assert.strictEqual(syncDrumArrangement({ arrangements: [gtr('Lead')], drumTab: 'nonsense' }), null); +}); + +// ── byte-identical save: drums excluded from arrangements[], drum_tab ships ── +t('the save filter excludes the drums arrangement but keeps a "Drums"-NAMED pitched part', () => { + // Simulates file-ops _buildSaveBody: body.arrangements = filter(!isDrum). + const named = { id: 'drumsolo', name: 'Drums', notes: [{ t: 0 }] }; // untyped pitched part + const S = { arrangements: [gtr('Lead'), named], drumTab: tab() }; + syncDrumArrangement(S); + const saved = S.arrangements.filter(a => !isDrumArrangement(a)); + assert.deepStrictEqual(saved.map(a => a.name), ['Lead', 'Drums'], + 'the derived drums arrangement is dropped; the pitched part literally named "Drums" survives'); + assert.strictEqual(findDrumArrangement(saved), null, 'no type:drums arrangement reaches the manifest'); +}); + +t('load β†’ save round-trips the pitched arrangements byte-identically', () => { + // A pack has 2 pitched arrangements + a song-level drum_tab. Migration adds + // the drums arrangement; the save filter must reproduce the original 2. + const loadedArrs = [gtr('Lead'), gtr('Bass')]; + const original = loadedArrs.map(a => ({ ...a })); + const S = { arrangements: loadedArrs, drumTab: tab() }; + syncDrumArrangement(S); // load migration + const savedArrs = S.arrangements.filter(a => !isDrumArrangement(a)); // save filter + assert.deepStrictEqual(savedArrs, original, 'the saved arrangements[] equal what was loaded'); +}); + +// ── no duplicate tracks/band rows for drums ─────────────────────────── +t('_trackSessionTargetsPure: the drums arrangement mixes on arr: but keeps its durable "drums" target id (PR2b)', () => { + const payload = tab(); + const S = { arrangements: [gtr('Lead')], drumTab: payload }; + syncDrumArrangement(S); // arrangements = [Lead, drumsArr] β†’ drums is idx 1 + const targets = _trackSessionTargetsPure(S.arrangements, S.drumTab); + const drumTargets = targets.filter(x => x.id === 'drums'); + assert.strictEqual(drumTargets.length, 1, 'exactly one drum target (no duplicate row)'); + assert.strictEqual(drumTargets[0].mixKey, 'arr:1', + 'the drums target now mixes on the drums arrangement index, not the legacy "drums" key'); + assert.ok(!targets.some(x => x.mixKey === 'drums'), 'the legacy "drums" mix-key singleton is retired'); + // Both parts mix on arr: now (the pitched part from the loop; drums from + // the appended target), but only ONE carries the durable "drums" id. + assert.strictEqual(targets.filter(x => x.mixKey.startsWith('arr:')).length, 2, 'both parts mix on arr:'); +}); + +t('_trackSessionTargetsPure: a pitched part NAMED "Drums" still gets its arr target (not filtered)', () => { + const arrs = [gtr('Lead'), { id: 'd', name: 'Drums', notes: [], chords: [] }]; // untyped + const targets = _trackSessionTargetsPure(arrs, null); + assert.strictEqual(targets.filter(x => x.mixKey.startsWith('arr:')).length, 2, + 'both pitched parts (incl. the one named "Drums") are arr targets'); +}); + +// ── index helpers keep S.currentArr / remove correct with drums in the array ── +const drums = () => ({ id: 'drums', name: 'Drums', type: DRUMS_ARR_TYPE, drumTab: tab() }); + +t('pitchedArrangementCount ignores the drums arrangement', () => { + assert.strictEqual(pitchedArrangementCount([gtr('Lead'), drums()]), 1); + assert.strictEqual(pitchedArrangementCount([gtr('Lead'), gtr('Bass'), drums()]), 2); + assert.strictEqual(pitchedArrangementCount([gtr('Lead')]), 1); + // The remove-last-arrangement guard fires when this is <= 1: a lone pitched + // part beside a drums arrangement must NOT be removable. + assert.ok(pitchedArrangementCount([gtr('Lead'), drums()]) <= 1); +}); + +t('clampAwayFromDrums never returns the drums index', () => { + assert.strictEqual(clampAwayFromDrums([gtr('Lead'), drums()], 1), 0, 'drums-last: idx 1 β†’ 0'); + assert.strictEqual(clampAwayFromDrums([gtr('Lead'), gtr('Bass'), drums()], 2), 1, 'idx 2 (drums) β†’ 1'); + assert.strictEqual(clampAwayFromDrums([gtr('Lead'), gtr('Bass'), drums()], 1), 1, 'a pitched idx is kept'); + assert.strictEqual(clampAwayFromDrums([gtr('Lead'), drums()], 9), 0, 'out-of-range clamps then skips drums'); + // Interspersed drums (a keys part appended after drums mid-session): + assert.strictEqual(clampAwayFromDrums([gtr('Lead'), drums(), gtr('Keys')], 2), 2, 'the trailing pitched part is kept'); +}); + +t('pitchedIndexOf maps a frontend index to the backend (drums-free) index', () => { + // Drums appended last (the post-load norm): pitched indices are unchanged. + assert.strictEqual(pitchedIndexOf([gtr('Lead'), gtr('Bass'), drums()], 1), 1); + assert.strictEqual(pitchedIndexOf([gtr('Lead'), gtr('Bass'), drums()], 0), 0); + // Interspersed drums: a pitched part AFTER the drums arrangement maps down. + assert.strictEqual(pitchedIndexOf([gtr('Lead'), drums(), gtr('Keys')], 2), 1, + 'Keys is frontend idx 2 but backend idx 1 (the drums arrangement is not in the manifest)'); + assert.strictEqual(pitchedIndexOf([gtr('Lead'), drums(), gtr('Keys')], 0), 0); +}); + +// ── the switcher shows drums as a selectable option (PR2) ───────────── +t('drumArrangementIndex finds the drums option, or -1', () => { + assert.strictEqual(drumArrangementIndex([gtr('Lead'), drums()]), 1); + assert.strictEqual(drumArrangementIndex([drums(), gtr('Lead')]), 0); + assert.strictEqual(drumArrangementIndex([gtr('Lead'), gtr('Bass')]), -1); + assert.strictEqual(drumArrangementIndex(null), -1); +}); + +t('switcherShownIndex: drum-edit mode shows the drums option; otherwise the current pitched part', () => { + const arrs = [gtr('Lead'), gtr('Bass'), drums()]; + // drum-edit mode on β†’ the dropdown displays the drums option (index 2)… + assert.strictEqual(switcherShownIndex(arrs, 1, true), 2); + // …even though currentArr stays on the pitched part (1) β€” that's the invariant. + assert.strictEqual(switcherShownIndex(arrs, 1, false), 1, 'not in drum mode β†’ current pitched part'); + assert.strictEqual(switcherShownIndex(arrs, 0, true), 2, 'drum mode always shows drums, whatever currentArr'); + // No drums arrangement β†’ always the current part, even if the flag is set. + assert.strictEqual(switcherShownIndex([gtr('Lead'), gtr('Bass')], 1, true), 1); +}); + +// ── N drum parts: the active part, adding, adopting (this PR) ───────── +t('activeDrumArrangementIndex: identity match on the ACTIVE tab, across several parts', () => { + const kit = tab(); + const live = tab({ name: 'Drums (Live)', hits: [{ t: 2, p: 'snare' }] }); + const arrs = [gtr('Lead'), + { id: 'drums', name: 'Drums', type: DRUMS_ARR_TYPE, drumTab: kit }, + { id: 'drums-2', name: 'Drums (Live)', type: DRUMS_ARR_TYPE, drumTab: live }]; + assert.strictEqual(activeDrumArrangementIndex(arrs, kit), 1); + assert.strictEqual(activeDrumArrangementIndex(arrs, live), 2); + assert.strictEqual(activeDrumArrangementIndex(arrs, { hits: [] }), -1, 'an unknown tab matches nothing'); + assert.strictEqual(activeDrumArrangementIndex(arrs, null), -1); + // The switcher displays the ACTIVE part while the grid is open… + assert.strictEqual(switcherShownIndex(arrs, 0, true, live), 2); + assert.strictEqual(switcherShownIndex(arrs, 0, true, kit), 1); + // …and falls back to the primary for a legacy unmaterialized tab. + assert.strictEqual(switcherShownIndex(arrs, 0, true, { hits: [] }), 1); +}); + +t('addDrumArrangement: unique durable ids and de-duplicated display names', () => { + const S = { arrangements: [gtr('Lead')], drumTab: null }; + const first = tab(); + S.drumTab = first; + syncDrumArrangement(S); // primary: id 'drums' + const second = tab(); + const arr2 = addDrumArrangement(S, second); + assert.strictEqual(arr2.id, 'drums-2', 'lowest unused id'); + assert.strictEqual(arr2.name, 'Drums 2', 'display name de-duplicated against the primary'); + assert.strictEqual(second.name, 'Drums 2', 'the tab’s own persisted name follows'); + assert.strictEqual(arr2.drumTab, second, 'SAME object reference β€” the grid edits it in place'); + const third = addDrumArrangement(S, tab()); + assert.strictEqual(third.id, 'drums-3'); + assert.deepStrictEqual(drumArrangements(S.arrangements).map(a => a.id), + ['drums', 'drums-2', 'drums-3'], 'primary first, in list order'); +}); + +t('adoptDrumParts: load-time extras keep their persisted ids and sort their hits', () => { + const S = { arrangements: [gtr('Lead')], drumTab: null }; + S.drumTab = tab(); + syncDrumArrangement(S); + adoptDrumParts(S, [ + { id: 'drums_live', name: 'Drums (Live)', drum_tab: { version: 1, hits: [{ t: 3, p: 'kick' }, { t: 1, p: 'snare' }] } }, + { id: 'drums', name: 'Clash', drum_tab: { version: 1, hits: [] } }, // id collides with the primary + { id: 'x', name: 'Bad', drum_tab: null }, // malformed β†’ skipped + ]); + const parts = drumArrangements(S.arrangements); + assert.strictEqual(parts.length, 3, 'primary + two adopted (the malformed part is skipped)'); + assert.strictEqual(parts[1].id, 'drums_live', 'persisted id kept'); + assert.deepStrictEqual(parts[1].drumTab.hits.map(h => h.t), [1, 3], 'hits sorted on adopt'); + assert.strictEqual(parts[2].id, 'drums-2', 'colliding id re-assigned, never a duplicate'); + assert.strictEqual(activeDrumArrangementIndex(S.arrangements, S.drumTab), 1, + 'the primary stays the active grid target'); +}); + +t('syncDrumArrangement with SEVERAL parts: a null active tab never mass-deletes', () => { + const S = { arrangements: [gtr('Lead')], drumTab: tab() }; + syncDrumArrangement(S); + addDrumArrangement(S, tab()); + S.drumTab = null; + syncDrumArrangement(S); + assert.strictEqual(drumArrangements(S.arrangements).length, 2, + 'with several parts, clearing the active tab is not a delete-everything instruction'); + // The singleton contract still holds: one part + null tab β†’ dropped. + const single = { arrangements: [gtr('Lead')], drumTab: tab() }; + syncDrumArrangement(single); + single.drumTab = null; + syncDrumArrangement(single); + assert.strictEqual(drumArrangements(single.arrangements).length, 0); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/tests/drum_delete_undo.test.mjs b/tests/drum_delete_undo.test.mjs index 2686c882..61f6f4d0 100644 --- a/tests/drum_delete_undo.test.mjs +++ b/tests/drum_delete_undo.test.mjs @@ -28,6 +28,7 @@ globalThis.document = globalThis.document || { getElementById: () => null }; const { DeleteDrumTabCmd } = await import('../src/track-session.js'); const { EditHistory } = await import('../src/history.js'); const { _buildSaveBody } = await import('../src/file-ops.js'); +const { addDrumArrangement, syncDrumArrangement } = await import('../src/drum-arrangement.js'); const { S } = await import('../src/state.js'); let pass = 0, fail = 0; @@ -47,13 +48,16 @@ function seed() { }], currentArr: 0, beats: [], sections: [], drumTab: tab, drumTabDirty: false, - partMix: { drums: { audible: false, vol: 0.5 }, 'arr:0': { audible: true, vol: 1 } }, + // The drums arrangement materializes at idx 1 (Lead is 0), so its mix + // strip is keyed 'arr:1' now β€” the retired 'drums' singleton (PR2b). + partMix: { 'arr:1': { audible: false, vol: 0.5 }, 'arr:0': { audible: true, vol: 1 } }, stemLinks: { drums: 'Drums_stem', lead: 'Guitar_L' }, trackSession: null, trackHeights: {}, stems: [], sessionDirty: false, history: new EditHistory(), sel: new Set(), }); + syncDrumArrangement(S); // materialize the type:"drums" arrangement (idx 1) return tab; } @@ -62,7 +66,7 @@ t('delete execs as one history command β€” the stack survives', () => { S.history.exec(new DeleteDrumTabCmd('Drums')); assert.strictEqual(S.drumTab, null, 'tab cleared'); assert.strictEqual(S.drumTabDirty, true, 'dirty β†’ the removal ships on the next save'); - assert.strictEqual('drums' in S.partMix, false, 'mixer strip dropped'); + assert.strictEqual('arr:1' in S.partMix, false, 'mixer strip dropped'); assert.strictEqual(S.stemLinks.drums, undefined, 'pairing retargeted away'); assert.strictEqual(S.stemLinks.lead, 'Guitar_L', 'other pairings untouched'); assert.strictEqual(S.history.undo.length, 1, 'the delete IS on the undo stack'); @@ -77,7 +81,7 @@ t('undo restores the very same tab object, flags, strip, and pairing', () => { 'IDENTITY restore β€” older drum commands hold references into these hits'); assert.strictEqual(S.drumTabDirty, false, 'a disk-clean tab returns clean, so an unrelated save does not re-serialize it'); - assert.deepStrictEqual(S.partMix.drums, { audible: false, vol: 0.5 }, 'mixer strip back'); + assert.deepStrictEqual(S.partMix['arr:1'], { audible: false, vol: 0.5 }, 'mixer strip back'); assert.strictEqual(S.stemLinks, linksBefore, 'pairing map reference restored'); assert.strictEqual(S.history.redo.length, 1, 'redo holds the delete'); }); @@ -89,7 +93,7 @@ t('redo re-deletes; a second undo restores again (round-trip stability)', () => S.history.doRedo(); assert.strictEqual(S.drumTab, null); assert.strictEqual(S.drumTabDirty, true); - assert.strictEqual('drums' in S.partMix, false); + assert.strictEqual('arr:1' in S.partMix, false); S.history.doUndo(); assert.strictEqual(S.drumTab, tab); assert.strictEqual(S.drumTabDirty, false); @@ -137,12 +141,82 @@ t('the save wire ships an explicit drum_tab null after a delete', () => { const body = _buildSaveBody(false); assert.ok('drum_tab' in body, 'field present β€” absent means preserve-on-disk'); assert.strictEqual(body.drum_tab, null, 'literal null is the removal wire'); + assert.deepStrictEqual(body.drum_parts, [], 'and the extras list ships empty beside it'); }); t('a clean, untouched tab still ships nothing (preserve path)', () => { seed(); const body = _buildSaveBody(false); assert.strictEqual('drum_tab' in body, false); + assert.strictEqual('drum_parts' in body, false, 'extras follow the same preserve path'); +}); + +// ── multiple drum parts (N drums) ───────────────────────────────────── + +// seed() + a second part "Drums (Live)" (id drums-2, arr idx 2), made ACTIVE. +function seedTwo() { + const tab1 = seed(); + const tab2 = { name: 'Drums (Live)', version: 1, hits: [{ t: 2.0, lane: 'snare' }] }; + addDrumArrangement(S, tab2); + S.drumTab = tab2; // the user is editing the second part + S.drumEditMode = true; + S.partMix = { 'arr:0': { vol: 100 }, 'arr:1': { vol: 50 }, 'arr:2': { vol: 25 } }; + return { tab1, tab2 }; +} + +t('deleting the ACTIVE second part hands the grid to the primary; undo is an EXACT round-trip', () => { + const { tab1, tab2 } = seedTwo(); + const beforeArrs = S.arrangements.slice(); + const beforePartMix = { ...S.partMix }; + S.history.exec(new DeleteDrumTabCmd('Drums (Live)', 2)); + assert.strictEqual(S.arrangements.length, 2, 'the part is gone'); + assert.strictEqual(S.drumTab, tab1, 'the grid hands over to the primary'); + assert.strictEqual(S.drumEditMode, true, 'drum mode stays on β€” a part remains'); + assert.deepStrictEqual(S.partMix, { 'arr:0': { vol: 100 }, 'arr:1': { vol: 50 } }, + 'the deleted strip is dropped; the survivors keep their state'); + S.history.doUndo(); + assert.strictEqual(S.arrangements.length, beforeArrs.length); + beforeArrs.forEach((a, i) => assert.strictEqual(S.arrangements[i], a, + `arrangement ${i} restored by IDENTITY (drum commands hold refs into its hits)`)); + assert.strictEqual(S.drumTab, tab2, 'the active part is back in the grid'); + assert.deepStrictEqual(S.partMix, beforePartMix, 'partMix exact round-trip'); + S.history.doRedo(); + assert.strictEqual(S.arrangements.length, 2, 'redo re-deletes'); + assert.strictEqual(S.drumTab, tab1); +}); + +t('deleting the PRIMARY promotes the second part β€” the save wire follows', () => { + const { tab2 } = seedTwo(); + S.drumTab = S.arrangements[1].drumTab; // editing the primary this time + S.history.exec(new DeleteDrumTabCmd('Drums', 1)); + assert.strictEqual(S.arrangements.length, 2, 'Lead + the surviving part'); + assert.strictEqual(S.drumTab, tab2, 'the survivor takes the grid'); + const body = _buildSaveBody(false); + assert.deepStrictEqual(body.drum_tab, tab2, + 'the promoted part ships as the song-level primary'); + assert.deepStrictEqual(body.drum_parts, [], 'no extras remain'); +}); + +t('deleting the LAST part exits drum-edit mode', () => { + seed(); + S.drumEditMode = true; + S.history.exec(new DeleteDrumTabCmd('Drums', 1)); + assert.strictEqual(S.drumTab, null); + assert.strictEqual(S.drumEditMode, false, 'nothing left for the grid to edit'); + S.history.doUndo(); + assert.strictEqual(S.drumEditMode, true, 'mode restored with the part'); +}); + +t('save wire: the PRIMARY ships as drum_tab even while a SECONDARY is active in the grid', () => { + const { tab1, tab2 } = seedTwo(); // active = tab2 (the secondary) + S.drumTabDirty = true; + const body = _buildSaveBody(false); + assert.deepStrictEqual(body.drum_tab, tab1, + 'primary = the FIRST drums arrangement, NOT S.drumTab (the active grid tab)'); + assert.strictEqual(body.drum_parts.length, 1); + assert.strictEqual(body.drum_parts[0].id, 'drums-2', 'durable id rides the wire'); + assert.strictEqual(body.drum_parts[0].name, 'Drums (Live)'); + assert.deepStrictEqual(body.drum_parts[0].drum_tab, tab2, 'the extra part’s own tab'); }); console.log(`\n${pass} passed, ${fail} failed`); diff --git a/tests/drum_delete_undo_middle.test.mjs b/tests/drum_delete_undo_middle.test.mjs new file mode 100644 index 00000000..ba88f5b8 --- /dev/null +++ b/tests/drum_delete_undo_middle.test.mjs @@ -0,0 +1,153 @@ +/* + * Regression: deleting the drum transcription when it is NOT the last + * arrangement (a pitched arrangement sits after it). + * + * The drums channel is `arr:` (PR2b). Splicing drums out shifts every + * higher pitched arrangement down one slot, so its `arr:` mix key must + * shift down too β€” exactly what the pitched-delete path does via + * _partMixDropArrangementPure. A bare `delete S.partMix['arr:']` + * stranded the pitched strips after drums (lost mute/solo/vol; a stranded solo + * silenced the whole band) and undo then OVERWROTE the stranded strip. + * + * Roster: Lead(arr:0) + Drums(arr:1) + Bass(arr:2). + * + * Run: node tests/drum_delete_undo_middle.test.mjs + */ +import assert from 'node:assert'; + +globalThis.localStorage = globalThis.localStorage || { + getItem: () => null, setItem: () => {}, removeItem: () => {}, +}; +globalThis.document = globalThis.document || { getElementById: () => null }; + +const { DeleteDrumTabCmd } = await import('../src/track-session.js'); +const { EditHistory } = await import('../src/history.js'); +const { syncDrumArrangement, isDrumArrangement } = await import('../src/drum-arrangement.js'); +const { _mixerAnySoloPure } = await import('../src/mixer-panel.js'); +const { S } = await import('../src/state.js'); + +let pass = 0, fail = 0; +function t(name, fn) { + try { fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +function seed() { + const pitched = () => ({ tuning: [0, 0, 0, 0, 0, 0], capo: 0, notes: [], chords: [], chord_templates: [] }); + const tab = { name: 'Drums', version: 1, hits: [{ t: 0.5, lane: 'kick' }] }; + Object.assign(S, { + sessionId: 'sess-1', createMode: false, format: 'sloppak', sloppakForm: 'zip', + filename: 'song.feedpak', title: 'T', artist: 'A', + // Lead at 0; syncDrumArrangement appends drums at 1; Bass added at 2 AFTER. + arrangements: [{ name: 'Lead', ...pitched() }], + currentArr: 0, beats: [], sections: [], + drumTab: tab, drumTabDirty: false, + partMix: { + 'arr:0': { audible: true, vol: 1 }, // Lead + 'arr:1': { audible: false, vol: 0.5 }, // Drums (mute-ish) + }, + stemLinks: {}, trackSession: null, trackHeights: {}, stems: [], + sessionDirty: false, history: new EditHistory(), sel: new Set(), + }); + syncDrumArrangement(S); // drums β†’ arr:1 + // Now add a pitched arrangement AFTER drums so drums is in the middle. + S.arrangements.push({ name: 'Bass', ...pitched() }); // Bass β†’ arr:2 + S.partMix['arr:2'] = { audible: true, vol: 0.8, solo: true }; + return tab; +} + +t('delete drums-in-the-middle renumbers higher pitched strips down', () => { + seed(); + S.history.exec(new DeleteDrumTabCmd('Drums')); + // Bass's arrangement is now index 1; its strip must follow to arr:1. + assert.deepStrictEqual(S.partMix['arr:1'], { audible: true, vol: 0.8, solo: true }, + 'Bass strip renumbered arr:2 β†’ arr:1'); + assert.strictEqual('arr:2' in S.partMix, false, 'no stray arr:2 left behind'); + assert.strictEqual(_mixerAnySoloPure(S.partMix), true, + 'the surviving solo stays on a LIVE strip β€” band not silenced'); +}); + +t('undo restores every strip AND the original arrangement order', () => { + seed(); + S.history.exec(new DeleteDrumTabCmd('Drums')); + S.history.doUndo(); + // True inverse: drums returns to its ORIGINAL middle slot (arr:1) and Bass + // back at arr:2 β€” NOT re-appended last, so index-based undo/mix keys stay valid. + assert.deepStrictEqual(S.partMix['arr:0'], { audible: true, vol: 1 }, 'Lead intact'); + assert.deepStrictEqual(S.partMix['arr:1'], { audible: false, vol: 0.5 }, + 'drums strip restored at its original middle slot'); + assert.deepStrictEqual(S.partMix['arr:2'], { audible: true, vol: 0.8, solo: true }, + 'Bass strip (solo+vol) restored at its original slot'); + assert.strictEqual(isDrumArrangement(S.arrangements[1]), true, 'drums back in the middle'); + assert.strictEqual(S.arrangements[2].name, 'Bass', 'Bass back last β€” order preserved'); +}); + +t('an index-based undo command survives a drums-in-the-middle delete', () => { + seed(); + S.currentArr = 2; // Bass selected (sits AFTER drums) + const bass = S.arrangements[2]; + // Stand-in for MoveNoteCmd et al: they target S.arrangements[arrIdx] by index. + let touched = null; + S.history.exec({ + arrIdx: 2, + exec() { touched = S.arrangements[this.arrIdx]; }, + rollback() { touched = S.arrangements[this.arrIdx]; }, + }); + S.history.exec(new DeleteDrumTabCmd('Drums')); // drums-in-the-middle removed + S.history.doUndo(); // un-delete drums + S.history.doUndo(); // the note command rolls back + assert.strictEqual(touched, bass, + 'index-based command still targets Bass, not the re-appended drums arrangement'); + assert.strictEqual(isDrumArrangement(touched), false, 'never lands on drums'); +}); + +t('currentArr follows the selected pitched arrangement across delete + undo', () => { + seed(); + S.currentArr = 2; // Bass selected β€” sits AFTER drums (arr:1) + S.history.exec(new DeleteDrumTabCmd('Drums')); + // Drums spliced out shifts Bass 2β†’1; currentArr must follow, not dangle + // out of bounds (arrangements.length is now 2). + assert.ok(S.arrangements[S.currentArr] && !isDrumArrangement(S.arrangements[S.currentArr]), + 'currentArr points at a live pitched arrangement, not out of bounds'); + assert.strictEqual(S.arrangements[S.currentArr].name, 'Bass', 'still Bass selected after delete'); + S.history.doUndo(); + // True-inverse undo restores the original slot, so currentArr returns to 2. + assert.strictEqual(S.currentArr, 2, 'currentArr restored to Bass original slot'); + assert.ok(!isDrumArrangement(S.arrangements[S.currentArr]), + 'currentArr is not the drums arrangement'); + assert.strictEqual(S.arrangements[S.currentArr].name, 'Bass', 'still Bass selected after undo'); +}); + +t('a live mix edit made while drums is gone survives the undo', () => { + seed(); + S.history.exec(new DeleteDrumTabCmd('Drums')); // Bass now at arr:1 + // Mixer/fader edits mutate S.partMix in place, outside EditHistory. + S.partMix['arr:1'] = { audible: true, vol: 0.3, solo: false }; // user re-faders Bass + S.history.doUndo(); + // Bass returns to arr:2 carrying the LIVE edit, not the stale seeded value. + assert.deepStrictEqual(S.partMix['arr:2'], { audible: true, vol: 0.3, solo: false }, + 'the post-delete Bass fader edit rode the undo, not clobbered'); + assert.deepStrictEqual(S.partMix['arr:1'], { audible: false, vol: 0.5 }, 'drums strip back'); +}); + +t('delete does not corrupt mix keys when the drums arrangement was never materialized', () => { + const pitched = () => ({ tuning: [0, 0, 0, 0, 0, 0], capo: 0, notes: [], chords: [], chord_templates: [] }); + Object.assign(S, { + sessionId: 'sess-1', createMode: false, format: 'sloppak', sloppakForm: 'zip', + filename: 'song.feedpak', title: 'T', artist: 'A', + arrangements: [{ name: 'Lead', ...pitched() }], // NO drums arrangement materialized + currentArr: 0, beats: [], sections: [], + drumTab: { name: 'Drums', version: 1, hits: [{ t: 0.5, lane: 'kick' }] }, // but drumTab set + drumTabDirty: false, + partMix: { 'arr:0': { audible: true, vol: 1 } }, + stemLinks: {}, trackSession: null, trackHeights: {}, stems: [], + sessionDirty: false, history: new EditHistory(), sel: new Set(), + }); + // drumArrangementIndex is -1 here; a -1 drop would rewrite arr:0 β†’ arr:-1. + S.history.exec(new DeleteDrumTabCmd('Drums')); + assert.deepStrictEqual(S.partMix['arr:0'], { audible: true, vol: 1 }, 'Lead strip untouched'); + assert.strictEqual('arr:-1' in S.partMix, false, 'no corrupt arr:-1 key'); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/tests/drum_primary_id_roundtrip.test.mjs b/tests/drum_primary_id_roundtrip.test.mjs new file mode 100644 index 00000000..78cc4d31 --- /dev/null +++ b/tests/drum_primary_id_roundtrip.test.mjs @@ -0,0 +1,99 @@ +/* + * MED regression: promoting a non-original primary must not silently change + * its persisted id on reload (which orphans its stem link + tree placement). + * + * feedpak-spec 1.17.0: the PRIMARY drum part persists as the song-level + * `drum_tab` alias entry. Before the fix that alias entry was HARDCODED + * id "drums" (routes.py) and the frontend re-materialized the primary as + * "drums" (`_nextDrumArrId`) regardless of what it saved. So after the user + * deletes the original primary and a survivor "drums-2" is promoted to + * parts[0], a saveβ†’reload returned the arrangement as id "drums" while + * `editor_stem_links["drums-2"]` and the track-session tree row for + * "drums-2" no longer matched β†’ `_trackSessionNormalizePure` dropped them. + * A stem pairing and a custom folder placement were LOST across the round + * trip. + * + * The fix makes the persisted primary id FOLLOW parts[0].id end to end: the + * save body ships `drum_tab_id`, the backend writes the alias entry under it, + * the load surfaces it, and `syncDrumArrangement(S, primaryId)` re-materializes + * the primary under it. This test drives the LOAD-side seam with the REAL + * pure functions and simulates the backend persist that ties them together; + * it FAILS on unfixed code (the primary comes back as "drums"). + * + * Run: node tests/drum_primary_id_roundtrip.test.mjs + */ +import assert from 'node:assert'; + +import { syncDrumArrangement, adoptDrumParts, findDrumArrangement } from '../src/drum-arrangement.js'; +import { _trackSessionTargetsPure, _trackSessionNormalizePure } from '../src/track-session.js'; + +let pass = 0, fail = 0; +function t(name, fn) { + try { fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +const drumTab = (name = 'Drums (Live)') => ({ version: 1, name, kit: [], hits: [{ t: 0, p: 'kick' }] }); +const gtr = (name = 'Lead') => ({ id: name.toLowerCase(), name, notes: [], chords: [] }); + +// The wire the backend answers with AFTER a delete-primary β†’ promote β†’ save. +// The original primary "drums" was deleted; "drums-2" was promoted to parts[0] +// and shipped as the song-level drum_tab whose alias entry the backend now +// persists under the incoming `drum_tab_id` (= parts[0].id). A stem link and a +// custom folder placement are keyed by that promoted id. +const PROMOTED_ID = 'drums-2'; +const reloadWire = () => ({ + drum_tab: drumTab(), + drum_tab_id: PROMOTED_ID, // the fix: persisted primary id follows parts[0].id + drum_parts: [], // only the promoted part remains + stem_links: { [PROMOTED_ID]: 'stem:kit' }, + track_session: { + tracks: [ + { id: 'folder:1', type: 'folder', name: 'My Folder', parentId: '' }, + // the promoted part's tree row, placed inside the custom folder + { id: 'transcription:' + PROMOTED_ID, type: 'transcription', targetId: PROMOTED_ID, parentId: 'folder:1' }, + ], + }, +}); + +// Mirror the file-ops.js load materialization (drums appended after the +// pitched arrangements; the persisted primary id threaded into +// syncDrumArrangement, then the extras adopted). +function loadDrums(data) { + const S = { arrangements: [gtr('Lead')], drumTab: null, stemLinks: {} }; + S.stemLinks = (data.stem_links && typeof data.stem_links === 'object') ? data.stem_links : {}; + S.drumTab = data.drum_tab ?? null; + syncDrumArrangement(S, data.drum_tab_id); + adoptDrumParts(S, data.drum_parts); + return S; +} + +t('a promoted primary re-materializes under its PERSISTED id (not the hardcoded "drums")', () => { + const S = loadDrums(reloadWire()); + const primary = findDrumArrangement(S.arrangements); + assert.ok(primary, 'the primary drums arrangement exists'); + assert.strictEqual(primary.id, PROMOTED_ID, + 'the primary keeps the id it saved β€” a promoted part does not silently become "drums"'); +}); + +t('the stem link keyed by the promoted id survives reload (a matching target exists)', () => { + const S = loadDrums(reloadWire()); + const targets = _trackSessionTargetsPure(S.arrangements, S.drumTab); + const linked = Object.keys(S.stemLinks); + assert.deepStrictEqual(linked, [PROMOTED_ID]); + assert.ok(targets.some(x => x.id === PROMOTED_ID), + 'a chart-track target matches the stem link key β€” the pairing is not orphaned'); +}); + +t('the custom folder placement of the promoted part survives normalization', () => { + const wire = reloadWire(); + const S = loadDrums(wire); + const model = _trackSessionNormalizePure(wire.track_session, [], S.arrangements, S.drumTab); + const row = model.tracks.find(r => r.type === 'transcription' && r.targetId === PROMOTED_ID); + assert.ok(row, 'the promoted part still has a tree row after normalization'); + assert.strictEqual(row.parentId, 'folder:1', + 'and it stays inside the custom folder β€” placement is not reset to the tree root'); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/tests/gm_guide.test.mjs b/tests/gm_guide.test.mjs index e7a07e5c..a788fffa 100644 --- a/tests/gm_guide.test.mjs +++ b/tests/gm_guide.test.mjs @@ -42,6 +42,7 @@ const { } = await import('../src/gm-guide.js'); const { EDITOR_MENUS, _menuModelPure } = await import('../src/menu-bar.js'); const { _editorShortcutRowsPure } = await import('../src/shortcuts.js'); +const { arrKind } = await import('../src/instrument.js'); let pass = 0, fail = 0; function t(name, fn) { @@ -102,18 +103,33 @@ t('URL builder: org without an http(s) base yields null (chain moves on), junk s // ── Kind inference + per-kind voices ────────────────────────────────── -t('kind inference mirrors the repo rule: KEYS_PATTERN start-anchored > /bass/i > guitar', () => { - assert.strictEqual(_gmKindPure('Keys'), 'keys'); - assert.strictEqual(_gmKindPure('Piano 2'), 'keys'); - assert.strictEqual(_gmKindPure('Synth Lead'), 'keys'); - assert.strictEqual(_gmKindPure('Electric Piano'), 'guitar', - 'start-anchored: "Electric Piano" is NOT a keys name (the pinned trap)'); - assert.strictEqual(_gmKindPure('Bass'), 'bass'); - assert.strictEqual(_gmKindPure('Lead Guitar'), 'guitar'); - assert.strictEqual(_gmKindPure(''), 'guitar'); +t('voice family collapses the resolved kind to keys / bass / guitar', () => { + // _gmKindPure now takes the RESOLVED instrument kind (arrKind), not a name. + // The three GM voice families are keys / bass / guitar; drums and vocals + // (and anything unrecognized) fall to the guitar voice. + assert.strictEqual(_gmKindPure('keys'), 'keys'); + assert.strictEqual(_gmKindPure('bass'), 'bass'); + assert.strictEqual(_gmKindPure('guitar'), 'guitar'); + assert.strictEqual(_gmKindPure('drums'), 'guitar', 'drums never reach the pitched voice'); + assert.strictEqual(_gmKindPure('vocals'), 'guitar'); assert.strictEqual(_gmKindPure(null), 'guitar'); }); +t('an authored `type` drives the guide voice β€” a mis-NAMED part voices by what it IS', () => { + // The rename-safety payoff: identity is DATA. arrKind resolves the type + // ahead of the name, so a bass-typed part named "Lead Guitar" takes the + // BASS voice, and a keys-typed part named "Gtr" takes the KEYS voice β€” + // on main (name-only) both would have voiced as guitar. + assert.strictEqual(_gmKindPure(arrKind({ type: 'bass', name: 'Lead Guitar' })), 'bass'); + assert.strictEqual(_gmKindPure(arrKind({ type: 'piano', name: 'Gtr' })), 'keys'); + // Untyped still reads the name (byte-identical to the old behavior): the + // pinned start-anchored trap β€” "Electric Piano" is guitar, "Synth …" keys. + assert.strictEqual(_gmKindPure(arrKind({ name: 'Electric Piano' })), 'guitar', + 'untyped: start-anchored name inference is unchanged'); + assert.strictEqual(_gmKindPure(arrKind({ name: 'Synth Lead' })), 'keys'); + assert.strictEqual(_gmKindPure(arrKind({ name: 'Bass' })), 'bass'); +}); + t('voice-for-kind: valid pref wins, garbage falls to the kind default, unknown kind is null', () => { assert.strictEqual(_gmVoiceForKindPure('34', 'bass'), 34); assert.strictEqual(_gmVoiceForKindPure(0, 'keys'), 0, 'program 0 is a real choice'); diff --git a/tests/gp5_export.test.mjs b/tests/gp5_export.test.mjs index 1495d0bd..b0f40693 100644 --- a/tests/gp5_export.test.mjs +++ b/tests/gp5_export.test.mjs @@ -17,6 +17,7 @@ import { _gp5ExportGuardPure, _gp5ExportNamePure, _gp5ExportHttpMessagePure, } from '../src/gp5-export.js'; import { _tabPreviewUrlPure } from '../src/tab-preview.js'; +import { arrKind } from '../src/instrument.js'; let pass = 0, fail = 0; function t(name, fn) { @@ -28,32 +29,30 @@ async function ta(name, fn) { catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } } -// ── 1. guard truth table ───────────────────────────────────────────────────── +// ── 1. guard truth table (the guard now takes the RESOLVED kind, not a name) ── t('guard: no arrangements β†’ load a song first', () => { - const g = _gp5ExportGuardPure('song.feedpak', 'Lead', false); + const g = _gp5ExportGuardPure('song.feedpak', 'guitar', false); assert.strictEqual(g.ok, false); assert.match(g.reason, /Load a song first/); }); -t('guard: keys / piano / synth / drums parts are refused (no tab)', () => { - for (const nm of ['Keys', 'Piano', 'Keyboard 2', 'Synth Lead', 'Drums', 'Drums (kit)']) { - const g = _gp5ExportGuardPure('song.feedpak', nm, true); - assert.strictEqual(g.ok, false, nm + ' should be refused'); +t('guard: keys / drums kinds are refused (no tab)', () => { + for (const kind of ['keys', 'drums']) { + const g = _gp5ExportGuardPure('song.feedpak', kind, true); + assert.strictEqual(g.ok, false, kind + ' should be refused'); assert.match(g.reason, /fretted tracks/); } }); t('guard: fretted part but no saved filename β†’ save first', () => { - const g = _gp5ExportGuardPure('', 'Lead', true); + const g = _gp5ExportGuardPure('', 'guitar', true); assert.strictEqual(g.ok, false); assert.match(g.reason, /Save the song first/); }); -t('guard: a fretted, saved part is OK', () => { - const g = _gp5ExportGuardPure('song.feedpak', 'Lead', true); - assert.deepStrictEqual(g, { ok: true, reason: '' }); - // a name that merely CONTAINS "keys" mid-string is still fretted (start-anchored) - assert.strictEqual(_gp5ExportGuardPure('song.feedpak', 'Rhythm (keys double)', true).ok, true); +t('guard: a fretted, saved part is OK (bass and guitar are the fretted kinds)', () => { + assert.deepStrictEqual(_gp5ExportGuardPure('song.feedpak', 'guitar', true), { ok: true, reason: '' }); + assert.strictEqual(_gp5ExportGuardPure('song.feedpak', 'bass', true).ok, true); }); // ── 2. download filename ───────────────────────────────────────────────────── @@ -123,6 +122,8 @@ function exportEnv(overrides = {}) { S: { arrangements: [{ name: 'Lead' }], currentArr: 0, filename: 'song.feedpak' }, statuses: [], downloads: [], fetched: [], setStatus: (msg) => env.statuses.push(msg), + // The orchestrator resolves the part's kind via arrKind before the guard. + arrKind, _gp5ExportGuardPure, _gp5ExportNamePure, _gp5ExportHttpMessagePure, _tabPreviewUrlPure, _downloadBytes: (_bytes, name) => env.downloads.push(name), fetch: (url) => { @@ -170,5 +171,27 @@ await ta('the song can close during the prompt β€” guards are re-checked after t assert.match(env.statuses.join(' '), /Load a song first/); }); +// ── 6. authored `type` drives the guard through the real orchestrator ───────── +// Byte-identical for untyped inputs, so these typed cases are what prove the +// conversion actually consults arrKind and not the name (the capstone payoff). +await ta('a keys-TYPED part named like a guitar is refused β€” identity is data', async () => { + const env = exportEnv({ + S: { arrangements: [{ type: 'piano', name: 'Lead' }], currentArr: 0, filename: 'song.feedpak' }, + }); + await env.run(); + assert.deepStrictEqual(env.fetched, [], 'a keys part exports no tab, whatever its name'); + assert.deepStrictEqual(env.downloads, []); + assert.match(env.statuses.join(' '), /fretted tracks/); +}); + +await ta('a guitar-TYPED part named "Piano" now exports β€” the free-rename payoff', async () => { + const env = exportEnv({ + S: { arrangements: [{ type: 'guitar', name: 'Piano' }], currentArr: 0, filename: 'song.feedpak' }, + }); + await env.run(); + assert.deepStrictEqual(env.downloads, ['song β€” Piano.gp5'], 'exports under its display name'); + assert.strictEqual(env.fetched.length, 1, 'the conversion actually ran'); +}); + console.log(`\n${pass} passed, ${fail} failed`); if (fail) process.exit(1); diff --git a/tests/instrument_type.test.mjs b/tests/instrument_type.test.mjs new file mode 100644 index 00000000..3e4b903c --- /dev/null +++ b/tests/instrument_type.test.mjs @@ -0,0 +1,210 @@ +/* + * Instrument type as first-class DATA (src/instrument.js). + * + * Pinned here: + * - _typeKind maps the manifest `type` facet (feedpak-spec Β§5.2 β€” "piano" for + * the keys family, plus the plural set the multitrack work grows into) to the + * editor's runtime kind, or null when absent/blank/unrecognized; + * - the load-bearing DATA/view predicates now HONOR an authored `type` over the + * name β€” isKeysArr (keys.js) and the 4-vs-6 bass baseline + * (_seedExtendedStringsFromTuning, lanes.js); + * - and they stay BYTE-IDENTICAL for untyped/legacy arrangements: with no + * `type`, every predicate falls back to its exact previous name test. + * + * Fails on main: src/instrument.js does not exist and the predicates read only + * the name (a typed part named against its instrument can't override). + * + * Run: node tests/instrument_type.test.mjs + */ +import assert from 'node:assert'; + +import { _typeKind, _arrTypeKind, _arrKindFromName, _isFrettedKind, arrKind } from '../src/instrument.js'; +import { isKeysArr, KEYS_PATTERN, _rollPitchCtxFor } from '../src/keys.js'; +import { _seedExtendedStringsFromTuning, _stringCountFor } from '../src/lanes.js'; +import { _isBassArr } from '../src/instrument.js'; +import { _trackKindBadgePure } from '../src/track-session.js'; +import { _gmKindPure } from '../src/gm-guide.js'; +import { _tabPreviewGuardPure } from '../src/tab-preview.js'; +import { _gp5ExportGuardPure } from '../src/gp5-export.js'; +import { _partsArrKindPure } from '../src/parts-view.js'; +import { seedState } from './_history_env.mjs'; + +let pass = 0; let fail = 0; +const tests = []; +const t = (name, fn) => tests.push([name, fn]); + +// ── _typeKind ───────────────────────────────────────────────────────── +t('_typeKind maps the manifest vocabulary to runtime kinds', () => { + for (const v of ['piano', 'keyboard', 'synth', 'keys']) assert.strictEqual(_typeKind(v), 'keys', v); + assert.strictEqual(_typeKind('bass'), 'bass'); + for (const v of ['guitar', 'lead', 'rhythm']) assert.strictEqual(_typeKind(v), 'guitar', v); + for (const v of ['drum', 'drums']) assert.strictEqual(_typeKind(v), 'drums', v); + for (const v of ['vocal', 'vocals', 'voice']) assert.strictEqual(_typeKind(v), 'vocals', v); + // case + whitespace insensitive + assert.strictEqual(_typeKind(' Piano '), 'keys'); + assert.strictEqual(_typeKind('BASS'), 'bass'); +}); + +t('_typeKind is null for absent / blank / unrecognized / non-string', () => { + assert.strictEqual(_typeKind(''), null); + assert.strictEqual(_typeKind(' '), null); + assert.strictEqual(_typeKind('tuba'), null, 'unrecognized β†’ null (falls back to name)'); + assert.strictEqual(_typeKind(undefined), null); + assert.strictEqual(_typeKind(null), null); + assert.strictEqual(_typeKind(42), null); +}); + +t('_arrTypeKind reads arr.type (and is null when untyped)', () => { + assert.strictEqual(_arrTypeKind({ type: 'piano' }), 'keys'); + assert.strictEqual(_arrTypeKind({ type: ' ' }), null); + assert.strictEqual(_arrTypeKind({ name: 'Piano' }), null, 'no type field β†’ null (name is the caller’s fallback)'); + assert.strictEqual(_arrTypeKind(null), null); +}); + +// ── isKeysArr: authored type wins; untyped falls back to name ────────── +const setArr = (arr) => { seedState({ arrangements: [arr], currentArr: 0 }); }; + +t('isKeysArr: an authored `type` overrides the name (both directions)', () => { + setArr({ name: 'Lead', type: 'keys' }); + assert.strictEqual(isKeysArr(), true, 'typed keys, non-keys name β†’ keys'); + setArr({ name: 'Grand Piano', type: 'guitar' }); + assert.strictEqual(isKeysArr(), false, 'typed guitar, keys-looking name β†’ NOT keys'); +}); + +t('isKeysArr: untyped falls back to the exact prefix name test (byte-identical)', () => { + setArr({ name: 'Piano' }); + assert.strictEqual(isKeysArr(), true, 'keys-prefix name β†’ keys'); + setArr({ name: 'Synth Lead' }); + assert.strictEqual(isKeysArr(), true, 'synth-prefix β†’ keys'); + setArr({ name: 'Electric Piano' }); + assert.strictEqual(isKeysArr(), false, 'NOT keys-prefixed β†’ falls through, same as today'); + setArr({ name: 'Lead' }); + assert.strictEqual(isKeysArr(), false); +}); + +// ── the bass 4-vs-6 baseline honors type, else the name ─────────────── +t('_seedExtendedStringsFromTuning: type drives the 4-vs-6 baseline', () => { + // typed bass with a NON-bass name β†’ baseline 4 (len 5 β†’ +1 extended) + const a = [{ name: 'Rhythm', type: 'bass', tuning: [0, 0, 0, 0, 0] }]; + _seedExtendedStringsFromTuning(a, true); + assert.strictEqual(a[0]._extendedStrings, 1, 'typed bass β†’ baseline 4'); + // typed KEYS named "Synth Bass" β†’ NOT bass β†’ baseline 6 (len 5 < 6 β†’ unset) + const b = [{ name: 'Synth Bass', type: 'keys', tuning: [0, 0, 0, 0, 0] }]; + _seedExtendedStringsFromTuning(b, true); + assert.strictEqual(b[0]._extendedStrings, undefined, 'typed keys β†’ baseline 6, len 5 not extended'); +}); + +t('_seedExtendedStringsFromTuning: untyped falls back to the /bass/ name test (byte-identical)', () => { + const bass = [{ name: 'Bass', tuning: [0, 0, 0, 0, 0] }]; // len 5, name bass β†’ baseline 4 + _seedExtendedStringsFromTuning(bass, true); + assert.strictEqual(bass[0]._extendedStrings, 1, 'untyped bass name β†’ baseline 4, +1'); + const gtr = [{ name: 'Rhythm', tuning: [0, 0, 0, 0, 0] }]; // len 5, non-bass β†’ baseline 6, unset + _seedExtendedStringsFromTuning(gtr, true); + assert.strictEqual(gtr[0]._extendedStrings, undefined, 'untyped non-bass, len 5 β†’ baseline 6, unset'); +}); + +// ── _arrKindFromName + arrKind (the canonical resolver) ─────────────── +t('KEYS_PATTERN is re-exported from keys.js (its home is now the leaf)', () => { + assert.strictEqual(KEYS_PATTERN.test('Piano'), true); + assert.strictEqual(KEYS_PATTERN.test('Electric Piano'), false, 'prefix-anchored, unchanged'); +}); + +t('_arrKindFromName mirrors the legacy runtime inference (keys before bass)', () => { + assert.strictEqual(_arrKindFromName('Piano'), 'keys'); + assert.strictEqual(_arrKindFromName('Synth Lead'), 'keys'); + assert.strictEqual(_arrKindFromName('Drums'), 'drums'); + assert.strictEqual(_arrKindFromName('Drums 2'), 'drums'); + assert.strictEqual(_arrKindFromName('Drumkit'), 'guitar', 'prefix is /^drums/ β€” "Drumkit" is not "drums"'); + assert.strictEqual(_arrKindFromName('Synth Bass'), 'keys', 'keys wins over bass'); + assert.strictEqual(_arrKindFromName('Bass'), 'bass'); + assert.strictEqual(_arrKindFromName('Lead'), 'guitar'); + assert.strictEqual(_arrKindFromName(''), 'guitar'); +}); + +t('arrKind: authored type wins, name inference is the fallback', () => { + assert.strictEqual(arrKind({ name: 'Lead', type: 'drums' }), 'drums', 'type wins'); + assert.strictEqual(arrKind({ name: 'Grand Piano', type: 'guitar' }), 'guitar', 'type wins over keys name'); + assert.strictEqual(arrKind({ name: 'Piano' }), 'keys', 'untyped β†’ name inference'); + assert.strictEqual(arrKind({ name: 'Bass' }), 'bass'); + assert.strictEqual(arrKind({ name: 'Backing Vox', type: 'vocals' }), 'vocals'); + assert.strictEqual(arrKind(null), 'guitar', 'no arr β†’ guitar default'); +}); + +t('_isFrettedKind gates string controls to guitar and bass only', () => { + assert.strictEqual(_isFrettedKind('guitar'), true); + assert.strictEqual(_isFrettedKind('bass'), true); + for (const kind of ['keys', 'drums', 'vocals', null, undefined]) { + assert.strictEqual(_isFrettedKind(kind), false, `${kind} has no string controls`); + } +}); + +// ── the Tracks-view kind badge ──────────────────────────────────────── +t('_trackKindBadgePure: audio shows the layer, transcription shows the instrument', () => { + assert.deepStrictEqual(_trackKindBadgePure({ type: 'audio', sourceKind: 'master' }, []), ['MIX', 'Master mix']); + assert.deepStrictEqual(_trackKindBadgePure({ type: 'audio', sourceKind: 'stem' }, []), ['AUD', 'Audio']); + assert.deepStrictEqual(_trackKindBadgePure({ type: 'transcription', targetId: 'drums' }, []), ['DRM', 'Drums']); + const arrs = [{ name: 'Lead' }, { name: 'Piano' }, { name: 'Rhythm', type: 'bass' }, { name: 'Choir', type: 'vocals' }]; + assert.deepStrictEqual(_trackKindBadgePure({ type: 'transcription', targetId: 'Lead', mixKey: 'arr:0' }, arrs), ['GTR', 'Guitar']); + assert.deepStrictEqual(_trackKindBadgePure({ type: 'transcription', targetId: 'Piano', mixKey: 'arr:1' }, arrs), ['KEY', 'Keys']); + assert.deepStrictEqual(_trackKindBadgePure({ type: 'transcription', targetId: 'Rhythm', mixKey: 'arr:2' }, arrs), ['BAS', 'Bass'], 'type wins over the non-bass name'); + assert.deepStrictEqual(_trackKindBadgePure({ type: 'transcription', targetId: 'Choir', mixKey: 'arr:3' }, arrs), ['VOX', 'Vocals']); +}); + +// ── _isBassArr keeps the INDEPENDENT /bass/ fallback (not arrKind===bass) ── +t('_isBassArr: type wins; untyped keeps the independent /bass/ test ("Synth Bass" stays bass)', () => { + assert.strictEqual(_isBassArr({ name: 'Rhythm', type: 'bass' }), true, 'typed bass, non-bass name'); + assert.strictEqual(_isBassArr({ name: 'Sub Bass', type: 'guitar' }), false, 'typed guitar wins'); + assert.strictEqual(_isBassArr({ name: 'Synth Bass' }), true, 'untyped "Synth Bass" is bass (arrKind would say keys)'); + assert.strictEqual(_isBassArr({ name: 'Lead' }), false); +}); + +// ── string count (the rename-safety-critical reader) honors type ────── +t('_stringCountFor: type drives the 4-vs-6 baseline; untyped independent /bass/ fallback', () => { + const mk = (o) => ({ tuning: [], notes: [], chords: [], chord_templates: [], ...o }); + assert.strictEqual(_stringCountFor(mk({ name: 'Rhythm', type: 'bass', tuning: [0, 0, 0, 0] })), 4, 'typed bass β†’ 4'); + assert.strictEqual(_stringCountFor(mk({ name: 'Sub Bass', type: 'guitar', tuning: [0, 0, 0, 0, 0, 0] })), 6, 'typed guitar β†’ 6'); + assert.strictEqual(_stringCountFor(mk({ name: 'Synth Bass', tuning: [0, 0, 0, 0] })), 4, 'untyped "Synth Bass" β†’ 4 (unchanged)'); + assert.strictEqual(_stringCountFor(mk({ name: 'Lead', tuning: [0, 0, 0, 0, 0, 0] })), 6); +}); + +// ── view routing honors type (a converted reader) ──────────────────── +t('_rollPitchCtxFor: a typed-keys part has no fretted context regardless of name', () => { + assert.strictEqual(_rollPitchCtxFor({ name: 'Lead', type: 'keys' }), null, 'typed keys β†’ null (no fretted ctx)'); + assert.strictEqual(_rollPitchCtxFor({ name: 'Piano' }), null, 'untyped keys name β†’ null (fallback, unchanged)'); + const gtr = _rollPitchCtxFor({ name: 'Grand Piano', type: 'guitar', tuning: [40, 45, 50, 55, 59, 64], capo: 0 }); + assert.notStrictEqual(gtr, null, 'typed guitar named "Piano" β†’ a real fretted ctx (type wins)'); +}); + +// ── the capstone readers consume arrKind β†’ they honor an authored type ──── +// Each of these was NAME-based and is now fed arrKind's output by its caller. +// The conversion is byte-identical for untyped arrangements, so ONLY a typed, +// contrarily-named arrangement proves the reader actually consults the type. +// Composed here with the real arrKind exactly as each call site does. +t('gm-guide voice: a bass-typed part named "Lead Guitar" takes the bass voice', () => { + assert.strictEqual(_gmKindPure(arrKind({ type: 'bass', name: 'Lead Guitar' })), 'bass'); + assert.strictEqual(_gmKindPure(arrKind({ type: 'piano', name: 'Gtr' })), 'keys'); + assert.strictEqual(_gmKindPure(arrKind({ name: 'Lead' })), 'guitar', 'untyped name fallback'); +}); + +t('tab-preview / gp5-export guards: a keys-typed guitar-named part is refused; a guitar-typed keys-named part is allowed', () => { + const keysTyped = arrKind({ type: 'piano', name: 'Lead' }); // β†’ keys + const gtrTyped = arrKind({ type: 'guitar', name: 'Piano' }); // β†’ guitar + assert.strictEqual(_tabPreviewGuardPure('s.sloppak', keysTyped, true).ok, false, 'keys-typed β†’ no tab preview'); + assert.strictEqual(_tabPreviewGuardPure('s.sloppak', gtrTyped, true).ok, true, 'guitar-typed "Piano" previews (payoff)'); + assert.strictEqual(_gp5ExportGuardPure('s.feedpak', keysTyped, true).ok, false, 'keys-typed β†’ no gp5 export'); + assert.strictEqual(_gp5ExportGuardPure('s.feedpak', gtrTyped, true).ok, true, 'guitar-typed "Piano" exports (payoff)'); +}); + +t('parts-view silhouette tag: a bass-typed part named "Rhythm" tags Bass', () => { + assert.strictEqual(_partsArrKindPure(arrKind({ type: 'bass', name: 'Rhythm' })), 'Bass'); + assert.strictEqual(_partsArrKindPure(arrKind({ type: 'guitar', name: 'Bass Line' })), 'Guitar', 'typed guitar over /bass/ name'); + assert.strictEqual(_partsArrKindPure(arrKind({ name: 'Bass' })), 'Bass', 'untyped name fallback'); +}); + +for (const [name, fn] of tests) { + try { await fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/tests/midi_playback.test.mjs b/tests/midi_playback.test.mjs index d0e0bab4..f5c64783 100644 --- a/tests/midi_playback.test.mjs +++ b/tests/midi_playback.test.mjs @@ -4,8 +4,8 @@ * real mixer over them (per-part gain nodes, whole-map solo rule). * * Pinned here: - * - the band roster uses EXACTLY the mixer panel's strip keys - * ('arr:' / 'drums'), so strips and engine can never disagree; + * - the band roster uses EXACTLY the mixer panel's strip keys (all 'arr:' + * now, the drums arrangement included), so strips and engine can't disagree; * - the per-key strip state applies the DAW rule ACROSS parts (a solo on * one track silences the others' gains, mute always wins); * - the dedupe key is part-scoped (two parts on the same millisecond @@ -32,6 +32,7 @@ const audio = await import('../src/audio.js'); const { editorPlayAllTracksEnabled, editorTogglePlayAllTracks } = audio; const { _mixerPartStripState } = await import('../src/mixer-panel.js'); const { host, setHostHooks } = await import('../src/host.js'); +const { drumArrangementIndex } = await import('../src/drum-arrangement.js'); const { S } = await import('../src/state.js'); let pass = 0, fail = 0; @@ -43,23 +44,42 @@ function t(name, fn) { // The @pure:midi-playback block, sliced (the pures are module-local). import { readFileSync } from 'node:fs'; const src = readFileSync(new URL('../src/audio.js', import.meta.url), 'utf8').replace(/\r\n/g, '\n'); -function slice(name) { +// deps are injected as named args so a sliced function can call a real import +// (`_bandPartsPure` now resolves the drums arrangement's index). +function slice(name, deps = {}) { const at = src.indexOf(`function ${name}`); const body = src.slice(at, src.indexOf('\n}', at) + 2); - return new Function(`return (${body.replace(`function ${name}`, 'function')})`)(); + const keys = Object.keys(deps); + return new Function(...keys, `return (${body.replace(`function ${name}`, 'function')})`)(...keys.map(k => deps[k])); } -const _bandPartsPure = slice('_bandPartsPure'); +const _bandPartsPure = slice('_bandPartsPure', { drumArrangementIndex }); const _bandFiredKeyPure = slice('_bandFiredKeyPure'); -t('the band roster mirrors the mixer strip keys, drums last', () => { +t('the band roster mirrors the mixer strip keys, each drum part on its own arr:', () => { + // Drum parts are type:"drums" arrangements now, each OWNING its tab β€” the + // roster keys each by its arrangement index, the SAME key its strip uses, + // and a song can hold several. + const kit = { hits: [{ t: 1 }] }; + const live = { hits: [{ t: 2 }] }; const parts = _bandPartsPure( - [{ name: 'Lead' }, { name: 'Bass' }], - { hits: [{ t: 1 }] }); - assert.deepStrictEqual(parts.map(p => p.key), ['arr:0', 'arr:1', 'drums']); + [{ name: 'Lead' }, { name: 'Bass' }, + { name: 'Drums', type: 'drums', drumTab: kit }, + { name: 'Drums (Live)', type: 'drums', drumTab: live }], + kit); + assert.deepStrictEqual(parts.map(p => p.key), ['arr:0', 'arr:1', 'arr:2', 'arr:3'], + 'both drum parts ride their own arr:, no "drums" singleton'); + assert.strictEqual(parts[3].name, 'Drums (Live)'); assert.deepStrictEqual(_bandPartsPure([{ name: 'Lead' }], null).map(p => p.key), ['arr:0'], 'no drum tab = no drums strip'); assert.deepStrictEqual(_bandPartsPure([{ name: 'Lead' }], { hits: [] }).map(p => p.key), ['arr:0'], 'an EMPTY drum tab is not a part'); + assert.deepStrictEqual(_bandPartsPure( + [{ name: 'Lead' }, { name: 'Kit', type: 'drums', drumTab: { hits: [] } }], { hits: [] }) + .map(p => p.key), ['arr:0'], + 'an EMPTY drum PART schedules nothing'); + assert.deepStrictEqual(_bandPartsPure([{ name: 'Lead' }], kit).map(p => p.key), + ['arr:0', 'drums'], + 'legacy unmaterialized tab (create mode) keeps the "drums" band key'); }); t('the dedupe key is part-scoped: same millisecond, two parts, both fire', () => { diff --git a/tests/mixer_panel.test.mjs b/tests/mixer_panel.test.mjs index b92be5a8..1dceac8b 100644 --- a/tests/mixer_panel.test.mjs +++ b/tests/mixer_panel.test.mjs @@ -66,7 +66,7 @@ globalThis.window = globalThis.window || globalThis; const { _mixerPartsPure, _mixerPartStatePure, _mixerAnySoloPure, _mixerPartAudiblePure, - _mixerClapStatePure, _mixerOpenFromStoredPure, _mixerClapState, + _mixerClapStatePure, _mixerActivePartKeyPure, _mixerOpenFromStoredPure, _mixerClapState, _mixerGainForFaderPure, _mixerFaderLabelPure, _mixerOrderedPartsPure, _mixerPanelRefresh, editorToggleMixerPanel, initMixerPanel, } = await import('../src/mixer-panel.js'); @@ -81,19 +81,24 @@ function t(name, fn) { // ── Pure strip model ───────────────────────────────────────────────── -t('one strip per arrangement, keyed by index, drums appended only with hits', () => { +t('one strip per arrangement, keyed by index; the drums arrangement is an ordinary strip', () => { const arrs = [{ name: 'Lead' }, { name: '' }, null]; assert.deepStrictEqual(_mixerPartsPure(arrs, null), [ { key: 'arr:0', name: 'Lead' }, { key: 'arr:1', name: 'Track 2' }, { key: 'arr:2', name: 'Track 3' }, ]); - assert.deepStrictEqual(_mixerPartsPure([], { hits: [] }), []); - assert.deepStrictEqual(_mixerPartsPure([], { hits: [{ t: 1 }] }), - [{ key: 'drums', name: 'Drums' }]); + // A bare drum tab no longer conjures a 'drums' strip β€” drums are a + // type:"drums" arrangement now (PR2b), so they ride the arrangement pass and + // get an `arr:` strip named for themselves. + assert.deepStrictEqual(_mixerPartsPure([], { hits: [{ t: 1 }] }), []); + assert.deepStrictEqual(_mixerPartsPure( + [{ name: 'Lead' }, { name: 'Kit', type: 'drums' }], { hits: [{ t: 1 }] }), + [{ key: 'arr:0', name: 'Lead' }, { key: 'arr:1', name: 'Kit' }]); assert.deepStrictEqual(_mixerPartsPure(null, null), []); }); + t('strip state defaults to audible unity; volume clamps into [0, 110] (+10 dB ceiling)', () => { assert.deepStrictEqual(_mixerPartStatePure({}, 'arr:0'), { vol: 100, mute: false, solo: false }); assert.deepStrictEqual(_mixerPartStatePure(null, 'arr:0'), { vol: 100, mute: false, solo: false }); @@ -161,11 +166,19 @@ t('master is the OUTPUT bus: others solo/mute never silence it, its own mute doe // ── The clap state the guide scheduler consumes ────────────────────── -t('clap state follows the active surface: drums in drum mode, else the current arrangement', () => { - const mix = { 'arr:1': { mute: true }, drums: { vol: 50 } }; - assert.deepStrictEqual(_mixerClapStatePure(mix, false, 1), { audible: false, vol: 100 / 100 }); - assert.deepStrictEqual(_mixerClapStatePure(mix, true, 1), { audible: true, vol: 0.5 }); - assert.deepStrictEqual(_mixerClapStatePure(mix, false, 0), { audible: true, vol: 1 }); +t('clap state follows the active surface: the drums arrangement in drum mode, else the current arrangement', () => { + // Lead is arr:1; the drums arrangement is arr:2 (drumIdx = 2). currentArr + // stays on the pitched part even in drum mode (#337) β€” the clap key is what + // switches to the drums channel. + const mix = { 'arr:1': { mute: true }, 'arr:2': { vol: 50 } }; + assert.deepStrictEqual(_mixerClapStatePure(mix, false, 1, 2), { audible: false, vol: 100 / 100 }, + 'not drum mode β†’ the current (muted) arrangement'); + assert.deepStrictEqual(_mixerClapStatePure(mix, true, 1, 2), { audible: true, vol: 0.5 }, + 'drum mode β†’ the drums arrangement channel (arr:2)'); + assert.deepStrictEqual(_mixerClapStatePure(mix, false, 0, 2), { audible: true, vol: 1 }); + // No drums arrangement materialized (drumIdx = -1) β†’ fall back to currentArr. + assert.deepStrictEqual(_mixerClapStatePure(mix, true, 1, -1), { audible: false, vol: 1 }, + 'drum mode with no drums arrangement β†’ currentArr'); }); t('solo keeps the reference audible (D5): the gate is per-PART, and the host default leaves audio untouched', () => { @@ -181,11 +194,60 @@ t('solo keeps the reference audible (D5): the gate is per-PART, and the host def }); t('_mixerClapState reads live S (the wiring main.js hands to host.partClapState)', () => { - Object.assign(S, { partMix: { 'arr:0': { mute: true, vol: 40 } }, drumEditMode: false, currentArr: 0 }); + Object.assign(S, { arrangements: [{ name: 'Lead' }], partMix: { 'arr:0': { mute: true, vol: 40 } }, drumEditMode: false, currentArr: 0 }); assert.deepStrictEqual(_mixerClapState(), { audible: false, vol: 0.4 }); S.partMix = {}; }); +t('_mixerActivePartKeyPure: drum mode addresses the drums arrangement index; currentArr stays pitched (#337)', () => { + // In drum mode the active clap channel is the drums arrangement (arr:2), + // NOT currentArr β€” which is deliberately left on a pitched part. + assert.strictEqual(_mixerActivePartKeyPure(true, 0, 2), 'arr:2'); + assert.strictEqual(_mixerActivePartKeyPure(true, 1, 2), 'arr:2', 'ignores currentArr in drum mode'); + // Out of drum mode β†’ the current pitched arrangement. + assert.strictEqual(_mixerActivePartKeyPure(false, 1, 2), 'arr:1'); + // No drums arrangement materialized (drumIdx < 0) β†’ fall back to currentArr, + // even with the flag set, so the key is always a real strip. + assert.strictEqual(_mixerActivePartKeyPure(true, 3, -1), 'arr:3'); + assert.strictEqual(_mixerActivePartKeyPure(false, 'junk', 2), 'arr:0', 'a bad currentArr degrades to arr:0'); +}); + +t('the drums mixer strip gates the drum guide clap in drum-edit mode (arr:, live S)', () => { + // The runtime integration: with the drums arrangement materialized at arr:1 + // and ITS tab active in the grid, muting its mixer strip must silence the + // drum-grid guide claps β€” even though currentArr is a pitched part. + const tab = { hits: [{ t: 1 }] }; + Object.assign(S, { + arrangements: [{ name: 'Lead' }, { name: 'Drums', type: 'drums', drumTab: tab }], + drumTab: tab, drumEditMode: true, currentArr: 0, + partMix: { 'arr:1': { mute: true, vol: 50 } }, + }); + assert.strictEqual(_mixerClapState().audible, false, 'the muted drums strip gates the drum guide clap'); + assert.strictEqual(_mixerClapState().vol, 0.5, 'and its fader scales the clap level'); + S.partMix = {}; + assert.strictEqual(_mixerClapState().audible, true, 'unmuted β†’ the drum guide clap sounds again'); + Object.assign(S, { drumEditMode: false, drumTab: null }); +}); + +t('with TWO drum parts, the clap gate follows the ACTIVE part (live S)', () => { + // Editing the second part: ITS strip (arr:2) gates the grid claps β€” the + // first part's strip does not. + const kit = { hits: [{ t: 1 }] }; + const live = { hits: [{ t: 2 }] }; + Object.assign(S, { + arrangements: [{ name: 'Lead' }, + { name: 'Drums', type: 'drums', drumTab: kit }, + { name: 'Drums (Live)', type: 'drums', drumTab: live }], + drumTab: live, drumEditMode: true, currentArr: 0, + partMix: { 'arr:1': { mute: true }, 'arr:2': { vol: 30 } }, + }); + assert.deepStrictEqual(_mixerClapState(), { audible: true, vol: 0.3 }, + 'the ACTIVE part (arr:2) gates/scales the claps; the muted arr:1 strip is irrelevant'); + S.drumTab = kit; // switch the grid to the first part + assert.strictEqual(_mixerClapState().audible, false, 'now the muted arr:1 strip gates them'); + Object.assign(S, { drumEditMode: false, drumTab: null, partMix: {} }); +}); + // ── Panel open state: pref round-trip + toggle ─────────────────────── t('open-state pref round-trip', () => { @@ -237,14 +299,14 @@ t('bus faders seed from host.mixUiState on open (incl. master, dB labels)', () = t('strips render one row per part; the refresh is memoized until state changes', () => { Object.assign(S, { - arrangements: [{ name: 'Lead ' }, { name: 'Bass' }], + arrangements: [{ name: 'Lead ' }, { name: 'Bass' }, { name: 'Drums', type: 'drums' }], drumTab: { hits: [{ t: 0.5 }] }, partMix: {}, currentArr: 0, }); editorToggleMixerPanel(true); const html = els['editor-mixer-parts'].innerHTML; assert.ok(html.includes('Lead <Guitar>'), 'part name rendered (escaped)'); assert.ok(html.includes('data-mix-part="arr:1"'), 'second strip'); - assert.ok(html.includes('data-mix-part="drums"'), 'drums strip'); + assert.ok(html.includes('data-mix-part="arr:2"'), 'the drums arrangement renders as an arr: strip'); assert.ok(html.includes('data-mix-act="solo"'), 'solo button'); assert.ok(html.includes('aria-valuetext="+0.0 dB"'), 'fader exposes its dB value to screen readers'); // Memo: same state β†’ no re-render (a sentinel survives the call). diff --git a/tests/new_track.test.mjs b/tests/new_track.test.mjs index 5e438510..c8715125 100644 --- a/tests/new_track.test.mjs +++ b/tests/new_track.test.mjs @@ -149,14 +149,62 @@ await ta('empty Keys failure reports in the calling New Track dialog', async () assert.strictEqual(S.arrangements.length, 1, 'failed Keys registration leaves state untouched'); }); -t('editorAddEmptyDrums: creates the create-flow blank shape once, refuses twice', () => { - Object.assign(S, { format: 'sloppak', sessionId: 'test-session', drumTab: null, drumTabDirty: false }); +t('editorAddEmptyDrums: blank shape once; a SECOND part in a saved sloppak session', () => { + Object.assign(S, { + format: 'sloppak', sessionId: 'test-session', createMode: false, + drumTab: null, drumTabDirty: false, + arrangements: [{ name: 'Lead', notes: [] }], drumSel: new Set(), + }); assert.strictEqual(editorAddEmptyDrums(), true); assert.deepStrictEqual(S.drumTab, { version: 1, name: 'Drums', kit: [], hits: [] }); assert.strictEqual(S.drumTabDirty, true, 'persists on next save'); - // Second call must not clobber an existing tab. + assert.strictEqual(S.arrangements.filter(a => a.type === 'drums').length, 1, + 'primary materialized beside the pitched part'); + // A second call adds ANOTHER part (a song can hold several) β€” the new + // part becomes the grid target and the first tab is untouched. S.drumTab.hits.push({ t: 1, piece: 'kick' }); - assert.strictEqual(editorAddEmptyDrums(), false); + const firstTab = S.drumTab; + assert.strictEqual(editorAddEmptyDrums(), true); + assert.strictEqual(S.arrangements.filter(a => a.type === 'drums').length, 2, 'two drum parts'); + assert.notStrictEqual(S.drumTab, firstTab, 'the NEW part is the active grid target'); + assert.strictEqual(firstTab.hits.length, 1, 'existing tab untouched'); + assert.strictEqual(S.drumTab.name, 'Drums 2', 'de-duplicated display name'); +}); + +t('editorAddEmptyDrums: create mode ADDS a second part too (its build persists N now)', () => { + // Parity with edit-mode: the create-mode /build path writes the extra + // drum parts, so create sessions add a 2nd part beside a pitched track. + Object.assign(S, { + format: 'sloppak', sessionId: 'test-session', createMode: true, + drumTab: null, drumTabDirty: false, + arrangements: [{ name: 'Lead', notes: [] }], drumSel: new Set(), + }); + assert.strictEqual(editorAddEmptyDrums(), true); + assert.strictEqual(S.arrangements.filter(a => a.type === 'drums').length, 1, + 'primary materialized beside the pitched part'); + const firstTab = S.drumTab; + firstTab.hits.push({ t: 1, piece: 'kick' }); + assert.strictEqual(editorAddEmptyDrums(), true, 'a second part is allowed in create mode'); + assert.strictEqual(S.arrangements.filter(a => a.type === 'drums').length, 2, 'two drum parts'); + assert.notStrictEqual(S.drumTab, firstTab, 'the new part is the active grid target'); + assert.strictEqual(firstTab.hits.length, 1, 'the first tab is untouched'); + Object.assign(S, { createMode: false }); +}); + +t('editorAddEmptyDrums: a drums-only session keeps the tab OFF the arrangement list, and can’t add a 2nd', () => { + // No pitched part: materializing would put drums at index 0, where the + // default currentArr lands β€” the tab must stay a legacy off-array + // singleton instead (the drum grid still edits it through the mode). And + // with no arrangement to sit beside, a second part can't be added. + Object.assign(S, { + format: 'sloppak', sessionId: 'test-session', createMode: false, + drumTab: null, drumTabDirty: false, arrangements: [], drumSel: new Set(), + }); + assert.strictEqual(editorAddEmptyDrums(), true); + assert.ok(S.drumTab, 'tab created'); + assert.strictEqual(S.arrangements.length, 0, 'no drums arrangement at index 0'); + S.drumTab.hits.push({ t: 1, piece: 'kick' }); + assert.strictEqual(editorAddEmptyDrums(), false, 'drums-only: no melodic track to sit beside, so one part max'); assert.strictEqual(S.drumTab.hits.length, 1, 'existing tab untouched'); }); diff --git a/tests/parts_view.test.js b/tests/parts_view.test.js index 5001e738..2bf7bfdf 100644 --- a/tests/parts_view.test.js +++ b/tests/parts_view.test.js @@ -83,19 +83,17 @@ t('lane hit-test respects the waveform band and lane bounds', () => { }); // ── per-lane instrument tag ──────────────────────────────────────────────────── -t('kind tag is inferred from each lane\'s OWN name, independent of the armed part', () => { - // Regression: _partsKindTag used to call the param-less isBassArr(), which - // always tested the armed arrangement β€” so a Bass lane got mistagged - // "Guitar" whenever a guitar part was armed. The pure helper keys off the - // lane's own name only, so a Bass lane tags Bass regardless of what's armed. - assert.strictEqual(_partsArrKindPure('Bass'), 'Bass'); - assert.strictEqual(_partsArrKindPure('Lead Guitar'), 'Guitar'); - assert.strictEqual(_partsArrKindPure('Rhythm'), 'Guitar'); - assert.strictEqual(_partsArrKindPure('Piano'), 'Keys'); - assert.strictEqual(_partsArrKindPure('Synth Lead'), 'Keys'); - // Keys precedence + name-anchored keys pattern (matches KEYS_PATTERN). - assert.strictEqual(_partsArrKindPure('Bass Synth'), 'Bass', 'unanchored keys word does not win'); - assert.strictEqual(_partsArrKindPure(''), 'Guitar', 'empty β†’ Guitar'); +t('tag maps the RESOLVED kind β†’ display label, per lane', () => { + // _partsArrKindPure now takes each lane's RESOLVED instrument kind (the + // caller passes arrKind(arr), so an authored `type` wins over the name and + // the tag is independent of both the armed part AND a misleading name). + // The old regression β€” the param-less isBassArr() always tested the armed + // arrangement β€” is prevented at the call site by resolving per lane. + assert.strictEqual(_partsArrKindPure('bass'), 'Bass'); + assert.strictEqual(_partsArrKindPure('guitar'), 'Guitar'); + assert.strictEqual(_partsArrKindPure('keys'), 'Keys'); + // Anything else (drums/vocals/unrecognized) falls to the guitar silhouette. + assert.strictEqual(_partsArrKindPure('vocals'), 'Guitar'); assert.strictEqual(_partsArrKindPure(null), 'Guitar', 'nullish β†’ Guitar'); }); diff --git a/tests/rename_part.test.mjs b/tests/rename_part.test.mjs index c9e6fdba..a36adbc6 100644 --- a/tests/rename_part.test.mjs +++ b/tests/rename_part.test.mjs @@ -107,7 +107,7 @@ t('a name the editor and the save read differently is refused (both facets guard assert.strictEqual(P._renameGuardPure('Rhythm', 'Synthwave Lead', []).ok, false); }); -t('cross-kind renames refuse and say why', () => { +t('cross-kind renames refuse and say why (untyped: name still drives the kind)', () => { const g = P._renameGuardPure('Lead', 'Bass 2', []); assert.strictEqual(g.ok, false); assert.ok(/guitar β†’ bass/.test(g.reason), 'names the kind change'); @@ -115,6 +115,28 @@ t('cross-kind renames refuse and say why', () => { assert.strictEqual(P._renameGuardPure('Lead', 'Keys Solo', []).ok, false); }); +t('a TYPED part renames freely across name-inferred kinds β€” identity is data', () => { + // The capstone payoff: when the arrangement carries an authored `type`, + // the caller passes typed=true and the kind-change refusal is skipped β€” + // the name is a pure display label. Every case below is REFUSED untyped + // (see the cross-kind test) and ALLOWED typed. + assert.strictEqual(P._renameGuardPure('Lead', 'Bass 2', [], true).ok, true, 'guitarβ†’bass name ok when typed'); + assert.strictEqual(P._renameGuardPure('Lead', 'Electric Piano', [], true).ok, true, 'save-side keys ok when typed'); + assert.strictEqual(P._renameGuardPure('Piano', 'Electric Piano', [], true).ok, true, 'runtime-facet move ok when typed'); + const g = P._renameGuardPure('Lead', ' Grand Piano ', [], true); + assert.deepStrictEqual(g, { ok: true, reason: '', name: 'Grand Piano' }, 'still trims'); +}); + +t('a TYPED part still obeys the name-agnostic rules (empty / too-long / duplicate / no-op)', () => { + // typed relaxes ONLY the kind-change refusal β€” the structural checks stay. + assert.strictEqual(P._renameGuardPure('Lead', ' ', [], true).ok, false, 'empty still refused'); + assert.strictEqual(P._renameGuardPure('Lead', 'x'.repeat(61), [], true).ok, false, 'too-long still refused'); + assert.strictEqual(P._renameGuardPure('Lead', 'rhythm', ['Rhythm'], true).ok, false, 'duplicate still refused'); + const noop = P._renameGuardPure('Lead', 'Lead', ['Rhythm'], true); + assert.strictEqual(noop.ok, false); + assert.strictEqual(noop.reason, '', 'a no-op is still a silent no-op'); +}); + t('empty, too-long, duplicate, and no-op inputs are handled', () => { assert.strictEqual(P._renameGuardPure('Lead', ' ', []).ok, false); assert.strictEqual(P._renameGuardPure('Lead', 'x'.repeat(61), []).ok, false); diff --git a/tests/reorder_part.test.js b/tests/reorder_part.test.js index d9cfeec4..a88366df 100644 --- a/tests/reorder_part.test.js +++ b/tests/reorder_part.test.js @@ -60,8 +60,50 @@ t('one-slot moves inside the list; ends and degenerate inputs refuse', () => { assert.strictEqual(P._movePartTargetPure(5, -1, 3), -1, 'out-of-range from'); }); +// ── #336 regression: Remove/Reorder button gates count PITCHED parts only ── +// A song with 1 pitched part + a drums arrangement is arrangements.length===2, +// so the pre-fix `length`-based gates wrongly showed Remove (a silent no-op) and +// reorder (which corrupts). The gate must key on the pitched count. Fails pre-fix. +const AFF = new Function( + '"use strict";' + extractBlock('arr-affordances').replace(/^export\s+/gm, '') + + '\nreturn { _arrAffordancePure };' +)(); + +t('a lone pitched part beside a drums arrangement offers neither Remove nor Reorder', () => { + // pitchedCount 1 (the drums arrangement is excluded before this call). + const a = AFF._arrAffordancePure(1, 0, 'sess-1', 'sloppak'); + assert.strictEqual(a.canRemove, false, 'Remove hidden β€” removing the last pitched part is refused'); + assert.strictEqual(a.canReorder, false, 'Reorder hidden β€” nothing to reorder'); + assert.strictEqual(a.upDisabled, true); + assert.strictEqual(a.downDisabled, true); +}); + +t('2 pitched parts on a sloppak session enable Remove + Reorder, bounded at the ends', () => { + const first = AFF._arrAffordancePure(2, 0, 'sess-1', 'sloppak'); + assert.strictEqual(first.canRemove, true); + assert.strictEqual(first.canReorder, true); + assert.strictEqual(first.upDisabled, true, 'first part can’t move earlier'); + assert.strictEqual(first.downDisabled, false, 'first part can move later'); + const last = AFF._arrAffordancePure(2, 1, 'sess-1', 'sloppak'); + // The LAST pitched part (idx 1) can't move down PAST the drums arrangement. + assert.strictEqual(last.downDisabled, true, 'last pitched part can’t move past drums'); + assert.strictEqual(last.upDisabled, false); +}); + +t('reorder stays gated to live sloppak sessions even with 2+ pitched parts', () => { + assert.strictEqual(AFF._arrAffordancePure(2, 0, 'sess-1', 'archive').canReorder, false, 'archive: no reorder'); + assert.strictEqual(AFF._arrAffordancePure(2, 0, '', 'sloppak').canReorder, false, 'no session: no reorder'); + // Remove doesn't depend on session/format β€” it only needs 2+ pitched parts. + assert.strictEqual(AFF._arrAffordancePure(2, 0, '', 'archive').canRemove, true); +}); + // ── The real handler over an injected environment ──────────────────── +// The real helper the handler uses to bound the move β€” the derived drums +// arrangement (type:"drums", appended last) is never counted. +const pitchedArrangementCount = (arrs) => + (Array.isArray(arrs) ? arrs : []).filter(a => !(a && a.type === 'drums')).length; + function makeEnv(arrs, currentArr, format) { const S = { currentArr, @@ -77,6 +119,7 @@ function makeEnv(arrs, currentArr, format) { const calls = { selector: 0, draw: 0 }; const env = new Function( 'S', '_recState', 'updateArrangementSelector', 'draw', 'updateStatus', 'setStatus', + 'pitchedArrangementCount', '"use strict";' + extractBlock('reorder-part') + '\n' + extractFn('_editorMovePart') + '\nreturn { move: _editorMovePart };' )( @@ -85,6 +128,7 @@ function makeEnv(arrs, currentArr, format) { () => { calls.draw++; }, () => {}, () => {}, + pitchedArrangementCount, ); return { ...env, S, calls }; } @@ -133,13 +177,49 @@ t('recording blocks reordering (a take pins its arrangement index)', () => { }; const env = new Function( 'S', '_recState', 'updateArrangementSelector', 'draw', 'updateStatus', 'setStatus', + 'pitchedArrangementCount', '"use strict";' + extractBlock('reorder-part') + '\n' + extractFn('_editorMovePart') + '\nreturn { move: _editorMovePart };' - )(S, 'recording', () => {}, () => {}, () => {}, () => {}); + )(S, 'recording', () => {}, () => {}, () => {}, () => {}, pitchedArrangementCount); env.move(+1); assert.deepStrictEqual(S.arrangements, [a, b], 'untouched mid-take'); assert.strictEqual(S.history.resets, 0); }); +// ── #336 regression: a pitched part must never move PAST the drums arrangement ── +// The drums arrangement is appended LAST (type:"drums"). Bounding the move by +// arrangements.length (instead of the pitched count) let the last pitched part +// swap BELOW drums β€” breaking append-last and shifting every arr: mix key +// (mute/solo/vol then apply to the wrong part). Fails pre-fix. +const drums = () => ({ id: 'drums', name: 'Drums', type: 'drums' }); + +t('the last pitched part cannot move later past the drums arrangement (no-op)', () => { + const a = { id: 'a', name: 'Lead' }, b = { id: 'b', name: 'Bass' }, d = drums(); + // Bass is the last PITCHED part (idx 1); drums sits at idx 2. + const { move, S } = makeEnv([a, b, d], 1); + move(+1); + assert.deepStrictEqual(S.arrangements, [a, b, d], 'order unchanged β€” Bass stays above drums'); + assert.strictEqual(S.currentArr, 1, 'selection unchanged'); + assert.strictEqual(S.arrangements[2].type, 'drums', 'drums stays LAST (append-last held)'); + assert.strictEqual(S.history.resets, 0, 'no history reset on a refused move'); +}); + +t('a single pitched part beside drums cannot reorder at all (no-op)', () => { + const a = { id: 'a', name: 'Lead' }, d = drums(); + const { move, S } = makeEnv([a, d], 0); + move(+1); + assert.deepStrictEqual(S.arrangements, [a, d], 'Lead never swaps below drums'); + assert.strictEqual(S.arrangements[1].type, 'drums', 'drums stays last'); +}); + +t('a valid pitched move still works with drums present, and drums stays last (arr:idx integrity)', () => { + const a = { id: 'a', name: 'Lead' }, b = { id: 'b', name: 'Bass' }, d = drums(); + const { move, S } = makeEnv([a, b, d], 0); // move Lead later + move(+1); + assert.deepStrictEqual(S.arrangements, [b, a, d], 'pitched parts swapped, drums untouched'); + assert.strictEqual(S.currentArr, 1, 'selection follows the moved part'); + assert.strictEqual(S.arrangements[2].type, 'drums', 'drums remains the last (arr:2) entry'); +}); + console.log(`\n${pass} passed, ${fail} failed`); if (fail) process.exit(1); diff --git a/tests/stem_engine.test.mjs b/tests/stem_engine.test.mjs index 59f88be6..1b7cd0ab 100644 --- a/tests/stem_engine.test.mjs +++ b/tests/stem_engine.test.mjs @@ -25,12 +25,13 @@ const t = (name, fn) => tests.push([name, fn]); t('the mixer lists stem strips (audio band first), honoring removals', () => { const parts = _mixerPartsPure( - [{ name: 'Lead' }], { hits: [{}] }, + [{ name: 'Lead' }, { name: 'Drums', type: 'drums' }], { hits: [{}] }, [{ id: 'Guitar_L', name: 'Gtr L' }, { id: 'Bass_DI', name: 'Bass' }, { id: 'gone' }], ['gone']); assert.deepStrictEqual(parts.map(p => p.key), - ['audio:Guitar_L', 'audio:Bass_DI', 'arr:0', 'drums'], - 'stems first (removed dropped), then parts, then drums'); + ['audio:Guitar_L', 'audio:Bass_DI', 'arr:0', 'arr:1'], + 'stems first (removed dropped), then parts β€” the drums arrangement is an ordinary arr: strip now'); + assert.strictEqual(parts[3].name, 'Drums', 'the drums strip follows its arrangement name'); assert.strictEqual(parts[0].name, 'Gtr L'); assert.strictEqual(parts[0].kind, 'audio'); }); diff --git a/tests/stem_tracks.test.mjs b/tests/stem_tracks.test.mjs index 3ef24e2a..177817f4 100644 --- a/tests/stem_tracks.test.mjs +++ b/tests/stem_tracks.test.mjs @@ -81,7 +81,7 @@ const flush = () => new Promise((r) => setImmediate(r)); const stemTracks = await import('../src/stem-tracks.js'); const { - _stemLinkSetPure, _stemRowsPure, _submitStemOp, + _stemLinkSetPure, _stemRowsPure, _stemPairArrsPure, _submitStemOp, editorSoloMyStem, initStemTracks, stemMixerAvailable, } = stemTracks; const { S } = await import('../src/state.js'); @@ -142,6 +142,24 @@ t('rows follow stem order and resolve pairings by the id-or-name key rule', () = assert.strictEqual(rows[2].pairedWith, null, 'unpaired is honest'); }); +// #336 regression: the stem-pairing dropdown lists PITCHED chart tracks only β€” +// the derived drums arrangement is a song-level sidecar, never a pairing target. +// Pre-fix arrOptions mapped ALL arrangements β†’ the Drums arrangement was offered. +t('the pairing option list excludes the drums arrangement', () => { + const arrangements = [ + { id: 'a1', name: 'Lead' }, + { id: 'drums', name: 'Drums', type: 'drums' }, + { id: 'a2', name: 'Bass' }, + ]; + const pairable = _stemPairArrsPure(arrangements); + assert.deepStrictEqual(pairable.map(a => a.name), ['Lead', 'Bass'], + 'the type:"drums" arrangement is not a pairing target'); + assert.ok(!pairable.some(a => a.type === 'drums'), 'no drums arrangement in the options'); + // A pitched part NAMED "Drums" (no authored type) is still pairable. + assert.strictEqual( + _stemPairArrsPure([{ name: 'Drums' }]).length, 1, 'a name-only "Drums" stays pairable'); +}); + t('a chart track pairs with ONE stem; re-pairing replaces; empty unlinks', () => { let links = _stemLinkSetPure({}, 'a1', 'Guitar_L'); assert.deepStrictEqual(links, { a1: 'Guitar_L' }); diff --git a/tests/strings_modal.test.mjs b/tests/strings_modal.test.mjs index fdbc175a..f0f099be 100644 --- a/tests/strings_modal.test.mjs +++ b/tests/strings_modal.test.mjs @@ -21,6 +21,7 @@ import { setHostHooks } from '../src/host.js'; import { seedState, trackHooks } from './_history_env.mjs'; import fs from 'node:fs'; import { _stringCountFor } from '../src/lanes.js'; +import { _isBassArr } from '../src/instrument.js'; const src = fs.readFileSync(new URL('../src/strings.js', import.meta.url), 'utf8'); @@ -80,7 +81,7 @@ function makeHandlerEnv(seed) { // editorAddString / editorRemoveString reach draw / updateStatus through host // now (they moved to src/strings.js), so inject a host stub for those two. const env = new Function( - 'window', 'S', 'host', '_renderStringsModal', '_stringCountFor', + 'window', 'S', 'host', '_renderStringsModal', '_stringCountFor', '_isBassArr', 'AddStringCmd', 'RemoveStringCmd', '"use strict";' + tuningBlock + '\n' + notesOnStringSrc + '\n' @@ -93,6 +94,7 @@ function makeHandlerEnv(seed) { { draw: () => {}, updateStatus: () => {} }, // host () => {}, // _renderStringsModal (DOM-free) _stringCountFor, // the REAL one, imported from src/lanes.js + _isBassArr, // type-authoritative bass predicate (src/instrument.js) AddStringCmd, RemoveStringCmd, ); S.history = new EditHistory(); diff --git a/tests/tab_preview.test.js b/tests/tab_preview.test.js index a8c8a2ce..e881c474 100644 --- a/tests/tab_preview.test.js +++ b/tests/tab_preview.test.js @@ -22,9 +22,8 @@ if (!m) { // Extract the @pure block ALONE β€” no outer globals prepended. This is the // self-containment contract (@pure convention): the block must reference no // global declared outside it, or the extracted sandbox throws -// "X is not defined". Pre-fix this block referenced the outer KEYS_PATTERN, -// so building it in isolation and calling the guard on a keys part threw; -// the guard now inlines its regexes. +// "X is not defined". The guard now takes the RESOLVED instrument kind (the +// caller passes arrKind), so it carries no regex and no outer ref at all. const { _tabPreviewGuardPure, _tabPreviewUrlPure, _tabPreviewHttpMessagePure, _tabPreviewKeyPolicyPure } = new Function( '"use strict";' + m[0] + '\nreturn { _tabPreviewGuardPure, _tabPreviewUrlPure, _tabPreviewHttpMessagePure, _tabPreviewKeyPolicyPure };' @@ -38,43 +37,43 @@ function t(name, fn) { t('guard: fretted saved parts preview; every refusal names its reason', () => { assert.deepStrictEqual( - _tabPreviewGuardPure('song.sloppak', 'Lead', true), { ok: true, reason: '' }); - assert.strictEqual(_tabPreviewGuardPure('s', 'Lead', false).ok, false, 'no arrangements'); - const keys = _tabPreviewGuardPure('s', 'Piano', true); + _tabPreviewGuardPure('song.sloppak', 'guitar', true), { ok: true, reason: '' }); + assert.strictEqual(_tabPreviewGuardPure('s', 'guitar', false).ok, false, 'no arrangements'); + const keys = _tabPreviewGuardPure('s', 'keys', true); assert.strictEqual(keys.ok, false); assert.ok(/fretted/.test(keys.reason), 'keys refusal explains itself'); - const unsaved = _tabPreviewGuardPure('', 'Lead', true); + const unsaved = _tabPreviewGuardPure('', 'guitar', true); assert.strictEqual(unsaved.ok, false); assert.ok(/Save/.test(unsaved.reason), 'unsaved refusal points at Save'); }); t('guard order: an empty session reads as "load a song", not "save first"', () => { - assert.ok(/Load/.test(_tabPreviewGuardPure('', '', false).reason)); + assert.ok(/Load/.test(_tabPreviewGuardPure('', 'guitar', false).reason)); }); t('guard order: an unsaved keys part reads as "fretted only", not "save first" (keys wins)', () => { // Both the keys and the unsaved conditions hold; the non-fretted check // runs first, so the honest reason is the modality one, not Save-first. - const r = _tabPreviewGuardPure('', 'Piano', true); + const r = _tabPreviewGuardPure('', 'keys', true); assert.strictEqual(r.ok, false); assert.ok(/fretted/.test(r.reason) && !/Save/.test(r.reason)); }); -t('guard: drums parts are non-fretted and refused (legacy guitar-encoded drums arrangements)', () => { - const drums = _tabPreviewGuardPure('song.sloppak', 'Drums', true); +t('guard: drums parts are non-fretted and refused', () => { + const drums = _tabPreviewGuardPure('song.sloppak', 'drums', true); assert.strictEqual(drums.ok, false, 'a drums arrangement has no fret/string tab'); assert.ok(/fretted/.test(drums.reason), 'drums refusal explains itself'); - // Case-insensitive, prefix-anchored β€” matches the editor-wide /^drums/i gate. - assert.strictEqual(_tabPreviewGuardPure('s', 'drums (EOF)', true).ok, false); }); -t('guard is self-contained: extracting the @pure block alone still classifies keys/drums (no outer KEYS_PATTERN ref)', () => { - // These calls execute the inlined regexes inside the isolated block; a - // reference to an outer KEYS_PATTERN would have thrown before we got here. - assert.strictEqual(_tabPreviewGuardPure('s', 'Piano', true).ok, false); - assert.strictEqual(_tabPreviewGuardPure('s', 'Keyboard', true).ok, false); - assert.strictEqual(_tabPreviewGuardPure('s', 'Synth Lead', true).ok, false); - assert.strictEqual(_tabPreviewGuardPure('s.sloppak', 'Rhythm', true).ok, true); +t('guard classifies by RESOLVED kind β€” bass and guitar are the fretted kinds', () => { + // The guard now consumes arrKind's output (an authored `type` wins over + // the name upstream), so name inference no longer lives here. bass and + // guitar are the fretted kinds; keys/drums are refused; anything else + // (e.g. an unrecognized kind) is treated as fretted, unchanged. + assert.strictEqual(_tabPreviewGuardPure('s.sloppak', 'bass', true).ok, true); + assert.strictEqual(_tabPreviewGuardPure('s.sloppak', 'guitar', true).ok, true); + assert.strictEqual(_tabPreviewGuardPure('s.sloppak', 'keys', true).ok, false); + assert.strictEqual(_tabPreviewGuardPure('s.sloppak', 'drums', true).ok, false); }); t('key policy: preview modal is a read-only lens β€” only Escape acts (closes), every other key is swallowed', () => { diff --git a/tests/tab_preview_race.test.js b/tests/tab_preview_race.test.js index fdda006a..3722f7b9 100644 --- a/tests/tab_preview_race.test.js +++ b/tests/tab_preview_race.test.js @@ -37,6 +37,9 @@ if (!m) { S: { arrangements: [{ name: 'Lead' }], currentArr: 0, filename: 'song.sloppak' }, alphaTab: {}, document: { getElementById: () => ({ innerHTML: '' }) }, + // _tabPreviewRender resolves the part's kind via arrKind before the + // guard; this race test stubs the guard, so a trivial resolver suffices. + arrKind: () => 'guitar', _tabPreviewGuardPure: () => ({ ok: true, reason: '' }), _tabPreviewUrlPure: () => '/api/plugins/tabview/gp5/song.sloppak?arrangement=0&t=1', _tabPreviewHttpMessagePure: (status) => 'Preview failed (' + status + ')', diff --git a/tests/test_drum_parts.py b/tests/test_drum_parts.py new file mode 100644 index 00000000..96634528 --- /dev/null +++ b/tests/test_drum_parts.py @@ -0,0 +1,176 @@ +"""Regression coverage for the multiple-drum-parts save/load helpers. + +A song can hold SEVERAL drum parts (feedpak-spec 1.17.0 "drums as +arrangements"): the PRIMARY persists as the song-level `drum_tab` (the +back-compat alias current cores play), the EXTRAS as `type: drums` +manifest arrangement entries carrying per-arrangement `drum_tab` file +pointers and NO note `file` β€” entries an old reader (and the core +loader's file/notation gate) skips cleanly. + +Pinned here: + - `_is_drum_pointer_entry` β€” the split that keeps the pitched save + pipeline from ever pairing a wire arrangement with a file-less + pointer entry (mispairing would corrupt the manifest); + - `_sanitize_extra_drum_tab` β€” every persisted extra tab holds the + same invariants as the primary (no malformed/duplicate hits, + millisecond-rounded, time-ordered) and the request body is never + mutated. + - `_create_build_drum_entries` β€” the CREATE-MODE /build write: the + `type: drums` manifest entries + the extra side files, mirroring the + /save_song drum-parts wire. +""" + +import json + +from routes import ( + _create_build_drum_entries, + _is_drum_pointer_entry, + _primary_drum_alias_id, + _sanitize_extra_drum_tab, +) + + +def test_primary_alias_id_follows_the_incoming_primary_id(): + # The MED fix: the song-level drum_tab alias entry is persisted under the + # promoted primary's actual id (parts[0].id), NOT a hardcoded "drums" β€” so + # deleting the original primary and promoting a survivor round-trips its + # id, keeping its stem links / tree rows matched on reload. + assert _primary_drum_alias_id("drums-2") == "drums-2" + assert _primary_drum_alias_id("drums_live") == "drums_live" + # Legacy / old-client back-compat: absent or blank β†’ the legacy "drums", + # so a single-drum pack stays byte-identical and old readers still match. + assert _primary_drum_alias_id(None) == "drums" + assert _primary_drum_alias_id("") == "drums" + assert _primary_drum_alias_id(" ") == "drums" + # Sanitized to the same filename-safe charset the extra parts use. + assert _primary_drum_alias_id("Drums (Live)") == "drums-live" + assert _primary_drum_alias_id("../evil") == "evil" + assert _primary_drum_alias_id("!!!") == "drums" + + +def test_pointer_entry_is_type_drums_without_a_note_file(): + assert _is_drum_pointer_entry( + {"id": "drums_live", "type": "drums", "drum_tab": "drum_tab_live.json"} + ) + # Case / synonym normalization mirrors the frontend's type test. + assert _is_drum_pointer_entry({"type": "Drums", "drum_tab": "x.json"}) + assert _is_drum_pointer_entry({"type": "drum", "drum_tab": "x.json"}) + # A whitespace-only `file` is still file-less (the core loader strips too). + assert _is_drum_pointer_entry({"type": "drums", "file": " "}) + + +def test_fretted_and_notation_entries_are_never_pointer_entries(): + # An ordinary fretted arrangement β€” even one NAMED Drums β€” is not a pointer. + assert not _is_drum_pointer_entry( + {"id": "lead", "name": "Drums", "file": "arrangements/lead.json"} + ) + # A type:drums entry WITH a real note file stays in the pitched pipeline + # (defensive: never orphan a file-backed entry into the drum block). + assert not _is_drum_pointer_entry({"type": "drums", "file": "arrangements/d.json"}) + # Untyped / other-typed / malformed entries are not pointers. + assert not _is_drum_pointer_entry({"id": "keys", "type": "piano"}) + assert not _is_drum_pointer_entry({"id": "x"}) + assert not _is_drum_pointer_entry("drums") + assert not _is_drum_pointer_entry(None) + + +def test_sanitize_drops_malformed_and_duplicate_hits_and_sorts(): + tab = { + "version": 1, + "name": "Drums (Live)", + "kit": [], + "hits": [ + {"t": 2.0004, "p": "snare"}, + {"t": 1.0, "p": "kick"}, + {"t": 2.0, "p": "snare"}, # dup of the first after rounding + {"t": -1.0, "p": "kick"}, # negative β†’ malformed + {"t": float("nan"), "p": "kick"}, # non-finite β†’ malformed + {"t": 3.0}, # no piece β†’ malformed + "junk", # not a dict β†’ malformed + {"t": "1.5", "p": "hh"}, # numeric string coerces + ], + } + out = _sanitize_extra_drum_tab(tab) + assert [(h["t"], h["p"]) for h in out["hits"]] == [ + (1.0, "kick"), (1.5, "hh"), (2.0, "snare"), + ] + # Never mutates the request body: the original keeps all 8 raw hits. + assert len(tab["hits"]) == 8 + # Non-hit fields pass through. + assert out["name"] == "Drums (Live)" + assert out["version"] == 1 + + +def test_sanitize_tolerates_a_missing_or_bogus_hits_field(): + assert _sanitize_extra_drum_tab({"version": 1})["hits"] == [] + assert _sanitize_extra_drum_tab({"version": 1, "hits": "junk"})["hits"] == [] + + +# ── create-mode /build write (_create_build_drum_entries) ──────────────────── + +def _dtab(name, hits=None): + return {"version": 1, "name": name, "kit": [], + "hits": hits if hits is not None else [{"t": 1.0, "p": "kick"}]} + + +def test_create_build_writes_side_files_and_primary_alias(tmp_path): + primary = _dtab("Drums") + entries = _create_build_drum_entries(tmp_path, primary, [ + {"id": "drums-2", "name": "Drums (Live)", "drum_tab": _dtab("Drums (Live)", [{"t": 2.0, "p": "snare"}])}, + ]) + # Primary alias FIRST (points at the song-level file, no side file written), + # then the extra with its own drum_tab_.json. + assert entries == [ + {"id": "drums", "name": "Drums", "type": "drums", "drum_tab": "drum_tab.json"}, + {"id": "drums-2", "name": "Drums (Live)", "type": "drums", "drum_tab": "drum_tab_drums-2.json"}, + ] + # The primary's file is the caller's job β€” only the extra is written here. + assert not (tmp_path / "drum_tab.json").exists() + written = json.loads((tmp_path / "drum_tab_drums-2.json").read_text()) + assert written["name"] == "Drums (Live)" + assert [h["p"] for h in written["hits"]] == ["snare"] + + +def test_create_build_empty_extras_writes_only_the_primary_alias(tmp_path): + entries = _create_build_drum_entries(tmp_path, _dtab("Drums"), []) + assert entries == [ + {"id": "drums", "name": "Drums", "type": "drums", "drum_tab": "drum_tab.json"}] + assert list(tmp_path.glob("drum_tab_*.json")) == [] + + +def test_create_build_sanitizes_and_names_the_extra_tab(tmp_path): + # Malformed/duplicate hits are dropped and the entry name propagates into + # the written tab's name field (kept in lockstep for reload). + dirty = _dtab("x", [{"t": 2.0, "p": "kick"}, {"t": 2.0, "p": "kick"}, {"t": -1, "p": "kick"}, {"t": 1.0, "p": "snare"}]) + _create_build_drum_entries(tmp_path, _dtab("Drums"), [ + {"id": "drums-2", "name": "Live Kit", "drum_tab": dirty}]) + tab = json.loads((tmp_path / "drum_tab_drums-2.json").read_text()) + assert tab["name"] == "Live Kit" + assert [(h["t"], h["p"]) for h in tab["hits"]] == [(1.0, "snare"), (2.0, "kick")] + # The request body is never mutated (sanitize returns a copy). + assert len(dirty["hits"]) == 4 + + +def test_create_build_de_collides_ids_and_sanitizes_filenames(tmp_path): + entries = _create_build_drum_entries(tmp_path, _dtab("Drums"), [ + {"id": "drums", "drum_tab": _dtab("A")}, # collides with the primary + {"id": "Weird Name!", "drum_tab": _dtab("B")}, # unsafe filename chars + {"id": "", "drum_tab": _dtab("C")}, # empty β†’ default + ]) + ids = [e["id"] for e in entries] + assert ids[0] == "drums" # the primary alias + assert len(set(ids)) == len(ids), "no duplicate ids" + assert "drums" not in ids[1:] # the colliding extra was renamed + for e in entries[1:]: + assert (tmp_path / f"drum_tab_{e['id']}.json").exists() + assert e["id"] == e["id"].lower() and " " not in e["id"] and "!" not in e["id"] + + +def test_create_build_sanitizes_a_non_list_hits_tab_to_empty(tmp_path): + # Schema validation is the endpoint's job (build_song_endpoint rejects a + # non-list `hits` with 400); if junk still reaches the pure writer, it + # sanitizes to empty rather than crashing the build. + entries = _create_build_drum_entries(tmp_path, _dtab("Drums"), [ + {"id": "bad", "drum_tab": {"version": 1, "hits": "not-a-list"}}]) + assert [e["id"] for e in entries] == ["drums", "bad"] + assert json.loads((tmp_path / "drum_tab_bad.json").read_text())["hits"] == [] diff --git a/tests/test_manifest_type_preserve.py b/tests/test_manifest_type_preserve.py index 5770636c..33841f55 100644 --- a/tests/test_manifest_type_preserve.py +++ b/tests/test_manifest_type_preserve.py @@ -119,3 +119,30 @@ def test_merge_does_not_mutate_inputs(): out["name"] = "changed" assert old["type"] == "bass" assert rebuilt["name"] == "A" + + +# ── editor-authored type round-trips (the save-carry contract) ──────────────── +# The save path now carries an editor-provided `type` into the rebuilt entry +# (previously the rebuilt entry never had one, so a set/changed type could only +# survive by accident of the merge preserving the OLD value). These pin that a +# type SET in the editor persists and can override a stale on-disk value β€” the +# contract drums-as-arrangements and a future "set instrument" action rely on. + +def test_a_newly_typed_arrangement_persists_its_type(): + # No old entry (a freshly added arrangement): the editor-set type survives. + rebuilt = {"id": "drm", "name": "Kit", "file": "arrangements/drm.json", + "tuning": [0] * 6, "capo": 0, "type": "drums"} + out = _merge_manifest_entry(None, rebuilt) + assert out["type"] == "drums" + + +def test_editor_set_type_overrides_a_stale_on_disk_type(): + # The rebuilt (editor) type wins over the old entry's type β€” so changing an + # instrument in the editor actually re-types the entry, rather than the old + # value silently sticking. + old = {"id": "x", "name": "X", "type": "guitar", "centOffset": -3} + rebuilt = {"id": "x", "name": "X", "file": "arrangements/x.json", + "tuning": [0] * 6, "capo": 0, "type": "drums"} + out = _merge_manifest_entry(old, rebuilt) + assert out["type"] == "drums", "editor type wins" + assert out["centOffset"] == -3, "unrelated preserved keys still survive" diff --git a/tests/view_switcher.test.mjs b/tests/view_switcher.test.mjs index 55491780..a2ef7204 100644 --- a/tests/view_switcher.test.mjs +++ b/tests/view_switcher.test.mjs @@ -21,6 +21,7 @@ import { _partViewKeyPure, _rollMidiForNote, _viewForPure, noteToMidi, updatePianoRange, } from '../src/keys.js'; import { LC } from '../src/lanes.js'; import { S } from '../src/state.js'; +import { isDrumArrangement } from '../src/drum-arrangement.js'; // Only @pure:edit-history is still sliced β€” it lives in src/main.js. const src = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8'); @@ -350,5 +351,83 @@ t('read-only roll: inspector editorInspectorSetFlag does not mutate the fretted assert.strictEqual(history.undo.length, 1, 'and now it is one undoable commit'); }); +// ── The arrangement switcher must drop the engraved-tab lens on a mode +// switch. draw() checks S.tabViewMode FIRST (main.js), so a stale lens +// keeps painting the previous part's tab over the new mode. The +// Edit-Drums button already clears it; editorSwitcherSelect (and its +// twin openTrackSessionTarget) did not. ──────────────────────────────── +const DRUMS = () => ({ id: 'drums', name: 'Drums', type: 'drums', drumTab: { version: 1 } }); +const GTR = (name = 'Lead') => ({ id: name.toLowerCase(), name, notes: [], chords: [] }); + +function makeSwitcher() { + let selectorSyncs = 0; + const fn = extractWinFn('editorSwitcherSelect', { + isDrumArrangement, + S, + _finalizeActiveDrag: () => {}, + _refreshPartsViewButton: () => {}, + _refreshDrumEditButton: () => {}, + _refreshTempoMapButton: () => {}, + updateArrangementSelector: () => { selectorSyncs++; }, + draw: () => {}, + updateStatus: () => {}, + window: { editorSelectArrangement: (v) => { S.currentArr = parseInt(v) || 0; } }, + }); + return { fn, counts: { get selectorSyncs() { return selectorSyncs; } } }; +} + +t('switcher β†’ Drums drops the engraved lens (draw checks tabViewMode first)', () => { + Object.assign(S, { + arrangements: [GTR('Lead'), DRUMS()], currentArr: 0, + drumTab: { version: 1 }, format: 'sloppak', + tabViewMode: true, drumEditMode: false, drumSel: new Set(), + partsViewMode: false, tempoMapMode: false, tempoSel: -1, sel: new Set(), + }); + makeSwitcher().fn('1'); // the Drums option + assert.strictEqual(S.drumEditMode, true, 'drum grid opened'); + assert.strictEqual(S.tabViewMode, false, 'lens dropped so the drum grid is what draws'); +}); + +t('switcher β†’ a pitched part drops the drum-notation lens', () => { + Object.assign(S, { + arrangements: [GTR('Lead'), GTR('Bass'), DRUMS()], currentArr: 0, + drumTab: { version: 1 }, format: 'sloppak', + tabViewMode: true, drumEditMode: true, drumSel: new Set(), sel: new Set(), + }); + makeSwitcher().fn('1'); // Bass β€” a pitched part + assert.strictEqual(S.drumEditMode, false, 'left drum mode'); + assert.strictEqual(S.tabViewMode, false, 'drum-notation lens dropped over the pitched arrangement'); +}); + +t('switcher β†’ a pitched part leaves Parts and Tempo Map view too (draw renders the live lens)', () => { + // Parts/Tempo are mutually-exclusive lenses like the drum grid: the drums + // branch and openTrackSessionTarget both drop them, so the pitched branch + // must too, else currentArr moves but draw() keeps painting the old lens. + Object.assign(S, { + arrangements: [GTR('Lead'), GTR('Bass'), DRUMS()], currentArr: 0, + drumTab: { version: 1 }, format: 'sloppak', + tabViewMode: false, drumEditMode: false, drumSel: new Set(), sel: new Set(), + partsViewMode: true, tempoMapMode: true, tempoSel: 3, + }); + makeSwitcher().fn('1'); // Bass β€” a pitched part + assert.strictEqual(S.currentArr, 1, 'switched to the pitched part'); + assert.strictEqual(S.partsViewMode, false, 'left Parts view so the part is what draws'); + assert.strictEqual(S.tempoMapMode, false, 'left Tempo Map view'); + assert.strictEqual(S.tempoSel, -1, 'tempo selection cleared with the mode'); +}); + +t('switcher β†’ Drums resyncs the