mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 17:54:30 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c717b37dc0 | ||
|
|
491039a12d | ||
|
|
f9607c5c94 | ||
|
|
3e3a98a0d0 | ||
|
|
db81d7dafb |
+2
-1
@@ -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 **A–Z 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 A–Z 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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user