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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **A drum edit no longer strips another tool's authored keys from drum-part
manifest entries.** Saving with drum changes rebuilds the `type: drums`
arrangement entries; that rebuild now merges onto the prior same-id entry —
the same unknown-key preservation rule the pitched pipeline already follows —
so additive spec fields the editor doesn't author (like feedpak-spec 1.18.0's
per-arrangement `tones` sound binding) survive the save instead of silently
vanishing. Entries the editor owns (`id`/`name`/`type`/`drum_tab`) still take
the rebuilt values, and single-drum packs stay byte-identical.

- **Corrected inaccurate and inconsistent UI labels.** The canvas status footer
said "Scroll: zoom" although the wheel pans (Ctrl+wheel zooms); it now reads
"Wheel/middle-drag: pan | Ctrl+wheel: zoom". In the in-app User Guide, Tempo Map
Expand Down
46 changes: 34 additions & 12 deletions routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,26 @@ def _is_drum_pointer_entry(entry):
)


def _drum_pointer_entry(old_entry, entry_id, name, drum_tab_rel):
"""One rebuilt drum-pointer manifest entry, merged ONTO its predecessor
(the prior entry with the same id) via the same ``_merge_manifest_entry``
rule the pitched pipeline uses — so additive spec keys the editor doesn't
author (the feedpak-spec 1.18.0 entry-level ``tones`` sound binding,
future extension keys) survive the drum block's full rebuild instead of
being silently stripped on every drum-dirty save. The editor-owned keys
(id/name/type/drum_tab) always take the rebuilt value; ``old_entry`` is
``None`` for a genuinely new part.

Module-level so pytest can reach it.
"""
return _merge_manifest_entry(old_entry, {
"id": entry_id,
"name": name,
"type": "drums",
"drum_tab": drum_tab_rel,
})


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):
Expand Down Expand Up @@ -5398,6 +5418,14 @@ def _build_wire(arr_dict, is_first):
new_drum_entries: list[dict] = []
kept_drum_files: set = set()
used_part_ids: set = set()
# Prior drum-pointer entries by id: each rebuilt entry merges
# ONTO its predecessor via _drum_pointer_entry, so additive
# spec keys (the 1.18.0 entry `tones` binding, extension
# keys) survive the rebuild — see that helper's docstring.
_old_drum_by_id = {
str(e.get("id")): e for e in old_drum_entries
if isinstance(e, dict) and e.get("id")
}
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
Expand All @@ -5408,12 +5436,10 @@ def _build_wire(arr_dict, is_first):
# 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",
})
new_drum_entries.append(_drum_pointer_entry(
_old_drum_by_id.get(_primary_id), _primary_id,
str(drum_tab_payload.get("name") or "Drums")[:120],
"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.
Expand Down Expand Up @@ -5441,12 +5467,8 @@ def _build_wire(arr_dict, is_first):
encoding="utf-8",
)
kept_drum_files.add(_p_path)
new_drum_entries.append({
"id": _pid,
"name": _p_name,
"type": "drums",
"drum_tab": _p_rel,
})
new_drum_entries.append(_drum_pointer_entry(
_old_drum_by_id.get(_pid), _pid, _p_name, _p_rel))
manifest["arrangements"] = (
[e for e in (manifest.get("arrangements") or [])
if not _is_drum_pointer_entry(e)]
Expand Down
49 changes: 49 additions & 0 deletions tests/test_drum_parts.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,17 @@
- `_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.
- `_drum_pointer_entry` — the /save_song drum block's entry rebuild
merges ONTO the prior same-id entry, so additive spec keys the editor
doesn't author (the feedpak-spec 1.18.0 entry-level `tones` sound
binding, extension keys) survive a drum-dirty save.
"""

import json

from routes import (
_create_build_drum_entries,
_drum_pointer_entry,
_is_drum_pointer_entry,
_primary_drum_alias_id,
_sanitize_extra_drum_tab,
Expand Down Expand Up @@ -106,6 +111,50 @@ def test_sanitize_tolerates_a_missing_or_bogus_hits_field():
assert _sanitize_extra_drum_tab({"version": 1, "hits": "junk"})["hits"] == []


# ── /save_song entry rebuild preserves authored keys (_drum_pointer_entry) ───

def test_drum_entry_rebuild_preserves_tones_and_extension_keys():
# A pack authored elsewhere binds a drum part's sound via the 1.18.0
# entry-level `tones` (and may carry extension keys). A drum-dirty
# editor save rebuilds the pointer entries — the rebuild must merge
# onto the prior same-id entry so those keys survive, exactly like the
# pitched pipeline's _merge_manifest_entry rule.
old = {
"id": "drums-2", "name": "Old Name", "type": "drums",
"drum_tab": "drum_tab_old.json",
"tones": {"rig": "kit-rock", "gm": {"program": 0, "bank": 128}},
"x_vendor_key": {"anything": True},
}
out = _drum_pointer_entry(old, "drums-2", "New Name", "drum_tab_drums-2.json")
# Authored/additive keys survive verbatim.
assert out["tones"] == {"rig": "kit-rock", "gm": {"program": 0, "bank": 128}}
assert out["x_vendor_key"] == {"anything": True}
# Editor-owned keys always take the rebuilt value.
assert out["id"] == "drums-2"
assert out["name"] == "New Name"
assert out["type"] == "drums"
assert out["drum_tab"] == "drum_tab_drums-2.json"
# The prior entry object is never mutated (merge returns a fresh dict).
assert old["name"] == "Old Name" and old["drum_tab"] == "drum_tab_old.json"


def test_drum_entry_rebuild_without_a_predecessor_is_exactly_the_clean_entry():
# A genuinely new part (or an id that changed) has nothing to inherit:
# the rebuilt entry is the four editor-owned keys and nothing else —
# so single-drum packs stay byte-identical with pre-1.18.0 saves.
out = _drum_pointer_entry(None, "drums", "Drums", "drum_tab.json")
assert out == {"id": "drums", "name": "Drums", "type": "drums",
"drum_tab": "drum_tab.json"}


def test_drum_entry_rebuild_tolerates_a_malformed_predecessor():
# Defensive: a non-dict predecessor contributes nothing rather than
# crashing the save.
out = _drum_pointer_entry("junk", "drums", "Drums", "drum_tab.json")
assert out == {"id": "drums", "name": "Drums", "type": "drums",
"drum_tab": "drum_tab.json"}


# ── create-mode /build write (_create_build_drum_entries) ────────────────────

def _dtab(name, hits=None):
Expand Down
Loading