Commit Graph
15 Commits
Author SHA1 Message Date
KeelrLeek 4c0c4a1ea5 cover logo: supersample the winner, and scale to the target not past it
Five changes to the mask the tracer receives, plus the diagnostic needed to
tell them apart.

The alpha ramp's bottom now scales with the measured ink distance instead of
sitting at a fixed 46. On a faint mark that constant ate the outer half of
every edge gradient -- Wand's "Machine Man / M.E." inks at distance 97, so the
pixel carrying ~20% coverage was forced fully transparent and the boundary
quantised to whole source pixels. The finished alpha is then rescaled so the
glyph body reaches opacity, bounded at 4x; narrowing the ramp onto the ink was
tried instead and let paper grain bite the edges.

A BLIND OCR read (zero lines, not "no match") retries once at 512px. Windows
OCR is scale-sensitive on large display type and reads that cover at 512 and
at no other size; gating on blindness rather than on no-match kept a 21-artist
regeneration at 1m47s where the looser gate took ~6min.

The winning cover is re-cut with the crop resampled up BEFORE the alpha ramp,
so smoothstep evaluates interpolated colour and places the edge between source
pixels. Selection still runs at native scale: extract_wordmark feeds MIN_MARK_H
and the aa/coverage/bleed score, so supersampling during scoring changes which
cover wins.

That scale is fractional. A floored integer factor is 1 for every crop wider
than half the target, so at 2400 a crop of 1201px was supersampled by nothing
at all while 1200px got 2x -- and the covers on the wrong side of that cliff
are the large printed wordmarks, where the staircase was worst. Measured over
eight artists, Hot Toddy's zigzag share fell 0.312 -> 0.048 and Crazy P's
0.220 -> 0.021, with every other artist unchanged. 2400 itself is confirmed by
sweeping 1200/3600/4800: past 2400 the mask overshoots the tracer's raster so
the browser downscales what Rust just built, every shape measure goes flat and
the data URL doubles.

catalog_cover_logo_debug reports the rejection reason and the mask itself, and
its cover cap follows COVER_LOGO_MAX_COVERS instead of undercutting it at 14 --
where it hid exactly the covers production relies on. The artist page also
re-runs the logo pass when ownership changes the covers on offer; that pass
races the identity merge and had been reading the unfiltered list, which is how
Wand wore a wordmark lifted from a co-tenant's single.
2026-07-26 09:33:11 -05:00
KeelrLeek 4d6e69e324 release ownership: one Deezer id can be seven artists
There is exactly one Deezer artist called "Wand" (1213178) and it holds the
LA psych band, a Brazilian funk MC, an Indonesian act, an Indian one, a 2020
house project and whoever released Hard Knox in 2009 — 46 of its 72 rows are
somebody else's. Ranking search results cannot help (there is one candidate)
and neither can credit role (every row is primary-credited to it).

The Apple namesake filter that should have caught this switched itself off:
APPLE_EXACT_ARTIST_CANDIDATE_LIMIT is 8 and Apple has TEN artists named Wand,
so the candidate fetch failed closed and every catalogue came back empty. It
fails closed for a good reason at page-load time — it picks the winner by
overlap with the requested artist's own Deezer releases, which on a fused
record are partly the co-tenants'. Circular evidence has to fail closed.

So the answer is not to raise that cap but to stop asking the fused list.
artist_release_ownership runs in the DEFERRED identity pass, where the linked
MBID has already named the artist, and picks the Apple performer that
reproduces THAT catalogue. Non-circular, so 20 candidates is safe. Evidence is
MB release groups of every type, the verified Apple catalogue, and the label
sets of both (MB at one sighting, Apple at two — Apple files two rafftheraff
singles under the LA band). Pruning only runs where a row is provably carried
by a different same-named Apple artist, so the gate opens on 4 of the 55
artists measured and stays shut on Wednesday's twenty namesakes, Jungle's
sixteen and Roosevelt's fourteen.

The same evidence repairs the opposite failure. The guest guard keeps an
appears_on row only if MB credits it to the artist's own MBID, which assumes
MB mirrors collaborations. It does for Watch the Throne, whose artist CREDIT
names both. It does not when the collaboration is its own entity: VULTURES 1,
VULTURES 2 and KIDS SEE GHOSTS appear nowhere in Ye's 315 release groups, and
all three were being erased from his page. Apple's artistName credit, read as
a list of principals, restores them without restoring guest spots — Estelle's
"American Boy (feat. Kanye West)" is still hers.

