From 8abbb56d83c88cde8e59fa6c8f117d31b001b84b Mon Sep 17 00:00:00 2001 From: carochacs <79524656+carochacs@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:32:19 -0600 Subject: [PATCH 1/8] core: Update .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 4fe31983..17589e76 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,5 @@ Thumbs.db *.swp .idea/ plugins/support_creators +/library +/static/sloppak_cache From cb6407b91f679b8a0ed94bcceb5c905842397fbf Mon Sep 17 00:00:00 2001 From: carochacs <79524656+carochacs@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:14:01 -0600 Subject: [PATCH 2/8] fix: Add arrangement type to WS and fix keyboard detection Adds explicit `type` field to WebSocket song_info and arrangements, allowing viz auto-selection to match on real instrument type instead of name-sniffing. Fixes misclassification of keyboard arrangements (e.g., GP imports with piano parts labeled 'Combo') by checking manifest `type` before arrangement name patterns. Reorders GP track classification to yield keyboard parts before assuming guitar. Also adds `_stemsRerouteInProgress` guards matching `_juceRerouteInProgress` to prevent spurious play/pause events during stems plugin Web-Audio takeover. --- lib/gp2rs_gpx.py | 12 +++++++++--- lib/routers/ws_highway.py | 8 ++++++++ lib/song.py | 8 ++++++++ plugins/highway_3d/screen.js | 6 ++++++ static/app.js | 2 ++ static/js/transport.js | 6 ++++++ 6 files changed, 39 insertions(+), 3 deletions(-) diff --git a/lib/gp2rs_gpx.py b/lib/gp2rs_gpx.py index 75d854a2..361beaeb 100644 --- a/lib/gp2rs_gpx.py +++ b/lib/gp2rs_gpx.py @@ -2469,12 +2469,20 @@ def _auto_select_gpx(tracks: list[dict]) -> tuple[list[int], dict[int, str]]: is_bass = (t['string_pitches'] and max(t['string_pitches']) <= 48) \ or t['midi_program'] in BASS_PROGS - is_guitar = bool(t['string_pitches']) and not is_bass is_keys = (not t['string_pitches'] and t['midi_program'] in KEYS_PROGS) \ or any(kw in name_l for kw in ('piano', 'keys', 'organ')) + # GP6+ often notates piano/keys parts on a fretted string template, so + # string_pitches alone can't distinguish a keyboard part from a real + # guitar. Check is_keys (which also matches on name/program) before + # is_guitar, so a track explicitly named "Keys ..." isn't swept into + # the unhinted-guitar Lead/Rhythm/Combo bucket just because it has + # string tuning data (feedBack: "Combo" mislabeled piano arrangement). + is_guitar = bool(t['string_pitches']) and not is_bass and not is_keys if is_bass: selected.append((i, 'bass')) + elif is_keys: + selected.append((i, 'keys')) elif is_guitar: # Honor "lead"/"rhythm" in the GP track name so two guitars keep # the author's roles instead of being labelled by appearance order @@ -2485,8 +2493,6 @@ def _auto_select_gpx(tracks: list[dict]) -> tuple[list[int], dict[int, str]]: selected.append((i, 'guitar_rhythm')) else: selected.append((i, 'guitar')) - elif is_keys: - selected.append((i, 'keys')) if not selected: for i, t in enumerate(tracks): diff --git a/lib/routers/ws_highway.py b/lib/routers/ws_highway.py index 61cb65b6..0ebecff3 100644 --- a/lib/routers/ws_highway.py +++ b/lib/routers/ws_highway.py @@ -496,6 +496,11 @@ def _evict_audio_cache(): "name": a.name, "smart_name": smart_names[i], "notes": len(a.notes) + sum(len(c.notes) for c in a.chords), + # Manifest `type` (sloppak.py:942) — authoritative instrument + # classification, independent of the display name. Lets viz + # auto-selection (e.g. the piano viz's matchesArrangement) + # match on real type instead of name-sniffing. + "type": (a.type or "").strip().lower() if isinstance(a.type, str) else "", } for i, a in enumerate(song.arrangements) ] @@ -508,6 +513,9 @@ def _evict_audio_cache(): "arrangement": arr.name, "arrangement_smart_name": smart_names[best], "arrangement_index": best, + # Named distinctly from the top-level "type" (WS message + # discriminator, = "song_info") to avoid colliding with it. + "arrangement_type": (arr.type or "").strip().lower() if isinstance(arr.type, str) else "", # Echo the resolved naming mode so highway.js doesn't have to # re-read localStorage (which can be unavailable / disagree with # app.js's in-memory cache when storage writes fail). diff --git a/lib/song.py b/lib/song.py index 3527d112..d154f86c 100644 --- a/lib/song.py +++ b/lib/song.py @@ -803,6 +803,14 @@ def _resolve(a: Arrangement) -> tuple[str | None, bool]: return "path_rhythm", bool(a.bonus_arr) if a.path_bass: return "path_bass", bool(a.bonus_arr) + # The manifest `type` field (sloppak.py:942) is authoritative when + # present — trust it over name-sniffing. A "keys"/"vocals"/"drums" + # arrangement is never part of the Lead/Rhythm/Bass grouping, even + # if its display name happens to collide with the name-fallback + # table below (e.g. a keys arrangement literally named "Combo"). + arr_type = (a.type or "").strip().lower() if isinstance(a.type, str) else "" + if arr_type in ("keys", "vocals", "drums"): + return None, bool(a.bonus_arr) name = a.name if isinstance(a.name, str) else "" entry = _NAME_FALLBACK.get(name.strip().lower()) if entry is None: diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 44b6775b..855c6f2b 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -16662,6 +16662,12 @@ // arrangements that merely contain these as substrings (e.g. a // "BasslineKeys" arrangement would otherwise match `bass`). window.feedBackViz_highway_3d.matchesArrangement = function (songInfo) { + // Manifest `type: keys` is authoritative and independent of the + // display name — a keys arrangement literally named "Combo" (GP + // import quirk) would otherwise match the /combo/ keyword below + // and steal the song from the piano/keys viz. Yield whenever the + // active arrangement's real type says keys. + if (songInfo && songInfo.arrangement_type === 'keys') return false; const arr = (songInfo && songInfo.arrangement) || ''; return /\b(?:lead|rhythm|bass|combo|guitar)\b/i.test(arr); }; diff --git a/static/app.js b/static/app.js index 6aef021d..76b90f94 100644 --- a/static/app.js +++ b/static/app.js @@ -1001,6 +1001,7 @@ audio.addEventListener('play', () => { // migration step — playback genuinely continues, so don't emit song:play or // flip feedBack.isPlaying (the watcher keeps the canonical state itself). if (window._juceRerouteInProgress) return; + if (window._stemsRerouteInProgress) return; window.feedBack.isPlaying = true; const payload = _songEventPayload(); window.feedBack.emit('song:play', payload); @@ -1011,6 +1012,7 @@ audio.addEventListener('pause', () => { // Same as above: suppress the song:pause emitted by a reroute's deliberate // audio.pause() — the migration is transparent to plugin play-state. if (window._juceRerouteInProgress) return; + if (window._stemsRerouteInProgress) return; window.feedBack.isPlaying = false; window.feedBack.emit('song:pause', _songEventPayload()); }); diff --git a/static/js/transport.js b/static/js/transport.js index 0da16018..f698a947 100644 --- a/static/js/transport.js +++ b/static/js/transport.js @@ -358,6 +358,12 @@ export async function togglePlay() { // leave the button showing Play while the song keeps playing — the // "two clicks to pause on the first song after a fresh load" bug. if (window._juceRerouteInProgress) return; + // Same shape of race, HTML5 -> stems-plugin Web-Audio takeover + // (get-flashbacks/feedBack#39): the stems plugin deliberately + // pauses the core element while it builds its own multi-stem + // transport, then dispatches a synthetic 'play' once that + // transport actually starts. Don't stomp the button in between. + if (window._stemsRerouteInProgress) return; console.error('[app] audio.play() rejected:', err); S.isPlaying = false; setPlayButtonState(false); From a8e003fe81224acd7079a53222b2f2f5fb20d017 Mon Sep 17 00:00:00 2001 From: carochacs <79524656+carochacs@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:01:10 -0600 Subject: [PATCH 3/8] fix(highway_3d): chord diagram no longer mirrors on Invert drawChordDiagram() was passed inverted: _invertedCached at both call sites, flipping its column order (high-e/low-E swapped) whenever the highway's Invert toggle was on. The diagram's orientation should be fixed regardless of that toggle, so both sites now pass inverted: false. Note: plugins/highway_3d/CLAUDE.md had documented the mirroring as this overlay's contract, but that line traces only to a single squashed "Clean release snapshot" commit with no surviving design rationale -- treated here as an inaccurate description of a bug, not a protected feature, and updated accordingly. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 10 ++++++++++ plugins/highway_3d/CLAUDE.md | 2 +- plugins/highway_3d/screen.js | 8 ++++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52105d66..af6358ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -293,6 +293,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry). ### Fixed +- **highway_3d chord diagram no longer mirrors on Invert.** The top-left chord + diagram overlay (`drawChordDiagram()`) was flipping its column order + (high-e/low-E swapped) whenever the highway's Invert toggle was on, passed + through as `inverted: _invertedCached` at both call sites. The diagram's + orientation should be fixed regardless of that toggle, so both call sites + now pass `inverted: false`. Note: `plugins/highway_3d/CLAUDE.md` had + documented the mirroring as this overlay's contract, but that line traces + only to a single squashed "Clean release snapshot" commit with no + surviving design rationale — treated here as an inaccurate description of + a bug, not a protected feature, and updated accordingly. - **GP8 asset resolution honours the directory the registry named.** `` is matched on filename stem so a format variant of the same recording can win (an `.ogg` beside the declared `.mp3` is copied out diff --git a/plugins/highway_3d/CLAUDE.md b/plugins/highway_3d/CLAUDE.md index 97890b20..39016e34 100644 --- a/plugins/highway_3d/CLAUDE.md +++ b/plugins/highway_3d/CLAUDE.md @@ -124,7 +124,7 @@ Each entry names the function or banner you should grep for, plus key sub-blocks ### Lyrics & overlays - **Lyrics overlay** → `drawLyrics()`. 2D canvas, top centre, semi-transparent rounded background, syllable-level highlighting (current syllable in white, played in muted, upcoming in dim). -- **Chord diagram overlay** → `drawChordDiagram()` (see "Chords" above). 2D canvas, top-left, fades over the 0.55 s linger window. Respects `inverted` (column 0 is high-e when inverted, low-E otherwise). +- **Chord diagram overlay** → `drawChordDiagram()` (see "Chords" above). 2D canvas, top-left, fades over the 0.55 s linger window. `drawChordDiagram()` still accepts an `inverted` param (column 0 is high-e when inverted, low-E otherwise), but the two call sites always pass `inverted: false` — the diagram's orientation is fixed and does not mirror when the highway's own Invert toggle is on. - **The `lyricsCanvas`** is created in `initScene()` with `z-index:1`, appended to `wrap` **after** `ren.domElement` — this is the empirically-correct stacking order for all browsers/contexts (including splitscreen panels with `position:relative; overflow:hidden`). Don't reorder; see Pitfall #5. ### Splitscreen diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 855c6f2b..f8de71c1 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -16544,7 +16544,9 @@ ? Math.min(1.0, Math.max(0, (bundle.currentTime - _diagPrev.t) / DIAG_ENTRANCE_S)) : 1.0, canvasW: lyricsCanvas.width, canvasH: lyricsCanvas.height, - inverted: _invertedCached, + // Chord diagram orientation is fixed regardless of the + // highway's own Invert toggle. + inverted: false, sizeSlider: chordDiagramSize, position: chordDiagramPosition, nStr: _diagPrev.nStr ?? nStr, lyricsBottom, @@ -16558,7 +16560,9 @@ opacity: Math.max(0, 1 + (_diagChord.t - bundle.currentTime) / DIAG_LINGER_S), entranceT: _diagEntranceT, canvasW: lyricsCanvas.width, canvasH: lyricsCanvas.height, - inverted: _invertedCached, + // Chord diagram orientation is fixed regardless of the + // highway's own Invert toggle. + inverted: false, sizeSlider: chordDiagramSize, position: chordDiagramPosition, nStr: _diagChord.nStr ?? nStr, lyricsBottom, From 48903d6a8fcfd0cd523e99626c9a2817c42494a4 Mon Sep 17 00:00:00 2001 From: carochacs <79524656+carochacs@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:03:53 -0600 Subject: [PATCH 4/8] Bump 3D Highway plugin version Update the bundled `highway_3d` plugin version from 3.34.1 to 3.34.2. --- plugins/highway_3d/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index 755ed1be..7d97f246 100644 --- a/plugins/highway_3d/plugin.json +++ b/plugins/highway_3d/plugin.json @@ -1,7 +1,7 @@ { "id": "highway_3d", "name": "3D Highway", - "version": "3.34.1", + "version": "3.34.2", "type": "visualization", "bundled": true, "script": "screen.js", From c4aba80b26cc3ce06c3dcc7e49ea377a96b39081 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 10:54:48 +0000 Subject: [PATCH 5/8] fix: Offload convert_wem calls in highway_ws off the event loop convert_wem shells out to vgmstream-cli/ffmpeg via subprocess.run with a 120s timeout. Called bare inside async def highway_ws, it blocked the whole event loop for the duration of the conversion, stalling every other concurrent WebSocket connection on that worker. Wrap both call sites in loop.run_in_executor(), reusing the contextvars.copy_context() snapshot already taken earlier in the function so the bound ws_conn_id correlation ID still applies to log lines raised inside the executor thread (matches this file's existing pattern for load_song/sloppak_mod.load_song). Flagged by CodeRabbit on #42; pre-existing, unrelated to that PR's diff, so fixed separately here. --- lib/routers/ws_highway.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/routers/ws_highway.py b/lib/routers/ws_highway.py index 241f87fa..e62be88a 100644 --- a/lib/routers/ws_highway.py +++ b/lib/routers/ws_highway.py @@ -460,7 +460,15 @@ def _evict_audio_cache(): tmp_suffix = uuid.uuid4().hex[:8] tmp_base = appstate.audio_cache_dir / f"audio_{audio_id}.{tmp_suffix}" try: - produced = convert_wem(str(wem_resolved), str(tmp_base)) + # convert_wem shells out to vgmstream-cli/ffmpeg via + # subprocess.run (up to 120s timeout) — bare-calling it + # here would block the whole event loop, stalling every + # other concurrent connection's WebSocket traffic for + # as long as the conversion takes. + produced = await loop.run_in_executor( + None, + lambda: _ctx.run(convert_wem, str(wem_resolved), str(tmp_base)), + ) ext = Path(produced).suffix final_path = appstate.audio_cache_dir / f"audio_{audio_id}{ext}" os.replace(produced, final_path) @@ -482,7 +490,13 @@ def _evict_audio_cache(): audio_error = "No WEM audio files were found inside this archive." else: try: - audio_path = convert_wem(wem_files[0], os.path.join(tmp, "audio")) + # Same reasoning as the loose-folder conversion above: + # convert_wem is a blocking subprocess call and must not + # run inline on the event loop. + audio_path = await loop.run_in_executor( + None, + lambda: _ctx.run(convert_wem, wem_files[0], os.path.join(tmp, "audio")), + ) ext = Path(audio_path).suffix audio_dest = appstate.audio_cache_dir / f"audio_{audio_id}{ext}" shutil.copy2(audio_path, audio_dest) From f1118040d6a7a65c145f4542bb2f16027569f058 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 10:55:23 +0000 Subject: [PATCH 6/8] Document the convert_wem event-loop fix in CHANGELOG --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1184f80c..a0226ad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -355,6 +355,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry). ### Fixed +- **`convert_wem` no longer blocks the event loop inside `highway_ws`.** Both + call sites in `lib/routers/ws_highway.py` (loose-folder and archive audio + conversion) invoked `convert_wem` directly inside the `async def + highway_ws` handler; `convert_wem` shells out to vgmstream-cli/ffmpeg via + `subprocess.run` with up to a 120s timeout, so a single slow/large + conversion held the whole event loop and stalled every other concurrent + WebSocket connection on that worker for as long as it ran. Both sites now + run through `loop.run_in_executor()`, reusing the `contextvars.copy_context()` + snapshot already taken earlier in the function so the bound `ws_conn_id` + correlation ID still applies to log lines raised inside the executor + thread — same pattern this file already uses for `load_song`/ + `sloppak_mod.load_song`. - **highway_3d chord diagram no longer mirrors on Invert.** The top-left chord diagram overlay (`drawChordDiagram()`) was flipping its column order (high-e/low-E swapped) whenever the highway's Invert toggle was on, passed From 89a9172da5734afd48244ac9a612fd0e4928607b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 04:37:34 +0000 Subject: [PATCH 7/8] fix: restore error handling for sloppak loading in websocket handler --- lib/routers/ws_highway.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/routers/ws_highway.py b/lib/routers/ws_highway.py index e62be88a..549c8fd6 100644 --- a/lib/routers/ws_highway.py +++ b/lib/routers/ws_highway.py @@ -209,10 +209,23 @@ async def _send_keepalives(): _ctx = contextvars.copy_context() if is_slop: appstate.sloppak_cache_dir.mkdir(parents=True, exist_ok=True) - loaded_slop = await loop.run_in_executor( - None, - lambda: _ctx.run(sloppak_mod.load_song, filename, dlc, appstate.sloppak_cache_dir), - ) + try: + loaded_slop = await loop.run_in_executor( + None, + lambda: _ctx.run(sloppak_mod.load_song, filename, dlc, appstate.sloppak_cache_dir), + ) + except Exception: + # load_song() never returns None on failure — it raises + # (bad zip, missing/corrupt manifest, ...). Catch that + # here so the client gets a clean, generic message + # instead of the outer handler's raw str(e), which can + # leak filesystem paths. + log.exception("sloppak load failed for %s", filename) + _keepalive_active = False + keepalive_task.cancel() + await websocket.send_json({"error": "Failed to load sloppak"}) + await websocket.close() + return song = loaded_slop.song tmp = str(loaded_slop.source_dir) owns_tmp = False From 8817a2a833c32bd774e5042745d0ceca31b3cd44 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 15:09:59 +0000 Subject: [PATCH 8/8] fix: use optional chaining for songInfo.arrangement fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trivial simplification flagged by DeepSource — (songInfo && songInfo.arrangement) || '' and songInfo?.arrangement || '' are equivalent. --- plugins/highway_3d/screen.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index f8de71c1..4e118ec6 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -16672,7 +16672,7 @@ // and steal the song from the piano/keys viz. Yield whenever the // active arrangement's real type says keys. if (songInfo && songInfo.arrangement_type === 'keys') return false; - const arr = (songInfo && songInfo.arrangement) || ''; + const arr = songInfo?.arrangement || ''; return /\b(?:lead|rhythm|bass|combo|guitar)\b/i.test(arr); };