feat(core): host per-instrument workingTuning capability + read-API/event (working-tuning PR 1) (#658)

* feat(core): host per-instrument workingTuning capability + read-API/event (working-tuning PR 1)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-07-01 08:05:56 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent f9607c5c94
commit 491039a12d
5 changed files with 524 additions and 0 deletions
+1
View File
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Host "working tuning" — a live, app-wide record of what tuning your instrument is *actually* in right now (foundation; no behavior change yet).** Introduces `window.feedBack.workingTuning`, a host-owned, session-lived state distinct from any one song's tuning and from a soft opt-in default: the offsets + string-count + reference pitch the player's instrument is currently in, plus an `assumed`/`verified` provenance flag. It's **per-instrument** — your guitar's current tuning and your bass's are kept *separately* (keyed like the instrument selector, e.g. `guitar-6` / `bass-4`), so switching instruments surfaces that instrument's own remembered tuning and you only ever deal with the one you've selected. It exists so a retune — or an instrument swap mid-session — is reflected **everywhere** (the highway, the library/song-picker, and plugins like the tuner, Virtuoso, and the minigames) instead of being re-derived per surface or wrongly assumed from a fixed profile. Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API: a **synchronous `get(instrument?)`** (returns the selected instrument's state, defaulting to the seed until known), a `set(state, {provenance, instrument})` mutator (the tuner becomes the sole writer in the next change), `setCurrentInstrument()` for the selector, `resetToDefault()`, and a `working-tuning-changed` event that fires on every change **and once on hydration** (carrying which instrument changed) so a late-mounting consumer is never stuck on stale state. State is **in-memory, seeded from `/api/settings` on boot and reset on restart** — a stale "you're in drop-A" assumption is worse than re-asking. Registered as a separate `working-tuning` **exclusive-owner** capability (tuner = writer, the rest = requesters). This is the foundation (plumbing only — nothing writes to it yet) of the working-tuning series, which fixes the tuner gate only ever prompting *away from* a fixed "home" tuning (never back) and makes the current tuning a first-class signal the whole app shares. Offsets use the same per-string semitone vocabulary as song tunings, so fully custom/extended tunings (e.g. a drop-A 8-string) are first-class. Frontend-only: new `static/capabilities/working-tuning.js`, loaded from `static/index.html` + `static/v3/index.html`.
- **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **AZ rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`. - **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **AZ rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`.
- **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback). - **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback).
- **Smart collections — save a set of library filters as a live, auto-updating source.** A collection is a saved `/api/library` query (e.g. "Drop-D tunings", "sloppak only", "recently added") that stays live: it's registered as a **library provider**, so it shows up in the v3 Songs source picker and inherits the whole grid UI — paging, stats, the AZ rail, art — for free, with **no new screen**. Storage reuses the playlist subsystem (a `playlists.rules` JSON blob = a smart collection; membership is the live filter result, not stored songs, and collections are excluded from the manual-playlist list + read-only to playlist mutations). New `GET`/`POST`/`PUT`/`DELETE /api/collections`; a per-collection `SmartCollectionProvider` delegates `query_page`/`query_stats`/`query_artists` to the local DB with the stored rules applied; providers are re-registered from a boot scan so collections survive a restart. Rules mirror the raw `/api/library` query params (unknown keys dropped, never 500). Frontend: a " Save as collection" action in the v3 filter drawer (shown when filters are active) names the current filter set and switches to it. The charrette's "the homelab primitive FeedBack was missing" pick (got-feedback/feedBack#636 item 2); richer rule fields (accuracy, genre, difficulty) follow as the metadata work lands. Tests: `tests/test_collections_api.py`, `tests/js/v3_collections.test.js`. - **Smart collections — save a set of library filters as a live, auto-updating source.** A collection is a saved `/api/library` query (e.g. "Drop-D tunings", "sloppak only", "recently added") that stays live: it's registered as a **library provider**, so it shows up in the v3 Songs source picker and inherits the whole grid UI — paging, stats, the AZ rail, art — for free, with **no new screen**. Storage reuses the playlist subsystem (a `playlists.rules` JSON blob = a smart collection; membership is the live filter result, not stored songs, and collections are excluded from the manual-playlist list + read-only to playlist mutations). New `GET`/`POST`/`PUT`/`DELETE /api/collections`; a per-collection `SmartCollectionProvider` delegates `query_page`/`query_stats`/`query_artists` to the local DB with the stored rules applied; providers are re-registered from a boot scan so collections survive a restart. Rules mirror the raw `/api/library` query params (unknown keys dropped, never 500). Frontend: a " Save as collection" action in the v3 filter drawer (shown when filters are active) names the current filter set and switches to it. The charrette's "the homelab primitive FeedBack was missing" pick (got-feedback/feedBack#636 item 2); richer rule fields (accuracy, genre, difficulty) follow as the metadata work lands. Tests: `tests/test_collections_api.py`, `tests/js/v3_collections.test.js`.
+315
View File
@@ -0,0 +1,315 @@
// Core "working tuning" capability domain — the live, host-authoritative CURRENT
// instrument tuning (session state), distinct from the soft opt-in default and from
// any one song's tuning. This is the single source of truth the whole app reads:
// the highway, the library/song-picker, Virtuoso, and the minigames all consult it,
// and the tuner is the sole WRITER (it updates this when the player retunes, clears
// the gate, or switches instruments).
//
// PER-INSTRUMENT: a player has separate physical instruments, each in its OWN tuning
// ("I'm not tuning two instruments when I pick a song"). So state is a MAP keyed by
// instrument — `${instrument}-${stringCount}` (e.g. "guitar-6", "bass-4"), the same key
// the v3 instrument selector uses. `get()` returns the CURRENTLY-SELECTED instrument's
// tuning; switching the selector surfaces that instrument's own remembered tuning. You
// only ever deal with the one you've picked.
//
// Design: WORKING-TUNING-STATE-DESIGN.md (host-first PR series, PR 1 = this file).
// Pattern mirrors `capabilities/tuning.js` (capability registration) + the host theme
// read-API (`window.feedBack.theme`): a synchronous `get()` plus a `working-tuning-
// changed` event that also fires once on hydration.
//
// State is IN-MEMORY and NOT persisted — reset-to-home on restart is deliberate (a
// stale "you're in drop-A" assumption is worse than re-asking). The opt-in "default
// tuning on app open" lands later; for now we seed the selected instrument from
// /api/settings.
//
// PR 1 is PURE PLUMBING: it introduces the state + read/write surface + event, but
// nothing writes to it yet and no behavior changes. The tuner becomes the writer (and
// the gate's E->C# asymmetry is fixed) in a later PR.
(function () {
'use strict';
window.feedBack = window.feedBack || {};
const capabilities = window.feedBack.capabilities;
const _byInstrument = {}; // key -> tuning state (the per-instrument map)
let _currentKey = null; // the selected instrument's key; cached so get() is sync
let _hydrated = false;
let _touched = false; // set once anything explicitly writes/selects; gates the async seed
function _normInstrument(instrument) {
return instrument === 'bass' ? 'bass' : 'guitar';
}
function _keyOf(instrument, stringCount) {
const inst = _normInstrument(instrument);
const sc = Number(stringCount) || (inst === 'bass' ? 4 : 6);
return inst + '-' + sc;
}
// Like _keyOf, but when the caller omits a string count we resolve it against the
// current selection (if it's the same instrument) before falling back to the
// per-instrument default — so `set({instrument:'bass'})` targets the selected
// bass-5, not a hard-coded bass-4.
function _keyOfResolved(instrument, stringCount) {
const inst = _normInstrument(instrument);
let sc = Number(stringCount);
if (!sc) {
if (_currentKey) {
const cur = _splitKey(_currentKey);
if (cur.instrument === inst) sc = cur.stringCount;
}
if (!sc) sc = (inst === 'bass' ? 4 : 6);
}
return inst + '-' + sc;
}
function _splitKey(key) {
const parts = (typeof key === 'string' ? key : '').split('-');
const inst = parts[0] === 'bass' ? 'bass' : 'guitar';
return { instrument: inst, stringCount: Number(parts[1]) || (inst === 'bass' ? 4 : 6) };
}
// The shape every consumer reads. `offsets` are per-string semitone offsets from
// standard (same vocabulary as song_info.tuning and /api/tunings); `instrument`
// disambiguates the open-string base so offsets resolve to real pitches. A drop-A
// 8-string is just an offsets array — fully custom tunings are first-class.
// `provenance` is the honesty flag: 'verified' means the tuner did a choreographed
// per-string mic check this session; everything else is 'assumed'.
function _defaultState(key) {
const id = _splitKey(key);
return {
offsets: null,
stringCount: id.stringCount,
instrument: id.instrument,
referencePitch: 440,
provenance: 'assumed',
verifiedStrings: null,
verifiedAt: null,
source: 'default',
};
}
// Resolve which instrument key a get/set targets: an explicit arg wins (a string
// key "guitar-6", a bare "guitar"/"bass", or { instrument, stringCount }); else the
// cached current selection.
function _resolveKey(instrument) {
if (instrument && typeof instrument === 'object') return _keyOfResolved(instrument.instrument, instrument.stringCount);
if (typeof instrument === 'string' && instrument) {
return instrument.indexOf('-') > 0 ? instrument : _keyOfResolved(instrument, null);
}
return _currentKey || _keyOf('guitar', 6);
}
// Synchronous read of an instrument's current tuning (default = selected
// instrument). Returns a deep-enough copy — the object plus its mutable array
// fields (`offsets`, `verifiedStrings`) — so a reader can't mutate the live state.
function get(instrument) {
const key = _resolveKey(instrument);
const state = Object.assign(_defaultState(key), _byInstrument[key] || {});
if (Array.isArray(state.offsets)) state.offsets = state.offsets.slice();
if (Array.isArray(state.verifiedStrings)) state.verifiedStrings = state.verifiedStrings.slice();
return state;
}
function _emitChanged(key) {
if (window.feedBack && typeof window.feedBack.emit === 'function') {
window.feedBack.emit('working-tuning-changed', { key: key, instrument: _splitKey(key).instrument, tuning: get(key) });
}
}
// The single mutator. The tuner calls this on retune / gate-clear / swap. Writes to
// the instrument the state targets (opts.instrument, or next.instrument+stringCount,
// or the current selection) and makes that the active instrument. `opts.provenance`
// stamps 'verified' (mic-confirmed) vs the default 'assumed'. Changing the tuning
// invalidates a prior verification unless fresh verifiedStrings are supplied — fail
// toward "assumed".
function set(next, opts) {
opts = opts || {};
next = next || {};
// Resolve the target key. An explicit opts.instrument wins; otherwise a
// next.instrument/next.stringCount targets that slot — but a bare stringCount
// (no instrument) applies to the CURRENTLY-SELECTED instrument, not a hard-coded
// guitar, so `set({stringCount:5})` on a selected bass writes bass-5.
let key;
if (opts.instrument) {
key = _resolveKey(opts.instrument);
} else if (next.instrument || next.stringCount) {
const inst = next.instrument ? _normInstrument(next.instrument)
: (_currentKey ? _splitKey(_currentKey).instrument : 'guitar');
key = _keyOfResolved(inst, next.stringCount);
} else {
key = _currentKey || _resolveKey();
}
const id = _splitKey(key);
const merged = Object.assign(get(key), next); // get() gives copies, so `merged` is ours to mutate
merged.instrument = id.instrument; // keep coherent with the key
merged.stringCount = id.stringCount; // the key is authoritative for string count
const tuningChanged = ('offsets' in next) || ('stringCount' in next) || ('referencePitch' in next);
// Provenance: explicit opts wins; a bare tuning change downgrades to 'assumed'.
if (opts.provenance) {
merged.provenance = opts.provenance;
} else if (tuningChanged) {
merged.provenance = 'assumed';
}
// Verification metadata is coherent by construction: a tuning change invalidates
// prior per-string verification unless the caller supplies a fresh bundle, and the
// metadata exists ONLY while provenance === 'verified'. So verified <=> we hold
// verifiedStrings — a "verified with no strings" state is impossible.
if (!('verifiedStrings' in next) && tuningChanged) {
merged.verifiedStrings = null;
}
if (merged.provenance === 'verified' && !Array.isArray(merged.verifiedStrings)) {
merged.provenance = 'assumed'; // claimed verified but no evidence — fail toward assumed
}
if (merged.provenance === 'verified') {
// verified always carries a real timestamp — a caller-supplied null/NaN/absent
// verifiedAt is stamped now, so 'verified' can never mean "at no known time".
if (typeof merged.verifiedAt !== 'number' || !isFinite(merged.verifiedAt)) {
merged.verifiedAt = Date.now();
}
} else {
merged.verifiedStrings = null;
merged.verifiedAt = null;
}
// Store copies of the mutable arrays so a caller can't mutate live state post-set.
if (Array.isArray(merged.offsets)) merged.offsets = merged.offsets.slice();
if (Array.isArray(merged.verifiedStrings)) merged.verifiedStrings = merged.verifiedStrings.slice();
_byInstrument[key] = merged;
_currentKey = key; // writing a tuning makes that instrument the active one
_touched = true; // an explicit write must not be clobbered by the async seed
_emitChanged(key);
return get(key);
}
// Tell the host which instrument is now selected (the v3 selector calls this when
// the player switches guitar<->bass / string count) so get() returns the right
// instrument's tuning. Emits if the selection actually changed.
function setCurrentInstrument(instrument, stringCount) {
const key = (typeof instrument === 'string' && instrument.indexOf('-') > 0) ? instrument : _keyOfResolved(instrument, stringCount);
_touched = true; // an explicit selection must not be reverted by the async seed
if (key === _currentKey) return get(key);
_currentKey = key;
_emitChanged(key);
return get(key);
}
// Reset an instrument's live tuning back to its baseline (the home/default).
function resetToDefault(instrument) {
const key = _resolveKey(instrument);
_byInstrument[key] = _defaultState(key);
_touched = true;
_emitChanged(key);
return get(key);
}
// Per-string semitone offsets of a named tuning relative to Standard, derived from
// the /api/tunings frequency tables. The reference pitch cancels in the ratio, so
// this is pitch-independent. Returns null if either row is missing/mismatched.
function _offsetsFromFreqs(named, standard) {
if (!Array.isArray(named) || !Array.isArray(standard) || named.length !== standard.length) return null;
const out = [];
for (let i = 0; i < named.length; i++) {
const a = Number(named[i]);
const b = Number(standard[i]);
if (!(a > 0) || !(b > 0)) return null;
out.push(Math.round(12 * Math.log2(a / b)));
}
return out;
}
// Seed the SELECTED instrument's slot from settings on boot (best-effort 'assumed'
// starting point, NOT a persisted working tuning). settings.tuning may be an offsets
// list OR a name ("Drop D") — a name is resolved to offsets via /api/tunings so a
// named tuning isn't lost. If settings can't be read we still hydrate so consumers
// aren't stuck waiting; an explicit set()/select before we resolve wins (no clobber).
function _seedFromSettings() {
fetch('/api/settings')
.then(function (r) { return r && r.ok ? r.json() : null; })
.then(function (s) {
if (!s || _touched) return; // nothing to seed, or a consumer already wrote — don't clobber
const inst = _normInstrument(s.instrument);
const sc = Number(s.string_count) || (inst === 'bass' ? 4 : 6);
const key = _keyOf(inst, sc);
function commit(offsets) {
if (_touched) return; // re-check: a write may have raced the /api/tunings fetch
_currentKey = key;
_byInstrument[key] = {
offsets: Array.isArray(offsets) ? offsets.slice(0, sc) : null,
stringCount: sc,
instrument: inst,
referencePitch: Number(s.reference_pitch) || 440,
provenance: 'assumed',
verifiedStrings: null,
verifiedAt: null,
source: 'settings',
};
}
if (Array.isArray(s.tuning)) { commit(s.tuning); return; }
if (typeof s.tuning === 'string' && s.tuning) {
return fetch('/api/tunings')
.then(function (r) { return r && r.ok ? r.json() : null; })
.then(function (t) {
const byName = t && t[key];
commit(byName ? _offsetsFromFreqs(byName[s.tuning], byName.Standard) : null);
})
.catch(function () { commit(null); });
}
commit(null);
})
.catch(function () { /* keep defaults */ })
.then(function () { _hydrate(); });
}
function _hydrate() {
if (_hydrated) return;
_hydrated = true;
_emitChanged(_currentKey || _resolveKey());
}
// ---- Capability registration (mirrors capabilities/tuning.js) ----------------
if (capabilities && capabilities.version === 1 &&
!(window.feedBack.workingTuning && window.feedBack.workingTuning.version === 1)) {
capabilities.registerOwner('working-tuning', {
description: 'The live, host-authoritative current instrument tuning (session state), per ' +
'instrument: offsets + string-count + reference pitch + assumed/verified provenance. ' +
'Written by the tuner, read by the highway/library/Virtuoso/minigames.',
operations: ['get-working-tuning', 'set-working-tuning'],
events: ['working-tuning-changed'],
kind: 'command',
ownership: 'exclusive-owner',
});
capabilities.registerParticipant('plugin.tuner', {
'working-tuning': {
roles: ['contributor', 'requester'],
operations: ['get-working-tuning', 'set-working-tuning'],
emits: ['working-tuning-changed'],
mode: 'active',
compatibility: 'none',
safety: 'safe',
},
});
capabilities.registerParticipant('core.settings.instruments', {
'working-tuning': {
roles: ['requester'],
operations: ['get-working-tuning'],
events: ['working-tuning-changed'],
mode: 'active',
compatibility: 'none',
safety: 'safe',
},
});
}
// ---- Public read/write surface (attached defensively, like feedBack.theme) ----
window.feedBack.workingTuning = Object.freeze({
version: 1,
get: get,
set: set,
setCurrentInstrument: setCurrentInstrument,
resetToDefault: resetToDefault,
});
_seedFromSettings();
})();
+1
View File
@@ -24,6 +24,7 @@
<script src="/static/capabilities.js"></script> <script src="/static/capabilities.js"></script>
<script src="/static/capabilities/library.js"></script> <script src="/static/capabilities/library.js"></script>
<script src="/static/capabilities/tuning.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-session.js"></script>
<script src="/static/capabilities/audio-effects.js"></script> <script src="/static/capabilities/audio-effects.js"></script>
<script src="/static/capabilities/playback.js"></script> <script src="/static/capabilities/playback.js"></script>
+1
View File
@@ -87,6 +87,7 @@
<script src="/static/capabilities.js"></script> <script src="/static/capabilities.js"></script>
<script src="/static/capabilities/library.js"></script> <script src="/static/capabilities/library.js"></script>
<script src="/static/capabilities/tuning.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-session.js"></script>
<script src="/static/capabilities/audio-effects.js"></script> <script src="/static/capabilities/audio-effects.js"></script>
<script src="/static/capabilities/playback.js"></script> <script src="/static/capabilities/playback.js"></script>
+206
View File
@@ -0,0 +1,206 @@
// Behavioral harness for the host `window.feedBack.workingTuning` capability
// (static/capabilities/working-tuning.js) — the per-instrument, in-memory current
// tuning. Runs the real capability in a stubbed window (same strategy as
// midi_input_domain.test.js) with a controllable fetch, and asserts the per-instrument
// state machine: isolated guitar/bass slots, selector switch, defensive copies, the
// provenance/verification invariant, unambiguous key routing, named-tuning seeding, and
// the boot-race guard.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { createWindow, ROOT } = require('./capabilities_test_harness');
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
const WORKING_TUNING_JS = path.join(ROOT, 'static', 'capabilities', 'working-tuning.js');
// A /api/tunings-shaped fixture (frequencies at 440), enough to resolve names to offsets.
const TUNINGS = {
'guitar-6': {
Standard: [82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
'Drop D': [73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
},
'bass-5': {
Standard: [30.87, 41.20, 55.00, 73.42, 98.00],
},
};
function deferred() {
let resolve;
const promise = new Promise((r) => { resolve = r; });
return { promise, resolve };
}
// `routes` maps a URL to: a plain JSON value (served as {ok:true}), a thenable that
// resolves to a full response object (for deferral/races), or nothing (served {ok:false}).
function loadWorkingTuning(routes = {}) {
const window = createWindow();
const changes = [];
window.fetch = function (url) {
const entry = routes[url];
if (entry && typeof entry.then === 'function') return entry;
if (entry !== undefined) return Promise.resolve({ ok: true, json: () => Promise.resolve(entry) });
return Promise.resolve({ ok: false, json: () => Promise.resolve(null) });
};
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
vm.runInContext(fs.readFileSync(WORKING_TUNING_JS, 'utf8'), context, { filename: WORKING_TUNING_JS });
// capabilities.js replaces window.feedBack with an EventTarget bus — subscribe on it,
// not on window. Attaching after load still catches the async hydration event.
window.feedBack.on('working-tuning-changed', (ev) => changes.push(ev.detail));
return { window, wt: window.feedBack.workingTuning, changes };
}
// Rebase a possibly-vm-realm array into this realm so deepStrictEqual compares by value,
// not by (cross-realm) Array.prototype identity.
const nums = (a) => (a == null ? a : Array.from(a));
// Drain the seed's fetch/promise chain (settings -> tunings -> hydrate).
const flush = async () => { for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r)); };
test('registers a working-tuning exclusive-owner capability + versioned surface', () => {
const { window, wt } = loadWorkingTuning();
assert.equal(wt.version, 1);
const pipeline = window.feedBack.capabilities.inspect('working-tuning');
assert.ok(pipeline, 'working-tuning pipeline exists');
const owner = (pipeline.participants || []).find((p) => p.pluginId === 'core.working-tuning');
assert.ok(owner, 'core.working-tuning owner registered');
for (const op of ['get-working-tuning', 'set-working-tuning']) {
assert.ok(owner.operations.includes(op), `owner exposes ${op}`);
}
});
test('get() defaults to a synchronous guitar-6 assumed seed before hydration', () => {
const { wt } = loadWorkingTuning();
const s = wt.get();
assert.equal(s.instrument, 'guitar');
assert.equal(s.stringCount, 6);
assert.equal(s.provenance, 'assumed');
assert.equal(s.offsets, null);
});
test('per-instrument slots are isolated; the selector surfaces the right one', async () => {
const { wt } = loadWorkingTuning();
await flush();
wt.set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' });
wt.set({ offsets: [0, 0, 0, 0] }, { instrument: 'bass-4' });
assert.deepEqual(nums(wt.get('guitar-6').offsets), [-2, 0, 0, 0, 0, 0]);
assert.deepEqual(nums(wt.get('bass-4').offsets), [0, 0, 0, 0]);
// Selecting an instrument makes get() (no arg) return that instrument's own state.
wt.setCurrentInstrument('guitar', 6);
assert.deepEqual(nums(wt.get().offsets), [-2, 0, 0, 0, 0, 0]);
wt.setCurrentInstrument('bass', 4);
assert.deepEqual(nums(wt.get().offsets), [0, 0, 0, 0]);
});
test('defensive copies: readers and post-set callers cannot mutate live state', async () => {
const { wt } = loadWorkingTuning();
await flush();
const input = [-2, -2, -2, -2, -2, -2];
wt.set({ offsets: input }, { instrument: 'guitar-6' });
input[0] = 99; // mutate caller's array after set()
assert.deepEqual(nums(wt.get('guitar-6').offsets), [-2, -2, -2, -2, -2, -2], 'set() stored a copy');
const read = wt.get('guitar-6');
read.offsets[0] = 99; // mutate a returned copy
assert.deepEqual(nums(wt.get('guitar-6').offsets), [-2, -2, -2, -2, -2, -2], 'get() returned a copy');
});
test('verification invariant: verified <=> we hold verifiedStrings', async () => {
const { wt } = loadWorkingTuning();
await flush();
// A complete verified bundle stamps verified + a timestamp.
let s = wt.set({ offsets: [0, 0, 0, 0, 0, 0], verifiedStrings: [1, 1, 1, 1, 1, 1] },
{ instrument: 'guitar-6', provenance: 'verified' });
assert.equal(s.provenance, 'verified');
assert.deepEqual(nums(s.verifiedStrings), [1, 1, 1, 1, 1, 1]);
assert.equal(typeof s.verifiedAt, 'number');
// Claiming verified on a tuning change WITHOUT fresh strings is impossible — it
// fails toward assumed and drops the metadata (no "verified with null strings").
s = wt.set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6', provenance: 'verified' });
assert.equal(s.provenance, 'assumed');
assert.equal(s.verifiedStrings, null);
assert.equal(s.verifiedAt, null);
// verified always carries a real timestamp — an explicit verifiedAt:null is stamped now.
s = wt.set({ verifiedStrings: [1, 1, 1, 1, 1, 1], verifiedAt: null },
{ instrument: 'guitar-6', provenance: 'verified' });
assert.equal(s.provenance, 'verified');
assert.equal(typeof s.verifiedAt, 'number');
});
test('a tuning change invalidates a prior verification', async () => {
const { wt } = loadWorkingTuning();
await flush();
wt.set({ offsets: [0, 0, 0, 0, 0, 0], verifiedStrings: [1, 1, 1, 1, 1, 1] },
{ instrument: 'guitar-6', provenance: 'verified' });
const s = wt.set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' });
assert.equal(s.provenance, 'assumed');
assert.equal(s.verifiedStrings, null);
assert.equal(s.verifiedAt, null);
});
test('bare-instrument writes target the current selection, not a hard-coded default', async () => {
const { wt } = loadWorkingTuning();
await flush();
wt.setCurrentInstrument('bass', 5); // a 5-string bass is selected
// A bare instrument string must write bass-5, not bass-4.
wt.set({ offsets: [0, 0, 0, 0, 0] }, { instrument: 'bass' });
assert.deepEqual(nums(wt.get('bass-5').offsets), [0, 0, 0, 0, 0]);
assert.equal(wt.get('bass-4').offsets, null, 'bass-4 slot untouched');
// A bare stringCount (no instrument) applies to the selected instrument.
const s = wt.set({ stringCount: 5, offsets: [-1, -1, -1, -1, -1] });
assert.equal(s.instrument, 'bass');
assert.equal(s.stringCount, 5);
});
test('seed resolves a NAMED tuning to offsets via /api/tunings', async () => {
const { wt, changes } = loadWorkingTuning({
'/api/settings': { instrument: 'guitar', string_count: 6, tuning: 'Drop D', reference_pitch: 440 },
'/api/tunings': TUNINGS,
});
await flush();
const s = wt.get('guitar-6');
assert.deepEqual(nums(s.offsets), [-2, 0, 0, 0, 0, 0], 'Drop D resolved to a -2 low string');
assert.equal(s.source, 'settings');
assert.equal(s.provenance, 'assumed');
// Hydration emitted once, carrying the seeded instrument.
const hydrations = changes.filter((c) => c.instrument === 'guitar');
assert.ok(hydrations.length >= 1, 'a working-tuning-changed fired for the seeded instrument');
});
test('seed accepts an offsets-list tuning directly', async () => {
const { wt } = loadWorkingTuning({
'/api/settings': { instrument: 'bass', string_count: 4, tuning: [-2, 0, 0, 0] },
});
await flush();
assert.deepEqual(nums(wt.get('bass-4').offsets), [-2, 0, 0, 0]);
});
test('boot race: an explicit set() before settings resolve is not clobbered by the seed', async () => {
const settings = deferred();
const { wt } = loadWorkingTuning({
'/api/settings': settings.promise, // held open
'/api/tunings': TUNINGS,
});
// A consumer writes before the seed lands.
wt.set({ offsets: [-5, -5, -5, -5, -5, -5] }, { instrument: 'guitar-6' });
// Now the seed resolves with a DIFFERENT tuning.
settings.resolve({ ok: true, json: () => Promise.resolve({ instrument: 'guitar', string_count: 6, tuning: 'Drop D' }) });
await flush();
assert.deepEqual(nums(wt.get('guitar-6').offsets), [-5, -5, -5, -5, -5, -5], 'explicit write survived the seed');
});
test('resetToDefault clears a slot back to its baseline and emits', async () => {
const { wt, changes } = loadWorkingTuning();
await flush();
wt.set({ offsets: [-2, -2, -2, -2, -2, -2] }, { instrument: 'guitar-6', provenance: 'verified', verifiedStrings: [1, 1, 1, 1, 1, 1] });
const before = changes.length;
const s = wt.resetToDefault('guitar-6');
assert.equal(s.offsets, null);
assert.equal(s.provenance, 'assumed');
assert.equal(s.verifiedStrings, null);
assert.ok(changes.length > before, 'reset emitted working-tuning-changed');
});