Compare commits

..
Author SHA1 Message Date
c717b37dc0 feat(core): route the highway chart to the selected instrument's part (working-tuning PR 2)
When a song loads without an explicit arrangement, highway_ws now reads the
player's selected `instrument` from config.json (the same file it already reads
for the default-arrangement preference) and picks the arrangement that matches:
bass -> the Bass part. Guitar — and any unknown/future instrument (drums, keys)
— falls through to the existing preference/most-notes default, which already
lands on a guitar part.

Previously the instrument selector only fed the tuner, so a bass player was
handed the default Lead/guitar chart, and the working-tuning coverage check then
compared a 4-string bass against a 6-string part (always "can't cover"). This is
the instrument->chart routing the working-tuning series leans on.

Server-only (every launch path flows through the WS, so no client change). An
explicit arrangement request always wins, so only the default part chosen on
load changes. Tests: tests/test_highway_ws_instrument_routing.py (bass->Bass,
guitar->default, explicit-wins) — 3 new, existing highway WS tests still green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
2026-07-01 08:56:46 +02:00
491039a12d feat(core): host per-instrument workingTuning capability + read-API/event (working-tuning PR 1) (#658)
* feat(core): host per-instrument workingTuning capability + read-API/event (working-tuning PR 1)

Introduce window.feedBack.workingTuning — the live, host-authoritative current
instrument tuning (offsets + string-count + reference pitch + assumed/verified
provenance), distinct from any one song's tuning and from a soft opt-in default.
It's the single source of truth the highway, library, and plugins (tuner,
Virtuoso, minigames) will read so a retune or instrument swap is reflected
app-wide instead of being re-derived per surface.