A fused record's PORTRAIT is wrong for the same reason its releases are: it is
whichever co-tenant uploaded last, a real photograph of the wrong person, so
deezerPortraitMissing cannot see it. The verdict is persisted, because the
search dropdown is typed against per keystroke and can never afford the
lookups that produced it. Its store is deliberately not in net-cache-v1 —
prune_disk_cache deletes every json there by mtime, and a verdict is not a
cached response.

Measured live: Wand 72 -> 26, Hot Toddy 36 -> 16, Crazy P 68 -> 51,
Turnover 30 -> 25, Kanye 50 -> 64; Weezer, Pond, Billy Cobb, JPEGMAFIA and
Daft Punk untouched. The page's "wand." wordmark went with the co-tenants and
should have: it was traced from the Indonesian single's cover.
2026-07-26 05:58:17 -05:00
KeelrLeek 0955dc02ce cover logo: trim the wordmark horizontally, and stop rewarding bleed
The extractor trimmed the crop VERTICALLY to the wordmark's true edge, walking
rows until a transparent gap. Horizontally it did nothing: left and right were
the OCR box plus a pad of 5% of the word's width. Three defects fell out of that
asymmetry, all measurable as ink touching the finished mask's boundary.

- Glyphs were sliced when the OCR box under-covered them. Snail Mail's italic
  "L" was cut down the middle: 87 of 163 rows inked at the right edge. Padding
  now scales with TYPE SIZE rather than word length, because an overhang is
  proportional to letter height and a long wordmark was getting the least room.
- Cover frames were swallowed when the box sat near an edge. Knocked Loose's
  mask had ink down its entire left column and across its entire top -- a
  border, not letters.
- Artwork BESIDE the text bridged the row walk. A decorative shape down the left
  of "Lamb's Wool (with Poolside)" kept every row between the two text lines
  non-empty, so the walk sailed through the gap and took the line above with it:
  the mask read "WITH / POOLSIDE". Row ink is now tallied over the artist name's
  own columns, where that gap is genuinely empty.

The column walk mirrors the row one exactly: outward from the name's columns
while ink stays contiguous, stopping at the first transparent column (so the
wider pad can never reach a neighbouring word) and at a near-fully-inked one
(a rule or frame, the same boundary `solid` already recognises for rows).

Candidate scoring gains a bleed penalty, because trimming alone made selection
WORSE. A clean mark ends with empty borders, so ink still touching one means
cover art is continuous with the text -- and that art is soft grey, which the
`aa` term reads as superb edge quality: Folamour's smeared 246x89 candidate
scores 0.44 against the clean 647x124's 0.03, and won. Penalising boundary ink
puts the clean one back in front.

Measured over the fixture roster: Knocked Loose 920x151 -> 872x88 with no frame,
Snail Mail's L intact, Poolside a clean "POOLSIDE" instead of "WITH / POOLSIDE",
Folamour back to 647x124. Camping in Alaska, Crazy P, Mildlife, Session Victim,
Turnover, Alvvays, Motor City Drum Ensemble, Spiritbox and Todd Terje all still
extract, each a little tighter now that the slack is trimmed.
2026-07-25 19:47:15 -05:00
KeelrLeek b16fc340b4 provider art: a duplicate MusicBrainz entry is not a namesake
TheAudioDB records are held to a cross-provider identity test because a shared
name is not evidence -- namesakes are the bug it protects against. But it
demanded that TheAudioDB's MBID EQUAL the MB artist Deezer links to, and treated
any other id as a different act.

MusicBrainz carries duplicate entries for the same artist more often than that
assumes. Roosevelt has two -- 84825fb6 and d7eecee2, both "Roosevelt", Person,
DE, "German producer & DJ from Cologne". Deezer links the first, TheAudioDB
references the second, so his logo was discarded as belonging to someone else.

The rescue keeps names untrusted, which is the entire point of the gate: when
the ids disagree it asks whether TheAudioDB's OWN MusicBrainz entry links back
to the Deezer artist being resolved. d7eecee2 lists
https://www.deezer.com/artist/85901 among its url relations -- two MB entries
pointing at one Deezer id are the same act whatever their ids say. It only runs
when the cheap checks fail and it rides the shared cached artist lookup, so the
hot path is unchanged.

A linked MBID still outranks TheAudioDB's own strDeezerID, which can be stale
(`linked_mbid_outranks_stale_audiodb_deezer_id` covers that and still passes) --
the doc comment's "either ... or" describes a precedence the code deliberately
does not have.

Manifest recipe bumped to v5, or artists cached under v4 keep serving the
logo-less result this build would now fill. Safe now that a stale-recipe
manifest survives as a floor rather than being deleted on read.

