Compare commits

...
Author SHA1 Message Date
gionnibgudandClaude Sonnet 5 c05d4fd6a1 fix(keys_highway_3d): stop auto-connect clobbering the global MIDI device
Opening the keys highway could silently switch the user's configured MIDI
device. Two coupled defects in the plugin's MIDI selection:

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: gionnibgud <gionnibgud@gmail.com>
2026-07-09 21:26:24 +02:00
Byron GamatosandGitHub 950e348357 R0: module-migration rails (src/ serving, live-edit cache, scriptType loading, governance) (#812)
ship-ci / ci (push) Has been cancelled
Host enablement for the plugin ES-module migration: sandboxed /api/plugins/{id}/src/ serving, no-cache+weak-ETag/304 live-edit caching on src/+screen.js+assets, scriptType:module loader injection + scriptType/minHost manifest passthrough; constitution v1.2.0 + module playbook + signed size-exemptions register + maintainer/CI-only ESLint gate; rerunnable perf-baseline harness. Reviewed by Codex (local), Copilot, and CodeRabbit.
2026-07-08 10:14:40 +02:00
a18a818e8b fix(playback): throttle legacy bridge-hit recording; emit loop-set for manual A/B (#811)
ship-ci / ci (push) Waiting to run
* fix(playback): throttle legacy bridge-hit recording; emit loop-set for manual A/B

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 00:00:58 +02:00
5cb4ea0623 feat(drums): capture velocities alongside times in unmapped-percussion reporting (#808)
* feat(drums): capture velocities alongside times in unmapped-percussion reporting

Both drum converters opt-in out_unmapped capture (convert_drum_track_from_midi,
convert_drum_track_to_drumtab) gain an index-aligned `velocities` list next to
`times`, carrying each dropped note real dynamics — MIDI velocity verbatim; GP
velocity with the same 1-127 gate as mapped hits, falling back to the 100
import default. A hand-mapping UI (the editor unmapped-notes dialog) can then
restore mapped notes at their source dynamics instead of flattening to v:100
(editor-side consumer: feedBack-plugin-editor#111).

The GP path chronological sort now reorders times and velocities in LOCKSTEP
so multi-voice measures cannot silently reassign dynamics. Additive: callers
that ignore the new key are unaffected.

Tests: extended tests/test_midi_import_drums.py + tests/test_gp2rs_drums.py
(alignment, lockstep sort, out-of-range fallback) — 26 passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu

* docs(gp2rs): clarify velocity-default comment, mark dead-path fallback

- The mapped-GP velocity comment conflated GP's authoring default (95,
  Velocities.default) with the drumtab render default (100,
  DEFAULT_VELOCITY in lib/drums.py) used when `v` is omitted. Clarify
  both defaults and that only the latter applies to omitted hits.
- Mark the `else: times.sort()` fallback in the unmapped-percussion
  time/velocity sort as belt-and-suspenders — times and velocities are
  always appended together under the same len<100 guard, so lengths
  can't actually diverge.

No behavior change; comment-only maintainability nits from PR review.

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

---------

Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-07 23:58:49 +02:00
fadaa154e9 feat(library): sort and badge by personal difficulty rating (#810)
* feat(library): sort and badge by personal difficulty rating

Adds sort=difficulty/difficulty-desc to the library API (correlated
subquery over song_user_meta.user_difficulty, unrated songs pushed to
the bottom either direction, same pattern as the existing mastery
sort) and surfaces the rating as a badge on library cards in both the
v2 grid/tree views and the v3 grid. The rating itself already existed
(song_user_meta) — this just makes it sortable and visible, so it's
no longer only readable in the per-song edit drawer.

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

* fix(library): escape difficulty badge, wire tree view, add changelog+tests

- Wrap song.user_difficulty in esc() at both badge call sites
  (static/app.js ~2082 and ~2283) for XSS-consistency with the
  sibling tuning badge, which already uses esc().
- server.py: query_artists (the classic tree view's data source, used
  by /api/library/artists) never batch-attached user_difficulty the
  way query_page does for the grid, so the tree-view difficulty badge
  added in 75673c3 was unreachable dead code (song.user_difficulty was
  always undefined there). Now attaches it via the existing
  user_meta_map() helper, same pattern as query_page.
- Add an [Unreleased] CHANGELOG.md entry for the difficulty sort +
  badge feature, matching the repo's existing entry format.
- Add tests/test_library_filters.py::test_difficulty_sort_pushes_unrated_to_bottom
  asserting unrated songs sort to the bottom in both sort=difficulty
  and sort=difficulty-desc directions, and
  ::test_tree_view_songs_carry_user_difficulty covering the
  query_artists fix above.

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

* fix(library): chunk user_meta_map + rebuild stale tailwind css

Address review-bot findings on the difficulty sort/badge:
- user_meta_map now chunks filenames into 400-row batches (like
  overrides_map) before the IN (...) query. query_artists (tree view)
  passes every song across up to 50 artists, which could push the
  placeholder count past SQLite's older variable limit; query_page's
  small pages are unaffected. (CodeRabbit: Stability & Availability)
- Rebuild static/tailwind.min.css: the ◆N difficulty badge introduced
  bg-blue-900/30 + text-blue-300, which were never compiled into the
  committed stylesheet, failing the tailwind-fresh CI gate.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-07 23:57:20 +02:00
LegionaryLeaderGitHubClaude Opus 4.8coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>byrongamatos
e446b05a99 feat(keys_highway_3d): key layout modes, lane-color opacity & octave lines (#803)
* feat(keys_highway_3d): sharp-layout modes, lane-color opacity, octave lines

Add a Highway layout section to the settings with a rebuilt way to draw
sharps/flats and lanes on the 3D piano highway:

- Sharps & flats layout (keys3d_bg_sharpMode): floating (original raised
  plane), flat (one plane, zero-overlap piano-shaped tiled lanes with the
  naturals evened out), and realistic (one plane, bars sized like the
  physical keys). All geometry lives in pure laneSpanFlat()/laneSpanReal()
  helpers. Default: realistic.
- Lane color opacity (keys3d_bg_laneOpacity, 0-1): fades the pitch-class
  lane tint; at 0 it is a dark floor with guide lines only at the key-block
  boundaries (E-F and each octave), toward 1 full vivid colored lanes. The
  strips, per-lane separators and block lines crossfade with the value.
  Default: 0.
- Octave separators (keys3d_bg_octaveGaps, default on) and Octave line
  contrast (keys3d_bg_octaveContrast, 0-1): the B->C octave line is a dark
  layer scaled by lane opacity plus a bright layer scaled by its inverse,
  so it auto-shifts dark->bright as the lanes fade.

Settings re-read on init() so they apply on the next chart build. All other
behavior (MIDI scoring, palettes, camera, themes, hit feedback) is unchanged.
Unit tests cover the new defaults, the sharp-mode setting, and the lane
geometry (tiling/evening for flat, uniform/overlap for realistic).

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

* Update plugins/keys_highway_3d/settings.html

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update plugins/keys_highway_3d/screen.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix(keys_highway_3d): don't trim active-range boundary lane when neighbor sharp is out of range

laneSpanFlat() trimmed a white key's edge for its neighboring black
key's lane even when that neighbor midi fell outside
range.activeLow..range.activeHigh — the neighbor's lane is never
drawn (see the activeLow/activeHigh skip around the lane-strip loop),
so the trim left a dark, unfilled sliver at the active-range boundary
with no sharp lane to fill it. Gate the trim on the neighbor being
in-range; callers that don't pass a range (e.g. the raw-tiling unit
tests) keep the prior unconditional-trim behavior.

Also add the CHANGELOG entry for this PR's feature set, following the
existing keys_highway_3d wording convention (no plugin-local
CHANGELOG exists; plugin.json was already bumped 0.1.2 -> 0.2.0 by
the original commits).

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

---------

Signed-off-by: LegionaryLeader <legionaryleader@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-07 23:57:14 +02:00
115c3529e9 fix(midi): guard non-positive division in the legacy inline tempo path (#805)
ship-ci / ci (push) Waiting to run
convert_midi_track_to_keys_wire builds its own inline tempo map and
divides by the raw midi.ticks_per_beat at two sites (the tempo-table
precompute and the tick_to_seconds closure). A malformed header with
division == 0 raised ZeroDivisionError, and an SMPTE division (which
mido returns as a NEGATIVE signed short) produced negative/garbage
note times.

Guard the divisor with `ticks_per_beat if ticks_per_beat > 0 else 480`
so both the zero and negative cases fall back to the SMF default. The
`> 0` form (not `or 480`) is required because a negative value is
truthy and would slip past `or`. Positive-division behavior is
unchanged.

Follow-up to #796, which fixed the same class of bug in the newer
convert_midi_tempo_map / _build_tick_to_seconds path.

Adds two focused tests: division == 0 no longer crashes and emits a
non-negative time, and a negative/SMPTE division yields sane
non-negative times.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 10:27:45 +02:00
1bccb8a9e8 feat(midi): convert_midi_tempo_map — extract tempos, time signatures, beat grid (#796)
* feat(midi): convert_midi_tempo_map — extract tempos, time signatures, beat grid

The keys/drums MIDI converters always built a tempo-aware tick->seconds
map internally (to bake note times) and then discarded it — and never
read time_signature meta at all — so every MIDI import landed with no
bars, no measures, and an implied 4/4 no matter what the file said.

New lib/midi_import.py helper convert_midi_tempo_map(midi_path,
track_index) extracts the grid a .mid actually carries:

- tempos: {time, bpm} per tempo event (deduped per tick, 120 default)
- time_signatures: {time, ts: [num, den]} — the song-timeline shape
- beats: one row per beat on the editor grid shape — numbered downbeats
  with a den hint, measure:-1 interior beats; the beat unit follows the
  active signature (6/8 = six eighth-note rows per bar)

Event scope mirrors _build_tick_to_seconds: SMF type 0/1 merge meta
from all tracks, type 2 reads ONLY the chosen track (independent
timelines — callers must never share one grid across type-2 tracks).
Mid-bar signature events (ill-formed but seen in the wild) apply at the
next bar boundary. All times compute from absolute ticks through the
cumulative tempo table and round once at emit — rounding error never
accumulates with song length. A bar-count safety valve guards malformed
files. Consumer: the editor's multitrack MIDI import (tempo-seed
dialog, feedBack-plugin-editor roadmap Phase 3).

Tests: tests/test_midi_tempo_map.py — 10 cases driving the REAL
function against real .mid files built with mido (no stubs): default
grid, tempo bends, 500-bar rounding-drift check, 4/4->3/4 and 6/8
signatures, mid-bar signature deferral, duplicate-tick last-wins,
type-2 meta isolation from a bogus sibling track, empty files, grid
coverage bounds. Full MIDI-adjacent suite green: 55 passed
(test_midi_tempo_map + test_midi_import + test_midi_import_drums +
test_gp2midi) under the project venv.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu

* fix(midi): make convert_midi_tempo_map robust — real division guard, symmetric tempo default, single-pass meta

- Push the ticks-per-beat fallback into _build_tick_to_seconds (the single
  place ticks route through), guarding on `> 0` so a division==0 (malformed)
  or negative SMPTE-division header no longer raises ZeroDivisionError or
  walks the beat grid into negative times. Mirror the guard at the
  convert_midi_tempo_map beat_ticks site. The local `or 480` was cosmetic
  before — the closure still divided by the raw division.
- Seed tempos_out with a 120 BPM row at time 0 when the first set_tempo
  lands after tick 0, symmetric with the (0, 4, 4) time-signature default,
  so the sidecar matches the grid the head of the song actually used.
- Collapse the duplicated meta_source/note_source lists into one
  source_tracks walked in a single pass (meta collection + end_tick).
- Fix a weak assert in test_mid_bar_signature_applies_at_the_next_boundary
  (operator-precedence `(A and B) or C`) to assert den == 4 outright.
- Add tests: non-positive division (0 + negative SMPTE), first tempo after
  start seeds 120@0, explicit SMF type-0 file, and the _TEMPO_MAP_MAX_BARS
  safety valve.

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

---------

Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-07 10:27:17 +02:00
Byron GamatosandGitHub 010edc239b Merge pull request #806 from got-feedBack/fix/tuner-inject-player-button-render
fix(tuner): anchor injectPlayerButton to a direct-child button (feedBack#800)
2026-07-07 10:01:20 +02:00
byrongamatosandClaude Opus 4.8 9fb63fd3b5 fix(tuner): anchor injectPlayerButton to a direct-child button (feedBack#800)
`injectPlayerButton()` anchored the injected Tuner button with
`controls.querySelector('button:last-child')`, which can match a NESTED
button that is not a direct child of `#player-controls`. `insertBefore(btn,
nestedButton)` then throws `NotFoundError` (the reference node must be a
direct child); since injection runs from the tuner's `screen:changed`
handler, the throw propagated out of the player-screen transition and
stalled its render. The v3 path was already safe (plugin-control slot);
only the classic anchor was bad.

Use `:scope > button:last-of-type` (direct child only) with a
`parentNode === controls` guard before insertBefore, falling back to
appendChild. Bump plugins/tuner 1.3.3 → 1.3.4.

Test: tests/plugins/tuner/js/inject_player_button.test.js — extracts the
real function and runs it over a faithful DOM model whose insertBefore
enforces the direct-child invariant; the nested-last-button case reproduces
the throw on the old anchor and passes on the new one (5 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: byrongamatos <xasiklas@gmail.com>
2026-07-07 09:54:06 +02:00
35 changed files with 4110 additions and 151 deletions
+25
View File
@@ -123,3 +123,28 @@ jobs:
sys.exit(1)
print(f"Validated {len(manifests)} manifest(s) — OK")
EOF
lint:
# Maintainer/CI-only size + module-hygiene gate (constitution Principle I:
# dev tooling, never on the serve/Docker path — same category as
# scripts/build-tailwind.sh). max-lines WARNS (the 1,500-line size ratchet;
# non-blocking), while import-x/no-unresolved + no-cycle HARD-ERROR on the
# ES-module graphs the refactor produces. Exemptions: docs/size-exemptions.md.
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# npm ci runs third-party postinstall scripts; don't leave the token in
# git config for them (this job never pushes).
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: ESLint (size norm + module hygiene)
run: npm run lint
+29 -1
View File
@@ -48,11 +48,30 @@ output is committed, so Docker / desktop / end users never build. New
features extend `app.js` and the existing globals (`window.playSong`,
`window.showScreen`, `window.createHighway`, `window.feedBack`).
Native ES modules are a first-class, build-free extension mechanism.
Because `<script type="module">` and `import` are browser features — not
a bundler — a large source file MAY be split into an `import`-ed module
graph of plain source files, with **no build step and no framework**. A
plugin opts in with `"scriptType": "module"` in `plugin.json`: its
`screen.js` becomes a one-line `import './src/main.js'`, and the host
serves the `src/` subtree from the sandboxed `/api/plugins/<id>/src/…`
route and injects the entry as `<script type="module">`. The classic
global-scope `screen.js` path remains fully supported; both coexist, and
module scripts are still source-served — the no-bundler, no-transpiler,
build-free-at-serve rule is unchanged. Core's own `static/` tree may
migrate to the same module-graph shape (`static/js/…`) over time under
this rule.
**Non-negotiable rules**
- Do not introduce a frontend framework, JSX, or a JS build pipeline in
core. Plugins MAY ship their own bundled assets but core MUST remain
source-served.
- ES-module plugins remain source-served: no bundler or transpiler, and
their own asset URLs (worklets, WASM, images) resolve via
`import.meta.url` — never `document.currentScript`, which is `null`
inside a module. `scriptType:"module"` and the optional `minHost`
version floor are the only new `plugin.json` keys the module path adds.
- Because the core Tailwind stylesheet is prebuilt, it contains only the
classes present in core source at build time. Core's committed
`static/tailwind.min.css` MUST stay in sync with source — CI enforces
@@ -214,6 +233,15 @@ no `..`, no absolute paths).
runs first). Plugins MUST tolerate dependent globals being absent
at load time and check at runtime
(`typeof window.X === 'function'`).
- **Module load contract**: a `scriptType:"module"` plugin is injected
as `<script type="module">`, whose load event fires only after its
whole static-import graph fetches and evaluates — so the loader's
completion-by-`onload` guarantee (and the `playSong` wrapper-chain
order above) is preserved exactly. The host loads `screen.js` once per
version and `showScreen` re-injects nothing, so a plugin's per-visit
re-initialization comes from its `screen:changed` handler, not from
screen.js re-running; ES-module plugins inherit this unchanged (module
top-level code does not re-execute on same-version re-mount).
## Development Workflow
@@ -256,4 +284,4 @@ no `..`, no absolute paths).
higher-numbered principle's escape hatch is to live in a plugin
with its own bundled assets.
**Version**: 1.1.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-06-01
**Version**: 1.2.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-08
+25
View File
@@ -7,10 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Plugins can ship an ES-module `src/` tree (module-migration rails, R0).** The host gains three things so a plugin can move off a single global-scope `screen.js` IIFE onto native ES modules with **no build step**: (1) a new sandboxed `GET /api/plugins/{id}/src/{path}` route that serves a plugin's `src/` source subtree, containment-checked by the same `safe_join` guard as `assets/` (traversal/absolute/NUL → 404); (2) the live-edit cache contract — `Cache-Control: no-cache` + a weak mtime/size `ETag` + `If-None-Match``304` — applied to `src/`, `screen.js`, and `assets/` (previously `screen.js` sent no cache headers and `assets/` emitted an ETag but never revalidated), so an edited module reloads on refresh while unchanged ones `304`; and (3) `scriptType`/`minHost` passthrough from `plugin.json` to `/api/plugins`, with the loader injecting a plugin that declares `"scriptType":"module"` as `<script type="module">` (its screen.js becomes `import './src/main.js'`). A `<script type=module>` fires its load event only after its whole static-import graph evaluates, preserving the loader's completion-by-`onload` + `_loadingPluginId` contract. Classic plugins are unaffected; `minHost` is passthrough-only for now (enforcement deferred). Tests: `tests/test_plugin_src_route.py` (serve/media-type/traversal/304/no-stale-304/screen.js+assets conditional), `tests/js/plugin_loader_script_type.test.js` (guarded module injection).
- **Module-migration governance & rails (R0).** Constitution amended to **v1.2.0**: Principle II now names native ES modules as a first-class, *build-free* extension mechanism (the `scriptType:"module"` load path, both plugins and — over time — core's `static/js/`), keeping the no-bundler/no-transpiler/source-served rule intact; Operating Constraints gains a "Module load contract" clause (a `<script type=module>` load event awaits the whole static-import graph, so completion-by-`onload` is preserved; per-visit re-init comes from the `screen:changed` event, not screen.js re-execution). Mirrored into `CLAUDE.md`. New `docs/plugin-modules.md` (the migration playbook — layering, import-time purity, `import.meta.url` assets, the ETag live-edit loop) and `docs/size-exemptions.md` (the signed 1,500-line size-norm register; Byron signs core/bundled rows, Christian the authored virtuoso row). Adds a **maintainer/CI-only** ESLint gate (`eslint.config.js` + a `lint` CI job): `max-lines` warns at 1,500 as a non-blocking ratchet (ceilings for exempt files mirror the register), and `import-x/no-unresolved` + `import-x/no-cycle` hard-error on ES-module graphs — dormant until module code lands, never on the serve/Docker path.
- **Perf-baseline harness (R0).** `scripts/perf-baseline.mjs` (maintainer-only, Playwright-driven) captures server p50/p95/p99 latency, cold boot-to-interactive, JS-heap after an idle soak, and the injected plugin-script shape (how many are `type=module`), so every refactor phase can be checked for "screen-entry and frame-time no worse." Methodology + the R0 baseline live in `docs/perf-baseline.md`; playback frame-time and chart-loaded screen-entry rows need a seeded library and are re-taken per environment.
- **Sort and filter the library by your personal difficulty rating — now visible at a glance, not just in the edit drawer.** `song_user_meta.user_difficulty` (the 15 planning rating, settable manually or seeded by a plugin like the community `difficulty_tagger`) already existed but was only readable by opening a song's per-song details drawer. The library API gains `sort=difficulty` / `sort=difficulty-desc` — a correlated subquery over `song_user_meta`, following the same unrated-rows-sort-to-the-bottom-in-both-directions pattern as the existing `mastery` sort — and library cards now show the rating as a `◆N` badge (v2 grid/tree views and the v3 grid alike), next to the tuning and lyrics badges. The classic tree view's `query_artists` batch-attaches `user_difficulty` the same way `query_page` already did for the grid, so the badge actually renders there too instead of staying dark. Tests: `tests/test_library_filters.py::test_difficulty_sort_pushes_unrated_to_bottom`, `tests/test_library_filters.py::test_tree_view_songs_carry_user_difficulty`.
- **`lib/midi_import.py`: `convert_midi_tempo_map` — MIDI imports can finally carry
their bars.** The keys/drums note converters always computed a tempo-aware
tick→seconds map internally (to bake note times to absolute seconds) and then threw
it away — and never read `time_signature` meta at all — so every MIDI import landed
with no measures and an implied 4/4 regardless of what the file said. The new helper
extracts the whole grid: `tempos` (`{time, bpm}`), `time_signatures` (`{time,
ts:[num,den]}`, the song-timeline sidecar shape), and a full `beats` grid on the
editor's row shape (numbered downbeats with a `den` hint, `-1` interior beats,
eighth-note rows in 6/8 etc.). Event scope mirrors the existing tick map — SMF
type 0/1 merge meta across tracks, type 2 reads only the chosen track (independent
timelines must never share a grid); mid-bar signature events apply at the next bar
boundary; times are computed from absolute ticks through the cumulative tempo table
and rounded once at emit, so rounding error never accumulates with song length.
Consumed by the editor's upcoming multitrack MIDI import (tempo-seed dialog). Tests:
`tests/test_midi_tempo_map.py`.
### Fixed
- **Tuner: opening the player screen no longer throws `NotFoundError` and aborts the player render (feedBack#800).** `injectPlayerButton()` anchored the injected Tuner button with `controls.querySelector('button:last-child')`, which — unlike a `:scope`-scoped query — can match a **nested** button that is not a direct child of `#player-controls`. `controls.insertBefore(btn, nestedButton)` then throws `NotFoundError` (the reference node must be a direct child), and because the injection runs from the tuner's `screen:changed` → player handler, the throw propagated out of the player-screen transition and stalled its render (surfaced by a headless render of a notation arrangement; the v3 path was already safe via the plugin-control slot, only the classic path had the bad anchor). The anchor is now `:scope > button:last-of-type` (a direct child only) with a `parentNode === controls` guard before `insertBefore`, falling back to `appendChild`. `plugins/tuner` → 1.3.4. Tests: `tests/plugins/tuner/js/inject_player_button.test.js` (nested-last-button repro, direct-child insert, no-button append, idempotency, v3 slot path).
- **Auto-sync: DTW step constraint — riff-based songs no longer produce garbage sync points.** `librosa.sequence.dtw`'s default step pattern allows unbounded horizontal/vertical path runs, and on music with long self-similar chroma stretches (riff-driven stoner/doom, drone sections) the flat cost surface let the warping path collapse — minutes of score mapped onto a single audio frame, so the per-bar warp imported charts wildly out of sync while reporting success (observed on a real 138 BPM tab: effective displayed tempo 159 BPM, three sync points sharing one audio timestamp). `_dtw_align` now uses the standard music-sync slope-constrained step pattern (`[[1,1],[1,2],[2,1]]`, local tempo ratio bounded to 0.5x2x), which makes the degenerate path impossible, with a fallback to unconstrained steps when the global length ratio makes the constrained pattern infeasible (e.g. a tab aligned against a full-concert video). Validated on the failing song: coarse points track the recording 1:1, refined downbeats land on onset peaks at 3.3x background energy.
### Added
- **3D Keys Highway: key layout modes, lane-color opacity & octave lines.** A new **Highway layout** settings section rebuilds how sharps/flats and lanes draw on the 3D piano highway. **Sharps & flats layout** (`keys3d_bg_sharpMode`) picks between **floating** (the original raised-sharp look), **flat** (one plane, zero-overlap piano-shaped tiled lanes with the naturals evened out), and **realistic** (one plane, bars sized like the physical keys) — default **realistic**; the geometry lives in pure, unit-tested `laneSpanFlat()`/`laneSpanReal()` helpers. **Lane color opacity** (`keys3d_bg_laneOpacity`, 01, default 0) fades the pitch-class lane tint from full vivid color down to a dark floor with guide lines only at the key-block boundaries (E→F and each octave); the lane strips, per-lane separators and block lines crossfade with the value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) and **Octave line contrast** (`keys3d_bg_octaveContrast`, 01) control the B→C octave divider, which auto-shifts from a dark to a bright layer as lane opacity fades. Settings re-read on init and apply on the next chart build. `plugins/keys_highway_3d` → 0.2.0. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (new defaults, sharp-mode setting, lane-geometry tiling/evening for flat, uniform/overlap for realistic, and an active-range boundary case where a white key's edge stays untrimmed when its neighboring sharp falls outside the active range).
- **Unmapped-percussion capture now records velocities alongside times.** Both drum converters' opt-in `out_unmapped` reporting (`lib/midi_import.py` `convert_drum_track_from_midi`, `lib/gp2rs.py` `convert_drum_track_to_drumtab`) gain an index-aligned `velocities` list next to `times`, carrying each dropped note's real dynamics (MIDI velocity verbatim; GP velocity with the same 1127 gate as mapped hits, falling back to the 100 import default). This lets a hand-mapping UI (the editor's unmapped-notes dialog) restore mapped notes at their source dynamics instead of flattening everything to `v:100`. The GP path's chronological sort now reorders times and velocities in lockstep so multi-voice measures can't silently reassign dynamics. Additive — callers that ignore the new key are unaffected. Tests: `tests/test_midi_import_drums.py`, `tests/test_gp2rs_drums.py`.
- **Handedness (left-handed) is now a first-class choice in the instrument selector — and surfaced during onboarding.** Left-handed players could already mirror the highway, but only via a buried Settings toggle they had to find *after* setup — so a lefty hit the tour, the tuner and calibration all right-handed first. The v3 instrument badge popover now has a **Handedness: Right / Left** row alongside Instrument / Strings / Tuning (all player-orientation choices), writing the same `lefty` preference (`highway.setLefty` when a live highway exists, else the `lefty` localStorage key it reads on init; the Settings checkbox stays in sync). The first-run tour's "Choose your instrument" step — which runs **before** the tuner/audio-calibration steps — now calls it out so lefties flip it up front. Frontend-only, additive: `static/v3/badges.js`, `static/v3/onboarding-tour.js`. Tests: `tests/js/badges_handedness.test.js`.
- **"Colorblind (deuteranope)" highway string-color preset.** Adds a one-click preset to the shared "Highway String Colors" picker, sitting next to the existing OkabeIto "Colorblind-friendly" preset — contributed by a deuteranopic player who found the OkabeIto set still hard to separate. It retunes the six main strings (red / yellow-green / blue / orange / teal / deep-purple) and keeps that set's 7/8-string colors, and applies to **both** the 2D and 3D highways via the shared picker. Frontend-only, additive: `static/app.js` (`HWC_PRESETS`).
- **`lib/gp_autosync.py`: piecewise time-warp helpers + a working `refine_sync()`.** `auto_sync()` has always computed per-bar sync points (DTW), but consumers could only apply the scalar bar-1 `audio_offset`, so any tempo difference between the recording and the tab's authored tempo accumulated audibly over the song. New librosa-free helpers expose the full mapping: `bar_start_times(gp_path)` (per-bar score times on the same axis as the sync points — GPIF bar-resolution map for `.gp`/`.gpx`, per-tick integration for GP3/4/5), `build_warp_anchors(points, bar_starts)` (strictly-monotonic `(score, audio)` anchor pairs), `warp_time(t, anchors)` (piecewise-linear map with edge-slope extrapolation for count-ins/tails), `warp_song_times(song, warp)` (retimes a `lib.song.Song` in place: notes + sustains, chords, beats, sections, anchors, handshapes, per-phrase difficulty levels, tone changes, tempo overrides), and `gp_has_expandable_repeats(gp_path)` (detects GP3/4/5 repeat/volta/direction markup whose playback expansion the as-written sync points cannot map — callers fall back to offset-only sync). Also implements `refine_sync()`, which the editor plugin's refine-sync endpoint has imported since the snapshot but which never existed in core (the Refine button 500'd): it densifies the coarse DTW points to every Nth bar and re-times each with a local onset phase sweep (sweep radius clamped under half a beat so periodic material can't lock a full beat off; short scoring grid + median residual snap). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`.
@@ -20,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. A second, throttled-CPU trace pass additionally removed: shader-program re-resolution churn from label texture swaps (`material.needsUpdate` is now only set on a null↔texture transition — swapping between two cached label textures never changes the compiled program), the 3D highway's per-frame `getBoundingClientRect` layout read in its canvas-size self-check (now every 10th frame, still immediate on backing-store change), and the core 60 Hz HUD clock rewriting `textContent` on every tick (now write-on-change, ~1/s). The dominant residual — steady `getParameters` shader-program re-resolution (~4% of throttled main thread) — turned out to be Three r158+'s transparent-DoubleSide two-pass rendering, which sets `material.needsUpdate` twice per object per frame; all 18 of the 3D highway's transparent DoubleSide materials are flat unlit quads (labels, rails, chord frames, lanes), so they now declare `forceSinglePass: true`, eliminating the recompile churn and halving those objects' draw calls.
### Fixed
- **`playback.loop-api` bridge no longer fires dozens of times per second.** Every `window.feedBack.getLoop()` call recorded a full bridge hit — compat-shim bookkeeping, a `playback:bridge-hit` event, and a diagnostics snapshot rebuild + stringify — so a plugin polling loop state from a HUD tick (note_detect at ~30 Hz) flooded the capability inspector and burned main-thread time even with no song playing. `_recordPlaybackBridge` now throttles per bridge/surface (5 s window): bridge hits are a "surface still in use" signal, not a call counter. The manual A/B loop buttons (`setLoopEnd`) also now emit the same `loop-set` transport event as `setLoop()`, so plugins can react to loop changes via `playback:loop-set` / `playback:loop-cleared` events instead of polling `getLoop()`.
- **3D Highway: recover from a WebGL context loss instead of crashing on alt-tab.** Switching the active window / alt-tabbing away from the app (most often on Windows) can trigger a GPU context reset; the 3D highway's WebGL renderer had **no `webglcontextlost` handler**, so a lost context was left to escalate into a render-process crash — matching the intermittent "randomly crashes when I change windows" desktop reports. The renderer now binds `webglcontextlost`/`webglcontextrestored` on its own WebGL canvas (`ren.domElement`): the loss is `preventDefault()`'d so the browser keeps the context restorable, `draw()` bails while the context is down so no GL work runs on a dead context, and on restore the viewport is re-applied and rendering resumes (Three re-uploads scene resources on the next frame). Listeners are torn down with the renderer. `plugins/highway_3d` → 3.31.3. Tests: `tests/js/highway_3d_context_loss.test.js`. (The sibling `keys_highway_3d` / `drum_highway_3d` renderers share the same gap — tracked as a follow-up in their repos.)
- **Guitar Pro 6 (`.gpx`) import no longer fails on every real file.** The GPX BCFS container reader (`lib/gp2rs_gpx.py`) rejected any file whose final sector wasn't a full `0x1000` block — but a real `.gpx`'s BCFZ-declared decompressed size isn't sector-aligned, so the last (small) container file always lands in a partial trailing sector. The bounds check *raised* `GPX BCFS sector pointer out of range (malformed file)` instead of clamping the tail read, so `_load_gpif` threw before `score.gpif` could be extracted and **no GP6 file could be imported into the song editor** (both real test files failed identically — this wasn't file-specific). GP7/GP8 `.gp` files were unaffected — they take the ZIP path, not BCFS, which is why prior GP-import work didn't surface it. The reader now **clamps the final sector read to the buffer end** (the per-file size field trims the padding anyway), matching canonical GPX readers (alphaTab / PyGuitarPro); a sector whose *start* is past the end still raises, preserving the malformed-file guard. Verified against two real GP6 files — both now unpack to valid GPIF with all tracks. Tests: `tests/test_gp2rs_gpx.py` (partial-final-sector round-trip, multi-file container, sector-aligned baseline, and the preserved out-of-range guard).
- **v3 Songs grid: fixed the scroll stutter that "skips every so many scrolls," up or down.** The virtualized grid rebuilt its **entire** visible window (`grid.innerHTML = …` + a full `wireCards` pass) every time it slid by one row, so each row-boundary crossing was a heavy synchronous frame that stalled the main thread and buffered held-arrow key-repeats into a visible lurch (a tester's "super fast for a second then slowed down") at fixed scroll offsets — in **both directions and regardless of whether the page was already loaded** (the cost was DOM teardown, not fetching, which is why scrolling back up over cached songs hitched too). `renderWindow()` now **reconciles the window in place**: it reuses the card nodes that stay on-screen and builds only the row that enters/leaves (~6 nodes per slide instead of ~60), keyed by absolute index with a real-vs-skeleton + select-mode signature so hole-fills (after a page fetch) and select-mode toggles still rebuild exactly the nodes that changed. `wireCards`'s `data-wired` guard then wires only the freshly-built nodes, so per-slide listener churn drops with it. Follow-up to the stage-2 virtualized grid (got-feedback/feedBack#636 item 3). Frontend-only: `static/v3/songs.js`. Tests: `tests/js/v3_songs_window_recycle.test.js` (window stays `[start,end)` contiguous + in-window node identity reused across a down-then-up scroll; select-mode toggle and rail-seek jump rebuild correctly).
+2
View File
@@ -117,6 +117,8 @@ Notes:
**Frontend scripts** — `screen.js` runs in the global scope via a `<script>` tag. It can access `window.playSong`, `window.showScreen`, `window.createHighway`, the `<audio>` element, and the `window.feedBack` event emitter.
**ES-module plugins (`scriptType:"module"`)** — a plugin may instead ship a native ES-module graph with **no build step**: set `"scriptType": "module"` in `plugin.json`, make `screen.js` a one-line `import './src/main.js'`, and put the module tree under `src/` (served by the sandboxed `/api/plugins/<id>/src/{path}` route). The host injects it as `<script type="module">`, whose `onload` fires only after the whole static-import graph evaluates — so the loader's completion-by-`onload` + `_loadingPluginId` + `playSong` wrapper-chain ordering all hold. Resolve your own asset URLs (worklets, WASM) with `import.meta.url``document.currentScript` is `null` in a module. Module top-level code does **not** re-run when the user re-enters the screen at the same version (the host loads screen.js once and `showScreen` re-injects nothing), so keep per-visit re-init in a `screen:changed` handler, exactly as classic plugins do. Classic global-scope `screen.js` remains fully supported. See `docs/plugin-modules.md`.
**The playSong wrapper chain** — Plugins commonly wrap `window.playSong` to hook into song playback. Plugins load alphabetically, so the last-loaded (alphabetically later) wrapper runs first, while the alphabetically first plugin runs closest to the original. Be aware that `await` calls in inner wrappers yield to the event loop — WebSocket messages can arrive before outer wrappers finish setup.
## Plugin Best Practices
+67
View File
@@ -0,0 +1,67 @@
# Perf baseline — module-migration refactor
The refactor promises "measured runtime wins, no hand-waved perf claims" and
"screen-entry and frame-time no worse." This is the baseline to hold it to.
Rerun the harness after every phase (R0 → R3c) and compare.
## Running it
```
# 1. start core against a library with real charts (see caveat below)
CONFIG_DIR=… DLC_DIR=/path/to/songs PYTHONPATH=lib \
python3 -m uvicorn server:app --host 127.0.0.1 --port 8000
# 2. capture (maintainer/CI-only; uses the committed Playwright chromium)
node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 --n 60 --soak 30
```
The script prints a markdown block; paste it under "Results" below with the date
and the commit it was taken at.
## What it measures
- **Server latency** — p50/p95/p99 over N requests for `/api/version`,
`/api/plugins`, `/api/library`, `/api/library/artists`.
- **Cold boot → interactive** — full page load to `networkidle`.
- **JS heap**`performance.memory.usedJSHeapSize` after load and after an idle
soak (a leak signal across a session).
- **Plugin-script shape** — how many plugin `<script>`s the loader injected (a
"the app booted with its plugins" sanity signal).
**Not yet captured — needs a seeded library with charts** (fill in when run
against a real environment): playback **frame-time p95** on the 2D and 3D
highway, and **screen-entry** (plugin inject → interactive) for
editor / notedetect / highway_3d with a chart loaded. These are the
perf-sensitive numbers that gate the `highway.js` split (R3c); the harness has
the hooks, they just need real songs in `DLC_DIR`.
## Results
### R0 baseline — 2026-07-08 (branch `feat/r0-plugin-module-rails`)
> ⚠️ A quick capture (`--n 50 --soak 8`) against an **empty** library (no charts
> in `DLC_DIR`), so the `/api/library*` and boot numbers are floor values —
> re-take on a seeded environment with the recommended `--n 60 --soak 30` for the
> real R0 baseline before comparing R1+ against it. Recorded here to prove the
> harness and lock the methodology.
Server latency (ms), n=50:
| Endpoint | status | p50 | p95 | p99 |
|---|---|---|---|---|
| `/api/version` | 200 | 0.9 | 1.8 | 22.3 |
| `/api/plugins` | 200 | 1.6 | 2.1 | 3.4 |
| `/api/library?limit=60` | 200 | 1.4 | 1.7 | 2.9 |
| `/api/library/artists` | 200 | 1.3 | 1.8 | 2.7 |
Client:
| Metric | Value |
|---|---|
| Cold boot → networkidle | 1268 ms |
| JS heap after load | 10.1 MB |
| JS heap after idle soak | 10.1 MB (no idle growth) |
| Plugin scripts injected | 12 |
No plugin has migrated yet, so all 12 are classic. When the R1 pilot (stems)
lands, cold-boot / heap should not regress.
+103
View File
@@ -0,0 +1,103 @@
# Plugin ES-module migration playbook
How to move a plugin off a single global-scope `screen.js` IIFE onto a native
ES-module graph — **no build step, no framework, no bundler**. This is the
mechanism the monolith-killing refactor uses; the host rails for it shipped in
R0 (see `.specify/memory/constitution.md` Principle II + the "Module load
contract" in Operating Constraints).
## The shape
```
my-plugin/
plugin.json + "scriptType": "module" ← opt in
screen.js import './src/main.js'; ← the entire file
src/
state.js (0) module state + accessors
util/… (1) pure helpers — real-import testable
…/… (2..4) model → render/audio/io → input
globals.js (5) THE ONLY file that writes window.*
main.js (5) boot: wire modules, register screen:changed
assets/… worklets / WASM / images (unchanged, served as today)
```
`screen.js` becomes a one-line static `import`. The host injects it as
`<script type="module">`, whose load event fires **only after the whole
static-import graph fetches and evaluates** — so the loader's
completion-by-`onload` + `_loadingPluginId` window + `playSong` wrapper-chain
order are all preserved. (A classic IIFE that fired a fire-and-forget
`import()` would break that contract — don't do that; use `scriptType:"module"`.)
## Non-negotiable rules
1. **Source-served, no build.** Modules are plain source files fetched from
`/api/plugins/<id>/src/<path>`. No bundler, transpiler, or TypeScript.
2. **Layering points downward** — `state → util → commands/model →
render/audio/io → input → globals/main`. A lint check (`import-x/no-cycle`)
enforces acyclicity; extract bottom-up so each move only imports
already-extracted layers.
3. **`globals.js` is the only writer of `window.*`.** The deliberate global
surface shrinks to one auditable file; everything else is module-scoped.
4. **Import-time purity.** `node --test` runs a module's top-level code on
import, so a module you want to unit-test must be side-effect-free at import:
no `document` / `window` / `localStorage` at module top level — lift init
into an exported `init()` called by `main.js`. (Constitution Principle V's
"no implicit IO at import time", applied to the frontend.) Tests are `.mjs`
and use real `import`, retiring the regex/`extractFunction` harness.
5. **Assets resolve via `import.meta.url`.** `document.currentScript` is `null`
inside a module. `assets/` lives at the plugin root, so a `src/` module must
climb out of `src/`: from `src/main.js`, `new URL('../assets/x.js',
import.meta.url)` (deeper modules need more `../`). Simpler and
depth-independent: the absolute route `/api/plugins/<id>/assets/x.js`.
Worklets run in a *separate* module graph (`AudioWorkletGlobalScope`) and
cannot share modules with `src/`.
6. **Re-init comes from `screen:changed`, not re-execution.** The host loads
`screen.js` once per version and `showScreen` re-injects nothing, so module
top-level code does **not** re-run when the user re-enters the screen at the
same version. Keep per-visit setup/teardown in a `window.feedBack.on(
'screen:changed', …)` handler — exactly as classic plugins (tuner,
minigames) already do. Do not rely on the IIFE re-running.
7. **Inline `onclick=` keeps working** during migration via `globals.js` (which
keeps every referenced symbol on `window`); retire inline handlers to
module-side `addEventListener` opportunistically, never as a blocking step.
## The live-edit loop
The host serves `screen.js`, `src/**`, and `assets/**` with
`Cache-Control: no-cache` + a weak `ETag` and honors `If-None-Match``304`.
So: edit a `src/` file → **refresh the browser** → the edited module returns
`200` and reloads while every unchanged module `304`s. There is no hot-reload;
the loop is edit → refresh → see change, exactly as before. The `?v=<version>`
query on `screen.js` is the legacy version buster; it does **not** propagate
into the `src/` graph and does not need to — ETag/mtime is the correctness
authority for the whole graph.
## Host-version floor (`minHost`)
A migrated plugin *requires* a host new enough to serve `src/` and inject
`type=module`. Declare the floor with `"minHost": "X.Y.Z"` in `plugin.json`.
(R0 plumbs the field through `/api/plugins`; enforcement — refuse-with-message
on an older host — is deferred, so bundled plugins are unaffected. Community
plugins should state the floor and not migrate below it.)
## Migration mechanics
- **Move-only PRs.** One slice extracts one module: cut code, add
imports/exports, update `globals.js` — zero behavior change. Behavior fixes
are separate PRs. (Init-lifts for import purity are the one non-pure move —
budget them.)
- **Bottom-up, layer by layer.** Within a layer, independent modules are
independent PRs (a DAG, not a chain); use a git worktree per branch.
- Tests move with their subject and convert to real `.mjs` imports in the same
PR (assertions unchanged).
- Size norm: no source file over **1,500 lines**; legitimate exceptions
(hot renderers, etc.) go in the signed register at `docs/size-exemptions.md`.
## Verifying a migration
`node --test <plugin>/tests/*.mjs`; load the plugin on the `:8000` testbed and
confirm it boots (`<script type=module>` in DevTools, the `src/` graph in
Network); edit a `src/` file → refresh → change visible (`200` on the edited
file, `304` on the rest); leave and re-enter the screen at the same version →
it re-inits via `screen:changed`. The R1 pilots (stems, then studio) certify
this end-to-end before the flagship repos migrate.
+62
View File
@@ -0,0 +1,62 @@
# Size-exemption register
The working norm (constitution Principle II; enforced by the `max-lines` lint
gate) is **no source file over 1,500 lines**. A few files are allowed to exceed
it because splitting them would do more harm than good — hot per-frame
renderers, C++, offline generators, cohesive registries. This register is the
list of those exceptions: each row is a **deliberate, signed** decision with a
ceiling, a rationale, and a review trigger. Without it, "no file over 1,500
without a *signed* exemption" is unenforceable.
**Rules**
- One row per file: a ceiling, a rationale, a signer, a review trigger.
- The `max-lines` per-file ceilings in `eslint.config.js` mirror this table —
keep them in sync (this register is canonical).
- Files with a scheduled split **plan** are *not* exempt — they live in
"Planned, not exempt" at the bottom so nothing falls between the two states.
- **Signers** (decided 2026-07-08): **Byron** signs core + bundled rows;
**Christian** signs the authored-plugin row (virtuoso, its own repo/track).
## Permanent exemptions (structural rationale)
| Repo / file | Lines (7-07) | Ceiling | Rationale | Signer | Review |
|---|---|---|---|---|---|
| core `static/highway.js` → residual `renderer-2d.js` (post-split) | ~2,4002,900 est. | **3,000** | 60 fps hot path; no module boundary inside the per-frame loop | Byron | after the highway.js split |
| core `plugins/highway_3d/` → residual renderer | sized at split; likely **>3,000** | set at split, flagged now | same hot-path rule; the draw core can't be cut without behavior risk | Byron | after the highway_3d split |
| core `static/capabilities.js` | 1,538 | 1,600 | cohesive registry + `window.feedBack` bus, 38 lines over; a split spends credibility for nothing | Byron | R4 |
| tutorials `builtin/reading-the-highway/generate.py` | 1,818 | 2,000 | offline content generator, never imported at runtime, deps not in runtime requirements | Byron | if a 3rd builtin pack appears |
| desktop `src/audio/NodeAddon.cpp` | 3,542 | as-is | C++, outside the ESM/routes playbooks; under active use-after-free crash work — do not churn | Byron | after crash-class work settles |
| desktop `src/audio/AudioEngine.cpp` | 2,977 | as-is | same | Byron | same |
| desktop `src/vst-host/main.cpp` | 1,928 | as-is | same | Byron | same |
| virtuoso `screen.js` (authored, own track) | 25,741 | as-is until its own split | authored plugin on a separate roadmap; migrates on its own schedule | Christian | virtuoso split kickoff |
## Split-when-touched (no scheduled train; row retires when split)
| Repo / file | Lines | Ceiling | Rationale | Signer | Review |
|---|---|---|---|---|---|
| core `lib/gp2rs_gpx.py` | 2,540 | as-is | import converter, off the serve-path hot loop | Byron | when next touched |
| core `lib/gp2rs.py` | 2,055 | as-is | same | Byron | when next touched |
| core `lib/song.py` | 1,689 | as-is | data models + wire format; cohesive | Byron | when next touched |
| core `lib/gp_autosync.py` | 1,572 | as-is | under active dev (#787/#791) — don't collide | Byron | after in-flight work lands |
| core `plugins/capability_inspector/screen.js` | 1,752 | as-is | bundled diagnostics plugin, low churn | Byron | when next touched |
| core `plugins/folder_library/screen.js` | 1,672 | as-is | bundled plugin, low churn | Byron | when next touched |
## Temporary rows (cleared by a scheduled PR)
| Repo / file | Lines | Cleared by |
|---|---|---|
| core `plugins/__init__.py` | ~2,470 (grew under R0) | the `plugins/_routes.py` + `plugins/_registry.py` split (rides the server.py router work) |
## Watch list (under the norm — no row needed, re-census each phase)
`musicxml-import/mxml2notation.py` (1,456) · core `static/capabilities/audio-effects.js`
(1,436) · `studio routes.py` (1,399) · `update-manager screen.js` (1,492 — zero headroom).
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
core `static/app.js` (11,821) · `static/highway.js` (4,154, whole file) · `server.py`
(13,948) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) — and every monolith with a PR
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
by policy — the norm governs source files.
+66
View File
@@ -0,0 +1,66 @@
// Flat ESLint config — MAINTAINER / CI ONLY. Never runs on the serve or Docker
// path (constitution Principle I: dev-only tooling is exempt, same category as
// scripts/build-tailwind.sh). It enforces the module-migration guardrails:
//
// * max-lines — the 1,500-line size norm, as a WARNING ratchet. Legacy
// monoliths warn (the "this is over the norm, split it" signal) and shrink
// as the refactor lands; warnings do not fail CI. Genuinely-large files are
// exempted below, mirroring the signed register in docs/size-exemptions.md.
// * import-x/no-unresolved + no-cycle — module hygiene, scoped to the real
// ES-module graphs the refactor produces (a plugin's src/ tree, .mjs
// tests). no-unresolved (a HARD error) catches broken import paths;
// no-cycle enforces the downward-only layering rule. Core's classic scripts
// have no import graph, so both are dormant today and become live gates the
// moment module code appears — validated against the first real module
// plugin (R1 pilot).
const importX = require('eslint-plugin-import-x');
// Per-file size ceilings — a mirror of docs/size-exemptions.md (canonical).
// Keep in sync; each entry corresponds to a signed row in the register.
const SIZE_EXEMPTIONS = [
{ files: ['**/static/capabilities.js'], max: 1600 },
{ files: ['**/plugins/capability_inspector/screen.js'], max: 100000 },
{ files: ['**/plugins/folder_library/screen.js'], max: 100000 },
];
const sizeRule = (max) => ['warn', { max, skipBlankLines: false, skipComments: false }];
module.exports = [
{
ignores: [
'node_modules/**',
'static/vendor/**',
'plugins/**/assets/vendor/**',
'**/*.min.js',
'static/tailwind.min.css',
],
},
// Size norm across all first-party JS. Classic scripts are parsed as
// scripts (no import/export); module files get their own block below.
{
files: ['**/*.js', '**/*.cjs'],
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
rules: { 'max-lines': sizeRule(1500) },
},
// ES-module graphs (a plugin's src/ tree, .mjs tests): module parsing + the
// acyclic-imports hard gate + the size norm. A migrated bundled plugin's
// entry `import './src/main.js'` screen.js must parse as a module — add its
// glob here in that plugin's migration PR (classic screen.js stays a script).
{
files: ['**/src/**/*.js', '**/*.mjs'],
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
plugins: { 'import-x': importX },
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
// it the import rules silently skip imports they can't resolve.
settings: { 'import-x/resolver-next': [importX.createNodeResolver()] },
rules: {
'max-lines': sizeRule(1500),
'import-x/no-unresolved': 'error',
'import-x/no-cycle': 'error',
},
},
// Signed size exemptions (docs/size-exemptions.md) — raise the ceiling so
// registered files don't warn below it.
...SIZE_EXEMPTIONS.map(({ files, max }) => ({ files, rules: { 'max-lines': sizeRule(max) } })),
];
+33 -9
View File
@@ -1836,9 +1836,10 @@ def convert_drum_track_to_drumtab(
drum strings. Unknown percussion sounds (cowbell, tambourine etc.) are
skipped round-tripping them would require teaching `lib/drums.py` first.
Callers can pass an empty dict as ``out_unmapped`` to receive a per-MIDI
record of every skipped note (``{midi: {"count": int, "times": [...]}}``,
times capped at 100 samples per note) so they can surface a warning or
offer a manual mapping UI.
record of every skipped note (``{midi: {"count": int, "times": [...],
"velocities": [...]}}``, times/velocities index-aligned and capped at
100 samples per note velocities carry the source notes' real dynamics)
so they can surface a warning or offer a manual mapping UI.
Honours GP repeat brackets and D.S./D.C./Coda/Fine jumps when
``expand_repeats`` is true same `_build_playback_schedule` machinery
@@ -1894,18 +1895,29 @@ def convert_drum_track_to_drumtab(
# NB: do NOT shadow the outer `entry` loop
# variable from `for entry in schedule:`.
unmapped_rec = out_unmapped.setdefault(
int(midi_note), {"count": 0, "times": []})
int(midi_note),
{"count": 0, "times": [], "velocities": []})
unmapped_rec["count"] += 1
if len(unmapped_rec["times"]) < 100:
unmapped_rec["times"].append(round(t, 3))
# Index-aligned with times: the note's real
# dynamics (same 1-127 gate as mapped hits,
# falling back to the 100 import default) so
# a hand-mapping UI doesn't flatten them.
_uv = int(getattr(note, "velocity", 0) or 0)
unmapped_rec["velocities"].append(
_uv if 1 <= _uv <= 127 else 100)
continue
hit: dict = {"t": round(t, 3), "p": piece}
# Velocity: GP stores 1-127 MIDI velocity directly; default
# is 95 (Velocities.default). Pass through verbatim,
# clamping defensively so a corrupt file can't poison the
# wire format.
# Velocity: GP stores 1-127 MIDI velocity directly. Note
# this is GP's *authoring* default (95, Velocities.default)
# — unrelated to the drumtab render default of 100
# (DEFAULT_VELOCITY, lib/drums.py:179), which only applies
# when `v` is omitted from a hit. Pass the GP value through
# verbatim, clamping defensively so a corrupt file can't
# poison the wire format.
vel = int(getattr(note, "velocity", 0) or 0)
if 1 <= vel <= 127:
hit["v"] = vel
@@ -1946,9 +1958,21 @@ def convert_drum_track_to_drumtab(
# Times for unmapped notes were collected in beat-iteration order;
# multi-voice measures can produce out-of-order beats, so sort each
# entry's `times` list chronologically before returning to the caller.
# Velocities are index-aligned with times, so they must sort in
# LOCKSTEP — sorting times alone would silently reassign dynamics.
if out_unmapped is not None:
for _rec in out_unmapped.values():
_rec["times"].sort()
_vels = _rec.get("velocities")
if _vels and len(_vels) == len(_rec["times"]):
_pairs = sorted(zip(_rec["times"], _vels))
_rec["times"] = [p[0] for p in _pairs]
_rec["velocities"] = [p[1] for p in _pairs]
else:
# Belt-and-suspenders: times & velocities are always appended
# together under the same `len(times) < 100` guard above, so
# in practice the lengths can't diverge. Kept as a defensive
# fallback, not a real divergence case.
_rec["times"].sort()
return {
"version": drums_mod.SCHEMA_VERSION,
+160 -7
View File
@@ -203,7 +203,13 @@ def convert_midi_track_to_keys_wire(
# a foreign track's tempo events do NOT apply to the chosen
# track. Merging would mis-time the notes — restrict the tempo
# scan to the selected track only.
ticks_per_beat = midi.ticks_per_beat
# ``ticks_per_beat`` is 0 for a malformed header and NEGATIVE for SMPTE
# division (mido returns the signed short as-is). Both feed the two
# divisions below (tempo-table build + tick_to_seconds), so guard here:
# 0 would raise ZeroDivisionError and a negative value would yield
# negative/garbage times. Use ``> 0`` (not ``or``) so the negative SMPTE
# case also falls back to the SMF default.
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
midi_type = getattr(midi, "type", 1)
tempo_source = (
@@ -352,7 +358,14 @@ def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[i
- type 1: parallel tracks share the timeline; merge tempo events.
- type 2: independent timelines; tempo only from the chosen track.
"""
ticks_per_beat = midi.ticks_per_beat
# A metrical header carries positive ticks-per-beat. mido reads the SMF
# division as a signed short, so an SMPTE-division file surfaces as a
# negative value and a malformed header as 0 — both make the two division
# sites below divide by a non-positive number (ZeroDivisionError, or
# negative seconds that send the bar walk off the rails). Fall back to the
# SMF default here, the single place every caller routes ticks through, so
# each caller's own fallback is real rather than cosmetic.
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
midi_type = getattr(midi, "type", 1)
tempo_source = (
@@ -393,6 +406,141 @@ def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[i
return tick_to_seconds
# Safety valve for the bar walk below: a malformed SMF (absurd tempo + long
# trailing meta) could otherwise imply millions of bars. Real charts sit
# orders of magnitude below this.
_TEMPO_MAP_MAX_BARS = 20000
def convert_midi_tempo_map(midi_path: str, track_index: int = 0) -> dict:
"""Extract the song-timeline grid a `.mid` file carries: tempos, time
signatures, and a full beat grid the data the note converters here
always computed internally (to bake note times) and then threw away,
which left every MIDI import with no bars, no measures, and an implied
4/4 no matter what the file said.
Returns ``{"tempos": [...], "time_signatures": [...], "beats": [...]}``:
- ``tempos``: ``{time, bpm}`` per tempo event (deduped per tick).
- ``time_signatures``: ``{time, ts: [num, den]}`` per signature event
the song-timeline sidecar shape (feedpak-spec §7.4).
- ``beats``: one row per beat on the editor grid shape downbeats carry
a running ``measure`` (1, 2, 3, ) plus a ``den`` hint (the signature
denominator), interior beats carry ``measure: -1``. The beat unit
follows the active signature (6/8 six eighth-note rows per bar).
Event scope mirrors ``_build_tick_to_seconds``: SMF type 0/1 merge meta
from all tracks (shared timeline); type 2 reads ONLY ``track_index``
(independent timelines callers must never share one grid across
type-2 tracks). Signature changes apply at the NEXT bar boundary when a
file places one mid-bar (ill-formed but seen in the wild). All times
are computed from absolute ticks through the cumulative tempo table and
rounded once at emit rounding error never accumulates with song
length. An SMF with no note events yields empty ``beats``.
"""
midi = mido.MidiFile(midi_path)
# Positive for metrical files; 0 (malformed) or negative (SMPTE division,
# read as a signed short) otherwise — fall back so beat_ticks below stays
# sane, mirroring the guard inside _build_tick_to_seconds.
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
midi_type = getattr(midi, "type", 1)
# Same scope both converters use: type 2 reads only the chosen track
# (independent timelines); type 0/1 merge all tracks (shared timeline).
source_tracks = (
[midi.tracks[track_index]] if midi_type == 2 else midi.tracks
)
tick_to_seconds = _build_tick_to_seconds(midi, track_index)
# ── collect meta + the end of musical content in one pass ────────────
sig_events: list[tuple[int, int, int]] = []
tempo_events: list[tuple[int, int]] = []
end_tick = 0
for tr in source_tracks:
abs_tick = 0
for msg in tr:
abs_tick += msg.time
if msg.type == "time_signature":
num = int(getattr(msg, "numerator", 4) or 4)
den = int(getattr(msg, "denominator", 4) or 4)
if num > 0 and den > 0:
sig_events.append((abs_tick, num, den))
elif msg.type == "set_tempo":
tempo_events.append((abs_tick, int(msg.tempo)))
elif msg.type in ("note_on", "note_off"):
end_tick = max(end_tick, abs_tick)
# Dedupe at equal ticks (last wins), matching the tempo-table rule.
sig_events.sort(key=lambda e: e[0])
sigs: list[tuple[int, int, int]] = []
for ev in sig_events:
if sigs and sigs[-1][0] == ev[0]:
sigs[-1] = ev
else:
sigs.append(ev)
if not sigs or sigs[0][0] > 0:
sigs.insert(0, (0, 4, 4))
tempo_events.sort(key=lambda e: e[0])
seen_tempo_ticks: dict[int, int] = {}
for ev_tick, ev_tempo in tempo_events:
seen_tempo_ticks[ev_tick] = ev_tempo
sorted_tempo_ticks = sorted(seen_tempo_ticks)
tempos_out: list[dict] = []
# Seed the MIDI default (120 BPM) at time 0 when the first tempo event
# lands after the start (or there are none). The beat grid already runs
# at 120 for the head of the song, so the sidecar must say so too —
# symmetric with the (0, 4, 4) default seeded into the signatures above.
if not sorted_tempo_ticks or sorted_tempo_ticks[0] > 0:
tempos_out.append({"time": 0.0, "bpm": 120.0})
for ev_tick in sorted_tempo_ticks:
tempos_out.append({
"time": round(tick_to_seconds(ev_tick), 3),
"bpm": round(60_000_000.0 / seen_tempo_ticks[ev_tick], 3),
})
time_signatures_out = [
{"time": round(tick_to_seconds(t), 3), "ts": [num, den]}
for t, num, den in sigs
]
# ── walk bars from tick 0 to the end of the notes ────────────────────
beats: list[dict] = []
if end_tick > 0:
cur_tick = 0.0
measure = 1
sig_idx = 0
while cur_tick < end_tick and measure <= _TEMPO_MAP_MAX_BARS:
# Active signature: the latest event at or before this bar's
# start. Mid-bar events wait for the next boundary by
# construction (we only re-read between bars).
while (sig_idx + 1 < len(sigs)
and sigs[sig_idx + 1][0] <= cur_tick + 1e-6):
sig_idx += 1
_, num, den = sigs[sig_idx]
beat_ticks = ticks_per_beat * 4.0 / den
beats.append({
"time": round(tick_to_seconds(int(round(cur_tick))), 3),
"measure": measure,
"den": den,
})
for k in range(1, num):
sub_tick = cur_tick + k * beat_ticks
if sub_tick >= end_tick:
break
beats.append({
"time": round(tick_to_seconds(int(round(sub_tick))), 3),
"measure": -1,
})
cur_tick += num * beat_ticks
measure += 1
return {
"tempos": tempos_out,
"time_signatures": time_signatures_out,
"beats": beats,
}
# ── Drum track listing (channel-9 only) ──────────────────────────────────────
# Velocity below this is treated as a ghost note. GM doesn't have an explicit
@@ -486,10 +634,12 @@ def convert_drum_track_from_midi(
Callers can pass an empty dict as ``out_unmapped`` to receive a
per-MIDI record of every channel-9 note_on that didn't resolve to a
piece-id (``{midi: {"count": int, "times": [float, ...]}}``, times
capped at 100 samples per note). The default path skips this
capture entirely so MIDIs heavy with cowbell/tambourine/etc. take
no extra work.
piece-id (``{midi: {"count": int, "times": [float, ...],
"velocities": [int, ...]}}``, times/velocities index-aligned and
capped at 100 samples per note velocities carry the source notes'
real dynamics so a hand-mapping UI doesn't have to flatten them to a
default). The default path skips this capture entirely so MIDIs
heavy with cowbell/tambourine/etc. take no extra work.
"""
offset = float(audio_offset)
if not math.isfinite(offset):
@@ -527,10 +677,13 @@ def convert_drum_track_from_midi(
continue
t = tick_to_seconds(abs_tick) + offset
entry = out_unmapped.setdefault(
midi_note, {"count": 0, "times": []})
midi_note, {"count": 0, "times": [], "velocities": []})
entry["count"] += 1
if len(entry["times"]) < 100:
entry["times"].append(round(t, 3))
# Index-aligned with times: the note's real dynamics,
# so hand-mapping doesn't flatten everything to 100.
entry["velocities"].append(int(msg.velocity))
continue
# Mapped note: compute t once for the raw entry.
t = tick_to_seconds(abs_tick) + offset
+1758 -1
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -8,9 +8,12 @@
"test:headed": "playwright test --headed",
"test:debug": "playwright test --debug",
"test:js": "node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js'",
"install:playwright": "playwright install chromium"
"install:playwright": "playwright install chromium",
"lint": "eslint ."
},
"devDependencies": {
"@playwright/test": "^1.59.1"
"@playwright/test": "^1.59.1",
"eslint": "^9.39.4",
"eslint-plugin-import-x": "^4.17.1"
}
}
+99 -15
View File
@@ -18,6 +18,54 @@ from safepath import safe_join
log = logging.getLogger("feedBack.plugins")
def _plugin_media_type(path: Path) -> str:
"""Best-effort Content-Type for a served plugin file. `.js`/`.css` must come
back as JavaScript/CSS so `<script type=module>` / `addModule()` / a `<link>`
accept them; `mimetypes.guess_type` can miss these on a stripped platform
registry, so fall back explicitly (mirrors the assets/ route)."""
media_type = mimetypes.guess_type(path.name)[0]
if media_type is None and path.suffix == ".js":
return "application/javascript"
if media_type is None and path.suffix == ".css":
return "text/css"
return media_type or "application/octet-stream"
def _plugin_file_etag(path: Path) -> str | None:
"""Weak ETag from mtime+size — cheap, stable across reads, changes on edit.
This is what makes the live-edit loop work for module graphs: a conditional
GET revalidates and 304s unchanged files on refresh instead of re-downloading
the whole `src/` tree. Returns None if the file can't be stat'd."""
try:
st = path.stat()
except OSError:
return None
return f'W/"{st.st_mtime_ns:x}-{st.st_size:x}"'
def _if_none_match(request: Request, etag: str) -> bool:
"""True when the client's If-None-Match already holds `etag`."""
# ponytail: we serve one weak ETag; the browser echoes it back verbatim, so
# a direct compare is enough (comma-split tolerates a proxy concatenation).
return etag in [t.strip() for t in request.headers.get("if-none-match", "").split(",")]
def _plugin_file_response(request: Request, path: Path, media_type: str) -> Response:
"""Serve a plugin source/asset file with the live-edit cache contract:
`Cache-Control: no-cache` (browser may store but MUST revalidate) + a weak
ETag, and a bodyless 304 when the client's If-None-Match already matches.
Starlette's `FileResponse` emits an ETag but never evaluates If-None-Match
itself, so the conditional handling has to live here."""
headers = {"Cache-Control": "no-cache"}
etag = _plugin_file_etag(path)
if etag:
headers["ETag"] = etag
if _if_none_match(request, etag):
return Response(status_code=304, headers=headers)
# FileResponse sets etag/last-modified via setdefault, so the ETag above wins.
return FileResponse(path, media_type=media_type, headers=headers)
PLUGINS_DIR = Path(__file__).parent
# Holds only *ready* (loaded) plugins — those whose dependencies installed
# and whose routes registered. A plugin GRADUATES from PENDING_PLUGINS into
@@ -1373,6 +1421,12 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
"version": manifest.get("version"),
"has_screen": bool(manifest.get("screen")),
"has_script": bool(manifest.get("script")),
# Module-migration (R0): `scriptType:"module"` tells the loader to
# inject screen.js as <script type="module">; `minHost` is the
# min core version a migrated plugin needs (passthrough only in R0 —
# enforcement is deferred to R4, master §4b). None when unset.
"script_type": manifest.get("scriptType"),
"min_host": manifest.get("minHost"),
"has_settings": bool(manifest.get("settings")),
"settings_category": _settings_category,
# Drives the v3 shell's immersive (full-screen) mode for this
@@ -2089,6 +2143,11 @@ def register_plugin_api(app: FastAPI):
"fallback": p.get("fallback", False),
"has_screen": p["has_screen"],
"has_script": p["has_script"],
# Module-migration passthrough (R0). Re-read from the manifest
# like `version` above so stubbed test entries (built without
# _nav_entry) don't need the key.
"script_type": (p.get("_manifest") or {}).get("scriptType"),
"min_host": (p.get("_manifest") or {}).get("minHost"),
"has_settings": p["has_settings"],
# v3 immersive screen opt-in (full-screen plugin UI).
"fullscreen": p.get("fullscreen", False),
@@ -2142,6 +2201,9 @@ def register_plugin_api(app: FastAPI):
"fallback": False,
"has_screen": e.get("has_screen", False),
"has_script": e.get("has_script", False),
# Pending entries come from _nav_entry, so they carry these.
"script_type": e.get("script_type"),
"min_host": e.get("min_host"),
"has_settings": e.get("has_settings", False),
"settings_category": e.get("settings_category"),
"fullscreen": e.get("fullscreen", False),
@@ -2307,7 +2369,7 @@ def register_plugin_api(app: FastAPI):
return HTMLResponse("", status_code=404)
@app.get("/api/plugins/{plugin_id}/screen.js")
def plugin_screen_js(plugin_id: str):
def plugin_screen_js(request: Request, plugin_id: str):
with PLUGINS_LOCK:
snapshot = list(LOADED_PLUGINS)
for p in snapshot:
@@ -2315,8 +2377,11 @@ def register_plugin_api(app: FastAPI):
if p.get("status", "ready") != "ready":
break
script_file = p["_dir"] / p["_manifest"].get("script", "screen.js")
if script_file.exists():
return Response(script_file.read_text(encoding="utf-8"), media_type="application/javascript")
if script_file.is_file():
# no-cache + ETag/304 so an edited screen.js reloads on
# refresh while an unchanged one revalidates cheaply — the
# same live-edit contract the src/ module graph relies on.
return _plugin_file_response(request, script_file, "application/javascript")
return Response("", status_code=404)
@app.get("/api/plugins/{plugin_id}/settings.html")
@@ -2377,7 +2442,7 @@ def register_plugin_api(app: FastAPI):
return Response("{}", status_code=404, media_type="application/json")
@app.get("/api/plugins/{plugin_id}/assets/{asset_path:path}")
def plugin_asset(plugin_id: str, asset_path: str):
def plugin_asset(request: Request, plugin_id: str, asset_path: str):
"""Serve a static file a plugin bundles under its own ``assets/``
directory (e.g. an AudioWorklet module, WASM, or image). Unlike the
fixed screen.js/settings.html handlers above, this is a generic
@@ -2399,16 +2464,35 @@ def register_plugin_api(app: FastAPI):
log.warning("Plugin %r: asset path rejected: %r", plugin_id, asset_path)
break
if target.is_file():
media_type = mimetypes.guess_type(target.name)[0]
# .js must come back as JavaScript so addModule() / <script>
# accept it; guess_type can miss this on some platforms.
if media_type is None and target.suffix == ".js":
media_type = "application/javascript"
# .css must come back as text/css so a <link rel=stylesheet>
# (the styles capability) is honoured; guess_type can miss it
# on a stripped platform mimetypes registry, same as .js.
elif media_type is None and target.suffix == ".css":
media_type = "text/css"
return FileResponse(target, media_type=media_type or "application/octet-stream")
# no-cache + ETag/304 so a live-edited worklet/asset reloads
# on refresh (bare FileResponse emits an ETag but never 304s).
return _plugin_file_response(request, target, _plugin_media_type(target))
break
return Response("", status_code=404)
@app.get("/api/plugins/{plugin_id}/src/{src_path:path}")
def plugin_src(request: Request, plugin_id: str, src_path: str):
"""Serve a file from a plugin's ES-module source tree under ``src/``.
This is the R0 host capability that lets a migrated plugin's
``screen.js`` (a one-line ``import './src/main.js'``) load its whole
module graph. Containment mirrors the assets/ route exactly
``safe_join`` against ``<plugin>/src`` rejects ``..``, absolute paths,
and NUL bytes and the live-edit cache contract (no-cache + ETag/304)
makes an edited module reload on refresh while unchanged ones 304.
Read-only; the src/ tree is source files, never executed server-side.
"""
with PLUGINS_LOCK:
snapshot = list(LOADED_PLUGINS)
for p in snapshot:
if p["id"] == plugin_id:
if p.get("status", "ready") != "ready":
break
target = safe_join(p["_dir"] / "src", src_path)
if target is None:
log.warning("Plugin %r: src path rejected: %r", plugin_id, src_path)
break
if target.is_file():
return _plugin_file_response(request, target, _plugin_media_type(target))
break
return Response("", status_code=404)
+23 -2
View File
@@ -3,13 +3,34 @@
RS+-style falling-note 3D piano highway for [Slopsmith](https://github.com/got-feedback/feedback), fed by the **Sloppak Notation Format** (sloppak-spec §5.3) — part of the piano/keys first-class epic (slopsmith#828, plugin workstream slopsmith#824).
- Consumes the `notation_info` / `notation_measures` highway-WS stream over a private per-instance socket and flattens measure → staff → voice → beat → note into `{midi, t, durSec, hand}` (durations derived from written `dur`/`dot`/`tu` at the running tempo; ties extend; overlap-clamped).
- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colours** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue. Selectable **note-colour palettes** (settings → Note colours, `keys3d_bg_palette`, default the per-octave scheme): a per-octave rainbow (each octave its own hue, darker sharps), the original per-pitch "Rainbow" table, vivid/pastel per-pitch variants, and single-hue two-tone palettes (uniform naturals, darker sharps) for players who want "black key coming" to read at a glance; notes, key glow, lane guides and hit flames all follow the pick live.
- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colors** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue. Selectable **note-color palettes** (settings → Note colors, `keys3d_bg_palette`, default the per-octave scheme): a per-octave rainbow (each octave its own hue, darker sharps), the original per-pitch "Rainbow" table, vivid/pastel per-pitch variants, and single-hue two-tone palettes (uniform naturals, darker sharps) for players who want "black key coming" to read at a glance; notes, key glow, lane guides and hit flames all follow the pick live.
- Full RS+ visual treatment: key **letter glyphs** printed on the active-range key tops (cached CanvasTextures), **bevelled gem-style note blocks** (ExtrudeGeometry, geometry/material caches keyed by size and pitch-class×hand), **floating bar numbers** scrolling with the notes, **active-range lane dimming** so the playable span pops, and a **glowing pulsing hit-line** (layered additive gradient planes — no postprocessing).
- Performance discipline: no per-frame allocations or DOM queries in `draw()`. Chart-scoped resources — note geometries/materials, bar-number and glow textures — are cached and disposed on chart teardown; the key-letter glyph `CanvasTexture`s live in a shared module-level cache that survives teardown and is reused across instances.
- Auto-selected for arrangements with notation via `matchesArrangement(songInfo.has_notation)`; capability-native `visualization` provider declaration.
- **Camera settings**: camera-rig presets (`keys3d_bg_camera` — classic low rig / elevated / overhead; default overhead, applied live, adaptive pan-zoom preserved) with base-rig fine-tune sliders for height, distance and tilt (`keys3d_bg_camHeight` / `camDist` / `camTilt`) that nudge the vantage point the follow-motion orbits. Numeric FX keys clamp to per-key declared ranges (`FX_RANGES`, default 01).
- **Highway-layout options** (settings → Highway layout). **Sharps & flats**
(`keys3d_bg_sharpMode`, string; default `realistic`) picks the sharp layout:
`floating` (original raised-plane sharps, white-only lanes); `flat` (one plane,
zero-overlap piano-shaped tiled lanes — white lanes trimmed where a sharp adjoins
them, and each sharp leaned toward the edge natural beside it so the naturals come
out close to even: C/D/E/F/B equal, G/A a hair smaller since G# can't lean; pure
`laneSpanFlat()`); `realistic` (one plane, bars sized like the physical keys — full
naturals always rendered full, full black keys drawn on top and only occluding a
natural where a sharp note actually coincides in time; pure `laneSpanReal()`).
**Lane color opacity** (`keys3d_bg_laneOpacity`, 01, default 0) fades the
pitch-class lane tint; at 0 (default) the strips are a dark floor with guide lines
only at the key-block boundaries (E→F and each octave B→C), so each block is bounded
rather than every lane — the notes keep their colors; toward 1 it fills in full,
vivid colored lanes. The strips, per-lane separators and block lines crossfade with
this value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) widens the
gap a touch at each B→C octave boundary. **Octave line contrast**
(`keys3d_bg_octaveContrast`, 01, default 0.5) scales how hard the B→C octave line
reads; it is drawn as a dark layer (scaled by lane opacity) plus a bright layer
(scaled by its inverse), so it auto-shifts dark→bright as the lanes fade — no mode
switch needed. All are geometry-time — applied on the next chart build via
`init()`'s re-read.
- **Web MIDI input scoring**: module-level MIDI singleton (one access per tab, focused-instance routing) with device auto-connect by saved id+name, loopback blocklist, channel filter, transpose and CC64 sustain (`keys3d_` localStorage prefix; `window.keysH3d*` settings API). Hit detection matches played MIDI against the flattened chart notes within ±0.10 s with per-note dedupe and a missed-note sweep (only while a device is connected — never retroactive across a mid-song connect).
- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class colour, ~400 ms).
- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class color, ~400 ms).
- **End-of-run stats**: POSTs `/api/stats` `{filename, arrangement, score, accuracy}` exactly once per run with the same formula as the guitar notedetect path (`accuracy = hits / max(1, hits+misses)`, `score = round(hits·100·accuracy)`), then notifies the progression core when present.
- **Capability wiring** (all guarded for servers without the hosts): registers as a note-detection `midi` provider (`keys-midi`, `verify.target`), opens a per-song binding scoped to the chart's keys range, reports hit/miss observability events, and exposes Web MIDI inputs to the audio-input domain with pseudonymized labels (`midi-input-1`, …) via `source.enumerate/describe/open/close`.
- Headless test hook: `window.__keysHwTest = { injectNoteOn(midi, when), getScore() }`.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "keys_highway_3d",
"name": "Keys Highway 3D",
"version": "0.1.2",
"version": "0.2.1",
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
"type": "visualization",
"bundled": true,
+370 -85
View File
@@ -9,7 +9,7 @@
//
// Visual contract is the frame analysis on slopsmith#824 (RS+ reference):
// 3D perspective highway to a vanishing point, notes landing on a real 3D
// keyboard, per-key Synthesia-style PITCH-CLASS colours (hand is only a
// keyboard, per-key Synthesia-style PITCH-CLASS colors (hand is only a
// secondary brightness cue), active-range key highlighting with letters,
// a glowing hit-line, bevelled cuboid notes sized by durSec, floating bar
// numbers, and key-depress + flame feedback driven by the LIVE MIDI input
@@ -59,7 +59,7 @@
// World scroll speed (units / second) — matches the sibling highways.
const TS = 130 * K;
// Per-pitch-class colours (Synthesia convention observed in the RS+
// Per-pitch-class colors (Synthesia convention observed in the RS+
// reference frames: C=red, D=yellow, E=blue, F=light blue-grey, …).
// Index = midi % 12 (C, C#, D, …, B). Sharps take a dimmed blend of
// their neighbours so black-key notes stay distinguishable.
@@ -79,11 +79,11 @@
];
// Hand cue is SECONDARY (slopsmith#824 design call): right hand renders
// at full brightness, left hand slightly darkened — colour stays the
// at full brightness, left hand slightly darkened — color stays the
// pitch class.
const HAND_BRIGHTNESS = { rh: 1.0, lh: 0.72 };
// Selectable note-colour palettes. Index = midi % 12, same contract as
// Selectable note-color palettes. Index = midi % 12, same contract as
// PITCH_CLASS_COLORS — which stays byte-identical as the 'classic'
// entry, so anyone who never touches the setting sees the stock look.
// Two palette families:
@@ -151,7 +151,7 @@
],
};
// Octave-based colour scheme ('octaves'): every octave gets a distinct
// Octave-based color scheme ('octaves'): every octave gets a distinct
// hue that steps like a rainbow (clear, uniform sections — NOT a smooth
// blend — so each octave is uniquely identifiable, but neighbouring
// octaves stay close so the change isn't jarring). Loops if a song runs
@@ -181,7 +181,80 @@
function _isBlackPc(midi) {
return [1, 3, 6, 8, 10].indexOf(((midi % 12) + 12) % 12) !== -1;
}
// Colour (24-bit int) for a midi note under the octave scheme: hue by
// Which way a sharp leans to even out the naturals: toward the EDGE
// natural next to it. +1 = up (toward the higher natural), 1 = down, 0 =
// centred. C#/F# sit below an inner natural so they lean down to C/F;
// D#/A# lean up to E/B; G# has an inner natural on both sides, so it can't
// lean and stays put.
function _sharpLeanDir(pc) {
if (pc === 1 || pc === 6) return -1; // C#, F#
if (pc === 3 || pc === 10) return 1; // D#, A#
return 0; // G#
}
// Floor span [left,right] of a key's lane in the FLAT (piano-shaped)
// layout, in world units, given the key's centre x (`cx`). Pure/isolated
// on purpose — this ONE function defines the layout, so a variant is a
// one-function swap. Zero-overlap tiling: a white lane is trimmed by
// `sharpHalf` wherever it meets a sharp, and the sharp fills that gap. Each
// sharp is nudged `shift` toward the edge natural beside it (see
// _sharpLeanDir), which steals a sliver from that edge natural and widens
// the squeezed inner natural — at shift = sharpHalf/3 the C-D-E-F-B
// naturals come out equal. Lanes still tile edge-to-edge (no overlap, no
// gap). With `gaps`, each B→C octave boundary opens an extra `octGap`
// divider by shaving half of it off the B and the C (naturals only).
// `range`, when given, gates the trim to a neighbouring sharp that is
// itself inside `range.activeLow..range.activeHigh`. A white key at the
// active-range boundary (see the `midi < range.activeLow ||
// midi > range.activeHigh` skip around the lane-strip loop) may sit next
// to a sharp pitch-class that falls just outside the active range — that
// sharp's lane is never drawn, so trimming the white key's edge for it
// leaves a dark, unfilled sliver. Gating on range keeps that edge full
// while leaving the normal (fully in-range) zero-overlap tiling intact.
// Callers that don't pass `range` (e.g. the unit tests exercising raw
// tiling geometry) keep the unconditional trim.
function laneSpanFlat(midi, black, cx, dims, gaps, range) {
const { whiteW, sharpHalf, shift, octGap } = dims;
if (black) {
const c = cx + _sharpLeanDir(((midi % 12) + 12) % 12) * shift;
return { left: c - sharpHalf, right: c + sharpHalf };
}
const neighborActive = (m) => !range || (m >= range.activeLow && m <= range.activeHigh);
// White: each side that meets a sharp is trimmed to that (leaned) sharp's
// near edge; a side that meets another white keeps the half-slot edge.
let left = cx - whiteW / 2;
let right = cx + whiteW / 2;
if (_isBlackPc(midi - 1) && neighborActive(midi - 1)) {
const bc = (cx - whiteW / 2) + _sharpLeanDir(((midi - 1) % 12 + 12) % 12) * shift;
left = bc + sharpHalf;
}
if (_isBlackPc(midi + 1) && neighborActive(midi + 1)) {
const bc = (cx + whiteW / 2) + _sharpLeanDir(((midi + 1) % 12 + 12) % 12) * shift;
right = bc - sharpHalf;
}
const pc = ((midi % 12) + 12) % 12;
if (gaps) {
if (pc === 11) right -= octGap / 2; // B: gap on its right (→ C)
if (pc === 0) left += octGap / 2; // C: gap on its left (← B)
}
return { left, right };
}
// 'realistic' layout span: every bar sized to the physical key it lands on.
// Naturals are the same full width (2·natHalf) centred on the key; sharps are
// the full black-key width (2·sharpHalf) at their standard half-slot, which
// makes them overlap — the caller draws sharps on top. A natural therefore
// always renders full and is only covered where a sharp note actually
// coincides in time. `gaps` widens the B→C divider (naturals only).
function laneSpanReal(midi, black, cx, dims, gaps) {
const half = black ? dims.sharpHalf : dims.natHalf;
let left = cx - half, right = cx + half;
if (gaps && !black) {
const pc = ((midi % 12) + 12) % 12;
if (pc === 11) right -= dims.octGap / 2;
if (pc === 0) left += dims.octGap / 2;
}
return { left, right };
}
// Color (24-bit int) for a midi note under the octave scheme: hue by
// octave, darker for sharps. Pure (no THREE) so it is unit-testable.
function octaveNoteColor(midi) {
const oct = Math.floor(midi / 12) - 1; // C1..B1 => 1
@@ -210,8 +283,8 @@
// Gem vertical gradient (bottom shade → top highlight), baked per-vertex into
// the note geometry so a block reads as a lit 3D gem instead of a flat fill —
// same approach as the bundled guitar highway_3d (`gNoteGrad`). The ramp is
// greyscale so one geometry serves every pitch-class colour; the material
// multiplies its colour by it via vertexColors.
// greyscale so one geometry serves every pitch-class color; the material
// multiplies its color by it via vertexColors.
const GEM_SHADE_BOT = 0.12, GEM_SHADE_TOP = 1.1; // strong gem gradient (top slightly blows toward a highlight)
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
@@ -740,20 +813,36 @@
_writeStore(STORE_KEYS.midiPick, JSON.stringify({ id: id || '', name: name || '', key: key || '' }));
}
function _midiAutoConnect(allowFallback) {
// Recovery (sources-changed after unplug) passes false: never switch to a
// fallback input, because _midiConnect persists the pick and that would
// overwrite the user's saved device on a transient multi-device unplug
// (the original returns on replug and reconnects then).
if (allowFallback === undefined) allowFallback = true;
const inputs = _midiSources();
if (!inputs.length) return;
const saved = _readSavedPick();
// Explicit "None" opt-out.
if (saved && saved.id === '' && saved.name === '') return;
// Prefer the globally-unique logicalSourceKey, then the legacy bare
// sourceId, then case-insensitive name (Chrome on Linux regenerates ids
// per page load), then first non-loopback.
// Pure decision logic (exported via __test): pick which device to
// auto-connect to from the current source list, the domain-wide selection
// (`globalKey`, from Settings → Input Setup), and this plugin's own legacy
// saved pick. Returns null for "connect to nothing" (explicit None opt-out,
// or the configured device currently absent during hotplug recovery).
//
// The domain-wide selection is the SOURCE OF TRUTH (checked first): a device
// configured globally must never be overridden by a stale plugin-local pick
// or an arbitrary first-device fallback — that override was the bug. The
// local pick is retained only as a fallback BELOW the global (and for
// name-recovery when the global's logicalSourceKey went stale, e.g. a
// browser that regenerates MIDI port ids across reloads). Auto-connect no
// longer writes the local pick, so it only ever holds a value an explicit
// selection put there (or a stale one from a pre-fix build — the global
// still wins over it).
function _pickMidiTarget(inputs, saved, globalKey, allowFallback) {
if (!inputs.length) return null;
const notBlocked = (i) => !!i && !_MIDI_BLOCKLIST_RE.test(i.name || '');
// Explicit "None" opt-out (set only via the device-select API).
if (saved && saved.id === '' && saved.name === '') return null;
// 1. Domain-wide selection (Settings → Input Setup) — source of truth.
if (globalKey) {
const g = inputs.find(i => i.key === globalKey);
if (notBlocked(g)) return g;
}
// 2. Legacy plugin-local pick, as a fallback below the global. Prefer the
// globally-unique logicalSourceKey, then the legacy bare sourceId, then
// case-insensitive name (Chrome on Linux regenerates ids per page load).
let target = null;
if (saved && saved.key) target = inputs.find(i => i.key === saved.key) || null;
if (!target && saved && saved.id) target = inputs.find(i => i.id === saved.id) || null;
@@ -761,23 +850,50 @@
const n = saved.name.toLowerCase();
target = inputs.find(i => (i.name || '').toLowerCase() === n) || null;
}
// Never honour a saved pick that's a loopback / "Midi Through" port — it
// carries no device input, so a stale pick silently eats every note. The
// saved-pick lookups above bypass the block-list; re-apply it here.
if (target && _MIDI_BLOCKLIST_RE.test(target.name || '')) target = null;
if (!target) {
// Skip the substitute ONLY when a saved pick exists but is currently
// absent (recovery: preserve it, don't clobber on a transient unplug).
// With no saved pick at all, a fallback is the intended first-hotplug
// auto-connect — allow it even in recovery.
const hasSavedPick = !!(saved && (saved.key || saved.id || saved.name));
if (!allowFallback && hasSavedPick) return;
target = inputs.find(i => !_MIDI_BLOCKLIST_RE.test(i.name || '')) || inputs[0];
}
// Never honour a saved pick that resolves to a loopback / "Midi Through"
// port — it carries no device input, so it silently eats every note.
if (target && !notBlocked(target)) target = null;
if (target) return target;
// 3. Nothing configured resolved to a present device. In recovery
// (allowFallback=false) with a configured preference — a global pick or a
// saved pick — that's currently absent, preserve it rather than switching
// to an arbitrary device on a transient multi-device unplug. With no
// preference at all, a first-device grab is the intended first-hotplug
// auto-connect, allowed even in recovery.
const hasPreference = !!(globalKey || (saved && (saved.key || saved.id || saved.name)));
if (!allowFallback && hasPreference) return null;
// Connect to nothing rather than a loopback: if every present device is
// blocklisted, a first-device grab would attach to a "Midi Through"/IAC
// port that carries no input and silently eats every note.
return inputs.find(notBlocked) || null;
}
function _midiAutoConnect(allowFallback) {
// Recovery (sources-changed after unplug) passes false: never switch to a
// fallback input on a transient multi-device unplug (the configured
// device returns on replug and reconnects then). Auto-connect is
// non-persisting (persist omitted → false): it opens the resolved device
// for this session WITHOUT writing the plugin-local pick or the shared
// domain selection, so opening this highway can't clobber the user's
// globally-configured device.
if (allowFallback === undefined) allowFallback = true;
const inputs = _midiSources();
const saved = _readSavedPick();
const mi = _mi();
const globalKey = mi && typeof mi.getSelected === 'function' ? mi.getSelected() : null;
const target = _pickMidiTarget(inputs, saved, globalKey, allowFallback);
if (!target) return;
_midiConnect(target.id, target.name, target.key);
}
async function _midiConnect(id, name, key) {
// `persist` gates the two preference writes. Only an EXPLICIT device
// selection (the device-select API) persists: it writes the plugin-local
// pick AND the shared domain selection (`mi.select`, so the user's choice
// becomes the global default). Auto-connect and programmatic opens pass
// falsy — they open the resolved device for this session only, never
// touching either store, so they can't clobber a globally-configured device.
async function _midiConnect(id, name, key, persist) {
// Capture our generation AFTER _midiDetach()'s own bump, so a later
// detach (device removal / new connect / opt-out) reliably supersedes us.
_midiDetach();
@@ -788,7 +904,7 @@
for (const inst of _instances) {
if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld();
}
_writeSavedPick(id || '', name || '', key || '');
if (persist) _writeSavedPick(id || '', name || '', key || '');
const mi = _mi();
if ((id || key) && mi) {
// Prefer the globally-unique logicalSourceKey so two providers that
@@ -801,13 +917,19 @@
const lkey = src.key || ('web-midi::' + src.id);
_midiInput = { id: src.id, name: src.name, key: lkey };
_midiJustConnected = true;
// Only an explicit selection writes the shared global default;
// open takes the logicalSourceKey directly, so select() is not
// needed to open — it exists purely to set the global. Persist it
// BEFORE the no-instance early return so a settings-panel pick with
// no live renderer still updates the shared default (best-effort:
// a select hiccup must not abort the connect).
if (persist) { try { await mi.select(lkey); } catch (_) { /* best-effort */ } }
// No live renderer to consume OR release a session — don't hold one
// open (settings-only ensure-init, or the last instance was torn
// down during async discovery). The pick is saved; a later renderer
// mount re-runs auto-connect and opens for real, releasing on destroy.
// down during async discovery). A later renderer mount re-runs
// auto-connect and opens for real, releasing on destroy.
if (_instances.size === 0) { _midiNotifyDeviceListChanged(); return; }
try {
await mi.select(lkey);
const res = await mi.open({ requester: PLUGIN_ID, logicalSourceKey: lkey });
// A newer _midiConnect (device switch / None / replug) ran while
// we awaited open — discard this stale session so we don't wire a
@@ -966,10 +1088,11 @@
window.keysH3dGetMidiInputId = function () { return _midiInput ? _midiInput.id : ''; };
window.keysH3dSetMidiInput = function (id) {
// `id` may be a logicalSourceKey (new host calls) or a legacy sourceId.
// Explicit user selection → persist (local pick + shared global default).
const src = id
? (_midiSources().find(s => s.key === id) || _midiSources().find(s => s.id === id))
: null;
_midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : '');
_midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : '', true);
return true;
};
window.keysH3dGetMidiChannel = function () { return _cfg.midiChannel; };
@@ -1026,6 +1149,14 @@
scoreFx: true, // 2D overlay: +N pops, combo rings, streak-break wash
bgIntensity: 0.5, // background-ambience density/strength
bgReactive: true, // background reacts to the audio analyser
// Highway-layout options (apply on the next chart build via init()'s
// fx re-read). The sharp LAYOUT is a separate string setting
// (keys3d_bg_sharpMode); these two are the booleans.
octaveGaps: true, // ON: wider divider gap at each B→C octave boundary
laneOpacity: 0.0, // 01: lane-color strength. 0 (default) = dark floor +
// block guide lines (E→F, B→C); 1 = full colored lanes; crossfades.
octaveContrast: 0.5, // 01: how strongly the B→C octave line stands out. It
// auto-darkens with lane opacity and brightens as it fades.
// Camera base-rig fine-tune. These shift the BASE vantage point the
// auto-pan/zoom follow-motion is built on (they multiply/offset the
// active CAM_PRESET before the per-frame pan + dolly), so the camera
@@ -1223,7 +1354,7 @@
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
};
// Note-colour palette id — string-valued like the theme, so it gets its
// Note-color palette id — string-valued like the theme, so it gets its
// own validated key + setter rather than an FX_DEFAULTS slot.
const FX_LS_PALETTE = 'keys3d_bg_palette';
function readPaletteSetting() {
@@ -1231,7 +1362,7 @@
const id = localStorage.getItem(FX_LS_PALETTE);
if (id && PALETTE_IDS.indexOf(id) !== -1) return id;
} catch (_) {}
// Default: the octave scheme (each octave its own colour, darker
// Default: the octave scheme (each octave its own color, darker
// sharps) — the plug-and-play piano look. Emerald/classic/etc. remain
// selectable.
return 'octaves';
@@ -1244,6 +1375,30 @@
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
};
// Sharp-display layout id — string-valued (3-way), its own validated key +
// setter. 'floating' = the original raised-plane sharps with white-only
// lanes; 'flat' = every note on one plane with piano-shaped tiled lanes
// (sharps leaned to even the naturals); 'realistic'
// = one plane with note bars sized like the physical keys (full naturals,
// full sharps overlapping on top). Geometry-time — applied on the next chart
// build via init()'s re-read.
const FX_LS_SHARPMODE = 'keys3d_bg_sharpMode';
const SHARP_MODES = ['floating', 'flat', 'realistic'];
function readSharpModeSetting() {
try {
const id = localStorage.getItem(FX_LS_SHARPMODE);
if (id && SHARP_MODES.indexOf(id) !== -1) return id;
} catch (_) {}
return 'realistic'; // default layout: physical-key-sized bars on one plane
}
window.keys3dSetSharpMode = function (id) {
if (SHARP_MODES.indexOf(id) === -1) return;
try { localStorage.setItem(FX_LS_SHARPMODE, id); } catch (_) {}
try {
window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { sharpMode: id } }));
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
};
// Camera-rig presets. 'classic' is the original low, near-telephoto rig
// (numerically identical to the historical constants, so 'classic' with the
// neutral camTilt default reproduces the exact stock framing). y/z/lookY/lookZ
@@ -1442,6 +1597,8 @@
function _aiOpen(req) {
// Opening a MIDI source connects the corresponding Web MIDI input.
// Programmatic open (audio-input source.open) — non-persisting: it must
// not rewrite the user's saved pick or the shared global default.
const idx = _aiIndexFor(req && (req.sourceId || req.logicalSourceKey));
const inputs = _midiSources(); // carries .key (logicalSourceKey), unlike _midiListInputs()
if (idx == null || idx >= inputs.length) {
@@ -1561,6 +1718,7 @@
// _applyTheme / _applyCinematic / the glow slider retune them live).
let _theme = readThemeSetting();
let _palette = readPaletteSetting();
let _sharpMode = readSharpModeSetting(); // 'floating' | 'flat' | 'realistic'
let ambLight = null, dirLight = null;
let _floorMat = null;
const _railMats = []; // lane-edge rail materials (theme laneDim)
@@ -1628,7 +1786,7 @@
return (NOTE_PALETTES[_palette] || PITCH_CLASS_COLORS)[pc];
}
// Base colour (24-bit int, no hand dimming) for a midi note under the
// Base color (24-bit int, no hand dimming) for a midi note under the
// active palette — the octave scheme is procedural, every other
// palette is a 12-entry pitch-class table.
function _noteHex(midi) {
@@ -1664,6 +1822,41 @@
const WHITE_W = 12 * K, WHITE_L = 46 * K, WHITE_H = 5 * K;
const BLACK_W = 6.4 * K, BLACK_L = 28 * K, BLACK_H = 6.5 * K;
const HIGHWAY_LEN = 1150 * K; // longer runway → ~8.8s of lookahead visible
// 'flat' piano-shaped-lane geometry (see laneSpanFlat). Zero-overlap tiling:
// white lanes are trimmed by FLAT_SHARP_HALF where they meet a sharp and
// the sharp fills the gap, so nothing overlaps. To keep the naturals
// even, each sharp is nudged FLAT_SHARP_SHIFT toward the EDGE natural
// beside it (C#→C, D#→E, F#→F, A#→B; G# stays centred, no edge to lean
// on) — that steals a sliver from the edge natural and hands it to the
// squeezed inner natural. At shift = sharpHalf/3 the C-D-E-F-B naturals
// come out exactly equal; G/A land a hair smaller (G# can't lean). The
// sharps ride the same flat plane (no lift — they never overlap a
// natural). OCT_GAP is the extra divider opened at each octave boundary
// when the octaveGaps option is on.
const FLAT_SHARP_HALF = 2.2 * K; // sharp half-width (4.4K wide)
const FLAT_SHARP_SHIFT = FLAT_SHARP_HALF / 3; // sharp lean that evens the naturals
const OCT_GAP = 0.9 * K;
const LANE_DIMS_FLAT = {
whiteW: WHITE_W, sharpHalf: FLAT_SHARP_HALF, shift: FLAT_SHARP_SHIFT, octGap: OCT_GAP,
};
// 'realistic' layout (laneSpanReal): every note bar is the size of the
// physical key it lands on — naturals the full white-key width (always
// rendered full, only occluded where a sharp note actually overlaps in
// time) and sharps the full black-key width at their standard positions,
// drawn on top with a hair of REAL_SHARP_LIFT (anti z-fight).
const REAL_NAT_HALF = WHITE_W * 0.47; // natural bar ≈ physical white key (~11.3K)
const REAL_SHARP_HALF = BLACK_W / 2; // sharp bar = physical black key (6.4K)
const REAL_SHARP_LIFT = 0.3 * K;
const LANE_DIMS_REAL = { natHalf: REAL_NAT_HALF, sharpHalf: REAL_SHARP_HALF, octGap: OCT_GAP };
// Lane span for the active non-floating sharp mode. `range`
// (activeLow/activeHigh) is optional and only consulted by the flat
// layout, to gate the boundary-key edge trim (see laneSpanFlat).
const _flatMode = () => _sharpMode === 'flat' || _sharpMode === 'realistic';
function laneSpanFor(midi, black, cx, gaps, range) {
return _sharpMode === 'realistic'
? laneSpanReal(midi, black, cx, LANE_DIMS_REAL, gaps)
: laneSpanFlat(midi, black, cx, LANE_DIMS_FLAT, gaps, range);
}
// Camera — the default 'classic' preset is a low, near-telephoto rig
// (RS+-style): a narrow FOV from low and back gives a deep receding
@@ -1708,7 +1901,7 @@
_rigOut.lookZ = _camPreset.lookZ;
return _rigOut;
}
// Per-key approach glow: a key lights in its pitch-class colour ONLY while a
// Per-key approach glow: a key lights in its pitch-class color ONLY while a
// note is heading for it, ramping up the closer that note gets to the hit-line.
const KEY_GLOW_AHEAD = 2.0; // seconds before the hit-line a key starts to light
const KEY_GLOW_STRENGTH = 1.15; // peak emissive intensity (note at the hit-line)
@@ -2297,9 +2490,9 @@
});
// Extrusion spans z ∈ [-bevel, depth + bevel]; centre it.
geo.translate(0, 0, -depth / 2);
// Bake a vertical brightness ramp into vertex colours (bottom shade →
// Bake a vertical brightness ramp into vertex colors (bottom shade →
// top highlight) so the gem reads 3D; the material multiplies its
// pitch-class colour by this (vertexColors).
// pitch-class color by this (vertexColors).
geo.computeBoundingBox();
const y0 = geo.boundingBox.min.y, yr = (geo.boundingBox.max.y - y0) || 1;
const pos = geo.attributes.position;
@@ -2314,10 +2507,10 @@
return geo;
}
// Glossy note material, cached per resolved colour. Keying by the
// final colour int (hand brightness already baked in by noteColor)
// Glossy note material, cached per resolved color. Keying by the
// final color int (hand brightness already baked in by noteColor)
// works for every palette — including 'octaves', where two notes of
// the same pitch class in different octaves are DIFFERENT colours and
// the same pitch class in different octaves are DIFFERENT colors and
// must not share a material (a pitch-class key would collide them).
function _noteMaterial(midi, hand) {
const col = noteColor(midi, hand);
@@ -2333,7 +2526,7 @@
// share one shader program.)
mat = new T.MeshPhysicalMaterial({
color: col,
vertexColors: true, // multiply colour by the baked gem ramp
vertexColors: true, // multiply color by the baked gem ramp
emissive: col,
emissiveIntensity: NOTE_EMISSIVE_BASE * _glowMul(),
roughness: 0.32,
@@ -2355,7 +2548,10 @@
return 0.72 + 0.22 * Math.min(1, Math.max(0, fx.vibrancy));
}
function _laneGuideOpacity() {
return 0.10 + 0.12 * Math.min(1, Math.max(0, fx.vibrancy));
// Vibrancy sets the ceiling (much brighter than the old subtle
// 0.100.22 range); the laneOpacity slider then scales 0 → ceiling.
const vib = 0.32 + 0.52 * Math.min(1, Math.max(0, fx.vibrancy)); // ~0.32..0.84
return vib * Math.min(1, Math.max(0, fx.laneOpacity));
}
// Live vibrancy slider: retint everything already built — the
@@ -2371,13 +2567,13 @@
for (const m of _laneGuideMats) m.opacity = lop;
}
// Live palette switch: recolour everything already built — cached
// Live palette switch: recolor everything already built — cached
// note materials (future clones), per-note clones, key emissives
// (incl. the wrong-flash restore state), lane guides — and drop the
// pitch-class flame textures so the next spawn bakes the new hues.
// Same no-rebuild approach as _applyVibrancy.
function _applyPalette() {
// The base-material cache is keyed by resolved colour, so old
// The base-material cache is keyed by resolved color, so old
// entries are simply stale under a new palette — drop them and let
// the next build re-cache. The live per-note clones below are
// retinted directly from each note's midi (palette-correct).
@@ -2441,10 +2637,10 @@
}
// Vertical flame texture for hit flares / held-key halos: white-hot
// base fading up into the note's colour, with a horizontal falloff.
// Cached per resolved colour (bounded — 12 for pitch-class palettes,
// base fading up into the note's color, with a horizontal falloff.
// Cached per resolved color (bounded — 12 for pitch-class palettes,
// up to ~one-per-octave for 'octaves'), so a flare always matches the
// struck note's colour whatever the palette.
// struck note's color whatever the palette.
function _flameTexture(midi) {
const c = _noteHex(midi);
let tex = _flameTexCache.get(c);
@@ -2624,9 +2820,9 @@
}
}
// Lane guides: a faint colour strip running up the runway from each
// active key, in that key's pitch-class colour. A falling note shares
// its target key's colour, so the player can trace it straight down
// Lane guides: a faint color strip running up the runway from each
// active key, in that key's pitch-class color. A falling note shares
// its target key's color, so the player can trace it straight down
// its lane to the right key even when it sits near the frame edge.
//
// The lanes sit at the NOTES' travel height (coplanar), not on the
@@ -2636,29 +2832,87 @@
// lane, perfectly aligned with the lane and its key.
const guideLen = HIGHWAY_LEN - WHITE_L;
const laneY = WHITE_H + NOTE_H / 2 + 0.5 * K; // == white-note travel height
const gaps = fx.octaveGaps;
const floating = _sharpMode === 'floating';
const t = Math.min(1, Math.max(0, fx.laneOpacity)); // lane-color opacity
const octC = Math.min(1, Math.max(0, fx.octaveContrast)); // 0..1 line-contrast
const themeLaneDim = (() => { const c = _bgThemeColors(_theme); return c.laneDim != null ? c.laneDim : 0x2a2a3e; })();
// A vertical guide line running the full runway at world x (skips
// near-transparent lines so the crossfade never builds dead meshes).
const addLine = (x, color, opacity, wpx, trackTheme) => {
if (opacity < 0.02) return;
const m = new T.MeshBasicMaterial({ color, transparent: true, opacity, depthWrite: false });
if (trackTheme) _railMats.push(m); // theme retint tracks these; fixed guides stay put
const line = new T.Mesh(new T.PlaneGeometry(wpx, guideLen), m);
line.rotation.x = -Math.PI / 2;
line.position.set(x, laneY + 0.06 * K, hitZ - guideLen / 2);
keyboardGroup.add(line);
};
for (const [midi, entry] of layout) {
if (midi < range.activeLow || midi > range.activeHigh) continue;
if (entry.black) continue; // one strip per semitone-slot lands on whites
const gmat = new T.MeshBasicMaterial({
color: noteColor(midi, 'rh'), transparent: true,
opacity: _laneGuideOpacity(), depthWrite: false,
});
gmat.userData.midi = midi; // palette retint needs the lane's pitch
_laneGuideMats.push(gmat);
const strip = new T.Mesh(new T.PlaneGeometry(WHITE_W * 0.84, guideLen), gmat);
strip.rotation.x = -Math.PI / 2;
strip.position.set(keyX(entry, whiteCount), laneY, hitZ - guideLen / 2);
keyboardGroup.add(strip);
// Thin brighter rails at the lane edges for crisp separation.
const railMat = new T.MeshBasicMaterial({
color: (() => { const c = _bgThemeColors(_theme); return c.laneDim != null ? c.laneDim : 0x2a2a3e; })(),
transparent: true, opacity: 0.5, depthWrite: false,
});
_railMats.push(railMat);
const rail = new T.Mesh(new T.PlaneGeometry(0.6 * K, guideLen), railMat);
rail.rotation.x = -Math.PI / 2;
rail.position.set(keyX(entry, whiteCount) - WHITE_W / 2, laneY + 0.05 * K, hitZ - guideLen / 2);
keyboardGroup.add(rail);
// Floating: white-only lanes (blacks float, lane-less). Flat/
// realistic: every key gets a piano-shaped lane.
if (entry.black && floating) continue;
// Lane footprint per mode.
let left, right, stripY = laneY;
if (floating) {
const cx = keyX(entry, whiteCount);
left = cx - WHITE_W / 2; right = cx + WHITE_W / 2;
if (gaps) {
const pc = ((midi % 12) + 12) % 12;
if (pc === 11) right -= OCT_GAP / 2; // B → C boundary
if (pc === 0) left += OCT_GAP / 2;
}
} else {
const span = laneSpanFor(midi, entry.black, keyX(entry, whiteCount), gaps, range);
left = span.left; right = span.right;
if (_sharpMode === 'realistic' && entry.black) stripY = laneY + REAL_SHARP_LIFT;
}
const center = (left + right) / 2;
// Colored lane strip + a subtle per-lane separator — fade in with
// lane opacity. (As lanes fade, the block/octave lines below take
// over as the guide.)
if (t > 0.02) {
// Floating keeps the historical 0.84-wide white strip; the
// piano-shaped lanes inset a touch for a dark separator.
const stripW = floating ? (right - left) - WHITE_W * 0.16 : (right - left) * 0.9;
const gmat = new T.MeshBasicMaterial({
color: noteColor(midi, 'rh'), transparent: true,
opacity: _laneGuideOpacity(), depthWrite: false, // includes lane opacity
});
gmat.userData.midi = midi; // palette retint needs the lane's pitch
_laneGuideMats.push(gmat);
const strip = new T.Mesh(new T.PlaneGeometry(stripW, guideLen), gmat);
strip.rotation.x = -Math.PI / 2;
strip.position.set(center, stripY, hitZ - guideLen / 2);
keyboardGroup.add(strip);
// Per-lane separator, fading with the strips. Skip realistic
// sharps (they overlap the white columns).
if (!(entry.black && _sharpMode === 'realistic')) {
addLine(left, themeLaneDim, 0.5 * t, 0.6 * K, true);
}
}
}
// Structural divider lines: ONE per "block" boundary — E→F and B→C —
// so each block of keys (C-D-E, F-G-A-B) is bounded, not every lane.
// They crossfade IN as the lanes fade OUT. The B→C octave line is a
// dark layer (reads over bright lanes, scales with lane opacity) plus
// a bright layer (reads over the dark floor, scales with the inverse),
// so it auto-shifts dark→bright as you fade lanes; octaveContrast
// scales the whole thing.
for (let midi = range.activeLow; midi <= range.activeHigh; midi++) {
const pc = ((midi % 12) + 12) % 12;
const isEF = pc === 4; // E → F block boundary
const isBC = pc === 11; // B → C octave boundary
if (!isEF && !isBC) continue;
const boundaryX = keyX(layout.get(midi), whiteCount) + WHITE_W / 2;
if (isBC) {
addLine(boundaryX, 0x05060a, octC * 0.92 * t, 1.1 * K, false); // dark, over lanes
addLine(boundaryX, 0xd8dcec, (0.42 + octC * 0.5) * (1 - t), 1.1 * K, false); // bright, over floor
} else {
// E→F block divider — a guide that appears as the lanes fade.
addLine(boundaryX, 0x6a6a7a, 0.5 * (1 - t), 0.8 * K, false);
}
}
// Keys (whites first so blacks overlay). Geometries are shared
@@ -2678,7 +2932,7 @@
const inRange = midi >= range.activeLow && midi <= range.activeHigh;
const material = new T.MeshStandardMaterial({
color: black ? 0x070708 : 0xe8e8ee,
// Pitch-class colour preset on emissive but OFF at rest — the key
// Pitch-class color preset on emissive but OFF at rest — the key
// is neutral until a note approaches, when updateScene ramps the
// intensity up by proximity.
emissive: noteColor(midi, 'rh'),
@@ -2764,11 +3018,31 @@
const entry = layout.get(note.midi);
if (!entry) continue;
const len = Math.max(4 * K, note.durSec * TS);
const w = (entry.black ? BLACK_W : WHITE_W * 0.94) * 0.9;
// Non-floating layouts: notes ride the naturals' plane and take
// their piano-shaped lane's width/centre. Floating (default):
// original elevated sharps, key-centred bars.
let w, x, y;
if (_flatMode()) {
const span = laneSpanFor(
note.midi, entry.black, keyX(entry, whiteCount), fx.octaveGaps, range);
// 'realistic' bars are full (physical-key size); 'flat' bars are
// inset a touch for a dark separator in the tight tiling.
const inset = _sharpMode === 'realistic' ? 1.0 : 0.9;
w = (span.right - span.left) * inset;
x = (span.left + span.right) / 2;
// Coplanar; in 'realistic' the sharps ride a hair proud so they
// draw over the naturals they overlap without z-fighting.
const lift = (_sharpMode === 'realistic' && entry.black) ? REAL_SHARP_LIFT : 0;
y = WHITE_H + NOTE_H / 2 + 0.5 * K + lift;
} else {
w = (entry.black ? BLACK_W : WHITE_W * 0.94) * 0.9;
x = keyX(entry, whiteCount);
y = (entry.black ? BLACK_H + WHITE_H : WHITE_H) + NOTE_H / 2 + 0.5 * K;
}
// Clone per note so each can glow independently while being consumed.
const mesh = new T.Mesh(_noteGeometry(w, len), _noteMaterial(note.midi, note.hand).clone());
mesh.position.x = keyX(entry, whiteCount);
mesh.position.y = (entry.black ? BLACK_H + WHITE_H : WHITE_H) + NOTE_H / 2 + 0.5 * K;
mesh.position.x = x;
mesh.position.y = y;
mesh.visible = false;
notesGroup.add(mesh);
// Note-name label: a camera-facing sprite (readable at this low camera
@@ -3483,6 +3757,7 @@
// listening (e.g. changed on the Settings screen, where the live
// viz is torn down) must not come up stale on a later init().
_palette = readPaletteSetting();
_sharpMode = readSharpModeSetting();
_camPreset = CAM_PRESETS[readCameraSetting()] || CAM_PRESETS.classic;
_theme = readThemeSetting();
_bgStyle = readBgStyleSetting();
@@ -3533,6 +3808,10 @@
_palette = d.palette;
_applyPalette();
}
if (d && d.sharpMode && SHARP_MODES.indexOf(d.sharpMode) !== -1) {
// Geometry-time — takes effect on the next chart build.
_sharpMode = d.sharpMode;
}
if (d && d.camera && CAM_PRESETS[d.camera]) {
_camPreset = CAM_PRESETS[d.camera];
// Position/lookAt re-derive next frame; only the
@@ -3749,6 +4028,8 @@
readBgStyleSetting,
readPaletteSetting,
readCameraSetting,
readSharpModeSetting,
SHARP_MODES,
_bgThemeColors,
BG_THEMES,
BG_STYLE_IDS,
@@ -3757,10 +4038,14 @@
PALETTE_IDS,
OCTAVE_HUES,
octaveNoteColor,
_isBlackPc,
laneSpanFlat,
laneSpanReal,
CAM_PRESETS,
FX_DEFAULTS,
FX_RANGES,
_classifyTiming,
_pickMidiTarget,
};
// Headless verification hook: lets Playwright drive synthetic note-ons
+74 -6
View File
@@ -12,11 +12,11 @@
<div class="mt-3">
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
<label for="keysh3d-fx-palette" class="text-xs font-medium text-gray-400 mb-1 block">Note colours</label>
<label for="keysh3d-fx-palette" class="text-xs font-medium text-gray-400 mb-1 block">Note colors</label>
<select id="keysh3d-fx-palette"
onchange="window.keys3dSetPalette && window.keys3dSetPalette(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="octaves" selected>Octaves (colour per octave, darker sharps)</option>
<option value="octaves" selected>Octaves (color per octave, darker sharps)</option>
<option value="emerald">Emerald (green, darker sharps)</option>
<option value="ice">Ice (blue, darker sharps)</option>
<option value="classic">Rainbow (per-pitch)</option>
@@ -24,7 +24,7 @@
<option value="pastel">Pastel (per-pitch, soft)</option>
</select>
<p class="text-xs text-gray-500 mt-1 mb-3">
Choose the colour scheme for the falling notes, key glow, lane
Choose the color scheme for the falling notes, key glow, lane
guides and hit flames. Each option is described in its own label.
</p>
@@ -46,8 +46,8 @@
</select>
<p class="text-xs text-gray-500 mt-1 mb-3">
Background gradient, floor and lane rails — the same theme names
as the guitar highway. Note colours come from the
"Note colours" palette above.
as the guitar highway. Note colors come from the
"Note colors" palette above.
</p>
<label for="keysh3d-fx-camera" class="text-xs font-medium text-gray-400 mb-1 block">Camera angle</label>
@@ -101,6 +101,64 @@
aims higher up the runway or down toward the keys. 0 = neutral.
</p>
<h4 class="text-xs font-medium text-gray-300 mb-2 mt-4">Highway layout</h4>
<label for="keysh3d-fx-sharpmode" class="text-xs font-medium text-gray-400 mb-1 block">Sharps &amp; flats</label>
<select id="keysh3d-fx-sharpmode"
onchange="window.keys3dSetSharpMode && window.keys3dSetSharpMode(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="floating">Floating</option>
<option value="flat">Non-floating</option>
<option value="realistic" selected>Realistic key sizes (default — best with no colored lanes)</option>
</select>
<p class="text-xs text-gray-500 mt-1 mb-3">
How sharps and flats are drawn. <em>Floating</em>: they ride a raised
plane above the naturals. <em>Non-floating</em>: everything on one
plane, each key its own even piano-shaped lane. <em>Realistic key
sizes</em>: one plane, bars sized like the real keys (full naturals,
full black keys on top). Applies next time you open a song.
</p>
<label for="keysh3d-fx-laneopacity" class="text-xs font-medium text-gray-400 mb-1 block">
Lane color opacity <span id="keysh3d-fx-laneopacity-val" class="text-gray-500 font-mono">0.00</span>
</label>
<input type="range" id="keysh3d-fx-laneopacity"
min="0" max="1" step="0.05" value="0"
oninput="window.keys3dSetFx && window.keys3dSetFx('laneOpacity', this.value); document.getElementById('keysh3d-fx-laneopacity-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1 mb-3">
How strongly each lane is tinted its note color. 0.00 (default) is a
dark floor with plain guide lines only between the key blocks (at EF
and each octave); the notes keep their colors and pop off the floor.
Raise toward 1.00 for full, vivid colored lanes. Applies next time you
open a song.
</p>
<label for="keysh3d-fx-octavegaps" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-octavegaps" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('octaveGaps', this.checked)">
Octave separators
</label>
<p class="text-xs text-gray-500 mt-1 mb-3">
Widen the gap a little at each octave boundary (every B to the C
above it) so octaves are easier to read. Applies next time you open
a song.
</p>
<label for="keysh3d-fx-octavecontrast" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Octave line contrast <span id="keysh3d-fx-octavecontrast-val" class="text-gray-500 font-mono">0.50</span>
</label>
<input type="range" id="keysh3d-fx-octavecontrast"
min="0" max="1" step="0.05" value="0.5"
oninput="window.keys3dSetFx && window.keys3dSetFx('octaveContrast', this.value); document.getElementById('keysh3d-fx-octavecontrast-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1 mb-3">
How strongly the octave line (every B to C) stands out. It adapts to
the lane color opacity automatically — darkening the line against
bright lanes and brightening it as you fade them toward the dark
floor. Applies next time you open a song.
</p>
<label for="keysh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
<input type="checkbox" id="keysh3d-fx-cinematic" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('cinematic', this.checked)">
@@ -184,7 +242,7 @@
<label for="keysh3d-fx-timing" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-timing" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('timingFx', this.checked)">
Timing colours
Timing colors
</label>
<p class="text-xs text-gray-500 mt-1">
Tint the sparks by timing — on-time green, early cyan, late
@@ -247,6 +305,9 @@
hydrateFxBool('cinematic', 'keysh3d-fx-cinematic');
hydrateFxBool('bgReactive', 'keysh3d-fx-bgreactive');
hydrateFxBool('scoreFx', 'keysh3d-fx-scorefx');
// Highway-layout: octaveGaps defaults ON (bool); laneOpacity /
// octaveContrast are 0-1 sliders hydrated with hydrateFxRange below.
hydrateFxBool('octaveGaps', 'keysh3d-fx-octavegaps');
const hydrateFxRange = (key, elId, valId) => {
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
if (!Number.isFinite(n)) return;
@@ -258,6 +319,8 @@
hydrateFxRange('vibrancy', 'keysh3d-fx-vibrancy', 'keysh3d-fx-vibrancy-val');
hydrateFxRange('glow', 'keysh3d-fx-glow', 'keysh3d-fx-glow-val');
hydrateFxRange('bgIntensity', 'keysh3d-fx-bgintensity', 'keysh3d-fx-bgintensity-val');
hydrateFxRange('laneOpacity', 'keysh3d-fx-laneopacity', 'keysh3d-fx-laneopacity-val');
hydrateFxRange('octaveContrast', 'keysh3d-fx-octavecontrast', 'keysh3d-fx-octavecontrast-val');
// Camera fine-tune sliders live outside 0-1 — clamp to the
// control's own min/max (mirrors screen.js FX_RANGES).
const hydrateFxRangeIn = (key, elId, valId) => {
@@ -291,6 +354,11 @@
if (storedPalette && Array.from(paletteSel.options).some(o => o.value === storedPalette)) {
paletteSel.value = storedPalette;
}
const storedSharp = localStorage.getItem('keys3d_bg_sharpMode');
const sharpSel = document.getElementById('keysh3d-fx-sharpmode');
if (storedSharp && Array.from(sharpSel.options).some(o => o.value === storedSharp)) {
sharpSel.value = storedSharp;
}
} catch (e) {
console.warn('[Keys-Hwy3D settings] hydration failed:', e);
}
@@ -188,3 +188,99 @@ test('measureMarkers extracts idx/t pairs', () => {
[{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }],
);
});
test('_pickMidiTarget: no plugin-local pick defers to the domain-wide selection, not "first device"', () => {
const { _pickMidiTarget } = load();
const inputs = [
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
];
// Fresh install / never picked here — must use the Input Setup global,
// NOT fall through to inputs[0].
const target = _pickMidiTarget(inputs, null, 'web-midi::b', true);
assert.equal(target.id, 'b');
});
test('_pickMidiTarget: the domain-wide selection is the source of truth — it wins over a stale plugin-local pick', () => {
const { _pickMidiTarget } = load();
const inputs = [
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
];
// A stale local pick (e.g. left by a pre-fix build's auto-connect) must
// NOT override the device the user configured in Settings → Input Setup.
const target = _pickMidiTarget(inputs, { id: 'a', name: 'Device A', key: 'web-midi::a' }, 'web-midi::b', true);
assert.equal(target.id, 'b');
});
test('_pickMidiTarget: local pick is used as a fallback when no global is configured', () => {
const { _pickMidiTarget } = load();
const inputs = [
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
];
const target = _pickMidiTarget(inputs, { id: 'a', name: 'Device A', key: 'web-midi::a' }, null, true);
assert.equal(target.id, 'a');
});
test('_pickMidiTarget: local pick name-recovers when its logicalSourceKey went stale (id regeneration)', () => {
const { _pickMidiTarget } = load();
// Same physical device, new id/key across a reload; the saved key/id miss
// but the name still matches.
const inputs = [{ id: 'a2', name: 'Device A', key: 'web-midi::a2' }];
const target = _pickMidiTarget(inputs, { id: 'a1', name: 'Device A', key: 'web-midi::a1' }, null, true);
assert.equal(target.id, 'a2');
});
test('_pickMidiTarget: domain-wide selection is ignored if it names a blocklisted loopback port', () => {
const { _pickMidiTarget } = load();
const inputs = [
{ id: 'thru', name: 'IAC Driver Bus 1', key: 'web-midi::thru' },
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
];
const target = _pickMidiTarget(inputs, null, 'web-midi::thru', true);
assert.equal(target.id, 'b'); // falls through to the first non-loopback device
});
test('_pickMidiTarget: when every present device is a loopback, connect to nothing (never a dead port)', () => {
const { _pickMidiTarget } = load();
const inputs = [
{ id: 'thru', name: 'MIDI Through Port-0', key: 'web-midi::thru' },
{ id: 'iac', name: 'IAC Driver Bus 1', key: 'web-midi::iac' },
];
// No non-loopback device exists — must NOT fall back to inputs[0] (a port
// that carries no input and would silently eat every note).
const target = _pickMidiTarget(inputs, null, null, true);
assert.equal(target, null);
});
test('_pickMidiTarget: explicit "None" opt-out still wins over any global default', () => {
const { _pickMidiTarget } = load();
const inputs = [{ id: 'a', name: 'Device A', key: 'web-midi::a' }];
const target = _pickMidiTarget(inputs, { id: '', name: '' }, 'web-midi::a', true);
assert.equal(target, null);
});
test('_pickMidiTarget: a present global wins even during hotplug recovery', () => {
const { _pickMidiTarget } = load();
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
// The configured global device is present — reconnect to it, don't bail.
const target = _pickMidiTarget(inputs, null, 'web-midi::b', false);
assert.equal(target.id, 'b');
});
test('_pickMidiTarget: recovery (allowFallback=false) preserves an absent configured device instead of grabbing a random one', () => {
const { _pickMidiTarget } = load();
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
// The configured device ('x', global) is currently unplugged; a transient
// recovery must NOT switch to the unrelated device that is present.
const target = _pickMidiTarget(inputs, null, 'web-midi::x', false);
assert.equal(target, null);
});
test('_pickMidiTarget: recovery with no preference at all still allows a first-hotplug grab', () => {
const { _pickMidiTarget } = load();
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
const target = _pickMidiTarget(inputs, null, null, false);
assert.equal(target.id, 'b');
});
@@ -397,3 +397,156 @@ test('keys3dSetCamera: persists + dispatches valid ids, ignores unknown', () =>
assert.equal(readCameraSetting(), 'overhead');
});
/* ── Flat-sharps / piano-shaped lanes (feat/keys3d-flat-lanes) ───────── */
test('FX defaults: octave separators on, lanes off (minimal default look)', () => {
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(FX_DEFAULTS.octaveGaps, true); // octave separators ship on
assert.equal(FX_DEFAULTS.laneOpacity, 0.0); // dark floor + guide lines by default
assert.equal(FX_DEFAULTS.octaveContrast, 0.5);
// Sharp LAYOUT is a string setting, not an FX bool.
assert.equal('flatSharps' in FX_DEFAULTS, false);
assert.equal('laneColors' in FX_DEFAULTS, false); // superseded by laneOpacity
});
test('keys3dSetFx: highway-layout controls persist (bool + sliders)', () => {
const store = {};
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: () => true,
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
});
win.keys3dSetFx('octaveGaps', true);
assert.equal(store.keys3d_bg_octaveGaps, '1');
// laneOpacity / octaveContrast are 0-1 numbers, persisted verbatim + clamped.
win.keys3dSetFx('laneOpacity', 0.35);
assert.equal(store.keys3d_bg_laneOpacity, '0.35');
win.keys3dSetFx('laneOpacity', 5); // clamps to the 0-1 range
assert.equal(store.keys3d_bg_laneOpacity, '1');
win.keys3dSetFx('octaveContrast', 0.8);
assert.equal(store.keys3d_bg_octaveContrast, '0.8');
});
test('sharpMode: realistic default, validated ids, persists + dispatches', () => {
const bare = load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual([...bare.SHARP_MODES], ['floating', 'flat', 'realistic']);
assert.equal(bare.readSharpModeSetting(), 'realistic'); // no localStorage → default
const store = {};
const events = [];
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: (ev) => { events.push(ev); return true; },
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
});
win.keys3dSetSharpMode('flat'); // a non-default id, to exercise persistence
assert.equal(store.keys3d_bg_sharpMode, 'flat');
assert.equal(events[0].detail.sharpMode, 'flat');
assert.equal(win.slopsmithViz_keys_highway_3d.__test.readSharpModeSetting(), 'flat');
// Unknown id ignored (no write, no event).
win.keys3dSetSharpMode('bogus');
assert.equal(store.keys3d_bg_sharpMode, 'flat');
assert.equal(events.length, 1);
});
test('laneSpanFlat (V5): lanes tile with zero overlap and even the naturals', () => {
const { laneSpanFlat, _isBlackPc } = load().slopsmithViz_keys_highway_3d.__test;
const sh = 2.2, shift = 2.2 / 3;
const dims = { whiteW: 12, sharpHalf: sh, shift, octGap: 0.9 }; // mirrors shipped LANE_DIMS_FLAT
// cx for one octave: whites on integer slots, blacks on half-slots — the
// same slot geometry keyLayout/keyX produce (cx = slot * whiteW=12).
const CX = {
60: 0, 61: 6, 62: 12, 63: 18, 64: 24, 65: 36, 66: 42,
67: 48, 68: 54, 69: 60, 70: 66, 71: 72, 72: 84,
};
const midis = [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72];
const spans = midis.map((m) => laneSpanFlat(m, _isBlackPc(m), CX[m], dims, false));
const wOf = (s) => s.right - s.left;
const w = (m) => wOf(spans[midis.indexOf(m)]);
// Zero-overlap tiling: every lane abuts the previous one (no gap, no overlap).
for (let i = 1; i < spans.length; i++) {
assert.ok(Math.abs(spans[i].left - spans[i - 1].right) < 1e-9, 'lane ' + midis[i] + ' abuts');
}
// Sharps are all the same width.
for (const m of [61, 63, 66, 68, 70]) {
assert.ok(Math.abs(w(m) - 2 * sh) < 1e-9, 'sharp ' + m + ' width');
}
// The lean evens the naturals: C, D, E, F, B all come out equal.
for (const m of [62, 64, 65, 71]) {
assert.ok(Math.abs(w(m) - w(60)) < 1e-9, 'natural ' + m + ' == C (evened)');
}
// G and A are the only slightly-smaller naturals (G# can't lean) — still
// clearly wider than a sharp, and MUCH closer to the rest than plain V2
// (which would leave D at 122·sh, far below C's 12sh).
assert.ok(Math.abs(w(67) - w(69)) < 1e-9, 'G == A');
assert.ok(w(67) < w(60) && w(67) > 2 * sh, 'G/A a touch smaller, still wider than a sharp');
assert.ok(w(60) - w(67) < sh, 'natural spread is under one sharp-width');
});
test('laneSpanReal (V4): naturals uniform, sharps full-width and overlapping', () => {
const { laneSpanReal } = load().slopsmithViz_keys_highway_3d.__test;
const dims = { natHalf: 5.64, sharpHalf: 3.2, octGap: 0.9 }; // mirrors LANE_DIMS_REAL
const wOf = (s) => s.right - s.left;
// Every natural is the same full width, whatever its neighbours.
for (const [midi, slot] of [[60, 0], [62, 1], [64, 2], [67, 4], [71, 6]]) {
assert.ok(Math.abs(wOf(laneSpanReal(midi, false, slot * 12, dims, false)) - 2 * 5.64) < 1e-9,
'natural ' + midi + ' uniform');
}
// Sharps are the full (wider) black-key width and overlap their naturals.
const C = laneSpanReal(60, false, 0, dims, false);
const Cs = laneSpanReal(61, true, 6, dims, false);
assert.ok(Math.abs(wOf(Cs) - 2 * 3.2) < 1e-9, 'sharp full width');
assert.ok(Cs.left < C.right, 'sharp overlaps (tucks over) the natural');
});
test('laneSpanFlat (V5): octaveGaps widens B→C by octGap, sharps unaffected', () => {
const { laneSpanFlat } = load().slopsmithViz_keys_highway_3d.__test;
const dims = { whiteW: 12, sharpHalf: 2.2, shift: 2.2 / 3, octGap: 0.9 };
const gapOff = laneSpanFlat(72, false, 84, dims, false).left - laneSpanFlat(71, false, 72, dims, false).right;
const gapOn = laneSpanFlat(72, false, 84, dims, true).left - laneSpanFlat(71, false, 72, dims, true).right;
assert.ok(Math.abs((gapOn - gapOff) - dims.octGap) < 1e-9, 'B→C divider grows by octGap');
// Sharps are unaffected by the octave-gap option.
const s = laneSpanFlat(61, true, 6, dims, true);
assert.ok(Math.abs((s.right - s.left) - 2 * dims.sharpHalf) < 1e-9, 'sharp width unchanged by gaps');
});
test('laneSpanFlat (V5): active-range boundary key is NOT trimmed by an out-of-range neighbor sharp', () => {
const { laneSpanFlat } = load().slopsmithViz_keys_highway_3d.__test;
const dims = { whiteW: 12, sharpHalf: 2.2, shift: 2.2 / 3, octGap: 0.9 }; // mirrors LANE_DIMS_FLAT
// F (midi 65, cx 36): its upper neighbor F# (66) is a sharp. When F sits
// at range.activeHigh and F# is excluded from the active range, F# never
// gets a lane drawn (see the activeLow/activeHigh skip around the
// lane-strip loop) — trimming F's right edge for it would leave a dark,
// unfilled sliver. The edge should stay full instead.
const highBoundary = { activeLow: 60, activeHigh: 65 };
const fAtBoundary = laneSpanFlat(65, false, 36, dims, false, highBoundary);
assert.ok(Math.abs(fAtBoundary.right - (36 + dims.whiteW / 2)) < 1e-9,
'F right edge stays full when F# is out of the active range');
// Same key, but now F# IS in the active range: normal zero-overlap
// tiling applies — the trim matches the ungated (no-range) call exactly,
// so in-range geometry is unaffected by this fix.
const highIncluded = { activeLow: 60, activeHigh: 66 };
const fWithSharpInRange = laneSpanFlat(65, false, 36, dims, false, highIncluded);
const fUngated = laneSpanFlat(65, false, 36, dims, false);
assert.ok(Math.abs(fWithSharpInRange.right - fUngated.right) < 1e-9,
'F trims normally once F# is back in range');
assert.ok(fWithSharpInRange.right < fAtBoundary.right, 'in-range trim is narrower than the boundary full edge');
// Symmetric case on the low edge: D (midi 62, cx 12), lower neighbor C#
// (61) excluded when D sits at range.activeLow.
const lowBoundary = { activeLow: 62, activeHigh: 72 };
const dAtBoundary = laneSpanFlat(62, false, 12, dims, false, lowBoundary);
assert.ok(Math.abs(dAtBoundary.left - (12 - dims.whiteW / 2)) < 1e-9,
'D left edge stays full when C# is out of the active range');
const lowIncluded = { activeLow: 61, activeHigh: 72 };
const dWithSharpInRange = laneSpanFlat(62, false, 12, dims, false, lowIncluded);
const dUngated = laneSpanFlat(62, false, 12, dims, false);
assert.ok(Math.abs(dWithSharpInRange.left - dUngated.left) < 1e-9,
'D trims normally once C# is back in range');
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "tuner",
"name": "Guitar/Bass Tuner",
"version": "1.3.3",
"version": "1.3.4",
"bundled": true,
"private": false,
"script": "screen.js",
+9 -2
View File
@@ -869,8 +869,15 @@ window._tunerUI = function(state, actions) {
btn.textContent = 'Tuner';
btn.title = 'Open Tuner';
btn.onclick = window.tuner.toggle;
const closeBtn = isV3 ? null : controls.querySelector('button:last-child');
if (closeBtn) controls.insertBefore(btn, closeBtn);
// Anchor to the last DIRECT-child button of `controls` (the classic
// transport's close/exit button). A bare `button:last-child` can match
// a NESTED button that is not a direct child of `controls`, and
// `insertBefore()` then throws NotFoundError — which propagated out of
// the player-screen transition and aborted its render (feedBack#800).
// `:scope > button:last-of-type` restricts the anchor to a direct child;
// the parentNode check is a belt-and-suspenders guard before insertBefore.
const closeBtn = isV3 ? null : controls.querySelector(':scope > button:last-of-type');
if (closeBtn && closeBtn.parentNode === controls) controls.insertBefore(btn, closeBtn);
else controls.appendChild(btn);
updatePlayerButton();
}
+94
View File
@@ -0,0 +1,94 @@
// Perf-baseline harness for the module-migration refactor (R0).
//
// Rerun this after every phase (R0 → R3c) to prove the split does not regress
// screen-entry, frame-time, memory, or server latency. It writes a markdown
// results block to stdout; paste it into docs/perf-baseline.md (or redirect).
//
// Usage:
// node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 [--n 60] [--soak 30]
//
// Maintainer/CI-only dev tooling (uses the committed @playwright/test browser);
// never part of the serve or Docker path. Metrics that need a seeded library
// with charts (playback frame-time, screen-entry into a live highway) are
// clearly labelled — run those against an environment with real songs.
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const { chromium } = require('@playwright/test');
const args = new Map();
for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i].replace(/^--/, ''), process.argv[i + 1]);
const BASE = args.get('base') || 'http://127.0.0.1:8000';
const N = parseInt(args.get('n') || '60', 10);
const SOAK_S = parseInt(args.get('soak') || '30', 10);
const pct = (xs, p) => {
if (!xs.length) return null;
const s = [...xs].sort((a, b) => a - b);
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
};
const ms = (x) => (x == null ? '—' : `${x.toFixed(1)}`);
// ── Server latency: p50/p95/p99 over N requests per endpoint ──────────────────
async function serverLatency(paths) {
const rows = [];
for (const path of paths) {
const t = [];
let status = 0;
for (let i = 0; i < N; i++) {
const t0 = performance.now();
try {
const r = await fetch(BASE + path);
status = r.status;
await r.arrayBuffer();
} catch { status = -1; }
t.push(performance.now() - t0);
}
rows.push({ path, status, p50: pct(t, 50), p95: pct(t, 95), p99: pct(t, 99) });
}
return rows;
}
// ── Client: cold boot-to-interactive + idle memory after a soak ───────────────
async function clientMetrics() {
const browser = await chromium.launch();
const page = await browser.newPage();
const t0 = Date.now();
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 });
const bootMs = Date.now() - t0;
// performance.memory is Chromium-only; JS heap after settle.
const mem0 = await page.evaluate(() => (performance.memory ? performance.memory.usedJSHeapSize : null));
await page.waitForTimeout(SOAK_S * 1000);
const mem1 = await page.evaluate(() => (performance.memory ? performance.memory.usedJSHeapSize : null));
const scripts = await page.evaluate(() =>
document.querySelectorAll('script[data-plugin-id]').length);
await browser.close();
return { bootMs, memStartMB: mem0 && mem0 / 1048576, memSoakMB: mem1 && mem1 / 1048576, scripts };
}
const server = await serverLatency([
'/api/version',
'/api/plugins',
'/api/library?limit=60',
'/api/library/artists',
]);
const client = await clientMetrics();
const now = new Date().toISOString();
let out = `\n<!-- generated by scripts/perf-baseline.mjs @ ${now} against ${BASE} (n=${N}, soak=${SOAK_S}s) -->\n\n`;
out += `### Server latency (ms)\n\n| Endpoint | status | p50 | p95 | p99 |\n|---|---|---|---|---|\n`;
for (const r of server) out += `| \`${r.path}\` | ${r.status} | ${ms(r.p50)} | ${ms(r.p95)} | ${ms(r.p99)} |\n`;
out += `\n### Client\n\n| Metric | Value |\n|---|---|\n`;
out += `| Cold boot → networkidle | ${client.bootMs} ms |\n`;
out += `| JS heap after load | ${client.memStartMB ? client.memStartMB.toFixed(1) + ' MB' : '—'} |\n`;
out += `| JS heap after ${SOAK_S}s idle soak | ${client.memSoakMB ? client.memSoakMB.toFixed(1) + ' MB' : '—'} |\n`;
out += `| Plugin scripts injected | ${client.scripts} |\n`;
out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`;
out += `> on the 2D + 3D highway, and screen-entry (plugin inject → interactive) for\n`;
out += `> editor / notedetect / highway_3d with a real chart loaded. Run this harness\n`;
out += `> against an environment with charts in \`DLC_DIR\` to fill those in.\n`;
console.log(out);
+34 -9
View File
@@ -1348,16 +1348,23 @@ class MetadataDB:
return [{"tag": r[0], "count": r[1]} for r in rows]
def user_meta_map(self, filenames) -> dict:
"""Batch {filename: user_difficulty} for a page of rows (set values
only). Lets query_page embed difficulty without an N+1."""
"""Batch {filename: user_difficulty} for a set of rows (set values
only). Lets query_page / query_artists embed difficulty without an
N+1. Chunked under SQLite's variable limit — query_artists can pass
every song across 50 artists, well past a single IN (...)."""
fns = list(filenames)
if not fns:
return {}
ph = ",".join("?" * len(fns))
rows = self.conn.execute(
f"SELECT filename, user_difficulty FROM song_user_meta "
f"WHERE filename IN ({ph}) AND user_difficulty IS NOT NULL", fns).fetchall()
return {r[0]: r[1] for r in rows}
out: dict = {}
for i in range(0, len(fns), 400):
chunk = fns[i:i + 400]
if not chunk:
break
ph = ",".join("?" * len(chunk))
rows = self.conn.execute(
f"SELECT filename, user_difficulty FROM song_user_meta "
f"WHERE filename IN ({ph}) AND user_difficulty IS NOT NULL", chunk).fetchall()
for fn, diff in rows:
out[fn] = diff
return out
def tags_map(self, filenames) -> dict:
"""Batch {filename: [tags]} for a page of rows."""
@@ -4107,6 +4114,18 @@ class MetadataDB:
"((SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) IS NULL) ASC, "
"(SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) DESC"
),
# Personal difficulty rating (song_user_meta.user_difficulty, 1..5 —
# manually set or seeded by the difficulty_tagger plugin), via a
# correlated subquery like mastery above (drops to OFFSET paging).
# Unrated songs push to the bottom in both directions.
"difficulty": (
"((SELECT user_difficulty FROM song_user_meta u WHERE u.filename = songs.filename) IS NULL) ASC, "
"(SELECT user_difficulty FROM song_user_meta u WHERE u.filename = songs.filename) ASC"
),
"difficulty-desc": (
"((SELECT user_difficulty FROM song_user_meta u WHERE u.filename = songs.filename) IS NULL) ASC, "
"(SELECT user_difficulty FROM song_user_meta u WHERE u.filename = songs.filename) DESC"
),
}
if group and sort in ("mastery", "mastery-desc"):
# Sort law (§7.1): mastery aggregates MAX across the WHOLE group —
@@ -4381,6 +4400,11 @@ class MetadataDB:
from collections import OrderedDict
estd = self._estd_set()
favs = self.favorite_set()
# Personal difficulty rides along here too (feedBack#810 follow-up),
# same batched pattern as query_page — without this the tree view's
# difficulty badge silently never renders (song.user_difficulty was
# always undefined for every row).
udm = self.user_meta_map([r[0] for r in rows])
artists = OrderedDict()
for r in rows:
artist = r[2] or "Unknown Artist"
@@ -4402,6 +4426,7 @@ class MetadataDB:
"tuning_name": r[12] or "",
"has_estd": r[0] in estd,
"favorite": r[0] in favs,
"user_difficulty": udm.get(r[0]),
})
# Pick most common name variant per artist/album
+31
View File
@@ -1110,6 +1110,7 @@ const _LIB_VIEW_VALUES = new Set(['grid', 'tree', 'folder']);
const _LIB_SORT_VALUES = new Set([
'artist', 'artist-desc', 'title', 'title-desc',
'recent', 'year-desc', 'year', 'tuning',
'difficulty', 'difficulty-desc',
]);
const _LIB_FORMAT_VALUES = new Set(['', 'sloppak', 'loose']);
// Tree-view expand/collapse persistence. Three states per tree:
@@ -2078,6 +2079,7 @@ function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace') {
${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()}
${tuning ? `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>` : ''}
${song.has_lyrics ? `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>` : ''}
${song.user_difficulty != null ? `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>` : ''}
${duration ? `<span class="text-gray-600">${duration}</span>` : ''}
</div>
${retuneBtn}
@@ -2277,6 +2279,8 @@ async function renderTreeInto(containerId, countId, stats, letter, q, favoritesO
html += `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>`;
if (song.has_lyrics)
html += `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>`;
if (song.user_difficulty != null)
html += `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>`;
if (duration)
html += `<span class="text-gray-600 w-10 text-right">${duration}</span>`;
if (stdRetune)
@@ -5564,9 +5568,24 @@ function _playbackApi() {
: null;
}
// Bridge hits are a "this legacy surface is still in use" signal, not a call
// counter — but recordBridgeHit is not cheap (compat-shim bookkeeping, a
// playback:bridge-hit event, and a diagnostics snapshot rebuild per call).
// Plugins legitimately poll read surfaces like window.feedBack.getLoop() from
// HUD ticks (note_detect polled at ~30 Hz), which turned every tick into a
// snapshot serialization on the main thread and saturated the inspector's
// hitCount. Throttle per surface: the first call records immediately, repeats
// within the window are dropped.
const _bridgeRecordLast = new Map();
const _BRIDGE_RECORD_MIN_MS = 5000;
function _recordPlaybackBridge(bridgeId, legacySurface, reason) {
const playback = _playbackApi();
if (!playback || typeof playback.recordBridgeHit !== 'function') return;
const key = `${bridgeId}|${legacySurface}`;
const now = Date.now();
const last = _bridgeRecordLast.get(key);
if (last != null && now - last < _BRIDGE_RECORD_MIN_MS) return;
_bridgeRecordLast.set(key, now);
playback.recordBridgeHit({
bridgeId,
legacySurface,
@@ -7940,6 +7959,10 @@ function setLoopEnd() {
if (loopB <= loopA) { loopB = null; return; }
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
updateLoopUI();
// Manual A/B arming is a loop mutation like setLoop()'s — emit the same
// transport event so event-driven consumers (note_detect drill sync) see
// button-armed loops without having to poll getLoop().
window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } });
}
function clearLoop(options) {
@@ -11544,6 +11567,14 @@ async function loadPlugins() {
// URL ?v=mtime convention elsewhere in this file).
const v = encodeURIComponent(wantedVersion);
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
// Module-migration (R0): a migrated plugin declares
// scriptType:"module" and its screen.js is `import
// './src/main.js'`. A <script type="module"> fires load
// only after its whole static-import graph evaluates, so
// the await-onload completion + _loadingPluginId contract
// below is preserved (a classic-IIFE dynamic import()
// would not). Classic plugins are unaffected.
if (plugin.script_type === 'module') script.type = 'module';
script.dataset.pluginId = plugin.id;
script.dataset.pluginVersion = wantedVersion;
window.feedBack._loadingPluginId = plugin.id;
+2
View File
@@ -119,6 +119,8 @@
<option value="year-desc">Year (newest)</option>
<option value="year">Year (oldest)</option>
<option value="tuning">Tuning</option>
<option value="difficulty">Difficulty (easiest first)</option>
<option value="difficulty-desc">Difficulty (hardest first)</option>
</select>
<!-- Format filter (shared) -->
<select id="lib-format" onchange="sortLibrary()"
+1 -1
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -38,6 +38,9 @@
// Mastery = best accuracy across arrangements (song_stats); unscored songs
// sort last either way. Ascending surfaces what needs work; never default.
['mastery', 'Needs practice first'], ['mastery-desc', 'Most mastered first'],
// Personal difficulty (song_user_meta.user_difficulty, 1-5); unrated
// songs sort last either way.
['difficulty', 'Difficulty (easiest first)'], ['difficulty-desc', 'Difficulty (hardest first)'],
];
const FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']];
const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];
@@ -0,0 +1,59 @@
// Guards the R0 module-migration loader change in static/app.js: a migrated
// plugin (manifest scriptType:"module", surfaced as plugin.script_type) must be
// injected as <script type="module"> so its screen.js `import './src/main.js'`
// graph loads, while classic plugins stay untouched.
//
// The injection is a single line inside the large async loadPlugins() closure
// (it depends on loadedScripts, _removePluginScriptTags, and the
// _loadingPluginId completion window), so a faithful behavioural harness would
// need to stub the whole loader. Instead this asserts the *structural*
// contract in source — the guard exists, is gated (not unconditional), and sits
// inside the screen.js injection block before appendChild. The behavioural proof
// is the R0 end-to-end live-edit check (a real module plugin booting in-browser).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const src = fs.readFileSync(APP_JS, 'utf8');
// Isolate the screen.js <script> injection block: from where its src is built
// to where the element is appended.
function injectionBlock() {
const start = src.indexOf('/api/plugins/${plugin.id}/screen.js');
assert.ok(start !== -1, 'screen.js injection src not found — loader moved?');
const end = src.indexOf('document.body.appendChild(script)', start);
assert.ok(end !== -1, 'appendChild(script) not found after screen.js src');
return src.slice(start, end);
}
test('module plugins are injected as <script type="module">', () => {
const block = injectionBlock();
assert.match(
block,
/if\s*\(\s*plugin\.script_type\s*===\s*['"]module['"]\s*\)\s*script\.type\s*=\s*['"]module['"]\s*;/,
'expected a guarded `script.type = "module"` keyed on plugin.script_type === "module"',
);
});
test('the module type is gated, never set unconditionally', () => {
const block = injectionBlock();
// Every assignment of script.type in the block must be on the same line as
// the plugin.script_type guard (i.e. no bare `script.type = 'module'`).
for (const line of block.split('\n')) {
if (/script\.type\s*=/.test(line)) {
assert.match(line, /plugin\.script_type\s*===\s*['"]module['"]/,
`unguarded script.type assignment: ${line.trim()}`);
}
}
});
test('the module guard sits before appendChild, after the src assignment', () => {
const guardAt = src.indexOf('script.type = \'module\'');
const srcAt = src.indexOf('/api/plugins/${plugin.id}/screen.js');
const appendAt = src.indexOf('document.body.appendChild(script)', srcAt);
assert.ok(guardAt > srcAt && guardAt < appendAt,
'the module guard must live inside the screen.js injection block');
});
@@ -0,0 +1,191 @@
// Regression test for feedBack#800: tuner injectPlayerButton() must anchor the
// injected button to a DIRECT-child button of #player-controls. The old
// `controls.querySelector('button:last-child')` could resolve to a NESTED
// button, and `controls.insertBefore(btn, nestedButton)` then throws
// NotFoundError — which propagated out of the player-screen transition and
// aborted its render.
//
// Same isolation strategy as the core tests/js suite: extract the real function
// from source with extractFunction() and run it in a vm sandbox over a small
// but faithful DOM model. The model's insertBefore() enforces the real DOM
// invariant (reference node must be a direct child, else NotFoundError), and
// querySelector() implements the exact semantics of both the old
// (`button:last-child`) and new (`:scope > button:last-of-type`) selectors — so
// reverting the fix makes this test throw.
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 { extractFunction } = require('../../../js/test_utils');
const UI_JS = path.join(__dirname, '..', '..', '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
const SRC = fs.readFileSync(UI_JS, 'utf8');
const FN_SRC = extractFunction(SRC, 'function injectPlayerButton(');
// ── Minimal, faithful DOM model ──────────────────────────────────────────────
class El {
constructor(tag, id = '') {
this.tagName = tag.toUpperCase();
this.id = id;
this.children = [];
this.parentNode = null;
this.textContent = '';
this.title = '';
this.onclick = null;
}
appendChild(node) {
node.parentNode = this;
this.children.push(node);
return node;
}
insertBefore(node, ref) {
const idx = this.children.indexOf(ref);
if (ref == null || idx === -1) {
// Faithful to the browser: ref must be a direct child.
const e = new Error(
"Failed to execute 'insertBefore' on 'Node': The node before which the "
+ 'new node is to be inserted is not a child of this node.'
);
e.name = 'NotFoundError';
throw e;
}
node.parentNode = this;
this.children.splice(idx, 0, node);
return node;
}
querySelector(sel) {
if (sel === ':scope > button:last-of-type') {
// Last direct-child <button>.
const btns = this.children.filter((c) => c.tagName === 'BUTTON');
return btns.length ? btns[btns.length - 1] : null;
}
if (sel === 'button:last-child') {
// First descendant <button> (document order) that is the last child
// of its own parent — the buggy legacy anchor.
let found = null;
const walk = (node) => {
for (const c of node.children) {
if (found) return;
const isLast = c.parentNode.children[c.parentNode.children.length - 1] === c;
if (c.tagName === 'BUTTON' && isLast) { found = c; return; }
walk(c);
}
};
walk(this);
return found;
}
throw new Error(`unhandled selector in stub: ${sel}`);
}
}
function findById(node, id) {
if (!node) return null;
if (node.id === id) return node;
for (const c of node.children) {
const r = findById(c, id);
if (r) return r;
}
return null;
}
// Run the extracted injectPlayerButton() against a given controls tree.
// Returns { controls, threw }.
function run({ controls, isV3 = false, slot = null }) {
const roots = [controls, slot].filter(Boolean);
const document = {
getElementById(id) {
if (id === 'player-controls') return controls;
for (const r of roots) {
const hit = findById(r, id);
if (hit) return hit;
}
return null;
},
createElement(tag) { return new El(tag); },
};
const window = {
feedBack: isV3
? { uiVersion: 'v3', ui: { playerControlSlot: () => slot } }
: { uiVersion: 'v2' },
tuner: { toggle: () => {} },
};
const sandbox = {
window,
document,
Element: El,
updatePlayerButton: () => {},
};
vm.createContext(sandbox);
let threw = null;
try {
vm.runInContext(FN_SRC + '\nglobalThis.__run = injectPlayerButton;\n__run();', sandbox);
} catch (e) {
threw = e;
}
return { controls, slot, threw };
}
// ── Tests ────────────────────────────────────────────────────────────────────
test('does not throw when the last button is nested (feedBack#800 repro)', () => {
// controls > div.transport > [play, close]; `close` is button:last-child of
// the div but NOT a direct child of controls. The old anchor threw here.
const controls = new El('div', 'player-controls');
const transport = new El('div');
transport.appendChild(new El('button', 'play'));
transport.appendChild(new El('button', 'close'));
controls.appendChild(transport);
const { threw } = run({ controls });
assert.equal(threw, null, threw && threw.message);
// With no direct-child button, it appends to controls.
assert.ok(findById(controls, 'btn-tuner-player'), 'tuner button was added');
assert.equal(controls.children[controls.children.length - 1].id, 'btn-tuner-player');
});
test('inserts before the last direct-child button when one exists', () => {
const controls = new El('div', 'player-controls');
controls.appendChild(new El('button', 'play'));
controls.appendChild(new El('button', 'close'));
const { threw } = run({ controls });
assert.equal(threw, null, threw && threw.message);
const ids = controls.children.map((c) => c.id);
// tuner button sits immediately before the last direct-child button.
assert.deepEqual(ids, ['play', 'btn-tuner-player', 'close']);
});
test('appends when controls has no buttons at all', () => {
const controls = new El('div', 'player-controls');
controls.appendChild(new El('span'));
const { threw } = run({ controls });
assert.equal(threw, null, threw && threw.message);
assert.equal(controls.children[controls.children.length - 1].id, 'btn-tuner-player');
});
test('is idempotent — a second call does not add a duplicate', () => {
const controls = new El('div', 'player-controls');
controls.appendChild(new El('button', 'close'));
run({ controls });
run({ controls });
const injected = controls.children.filter((c) => c.id === 'btn-tuner-player');
assert.equal(injected.length, 1);
});
test('v3 mounts into the plugin-control slot and never uses the legacy anchor', () => {
const slot = new El('div', 'plugin-control-slot');
// A nested button in the slot would trip the legacy anchor; v3 must ignore it.
const inner = new El('div');
inner.appendChild(new El('button', 'other'));
slot.appendChild(inner);
const controls = new El('div', 'player-controls');
const { threw } = run({ controls, isV3: true, slot });
assert.equal(threw, null, threw && threw.message);
assert.ok(findById(slot, 'btn-tuner-player'), 'tuner button mounted into the slot');
assert.equal(findById(controls, 'btn-tuner-player'), null, 'not mounted into #player-controls');
});
+43 -5
View File
@@ -223,15 +223,15 @@ def test_unmapped_percussion_silently_skipped(monkeypatch):
def test_unmapped_percussion_reported_via_out_unmapped(monkeypatch):
"""Opting in via out_unmapped records the dropped MIDI notes (count +
times) so a caller can surface a warning / mapping UI."""
times + velocities) so a caller can surface a warning / mapping UI."""
_setup(monkeypatch)
track = _fake_track(
string_midis=[56, 36, 54],
beats=[
(0.0, [_fake_note(string_idx=1)]), # cowbell — drop
(1.0, [_fake_note(string_idx=2)]), # kick — keep
(1.5, [_fake_note(string_idx=3)]), # tambourine — drop
(2.0, [_fake_note(string_idx=1)]), # cowbell again — drop
(0.0, [_fake_note(string_idx=1, velocity=88)]), # cowbell — drop
(1.0, [_fake_note(string_idx=2)]), # kick — keep
(1.5, [_fake_note(string_idx=3, velocity=25)]), # tambourine — drop
(2.0, [_fake_note(string_idx=1, velocity=44)]), # cowbell again — drop
],
)
song = SimpleNamespace(tracks=[track])
@@ -247,6 +247,44 @@ def test_unmapped_percussion_reported_via_out_unmapped(monkeypatch):
# Times are captured (rounded to 3 dp).
assert unmapped[56]["times"] == [0.0, 2.0]
assert unmapped[54]["times"] == [1.5]
# Velocities ride index-aligned with times — the mapping UI can carry
# the source dynamics through instead of flattening to a default.
assert unmapped[56]["velocities"] == [88, 44]
assert unmapped[54]["velocities"] == [25]
def test_unmapped_velocities_sort_in_lockstep_with_times(monkeypatch):
"""Multi-voice measures can capture times out of order; the final sort
must reorder velocities WITH their times, not leave them behind."""
_setup(monkeypatch)
track = _fake_track(
string_midis=[56],
beats=[
# Deliberately reversed chronology within the measure.
(2.0, [_fake_note(string_idx=1, velocity=44)]),
(0.0, [_fake_note(string_idx=1, velocity=88)]),
],
)
song = SimpleNamespace(tracks=[track])
unmapped: dict[int, dict] = {}
gp2rs.convert_drum_track_to_drumtab(song, 0, out_unmapped=unmapped)
assert unmapped[56]["times"] == [0.0, 2.0]
assert unmapped[56]["velocities"] == [88, 44], \
"velocity must follow its time through the sort"
def test_unmapped_out_of_range_velocity_falls_back_to_default(monkeypatch):
"""A corrupt/zero GP velocity records the 100 import default rather
than poisoning the aligned list."""
_setup(monkeypatch)
track = _fake_track(
string_midis=[56],
beats=[(0.0, [_fake_note(string_idx=1, velocity=0)])],
)
song = SimpleNamespace(tracks=[track])
unmapped: dict[int, dict] = {}
gp2rs.convert_drum_track_to_drumtab(song, 0, out_unmapped=unmapped)
assert unmapped[56]["velocities"] == [100]
def test_zero_velocity_omitted_from_wire(monkeypatch):
+39
View File
@@ -293,6 +293,45 @@ def test_year_sort_asc_oldest_first(client, seeded):
assert files == ["b.archive", "a.archive", "f.archive", "d.sloppak", "c.sloppak", "e.sloppak"]
def test_difficulty_sort_pushes_unrated_to_bottom(client, server_mod):
"""Personal difficulty (song_user_meta.user_difficulty) sorts like
mastery: an unrated (NULL) row must fall to the bottom in BOTH
directions rather than colliding with a real 1..5 rating at either
end."""
_put(server_mod, filename="easy.archive", title="Easy", artist="A",
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
_put(server_mod, filename="hard.archive", title="Hard", artist="B",
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
_put(server_mod, filename="unrated.archive", title="Unrated", artist="C",
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
server_mod.meta_db.set_song_user_meta("easy.archive", user_difficulty=1)
server_mod.meta_db.set_song_user_meta("hard.archive", user_difficulty=5)
asc = [s["filename"] for s in _get(client, sort="difficulty")["songs"]]
assert asc == ["easy.archive", "hard.archive", "unrated.archive"]
desc = [s["filename"] for s in _get(client, sort="difficulty-desc")["songs"]]
assert desc == ["hard.archive", "easy.archive", "unrated.archive"]
def test_tree_view_songs_carry_user_difficulty(client, server_mod):
"""`/api/library/artists` (the classic tree view's `query_artists`) must
batch-attach `user_difficulty` the same way `query_page` does for the
grid otherwise the tree view's difficulty badge silently never
renders (song.user_difficulty stays undefined for every row)."""
_put(server_mod, filename="rated.archive", title="Rated", artist="A",
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
_put(server_mod, filename="unrated.archive", title="Unrated", artist="A",
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
server_mod.meta_db.set_song_user_meta("rated.archive", user_difficulty=4)
data = client.get("/api/library/artists").json()
songs = data["artists"][0]["albums"][0]["songs"]
by_filename = {s["filename"]: s for s in songs}
assert by_filename["rated.archive"]["user_difficulty"] == 4
assert by_filename["unrated.archive"]["user_difficulty"] is None
def test_tuning_sort_down_tuned_before_up_tuned_at_same_distance(client, server_mod):
"""Within an ABS(tuning_sort_key) tier, the down-tuned variant
must come before the up-tuned one so the order matches the chart's
+53
View File
@@ -277,3 +277,56 @@ def test_wire_format_shape(tmp_path):
assert "anchors" in result
assert "tuning" in result
assert "capo" in result
# ── non-positive division guard (legacy inline tempo path) ───────────────────
def test_zero_division_does_not_crash(tmp_path):
"""A malformed header (ticks_per_beat == 0) must not raise ZeroDivisionError.
The legacy inline tempo map in convert_midi_track_to_keys_wire divides by
ticks_per_beat at two sites; a 0 division falls back to the SMF default so
the note is still emitted with a sane, non-negative time.
"""
mid = mido.MidiFile(ticks_per_beat=0)
track = mido.MidiTrack()
mid.tracks.append(track)
# Note starts after a one-"beat" rest so a bad divisor would skew its start.
track.append(mido.Message("note_on", channel=0, note=60, velocity=64, time=480))
track.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=480))
path = _save(mid, tmp_path)
assert mido.MidiFile(path).ticks_per_beat == 0 # precondition: divisor is 0
result = convert_midi_track_to_keys_wire(path, track_index=0)
assert len(result["notes"]) == 1
n = result["notes"][0]
# 480-tick fallback @ 120 BPM: one beat = 0.5 s.
assert n["t"] == pytest.approx(0.5)
assert n["t"] >= 0.0
assert n["sus"] == pytest.approx(0.5)
def test_smpte_negative_division_produces_nonnegative_times(tmp_path):
"""SMPTE division (mido returns a NEGATIVE ticks_per_beat) must not yield
negative times through the legacy inline path.
``or 480`` would miss this (a negative value is truthy); the ``> 0`` guard
falls back so the emitted note keeps a sane, non-negative start time.
"""
mid = mido.MidiFile()
mid.ticks_per_beat = -1 # simulate a SMPTE / malformed signed-short division
track = mido.MidiTrack()
mid.tracks.append(track)
track.append(mido.Message("note_on", channel=0, note=60, velocity=64, time=480))
track.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=480))
path = _save(mid, tmp_path)
assert mido.MidiFile(path).ticks_per_beat < 0 # precondition: negative divisor
result = convert_midi_track_to_keys_wire(path, track_index=0)
assert len(result["notes"]) == 1
n = result["notes"][0]
assert n["t"] >= 0.0
assert n["sus"] >= 0.0
# 480-tick fallback @ 120 BPM: one beat = 0.5 s.
assert n["t"] == pytest.approx(0.5)
assert n["sus"] == pytest.approx(0.5)
+8 -4
View File
@@ -185,18 +185,18 @@ def test_unmapped_drum_note_skipped(tmp_path):
def test_unmapped_drum_note_reported_via_out_unmapped(tmp_path):
"""Opting in via out_unmapped records the dropped MIDI notes (count +
times) so a caller can surface a warning / mapping UI."""
times + velocities) so a caller can surface a warning / mapping UI."""
mid = mido.MidiFile(type=1, ticks_per_beat=480)
track = mido.MidiTrack()
mid.tracks.append(track)
track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
track.append(mido.Message("note_on", channel=9, note=56, velocity=100, time=0)) # cowbell — drop
track.append(mido.Message("note_on", channel=9, note=56, velocity=88, time=0)) # cowbell — drop
track.append(mido.Message("note_off", channel=9, note=56, velocity=0, time=240))
track.append(mido.Message("note_on", channel=9, note=36, velocity=100, time=0)) # kick — keep
track.append(mido.Message("note_off", channel=9, note=36, velocity=0, time=240))
track.append(mido.Message("note_on", channel=9, note=54, velocity=100, time=0)) # tambourine — drop
track.append(mido.Message("note_on", channel=9, note=54, velocity=25, time=0)) # tambourine — drop
track.append(mido.Message("note_off", channel=9, note=54, velocity=0, time=240))
track.append(mido.Message("note_on", channel=9, note=56, velocity=100, time=0)) # cowbell again — drop
track.append(mido.Message("note_on", channel=9, note=56, velocity=44, time=0)) # cowbell again — drop
track.append(mido.Message("note_off", channel=9, note=56, velocity=0, time=240))
unmapped: dict[int, dict] = {}
@@ -209,6 +209,10 @@ def test_unmapped_drum_note_reported_via_out_unmapped(tmp_path):
# Each unmapped MIDI carries the times at which it fired (rounded 3 dp).
assert all(isinstance(t, float) for t in unmapped[56]["times"])
assert len(unmapped[56]["times"]) == 2
# Velocities ride index-aligned with times — the mapping UI can carry
# the source dynamics through instead of flattening to a default.
assert unmapped[56]["velocities"] == [88, 44]
assert unmapped[54]["velocities"] == [25]
def test_non_channel9_events_ignored(tmp_path):
+251
View File
@@ -0,0 +1,251 @@
"""Tests for lib/midi_import.py — convert_midi_tempo_map.
The note converters always computed a tempo-aware tickseconds map internally
(to bake note times) and then threw it away and never read time_signature
meta at all so every MIDI import landed with no bars, no measures, and an
implied 4/4 regardless of the file. convert_midi_tempo_map extracts the grid:
tempos, time signatures (song-timeline shape), and a full beat grid on the
editor's row shape (numbered downbeats with a `den` hint, `-1` sub-beats).
Every test drives the REAL function against a real .mid built in-memory with
mido and saved to tmp_path no stubs, adversarial inputs included (type-2
scoping, mid-bar signatures, duplicate meta ticks, empty files, long files
for rounding drift).
Run: pytest tests/test_midi_tempo_map.py -v
"""
import mido
import pytest
from midi_import import _TEMPO_MAP_MAX_BARS, convert_midi_tempo_map
# ── helpers ───────────────────────────────────────────────────────────────────
def _save(mid: mido.MidiFile, tmp_path, name: str = "t.mid") -> str:
p = tmp_path / name
mid.save(str(p))
return str(p)
def _note_pair(track, pitch=60, at=0, dur=240):
track.append(mido.Message("note_on", note=pitch, velocity=90, time=at))
track.append(mido.Message("note_off", note=pitch, velocity=0, time=dur))
def _downbeats(result):
return [b for b in result["beats"] if b["measure"] > 0]
def _subbeats(result):
return [b for b in result["beats"] if b["measure"] == -1]
# ── the plain case ────────────────────────────────────────────────────────────
def test_default_grid_is_120_bpm_four_four(tmp_path):
mid = mido.MidiFile(ticks_per_beat=480)
tr = mido.MidiTrack(); mid.tracks.append(tr)
_note_pair(tr, at=0, dur=480 * 8) # two 4/4 bars of content
res = convert_midi_tempo_map(_save(mid, tmp_path))
assert res["tempos"] == [{"time": 0.0, "bpm": 120.0}]
assert res["time_signatures"] == [{"time": 0.0, "ts": [4, 4]}]
dbs = _downbeats(res)
assert [d["measure"] for d in dbs] == [1, 2]
assert [d["time"] for d in dbs] == [0.0, 2.0] # 4 beats at 0.5 s
assert all(d["den"] == 4 for d in dbs)
# 3 interior beats per full bar at 0.5 s spacing.
assert [b["time"] for b in _subbeats(res)][:3] == [0.5, 1.0, 1.5]
# ── tempo handling ────────────────────────────────────────────────────────────
def test_tempo_change_bends_the_grid(tmp_path):
mid = mido.MidiFile(ticks_per_beat=480)
meta = mido.MidiTrack(); mid.tracks.append(meta)
meta.append(mido.MetaMessage("set_tempo", tempo=500000, time=0)) # 120
meta.append(mido.MetaMessage("set_tempo", tempo=250000, time=480 * 4)) # 240 at bar 2
notes = mido.MidiTrack(); mid.tracks.append(notes)
_note_pair(notes, at=0, dur=480 * 8)
res = convert_midi_tempo_map(_save(mid, tmp_path))
assert [t["bpm"] for t in res["tempos"]] == [120.0, 240.0]
dbs = _downbeats(res)
# Bar 1 spans 2.0 s at 120; bar 2 starts at 2.0 and its beats halve.
assert dbs[0]["time"] == 0.0 and dbs[1]["time"] == 2.0
bar2_subs = [b["time"] for b in _subbeats(res) if b["time"] > 2.0]
assert bar2_subs[:3] == [2.25, 2.5, 2.75]
def test_rounding_does_not_accumulate_over_a_long_file(tmp_path):
# 500 bars at 120 BPM: beat times must stay exactly on the 0.5 s lattice
# (absolute-tick computation — never beat N derived from beat N-1).
mid = mido.MidiFile(ticks_per_beat=480)
tr = mido.MidiTrack(); mid.tracks.append(tr)
_note_pair(tr, at=0, dur=480 * 4 * 500)
res = convert_midi_tempo_map(_save(mid, tmp_path))
dbs = _downbeats(res)
assert len(dbs) == 500
assert dbs[-1]["time"] == pytest.approx((500 - 1) * 2.0, abs=0.0005)
assert dbs[250]["time"] == pytest.approx(250 * 2.0, abs=0.0005)
# ── time signatures (the previously-unread meta) ─────────────────────────────
def test_time_signature_changes_shape_the_bars(tmp_path):
mid = mido.MidiFile(ticks_per_beat=480)
meta = mido.MidiTrack(); mid.tracks.append(meta)
meta.append(mido.MetaMessage("time_signature", numerator=4, denominator=4, time=0))
meta.append(mido.MetaMessage("time_signature", numerator=3, denominator=4, time=480 * 4))
notes = mido.MidiTrack(); mid.tracks.append(notes)
_note_pair(notes, at=0, dur=480 * 10) # 4/4 bar + two 3/4 bars
res = convert_midi_tempo_map(_save(mid, tmp_path))
assert [s["ts"] for s in res["time_signatures"]] == [[4, 4], [3, 4]]
dbs = _downbeats(res)
assert [d["time"] for d in dbs] == [0.0, 2.0, 3.5] # 3/4 bars are 1.5 s
# Bar 2 has exactly two interior beats.
bar2 = [b for b in res["beats"] if 2.0 < b["time"] < 3.5]
assert [b["measure"] for b in bar2] == [-1, -1]
def test_six_eight_uses_eighth_note_rows(tmp_path):
mid = mido.MidiFile(ticks_per_beat=480)
meta = mido.MidiTrack(); mid.tracks.append(meta)
meta.append(mido.MetaMessage("time_signature", numerator=6, denominator=8, time=0))
notes = mido.MidiTrack(); mid.tracks.append(notes)
_note_pair(notes, at=0, dur=480 * 3) # one full 6/8 bar
res = convert_midi_tempo_map(_save(mid, tmp_path))
dbs = _downbeats(res)
assert dbs[0]["den"] == 8
bar1 = [b["time"] for b in res["beats"] if b["time"] < 1.5]
# Six eighth-note rows at 120 BPM (quarter = 0.5 s ⇒ eighth = 0.25 s).
assert bar1 == [0.0, 0.25, 0.5, 0.75, 1.0, 1.25]
def test_mid_bar_signature_applies_at_the_next_boundary(tmp_path):
mid = mido.MidiFile(ticks_per_beat=480)
meta = mido.MidiTrack(); mid.tracks.append(meta)
# Ill-formed: 3/4 lands halfway through bar 1.
meta.append(mido.MetaMessage("time_signature", numerator=3, denominator=4, time=480 * 2))
notes = mido.MidiTrack(); mid.tracks.append(notes)
_note_pair(notes, at=0, dur=480 * 8)
res = convert_midi_tempo_map(_save(mid, tmp_path))
dbs = _downbeats(res)
# Bar 1 stays 4/4 (2.0 s); bar 2 onward is 3/4.
assert dbs[0]["time"] == 0.0 and dbs[0]["den"] == 4
# Bar 2 is the 3/4 bar, but its denominator is still 4 (3 quarter notes).
assert dbs[1]["time"] == 2.0 and dbs[1]["den"] == 4
assert dbs[2]["time"] - dbs[1]["time"] == pytest.approx(1.5, abs=0.002)
def test_duplicate_signature_ticks_last_wins(tmp_path):
mid = mido.MidiFile(ticks_per_beat=480)
meta = mido.MidiTrack(); mid.tracks.append(meta)
meta.append(mido.MetaMessage("time_signature", numerator=4, denominator=4, time=0))
meta.append(mido.MetaMessage("time_signature", numerator=7, denominator=8, time=0))
notes = mido.MidiTrack(); mid.tracks.append(notes)
_note_pair(notes, at=0, dur=480 * 4)
res = convert_midi_tempo_map(_save(mid, tmp_path))
assert res["time_signatures"][-1]["ts"] == [7, 8]
assert _downbeats(res)[0]["den"] == 8
# ── SMF type scoping (adversarial) ───────────────────────────────────────────
def test_type2_reads_meta_from_the_chosen_track_only(tmp_path):
mid = mido.MidiFile(ticks_per_beat=480, type=2)
bogus = mido.MidiTrack(); mid.tracks.append(bogus)
bogus.append(mido.MetaMessage("set_tempo", tempo=100000, time=0)) # 600 BPM
bogus.append(mido.MetaMessage("time_signature", numerator=7, denominator=8, time=0))
_note_pair(bogus, at=0, dur=480)
real = mido.MidiTrack(); mid.tracks.append(real)
real.append(mido.MetaMessage("set_tempo", tempo=500000, time=0)) # 120 BPM
_note_pair(real, at=0, dur=480 * 4)
res = convert_midi_tempo_map(_save(mid, tmp_path), track_index=1)
# The bogus track's 600 BPM / 7-8 never leak into track 1's grid.
assert [t["bpm"] for t in res["tempos"]] == [120.0]
assert res["time_signatures"] == [{"time": 0.0, "ts": [4, 4]}]
assert _downbeats(res)[0]["den"] == 4
# ── degenerate inputs ────────────────────────────────────────────────────────
def test_empty_file_yields_empty_beats_but_valid_shape(tmp_path):
mid = mido.MidiFile(ticks_per_beat=480)
mid.tracks.append(mido.MidiTrack())
res = convert_midi_tempo_map(_save(mid, tmp_path))
assert res["beats"] == []
assert res["tempos"] == [{"time": 0.0, "bpm": 120.0}]
assert res["time_signatures"] == [{"time": 0.0, "ts": [4, 4]}]
def test_grid_covers_all_notes_and_stops_after_them(tmp_path):
mid = mido.MidiFile(ticks_per_beat=480)
tr = mido.MidiTrack(); mid.tracks.append(tr)
_note_pair(tr, at=480 * 5, dur=480) # note inside bar 2 only
res = convert_midi_tempo_map(_save(mid, tmp_path))
dbs = _downbeats(res)
assert dbs[0]["time"] == 0.0, "grid starts at zero (SMF convention)"
assert dbs[-1]["measure"] == 2
assert all(b["time"] <= 3.0 + 1e-9 for b in res["beats"]), \
"no beats past the end of musical content"
@pytest.mark.parametrize("division", [0, -1, -25600])
def test_non_positive_division_header_does_not_crash(tmp_path, division):
# A malformed header reloads with ticks_per_beat == 0; a true SMPTE-division
# file reloads negative (mido reads the division as a signed short). Either
# way the tick→seconds closure would divide by a non-positive number —
# raising ZeroDivisionError (0) or walking off into negative times
# (negative) — without the header fallback. The grid must still come out on
# a sane, bounded 4/4 / 120-BPM default.
mid = mido.MidiFile(ticks_per_beat=division)
tr = mido.MidiTrack(); mid.tracks.append(tr)
_note_pair(tr, at=0, dur=480 * 8)
assert mido.MidiFile(_save(mid, tmp_path)).ticks_per_beat == division # precondition
res = convert_midi_tempo_map(_save(mid, tmp_path))
assert res["tempos"] == [{"time": 0.0, "bpm": 120.0}]
assert res["time_signatures"] == [{"time": 0.0, "ts": [4, 4]}]
dbs = _downbeats(res)
assert [d["measure"] for d in dbs] == [1, 2]
assert all(isinstance(b["time"], float) and b["time"] >= 0.0
for b in res["beats"])
def test_first_tempo_after_start_seeds_default_120_at_zero(tmp_path):
# First (and only) set_tempo lands at bar 2. The head of the song already
# played at the MIDI default of 120 BPM, so the tempos sidecar must open
# with a 120-BPM row at time 0 — symmetric with the 4/4 signature default.
mid = mido.MidiFile(ticks_per_beat=480)
meta = mido.MidiTrack(); mid.tracks.append(meta)
meta.append(mido.MetaMessage("set_tempo", tempo=250000, time=480 * 4)) # 240 at bar 2
notes = mido.MidiTrack(); mid.tracks.append(notes)
_note_pair(notes, at=0, dur=480 * 8)
res = convert_midi_tempo_map(_save(mid, tmp_path))
assert res["tempos"][0] == {"time": 0.0, "bpm": 120.0}
assert res["tempos"][1] == {"time": 2.0, "bpm": 240.0}
# The seeded default actually matches the grid the head of the song used.
assert _downbeats(res)[0]["time"] == 0.0
def test_type0_single_track_carries_tempo_timesig_and_notes(tmp_path):
# Explicit SMF format 0: one track holds tempo + signature + notes.
mid = mido.MidiFile(ticks_per_beat=480, type=0)
tr = mido.MidiTrack(); mid.tracks.append(tr)
tr.append(mido.MetaMessage("set_tempo", tempo=500000, time=0)) # 120
tr.append(mido.MetaMessage("time_signature", numerator=3, denominator=4, time=0))
_note_pair(tr, at=0, dur=480 * 6) # two 3/4 bars
res = convert_midi_tempo_map(_save(mid, tmp_path))
assert mido.MidiFile(_save(mid, tmp_path)).type == 0 # precondition
assert res["tempos"] == [{"time": 0.0, "bpm": 120.0}]
assert res["time_signatures"] == [{"time": 0.0, "ts": [3, 4]}]
dbs = _downbeats(res)
assert [d["measure"] for d in dbs] == [1, 2]
assert [d["time"] for d in dbs] == [0.0, 1.5] # 3/4 bar = 1.5 s at 120
assert all(d["den"] == 4 for d in dbs)
def test_max_bars_safety_valve_caps_the_walk(tmp_path):
# A note one bar past the cap must not blow the walk past its ceiling.
mid = mido.MidiFile(ticks_per_beat=480)
tr = mido.MidiTrack(); mid.tracks.append(tr)
_note_pair(tr, at=0, dur=480 * 4 * (_TEMPO_MAP_MAX_BARS + 1))
res = convert_midi_tempo_map(_save(mid, tmp_path))
dbs = _downbeats(res)
assert len(dbs) == _TEMPO_MAP_MAX_BARS
assert dbs[-1]["measure"] == _TEMPO_MAP_MAX_BARS
+140
View File
@@ -0,0 +1,140 @@
"""Tests for the plugin `src/` module-serving route and the live-edit cache
contract added in R0 (module-migration rails).
Covers:
* GET /api/plugins/{id}/src/{path} serves a plugin's ES-module source tree
with the right Content-Type, including nested paths.
* Path containment: `..`, absolute, and NUL are rejected (404) the same
`safe_join` guard the assets/ route uses.
* The live-edit cache contract: no-cache + a weak ETag, a bodyless 304 on
matching If-None-Match, and no stale 304 after an in-place edit.
* screen.js and assets/ now also emit an ETag and honor If-None-Match
(previously screen.js sent no headers and assets/ never returned 304).
The routes read the module-global `plugins.LOADED_PLUGINS`, so each test
registers a fake ready plugin directly (save/restore that global) and drives
`register_plugin_api` on a fresh FastAPI app no full server import needed.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import plugins
PLUGIN_ID = "srctest"
@pytest.fixture()
def client(tmp_path):
"""A TestClient with `register_plugin_api` wired and a single fake ready
plugin whose dir (`tmp_path`) holds a src/ tree, an asset, and a screen.js.
Restores LOADED_PLUGINS afterward."""
(tmp_path / "src").mkdir()
(tmp_path / "src" / "main.js").write_text("import './util/x.js';\nexport const boot = 1;\n")
(tmp_path / "src" / "util").mkdir()
(tmp_path / "src" / "util" / "x.js").write_text("export const x = 42;\n")
(tmp_path / "src" / "theme.css").write_text(".a{color:red}\n")
(tmp_path / "assets").mkdir()
(tmp_path / "assets" / "worklet.js").write_text("// worklet\n")
(tmp_path / "screen.js").write_text("import './src/main.js';\n")
saved = list(plugins.LOADED_PLUGINS)
plugins.LOADED_PLUGINS.clear()
plugins.LOADED_PLUGINS.append({
"id": PLUGIN_ID,
"status": "ready",
"_dir": tmp_path,
"_manifest": {"script": "screen.js", "scriptType": "module"},
})
app = FastAPI()
plugins.register_plugin_api(app)
c = TestClient(app, raise_server_exceptions=True)
try:
yield c, tmp_path
finally:
c.close()
plugins.LOADED_PLUGINS.clear()
plugins.LOADED_PLUGINS.extend(saved)
def test_src_file_served_with_js_media_type(client):
c, _ = client
r = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js")
assert r.status_code == 200
# Either application/javascript or text/javascript is a valid module-script
# MIME (guess_type returns text/javascript on newer platforms); browsers
# accept both for <script type=module>.
assert "javascript" in r.headers["content-type"]
assert "export const boot" in r.text
assert r.headers["cache-control"] == "no-cache"
assert r.headers.get("etag")
def test_src_nested_path_and_css_media_type(client):
c, _ = client
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/util/x.js").status_code == 200
r = c.get(f"/api/plugins/{PLUGIN_ID}/src/theme.css")
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/css")
@pytest.mark.parametrize("bad", [
"..%2f..%2fplugin.json", # escape the src/ dir
"..%2f..%2f..%2fetc%2fpasswd",
"%2fetc%2fpasswd", # absolute
"util%2f..%2f..%2fscreen.js",
])
def test_src_traversal_rejected(client, bad):
c, _ = client
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/{bad}").status_code == 404
def test_src_missing_is_404(client):
c, _ = client
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/nope.js").status_code == 404
def test_src_conditional_304(client):
c, _ = client
r1 = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js")
etag = r1.headers["etag"]
r2 = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js", headers={"If-None-Match": etag})
assert r2.status_code == 304
assert r2.content == b""
def test_src_no_stale_304_after_edit(client):
c, root = client
etag = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js").headers["etag"]
(root / "src" / "main.js").write_text("export const boot = 2; // edited, longer body\n")
r = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js", headers={"If-None-Match": etag})
assert r.status_code == 200
assert "boot = 2" in r.text
assert r.headers["etag"] != etag
def test_screen_js_now_conditional(client):
c, _ = client
r1 = c.get(f"/api/plugins/{PLUGIN_ID}/screen.js")
assert r1.status_code == 200
assert r1.headers["cache-control"] == "no-cache"
etag = r1.headers["etag"]
r2 = c.get(f"/api/plugins/{PLUGIN_ID}/screen.js", headers={"If-None-Match": etag})
assert r2.status_code == 304
def test_asset_now_conditional(client):
c, _ = client
r1 = c.get(f"/api/plugins/{PLUGIN_ID}/assets/worklet.js")
assert r1.status_code == 200
etag = r1.headers["etag"]
r2 = c.get(f"/api/plugins/{PLUGIN_ID}/assets/worklet.js", headers={"If-None-Match": etag})
assert r2.status_code == 304
def test_unready_plugin_src_is_404(client):
c, _ = client
plugins.LOADED_PLUGINS[0]["status"] = "installing"
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js").status_code == 404