Compare commits

..
Author SHA1 Message Date
ChrisBeWithYouandClaude Fable 5 66bbbf1600 Serve exact MIDI notes from GET /api/tunings (tuningMidis)
The tunings catalog is served as frequencies scaled to the reference pitch,
so every consumer that needs note identities (the v3 instrument badge's
TUNING_NOTE, plugins converging on the host profile model) reconstructs MIDI
numbers client-side via log2 — a rounding footgun at non-440 references, and
N copies of code the host can run once.

Add `tuningMidis` to the response: the same catalog keyed instrument-count →
name → absolute open-string MIDI notes (low → high). Built-ins come straight
from TUNING_PRESET_MIDIS (no float round-trip at all); provider-contributed
entries are inverted from their frequencies at the served reference via the
new freqs_to_midis() (the inverse of open_midis_to_freqs, garbage-guarded).
Purely additive — referencePitch/tunings are unchanged.

Tests: every built-in round-trips at 440; round-trip holds at 430/432/444/450
(the exact case client-side reconstruction drifts on); garbage rejected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MS2YFb6UUSwJVV6CmEa25i
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
2026-07-10 02:22:30 -05:00
1c1a0e0268 feat(audio): renderer-bus feeder — song audio into engine output under exclusive mode (Phase 2) (#828)
ship-ci / ci (push) Waiting to run
* feat(audio): route feedpak full-mix natively under exclusive output

Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(audio): renderer-bus feeder — mix renderer song audio into engine output (Phase 2)

Under exclusive-style output the native backing transport (Phase 1, #824)
carries loose /audio/ songs and feedpak full-mixes, but not the stems
plugin's multi-stem WebAudio graph or tracks JUCE rejected. The feeder taps
the renderer-side master with an AudioWorklet, re-points the owning
AudioContext at a null sink so it keeps rendering without a device, and
pushes ~10 ms chunks over IPC into the desktop engine's renderer bus
(feedBack-desktop#90 follow-up). Inert in the Docker sphere and in shared
mode. Validated by the fix12 tester spike: null-sink rendering works,
clocks hold, no overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:51:09 +02:00
0a16014698 fix(keys_highway_3d): stop auto-connect clobbering the global MIDI device (#825)
Opening the keys highway could silently switch the user's configured MIDI
device. Two coupled defects in the plugin's MIDI selection:

1. _midiAutoConnect only consulted the plugin's own localStorage pick
   (keys3d_midi_pick); with none saved it fell straight through to "first
   non-loopback device", ignoring the core midi-input domain's global
   selection (Settings -> Input Setup, window.slopsmith.midiInput.getSelected()).

2. _midiConnect unconditionally persisted every connect to BOTH the local
   pick and the shared domain selection (mi.select). So the first-device
   guess got frozen locally and overwrote the global default that other
   consumers (drums, Input Setup) rely on.

Make the domain-wide selection the source of truth: _pickMidiTarget now
resolves global -> legacy local pick (fallback + name-recovery for stale
ids) -> first device, and gates the "don't grab a random device" recovery
guard on any configured preference. Gate persistence behind an explicit
`persist` flag so only a deliberate device selection writes the local pick
and the shared global; auto-connect and programmatic (audio-input) opens
open the resolved device for the session without touching either store.
mi.select() is not needed to open (open takes the logicalSourceKey directly),
so dropping it from the auto path costs nothing.

Interim step toward instrument-scoped selection in the midi-input domain
itself (the input_setup wizard is already per-instrument, but the domain
stores a single selection); tracked as a separate core follow-up.

Pure decision logic extracted to _pickMidiTarget and covered by unit tests
in data_layer.test.js.

Signed-off-by: gionnibgud <gionnibgud@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 22:40:33 +02:00
K. O. A.andGitHub aaf593bdd1 Merge pull request #823 from got-feedBack/feat/highway-per-panel-camera
feat(highways): per-splitscreen-panel Camera Director cameras
2026-07-09 16:38:01 -04:00
Kris AndersonandClaude Opus 4.8 14d116d827 fix(highways): validate panel index before indexing the camera map
_resolveFreeCam() (keys/drum) and _freeCamFor() (highway_3d) guarded the panel
map lookup with only `i != null`, so a non-integer / negative / string index
from panelIndexFor() could resolve an unintended or inherited property (e.g.
map['toString']) instead of cleanly falling back to the global camera. Gate the
index on `Number.isInteger(i) && i >= 0` before `map[i]`, matching the hardening
already applied in _bgPanelKey(). Extend the resolver tests with float/string
(prototype-key) cases. Behavior change only for malformed indices.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-09 16:33:57 -04:00
Kris AndersonandClaude Opus 4.8 54b5d2e426 docs(highways): JSDoc the delegating _freeCamFor wrappers (keys, drum)
Finish the docstring pass for the CamDir bridge functions changed in this PR:
convert the two per-panel _freeCamFor delegating wrappers to JSDoc, matching the
pure _resolveFreeCam / _ssApi helpers. Comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-09 16:28:58 -04:00
Kris AndersonandClaude Opus 4.8 bcee2e8610 fix(highway_3d): _bgPanelKey rejects non-integer panel index; JSDoc bridge fns
- _bgPanelKey() treated any non-null panelIndexFor() return as a valid panel id,
  so a NaN/non-finite index minted a bogus "panelNaN" localStorage key instead
  of falling back to "main". Gate on Number.isInteger(idx) && idx >= 0. (The
  camera path is already NaN-safe — panelsMap[NaN] misses and falls through.)
- Add a NaN/negative-index case to the resolver tests (drum 22, keys 57, pass).
- Convert the camera-bridge helpers' comments to JSDoc (_bgPanelKey, _freeCamFor,
  _resolveFreeCam, _ssApi across the three plugins) to lift docstring coverage on
  the changed surface. Comment/robustness only; no behavior change beyond the
  NaN guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-09 16:27:56 -04:00
Kris AndersonandClaude Opus 4.8 a6a5186180 fix(highway_3d): make _bgPanelKey throw-safe on panelIndexFor
Follow-up to the _bgPanelKey alias fix: _freeCamFor already treats
panelIndexFor as potentially throwy and catches to keep framing stable, but
_bgPanelKey called it bare. A throwing splitscreen build would take down
background-settings resolution (and the render path) even though the camera
path falls back safely. Wrap the call in try/catch, falling back to 'main'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-09 16:21:14 -04:00
1b3178037b feat(audio): route feedpak full-mix natively under exclusive output (#824)
Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:18:00 +02:00
845255e404 fix(audio-effects): accept pre-rebrand chain plan schema as alias (#816)
* fix(playback): throttle legacy bridge-hit recording; emit loop-set for manual A/B

window.feedBack.getLoop() is a read surface plugins legitimately poll
(note_detect HUD ticked it at ~30 Hz), but every call recorded a
playback.loop-api bridge hit: compat-shim bookkeeping, a
playback:bridge-hit event, and a diagnostics snapshot rebuild +
stringify per call — real main-thread cost and a saturated hitCount in
the capability inspector, even with no song playing.

- _recordPlaybackBridge now throttles per bridgeId|surface (5 s window).
  Bridge hits are a 'surface still in use' signal, not a call counter.
- setLoopEnd() (manual A/B buttons) now emits the same loop-set
  transport event as setLoop(), so event-driven consumers no longer
  need to poll getLoop() to see button-armed loops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): note loop-api bridge throttle fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audio-effects): accept pre-rebrand chain plan schema as alias

The rebrand renamed PLAN_SCHEMA to 'feedBack.audio_effects.chain_plan.v1'
but shipped plugin bundles (rig_builder <= 2.9.x) still send the
slopsmith-era id, so _validatePlan rejected every plan and providers fell
back to their heavyweight legacy load paths (full chain rebuild per poll
cycle — audible as continuous distortion during songs). Accept the old id
as an explicit alias.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:17:57 +02:00
Kris AndersonandClaude Opus 4.8 0d4d8229c7 fix(highways): address review — bg-key alias, drum cam guard, resolver tests
Three review findings on the per-panel camera work:

- highway_3d: _bgPanelKey() resolved splitscreen via window.feedBackSplitscreen
  only, while _freeCamFor() uses the feedBackSplitscreen||slopsmithSplitscreen
  alias it claims to "mirror". If the rename lands, per-panel background settings
  would silently stop being per-panel while the camera stayed per-panel. Resolve
  the alias the same way in _bgPanelKey.
- drum_highway_3d: applyCamera()'s "before first positionCamera()" guard tested
  `_camBaseH == null`, but _camBaseH/_camBaseD were initialized to 0, so the guard
  never fired (and could apply a base-0 pose for a frame). Initialize to null.
- keys + drum: the PR claimed the Camera Director resolver was unit-checked, but
  nothing exercised it. Extract the resolver into pure, exported helpers
  (_resolveFreeCam + _ssApi), delegate the per-instance _freeCamFor to them, and
  add tests/camera_bridge.test.js covering per-panel select, global fallback,
  null-when-absent, throw-safety, and the slopsmith-alias resolution. Drum 15→21,
  keys 50→56, all pass; behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-09 16:15:29 -04:00
Kris AndersonandClaude Opus 4.8 ff8a638d28 docs(highway_3d): name the concrete camera-bridge globals in comments
Address a review note on the free-camera block: the comments described the
bridge as "per-panel-aware" without naming the actual globals. Spell out that
_freeCam comes from _freeCamFor(highwayCanvas) — window.__h3dCamCtlPanels[
panelIndexFor(canvas)] when split, else the global window.__h3dCamCtl, else
null — and update the nearby comment that mentioned only __h3dCamCtl. Comment-
only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-09 15:58:12 -04:00
Kris AndersonandClaude Fable 5 5aa336961c feat(highways): per-splitscreen-panel Camera Director cameras
Make the three 3D highways read the Camera Director bridge per panel so each
splitscreen panel renders its own camera (independent orbit/height/zoom/tilt/pan),
instead of all panels sharing the focused camera.

- Add a shared `_freeCamFor(canvas)` resolver to each highway: prefer this panel's
  entry in `window.__h3dCamCtlPanels[panelIndexFor(canvas)]`, fall back to the
  global `window.__h3dCamCtl`, else null (100% stock). Defensive on the splitscreen
  global name (feedBackSplitscreen || slopsmithSplitscreen), NaN-safe, allocation-free.
- highway_3d (guitar): source `_freeCam` from the resolver (was global-only).
- keys_highway_3d: adopt the bridge for the first time — layer dolly/height/orbit +
  pan/pitch offsets onto the pan/zoom follow rig at the camera write.
- drum_highway_3d: adopt the bridge — new per-frame `applyCamera()` folds the static
  base pose + kick-pulse dip + free-cam offsets.
- In a follower (popped-out) window there is one panel, so the resolver yields
  whatever camera the plugin set in that window; no highway change needed for pop-out.

Camera Director absent → resolver returns null → renderers behave exactly as before.
Bump each plugin patch version. Existing plugin tests pass (drum 15, keys 30); the
keys "default look unchanged" test confirms the stock path is byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-09 12:24:29 -04:00
27 changed files with 1392 additions and 222 deletions
+2 -3
View File
@@ -90,7 +90,7 @@ Best practices:
- `extract_meta()` — metadata extraction callable - `extract_meta()` — metadata extraction callable
- `meta_db` — shared MetadataDB instance - `meta_db` — shared MetadataDB instance
- `library_providers` — shared library provider registry for source-aware browsing - `library_providers` — shared library provider registry for source-aware browsing
- `register_library_provider(provider)` — register a plugin-provided library source. Providers expose `id`, `label`, optional `kind`/`capabilities`, optional `slow: true` (or `is_slow`/`slow_mode`) when responses may take a while, and callable `query_page`, `query_artists`, `query_stats`, and `tuning_names` methods. Providers with `art.read` may also expose `get_art(song_id)` returning one of: a `Response` object (any media type, served as-is); raw `bytes` or `bytearray` (**assumed PNG** — use a `Response` or a `dict` with `content`+`media_type` keys for JPEG/WebP or other formats); a URL string (http/https → 302 redirect; other schemes are rejected with 400); a filesystem path string or `Path` (served as a file with auto-detected media type); or a `dict` with a `url`, `path`, or `content` key. Providers with `song.sync` may expose `sync_song(song_id)` returning `None` (success with no local file) or a `dict` — the dict is passed through as the JSON response and should include `filename`/`local_filename` if a local playable file was produced. - `register_library_provider(provider)` — register a plugin-provided library source. Providers expose `id`, `label`, optional `kind`/`capabilities`, and callable `query_page`, `query_artists`, `query_stats`, and `tuning_names` methods. Providers with `art.read` may also expose `get_art(song_id)` returning one of: a `Response` object (any media type, served as-is); raw `bytes` or `bytearray` (**assumed PNG** — use a `Response` or a `dict` with `content`+`media_type` keys for JPEG/WebP or other formats); a URL string (http/https → 302 redirect; other schemes are rejected with 400); a filesystem path string or `Path` (served as a file with auto-detected media type); or a `dict` with a `url`, `path`, or `content` key. Providers with `song.sync` may expose `sync_song(song_id)` returning `None` (success with no local file) or a `dict` — the dict is passed through as the JSON response and should include `filename`/`local_filename` if a local playable file was produced.
- `unregister_library_provider(provider_id)` — remove a plugin-provided library source by id. The built-in `local` provider cannot be removed. - `unregister_library_provider(provider_id)` — remove a plugin-provided library source by id. The built-in `local` provider cannot be removed.
- `get_sloppak_cache_dir()` — sloppak cache path - `get_sloppak_cache_dir()` — sloppak cache path
- `load_sibling(name)` — loads a sibling module from this plugin's directory under a unique, namespaced module name. See "Sibling imports" below. - `load_sibling(name)` — loads a sibling module from this plugin's directory under a unique, namespaced module name. See "Sibling imports" below.
@@ -585,13 +585,12 @@ a local pointer + code map.
pytest # Run all tests pytest # Run all tests
pytest tests/test_song.py -v # Specific file pytest tests/test_song.py -v # Specific file
pytest -k "round_trip" -v # Pattern match pytest -k "round_trip" -v # Pattern match
uv run pytest # Run through the locked local uv environment
``` ```
- Framework: pytest - Framework: pytest
- Config: `pyproject.toml` sets `pythonpath = [".", "lib"]` and `testpaths = ["tests"]` - Config: `pyproject.toml` sets `pythonpath = [".", "lib"]` and `testpaths = ["tests"]`
- CI: GitHub Actions runs pytest on push/PR to main (Python 3.12) - CI: GitHub Actions runs pytest on push/PR to main (Python 3.12)
- Test dependencies: `requirements-test.txt`; uv reads the mirrored `test` dependency group in `pyproject.toml` - Test dependencies: `requirements-test.txt`
## Tuning the note_detect plugin ## Tuning the note_detect plugin
-2
View File
@@ -80,8 +80,6 @@ A plugin that only reads public events should declare `observer` and no command
A plugin that registers a remote client or generated library source declares itself as a `library` provider. The backend registration call is still made from `routes.py` with `context["register_library_provider"](...)`; the native browser library capability turns the provider registry into runtime provider participants. A thin server wrapper that only exposes the local library over HTTP should not declare `library` as a provider unless it also registers a provider in the library registry. A plugin that registers a remote client or generated library source declares itself as a `library` provider. The backend registration call is still made from `routes.py` with `context["register_library_provider"](...)`; the native browser library capability turns the provider registry into runtime provider participants. A thin server wrapper that only exposes the local library over HTTP should not declare `library` as a provider unless it also registers a provider in the library registry.
Provider objects may set `slow: true` when their backing connection can take a while to answer. Core exposes that flag through `/api/library/providers` and the browser library capability so library views show explicit loading indicators instead of looking idle during the wait.
```json ```json
{ {
"id": "remote_library_client", "id": "remote_library_client",
+16
View File
@@ -98,6 +98,22 @@ def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFER
return [round(midi_to_freq(m, reference_pitch), 2) for m in midis] return [round(midi_to_freq(m, reference_pitch), 2) for m in midis]
def freqs_to_midis(freqs: list[float], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[int] | None:
"""Return absolute open-string MIDI notes for frequencies at the supplied
A4 reference — the inverse of open_midis_to_freqs. None if any entry is
non-numeric or non-positive (a provider could hand us anything)."""
out: list[int] = []
for f in freqs:
try:
f = float(f)
except (TypeError, ValueError):
return None
if f <= 0:
return None
out.append(int(round(69 + 12 * math.log2(f / reference_pitch))))
return out
def tuning_offsets_from_midis(instrument_key: str, midis: list[int]) -> list[int] | None: def tuning_offsets_from_midis(instrument_key: str, midis: list[int]) -> list[int] | None:
"""Return semitone offsets from the instrument's standard open strings.""" """Return semitone offsets from the instrument's standard open strings."""
standard = STANDARD_OPEN_MIDIS.get(instrument_key) standard = STANDARD_OPEN_MIDIS.get(instrument_key)
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "drum_highway_3d", "id": "drum_highway_3d",
"name": "3D Drum Highway", "name": "3D Drum Highway",
"version": "0.3.1", "version": "0.3.2",
"type": "visualization", "type": "visualization",
"bundled": true, "bundled": true,
"script": "screen.js", "script": "screen.js",
+85 -3
View File
@@ -1361,6 +1361,41 @@
} catch (_) { /* dispatch unavailable — persisted value applies next init */ } } catch (_) { /* dispatch unavailable — persisted value applies next init */ }
}; };
/* ======================================================================
* Camera Director bridge resolver (pure — exported via createFactory.__test)
* ====================================================================== */
/**
* The active splitscreen API, defensive on the global-name rename in flight
* (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy alias).
* @returns {object|null} the splitscreen API, or null when not present
*/
function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; }
/**
* Resolve the Camera Director camera for a canvas: this panel's camera under
* splitscreen, else the global, else null (Camera Director absent → stock
* framing). Throw-safe on panelIndexFor so a misbehaving splitscreen build
* can't break framing.
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
* @param {object|null} ss the splitscreen API (see _ssApi)
* @param {object|null} panelsMap window.__h3dCamCtlPanels (per-panel cameras by index)
* @param {object|null} globalCam window.__h3dCamCtl (single global camera)
* @returns {object|null} the resolved free-camera bridge, or null
*/
function _resolveFreeCam(canvas, ss, panelsMap, globalCam) {
if (panelsMap && ss && typeof ss.panelIndexFor === 'function') {
try {
const i = ss.panelIndexFor(canvas);
// Only a non-negative integer indexes the panel map — a non-int /
// negative / string index (or a prototype key) must not resolve an
// unintended/inherited property; fall through to the global then.
if (Number.isInteger(i) && i >= 0 && panelsMap[i]) return panelsMap[i];
} catch (e) { /* ignore */ }
}
return globalCam || null;
}
/* ====================================================================== /* ======================================================================
* Renderer factory * Renderer factory
* ====================================================================== */ * ====================================================================== */
@@ -1414,7 +1449,7 @@
let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null; let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null;
let _fxLastWall = 0; // wall clock for FX integration (sparks, pulse decay) let _fxLastWall = 0; // wall clock for FX integration (sparks, pulse decay)
let _kickPulse = 0; // kick-hit camera-dip + floor-wash envelope let _kickPulse = 0; // kick-hit camera-dip + floor-wash envelope
let _camBaseH = 0, _camBaseD = 0; // positionCamera's unpulsed pose let _camBaseH = null, _camBaseD = null; // positionCamera's unpulsed pose (null until it first runs; applyCamera's guard depends on this)
let _gaussTex = null; // shared soft-falloff texture for flash quads let _gaussTex = null; // shared soft-falloff texture for flash quads
let _laneFlashQuads = []; // pooled additive quad per hand lane (z=0) let _laneFlashQuads = []; // pooled additive quad per hand lane (z=0)
let _kickFlashQuad = null; // full-width flash quad for the kick bar let _kickFlashQuad = null; // full-width flash quad for the kick bar
@@ -2651,6 +2686,48 @@
cam.lookAt(0, 0, -AHEAD * TS * 0.45); cam.lookAt(0, 0, -AHEAD * TS * 0.45);
} }
/**
* Camera Director bridge for THIS panel — delegates to the pure, unit-
* tested _resolveFreeCam / _ssApi (resolver block above the factory).
* Reads the live globals: per-panel map __h3dCamCtlPanels → this panel's
* camera, else the global __h3dCamCtl, else null (stock framing).
* @param {HTMLCanvasElement} canvas this panel's highway canvas
* @returns {object|null} the resolved free-camera bridge, or null
*/
function _freeCamFor(canvas) {
return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl);
}
// Per-frame camera write: static base pose (positionCamera) + kick-pulse Y
// dip, then layer Camera Director free-cam offsets (dolly/height/orbit on
// the camera-from-target vector; pan/pitch on the look target). Runs every
// frame so a live free-cam drag is smooth; allocation-free; NaN-safe; a
// null/disabled bridge reproduces the stock static+pulse pose exactly.
function applyCamera() {
if (_camBaseH == null) return; // before first positionCamera()
const _dip = (_kickPulse > 0.001) ? (0.8 * K * _kickPulse * fx.hitFx) : 0;
let _cx = 0, _cy = _camBaseH - _dip, _cz = _camBaseD;
let _lx = 0, _ly = 0, _lz = -AHEAD * TS * 0.45;
const _fc = _freeCamFor(highwayCanvas);
if (_fc && _fc.enabled) {
const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1;
const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1;
const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0;
let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz;
_vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom)
_vy *= _hm; // height
const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw);
const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y
_cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz;
const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0;
const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0;
const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0;
_lx += _px * K; _ly += (_pt + _py) * K;
}
cam.position.set(_cx, _cy, _cz);
cam.lookAt(_lx, _ly, _lz);
}
function buildLanes(_floorW, floorD) { function buildLanes(_floorW, floorD) {
laneGroup = new T.Group(); laneGroup = new T.Group();
laneStripeMats = []; laneStripeMats = [];
@@ -3431,15 +3508,18 @@
BG_STYLES[_bgState._style].update(_bgState.s, bands, fdt, nowMs / 1000); BG_STYLES[_bgState._style].update(_bgState.s, bands, fdt, nowMs / 1000);
} catch (_) { /* visual-only */ } } catch (_) { /* visual-only */ }
} }
// Kick pulse decays each frame; it drives the floor flash and,
// via applyCamera(), the camera Y dip.
if (_kickPulse > 0.001) { if (_kickPulse > 0.001) {
_kickPulse *= Math.exp(-fdt * 7); _kickPulse *= Math.exp(-fdt * 7);
cam.position.y = _camBaseH - 0.8 * K * _kickPulse * fx.hitFx;
if (_floorFlash) _floorFlash.material.opacity = 0.25 * _kickPulse * fx.hitFx; if (_floorFlash) _floorFlash.material.opacity = 0.25 * _kickPulse * fx.hitFx;
} else if (_kickPulse !== 0) { } else if (_kickPulse !== 0) {
_kickPulse = 0; _kickPulse = 0;
cam.position.y = _camBaseH;
if (_floorFlash) _floorFlash.material.opacity = 0; if (_floorFlash) _floorFlash.material.opacity = 0;
} }
// Write the camera every frame: static base pose + kick dip +
// Camera Director free-cam offsets (per-panel-aware).
applyCamera();
} }
// Approach highlight: raise each lane stripe toward its next // Approach highlight: raise each lane stripe toward its next
// note (accumulated by the rebuildNotes walk above). // note (accumulated by the rebuildNotes walk above).
@@ -3565,6 +3645,8 @@
// vm-loaded with no DOM/WebGL; everything here must stay side-effect // vm-loaded with no DOM/WebGL; everything here must stay side-effect
// free to call). // free to call).
window.slopsmithViz_drum_highway_3d.__test = { window.slopsmithViz_drum_highway_3d.__test = {
_resolveFreeCam,
_ssApi,
_variantForHit, _variantForHit,
_classifyTiming, _classifyTiming,
readFxSettings, readFxSettings,
@@ -0,0 +1,78 @@
// Camera Director bridge resolver tests: per-panel select, global fallback,
// null-when-absent, throw-safety, and the splitscreen global-name alias. Loads
// screen.js in a bare vm window and exercises the __test exports (no DOM/WebGL).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load() {
const window = {
console,
location: { protocol: 'http:', host: 'localhost' },
slopsmith: {},
};
window.window = window;
window.globalThis = window;
const context = vm.createContext(window);
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
vm.runInContext(src, context, { filename: 'screen.js' });
return { window, __test: window.slopsmithViz_drum_highway_3d.__test };
}
test('_resolveFreeCam: per-panel camera under splitscreen', () => {
const { __test } = load();
const c0 = {}, c1 = {};
const ss = { panelIndexFor: (c) => (c === c0 ? 0 : 1) };
const map = { 0: { id: 'p0' }, 1: { id: 'p1' } };
assert.equal(__test._resolveFreeCam(c0, ss, map, { id: 'g' }).id, 'p0');
assert.equal(__test._resolveFreeCam(c1, ss, map, { id: 'g' }).id, 'p1');
});
test('_resolveFreeCam: falls back to global when there is no panel map', () => {
const { __test } = load();
const g = { id: 'global' };
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, null, g), g);
});
test('_resolveFreeCam: falls back to global when the panel has no map entry', () => {
const { __test } = load();
const g = { id: 'global' };
const ss = { panelIndexFor: () => 3 }; // index 3 absent from map
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
});
test('_resolveFreeCam: null when Camera Director is absent (no global)', () => {
const { __test } = load();
assert.equal(__test._resolveFreeCam({}, null, null, null), null);
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, {}, undefined), null);
});
test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () => {
const { __test } = load();
const g = { id: 'global' };
const ss = { panelIndexFor: () => { throw new Error('boom'); } };
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
});
test('_resolveFreeCam: NaN/negative/float/string index → falls back to global', () => {
const { __test } = load();
const g = { id: 'global' };
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g);
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g);
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0.5 }, { 0: {} }, g), g);
// A string/prototype key must not resolve an inherited property (e.g. toString).
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 'toString' }, {}, g), g);
});
test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => {
const { window, __test } = load();
assert.equal(__test._ssApi(), null);
const legacy = { panelIndexFor: () => 0 };
window.slopsmithSplitscreen = legacy;
assert.equal(__test._ssApi(), legacy); // legacy alias picked up
const current = { panelIndexFor: () => 1 };
window.feedBackSplitscreen = current;
assert.equal(__test._ssApi(), current); // canonical name takes precedence
});
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "highway_3d", "id": "highway_3d",
"name": "3D Highway", "name": "3D Highway",
"version": "3.31.3", "version": "3.31.4",
"type": "visualization", "type": "visualization",
"bundled": true, "bundled": true,
"script": "screen.js", "script": "screen.js",
+53 -6
View File
@@ -2595,10 +2595,51 @@
} }
const FRET_NUMBER_GHOST_SCOPE_IDS = ['chords', 'all']; const FRET_NUMBER_GHOST_SCOPE_IDS = ['chords', 'all'];
/**
* localStorage panel key for per-panel background settings ('main' or
* 'panel<index>'). Defensive on the splitscreen global-name rename in flight,
* and throw-safe on panelIndexFor same as _freeCamFor so a misbehaving
* splitscreen build can't take down background-settings resolution. Only a
* non-negative integer index yields a 'panel<N>' key; anything else (null,
* NaN, negative, non-integer) falls back to 'main' so a bad index can never
* mint a bogus "panelNaN"-style key.
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
* @returns {string} 'main' or 'panel<index>'
*/
function _bgPanelKey(canvas) { function _bgPanelKey(canvas) {
const ss = window.feedBackSplitscreen; const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen;
const idx = (ss && typeof ss.panelIndexFor === 'function') ? ss.panelIndexFor(canvas) : null; let idx = null;
return (idx == null) ? 'main' : 'panel' + idx; if (ss && typeof ss.panelIndexFor === 'function') {
try { idx = ss.panelIndexFor(canvas); } catch (e) { idx = null; }
}
return (Number.isInteger(idx) && idx >= 0) ? 'panel' + idx : 'main';
}
/**
* Camera Director bridge resolver. Prefers THIS panel's per-panel camera under
* splitscreen (window.__h3dCamCtlPanels[panelIndex]) and falls back to the
* single global (window.__h3dCamCtl); returns null when Camera Director is
* absent 100% stock framing. Defensive on the splitscreen global-name rename
* in flight (feedBackSplitscreen vs slopsmithSplitscreen); throw-safe on
* panelIndexFor. Mirrors the panel resolution in _bgPanelKey.
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
* @returns {object|null} the resolved free-camera bridge, or null
*/
function _freeCamFor(canvas) {
const map = window.__h3dCamCtlPanels;
if (map) {
const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen;
if (ss && typeof ss.panelIndexFor === 'function') {
try {
const i = ss.panelIndexFor(canvas);
// Only a non-negative integer indexes the map (same hardening
// as _bgPanelKey) — a non-int / negative / string index must not
// resolve an unintended/inherited property; fall through then.
if (Number.isInteger(i) && i >= 0 && map[i]) return map[i];
} catch (e) { /* ignore */ }
}
}
return window.__h3dCamCtl || null;
} }
// In-memory fallback for when localStorage is blocked (private mode, // In-memory fallback for when localStorage is blocked (private mode,
// sandboxed iframes, some test runners). _bgWriteGlobal stages the // sandboxed iframes, some test runners). _bgWriteGlobal stages the
@@ -14664,7 +14705,10 @@
// suppressed while the Camera Director owns the view (it wins). // suppressed while the Camera Director owns the view (it wins).
const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0) const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0)
? _tune.startAspect : HORPLUS_START_ASPECT; ? _tune.startAspect : HORPLUS_START_ASPECT;
const _dirActive = !!(window.__h3dCamCtl && window.__h3dCamCtl.enabled); // Resolve the Camera Director bridge once (per-panel under splitscreen,
// else global). Used both for the wide-pane gate and the transforms below.
const _freeCam = _freeCamFor(highwayCanvas);
const _dirActive = !!(_freeCam && _freeCam.enabled);
const _wide = !!(_tune && _paneAspect > _startAspect) && !_dirActive; const _wide = !!(_tune && _paneAspect > _startAspect) && !_dirActive;
const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1; const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1;
const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1; const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1;
@@ -14691,13 +14735,16 @@
if (_poseHMul !== 1) _camY *= _poseHMul; if (_poseHMul !== 1) _camY *= _poseHMul;
if (_poseDMul !== 1) _camZ *= _poseDMul; if (_poseDMul !== 1) _camZ *= _poseDMul;
// ── Free-camera user tweaks (orbit / height / zoom / pan) ── // ── Free-camera user tweaks (orbit / height / zoom / pan) ──
// Driven by the Camera Director plugin via window.__h3dCamCtl. // Driven by the Camera Director plugin via the camera bridge:
// window.__h3dCamCtlPanels[panelIndexFor(canvas)] when split (this
// panel's own camera), falling back to the global window.__h3dCamCtl.
// Layered ON TOP of the auto-framing so note tracking still works. // Layered ON TOP of the auto-framing so note tracking still works.
// The bridge is read once into _freeCam and reused for both the // The bridge is read once into _freeCam and reused for both the
// position and the look-at transforms; every field is coerced to a // position and the look-at transforms; every field is coerced to a
// finite number before use so a malformed object can never feed NaN // finite number before use so a malformed object can never feed NaN
// into cam.position / cam.lookAt. // into cam.position / cam.lookAt.
const _freeCam = window.__h3dCamCtl; // _freeCam resolved above via _freeCamFor(highwayCanvas): the
// per-panel __h3dCamCtlPanels entry, else global __h3dCamCtl, else null.
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul; const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
if (_freeCam && _freeCam.enabled) { if (_freeCam && _freeCam.enabled) {
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1; const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "keys_highway_3d", "id": "keys_highway_3d",
"name": "Keys Highway 3D", "name": "Keys Highway 3D",
"version": "0.2.0", "version": "0.2.1",
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.", "description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
"type": "visualization", "type": "visualization",
"bundled": true, "bundled": true,
+162 -34
View File
@@ -813,20 +813,36 @@
_writeStore(STORE_KEYS.midiPick, JSON.stringify({ id: id || '', name: name || '', key: key || '' })); _writeStore(STORE_KEYS.midiPick, JSON.stringify({ id: id || '', name: name || '', key: key || '' }));
} }
function _midiAutoConnect(allowFallback) { // Pure decision logic (exported via __test): pick which device to
// Recovery (sources-changed after unplug) passes false: never switch to a // auto-connect to from the current source list, the domain-wide selection
// fallback input, because _midiConnect persists the pick and that would // (`globalKey`, from Settings → Input Setup), and this plugin's own legacy
// overwrite the user's saved device on a transient multi-device unplug // saved pick. Returns null for "connect to nothing" (explicit None opt-out,
// (the original returns on replug and reconnects then). // or the configured device currently absent during hotplug recovery).
if (allowFallback === undefined) allowFallback = true; //
const inputs = _midiSources(); // The domain-wide selection is the SOURCE OF TRUTH (checked first): a device
if (!inputs.length) return; // configured globally must never be overridden by a stale plugin-local pick
const saved = _readSavedPick(); // or an arbitrary first-device fallback — that override was the bug. The
// Explicit "None" opt-out. // local pick is retained only as a fallback BELOW the global (and for
if (saved && saved.id === '' && saved.name === '') return; // name-recovery when the global's logicalSourceKey went stale, e.g. a
// Prefer the globally-unique logicalSourceKey, then the legacy bare // browser that regenerates MIDI port ids across reloads). Auto-connect no
// sourceId, then case-insensitive name (Chrome on Linux regenerates ids // longer writes the local pick, so it only ever holds a value an explicit
// per page load), then first non-loopback. // selection put there (or a stale one from a pre-fix build — the global
// still wins over it).
function _pickMidiTarget(inputs, saved, globalKey, allowFallback) {
if (!inputs.length) return null;
const notBlocked = (i) => !!i && !_MIDI_BLOCKLIST_RE.test(i.name || '');
// Explicit "None" opt-out (set only via the device-select API).
if (saved && saved.id === '' && saved.name === '') return null;
// 1. Domain-wide selection (Settings → Input Setup) — source of truth.
if (globalKey) {
const g = inputs.find(i => i.key === globalKey);
if (notBlocked(g)) return g;
}
// 2. Legacy plugin-local pick, as a fallback below the global. Prefer the
// globally-unique logicalSourceKey, then the legacy bare sourceId, then
// case-insensitive name (Chrome on Linux regenerates ids per page load).
let target = null; let target = null;
if (saved && saved.key) target = inputs.find(i => i.key === saved.key) || null; if (saved && saved.key) target = inputs.find(i => i.key === saved.key) || null;
if (!target && saved && saved.id) target = inputs.find(i => i.id === saved.id) || null; if (!target && saved && saved.id) target = inputs.find(i => i.id === saved.id) || null;
@@ -834,23 +850,50 @@
const n = saved.name.toLowerCase(); const n = saved.name.toLowerCase();
target = inputs.find(i => (i.name || '').toLowerCase() === n) || null; target = inputs.find(i => (i.name || '').toLowerCase() === n) || null;
} }
// Never honour a saved pick that's a loopback / "Midi Through" port — it // Never honour a saved pick that resolves to a loopback / "Midi Through"
// carries no device input, so a stale pick silently eats every note. The // port — it carries no device input, so it silently eats every note.
// saved-pick lookups above bypass the block-list; re-apply it here. if (target && !notBlocked(target)) target = null;
if (target && _MIDI_BLOCKLIST_RE.test(target.name || '')) target = null; if (target) return target;
if (!target) {
// Skip the substitute ONLY when a saved pick exists but is currently // 3. Nothing configured resolved to a present device. In recovery
// absent (recovery: preserve it, don't clobber on a transient unplug). // (allowFallback=false) with a configured preference — a global pick or a
// With no saved pick at all, a fallback is the intended first-hotplug // saved pick — that's currently absent, preserve it rather than switching
// auto-connect — allow it even in recovery. // to an arbitrary device on a transient multi-device unplug. With no
const hasSavedPick = !!(saved && (saved.key || saved.id || saved.name)); // preference at all, a first-device grab is the intended first-hotplug
if (!allowFallback && hasSavedPick) return; // auto-connect, allowed even in recovery.
target = inputs.find(i => !_MIDI_BLOCKLIST_RE.test(i.name || '')) || inputs[0]; const hasPreference = !!(globalKey || (saved && (saved.key || saved.id || saved.name)));
} if (!allowFallback && hasPreference) return null;
// Connect to nothing rather than a loopback: if every present device is
// blocklisted, a first-device grab would attach to a "Midi Through"/IAC
// port that carries no input and silently eats every note.
return inputs.find(notBlocked) || null;
}
function _midiAutoConnect(allowFallback) {
// Recovery (sources-changed after unplug) passes false: never switch to a
// fallback input on a transient multi-device unplug (the configured
// device returns on replug and reconnects then). Auto-connect is
// non-persisting (persist omitted → false): it opens the resolved device
// for this session WITHOUT writing the plugin-local pick or the shared
// domain selection, so opening this highway can't clobber the user's
// globally-configured device.
if (allowFallback === undefined) allowFallback = true;
const inputs = _midiSources();
const saved = _readSavedPick();
const mi = _mi();
const globalKey = mi && typeof mi.getSelected === 'function' ? mi.getSelected() : null;
const target = _pickMidiTarget(inputs, saved, globalKey, allowFallback);
if (!target) return;
_midiConnect(target.id, target.name, target.key); _midiConnect(target.id, target.name, target.key);
} }
async function _midiConnect(id, name, key) { // `persist` gates the two preference writes. Only an EXPLICIT device
// selection (the device-select API) persists: it writes the plugin-local
// pick AND the shared domain selection (`mi.select`, so the user's choice
// becomes the global default). Auto-connect and programmatic opens pass
// falsy — they open the resolved device for this session only, never
// touching either store, so they can't clobber a globally-configured device.
async function _midiConnect(id, name, key, persist) {
// Capture our generation AFTER _midiDetach()'s own bump, so a later // Capture our generation AFTER _midiDetach()'s own bump, so a later
// detach (device removal / new connect / opt-out) reliably supersedes us. // detach (device removal / new connect / opt-out) reliably supersedes us.
_midiDetach(); _midiDetach();
@@ -861,7 +904,7 @@
for (const inst of _instances) { for (const inst of _instances) {
if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld(); if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld();
} }
_writeSavedPick(id || '', name || '', key || ''); if (persist) _writeSavedPick(id || '', name || '', key || '');
const mi = _mi(); const mi = _mi();
if ((id || key) && mi) { if ((id || key) && mi) {
// Prefer the globally-unique logicalSourceKey so two providers that // Prefer the globally-unique logicalSourceKey so two providers that
@@ -874,13 +917,19 @@
const lkey = src.key || ('web-midi::' + src.id); const lkey = src.key || ('web-midi::' + src.id);
_midiInput = { id: src.id, name: src.name, key: lkey }; _midiInput = { id: src.id, name: src.name, key: lkey };
_midiJustConnected = true; _midiJustConnected = true;
// Only an explicit selection writes the shared global default;
// open takes the logicalSourceKey directly, so select() is not
// needed to open — it exists purely to set the global. Persist it
// BEFORE the no-instance early return so a settings-panel pick with
// no live renderer still updates the shared default (best-effort:
// a select hiccup must not abort the connect).
if (persist) { try { await mi.select(lkey); } catch (_) { /* best-effort */ } }
// No live renderer to consume OR release a session — don't hold one // No live renderer to consume OR release a session — don't hold one
// open (settings-only ensure-init, or the last instance was torn // open (settings-only ensure-init, or the last instance was torn
// down during async discovery). The pick is saved; a later renderer // down during async discovery). A later renderer mount re-runs
// mount re-runs auto-connect and opens for real, releasing on destroy. // auto-connect and opens for real, releasing on destroy.
if (_instances.size === 0) { _midiNotifyDeviceListChanged(); return; } if (_instances.size === 0) { _midiNotifyDeviceListChanged(); return; }
try { try {
await mi.select(lkey);
const res = await mi.open({ requester: PLUGIN_ID, logicalSourceKey: lkey }); const res = await mi.open({ requester: PLUGIN_ID, logicalSourceKey: lkey });
// A newer _midiConnect (device switch / None / replug) ran while // A newer _midiConnect (device switch / None / replug) ran while
// we awaited open — discard this stale session so we don't wire a // we awaited open — discard this stale session so we don't wire a
@@ -1039,10 +1088,11 @@
window.keysH3dGetMidiInputId = function () { return _midiInput ? _midiInput.id : ''; }; window.keysH3dGetMidiInputId = function () { return _midiInput ? _midiInput.id : ''; };
window.keysH3dSetMidiInput = function (id) { window.keysH3dSetMidiInput = function (id) {
// `id` may be a logicalSourceKey (new host calls) or a legacy sourceId. // `id` may be a logicalSourceKey (new host calls) or a legacy sourceId.
// Explicit user selection → persist (local pick + shared global default).
const src = id const src = id
? (_midiSources().find(s => s.key === id) || _midiSources().find(s => s.id === id)) ? (_midiSources().find(s => s.key === id) || _midiSources().find(s => s.id === id))
: null; : null;
_midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : ''); _midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : '', true);
return true; return true;
}; };
window.keysH3dGetMidiChannel = function () { return _cfg.midiChannel; }; window.keysH3dGetMidiChannel = function () { return _cfg.midiChannel; };
@@ -1547,6 +1597,8 @@
function _aiOpen(req) { function _aiOpen(req) {
// Opening a MIDI source connects the corresponding Web MIDI input. // Opening a MIDI source connects the corresponding Web MIDI input.
// Programmatic open (audio-input source.open) — non-persisting: it must
// not rewrite the user's saved pick or the shared global default.
const idx = _aiIndexFor(req && (req.sourceId || req.logicalSourceKey)); const idx = _aiIndexFor(req && (req.sourceId || req.logicalSourceKey));
const inputs = _midiSources(); // carries .key (logicalSourceKey), unlike _midiListInputs() const inputs = _midiSources(); // carries .key (logicalSourceKey), unlike _midiListInputs()
if (idx == null || idx >= inputs.length) { if (idx == null || idx >= inputs.length) {
@@ -1600,6 +1652,41 @@
_aiRegisteredCount = 0; _aiRegisteredCount = 0;
} }
/* ======================================================================
* Camera Director bridge resolver (pure — exported via createFactory.__test)
* ====================================================================== */
/**
* The active splitscreen API, defensive on the global-name rename in flight
* (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy alias).
* @returns {object|null} the splitscreen API, or null when not present
*/
function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; }
/**
* Resolve the Camera Director camera for a canvas: this panel's camera under
* splitscreen, else the global, else null (Camera Director absent → 100% stock
* framing). Throw-safe on panelIndexFor so a misbehaving splitscreen build
* can't break framing.
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
* @param {object|null} ss the splitscreen API (see _ssApi)
* @param {object|null} panelsMap window.__h3dCamCtlPanels (per-panel cameras by index)
* @param {object|null} globalCam window.__h3dCamCtl (single global camera)
* @returns {object|null} the resolved free-camera bridge, or null
*/
function _resolveFreeCam(canvas, ss, panelsMap, globalCam) {
if (panelsMap && ss && typeof ss.panelIndexFor === 'function') {
try {
const i = ss.panelIndexFor(canvas);
// Only a non-negative integer indexes the panel map — a non-int /
// negative / string index (or a prototype key) must not resolve an
// unintended/inherited property; fall through to the global then.
if (Number.isInteger(i) && i >= 0 && panelsMap[i]) return panelsMap[i];
} catch (e) { /* ignore */ }
}
return globalCam || null;
}
/* ====================================================================== /* ======================================================================
* Renderer factory * Renderer factory
* ====================================================================== */ * ====================================================================== */
@@ -1849,6 +1936,18 @@
_rigOut.lookZ = _camPreset.lookZ; _rigOut.lookZ = _camPreset.lookZ;
return _rigOut; return _rigOut;
} }
/**
* Camera Director bridge for THIS panel — delegates to the pure, unit-
* tested _resolveFreeCam / _ssApi (resolver block above the factory).
* Reads the live globals: per-panel map __h3dCamCtlPanels → this panel's
* camera, else the global __h3dCamCtl, else null (stock framing).
* @param {HTMLCanvasElement} canvas this panel's highway canvas
* @returns {object|null} the resolved free-camera bridge, or null
*/
function _freeCamFor(canvas) {
return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl);
}
// Per-key approach glow: a key lights in its pitch-class color ONLY while a // Per-key approach glow: a key lights in its pitch-class color ONLY while a
// note is heading for it, ramping up the closer that note gets to the hit-line. // note is heading for it, ramping up the closer that note gets to the hit-line.
const KEY_GLOW_AHEAD = 2.0; // seconds before the hit-line a key starts to light const KEY_GLOW_AHEAD = 2.0; // seconds before the hit-line a key starts to light
@@ -3259,7 +3358,33 @@
} }
_camX += (_camTargetX - _camX) * CAM_PAN_LERP; _camX += (_camTargetX - _camX) * CAM_PAN_LERP;
_camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP; _camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP;
{ const r = _rig(); cam.position.set(_camX, r.y * K * _camZoom, r.z * K * _camZoom); cam.lookAt(_camX, r.lookY * K * _camZoom, r.lookZ * K * _camZoom); } {
const r = _rig();
let _cx = _camX, _cy = r.y * K * _camZoom, _cz = r.z * K * _camZoom;
let _lx = _camX, _ly = r.lookY * K * _camZoom, _lz = r.lookZ * K * _camZoom;
// Camera Director free-cam offsets (per-panel-aware), layered on top
// of the auto-framing so pan/zoom-follow still works. Dolly/height/
// orbit act on the camera-from-target vector; pan/pitch shift the
// look target. NaN-safe; null/disabled bridge → stock.
const _fc = _freeCamFor(highwayCanvas);
if (_fc && _fc.enabled) {
const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1;
const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1;
const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0;
let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz;
_vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom)
_vy *= _hm; // height
const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw);
const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y
_cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz;
const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0;
const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0;
const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0;
_lx += _px * K; _ly += (_pt + _py) * K;
}
cam.position.set(_cx, _cy, _cz);
cam.lookAt(_lx, _ly, _lz);
}
for (const km of keyMeshes.values()) km.userData.glow = 0; for (const km of keyMeshes.values()) km.userData.glow = 0;
for (const { mesh, note, len, label } of noteMeshes) { for (const { mesh, note, len, label } of noteMeshes) {
@@ -3960,6 +4085,8 @@
}; };
// Pure data-layer + scoring hooks for headless tests. // Pure data-layer + scoring hooks for headless tests.
window.slopsmithViz_keys_highway_3d.__test = { window.slopsmithViz_keys_highway_3d.__test = {
_resolveFreeCam,
_ssApi,
beatDurSec, beatDurSec,
flattenNotation, flattenNotation,
keyRange, keyRange,
@@ -3993,6 +4120,7 @@
FX_DEFAULTS, FX_DEFAULTS,
FX_RANGES, FX_RANGES,
_classifyTiming, _classifyTiming,
_pickMidiTarget,
}; };
// Headless verification hook: lets Playwright drive synthetic note-ons // Headless verification hook: lets Playwright drive synthetic note-ons
@@ -0,0 +1,78 @@
// Camera Director bridge resolver tests: per-panel select, global fallback,
// null-when-absent, throw-safety, and the splitscreen global-name alias. Loads
// screen.js in a bare vm window and exercises the __test exports (no DOM/WebGL).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load() {
const window = {
console,
location: { protocol: 'http:', host: 'localhost' },
slopsmith: {},
};
window.window = window;
window.globalThis = window;
const context = vm.createContext(window);
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
vm.runInContext(src, context, { filename: 'screen.js' });
return { window, __test: window.slopsmithViz_keys_highway_3d.__test };
}
test('_resolveFreeCam: per-panel camera under splitscreen', () => {
const { __test } = load();
const c0 = {}, c1 = {};
const ss = { panelIndexFor: (c) => (c === c0 ? 0 : 1) };
const map = { 0: { id: 'p0' }, 1: { id: 'p1' } };
assert.equal(__test._resolveFreeCam(c0, ss, map, { id: 'g' }).id, 'p0');
assert.equal(__test._resolveFreeCam(c1, ss, map, { id: 'g' }).id, 'p1');
});
test('_resolveFreeCam: falls back to global when there is no panel map', () => {
const { __test } = load();
const g = { id: 'global' };
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, null, g), g);
});
test('_resolveFreeCam: falls back to global when the panel has no map entry', () => {
const { __test } = load();
const g = { id: 'global' };
const ss = { panelIndexFor: () => 3 }; // index 3 absent from map
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
});
test('_resolveFreeCam: null when Camera Director is absent (no global)', () => {
const { __test } = load();
assert.equal(__test._resolveFreeCam({}, null, null, null), null);
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, {}, undefined), null);
});
test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () => {
const { __test } = load();
const g = { id: 'global' };
const ss = { panelIndexFor: () => { throw new Error('boom'); } };
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
});
test('_resolveFreeCam: NaN/negative/float/string index → falls back to global', () => {
const { __test } = load();
const g = { id: 'global' };
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g);
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g);
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0.5 }, { 0: {} }, g), g);
// A string/prototype key must not resolve an inherited property (e.g. toString).
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 'toString' }, {}, g), g);
});
test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => {
const { window, __test } = load();
assert.equal(__test._ssApi(), null);
const legacy = { panelIndexFor: () => 0 };
window.slopsmithSplitscreen = legacy;
assert.equal(__test._ssApi(), legacy); // legacy alias picked up
const current = { panelIndexFor: () => 1 };
window.feedBackSplitscreen = current;
assert.equal(__test._ssApi(), current); // canonical name takes precedence
});
@@ -188,3 +188,99 @@ test('measureMarkers extracts idx/t pairs', () => {
[{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }], [{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }],
); );
}); });
test('_pickMidiTarget: no plugin-local pick defers to the domain-wide selection, not "first device"', () => {
const { _pickMidiTarget } = load();
const inputs = [
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
];
// Fresh install / never picked here — must use the Input Setup global,
// NOT fall through to inputs[0].
const target = _pickMidiTarget(inputs, null, 'web-midi::b', true);
assert.equal(target.id, 'b');
});
test('_pickMidiTarget: the domain-wide selection is the source of truth — it wins over a stale plugin-local pick', () => {
const { _pickMidiTarget } = load();
const inputs = [
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
];
// A stale local pick (e.g. left by a pre-fix build's auto-connect) must
// NOT override the device the user configured in Settings → Input Setup.
const target = _pickMidiTarget(inputs, { id: 'a', name: 'Device A', key: 'web-midi::a' }, 'web-midi::b', true);
assert.equal(target.id, 'b');
});
test('_pickMidiTarget: local pick is used as a fallback when no global is configured', () => {
const { _pickMidiTarget } = load();
const inputs = [
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
];
const target = _pickMidiTarget(inputs, { id: 'a', name: 'Device A', key: 'web-midi::a' }, null, true);
assert.equal(target.id, 'a');
});
test('_pickMidiTarget: local pick name-recovers when its logicalSourceKey went stale (id regeneration)', () => {
const { _pickMidiTarget } = load();
// Same physical device, new id/key across a reload; the saved key/id miss
// but the name still matches.
const inputs = [{ id: 'a2', name: 'Device A', key: 'web-midi::a2' }];
const target = _pickMidiTarget(inputs, { id: 'a1', name: 'Device A', key: 'web-midi::a1' }, null, true);
assert.equal(target.id, 'a2');
});
test('_pickMidiTarget: domain-wide selection is ignored if it names a blocklisted loopback port', () => {
const { _pickMidiTarget } = load();
const inputs = [
{ id: 'thru', name: 'IAC Driver Bus 1', key: 'web-midi::thru' },
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
];
const target = _pickMidiTarget(inputs, null, 'web-midi::thru', true);
assert.equal(target.id, 'b'); // falls through to the first non-loopback device
});
test('_pickMidiTarget: when every present device is a loopback, connect to nothing (never a dead port)', () => {
const { _pickMidiTarget } = load();
const inputs = [
{ id: 'thru', name: 'MIDI Through Port-0', key: 'web-midi::thru' },
{ id: 'iac', name: 'IAC Driver Bus 1', key: 'web-midi::iac' },
];
// No non-loopback device exists — must NOT fall back to inputs[0] (a port
// that carries no input and would silently eat every note).
const target = _pickMidiTarget(inputs, null, null, true);
assert.equal(target, null);
});
test('_pickMidiTarget: explicit "None" opt-out still wins over any global default', () => {
const { _pickMidiTarget } = load();
const inputs = [{ id: 'a', name: 'Device A', key: 'web-midi::a' }];
const target = _pickMidiTarget(inputs, { id: '', name: '' }, 'web-midi::a', true);
assert.equal(target, null);
});
test('_pickMidiTarget: a present global wins even during hotplug recovery', () => {
const { _pickMidiTarget } = load();
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
// The configured global device is present — reconnect to it, don't bail.
const target = _pickMidiTarget(inputs, null, 'web-midi::b', false);
assert.equal(target.id, 'b');
});
test('_pickMidiTarget: recovery (allowFallback=false) preserves an absent configured device instead of grabbing a random one', () => {
const { _pickMidiTarget } = load();
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
// The configured device ('x', global) is currently unplugged; a transient
// recovery must NOT switch to the unrelated device that is present.
const target = _pickMidiTarget(inputs, null, 'web-midi::x', false);
assert.equal(target, null);
});
test('_pickMidiTarget: recovery with no preference at all still allows a first-hotplug grab', () => {
const { _pickMidiTarget } = load();
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
const target = _pickMidiTarget(inputs, null, null, false);
assert.equal(target.id, 'b');
});
+77 -41
View File
@@ -45,9 +45,10 @@ from song import (
from audio import find_wem_files, convert_wem from audio import find_wem_files, convert_wem
from tunings import ( from tunings import (
DEFAULT_REFERENCE_PITCH, DEFAULT_TUNINGS, PROFILE_IDS, PROFILE_PATHWAYS, DEFAULT_REFERENCE_PITCH, DEFAULT_TUNINGS, PROFILE_IDS, PROFILE_PATHWAYS,
apply_flat_instrument_patch_to_profiles, apply_reference_pitch, TUNING_PRESET_MIDIS, apply_flat_instrument_patch_to_profiles,
normalize_instrument_profile, normalize_instrument_profiles, apply_reference_pitch, freqs_to_midis, normalize_instrument_profile,
settings_with_instrument_profiles, tuning_name, normalize_instrument_profiles, settings_with_instrument_profiles,
tuning_name,
) )
import sloppak as sloppak_mod import sloppak as sloppak_mod
import drums as drums_mod import drums as drums_mod
@@ -5038,7 +5039,6 @@ class LibraryProviderRegistry:
"kind": self.provider_field(provider, "kind", "local" if provider_id == "local" else "remote"), "kind": self.provider_field(provider, "kind", "local" if provider_id == "local" else "remote"),
"capabilities": sorted(self.provider_capabilities(provider)), "capabilities": sorted(self.provider_capabilities(provider)),
"owner_plugin_id": owner_plugin_id, "owner_plugin_id": owner_plugin_id,
"slow": self.provider_slow(provider),
"default": provider_id == "local", "default": provider_id == "local",
} }
@@ -5059,13 +5059,6 @@ class LibraryProviderRegistry:
return "" return ""
return label.strip() return label.strip()
def provider_slow(self, provider: object) -> bool:
return bool(
self.provider_field(provider, "slow", False)
or self.provider_field(provider, "is_slow", False)
or self.provider_field(provider, "slow_mode", False)
)
def _declared_capabilities(self, provider: object) -> set[str]: def _declared_capabilities(self, provider: object) -> set[str]:
"""Return only the capabilities explicitly declared on the provider object.""" """Return only the capabilities explicitly declared on the provider object."""
raw = self.provider_field(provider, "capabilities", ()) raw = self.provider_field(provider, "capabilities", ())
@@ -10774,7 +10767,25 @@ def get_tunings():
ref = DEFAULT_REFERENCE_PITCH ref = DEFAULT_REFERENCE_PITCH
except (TypeError, ValueError): except (TypeError, ValueError):
ref = DEFAULT_REFERENCE_PITCH ref = DEFAULT_REFERENCE_PITCH
return {"referencePitch": ref, "tunings": tuning_providers.get_merged(ref)} merged = tuning_providers.get_merged(ref)
# tuningMidis: the same catalog as exact integer MIDI notes (low → high).
# Built-ins come straight from TUNING_PRESET_MIDIS (no float round-trip);
# provider-contributed entries are recovered from their frequencies at the
# served reference pitch. Every consumer today (the v3 badges, plugins)
# reconstructs midis client-side via log2 — a rounding footgun at non-440
# references — so serve the integers once, host-side. Additive: the
# existing referencePitch/tunings shape is unchanged.
tuning_midis: dict[str, dict[str, list[int]]] = {}
for key, names in merged.items():
builtin = TUNING_PRESET_MIDIS.get(key, {})
resolved: dict[str, list[int]] = {}
for name, freqs in names.items():
midis = builtin.get(name) or freqs_to_midis(freqs, ref)
if midis:
resolved[name] = list(midis)
if resolved:
tuning_midis[key] = resolved
return {"referencePitch": ref, "tunings": merged, "tuningMidis": tuning_midis}
@app.get("/api/settings") @app.get("/api/settings")
@@ -12949,53 +12960,57 @@ _extract_cache = {} # filename -> (tmp_dir, song, timestamp)
_extract_cache_lock = threading.Lock() _extract_cache_lock = threading.Lock()
@app.get("/api/sloppak/{filename:path}/file/{rel_path:path}") def _resolve_sloppak_local_file(filename: str, rel_path: str):
def serve_sloppak_file(filename: str, rel_path: str): """Resolve a file inside a sloppak to its on-disk path.
"""Serve a file from inside a sloppak (stems, cover, etc.)."""
Applies the same containment guards as ``serve_sloppak_file``. Returns the
resolved ``Path`` on success, or an ``(error, status)`` tuple on failure so
callers can produce their endpoint-appropriate response.
"""
dlc = _get_dlc_dir() dlc = _get_dlc_dir()
if not dlc: if not dlc:
return JSONResponse({"error": "not configured"}, 404) return ("not configured", 404)
# `filename` is an attacker-controlled `:path` param. Contain it under # `filename` is caller-controlled. Contain it under DLC_DIR before it
# DLC_DIR before it reaches the resolver, which does a bare # reaches the resolver (see serve_sloppak_file for the traversal rationale).
# `dlc_root / filename`. Without this, `../../../etc` escapes the root
# and the rel_path guard below validates `target` against the already-
# escaped `src`, which trivially passes — yielding arbitrary file reads
# (e.g. /api/sloppak/../../../../etc/file/passwd). Mirrors the guard
# `get_song_art` applies to the same filename param.
resolved = _resolve_dlc_path(dlc, filename) resolved = _resolve_dlc_path(dlc, filename)
if resolved is None: if resolved is None:
return JSONResponse({"error": "forbidden"}, 403) return ("forbidden", 403)
# Confine the endpoint to actual sloppak bundles. Without this, a # Confine to actual sloppak bundles — otherwise any plain subdirectory
# contained-but-non-sloppak `filename` (e.g. `.` → DLC_DIR itself, or # would become a read-any-file-under-DLC_DIR source.
# any plain subdirectory) would make `resolve_source_dir` hand back a
# directory and turn this into a read-any-file-under-DLC_DIR endpoint.
# Mirrors get_song_art's `is_sloppak` dispatch.
if not sloppak_mod.is_sloppak(resolved): if not sloppak_mod.is_sloppak(resolved):
return JSONResponse({"error": "not found"}, 404) return ("not found", 404)
# Canonicalise the cache key against the resolved path so equivalent # Canonicalise the cache key against the resolved path so equivalent URL
# URL forms of the same sloppak (e.g. `A/../B/x.sloppak` vs # forms of the same sloppak converge on one _source_cache entry.
# `B/x.sloppak`) converge on one `_source_cache` entry instead of
# fragmenting / re-unpacking — mirrors get_song_info's keying.
try: try:
filename = resolved.relative_to(dlc.resolve()).as_posix() filename = resolved.relative_to(dlc.resolve()).as_posix()
except ValueError: except ValueError:
# safe_join already proved containment, so this is unreachable in # safe_join already proved containment; fail closed regardless.
# practice; fail closed rather than fall back to the raw param. return ("forbidden", 403)
return JSONResponse({"error": "forbidden"}, 403)
src = sloppak_mod.get_cached_source_dir(filename) src = sloppak_mod.get_cached_source_dir(filename)
if src is None: if src is None:
try: try:
src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR) src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR)
except Exception: except Exception:
return JSONResponse({"error": "not found"}, 404) return ("not found", 404)
# Prevent path traversal within the sloppak. # Prevent path traversal within the sloppak.
target = (src / rel_path).resolve() target = (src / rel_path).resolve()
try: try:
target.relative_to(src.resolve()) target.relative_to(src.resolve())
except ValueError: except ValueError:
return JSONResponse({"error": "forbidden"}, 403) return ("forbidden", 403)
if not target.exists() or not target.is_file(): if not target.exists() or not target.is_file():
return JSONResponse({"error": "not found"}, 404) return ("not found", 404)
return target
@app.get("/api/sloppak/{filename:path}/file/{rel_path:path}")
def serve_sloppak_file(filename: str, rel_path: str):
"""Serve a file from inside a sloppak (stems, cover, etc.)."""
result = _resolve_sloppak_local_file(filename, rel_path)
if isinstance(result, tuple):
error, status = result
return JSONResponse({"error": error}, status)
target = result
ext = target.suffix.lower() ext = target.suffix.lower()
mt = { mt = {
".ogg": "audio/ogg", ".opus": "audio/ogg", ".oga": "audio/ogg", ".ogg": "audio/ogg", ".opus": "audio/ogg", ".oga": "audio/ogg",
@@ -13918,13 +13933,19 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
@app.get("/api/audio-local-path") @app.get("/api/audio-local-path")
def audio_local_path(url: str, request: Request): def audio_local_path(url: str, request: Request):
"""Return absolute local filesystem path for an /audio/… URL (Electron desktop only). """Return absolute local filesystem path for a song URL (Electron desktop only).
Accepts ``/audio/<path>`` where ``<path>`` may include subdirectory segments Accepts ``/audio/<path>`` where ``<path>`` may include subdirectory segments
no scheme, no host, no query string, no fragment. The resolved path must stay no scheme, no host, no query string, no fragment. The resolved path must stay
inside AUDIO_CACHE_DIR or STATIC_DIR; ``..`` traversal, backslashes, and inside AUDIO_CACHE_DIR or STATIC_DIR; ``..`` traversal, backslashes, and
absolute ``filename`` values are rejected. absolute ``filename`` values are rejected.
Also accepts ``/api/sloppak/<filename>/file/<rel>`` (percent-encoded, as
emitted by the highway song payload) and resolves it to the unpacked
sloppak cache file via the same containment guards as
``serve_sloppak_file`` this lets the desktop engine play a feedpak
full-mix natively under WASAPI-exclusive output.
This endpoint returns a raw filesystem path and is intended exclusively for This endpoint returns a raw filesystem path and is intended exclusively for
the Electron desktop process (which runs on loopback). Requests from non- the Electron desktop process (which runs on loopback). Requests from non-
loopback clients are rejected with 403. loopback clients are rejected with 403.
@@ -13937,6 +13958,21 @@ def audio_local_path(url: str, request: Request):
is_loopback = client_host == "localhost" is_loopback = client_host == "localhost"
if not is_loopback: if not is_loopback:
return JSONResponse({"error": "forbidden"}, status_code=403) return JSONResponse({"error": "forbidden"}, status_code=403)
# Sloppak in-pack file (feedpak full-mix): /api/sloppak/<fn>/file/<rel>.
# Both segments arrive percent-encoded (built with urllib quote() in the
# highway payload); decode before handing to the shared resolver, which
# re-applies all containment guards on the decoded values.
slop_match = re.fullmatch(r"/api/sloppak/([^?#]+)/file/([^?#]+)", url)
if slop_match:
from urllib.parse import unquote
result = _resolve_sloppak_local_file(
unquote(slop_match.group(1)), unquote(slop_match.group(2))
)
if isinstance(result, tuple):
error, status = result
return JSONResponse({"error": error}, status_code=status)
return JSONResponse({"path": str(result)})
# Accept only simple /audio/<filename> — no scheme, no host, no query/fragment # Accept only simple /audio/<filename> — no scheme, no host, no query/fragment
if not re.fullmatch(r"/audio/[^?#]+", url): if not re.fullmatch(r"/audio/[^?#]+", url):
return JSONResponse({"error": "invalid url"}, status_code=400) return JSONResponse({"error": "invalid url"}, status_code=400)
+281 -17
View File
@@ -1871,10 +1871,7 @@ function _setLibraryLoadingMessage(containerId, countId, message) {
const count = document.getElementById(countId); const count = document.getElementById(countId);
if (count) count.textContent = 'Loading source...'; if (count) count.textContent = 'Loading source...';
if (container) { if (container) {
container.innerHTML = `<div role="status" aria-live="polite" class="rounded-xl border border-gray-800/50 bg-dark-700/30 px-4 py-6 text-sm text-gray-300 flex items-center gap-3"> container.innerHTML = `<div class="rounded-xl border border-gray-800/50 bg-dark-700/30 px-4 py-6 text-sm text-gray-300">${esc(message || 'Loading library...')}</div>`;
<span class="inline-block h-4 w-4 rounded-full border-2 border-gray-600 border-t-accent animate-spin" aria-hidden="true"></span>
<span>${esc(message || 'Loading library...')}</span>
</div>`;
} }
} }
@@ -1883,9 +1880,6 @@ function _libraryLoadingText() {
if (!provider || provider.id === 'local' || provider.kind === 'local') { if (!provider || provider.id === 'local' || provider.kind === 'local') {
return 'Loading library...'; return 'Loading library...';
} }
if (provider.slow === true) {
return `Loading ${provider.label || provider.id}... this source may take a while.`;
}
return `Connecting to ${provider.label || provider.id}...`; return `Connecting to ${provider.label || provider.id}...`;
} }
@@ -4870,6 +4864,47 @@ window.jucePlayer = jucePlayer;
// (a network blip on /api/audio-local-path, an isAudioRunning() race // (a network blip on /api/audio-local-path, an isAudioRunning() race
// during a device restart) are deliberately NOT memoised so they retry. // during a device restart) are deliberately NOT memoised so they retry.
let _rerouteRejectedUrl = null; let _rerouteRejectedUrl = null;
// Exclusive-style output backends silence every other client on the
// endpoint — including our own <audio> element. The share mode IS the
// JUCE output device type: "Windows Audio (Exclusive Mode)" is a
// hardcoded, unlocalised JUCE type name; ASIO drivers typically hold
// the endpoint exclusively too. "Windows Audio (Low Latency Mode)" is
// shared and must NOT match.
function _isExclusiveOutputType(t) {
return t === 'Windows Audio (Exclusive Mode)' || t === 'ASIO';
}
// [feedpak-route] diagnostics: log the raw outputType string once per
// value change (this runs on a 350ms poll — logging every tick would
// flood the diagnostics buffer).
let _loggedOutputType;
async function _outputIsExclusive() {
if (typeof juceApi.getCurrentDevice !== 'function') {
if (_loggedOutputType !== '<no-getCurrentDevice>') {
_loggedOutputType = '<no-getCurrentDevice>';
console.warn('[feedpak-route] juceApi.getCurrentDevice missing — cannot detect exclusive output');
}
return false;
}
try {
const dev = await juceApi.getCurrentDevice();
const t = dev?.outputType || dev?.type || '';
const excl = _isExclusiveOutputType(t);
if (t !== _loggedOutputType) {
_loggedOutputType = t;
console.log('[feedpak-route] outputType=', JSON.stringify(t), '→ exclusive=', excl);
}
return excl;
} catch (e) {
if (_loggedOutputType !== '<getCurrentDevice-failed>') {
_loggedOutputType = '<getCurrentDevice-failed>';
console.warn('[feedpak-route] getCurrentDevice failed:', e);
}
return false;
}
}
// highway.js's initial song-load routing consults this for the same
// feedpak-under-exclusive decision the watcher makes below.
window._juceOutputIsExclusive = _outputIsExclusive;
// Returns true when window._currentSongAudio no longer references the exact // Returns true when window._currentSongAudio no longer references the exact
// snapshot object captured at reroute entry — i.e. the song was swapped (or // snapshot object captured at reroute entry — i.e. the song was swapped (or
// cleared) mid-flight. Staleness is detected by object-reference identity, // cleared) mid-flight. Staleness is detected by object-reference identity,
@@ -4910,8 +4945,12 @@ window.jucePlayer = jucePlayer;
audio.pause(); audio.pause();
try { try {
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(url)}`); const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(url)}`);
if (!res.ok) throw new Error('HTTP ' + res.status); if (!res.ok) {
console.warn('[feedpak-route] audio-local-path HTTP', res.status, 'for', url);
throw new Error('HTTP ' + res.status);
}
const { path } = await res.json(); const { path } = await res.json();
console.log('[feedpak-route] audio-local-path resolved:', (typeof path === 'string' && path.split(/[\\/]/).pop()) || '<missing>');
if (_isStale(songAudio)) return 'stale'; // song changed mid-fetch if (_isStale(songAudio)) return 'stale'; // song changed mid-fetch
const ok = await juceApi.loadBackingTrack(path); const ok = await juceApi.loadBackingTrack(path);
if (ok === false) { if (ok === false) {
@@ -5129,8 +5168,12 @@ window.jucePlayer = jucePlayer;
async function _reevaluateJuceRouting() { async function _reevaluateJuceRouting() {
if (_rerouteInFlight) return; if (_rerouteInFlight) return;
const songAudio = window._currentSongAudio; const songAudio = window._currentSongAudio;
// Only /audio/ songs are JUCE-routable; sloppak stems stay on HTML5. // /audio/ songs are always JUCE-routable. A feedpak full-mix
if (!songAudio || !songAudio.juceEligible) return; // (single-mix pack, no stems) is routable ONLY under an
// exclusive-style output — in shared mode it must stay on HTML5 so
// the stem mixer / WebAudio path keeps working. Sloppak stem URLs
// are never routable (per-stem mix can't ride a single transport).
if (!songAudio || (!songAudio.juceEligible && !songAudio.feedpakFullMix)) return;
// Don't race highway.js's own initial song-load routing: it owns // Don't race highway.js's own initial song-load routing: it owns
// _juceMode until _juceRoutingPromise settles. Re-running our switch // _juceMode until _juceRoutingPromise settles. Re-running our switch
// concurrently would double-call loadBackingTrack for the same URL. // concurrently would double-call loadBackingTrack for the same URL.
@@ -5148,13 +5191,30 @@ window.jucePlayer = jucePlayer;
try { running = await juceApi.isAudioRunning(); } try { running = await juceApi.isAudioRunning(); }
catch (_) { return; } catch (_) { return; }
if (_isStale(songAudio)) return; // song changed during IPC if (_isStale(songAudio)) return; // song changed during IPC
if (!!running === !!window._juceMode) return; // routing already consistent // Eligibility is evaluated per tick, not snapshotted at song load:
// the output share mode can change mid-song (device switch in the
const wantJuce = running && !window._juceMode; // Audio Engine panel), and a feedpak full-mix must follow it —
// exclusive → ride the engine; back to shared → return to HTML5.
let eligible = !!songAudio.juceEligible;
if (!eligible && songAudio.feedpakFullMix && running) {
eligible = await _outputIsExclusive();
if (_isStale(songAudio)) return; // song changed during IPC
}
const wantJuce = !!(running && eligible);
// [feedpak-route] diagnostics: one line per decision change (the
// watcher polls at 350ms; steady state must not spam the buffer).
const _decision = 'running=' + running + ' eligible=' + eligible
+ ' feedpakFullMix=' + !!songAudio.feedpakFullMix
+ ' juceMode=' + !!window._juceMode + ' url=' + songAudio.url;
if (_decision !== window._lastFeedpakRouteDecision) {
window._lastFeedpakRouteDecision = _decision;
console.log('[feedpak-route] watcher:', _decision);
}
if (wantJuce === !!window._juceMode) return; // routing already consistent
// Don't keep retrying a track JUCE explicitly rejected. // Don't keep retrying a track JUCE explicitly rejected.
if (wantJuce && songAudio.url === _rerouteRejectedUrl) return; if (wantJuce && songAudio.url === _rerouteRejectedUrl) return;
if (running) { if (wantJuce) {
const outcome = await _switchHtml5ToJuce(songAudio); const outcome = await _switchHtml5ToJuce(songAudio);
// Memoise ONLY an explicit hard JUCE reject. A successful // Memoise ONLY an explicit hard JUCE reject. A successful
// switch clears the memo; a 'stale' abort (song changed // switch clears the memo; a 'stale' abort (song changed
@@ -5169,9 +5229,10 @@ window.jucePlayer = jucePlayer;
// outcome === 'stale': leave _rerouteRejectedUrl as-is. // outcome === 'stale': leave _rerouteRejectedUrl as-is.
} else { } else {
await _switchJuceToHtml5(songAudio); await _switchJuceToHtml5(songAudio);
// The engine just stopped. Clear any hard-reject memo so a // The engine stopped (or a feedpak's output left exclusive
// later engine restart re-evaluates the track at least once — // mode). Clear any hard-reject memo so a later engine restart
// the rejection may have been a transient device/decoder state. // or mode change re-evaluates the track at least once — the
// rejection may have been a transient device/decoder state.
_rerouteRejectedUrl = null; _rerouteRejectedUrl = null;
} }
} catch (e) { } catch (e) {
@@ -5203,6 +5264,209 @@ window.jucePlayer = jucePlayer;
}, 350); }, 350);
})(); })();
// Renderer-audio bus feeder (desktop Phase 2): when the engine holds the
// output endpoint in an exclusive-style mode, Chromium cannot reach the
// device, so any song audio still played by the renderer goes silent. The
// Phase 1 watcher above already migrates what a single-file transport can
// carry (loose /audio/ songs, feedpak full-mixes) onto the native backing
// transport. This feeder covers the rest — the stems plugin's multi-stem
// WebAudio graph, plus <audio>-element songs the native transport could not
// take (e.g. a codec loadBackingTrack rejected).
//
// Mechanism: capture the renderer-side master with an AudioWorklet tap,
// re-point the owning AudioContext at a null sink so it keeps rendering
// without a device, and push ~10 ms chunks over IPC into the engine's
// renderer bus, where they are mixed into the exclusive output like a
// backing track (~10-20 ms added latency on song audio only; the guitar
// monitoring path is untouched). Validated by the fix12 tester spike:
// null-sink rendering works, clocks hold (drift → 0), no overflow.
//
// Docker sphere: window.feedBackDesktop is undefined → this whole block is
// inert. Shared-mode desktop: the bus stays disabled (no double audio) and
// captured contexts keep/regain their default sink.
(function _installRendererBusFeeder() {
const api = window.feedBackDesktop?.audio;
if (!api || typeof api.setRendererBus !== 'function'
|| typeof api.pushRendererAudio !== 'function') return;
const TAP_WORKLET = `
class FeedbackBusTap extends AudioWorkletProcessor {
process(inputs) {
const inp = inputs[0];
if (inp && inp[0]) {
const L = inp[0], R = inp[1] || inp[0];
const out = new Float32Array(L.length * 2);
for (let i = 0; i < L.length; i++) { out[i*2] = L[i]; out[i*2+1] = R[i]; }
this.port.postMessage(out, [out.buffer]);
}
return true;
}
}
registerProcessor('feedback-bus-tap', FeedbackBusTap);
`;
const _tapModuleUrl = URL.createObjectURL(new Blob([TAP_WORKLET], { type: 'application/javascript' }));
const _tapModuleLoaded = new WeakSet(); // AudioContexts with the module added
// One tap per captured graph. `active` gates the push (the worklet keeps
// running when inactive — it's silent bookkeeping, not audio).
function _makeTap(ctx) {
const state = { node: null, active: false, batch: [], batchFrames: 0 };
state.attach = async (sourceNode) => {
if (!_tapModuleLoaded.has(ctx)) {
await ctx.audioWorklet.addModule(_tapModuleUrl);
_tapModuleLoaded.add(ctx);
}
if (!state.node) {
state.node = new AudioWorkletNode(ctx, 'feedback-bus-tap', { numberOfInputs: 1, channelCount: 2 });
const BATCH = Math.round(ctx.sampleRate / 100); // ~10 ms
state.node.port.onmessage = (e) => {
if (!state.active) { state.batch = []; state.batchFrames = 0; return; }
state.batch.push(e.data);
state.batchFrames += e.data.length / 2;
if (state.batchFrames >= BATCH) {
const merged = new Float32Array(state.batchFrames * 2);
let o = 0;
for (const c of state.batch) { merged.set(c, o); o += c.length; }
api.pushRendererAudio(merged, ctx.sampleRate);
state.batch = []; state.batchFrames = 0;
}
};
}
sourceNode.connect(state.node);
// No onward connection: the tap is a sink-side observer; audibility
// in shared mode comes from the graph's own destination path.
};
state.detach = (sourceNode) => {
state.active = false;
state.batch = []; state.batchFrames = 0;
if (state.node && sourceNode) {
try { sourceNode.disconnect(state.node); } catch (_) { /* already gone */ }
}
};
return state;
}
// ── Core <audio> element capture ─────────────────────────────────────────
// createMediaElementSource permanently reroutes the element into its
// context, so it is created lazily — only the first time an exclusive
// device actually needs it — and never torn down. From then on the element
// always plays through _elCtx; sink toggling routes it to the speakers
// (shared mode) or the null sink + bus (exclusive mode).
let _elCtx = null, _elSource = null, _elTap = null;
async function _ensureElementCapture() {
if (_elCtx) return;
const el = document.getElementById('audio');
if (!el) throw new Error('no core audio element');
_elCtx = new AudioContext();
_elSource = _elCtx.createMediaElementSource(el);
_elSource.connect(_elCtx.destination);
_elTap = _makeTap(_elCtx);
await _elTap.attach(_elSource);
}
// ── Engagement state machine ─────────────────────────────────────────────
// 'off' | 'element' | 'stems'
let _mode = 'off';
let _stemsGraph = null; // { context, masterNode } snapshot while engaged
let _stemsTap = null;
const _stemsTaps = new WeakMap(); // context → tap (stems ctx is reused across songs)
let _busy = false;
async function _setSink(ctx, exclusive) {
if (typeof ctx.setSinkId !== 'function') throw new Error('setSinkId unsupported');
await ctx.setSinkId(exclusive ? { type: 'none' } : '');
if (ctx.state !== 'running') await ctx.resume().catch(() => {});
}
async function _disengage() {
if (_mode === 'off') return;
const prev = _mode;
_mode = 'off';
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
if (prev === 'element' && _elCtx) {
_elTap.active = false;
await _setSink(_elCtx, false).catch(() => {});
} else if (prev === 'stems' && _stemsGraph) {
if (_stemsTap) _stemsTap.detach(_stemsGraph.masterNode);
await _setSink(_stemsGraph.context, false).catch(() => {});
_stemsGraph = null; _stemsTap = null;
}
console.log('[renderer-bus] disengaged (' + prev + ')');
}
async function _engageStems(graph) {
await _setSink(graph.context, true);
let tap = _stemsTaps.get(graph.context);
if (!tap) { tap = _makeTap(graph.context); _stemsTaps.set(graph.context, tap); }
await tap.attach(graph.masterNode);
await api.setRendererBus(true, 1.0);
tap.active = true;
_stemsGraph = graph; _stemsTap = tap;
_mode = 'stems';
console.log('[renderer-bus] engaged: stems graph → engine bus');
}
async function _engageElement() {
await _ensureElementCapture();
await _setSink(_elCtx, true);
await api.setRendererBus(true, 1.0);
_elTap.active = true;
_mode = 'element';
console.log('[renderer-bus] engaged: <audio> element → engine bus');
}
async function _reevaluate() {
if (_busy) return;
_busy = true;
try {
let running = false, exclusive = false;
try {
running = await api.isAudioRunning();
} catch (_) { /* engine unreachable → treat as not running */ }
if (running) {
// Reuse the Phase 1 predicate installed by the routing watcher
// (getCurrentDevice + exclusive-type check with change-logged
// diagnostics). Fail closed if it is somehow absent.
exclusive = !!(await window._juceOutputIsExclusive?.());
}
// The stems plugin publishes its live graph while a multi-stem
// song is loaded (and removes it on teardown).
const stems = (window.feedBack || window.slopsmith)?.stems?.audioGraph || null;
// Element songs: a song is loaded, it is NOT riding the native
// transport (Phase 1 owns those), and the stems graph is not the
// player. Covers native-transport rejects (codec) in exclusive
// mode — without this they would be silent.
const songAudio = window._currentSongAudio;
const elementSong = !!songAudio && !window._juceMode && !stems;
let want = 'off';
if (running && exclusive) {
if (stems) want = 'stems';
else if (elementSong) want = 'element';
}
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
if (want !== _mode || stemsGraphChanged) {
await _disengage();
if (want === 'stems') await _engageStems(stems);
else if (want === 'element') await _engageElement();
}
} catch (e) {
console.warn('[renderer-bus] reevaluate failed (will retry):', e);
_mode = 'off';
} finally {
_busy = false;
}
}
// Same cadence/rationale as the routing watcher above. Also re-check on
// visibility return so a device switch made while hidden is reconciled.
setInterval(() => { if (!document.hidden) void _reevaluate(); }, 500);
document.addEventListener('visibilitychange', () => { if (!document.hidden) void _reevaluate(); });
window._reevaluateRendererBus = _reevaluate;
})();
// Desktop JUCE backing uses an empty <audio> element; plugins such as Section Map // Desktop JUCE backing uses an empty <audio> element; plugins such as Section Map
// still seek via audio.currentTime / pause / play. Mirror those onto jucePlayer // still seek via audio.currentTime / pause / play. Mirror those onto jucePlayer
// while _juceMode is active. Same-tick pause+seek coalesce into a single seek // while _juceMode is active. Same-tick pause+seek coalesce into a single seek
-1
View File
@@ -86,7 +86,6 @@
// operation is still completing through the provider. // operation is still completing through the provider.
const COMMAND_TIMEOUTS_MS = { const COMMAND_TIMEOUTS_MS = {
'audio-mix': { 'get-fader-value': 2100, 'set-fader-value': 2100 }, 'audio-mix': { 'get-fader-value': 2100, 'set-fader-value': 2100 },
'library': { 'refresh-providers': 15000, 'sync-song': 15000 },
'midi-input': { 'discover': 15000, 'open-source': 15000 }, 'midi-input': { 'discover': 15000, 'open-source': 15000 },
}; };
function _commandTimeoutFor(capability, commandName) { function _commandTimeoutFor(capability, commandName) {
+3 -1
View File
@@ -9,6 +9,8 @@
const SCHEMA = 'feedBack.audio_effects.diagnostics.v1'; const SCHEMA = 'feedBack.audio_effects.diagnostics.v1';
const PLAN_SCHEMA = 'feedBack.audio_effects.chain_plan.v1'; const PLAN_SCHEMA = 'feedBack.audio_effects.chain_plan.v1';
// Pre-rebrand plugins (rig_builder <= 2.9.x) still send the old schema id — accept it as an alias.
const LEGACY_PLAN_SCHEMA = 'slopsmith.audio_effects.chain_plan.v1';
const OWNER_ID = 'core.audio.effects'; const OWNER_ID = 'core.audio.effects';
const DEFAULT_ROUTE_KEY = 'desktop-main'; const DEFAULT_ROUTE_KEY = 'desktop-main';
const DEFAULT_TIMEOUT_MS = 2000; const DEFAULT_TIMEOUT_MS = 2000;
@@ -734,7 +736,7 @@
const errors = []; const errors = [];
const source = _plainObject(rawPlan); const source = _plainObject(rawPlan);
const schema = _string(source.schema || source.version, PLAN_SCHEMA); const schema = _string(source.schema || source.version, PLAN_SCHEMA);
if (schema !== PLAN_SCHEMA && schema !== '1') errors.push('Unsupported chain plan schema'); if (schema !== PLAN_SCHEMA && schema !== LEGACY_PLAN_SCHEMA && schema !== '1') errors.push('Unsupported chain plan schema');
const planRoute = _safeRoute(source.routeKey || source.route || routeKey); const planRoute = _safeRoute(source.routeKey || source.route || routeKey);
if (planRoute !== routeKey) errors.push('Chain plan route does not match selected route'); if (planRoute !== routeKey) errors.push('Chain plan route does not match selected route');
const providerId = _safeId(source.providerId || provider.providerId, provider.providerId); const providerId = _safeId(source.providerId || provider.providerId, provider.providerId);
-3
View File
@@ -13,7 +13,6 @@
label: 'My Library', label: 'My Library',
kind: 'local', kind: 'local',
capabilities: ['library.read', 'art.read', 'song.play', 'favorite.write', 'metadata.write', 'retune.write'], capabilities: ['library.read', 'art.read', 'song.play', 'favorite.write', 'metadata.write', 'retune.write'],
slow: false,
default: true, default: true,
}); });
const PROVIDER_OPERATIONS = Object.freeze({ const PROVIDER_OPERATIONS = Object.freeze({
@@ -73,7 +72,6 @@
label: String(provider.label || provider.name || providerId), label: String(provider.label || provider.name || providerId),
kind: String(provider.kind || (providerId === 'local' ? 'local' : 'remote')), kind: String(provider.kind || (providerId === 'local' ? 'local' : 'remote')),
capabilities: _strings(provider.capabilities), capabilities: _strings(provider.capabilities),
slow: provider.slow === true || provider.is_slow === true || provider.slow_mode === true,
default: provider.default === true || providerId === 'local', default: provider.default === true || providerId === 'local',
}; };
} }
@@ -143,7 +141,6 @@
label: provider.label || providerId, label: provider.label || providerId,
kind: provider.kind || (providerId === 'local' ? 'local' : 'remote'), kind: provider.kind || (providerId === 'local' ? 'local' : 'remote'),
capabilities: _strings(provider.capabilities), capabilities: _strings(provider.capabilities),
slow: provider.slow === true,
ownerPluginId: _ownerPluginId(provider) || null, ownerPluginId: _ownerPluginId(provider) || null,
default: provider.default === true || providerId === 'local', default: provider.default === true || providerId === 'local',
}, },
+58 -5
View File
@@ -3409,21 +3409,57 @@ function createHighway() {
if (msg.audio_url) { if (msg.audio_url) {
const audio = document.getElementById('audio'); const audio = document.getElementById('audio');
const audioFilename = msg.audio_url.split('/').pop(); const audioFilename = msg.audio_url.split('/').pop();
// Only attempt JUCE routing for /audio/ URLs — sloppak stems // /audio/ URLs are always JUCE-routable. A feedpak full-mix
// (/api/sloppak/…) are not resolvable via audio-local-path. // (single-mix pack: original audio, no stems) is routable too,
// but ONLY under an exclusive-style output — the actual
// exclusive check happens at routing time (app.js watcher /
// the async block below), not here, because the share mode
// can change while the song is loaded. Sloppak stem URLs are
// never routable.
const isAudioUrl = msg.audio_url.startsWith('/audio/'); const isAudioUrl = msg.audio_url.startsWith('/audio/');
// "Full mix" covers BOTH single-mix pack shapes:
// - stem-less packs (original_audio: in the manifest,
// audio_url == original_audio_url), and
// - single-stem packs (stems: [full.ogg] only) — the server
// puts the full mix in the stems list, has_original_audio
// is false, and audio_url points at the one stem. With one
// stem there is no per-stem mix to preserve, so routing it
// natively loses nothing. Real multi-stem (>1) stays out
// until Phase 2.
const isFeedpakFullMix = !isAudioUrl
&& msg.audio_url.startsWith('/api/sloppak/')
&& ((!!msg.has_original_audio && !msg.has_stems)
|| (msg.stems || []).length === 1);
// Record the loaded song's audio so app.js can re-route it // Record the loaded song's audio so app.js can re-route it
// between the HTML5 and JUCE paths if the audio engine is // between the HTML5 and JUCE paths if the audio engine is
// started/stopped after the song is already loaded. Set this // started/stopped after the song is already loaded. Set this
// unconditionally (not just on reload): when alreadyLoaded is // unconditionally (not just on reload): when alreadyLoaded is
// true the watcher must still see correct, current metadata. // true the watcher must still see correct, current metadata.
window._currentSongAudio = { url: msg.audio_url, juceEligible: isAudioUrl }; window._currentSongAudio = {
url: msg.audio_url,
juceEligible: isAudioUrl,
feedpakFullMix: isFeedpakFullMix,
};
const alreadyLoaded = window._juceMode const alreadyLoaded = window._juceMode
? window._juceAudioUrl === msg.audio_url ? window._juceAudioUrl === msg.audio_url
: (audio.src && audio.src.includes(audioFilename)); : (audio.src && audio.src.includes(audioFilename));
// [feedpak-route] diagnostics: every eligibility input in one
// line — shows up in the exported diagnostics bundle. If
// has_stems is true the pack is multi-stem and Phase 1
// deliberately does not route it (Phase 2 work).
console.log('[feedpak-route] song-load:',
'url=', msg.audio_url,
'isAudioUrl=', isAudioUrl,
'isFeedpakFullMix=', isFeedpakFullMix,
'has_stems=', !!msg.has_stems,
'stems=', (msg.stems || []).length,
'has_original_audio=', !!msg.has_original_audio,
'format=', msg.format,
'alreadyLoaded=', alreadyLoaded,
'juceApi=', !!window.feedBackDesktop?.audio);
if (!alreadyLoaded) { if (!alreadyLoaded) {
const juceApi = window.feedBackDesktop?.audio; const juceApi = window.feedBackDesktop?.audio;
if (isAudioUrl && juceApi) { if ((isAudioUrl || isFeedpakFullMix) && juceApi) {
// Run JUCE routing off the critical message-processing chain // Run JUCE routing off the critical message-processing chain
// so subsequent notes/chords/ready messages aren't blocked // so subsequent notes/chords/ready messages aren't blocked
// waiting for IPC + HTTP round-trips. The 'ready' handler // waiting for IPC + HTTP round-trips. The 'ready' handler
@@ -3466,7 +3502,24 @@ function createHighway() {
clearTimeout(barrierTimer); clearTimeout(barrierTimer);
if (gen !== _wsGen) return; // navigated away during the wait if (gen !== _wsGen) return; // navigated away during the wait
} }
if (await juceApi.isAudioRunning()) { // Feedpak full-mix rides the engine ONLY under an
// exclusive-style output (shared mode falls through to
// the HTML5 fallback below, keeping the WebAudio path
// fully working). /audio/ songs route whenever the
// engine runs, as before. If the share mode changes
// later, the app.js watcher re-evaluates and migrates.
let routeToJuce = await juceApi.isAudioRunning();
console.log('[feedpak-route] initial-load: engineRunning=', routeToJuce);
if (routeToJuce) {
if (gen !== _wsGen) return; // stale
if (isFeedpakFullMix) {
const exclFn = window._juceOutputIsExclusive;
routeToJuce = !!(await exclFn?.());
console.log('[feedpak-route] initial-load: feedpak exclusive check →',
routeToJuce, '(predicate installed=', typeof exclFn === 'function', ')');
}
}
if (routeToJuce) {
if (gen !== _wsGen) return; // stale if (gen !== _wsGen) return; // stale
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(audioUrl)}`); const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(audioUrl)}`);
if (!res.ok) throw new Error('HTTP ' + res.status); if (!res.ok) throw new Error('HTTP ' + res.status);
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -28
View File
@@ -64,7 +64,6 @@
filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [] }, filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [] },
page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [], genres: [], page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [], genres: [],
providers: [],
artistCatalog: [], renderedHash: '', artistCatalog: [], renderedHash: '',
scrollBound: false, scrollBound: false,
songsById: {}, selectMode: false, selected: new Set(), songsById: {}, selectMode: false, selected: new Set(),
@@ -443,24 +442,6 @@
async function jget(url) { try { const r = await fetch(url); return r.ok ? r.json() : null; } catch (e) { return null; } } async function jget(url) { try { const r = await fetch(url); return r.ok ? r.json() : null; } catch (e) { return null; } }
function activeProvider() {
return (state.providers || []).find((provider) => provider.id === state.provider) || { id: state.provider || 'local', label: 'My Library', kind: 'local' };
}
function providerLoadingText() {
const provider = activeProvider();
if (!provider || provider.id === 'local' || provider.kind === 'local') return 'Loading library...';
if (provider.slow === true) return 'Loading ' + (provider.label || provider.id) + '... this source may take a while.';
return 'Connecting to ' + (provider.label || provider.id) + '...';
}
function loadingPanelHtml() {
return '<div role="status" aria-live="polite" class="rounded-lg border border-fb-border/50 bg-fb-card/50 px-4 py-5 text-sm text-fb-textDim flex items-center gap-3" style="grid-column:1/-1">' +
'<span class="inline-block h-4 w-4 rounded-full border-2 border-fb-border border-t-fb-primary animate-spin" aria-hidden="true"></span>' +
'<span>' + esc(providerLoadingText()) + '</span>' +
'</div>';
}
// ── Provider-aware song helpers ──────────────────────────────────────── // ── Provider-aware song helpers ────────────────────────────────────────
// Remote library providers (feedBack-plugin-remote-library-*) expose songs // Remote library providers (feedBack-plugin-remote-library-*) expose songs
// by provider-owned id with their own art/sync/play flow. Reuse the legacy // by provider-owned id with their own art/sync/play flow. Reuse the legacy
@@ -2109,10 +2090,6 @@
grid.style.top = '0px'; grid.style.top = '0px';
const sizer = _sizerEl(); const sizer = _sizerEl();
if (sizer) sizer.style.height = '0px'; if (sizer) sizer.style.height = '0px';
const countEl = document.getElementById('v3-songs-count');
if (countEl) countEl.textContent = 'Loading source...';
grid.innerHTML = loadingPanelHtml();
if (sizer) sizer.style.height = grid.offsetHeight + 'px';
} }
state.loading = true; state.loading = true;
await _loadPage(0); await _loadPage(0);
@@ -2408,7 +2385,7 @@
async function loadAlbums() { async function loadAlbums() {
const host = document.getElementById('v3-songs-albums'); const host = document.getElementById('v3-songs-albums');
if (!host) return; if (!host) return;
host.innerHTML = '<div role="status" aria-live="polite" class="text-fb-textDim text-sm flex items-center gap-3"><span class="inline-block h-4 w-4 rounded-full border-2 border-fb-border border-t-fb-primary animate-spin" aria-hidden="true"></span><span>' + esc(providerLoadingText()) + '</span></div>'; host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
const data = await jget('/api/library/albums?' + queryParams().toString()); const data = await jget('/api/library/albums?' + queryParams().toString());
const albums = (data && data.albums) || []; const albums = (data && data.albums) || [];
if (!albums.length) { host.innerHTML = '<p class="text-fb-textDim text-sm py-8 text-center">No albums match.</p>'; return; } if (!albums.length) { host.innerHTML = '<p class="text-fb-textDim text-sm py-8 text-center">No albums match.</p>'; return; }
@@ -2793,7 +2770,7 @@
// (e.g. toggling select mode) restores them instead of collapsing all. // (e.g. toggling select mode) restores them instead of collapsing all.
const openArtists = new Set( const openArtists = new Set(
[...host.querySelectorAll('details[open]')].map((d) => d.getAttribute('data-artist'))); [...host.querySelectorAll('details[open]')].map((d) => d.getAttribute('data-artist')));
host.innerHTML = '<div role="status" aria-live="polite" class="text-fb-textDim text-sm flex items-center gap-3"><span class="inline-block h-4 w-4 rounded-full border-2 border-fb-border border-t-fb-primary animate-spin" aria-hidden="true"></span><span>' + esc(providerLoadingText()) + '</span></div>'; host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
// Page through ALL artists — the endpoint clamps size to 100, so a // Page through ALL artists — the endpoint clamps size to 100, so a
// single request would silently truncate libraries with >100 artists. // single request would silently truncate libraries with >100 artists.
const artists = []; const artists = [];
@@ -3480,14 +3457,12 @@
const snap = await fn.call(lp); const snap = await fn.call(lp);
if (snap && Array.isArray(snap.providers)) { if (snap && Array.isArray(snap.providers)) {
state.provider = snap.current || (snap.providers[0] && snap.providers[0].id) || 'local'; state.provider = snap.current || (snap.providers[0] && snap.providers[0].id) || 'local';
state.providers = snap.providers;
return snap.providers; return snap.providers;
} }
} }
} catch (e) { /* */ } } catch (e) { /* */ }
const data = await jget('/api/library/providers'); const data = await jget('/api/library/providers');
state.providers = (data && data.providers) || [{ id: 'local', label: 'My Library', kind: 'local' }]; return (data && data.providers) || [{ id: 'local', label: 'My Library' }];
return state.providers;
} }
async function render() { async function render() {
+1 -31
View File
@@ -77,34 +77,4 @@ test('failed no-op registrations do not block reload and rehydrate replacement',
assert.equal(participants.length, 1); assert.equal(participants.length, 1);
assert.equal(result.status, 'applied'); assert.equal(result.status, 'applied');
assert.equal(result.payload.generation, 2); assert.equal(result.payload.generation, 2);
}); });
test('library long-running commands override the default handler timeout', async () => {
const window = loadCapabilities();
const api = window.feedBack.capabilities;
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
api.registerParticipant('stems', {
stems: {
roles: ['owner'],
commands: ['slow-probe'],
handlers: { 'slow-probe': async () => { await delay(300); return { outcome: 'handled' }; } },
runtime: true,
},
});
const timedOut = await api.dispatch({ capability: 'stems', command: 'slow-probe', source: 'test' });
assert.equal(timedOut.outcome, 'failed');
assert.match(timedOut.reason, /timed out after 250 ms/);
api.registerParticipant('core.library', {
library: {
roles: ['owner'],
commands: ['sync-song'],
handlers: { 'sync-song': async () => { await delay(300); return { outcome: 'handled', payload: { ok: true } }; } },
runtime: true,
},
});
const synced = await api.dispatch({ capability: 'library', command: 'sync-song', source: 'test' });
assert.equal(synced.status, 'applied');
assert.equal(synced.payload.ok, true);
});
+83 -1
View File
@@ -40,7 +40,7 @@ function extractWatcherIIFE(src) {
// Build a sandbox with fakes and run the watcher IIFE inside it. Returns the // Build a sandbox with fakes and run the watcher IIFE inside it. Returns the
// sandbox so tests can drive window._reevaluateJuceRouting and inspect state. // sandbox so tests can drive window._reevaluateJuceRouting and inspect state.
function makeSandbox({ isAudioRunning, loadBackingTrack }) { function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows Audio' }) {
const calls = { loadBackingTrack: [], jucePlay: 0, jucePause: 0, audioPlay: 0 }; const calls = { loadBackingTrack: [], jucePlay: 0, jucePause: 0, audioPlay: 0 };
const audio = { const audio = {
@@ -65,6 +65,7 @@ function makeSandbox({ isAudioRunning, loadBackingTrack }) {
const juceApi = { const juceApi = {
isAudioRunning: () => Promise.resolve(isAudioRunning()), isAudioRunning: () => Promise.resolve(isAudioRunning()),
loadBackingTrack: (p) => { calls.loadBackingTrack.push(p); return Promise.resolve(loadBackingTrack()); }, loadBackingTrack: (p) => { calls.loadBackingTrack.push(p); return Promise.resolve(loadBackingTrack()); },
getCurrentDevice: () => Promise.resolve({ outputType: typeof outputType === 'function' ? outputType() : outputType }),
getBackingDuration: () => Promise.resolve(180), getBackingDuration: () => Promise.resolve(180),
seekBacking: () => Promise.resolve(), seekBacking: () => Promise.resolve(),
startBacking: () => Promise.resolve(), startBacking: () => Promise.resolve(),
@@ -152,6 +153,87 @@ test('non-JUCE-eligible song (sloppak stems) is never rerouted', async () => {
assert.equal(sb.__calls.loadBackingTrack.length, 0); assert.equal(sb.__calls.loadBackingTrack.length, 0);
}); });
test('feedpak full-mix + exclusive output → migrates to JUCE', async () => {
const sb = makeSandbox({
isAudioRunning: () => true,
loadBackingTrack: () => true,
outputType: 'Windows Audio (Exclusive Mode)',
});
sb.window._juceMode = false;
sb.window._currentSongAudio = {
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
juceEligible: false,
feedpakFullMix: true,
};
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, true, 'feedpak full-mix rides the engine under exclusive output');
assert.equal(sb.__calls.loadBackingTrack.length, 1);
});
test('feedpak full-mix + ASIO output → migrates to JUCE', async () => {
const sb = makeSandbox({
isAudioRunning: () => true,
loadBackingTrack: () => true,
outputType: 'ASIO',
});
sb.window._juceMode = false;
sb.window._currentSongAudio = {
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
juceEligible: false,
feedpakFullMix: true,
};
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, true, 'ASIO is exclusive-style; feedpak rides the engine');
});
test('feedpak full-mix + shared output → stays on HTML5 (stem mixer untouched)', async () => {
for (const shared of ['Windows Audio', 'Windows Audio (Low Latency Mode)', 'DirectSound']) {
const sb = makeSandbox({
isAudioRunning: () => true,
loadBackingTrack: () => true,
outputType: shared,
});
sb.window._juceMode = false;
sb.window._currentSongAudio = {
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
juceEligible: false,
feedpakFullMix: true,
};
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, false, `stays on HTML5 for shared type "${shared}"`);
assert.equal(sb.__calls.loadBackingTrack.length, 0);
}
});
test('feedpak on JUCE + output leaves exclusive mode → migrates back to HTML5', async () => {
let type = 'Windows Audio (Exclusive Mode)';
const sb = makeSandbox({
isAudioRunning: () => true,
loadBackingTrack: () => true,
outputType: () => type,
});
const url = '/api/sloppak/song.sloppak/file/stems/full.ogg';
sb.window._juceMode = true;
sb.window._juceAudioUrl = url;
sb.window._currentSongAudio = { url, juceEligible: false, feedpakFullMix: true };
// Still exclusive: routing is consistent, no switch.
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, true, 'consistent while exclusive');
// Device switched to shared mid-song: must return to HTML5.
type = 'Windows Audio';
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, false, 'returned to HTML5 after leaving exclusive mode');
assert.equal(sb.audio.src, url, 'HTML5 element re-pointed at the song');
});
test('JUCE hard-reject is memoised → not retried on the next poll', async () => { test('JUCE hard-reject is memoised → not retried on the next poll', async () => {
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => false }); const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => false });
sb.window._juceMode = false; sb.window._juceMode = false;
+195
View File
@@ -0,0 +1,195 @@
// Behavioral tests for the renderer-audio bus feeder in static/app.js.
//
// The feeder (an IIFE, `_installRendererBusFeeder`) captures renderer-side
// song audio (stems-plugin WebAudio master, or the core <audio> element) and
// pushes it into the desktop engine's renderer bus while the output device is
// exclusive-style — the Phase 2 path for audio the native backing transport
// cannot carry. These tests extract that IIFE from source and exercise
// `window._reevaluateRendererBus` against fakes, covering: stems engagement
// under exclusive output, disengagement on return to shared mode, inertness
// in shared mode / while the native transport owns the song, and the
// element-capture fallback.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
function extractFeederIIFE(src) {
const marker = '(function _installRendererBusFeeder() {';
const start = src.indexOf(marker);
assert.ok(start !== -1, 'feeder IIFE not found in app.js');
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, 'unbalanced braces in feeder IIFE');
const tail = src.slice(i, i + 5);
assert.match(tail, /^\)\(\)/, 'feeder IIFE not immediately invoked');
return src.slice(start, i) + ')();';
}
function makeFakeContext(sampleRate = 48000) {
const ctx = {
sampleRate,
state: 'running',
sinkIdCalls: [],
destination: { isDestination: true },
setSinkId(v) { this.sinkIdCalls.push(v); return Promise.resolve(); },
resume() { this.state = 'running'; return Promise.resolve(); },
audioWorklet: { addModule: () => Promise.resolve() },
createMediaElementSource(el) {
this.mediaSourceEl = el;
return { connect() {}, disconnect() {} };
},
};
return ctx;
}
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {}) {
const calls = { setRendererBus: [], pushRendererAudio: [] };
const api = {
isAudioRunning: () => Promise.resolve(isAudioRunning()),
setRendererBus: (en, g) => { calls.setRendererBus.push([en, g]); return Promise.resolve(); },
pushRendererAudio: (buf, rate) => { calls.pushRendererAudio.push([buf.length, rate]); },
};
class FakeWorkletNode {
constructor() { this.port = { onmessage: null }; }
connect() {}
disconnect() {}
}
const sandbox = {
console: { log() {}, warn() {}, error() {} },
URL: { createObjectURL: () => 'blob:tap', revokeObjectURL() {} },
Blob: class { constructor() {} },
AudioWorkletNode: FakeWorkletNode,
AudioContext: function () { const c = makeFakeContext(); sandbox.__createdContexts.push(c); return c; },
WeakSet, WeakMap, Promise, Float32Array, Math,
setInterval: () => 0,
document: {
hidden: false,
addEventListener() {},
getElementById: () => sandbox.__audioEl,
},
__createdContexts: [],
__audioEl: { id: 'audio' },
__calls: calls,
window: null,
};
sandbox.window = {
feedBackDesktop: { audio: api },
_juceOutputIsExclusive: () => Promise.resolve(exclusive()),
_juceMode: false,
_currentSongAudio: null,
feedBack: { stems: {} },
};
sandbox.globalThis = sandbox;
const src = fs.readFileSync(APP_JS, 'utf8');
vm.createContext(sandbox);
vm.runInContext(extractFeederIIFE(src), sandbox);
assert.equal(typeof sandbox.window._reevaluateRendererBus, 'function',
'feeder must expose window._reevaluateRendererBus');
return sandbox;
}
function makeStemsGraph() {
return {
context: makeFakeContext(),
masterNode: { connect() {}, disconnect() {} },
};
}
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked', async () => {
const sb = makeSandbox({ exclusive: () => true });
const graph = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = graph;
await sb.window._reevaluateRendererBus();
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems ctx re-pointed at null sink');
});
test('output returns to shared → bus disabled, sink restored', async () => {
let excl = true;
const sb = makeSandbox({ exclusive: () => excl });
const graph = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = graph;
await sb.window._reevaluateRendererBus();
excl = false;
await sb.window._reevaluateRendererBus();
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled');
assert.equal(graph.context.sinkIdCalls.at(-1), '', 'default sink restored');
});
test('stems graph + shared output → feeder stays off (no double audio)', async () => {
const sb = makeSandbox({ exclusive: () => false });
sb.window.feedBack.stems.audioGraph = makeStemsGraph();
await sb.window._reevaluateRendererBus();
assert.equal(sb.__calls.setRendererBus.length, 0, 'bus never touched in shared mode');
});
test('element song + exclusive → element captured into bus', async () => {
const sb = makeSandbox({ exclusive: () => true });
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
sb.window._juceMode = false;
await sb.window._reevaluateRendererBus();
assert.equal(sb.__createdContexts.length, 1, 'capture context created');
assert.equal(sb.__createdContexts[0].mediaSourceEl, sb.__audioEl, 'element source captured');
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
});
test('song riding the native transport (_juceMode) → feeder stays off', async () => {
const sb = makeSandbox({ exclusive: () => true });
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
sb.window._juceMode = true;
await sb.window._reevaluateRendererBus();
assert.equal(sb.__calls.setRendererBus.length, 0, 'native transport owns the song');
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
});
test('stems graph replaced mid-engagement → re-engages on the new graph', async () => {
const sb = makeSandbox({ exclusive: () => true });
const g1 = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = g1;
await sb.window._reevaluateRendererBus();
const g2 = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = g2;
await sb.window._reevaluateRendererBus();
assert.equal(g2.context.sinkIdCalls.at(-1)?.type, 'none', 'new graph null-sinked');
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 're-enabled for new graph');
});
test('engine stops → bus disabled', async () => {
let running = true;
const sb = makeSandbox({ isAudioRunning: () => running, exclusive: () => true });
sb.window.feedBack.stems.audioGraph = makeStemsGraph();
await sb.window._reevaluateRendererBus();
running = false;
await sb.window._reevaluateRendererBus();
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled after engine stop');
});
-36
View File
@@ -1,36 +0,0 @@
// Pins the slow-library-provider loading states in the classic and v3 library
// surfaces. Providers can declare `slow: true`; the UI should show explicit
// wait copy plus a status region instead of silently blanking while fetches run.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..', '..');
const SONGS = fs.readFileSync(path.join(root, 'static', 'v3', 'songs.js'), 'utf8');
const APP = fs.readFileSync(path.join(root, 'static', 'app.js'), 'utf8');
test('v3 Songs slow providers get explicit loading copy', () => {
assert.match(SONGS, /function providerLoadingText\(\)[\s\S]*?provider\.slow\s*===\s*true[\s\S]*?this source may take a while\./,
'v3 providerLoadingText must branch on provider.slow and explain the longer wait');
assert.match(SONGS, /function loadingPanelHtml\(\)[\s\S]*?providerLoadingText\(\)/,
'v3 grid loading panel must render the provider-aware loading message');
});
test('v3 loading indicators are announced as polite status regions', () => {
assert.match(SONGS, /function loadingPanelHtml\(\)[\s\S]*?role="status"\s+aria-live="polite"/,
'v3 grid loading panel must be a polite status region');
assert.match(SONGS, /loadAlbums[\s\S]*role="status"\s+aria-live="polite"[\s\S]*providerLoadingText\(\)/,
'v3 albums loading state must be a polite status region');
assert.match(SONGS, /loadTree[\s\S]*role="status"\s+aria-live="polite"[\s\S]*providerLoadingText\(\)/,
'v3 list loading state must be a polite status region');
});
test('classic library loading indicator is announced and uses slow provider copy', () => {
assert.match(APP, /function _setLibraryLoadingMessage[\s\S]*?role="status"\s+aria-live="polite"/,
'classic library loading card must be a polite status region');
assert.match(APP, /function _libraryLoadingText\(\)[\s\S]*?provider\.slow\s*===\s*true[\s\S]*?this source may take a while\./,
'classic library loading text must branch on provider.slow');
});
+90 -4
View File
@@ -103,12 +103,98 @@ def test_returns_404_for_nonexistent_file(client_and_server):
assert "error" in r.json() assert "error" in r.json()
# ── rejected non-/audio/ inputs ─────────────────────────────────────────────── # ── sloppak URLs (feedpak full-mix, desktop exclusive-mode routing) ──────────
def test_rejects_sloppak_url(client_and_server): @pytest.fixture()
def dlc_client(tmp_path, monkeypatch):
"""Loopback TestClient with a temp DLC_DIR for sloppak resolution."""
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "cfg"))
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
sys.modules.pop("server", None)
server = importlib.import_module("server")
# Module-level sloppak source-dir cache survives re-import; clear it so a
# prior test's filename key can't shadow this test's temp DLC_DIR.
server.sloppak_mod._source_cache.clear()
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
monkeypatch.setattr(server, "startup_scan", lambda: None)
static_tmp = tmp_path / "static"
static_tmp.mkdir()
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
tc = TestClient(server.app, client=("127.0.0.1", 50000))
try:
yield tc, server, dlc
finally:
tc.close()
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
def _make_sloppak(dlc, name="song.sloppak"):
"""Create a minimal directory-form sloppak with a full-mix file."""
pak = dlc / name
(pak / "stems").mkdir(parents=True)
(pak / "stems" / "full.ogg").write_bytes(b"OggS-fake")
return pak
def test_sloppak_url_resolves_to_local_path(dlc_client):
tc, _server, dlc = dlc_client
pak = _make_sloppak(dlc)
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/song.sloppak/file/stems/full.ogg"},
)
assert r.status_code == 200, r.text
assert r.json()["path"] == str((pak / "stems" / "full.ogg").resolve())
def test_sloppak_url_percent_encoded_segments_decode(dlc_client):
tc, _server, dlc = dlc_client
_make_sloppak(dlc, name="My Song.sloppak")
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/My%20Song.sloppak/file/stems/full.ogg"},
)
assert r.status_code == 200, r.text
def test_sloppak_url_rel_traversal_is_403(dlc_client):
tc, _server, dlc = dlc_client
_make_sloppak(dlc)
(dlc / "secret.txt").write_text("top secret")
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/song.sloppak/file/..%2Fsecret.txt"},
)
assert r.status_code == 403, r.text
def test_sloppak_url_filename_traversal_is_403(dlc_client):
tc, _server, _dlc = dlc_client
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/..%2F..%2F..%2Fetc/file/passwd"},
)
assert r.status_code == 403, r.text
def test_sloppak_url_without_dlc_configured_is_404(client_and_server):
"""No DLC_DIR in the base fixture — resolver reports 'not configured'."""
client, _ = client_and_server client, _ = client_and_server
r = client.get("/api/audio-local-path", params={"url": "/api/sloppak/mysong/file/stems/full.ogg"}) r = client.get(
assert r.status_code == 400 "/api/audio-local-path",
params={"url": "/api/sloppak/mysong/file/stems/full.ogg"},
)
assert r.status_code == 404
# ── rejected non-/audio/ inputs ───────────────────────────────────────────────
def test_rejects_empty_url(client_and_server): def test_rejects_empty_url(client_and_server):
-2
View File
@@ -50,7 +50,6 @@ class FakeLibraryProvider:
id = "remote:frodo" id = "remote:frodo"
label = "Frodo's Library" label = "Frodo's Library"
kind = "remote" kind = "remote"
slow = True
capabilities = ("library.read", "art.read", "song.sync") capabilities = ("library.read", "art.read", "song.sync")
def __init__(self): def __init__(self):
@@ -171,7 +170,6 @@ def test_registered_provider_handles_library_endpoints(server_mod, client):
assert remote["label"] == "Frodo's Library" assert remote["label"] == "Frodo's Library"
assert remote["kind"] == "remote" assert remote["kind"] == "remote"
assert remote["default"] is False assert remote["default"] is False
assert remote["slow"] is True
assert remote["capabilities"] == ["art.read", "library.read", "song.sync"] assert remote["capabilities"] == ["art.read", "library.read", "song.sync"]
songs = client.get("/api/library", params={ songs = client.get("/api/library", params={
+27
View File
@@ -249,3 +249,30 @@ def test_flat_string_count_patch_resets_incompatible_named_tuning():
patched = apply_flat_instrument_patch_to_profiles(settings, {"string_count": 7}) patched = apply_flat_instrument_patch_to_profiles(settings, {"string_count": 7})
assert patched["string_count"] == 7 assert patched["string_count"] == 7
assert patched["tuning"] == "Standard" assert patched["tuning"] == "Standard"
# ── freqs_to_midis (the /api/tunings tuningMidis inverse) ────────────────────
def test_freqs_to_midis_round_trips_every_builtin_at_440():
from tunings import freqs_to_midis
for key, presets in TUNING_PRESET_MIDIS.items():
for name, midis in presets.items():
assert freqs_to_midis(open_midis_to_freqs(midis)) == midis, f"{key}/{name}"
def test_freqs_to_midis_round_trips_at_nonstandard_reference():
# The consumer footgun this exists to kill: frequencies served at a 432/450
# reference must recover the SAME integer midis when inverted at that
# reference (client-side log2-at-440 reconstruction drifts here).
from tunings import freqs_to_midis
for ref in (430.0, 432.0, 444.0, 450.0):
for midis in (TUNING_PRESET_MIDIS["guitar-8"]["Standard"], TUNING_PRESET_MIDIS["bass-5"]["Standard"]):
freqs = open_midis_to_freqs(midis, ref)
assert freqs_to_midis(freqs, ref) == midis, f"ref={ref}"
def test_freqs_to_midis_rejects_garbage():
from tunings import freqs_to_midis
assert freqs_to_midis([82.41, 0]) is None # non-positive
assert freqs_to_midis([82.41, "x"]) is None # non-numeric
assert freqs_to_midis([]) == [] # vacuously fine