Roosevelt: 0 logos -> 1 (his TheAudioDB logo). Verified unchanged: Fishmans 5,
King Gizzard 7, Wednesday 4, Pond 1, Ross from Friends 1, Camping in Alaska 0
(genuinely has none).
2026-07-25 19:13:29 -05:00
KeelrLeek 613ae18e87 cover logo: fix the two ways artists silently lost their wordmark
Both regressions were invisible because the pipeline's failure mode is a shrug:
no logo, no error, fall back to plain text.

1. The dominance guard was resolution-dependent. It bins the crop into a coarse
   16-cubed RGB histogram and rejects when the dominant colour holds under half
   the pixels ("text over a photo"). A BIGGER source resolves more grain and
   JPEG texture, which spreads pixels across bins and shrinks that share -- so
   the identical crop of Alvvays' "Blue Rev" measures dominance 0.746 at 1000px
   and 0.226 at 3000px. Once the upgrade path started supplying hi-res artwork,
   covers that had segmented cleanly for months began failing a guard that had
   not changed; its input grew. The histogram and guard now read a copy
   normalised to the 1000px they were calibrated at, while the alpha ramp still
   runs at full resolution -- which is the whole reason for fetching the larger
   cover.

2. Only the top 14 covers were ever offered. The rank puts albums first and
   penalises remix/mix/edit/live titles, but a prolific artist's wordmark is
   often on exactly those: Poolside has 74 releases and SEVEN covers that OCR
   reads as "POOLSIDE", every one of them outside a 14-cover window. The cap
   goes to 40 on both sides (the frontend list must not undercut the backend's,
   since whatever it trims the extractor can never see). This is free for
   artists that hit early -- the locate loop stops at COVER_LOGO_MAX_HITS -- and
   is paid once, then cached, by artists with no wordmark at all.

Recovered: Alvvays (nothing -> 871x173), Poolside (nothing -> 528x225),
Men I Trust, and Motor City Drum Ensemble now extracts too. Verified no
regression across the rest of the research roster: Camping in Alaska, Crazy P,
Folamour, Hot Toddy, Mildlife, Session Victim, Turnover, Knocked Loose,
Snail Mail, Spiritbox and Todd Terje all still extract at their previous sizes.
2026-07-25 18:51:30 -05:00
KeelrLeek 88e0d98ddd cover logo: bump the cache recipe for the ink-scaled alpha ramp
Every persisted mask predates the anti-aliasing the trace path now relies on,
so without this bump a cached artist keeps rendering from a near-binary mask
while the tracer runs at the lighter blur that assumes real gradients -- the
worst of both. The key already hashes the recipe, so old entries simply miss.
2026-07-25 18:31:43 -05:00
KeelrLeek 21ef73770c cover logo: keep the source's anti-aliasing, then trace it lightly
The wordmark segmentation mapped alpha with smoothstep(46, 120) over distance
from the background colour. That ceiling is fixed, but the ink distance is not:
white text on a black band sits at ~438, so every edge pixel brighter than about
27% grey saturated to fully opaque. A hard threshold wearing a smoothstep's
clothing. Measured on Camping in Alaska's "Needlemouth" cover, the extracted
mask's edges transitioned over 0.10px against the source's 0.88px -- nine times
harder than the artwork it came from.

The ramp's top now tracks the measured ink distance (95th percentile of
distance-from-background, so it reads the glyph body rather than the brightest
speck), with the old constant kept as a floor so low-contrast marks are
untouched. Retained anti-aliasing on that cover goes from 10.4% of inked pixels
to 22.2%, and the glyphs thin back toward the source instead of being dilated by
the saturation.

That unlocks the trace side, which is the visible half. The blur there was never
a goal -- it exists to synthesise anti-aliasing a mask lacks, and it melts serif
terminals into blobs. With real gradients in the alpha, the cover-logo branch
drops from ~0.93px of blur to a 0.5px floor and from eps 0.5 to 0.2, at a 2400px
raster instead of 1400. Lowering eps ALONE was tried and rejected: against the
old near-binary mask it just captured the pixel staircase in finer detail.

Camping in Alaska's traced path goes 900 -> ~2500 vertices with no staircasing,
and the serif terminals keep their shape. Cost is 35ms -> 65ms of main-thread
work, once per cover-derived logo at page load, not per frame.
2026-07-25 18:12:46 -05:00
KeelrLeek 7ce9fd0efc artist art: keep a known-good hero instead of rediscovering it daily
An artist's hero was re-discovered every 24 hours through a tier that fails
per-name, and the stale manifest was deleted on the way in -- before the
replacement lookup had run. So one bad discovery day did not merely fail to
refresh a hero, it destroyed the good one first and left the artist blank.
On this machine that took the persisted store from 26 artists down to 3;
Crazy P, MSPAINT, Sword II, Hermeth, Billy Cobb, Pond, Weezer, Daft Punk and
King Krule all had correct banners on disk that nothing would ever read again.

