diff --git a/CHANGELOG.md b/CHANGELOG.md index b04985a..79be67f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Full-mix audio exposed alongside stems for the stem mixer's auto-switch.** `lib/sloppak.py::load_song` now parses the optional manifest `original_audio:` key (the single pre-separation mixdown, e.g. `original/full.ogg`) into a new `LoadedSloppak.original_audio` field, with the same path-traversal guard and permissive "missing → disabled" posture as the `drum_tab` loader. The highway WS `song_info` frame additively carries three new fields next to `stems`: `original_audio_url` (served by the existing `/api/sloppak/{filename}/file/{rel_path}` endpoint, `None` when the pack ships stems only), `has_original_audio`, and `has_stems` (mirroring the `has_drum_tab`/`has_keys` flag convention). The stems plugin consumes `original_audio_url` to play the untouched single file while every stem slider is at unity and switch to the separate stems the moment one drops below 100%. **Migration notes:** the `song_info` message shape is a stable contract — these are purely additive; all existing fields are unchanged. `audio_url` still points at stem[0] when stems exist (it is only the degraded native fallback); the one behavioural change is that a stem-less, full-mix-only sloppak now sets `audio_url` to the full mix instead of emitting `audio_error`, so it plays natively. - **Autoplay & auto-exit — a global "click it, it plays; finish, you're back at the menu" option (default ON).** New single Settings toggle (`autoplayExit` in `localStorage`, surfaced in both the v3 and classic settings screens; absence of the key = enabled) that closes the friction at both ends of the play loop. **Autoplay:** `playSong()` previously loaded a chart paused, requiring a Play press; a one-shot flag armed per fresh load is now consumed by the next `song:ready` (highway.js) to auto-start via the existing `togglePlay()` path (HTML5 + `_juceMode` + count-in). Arrangement switches / seeks reuse the same `song:ready` event but never arm the flag, so they don't auto-restart. **Auto-exit:** on `song:ended`, core returns to the launching menu after a short grace delay — unless a visible full-screen results/dialog overlay is on top (detected via `[role=dialog][aria-modal]` / `.fixed.inset-0` with a `getClientRects()` visibility test that works for `position:fixed`), in which case the return is deferred so that score screen's own Close button (calling `window.closeCurrentSong()`) drives the exit. A plugin can also defer explicitly via the new `window.feedBack.holdAutoExit()` (called synchronously from its own `song:ended` handler — core's listener runs first). Both paths mean **no external plugin PR is required** for a results screen to be respected. **Context-aware destination:** the player's remembered origin (`_playerOriginScreen`) now honours any real launch screen instead of clamping to library/home/favorites, and a one-shot `window.feedBack.setReturnScreen(id)` override lets the lessons catalog (`static/v3/lessons.js`) send a finished lesson back to the lessons screen — not the song library — even though the external tutorials plugin owns the `playSong` call. Also exposes a read-only `window.feedBack.autoplayExit` getter for plugins. Songs and lessons share the same `playSong` → highway path, so both inherit the behaviour. Core-only (`static/app.js`, `static/v3/lessons.js`, both `index.html`s); the end-of-song score screen itself remains a plugin. Optional polish (not required — the overlay heuristic already covers it): external scoring/note-detection plugins (e.g. SlopScale) may call `holdAutoExit()` + `closeCurrentSong()` for an exact, heuristic-free handoff. - **"Song Editor" promoted to a first-class v3 sidebar item.** The editor plugin (`id: editor`) now gets its own dedicated sidebar entry — under the diff --git a/lib/sloppak.py b/lib/sloppak.py index 8ee837a..d19c460 100644 --- a/lib/sloppak.py +++ b/lib/sloppak.py @@ -367,6 +367,14 @@ class LoadedSloppak: # song.arrangements (not to manifest["arrangements"]) — skipped entries are # absent so indexing by song.arrangements index is safe. arrangement_ids: list[str | None] = field(default_factory=list) + # Manifest-relative path to the single full-mix audio file, taken from the + # manifest `original_audio:` key (e.g. "original/full.ogg"). This is the + # pre-separation mixdown that exists alongside the per-instrument `stems`. + # None when the key is absent, points outside source_dir, or the file is + # missing on disk. Served to the front-end via the highway WS as + # `original_audio_url`; the stems plugin uses it to play the untouched mix + # when every stem slider is at unity (and the separate stems otherwise). + original_audio: str | None = None def load_song( @@ -808,6 +816,29 @@ def load_song( } _fpv = manifest.get("feedpak_version") + # Optional full-mix audio — manifest `original_audio:` key. The single + # pre-separation mixdown that ships alongside the per-instrument stems. + # Same permissive, path-traversal-guarded posture as drum_tab above: a + # missing/escaping/absent file simply leaves the full mix unavailable (the + # player falls back to the separate stems) rather than aborting the load. + # We store the manifest-relative string so server.py can build its URL the + # same way it builds stem URLs (via the /api/sloppak/.../file/ endpoint). + original_audio_data: str | None = None + original_audio_rel = manifest.get("original_audio") + if isinstance(original_audio_rel, str) and original_audio_rel.strip(): + rel = original_audio_rel.strip() + try: + oa_path = (source_dir / rel).resolve() + oa_path.relative_to(source_dir.resolve()) + except ValueError: + log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel) + oa_path = None + except OSError as e: + log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e) + oa_path = None + if oa_path is not None and oa_path.is_file(): + original_audio_data = rel + return LoadedSloppak( song=song, stems=stems, @@ -821,6 +852,7 @@ def load_song( keys=keys_data, notation_by_id=notation_by_id_data, arrangement_ids=arrangement_ids_acc, + original_audio=original_audio_data, ) diff --git a/server.py b/server.py index 2d3c266..d6b99f9 100644 --- a/server.py +++ b/server.py @@ -6923,6 +6923,11 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, audio_url = None audio_error: str | None = None # Surfaced in song_info when audio_url is None stems_payload: list[dict] = [] + # URL of the single full-mix audio (sloppak `original_audio:`), when the + # pack ships one. The stems plugin uses this to play the untouched mix + # while every stem slider is at unity; None otherwise (separate stems + # only, loose folder, or archive). + original_audio_url: str | None = None if is_loose: # Loose folder filenames are relative paths (artist/album/song). # Hash the *canonical* dlc-relative path (so two URL spellings @@ -6961,8 +6966,22 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, for s in loaded_slop.stems: url = f"/api/sloppak/{q_fn}/file/{quote(s['file'])}" stems_payload.append({"id": s["id"], "url": url, "default": s["default"]}) + # Full-mix URL (served by the same /api/sloppak/.../file/ endpoint). + if loaded_slop is not None and loaded_slop.original_audio: + original_audio_url = ( + f"/api/sloppak/{q_fn}/file/{quote(loaded_slop.original_audio)}" + ) if stems_payload: + # Stems present: keep the core