Compare commits

...
Author SHA1 Message Date
web-flow 97ff43bc28 Add automated code review workflow 2026-07-07 03:44:48 +00:00
K. O. A.andGitHub cb72c5ab34 Merge pull request #802 from got-feedBack/fix/v3-library-filters-drawer
ship-ci / ci (push) Waiting to run
Fix v3 library Filters drawer crash on saved prefs
2026-07-06 19:51:38 -04:00
topkoa 92e78be62d Fix v3 library Filters drawer crash on saved prefs; rename Stems label
applySavedPrefs() rebuilt state.filters without the `genre` key, so with
saved prefs restored from localStorage state.filters.genre was undefined.
Clicking Filters ran renderDrawer(), which indexes f.genre.includes(g)
whenever the library has >=1 genre -> TypeError, renderDrawer aborts, and
openDrawer never removes translate-x-full. The drawer stayed off-screen so
the menu appeared dead. Only triggered for users with saved prefs AND a
non-empty genre list, matching the intermittent report.

Carry genre: [] alongside the other session-only facets (mastery, match),
mirroring the default and clear-all shapes which already include it.

Also rename the visible "Stems (sloppak)" drawer label to "Stems (feedpak)"
to match the public format name used elsewhere in the UI.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-06 19:50:34 -04:00
K. O. A.andGitHub 1840170e95 Merge pull request #799 from got-feedBack/chore/lyrics-source-transcribed
Accept spec lyrics_source values (authored, transcribed)
2026-07-06 17:31:11 -04:00
topkoaandClaude Opus 4.8 7cbf9824b1 Drop dead whisperx entry from allowed lyrics_source set
The whisperx->transcribed alias runs before the membership check, so the literal whisperx never reaches _ALLOWED_LYRICS_SOURCES (same reason sng is omitted). Remove the dead entry. Per CodeRabbit review on #799.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-06 17:30:26 -04:00
topkoaandClaude Opus 4.8 33146cc7f6 Accept spec lyrics_source values (authored, transcribed)
The feedpak spec (§7.1) defines the lyrics_source vocabulary as {authored, transcribed, user}, but the reader only accepted the legacy {xml, notechart, whisperx, user} set and silently downgraded anything else to "xml". A spec-compliant writer (e.g. the stem_splitter plugin, which emits transcribed for WhisperX-produced lyrics) therefore lost its provenance badge.

Widen the allowed set to the union of the spec vocabulary and the legacy values so both validate, and alias the legacy whisperx engine name to the spec transcribed so existing packs normalise to the spec badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-06 17:24:26 -04:00
69c8ad4e0c fix(settings): don't let a stale DLC path block saving the Demucs server address (#795)
ship-ci / ci (push) Waiting to run
The v3 Settings "Save" button posts dlc_dir together with demucs_server_url,
default_arrangement and av_offset_ms in one request. POST /api/settings
validated dlc_dir first and early-returned "DLC directory not found" before it
ever processed demucs_server_url, so on a machine whose DLC path doesn't resolve
(fresh install, unplugged/network drive, a path carried over from another
machine) setting the Demucs server address silently failed — reported in
got-feedBack/feedBack-demucs-server#3 (macOS 07-05 nightly).

- server: a non-resolving dlc_dir is now recorded as a warning and skipped
  rather than aborting the whole POST, so the co-submitted keys still persist.
  The bad path is surfaced via a new additive `warnings` field and folded into
  `message` so the settings status line still shows it.
- client (v3): the Demucs input now autosaves on blur/enter via a single-key
  persistSetting POST, like every other v3 setting, so it never depends on the
  coupled Save button.