Three changes:

- The manifest TTL goes from 24 hours to 30 days, matching the cover
  resolution horizon. A press photo is still the artist's press photo a month
  later; it does not go stale the way metadata does.

- Reading a manifest no longer destroys it. Stale and older-recipe manifests
  are retained as a floor and only replaced once a lookup returns something
  better, which also migrates them forward to the current recipe. Only files
  that are unusable -- oversized, unparseable, or belonging to another artist
  -- are removed.

- A failed hero discovery is detected by an EMPTY BANNER LIST, not a None:
  catalog_artist_art returns Some as soon as the Deezer profile resolves, so
  the previous None check could never fire, and an empty result would also
  overwrite good banners whenever logos were present.

Also widens discovery so fewer artists need the floor at all. The query ladder
gains the bare artist name: decorated queries can collapse a rare artist onto a
popular entity sharing a word -- every "Hana Vu ... photo" phrasing returns the
Japanese group HANA (525 of 525 cards, quoting included), while the bare name
returns her real press photos with 16 of 17 passing the identity gate as
Strong. The gate, not the query, is what filters.

And a near-empty Bing page now counts as degraded. The retry test was
is_empty(), which let the commonest degraded shape through: a one-card page of
something unrelated, cached as that artist's verdict for a quarter hour.
Measured 15 of 30 waves for MSPAINT and Billy Cobb. The richer of the two
reads is kept so a thin retry cannot lose a good first answer.

Verified live: Crazy P, MSPAINT, Sword II and Hermeth all render heroes again.
2026-07-25 14:50:55 -05:00
KeelrLeek 86e0d86604 fix: preview crossfade no longer strands the main volume (silent playback)
Clicking a track could play silently: the store showed status=playing with
the position advancing and no error, but the sink was at volume 0. The
open/close crossfade ramped the main player's USER VOLUME as its fade
channel and left it at 0 on an ack timeout (or when a track change
suppressed the close resume). User volume persists across tracks and a new
Play doesn't reset it, so every later click played at 0 while the slider
still read 0.7.

Give the main player a separate fade_gain channel (Cmd::SetFadeGain, sink =
volume * norm * fade_gain), reset to 1 by every Play/Stop. The open/close
fades ramp fade_gain instead of set_volume (their volume arg is gone, JS
callers too), so the user volume is never touched -- store and backend can't
desync -- and a clicked track always resets fade_gain to 1, so it can never
be stranded silent. fade_resume lands exactly at 1. Player::fade_gain()
mirrors it into a report atomic; debug overlay shows main fade + this proved
the fix live (open 1->0, close 0->1, new track 1.0, volume steady at 0.7).

39 app + 12 player + 85 JS tests pass.
2026-07-23 17:31:06 -05:00
KeelrLeek 28aba3a3b3 feat: normalize preview clips to -14 LUFS to match the library
Deezer preview clips are cut from the raw master (not pre-normalized), so
they played several dB hotter than the user's normalized -14 LUFS files —
measured across one artist the previews ranged -0.1 to -8.8 dB from target,
an 8.7 dB spread, so a flat offset would have been wrong either way.

Each clip is now measured with the same BS.1770 meter the main player uses
(preview.rs measure_clip_gain -> loudness::normalization_gain_db) right after
it is written in preview_resolve -- off the async worker, and fully off the
playback path on a prefetched neighbour. The linear gain is stored in the
lease and the reuse cache, and rides UNDER the app-volume mirror per voice
(effective = target_vol * clip_gain), so it composes with the slider, mute
and the crossfade (each clip crosses in at its own matched level). Gated on
the volume_normalization setting; reset on dispose. Debug overlay PREVIEW row
now shows the active clip's norm dB.

Verified live: per-clip gains -0.1..-8.8 dB applied; mute still zeroes the
preview; 39 app + 12 player tests pass.
2026-07-23 17:12:04 -05:00
KeelrLeek 691fdcdc9a fix: previews follow the app volume; volume bar fill always accent
The preview backend never received a volume — clips played at the crate
default (0.7) regardless of the slider, even muted. The session now sets
the preview volume from the app store (mute → 0) before the first load and
mirrors it live while the modal is open; the debug overlay's PREVIEW row
exposes the backend's resting volume as proof.

The player-bar volume (and fallback seek) fill is now always the accent
color — matching the waveform scrubber's played region — stepping up to
the hover shade on hover instead of white-until-hover.

