Compare commits

..
Author SHA1 Message Date
Byron GamatosandClaude Opus 4.8 283d5355ff chore(highway_3d): bump to 3.31.5 (3.31.4 taken by #823 on main)
Rebased onto main; #823 already shipped 3.31.4 (per-panel camera), so this
Butterchurn buffer-sizing fix advances to 3.31.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:08:00 +02:00
c2036f5dc1 fix(highway_3d): size Butterchurn output canvas buffer to fill the highway
The 3D-highway Butterchurn background set only the output canvas CSS size
and called setRendererSize(), but never sized the canvas DRAWING BUFFER
(canvas.width/height). Butterchurn does not size the output canvas itself
(renderToScreen viewports to the reported size into the default
framebuffer), so the buffer stayed at the browser default 300x150 while the
viewport was the full highway. Only the bottom-left ~300x150 of the pattern
was drawn, then CSS-stretched across the whole highway -- zoomed, soft, and
aspect-wrong, worse the larger the panel.

Add _bcApplySize(cssW, cssH): set the drawing buffer to the device-pixel
render size (round(css * min(DPR, 1.5))), confine every layer (canvas,
backdrop, scrim, tint) to the highway rect, and report the same device px
to setRendererSize so buffer == on-screen viewport. Seed the buffer at
create and switch createVisualizer to pixelRatio:1, textureRatio:1 (DPR is
now folded into the reported size, so buffer == viewport == internal
texsize, no double-counting). render() and resize() both route through it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
2026-07-10 13:07:41 +02:00
751209b80e Serve exact MIDI notes from GET /api/tunings (tuningMidis) (#829)
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.


Claude-Session: https://claude.ai/code/session_01MS2YFb6UUSwJVV6CmEa25i

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:05:39 +02: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
16 changed files with 1237 additions and 77 deletions
+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]
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:
"""Return semitone offsets from the instrument's standard open strings."""
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "drum_highway_3d",
"name": "3D Drum Highway",
"version": "0.3.1",
"version": "0.3.2",
"type": "visualization",
"bundled": true,
"script": "screen.js",
+85 -3
View File
@@ -1361,6 +1361,41 @@
} 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
* ====================================================================== */
@@ -1414,7 +1449,7 @@
let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null;
let _fxLastWall = 0; // wall clock for FX integration (sparks, pulse decay)
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 _laneFlashQuads = []; // pooled additive quad per hand lane (z=0)
let _kickFlashQuad = null; // full-width flash quad for the kick bar
@@ -2651,6 +2686,48 @@
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) {
laneGroup = new T.Group();
laneStripeMats = [];
@@ -3431,15 +3508,18 @@
BG_STYLES[_bgState._style].update(_bgState.s, bands, fdt, nowMs / 1000);
} catch (_) { /* visual-only */ }
}
// Kick pulse decays each frame; it drives the floor flash and,
// via applyCamera(), the camera Y dip.
if (_kickPulse > 0.001) {
_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;
} else if (_kickPulse !== 0) {
_kickPulse = 0;
cam.position.y = _camBaseH;
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
// note (accumulated by the rebuildNotes walk above).
@@ -3565,6 +3645,8 @@
// vm-loaded with no DOM/WebGL; everything here must stay side-effect
// free to call).
window.slopsmithViz_drum_highway_3d.__test = {
_resolveFreeCam,
_ssApi,
_variantForHit,
_classifyTiming,
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",
"name": "3D Highway",
"version": "3.31.3",
"version": "3.31.5",
"type": "visualization",
"bundled": true,
"script": "screen.js",
+89 -17
View File
@@ -548,9 +548,20 @@
ctrl.ownsActx = !(fogAudio && fogAudio.ctx);
ctrl.actx = (fogAudio && fogAudio.ctx) || new Ctx();
if (ctrl.actx.state === 'suspended' && ctrl.actx.resume) ctrl.actx.resume().catch(() => {});
// Seed the DRAWING BUFFER (canvas.width/height) to the device-pixel
// render size and report that SAME size to Butterchurn. Its on-screen
// pass viewports to the reported size but never sizes the output canvas
// itself — leaving the buffer at the 300x150 default blits the whole
// visualizer into a corner that CSS then stretches across the highway.
// pixelRatio:1 because DPR is now folded into the reported size, so
// buffer == viewport == internal texsize (no double-counting).
const _bcRatio0 = Math.min(window.devicePixelRatio || 1, 1.5);
const _bcW0 = Math.max(1, Math.round((sz.w || 1280) * _bcRatio0));
const _bcH0 = Math.max(1, Math.round((sz.h || 720) * _bcRatio0));
canvas.width = _bcW0; canvas.height = _bcH0;
ctrl.viz = bc.createVisualizer(ctrl.actx, canvas, {
width: sz.w || 1280, height: sz.h || 720,
pixelRatio: Math.min(window.devicePixelRatio || 1, 1.5), textureRatio: 1,
width: _bcW0, height: _bcH0,
pixelRatio: 1, textureRatio: 1,
});
if (_bcIsDesktop()) {
try {
@@ -584,6 +595,27 @@
ctrl.actx = null; ctrl.viz = null; ctrl.dead = true;
_bcControllers.delete(ctrl);
});
// Size the Butterchurn output: set the canvas DRAWING BUFFER to the
// device-pixel render size AND report that same size, so buffer ==
// on-screen viewport == full fill. Butterchurn never sizes the output
// canvas itself; the previous code set only CSS size, leaving the buffer
// at the 300x150 default -> the viz showed a stretched lower-left corner
// (worse the larger the panel). Ratio reuses the highway's DPR budget.
function _bcApplySize(cssW, cssH) {
if (!(cssW > 0 && cssH > 0)) return;
ctrl.lastW = cssW; ctrl.lastH = cssH;
const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
const bw = Math.max(1, Math.round(cssW * ratio)), bh = Math.max(1, Math.round(cssH * ratio));
if (canvas.width !== bw) canvas.width = bw;
if (canvas.height !== bh) canvas.height = bh;
const wpx = cssW + 'px', hpx = cssH + 'px';
// Confine ALL layers to exactly the highway-canvas rect so the opaque
// backdrop can't bleed over the transport bar above the highway.
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => {
if (el) { el.style.width = wpx; el.style.height = hpx; el.style.right = 'auto'; el.style.bottom = 'auto'; }
});
if (ctrl.viz && ctrl.viz.setRendererSize) { try { ctrl.viz.setRendererSize(bw, bh); } catch (e) {} }
}
return {
applySettings() { ctrl.applySettings(); },
dead() { return ctrl.dead; },
@@ -612,18 +644,11 @@
if (!ctrl.viz || !s.enabled) return; // skip GPU work when the bg is off
const sz = sizeProvider && sizeProvider();
if (sz && sz.w > 0 && sz.h > 0 && (sz.w !== ctrl.lastW || sz.h !== ctrl.lastH)) {
ctrl.lastW = sz.w; ctrl.lastH = sz.h;
const wpx = sz.w + 'px', hpx = sz.h + 'px';
// Confine ALL layers to exactly the highway-canvas rect so the opaque
// backdrop can't bleed over the transport bar above the highway.
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => {
if (el) { el.style.width = wpx; el.style.height = hpx; el.style.right = 'auto'; el.style.bottom = 'auto'; }
});
try { ctrl.viz.setRendererSize(sz.w, sz.h); } catch (e) {}
_bcApplySize(sz.w, sz.h);
}
try { ctrl.viz.render(); } catch (e) {}
},
resize(w, h) { if (ctrl.viz && ctrl.viz.setRendererSize) { try { ctrl.viz.setRendererSize(w, h); } catch (e) {} ctrl.lastW = w; ctrl.lastH = h; } },
resize(w, h) { _bcApplySize(w, h); },
destroy() {
ctrl.dead = true;
_bcControllers.delete(ctrl);
@@ -2595,10 +2620,51 @@
}
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) {
const ss = window.feedBackSplitscreen;
const idx = (ss && typeof ss.panelIndexFor === 'function') ? ss.panelIndexFor(canvas) : null;
return (idx == null) ? 'main' : 'panel' + idx;
const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen;
let idx = null;
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,
// sandboxed iframes, some test runners). _bgWriteGlobal stages the
@@ -14664,7 +14730,10 @@
// suppressed while the Camera Director owns the view (it wins).
const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0)
? _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 _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1;
const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1;
@@ -14691,13 +14760,16 @@
if (_poseHMul !== 1) _camY *= _poseHMul;
if (_poseDMul !== 1) _camZ *= _poseDMul;
// ── 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.
// The bridge is read once into _freeCam and reused for both the
// position and the look-at transforms; every field is coerced to a
// finite number before use so a malformed object can never feed NaN
// 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;
if (_freeCam && _freeCam.enabled) {
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
+76 -1
View File
@@ -1652,6 +1652,41 @@
_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
* ====================================================================== */
@@ -1901,6 +1936,18 @@
_rigOut.lookZ = _camPreset.lookZ;
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
// 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
@@ -3311,7 +3358,33 @@
}
_camX += (_camTargetX - _camX) * CAM_PAN_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 { mesh, note, len, label } of noteMeshes) {
@@ -4012,6 +4085,8 @@
};
// Pure data-layer + scoring hooks for headless tests.
window.slopsmithViz_keys_highway_3d.__test = {
_resolveFreeCam,
_ssApi,
beatDurSec,
flattenNotation,
keyRange,
@@ -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
});
+77 -33
View File
@@ -45,9 +45,10 @@ from song import (
from audio import find_wem_files, convert_wem
from tunings import (
DEFAULT_REFERENCE_PITCH, DEFAULT_TUNINGS, PROFILE_IDS, PROFILE_PATHWAYS,
apply_flat_instrument_patch_to_profiles, apply_reference_pitch,
normalize_instrument_profile, normalize_instrument_profiles,
settings_with_instrument_profiles, tuning_name,
TUNING_PRESET_MIDIS, apply_flat_instrument_patch_to_profiles,
apply_reference_pitch, freqs_to_midis, normalize_instrument_profile,
normalize_instrument_profiles, settings_with_instrument_profiles,
tuning_name,
)
import sloppak as sloppak_mod
import drums as drums_mod
@@ -10766,7 +10767,25 @@ def get_tunings():
ref = DEFAULT_REFERENCE_PITCH
except (TypeError, ValueError):
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")
@@ -12941,53 +12960,57 @@ _extract_cache = {} # filename -> (tmp_dir, song, timestamp)
_extract_cache_lock = threading.Lock()
@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.)."""
def _resolve_sloppak_local_file(filename: str, rel_path: str):
"""Resolve a file inside a sloppak to its on-disk path.
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()
if not dlc:
return JSONResponse({"error": "not configured"}, 404)
# `filename` is an attacker-controlled `:path` param. Contain it under
# DLC_DIR before it reaches the resolver, which does a bare
# `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.
return ("not configured", 404)
# `filename` is caller-controlled. Contain it under DLC_DIR before it
# reaches the resolver (see serve_sloppak_file for the traversal rationale).
resolved = _resolve_dlc_path(dlc, filename)
if resolved is None:
return JSONResponse({"error": "forbidden"}, 403)
# Confine the endpoint to actual sloppak bundles. Without this, a
# contained-but-non-sloppak `filename` (e.g. `.` → DLC_DIR itself, or
# 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.
return ("forbidden", 403)
# Confine to actual sloppak bundles — otherwise any plain subdirectory
# would become a read-any-file-under-DLC_DIR source.
if not sloppak_mod.is_sloppak(resolved):
return JSONResponse({"error": "not found"}, 404)
# Canonicalise the cache key against the resolved path so equivalent
# URL forms of the same sloppak (e.g. `A/../B/x.sloppak` vs
# `B/x.sloppak`) converge on one `_source_cache` entry instead of
# fragmenting / re-unpacking — mirrors get_song_info's keying.
return ("not found", 404)
# Canonicalise the cache key against the resolved path so equivalent URL
# forms of the same sloppak converge on one _source_cache entry.
try:
filename = resolved.relative_to(dlc.resolve()).as_posix()
except ValueError:
# safe_join already proved containment, so this is unreachable in
# practice; fail closed rather than fall back to the raw param.
return JSONResponse({"error": "forbidden"}, 403)
# safe_join already proved containment; fail closed regardless.
return ("forbidden", 403)
src = sloppak_mod.get_cached_source_dir(filename)
if src is None:
try:
src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR)
except Exception:
return JSONResponse({"error": "not found"}, 404)
return ("not found", 404)
# Prevent path traversal within the sloppak.
target = (src / rel_path).resolve()
try:
target.relative_to(src.resolve())
except ValueError:
return JSONResponse({"error": "forbidden"}, 403)
return ("forbidden", 403)
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()
mt = {
".ogg": "audio/ogg", ".opus": "audio/ogg", ".oga": "audio/ogg",
@@ -13910,13 +13933,19 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
@app.get("/api/audio-local-path")
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
no scheme, no host, no query string, no fragment. The resolved path must stay
inside AUDIO_CACHE_DIR or STATIC_DIR; ``..`` traversal, backslashes, and
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
the Electron desktop process (which runs on loopback). Requests from non-
loopback clients are rejected with 403.
@@ -13929,6 +13958,21 @@ def audio_local_path(url: str, request: Request):
is_loopback = client_host == "localhost"
if not is_loopback:
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
if not re.fullmatch(r"/audio/[^?#]+", url):
return JSONResponse({"error": "invalid url"}, status_code=400)
+280 -10
View File
@@ -4864,6 +4864,47 @@ window.jucePlayer = jucePlayer;
// (a network blip on /api/audio-local-path, an isAudioRunning() race
// during a device restart) are deliberately NOT memoised so they retry.
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
// snapshot object captured at reroute entry — i.e. the song was swapped (or
// cleared) mid-flight. Staleness is detected by object-reference identity,
@@ -4904,8 +4945,12 @@ window.jucePlayer = jucePlayer;
audio.pause();
try {
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();
console.log('[feedpak-route] audio-local-path resolved:', (typeof path === 'string' && path.split(/[\\/]/).pop()) || '<missing>');
if (_isStale(songAudio)) return 'stale'; // song changed mid-fetch
const ok = await juceApi.loadBackingTrack(path);
if (ok === false) {
@@ -5123,8 +5168,12 @@ window.jucePlayer = jucePlayer;
async function _reevaluateJuceRouting() {
if (_rerouteInFlight) return;
const songAudio = window._currentSongAudio;
// Only /audio/ songs are JUCE-routable; sloppak stems stay on HTML5.
if (!songAudio || !songAudio.juceEligible) return;
// /audio/ songs are always JUCE-routable. A feedpak full-mix
// (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
// _juceMode until _juceRoutingPromise settles. Re-running our switch
// concurrently would double-call loadBackingTrack for the same URL.
@@ -5142,13 +5191,30 @@ window.jucePlayer = jucePlayer;
try { running = await juceApi.isAudioRunning(); }
catch (_) { return; }
if (_isStale(songAudio)) return; // song changed during IPC
if (!!running === !!window._juceMode) return; // routing already consistent
const wantJuce = running && !window._juceMode;
// Eligibility is evaluated per tick, not snapshotted at song load:
// the output share mode can change mid-song (device switch in the
// 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.
if (wantJuce && songAudio.url === _rerouteRejectedUrl) return;
if (running) {
if (wantJuce) {
const outcome = await _switchHtml5ToJuce(songAudio);
// Memoise ONLY an explicit hard JUCE reject. A successful
// switch clears the memo; a 'stale' abort (song changed
@@ -5163,9 +5229,10 @@ window.jucePlayer = jucePlayer;
// outcome === 'stale': leave _rerouteRejectedUrl as-is.
} else {
await _switchJuceToHtml5(songAudio);
// The engine just stopped. Clear any hard-reject memo so a
// later engine restart re-evaluates the track at least once —
// the rejection may have been a transient device/decoder state.
// The engine stopped (or a feedpak's output left exclusive
// mode). Clear any hard-reject memo so a later engine restart
// or mode change re-evaluates the track at least once — the
// rejection may have been a transient device/decoder state.
_rerouteRejectedUrl = null;
}
} catch (e) {
@@ -5197,6 +5264,209 @@ window.jucePlayer = jucePlayer;
}, 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
// still seek via audio.currentTime / pause / play. Mirror those onto jucePlayer
// while _juceMode is active. Same-tick pause+seek coalesce into a single seek
+3 -1
View File
@@ -9,6 +9,8 @@
const SCHEMA = 'feedBack.audio_effects.diagnostics.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 DEFAULT_ROUTE_KEY = 'desktop-main';
const DEFAULT_TIMEOUT_MS = 2000;
@@ -734,7 +736,7 @@
const errors = [];
const source = _plainObject(rawPlan);
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);
if (planRoute !== routeKey) errors.push('Chain plan route does not match selected route');
const providerId = _safeId(source.providerId || provider.providerId, provider.providerId);
+58 -5
View File
@@ -3409,21 +3409,57 @@ function createHighway() {
if (msg.audio_url) {
const audio = document.getElementById('audio');
const audioFilename = msg.audio_url.split('/').pop();
// Only attempt JUCE routing for /audio/ URLs — sloppak stems
// (/api/sloppak/…) are not resolvable via audio-local-path.
// /audio/ URLs are always JUCE-routable. A feedpak full-mix
// (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/');
// "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
// between the HTML5 and JUCE paths if the audio engine is
// started/stopped after the song is already loaded. Set this
// unconditionally (not just on reload): when alreadyLoaded is
// 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
? window._juceAudioUrl === msg.audio_url
: (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) {
const juceApi = window.feedBackDesktop?.audio;
if (isAudioUrl && juceApi) {
if ((isAudioUrl || isFeedpakFullMix) && juceApi) {
// Run JUCE routing off the critical message-processing chain
// so subsequent notes/chords/ready messages aren't blocked
// waiting for IPC + HTTP round-trips. The 'ready' handler
@@ -3466,7 +3502,24 @@ function createHighway() {
clearTimeout(barrierTimer);
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
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(audioUrl)}`);
if (!res.ok) throw new Error('HTTP ' + res.status);
+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
// 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 audio = {
@@ -65,6 +65,7 @@ function makeSandbox({ isAudioRunning, loadBackingTrack }) {
const juceApi = {
isAudioRunning: () => Promise.resolve(isAudioRunning()),
loadBackingTrack: (p) => { calls.loadBackingTrack.push(p); return Promise.resolve(loadBackingTrack()); },
getCurrentDevice: () => Promise.resolve({ outputType: typeof outputType === 'function' ? outputType() : outputType }),
getBackingDuration: () => Promise.resolve(180),
seekBacking: () => 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);
});
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 () => {
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => 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');
});
+90 -4
View File
@@ -103,12 +103,98 @@ def test_returns_404_for_nonexistent_file(client_and_server):
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
r = client.get("/api/audio-local-path", params={"url": "/api/sloppak/mysong/file/stems/full.ogg"})
assert r.status_code == 400
r = client.get(
"/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):
+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})
assert patched["string_count"] == 7
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