mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 05:24:30 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c23270442 | ||
|
|
f3104b35a9 | ||
|
|
115c3529e9 | ||
|
|
1bccb8a9e8 | ||
|
|
010edc239b | ||
|
|
9fb63fd3b5 | ||
|
|
cb72c5ab34 | ||
|
|
92e78be62d | ||
|
|
1840170e95 | ||
|
|
7cbf9824b1 | ||
|
|
33146cc7f6 | ||
|
|
69c8ad4e0c | ||
|
|
a20dca21bb | ||
|
|
021ee55f2a | ||
|
|
1f621e5fe5 |
@@ -7,7 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **`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.5x–2x), 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
|
||||
@@ -20,6 +38,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).
|
||||
|
||||
+150
-2
@@ -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
|
||||
|
||||
+26
-14
@@ -703,20 +703,32 @@ def load_song(
|
||||
and isinstance(e.get("d"), (int, float))
|
||||
]
|
||||
if song.lyrics:
|
||||
# Provenance — populated by the converter (xml/notechart),
|
||||
# the WhisperX fallback (whisperx), or hand-edits
|
||||
# (user). Validate against the closed enum so a
|
||||
# hand-edited (or otherwise malformed) manifest can't
|
||||
# propagate a YAML dict / list / arbitrary string
|
||||
# into the highway WS `lyrics.source` field and out
|
||||
# to plugin badges. Anything outside the enum (or
|
||||
# the wrong type) falls back to "xml" — the spec's
|
||||
# back-compat default — instead of being stringified
|
||||
# and trusted.
|
||||
_ALLOWED_LYRICS_SOURCES = {"xml", "notechart", "whisperx", "user"}
|
||||
# Legacy alias: older manifests labelled note-chart-derived
|
||||
# lyrics with the source format's name; normalise it.
|
||||
_LYRICS_SOURCE_ALIASES = {"sng": "notechart"}
|
||||
# Provenance. The feedpak spec (§7.1) vocabulary is
|
||||
# {authored, transcribed, user}; older manifests + the
|
||||
# in-tree readers also use the source-format names
|
||||
# (xml/notechart) and the WhisperX engine name
|
||||
# (whisperx). Accept the union so both spec-compliant
|
||||
# writers (e.g. the stem_splitter plugin emitting
|
||||
# `transcribed`) and legacy packs validate. Validate
|
||||
# against the closed enum so a hand-edited (or otherwise
|
||||
# malformed) manifest can't propagate a YAML dict / list /
|
||||
# arbitrary string into the highway WS `lyrics.source`
|
||||
# field and out to plugin badges. Anything outside the
|
||||
# enum (or the wrong type) falls back to "xml" — the
|
||||
# back-compat default — instead of being stringified and
|
||||
# trusted.
|
||||
# Post-alias values only: `whisperx` is normalised to
|
||||
# `transcribed` before the membership check below, so (like
|
||||
# `sng`) it is intentionally absent from this set.
|
||||
_ALLOWED_LYRICS_SOURCES = {
|
||||
"xml", "notechart", "user",
|
||||
"authored", "transcribed",
|
||||
}
|
||||
# Legacy aliases: older manifests labelled note-chart-derived
|
||||
# lyrics with the source format's name, and the WhisperX
|
||||
# fallback with the engine name — normalise both to the
|
||||
# spec vocabulary the badges now expect.
|
||||
_LYRICS_SOURCE_ALIASES = {"sng": "notechart", "whisperx": "transcribed"}
|
||||
raw_source = manifest.get("lyrics_source")
|
||||
if isinstance(raw_source, str):
|
||||
raw_source = _LYRICS_SOURCE_ALIASES.get(raw_source, raw_source)
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
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.
|
||||
- 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.
|
||||
- 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 0–1).
|
||||
- **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).
|
||||
- **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.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "keys_highway_3d",
|
||||
"name": "Keys Highway 3D",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
|
||||
@@ -83,6 +83,119 @@
|
||||
// pitch class.
|
||||
const HAND_BRIGHTNESS = { rh: 1.0, lh: 0.72 };
|
||||
|
||||
// Selectable note-colour 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:
|
||||
// per-pitch — every pitch class gets its own hue (classic/vivid/pastel)
|
||||
// two-tone — naturals share one hue, sharps a darker shade of it, so
|
||||
// a dark gem always telegraphs "black key coming"
|
||||
// (emerald/ice)
|
||||
const NOTE_PALETTES = {
|
||||
classic: PITCH_CLASS_COLORS,
|
||||
emerald: [
|
||||
0x3fe25f, // C — bright green (naturals)
|
||||
0x17863a, // C# — dark green (sharps)
|
||||
0x3fe25f, // D
|
||||
0x17863a, // D#
|
||||
0x3fe25f, // E
|
||||
0x3fe25f, // F
|
||||
0x17863a, // F#
|
||||
0x3fe25f, // G
|
||||
0x17863a, // G#
|
||||
0x3fe25f, // A
|
||||
0x17863a, // A#
|
||||
0x3fe25f, // B
|
||||
],
|
||||
vivid: [
|
||||
0xff2020, // C
|
||||
0xd45a10, // C#
|
||||
0xffe000, // D
|
||||
0xb8c010, // D#
|
||||
0x1e6aff, // E
|
||||
0x9fd0e8, // F
|
||||
0x2fae7e, // F#
|
||||
0x20e050, // G
|
||||
0xa89a20, // G#
|
||||
0xff8a00, // A
|
||||
0xd05888, // A#
|
||||
0xd040ff, // B
|
||||
],
|
||||
pastel: [
|
||||
0xff9a9a, // C
|
||||
0xd0a078, // C#
|
||||
0xffe9a0, // D
|
||||
0xcfd08a, // D#
|
||||
0x9ec0ff, // E
|
||||
0xc8dde8, // F
|
||||
0x9ecfba, // F#
|
||||
0x9fe8b0, // G
|
||||
0xcfc79a, // G#
|
||||
0xffc890, // A
|
||||
0xd8a8ba, // A#
|
||||
0xe0b0ff, // B
|
||||
],
|
||||
ice: [
|
||||
0x58c8ff, // C — bright ice blue (naturals)
|
||||
0x2a6a9a, // C# — deep blue (sharps)
|
||||
0x58c8ff, // D
|
||||
0x2a6a9a, // D#
|
||||
0x58c8ff, // E
|
||||
0x58c8ff, // F
|
||||
0x2a6a9a, // F#
|
||||
0x58c8ff, // G
|
||||
0x2a6a9a, // G#
|
||||
0x58c8ff, // A
|
||||
0x2a6a9a, // A#
|
||||
0x58c8ff, // B
|
||||
],
|
||||
};
|
||||
|
||||
// Octave-based colour 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
|
||||
// past the table. Within an octave, sharps/flats take a darker shade of
|
||||
// the same hue — the same two-tone idea as 'emerald'. Octave index =
|
||||
// floor(midi/12) - 1 (so C1..B1 = octave 1). The three keys below C1
|
||||
// (A0/A#0/B0 = octave 0) and anything lower get a distinct cool slate so
|
||||
// the very bottom of the board reads apart from the red start.
|
||||
const OCTAVE_HUES = [
|
||||
0xe23a3a, // oct 1 (C1–B1) red
|
||||
0xe2803a, // oct 2 orange
|
||||
0xe0c73a, // oct 3 yellow
|
||||
0x5fc23a, // oct 4 green
|
||||
0x3ac2a0, // oct 5 teal
|
||||
0x3a86e2, // oct 6 blue
|
||||
0x6a4ae2, // oct 7 indigo
|
||||
0xc23ae2, // oct 8 (C8) magenta
|
||||
];
|
||||
const OCTAVE_SUBC1_HUE = 0x8090a0; // A0/A#0/B0 and below — cool slate
|
||||
const OCTAVE_SHARP_DARKEN = 0.5; // sharps render at 50% of the octave hue
|
||||
function _darkenHex(hex, f) {
|
||||
const r = Math.round(((hex >> 16) & 0xff) * f);
|
||||
const g = Math.round(((hex >> 8) & 0xff) * f);
|
||||
const b = Math.round((hex & 0xff) * f);
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
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
|
||||
// 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
|
||||
let hex = (oct <= 0)
|
||||
? OCTAVE_SUBC1_HUE
|
||||
: OCTAVE_HUES[(oct - 1) % OCTAVE_HUES.length];
|
||||
if (_isBlackPc(midi)) hex = _darkenHex(hex, OCTAVE_SHARP_DARKEN);
|
||||
return hex;
|
||||
}
|
||||
// Every valid palette id: the 12-entry pitch-class tables PLUS the
|
||||
// procedural 'octaves' scheme (which is not a 12-array, so it lives
|
||||
// outside NOTE_PALETTES and is validated through this list).
|
||||
const PALETTE_IDS = [...Object.keys(NOTE_PALETTES), 'octaves'];
|
||||
|
||||
// Note block cross-section height and bevel (world units). The bevel
|
||||
// turns the flat slabs into glossy gem-like blocks that catch the light
|
||||
// on their edges — the RS+ reference look.
|
||||
@@ -913,7 +1026,27 @@
|
||||
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
|
||||
// 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
|
||||
// still tracks the notes — just from a nudged height/distance/tilt.
|
||||
camHeight: 1.0, // ×preset camera height (higher = more overhead)
|
||||
camDist: 1.0, // ×preset camera distance (larger = further back)
|
||||
camTilt: 0.0, // aim offset up(+)/down(−); 0 = neutral — the tuned overhead aim lives in CAM_PRESETS.overhead, so this fine-tune only nudges from a preset (and Classic + tilt 0 == the historical rig)
|
||||
};
|
||||
// Numeric FX keys clamp to a declared [min, max]; keys absent from this
|
||||
// table keep the historical 0–1 slider range. The camera fine-tune knobs
|
||||
// are multipliers/offsets centred on 1 (or 0), so they need headroom and a
|
||||
// floor a 0–1 range couldn't express.
|
||||
const FX_RANGES = {
|
||||
camHeight: [0.4, 2.2],
|
||||
camDist: [0.4, 2.2],
|
||||
camTilt: [-1.0, 1.0],
|
||||
};
|
||||
function _fxClamp(key, n) {
|
||||
const r = FX_RANGES[key] || [0, 1];
|
||||
return Math.min(r[1], Math.max(r[0], n));
|
||||
}
|
||||
const FX_LS_PREFIX = 'keys3d_bg_';
|
||||
// Theme id lives OUTSIDE FX_DEFAULTS (string, not bool/number) — its own
|
||||
// localStorage key + validation against BG_THEMES.
|
||||
@@ -1090,6 +1223,64 @@
|
||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||||
};
|
||||
|
||||
// Note-colour 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() {
|
||||
try {
|
||||
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
|
||||
// sharps) — the plug-and-play piano look. Emerald/classic/etc. remain
|
||||
// selectable.
|
||||
return 'octaves';
|
||||
}
|
||||
window.keys3dSetPalette = function (id) {
|
||||
if (PALETTE_IDS.indexOf(id) === -1) return;
|
||||
try { localStorage.setItem(FX_LS_PALETTE, id); } catch (_) {}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { palette: 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
|
||||
// are in pre-K world units — the instance multiplies by K at the use sites,
|
||||
// same as the old constants did. Zoom scales position AND look-at every
|
||||
// frame, so all presets inherit the adaptive dolly behaviour unchanged.
|
||||
// Each preset carries its OWN tuned aim in lookY: 'overhead' bakes in the
|
||||
// plug-and-play downward tilt (the −0.6 × CAM_TILT_UNITS = −33 that used to
|
||||
// ship as the camTilt default) so the default look is unchanged while
|
||||
// camTilt now defaults to 0 (a neutral nudge from whatever preset is picked).
|
||||
const CAM_PRESETS = {
|
||||
classic: { fov: 40, y: 46, z: 112, lookY: 8, lookZ: -165 },
|
||||
elevated: { fov: 44, y: 78, z: 118, lookY: 4, lookZ: -150 },
|
||||
overhead: { fov: 48, y: 118, z: 74, lookY: -33, lookZ: -115 },
|
||||
};
|
||||
// Camera preset id — string-valued like the theme, so it gets its own
|
||||
// validated key + setter rather than an FX_DEFAULTS slot.
|
||||
const FX_LS_CAMERA = 'keys3d_bg_camera';
|
||||
function readCameraSetting() {
|
||||
try {
|
||||
const id = localStorage.getItem(FX_LS_CAMERA);
|
||||
if (id && CAM_PRESETS[id]) return id;
|
||||
} catch (_) {}
|
||||
// Default: the overhead reading rig — its lookY already carries the
|
||||
// tuned downward aim, so with the neutral camTilt default it gives the
|
||||
// plug-and-play piano view out of the box. Others selectable ('classic'
|
||||
// + neutral tilt = the exact historical rig).
|
||||
return 'overhead';
|
||||
}
|
||||
window.keys3dSetCamera = function (id) {
|
||||
if (!CAM_PRESETS[id]) return;
|
||||
try { localStorage.setItem(FX_LS_CAMERA, id); } catch (_) {}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { camera: id } }));
|
||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||||
};
|
||||
|
||||
function readFxSettings() {
|
||||
const fx = Object.assign({}, FX_DEFAULTS);
|
||||
try {
|
||||
@@ -1104,10 +1295,10 @@
|
||||
else if (raw === '0' || raw === 'false') fx[k] = false;
|
||||
} else {
|
||||
const n = parseFloat(raw);
|
||||
// All numeric FX keys are 0-1 sliders — clamp so a
|
||||
// corrupt/foreign write can't overdrive opacities
|
||||
// or the camera pulse.
|
||||
if (Number.isFinite(n)) fx[k] = Math.min(1, Math.max(0, n));
|
||||
// Numeric FX keys clamp to their declared range
|
||||
// (default 0-1) so a corrupt/foreign write can't
|
||||
// overdrive opacities or the geometry multipliers.
|
||||
if (Number.isFinite(n)) fx[k] = _fxClamp(k, n);
|
||||
}
|
||||
}
|
||||
} catch (_) { /* localStorage unavailable — use defaults */ }
|
||||
@@ -1127,7 +1318,7 @@
|
||||
} else {
|
||||
v = Number(value);
|
||||
if (!Number.isFinite(v)) return;
|
||||
v = Math.min(1, Math.max(0, v)); // all numeric FX keys are 0-1
|
||||
v = _fxClamp(key, v); // declared range, default 0-1
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(FX_LS_PREFIX + key, typeof v === 'boolean' ? (v ? '1' : '0') : String(v));
|
||||
@@ -1369,6 +1560,7 @@
|
||||
// Theme/material handles (built by buildScene/buildKeyboardAndHighway;
|
||||
// _applyTheme / _applyCinematic / the glow slider retune them live).
|
||||
let _theme = readThemeSetting();
|
||||
let _palette = readPaletteSetting();
|
||||
let ambLight = null, dirLight = null;
|
||||
let _floorMat = null;
|
||||
const _railMats = []; // lane-edge rail materials (theme laneDim)
|
||||
@@ -1432,8 +1624,20 @@
|
||||
return [1, 3, 6, 8, 10].includes(((midi % 12) + 12) % 12);
|
||||
}
|
||||
|
||||
function _paletteColor(pc) {
|
||||
return (NOTE_PALETTES[_palette] || PITCH_CLASS_COLORS)[pc];
|
||||
}
|
||||
|
||||
// Base colour (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) {
|
||||
if (_palette === 'octaves') return octaveNoteColor(midi);
|
||||
return _paletteColor(((midi % 12) + 12) % 12);
|
||||
}
|
||||
|
||||
function noteColor(midi, hand) {
|
||||
const base = new T.Color(PITCH_CLASS_COLORS[((midi % 12) + 12) % 12]);
|
||||
const base = new T.Color(_noteHex(midi));
|
||||
const b = HAND_BRIGHTNESS[hand] != null ? HAND_BRIGHTNESS[hand] : 1.0;
|
||||
base.multiplyScalar(b);
|
||||
return base;
|
||||
@@ -1461,14 +1665,15 @@
|
||||
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
|
||||
|
||||
// Camera — low, near-telephoto rig (RS+-style): a narrow FOV from low
|
||||
// and back gives a deep receding runway and frames ~2 octaves instead
|
||||
// of cramming the whole note range full-width. The x position pans to
|
||||
// follow the active notes (see updateScene), so wide pieces stay zoomed
|
||||
// in on the played hand rather than shrinking every key.
|
||||
const CAM_FOV = 40;
|
||||
const CAM_Y = 46 * K, CAM_Z = 112 * K;
|
||||
const LOOK_Y = 8 * K, LOOK_Z = -165 * K;
|
||||
// Camera — the default 'classic' preset is a low, near-telephoto rig
|
||||
// (RS+-style): a narrow FOV from low and back gives a deep receding
|
||||
// runway and frames ~2 octaves instead of cramming the whole note
|
||||
// range full-width. The x position pans to follow the active notes
|
||||
// (see updateScene), so wide pieces stay zoomed in on the played hand
|
||||
// rather than shrinking every key. The rig numbers now come from the
|
||||
// user-selectable CAM_PRESETS table; switching applies live because
|
||||
// position/lookAt are re-derived every frame.
|
||||
let _camPreset = CAM_PRESETS[readCameraSetting()] || CAM_PRESETS.classic;
|
||||
// Pan-follow: a slow ease toward a wide, gently-weighted centroid so the
|
||||
// camera glides with the melody instead of darting as notes enter/leave.
|
||||
const CAM_PAN_LERP = 0.022; // per-frame ease (~1s glide @60fps)
|
||||
@@ -1484,6 +1689,25 @@
|
||||
const CAM_ZOOM_BASE_KEYS = 11; // white keys framed at zoom = 1
|
||||
const CAM_ZOOM_MIN = 0.9, CAM_ZOOM_MAX = 4.8;
|
||||
const CAM_ZOOM_LERP = 0.025; // smooth zoom ease
|
||||
// Full-swing of the camTilt aim offset (pre-K units) at slider ±1.
|
||||
const CAM_TILT_UNITS = 55;
|
||||
|
||||
// Base rig with the live fine-tune knobs applied — height/distance
|
||||
// multiply the preset, tilt offsets the aim height. Returns the
|
||||
// effective {y, z, lookY, lookZ} in pre-K units; the caller scales by
|
||||
// K and the auto-zoom. Keeps the pan/dolly follow-motion intact —
|
||||
// these only move the vantage point it orbits around. Writes into a
|
||||
// reusable object (returned live) so the per-frame camera update stays
|
||||
// allocation-free — the callers read it synchronously and never retain
|
||||
// it, so a single shared instance is safe.
|
||||
const _rigOut = { y: 0, z: 0, lookY: 0, lookZ: 0 };
|
||||
function _rig() {
|
||||
_rigOut.y = _camPreset.y * fx.camHeight;
|
||||
_rigOut.z = _camPreset.z * fx.camDist;
|
||||
_rigOut.lookY = _camPreset.lookY + fx.camTilt * CAM_TILT_UNITS;
|
||||
_rigOut.lookZ = _camPreset.lookZ;
|
||||
return _rigOut;
|
||||
}
|
||||
// Per-key approach glow: a key lights in its pitch-class colour 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
|
||||
@@ -1945,10 +2169,9 @@
|
||||
_envRT = _makeStudioEnv(T, ren);
|
||||
if (_envRT) scene.environment = _envRT.texture;
|
||||
|
||||
cam = new T.PerspectiveCamera(CAM_FOV, 1, 0.1, 2000 * K);
|
||||
cam = new T.PerspectiveCamera(_camPreset.fov, 1, 0.1, 2000 * K);
|
||||
_camX = 0; _camTargetX = 0; _camZoom = 1; _camTargetZoom = 1;
|
||||
cam.position.set(0, CAM_Y, CAM_Z);
|
||||
cam.lookAt(0, LOOK_Y, LOOK_Z);
|
||||
{ const r = _rig(); cam.position.set(0, r.y * K, r.z * K); cam.lookAt(0, r.lookY * K, r.lookZ * K); }
|
||||
|
||||
ambLight = new T.AmbientLight(0xffffff, 0.75);
|
||||
dirLight = new T.DirectionalLight(0xffffff, 1.1);
|
||||
@@ -2091,13 +2314,16 @@
|
||||
return geo;
|
||||
}
|
||||
|
||||
// Glossy note material, cached per (pitch class, hand).
|
||||
// Glossy note material, cached per resolved colour. Keying by the
|
||||
// final colour 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
|
||||
// must not share a material (a pitch-class key would collide them).
|
||||
function _noteMaterial(midi, hand) {
|
||||
const handKey = HAND_BRIGHTNESS[hand] != null ? hand : 'rh';
|
||||
const key = (((midi % 12) + 12) % 12) + '|' + handKey;
|
||||
const col = noteColor(midi, hand);
|
||||
const key = col.getHex();
|
||||
let mat = _noteMatCache.get(key);
|
||||
if (mat) return mat;
|
||||
const col = noteColor(midi, hand);
|
||||
// MeshPhysicalMaterial with a clearcoat: lacquered glass-gem
|
||||
// look — a sharp coat highlight over a colored body, lit by the
|
||||
// studio env map. This is the "not plastic" ask: the old matte
|
||||
@@ -2145,6 +2371,39 @@
|
||||
for (const m of _laneGuideMats) m.opacity = lop;
|
||||
}
|
||||
|
||||
// Live palette switch: recolour 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
|
||||
// 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).
|
||||
for (const m of _noteMatCache.values()) m.dispose();
|
||||
_noteMatCache.clear();
|
||||
for (const nm of noteMeshes) {
|
||||
if (!nm.mesh || !nm.mesh.material) continue;
|
||||
const col = noteColor(nm.note.midi, nm.note.hand);
|
||||
nm.mesh.material.color.copy(col);
|
||||
nm.mesh.material.emissive.copy(col);
|
||||
}
|
||||
for (const [midi, km] of keyMeshes) {
|
||||
const col = noteColor(midi, 'rh');
|
||||
km.material.emissive.copy(col);
|
||||
km.userData.origEmissive = col.getHex();
|
||||
}
|
||||
for (const m of _laneGuideMats) {
|
||||
if (m.userData.midi != null) m.color.copy(noteColor(m.userData.midi, 'rh'));
|
||||
}
|
||||
_clearFlameTextures();
|
||||
// Re-arm the pool so no slot keeps rendering a disposed texture
|
||||
// (a flame mid-flight briefly re-tints — next spawn sets its
|
||||
// true pitch texture).
|
||||
for (const s of _flamePool) s.mat.map = _flameTexture(0);
|
||||
}
|
||||
|
||||
function _barNumberTexture(idx) {
|
||||
let tex = _barTexCache.get(idx);
|
||||
if (tex) return tex;
|
||||
@@ -2181,13 +2440,15 @@
|
||||
return _glowTex;
|
||||
}
|
||||
|
||||
// Vertical flame texture for hit flares: white-hot base fading up
|
||||
// into the pitch-class colour, with a horizontal falloff. Cached per
|
||||
// pitch class (bounded, 12 entries).
|
||||
function _flameTexture(pc) {
|
||||
let tex = _flameTexCache.get(pc);
|
||||
// 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,
|
||||
// up to ~one-per-octave for 'octaves'), so a flare always matches the
|
||||
// struck note's colour whatever the palette.
|
||||
function _flameTexture(midi) {
|
||||
const c = _noteHex(midi);
|
||||
let tex = _flameTexCache.get(c);
|
||||
if (tex) return tex;
|
||||
const c = PITCH_CLASS_COLORS[pc];
|
||||
const r = (c >> 16) & 0xff, g = (c >> 8) & 0xff, b = c & 0xff;
|
||||
const cnv = document.createElement('canvas');
|
||||
cnv.width = 64;
|
||||
@@ -2207,7 +2468,7 @@
|
||||
ctx.fillStyle = falloff;
|
||||
ctx.fillRect(0, 0, 64, 128);
|
||||
tex = new T.CanvasTexture(cnv);
|
||||
_flameTexCache.set(pc, tex);
|
||||
_flameTexCache.set(c, tex);
|
||||
return tex;
|
||||
}
|
||||
|
||||
@@ -2294,7 +2555,7 @@
|
||||
if (!entry) return;
|
||||
const slot = _flamePool[_flameIdx];
|
||||
_flameIdx = (_flameIdx + 1) % _flamePool.length;
|
||||
slot.mat.map = _flameTexture(((midi % 12) + 12) % 12);
|
||||
slot.mat.map = _flameTexture(midi);
|
||||
slot.start = wallNow;
|
||||
slot.baseY = entry.black ? BLACK_H + WHITE_H * 0.6 : WHITE_H;
|
||||
slot.sprite.position.x = keyX(entry, _layoutInfo.whiteCount);
|
||||
@@ -2382,6 +2643,7 @@
|
||||
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;
|
||||
@@ -2775,8 +3037,7 @@
|
||||
}
|
||||
_camX += (_camTargetX - _camX) * CAM_PAN_LERP;
|
||||
_camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP;
|
||||
cam.position.set(_camX, CAM_Y * _camZoom, CAM_Z * _camZoom);
|
||||
cam.lookAt(_camX, LOOK_Y * _camZoom, LOOK_Z * _camZoom);
|
||||
{ const r = _rig(); cam.position.set(_camX, r.y * K * _camZoom, r.z * K * _camZoom); cam.lookAt(_camX, r.lookY * K * _camZoom, r.lookZ * K * _camZoom); }
|
||||
|
||||
for (const km of keyMeshes.values()) km.userData.glow = 0;
|
||||
for (const { mesh, note, len, label } of noteMeshes) {
|
||||
@@ -3217,9 +3478,12 @@
|
||||
if (_isReady) teardown();
|
||||
highwayCanvas = canvas;
|
||||
fx = readFxSettings();
|
||||
// Persisted string settings refresh here too — a theme saved
|
||||
// while no instance was listening must not come up stale on a
|
||||
// later init().
|
||||
// Persisted string settings refresh here too — a palette,
|
||||
// camera, theme or background style saved while no instance was
|
||||
// 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();
|
||||
_camPreset = CAM_PRESETS[readCameraSetting()] || CAM_PRESETS.classic;
|
||||
_theme = readThemeSetting();
|
||||
_bgStyle = readBgStyleSetting();
|
||||
loadThree().then(() => {
|
||||
@@ -3265,6 +3529,19 @@
|
||||
_bgStyle = d.bgStyle;
|
||||
_bgMountStyle();
|
||||
}
|
||||
if (d && d.palette && PALETTE_IDS.indexOf(d.palette) !== -1) {
|
||||
_palette = d.palette;
|
||||
_applyPalette();
|
||||
}
|
||||
if (d && d.camera && CAM_PRESETS[d.camera]) {
|
||||
_camPreset = CAM_PRESETS[d.camera];
|
||||
// Position/lookAt re-derive next frame; only the
|
||||
// projection needs an explicit poke.
|
||||
if (cam) {
|
||||
cam.fov = _camPreset.fov;
|
||||
cam.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keys3d:settings', _fxThemeHandler);
|
||||
window.addEventListener('keys3d:settings', _fxHandler);
|
||||
@@ -3470,10 +3747,19 @@
|
||||
readFxSettings,
|
||||
readThemeSetting,
|
||||
readBgStyleSetting,
|
||||
readPaletteSetting,
|
||||
readCameraSetting,
|
||||
_bgThemeColors,
|
||||
BG_THEMES,
|
||||
BG_STYLE_IDS,
|
||||
NOTE_PALETTES,
|
||||
PITCH_CLASS_COLORS,
|
||||
PALETTE_IDS,
|
||||
OCTAVE_HUES,
|
||||
octaveNoteColor,
|
||||
CAM_PRESETS,
|
||||
FX_DEFAULTS,
|
||||
FX_RANGES,
|
||||
_classifyTiming,
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,22 @@
|
||||
<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>
|
||||
<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="emerald">Emerald (green, darker sharps)</option>
|
||||
<option value="ice">Ice (blue, darker sharps)</option>
|
||||
<option value="classic">Rainbow (per-pitch)</option>
|
||||
<option value="vivid">Vivid (per-pitch, punchier)</option>
|
||||
<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
|
||||
guides and hit flames. Each option is described in its own label.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-theme" class="text-xs font-medium text-gray-400 mb-1 block">Scene theme</label>
|
||||
<select id="keysh3d-fx-theme"
|
||||
onchange="window.keys3dSetTheme && window.keys3dSetTheme(this.value)"
|
||||
@@ -30,7 +46,59 @@
|
||||
</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. Pitch-class note colours never change.
|
||||
as the guitar highway. Note colours come from the
|
||||
"Note colours" palette above.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camera" class="text-xs font-medium text-gray-400 mb-1 block">Camera angle</label>
|
||||
<select id="keysh3d-fx-camera"
|
||||
onchange="window.keys3dSetCamera && window.keys3dSetCamera(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="classic">Classic (low, deep runway)</option>
|
||||
<option value="elevated">Elevated (higher, more board)</option>
|
||||
<option value="overhead" selected>Overhead (top-down reading view)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Where the camera sits. Classic is the original low rig; Elevated
|
||||
lifts it for a fuller view of the keybed; Overhead looks down the
|
||||
lanes for a sheet-reading feel. Applies live, keeps the
|
||||
auto-pan/zoom that follows your hands.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camheight" class="text-xs font-medium text-gray-400 mb-1 block">
|
||||
Camera height <span id="keysh3d-fx-camheight-val" class="text-gray-500 font-mono">1.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-camheight"
|
||||
min="0.4" max="2.2" step="0.02" value="1"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('camHeight', this.value); document.getElementById('keysh3d-fx-camheight-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Raise or lower the camera around the angle above (higher = more
|
||||
top-down). Fine-tunes the base view; the follow-motion stays.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camdist" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Camera distance <span id="keysh3d-fx-camdist-val" class="text-gray-500 font-mono">1.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-camdist"
|
||||
min="0.4" max="2.2" step="0.02" value="1"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('camDist', this.value); document.getElementById('keysh3d-fx-camdist-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Pull the camera back or push it in (larger = further away, smaller
|
||||
= closer).
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camtilt" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Camera tilt <span id="keysh3d-fx-camtilt-val" class="text-gray-500 font-mono">0.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-camtilt"
|
||||
min="-1" max="1" step="0.02" value="0"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('camTilt', this.value); document.getElementById('keysh3d-fx-camtilt-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Tilt the view up (+) or down (−) without moving the camera —
|
||||
aims higher up the runway or down toward the keys. 0 = neutral.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||||
@@ -190,6 +258,24 @@
|
||||
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');
|
||||
// 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) => {
|
||||
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
|
||||
if (!Number.isFinite(n)) return;
|
||||
const el = document.getElementById(elId);
|
||||
const v = Math.min(parseFloat(el.max), Math.max(parseFloat(el.min), n));
|
||||
el.value = String(v);
|
||||
document.getElementById(valId).textContent = v.toFixed(2);
|
||||
};
|
||||
hydrateFxRangeIn('camHeight', 'keysh3d-fx-camheight', 'keysh3d-fx-camheight-val');
|
||||
hydrateFxRangeIn('camDist', 'keysh3d-fx-camdist', 'keysh3d-fx-camdist-val');
|
||||
hydrateFxRangeIn('camTilt', 'keysh3d-fx-camtilt', 'keysh3d-fx-camtilt-val');
|
||||
const storedCamera = localStorage.getItem('keys3d_bg_camera');
|
||||
const cameraSel = document.getElementById('keysh3d-fx-camera');
|
||||
if (storedCamera && Array.from(cameraSel.options).some(o => o.value === storedCamera)) {
|
||||
cameraSel.value = storedCamera;
|
||||
}
|
||||
const storedStyle = localStorage.getItem('keys3d_bg_style');
|
||||
const styleSel = document.getElementById('keysh3d-fx-bgstyle');
|
||||
if (storedStyle && Array.from(styleSel.options).some(o => o.value === storedStyle)) {
|
||||
@@ -200,6 +286,11 @@
|
||||
if (storedTheme && Array.from(themeSel.options).some(o => o.value === storedTheme)) {
|
||||
themeSel.value = storedTheme;
|
||||
}
|
||||
const storedPalette = localStorage.getItem('keys3d_bg_palette');
|
||||
const paletteSel = document.getElementById('keysh3d-fx-palette');
|
||||
if (storedPalette && Array.from(paletteSel.options).some(o => o.value === storedPalette)) {
|
||||
paletteSel.value = storedPalette;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Keys-Hwy3D settings] hydration failed:', e);
|
||||
}
|
||||
|
||||
@@ -148,3 +148,252 @@ test('FX defaults: ambience + score FX ship enabled', () => {
|
||||
assert.equal(FX_DEFAULTS.bgIntensity, 0.5);
|
||||
assert.equal(FX_DEFAULTS.bgReactive, true);
|
||||
});
|
||||
|
||||
/* ── Note-colour palettes (feat/keys3d-note-palettes) ────────────────── */
|
||||
|
||||
test('note palettes: 12 entries each, classic IS the stock table', () => {
|
||||
const { NOTE_PALETTES, PITCH_CLASS_COLORS } =
|
||||
load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual(Object.keys(NOTE_PALETTES),
|
||||
['classic', 'emerald', 'vivid', 'pastel', 'ice']);
|
||||
for (const [id, colors] of Object.entries(NOTE_PALETTES)) {
|
||||
assert.equal(colors.length, 12, id + ' has one colour per pitch class');
|
||||
for (const c of colors) {
|
||||
assert.ok(Number.isInteger(c) && c >= 0 && c <= 0xffffff,
|
||||
id + ' colours are 24-bit ints');
|
||||
}
|
||||
}
|
||||
// 'classic' preserves the shipped look byte-identically — it is the
|
||||
// same array, not a copy that could drift.
|
||||
assert.equal(NOTE_PALETTES.classic, PITCH_CLASS_COLORS);
|
||||
assert.equal(PITCH_CLASS_COLORS[0], 0xff3030); // C stays red in classic
|
||||
});
|
||||
|
||||
test('note palettes: two-tone tables use darker sharps than naturals', () => {
|
||||
const { NOTE_PALETTES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const luma = (c) =>
|
||||
0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff);
|
||||
for (const id of ['emerald', 'ice']) {
|
||||
const p = NOTE_PALETTES[id];
|
||||
for (const sharp of [1, 3, 6, 8, 10]) {
|
||||
assert.ok(luma(p[sharp]) < luma(p[0]),
|
||||
id + ' sharp pc ' + sharp + ' darker than naturals');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('readPaletteSetting: octaves default, validated overrides only', () => {
|
||||
// No localStorage in the vm → the plug-and-play default.
|
||||
const bare = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(bare.readPaletteSetting(), 'octaves');
|
||||
// An explicit non-default value (classic) overrides.
|
||||
const store = { keys3d_bg_palette: 'classic' };
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
});
|
||||
const { readPaletteSetting } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(readPaletteSetting(), 'classic');
|
||||
// Corrupt/foreign value → the default rather than an undefined scheme.
|
||||
store.keys3d_bg_palette = 'banana';
|
||||
assert.equal(readPaletteSetting(), 'octaves');
|
||||
});
|
||||
|
||||
test('keys3dSetPalette: persists + dispatches valid ids, ignores unknown', () => {
|
||||
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 CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
win.keys3dSetPalette('emerald');
|
||||
assert.equal(store.keys3d_bg_palette, 'emerald');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].type, 'keys3d:settings');
|
||||
assert.equal(events[0].detail.palette, 'emerald');
|
||||
// Unknown id: no write, no event.
|
||||
win.keys3dSetPalette('banana');
|
||||
assert.equal(store.keys3d_bg_palette, 'emerald');
|
||||
assert.equal(events.length, 1);
|
||||
// 'octaves' (procedural, not a 12-array) is a valid selectable id.
|
||||
win.keys3dSetPalette('octaves');
|
||||
assert.equal(store.keys3d_bg_palette, 'octaves');
|
||||
assert.equal(events.length, 2);
|
||||
});
|
||||
|
||||
test('PALETTE_IDS: the array palettes plus the procedural octaves scheme', () => {
|
||||
const { PALETTE_IDS, NOTE_PALETTES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual([...PALETTE_IDS],
|
||||
[...Object.keys(NOTE_PALETTES), 'octaves']);
|
||||
assert.ok(PALETTE_IDS.indexOf('octaves') !== -1);
|
||||
assert.ok(!('octaves' in NOTE_PALETTES)); // it is NOT a 12-entry table
|
||||
});
|
||||
|
||||
test('octaveNoteColor: hue steps per octave, loops, sharps darker, sub-C1 distinct', () => {
|
||||
const { octaveNoteColor, OCTAVE_HUES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const luma = (c) =>
|
||||
0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff);
|
||||
// C1 (midi 24) = first hue; C2 (36) = second; C8 (108) = 8th (index 7).
|
||||
assert.equal(octaveNoteColor(24), OCTAVE_HUES[0]); // C1 red
|
||||
assert.equal(octaveNoteColor(35), OCTAVE_HUES[0]); // B1 still octave 1
|
||||
assert.equal(octaveNoteColor(36), OCTAVE_HUES[1]); // C2 orange
|
||||
assert.equal(octaveNoteColor(60), OCTAVE_HUES[3]); // C4 (middle C)
|
||||
assert.equal(octaveNoteColor(108), OCTAVE_HUES[7]); // C8 last hue
|
||||
// Naturals across one octave (C1..B1 whites) all share the octave hue.
|
||||
for (const nat of [24, 26, 28, 29, 31, 33, 35]) {
|
||||
assert.equal(octaveNoteColor(nat), OCTAVE_HUES[0], 'natural ' + nat);
|
||||
}
|
||||
// Sharps in an octave are a DARKER shade of that same hue.
|
||||
for (const sharp of [25, 27, 30, 32, 34]) { // C#1..A#1
|
||||
assert.ok(luma(octaveNoteColor(sharp)) < luma(OCTAVE_HUES[0]),
|
||||
'sharp ' + sharp + ' darker than the octave natural');
|
||||
}
|
||||
// The three keys below C1 (A0/A#0/B0) share a distinct sub-C1 colour,
|
||||
// different from the red octave-1 start.
|
||||
assert.equal(octaveNoteColor(21), octaveNoteColor(23)); // A0 == B0 hue
|
||||
assert.notEqual(octaveNoteColor(21), OCTAVE_HUES[0]);
|
||||
// Loop: an octave past the table wraps (safety for out-of-88 midi).
|
||||
assert.equal(octaveNoteColor(24 + 12 * OCTAVE_HUES.length), OCTAVE_HUES[0]);
|
||||
});
|
||||
|
||||
/* ── Camera presets + fine-tune (feat/keys3d-camera) ─────────────────── */
|
||||
|
||||
test('FX defaults: camera height/distance/tilt all neutral (preset carries the tuned aim)', () => {
|
||||
const { FX_DEFAULTS, FX_RANGES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(FX_DEFAULTS.camHeight, 1.0);
|
||||
assert.equal(FX_DEFAULTS.camDist, 1.0);
|
||||
// Tilt ships NEUTRAL (0): the tuned plug-and-play aim now lives in
|
||||
// CAM_PRESETS.overhead.lookY, so the fine-tune only nudges from a preset
|
||||
// and 'classic' + this default reproduces the exact historical rig.
|
||||
assert.equal(FX_DEFAULTS.camTilt, 0.0);
|
||||
assert.ok(FX_DEFAULTS.camTilt >= FX_RANGES.camTilt[0] && FX_DEFAULTS.camTilt <= FX_RANGES.camTilt[1]);
|
||||
// Height/distance bracket 1 (can go lower AND higher); tilt spans 0.
|
||||
assert.ok(FX_RANGES.camHeight[0] < 1 && 1 < FX_RANGES.camHeight[1]);
|
||||
assert.ok(FX_RANGES.camDist[0] < 1 && 1 < FX_RANGES.camDist[1]);
|
||||
assert.ok(FX_RANGES.camTilt[0] < 0 && 0 < FX_RANGES.camTilt[1]);
|
||||
});
|
||||
|
||||
test('camTilt: negative values survive the clamp (down-tilt must be reachable)', () => {
|
||||
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; } },
|
||||
});
|
||||
const { FX_RANGES } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
win.keys3dSetFx('camTilt', -0.5);
|
||||
assert.equal(store.keys3d_bg_camTilt, '-0.5'); // NOT crushed to 0 by a 0-1 clamp
|
||||
win.keys3dSetFx('camTilt', -99);
|
||||
assert.equal(parseFloat(store.keys3d_bg_camTilt), FX_RANGES.camTilt[0]);
|
||||
});
|
||||
|
||||
test('FX ranges: reader + setter clamp to the declared range, not 0-1', () => {
|
||||
const store = { keys3d_bg_camHeight: '5', keys3d_bg_camDist: '0.01' };
|
||||
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 CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
const { readFxSettings, FX_RANGES } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
// Reader: corrupt/out-of-range writes clamp to the declared bounds.
|
||||
assert.equal(readFxSettings().camHeight, FX_RANGES.camHeight[1]);
|
||||
assert.equal(readFxSettings().camDist, FX_RANGES.camDist[0]);
|
||||
// Setter: same clamp on the way in; a value above 1 must survive
|
||||
// (the historical 0-1 clamp would have crushed 1.3 to 1).
|
||||
win.keys3dSetFx('camHeight', 1.3);
|
||||
assert.equal(store.keys3d_bg_camHeight, '1.3');
|
||||
win.keys3dSetFx('camDist', 99);
|
||||
assert.equal(parseFloat(store.keys3d_bg_camDist), FX_RANGES.camDist[1]);
|
||||
// Un-ranged keys keep the historical 0-1 clamp.
|
||||
win.keys3dSetFx('vibrancy', 2);
|
||||
assert.equal(store.keys3d_bg_vibrancy, '1');
|
||||
});
|
||||
|
||||
test('scrollZ: distance-to-hitline scales linearly with the speed argument', () => {
|
||||
const { scrollZ } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const hitZ = 0;
|
||||
const d1 = scrollZ(2, 0, hitZ, 130) - hitZ; // 2s ahead at stock speed
|
||||
const d2 = scrollZ(2, 0, hitZ, 260) - hitZ; // same note at 2x speed
|
||||
assert.equal(d2, d1 * 2);
|
||||
// At the hit moment the note is at the hit-line regardless of speed.
|
||||
assert.equal(scrollZ(5, 5, hitZ, 130), hitZ);
|
||||
assert.equal(scrollZ(5, 5, hitZ, 260), hitZ);
|
||||
});
|
||||
|
||||
test('camera presets: classic preserves the stock rig, overhead is the default', () => {
|
||||
const { CAM_PRESETS, readCameraSetting } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual(Object.keys(CAM_PRESETS), ['classic', 'elevated', 'overhead']);
|
||||
// 'classic' preserves the historical constants (pre-K units) even though
|
||||
// it is no longer the default — anyone who picks it gets the old rig back
|
||||
// EXACTLY, because camTilt now defaults to 0 (neutral): effective aim =
|
||||
// classic.lookY + 0*CAM_TILT_UNITS = 8, the historical LOOK_Y.
|
||||
assert.deepEqual({ ...CAM_PRESETS.classic },
|
||||
{ fov: 40, y: 46, z: 112, lookY: 8, lookZ: -165 });
|
||||
for (const [id, p] of Object.entries(CAM_PRESETS)) {
|
||||
for (const f of ['fov', 'y', 'z', 'lookY', 'lookZ']) {
|
||||
assert.ok(Number.isFinite(p[f]), id + '.' + f + ' is a number');
|
||||
}
|
||||
assert.ok(p.y > 0 && p.z > 0, id + ' sits above and behind the keys');
|
||||
}
|
||||
assert.equal(readCameraSetting(), 'overhead'); // no localStorage in the vm → tuned default
|
||||
});
|
||||
|
||||
test('camera default look is unchanged: overhead bakes the old tuned tilt, camTilt is neutral', () => {
|
||||
const { CAM_PRESETS, FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const CAM_TILT_UNITS = 55; // full-swing of the camTilt offset at ±1 (screen.js)
|
||||
// The shipped default look = overhead preset + the default camTilt. Before,
|
||||
// that was lookY 0 + (−0.6 × 55) = −33; the tuned aim now lives in the
|
||||
// preset (lookY −33) with a neutral camTilt (0), so the effective aim — and
|
||||
// thus the out-of-the-box framing — is byte-identical.
|
||||
const effOverhead = CAM_PRESETS.overhead.lookY + FX_DEFAULTS.camTilt * CAM_TILT_UNITS;
|
||||
assert.equal(effOverhead, -33);
|
||||
// 'classic' + the neutral default reproduces the historical LOOK_Y (8) —
|
||||
// the "pick Classic for the original look" promise, now actually true.
|
||||
const effClassic = CAM_PRESETS.classic.lookY + FX_DEFAULTS.camTilt * CAM_TILT_UNITS;
|
||||
assert.equal(effClassic, 8);
|
||||
});
|
||||
|
||||
test('keys3dSetCamera: persists + dispatches valid ids, ignores unknown', () => {
|
||||
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 CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
win.keys3dSetCamera('overhead');
|
||||
assert.equal(store.keys3d_bg_camera, 'overhead');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].type, 'keys3d:settings');
|
||||
assert.equal(events[0].detail.camera, 'overhead');
|
||||
win.keys3dSetCamera('helicopter');
|
||||
assert.equal(store.keys3d_bg_camera, 'overhead');
|
||||
assert.equal(events.length, 1);
|
||||
const { readCameraSetting } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(readCameraSetting(), 'overhead');
|
||||
store.keys3d_bg_camera = 'garbage';
|
||||
assert.equal(readCameraSetting(), 'overhead');
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -10765,6 +10765,9 @@ def save_settings(data: dict):
|
||||
config_file = CONFIG_DIR / "config.json"
|
||||
updates: dict = {}
|
||||
messages: list[str] = []
|
||||
# Named dlc_warnings (not `warnings`) so it can't shadow the module-level
|
||||
# `import warnings` used elsewhere in this file.
|
||||
dlc_warnings: list[str] = []
|
||||
|
||||
if "dlc_dir" in data:
|
||||
dlc_path = data["dlc_dir"]
|
||||
@@ -10784,7 +10787,16 @@ def save_settings(data: dict):
|
||||
if f.suffix.lower() in sloppak_mod.SONG_EXTS)
|
||||
messages.append(f"DLC folder: {count} song files found")
|
||||
else:
|
||||
return {"error": f"DLC directory not found: {dlc_path}"}
|
||||
# A non-resolving DLC path (a stale value, an unplugged
|
||||
# external/network drive, or a path carried over from another
|
||||
# machine) must NOT abort the whole POST. saveSettings() bundles
|
||||
# dlc_dir together with demucs_server_url / default_arrangement /
|
||||
# av_offset_ms in a single request, so an early `return` here
|
||||
# silently dropped every co-submitted key — this is the "can't
|
||||
# set the Demucs server address" report (feedBack-demucs-server
|
||||
# #3). Record it as a warning, skip persisting dlc_dir, and keep
|
||||
# validating the rest so the other settings still save.
|
||||
dlc_warnings.append(f"DLC directory not found: {dlc_path}")
|
||||
|
||||
# Both of these are consumed downstream as strings (e.g.
|
||||
# demucs_server_url.rstrip('/')), so reject non-string shapes
|
||||
@@ -11041,7 +11053,15 @@ def save_settings(data: dict):
|
||||
return {"error": str(exc)}
|
||||
cfg = settings_with_instrument_profiles(cfg)
|
||||
_atomic_write_file(config_file, json.dumps(cfg, indent=2).encode("utf-8"))
|
||||
return {"message": ". ".join(messages) if messages else "Settings saved"}
|
||||
resp = {"message": ". ".join(messages) if messages else "Settings saved"}
|
||||
if dlc_warnings:
|
||||
# `warnings` is an additive response field (existing clients read
|
||||
# `message || error`); fold the text into `message` too so the current
|
||||
# settings status line still surfaces the bad DLC path even though the
|
||||
# rest of the save succeeded.
|
||||
resp["warnings"] = dlc_warnings
|
||||
resp["message"] = resp["message"] + " — " + "; ".join(dlc_warnings)
|
||||
return resp
|
||||
|
||||
|
||||
# Keys a client "Reset {category}" action may clear. Resetting removes the key
|
||||
|
||||
+20
-1
@@ -4379,7 +4379,7 @@ async function uploadSongs(fileList) {
|
||||
if (lower.endsWith('.feedpak') || lower.endsWith('.sloppak')) {
|
||||
files.push(f);
|
||||
} else {
|
||||
failures.push(`${f.name}: only .feedpak accepted`);
|
||||
failures.push(`${f.name}: only .feedpak or .sloppak accepted`);
|
||||
}
|
||||
}
|
||||
if (files.length === 0) {
|
||||
@@ -5564,9 +5564,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 +7955,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) {
|
||||
|
||||
+24
-10
@@ -2004,18 +2004,32 @@ function createHighway() {
|
||||
const seedBase = (_frameIdx + n.s + ((n.t * 60) | 0)) | 0;
|
||||
ctx.save();
|
||||
ctx.fillStyle = col;
|
||||
ctx.shadowColor = col;
|
||||
ctx.shadowBlur = (8 + 6 * _shimmerNoise(seedBase)) * a; // shimmering glow
|
||||
ctx.globalAlpha = (0.45 + 0.45 * a) * (0.78 + 0.22 * _shimmerNoise(seedBase + 17));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0 - sw0, y0);
|
||||
ctx.lineTo(x0 + sw0, y0);
|
||||
ctx.lineTo(x1 + sw1, y1);
|
||||
ctx.lineTo(x1 - sw1, y1);
|
||||
ctx.fill();
|
||||
// Shimmering glow WITHOUT ctx.shadowBlur: blur cost scales with
|
||||
// the blurred DEVICE-pixel area, and a held sustain's trail can
|
||||
// span half the (DPR-scaled) canvas — profiling the "stutters
|
||||
// while playing" report put this per-frame blur pass at the top
|
||||
// exactly while a sustain is held. Three inflated low-alpha
|
||||
// fills of the same quad read as the same soft glow at a flat,
|
||||
// area-independent cost. The shimmer LUT still drives the
|
||||
// per-frame size/brightness flicker (feedBack#254 intent).
|
||||
const glowPx = (8 + 6 * _shimmerNoise(seedBase)) * a;
|
||||
const baseA = (0.45 + 0.45 * a) * (0.78 + 0.22 * _shimmerNoise(seedBase + 17));
|
||||
const fillTrail = (inflate) => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0 - sw0 - inflate, y0);
|
||||
ctx.lineTo(x0 + sw0 + inflate, y0);
|
||||
ctx.lineTo(x1 + sw1 + inflate, y1);
|
||||
ctx.lineTo(x1 - sw1 - inflate, y1);
|
||||
ctx.fill();
|
||||
};
|
||||
ctx.globalAlpha = baseA * 0.22;
|
||||
fillTrail(glowPx);
|
||||
ctx.globalAlpha = baseA * 0.4;
|
||||
fillTrail(glowPx * 0.45);
|
||||
ctx.globalAlpha = baseA;
|
||||
fillTrail(0);
|
||||
// Crackling "current" — a jittery white core line down
|
||||
// the trail, re-randomised each frame.
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.globalAlpha = a * (0.55 + 0.45 * _shimmerNoise(seedBase + 31));
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
|
||||
<!-- Hidden file input shared by the navbar "Upload" link. Kept at body
|
||||
level so it stays reachable regardless of which screen is active. -->
|
||||
<input type="file" id="upload-songs-file" accept=".sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
|
||||
<input type="file" id="upload-songs-file" accept=".feedpak,.sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
|
||||
|
||||
<!-- ══ HOME (Hero + Library) — reused as the v3 "Songs" screen ════════ -->
|
||||
<div id="home" class="screen">
|
||||
@@ -641,7 +641,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<!-- Autosave on blur/enter via a single-key POST (like every other v3
|
||||
setting), so setting the address never depends on the shared Save
|
||||
button — whose bundled dlc_dir could otherwise block it. Save button
|
||||
kept for discoverability. -->
|
||||
<input type="text" id="demucs-server-url" placeholder="http://192.168.1.100:7865"
|
||||
onchange="persistSetting('demucs_server_url', this.value.trim())"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
|
||||
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
|
||||
</div>
|
||||
|
||||
+5
-5
@@ -166,15 +166,15 @@
|
||||
const f = saved.filters;
|
||||
if (f && typeof f === 'object') {
|
||||
const arr = (x) => (Array.isArray(x) ? x.slice() : []);
|
||||
// mastery + match are session-only facets (deliberately not
|
||||
// mastery + match + genre are session-only facets (deliberately not
|
||||
// persisted), but the restored object must still CARRY the keys —
|
||||
// the filter drawer indexes f.mastery/f.match unconditionally, so
|
||||
// dropping them here breaks the drawer for anyone with saved prefs.
|
||||
// the filter drawer indexes f.mastery/f.match/f.genre unconditionally,
|
||||
// so dropping them here breaks the drawer for anyone with saved prefs.
|
||||
state.filters = {
|
||||
arr_has: arr(f.arr_has), arr_lacks: arr(f.arr_lacks),
|
||||
stem_has: arr(f.stem_has), stem_lacks: arr(f.stem_lacks),
|
||||
lyrics: f.lyrics || '', tunings: arr(f.tunings),
|
||||
mastery: [], match: [],
|
||||
mastery: [], match: [], genre: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2821,7 +2821,7 @@
|
||||
'<div class="flex items-center justify-between"><h3 class="text-lg font-semibold text-fb-text">Filters</h3>' +
|
||||
'<button data-drawer-close class="text-fb-textDim hover:text-fb-text">✕</button></div>' +
|
||||
section('Arrangements', ARRANGEMENTS.map((a) => triPill('arr', a, a, triState(f.arr_has, f.arr_lacks, a))).join('')) +
|
||||
section('Stems (sloppak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) +
|
||||
section('Stems (feedpak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) +
|
||||
section('Lyrics', ['', '1', '0'].map((v) => '<button data-lyrics="' + v + '" class="px-2 py-1 rounded-md text-xs border ' + (f.lyrics === v ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + (v === '' ? 'Any' : v === '1' ? 'Has lyrics' : 'No lyrics') + '</button>').join('')) +
|
||||
// Progress (mastery bands) — multi-select; server filters via song_stats.
|
||||
section('Progress', [['mastered', 'Mastered'], ['in_progress', 'In progress'], ['not_started', 'Not started']].map((it) => '<button data-mastery="' + it[0] + '" class="px-2 py-1 rounded-md text-xs border ' + (f.mastery.includes(it[0]) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + it[1] + '</button>').join('')) +
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Tests for lib/midi_import.py — convert_midi_tempo_map.
|
||||
|
||||
The note converters always computed a tempo-aware tick→seconds 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
|
||||
@@ -219,6 +219,47 @@ def test_dlc_dir_empty_string_clears(client, tmp_path):
|
||||
assert _read_cfg(tmp_path)["dlc_dir"] == ""
|
||||
|
||||
|
||||
def test_unresolvable_dlc_dir_does_not_block_other_keys(client, tmp_path):
|
||||
# Regression (feedBack-demucs-server#3): the v3 "Save" button next to the
|
||||
# Demucs field bundles dlc_dir with demucs_server_url in one POST. A DLC
|
||||
# path that doesn't resolve on THIS machine (stale value / unplugged drive)
|
||||
# must not abort the whole request — the co-submitted demucs_server_url has
|
||||
# to persist, and the bad path is surfaced as a warning rather than a hard
|
||||
# error that drops every other key.
|
||||
missing = str(tmp_path / "does-not-exist")
|
||||
r = client.post("/api/settings", json={
|
||||
"dlc_dir": missing,
|
||||
"demucs_server_url": "http://demucs.example:7865",
|
||||
"default_arrangement": "Lead",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# No hard error; the bad path is reported as a warning.
|
||||
assert "error" not in body
|
||||
assert any("does-not-exist" in w for w in body.get("warnings", []))
|
||||
assert "does-not-exist" in body["message"]
|
||||
cfg = _read_cfg(tmp_path)
|
||||
# The valid keys persisted...
|
||||
assert cfg["demucs_server_url"] == "http://demucs.example:7865"
|
||||
assert cfg["default_arrangement"] == "Lead"
|
||||
# ...and the unresolvable path was NOT written.
|
||||
assert cfg.get("dlc_dir", "") != missing
|
||||
|
||||
|
||||
def test_valid_dlc_dir_still_reports_song_count(client, tmp_path):
|
||||
# The happy path is unchanged: a resolvable DLC dir persists and the
|
||||
# response message still carries the "N song files found" summary (no
|
||||
# warnings key when nothing went wrong).
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
r = client.post("/api/settings", json={"dlc_dir": str(dlc)})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "warnings" not in body
|
||||
assert "song files found" in body["message"]
|
||||
assert _read_cfg(tmp_path)["dlc_dir"] == str(dlc)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["default_arrangement", "demucs_server_url"])
|
||||
def test_string_key_null_is_noop(client, tmp_path, key):
|
||||
# Match the dlc_dir contract: null preserves the on-disk value.
|
||||
|
||||
Reference in New Issue
Block a user