PER-INSTRUMENT: state is a map keyed by `${instrument}-${stringCount}` (e.g.
guitar-6 / bass-4, the selector's key) — your guitar's tuning and your bass's are
kept separately; get() returns the selected instrument's, and switching the
selector surfaces that instrument's own remembered tuning. You only ever deal
with the one you've picked.

Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API:
synchronous get(instrument?), set(state,{provenance,instrument}) mutator,
setCurrentInstrument(), resetToDefault(), and a `working-tuning-changed` event
that fires on change and once on hydration (carrying which instrument changed).
In-memory, seeded from /api/settings, reset-on-restart. Registered as a separate
`working-tuning` exclusive-owner capability (tuner = sole writer, others read).

Foundation only — pure plumbing, nothing writes to it yet and no behavior
changes. The tuner becomes the writer (and the gate's E->C# asymmetry is fixed)
in a later PR.

Frontend-only: new static/capabilities/working-tuning.js, loaded from
static/index.html + static/v3/index.html. Per-instrument state machine verified
by a stubbed node harness (separate guitar/bass slots, selector switch, isolated
writes, verified stamp, reset, defensive copies, capability registration).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(working-tuning): resolve review P1/P2s + add behavioral test harness

Addresses the manual + Codex review of PR 1 (working-tuning foundation).

P1 — named tunings were dropped by the boot seed: /api/settings.tuning may be a
name ("Drop D") OR an offsets list, but the seed only handled the list and stored
offsets:null for names. The seed now resolves a name to per-string semitone offsets
via /api/tunings (ratio vs Standard; reference pitch cancels).

P1 — async seed could clobber state a consumer had already written: _seedFromSettings
resolves after boot and used to overwrite _currentKey/_byInstrument unconditionally.
It now bails when state was already _touched (and re-checks after the /api/tunings
leg), so an explicit set()/setCurrentInstrument()/resetToDefault() before hydration
wins. Hydration still fires.

P1/P2 — shallow copy leaked live nested arrays: get() and set() now clone offsets and
verifiedStrings on both ingress and egress, honouring the "readers can't mutate live
state" contract.

P2 — provenance/verification state machine made coherent by construction:
verified <=> verifiedStrings is an array AND verifiedAt is a finite number. A tuning
change invalidates prior verification unless a fresh bundle is supplied; a "verified"
claim with no strings or a null/absent timestamp is repaired (assumed / stamped now).

P2 — bare-instrument writes targeted a hard-coded default string count: _keyOfResolved()
resolves an omitted string count against the current selection (same instrument), so
set({instrument:'bass'}) / set({stringCount:5}) hit the selected bass-5, not bass-4.

Test — adds tests/js/working_tuning.test.js (the harness the PR described but did not
commit): 11 behavioral cases over a stubbed window — registration, per-instrument
isolation + selector switch, defensive copies, the verification invariant, bare-key
routing, named + offsets-list seeding, and the boot-race guard. Full tests/js suite:
no new failures (the 12 pre-existing branch failures are unrelated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-01 08:05:56 +02:00
f9607c5c94 Port the Min res (minimum auto-resolution) selector into the v3 player (#663)
The v2 control bar exposes a "Min res" selector next to Quality that sets
highway.setMinRenderScale — capping how far the load-adaptive resolution
scaler (feedBack#654) may downscale, or disabling it entirely (Full). The
v3 UI only ported the Quality selector, so v3 users had no way to stop the
highway auto-downscaling to as low as quarter-res on heavy scenes / weak-
GPU launches — pixelated even at Quality = HD, with no workaround (worse
than v2).

Add the Min res row to the v3 viz/quality rail popover, under Quality,
mirroring the v2 control (same options, handler, title, aria). The handler,
the setMinRenderScale/getMinRenderScale API, and the shared app.js init
that syncs the selector's value (guarded by element id) all already exist —
only the v3 markup was missing.

Fixes #662

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:19:08 +02:00
3e3a98a0d0 Aspect-aware framing for ultra-wide 3D highway panes (#652)
* Add aspect-aware framing for ultra-wide highway panes

On a top/bottom 2-player split each 3D highway pane is full-width /
half-height (~32:9). The camera's vertical FOV was locked at a single
value, so at that aspect the horizontal cone ballooned past 130deg and
squeezed the fixed-width neck into a thin central sliver with large dead
margins on either side.

Add a "horizontal-FOV-hold" path: past a configurable start aspect the
effective vertical FOV is lowered so the horizontal cone stays roughly
constant, letting the neck fill a wide pane. At/under the start aspect it
is an exact no-op, so normal ~16:9 single-player and most 2x2 panes are
unchanged. Optional pose nudges (height / dolly / pitch / look-depth)
further flatten the view toward a low, immersive angle.

Everything is driven by a runtime bridge (window.__h3dAspectTune) with a
live tuner panel (Shift+A in the player) exposing every knob plus a live
aspect/FOV readout, localStorage persistence, and a Copy button. Toggling
the feature off restores the exact prior framing, so it doubles as an A/B
control. Shipped on by default for wide panes for testing feedback.

Source-pinned by tests/js/highway_3d_wide_fov.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>

* fix(highway_3d): ship wide-pane framing default-OFF with a coherent config

Review fixes for the aspect-aware framing. The first cut shipped
_ASPECT_DEFAULTS = { enabled:true, baseVfov:30, blend:0, minVfovDeg:36 },
which contradicted the PR's own "default off → byte-for-byte prior behaviour"
claim:

- enabled:true made the tune active for everyone, and baseVfov:30 forced every
  pane's vertical fov from 70° to 30° (normal single-player/2x2 panes included —
  a drastic global zoom, not the advertised no-op).
- blend:0 collapsed the Hor+ math back to base, so the actual horizontal-FOV-
  hold did nothing even on wide panes — the only net effect was the zoom.
- minVfovDeg:36 > baseVfov:30 was an inverted floor (clamped wide panes UP to
  36° rather than flooring a real reduction).

New defaults: { enabled:false, baseVfov:BASE_VFOV(70), blend:1,
minVfovDeg:HORPLUS_MIN_VFOV(28) }. Now:

- OFF by default → camUpdate passes a null tune → effectiveVfov returns
  BASE_VFOV → exact no-op on every pane (verified: 70° at 16:9 and 32:9).
- When a tester enables it (Shift+A), baseVfov==BASE_VFOV keeps normal/≤start
  panes at 70° (still a no-op there) and blend:1 makes the hold actually engage
  on genuinely wide panes (47.7° at 32:9, flooring toward 28° as aspect grows).
- minVfovDeg < baseVfov is a real floor.

Also bumps the localStorage key (h3d_aspect_tune → h3d_aspect_tune2) so a
machine that persisted the old broken default gets the corrected one, and adds
source-pin tests guarding default-off + the coherent base/blend/floor so this
can't silently regress to default-on again. The pose-nudge values are left as
the author's in-progress wide-pane look (dormant until enabled). 110/110 tests
pass; node --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-30 17:36:32 +02:00
db81d7dafb Add progress bar to v3 "Up Next" pill (#649)
The persistent top-right "Up Next" pill showed the upcoming section name
and a countdown ("in 12.3s") but no at-a-glance sense of how far through
the current section the song is. Add a thin progress bar directly under
the existing text that fills as the current section elapses toward the
next, reaching full when the section flips.

The text row is wrapped unchanged in a flex row and the pill stacks the
bar beneath it; nothing else about the pill's content or styling changes.
Progress is computed in updateUpNext() as the fraction elapsed between the
previous section boundary (last section at/before now, else song start)
and the next section. The fill uses the same gradient as the section name
for visual cohesion.

Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:44:09 +02:00
13 changed files with 1345 additions and 53 deletions
+2 -1
View File
@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **The highway now loads the part that matches your selected instrument — a bass player gets the Bass arrangement, not the default Lead/guitar chart.** When you open a song without an explicit arrangement, the WebSocket handler (`server.py` `highway_ws`) reads your selected `instrument` from `config.json` (the same file it already reads for your default-arrangement preference) and routes to the matching part: **bass → the Bass arrangement**; guitar — and any unknown/future instrument (drums, keys) — falls through to the existing preference/most-notes default, which already lands on a guitar part. Previously the instrument selector only fed the tuner, so a bass player was handed a guitar chart (and a tune/coverage check then compared a 4-string bass against a 6-string part). An **explicit arrangement request always wins** (a manual arrangement switch is untouched), and a bass player's saved default-arrangement preference is still honored **within** the bass parts (so a preferred `Bass 2` / `Alt. Bass` wins over the canonical Bass), so this only changes the *default* part chosen on load. Server-only — every launch path already flows through the WS, so there's no client change. This is the instrument↔chart-routing piece the working-tuning series leans on (otherwise coverage compares across instruments). Tests: `tests/test_highway_ws_instrument_routing.py` (bass→Bass, bass-honors-pref, bass-no-bass-part→guitar, guitar→default, explicit-wins).
- **Host "working tuning" — a live, app-wide record of what tuning your instrument is *actually* in right now (foundation; no behavior change yet).** Introduces `window.feedBack.workingTuning`, a host-owned, session-lived state distinct from any one song's tuning and from a soft opt-in default: the offsets + string-count + reference pitch the player's instrument is currently in, plus an `assumed`/`verified` provenance flag. It's **per-instrument** — your guitar's current tuning and your bass's are kept *separately* (keyed like the instrument selector, e.g. `guitar-6` / `bass-4`), so switching instruments surfaces that instrument's own remembered tuning and you only ever deal with the one you've selected. It exists so a retune — or an instrument swap mid-session — is reflected **everywhere** (the highway, the library/song-picker, and plugins like the tuner, Virtuoso, and the minigames) instead of being re-derived per surface or wrongly assumed from a fixed profile. Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API: a **synchronous `get(instrument?)`** (returns the selected instrument's state, defaulting to the seed until known), a `set(state, {provenance, instrument})` mutator (the tuner becomes the sole writer in the next change), `setCurrentInstrument()` for the selector, `resetToDefault()`, and a `working-tuning-changed` event that fires on every change **and once on hydration** (carrying which instrument changed) so a late-mounting consumer is never stuck on stale state. State is **in-memory, seeded from `/api/settings` on boot and reset on restart** — a stale "you're in drop-A" assumption is worse than re-asking. Registered as a separate `working-tuning` **exclusive-owner** capability (tuner = writer, the rest = requesters). This is the foundation (plumbing only — nothing writes to it yet) of the working-tuning series, which fixes the tuner gate only ever prompting *away from* a fixed "home" tuning (never back) and makes the current tuning a first-class signal the whole app shares. Offsets use the same per-string semitone vocabulary as song tunings, so fully custom/extended tunings (e.g. a drop-A 8-string) are first-class. Frontend-only: new `static/capabilities/working-tuning.js`, loaded from `static/index.html` + `static/v3/index.html`.
- **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **AZ rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`.
- **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback).
- **Smart collections — save a set of library filters as a live, auto-updating source.** A collection is a saved `/api/library` query (e.g. "Drop-D tunings", "sloppak only", "recently added") that stays live: it's registered as a **library provider**, so it shows up in the v3 Songs source picker and inherits the whole grid UI — paging, stats, the AZ rail, art — for free, with **no new screen**. Storage reuses the playlist subsystem (a `playlists.rules` JSON blob = a smart collection; membership is the live filter result, not stored songs, and collections are excluded from the manual-playlist list + read-only to playlist mutations). New `GET`/`POST`/`PUT`/`DELETE /api/collections`; a per-collection `SmartCollectionProvider` delegates `query_page`/`query_stats`/`query_artists` to the local DB with the stored rules applied; providers are re-registered from a boot scan so collections survive a restart. Rules mirror the raw `/api/library` query params (unknown keys dropped, never 500). Frontend: a " Save as collection" action in the v3 filter drawer (shown when filters are active) names the current filter set and switches to it. The charrette's "the homelab primitive FeedBack was missing" pick (got-feedback/feedBack#636 item 2); richer rule fields (accuracy, genre, difficulty) follow as the metadata work lands. Tests: `tests/test_collections_api.py`, `tests/js/v3_collections.test.js`.
@@ -47,7 +49,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where``query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
### Fixed
- **`audio-input` `open-source` now reports the device the engine *actually* bound, so the wrong-mic case can be caught instead of trusting the pick blind.** A provider's `source.open` handler may return `payload: { boundType, boundName }`; `openInputSource()` (`static/capabilities/audio-session.js`) surfaces it on the command's **return** value as `payload.bound = { type, name }` for the trusted in-process caller, enabling an honest "Now listening to: <device>" readout and detection of a silent substitution (picked BlackHole, got the internal mic). The raw device name is PII, so it is deliberately kept **out** of the emitted `source-opened` event and the diagnostics snapshot (which stay redacted) — mirroring how `list-sources` already returns the device `label` verbatim to the UI but pseudonymizes it in diagnostics. Purely additive: the open-session summary shape is unchanged and `bound` is omitted when the provider reports nothing. Pairs with feedBack-desktop's stable name-based input identity + fail-loud open. Tests: `tests/js/audio_session_input.test.js` (read-back surfaced to the caller, absent from event + snapshot).
- **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`.
- **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`.
- **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible.
+352 -4
View File
@@ -1083,6 +1083,22 @@
const FOCUS_D = 600 * K;
const CAM_LERP_BASE = 0.02;
// Base vertical field of view (deg). THREE's PerspectiveCamera fov is the
// VERTICAL angle; horizontal follows from the aspect ratio. At a normal
// ~16:9 pane this gives a ~102° horizontal cone. On an ultra-wide pane
// (top/bottom 2-player split → full-width/half-height → ~32:9) that
// horizontal cone balloons past 130° and squeezes the fixed-width neck into
// a central sliver. The optional horizontal-FOV-hold path below counters
// that by lowering the effective vertical fov as the pane widens.
const BASE_VFOV = 70;
// Horizontal-FOV-hold ("Hor+") defaults. At/under HORPLUS_START_ASPECT the
// effective vertical fov equals BASE_VFOV (exact no-op); past it the
// vertical fov drops to keep the horizontal cone ~constant so the neck
// fills a wide pane. HORPLUS_MIN_VFOV floors the result on pathological
// aspects. Engaged only via the window.__h3dAspectTune bridge (default off).
const HORPLUS_START_ASPECT = 16 / 9;
const HORPLUS_MIN_VFOV = 28;
// Zoom-dependent framing — height (h*) and depth (dist*) multipliers
// applied to cam.position. Interpolated by `dist`:
// NEAR = tight view (nut position, span<=4 -> dist~=93*K): lower/closer.
@@ -1591,6 +1607,257 @@
ss.isCanvasFocused(highwayCanvas));
}
// A/B toggle for the wide-pane horizontal-FOV-hold. Flips
// window.__h3dAspectTune.enabled so the running app can switch between the
// current framing (off, the baseline) and the Hor+ framing (on) with one
// keypress, across all panes at once. Registered once per session via a
// module-level guard (it toggles a shared global, so per-instance
// registration would stack duplicate handlers and cancel itself out); it's
// a harmless debug control, so it is never unregistered. No-ops where the
// core shortcut API isn't present (older core / borrowed contexts).
let _abShortcutRegistered = false;
function _registerAspectAbShortcut() {
if (_abShortcutRegistered) return;
if (typeof window.registerShortcut !== 'function') return;
_abShortcutRegistered = true;
try {
window.registerShortcut({
key: 'A', // uppercase e.key → produced with Shift held (Shift+A)
description: '3D Highway: toggle wide-pane framing A/B (Shift+A)',
scope: 'player',
handler: () => {
const t = _aspectTune();
t.enabled = !t.enabled;
try { console.log('[h3d] wide-pane framing', t.enabled ? 'ON' : 'OFF'); } catch (e) {}
// Surface the live tuner panel whenever the feature is on,
// hide it when off. Built lazily on first use.
_ensureAspectPanel();
_setAspectPanelVisible(t.enabled);
_syncAspectPanel();
},
});
} catch (e) {
_abShortcutRegistered = false; // allow a later retry if it threw
}
}
// ── Wide-pane framing: live tuner bridge + panel ──────────────────────────
// window.__h3dAspectTune is the single source of truth the renderer reads
// each frame (see effectiveVfov + camUpdate). The defaults reproduce the
// current framing exactly (enabled:false). Values persist to localStorage so
// a tuning session survives reloads; the floating panel (Shift+A) writes the
// same object live. All of this is a debug aid — none of it runs unless the
// user opts in.
// Versioned key: the first iteration shipped a broken default (enabled:true,
// baseVfov:30) and may have persisted it. Bumping the key ignores that stale
// state so the corrected default-off config actually takes effect.
const _ASPECT_LS = 'h3d_aspect_tune2';
// Working defaults. Default OFF, so out of the box this is an exact no-op —
// every pane renders byte-for-byte as before (effectiveVfov returns
// BASE_VFOV and the pose nudges gate off). The config is also coherent when
// a tester turns it ON via Shift+A: baseVfov == BASE_VFOV so normal ~16:9
// panes (single-player, most 2x2) stay at 70° even enabled, and only panes
// wider than startAspect (2.25) engage the Hor+ hold; blend:1 makes that
// hold actually take effect; minVfovDeg (28) sits below baseVfov so the floor
// is a real floor. The pose nudges are the in-progress wide-pane look a
// tester sees once enabled. localStorage overrides all of this per machine.
const _ASPECT_DEFAULTS = {
enabled: false, baseVfov: BASE_VFOV, startAspect: 2.25, hfovDeg: null,
blend: 1, minVfovDeg: HORPLUS_MIN_VFOV, splitOnly: false,
heightMul: 0.30, distMul: 0.95, pitchAdd: -1.5, lookDepthMul: 1,
};
// Slider specs (numeric fields). Checkboxes (enabled/splitOnly) + the hfov
// override are handled separately in the panel builder. Ranges are wide on
// purpose — this is a tuning aid, the no-op default sits mid-range.
const _ASPECT_FIELDS = [
{ k: 'baseVfov', label: 'Base vFOV°', min: 18, max: 90, step: 1 },
{ k: 'startAspect', label: 'Start aspect', min: 1.0, max: 4.0, step: 0.05 },
{ k: 'blend', label: 'Blend', min: 0, max: 1, step: 0.05 },
{ k: 'minVfovDeg', label: 'Min vFOV°', min: 10, max: 60, step: 1 },
{ k: 'heightMul', label: 'Height ×', min: 0.1, max: 2.5, step: 0.05 },
{ k: 'distMul', label: 'Dolly ×', min: 0.2, max: 3.0, step: 0.05 },
{ k: 'pitchAdd', label: 'Pitch +', min: -40, max: 40, step: 0.5 },
// Aims the camera further down the neck (>1) or pulls the aim back (<1).
// This is the lever that flattens the mid-distance "hump" toward a
// straight gradual recede.
{ k: 'lookDepthMul', label: 'Look depth', min: 0.2, max: 3.0, step: 0.05 },
];
let _aspectPanelEl = null; // the floating panel root (built once)
let _aspectPanelRO = null; // readout <div>
let _aspectPanelRAF = 0; // readout poll handle
// Get-or-create the live bridge object, seeded from defaults + localStorage.
function _aspectTune() {
let t = window.__h3dAspectTune;
if (!t || typeof t !== 'object') {
t = Object.assign({}, _ASPECT_DEFAULTS);
try {
const raw = localStorage.getItem(_ASPECT_LS);
if (raw) Object.assign(t, JSON.parse(raw));
} catch (e) {}
window.__h3dAspectTune = t;
}
return t;
}
function _aspectPersist() {
try {
const t = _aspectTune(), out = {};
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t[k]; });
localStorage.setItem(_ASPECT_LS, JSON.stringify(out));
} catch (e) {}
}
function _ensureAspectPanel() {
if (_aspectPanelEl || typeof document === 'undefined') return;
const t = _aspectTune();
const wrap = document.createElement('div');
wrap.id = 'h3d-aspect-tuner';
wrap.style.cssText = [
'position:fixed', 'top:64px', 'right:12px', 'z-index:99999',
'width:230px', 'padding:10px 12px', 'border-radius:8px',
'background:rgba(12,18,28,0.92)', 'border:1px solid rgba(120,150,200,0.35)',
'box-shadow:0 6px 24px rgba(0,0,0,0.5)', 'color:#cfe0f5',
'font:11px/1.35 system-ui,sans-serif', 'user-select:none',
'pointer-events:auto',
].join(';');
const title = document.createElement('div');
title.textContent = 'Wide-pane framing (A/B)';
title.style.cssText = 'font-weight:700;margin-bottom:6px;color:#e8c040;';
wrap.appendChild(title);
// enabled + splitOnly checkboxes
[['enabled', 'Enabled (Shift+A)'], ['splitOnly', 'Split panes only']].forEach(([k, lbl]) => {
const row = document.createElement('label');
row.style.cssText = 'display:flex;align-items:center;gap:6px;margin:2px 0;cursor:pointer;';
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = !!t[k]; cb.dataset.k = k;
cb.addEventListener('change', () => {
_aspectTune()[k] = cb.checked; _aspectPersist();
if (k === 'enabled') _setAspectPanelVisible(cb.checked);
});
const span = document.createElement('span'); span.textContent = lbl;
row.appendChild(cb); row.appendChild(span); wrap.appendChild(row);
});
// numeric sliders
_ASPECT_FIELDS.forEach((f) => {
const row = document.createElement('div');
row.style.cssText = 'margin:5px 0;';
const head = document.createElement('div');
head.style.cssText = 'display:flex;justify-content:space-between;';
const lab = document.createElement('span'); lab.textContent = f.label;
const val = document.createElement('span');
val.style.cssText = 'color:#8fb6ff;font-variant-numeric:tabular-nums;';
head.appendChild(lab); head.appendChild(val); row.appendChild(head);
const sl = document.createElement('input');
sl.type = 'range'; sl.min = f.min; sl.max = f.max; sl.step = f.step;
sl.value = Number.isFinite(t[f.k]) ? t[f.k] : _ASPECT_DEFAULTS[f.k];
sl.dataset.k = f.k;
sl.style.cssText = 'width:100%;';
const show = () => { val.textContent = (+sl.value).toFixed(f.step < 1 ? 2 : 0); };
show();
sl.addEventListener('input', () => {
_aspectTune()[f.k] = parseFloat(sl.value); show(); _aspectPersist();
});
row.appendChild(sl); wrap.appendChild(row);
});
// hfov override (checkbox enables a slider; off → hfovDeg=null = auto)
{
const row = document.createElement('div'); row.style.cssText = 'margin:5px 0;';
const head = document.createElement('label');
head.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;';
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = Number.isFinite(t.hfovDeg);
const lbl = document.createElement('span'); lbl.textContent = 'Override held hFOV°';
head.appendChild(cb); head.appendChild(lbl); row.appendChild(head);
const sl = document.createElement('input');
sl.type = 'range'; sl.min = 40; sl.max = 160; sl.step = 1;
sl.value = Number.isFinite(t.hfovDeg) ? t.hfovDeg : 102;
sl.disabled = !cb.checked;
sl.style.cssText = 'width:100%;';
cb.addEventListener('change', () => {
sl.disabled = !cb.checked;
_aspectTune().hfovDeg = cb.checked ? parseFloat(sl.value) : null;
_aspectPersist();
});
sl.addEventListener('input', () => {
if (cb.checked) { _aspectTune().hfovDeg = parseFloat(sl.value); _aspectPersist(); }
});
row.appendChild(sl); wrap.appendChild(row);
}
// live readout
_aspectPanelRO = document.createElement('div');
_aspectPanelRO.style.cssText = 'margin-top:6px;padding-top:6px;border-top:1px solid rgba(120,150,200,0.25);color:#9fb;font-variant-numeric:tabular-nums;';
_aspectPanelRO.textContent = 'aspect — · vFOV —';
wrap.appendChild(_aspectPanelRO);
// buttons
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex;gap:6px;margin-top:8px;';
const mkBtn = (txt, fn) => {
const b = document.createElement('button');
b.textContent = txt;
b.style.cssText = 'flex:1;padding:4px 0;border-radius:5px;border:1px solid rgba(120,150,200,0.4);background:rgba(40,60,90,0.6);color:#cfe0f5;cursor:pointer;font:11px system-ui;';
b.addEventListener('click', fn);
return b;
};
btnRow.appendChild(mkBtn('Reset', () => {
const t2 = _aspectTune();
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { t2[k] = _ASPECT_DEFAULTS[k]; });
t2.enabled = true; // keep panel up after reset
_aspectPersist(); _syncAspectPanel();
}));
btnRow.appendChild(mkBtn('Copy', () => {
const t2 = _aspectTune(), out = {};
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t2[k]; });
const json = JSON.stringify(out, null, 2);
try { console.log('[h3d] wide-pane framing values:\n' + json); } catch (e) {}
try { if (navigator.clipboard) navigator.clipboard.writeText(json); } catch (e) {}
}));
wrap.appendChild(btnRow);
document.body.appendChild(wrap);
_aspectPanelEl = wrap;
_aspectPanelEl.style.display = 'none';
}
// Push current bridge values back into the panel controls (after Reset or an
// external edit). Cheap; only runs on demand.
function _syncAspectPanel() {
if (!_aspectPanelEl) return;
const t = _aspectTune();
_aspectPanelEl.querySelectorAll('input[type=checkbox][data-k]').forEach((cb) => {
cb.checked = !!t[cb.dataset.k];
});
_aspectPanelEl.querySelectorAll('input[type=range][data-k]').forEach((sl) => {
const k = sl.dataset.k;
if (Number.isFinite(t[k])) sl.value = t[k];
sl.dispatchEvent(new Event('input')); // refresh the value label
});
}
function _setAspectPanelVisible(on) {
_ensureAspectPanel();
if (!_aspectPanelEl) return;
_aspectPanelEl.style.display = on ? 'block' : 'none';
window.__h3dAspectPanelOpen = !!on; // gates the per-frame readout publish
if (on && !_aspectPanelRAF) {
const tick = () => {
if (!window.__h3dAspectPanelOpen) { _aspectPanelRAF = 0; return; }
const ro = window.__h3dAspectReadout;
if (_aspectPanelRO && ro && Number.isFinite(ro.aspect)) {
_aspectPanelRO.textContent =
'aspect ' + ro.aspect.toFixed(2) + ' · vFOV ' + ro.vfov.toFixed(1) + '°';
}
_aspectPanelRAF = requestAnimationFrame(tick);
};
_aspectPanelRAF = requestAnimationFrame(tick);
}
}
/* ======================================================================
* Background animations (issue #13)
*
@@ -3498,6 +3765,11 @@
// that CSS-box drift and re-frame, instead of the user having to
// un/re-maximize the window.
let _appliedW = 0, _appliedH = 0;
// Last pane aspect (w/h) handed to the camera, cached so camUpdate can
// recompute the horizontal-FOV-hold each frame (and react to live
// __h3dAspectTune edits) without waiting for a resize. 0 until first
// applySize().
let _paneAspect = 0;
// True once applySize() has pinned the .h3d-wrap overlay to the
// highway canvas's offset box. Stays false while the canvas has no
// layout yet (init() can run before #highway has a real box, where
@@ -5976,7 +6248,7 @@
scene = new T.Scene();
scene.fog = new T.Fog(0x101820, FOG_START * 0.8, FOG_END * 1.2);
cam = new T.PerspectiveCamera(70, 1, 0.01, FOG_END * 3);
cam = new T.PerspectiveCamera(BASE_VFOV, 1, 0.01, FOG_END * 3);
ambLight = new T.AmbientLight(0xffffff, 0.85);
scene.add(ambLight);
@@ -13915,11 +14187,77 @@
ctx.restore();
}
// Horizontal-FOV-hold ("Hor+"). Returns the vertical fov (deg) the
// camera should use for the given pane aspect. With the bridge off (or
// absent), or at/under the start aspect, it returns the base vertical
// fov unchanged — an exact no-op, so normal panes render identically to
// before. Past the start aspect it lowers the vertical fov to keep the
// horizontal cone ~constant, so the neck fills an ultra-wide pane
// instead of collapsing into a central sliver. Pure + finite-guarded.
function effectiveVfov(aspect, tune) {
const base = (tune && Number.isFinite(tune.baseVfov)) ? tune.baseVfov : BASE_VFOV;
if (!tune || !tune.enabled || !Number.isFinite(aspect) || aspect <= 0) return base;
const start = (Number.isFinite(tune.startAspect) && tune.startAspect > 0)
? tune.startAspect : HORPLUS_START_ASPECT;
if (aspect <= start) return base;
const floor = Number.isFinite(tune.minVfovDeg) ? tune.minVfovDeg : HORPLUS_MIN_VFOV;
const DEG = Math.PI / 180;
// Held horizontal fov: explicit hfovDeg if given, else the horizontal
// cone the base vertical fov produces at the start aspect.
const hfov = (Number.isFinite(tune.hfovDeg) && tune.hfovDeg > 0)
? tune.hfovDeg * DEG
: 2 * Math.atan(Math.tan(base * DEG / 2) * start);
// Vertical fov that reproduces that horizontal cone at this aspect.
let vfov = 2 * Math.atan(Math.tan(hfov / 2) / aspect) / DEG;
const blend = Number.isFinite(tune.blend) ? Math.max(0, Math.min(1, tune.blend)) : 1;
vfov = base + (vfov - base) * blend; // 0 = base, 1 = full Hor+
if (!Number.isFinite(vfov)) return base;
return Math.max(floor, Math.min(base, vfov));
}
/* ── Camera smooth lerp ──────────────────────────────────────────── */
function camUpdate(bundle) {
const bpm = computeBPM(bundle.beats, bundle.currentTime);
const lerp = CAM_LERP_BASE * Math.max(bpm, 60) / 120;
// ── Horizontal-FOV-hold + optional wide-pane pose nudges ──
// Driven by window.__h3dAspectTune (default off → exact no-op).
// _aspectTune() returns the live bridge object, seeded from defaults
// + localStorage on first read so a persisted tuning session applies
// on load without opening the panel. Every field is finite-coerced.
// When disabled (or splitOnly and not in a split) the tune is treated
// as null, so effectiveVfov returns the base vertical fov and cam.fov
// is restored to it. The fov write is guarded on an actual change so
// a steady pane costs nothing.
const _aspTune = _aspectTune();
const _aspActive = !!(_aspTune && _aspTune.enabled
&& !(_aspTune.splitOnly && !_ssActive()));
const _tune = _aspActive ? _aspTune : null;
const _vfov = effectiveVfov(_paneAspect, _tune);
if (Number.isFinite(_vfov) && Math.abs(_vfov - cam.fov) > 1e-4) {
cam.fov = _vfov;
cam.updateProjectionMatrix();
}
// Publish a live readout for the tuner panel (only while it's open,
// so the steady path stays allocation-free). Last pane to render wins
// the slot — fine, all panes share the same aspect in a split layout.
if (window.__h3dAspectPanelOpen) {
const _ro = window.__h3dAspectReadout || (window.__h3dAspectReadout = {});
_ro.aspect = _paneAspect; _ro.vfov = _vfov;
}
// Optional pose nudges (height / dolly / pitch) to chase a low-flat
// wide-pane look if fov alone isn't enough. Gated to wide panes and
// 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);
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;
const _poseLookYAdd = (_wide && Number.isFinite(_tune.pitchAdd)) ? _tune.pitchAdd * K : 0;
const _poseLookZMul = (_wide && Number.isFinite(_tune.lookDepthMul) && _tune.lookDepthMul > 0)
? _tune.lookDepthMul : 1;
curX += (tgtX - curX) * lerp;
// The fret-row fit guard (end of camUpdate) may dolly the camera back
// via _fretRowFitBoost; the span-driven tgtDist still owns zooming IN.
@@ -13935,6 +14273,9 @@
const _dMul = CAM_FRAME_D_NEAR + (CAM_FRAME_D_FAR - CAM_FRAME_D_NEAR) * _zt;
const shoulderOffset = (_leftyCached ? -1 : 1) * 10 * K;
let _camX = curX + shoulderOffset, _camY = h * _hMul, _camZ = dist * _dMul;
// Optional wide-pane pose nudges (default identity → no-op).
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.
// Layered ON TOP of the auto-framing so note tracking still works.
@@ -13943,7 +14284,7 @@
// finite number before use so a malformed object can never feed NaN
// into cam.position / cam.lookAt.
const _freeCam = window.__h3dCamCtl;
const _lookAtZ = -FOCUS_D * 0.35;
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
if (_freeCam && _freeCam.enabled) {
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
const _heightMul = Number.isFinite(_freeCam.heightMul) ? _freeCam.heightMul : 1;
@@ -13964,7 +14305,7 @@
// This lets the camera adapt to any panel aspect ratio automatically.
const fretMidY = (sY(0) + sY(nStr - 1)) / 2;
_probe.set(curX, fretMidY, 0); // play-line fretboard centre
cam.lookAt(curX, curLookY, -FOCUS_D * 0.35); // tentative look — needed for project()
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ); // tentative look — needed for project()
cam.updateMatrixWorld();
_probe.project(cam); // _probe.y → NDC in [-1, 1]
@@ -13993,7 +14334,7 @@
const _pitch = Number.isFinite(_freeCam.pitch) ? _freeCam.pitch : 0;
cam.lookAt(curX + _panX * K, curLookY + (_pitch + _panY) * K, _lookAtZ);
} else {
cam.lookAt(curX, curLookY, _lookAtZ);
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ);
}
// ── Fret-row fit guard ────────────────────────────────────────────
@@ -14090,6 +14431,10 @@
cam.aspect = w / h;
cam.updateProjectionMatrix();
aspectScale = Math.max(1, REF_ASPECT / Math.max(cam.aspect, 0.5));
// Cache the pane aspect for the horizontal-FOV-hold in camUpdate.
// cam.fov itself is owned by camUpdate (not set here) so live
// __h3dAspectTune edits apply every frame without a resize.
_paneAspect = cam.aspect;
_appliedW = w; _appliedH = h;
}
@@ -14333,6 +14678,7 @@
}
_destroyed = _isReady = false;
_isFocused = true;
_registerAspectAbShortcut(); // session-global A/B toggle (self-guarded)
const myToken = ++_initToken;
highwayCanvas = canvas;
_invertedCached = !!(bundle && bundle.inverted);
@@ -14751,6 +15097,8 @@
_destroyed = true; _isReady = false; _diagChord = null; _diagPrev = null; _diagLastKey = null; _diagRenderCache.clear();
_lastHwW = 0; _lastHwH = 0;
_appliedW = 0; _appliedH = 0;
_paneAspect = 0;
if (cam && cam.fov !== BASE_VFOV) { cam.fov = BASE_VFOV; cam.updateProjectionMatrix(); }
_wrapPinned = false;
_unsubscribeFocus(); teardown();
highwayCanvas = null;
+51 -3
View File
@@ -7842,15 +7842,63 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
if 0 <= arrangement < len(song.arrangements):
best = arrangement
else:
# Check user's default arrangement preference
# Read the user's config once: their selected instrument (route the chart
# to the matching part) and their default-arrangement preference.
pref = ""
sel_instrument = ""
config_file = CONFIG_DIR / "config.json"
if config_file.exists():
try:
pref = json.loads(config_file.read_text(encoding="utf-8")).get("default_arrangement", "")
_cfg = json.loads(config_file.read_text(encoding="utf-8"))
pref = _cfg.get("default_arrangement", "")
sel_instrument = (_cfg.get("instrument", "") or "")
except Exception:
pass
if pref:
# Instrument routing: load the part that matches the selected instrument so
# "your instrument" and "the chart you play" line up. The default ordering
# is Lead/guitar-first, so without this a bass player gets handed a guitar
# chart (and any tune-check then compares a 4-string bass against a 6-string
# part). Currently routes bass -> a Bass arrangement; guitar — and any
# unknown/future instrument (drums, keys) — falls through to the
# preference/most-notes logic below, which already lands on a guitar part.
# Drums/keys get their own match when those arrangement types + selector
# entries land. Only applies when no explicit arrangement was requested, so
# a manual arrangement switch is always respected.
if sel_instrument.lower() == "bass":
# Candidate bass parts, preferring the structured pathBass flag; the
# normalized smart name (itself pathBass-derived) and raw name are
# fallbacks for sources without the flag.
bass_idxs = [
i
for i, a in enumerate(song.arrangements)
if getattr(a, "path_bass", False)
or (smart_names[i] or "").lower().startswith("bass")
or "bass" in (getattr(a, "name", "") or "").lower()
]
if bass_idxs:
# Among the bass parts: (1) honor the saved default-arrangement
# preference if it names one of them (so a bass player who prefers
# "Bass 2"/"Alt. Bass" keeps it), (2) else the canonical main "Bass",
# (3) else the first bass part in order.
pref_bass = -1
if pref:
for i in bass_idxs:
nm = (smart_names[i] if naming_mode == "smart" and i < len(smart_names)
else getattr(song.arrangements[i], "name", ""))
if nm == pref:
pref_bass = i
break
if pref_bass >= 0:
best = pref_bass
else:
best = next(
(i for i in bass_idxs
if (smart_names[i] if i < len(smart_names) else "") == "Bass"),
bass_idxs[0],
)
# User's default arrangement preference (only when instrument routing did not
# already resolve a part — i.e. guitar, or a bass player with no bass part).
if best < 0 and pref:
if naming_mode == "smart":
best = _pick_smart_arrangement(song.arrangements, smart_names, pref)
else:
+1 -11
View File
@@ -1766,16 +1766,6 @@
const providerResult = _providerOutcome(raw);
openSession.state = providerResult.outcome === 'handled' ? 'open' : (providerResult.status || providerResult.outcome);
openSession.reason = providerResult.reason;
// Read-back: the provider (desktop renderer) reports which device the
// native engine ACTUALLY bound. Surface it to the in-process caller so
// the wizard can show "Now listening to: <device>" and catch a silent
// mismatch (picked BlackHole, got the internal mic). Kept OUT of the
// redacted summary/event below: that flows into diagnostics, where a raw
// device name (e.g. "Byron's AirPods") is PII — but it is fine to return
// verbatim to the trusted same-renderer caller, exactly as list-sources
// already returns the device `label` verbatim.
const boundInfo = _plainObject(providerResult.payload);
const bound = { type: _string(boundInfo.boundType, ''), name: _string(boundInfo.boundName, '') };
if (providerResult.outcome === 'handled') {
openSession.openedAt = _now();
currentSession.openInputSessions.set(key, openSession);
@@ -1783,7 +1773,7 @@
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, openSessionId: openSession.openSessionId, outcome: 'handled', status: 'open' });
capabilities.emitEvent('audio-input', 'source-opened', summary);
_touch();
return _handled((bound.name || bound.type) ? { ...summary, bound } : summary);
return _handled(summary);
}
const summary = _redactedOpenSession(openSession, _newPseudonymizer());
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, openSessionId: openSession.openSessionId, outcome: providerResult.outcome, status: openSession.state, reason: providerResult.reason });
+315
View File
@@ -0,0 +1,315 @@
// Core "working tuning" capability domain — the live, host-authoritative CURRENT
// instrument tuning (session state), distinct from the soft opt-in default and from
// any one song's tuning. This is the single source of truth the whole app reads:
// the highway, the library/song-picker, Virtuoso, and the minigames all consult it,
// and the tuner is the sole WRITER (it updates this when the player retunes, clears
// the gate, or switches instruments).
//
// PER-INSTRUMENT: a player has separate physical instruments, each in its OWN tuning
// ("I'm not tuning two instruments when I pick a song"). So state is a MAP keyed by
// instrument — `${instrument}-${stringCount}` (e.g. "guitar-6", "bass-4"), the same key
// the v3 instrument selector uses. `get()` returns the CURRENTLY-SELECTED instrument's
// tuning; switching the selector surfaces that instrument's own remembered tuning. You
// only ever deal with the one you've picked.
//
// Design: WORKING-TUNING-STATE-DESIGN.md (host-first PR series, PR 1 = this file).
// Pattern mirrors `capabilities/tuning.js` (capability registration) + the host theme
// read-API (`window.feedBack.theme`): a synchronous `get()` plus a `working-tuning-
// changed` event that also fires once on hydration.
//
// State is IN-MEMORY and NOT persisted — reset-to-home on restart is deliberate (a
// stale "you're in drop-A" assumption is worse than re-asking). The opt-in "default
// tuning on app open" lands later; for now we seed the selected instrument from
// /api/settings.
//
// PR 1 is PURE PLUMBING: it introduces the state + read/write surface + event, but
// nothing writes to it yet and no behavior changes. The tuner becomes the writer (and
// the gate's E->C# asymmetry is fixed) in a later PR.
(function () {
'use strict';
window.feedBack = window.feedBack || {};
const capabilities = window.feedBack.capabilities;
const _byInstrument = {}; // key -> tuning state (the per-instrument map)
let _currentKey = null; // the selected instrument's key; cached so get() is sync
let _hydrated = false;
let _touched = false; // set once anything explicitly writes/selects; gates the async seed
function _normInstrument(instrument) {
return instrument === 'bass' ? 'bass' : 'guitar';
}
function _keyOf(instrument, stringCount) {
const inst = _normInstrument(instrument);
const sc = Number(stringCount) || (inst === 'bass' ? 4 : 6);
return inst + '-' + sc;
}
// Like _keyOf, but when the caller omits a string count we resolve it against the
// current selection (if it's the same instrument) before falling back to the
// per-instrument default — so `set({instrument:'bass'})` targets the selected
// bass-5, not a hard-coded bass-4.
function _keyOfResolved(instrument, stringCount) {
const inst = _normInstrument(instrument);
let sc = Number(stringCount);
if (!sc) {
if (_currentKey) {
const cur = _splitKey(_currentKey);
if (cur.instrument === inst) sc = cur.stringCount;
}
if (!sc) sc = (inst === 'bass' ? 4 : 6);
}
return inst + '-' + sc;
}
function _splitKey(key) {
const parts = (typeof key === 'string' ? key : '').split('-');
const inst = parts[0] === 'bass' ? 'bass' : 'guitar';
return { instrument: inst, stringCount: Number(parts[1]) || (inst === 'bass' ? 4 : 6) };
}
// The shape every consumer reads. `offsets` are per-string semitone offsets from
// standard (same vocabulary as song_info.tuning and /api/tunings); `instrument`
// disambiguates the open-string base so offsets resolve to real pitches. A drop-A
// 8-string is just an offsets array — fully custom tunings are first-class.
// `provenance` is the honesty flag: 'verified' means the tuner did a choreographed
// per-string mic check this session; everything else is 'assumed'.
function _defaultState(key) {
const id = _splitKey(key);
return {
offsets: null,
stringCount: id.stringCount,
instrument: id.instrument,
referencePitch: 440,
provenance: 'assumed',
verifiedStrings: null,
verifiedAt: null,
source: 'default',
};
}
// Resolve which instrument key a get/set targets: an explicit arg wins (a string
// key "guitar-6", a bare "guitar"/"bass", or { instrument, stringCount }); else the
// cached current selection.
function _resolveKey(instrument) {
if (instrument && typeof instrument === 'object') return _keyOfResolved(instrument.instrument, instrument.stringCount);
if (typeof instrument === 'string' && instrument) {
return instrument.indexOf('-') > 0 ? instrument : _keyOfResolved(instrument, null);
}
return _currentKey || _keyOf('guitar', 6);
}
// Synchronous read of an instrument's current tuning (default = selected
// instrument). Returns a deep-enough copy — the object plus its mutable array
// fields (`offsets`, `verifiedStrings`) — so a reader can't mutate the live state.
function get(instrument) {
const key = _resolveKey(instrument);
const state = Object.assign(_defaultState(key), _byInstrument[key] || {});
if (Array.isArray(state.offsets)) state.offsets = state.offsets.slice();
if (Array.isArray(state.verifiedStrings)) state.verifiedStrings = state.verifiedStrings.slice();
return state;
}
function _emitChanged(key) {
if (window.feedBack && typeof window.feedBack.emit === 'function') {
window.feedBack.emit('working-tuning-changed', { key: key, instrument: _splitKey(key).instrument, tuning: get(key) });
}
}
// The single mutator. The tuner calls this on retune / gate-clear / swap. Writes to
// the instrument the state targets (opts.instrument, or next.instrument+stringCount,
// or the current selection) and makes that the active instrument. `opts.provenance`
// stamps 'verified' (mic-confirmed) vs the default 'assumed'. Changing the tuning
// invalidates a prior verification unless fresh verifiedStrings are supplied — fail
// toward "assumed".
function set(next, opts) {
opts = opts || {};
next = next || {};
// Resolve the target key. An explicit opts.instrument wins; otherwise a
// next.instrument/next.stringCount targets that slot — but a bare stringCount
// (no instrument) applies to the CURRENTLY-SELECTED instrument, not a hard-coded
// guitar, so `set({stringCount:5})` on a selected bass writes bass-5.
let key;
if (opts.instrument) {
key = _resolveKey(opts.instrument);
} else if (next.instrument || next.stringCount) {
const inst = next.instrument ? _normInstrument(next.instrument)
: (_currentKey ? _splitKey(_currentKey).instrument : 'guitar');
key = _keyOfResolved(inst, next.stringCount);
} else {
key = _currentKey || _resolveKey();
}
const id = _splitKey(key);
const merged = Object.assign(get(key), next); // get() gives copies, so `merged` is ours to mutate
merged.instrument = id.instrument; // keep coherent with the key
merged.stringCount = id.stringCount; // the key is authoritative for string count
const tuningChanged = ('offsets' in next) || ('stringCount' in next) || ('referencePitch' in next);
// Provenance: explicit opts wins; a bare tuning change downgrades to 'assumed'.
if (opts.provenance) {
merged.provenance = opts.provenance;
} else if (tuningChanged) {
merged.provenance = 'assumed';
}
// Verification metadata is coherent by construction: a tuning change invalidates
// prior per-string verification unless the caller supplies a fresh bundle, and the
// metadata exists ONLY while provenance === 'verified'. So verified <=> we hold
// verifiedStrings — a "verified with no strings" state is impossible.
if (!('verifiedStrings' in next) && tuningChanged) {
merged.verifiedStrings = null;
}
if (merged.provenance === 'verified' && !Array.isArray(merged.verifiedStrings)) {
merged.provenance = 'assumed'; // claimed verified but no evidence — fail toward assumed
}
if (merged.provenance === 'verified') {
// verified always carries a real timestamp — a caller-supplied null/NaN/absent
// verifiedAt is stamped now, so 'verified' can never mean "at no known time".
if (typeof merged.verifiedAt !== 'number' || !isFinite(merged.verifiedAt)) {
merged.verifiedAt = Date.now();
}
} else {
merged.verifiedStrings = null;
merged.verifiedAt = null;
}
// Store copies of the mutable arrays so a caller can't mutate live state post-set.
if (Array.isArray(merged.offsets)) merged.offsets = merged.offsets.slice();
if (Array.isArray(merged.verifiedStrings)) merged.verifiedStrings = merged.verifiedStrings.slice();
_byInstrument[key] = merged;
_currentKey = key; // writing a tuning makes that instrument the active one
_touched = true; // an explicit write must not be clobbered by the async seed
_emitChanged(key);
return get(key);
}
// Tell the host which instrument is now selected (the v3 selector calls this when
// the player switches guitar<->bass / string count) so get() returns the right
// instrument's tuning. Emits if the selection actually changed.
function setCurrentInstrument(instrument, stringCount) {
const key = (typeof instrument === 'string' && instrument.indexOf('-') > 0) ? instrument : _keyOfResolved(instrument, stringCount);
_touched = true; // an explicit selection must not be reverted by the async seed
if (key === _currentKey) return get(key);
_currentKey = key;
_emitChanged(key);
return get(key);
}
// Reset an instrument's live tuning back to its baseline (the home/default).
function resetToDefault(instrument) {
const key = _resolveKey(instrument);
_byInstrument[key] = _defaultState(key);
_touched = true;
_emitChanged(key);
return get(key);
}
// Per-string semitone offsets of a named tuning relative to Standard, derived from
// the /api/tunings frequency tables. The reference pitch cancels in the ratio, so
// this is pitch-independent. Returns null if either row is missing/mismatched.
function _offsetsFromFreqs(named, standard) {
if (!Array.isArray(named) || !Array.isArray(standard) || named.length !== standard.length) return null;
const out = [];
for (let i = 0; i < named.length; i++) {
const a = Number(named[i]);
const b = Number(standard[i]);
if (!(a > 0) || !(b > 0)) return null;
out.push(Math.round(12 * Math.log2(a / b)));
}
return out;
}
// Seed the SELECTED instrument's slot from settings on boot (best-effort 'assumed'
// starting point, NOT a persisted working tuning). settings.tuning may be an offsets
// list OR a name ("Drop D") — a name is resolved to offsets via /api/tunings so a
// named tuning isn't lost. If settings can't be read we still hydrate so consumers
// aren't stuck waiting; an explicit set()/select before we resolve wins (no clobber).
function _seedFromSettings() {
fetch('/api/settings')
.then(function (r) { return r && r.ok ? r.json() : null; })
.then(function (s) {
if (!s || _touched) return; // nothing to seed, or a consumer already wrote — don't clobber
const inst = _normInstrument(s.instrument);
const sc = Number(s.string_count) || (inst === 'bass' ? 4 : 6);
const key = _keyOf(inst, sc);
function commit(offsets) {
if (_touched) return; // re-check: a write may have raced the /api/tunings fetch
_currentKey = key;
_byInstrument[key] = {
offsets: Array.isArray(offsets) ? offsets.slice(0, sc) : null,
stringCount: sc,
instrument: inst,
referencePitch: Number(s.reference_pitch) || 440,
provenance: 'assumed',
verifiedStrings: null,
verifiedAt: null,
source: 'settings',
};
}
if (Array.isArray(s.tuning)) { commit(s.tuning); return; }
if (typeof s.tuning === 'string' && s.tuning) {
return fetch('/api/tunings')
.then(function (r) { return r && r.ok ? r.json() : null; })
.then(function (t) {
const byName = t && t[key];
commit(byName ? _offsetsFromFreqs(byName[s.tuning], byName.Standard) : null);
})
.catch(function () { commit(null); });
}
commit(null);
})
.catch(function () { /* keep defaults */ })
.then(function () { _hydrate(); });
}
function _hydrate() {
if (_hydrated) return;
_hydrated = true;
_emitChanged(_currentKey || _resolveKey());
}
// ---- Capability registration (mirrors capabilities/tuning.js) ----------------
if (capabilities && capabilities.version === 1 &&
!(window.feedBack.workingTuning && window.feedBack.workingTuning.version === 1)) {
capabilities.registerOwner('working-tuning', {
description: 'The live, host-authoritative current instrument tuning (session state), per ' +
'instrument: offsets + string-count + reference pitch + assumed/verified provenance. ' +
'Written by the tuner, read by the highway/library/Virtuoso/minigames.',
operations: ['get-working-tuning', 'set-working-tuning'],
events: ['working-tuning-changed'],
kind: 'command',
ownership: 'exclusive-owner',
});
capabilities.registerParticipant('plugin.tuner', {
'working-tuning': {
roles: ['contributor', 'requester'],
operations: ['get-working-tuning', 'set-working-tuning'],
emits: ['working-tuning-changed'],
mode: 'active',
compatibility: 'none',
safety: 'safe',
},
});
capabilities.registerParticipant('core.settings.instruments', {
'working-tuning': {
roles: ['requester'],
operations: ['get-working-tuning'],
events: ['working-tuning-changed'],
mode: 'active',
compatibility: 'none',
safety: 'safe',
},
});
}
// ---- Public read/write surface (attached defensively, like feedBack.theme) ----
window.feedBack.workingTuning = Object.freeze({
version: 1,
get: get,
set: set,
setCurrentInstrument: setCurrentInstrument,
resetToDefault: resetToDefault,
});
_seedFromSettings();
})();
+1
View File
@@ -24,6 +24,7 @@
<script src="/static/capabilities.js"></script>
<script src="/static/capabilities/library.js"></script>
<script src="/static/capabilities/tuning.js"></script>
<script src="/static/capabilities/working-tuning.js"></script>
<script src="/static/capabilities/audio-session.js"></script>
<script src="/static/capabilities/audio-effects.js"></script>
<script src="/static/capabilities/playback.js"></script>
+16 -3
View File
@@ -87,6 +87,7 @@
<script src="/static/capabilities.js"></script>
<script src="/static/capabilities/library.js"></script>
<script src="/static/capabilities/tuning.js"></script>
<script src="/static/capabilities/working-tuning.js"></script>
<script src="/static/capabilities/audio-session.js"></script>
<script src="/static/capabilities/audio-effects.js"></script>
<script src="/static/capabilities/playback.js"></script>
@@ -855,9 +856,12 @@
<div id="v3-live-performance-state" class="v3-live-performance-state" aria-hidden="true"></div>
</div>
<div id="v3-upnext" class="v3-upnext hidden">
<span class="text-gray-400">Up Next:</span>
<span id="v3-upnext-name" class="v3-upnext-name"></span>
<span id="v3-upnext-eta" class="text-gray-400 text-xs"></span>
<div class="v3-upnext-row">
<span class="text-gray-400">Up Next:</span>
<span id="v3-upnext-name" class="v3-upnext-name"></span>
<span id="v3-upnext-eta" class="text-gray-400 text-xs"></span>
</div>
<div class="v3-upnext-bar"><div id="v3-upnext-bar-fill" class="v3-upnext-bar-fill"></div></div>
</div>
</div>
</div>
@@ -923,6 +927,15 @@
<option value="0.5">Low</option>
</select>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label" id="min-scale-label">Min res</span>
<select id="min-scale-select" onchange="highway.setMinRenderScale && highway.setMinRenderScale(parseFloat(this.value))" class="v3-pop-select" aria-labelledby="min-scale-label" title="Minimum auto resolution — how far the highway may lower its resolution to hold the frame rate on heavy scenes. 'Full' disables auto-downscaling, but the Quality selector still caps the maximum (so it's only full resolution at Quality = HD).">
<option value="0.25">25%</option>
<option value="0.5">50%</option>
<option value="0.75">75%</option>
<option value="1">Full</option>
</select>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label" id="scoreboard-label">Scoreboard</span>
<select id="scoreboard-select" onchange="setScoreboard(this.value)" class="v3-pop-select" aria-labelledby="scoreboard-label" title="Highway scoreboard">
+13
View File
@@ -187,6 +187,19 @@
const nm = $('v3-upnext-name'), eta = $('v3-upnext-eta');
if (nm) nm.textContent = next.name || '—';
if (eta) eta.textContent = 'in ' + dt.toFixed(1) + 's';
// Progress bar: fraction of the current section elapsed toward `next`.
// Previous boundary is the last section at/before now (else song start).
const fill = $('v3-upnext-bar-fill');
if (fill) {
let prevT = 0;
for (let i = 0; i < secs.length; i++) {
if (typeof secs[i].time === 'number' && secs[i].time <= t) prevT = secs[i].time;
else break;
}
const span = next.time - prevT;
const prog = span > 0 ? Math.max(0, Math.min(1, (t - prevT) / span)) : 0;
fill.style.width = (prog * 100).toFixed(1) + '%';
}
pill.classList.remove('hidden');
}
+23 -2
View File
@@ -340,8 +340,9 @@ input, textarea, select,
/* — Up Next pill (top-right, persistent) — */
#player-hud .v3-upnext {
display: flex;
align-items: center;
gap: .5rem;
flex-direction: column;
align-items: stretch;
gap: .35rem;
padding: .45rem .9rem;
border-radius: .75rem;
background: rgba(15, 23, 42, .7);
@@ -351,6 +352,26 @@ input, textarea, select,
pointer-events: auto;
}
#player-hud .v3-upnext.hidden { display: none; }
/* Text row keeps the original inline layout untouched. */
#player-hud .v3-upnext .v3-upnext-row {
display: flex;
align-items: center;
gap: .5rem;
}
/* Progress bar under the text — fills as the current section elapses. */
#player-hud .v3-upnext .v3-upnext-bar {
height: 4px;
border-radius: 999px;
background: rgba(148, 163, 184, .25);
overflow: hidden;
}
#player-hud .v3-upnext .v3-upnext-bar-fill {
height: 100%;
width: 0%;
border-radius: inherit;
background: linear-gradient(90deg, #22d3ee, #a855f7, #f472b6);
transition: width .12s linear;
}
/* — Live performance HUD (top-right, read-only) — */
.v3-live-performance-hud {
-29
View File
@@ -281,35 +281,6 @@ test('open-source and close-source record outcomes events and no live handles',
assert.equal(encoded.includes('token=abc'), false);
});
test('open-source returns the actually-bound device for read-back without leaking it into diagnostics', async () => {
const window = loadAudioSession();
const api = window.feedBack.capabilities;
const openedEvents = captureEvents(window, 'audio-input:source-opened');
await registerSource(api, {
sourceId: 'readback-source',
logicalSourceKey: 'test:readback',
operationHandlers: {
'source.open': () => ({ outcome: 'handled', status: 'open', payload: { boundType: 'CoreAudio', boundName: 'BlackHole 16ch', requestedName: 'BlackHole 16ch' } }),
'source.close': () => ({ outcome: 'handled', status: 'closed' }),
},
});
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:readback' } });
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', purpose: 'note-detection' } });
// The trusted in-process caller (input_setup's confirmation gate) gets the real bound device,
// so it can show "Now listening to: <device>" and catch a silent wrong-mic substitution.
assert.equal(open.outcome, 'handled');
assert.equal(open.payload.bound.type, 'CoreAudio');
assert.equal(open.payload.bound.name, 'BlackHole 16ch');
// ...but a raw device name is PII: it must NOT reach the emitted event or the diagnostics snapshot.
assert.equal(openedEvents.length, 1);
assert.equal('bound' in openedEvents[0], false);
const encoded = JSON.stringify(window.feedBack.audioSession.snapshot());
assert.equal(encoded.includes('BlackHole'), false);
});
test('open-source reports no-owner no-handler unsupported failed and malformed provider data distinctly', async () => {
const window = loadAudioSession();
const api = window.feedBack.capabilities;
+174
View File
@@ -0,0 +1,174 @@
// Pins the wide-pane horizontal-FOV-hold ("Hor+") framing in
// plugins/highway_3d/screen.js.
//
// What it guards: ultra-wide panes (top/bottom 2-player split → full-width /
// half-height → ~32:9) used to render the neck as a thin central sliver because
// THREE's PerspectiveCamera fov is VERTICAL and was locked at 70°, ballooning
// the horizontal cone past 130°. The fix lets camUpdate lower the effective
// vertical fov as the pane widens (holding the horizontal cone ~constant) so the
// neck fills the pane. It is gated behind window.__h3dAspectTune (default off →
// byte-for-byte the prior behaviour) for live A/B comparison.
//
// A refactor that re-hardcodes the camera fov, drops the change-guarded cam.fov
// write, stops caching the pane aspect, or removes the no-op-at-startAspect
// guarantee would silently regress the feature (or worse, change normal-pane
// framing). These are source-level pins — same strategy as the other
// tests/js/ files (no DOM / WebGL in CI).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// ── Constants ────────────────────────────────────────────────────────────────
test('BASE_VFOV is a named constant (not a literal in the camera ctor)', () => {
assert.match(
src,
/const\s+BASE_VFOV\s*=\s*70\s*;/,
'BASE_VFOV must be declared as a constant',
);
});
test('the camera is constructed with BASE_VFOV, not a bare 70', () => {
assert.match(
src,
/new\s+T\.PerspectiveCamera\(\s*BASE_VFOV\s*,/,
'PerspectiveCamera must take BASE_VFOV as its vertical fov',
);
});
test('the Hor+ start-aspect and min-vfov defaults exist', () => {
assert.match(src, /const\s+HORPLUS_START_ASPECT\s*=\s*16\s*\/\s*9\s*;/,
'HORPLUS_START_ASPECT must default to 16/9 (no-op at/under the reference aspect)');
assert.match(src, /const\s+HORPLUS_MIN_VFOV\s*=\s*\d+\s*;/,
'HORPLUS_MIN_VFOV floor must be declared');
});
// ── effectiveVfov: no-op guarantees ──────────────────────────────────────────
test('effectiveVfov returns the base fov when the bridge is off/absent', () => {
// The disabled / malformed-input guard returns `base` before any Hor+ math,
// so normal panes are unaffected when __h3dAspectTune is missing or off.
assert.match(
src,
/function\s+effectiveVfov\s*\(\s*aspect\s*,\s*tune\s*\)\s*\{[\s\S]*?if\s*\(\s*!tune\s*\|\|\s*!tune\.enabled[\s\S]*?return\s+base\s*;/,
'effectiveVfov must short-circuit to the base fov when disabled',
);
});
test('effectiveVfov is a no-op at/under the start aspect', () => {
assert.match(
src,
/if\s*\(\s*aspect\s*<=\s*start\s*\)\s*return\s+base\s*;/,
'effectiveVfov must return base when aspect <= start (no-op for normal/2x2 panes)',
);
});
// ── shipped defaults: off + coherent ─────────────────────────────────────────
// The "default off → byte-for-byte prior behaviour" contract only holds if the
// shipped _ASPECT_DEFAULTS actually ship disabled with a base that matches the
// camera's constructed fov. A previous revision shipped enabled:true with
// baseVfov:30 (and blend:0), which forced every pane's fov to 30/36 and
// silently re-framed normal single-player panes. These pin against that.
test('_ASPECT_DEFAULTS ships disabled (no-op out of the box)', () => {
assert.match(
src,
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\benabled\s*:\s*false\b/,
'_ASPECT_DEFAULTS.enabled must default to false so the feature is opt-in',
);
});
test('the default base fov matches BASE_VFOV (enabling is still a no-op on normal panes)', () => {
// baseVfov === BASE_VFOV means even with the feature ON, a <=startAspect pane
// returns the unchanged 70° — the effect is confined to genuinely wide panes.
assert.match(
src,
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bbaseVfov\s*:\s*BASE_VFOV\b/,
'_ASPECT_DEFAULTS.baseVfov must default to BASE_VFOV, not a divergent literal',
);
});
test('the default blend engages the hold and the floor sits below the base', () => {
// blend:1 means turning the feature on actually holds the horizontal cone
// (blend:0 would collapse effectiveVfov back to base = feature inert), and
// minVfovDeg:HORPLUS_MIN_VFOV keeps the floor below baseVfov (a real floor,
// not one that clamps the base upward).
assert.match(
src,
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bblend\s*:\s*1\b/,
'_ASPECT_DEFAULTS.blend must default to 1 so the Hor+ hold actually applies when enabled',
);
assert.match(
src,
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bminVfovDeg\s*:\s*HORPLUS_MIN_VFOV\b/,
'_ASPECT_DEFAULTS.minVfovDeg must default to HORPLUS_MIN_VFOV (a floor below baseVfov)',
);
});
// ── camUpdate: change-guarded fov write + cached aspect ───────────────────────
test('applySize caches the pane aspect for camUpdate', () => {
assert.match(
src,
/_paneAspect\s*=\s*cam\.aspect\s*;/,
'applySize must cache cam.aspect into _paneAspect',
);
});
test('camUpdate reads the live tune bridge and respects splitOnly', () => {
assert.match(
src,
/const\s+_aspTune\s*=\s*_aspectTune\(\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/,
'camUpdate must read the bridge via _aspectTune() and gate splitOnly on _ssActive()',
);
});
test('the tune bridge seeds from localStorage (persisted sessions apply on load)', () => {
assert.match(
src,
/function\s+_aspectTune\s*\(\)[\s\S]*?localStorage\.getItem\(\s*_ASPECT_LS\s*\)/,
'_aspectTune() must seed the bridge from localStorage',
);
});
test('a floating tuner panel is built and toggled with the A/B state', () => {
assert.match(src, /function\s+_ensureAspectPanel\s*\(\)/,
'_ensureAspectPanel() must exist to build the live panel');
assert.match(src, /function\s+_setAspectPanelVisible\s*\(/,
'_setAspectPanelVisible() must show/hide the panel with the feature');
});
test('camUpdate only writes cam.fov when it actually changes', () => {
// Guarding the write avoids a per-frame updateProjectionMatrix on a steady
// pane and keeps the disabled path free.
assert.match(
src,
/Math\.abs\(\s*_vfov\s*-\s*cam\.fov\s*\)\s*>\s*1e-4[\s\S]*?cam\.fov\s*=\s*_vfov\s*;[\s\S]*?cam\.updateProjectionMatrix\(\)/,
'camUpdate must guard the cam.fov write behind a change check',
);
});
// ── A/B toggle + lifecycle reset ──────────────────────────────────────────────
test('an A/B toggle shortcut flips the tune enabled flag', () => {
assert.match(
src,
/registerShortcut\(\{[\s\S]*?const\s+t\s*=\s*_aspectTune\(\)\s*;[\s\S]*?t\.enabled\s*=\s*!\s*t\.enabled/,
'a registerShortcut handler must toggle the bridge enabled flag',
);
});
test('destroy() resets the pane aspect and restores the base fov', () => {
assert.match(src, /_paneAspect\s*=\s*0\s*;/,
'destroy() must reset _paneAspect to 0');
assert.match(
src,
/cam\.fov\s*!==\s*BASE_VFOV[\s\S]*?cam\.fov\s*=\s*BASE_VFOV\s*;\s*cam\.updateProjectionMatrix\(\)/,
'destroy() must restore cam.fov to BASE_VFOV for instance reuse',
);
});
+206
View File
@@ -0,0 +1,206 @@
// Behavioral harness for the host `window.feedBack.workingTuning` capability
// (static/capabilities/working-tuning.js) — the per-instrument, in-memory current
// tuning. Runs the real capability in a stubbed window (same strategy as
// midi_input_domain.test.js) with a controllable fetch, and asserts the per-instrument
// state machine: isolated guitar/bass slots, selector switch, defensive copies, the
// provenance/verification invariant, unambiguous key routing, named-tuning seeding, and
// the boot-race guard.
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 { createWindow, ROOT } = require('./capabilities_test_harness');
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
const WORKING_TUNING_JS = path.join(ROOT, 'static', 'capabilities', 'working-tuning.js');
// A /api/tunings-shaped fixture (frequencies at 440), enough to resolve names to offsets.
const TUNINGS = {
'guitar-6': {
Standard: [82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
'Drop D': [73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
},
'bass-5': {
Standard: [30.87, 41.20, 55.00, 73.42, 98.00],
},
};
function deferred() {
let resolve;
const promise = new Promise((r) => { resolve = r; });
return { promise, resolve };
}
// `routes` maps a URL to: a plain JSON value (served as {ok:true}), a thenable that
// resolves to a full response object (for deferral/races), or nothing (served {ok:false}).
function loadWorkingTuning(routes = {}) {
const window = createWindow();
const changes = [];
window.fetch = function (url) {
const entry = routes[url];
if (entry && typeof entry.then === 'function') return entry;
if (entry !== undefined) return Promise.resolve({ ok: true, json: () => Promise.resolve(entry) });
return Promise.resolve({ ok: false, json: () => Promise.resolve(null) });
};
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
vm.runInContext(fs.readFileSync(WORKING_TUNING_JS, 'utf8'), context, { filename: WORKING_TUNING_JS });
// capabilities.js replaces window.feedBack with an EventTarget bus — subscribe on it,
// not on window. Attaching after load still catches the async hydration event.
window.feedBack.on('working-tuning-changed', (ev) => changes.push(ev.detail));
return { window, wt: window.feedBack.workingTuning, changes };
}
// Rebase a possibly-vm-realm array into this realm so deepStrictEqual compares by value,
// not by (cross-realm) Array.prototype identity.
const nums = (a) => (a == null ? a : Array.from(a));
// Drain the seed's fetch/promise chain (settings -> tunings -> hydrate).
const flush = async () => { for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r)); };
test('registers a working-tuning exclusive-owner capability + versioned surface', () => {
const { window, wt } = loadWorkingTuning();
assert.equal(wt.version, 1);
const pipeline = window.feedBack.capabilities.inspect('working-tuning');
assert.ok(pipeline, 'working-tuning pipeline exists');
const owner = (pipeline.participants || []).find((p) => p.pluginId === 'core.working-tuning');
assert.ok(owner, 'core.working-tuning owner registered');
for (const op of ['get-working-tuning', 'set-working-tuning']) {
assert.ok(owner.operations.includes(op), `owner exposes ${op}`);
}
});
test('get() defaults to a synchronous guitar-6 assumed seed before hydration', () => {
const { wt } = loadWorkingTuning();
const s = wt.get();
assert.equal(s.instrument, 'guitar');
assert.equal(s.stringCount, 6);
assert.equal(s.provenance, 'assumed');
assert.equal(s.offsets, null);
});
test('per-instrument slots are isolated; the selector surfaces the right one', async () => {
const { wt } = loadWorkingTuning();
await flush();
wt.set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' });
wt.set({ offsets: [0, 0, 0, 0] }, { instrument: 'bass-4' });
assert.deepEqual(nums(wt.get('guitar-6').offsets), [-2, 0, 0, 0, 0, 0]);
assert.deepEqual(nums(wt.get('bass-4').offsets), [0, 0, 0, 0]);
// Selecting an instrument makes get() (no arg) return that instrument's own state.
wt.setCurrentInstrument('guitar', 6);
assert.deepEqual(nums(wt.get().offsets), [-2, 0, 0, 0, 0, 0]);
wt.setCurrentInstrument('bass', 4);
assert.deepEqual(nums(wt.get().offsets), [0, 0, 0, 0]);
});
test('defensive copies: readers and post-set callers cannot mutate live state', async () => {
const { wt } = loadWorkingTuning();
await flush();
const input = [-2, -2, -2, -2, -2, -2];
wt.set({ offsets: input }, { instrument: 'guitar-6' });
input[0] = 99; // mutate caller's array after set()
assert.deepEqual(nums(wt.get('guitar-6').offsets), [-2, -2, -2, -2, -2, -2], 'set() stored a copy');
const read = wt.get('guitar-6');
read.offsets[0] = 99; // mutate a returned copy
assert.deepEqual(nums(wt.get('guitar-6').offsets), [-2, -2, -2, -2, -2, -2], 'get() returned a copy');
});
test('verification invariant: verified <=> we hold verifiedStrings', async () => {
const { wt } = loadWorkingTuning();
await flush();
// A complete verified bundle stamps verified + a timestamp.
let s = wt.set({ offsets: [0, 0, 0, 0, 0, 0], verifiedStrings: [1, 1, 1, 1, 1, 1] },
{ instrument: 'guitar-6', provenance: 'verified' });
assert.equal(s.provenance, 'verified');
assert.deepEqual(nums(s.verifiedStrings), [1, 1, 1, 1, 1, 1]);
assert.equal(typeof s.verifiedAt, 'number');
// Claiming verified on a tuning change WITHOUT fresh strings is impossible — it
// fails toward assumed and drops the metadata (no "verified with null strings").
s = wt.set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6', provenance: 'verified' });
assert.equal(s.provenance, 'assumed');
assert.equal(s.verifiedStrings, null);
assert.equal(s.verifiedAt, null);
// verified always carries a real timestamp — an explicit verifiedAt:null is stamped now.
s = wt.set({ verifiedStrings: [1, 1, 1, 1, 1, 1], verifiedAt: null },
{ instrument: 'guitar-6', provenance: 'verified' });
assert.equal(s.provenance, 'verified');
assert.equal(typeof s.verifiedAt, 'number');
});
test('a tuning change invalidates a prior verification', async () => {
const { wt } = loadWorkingTuning();
await flush();
wt.set({ offsets: [0, 0, 0, 0, 0, 0], verifiedStrings: [1, 1, 1, 1, 1, 1] },
{ instrument: 'guitar-6', provenance: 'verified' });
const s = wt.set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' });
assert.equal(s.provenance, 'assumed');
assert.equal(s.verifiedStrings, null);
assert.equal(s.verifiedAt, null);
});
test('bare-instrument writes target the current selection, not a hard-coded default', async () => {
const { wt } = loadWorkingTuning();
await flush();
wt.setCurrentInstrument('bass', 5); // a 5-string bass is selected
// A bare instrument string must write bass-5, not bass-4.
wt.set({ offsets: [0, 0, 0, 0, 0] }, { instrument: 'bass' });
assert.deepEqual(nums(wt.get('bass-5').offsets), [0, 0, 0, 0, 0]);
assert.equal(wt.get('bass-4').offsets, null, 'bass-4 slot untouched');
// A bare stringCount (no instrument) applies to the selected instrument.
const s = wt.set({ stringCount: 5, offsets: [-1, -1, -1, -1, -1] });
assert.equal(s.instrument, 'bass');
assert.equal(s.stringCount, 5);
});
test('seed resolves a NAMED tuning to offsets via /api/tunings', async () => {
const { wt, changes } = loadWorkingTuning({
'/api/settings': { instrument: 'guitar', string_count: 6, tuning: 'Drop D', reference_pitch: 440 },
'/api/tunings': TUNINGS,
});
await flush();
const s = wt.get('guitar-6');
assert.deepEqual(nums(s.offsets), [-2, 0, 0, 0, 0, 0], 'Drop D resolved to a -2 low string');
assert.equal(s.source, 'settings');
assert.equal(s.provenance, 'assumed');
// Hydration emitted once, carrying the seeded instrument.
const hydrations = changes.filter((c) => c.instrument === 'guitar');
assert.ok(hydrations.length >= 1, 'a working-tuning-changed fired for the seeded instrument');
});
test('seed accepts an offsets-list tuning directly', async () => {
const { wt } = loadWorkingTuning({
'/api/settings': { instrument: 'bass', string_count: 4, tuning: [-2, 0, 0, 0] },
});
await flush();
assert.deepEqual(nums(wt.get('bass-4').offsets), [-2, 0, 0, 0]);
});
test('boot race: an explicit set() before settings resolve is not clobbered by the seed', async () => {
const settings = deferred();
const { wt } = loadWorkingTuning({
'/api/settings': settings.promise, // held open
'/api/tunings': TUNINGS,
});
// A consumer writes before the seed lands.
wt.set({ offsets: [-5, -5, -5, -5, -5, -5] }, { instrument: 'guitar-6' });
// Now the seed resolves with a DIFFERENT tuning.
settings.resolve({ ok: true, json: () => Promise.resolve({ instrument: 'guitar', string_count: 6, tuning: 'Drop D' }) });
await flush();
assert.deepEqual(nums(wt.get('guitar-6').offsets), [-5, -5, -5, -5, -5, -5], 'explicit write survived the seed');
});
test('resetToDefault clears a slot back to its baseline and emits', async () => {
const { wt, changes } = loadWorkingTuning();
await flush();
wt.set({ offsets: [-2, -2, -2, -2, -2, -2] }, { instrument: 'guitar-6', provenance: 'verified', verifiedStrings: [1, 1, 1, 1, 1, 1] });
const before = changes.length;
const s = wt.resetToDefault('guitar-6');
assert.equal(s.offsets, null);
assert.equal(s.provenance, 'assumed');
assert.equal(s.verifiedStrings, null);
assert.ok(changes.length > before, 'reset emitted working-tuning-changed');
});
+191
View File
@@ -0,0 +1,191 @@
"""Tests for instrument->chart arrangement routing in the highway WS.
When no explicit arrangement is requested, the WS picks the arrangement matching
the player's selected instrument (config.json `instrument`) so a bass player gets
the Bass part instead of the default Lead/guitar chart. An explicit arrangement
request always wins.
"""
from __future__ import annotations
import importlib
import json
import sys
import pytest
import yaml
from fastapi.testclient import TestClient
def _arr(notes):
return {
"notes": notes,
"chords": [],
"anchors": [],
"handshapes": [],
"templates": [],
"beats": [{"time": 0.0, "measure": 1}],
"sections": [{"name": "intro", "number": 1, "time": 0.0}],
}
def _write_multi_arr_sloppak(dlc_root):
"""A song with a Lead (guitar) and a Bass arrangement, Lead first (index 0)."""
pak = dlc_root / "multi.sloppak"
pak.mkdir()
(pak / "arrangements").mkdir()
(pak / "arrangements" / "lead.json").write_text(json.dumps(_arr([])))
(pak / "arrangements" / "bass.json").write_text(json.dumps(_arr([])))
manifest = {
"title": "Multi",
"artist": "Tester",
"album": "",
"year": 2026,
"duration": 10.0,
"arrangements": [
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
{"id": "bass", "name": "Bass", "file": "arrangements/bass.json"},
],
"stems": [],
}
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
return pak
def _write_sloppak(dlc_root, name, arrangements):
"""Write a .sloppak whose arrangements are (id, display-name) pairs, in order."""
pak = dlc_root / f"{name}.sloppak"
pak.mkdir()
(pak / "arrangements").mkdir()
manifest_arrs = []
for arr_id, arr_name in arrangements:
(pak / "arrangements" / f"{arr_id}.json").write_text(json.dumps(_arr([])))
manifest_arrs.append(
{"id": arr_id, "name": arr_name, "file": f"arrangements/{arr_id}.json"}
)
manifest = {
"title": name,
"artist": "Tester",
"album": "",
"year": 2026,
"duration": 10.0,
"arrangements": manifest_arrs,
"stems": [],
}
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
return pak
@pytest.fixture()
def make_client(tmp_path, monkeypatch):
def _make(instrument=None, default_arrangement=None):
cfg = tmp_path / "config"
cfg.mkdir(exist_ok=True)
conf = {}
if instrument is not None:
conf["instrument"] = instrument
if default_arrangement is not None:
conf["default_arrangement"] = default_arrangement
if conf:
(cfg / "config.json").write_text(json.dumps(conf), encoding="utf-8")
monkeypatch.setenv("CONFIG_DIR", str(cfg))
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
sys.modules.pop("server", None)
server = importlib.import_module("server")
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
monkeypatch.setattr(server, "startup_scan", lambda: None)
monkeypatch.setattr(server, "SLOPPAK_CACHE_DIR", tmp_path / "cache")
return server
(tmp_path / "dlc").mkdir()
yield _make
server = sys.modules.get("server")
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
def _arr_index(client, path):
with client.websocket_connect(path) as ws:
for _ in range(200):
msg = ws.receive_json()
if msg.get("error"):
raise AssertionError(f"WS error frame: {msg}")
if msg.get("type") == "song_info":
return msg["arrangement_index"]
if msg.get("type") == "ready":
break
raise AssertionError("no song_info frame received")
def test_bass_instrument_routes_to_bass_arrangement(make_client):
server = make_client(instrument="bass")
_write_multi_arr_sloppak(server._get_dlc_dir())
with TestClient(server.app) as client:
# No explicit arrangement → route to Bass (index 1), not the default Lead.
idx = _arr_index(client, "/ws/highway/multi.sloppak?naming_mode=smart")
assert idx == 1
def test_guitar_instrument_keeps_default(make_client):
server = make_client(instrument="guitar")
_write_multi_arr_sloppak(server._get_dlc_dir())
with TestClient(server.app) as client:
idx = _arr_index(client, "/ws/highway/multi.sloppak?naming_mode=smart")
assert idx == 0 # guitar falls through to the default → Lead
def test_explicit_arrangement_overrides_instrument(make_client):
server = make_client(instrument="bass")
_write_multi_arr_sloppak(server._get_dlc_dir())
with TestClient(server.app) as client:
# An explicit arrangement request wins even for a bass player.
idx = _arr_index(client, "/ws/highway/multi.sloppak?arrangement=0")
assert idx == 0
def test_bass_with_no_bass_part_falls_through_to_guitar(make_client):
server = make_client(instrument="bass")
# Lead + Rhythm, no bass part at all.
_write_sloppak(server._get_dlc_dir(), "gtr", [("lead", "Lead"), ("rhythm", "Rhythm")])
with TestClient(server.app) as client:
idx = _arr_index(client, "/ws/highway/gtr.sloppak")
assert idx == 0 # no bass candidate → existing default (a guitar part)
def test_bass_no_pref_picks_the_primary_bass_not_an_alt(make_client):
server = make_client(instrument="bass")
# Lead + two bass parts; the canonical "Bass" should win over "Bass 2".
_write_sloppak(
server._get_dlc_dir(), "bb",
[("lead", "Lead"), ("bass", "Bass"), ("bass2", "Bass 2")],
)
with TestClient(server.app) as client:
idx = _arr_index(client, "/ws/highway/bb.sloppak")
assert idx == 1 # the primary Bass, not the first-in-order-if-it-were-an-alt
def test_bass_honors_saved_pref_within_the_bass_parts(make_client):
# A bass player who saved "Bass 2" keeps it — instrument routing must not clobber
# the preference with the primary Bass.
server = make_client(instrument="bass", default_arrangement="Bass 2")
_write_sloppak(
server._get_dlc_dir(), "bb",
[("lead", "Lead"), ("bass", "Bass"), ("bass2", "Bass 2")],
)
with TestClient(server.app) as client:
idx = _arr_index(client, "/ws/highway/bb.sloppak")
assert idx == 2 # the preferred Bass 2, not the primary Bass (index 1)
def test_guitar_still_honors_saved_pref(make_client):
# Guitar routing unchanged: a saved default_arrangement still applies.
server = make_client(instrument="guitar", default_arrangement="Rhythm")
_write_sloppak(
server._get_dlc_dir(), "gtr2",
[("lead", "Lead"), ("rhythm", "Rhythm"), ("bass", "Bass")],
)
with TestClient(server.app) as client:
idx = _arr_index(client, "/ws/highway/gtr2.sloppak")
assert idx == 1 # Rhythm, per preference