Also syncs package-lock to the soulstream name + alpha version.
2026-07-23 16:25:42 -05:00
KeelrLeek 793f78370e feat: crossfade preview suite, loudness normalization, debug overlay
Preview modal audio, five rounds:
- True track-change crossfade: scheduleSettle no longer silences the
  outgoing clip at t=0; preview_load's equal-power fade owns both ramps,
  gated on the incoming voice's first Playing event (audible_marks), with
  failure/overstay fallbacks. CROSSFADE_MS = 340 — locked to the reel's
  card-slide so audio and visuals land together.
- Two-neighbour lease prefetch + a 15-min on-disk clip byte-cache
  (recent_clips): steps in BOTH directions crossfade in ~15 ms; revisits
  never re-download.
- Adaptive settle (isolated steps fire immediately, bursts coalesce) and
  anticipatory auto-advance (step at clip end minus 400 ms) so auto-play
  crossfades over the tail instead of gapping.
- Session resume memory: revisited tracks continue where they left off
  (decoder-level start offset via play_local_from/resume_at); finished
  tracks restart from the top.
- Open/close crossfades with the MAIN player: fade-pause with the
  desired-paused latch landing at ramp end, fade-resume from the paused
  position; MAIN_FADE_GEN guards; focus handshake relaxation documented
  in focusBridge.

Volume normalization (Settings > Playback, default on):
- Dependency-free BS.1770/EBU R128 meter in the player crate (K-weighting,
  400 ms gated blocks, incremental) + analyzer that reads the same growing
  file playback streams from — first plays are measured at download speed.
- Gain layered under user volume; audible start GATED on a preliminary
  measurement (10 s of audio or 800 ms budget) so the level is right from
  the first sample and never changes mid-track; prelim boosts capped 3 dB,
  peak-aware clipping guard, persistent loudness-cache.json.
- Gapless boundaries get their own sample-accurate gain via
  SetNextNormGainDb, fed by preload analysis.

Debug overlay (Ctrl+Shift+D): process-tree CPU / PDH GPU / working-set +
private-commit RAM / network rates / FPS / current-track download progress
read from the backend's own StreamProgress / live normalization state /
preview session phase. Polls only while visible.

131 JS + player + app tests green; every path verified live over CDP.
2026-07-23 16:05:14 -05:00
KeelrLeek 5e877c0b0a chore: ignore dev-note artifacts, start alpha versioning at 0.1.0-alpha.1
.gitignore now excludes everything that is not part of the app itself:
memory/, research/, TODO-Feature-Docs/, HANDOFF*/GAMEPLAN notes, .codex/,
.claude/ and logs. Version begins the alpha line at 0.1.0-alpha.1 across
the cargo workspace, package.json and tauri.conf.json.
2026-07-23 16:04:54 -05:00
KeelrLeek 8cb3cd593b Phase 2A+2B: native preview backend and renderer glue
Rust (2A):
- metadata: deezer_track_uncached (fresh mint, cache-updating, paced) and
  fetch_preview_media — dedicated client whose custom redirect policy
  re-validates every hop against the media allowlist; bounded body read
  (cap enforced on received bytes, not Content-Length's promise); Date
  header captured for provider-clock lease math. httpdate dep added.
- src-tauri/preview.rs: second Player instance + soulstream:preview event
  channel; preview_resolve (fresh mint → host gate → MIME gate → ID3v2
  shell strip → MPEG sync check → clip file + opaque lease id → safe_for_ms
  mirroring lease.js); preview_load with start_paused via the
  desired-paused latch; preview_control/dispose; player_pause_acked
  exposing the Phase 0.4 ack on the MAIN player. 9 unit tests.

JS (2B):
- api.js: preview command surface (opaque lease ids only).
- nativeTransport.js: stamps {sessionId, generation, sessionKey} onto
  backend events; cleared context makes stragglers dispatch nothing.
- productionResolver.js: DTO → MediaLease, failure classes preserved.
- focusBridge.js: production coordinator binding with the documented
  renderer-side generation invariant (buffering counter + gateway).
- service.js: the orchestrator — settle window, resolve→load→play under
  focus, single capped retry, one forward lease prefetch with safe-window
  expiry, stale re-checks before every transport command, close/handoff.
  The service constructs its transport around its own dispatch — the
  factory signature exists because a pre-built transport silently loses
  the event wiring (caught by test).

70 JS tests passing (npm test); metadata 46 + app preview 9 in cargo.
2026-07-22 18:45:29 -05:00
KeelrLeek a667b4b28e Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
2026-07-22 17:57:00 -05:00