Compare commits

...
Author SHA1 Message Date
Byron GamatosandGitHub 62df3aec2e Merge branch 'main' into perf/highway-sustain-glow-no-shadowblur 2026-07-06 09:45:05 +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
6397a959a4 feat(library): Match→Details "use these values" bridge (#779)
Connects the popup's two tabs. A match only improves the underlying canon+art;
by design it never silently re-titles the grid. This adds the explicit opt-in
path: each Match candidate (search or Identify-by-audio) gets a "Use these
values →" action that copies its title/artist/album/year into the Details tab
as pending (unsaved) inputs and lands you there for review — pinning the match
too so the art/canon follow. You then Save (overlay) or Write to file. Queue-
review candidates are unchanged (they still accept/pin on click).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:11:14 +02:00
5f499a8a3a feat(v3 library): "Write to file" in the Fix-metadata popup (#778)
* feat(library): "Write to file" in the Fix-metadata popup's Details tab

Completes the confirmed edit model: Save keeps edits as a reversible display
overlay (files untouched); "Write to file" bakes the shown title/artist/album/
year into the pack itself via the existing POST /api/song/{fn}/meta (writes the
manifest, re-stats, coalesces a rescan). On a real file write the now-redundant
override values are cleared (locks kept) and the tab re-renders, so the fields
read from the file as "Pack". Loose-folder / unwritable packs fall back to a
DB-only update and say so (may revert on a full rescan). Secondary button next
to Save; touches only the four file-safe fields, the rest of the pack verbatim.

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

* fix(library): mirror server year coercion in Write-to-file grid sync

update_song_meta coerces a non-numeric/empty year to "" before persisting,
but writeToFile optimistically set song.year to the raw typed text — so the
library card flashed e.g. "abcd" until the next natural refresh. Apply the
same integer coercion client-side so the in-memory song matches what was
written.

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 21:10:34 +02:00
af1170cec3 feat(v3 library): "Fix metadata" popup — per-song override + lock, cover picker, MusicBrainz + AcoustID (#777)
* feat(library): metadata override + lock store, enforced by enrichment (popup slices 1–2)

Backend foundation for the Fix-metadata popup. Not yet surfaced in the UI (the
display + 3-tab popup are the next slices); no PR until it's user-visible.

Slice 1 — the store:
- `song_field_override(filename, field, value, locked)` table: a reversible
  DISPLAY overlay (never written to the pack), filename-keyed so it survives a
  rescan (never purged by delete_missing) and is dropped only with the song.
- DB methods (partial upsert that drops empty+unlocked rows; batch map) +
  `GET`/`PUT /api/song/{fn}/overrides` (field allowlist title/artist/album/
  year/genre; clearing rides PUT since DELETE /api/song/{path} shadows sub-
  routes; PUT demo-blocked).

Slice 2 — locks respected by enrichment:
- The auto-matcher composes a per-song `_compose_lock_filter` onto the global
  apply-filter, so a match still applies IDENTITY (mbid/release → art) but never
  re-canonicalizes a LOCKED display field.
- Gap-fill (write-to-file) skips locked album/year/genre — writing the matched
  value would be exactly the clobber the lock exists to prevent.
- Review/manual picks bypass the filter (an explicit confirm overrides a lock).

Tests: store semantics + rescan-survival + API; the lock filter + reader; an
auto-match leaving a locked field un-canonicalized; gap-fill excluding locked
keys.

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

* feat(library): show per-song overrides in the grid (popup slice 3)

The grid now displays the user's per-song title/artist/album/year override
in place of the pack value ("grid shows only overrides") — a matched
MusicBrainz canon never silently re-titles a card; canon stays in the
Details drawer + art. Overlaid in Python over the visible window, keyset-safe
like the P4 artist-alias re-label: the seek still runs on the raw column, and
the one overridable keyset column (title) stashes its raw value for the cursor
so paging never skips/dupes. The private stash is dropped from the payload.

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

* feat(library): 3-tab Fix-metadata popup — Details / Cover art / Match (slice 4)

Turns the thin single-song fix-match modal into the Plex-style metadata
editor reached from a card's "Fix metadata…" menu:

- Details tab: type + lock the displayed title/artist/album/year. Values
  ride the reversible override store (GET/PUT /api/song/{fn}/overrides); each
  field sits on its pack value (Yours/Pack provenance + revert-to-pack), a lock
  pins it against auto-match, and Save repaints the grid via library:changed
  (slice-3 overlay). This is the real tool for the blank-artist city-pop pile
  MusicBrainz can't surface — you just type the right title.
- Cover art tab: hands off to the shared image picker (its own modal); the
  pick refreshes the thumbnail everywhere.
- Match tab: the existing MusicBrainz search + candidate/pick flow, refactored
  into shared body/footer helpers (the queue-review flow is untouched).

Backend: GET /overrides now also returns the pack baseline so the Details tab
can pre-fill + show provenance. tailwind.min.css regenerated (build-tailwind.sh)
for the popup's new utility classes.

Identify-by-audio (AcoustID) is deferred: it lives in unmerged PR #759, off
main — the Match tab gains the button once #759 lands and this branch rebases.

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

* fix(library): wire "Identify by audio" in the tabbed popup's Match tab

The AcoustID Identify button (#759) merged in referencing an out-of-scope
`panel` in the wiring — a leftover from the pre-popup fix-match modal that my
tab refactor renamed to `root`. Under strict mode that threw, so the handler
never attached and the button did nothing. Scope it to `root` (the tab body),
which is where the search-results area it renders into lives.

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

* fix(library): make "Identify by audio" outcomes unmistakable

An empty AcoustID result read the same as a broken button. Each state now says
plainly which outcome it is — ✓ fingerprinted-but-no-match vs no-audio vs off vs
unavailable — and, in the popup, points at the manual fallback (Search, or set
the album in Details + cover in Cover art by hand). A ✓ marks the states that
actually ran, so "worked, found nothing" no longer looks like a failure.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:09:38 +02:00
3e036e3db6 feat(v3 library): persistent "no match" badge + Unmatched quick filter (#781)
* feat(v3 library): persistent "no match" badge + Unmatched quick filter

The Refresh-Metadata batch (#764) shows a transient per-tile "no match" only
while a pass runs, so the unmatched pile goes quiet at rest. Two additions make
it visible + reachable:

- Persistent per-card "No match" badge: query_page now marks each row
  `unmatched` (a cheap failed-set membership like favs/estd), and enrichBadge
  paints a subtle resting marker for those cards — tracked in a `_unmatched` set
  so a batch tile clearing falls back to it instead of wiping it. A live batch
  tile still wins while a pass runs.
- "Unmatched" toolbar toggle (local-only): one click applies the same filter as
  the drawer's Match → Unmatched (match_state='failed'), so the no-match pile is
  a click away right after a batch. Re-queries + reflects active state.

Test: query_page flags a failed row + the match=unmatched filter returns it.

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

* fix(v3 library): repaint persistent no-match badge after metadata tile-clear

_clearMetaTiles removed every .v3-meta-tile node — including the new
persistent 'No match' resting badge, which derives from _unmatched rather
than _metaTile. A metadata rescan's tile-clear therefore dropped the badge
until the next scroll re-rendered the card. Repaint it from _unmatched.

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 21:00:30 +02:00
92dc321fdf feat(gp_autosync): piecewise time-warp helpers + refine_sync onset pass (#787)
* feat(gp_autosync): piecewise time-warp helpers + refine_sync onset pass

auto_sync computes per-bar sync points but consumers only ever applied the
scalar bar-1 audio_offset, so any tempo drift between the recording and the
tab's authored tempo accumulated over the song. Add the librosa-free helpers
needed to apply the full piecewise mapping:

- bar_start_times(gp_path): per-bar score times sharing auto_sync's axis
  (GPIF bar-resolution map, GP3/4/5 per-tick integration)
- build_warp_anchors(points, bar_starts): monotonic (score, audio) anchors
- warp_time(t, anchors): piecewise-linear map with edge-slope extrapolation
- warp_song_times(song, warp): retime a lib.song.Song in place (notes,
  sustains, chords, beats, sections, anchors, handshapes, phrase levels,
  tone changes, tempo overrides)
- gp_has_expandable_repeats(gp_path): detects GP3/4/5 repeat/volta/direction
  markup whose playback expansion auto_sync's as-written points cannot map

Also implement refine_sync() — the editor's refine-sync endpoint has imported
it since the snapshot but it never existed in lib, so the Refine button 500'd.
It densifies the DTW points to every Nth bar and re-times each with a local
onset phase sweep (radius clamped under half a beat to avoid one-beat locks,
short scoring grid + median residual snap against the first beats). Synthetic
click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input
across 117-123 BPM recordings of a 120 BPM tab.

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

* review: Copilot round 1 — normalize bar_start_times GP3/4/5 parse failures to ValueError, document ImportError

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:08:19 +02:00
74cff4e0d6 feat(enrichment): alias-aware scoring — auto-confirm non-Latin-primary artists (#772)
ship-ci / ci (push) Waiting to run
* feat(enrichment): loose MusicBrainz search fallback (find aliased artists)

The MB text search used a strict field-phrase query
(`recording:"<title>" AND artist:"<artist>"`). A field phrase only matches
MusicBrainz's *primary* artist/title — it never searches ALIASES — so a
recording stored under a non-Latin primary name (大橋純子) whose romanized
form ("Junko Ohashi") is only an alias returns ZERO results, even though MB
has it. Whole swaths of a community library (e.g. romanized J-pop / city-pop
charts) were unsearchable.

- `build_recording_query(..., loose=True)` drops the field scoping + phrases
  for plain AND-ed term groups (`(telephone number) AND (junko ohashi)`),
  which searches the whole document incl. aliases.
- `_mb_search_recordings` runs the strict query first (unchanged, high
  precision) and only on an EMPTY result retries once with the loose query —
  so mainstream matches are untouched and the extra throttled request is spent
  only on a miss. Results are re-scored by rank_candidates, so recall goes up
  without lowering match quality (auto-accept still needs the per-field floors).

Verified live: "Junko Ohashi / Telephone Number" and "Anri / Windy Summer"
(both 0 under the strict query) now surface the real records; "AC/DC /
Highway to Hell" still hits strict at score 1.0 with no loose retry.

Follow-up (separate): alias-aware SCORING so these can auto-confirm, not just
appear as manual candidates.

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

* feat(enrichment): alias-aware scoring (auto-confirm non-Latin-primary artists)

Builds on the loose-search fallback: that surfaces a recording stored under a
Japanese primary name (大橋純子) via its romanized alias, but the SCORER still
compared the reference ("Junko Ohashi") against the primary only → artist
similarity 0 → below the auto floor, so it could only ever be a manual
candidate, never an auto-fill.

- mb_match: `cand_artist_sim` takes the best similarity over the candidate's
  primary name AND its `artist_aliases`; score_candidate + classify use it.
- server: `_mb_artist_aliases(id)` fetches an artist's aliases (one throttled
  lookup, process-cached — a one-artist discography costs ONE request) and
  `_alias_enrich` attaches them ONLY to promising near-misses (title agrees,
  primary artist doesn't) so a normal pass spends zero extra requests. Wired
  into both the auto-matcher (_enrich_one) and the manual search proxy.

Verified live: "Junko Ohashi / Telephone Number" → 大橋純子 candidate goes from
score 0.5 (loose-only) to 1.0 (auto-confirmable), ranked #1; "AC/DC / Highway
to Hell" unchanged at 1.0 with no alias lookup.

Stacks on #771 (feat/mb-loose-search-fallback).

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

* fix(enrichment): keep live exclusion in the loose search fallback

The loose fallback dropped the strict path's -secondarytype:Live filter, so a
studio chart whose strict query missed could fall back to — and, since
score_candidate doesn't penalize live takes, auto-confirm — a live-only
recording. Apply the same live gate to the loose query (skipped only when the
source title is itself a live take).

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 01:11:38 +02:00
18c4e229e1 feat(enrichment): loose MusicBrainz search fallback (find aliased/romanized artists) (#771)
* feat(enrichment): loose MusicBrainz search fallback (find aliased artists)

The MB text search used a strict field-phrase query
(`recording:"<title>" AND artist:"<artist>"`). A field phrase only matches
MusicBrainz's *primary* artist/title — it never searches ALIASES — so a
recording stored under a non-Latin primary name (大橋純子) whose romanized
form ("Junko Ohashi") is only an alias returns ZERO results, even though MB
has it. Whole swaths of a community library (e.g. romanized J-pop / city-pop
charts) were unsearchable.

- `build_recording_query(..., loose=True)` drops the field scoping + phrases
  for plain AND-ed term groups (`(telephone number) AND (junko ohashi)`),
  which searches the whole document incl. aliases.
- `_mb_search_recordings` runs the strict query first (unchanged, high
  precision) and only on an EMPTY result retries once with the loose query —
  so mainstream matches are untouched and the extra throttled request is spent
  only on a miss. Results are re-scored by rank_candidates, so recall goes up
  without lowering match quality (auto-accept still needs the per-field floors).

Verified live: "Junko Ohashi / Telephone Number" and "Anri / Windy Summer"
(both 0 under the strict query) now surface the real records; "AC/DC /
Highway to Hell" still hits strict at score 1.0 with no loose retry.

Follow-up (separate): alias-aware SCORING so these can auto-confirm, not just
appear as manual candidates.

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

* fix(enrichment): keep live exclusion in the loose search fallback

The loose fallback dropped the strict path's -secondarytype:Live filter, so a
studio chart whose strict query missed could fall back to — and, since
score_candidate doesn't penalize live takes, auto-confirm — a live-only
recording. Apply the same live gate to the loose query (skipped only when the
source title is itself a live take).

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 01:09:48 +02:00
bde25c0bc8 fix(gpx): clamp partial final BCFS sector so GP6 .gpx import works (#749)
Every real Guitar Pro 6 (.gpx) file failed to import with
"GPX BCFS sector pointer out of range (malformed file)".

A real .gpx's BCFZ-declared decompressed size isn't 0x1000-aligned, so
its last (small) container file lands in a partial trailing sector.
_parse_bcfs raised whenever a sector read would run past the buffer
end, rejecting the whole container before score.gpif could be extracted
-- so no GP6 file could be charted in the song editor. (GP7/GP8 .gp
files take the ZIP path, not BCFS, which is why this wasn't caught
earlier.)

Clamp 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, so the
malformed-file guard is preserved.

Verified against two real GP6 files -- both now unpack to valid GPIF
with all tracks. Adds the previously-missing positive BCFS round-trip
coverage: partial-final-sector, multi-file, sector-aligned baseline,
and the preserved out-of-range guard.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-05 00:20:31 +02:00
Jafz2001andClaude Fable 5 326001d618 perf(highway): draw the held-sustain glow without ctx.shadowBlur
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>
2026-07-02 21:00:49 -04:00
26 changed files with 2633 additions and 144 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:
+8
View File
@@ -7,13 +7,21 @@ 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`.
### Changed
- **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`).
- **Edit Metadata now writes into `.feedpak` files, not just legacy `.sloppak` ones.** `lib/songmeta.py`'s suffix gate predated the format rename — core reads both suffixes everywhere else (`sloppak.SONG_EXTS`), but the metadata writer only dispatched on `.sloppak`, so editing a zip-form `.feedpak`'s title/artist/album/year silently fell back to a DB-only update. That looked fine until the next **full library rescan** re-derived metadata from the file and reverted the edit (directory-form packages were unaffected — they dispatch on manifest presence, not suffix). The gate now accepts both package suffixes. Tests: `tests/test_songmeta.py` `TestWriteSongMetadata` (both zip suffixes, mixed-case suffix, directory form, unknown-suffix fallback).
+15 -3
View File
@@ -121,10 +121,18 @@ def _parse_bcfs(bcfs: bytes) -> dict:
while sc <= max_sectors:
s = _gi(po + 4 * sc); sc += 1
if s == 0: break
so = s * SECTOR
if HDR + so + SECTOR > len(data):
start = HDR + s * SECTOR
# Real .gpx files' final sector is a few bytes short of a full
# 0x1000 block: the BCFZ-declared decompressed size isn't
# sector-aligned, so the last (small) container file lands in a
# partial trailing sector. Clamp the read to the buffer end —
# the per-file size field (`fs`, applied below) trims any
# padding — matching canonical GPX readers (alphaTab /
# PyGuitarPro slice-and-clamp). Only a sector whose *start* is
# past the end is genuinely malformed.
if start < 0 or start >= len(data):
raise ValueError("GPX BCFS sector pointer out of range (malformed file)")
fb.extend(data[HDR + so: HDR + so + SECTOR])
fb.extend(data[start: min(start + SECTOR, len(data))])
else:
raise ValueError("GPX BCFS sector chain too long (malformed file)")
files[fn] = bytes(fb[:fs])
@@ -1498,6 +1506,10 @@ def convert_file(
# Surface that to the caller rather than only the docstring: if the score
# actually uses repeats, the produced bar count/timing will differ from the
# equivalent .gp5. Warn once so plugin code/logs don't silently drift.
# NB: lib/gp_autosync.gp_has_expandable_repeats() encodes this single-pass
# behaviour (.gp/.gpx never expand). Implementing GPIF expansion here MUST
# update that helper in the same change, or the editor's per-bar sync warp
# would silently retime repeated sections onto the wrong bars.
if expand_repeats and any(
mb.find('Repeat') is not None or mb.find('AlternateEndings') is not None
for mb in masterbars
+524 -29
View File
@@ -18,8 +18,22 @@ plugin is installed; graceful ImportError otherwise with clear message).
Public API:
is_available() -> bool
auto_sync(gp_path, audio_path, ...) -> GpSyncData
refine_sync(sync, audio_path, ...) -> GpSyncData
estimate_audio_offset(gp_path,
audio_path) -> float
bar_start_times(gp_path) -> list[float]
gp_has_expandable_repeats(gp_path) -> bool
build_warp_anchors(sync_points,
bar_starts) -> list[tuple[float, float]]
warp_time(t, anchors) -> float
warp_song_times(song, warp) -> None
The warp helpers (bar_start_times / build_warp_anchors / warp_time /
warp_song_times) are librosa-free: they turn a GpSyncData produced by
auto_sync (or extracted from a GP8 file) into a piecewise-linear
score-time -> audio-time mapping and apply it to a lib.song.Song, so
converted charts follow the recording's actual tempo drift instead of a
single scalar offset.
"""
from __future__ import annotations
@@ -353,15 +367,22 @@ def _synthesise_score_chroma(
return chroma
_GP345_TICKS_PER_QUARTER = 960
# PyGuitarPro absolute ticks start at quarterTime (measure 1 begins at tick
# 960, not 0). All tick math in this module runs on a 0-based axis (cumulative
# measure starts), so raw beat.start values must be shifted by this origin —
# mixing the two axes applied every mid-song tempo change a quarter note late
# and skewed the synthesised chroma against the bar timeline.
_GP345_TICK_ORIGIN = 960
def _gp345_tempo_events(song) -> list[tuple[int, float]]:
"""Sorted, tick-deduplicated ``[(tick, bpm)]`` tempo events for a GP3/4/5 song.
Seeds with the song's initial tempo at tick 0, then appends every
``mixTableChange`` tempo. Shared by chroma synthesis and bar-time
computation so both use one identical tempo model (mirrors
``gp2rs._build_tempo_map``).
``mixTableChange`` tempo. Ticks are normalised to the 0-based axis
(raw ``beat.start`` minus ``_GP345_TICK_ORIGIN``). Shared by chroma
synthesis and bar-time computation so both use one identical tempo
model (mirrors ``gp2rs._build_tempo_map``).
"""
events: list[tuple[int, float]] = [(0, float(song.tempo))]
for track in song.tracks:
@@ -371,7 +392,10 @@ def _gp345_tempo_events(song) -> list[tuple[int, float]]:
if beat.effect and beat.effect.mixTableChange:
mtc = beat.effect.mixTableChange
if mtc.tempo and mtc.tempo.value > 0:
events.append((beat.start, float(mtc.tempo.value)))
events.append((
max(0, beat.start - _GP345_TICK_ORIGIN),
float(mtc.tempo.value),
))
events.sort(key=lambda e: e[0])
seen_ticks: set[int] = set()
unique: list[tuple[int, float]] = []
@@ -459,8 +483,9 @@ def _synthesise_score_chroma_gp345(
for beat in voice.beats:
if not beat.notes:
continue
beat_secs = tick_to_secs(beat.start)
cur_tempo = tempo_at_tick(beat.start)
beat_tick = max(0, beat.start - _GP345_TICK_ORIGIN)
beat_secs = tick_to_secs(beat_tick)
cur_tempo = tempo_at_tick(beat_tick)
dur_secs = duration_to_secs(beat.duration, cur_tempo)
for note in beat.notes:
@@ -513,13 +538,75 @@ 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 ──────────────────────────────────────
def _gpif_bar_starts(root: ET.Element) -> list[float]:
"""Score-time (seconds) at the start of each masterbar in a GPIF score.
Integrates bar durations from the bar-resolution tempo map and each
masterbar's time signature — the same time model _synthesise_score_chroma
uses, so bar times land where the bars sit in the synthesised chroma.
"""
tempo_map = _get_tempo_map(root)
masterbars = _children(root, 'MasterBars')
tempo_iter = iter(tempo_map)
next_tb, next_bpm = next(tempo_iter, (999999, tempo_map[0][1]))
ct = tempo_map[0][1]
t_cur = 0.0
bar_starts: list[float] = []
for mb_idx, mb in enumerate(masterbars):
while mb_idx >= next_tb:
ct = next_bpm
next_tb, next_bpm = next(tempo_iter, (999999, ct))
bar_starts.append(t_cur)
ts = mb.findtext('Time', '4/4')
try:
n_b, d_b = [int(x) for x in ts.split('/')]
except ValueError:
n_b, d_b = 4, 4
t_cur += n_b * (4.0 / d_b) * (60.0 / ct)
return bar_starts
def _gp345_measure_start_ticks(song) -> list[int]:
"""Cumulative start tick of each measure in a PyGuitarPro song."""
starts: list[int] = []
cum = 0
for mh in song.measureHeaders:
starts.append(cum)
ts = mh.timeSignature
cum += int(ts.numerator * (4.0 / ts.denominator.value) * _GP345_TICKS_PER_QUARTER)
return starts
def _extract_sync_points(
wp: 'np.ndarray',
root: ET.Element,
@@ -565,22 +652,7 @@ def _extract_sync_points(
if bar_starts_override is not None:
bar_starts_score = list(bar_starts_override)
else:
tempo_iter = iter(tempo_map)
next_tb, next_bpm = next(tempo_iter, (999999, tempo_map[0][1]))
ct = tempo_map[0][1]
t_cur = 0.0
bar_starts_score = []
for mb_idx, mb in enumerate(masterbars):
while mb_idx >= next_tb:
ct = next_bpm
next_tb, next_bpm = next(tempo_iter, (999999, ct))
bar_starts_score.append(t_cur)
ts = mb.findtext('Time', '4/4')
try:
n_b, d_b = [int(x) for x in ts.split('/')]
except ValueError:
n_b, d_b = 4, 4
t_cur += n_b * (4.0 / d_b) * (60.0 / ct)
bar_starts_score = _gpif_bar_starts(root)
# Map each sampled bar to its audio time via the DTW path
sync_points: list[SyncPoint] = []
@@ -663,6 +735,224 @@ def _tempo_at_bar(tempo_map: list[tuple[int, float]], bar: int) -> float:
# ── Audio offset estimation ───────────────────────────────────────────────────
# ── Piecewise time warp (librosa-free) ───────────────────────────────────────
#
# auto_sync's per-bar sync points describe where each sampled bar of the tab
# falls in the real recording. Applying only the scalar audio_offset (bar 1)
# assumes the recording holds the authored tempo for the whole song — any
# drift accumulates. These helpers build the full piecewise-linear
# score-time -> audio-time mapping and apply it to a converted Song, so the
# chart follows the recording bar by bar (Songsterr-style sync).
def bar_start_times(gp_path: str) -> list[float]:
"""Score-time (seconds) at the start of every bar of a GP file.
Uses the same tempo models as auto_sync's chroma synthesis (GPIF
bar-resolution map for .gp/.gpx, per-tick integration for .gp3/4/5), so
the returned times share an axis with auto_sync's sync points.
Raises ValueError if the file cannot be parsed, ImportError if the file
is GP3/4/5 and PyGuitarPro is not installed.
"""
try:
root = _load_gpif(gp_path)
except _Gp345FileError:
import guitarpro
try:
song = guitarpro.parse(gp_path)
except Exception as exc:
raise ValueError(f"Cannot parse GP3/4/5 file {gp_path!r}: {exc}") from exc
tempo_events = _gp345_tempo_events(song)
return [
_gp345_tick_to_secs(tempo_events, tick)
for tick in _gp345_measure_start_ticks(song)
]
return _gpif_bar_starts(root)
def gp_has_expandable_repeats(gp_path: str) -> bool:
"""True when converting `gp_path` expands repeats into a longer timeline
than the as-written score auto_sync aligned against.
gp2rs.convert_file walks the GP3/4/5 playback graph (repeat brackets,
voltas, D.S./D.C. directions), so a file using any of those produces an
as-performed timeline that auto_sync's as-written sync points cannot be
mapped onto. GPIF (.gp/.gpx) conversion is single-pass as-written today,
so those files always return False both sides share one bar order.
Returns False when the file cannot be parsed (callers fall back to
offset-only sync on parse failure anyway).
"""
if Path(gp_path).suffix.lower() in ('.gp', '.gpx'):
return False
try:
import guitarpro
song = guitarpro.parse(gp_path)
except Exception:
return False
for mh in song.measureHeaders:
if mh.isRepeatOpen or mh.repeatClose >= 0 or mh.repeatAlternative:
return True
# Both jump SOURCES (fromDirection: D.C., D.S., Da Coda) and jump
# TARGETS (direction: Segno, Coda, Fine) count — a plain Da Capo
# needs no target marker, so checking `direction` alone would miss
# it while gp2rs's playback walker still expands the jump.
if (getattr(mh, 'direction', None) is not None
or getattr(mh, 'fromDirection', None) is not None):
return True
return False
def build_warp_anchors(
sync_points: list[SyncPoint],
bar_starts: list[float],
) -> list[tuple[float, float]]:
"""Turn sync points into (score_secs, audio_secs) anchor pairs.
Drops points whose bar index is out of range, points that would break
strict monotonicity on either axis (DTW can locally fold on noisy audio;
a non-monotonic anchor would make the warp non-invertible and reorder
notes), and points whose segment slope implies a physically implausible
tempo ratio (outside 0.2x-5x authored). Returns [] when fewer than 2
usable anchors remain callers should fall back to scalar-offset sync
in that case.
"""
anchors: list[tuple[float, float]] = []
for sp in sorted(sync_points, key=lambda p: p.bar):
if not 0 <= sp.bar < len(bar_starts):
continue
score_t = bar_starts[sp.bar]
audio_t = float(sp.time_secs)
if anchors and (score_t <= anchors[-1][0] + 1e-6
or audio_t <= anchors[-1][1] + 1e-3):
continue
if anchors:
# Slope sanity gate: a segment whose audio/score tempo ratio is
# outside [0.2, 5] is not a performance — it's a DTW fold onto a
# repeated section, an abridged recording, or a run of
# monotonicity-clamped refine points. Keeping it would crush (or
# absurdly stretch) every bar in the span, which is far worse
# than interpolating through from the neighbouring anchors.
slope = (audio_t - anchors[-1][1]) / (score_t - anchors[-1][0])
if not 0.2 <= slope <= 5.0:
continue
anchors.append((score_t, audio_t))
return anchors if len(anchors) >= 2 else []
def warp_time(t: float, anchors: list[tuple[float, float]]) -> float:
"""Map a score-time (seconds) to audio-time via piecewise-linear anchors.
Between anchors: linear interpolation. Outside the anchor range: the
nearest segment's slope is extended, so a count-in before bar 1 and the
tail after the last sampled bar keep the local tempo ratio.
`anchors` must be the >=2-point strictly-monotonic list produced by
build_warp_anchors.
"""
lo = 0
hi = len(anchors) - 1
if t <= anchors[0][0]:
seg = (anchors[0], anchors[1])
elif t >= anchors[hi][0]:
seg = (anchors[hi - 1], anchors[hi])
else:
# Binary search for the segment containing t
while hi - lo > 1:
mid = (lo + hi) // 2
if anchors[mid][0] <= t:
lo = mid
else:
hi = mid
seg = (anchors[lo], anchors[hi])
(s0, a0), (s1, a1) = seg
slope = (a1 - a0) / (s1 - s0)
return a0 + (t - s0) * slope
def warp_song_times(song, warp) -> None:
"""Apply a monotonic time-mapping callable to every absolute time in a
lib.song.Song, in place.
Covers beats, sections, song_length, and per-arrangement notes (onset +
sustain), chords (incl. chord notes), anchors, hand shapes, per-phrase
difficulty levels, tone changes, and tempo overrides. Durations (note
sustain, handshape span) are warped as end-start so they stretch with the
local tempo ratio; sub-second intra-note envelopes (bend curves, which are
relative to the note onset) are left untouched.
Duck-typed: accepts any object with the lib.song.Song surface.
Identity-safe: parse_arrangement shares the SAME Note/Chord/Anchor/
HandShape objects between the flat arrangement lists and the
max-difficulty phrase level, so each object is warped at most once no
matter how many containers reference it.
"""
seen: set[int] = set()
def _once(obj) -> bool:
key = id(obj)
if key in seen:
return False
seen.add(key)
return True
def _warp_notes(notes):
for n in notes or []:
if not _once(n):
continue
end = warp(n.time + n.sustain)
n.time = warp(n.time)
n.sustain = max(0.0, end - n.time)
def _warp_chords(chords):
for c in chords or []:
if not _once(c):
continue
c.time = warp(c.time)
_warp_notes(c.notes)
def _warp_anchors(anchors):
for a in anchors or []:
if _once(a):
a.time = warp(a.time)
def _warp_handshapes(shapes):
for h in shapes or []:
if not _once(h):
continue
start = warp(h.start_time)
end = warp(h.end_time)
h.start_time = start
h.end_time = max(start, end)
song.song_length = max(0.0, warp(song.song_length))
for b in song.beats:
b.time = warp(b.time)
for s in song.sections:
s.start_time = warp(s.start_time)
for arr in song.arrangements:
_warp_notes(arr.notes)
_warp_chords(arr.chords)
_warp_anchors(arr.anchors)
_warp_handshapes(arr.hand_shapes)
for ph in arr.phrases or []:
ph.start_time = warp(ph.start_time)
ph.end_time = warp(ph.end_time)
for lvl in ph.levels or []:
_warp_notes(lvl.notes)
_warp_chords(lvl.chords)
_warp_anchors(lvl.anchors)
_warp_handshapes(lvl.hand_shapes)
if arr.tones and isinstance(arr.tones, dict):
for change in arr.tones.get('changes') or []:
if isinstance(change, dict) and isinstance(change.get('t'), (int, float)):
change['t'] = warp(float(change['t']))
for tempo_ev in arr.tempos or []:
if isinstance(tempo_ev, dict) and isinstance(tempo_ev.get('time'), (int, float)):
tempo_ev['time'] = warp(float(tempo_ev['time']))
def _estimate_audio_offset(
root: ET.Element,
audio_path: str,
@@ -932,12 +1222,7 @@ def auto_sync(
# below line up with the chroma timeline.
_tempo_events_gp345 = _gp345_tempo_events(_gp345x_song)
# Convert tick events to bar events using actual measure start ticks
_measure_starts = [] # cumulative tick at start of each bar
_cum = 0
for _mh2 in _gp345x_song.measureHeaders:
_measure_starts.append(_cum)
_ts = _mh2.timeSignature
_cum += int(_ts.numerator * (4.0 / _ts.denominator.value) * _GP345_TICKS_PER_QUARTER)
_measure_starts = _gp345_measure_start_ticks(_gp345x_song)
def _tick_to_bar(tick):
"""Return 0-based bar index for a given tick position."""
@@ -1024,6 +1309,216 @@ def auto_sync(
sync_points=sync_points,
)
def refine_sync(
sync: GpSyncData,
audio_path: str,
bars_per_point: int = 8,
gp_path: str | None = None,
sr: int = _SR,
search_radius: float = 0.35,
phase_step: float = 0.005,
onset_tolerance: float = 0.05,
) -> GpSyncData:
"""Refine coarse DTW sync points with a per-bar onset phase sweep.
auto_sync's mid-song points inherit the DTW frame granularity (~186ms at
the default hop). This pass re-times a denser grid of bars every
`bars_per_point`-th bar plus the first and last by sweeping a local
beat grid (±`search_radius`s in `phase_step` steps) against detected
onsets and keeping the phase that aligns best, narrowing each kept point
to roughly the phase-step resolution on percussive material.
Args:
sync: Coarse sync data from auto_sync (or a prior refine).
audio_path: The same audio file auto_sync aligned against.
bars_per_point: Refined-point density; every Nth bar gets a point.
gp_path: Optional path to the GP file. When given, exact
per-bar score times (bar_start_times) drive the
densified grid; without it the grid is limited to
a 4/4 approximation built from the points' authored
tempos, and accuracy degrades on odd meters.
sr: Analysis sample rate.
search_radius: ±seconds around each coarse estimate to sweep.
phase_step: Sweep resolution in seconds.
onset_tolerance: Max onset-to-click distance that counts as aligned.
Returns:
A new GpSyncData with the refined (and usually denser) points and a
recomputed audio_offset. Returns `sync` unchanged when it has no
usable points. Quiet bars (fewer than 4 onsets nearby) keep their
coarse interpolated time rather than locking onto noise.
"""
if not sync.sync_points:
return sync
pts = sorted(sync.sync_points, key=lambda p: p.bar)
bar_starts: list[float] | None = None
if gp_path:
try:
bar_starts = bar_start_times(gp_path)
except Exception as exc:
_log.warning("refine_sync: bar_start_times(%s) failed (%s) — "
"falling back to 4/4 tempo model", gp_path, exc)
if bar_starts is None:
# Approximate score bar starts from the points' authored tempos,
# assuming 4 beats per bar (all GpSyncData carries without the file).
max_bar = pts[-1].bar
bar_starts = [0.0]
ti = 0
cur_bpm = pts[0].original_tempo or 120.0
for b in range(1, max_bar + 1):
while ti + 1 < len(pts) and pts[ti + 1].bar <= b - 1:
ti += 1
cur_bpm = pts[ti].original_tempo or cur_bpm
bar_starts.append(bar_starts[-1] + 4 * 60.0 / max(cur_bpm, 1e-3))
anchors = build_warp_anchors(pts, bar_starts)
if len(anchors) < 2:
_log.warning("refine_sync: fewer than 2 usable anchors — returning "
"input unchanged")
return sync
# Authored-tempo lookup via the shared bar-map scan (_tempo_at_bar) so
# boundary semantics can't drift from the rest of the module.
_orig_map = [(p.bar, p.original_tempo or 120.0) for p in pts]
def _orig_bpm_at(bar: int) -> float:
return max(_tempo_at_bar(_orig_map, bar), 1e-3)
n_bars = len(bar_starts)
step = max(1, int(bars_per_point))
targets = sorted(set(range(0, n_bars, step)) | {n_bars - 1})
# Deferred past the pure early-return paths above so degenerate inputs
# (no points, <2 anchors) resolve without librosa installed.
import librosa
import numpy as np
y, _ = librosa.load(audio_path, sr=sr, mono=True)
audio_dur = len(y) / sr
hop = 512 # ~23ms at 22050Hz — fine enough for onset timing
onset_frames = librosa.onset.onset_detect(
y=y, sr=sr, hop_length=hop, backtrack=True
)
onset_times = np.asarray(
librosa.frames_to_time(onset_frames, sr=sr, hop_length=hop)
)
refined: list[tuple[int, float]] = []
for b in targets:
score_t = bar_starts[b]
coarse = warp_time(score_t, anchors)
if coarse > audio_dur + 1.0:
break # bar falls past the end of the recording
# Local beat period in AUDIO time: authored beat period scaled by the
# local warp slope (recording tempo / authored tempo around this bar).
slope = warp_time(score_t + 1.0, anchors) - coarse
slope = min(max(slope, 0.25), 4.0)
beat_period = (60.0 / _orig_bpm_at(b)) * slope
# Keep the scoring grid short: beat_period is estimated from the
# coarse anchors (a few % off), and grid drift grows linearly with
# distance — 16 beats at 2% error is already ~150ms of skew at the
# far end, which drags the sweep. 8 beats bounds that to ~beat noise.
grid_span = 8 * beat_period
# Clamp the sweep window below half a beat so the neighbouring beat
# is never a candidate — on periodic material (steady drums) a grid
# shifted by one whole beat scores identically and the sweep could
# lock a full beat off. DTW coarse error is ~1 analysis frame, which
# this window still covers at all but extreme tempos.
radius = min(search_radius, 0.45 * beat_period)
w_lo = coarse - radius - onset_tolerance
w_hi = coarse + radius + grid_span + onset_tolerance
local = onset_times[(onset_times >= w_lo) & (onset_times <= w_hi)]
if len(local) < 4:
refined.append((b, coarse))
continue
best_t, best_score, best_dist = coarse, -1, 0.0
for phase in np.arange(coarse - radius, coarse + radius + 1e-9,
phase_step):
clicks = np.arange(phase, phase + grid_span, beat_period)
score = int(sum(
1 for t in local
if float(np.min(np.abs(clicks - t))) < onset_tolerance
))
dist = abs(float(phase) - coarse)
# Ties break toward the coarse estimate so a flat score surface
# (sustained pads, sparse onsets) can't drag the point sideways.
if score > best_score or (score == best_score and dist < best_dist):
best_score, best_t, best_dist = score, float(phase), dist
# A sweep that matched almost nothing found a spurious edge
# alignment, not the beat grid — this happens when the true phase
# lies outside the (ambiguity-clamped) window, e.g. fast tempos
# where the DTW coarse error exceeds half a beat. Keeping the
# coarse estimate degrades gracefully instead of locking a
# fraction of a beat off.
if best_score < 3:
refined.append((b, coarse))
continue
# The onset-count score is flat within ±onset_tolerance of the true
# phase, so the sweep alone can be off by up to the tolerance. Snap
# inside that plateau: shift by the median residual between matched
# onsets and their nearest grid click. Only the first few beats
# count here — they are nearly insensitive to beat_period error,
# while far clicks would leak that error into the residuals.
if best_score > 0:
clicks = np.arange(best_t, best_t + 4 * beat_period + 1e-9,
beat_period)
residuals = []
for t in local:
d = clicks - float(t)
j = int(np.argmin(np.abs(d)))
if abs(d[j]) < onset_tolerance:
residuals.append(-float(d[j])) # onset minus click
if residuals:
best_t += float(np.median(residuals))
refined.append((b, best_t))
if not refined:
return sync
# Enforce monotonicity: a point refined earlier than its predecessor
# would fold the warp. Clamp to a small positive gap.
mono: list[tuple[int, float]] = []
prev_t: float | None = None
for b, t in refined:
t = max(t, 0.0)
if prev_t is not None and t <= prev_t + 0.02:
t = prev_t + 0.02
mono.append((b, t))
prev_t = t
# Recompute per-segment modified tempos from the refined times (same
# formula _extract_sync_points uses; the last point carries the previous
# segment's tempo forward).
new_points: list[SyncPoint] = []
for i, (b, t) in enumerate(mono):
obpm = _orig_bpm_at(b)
if i + 1 < len(mono):
b2, t2 = mono[i + 1]
score_seg = bar_starts[b2] - bar_starts[b]
audio_seg = t2 - t
mod = obpm * (score_seg / audio_seg) if audio_seg > 1e-3 else obpm
mod = max(20.0, min(300.0, mod))
else:
mod = new_points[-1].modified_tempo if new_points else obpm
new_points.append(SyncPoint(
bar=b, time_secs=t, modified_tempo=mod, original_tempo=obpm,
))
_log.info("refine_sync: %d points (was %d), audio_offset=%.3fs",
len(new_points), len(pts), -new_points[0].time_secs)
return GpSyncData(
audio_offset=-new_points[0].time_secs,
audio_asset_id=sync.audio_asset_id,
sync_points=new_points,
)
def estimate_audio_offset(gp_path: str, audio_path: str) -> float:
"""
Estimate the audio_offset for a GP file aligned to an audio file.
+48 -4
View File
@@ -144,12 +144,31 @@ def _duration_int(v):
return None
def cand_artist_sim(song: dict, cand: dict) -> float:
"""Best artist similarity between the song's reference artist and the
candidate's PRIMARY name OR any of its `artist_aliases` (romanized/alternate
names). MusicBrainz stores many artists under a non-Latin primary name
(大橋純子) with the romanized form ("Junko Ohashi") only as an alias, so a
reference typed/derived in romaji scores 0 against the primary but 1.0
against the alias. The caller (server) attaches `artist_aliases` only for
promising near-misses, so this is a plain max when they're present and the
original single comparison when they're not."""
best = similarity(song.get("artist"), cand.get("artist"), artist=True)
for alias in cand.get("artist_aliases") or []:
if best >= 1.0:
break
s = similarity(song.get("artist"), alias, artist=True)
if s > best:
best = s
return best
def score_candidate(song: dict, cand: dict) -> float:
"""Combined confidence that MusicBrainz candidate `cand` is the song the
chart transcribes. 0.5*artist + 0.5*title, plus small year/duration
corroboration bonuses, capped at 1.0. Missing fields score 0 on their
half classify() separately refuses to auto-match without both."""
artist_sim = similarity(song.get("artist"), cand.get("artist"), artist=True)
artist_sim = cand_artist_sim(song, cand)
title_sim = similarity(song.get("title"), cand.get("title"))
score = 0.5 * artist_sim + 0.5 * title_sim
sy, cy = _year_int(song.get("year")), _year_int(cand.get("year"))
@@ -180,7 +199,7 @@ def classify(song: dict, cand: dict, score: float, auto_min: float | None = None
"""
if auto_min is None:
auto_min = AUTO_MIN
artist_sim = similarity(song.get("artist"), cand.get("artist"), artist=True)
artist_sim = cand_artist_sim(song, cand)
title_sim = similarity(song.get("title"), cand.get("title"))
if (score >= auto_min and artist_sim >= AUTO_ARTIST_MIN
and title_sim >= AUTO_TITLE_MIN):
@@ -234,12 +253,37 @@ def _lucene_escape_phrase(s: str) -> str:
_LIVE_GROUP_RE = re.compile(r"[(\[][^)\]]*\blive\b[^)\]]*[)\]]", re.IGNORECASE)
def build_recording_query(artist, title) -> str:
def build_recording_query(artist, title, *, loose: bool = False) -> str:
"""Lucene query for /ws/2/recording. Built from the DENOISED fields —
the noise we strip (author credits, "(Live)", "(v2)") would otherwise
poison the search server's own scoring."""
poison the search server's own scoring.
``loose=True`` drops the field-scoped quoted PHRASES for plain AND-ed
term groups (``(telephone number) AND (junko ohashi)``). The point:
a field phrase like ``artist:"Junko Ohashi"`` only matches MusicBrainz's
*primary* artist name it never searches ALIASES so a recording stored
under a non-Latin primary (大橋純子) whose romanized name is only an alias
is invisible to the strict query. A loose term query searches the whole
document, aliases included, and surfaces it. Lower precision by design: it
is a FALLBACK for when the strict query returns nothing, and its results
are re-scored by ``rank_candidates`` (and, for auto-match, gated by the
per-field floors), so noise never auto-applies."""
t = denoise(title)
a = denoise(artist)
if loose:
# denoise() already reduced each field to lowercase [a-z0-9 and] tokens
# (punctuation → spaces, diacritics stripped, & → "and"), so no
# Lucene-special character survives to need escaping. Group each field's
# terms and require both groups.
q = " AND ".join("(%s)" % g for g in (t, a) if g)
# Keep the SAME live exclusion as the strict path: the loose query is
# lower-precision, and score_candidate doesn't penalize a live take, so
# without this a studio chart whose strict query missed could fall back
# to — and auto-confirm — a live-only recording. Skipped only when the
# source title is itself a live take (mirrors the strict path).
if q and not _LIVE_GROUP_RE.search(str(title or "")):
q += " AND -secondarytype:Live"
return q
parts = []
if t:
parts.append('recording:"%s"' % _lucene_escape_phrase(t))
+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);
+466 -14
View File
@@ -199,6 +199,7 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
("DELETE", re.compile(r"^/api/audio-effects/active-mapping$")),
("POST", re.compile(r"^/api/song/.*/meta$")),
("POST", re.compile(r"^/api/song/.*/art/upload$")),
("PUT", re.compile(r"^/api/song/.+/overrides$")),
("GET", re.compile(r"^/api/plugins/updates$")),
("POST", re.compile(r"^/api/plugins/[^/]+/update$")),
("POST", re.compile(r"^/api/plugins/editor/save$")),
@@ -555,7 +556,13 @@ def next_library_cursor(sort: str, last_song: dict | None) -> str | None:
key = "mtime" if col == "mtime" else col
if key not in last_song or "filename" not in last_song:
return None
return _encode_cursor([last_song[key], last_song["filename"]])
# A title display-override (Fix-metadata popup) replaces last_song["title"]
# for the card, but the keyset seek runs on the RAW title column — resume
# from the raw value query_page stashed (present only when the last row's
# title was overridden), so paging never skips/dupes.
val = (last_song["_sort_title"] if (key == "title" and "_sort_title" in last_song)
else last_song[key])
return _encode_cursor([val, last_song["filename"]])
# Song-level "mastered" threshold — best accuracy across a song's arrangements
@@ -667,6 +674,26 @@ class MetadataDB:
)
""")
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_tags_tag ON song_tags(tag COLLATE NOCASE)")
# Per-field metadata OVERRIDES + LOCKS (the Fix-metadata popup). A
# reversible DISPLAY overlay, never written to the pack: `value` is the
# user's corrected value for a catalog field (title/artist/album/year/
# genre), `locked=1` pins the field so a metadata refresh / auto-match
# never changes what's shown for it (Plex-style field lock). Effective
# display value = override → matched-MusicBrainz → pack → derived.
# Filename-keyed → purged with the song on delete_song, NEVER on a
# rescan (delete_missing), so an edit survives re-import like every other
# local layer.
self.conn.execute("""
CREATE TABLE IF NOT EXISTS song_field_override (
filename TEXT NOT NULL,
field TEXT NOT NULL, -- title|artist|album|year|genre
value TEXT, -- corrected value (NULL = lock only, no override)
locked INTEGER NOT NULL DEFAULT 0,
updated_at TEXT,
PRIMARY KEY (filename, field)
)
""")
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_field_override_fn ON song_field_override(filename)")
# Artist-name aliases (P4): "ACDC" → "AC/DC", "the beatles" → "The Beatles".
# A CANONICALIZATION OVERRIDE applied AT DISPLAY only — the scanner-derived
# `songs.artist` and the feedpak files are never rewritten (a rescan can't
@@ -1168,6 +1195,125 @@ class MetadataDB:
self.conn.commit()
return self.get_song_user_meta(filename)
# ── Per-field metadata overrides + locks (Fix-metadata popup) ─────────────
def get_song_overrides(self, filename: str) -> dict:
"""{field: {"value": str|None, "locked": bool}} for one song."""
rows = self.conn.execute(
"SELECT field, value, locked FROM song_field_override WHERE filename = ?",
(filename,)).fetchall()
return {r[0]: {"value": r[1], "locked": bool(r[2])} for r in rows}
def set_song_override(self, filename: str, field: str, *,
value="__keep__", locked="__keep__") -> dict:
"""Partial upsert of one field's override value and/or lock. Pass a
value/locked to set it or leave the sentinel to keep the current one. A
row with neither a value nor a lock is dropped (no empty shell). Returns
the song's full override map."""
with self._lock:
cur = self.conn.execute(
"SELECT value, locked FROM song_field_override WHERE filename = ? AND field = ?",
(filename, field)).fetchone()
new_val = (cur[0] if cur else None) if value == "__keep__" else value
new_lock = (bool(cur[1]) if cur else False) if locked == "__keep__" else bool(locked)
new_val = (new_val or "").strip() or None
if new_val is None and not new_lock:
self.conn.execute(
"DELETE FROM song_field_override WHERE filename = ? AND field = ?",
(filename, field))
else:
self.conn.execute(
"INSERT INTO song_field_override (filename, field, value, locked, updated_at) "
"VALUES (?, ?, ?, ?, datetime('now')) "
"ON CONFLICT(filename, field) DO UPDATE SET "
"value = excluded.value, locked = excluded.locked, updated_at = excluded.updated_at",
(filename, field, new_val, 1 if new_lock else 0))
self.conn.commit()
return self.get_song_overrides(filename)
def locked_fields(self, filename: str) -> set:
"""The catalog fields the user LOCKED for a song (Fix-metadata popup).
An automatic match must never (re)canonicalize these, and gap-fill must
never write them to the file. Locked read (the enrichment worker calls
it), minimal projection."""
with self._lock:
return {r[0] for r in self.conn.execute(
"SELECT field FROM song_field_override WHERE filename = ? AND locked = 1",
(filename,)).fetchall()}
def clear_song_override(self, filename: str, field: str) -> dict:
"""Remove a field's override + lock entirely (revert to the resolved
pack/matched value)."""
with self._lock:
self.conn.execute(
"DELETE FROM song_field_override WHERE filename = ? AND field = ?",
(filename, field))
self.conn.commit()
return self.get_song_overrides(filename)
def overrides_map(self, filenames) -> dict:
"""{filename: {field: {value, locked}}} for a batch — feeds the grid's
effective-value resolution (display slice). Chunked under SQLite's
variable limit."""
fns = list(filenames)
out: dict = {}
for i in range(0, len(fns), 400):
chunk = fns[i:i + 400]
if not chunk:
break
q = ("SELECT filename, field, value, locked FROM song_field_override "
"WHERE filename IN (%s)" % ",".join("?" * len(chunk)))
for fn, field, value, locked in self.conn.execute(q, chunk).fetchall():
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
pack' reference + the Yours/Pack provenance. Empty strings for a missing
song so the popup always has a value to render."""
keys = ("title", "artist", "album", "year", "genre")
row = self.conn.execute(
"SELECT title, artist, album, year, genre FROM songs WHERE filename = ?",
(filename,)).fetchone()
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;
blanks + case-dupes dropped). Full-replace so the whole personal-meta
@@ -1232,6 +1378,7 @@ class MetadataDB:
INSIDE the caller's `meta_db._lock` — must not re-acquire the lock."""
self.conn.execute("DELETE FROM song_user_meta WHERE filename = ?", (filename,))
self.conn.execute("DELETE FROM song_tags WHERE filename = ?", (filename,))
self.conn.execute("DELETE FROM song_field_override WHERE filename = ?", (filename,))
def batch_user_meta(self, filenames, *, set_difficulty="__keep__",
add_tags=None, remove_tags=None) -> int:
@@ -3038,6 +3185,22 @@ class MetadataDB:
out[fn] = st
return out
def _unmatched_set(self, filenames) -> set:
"""The subset of `filenames` whose enrichment landed in the 'failed'
(no-match) state feeds the grid's persistent per-card "no match" badge,
so the misses stay visible at rest (the batch tile only shows while a
refresh runs). Chunked set membership, like favorite_set."""
fns = list(filenames)
out: set = set()
for i in range(0, len(fns), 400):
chunk = fns[i:i + 400]
if not chunk:
break
q = ("SELECT filename FROM song_enrichment WHERE match_state = 'failed' "
"AND filename IN (%s)" % ",".join("?" * len(chunk)))
out.update(r[0] for r in self.conn.execute(q, chunk).fetchall())
return out
def enrichment_song_row(self, filename: str) -> dict | None:
"""The identity fields the matcher/scorer keys on, for one song."""
row = self.conn.execute(
@@ -3338,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 =
@@ -4042,6 +4205,20 @@ class MetadataDB:
fns = [s["filename"] for s in songs]
udm = self.user_meta_map(fns)
tgm = self.tags_map(fns)
# Enrichment "no match" (failed) set for the page, so a card can show a
# persistent "no match" badge — the Refresh-Metadata batch's transient
# per-tile state only paints while a pass runs. Cheap set membership like
# favs/estd, so the misses stay visible at rest.
um = self._unmatched_set(fns)
# Per-song display OVERRIDES (Fix-metadata popup, slice 3). "Grid shows
# only overrides": the effective cell is the user's override else the
# pack value — a matched MusicBrainz canon NEVER silently re-titles a
# card (canon lives in the Details drawer + art). Overlaid in Python
# over the visible window, keyset-safe exactly like the P4 alias re-label
# below: the seek still runs on the raw column (the one overridable
# keyset column, title, stashes its raw value for the cursor — see
# _sort_title / next_library_cursor).
omap = self.overrides_map(fns)
# Canonical artist at display (P4): re-label the card's artist through the
# alias override so "ACDC" reads as "AC/DC". Display-only — the row's sort
# position (raw artist) is untouched, so a card can show a canonical name
@@ -4051,8 +4228,32 @@ class MetadataDB:
for s in songs:
s["user_difficulty"] = udm.get(s["filename"])
s["tags"] = tgm.get(s["filename"], [])
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.
ov = omap.get(s["filename"])
if ov:
for field in ("title", "artist", "album", "year"):
cell = ov.get(field)
val = cell.get("value") if cell else None
if val:
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
# materialized read-model, so the card can render the "N charts" chip and
# address the Charts drawer (GET /api/work/{work_key}/charts) without a
@@ -6320,15 +6521,60 @@ def _mb_http_get(path: str, params: dict) -> dict | None:
def _mb_search_recordings(artist, title, limit: int = 12) -> list[dict]:
"""Text search (tier 24): denoised Lucene query over /recording. The query
now drops live-only recordings and our ranker rewards the studio take, so a
"""Text search (tier 24): denoised Lucene query over /recording. The strict
query drops live-only recordings and the ranker rewards the studio take, so a
slightly larger default result set gives the re-ranker room to surface the
canonical version (one request per song regardless of limit)."""
canonical version.
Runs the strict field-phrase query first (high precision); if it finds
nothing, retries ONCE with a loose term query. The strict phrase only matches
MusicBrainz's *primary* artist/title, so a recording stored under a non-Latin
primary name (大橋純子) whose romanized form ("Junko Ohashi") is only an alias
is invisible to it the loose query searches aliases and rescues it. The
retry spends a second throttled request only on a miss; results are re-scored
by rank_candidates, so the looser recall doesn't lower match quality
(auto-accept still needs the per-field floors)."""
query = mb_match.build_recording_query(artist, title)
if not query:
cands: list[dict] = []
if query:
body = _mb_http_get("recording", {"query": query, "limit": limit})
cands = mb_match.parse_search_response(body or {})
if not cands:
loose = mb_match.build_recording_query(artist, title, loose=True)
if loose and loose != query:
body = _mb_http_get("recording", {"query": loose, "limit": limit})
cands = mb_match.parse_search_response(body or {})
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("recording", {"query": query, "limit": limit})
return mb_match.parse_search_response(body or {})
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) ──────────────
@@ -6529,6 +6775,78 @@ _MBID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f
_ISRC_RE = re.compile(r"^[A-Z]{2}[A-Z0-9]{3}[0-9]{7}$")
# ── Alias-aware scoring ───────────────────────────────────────────────────────
# MusicBrainz stores many artists under a non-Latin PRIMARY name (大橋純子) with
# the romanized form ("Junko Ohashi") only as an ALIAS. A recording search
# returns the primary name in its artist-credit, never the aliases — so scoring
# a romanized reference against the primary gives 0 and the match can't confirm.
# We fetch the artist's aliases (one throttled lookup, process-cached) and hand
# them to the scorer, but ONLY for a promising near-miss (title already agrees,
# artist doesn't) so a normal pass spends no extra requests.
_ALIAS_ENRICH_MAX = 3 # cap alias lookups per song/search (each is ≤1/s)
_artist_alias_cache: dict[str, list[str]] = {}
def _mb_artist_aliases(artist_id: str) -> list[str]:
"""Romanized/alternate names for a MusicBrainz artist, process-cached (an
artist recurs across a whole discography, so a library of one artist costs
ONE lookup). Returns [] for an unknown/aliasless artist. Raises
EnrichTransportError on a network failure so the caller pauses the pass
(nothing is cached on failure retried next pass)."""
aid = str(artist_id or "")
if aid in _artist_alias_cache:
return _artist_alias_cache[aid]
if not _MBID_RE.match(aid):
return []
body = _mb_http_get(f"artist/{aid}", {"inc": "aliases"})
names: list[str] = []
if body:
sort_name = str(body.get("sort-name") or "").strip()
if sort_name:
names.append(sort_name) # often the romanized form for JP artists
for al in body.get("aliases") or []:
if isinstance(al, dict) and al.get("name"):
names.append(str(al["name"]))
seen: set[str] = set()
out: list[str] = []
for n in names:
k = n.casefold()
if k and k not in seen:
seen.add(k)
out.append(n)
out = out[:12]
_artist_alias_cache[aid] = out
return out
def _alias_enrich(ref: dict, cands: list[dict]) -> None:
"""Attach `artist_aliases` in place to candidates that look like the
non-Latin-primary case title agrees with the reference but the primary
artist doesn't — so the scorer can confirm them via a romanized alias.
Bounded by _ALIAS_ENRICH_MAX + the process cache; a no-op when the
reference has no artist or nothing is aliasable."""
ref_artist = (ref.get("artist") or "").strip()
if not ref_artist:
return
spent = 0
for c in cands:
if spent >= _ALIAS_ENRICH_MAX:
break
if not isinstance(c, dict) or c.get("artist_aliases") is not None:
continue
aid = c.get("artist_id")
if not aid:
continue
# Only spend a lookup on a promising near-miss: the title already
# matches, but the primary artist doesn't (that's the alias signature).
if mb_match.similarity(ref.get("title"), c.get("title")) < mb_match.AUTO_TITLE_MIN:
continue
if mb_match.similarity(ref_artist, c.get("artist"), artist=True) >= mb_match.AUTO_ARTIST_MIN:
continue
c["artist_aliases"] = _mb_artist_aliases(aid) # cached; attach [] to avoid refetch
spent += 1
def _manifest_exact_ids(filename: str) -> dict:
"""Optional `mbid`/`isrc` from the pack manifest — the spec's additive
identity keys. Feature-detected: packs published before that spec
@@ -6877,6 +7195,34 @@ def _artist_title_from_filename(filename: str) -> dict | None:
return {"artist": artist, "title": title}
# A per-song LOCK (Fix-metadata popup) → the candidate display keys it
# suppresses on an AUTOMATIC match. Identity keys (recording/release/artist ids,
# isrc) are deliberately absent: a locked DISPLAY field still gets matched for
# art + future re-match, it just isn't re-canonicalized behind the user's back.
_LOCK_FIELD_TO_CAND = {
"artist": ("artist", "artist_sort"),
"title": ("title",),
"album": ("album",),
"year": ("year",),
"genre": ("genres",),
}
def _compose_lock_filter(base_filter, locked_fields):
"""Wrap the pass's global per-field apply-filter with a per-song filter that
also strips the song's LOCKED display fields, so an automatic match never
re-canonicalizes a field the user pinned. Returns base_filter unchanged when
the song has no relevant lock (the common path)."""
blocked = {ck for f in locked_fields for ck in _LOCK_FIELD_TO_CAND.get(f, ())}
if not blocked:
return base_filter
def lock_filter(cand):
c = base_filter(cand) if base_filter else cand
return {k: v for k, v in c.items() if k not in blocked}
return lock_filter
def _enrich_one(row: dict, auto_min: float | None = None, field_filter=None,
apply_mask: str = "") -> None:
"""The matcher (P8; replaces P7's no-op). Precedence per design §5:
@@ -6902,6 +7248,14 @@ def _enrich_one(row: dict, auto_min: float | None = None, field_filter=None,
siblings. Network errors raise EnrichTransportError so the pass pauses
instead of burning attempts while offline."""
fn, chash = row["filename"], row["content_hash"]
# Respect per-song field LOCKS (Fix-metadata popup): an automatic match must
# not re-canonicalize a field the user pinned. Compose the lock filter onto
# the pass's global apply-filter — both the cache-copy and text-match auto
# paths run their candidate through it. (Review/manual picks bypass the
# filter, so confirming a match in the modal is an explicit override.)
locked = meta_db.locked_fields(fn)
if locked:
field_filter = _compose_lock_filter(field_filter, locked)
cached = meta_db.enrichment_cache_lookup(chash, exclude_filename=fn)
if cached:
@@ -6941,7 +7295,13 @@ def _enrich_one(row: dict, auto_min: float | None = None, field_filter=None,
cand=field_filter(cands[0]) if field_filter else cands[0])
return
ranked = mb_match.rank_candidates(ref, _mb_search_recordings(ref.get("artist"), ref.get("title")))
cands = _mb_search_recordings(ref.get("artist"), ref.get("title"))
# Alias-enrich promising near-misses (title agrees, primary artist doesn't)
# so a non-Latin-primary artist can confirm via its romanized alias, then
# rank once with the aliases in hand. `ref` carries any filename-derived
# artist seed, so alias scoring runs against the searched identity.
_alias_enrich(ref, cands)
ranked = mb_match.rank_candidates(ref, cands)
best = ranked[0] if ranked else None
tier = mb_match.classify(ref, best, best["score"], auto_min=auto_min) if best else "none"
if tier == "auto":
@@ -7836,6 +8196,13 @@ def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8,
if duration and duration > 0 and not ref.get("duration"):
ref = dict(ref)
ref["duration"] = duration
# Alias-enrich so a non-Latin-primary artist (大橋純子) ranks by its
# romanized alias against the typed query ("Junko Ohashi") instead of
# sinking to the bottom with a 0 artist score.
try:
_alias_enrich(ref, cands)
except EnrichTransportError:
pass # aliases are a ranking nicety here; fall back to primary-name scoring
return {"candidates": mb_match.rank_candidates(ref, cands)}
@@ -8574,6 +8941,10 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
# The cursor to resume after this page (effective sort folds in dir=desc).
next_cursor = (next_library_cursor(_effective_keyset_sort(sort, dir), songs[-1])
if (is_local and songs) else None)
# Drop the private raw-title stash query_page attached for the cursor — it's
# an internal keyset detail, not part of the card payload.
for s in songs:
s.pop("_sort_title", None)
return {"songs": songs, "total": total, "page": page, "size": size,
"next_cursor": next_cursor}
@@ -8825,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]}
@@ -8912,6 +9284,60 @@ def put_song_user_meta(filename: str, data: dict):
return meta_db.get_song_user_meta(key)
# Catalog fields the Fix-metadata popup may override/lock — the intersection of
# "displayable identity" and "safe to correct locally". Guitar/practice facts
# and personal fields are never overrides.
_OVERRIDE_FIELDS = frozenset({"title", "artist", "album", "year", "genre"})
@app.get("/api/song/{filename:path}/overrides")
def get_song_overrides(filename: str):
"""Per-field metadata overrides + locks for one song (Fix-metadata popup):
{"overrides": {field: {"value": str|null, "locked": bool}},
"pack": {field: str}}. `pack` is the stored value each override sits on top
of the popup's Details tab renders it as the revert-to-pack reference and
the Yours/Pack provenance."""
key = meta_db._canonical_song_filename(filename)
return {"overrides": meta_db.get_song_overrides(key),
"pack": meta_db.pack_fields(key)}
@app.put("/api/song/{filename:path}/overrides")
def put_song_overrides(filename: str, data: dict):
"""Set/clear per-field overrides + locks. Body:
`{"overrides": {field: {"value": str|null, "locked": bool}}}`. Only catalog
fields (title/artist/album/year/genre) are accepted. A field left with no
value and unlocked is removed. Returns the merged override map.
Clearing rides this PUT (send value:null, locked:false) rather than a DELETE
sub-route, because `DELETE /api/song/{filename:path}` already owns every
DELETE under /api/song and would shadow it (same reason as tags)."""
ov = (data or {}).get("overrides")
if not isinstance(ov, dict) or not ov:
return JSONResponse({"error": "overrides must be a non-empty object"}, 400)
bad = sorted(f for f in ov if f not in _OVERRIDE_FIELDS)
if bad:
return JSONResponse({"error": "unknown field(s): " + ", ".join(bad)}, 400)
key = meta_db._canonical_song_filename(filename)
for field, spec in ov.items():
if not isinstance(spec, dict):
return JSONResponse({"error": f"'{field}' must be an object with value/locked"}, 400)
kwargs: dict = {}
if "value" in spec:
v = spec["value"]
if v is None:
kwargs["value"] = None
elif isinstance(v, (str, int, float)) and not isinstance(v, bool):
kwargs["value"] = str(v).strip()[:500]
else:
return JSONResponse({"error": f"'{field}' value must be a string or null"}, 400)
if "locked" in spec:
kwargs["locked"] = bool(spec["locked"])
if kwargs:
meta_db.set_song_override(key, field, **kwargs)
return {"overrides": meta_db.get_song_overrides(key)}
@app.post("/api/songs/user-meta/batch")
def batch_song_user_meta(data: dict):
"""Bulk personal-meta edit over a selection — one request instead of N×2
@@ -11853,6 +12279,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
@@ -12080,15 +12526,21 @@ def _gap_fill_proposals(cache_key: str, resolved) -> tuple[dict, str]:
manifest = sloppak_mod.load_manifest(resolved) or {}
except Exception:
return {}, "not-sloppak"
# A LOCKED field (Fix-metadata popup) is never gap-filled — the user pinned
# it away from the matched value, so writing that value to the file would
# be exactly the clobber the lock exists to prevent. (The lock field name is
# `genre`; the manifest/gap-fill key is `genres`.)
locked = meta_db.locked_fields(cache_key)
out = {}
album = (row.get("canon_album") or "").strip()
if album and _gap_fill_manifest_absent(manifest, "album"):
if album and "album" not in locked and _gap_fill_manifest_absent(manifest, "album"):
out["album"] = album
year = (row.get("canon_year") or "").strip()
if year.isdigit() and int(year) and _gap_fill_manifest_absent(manifest, "year"):
if (year.isdigit() and int(year) and "year" not in locked
and _gap_fill_manifest_absent(manifest, "year")):
out["year"] = int(year)
genres = [str(g) for g in (row.get("genres") or []) if isinstance(g, str) and g.strip()]
if genres and _gap_fill_manifest_absent(manifest, "genres"):
if genres and "genre" not in locked and _gap_fill_manifest_absent(manifest, "genres"):
out["genres"] = genres
# Identity keys (feedpak spec 1.14.0) — written in canonical form only.
mbid = (row.get("mb_recording_id") or "").strip().lower()
+35 -10
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' },
@@ -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';
+1 -1
View File
File diff suppressed because one or more lines are too long
+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');
+370 -54
View File
@@ -131,7 +131,8 @@
let _queue = [];
let _idx = 0;
let _lastFocus = null;
let _single = false; // Fix-match mode: one song, no queue navigation
let _single = false; // Fix-metadata mode: one song, no queue navigation
let _tab = 'details'; // active tab in single mode: details | cover | match
function ensureModal() {
let m = document.getElementById('v3-match-modal');
@@ -180,14 +181,17 @@
loadQueue();
}
// Fix-match (R2): the same modal for ONE song — the escape hatch for a
// wrong (or missing) match, reachable from the card's ⋮ / right-click
// menu. No stored candidates are required: the search panel opens
// pre-filled, and a pick pins the match exactly like the review flow.
// Fix metadata (R2 → popup slice 4): the tabbed per-song editor for ONE
// song, reachable from the card's ⋮ / right-click menu. Three tabs —
// Details (type + lock the displayed fields), Cover art (launch the picker),
// Match (pin a MusicBrainz identity). Opens on Details: for the obscure /
// blank-artist packs this exists to fix, typing the right title is the tool,
// and Match is the escape hatch when text search can surface a record.
function fixMatch(song) {
if (!song || !song.filename) return;
_lastFocus = document.activeElement;
_single = true;
_tab = 'details';
_queue = [{
filename: song.filename, title: song.title || song.filename,
artist: song.artist || '', album: song.album || '',
@@ -198,10 +202,7 @@
const m = ensureModal();
m.classList.remove('hidden');
document.getElementById('v3-match-overlay')?.classList.remove('hidden');
renderCurrent();
// Straight to the point: the search panel is why this mode exists.
document.getElementById('v3-match-panel')
?.querySelector('[data-mr-search-toggle]')?.click();
renderCurrent(); // _single ⇒ renderTabbed()
}
function closeModal() {
@@ -214,7 +215,7 @@
}
function nav(step) {
if (!_queue.length) return;
if (_single || !_queue.length) return; // single mode has no queue to page
_idx = Math.min(Math.max(_idx + step, 0), _queue.length - 1);
renderCurrent();
}
@@ -302,20 +303,20 @@
'<span class="text-xs text-fb-textDim shrink-0">' + esc(pct) + '</span></span>' +
'<span class="block text-xs text-fb-textDim truncate">' + esc(meta) + '</span>' +
diffChips(song, c) +
(_single ? '<span class="block text-xs text-fb-primary pt-1">Use these values →</span>' : '') +
'</button>';
}
function renderCurrent() {
const panel = document.getElementById('v3-match-panel');
if (!panel) return;
if (!_queue.length) { renderDone(); return; }
_idx = Math.min(_idx, _queue.length - 1);
const song = _queue[_idx];
if (song._sel == null) song._sel = 0;
// The middle content shared by the queue-review render and the single-song
// popup's Match tab: the chart being matched, its candidate list, and the
// "search instead" panel. Header + footer differ per surface. When there
// are no stored candidates (a manual fix), the search panel opens pre-filled
// — searching IS the point in that case.
function reviewBodyHtml(song) {
const sub = [song.artist, song.album, song.year, fmtDur(song.duration)].filter(Boolean).join(' · ');
panel.innerHTML = headerHtml() +
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll">' +
const noCands = !(song.candidates || []).length;
const prefill = noCands ? [song.artist, song.title].filter(Boolean).join(' ') : '';
return '<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0">' +
// The chart being matched
'<div class="flex items-start gap-3">' +
'<img data-mr-art src="' + esc(artUrl(song)) + '" alt="" loading="lazy" class="w-16 h-16 rounded-lg object-cover bg-fb-card shrink-0">' +
@@ -325,25 +326,27 @@
'<div class="text-xs text-fb-textDim/70 truncate" title="' + esc(song.filename) + '">' + esc(song.filename) + '</div>' +
missingChips(song) +
'</div></div>' +
// Candidates (Fix-match mode arrives with none — the search panel
// is its whole point, so the empty header is suppressed).
((song.candidates || []).length
? '<div class="space-y-1" role="radiogroup" aria-label="Candidates">' +
(noCands
? ''
: '<div class="space-y-1" role="radiogroup" aria-label="Candidates">' +
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Candidates (MusicBrainz)</div>' +
song.candidates.map((c, i) => candRowHtml(song, c, i, i === song._sel)).join('') +
'</div>'
: '') +
// Search-instead panel
'<div data-mr-search-panel class="hidden space-y-2">' +
'</div>') +
// Search panel — hidden when candidates exist (a "Search instead…"
// toggle reveals it); open + pre-filled when there are none.
'<div data-mr-search-panel class="' + (noCands ? '' : 'hidden') + ' space-y-2">' +
'<div class="flex gap-2">' +
'<input data-mr-search-input type="text" 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" placeholder="Artist Title">' +
'<input data-mr-search-input type="text" value="' + esc(prefill) + '" 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" placeholder="Artist Title">' +
'<button data-mr-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-mr-search-results class="space-y-1"></div></div>' +
'</div>' +
// Footer actions. Fix-match mode drops Skip (no queue) and the
// accept button when there is nothing to accept — search-result
// rows carry their own pick action.
'<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
'</div>';
}
// Footer actions. Single mode drops Skip / Not-a-match (no queue); the
// accept button only shows when there is a stored candidate to accept —
// search-result rows carry their own pick action.
function footerHtml(song) {
return '<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
'<div class="flex items-center gap-3">' +
(_single ? '' : '<button data-mr-reject class="text-sm text-fb-textDim hover:text-fb-text">Not a match</button>') +
'<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button>' +
@@ -352,57 +355,362 @@
(_single ? '' : '<button data-mr-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip</button>') +
((song.candidates || []).length ? '<button data-mr-accept class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Use selected</button>' : '') +
'</div></div>';
wireCurrent(panel, song);
}
function wireCurrent(panel, song) {
function renderCurrent() {
const panel = document.getElementById('v3-match-panel');
if (!panel) return;
if (_single) { renderTabbed(); return; } // popup: the tabbed shell
if (!_queue.length) { renderDone(); return; }
_idx = Math.min(_idx, _queue.length - 1);
const song = _queue[_idx];
if (song._sel == null) song._sel = 0;
panel.innerHTML = headerHtml() + reviewBodyHtml(song) + footerHtml(song);
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
panel.querySelector('[data-mr-prev]')?.addEventListener('click', () => nav(-1));
panel.querySelector('[data-mr-next]')?.addEventListener('click', () => nav(1));
panel.querySelector('[data-mr-skip]')?.addEventListener('click', () => nav(1));
wireReviewBody(panel, song);
}
// Candidate / search / accept-reject wiring shared by the queue render and
// the popup's Match tab. Scoped to `root` so the tabbed shell can wire just
// its tab body — its close + tab chrome live in the header (wired once by
// renderTabbed), so wiring here must NOT touch close/prev/next/skip.
function wireReviewBody(root, song) {
// Art failure → flag + re-render once so the "cover art" chip shows.
const img = panel.querySelector('[data-mr-art]');
const img = root.querySelector('[data-mr-art]');
if (img) img.onerror = () => {
img.style.visibility = 'hidden';
if (!song._artMissing) { song._artMissing = true; renderCurrent(); }
};
panel.querySelectorAll('[data-mr-cand]').forEach((btn) => {
root.querySelectorAll('[data-mr-cand]').forEach((btn) => {
btn.addEventListener('click', () => {
song._sel = Number(btn.getAttribute('data-mr-cand'));
renderCurrent();
});
});
panel.querySelector('[data-mr-accept]')?.addEventListener('click', async () => {
root.querySelector('[data-mr-accept]')?.addEventListener('click', async () => {
const cand = (song.candidates || [])[song._sel || 0];
if (!cand) return;
await post('/api/enrichment/review/' + enc(song.filename) + '/accept',
{ recording_id: cand.recording_id });
settle(song);
});
panel.querySelector('[data-mr-reject]')?.addEventListener('click', async () => {
root.querySelector('[data-mr-reject]')?.addEventListener('click', async () => {
await post('/api/enrichment/review/' + enc(song.filename) + '/reject');
settle(song);
});
const sp = panel.querySelector('[data-mr-search-panel]');
const input = panel.querySelector('[data-mr-search-input]');
panel.querySelector('[data-mr-search-toggle]')?.addEventListener('click', () => {
const sp = root.querySelector('[data-mr-search-panel]');
const input = root.querySelector('[data-mr-search-input]');
root.querySelector('[data-mr-search-toggle]')?.addEventListener('click', () => {
sp?.classList.toggle('hidden');
if (sp && !sp.classList.contains('hidden') && input && !input.value) {
input.value = [song.artist, song.title].filter(Boolean).join(' ');
input.focus();
}
});
const go = () => runSearch(panel, song);
panel.querySelector('[data-mr-search-go]')?.addEventListener('click', go);
const go = () => runSearch(root, song);
root.querySelector('[data-mr-search-go]')?.addEventListener('click', go);
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } });
panel.querySelector('[data-mr-identify]')?.addEventListener('click', () => runIdentify(panel, song));
// Identify-by-audio (AcoustID, #759) renders its hits into the same
// search-results area — scope to `root` (the tab body / panel), not the
// out-of-scope `panel` the pre-refactor #759 wiring referenced.
root.querySelector('[data-mr-identify]')?.addEventListener('click', () => runIdentify(root, song));
}
// ── Tabbed single-song popup (slice 4) ───────────────────────────────────
// Header + tab bar, then the active tab's body. The queue-review render
// above is untouched; this is only reached in _single mode.
function tabHeaderHtml() {
const tab = (id, label) =>
'<button data-mr-tab="' + id + '" role="tab" aria-selected="' + (_tab === id ? 'true' : 'false') + '" ' +
'class="px-3 py-2 text-sm -mb-px border-b-2 ' + (_tab === id
? 'border-fb-primary text-fb-text'
: 'border-transparent text-fb-textDim hover:text-fb-text') + '">' + label + '</button>';
return '<div class="flex items-center justify-between gap-3 px-5 pt-4 shrink-0">' +
'<h3 class="text-lg font-semibold text-fb-text">Fix metadata</h3>' +
'<button data-mr-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>' +
'<div role="tablist" class="flex gap-1 px-4 border-b border-fb-border/40 shrink-0">' +
tab('details', 'Details') + tab('cover', 'Cover art') + tab('match', 'Match') + '</div>';
}
function renderTabbed() {
const panel = document.getElementById('v3-match-panel');
if (!panel) return;
const song = _queue[0];
if (!song) { closeModal(); return; }
panel.innerHTML = tabHeaderHtml() +
'<div data-mr-tabbody role="tabpanel" class="flex flex-col min-h-0 flex-1 overflow-hidden"></div>';
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
panel.querySelectorAll('[data-mr-tab]').forEach((b) => b.addEventListener('click', () => {
const t = b.getAttribute('data-mr-tab');
if (t !== _tab) { _tab = t; renderTabbed(); }
}));
const body = panel.querySelector('[data-mr-tabbody]');
if (_tab === 'details') { renderDetailsTab(body, song); }
else if (_tab === 'cover') { renderCoverTab(body, song); }
else {
body.innerHTML = reviewBodyHtml(song) + footerHtml(song);
wireReviewBody(body, song);
if (!(song.candidates || []).length) body.querySelector('[data-mr-search-input]')?.focus();
}
}
// Details tab: type + lock the DISPLAYED fields. Values ride the reversible
// override store (GET/PUT /api/song/{fn}/overrides) — never the pack file.
// 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'], ['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>';
let data = { overrides: {}, pack: {} };
try {
const r = await fetch('/api/song/' + enc(song.filename) + '/overrides');
if (r.ok) data = await r.json();
} catch (_) { /* offline — fall back to the empty baseline */ }
if (!_single || _tab !== 'details') return; // tab/modal changed while fetching
const pack = data.pack || {};
const ov = data.overrides || {};
const st = {};
for (const [f] of DETAIL_FIELDS) {
const o = ov[f] || {};
st[f] = {
pack: pack[f] || '',
value: (o.value != null ? o.value : (pack[f] || '')),
locked: !!o.locked,
};
}
song._detailsState = st;
// Match→Details bridge: a candidate picked with "Use these values" lands
// its fields here as the pending (unsaved) input values, shown pre-filled
// for review — the grid never adopts a match silently, so the user still
// Saves (or Writes to file).
const adopted = song._pendingDetails;
if (adopted) {
for (const [f] of DETAIL_FIELDS) {
if (f in adopted) st[f].value = String(adopted[f] || '');
}
song._pendingDetails = null;
}
paintDetails(body, song);
if (adopted) {
const s = body.querySelector('[data-df-status]');
if (s) { s.className = 'text-xs leading-relaxed text-fb-textDim'; s.textContent = 'Filled from the match — review, then Save or Write to file.'; }
}
}
// Match→Details bridge: adopt a candidate's display fields into the Details
// tab (opt-in — never silent). Pin the match too so the art/canon follow,
// 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 = {
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 });
} catch (_) { /* pin is best-effort; the values still populate Details */ }
try { window.feedBack?.emit('library:changed', { reason: 'match' }); } catch (_) { }
_tab = 'details';
renderTabbed();
}
function paintDetails(body, song) {
const st = song._detailsState;
const row = ([f, label]) => {
const s = st[f];
const isYours = !!(String(s.value).trim() && String(s.value).trim() !== String(s.pack).trim());
return '<div class="space-y-1">' +
'<div class="flex items-center justify-between">' +
'<label class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">' + esc(label) + '</label>' +
(isYours
? '<span class="text-[0.625rem] px-1.5 py-0.5 rounded bg-fb-primary/15 text-fb-primary">Yours</span>'
: '<span class="text-[0.625rem] px-1.5 py-0.5 rounded bg-fb-card text-fb-textDim">Pack</span>') +
'</div>' +
'<div class="flex items-center gap-2">' +
'<input data-df-input="' + f + '" type="text" value="' + esc(s.value) + '" ' +
'class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none focus:border-fb-primary" ' +
'placeholder="' + esc(s.pack || label) + '">' +
'<button data-df-lock="' + f + '" type="button" aria-pressed="' + (s.locked ? 'true' : 'false') + '" ' +
'title="' + (s.locked ? 'Locked — auto-match wont change this field' : 'Lock this field against auto-match') + '" ' +
'class="px-2 py-1.5 rounded-md border ' + (s.locked ? 'border-fb-primary text-fb-primary bg-fb-primary/10' : 'border-fb-border/50 text-fb-textDim hover:text-fb-text') + '">' +
(s.locked ? '🔒' : '🔓') + '</button>' +
'<button data-df-revert="' + f + '" type="button" title="Revert to the pack value" ' +
'class="px-2 py-1.5 rounded-md border border-fb-border/50 text-fb-textDim hover:text-fb-text">↺</button>' +
'</div></div>';
};
body.innerHTML =
'<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 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>' +
'</div>' +
'<div class="flex items-center justify-between gap-2 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
'<button data-df-write type="button" title="Write these values into the song file itself — permanent, survives a full rescan. The rest of the pack is untouched." class="text-sm text-fb-textDim hover:text-fb-text border border-fb-border/50 rounded-md px-3 py-2">Write to file</button>' +
'<button data-df-save class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Save</button>' +
'</div>';
body.querySelectorAll('[data-df-input]').forEach((inp) => {
inp.addEventListener('input', () => { st[inp.getAttribute('data-df-input')].value = inp.value; });
});
body.querySelectorAll('[data-df-lock]').forEach((b) => {
b.addEventListener('click', () => { const f = b.getAttribute('data-df-lock'); st[f].locked = !st[f].locked; paintDetails(body, song); });
});
body.querySelectorAll('[data-df-revert]').forEach((b) => {
b.addEventListener('click', () => { const f = b.getAttribute('data-df-revert'); st[f].value = st[f].pack || ''; st[f].locked = false; paintDetails(body, song); });
});
body.querySelector('[data-df-save]')?.addEventListener('click', () => saveDetails(body, song));
body.querySelector('[data-df-write]')?.addEventListener('click', () => writeToFile(body, song));
}
async function saveDetails(body, song) {
const st = song._detailsState;
const overrides = {};
for (const [f] of DETAIL_FIELDS) {
const v = String(st[f].value || '').trim();
const p = String(st[f].pack || '').trim();
// Only store a value that differs from the pack; equal / blank clears
// the override (the server drops a value-less, unlocked row).
overrides[f] = { value: (v && v !== p) ? v : null, locked: !!st[f].locked };
}
const status = body.querySelector('[data-df-status]');
const saveBtn = body.querySelector('[data-df-save]');
if (saveBtn) saveBtn.disabled = true;
let ok = false;
try {
const r = await fetch('/api/song/' + enc(song.filename) + '/overrides', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ overrides }),
});
ok = r.ok;
} catch (_) { ok = false; }
if (saveBtn) saveBtn.disabled = false;
if (!ok) {
if (status) { status.className = 'text-xs h-4 text-fb-accent'; status.textContent = 'Could not save — try again.'; }
return;
}
// Reflect the new effective values on the in-memory song (keeps the Match
// tab header consistent) and repaint the library so the card shows them —
// the grid reloads on library:changed (slice 3 overlay does the rest).
for (const [f] of DETAIL_FIELDS) {
const v = String(st[f].value || '').trim(); const p = String(st[f].pack || '').trim();
song[f] = (v && v !== p) ? v : (st[f].pack || '');
}
try { window.feedBack?.emit('library:changed', { reason: 'override' }); } catch (_) { }
if (status) { status.className = 'text-xs h-4 text-fb-good'; status.textContent = 'Saved.'; }
}
// "Write to file" — bake the shown title/artist/album/year INTO the pack
// itself (the one action here that touches the file), via the existing
// POST /api/song/{fn}/meta (writes the manifest, re-stats, coalesces a
// rescan). On a real file write the display overrides for those fields are
// now redundant, so clear their VALUES (keeping any locks) and re-render —
// the field then reads from the file as "Pack". Loose-folder / unwritable
// packs fall back to a DB-only update: we say so and keep the overlay.
async function writeToFile(body, song) {
const st = song._detailsState;
const fields = {};
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]');
if (writeBtn) writeBtn.disabled = true;
if (saveBtn) saveBtn.disabled = true;
if (status) { status.className = 'text-xs leading-relaxed text-fb-textDim'; status.textContent = 'Writing to the song file…'; }
let ok = false, persisted = false;
try {
const r = await fetch('/api/song/' + enc(song.filename) + '/meta', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields),
});
ok = r.ok;
const j = await r.json().catch(() => ({}));
persisted = !!(j && j.persisted);
} catch (_) { ok = false; }
if (writeBtn) writeBtn.disabled = false;
if (saveBtn) saveBtn.disabled = false;
if (!ok) {
if (status) { status.className = 'text-xs leading-relaxed text-fb-accent'; status.textContent = 'Could not write to the file — try again.'; }
return;
}
// Keep the in-memory song + grid in step with what was *persisted*, not
// the raw input: the server coerces a non-numeric/empty year to "" (see
// update_song_meta), so mirror that here or the grid card flashes the
// typed text (e.g. "abcd") until the next natural refresh corrects it.
const applied = { ...fields };
if ('year' in applied) {
const yr = /^[+-]?\d+$/.test(applied.year) ? parseInt(applied.year, 10) : 0;
applied.year = yr ? String(yr) : '';
}
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 WRITE_FIELDS) clear[f] = { value: null, locked: !!st[f].locked };
try {
await fetch('/api/song/' + enc(song.filename) + '/overrides', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ overrides: clear }),
});
} catch (_) { /* the file write still succeeded; the overlay just lingers */ }
await renderDetailsTab(body, song); // re-fetch: pack now = written values, overrides cleared
const s2 = body.querySelector('[data-df-status]');
if (s2) { s2.className = 'text-xs leading-relaxed text-fb-good'; s2.textContent = 'Written to the song file.'; }
} else if (status) {
status.className = 'text-xs leading-relaxed text-fb-textDim';
status.textContent = 'Saved to the library — this packs file couldnt be written, so it may revert on a full rescan.';
}
}
// Cover-art tab: the current art + a button that hands off to the shared
// cover picker (image-picker.js, its own z-[200] modal). A pick there
// refreshes every <img> for this song's art — including this thumbnail — so
// there's nothing to wire back.
function renderCoverTab(body, song) {
body.innerHTML =
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0 flex flex-col items-center text-center">' +
'<img src="' + esc(artUrl(song)) + '" alt="" onerror="this.style.visibility=\'hidden\'" class="w-40 h-40 rounded-xl object-cover bg-fb-card">' +
'<p class="text-sm text-fb-textDim max-w-sm">Choose from the Cover Art Archive, paste an image link, or upload your own. Your song files are never changed.</p>' +
'<button data-cover-open class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Choose cover art…</button>' +
'</div>';
body.querySelector('[data-cover-open]')?.addEventListener('click', () => {
if (window.__fbOpenImagePicker) {
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
}
});
}
// Silent-on-success: the chart just leaves the queue and the next one
// renders; the last one renders the done state. No toasts, no sounds.
function settle(song) {
if (_single) { closeModal(); return; } // Fix-match: done means done
if (_single) {
// Popup Match tab: a pinned identity can change the art/canon — nudge
// the grid to repaint (silent otherwise, like the queue flow).
try { window.feedBack?.emit('library:changed', { reason: 'match' }); } catch (_) { }
closeModal();
return;
}
const i = _queue.indexOf(song);
if (i >= 0) _queue.splice(i, 1);
if (_idx >= _queue.length) _idx = Math.max(0, _queue.length - 1);
@@ -442,6 +750,7 @@
btn.addEventListener('click', async () => {
const cand = cands[Number(btn.getAttribute('data-mr-cand'))];
if (!cand) return;
if (_single) { useTheseValues(song, cand); return; } // popup → adopt into Details
await post('/api/enrichment/review/' + enc(song.filename) + '/pick',
{ candidate: cand });
settle(song);
@@ -464,30 +773,37 @@
status = r.status;
body = await r.json().catch(() => null);
} catch (_) { /* falls through to the no-results line */ }
// Honest states — never a fake hit.
// Honest states — never a fake hit. Each says plainly WHICH outcome this
// is, so an empty result reads as "it ran, found nothing" (not "broken")
// and points at the manual fallback when there's nothing to pick.
const note = (html) => { out.innerHTML = '<p class="text-xs text-fb-textDim leading-relaxed">' + html + '</p>'; };
const manual = _single
? ' Try <b class="text-fb-text">Search</b>, or just set the album in <b class="text-fb-text">Details</b> and the cover in <b class="text-fb-text">Cover art</b> by hand.'
: ' Try <b class="text-fb-text">Search instead</b>.';
if (status === 412 || (body && body.needs_setup)) {
out.innerHTML = '<p class="text-xs text-fb-textDim">Audio identification is off — enable AcoustID and add a free API key to use it.</p>';
note('Audio identification is <b class="text-fb-text">off</b>. Turn it on and add a free AcoustID API key in Settings → Library to use it.');
return;
}
if (status === 404) {
out.innerHTML = "<p class=\"text-xs text-fb-textDim\">No full-mix audio to fingerprint for this song.</p>";
note('This pack has <b class="text-fb-text">no full mix to fingerprint</b> (it\'s chart-only or stems-only).' + manual);
return;
}
if (status === 503) {
out.innerHTML = '<p class="text-xs text-fb-textDim">Audio identification is unavailable right now — try again.</p>';
note('Could not run the fingerprint right now — the audio tool or network is unavailable. Try again in a moment.');
return;
}
const cands = (body && body.candidates) || [];
if (!cands.length) {
out.innerHTML = '<p class="text-xs text-fb-textDim">No fingerprint match — try text search.</p>';
note('<span class="text-fb-good">✓ Fingerprinted the audio</span> — but AcoustID has <b class="text-fb-text">no match</b> for this exact recording (common for obscure or import tracks).' + manual);
return;
}
out.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Fingerprint matches (AcoustID)</div>' +
out.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-good mb-1">Fingerprint matches (AcoustID)</div>' +
cands.map((c, i) => candRowHtml(song, c, i, false)).join('');
out.querySelectorAll('[data-mr-cand]').forEach((btn) => {
btn.addEventListener('click', async () => {
const cand = cands[Number(btn.getAttribute('data-mr-cand'))];
if (!cand) return;
if (_single) { useTheseValues(song, cand); return; } // popup → adopt into Details
await post('/api/enrichment/review/' + enc(song.filename) + '/pick',
{ candidate: cand });
settle(song);
+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',
+70 -15
View File
@@ -498,21 +498,39 @@
// to before (keeps the windowed grid's height math untouched). Honest state
// transitions, NOT a fake per-song %: a match is binary (design §11).
const _metaTile = {}; // fn -> 'queued' | 'working' | 'done' | 'nochange'
function enrichBadge(fn) {
const st = _metaTile[fn];
// Cards whose enrichment landed 'failed' (from the grid payload) — tracked so
// the PERSISTENT "no match" badge survives a batch tile clearing (a
// _patchCardEnrich with no flag falls back to this instead of wiping it).
// Populated as cards render (enrichBadge is called per card with the flag).
const _unmatched = new Set();
function enrichBadge(fn, unmatched) {
if (unmatched !== undefined) { if (unmatched) _unmatched.add(fn); else _unmatched.delete(fn); }
// A live batch tile wins over the resting no-match marker (they never
// coexist — the batch clears its tiles when it finishes).
const st = _metaTile[fn] || (_unmatched.has(fn) ? 'nomatch' : null);
if (!st) return '';
const M = {
queued: ['bg-black/60 text-fb-textDim', '• Queued'],
working: ['bg-fb-primary text-white', '⟳ Matching…'],
done: ['bg-fb-good/90 text-black', '✓ Updated'],
nochange: ['bg-black/60 text-fb-textDim', '— No match'],
queued: ['bg-black/60 text-fb-textDim', '• Queued', ''],
working: ['bg-fb-primary text-white', '⟳ Matching…', ''],
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. 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[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
@@ -870,7 +888,7 @@
return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' +
'<img src="' + esc(artUrl(shown)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + enrichBadge(key) + overlay +
tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + enrichBadge(key, song.unmatched) + overlay +
'<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
inlineBtns +
'<button data-fav data-fav-idle="text-white" title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' +
@@ -938,7 +956,7 @@
// address the local DB / filesystem). Both openers (⋮ and
// right-click) share this list, so parity is structural.
...(state.provider === 'local' && song.filename ? [
{ id: '__fixmatch', label: 'Fix match…' },
{ id: '__fixmatch', label: 'Fix metadata…' },
{ id: '__cover', label: 'Change cover…' },
{ id: '__refreshmeta', label: 'Refresh metadata' },
{ id: '__getinfo', label: 'Get info…' },
@@ -989,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') {
@@ -1456,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) => {
@@ -3101,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();
}
@@ -3489,6 +3514,7 @@
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
'<button id="v3-songs-refresh" title="Refresh library (scan for new songs)" class="' + ctrl + '">⟳ Refresh</button>' +
'<button id="v3-songs-refresh-meta" title="Refresh metadata for the songs shown (re-match titles, artwork &amp; more)" class="' + ctrl + '">🏷 Metadata</button>' +
'<button id="v3-songs-unmatched" title="Show only songs with no metadata match" class="' + ctrl + ((state.filters.match || []).includes('unmatched') ? ' bg-fb-primary text-white' : '') + '">Unmatched</button>' +
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
'</div></div></div>' +
// Practice-aware library home: a repertoire progress meter + a
@@ -3559,6 +3585,7 @@
// Refresh Metadata: local-only, so hide it for remote providers. The
// button doubles as its own Stop while a pass runs (see onMetaBtnClick).
byId('v3-songs-refresh-meta')?.addEventListener('click', onMetaBtnClick);
byId('v3-songs-unmatched')?.addEventListener('click', toggleUnmatchedFilter);
_updateMetaBtnVisibility();
// Reflect a scan already in progress (Settings button or a background
// pass) on the Refresh button, so its state isn't just tied to clicks here.
@@ -3808,8 +3835,24 @@
let _metaRunning = false;
function _updateMetaBtnVisibility() {
const local = state.provider === 'local'; // enrichment + its filter are local-only
const btn = document.getElementById('v3-songs-refresh-meta');
if (btn) btn.style.display = (state.provider === 'local') ? '' : 'none';
if (btn) btn.style.display = local ? '' : 'none';
const um = document.getElementById('v3-songs-unmatched');
if (um) um.style.display = local ? '' : 'none';
}
// Quick "Show unmatched" — the same filter as the drawer's Match → Unmatched,
// one click from the toolbar so the no-match pile is reachable right after a
// batch. Toggles the button + re-queries the grid.
function toggleUnmatchedFilter() {
const m = state.filters.match || (state.filters.match = []);
const i = m.indexOf('unmatched');
const on = i < 0;
if (on) m.push('unmatched'); else m.splice(i, 1);
const btn = document.getElementById('v3-songs-unmatched');
if (btn) { btn.classList.toggle('bg-fb-primary', on); btn.classList.toggle('text-white', on); }
reload();
}
// The local filenames the grid is currently SHOWING (data-fn is the local
@@ -3838,6 +3881,18 @@
function _clearMetaTiles() {
Object.keys(_metaTile).forEach((fn) => { delete _metaTile[fn]; });
document.querySelectorAll('.v3-meta-tile').forEach((el) => el.remove());
// The persistent "No match" badge derives from _unmatched (not _metaTile),
// yet shares the .v3-meta-tile class — so the blanket remove above strips it.
// Repaint the resting indicator on any rendered card so a metadata rescan's
// tile-clear doesn't silently drop it until the next scroll/re-render.
_unmatched.forEach((fn) => {
const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn;
document.querySelectorAll('[data-fn="' + sel + '"] [data-v3-play]').forEach((play) => {
if (play.querySelector('.v3-meta-tile')) return;
const html = enrichBadge(fn);
if (html) play.insertAdjacentHTML('beforeend', html);
});
});
}
+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');
});
+274
View File
@@ -0,0 +1,274 @@
"""Tests for the per-field metadata override + lock store (Fix-metadata popup).
A reversible DISPLAY overlay, never written to the pack: filename-keyed, so it
survives a rescan (never purged by delete_missing) and is dropped only with the
song (delete_song). Locks pin a field against a later auto-match.
"""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _put(server, fn, **meta):
base = {"title": "Song", "artist": "Artist", "album": "", "duration": 100,
"arrangements": [{"name": "Lead", "index": 0}]}
base.update(meta)
server.meta_db.put(fn, 0, 0, base)
# ── store semantics ───────────────────────────────────────────────────────────
def test_set_get_and_partial_upsert(server):
db = server.meta_db
assert db.get_song_overrides("a.archive") == {}
db.set_song_override("a.archive", "artist", value="AC/DC")
assert db.get_song_overrides("a.archive") == {"artist": {"value": "AC/DC", "locked": False}}
# partial: lock without touching the value
db.set_song_override("a.archive", "artist", locked=True)
assert db.get_song_overrides("a.archive")["artist"] == {"value": "AC/DC", "locked": True}
# partial: change the value, keep the lock
db.set_song_override("a.archive", "artist", value="AC/DC (fixed)")
assert db.get_song_overrides("a.archive")["artist"] == {"value": "AC/DC (fixed)", "locked": True}
def test_lock_only_row_persists_without_a_value(server):
db = server.meta_db
db.set_song_override("a.archive", "year", locked=True)
# a pure lock (no override value) is a valid, kept row
assert db.get_song_overrides("a.archive") == {"year": {"value": None, "locked": True}}
def test_empty_and_unlocked_drops_the_row(server):
db = server.meta_db
db.set_song_override("a.archive", "album", value="X", locked=True)
db.set_song_override("a.archive", "album", value="", locked=False)
assert db.get_song_overrides("a.archive") == {} # no empty shell
def test_clear_one_field_leaves_others(server):
db = server.meta_db
db.set_song_override("a.archive", "title", value="T")
db.set_song_override("a.archive", "artist", value="A")
db.clear_song_override("a.archive", "title")
assert set(db.get_song_overrides("a.archive")) == {"artist"}
# ── lifecycle: rescan survival vs explicit delete ─────────────────────────────
def test_rescan_never_purges_overrides_delete_does(server):
_put(server, "a.archive")
server.meta_db.set_song_override("a.archive", "artist", value="AC/DC", locked=True)
server.meta_db.delete_missing(set()) # file vanished from a scan
assert server.meta_db.get_song_overrides("a.archive")["artist"]["value"] == "AC/DC"
server.meta_db.purge_song_user_data("a.archive") # the delete_song purge
assert server.meta_db.get_song_overrides("a.archive") == {}
def test_overrides_map_batches(server):
db = server.meta_db
db.set_song_override("a.archive", "artist", value="A")
db.set_song_override("b.archive", "title", value="B", locked=True)
m = db.overrides_map(["a.archive", "b.archive", "missing.archive"])
assert m["a.archive"]["artist"]["value"] == "A"
assert m["b.archive"]["title"] == {"value": "B", "locked": True}
assert "missing.archive" not in m
assert db.overrides_map([]) == {}
# ── API ───────────────────────────────────────────────────────────────────────
def test_api_put_get_and_clear(client, server):
_put(server, "a.archive")
r = client.put("/api/song/a.archive/overrides",
json={"overrides": {"artist": {"value": "AC/DC", "locked": True},
"year": {"value": "1979"}}})
assert r.status_code == 200
ov = r.json()["overrides"]
assert ov["artist"] == {"value": "AC/DC", "locked": True}
assert ov["year"] == {"value": "1979", "locked": False}
assert client.get("/api/song/a.archive/overrides").json()["overrides"]["artist"]["value"] == "AC/DC"
# clear via PUT (value null + unlocked) — DELETE is shadowed by /api/song/{path}
client.put("/api/song/a.archive/overrides",
json={"overrides": {"artist": {"value": None, "locked": False}}})
assert "artist" not in client.get("/api/song/a.archive/overrides").json()["overrides"]
def test_api_get_returns_pack_values(client, server):
_put(server, "a.archive", title="Pack Title", artist="Pack Artist",
album="Pack Album", year="1988")
server.meta_db.set_song_override("a.archive", "title", value="Fixed Title")
body = client.get("/api/song/a.archive/overrides").json()
# the override rides "overrides"; the pack baseline rides "pack" (all 5 fields)
assert body["overrides"]["title"]["value"] == "Fixed Title"
assert body["pack"] == {"title": "Pack Title", "artist": "Pack Artist",
"album": "Pack Album", "year": "1988", "genre": ""}
# a song with no row still gets an all-empty pack (popup always has values)
assert client.get("/api/song/ghost.archive/overrides").json()["pack"]["title"] == ""
def test_api_rejects_unknown_field(client, server):
_put(server, "a.archive")
r = client.put("/api/song/a.archive/overrides",
json={"overrides": {"tuning": {"value": "Drop D"}}})
assert r.status_code == 400
assert "unknown field" in r.json()["error"]
# ── lock enforcement (slice 2) ────────────────────────────────────────────────
def test_locked_fields_reader(server):
db = server.meta_db
db.set_song_override("a.archive", "artist", value="X", locked=True)
db.set_song_override("a.archive", "title", value="Y") # override, not locked
db.set_song_override("a.archive", "year", locked=True) # lock only
assert db.locked_fields("a.archive") == {"artist", "year"}
def test_compose_lock_filter_strips_locked_cand_keys(server):
f = server._compose_lock_filter(None, {"artist", "year"})
cand = {"recording_id": "r", "artist": "X", "artist_sort": "X", "title": "T",
"year": "1990", "album": "A", "genres": ["rock"]}
out = f(cand)
# locked display keys stripped (artist maps to artist + artist_sort)…
assert not ({"artist", "artist_sort", "year"} & set(out))
# …identity + unlocked display fields survive
assert out["recording_id"] == "r" and out["title"] == "T" and out["album"] == "A"
# no locks → base filter returned unchanged (zero-copy common path)
assert server._compose_lock_filter(None, set()) is None
# ── display overlay in the grid (slice 3) ─────────────────────────────────────
# "Grid shows only overrides": the effective cell is the user's override else the
# pack value. Display-only + keyset-safe — the seek stays on the raw column.
def _grid(server, **kw):
songs, _ = server.meta_db.query_page(**kw)
return {s["filename"]: s for s in songs}
def test_grid_shows_override_value_over_pack(server):
_put(server, "a.archive", title="Wrong Title", artist="Wrong",
album="Pack Album", year="1999")
server.meta_db.set_song_override("a.archive", "title", value="Right Title")
server.meta_db.set_song_override("a.archive", "artist", value="Right Artist")
server.meta_db.set_song_override("a.archive", "year", value="1979")
s = _grid(server)["a.archive"]
assert s["title"] == "Right Title"
assert s["artist"] == "Right Artist"
assert s["year"] == "1979"
assert s["album"] == "Pack Album" # no override → pack value shows
assert s["_sort_title"] == "Wrong Title" # raw title stashed for the cursor
def test_grid_ignores_lock_only_override(server):
_put(server, "a.archive", title="Pack Title")
server.meta_db.set_song_override("a.archive", "title", locked=True) # lock, no value
s = _grid(server)["a.archive"]
assert s["title"] == "Pack Title" # a lock without a value never retitles
assert "_sort_title" not in s # …and stashes nothing
def test_override_beats_alias_relabel_for_artist(server):
_put(server, "a.archive", artist="ACDC")
server.meta_db.set_artist_alias("ACDC", "AC/DC") # P4 alias
assert _grid(server)["a.archive"]["artist"] == "AC/DC" # alias applies alone
server.meta_db.set_song_override("a.archive", "artist", value="AC-DC (mine)")
assert _grid(server)["a.archive"]["artist"] == "AC-DC (mine)" # override wins over alias
def test_route_strips_private_sort_title(client, server):
_put(server, "a.archive", title="Pack")
server.meta_db.set_song_override("a.archive", "title", value="Shown")
row = next(s for s in client.get("/api/library?sort=title").json()["songs"]
if s["filename"] == "a.archive")
assert row["title"] == "Shown"
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")
_put(server, "a.archive", title="A")
_put(server, "c.archive", title="C")
# Overrides that would reshuffle the order IF the cursor wrongly used the
# displayed value — the seek must stay on the raw title, so paging still
# covers every row exactly once (no skip/dupe).
server.meta_db.set_song_override("a.archive", "title", value="ZZZ")
server.meta_db.set_song_override("c.archive", "title", value="AAA")
seen, cursor = [], None
for _ in range(10):
url = "/api/library?sort=title&size=1" + (f"&after={cursor}" if cursor else "")
data = client.get(url).json()
if not data["songs"]:
break
seen.append(data["songs"][0]["filename"])
cursor = data["next_cursor"]
if not cursor:
break
assert sorted(seen) == ["a.archive", "b.archive", "c.archive"] # each exactly once
+13
View File
@@ -110,6 +110,19 @@ def test_preview_excludes_author_set_keys(server, client):
assert {"genres", "mbid", "isrc"} <= got
def test_preview_excludes_locked_fields(server, client):
"""A field LOCKED in the Fix-metadata popup is never gap-filled — writing
the matched value would be exactly the clobber the lock prevents even
though the match has a value and the manifest lacks it."""
make_dir_sloppak(server, "a.sloppak")
seed_match(server, "a.sloppak")
server.meta_db.set_song_override("a.sloppak", "album", locked=True)
server.meta_db.set_song_override("a.sloppak", "year", locked=True)
got = {m["key"] for m in client.get("/api/song/a.sloppak/gap-fill").json()["missing"]}
assert "album" not in got and "year" not in got
assert {"genres", "mbid", "isrc"} <= got # unlocked keys still offered
def test_preview_excludes_present_but_empty_keys(server, client):
"""Gap-fill is append-only, so a present-but-empty value (album: '',
year: 0) is NOT a gap the writer can fill appending would duplicate the
+73
View File
@@ -122,6 +122,79 @@ def test_parse_bcfs_rejects_bad_magic():
_parse_bcfs(b"NOPE" + b"\x00" * 16)
# ── _parse_bcfs container round-trip (GP6 .gpx partial final-sector) ─────────
def _build_bcfs(entries, short_by=0):
"""Assemble a minimal in-memory BCFS container for _parse_bcfs.
``entries`` is ``[(name: bytes, payload: bytes, data_sector: int), ...]``.
The directory entry for entry *i* is written to sector ``i + 1``; each
entry's payload goes in the sector index it names. ``short_by`` truncates
the final buffer by N bytes to emulate a real .gpx's partial trailing
sector (the BCFZ-declared decompressed size isn't 0x1000-aligned). Layout
mirrors the reader: a 4-byte ``BCFS`` header, then 0x1000-byte sectors,
with every value read at ``HDR + sector * 0x1000``.
"""
SECTOR = 0x1000
HDR = 4
max_sector = max([e[2] for e in entries] + [len(entries)])
buf = bytearray(b"BCFS" + b"\x00" * ((max_sector + 1) * SECTOR))
def put_u32(off, val):
struct.pack_into("<I", buf, HDR + off, val)
for i, (name, payload, data_sector) in enumerate(entries):
dir_off = (i + 1) * SECTOR # directory entry -> sector i+1
put_u32(dir_off + 0x00, 2) # entry type: file
nm = name[:127]
buf[HDR + dir_off + 0x04: HDR + dir_off + 0x04 + len(nm)] = nm
put_u32(dir_off + 0x8C, len(payload)) # declared file size
put_u32(dir_off + 0x94, data_sector) # first data-sector pointer
put_u32(dir_off + 0x94 + 4, 0) # chain terminator
dpos = HDR + data_sector * SECTOR
buf[dpos: dpos + len(payload)] = payload
if short_by:
del buf[len(buf) - short_by:]
return bytes(buf)
def test_parse_bcfs_reads_short_final_sector():
"""The regression: a real .gpx ends a byte short of a full 0x1000 sector,
so its last (small) container file lands in a partial trailing sector. The
reader must clamp that read, not reject the whole container rejecting it
is what made every GP6 .gpx fail to import with 'sector pointer out of
range'."""
bcfs = _build_bcfs([(b"score.gpif", b"hello", 2)], short_by=1)
assert (len(bcfs) - 4) % 0x1000 == 0x1000 - 1 # final sector is 1 short
assert _parse_bcfs(bcfs)["score.gpif"] == b"hello"
def test_parse_bcfs_full_sector_round_trip():
"""A sector-aligned container round-trips unchanged (baseline)."""
assert _parse_bcfs(_build_bcfs([(b"misc.xml", b"<x/>", 2)]))["misc.xml"] == b"<x/>"
def test_parse_bcfs_multi_file_short_final_sector():
"""Real-world shape: score.gpif plus small config files, the last one in
the partial trailing sector."""
out = _parse_bcfs(_build_bcfs([
(b"score.gpif", b"<GPIF/>", 3),
(b"LayoutConfiguration", b"AB", 4),
], short_by=1))
assert out["score.gpif"] == b"<GPIF/>"
assert out["LayoutConfiguration"] == b"AB"
def test_parse_bcfs_rejects_sector_starting_past_end():
"""A sector pointer whose *start* is beyond the container is genuinely
malformed and must still raise the clamp tolerates a partial final
sector, not arbitrary out-of-range pointers."""
bcfs = bytearray(_build_bcfs([(b"x", b"y", 2)]))
struct.pack_into("<I", bcfs, 4 + 0x1000 + 0x94, 9999) # absurd data-sector ptr
with pytest.raises(ValueError, match="out of range"):
_parse_bcfs(bytes(bcfs))
# ── _note_is_tie ────────────────────────────────────────────────────────────
def test_note_is_tie_destination():
+284
View File
@@ -0,0 +1,284 @@
"""Tests for the librosa-free piecewise time-warp helpers in lib/gp_autosync.py
(bar_start_times / build_warp_anchors / warp_time / warp_song_times /
gp_has_expandable_repeats) plus refine_sync's pure fallbacks.
Fixture-free, matching tests/test_gp_audio_sync.py: every test drives a pure
helper with hand-built inputs (in-memory GPIF zips, synthetic sync points,
hand-rolled Song objects). The librosa-backed sweep inside refine_sync needs
real audio and is covered by manual validation in the PR.
"""
import io
import zipfile
import xml.etree.ElementTree as ET
import pytest
import gp_autosync as ga
from gp8_audio_sync import GpSyncData, SyncPoint
from song import (
Anchor,
Arrangement,
Beat,
Chord,
HandShape,
Note,
Phrase,
PhraseLevel,
Section,
Song,
)
# ── helpers ───────────────────────────────────────────────────────────────────
def _gpif_zip(gpif_xml: str) -> bytes:
"""Build an in-memory .gp container holding the given score.gpif."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("Content/score.gpif", gpif_xml)
return buf.getvalue()
def _gpif(tempo_autos: list[tuple[int, float]], bar_sigs: list[str]) -> str:
autos = "".join(
f"<Automation><Type>Tempo</Type><Bar>{bar}</Bar>"
f"<Value>{bpm} 2</Value></Automation>"
for bar, bpm in tempo_autos
)
bars = "".join(f"<MasterBar><Time>{sig}</Time></MasterBar>" for sig in bar_sigs)
return (
"<GPIF><MasterTrack><Automations>"
f"{autos}</Automations></MasterTrack>"
f"<MasterBars>{bars}</MasterBars></GPIF>"
)
def _sp(bar, t, mod=120.0, orig=120.0):
return SyncPoint(bar=bar, time_secs=t, modified_tempo=mod, original_tempo=orig)
# ── bar_start_times ───────────────────────────────────────────────────────────
def test_bar_start_times_constant_tempo(tmp_path):
# 120 BPM, 4/4 → every bar is exactly 2s
gp = tmp_path / "song.gp"
gp.write_bytes(_gpif_zip(_gpif([(0, 120.0)], ["4/4"] * 4)))
assert ga.bar_start_times(str(gp)) == pytest.approx([0.0, 2.0, 4.0, 6.0])
def test_bar_start_times_tempo_change_and_meter(tmp_path):
# Bar 0-1 at 120 (4/4 → 2s each), bar 2 switches to 60 in 3/4 (3s)
gp = tmp_path / "song.gp"
gp.write_bytes(
_gpif_zip(_gpif([(0, 120.0), (2, 60.0)], ["4/4", "4/4", "3/4", "3/4"]))
)
assert ga.bar_start_times(str(gp)) == pytest.approx([0.0, 2.0, 4.0, 7.0])
# ── build_warp_anchors ────────────────────────────────────────────────────────
def test_build_warp_anchors_maps_bars_to_score_time():
bar_starts = [0.0, 2.0, 4.0, 6.0]
points = [_sp(0, 1.0), _sp(2, 5.4)]
assert ga.build_warp_anchors(points, bar_starts) == [(0.0, 1.0), (4.0, 5.4)]
def test_build_warp_anchors_drops_nonmonotonic_and_out_of_range():
bar_starts = [0.0, 2.0, 4.0, 6.0]
points = [
_sp(0, 1.0),
_sp(1, 0.5), # audio time goes backwards — dropped
_sp(2, 5.4),
_sp(99, 9.9), # bar out of range — dropped
]
assert ga.build_warp_anchors(points, bar_starts) == [(0.0, 1.0), (4.0, 5.4)]
def test_build_warp_anchors_drops_implausible_slopes():
# 2s score bars. A DTW fold (or a run of monotonicity-clamped refine
# points) can produce a near-flat audio segment — slope far below the
# 0.2x plausibility floor — which would crush every bar in the span.
bar_starts = [float(2 * b) for b in range(11)]
points = [
_sp(0, 1.0),
_sp(4, 9.0), # slope 1.0 — kept
_sp(8, 9.1), # slope 0.0125 over 8s of score — dropped
_sp(10, 21.0), # slope 1.0 vs the last KEPT anchor — kept
]
assert ga.build_warp_anchors(points, bar_starts) == [
(0.0, 1.0), (8.0, 9.0), (20.0, 21.0)
]
def test_build_warp_anchors_requires_two_points():
assert ga.build_warp_anchors([_sp(0, 1.0)], [0.0, 2.0]) == []
assert ga.build_warp_anchors([], [0.0, 2.0]) == []
# ── warp_time ─────────────────────────────────────────────────────────────────
def test_warp_time_interpolates_between_anchors():
anchors = [(0.0, 1.0), (10.0, 21.0)] # slope 2, offset 1
assert ga.warp_time(0.0, anchors) == pytest.approx(1.0)
assert ga.warp_time(5.0, anchors) == pytest.approx(11.0)
assert ga.warp_time(10.0, anchors) == pytest.approx(21.0)
def test_warp_time_piecewise_segments():
# First half plays at authored speed, second half at half speed
anchors = [(0.0, 0.0), (10.0, 10.0), (20.0, 30.0)]
assert ga.warp_time(5.0, anchors) == pytest.approx(5.0)
assert ga.warp_time(15.0, anchors) == pytest.approx(20.0)
def test_warp_time_extrapolates_with_edge_slopes():
anchors = [(10.0, 20.0), (20.0, 40.0)] # slope 2
assert ga.warp_time(5.0, anchors) == pytest.approx(10.0) # before first
assert ga.warp_time(25.0, anchors) == pytest.approx(50.0) # after last
def test_warp_time_preserves_order():
anchors = [(0.0, 0.5), (4.0, 4.1), (8.0, 9.3), (12.0, 12.9)]
times = [i * 0.37 for i in range(40)]
warped = [ga.warp_time(t, anchors) for t in times]
assert warped == sorted(warped)
# ── warp_song_times ───────────────────────────────────────────────────────────
def _shifted_double(t):
return 2.0 * t + 1.0
def test_warp_song_times_covers_all_time_fields():
song = Song(
song_length=100.0,
beats=[Beat(time=0.0, measure=1), Beat(time=1.0, measure=-1)],
sections=[Section(name="verse", number=1, start_time=10.0)],
arrangements=[
Arrangement(
name="Lead",
notes=[Note(time=2.0, string=0, fret=3, sustain=1.0)],
chords=[
Chord(
time=4.0,
chord_id=0,
notes=[Note(time=4.0, string=1, fret=2, sustain=0.5)],
)
],
anchors=[Anchor(time=6.0, fret=3)],
hand_shapes=[HandShape(chord_id=0, start_time=4.0, end_time=5.0)],
phrases=[
Phrase(
start_time=0.0,
end_time=8.0,
max_difficulty=0,
levels=[
PhraseLevel(
difficulty=0,
notes=[Note(time=3.0, string=0, fret=0, sustain=2.0)],
)
],
)
],
tones={"base": "clean", "changes": [{"t": 7.0, "name": "lead"}]},
tempos=[{"time": 0.0, "bpm": 120.0}],
)
],
)
ga.warp_song_times(song, _shifted_double)
assert song.song_length == pytest.approx(201.0)
assert [b.time for b in song.beats] == pytest.approx([1.0, 3.0])
assert song.sections[0].start_time == pytest.approx(21.0)
arr = song.arrangements[0]
n = arr.notes[0]
assert n.time == pytest.approx(5.0)
assert n.sustain == pytest.approx(2.0) # (2+1)*2+1 - 5
ch = arr.chords[0]
assert ch.time == pytest.approx(9.0)
assert ch.notes[0].time == pytest.approx(9.0)
assert ch.notes[0].sustain == pytest.approx(1.0)
assert arr.anchors[0].time == pytest.approx(13.0)
hs = arr.hand_shapes[0]
assert (hs.start_time, hs.end_time) == (pytest.approx(9.0), pytest.approx(11.0))
ph = arr.phrases[0]
assert (ph.start_time, ph.end_time) == (pytest.approx(1.0), pytest.approx(17.0))
assert ph.levels[0].notes[0].time == pytest.approx(7.0)
assert ph.levels[0].notes[0].sustain == pytest.approx(4.0)
assert arr.tones["changes"][0]["t"] == pytest.approx(15.0)
assert arr.tempos[0]["time"] == pytest.approx(1.0)
def test_warp_song_times_clamps_negative_sustain():
# A non-monotonic warp callable must not produce negative sustains
song = Song(arrangements=[
Arrangement(name="Lead",
notes=[Note(time=1.0, string=0, fret=0, sustain=1.0)])
])
ga.warp_song_times(song, lambda t: 5.0 - t) # decreasing map
assert song.arrangements[0].notes[0].sustain == 0.0
# ── gp_has_expandable_repeats ─────────────────────────────────────────────────
def test_gpif_files_never_expand_repeats(tmp_path):
# GPIF conversion is single-pass as-written, so .gp/.gpx are always False
gp = tmp_path / "song.gp"
gp.write_bytes(_gpif_zip(_gpif([(0, 120.0)], ["4/4"])))
assert ga.gp_has_expandable_repeats(str(gp)) is False
def test_gp345_unparseable_returns_false(tmp_path):
bad = tmp_path / "song.gp5"
bad.write_bytes(b"not a real gp5 file")
assert ga.gp_has_expandable_repeats(str(bad)) is False
def test_gp345_repeats_detected(tmp_path):
guitarpro = pytest.importorskip("guitarpro")
song = guitarpro.models.Song()
track = guitarpro.models.Track(song)
song.tracks = [track]
# Bar 2 of 3 opens a repeat
for _ in range(2):
header = guitarpro.models.MeasureHeader()
song.addMeasureHeader(header)
song.measureHeaders[1].isRepeatOpen = True
for header in song.measureHeaders:
track.measures.append(guitarpro.models.Measure(track, header))
path = tmp_path / "repeat.gp5"
guitarpro.write(song, str(path))
assert ga.gp_has_expandable_repeats(str(path)) is True
def test_gp345_plain_song_no_repeats(tmp_path):
guitarpro = pytest.importorskip("guitarpro")
song = guitarpro.models.Song()
track = guitarpro.models.Track(song)
song.tracks = [track]
for _ in range(2):
header = guitarpro.models.MeasureHeader()
song.addMeasureHeader(header)
for header in song.measureHeaders:
track.measures.append(guitarpro.models.Measure(track, header))
path = tmp_path / "plain.gp5"
guitarpro.write(song, str(path))
assert ga.gp_has_expandable_repeats(str(path)) is False
# ── refine_sync pure fallbacks ────────────────────────────────────────────────
def test_refine_sync_empty_points_returns_input():
sync = GpSyncData(audio_offset=0.0, audio_asset_id="", sync_points=[])
assert ga.refine_sync(sync, "/nonexistent.ogg") is sync
def test_refine_sync_single_point_returns_input():
# One point → fewer than 2 warp anchors → unchanged, no audio load
sync = GpSyncData(audio_offset=-1.0, audio_asset_id="",
sync_points=[_sp(0, 1.0)])
assert ga.refine_sync(sync, "/nonexistent.ogg") is sync
+13
View File
@@ -614,3 +614,16 @@ def test_artist_filter_is_case_insensitive(client, seeded):
data = _get(client, artist="a band")
assert data["total"] == 1
assert data["songs"][0]["filename"] == "a.archive"
def test_unmatched_flag_and_quick_filter(server_mod, client):
# A per-card "no match" badge needs the row to carry the enrichment state.
_put(server_mod, filename="a.archive", title="Matched", artist="A")
_put(server_mod, filename="b.archive", title="Missed", artist="B")
server_mod.meta_db.apply_enrichment_match("b.archive", "h", "failed") # no-match
rows = {s["filename"]: s for s in server_mod.meta_db.query_page()[0]}
assert rows["b.archive"]["unmatched"] is True
assert rows["a.archive"]["unmatched"] is False
# The "Unmatched" quick-filter (match=unmatched) returns only the failed song.
fns = [s["filename"] for s in client.get("/api/library?match=unmatched").json()["songs"]]
assert fns == ["b.archive"]
+104
View File
@@ -97,6 +97,110 @@ def mb_doc(rid="rec-1", title="Thunderstruck", artist="AC/DC", artist_id="art-1"
}
# ── strict-then-loose search fallback ────────────────────────────────────────
def test_search_falls_back_to_loose_when_strict_is_empty(server, monkeypatch):
"""The strict field-phrase query misses a non-Latin-primary artist; the
loose retry (no field scoping) searches aliases and finds it."""
calls = []
def _routed(path, params):
q = params.get("query", "")
calls.append(q)
if q.startswith("recording:"): # strict phrase → nothing
return {"recordings": []}
return {"recordings": [mb_doc(rid="rec-x", title="Telephone Number")]}
monkeypatch.setattr(server, "_mb_http_get", _routed)
cands = server._mb_search_recordings("Junko Ohashi", "Telephone Number")
assert len(cands) == 1
assert len(calls) == 2 # strict first, then the loose retry
assert calls[0].startswith("recording:") # strict is the field-phrase form
assert "artist:" not in calls[1] and '"' not in calls[1] # loose retry
def test_search_does_not_retry_when_strict_hits(server, monkeypatch):
"""A strict hit must not spend a second (throttled) request on the loose
query."""
calls = []
def _routed(path, params):
calls.append(params.get("query", ""))
return {"recordings": [mb_doc()]}
monkeypatch.setattr(server, "_mb_http_get", _routed)
cands = server._mb_search_recordings("AC/DC", "Thunderstruck")
assert len(cands) == 1
assert len(calls) == 1
# ── alias-aware scoring (non-Latin-primary artists) ──────────────────────────
_AID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
def test_artist_aliases_fetched_and_cached(server, monkeypatch):
calls = []
def fake(path, params):
calls.append(path)
return {"sort-name": "Ohashi, Junko",
"aliases": [{"name": "Junko Ohashi"}, {"name": "大橋 純子"}]}
monkeypatch.setattr(server, "_mb_http_get", fake)
names = server._mb_artist_aliases(_AID)
assert "Junko Ohashi" in names and "Ohashi, Junko" in names
server._mb_artist_aliases(_AID) # cached → no second request
assert len(calls) == 1
def test_artist_aliases_rejects_bad_id(server, monkeypatch):
def boom(path, params):
raise AssertionError("must not fetch for a non-UUID id")
monkeypatch.setattr(server, "_mb_http_get", boom)
assert server._mb_artist_aliases("not-a-uuid") == []
def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
# A pack whose (romanized) artist MB stores under a Japanese primary name.
_put(server, "x.sloppak", title="Telephone Number", artist="Junko Ohashi")
def _routed(path, params):
if path.startswith("artist/"): # alias lookup
return {"sort-name": "Ohashi, Junko",
"aliases": [{"name": "Junko Ohashi"}]}
q = params.get("query", "")
if q.startswith("recording:"): # strict phrase → nothing
return {"recordings": []}
return {"recordings": [mb_doc(rid="rec-jp", title="Telephone Number",
artist="大橋純子", artist_id=_AID)]} # loose hit
monkeypatch.setattr(server, "_mb_http_get", _routed)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
server._background_enrich()
row = server.meta_db.get_enrichment("x.sloppak")
# The romanized alias lifts the artist over the auto floor → auto-confirmed.
assert row["match_state"] == "matched"
assert row["mb_recording_id"] == "rec-jp"
# ── per-song field locks respected by the auto-matcher ───────────────────────
def test_locked_field_not_canonicalized_by_auto_match(server, monkeypatch):
_put(server, "x.sloppak") # title "Thunderstruck (v2)", artist "ACDC"
server.meta_db.set_song_override("x.sloppak", "artist", locked=True)
monkeypatch.setattr(server, "_mb_http_get",
lambda path, params: {"recordings": [mb_doc()]})
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
server._background_enrich()
row = server.meta_db.get_enrichment("x.sloppak")
assert row["match_state"] == "matched" # still matches (identity applies)…
assert row["canon_artist"] is None # …but the LOCKED artist isn't canonicalized
assert row["canon_title"] == "Thunderstruck" # unlocked display fields still apply
assert row["mb_recording_id"] # identity keys still stored (art needs them)
# ── offline safety (the pytest-never-hits-network contract) ──────────────────
def test_offline_default_skips_matching(server, monkeypatch):
+48
View File
@@ -189,6 +189,54 @@ def test_build_recording_query_escapes_and_handles_missing_artist():
assert "artist:" not in q
def test_build_recording_query_loose_drops_field_phrases():
# The strict form locks to the *primary* artist/title phrase (and drops
# live-only recordings — the chart isn't a live take).
assert m.build_recording_query("Junko Ohashi", "Telephone Number") == \
'recording:"telephone number" AND artist:"junko ohashi" AND -secondarytype:Live'
# The loose form has no field scoping and no phrases, so MusicBrainz also
# searches artist ALIASES — rescues non-Latin-primary artists (大橋純子) —
# but keeps the same live exclusion (a studio chart must not fall back to a
# live-only recording).
loose = m.build_recording_query("Junko Ohashi", "Telephone Number", loose=True)
assert loose == "(telephone number) AND (junko ohashi) AND -secondarytype:Live"
assert "artist:" not in loose and '"' not in loose
def test_build_recording_query_loose_missing_artist():
assert m.build_recording_query("", "Fantasy", loose=True) == \
"(fantasy) AND -secondarytype:Live"
def test_build_recording_query_loose_keeps_live_for_live_charts():
# A live chart's loose fallback must NOT exclude live recordings (same gate
# as the strict path) — else its only correct recording is filtered out.
loose = m.build_recording_query("AC/DC", "Highway to Hell (Live at Donington)", loose=True)
assert "-secondarytype:Live" not in loose
assert loose == "(highway to hell) AND (ac dc)"
# ── alias-aware artist scoring ────────────────────────────────────────────────
def test_cand_artist_sim_uses_aliases():
song = {"artist": "Junko Ohashi", "title": "Telephone Number"}
# primary is the Japanese name → romanized reference scores 0…
assert m.cand_artist_sim(song, {"artist": "大橋純子"}) == 0.0
# …but a romanized alias confirms it
assert m.cand_artist_sim(
song, {"artist": "大橋純子", "artist_aliases": ["Ohashi Junko", "Junko Ohashi"]}) == 1.0
def test_alias_lifts_candidate_to_auto():
song = {"artist": "Junko Ohashi", "title": "Telephone Number"}
jp = {"artist": "大橋純子", "title": "Telephone Number"}
# Without the alias: title matches but the artist floor fails → never auto.
assert m.classify(song, jp, m.score_candidate(song, jp)) != "auto"
# With the romanized alias attached: artist clears the floor → auto.
jp_alias = dict(jp, artist_aliases=["Junko Ohashi"])
assert m.classify(song, jp_alias, m.score_candidate(song, jp_alias)) == "auto"
# ── MusicBrainz response parsing ──────────────────────────────────────────────
MB_DOC = {