- tests: cover the decoupling and the unchanged happy path.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:35:51 +02:00
a20dca21bb feat(keys_highway_3d): add note-colour palettes and selectable camera angles (#794)
ship-ci / ci (push) Waiting to run
* feat(keys_highway_3d): add note-colour palettes and selectable camera angles

Give the 3D keys highway player-facing view options and a tuned default
look, so the piano highway is readable out of the box and customisable
from the settings panel without touching code.

Note colours (settings -> Note colours, `keys3d_bg_palette`):
- Octaves (new default): each octave its own hue climbing the rainbow,
  darker sharps, so pitch height reads at a glance on any note range.
- Rainbow: the original per-pitch table (colours unchanged).
- Vivid / Pastel: per-pitch variants.
- Emerald / Ice: single-hue two-tone (uniform naturals, darker sharps).
The pick drives the notes, key glow, lane guides and hit flames, live.

Camera (settings -> Camera angle, `keys3d_bg_camera`):
- Classic (the original low rig) / Elevated / Overhead (new default).
- Height, distance and tilt fine-tune sliders nudge the base vantage the
  auto-pan/zoom follow-motion orbits; presets apply live.

The new defaults are opinionated for plug-and-play (octaves palette,
overhead camera, tilt -0.6); anyone who prefers the original look can
pick Rainbow + Classic. Settings changes are re-read on init() so they
apply on return from the settings screen, not only after a relaunch.

Scoring, hit-timing and MIDI handling are untouched -- these are purely
visual. Numeric FX keys clamp to declared ranges (FX_RANGES); the pure
colour/camera helpers are covered by unit tests (node --test).

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

* fix(keys_highway_3d): make Classic camera reproduce the original rig + drop per-frame camera alloc

Review follow-ups on the palettes/camera feature:

- "Classic" preset now reproduces the historical rig exactly. The tuned
  plug-and-play downward aim (camTilt -0.6 x CAM_TILT_UNITS = -33) is baked
  into CAM_PRESETS.overhead.lookY, and camTilt now defaults to 0 (neutral).
  The default overhead look is byte-identical (effective lookY still -33),
  but "pick Classic for the original look" is now actually true instead of
  leaving a -33 down-tilt applied. settings.html tilt slider defaults to 0.

- _rig() writes into a hoisted reusable object instead of allocating a fresh
  {y,z,lookY,lookZ} literal every frame, honoring the module's documented
  "no per-frame allocations in draw()" discipline. Callers read it
  synchronously and never retain it, so one shared instance is safe.

Tests updated for the neutral camTilt default; adds an invariant test that
the default overhead framing is unchanged and Classic + neutral tilt == the
historical LOOK_Y. Full JS suite green (1069 pass).

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

---------

Signed-off-by: LegionaryLeader <legionaryleader@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-06 10:34:09 +02:00
021ee55f2a Allow .feedpak files in library when uploading (#770)
Signed-off-by: Rob Sassack <rsassack25@gmail.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-06 10:05:27 +02:00
1f621e5fe5 perf(highway): draw the held-sustain glow without ctx.shadowBlur (#729)
shadowBlur cost scales with the blurred device-pixel area. The lit-sustain
trail can span half the canvas, the canvas is DPR-scaled (4x pixels on a
2x Mac), and the blur ran on every frame exactly while a sustain is HELD
— i.e. at the moment the player most notices a hitch. Sustain-heavy songs
(e.g. fingerpicked acoustic charts) hit this constantly.

Replace the blur with three inflated low-alpha fills of the same trail
quad: reads as the same soft shimmering glow (the shimmer LUT still
drives per-frame flicker, feedBack#254 intent preserved) at a flat,
area-independent cost. Also drops the now-dead shadowBlur reset in the
crackle pass; no shadowBlur uses remain in highway.js.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-06 09:49:06 +02:00
612b1f2e0d feat(v3): choose handedness in the instrument selector + onboarding (give lefties a break) (#793)
ship-ci / ci (push) Waiting to run
* feat(v3): add a handedness (left-handed) choice to the instrument selector + onboarding

Left-handed players could already mirror the highway, but only via a buried
Settings toggle they had to find AFTER setup -- so a lefty went through the tour,
the tuner and calibration all right-handed first (community callout).

Add a "Handedness: Right / Left" row to the v3 instrument badge popover, alongside
Instrument / Strings / Tuning (all player-orientation choices). It writes the same
lefty preference -- highway.setLefty when a live highway exists (flips it
immediately + persists), else the 'lefty' localStorage key the highway reads on
init -- and keeps the Settings "Left-handed" checkbox in sync. The first-run
tour's "Choose your instrument" step, which runs before the tuner/audio-
calibration steps, now calls it out so lefties flip it up front.

Frontend-only, additive. Full core JS suite green (938). Tests:
tests/js/badges_handedness.test.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UR2Cr7GEu3yMY7SrfxH6c1

* docs: split the spliced Handedness/Colorblind CHANGELOG entries

A rebase pasted the Handedness bullet over the Colorblind preset entry's bold
lead, merging two unrelated Added entries into one run-on bullet. Restore them
as two separate bullets.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-05 23:50:30 +02:00
ChrisBeWithYouandGitHub 4f6dc233f1 feat(player): seed editor region handoff state (#762)
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
2026-07-05 23:37:39 +02:00
ChrisBeWithYouandGitHub 5be70939e4 feat(v3 library): searchable Cover Art Archive picker in Change-cover (#783)
The cover picker only offered CAA covers from a song's MATCHED release, so an
unmatched song (the city-pop pile) got nothing but Current/Pack/Upload/URL. Add
a search box: GET /api/song/{fn}/art/cover-search?q= searches MusicBrainz
release-groups and returns each album's CAA front-250 thumb; the picker renders
them as pickable tiles (same apply→/art/url path; covers with no CAA art
self-hide). Pre-filled from the song's artist + album/title (romaji fallback), so
a blank-artist pack pre-fills "Junko Yagami …". Reuses the throttled _mb_http_get.
2026-07-05 23:36:49 +02:00
ChrisBeWithYouandGitHub 6aaa2dcf47 feat(v3 library): batch→popup handoff + English-base romaji (metadata-curation capstone) (#782)
* feat(v3 library): click a "No match" badge to fix it — batch → popup handoff

Connects the two halves: the "No match" badge (the unmatched pile) now opens the
Fix-metadata popup for that song in one click, instead of right-click → menu.
The resting badge becomes interactive (pointer-events-auto + hover), carrying a
data-meta-fix hook; wireCards opens window.__fbFixMatch(playTarget) on click and
stops propagation so it doesn't also play the card. Batch tile states stay
non-interactive. Loop becomes: Unmatched filter → see the pile → click one →
fix it. tailwind.min.css regenerated for the badge's hover classes.

* feat(v3 library): show the author's romaji, not blank/native script (English base)

Two changes so an English-speaking base never sees a blank name or native script:

- Filename romaji fallback: a blank-artist CDLC pack ("Artist_Title_v1_p") shows
  nothing useful (artist blank; title = the raw filename), and a match fills it
  with kanji/kana. query_page + pack_fields now surface the author's own romaji
  parsed from the filename ("Junko Yagami — BAY CITY") when the pack has no
  artist of its own — display-only, keyset-safe (raw title stashed for the
  cursor), a real pack artist or a user override still wins.
- Smart adopt: "Use these values" now KEEPS the readable romaji name + title the
  card already shows and takes only album/year/genre (+ art via the pin) from the
  match, so identifying a Japanese song gives "Junko Yagami — BAY CITY — FULL MOON"
  with the right cover, never native script.

Tests: romaji fallback fires for a blank-artist CDLC pack (grid + pack_fields
agree) and is left alone when the pack has a real artist.
2026-07-05 23:35:58 +02:00
1a8540935b fix(gp_autosync): slope-constrained DTW steps — stop path collapse on riff-based songs (#791)
librosa.sequence.dtw's default step sizes permit pure horizontal/vertical
moves; on songs whose chroma is self-similar for long stretches the flat
cost surface let the path collapse (minutes of score onto one audio frame),
so auto-sync produced monotonic-but-garbage sync points and the per-bar
warp imported charts badly out of sync while reporting success.

Use the standard music-sync step pattern [[1,1],[1,2],[2,1]] (local tempo
ratio bounded to 0.5x-2x), falling back to unconstrained steps if the
global length ratio makes it infeasible.

Validated on the reported song (138 BPM tab, YouTube audio): coarse points
now track 1:1, refine holds slopes 0.77-1.04, warped downbeats hit onset
peaks at 3.3x background energy.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:12:31 +02:00
OmikronApexandGitHub d567fd5597 Change nightly workflow schedule time
ship-ci / ci (push) Waiting to run
2026-07-05 22:48:01 +02:00
b914612f9d fix(highway_3d): recover from WebGL context loss instead of crashing on alt-tab (#790)
Switching the active window / alt-tabbing away (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 removed in
teardown.

Root cause is a strong hypothesis -- the crash is intermittent and
unreproducible -- but the fix is low-risk and additive and closes a real gap:
there was no context-loss handling anywhere in the renderer.

plugins/highway_3d 3.31.2 -> 3.31.3. Tests:
tests/js/highway_3d_context_loss.test.js (source-contract, like the other
highway_* tests). The sibling keys_highway_3d / drum_highway_3d renderers share
the same gap -- follow-up in their repos.


Claude-Session: https://claude.ai/code/session_01UR2Cr7GEu3yMY7SrfxH6c1

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 22:20:32 +02:00
de002cdc24 feat(highway): add "Colorblind (deuteranope)" string-color preset (#788)
Adds a one-click preset to the shared "Highway String Colors" picker,
next to the existing Okabe-Ito "Colorblind-friendly" preset. Contributed
by a deuteranopic player who found the Okabe-Ito set still hard to
separate: it retunes the six main strings and keeps that set's 7/8-string
colors. Applies to both the 2D and 3D highways via the shared picker,
which writes the slot->hex map the renderers already consume.

Additive frontend-only change to HWC_PRESETS in static/app.js; the picker
UI and both highways pick it up automatically (the preset list is
generated from HWC_PRESETS and applied by id). All 20 highway
string-color JS tests pass; app.js syntax-checks clean.


Claude-Session: https://claude.ai/code/session_01UR2Cr7GEu3yMY7SrfxH6c1

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:13:13 +02:00
3100d68a45 feat(v3 library): genre field in the Fix-metadata popup Details tab (#780)
Adds Genre as a fifth Details field (edit / lock / revert / Yours-Pack
provenance), backed by the existing override store. To make it actually useful,
the genre FILTER and FACET now resolve the per-song override (effective genre =
override else scanned pack genre) — guarded so the common no-override case stays
on the plain indexed column — so a corrected/added genre is immediately
browsable. Genre stays a library-only overlay: it is NOT a write-to-file field
(split WRITE_FIELDS = the four file-safe fields from the five DETAIL_FIELDS), so
Write to file leaves the genre override in place and the copy says so. The
Match→Details bridge also carries a candidate's first genre.

Tests: effective-genre facet + filter, and that a value-less lock doesn't invent
an effective genre.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:12:55 +02:00
26 changed files with 1486 additions and 106 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ name: Nightly
# builds from release/** come from rc.yml instead.
on:
schedule:
- cron: '0 2 * * *'
- cron: '0 23 * * *'
workflow_dispatch:
permissions:
+181
View File
@@ -0,0 +1,181 @@
name: OpenCodeReview PR Review
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
on:
issue_comment:
types: [created]
jobs:
code-review:
permissions:
contents: read
issues: write
pull-requests: write
if: |
github.event_name == 'pull_request' ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request &&
(startsWith(github.event.comment.body, '/open-code-review') ||
startsWith(github.event.comment.body, '@open-code-review')))
runs-on: [self-hosted, linux, x64]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install OpenCodeReview
run: npm install -g @alibaba-group/open-code-review
- name: Configure OCR
run: |
ocr config set llm.url "http://coordinator:8080/v1"
ocr config set llm.auth_token "${{ secrets.OCR_COORDINATOR_SECRET }}"
ocr config set llm.model "${{ secrets.OCR_LLM_MODEL || 'qwen3-30b-a3b' }}"
ocr config set llm.use_anthropic false
ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}'
ocr config set language English
- name: Run OpenCodeReview
id: review
run: |
ocr review \
--from "origin/${{ github.base_ref }}" \
--to "origin/${{ github.head_ref }}" \
--format json \
--audience agent \
> /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true
- name: Post review comments to PR
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const path = '/tmp/ocr-result.json';
// Read OCR output
let result;
let raw = '';
try {
raw = fs.readFileSync(path, 'utf8').trim();
if (!raw) {
throw new Error('OCR output file is empty');
}
// OCR may prefix a status line before JSON; try whole file first, then skip first line
try {
result = JSON.parse(raw);
} catch {
const jsonContent = raw.substring(raw.indexOf('\n') + 1);
result = JSON.parse(jsonContent);
}
} catch (e) {
console.log('Failed to parse OCR output:', e.message);
console.log('Raw OCR output (first 2000 chars):', raw.substring(0, 2000));
const stderr = fs.readFileSync('/tmp/ocr-stderr.log', 'utf8').trim();
if (stderr) {
console.log('OCR stderr:', stderr.substring(0, 2000));
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `⚠️ **OpenCodeReview** encountered an error:\n\`\`\`\n${stderr}\n\`\`\``
});
} else if (raw) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `⚠️ **OpenCodeReview** encountered an error. Raw output:\n\`\`\`\n${raw.substring(0, 2000)}\n\`\`\``
});
}
return;
}
const comments = result.comments || [];
if (comments.length === 0) {
const message = result.message || 'No comments generated. Looks good to me.';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `✅ **OpenCodeReview**: ${message}`
});
return;
}
// Prepare PR review with inline comments
const prNumber = context.issue.number;
const commitSha = context.payload.pull_request.head.sha;
const reviewComments = [];
const commentsWithoutLine = [];
for (const comment of comments) {
const body = comment.content || '';
const hasValidLine = (comment.start_line >= 1) || (comment.end_line >= 1);
if (!hasValidLine) {
commentsWithoutLine.push(comment);
continue;
}
const reviewComment = { path: comment.path, body };
if (comment.start_line >= 1 && comment.end_line >= 1 && comment.start_line !== comment.end_line) {
reviewComment.start_line = comment.start_line;
reviewComment.line = comment.end_line;
reviewComment.start_side = 'RIGHT';
reviewComment.side = 'RIGHT';
} else if (comment.end_line >= 1) {
reviewComment.line = comment.end_line;
reviewComment.side = 'RIGHT';
} else if (comment.start_line >= 1) {
reviewComment.line = comment.start_line;
reviewComment.side = 'RIGHT';
}
reviewComments.push(reviewComment);
}
// Build summary
const totalCount = comments.length;
const inlineCount = reviewComments.length;
const summaryCount = commentsWithoutLine.length;
let summaryBody = `🔍 **OpenCodeReview** found **${totalCount}** issue(s) in this PR.`;
summaryBody += `\n- ✅ ${inlineCount} posted as inline comment(s)`;
summaryBody += `\n- 📝 ${summaryCount} posted in summary`;
// Add non-inline comments to summary
for (const comment of commentsWithoutLine) {
summaryBody += `\n\n---\n\n### 📄 \`${comment.path}\`\n\n${comment.content || ''}`;
}
try {
await github.rest.pulls.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
commit_id: commitSha,
body: summaryBody,
event: 'COMMENT',
comments: reviewComments
});
} catch (e) {
console.log('Failed to post review:', e.message);
// Fallback: post summary comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: summaryBody + `\n\n⚠️ Could not post inline comments: ${e.message}`
});
}
+6
View File
@@ -7,7 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **Auto-sync: DTW step constraint — riff-based songs no longer produce garbage sync points.** `librosa.sequence.dtw`'s default step pattern allows unbounded horizontal/vertical path runs, and on music with long self-similar chroma stretches (riff-driven stoner/doom, drone sections) the flat cost surface let the warping path collapse — minutes of score mapped onto a single audio frame, so the per-bar warp imported charts wildly out of sync while reporting success (observed on a real 138 BPM tab: effective displayed tempo 159 BPM, three sync points sharing one audio timestamp). `_dtw_align` now uses the standard music-sync slope-constrained step pattern (`[[1,1],[1,2],[2,1]]`, local tempo ratio bounded to 0.5x2x), which makes the degenerate path impossible, with a fallback to unconstrained steps when the global length ratio makes the constrained pattern infeasible (e.g. a tab aligned against a full-concert video). Validated on the failing song: coarse points track the recording 1:1, refined downbeats land on onset peaks at 3.3x background energy.
### Added
- **Handedness (left-handed) is now a first-class choice in the instrument selector — and surfaced during onboarding.** Left-handed players could already mirror the highway, but only via a buried Settings toggle they had to find *after* setup — so a lefty hit the tour, the tuner and calibration all right-handed first. The v3 instrument badge popover now has a **Handedness: Right / Left** row alongside Instrument / Strings / Tuning (all player-orientation choices), writing the same `lefty` preference (`highway.setLefty` when a live highway exists, else the `lefty` localStorage key it reads on init; the Settings checkbox stays in sync). The first-run tour's "Choose your instrument" step — which runs **before** the tuner/audio-calibration steps — now calls it out so lefties flip it up front. Frontend-only, additive: `static/v3/badges.js`, `static/v3/onboarding-tour.js`. Tests: `tests/js/badges_handedness.test.js`.
- **"Colorblind (deuteranope)" highway string-color preset.** Adds a one-click preset to the shared "Highway String Colors" picker, sitting next to the existing OkabeIto "Colorblind-friendly" preset — contributed by a deuteranopic player who found the OkabeIto set still hard to separate. It retunes the six main strings (red / yellow-green / blue / orange / teal / deep-purple) and keeps that set's 7/8-string colors, and applies to **both** the 2D and 3D highways via the shared picker. Frontend-only, additive: `static/app.js` (`HWC_PRESETS`).
- **`lib/gp_autosync.py`: piecewise time-warp helpers + a working `refine_sync()`.** `auto_sync()` has always computed per-bar sync points (DTW), but consumers could only apply the scalar bar-1 `audio_offset`, so any tempo difference between the recording and the tab's authored tempo accumulated audibly over the song. New librosa-free helpers expose the full mapping: `bar_start_times(gp_path)` (per-bar score times on the same axis as the sync points — GPIF bar-resolution map for `.gp`/`.gpx`, per-tick integration for GP3/4/5), `build_warp_anchors(points, bar_starts)` (strictly-monotonic `(score, audio)` anchor pairs), `warp_time(t, anchors)` (piecewise-linear map with edge-slope extrapolation for count-ins/tails), `warp_song_times(song, warp)` (retimes a `lib.song.Song` in place: notes + sustains, chords, beats, sections, anchors, handshapes, per-phrase difficulty levels, tone changes, tempo overrides), and `gp_has_expandable_repeats(gp_path)` (detects GP3/4/5 repeat/volta/direction markup whose playback expansion the as-written sync points cannot map — callers fall back to offset-only sync). Also implements `refine_sync()`, which the editor plugin's refine-sync endpoint has imported since the snapshot but which never existed in core (the Refine button 500'd): it densifies the coarse DTW points to every Nth bar and re-times each with a local onset phase sweep (sweep radius clamped under half a beat so periodic material can't lock a full beat off; short scoring grid + median residual snap). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`.
- **Playlist shuffle.** The v3 playlist detail page gains a crossing-arrows shuffle toggle next to Play all / Play album. When on, `playQueue.start` Fisher-Yates-shuffles the queue once at start (on a copy — the stored playlist order is untouched), swapping any per-slot album arrangements in lockstep so each slot keeps its pinned arrangement. The preference is global and persists in `localStorage` (`v3PlaylistShuffle`). Tests: `tests/js/play_queue_shuffle.test.js`.
@@ -15,6 +20,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
- **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).
- **Starter content seeds again (and now ships The Adicts' "Ode to Joy").** `_BUILTIN_STARTER_SOURCES` still listed `beethoven-ode_to_joy.feedpak` after that pack was deleted, and never wired up its replacement `the_adicts-ode-to-joy_vst_cover.feedpak` that landed on disk. The listed-but-missing file made the all-present gate never fire, so **no** starter songs seeded on first run. Synced the manifest to what's on disk (Für Elise, Star Spangled Banner, The Adicts' Ode to Joy). Tests: `tests/test_builtin_starter_seed.py` (the present/unlisted guards were red on `main`).
+24 -1
View File
@@ -538,9 +538,32 @@ def _dtw_align(
Returns wp where wp[i] = [score_frame_index, audio_frame_index].
"""
import librosa
import numpy as np
cs = _safe_normalise(chroma_score)
ca = _safe_normalise(chroma_audio)
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
# Slope-constrained step pattern ([[1,1],[1,2],[2,1]], Müller's standard
# music-sync config): every step advances BOTH axes, bounding the local
# tempo ratio to 0.5x-2x. librosa's default steps allow pure
# horizontal/vertical runs, and on riff-based music (long self-similar
# chroma stretches, e.g. stoner/doom) the flat cost surface let the path
# collapse — whole minutes of score mapped onto a single audio frame,
# producing garbage sync points. The constrained pattern makes that
# degenerate path impossible.
steps = np.array([[1, 1], [1, 2], [2, 1]])
weights = np.array([1.0, 1.0, 1.0])
try:
_D, wp = librosa.sequence.dtw(
cs, ca, metric='cosine',
step_sizes_sigma=steps, weights_mul=weights,
)
except Exception as exc:
# The constrained pattern needs the global length ratio within its
# 0.5x-2x slope bounds; a pathological pairing (e.g. a 3-minute tab
# against a 20-minute video) is infeasible and librosa raises. Fall
# back to the unconstrained path rather than failing the whole sync.
_log.warning("gp_autosync: constrained DTW infeasible (%s) — "
"falling back to unconstrained steps", exc)
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
return wp[::-1] # reverse to forward order
# ── Sync point extraction from DTW path ──────────────────────────────────────
+26 -14
View File
@@ -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)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.31.2",
"version": "3.31.3",
"type": "visualization",
"bundled": true,
"script": "screen.js",
+39
View File
@@ -3747,6 +3747,15 @@
// ── Per-instance Three.js state ───────────────────────────────────
let scene = null, cam = null, ren = null;
let wrap = null;
// WebGL context-loss recovery. Switching the active window / alt-tabbing
// (especially on Windows) can trigger a GPU context reset; with no
// handler the lost context escalates into a render-process crash. The
// listeners (bound in initScene on ren.domElement, removed in teardown)
// preventDefault the loss so the browser keeps the context restorable,
// _ctxLost gates draw() off the dead context, and on restore we reset the
// viewport + resume (Three re-uploads scene resources on the next render).
let _ctxLost = false;
let _onCtxLost = null, _onCtxRestored = null;
let bcCtrl = null; // Butterchurn audio-reactive background (the 'butterchurn' bg-style)
let _chartEnv = 0, _chartPrevT = -1, _bcBeatIdx = 0, _bcNoteIdx = 0, _bcChordIdx = 0, _bcTintTarget = null;
let _tintR = 20, _tintG = 24, _tintB = 40; // smoothed instrument-color tint for the bg
@@ -6526,6 +6535,26 @@
ren.setClearColor(0x101820, _bcActive() ? 0 : 1);
wrap.appendChild(ren.domElement);
// WebGL context-loss recovery (see the _ctxLost declaration). Bound
// on Three's own canvas — the context that actually resets on a GPU
// reset / alt-tab. preventDefault() keeps the context restorable
// instead of letting the loss escalate to a render-process crash;
// _ctxLost then makes draw() bail so no GL work runs on the dead
// context; on restore we reset the viewport and resume (Three
// re-uploads geometry/materials/textures lazily on the next render).
_onCtxLost = (e) => {
if (e && typeof e.preventDefault === 'function') e.preventDefault();
_ctxLost = true;
console.warn('[3D-Hwy] WebGL context lost — pausing render until it is restored.');
};
_onCtxRestored = () => {
_ctxLost = false;
console.warn('[3D-Hwy] WebGL context restored — resuming render.');
try { const s = canvasSize(highwayCanvas); if (s.w > 0 && s.h > 0) applySize(s.w, s.h); } catch (err) {}
};
ren.domElement.addEventListener('webglcontextlost', _onCtxLost, false);
ren.domElement.addEventListener('webglcontextrestored', _onCtxRestored, false);
lyricsCanvas = document.createElement('canvas');
lyricsCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:1;';
lyricsCtx = lyricsCanvas.getContext('2d');
@@ -14829,6 +14858,15 @@
// mid-teardown settings change doesn't try to rebuild a torn-
// down scene; then dispose the active style's resources.
if (_bgListener) { _bgUnsubscribe(_bgListener); _bgListener = null; }
// WebGL context-loss listeners (bound in initScene on ren.domElement).
// Remove before ren is disposed below so a torn-down instance can't
// keep firing them; reset the flag so a reused instance starts clean.
if (ren && ren.domElement) {
if (_onCtxLost) { try { ren.domElement.removeEventListener('webglcontextlost', _onCtxLost, false); } catch (e) {} }
if (_onCtxRestored) { try { ren.domElement.removeEventListener('webglcontextrestored', _onCtxRestored, false); } catch (e) {} }
}
_onCtxLost = _onCtxRestored = null;
_ctxLost = false;
// Notedetect listeners (issue #9). Remove on destroy so a
// panel that stops doesn't keep accumulating marks. Marks
// arrays are cleared too — they hold stale chart positions
@@ -15154,6 +15192,7 @@
draw(bundle) {
if (!_isReady) return;
if (_ctxLost) return; // GPU context lost (alt-tab / reset) — skip until restored
if (!_chartPrewarmed) {
_chartPrewarmed = true;
_prewarmChart(bundle);
+2 -1
View File
@@ -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 01).
- **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 -1
View File
@@ -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,
+320 -34
View File
@@ -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 (C1B1) 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 01 slider range. The camera fine-tune knobs
// are multipliers/offsets centred on 1 (or 0), so they need headroom and a
// floor a 01 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,
};
+92 -1
View File
@@ -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');
});
+126 -7
View File
@@ -1266,6 +1266,18 @@ class MetadataDB:
out.setdefault(fn, {})[field] = {"value": value, "locked": bool(locked)}
return out
def _romaji_display(self, filename: str, artist: str, title: str):
"""English-base display fallback. A blank-artist CDLC pack named
'Artist_Title_v1_p' has no readable name (artist blank; title = the raw
filename), and a match would fill it with the artist's NATIVE script
(kanji/kana). Surface the author's own romaji parsed from the filename
instead, so an English base reads 'Junko Yagami - BAY CITY'. Only kicks in
when the pack has no artist of its own a real pack artist is untouched."""
if (artist or "").strip():
return artist, title
d = _artist_title_from_filename(filename)
return (d["artist"], d["title"]) if d else (artist, title)
def pack_fields(self, filename: str) -> dict:
"""The stored (pack) values for the overridable catalog fields — the
Fix-metadata popup shows these behind each override as the 'revert to
@@ -1275,7 +1287,32 @@ class MetadataDB:
row = self.conn.execute(
"SELECT title, artist, album, year, genre FROM songs WHERE filename = ?",
(filename,)).fetchone()
return {k: ((row[i] or "") if row else "") for i, k in enumerate(keys)}
vals = {k: ((row[i] or "") if row else "") for i, k in enumerate(keys)}
# Baseline the author's romaji (from the filename) for a blank-artist pack,
# so the Details tab's Pack reference matches what the grid shows.
vals["artist"], vals["title"] = self._romaji_display(filename, vals["artist"], vals["title"])
return vals
# Effective genre = a per-song genre OVERRIDE (Fix-metadata popup) else the
# scanned pack genre. Applied at FILTER/FACET time (like the P4 artist alias)
# so a corrected genre is browsable — the correlated subquery is used ONLY
# when genre overrides actually exist; the common case stays on the plain
# indexed `genre` column. Genre stays a library-only overlay (it isn't a
# write-to-file field), so it never touches the pack.
_EFFECTIVE_GENRE_SQL = (
"COALESCE((SELECT o.value FROM song_field_override o "
"WHERE o.filename = songs.filename AND o.field = 'genre' "
"AND o.value IS NOT NULL AND o.value != ''), genre)"
)
def _has_genre_overrides(self) -> bool:
return self.conn.execute(
"SELECT 1 FROM song_field_override WHERE field = 'genre' "
"AND value IS NOT NULL AND value != '' LIMIT 1").fetchone() is not None
def _effective_genre_expr(self) -> str:
"""`genre` normally; the override-aware COALESCE only when overrides exist."""
return self._EFFECTIVE_GENRE_SQL if self._has_genre_overrides() else "genre"
def set_song_tags(self, filename: str, tags) -> list:
"""Replace ALL of a song's tags with the given set (each normalized;
@@ -3464,7 +3501,7 @@ class MetadataDB:
# list on scan). OR within the selected set.
if genre:
_gph = ",".join(["?"] * len(genre))
where += f" AND genre COLLATE NOCASE IN ({_gph})"
where += f" AND ({self._effective_genre_expr()}) COLLATE NOCASE IN ({_gph})"
params += list(genre)
# Mastery bands = best accuracy across a song's arrangements (song_stats,
# a separate table -> correlated subquery). mastered >= 0.9, in_progress =
@@ -4194,6 +4231,17 @@ class MetadataDB:
s["unmatched"] = s["filename"] in um
if amap:
s["artist"] = amap.get((s.get("artist") or "").lower(), s.get("artist"))
# English-base romaji fallback: a blank-artist CDLC pack shows nothing
# useful (artist blank; title = the raw filename). Surface the author's
# romaji from the "Artist_Title_v1_p" filename so the card reads
# "Junko Yagami — BAY CITY", never blank or native script. Display-only;
# a user override (below) still wins. Keyset-safe: stash the raw title
# for the cursor before replacing it.
if not (s.get("artist") or "").strip():
r_artist, r_title = self._romaji_display(s["filename"], s.get("artist"), s.get("title"))
if r_title != s.get("title") and "_sort_title" not in s:
s["_sort_title"] = s["title"]
s["artist"], s["title"] = r_artist, r_title
# Override wins over the pack AND the alias re-label — it's the user's
# explicit per-song choice. Only a non-empty override VALUE replaces a
# cell; a lock-only row (value None) leaves the displayed value alone.
@@ -4203,7 +4251,7 @@ class MetadataDB:
cell = ov.get(field)
val = cell.get("value") if cell else None
if val:
if field == "title":
if field == "title" and "_sort_title" not in s:
s["_sort_title"] = s["title"] # raw title, for the keyset cursor
s[field] = val
# Grouped rows carry the ⚑ N (chart_count) + the work_key from the
@@ -6499,6 +6547,36 @@ def _mb_search_recordings(artist, title, limit: int = 12) -> list[dict]:
return cands
def _mb_search_release_groups(query: str, limit: int = 8) -> list[dict]:
"""Text search /release-group for the Change-cover picker: albums matching a
free query, each mapped to its Cover Art Archive front thumb. One request;
tiles whose CAA art is missing self-hide client-side (front-250 404s). Lets a
cover be found even for a song with no metadata match (the city-pop pile)."""
q = (query or "").strip()
if not q:
return []
body = _mb_http_get("release-group", {"query": q, "limit": limit})
out: list[dict] = []
for rg in ((body or {}).get("release-groups") or []):
rid = rg.get("id")
if not rid:
continue
# artist-credit is a list of {name, joinphrase, artist} (joinphrase glues
# collaborations) — reconstruct the credited name.
artist = "".join(
(c.get("name", "") + c.get("joinphrase", "")) if isinstance(c, dict) else str(c)
for c in (rg.get("artist-credit") or [])
).strip()
title = rg.get("title") or ""
year = (rg.get("first-release-date") or "")[:4]
out.append({
"id": rid,
"label": " · ".join(x for x in (title, artist, year) if x) or title or "Cover",
"thumb_url": f"https://coverartarchive.org/release-group/{rid}/front-250",
})
return out
# ── AcoustID audio fingerprinting (content-based identification) ──────────────
# Optional path: requires the Chromaprint `fpcalc` binary AND an AcoustID API
# key ($ACOUSTID_API_KEY). Both absent ⇒ graceful no-op; the text matcher runs.
@@ -9118,9 +9196,10 @@ def library_genres(provider: str = "local"):
if is_remote:
return {"genres": []}
with meta_db._lock:
g = meta_db._effective_genre_expr()
rows = meta_db.conn.execute(
"SELECT DISTINCT genre FROM songs WHERE genre IS NOT NULL AND genre != '' "
"ORDER BY genre COLLATE NOCASE"
f"SELECT g FROM (SELECT DISTINCT ({g}) AS g FROM songs) "
"WHERE g IS NOT NULL AND g != '' ORDER BY g COLLATE NOCASE"
).fetchall()
return {"genres": [r[0] for r in rows]}
@@ -10686,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"]
@@ -10705,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
@@ -10962,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
@@ -12200,6 +12299,26 @@ async def get_song_art(filename: str, request: Request = None, source: str = "")
_ART_PICKER_MAX_CAA = 12
@app.get("/api/song/{filename:path}/art/cover-search")
def api_art_cover_search(filename: str, q: str = ""):
"""Search Cover Art Archive (via MusicBrainz release-groups) for album covers
powers the Change-cover picker's search box, so a cover can be found even
for a song with no metadata match (the unmatched city-pop pile, where
/art/candidates is empty). `q` defaults to the song's own artist + album/
title (romaji fallback applied). Read-only; the picker renders the thumbs and
applies a pick through the existing /art/url route."""
query = (q or "").strip()
if not query:
pack = meta_db.pack_fields(meta_db._canonical_song_filename(filename))
query = " ".join(x for x in (pack.get("artist"), pack.get("album") or pack.get("title")) if x).strip()
if not query:
return {"query": "", "covers": []}
try:
return {"query": query, "covers": _mb_search_release_groups(query, limit=8)}
except EnrichTransportError:
return {"query": query, "covers": [], "error": "unavailable"}
@app.get("/api/song/{filename:path}/art/candidates")
def get_song_art_candidates(filename: str):
"""Everything the cover picker can offer for one song, without fetching a
+36 -11
View File
@@ -2870,6 +2870,10 @@ const HWC_DEFAULT_FALLBACK = { lowE: '#cc0000', A: '#cca800', D: '#0066cc', G: '
// - colorblind: the OkabeIto accessible qualitative palette (vermillion,
// orange, yellow, bluish-green, sky-blue, blue, reddish-purple), the most
// distinguishable option for deuteranopia/protanopia.
// - colorblind_deuteranope: a deuteranope-tuned variant of the OkabeIto set
// above, contributed by a deuteranopic player who still found that set hard
// to separate. Retunes the six main strings (red / yellow-green / blue /
// orange / teal / deep-purple) and keeps its 7/8-string colors unchanged.
// - neon: electric, max-saturation hues whose LIGHTNESS deliberately zig-zags
// between neighbours (bright→bright→brightest→dark blue→bright green→dark
// violet) so adjacent strings separate harder than vivid — a stage/stream
@@ -2906,6 +2910,10 @@ const HWC_PRESETS = [
id: 'colorblind', label: 'Colorblind-friendly',
colors: { lowE: '#d55e00', A: '#e69f00', D: '#f0e442', G: '#009e73', B: '#56b4e9', highE: '#cc79a7', low7: '#0072b2', low8: '#999999' },
},
{
id: 'colorblind_deuteranope', label: 'Colorblind (deuteranope)',
colors: { lowE: '#aa1414', A: '#88de00', D: '#1889e3', G: '#c6601c', B: '#00f5b2', highE: '#4d2173', low7: '#0072b2', low8: '#999999' },
},
{
id: 'neon', label: 'Neon',
colors: { lowE: '#ff1f4e', A: '#ff9d00', D: '#e9ff00', G: '#1844ff', B: '#00ff84', highE: '#d000ff', low7: '#ff00aa', low8: '#00f0ff' },
@@ -4371,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) {
@@ -8092,6 +8100,26 @@ function _resolveEditRegion() {
return { a: Math.max(0, t - 4), b: t + 4 };
}
/* @pure:editor-pending-view:start */
function _buildEditorPendingViewPure(filename, arrangement, region, opts) {
const options = opts || {};
const view = {
filename,
arrangement: Number.isFinite(arrangement) && arrangement >= 0 ? arrangement : 0,
barSel: region ? { startTime: region.a, endTime: region.b } : null,
};
if (options.returnToHighway) view.returnToHighway = true;
if (typeof options.cursorTime === 'number') {
view.cursorTime = options.cursorTime;
} else if (region && typeof region.a === 'number') {
view.cursorTime = region.a;
}
if (typeof options.scrollX === 'number') view.scrollX = Math.max(0, options.scrollX);
if (typeof options.zoom === 'number' && options.zoom > 0) view.zoom = options.zoom;
return view;
}
/* @pure:editor-pending-view:end */
// Enable "Edit region" whenever the editor plugin is present and a song is
// loaded; show "↩ Editor" only while a return context is pending.
function _updateEditRegionBtn() {
@@ -8118,12 +8146,9 @@ function editRegionInEditor() {
arrangement = si.arrangement_index;
}
} catch (_) { /* default to 0 */ }
window._editorPendingView = {
filename: currentFilename,
arrangement,
barSel: { startTime: region.a, endTime: region.b },
window._editorPendingView = _buildEditorPendingViewPure(currentFilename, arrangement, region, {
returnToHighway: true,
};
});
window.editSong(currentFilename);
}
window.editRegionInEditor = editRegionInEditor;
@@ -8135,14 +8160,14 @@ function returnToEditorFromHighway() {
const ctx = window._highwayReturnCtx;
if (!ctx || typeof window.editSong !== 'function') return;
window._highwayReturnCtx = null;
window._editorPendingView = {
filename: ctx.filename,
arrangement: ctx.arrangement,
const region = ctx.barSel
? { a: ctx.barSel.startTime, b: ctx.barSel.endTime }
: null;
window._editorPendingView = _buildEditorPendingViewPure(ctx.filename, ctx.arrangement, region, {
scrollX: ctx.scrollX,
zoom: ctx.zoom,
cursorTime: ctx.cursorTime,
barSel: ctx.barSel,
};
});
window.editSong(ctx.filename);
}
window.returnToEditorFromHighway = returnToEditorFromHighway;
+24 -10
View File
@@ -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';
+26
View File
@@ -451,6 +451,12 @@
pill('inst', v, v[0].toUpperCase() + v.slice(1), settings.instrument === v)).join('')) +
instRow('Strings', STRING_COUNTS[settings.instrument].map((v) =>
pill('strings', v, v + '', settings.string_count === v)).join('')) +
// Handedness — a left-hander flips the whole highway (frets mirror).
// Lives with the other player-orientation choices so it's part of the
// same "Choose your instrument" step the onboarding tour spotlights —
// i.e. set before you ever tune up or calibrate.
instRow('Handedness', pill('hand', 'right', 'Right', !_leftyPref()) +
pill('hand', 'left', 'Left', _leftyPref())) +
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Tuning</div>' +
'<select data-inst-tuning class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-fb-text outline-none focus:border-fb-primary">' +
// An offset-array tuning has no named option — surface it as a
@@ -513,6 +519,10 @@
setWorkingInstrument(settings.instrument, newSc);
renderInstrument(); keepOpen();
}));
menu.querySelectorAll('[data-pill="hand"]').forEach((b) => b.addEventListener('click', () => {
_setLeftyPref(b.getAttribute('data-val') === 'left');
renderInstrument(); keepOpen(); // reflect the active pill; keep the menu open
}));
menu.querySelector('[data-inst-tuning]').addEventListener('change', (e) => saveSettings({ tuning: e.target.value }));
menu.querySelector('[data-inst-pathway]').addEventListener('change', (e) => saveSettings({ pathway: e.target.value }));
const ref = menu.querySelector('[data-inst-ref]');
@@ -528,6 +538,22 @@
function instRow(label, inner) {
return '<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
}
// Handedness (left-handed) preference. The canonical store is the highway's
// `lefty` localStorage key; when a live highway exists, setLefty() also flips
// it immediately. Feature-detected so it works on the dashboard before any
// highway has been created (the value is read on the highway's next init).
function _leftyPref() {
try { if (window.highway && typeof window.highway.getLefty === 'function') return !!window.highway.getLefty(); } catch (_) { /* */ }
try { return localStorage.getItem('lefty') === '1'; } catch (_) { return false; }
}
function _setLeftyPref(on) {
try {
if (window.highway && typeof window.highway.setLefty === 'function') window.highway.setLefty(!!on);
else localStorage.setItem('lefty', on ? '1' : '0');
} catch (_) { /* storage blocked — the pill still reflects the choice via re-render */ }
// Keep the Settings "Left-handed" checkbox in sync when it's mounted.
try { const cb = document.getElementById('setting-lefty'); if (cb) cb.checked = !!on; } catch (_) { /* */ }
}
function pill(group, val, label, active) {
return '<button type="button" data-pill="' + group + '" data-val="' + val + '" class="px-2 py-1 rounded-md text-xs ' +
(active ? 'bg-fb-primary text-white' : 'bg-gray-800/50 text-fb-textDim hover:text-fb-text') + '">' + esc(label) + '</button>';
+56 -1
View File
@@ -138,6 +138,17 @@
'<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + SKELETON_TILE + '</div>' +
'<div class="text-xs text-fb-textDim pt-2">Fetching covers… the source is rate-limited.</div>' +
'</div>' +
// Search Cover Art Archive — find an album cover even when the song has
// no match (the auto candidates above are empty then). Pre-filled from
// the song's artist + album/title; the source is rate-limited.
'<div class="space-y-2 pt-1">' +
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Search covers</div>' +
'<div class="flex gap-2">' +
'<input data-ip-search-input type="text" value="' + esc(_cur.query || '') + '" placeholder="artist album" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary">' +
'<button data-ip-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button>' +
'</div>' +
'<div data-ip-search-results class="flex flex-wrap gap-3"></div>' +
'</div>' +
'<div data-ip-status class="hidden text-xs text-fb-accent"></div>' +
'</div></div>' +
'<input type="file" accept="image/*" data-ip-file class="hidden">';
@@ -185,9 +196,47 @@
}
});
});
const searchInput = panel.querySelector('[data-ip-search-input]');
const runSearch = () => coverSearch(panel, (searchInput && searchInput.value) || '');
panel.querySelector('[data-ip-search-go]')?.addEventListener('click', runSearch);
searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); runSearch(); } });
panel.querySelector('[data-ip-close]')?.focus();
}
// Search Cover Art Archive (via the song-scoped cover-search endpoint) and
// render the album covers as pickable tiles — the same apply('url') path as
// the auto candidates. Covers with no CAA art self-hide (img onerror).
async function coverSearch(panel, query) {
const out = panel.querySelector('[data-ip-search-results]');
const fn = _cur && _cur.filename;
if (!out || !fn) return;
out.innerHTML = '<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + '</div>';
let body = null;
try {
const r = await fetch('/api/song/' + enc(fn) + '/art/cover-search?q=' + enc(String(query).trim()));
if (r.ok) body = await r.json();
} catch (_) { /* falls through to the empty state */ }
if (!_cur || _cur.filename !== fn) return; // closed / changed song while searching
const covers = (body && body.covers) || [];
if (!covers.length) {
out.innerHTML = '<div class="text-xs text-fb-textDim">' +
((body && body.error) ? 'Cover search is unavailable right now.' : 'No covers found — try a different search.') +
'</div>';
return;
}
out.innerHTML = covers.map((c, i) =>
tileHtml('data-ip-cover="' + i + '"', imgFace(c.thumb_url), c.label || 'Cover')).join('');
out.querySelectorAll('[data-ip-cover]').forEach((btn) => {
const img = btn.querySelector('img');
if (img) img.onerror = () => btn.classList.add('hidden'); // no CAA art for this album → hide
btn.addEventListener('click', () => {
if (_busy) return;
const c = covers[Number(btn.getAttribute('data-ip-cover'))];
if (c) apply('url', c.thumb_url);
});
});
}
// The one candidates fetch, cancelled if the modal closes first. Failure
// (offline, demo mode, aborted) is silent: the skeletons just clear and
// the instant tiles remain — never an error wall.
@@ -281,7 +330,13 @@
const filename = opts && opts.filename;
if (!filename) return;
_lastFocus = document.activeElement;
_cur = { filename: filename, title: (opts && opts.title) || filename };
const title = (opts && opts.title) || filename;
const artist = (opts && opts.artist) || '';
const album = (opts && opts.album) || '';
// Pre-fill the cover search: "artist album" when the album is known, else
// just the artist, else the title — the server default backs it up.
const query = [artist, album].filter(Boolean).join(' ').trim() || title;
_cur = { filename: filename, title: title, query: query };
_busy = false;
const m = ensureModal();
const panel = document.getElementById('v3-imgpick-panel');
+6 -1
View File
@@ -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>
+20 -8
View File
@@ -462,7 +462,11 @@
// Each field sits on its pack value: editing above the pack makes it an
// override ("Yours"); a lock pins it so an auto-match can't recanonicalize
// it; revert (↺) drops back to the pack value.
const DETAIL_FIELDS = [['title', 'Title'], ['artist', 'Artist'], ['album', 'Album'], ['year', 'Year']];
const DETAIL_FIELDS = [['title', 'Title'], ['artist', 'Artist'], ['album', 'Album'], ['year', 'Year'], ['genre', 'Genre']];
// Only these four are written into the pack file; genre is a library-only
// overlay (drives the genre filter/facet + the auto-match lock), never baked
// to the file — so Write to file leaves genre's override in place.
const WRITE_FIELDS = ['title', 'artist', 'album', 'year'];
async function renderDetailsTab(body, song) {
body.innerHTML = '<div class="p-5"><p class="text-sm text-fb-textDim">Loading…</p></div>';
@@ -507,9 +511,17 @@
// then land on Details pre-filled for review.
async function useTheseValues(song, cand) {
if (!cand) return;
// Smart adopt for an English base: KEEP the readable name + title the card
// already shows (the author's romaji, e.g. "Junko Yagami / BAY CITY") — the
// match is often native script (kanji/kana). Take only what the pack lacks
// — album / year / genre — from the match; the pin below still brings the
// correct art + identity. The user can still edit any field.
song._pendingDetails = {
title: String(cand.title || ''), artist: String(cand.artist || ''),
album: String(cand.album || ''), year: String(cand.year || ''),
artist: String(song.artist || cand.artist || ''),
title: String(song.title || cand.title || ''),
album: String(cand.album || song.album || ''),
year: String(cand.year || song.year || ''),
genre: String((Array.isArray(cand.genres) && cand.genres[0]) || cand.genre || ''),
};
try {
await post('/api/enrichment/review/' + enc(song.filename) + '/pick', { candidate: cand });
@@ -547,7 +559,7 @@
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0">' +
'<div class="flex items-start gap-3">' +
'<img src="' + esc(artUrl(song)) + '" alt="" onerror="this.style.visibility=\'hidden\'" class="w-14 h-14 rounded-lg object-cover bg-fb-card shrink-0">' +
'<p class="text-xs text-fb-textDim pt-1"><span class="text-fb-text">Save</span> keeps edits as a reversible library overlay — the song files aren\'t touched. <span class="text-fb-text">Write to file</span> bakes them into the pack itself. Lock a field to keep an auto-match from changing it.</p>' +
'<p class="text-xs text-fb-textDim pt-1"><span class="text-fb-text">Save</span> keeps edits as a reversible library overlay — the song files aren\'t touched. <span class="text-fb-text">Write to file</span> bakes the title, artist, album and year into the pack (genre stays a library-only tag). Lock a field to keep an auto-match from changing it.</p>' +
'</div>' +
DETAIL_FIELDS.map(row).join('') +
'<p data-df-status class="text-xs leading-relaxed"></p>' +
@@ -617,7 +629,7 @@
async function writeToFile(body, song) {
const st = song._detailsState;
const fields = {};
for (const [f] of DETAIL_FIELDS) fields[f] = String(st[f].value || '').trim();
for (const f of WRITE_FIELDS) fields[f] = String(st[f].value || '').trim();
const status = body.querySelector('[data-df-status]');
const writeBtn = body.querySelector('[data-df-write]');
const saveBtn = body.querySelector('[data-df-save]');
@@ -650,11 +662,11 @@
const yr = /^[+-]?\d+$/.test(applied.year) ? parseInt(applied.year, 10) : 0;
applied.year = yr ? String(yr) : '';
}
for (const [f] of DETAIL_FIELDS) song[f] = applied[f];
for (const f of WRITE_FIELDS) song[f] = applied[f];
try { window.feedBack?.emit('library:changed', { reason: 'write' }); } catch (_) { }
if (persisted) {
const clear = {};
for (const [f] of DETAIL_FIELDS) clear[f] = { value: null, locked: !!st[f].locked };
for (const f of WRITE_FIELDS) clear[f] = { value: null, locked: !!st[f].locked };
try {
await fetch('/api/song/' + enc(song.filename) + '/overrides', {
method: 'PUT',
@@ -684,7 +696,7 @@
'</div>';
body.querySelector('[data-cover-open]')?.addEventListener('click', () => {
if (window.__fbOpenImagePicker) {
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
}
});
}
+1 -1
View File
@@ -42,7 +42,7 @@
id: 'instrument', shape: 'spotlight', position: 'bottom',
selector: '#v3-instrument-wrap', waitFor: '#v3-instrument-wrap',
title: 'Choose your instrument',
content: 'Set your instrument, string count and tuning here. The highway, tuner and scoring all adapt to this selection.',
content: 'Set your instrument, string count and tuning here — and if you play left-handed, flip Handedness to Left so the whole highway mirrors. The highway, tuner and scoring all adapt to this selection.',
},
{
id: 'tuner', shape: 'spotlight', position: 'bottom',
+27 -13
View File
@@ -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: [],
};
}
}
@@ -515,15 +515,22 @@
done: ['bg-fb-good/90 text-black', '✓ Updated', ''],
nochange: ['bg-black/60 text-fb-textDim', '— No match', ''],
// Resting indicator: subtle, so a mostly-unmatched library isn't a
// wall of loud badges; points at the manual fix.
nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'No metadata match found — right-click to fix it by hand'],
// wall of loud badges. Clickable — a one-click handoff into the
// Fix-metadata popup for this song (see the [data-meta-fix] wiring).
nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'Click to fix the metadata by hand'],
};
const conf = M[st] || M.queued;
const fixable = st === 'nomatch'; // resting badge → opens Fix-metadata
// top-10 clears the tuning chip (top-2) in both normal and select mode;
// z-20 sits it above the art. Non-interactive so it never eats a click.
return '<span class="v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] +
' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight pointer-events-none"' +
(conf[2] ? ' title="' + conf[2] + '"' : '') + '>' + conf[1] + '</span>';
// z-20 sits it above the art. Batch states are non-interactive; the
// resting "no match" badge is the handoff into the popup.
const cls = 'v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] +
' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight ' +
(fixable ? 'pointer-events-auto cursor-pointer hover:bg-fb-primary hover:text-white transition-colors' : 'pointer-events-none');
return '<span class="' + cls + '"' +
(fixable ? ' data-meta-fix="1"' : '') +
(conf[2] ? ' title="' + conf[2] + '"' : '') +
'>' + conf[1] + '</span>';
}
// After a song is scored, the badge for that card is stale until the next
@@ -1000,7 +1007,7 @@
// the group's work_key/chart_count and pre-ticks the shown chart.)
if (id === '__fixmatch') { if (window.__fbFixMatch) window.__fbFixMatch(playTarget); return; }
if (id === '__cover') {
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename });
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename, artist: playTarget.artist, album: playTarget.album });
return;
}
if (id === '__refreshmeta') {
@@ -1467,6 +1474,13 @@
e.stopPropagation();
openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song);
});
// "No match" badge → straight into the Fix-metadata popup for this
// song (the batch → fix handoff). stopPropagation so it doesn't also
// trigger the card's play. Follows the displayed chart, like the menu.
el.querySelector('[data-meta-fix]')?.addEventListener('click', (e) => {
e.stopPropagation();
if (window.__fbFixMatch) window.__fbFixMatch(playTarget);
});
// Artist line → the artist page (PR-B). In select mode the grid's
// capture-phase toggle intercepts first, so selection still wins.
el.querySelector('[data-v3-artist]')?.addEventListener('click', (e) => {
@@ -2807,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('')) +
@@ -3112,7 +3126,7 @@
// when image-picker.js isn't loaded.
artWrap.addEventListener('click', () => {
if (window.__fbOpenImagePicker) {
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
} else {
artFile.click();
}
+47
View File
@@ -0,0 +1,47 @@
// Pins the onboarding handedness control: a Right/Left choice lives in the
// instrument selector (the "Choose your instrument" onboarding step, which the
// tour spotlights BEFORE the tuner/audio-calibration steps) and writes the
// highway 'lefty' preference. Source-level, matching the other tests/js/
// browser-heavy regression guards (the runtime path is DOM/WebGL-heavy).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const BADGES = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'badges.js'), 'utf8');
const TOUR = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'onboarding-tour.js'), 'utf8');
test('instrument selector offers a Handedness Right/Left choice', () => {
assert.match(BADGES, /instRow\('Handedness'/, 'a Handedness row must be in the instrument menu');
assert.match(BADGES, /pill\('hand',\s*'right'/, 'Right handedness pill');
assert.match(BADGES, /pill\('hand',\s*'left'/, 'Left handedness pill');
});
test('clicking a handedness pill writes the lefty preference from its value', () => {
assert.match(
BADGES,
/\[data-pill="hand"\][\s\S]*?_setLeftyPref\(\s*b\.getAttribute\('data-val'\)\s*===\s*'left'\s*\)/,
'the handedness click handler sets lefty from the pill value');
});
test('_setLeftyPref prefers highway.setLefty and falls back to the lefty localStorage key', () => {
const setter = BADGES.match(/function _setLeftyPref\(on\)\s*\{[\s\S]*?\n \}/);
assert.ok(setter, '_setLeftyPref must exist');
assert.match(setter[0], /highway\.setLefty/, 'prefers highway.setLefty (flips a live highway + persists)');
assert.match(setter[0], /localStorage\.setItem\('lefty'/, 'falls back to the lefty localStorage key the highway reads on init');
});
test('_leftyPref reads highway.getLefty with a localStorage fallback', () => {
assert.match(
BADGES,
/function _leftyPref\(\)\s*\{[\s\S]*?getLefty[\s\S]*?localStorage\.getItem\('lefty'\)/,
'_leftyPref reads the current handedness with a storage fallback');
});
test('onboarding instrument step calls out left-handed players + the Handedness control', () => {
assert.match(TOUR, /Choose your instrument/);
assert.match(TOUR, /left-handed/i, 'the instrument step must call out left-handed players');
assert.match(TOUR, /Handedness/, 'and name the Handedness control');
});
+47
View File
@@ -0,0 +1,47 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
const m = src.match(/\/\* @pure:editor-pending-view:start \*\/[\s\S]*?\/\* @pure:editor-pending-view:end \*\//);
if (!m) throw new Error('pending-view helper block not found');
const api = new Function('"use strict";' + m[0] + '\nreturn { _buildEditorPendingViewPure };')();
test('edit-region handoff defaults cursor to region start and marks return path', () => {
const out = api._buildEditorPendingViewPure('song.sloppak', 2, { a: 12.5, b: 20 }, { returnToHighway: true });
assert.deepStrictEqual(out, {
filename: 'song.sloppak',
arrangement: 2,
barSel: { startTime: 12.5, endTime: 20 },
returnToHighway: true,
cursorTime: 12.5,
});
});
test('return-trip handoff preserves explicit viewport state', () => {
const out = api._buildEditorPendingViewPure('song.sloppak', 1, { a: 8, b: 14 }, {
scrollX: -4,
zoom: 160,
cursorTime: 9.25,
});
assert.deepStrictEqual(out, {
filename: 'song.sloppak',
arrangement: 1,
barSel: { startTime: 8, endTime: 14 },
cursorTime: 9.25,
scrollX: 0,
zoom: 160,
});
});
test('missing region still produces a stable pending view shell', () => {
const out = api._buildEditorPendingViewPure('song.sloppak', -1, null, {});
assert.deepStrictEqual(out, {
filename: 'song.sloppak',
arrangement: 0,
barSel: null,
});
});
+45
View File
@@ -0,0 +1,45 @@
// Contract test: 3D Highway WebGL context-loss recovery.
//
// Switching the active window / alt-tabbing (especially on Windows) can trigger
// a GPU context reset. Without a handler the lost WebGL context escalates into a
// render-process crash. The renderer owns its own WebGL canvas + heavy Three.js
// lifecycle (too much to construct in a vm sandbox), so — like the other
// highway_* source-contract tests here — this pins the wiring at the source
// level: the loss must be preventDefault()'d (so the browser restores it), draw
// must bail while lost, and the listeners must be torn down.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
test('binds webglcontextlost + webglcontextrestored on the renderer canvas', () => {
assert.match(src, /ren\.domElement\.addEventListener\(\s*['"]webglcontextlost['"]/,
'must listen for webglcontextlost on ren.domElement (the WebGL canvas)');
assert.match(src, /ren\.domElement\.addEventListener\(\s*['"]webglcontextrestored['"]/,
'must listen for webglcontextrestored on ren.domElement');
});
test('the context-lost handler preventDefaults and pauses drawing', () => {
// Without preventDefault() the browser will not attempt to restore the
// context and the loss can escalate to a renderer crash.
const m = src.match(/_onCtxLost\s*=\s*\(e\)\s*=>\s*\{[\s\S]*?\};/);
assert.ok(m, '_onCtxLost handler must exist');
assert.match(m[0], /preventDefault\(\)/, 'context-lost handler must call preventDefault()');
assert.match(m[0], /_ctxLost\s*=\s*true/, 'context-lost handler must set _ctxLost = true');
});
test('draw() early-returns while the context is lost', () => {
assert.match(src, /draw\(bundle\)\s*\{[\s\S]*?if\s*\(_ctxLost\)\s*return;/,
'draw() must bail while _ctxLost is set so no GL work runs on a dead context');
});
test('teardown removes the context-loss listeners', () => {
assert.match(src, /removeEventListener\(\s*['"]webglcontextlost['"]/,
'teardown must remove the webglcontextlost listener');
assert.match(src, /removeEventListener\(\s*['"]webglcontextrestored['"]/,
'teardown must remove the webglcontextrestored listener');
});
+42
View File
@@ -209,6 +209,48 @@ def test_route_strips_private_sort_title(client, server):
assert "_sort_title" not in row # private keyset stash never leaks to the client
def test_genre_override_drives_facet_and_filter(client, server):
# a.archive: pack genre "Rock"; b.archive: blank genre, overridden to "City Pop".
_put(server, "a.archive", title="A", genre="Rock")
_put(server, "b.archive", title="B", genre="")
server.meta_db.set_song_override("b.archive", "genre", value="City Pop")
# Facet lists the EFFECTIVE genres (override surfaces; empty raw doesn't).
genres = client.get("/api/library/genres").json()["genres"]
assert "City Pop" in genres and "Rock" in genres
# Filtering by the override genre returns the overridden song…
fns = [s["filename"] for s in client.get("/api/library?genre=City%20Pop").json()["songs"]]
assert fns == ["b.archive"]
# …and its raw (blank) genre no longer matches a stale query for it.
rock = [s["filename"] for s in client.get("/api/library?genre=Rock").json()["songs"]]
assert rock == ["a.archive"]
def test_lock_only_genre_does_not_change_facet(server):
# A pure lock (no value) must not invent an effective genre.
_put(server, "a.archive", title="A", genre="Metal")
server.meta_db.set_song_override("a.archive", "genre", locked=True)
assert server.meta_db._has_genre_overrides() is False # value-less rows don't count
assert server.meta_db._effective_genre_expr() == "genre"
def test_romaji_fallback_for_blank_artist_pack(server):
fn = "CDLC/0 - City Pop/Junko-Yagami_BAY-CITY_v1_p.feedpak"
_put(server, fn, title="Junko-Yagami_BAY-CITY_v1_p", artist="") # scanner fell back to the filename
s = {x["filename"]: x for x in server.meta_db.query_page()[0]}[fn]
# the grid shows the author's romaji, not blank / the raw filename / kanji
assert s["artist"] == "Junko Yagami"
assert s["title"] == "BAY CITY"
# the Details baseline (pack_fields) matches, so the popup agrees with the grid
pack = server.meta_db.pack_fields(fn)
assert pack["artist"] == "Junko Yagami" and pack["title"] == "BAY CITY"
def test_romaji_fallback_left_alone_when_pack_has_artist(server):
_put(server, "a.archive", title="Real Title", artist="Real Artist")
s = {x["filename"]: x for x in server.meta_db.query_page()[0]}["a.archive"]
assert s["artist"] == "Real Artist" and s["title"] == "Real Title"
def test_title_keyset_paging_is_complete_with_overrides(client, server):
# Raw titles A/B/C → title-sort order is A, B, C on the RAW column.
_put(server, "b.archive", title="B")
+41
View File
@@ -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.