From fba63bdb5a2888edd6043759cecf61da7bf60678 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Tue, 14 Jul 2026 22:42:23 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(editor):=20multitrack=20stem=20ingest?= =?UTF-8?q?=20=E2=80=94=20import,=20rename,=20reorder,=20and=20pair=20stud?= =?UTF-8?q?io=20tracks=20with=20chart=20tracks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Christian's ask: more than demucs's five — take N tracks from an actual studio session, ingest, rename, rearrange, and transcribe/chart against each as the user wishes. Backend (routes.py): - POST /import-stems (multipart, any count): filename-derived ids (_stem_safe_id: sanitized, deduped, 60-char cap), files into the sloppak session's stems/ + manifest stems list (zip-form persists on Save — the replace-audio rule) or, for create-mode GP/MIDI imports, into the session's stem_files which Build packs into the new pak (staging/stems/ + manifest entries in _write_sloppak_pak). - POST /stem-op rename|reorder|delete: pure list ops (_stems_rename_pure refuses collisions; _stems_reorder_pure demands a full permutation — partial orders get no authority to drop entries; _stems_manifest_list preserves unknown entry fields so another tool's stem metadata survives our edits); renames MOVE the file and follow the links; deletes drop their links. - editor_stem_links manifest extension key (chart-track key → stem id, the audio_shift absent/empty/garbage contract) at all three save paths + /load; /load's stems gate relaxed >=2 → >=1 (a lone imported track must reach the manager; the mixer strips keep their own gate). Frontend (src/stem-tracks.js + modal): - File ▸ Audio tracks…: import (multi-file), rename (click the name), reorder (▴▾ — the manifest is order-authoritative), delete, and a per-track pairing select over the chart tracks. Chart keys use the _partViewKeyPure id-or-name rule — never a bare index, so links survive part reordering. - 'Solo my source track' (registry + Transport menu): solos the current track's paired stem via S.stemMix — the transcription move; audible once the stem-mixer strips (#275) land, honest status either way. tests/stem_tracks.test.mjs (4, fails on main): row model + key rule, one-stem-per-track pairing, solo verb exactness, persistence wire. tests/test_stem_tracks.py (5): id sanitize/dedupe, rename/reorder pure hard-edges, unknown-field preservation, the links contract. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q --- CHANGELOG.md | 11 ++ routes.py | 333 ++++++++++++++++++++++++++++++++++- screen.html | 18 ++ src/file-ops.js | 6 + src/input.js | 3 + src/main.js | 4 + src/menu-bar.js | 2 + src/shortcuts.js | 2 + src/state.js | 7 + src/stem-tracks.js | 237 +++++++++++++++++++++++++ tests/screen_markup.test.mjs | 2 +- tests/stem_tracks.test.mjs | 80 +++++++++ tests/test_stem_tracks.py | 58 ++++++ 13 files changed, 761 insertions(+), 2 deletions(-) create mode 100644 src/stem-tracks.js create mode 100644 tests/stem_tracks.test.mjs create mode 100644 tests/test_stem_tracks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9986521d..dcfde9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 tracks now sound out of the box, including alongside recordings and stems: **every track plays its instrument** (band mode defaults on), and the guide voice is always a real instrument — the old "clap" +- **Bring in a whole studio session — File › Audio tracks…** Import any + number of audio tracks (wav / ogg / opus / mp3 / flac — a real session's + multitrack, not just separated stems), then rename them, reorder them, + delete them, and **pair each with the chart track that transcribes it**. + Pairings are saved with the song and survive track reordering. A new + **Solo my source track** command isolates the current track's paired + audio while you chart against it (audible once the stem-mixer strips + land; the pairing itself works today). Imported tracks are packed into + the feedpak on Save (or Build for a fresh Guitar Pro/MIDI import) and + show up as stems everywhere stems already work. + - **Drums sound like a drum kit now.** Every drum piece in the chart plays its real sound — kick, snares (and cross-stick), open/closed/pedal hats, all four toms, crashes, splash, china, ride and bell, cowbell — instead of diff --git a/routes.py b/routes.py index 2762e03a..db6b64df 100644 --- a/routes.py +++ b/routes.py @@ -241,6 +241,87 @@ def _apply_tempo_marks(manifest: dict, marks) -> None: manifest["editor_tempo_marks"] = marks else: manifest.pop("editor_tempo_marks", None) +_STEM_ID_SUB_RE = re.compile(r"[^a-zA-Z0-9_-]") +_STEM_AUDIO_EXTS = {".wav", ".ogg", ".opus", ".mp3", ".flac", ".m4a", ".aac"} + + +def _stem_safe_id(name, taken): + """A filename → a unique, filesystem-safe stem id ('Guitar L (DI).wav' → + 'Guitar_L__DI_', deduped against `taken`). The id IS the identity the + manifest, the mixer strips, and the chart links all share.""" + base = _STEM_ID_SUB_RE.sub("_", Path(name or "").stem).strip("_")[:60] or "track" + sid, n = base, 2 + while sid in taken: + sid = f"{base}_{n}" + n += 1 + return sid + + +def _stems_manifest_list(manifest): + """The manifest's stems as a normalized mutable copy (unknown entry + fields preserved — another tool's stem metadata must survive our edits).""" + out = [] + for e in (manifest.get("stems") or []): + if isinstance(e, dict) and isinstance(e.get("id"), str) and e.get("id"): + out.append(dict(e)) + return out + + +def _stems_rename_pure(stems, old_id, new_id): + """Renamed stems list + the entry, or (None, None) when old is missing + or new collides. File path is NOT touched here (the endpoint moves it).""" + if any(e["id"] == new_id for e in stems): + return None, None + for e in stems: + if e["id"] == old_id: + e = dict(e) + e["id"] = new_id + return [dict(x) if x["id"] != old_id else e for x in stems], e + return None, None + + +def _stems_reorder_pure(stems, order): + """Stems re-sorted to `order` (a full permutation of the ids) or None — + a partial/foreign order gets no authority to drop entries.""" + ids = [e["id"] for e in stems] + if not isinstance(order, list) or sorted(order) != sorted(ids): + return None + by_id = {e["id"]: e for e in stems} + return [dict(by_id[i]) for i in order] + + +def _coerce_stem_links(value): + """`editor_stem_links` as a sanitized {chart-track key → stem id} dict. + Same manifest-extension retention contract as `editor_tempo_marks`.""" + if not isinstance(value, dict): + return {} + out = {} + for k, v in list(value.items())[:200]: + if isinstance(k, str) and k and isinstance(v, str) and v: + out[k[:120]] = v[:120] + return out + + +def _parse_stem_links(data: dict): + """Absent → _FIELD_ABSENT (older client leaves persisted links alone); + a non-dict → _FIELD_ABSENT (garbage gets no authority to erase); an + empty dict is a genuine "no links" and removes the key.""" + if "stem_links" not in data: + return _FIELD_ABSENT + value = data.get("stem_links") + if not isinstance(value, dict): + return _FIELD_ABSENT + return _coerce_stem_links(value) + + +def _apply_stem_links(manifest: dict, links) -> None: + """Write/remove the `editor_stem_links` manifest extension key.""" + if links is _FIELD_ABSENT: + return + if links: + manifest["editor_stem_links"] = links + else: + manifest.pop("editor_stem_links", None) def _parse_audio_shift(data: dict): @@ -3838,8 +3919,14 @@ def _safe_stem_path(stem_entry: dict) -> "Path | None": result["beats"] = timeline["beats"] if "sections" in timeline: result["sections"] = timeline["sections"] - if len(_stem_urls) >= 2: + # >=1 (was >=2): a lone imported studio track must still reach + # the stem manager; the mixer STRIPS keep their own >=2 gate. + if len(_stem_urls) >= 1: result["stems"] = _stem_urls + # Chart-track <-> stem pairings (multitrack transcription). + _links = _coerce_stem_links(loaded.manifest.get("editor_stem_links")) + if _links: + result["stem_links"] = _links # `lib/sloppak.load_song()` doesn't restore song.offset (the # sloppak format doesn't carry an explicit offset field today), # so song.offset is 0 here. If the manifest happens to surface @@ -4025,6 +4112,8 @@ async def save_song(data: dict): audio_shift = _parse_audio_shift(data) # Authored tempo/meter marks (P2-5) — same absent-vs-empty contract. tempo_marks = _parse_tempo_marks(data) + # Chart-track <-> stem pairings — the same absent-vs-empty contract. + stem_links = _parse_stem_links(data) # Merge session metadata (album/year captured at archive load # time) with anything the frontend sent. `_buildSaveBody` ships # `{title, artist}` on every save path; this merge keeps the @@ -4443,6 +4532,7 @@ def _build_wire(arr_dict, is_first): # `data.audio_shift` on load. _apply_audio_shift(manifest, audio_shift) _apply_tempo_marks(manifest, tempo_marks) + _apply_stem_links(manifest, stem_links) _write_song_timeline_sidecar(source_dir, manifest, beats, sections) @@ -4536,6 +4626,9 @@ async def save_as_sloppak(data: dict): _req_marks = _parse_tempo_marks(data) if _req_marks is not _FIELD_ABSENT and _req_marks: meta["tempo_marks"] = _req_marks + _req_links = _parse_stem_links(data) + if _req_links is not _FIELD_ABSENT and _req_links: + meta["stem_links"] = _req_links audio_file = session.get("audio_file") or "" if not audio_file or not Path(audio_file).exists(): @@ -5439,6 +5532,223 @@ def _download(): except Exception as e: return JSONResponse({"error": str(e)}, 500) + # ── Multitrack stem management (studio-session ingest) ─────────── + # Import ANY number of audio tracks (a real session's multitrack, not + # just demucs's five), rename, reorder, delete — and pair each with the + # chart track being transcribed against it (editor_stem_links). + + def _stem_session_kind(session): + """('sloppak', source_dir) | ('create', None) | (None, response).""" + if session.get("format") == "sloppak" and session.get("sloppak_state"): + return "sloppak", Path(session["dir"]).resolve() + if session.get("create_mode"): + return "create", None + return None, JSONResponse( + {"error": "stems can be managed on sloppak or fresh-import sessions only"}, 400) + + def _stem_state_payload(session, session_id): + """The session's current stems + links, as the frontend consumes them.""" + kind = "sloppak" if (session.get("format") == "sloppak" and session.get("sloppak_state")) else "create" + out = [] + if kind == "sloppak": + source_dir = Path(session["dir"]).resolve() + manifest = dict(session["sloppak_state"].get("manifest") or {}) + for e in _stems_manifest_list(manifest): + sid = e["id"] + if sid == "full": + continue + sp = (source_dir / str(e.get("file") or "")).resolve() + try: + sp.relative_to(source_dir) + except ValueError: + continue + if not sp.exists(): + continue + safe = _STEM_ID_SUB_RE.sub("_", sid) + dest = STORAGE_DIR / ("editor_stem_s%s_%s%s" % (session_id[:8], safe, sp.suffix)) + try: + shutil.copy2(sp, dest) + except OSError: + continue + out.append({"id": sid, "url": "%s/%s" % (STORAGE_URL, dest.name)}) + else: + for e in (session.get("stem_files") or []): + sp = Path(e.get("path") or "") + if sp.exists(): + out.append({"id": e["id"], "url": "%s/%s" % (STORAGE_URL, sp.name)}) + return {"stems": out, "stem_links": _coerce_stem_links(session.get("stem_links"))} + + @app.post("/api/plugins/editor/import-stems") + async def import_stems(session_id: str = Form(...), files: list[UploadFile] = File(...)): + """Ingest one or more audio files as named stems of this session. + + Ids come from the filenames (sanitized, deduped); the files land in + the sloppak's stems/ dir + manifest (zip-form persists on Save, the + replace-audio rule) or, for a fresh GP/MIDI import, in the session's + stem list which the Build packs into the new sloppak. + """ + session = sessions.get(session_id) + if not session: + return JSONResponse({"error": "session not found"}, 404) + kind, where = _stem_session_kind(session) + if kind is None: + return where + session["last_touched"] = time.time() + imported, skipped = [], [] + if kind == "sloppak": + source_dir = where + manifest = dict(session["sloppak_state"].get("manifest") or {}) + stems = _stems_manifest_list(manifest) + taken = {e["id"] for e in stems} | {"full"} + stems_dir = source_dir / "stems" + stems_dir.mkdir(parents=True, exist_ok=True) + for f in files: + ext = (Path(f.filename or "").suffix or "").lower() + if ext not in _STEM_AUDIO_EXTS: + skipped.append(f.filename or "?") + continue + sid = _stem_safe_id(f.filename, taken) + taken.add(sid) + dest = (stems_dir / (sid + ext)).resolve() + try: + dest.relative_to(source_dir) + except ValueError: + skipped.append(f.filename or "?") + continue + dest.write_bytes(await f.read()) + stems.append({"id": sid, "file": "stems/" + dest.name, + "codec": ext.lstrip("."), "default": "on"}) + imported.append(sid) + manifest["stems"] = stems + (source_dir / "manifest.yaml").write_text( + yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True), + encoding="utf-8") + session["sloppak_state"]["manifest"] = manifest + else: + stem_files = list(session.get("stem_files") or []) + taken = {e["id"] for e in stem_files} | {"full"} + for f in files: + ext = (Path(f.filename or "").suffix or "").lower() + if ext not in _STEM_AUDIO_EXTS: + skipped.append(f.filename or "?") + continue + sid = _stem_safe_id(f.filename, taken) + taken.add(sid) + dest = STORAGE_DIR / ("editor_stem_s%s_%s%s" % (session_id[:8], sid, ext)) + dest.write_bytes(await f.read()) + stem_files.append({"id": sid, "path": str(dest)}) + imported.append(sid) + session["stem_files"] = stem_files + payload = _stem_state_payload(session, session_id) + payload["imported"] = imported + payload["skipped"] = skipped + payload["next_step"] = ("none" if (kind == "sloppak" + and session["sloppak_state"].get("form") == "dir") else ( + "save" if kind == "sloppak" else "build")) + return payload + + @app.post("/api/plugins/editor/stem-op") + async def stem_op(data: dict): + """Rename / reorder / delete a stem, keeping chart links honest + (a rename follows the id; a delete drops its links).""" + session_id = data.get("session_id", "") + session = sessions.get(session_id) + if not session: + return JSONResponse({"error": "session not found"}, 404) + kind, where = _stem_session_kind(session) + if kind is None: + return where + session["last_touched"] = time.time() + op = data.get("op") + links = _coerce_stem_links(session.get("stem_links")) + + def _sync_links(fn): + session["stem_links"] = {k: fn(v) for k, v in links.items() if fn(v)} + + if kind == "sloppak": + source_dir = where + manifest = dict(session["sloppak_state"].get("manifest") or {}) + stems = _stems_manifest_list(manifest) + if op == "rename": + old = data.get("id") + new = _stem_safe_id(data.get("new_id"), + {e["id"] for e in stems if e["id"] != old} | {"full"}) + renamed, entry = _stems_rename_pure(stems, old, new) + if renamed is None: + return JSONResponse({"error": "unknown stem or name taken"}, 400) + old_path = (source_dir / str(entry.get("file") or "")).resolve() + new_rel = "stems/" + new + old_path.suffix + try: + old_path.relative_to(source_dir) + except ValueError: + return JSONResponse({"error": "stem path escapes session dir"}, 400) + if old_path.exists(): + old_path.rename(source_dir / new_rel) + for e in renamed: + if e["id"] == new: + e["file"] = new_rel + stems = renamed + _sync_links(lambda v: new if v == old else v) + elif op == "reorder": + stems2 = _stems_reorder_pure(stems, data.get("order")) + if stems2 is None: + return JSONResponse({"error": "order must be a full permutation"}, 400) + stems = stems2 + elif op == "delete": + sid = data.get("id") + entry = next((e for e in stems if e["id"] == sid), None) + if entry is None: + return JSONResponse({"error": "unknown stem"}, 400) + sp = (source_dir / str(entry.get("file") or "")).resolve() + try: + sp.relative_to(source_dir) + if sp.exists(): + sp.unlink() + except (ValueError, OSError): + pass + stems = [e for e in stems if e["id"] != sid] + _sync_links(lambda v: None if v == sid else v) + else: + return JSONResponse({"error": "op must be rename|reorder|delete"}, 400) + manifest["stems"] = stems + (source_dir / "manifest.yaml").write_text( + yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True), + encoding="utf-8") + session["sloppak_state"]["manifest"] = manifest + else: + stem_files = list(session.get("stem_files") or []) + if op == "rename": + old = data.get("id") + new = _stem_safe_id(data.get("new_id"), + {e["id"] for e in stem_files if e["id"] != old} | {"full"}) + hit = next((e for e in stem_files if e["id"] == old), None) + if hit is None: + return JSONResponse({"error": "unknown stem"}, 400) + hit["id"] = new + _sync_links(lambda v: new if v == old else v) + elif op == "reorder": + order = data.get("order") + ids = [e["id"] for e in stem_files] + if not isinstance(order, list) or sorted(order) != sorted(ids): + return JSONResponse({"error": "order must be a full permutation"}, 400) + by_id = {e["id"]: e for e in stem_files} + stem_files = [by_id[i] for i in order] + elif op == "delete": + sid = data.get("id") + hit = next((e for e in stem_files if e["id"] == sid), None) + if hit is None: + return JSONResponse({"error": "unknown stem"}, 400) + try: + Path(hit["path"]).unlink() + except OSError: + pass + stem_files = [e for e in stem_files if e["id"] != sid] + _sync_links(lambda v: None if v == sid else v) + else: + return JSONResponse({"error": "op must be rename|reorder|delete"}, 400) + session["stem_files"] = stem_files + return _stem_state_payload(session, session_id) + # ── Replace audio on a loaded session ──────────────────────────── @app.post("/api/plugins/editor/replace-audio") @@ -7252,6 +7562,12 @@ async def build_song_endpoint(data: dict): _req_marks = _parse_tempo_marks(data) if _req_marks is not _FIELD_ABSENT and _req_marks: meta["tempo_marks"] = _req_marks + _req_links = _parse_stem_links(data) + if _req_links is not _FIELD_ABSENT and _req_links: + meta["stem_links"] = _req_links + # Create-mode imported stems ride the session into the build. + if session.get("stem_files"): + meta["stem_files"] = list(session["stem_files"]) audio_url = data.get("audio_url", "") art_path = data.get("art_path", "") preview_path = data.get("preview_path", "") @@ -7609,6 +7925,21 @@ def _write_sloppak_pak(*, audio_file: str, art_path: str, # Written only when nonzero so unshifted packs stay byte-identical. _apply_audio_shift(manifest, _coerce_audio_shift(meta.get("audio_shift"))) _apply_tempo_marks(manifest, _coerce_tempo_marks(meta.get("tempo_marks"))) + _apply_stem_links(manifest, _coerce_stem_links(meta.get("stem_links"))) + # Create-mode imported studio tracks: pack them under stems/ and + # declare them (the manifest's stems list is order-authoritative). + _mstems = list(manifest.get("stems") or []) + for _sf in (meta.get("stem_files") or []): + _sp = Path(_sf.get("path") or "") + if not _sp.exists(): + continue + (staging / "stems").mkdir(parents=True, exist_ok=True) + _dest = staging / "stems" / (_sf["id"] + _sp.suffix.lower()) + shutil.copy2(_sp, _dest) + _mstems.append({"id": _sf["id"], "file": "stems/" + _dest.name, + "codec": _sp.suffix.lower().lstrip("."), "default": "on"}) + if _mstems: + manifest["stems"] = _mstems # Spec-complete optional metadata (feedpak §5.1) — written only when # present so packs without them stay minimal. String scalars, diff --git a/screen.html b/screen.html index 0c18ddd3..23ff7322 100644 --- a/screen.html +++ b/screen.html @@ -242,6 +242,24 @@ + +