forked from Keelrleek/SoulStream
4c9a6e312a938a3d185f9938b57d0fa5a4ac98c9
28
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f55562f0ab |
playback: a cached track plays without asking Soulseek where it lives
Hopping between songs of an already-cached album still ran the full "finding best source…" resolve for audio sitting complete on disk — a second of popup between every click. The player now remembers the source each stream item was queued from (localStorage, LRU-capped at 400) and, before any search, asks the backend whether that exact file is byte-complete in the stream cache. If it is, the track plays those bytes immediately: no resolver, no popup, and no online requirement — cached audio is playable offline, so the check sits before requireOnline. The memory is a hint, never a promise. It is written eagerly when an item is queued to PLAY, because a source that never delivers leaves no complete cache file and the byte-complete probe rejects it later at no cost; eviction and half-finished transfers fall back to the normal resolver the same way. First play of a track records it, every play after that is instant. |
||
|
|
b33d403634 |
player bar: the name sets the width, and the waveform stops flattering the clamp
The source chips are gone — the rail already tells that story — and with them the measured slot, its effects, and the bar's only container query. The now-playing cluster hugs the song name with no cap, giving ground only once the scrubber hits its 260px floor; the artist's 55% ceiling applies only when an album sits beside it, so a lone loading artist no longer renders as two letters in a twelve-pixel box. The waveform is rebuilt on honesty. WAVE_BUCKETS rises 400 → 2000 (~128ms per bucket — at 400, every display bar was ~2s of audio averaged twice), and one shared shaping in waveshape.js replaces both lanes' gain-into-clamp rule, which flat-topped 53.8% of the bars on a real decode and was propped up by per-bar noise in the rail — deleted; the lane shows the record, not a hash of the bar index. Bars fold with a mean+peak blend and stretch across the track's own p05–p97, so a loud master stops pinning to the ceiling and a quiet one stops vanishing. The playbar copies the rail's fade verbatim: whole bars blend accent→grey with cuts landing in the 4px gap, and the one bar under the playhead crossfades toward whatever its neighbours are — the wash while hovering, the muted grey otherwise. The redraw gate watches that blend instead of a 0.75px playhead move, so both lanes now step on every position event. Loading is drawn, not implied: the solid fallback bar is gone and the lane is always the bars, always seekable. Empty, it rests as the rail's dots; a swell crosses them while a waveform is computed (2s sweep, 0.5s rest); a streaming track's bars stand up left→right on a 40ms cadence, each rising over 2s. The rise frontier is the waveform's OWN coverage, reported by stream_waveform beside the peaks — the old contract zero-padded partial peaks to full length and let the UI fold them against a fresher byte count, which stretched the whole shape between refreshes. Where the bars end is how much of the track has actually loaded; cached and local tracks arrive at coverage 1 and pop in complete. The first partial is also requested eagerly (fresh refresh clock per track, 700ms retries until peaks exist), closing most of the audio-before-lane gap. |
||
|
|
7547fed56d |
playback: remember the last transport so a reload lands where you left off
PlayerState carries last_transport, mirrored as events go past, and player_state reports it. The frontend restores everything else from localStorage, but the transport cannot come from there — the audio lives in Rust, and a reload that trusted the browser's copy would show a position the engine had never heard of. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dc918c98ef |
metadata: per-track listeners, and an artist profile the search hero can ask for
CatalogTrack gains artist_id — Deezer sends it on every search row and it was simply unparsed — and rank, its 0-1,000,000 popularity figure. Together they let a listener count be resolved for the PERFORMING artist of each row rather than for the query's top artist, so a "panda bear" search shows Daft Punk's "Doin' it Right (feat. Panda Bear)" at its real 464,046. Resolution rides the existing deezer_linked_mbid -> lastfm_listener_ranks chain behind a new catalog_track_listeners command, deliberately kept separate from catalog_search: that one is typed against per keystroke and must never wait on MusicBrainz's 1 req/s gate. catalog_artist_profile backs the hero's middle third — MusicBrainz tags off the cached artist document plus one Last.fm artist.getinfo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
69f64c11eb |
previews: the glow goes, the eq steps, and every clip lands before you scroll to it
The track preview modal could hold a machine at 25% GPU. Three separate causes, each measured in isolation with a kill-style ladder and, once the GPU-engine counter proved to be clock-relative (a lighter workload read HIGHER), with clock-immune process CPU-seconds. Idle drain. The scrim's backdrop-filter is re-executed on every presented frame -- a backdrop filter is never cached -- so its cost is presents/sec times window area. Two infinite animations kept the modal presenting at the display's refresh rate forever: the eq capsule's bars animated `height` (a layout property, so style/layout/paint on the main thread every vsync) and the cover glow's halo-breathe kept a blur(48px) copy recompositing. Either one alone kept the pipeline hot, which is why killing one changed nothing. The glow is deleted outright at Kyle's request; the eq bars now scale with a stepped transform whose three delays are multiples of the step interval, so all three jump on the same instants and one present serves them all. Playing steady state: 7.2-10.5% of a core down to 1.4-2.2%. Paused: 0.2%. Scrolling. The animations turned out to be innocent here -- reverting them moved the number not at all. The cost was per-step work: re-decoding the full artwork for the background wash, a main-thread accent scan, a new cover mount, three times a second. The wash is now a 96x58 canvas with the blur/saturate/brightness baked in at draw time and cached per artwork, and an overlay preloader walks the manifest once at open -- nearest to the active card first, capped at 60 -- filling the decoded-image, wash and accent caches ahead of the user. A rapid-nav jump-cut was tried and rejected on sight; the animations stay full at every rate. Backend. preview_resolve swept the clip directory on every call, and any landing outside the +/-1 prefetch window paid a full Deezer mint, download, validate, write and loudness measure. The fetch-store-measure core is factored out and a new preview_prewarm walks the session's track ids at open, paced 250ms on top of the existing Deezer throttle and starting 1.5s in so the first clip owns the network, halted by a generation guard on dispose. A burst landing on a never-visited track is now audible in 154ms off local disk. The sweep is throttled to ten minutes, and dispose deletes prewarmed files instead of orphaning them until the hourly reap. The debug overlay's GPU row was summing the counters machine-wide, so it read roughly double the app's share with Discord and DWM folded in. It now reports `app X% - sys Y%`, the app figure restricted to our process tree, matching Task Manager's per-process rows. |
||
|
|
eba29824c9 | sidebar and artist pipeline: one list in the rail, recents anchored to ids, and popular takes its own lane | ||
|
|
52f5878297 |
loudness: a first play starts three quarters of a decibel under the prefix
The prefix estimator is BIASED, not merely noisy, and the sign is known: a quiet opening under-reads the track, so the head asks for too much gain far more often than too little. Subtracting a constant moves the whole distribution toward the truth, so a first play begins nearer its real level and the correction that follows has less ground to cover. Measured by a new `head_start_offset_sweep` over 1046 tracks sampled across 182 artists — at most six per artist, spread across each discography rather than taken from the front, since openers are where intros are least typical and intros are the whole source of the bias. The cached corpus it replaces was a dozen artists and put the median at +3.48 dB. Sampled diversely it is +1.55, so the old number was overstating the bias by more than double. The size comes from the MARGINAL trade, not the smallest mean error: step loud fixed quiet created ratio 0.0 -> 0.5 -89 +41 2.2 : 1 0.5 -> 1.0 -83 +85 1.0 : 1 1.0 -> 1.5 -61 +132 0.5 : 1 Only the first step is clearly worth taking. mean|err| bottoms out at 1.5 dB instead and is the wrong objective: it weights an error that begins decaying at once the same as one that waits for Analysis::Complete, which on the streamed first plays this path exists for is download-bound and therefore late. A constant is deliberately a weak lever. The per-artist median error runs from -4.58 dB (Boards Of Canada) to +7.03 dB (Ponytail); no single number fits everyone, and an offset shifts that spread without narrowing it. The levers that would are letting the follow recover UPWARD, and predicting the offset per track from the prefix. Both still open. The overlay reports it as a MOVE — what the prefix asked for, where the track actually started, and a ghost tick on the dB scale at the prefix position, so the gap to the applied gain IS the head start. Gated on `analyzing`: at Analysis::Complete the player releases to the whole-track gain while loudness.gainDb goes on reporting the prefix value, which it keeps for the next play. Without that condition the line advertised a correction that had already been released. |
||
|
|
4179247203 |
build: strip the UTF-8 BOM that stopped both halves of the app starting
0.1.0-alpha.2 bumped three version strings with a tool that writes UTF-8
WITH a BOM, and committed it. Neither build survives that:
package.json vitefu JSON.parses the raw string, so `npm run dev`
exited instantly — "Unable to read package.json"
tauri.conf.json the Tauri build script failed with "expected value at
line 1 column 1"; cargo exit 101
Cargo.toml tolerated, but wrong
`require()` hides this. Node strips a BOM on require and JSON.parse does
not, so reading package.json from a REPL reports a perfectly good file
while both builds refuse it. Only the raw bytes show it: EF BB BF.
All 191 tracked files scanned. These three were the only ones affected,
and they are exactly the three
|
||
|
|
5682dfc22f | release: 0.1.0-alpha.2 | ||
|
|
1e35c5e039 |
loudness: correct the gain during the track instead of only guessing at its start
A streamed first play is gained from a prefix; every later play is gained from the whole track. Measured across the 89 cached tracks, a 10s head asks for a median 3.4 dB too much gain and is more than 1 dB hot on 68 of them — that gap is the "first track is so much louder" complaint. The curve never flattens either: even a 60s head is still ~1 dB out with a 6 dB worst case, so no window size fixes it. Live traces then showed something worse. The hard deadline was anchored to the play REQUEST, so on a peer that took a moment to start sending, the 2.5s ran out before a single gating block existed and the track began at unity — no normalization at all, three plays out of four on one run. Releasing early bought nothing in exchange: playback is blocked on the same bytes, so the gate was discarding the measurement without making sound arrive sooner. - The deadline now starts at the first byte, with an absolute 4s ceiling from the request so a slow peer cannot hold audio that is ready to play. - The audio thread's slew drops 2.5 -> 0.25 dB/s. Mid-track correction was switched off in July because the glide was audible; at a tenth the rate it is not, and being 3 dB wrong for a whole track is the larger error. - The correction follows the running measurement from its first publish and may only ratchet DOWN until the track completes. A convergence test lived here and was deleted: an integrated average over 30s cannot move much when one more second is added, so "3 publishes within 0.3 dB" was measuring the inertia of a long average, fired at its floor every time, and declared settled while 1.5 and 2.1 dB from truth. The protection was never the gate — it is the slew rate and the ratchet. - Seeking lands the pending correction immediately. The slew is slow only because a gain change is audible against continuous music, and a seek is already a discontinuity. Observability: SOULSTREAM_LOUDNESS_TRACE=1 reports every commit with the seconds analyzed, which limit fired, the hold, and the bytes received; the debug overlay gains a SLEW line showing applied gain, target, distance and ETA, because one number cannot distinguish "settled" from "in flight". Also adds an #[ignore] corpus harness that sweeps head-window bias in 30s. |
||
|
|
d460352973 |
backend: a cached track says so, and fallback hero art expires in a day
play_stream's fully-cached fast path plays the file and returns early -- no StreamProgress, no download task, so the loop that emits soulseek:stream never runs and not one event is ever emitted for that track. The frontend had already set received=0/total=size when it asked to play, so a record that was 100% on disk sat pinned at "peer - 0%" for its whole length. It emits one completed event now; done also lets the frontend take its finished-track path (final waveform, pre-buffer the next). The artist-art manifest TTL was a flat 30 days. Fanart.tv's CDN refused connections on 2026-07-27, the chain correctly degraded to Bing -- and then those Bing-tier heroes counted as fresh for a month, which is why low quality art stayed put after the provider came back. The TTL is tier-aware now: art whose banner came from fanart.tv keeps the 30 days, anything from below it is re-checked daily. Tier is read back off the URL host because the manifest never recorded which provider won, and banners only -- logos legitimately come from TheAudioDB even when fanart covered the artist, and treating that as a fallback would put every artist on a daily re-check. Recipe bumped to v6 so every stored manifest re-resolves once against the providers that are up today. Weezer went from Bing-tier art to 8 banners, all assets.fanart.tv. |
||
|
|
fa3cbc64ca |
loudness: a prefix may lower a track's volume but never raise it
A track that opens on a lone guitar and swells into a full chorus played that
chorus far too loud. The head of an unmeasured stream was being treated as a
measurement of the whole song: at ten decoded seconds the analyzer's running
integrated value became the gain for the entire play, and a quiet opening
under-reads, so the estimator asked to turn the track UP and the song then
arrived at its real level already amplified. `PRELIM_MAX_BOOST_DB = 3.0`
bounded that mistake without preventing it, and the three decibels land
exactly on the loudest part of the song.
Measured on the 69 complete tracks in the stream cache, with the app's own
meter and its own gain policy, comparing what the 10 s head asks for against
what the finished track asks for:
prefix error in applied gain (+ = plays too loud)
1s min -4.6 p50 +7.7 p95 +15.8 max +17.0 too loud: 61/69
3s min -0.9 p50 +5.2 p95 +15.7 max +17.0 too loud: 57/69
10s min -1.5 p50 +2.9 p95 +14.6 max +17.0 too loud: 57/69
The head is wrong in one direction and wrong by a lot. So it may only ever
cut: `PRELIM_MAX_BOOST_DB` is 0.0, and a boost has to be earned by a
measurement that reached the end of the media.
**Cuts-only rather than the flat unity the design called for.** The design
doc's stated first-release policy is that an unknown partial stream stays at
0 dB, with cuts-only left as a later experiment. Measured against this
corpus, unity is the worse of the two in the direction being complained
about — first-play loudness relative to the −14 LUFS target:
unity (doc's policy) p50 +5.6 p95 +8.5 LOUDEST +10.6 >3dB over: 57/69
cuts-only p50 +2.7 p95 +6.7 LOUDEST +8.0 >3dB over: 31/69
old policy, capped +3 p50 +2.7 p95 +9.5 LOUDEST +11.0 >3dB over: 32/69
verified (later plays) p50 +0.0 p95 +0.0 LOUDEST +0.0 >3dB over: 0/69
Unity would hand back the cut that loud-opening tracks already get today:
"Spread Eagle Cross the Block" reads −3.4 LUFS over its head and −3.4 over
the whole track, and is cut 10.6 dB — under unity its first play would be
10.6 dB louder than it is now, which is the reported bug wearing a different
hat. The tail risk that argues for unity is a loud opening over a quiet body,
where the head over-cuts; on this corpus that case tops out at 1.5 dB (My
Bloody Valentine, "Only Shallow": head −11.3, track −12.8) and the existing
−15 dB floor bounds it. The corpus is one library and contains no fortissimo
opening over a pianissimo body, so it bounds the error it can see, not the
error that exists. Revisit if a cut ever sounds wrong.
**Only a whole programme may be cached.** `analyze_loudness` returned the
same `Option<(f64, f32)>` whether it reached the end of the media or the
bytes simply stopped, and `on_track_started` stored it either way — with a
comment saying it was valid "whatever happened to this one". It is not:
`GrowingFile::read` maps an aborted transfer to `Ok(0)`, a graceful EOF, so
skipping a song mid-download recorded whatever its intro measured, filed
under the full declared remote size, and every later play boosted the whole
track off it by up to `MAX_GAIN_DB` — the same bug as above, but permanent
and three times larger. It now returns `Analysis::Complete | Incomplete`,
where completeness is decided by asking the transfer how it ended
(`StreamProgress::terminal`) rather than by asking a reader that cannot tell,
and only `Complete` reaches the cache, the preload boundary gain, or a
preview clip's gain.
That path was reachable but had not fired. Re-measuring every cached track
against its stored value found no disagreement, and of the ten interrupted
transfers still sitting in the stream cache, none had left a loudness entry
at all — the analyzer normally wins its race with the download and either
finishes properly or is cancelled before the abort reaches it. So the
existing cache is not quarantined and not wiped: of 141 entries only 6 ask
for any boost above 0.5 dB, four of those are present on disk and verified
correct by a fresh scan, and the other two ask +1.4 and +1.0 dB. Discarding
141 good measurements to protect against a hazard that measurement says is
not there would cost more than it saves, and any record that is wrong is
overwritten by the next complete play.
**The audible start is now bounded from the play request.** Both existing
windows are measured from the first measurement, and a track opening below
the −70 LUFS gate never produces one, so silence or a long fade-in held
playback until the analyzer found audio loud enough to measure — on a slow
stream, a wait with no upper bound. `PRELIM_HARD_DEADLINE` (2.5 s) releases
the start regardless, at the best cut the evidence so far justifies. This
costs nothing: the player blocks on the growing file anyway, so releasing
early hands it the start rather than making it wait. Verified against a
223 MB FLAC whose first bytes had not arrived at 2.2 s.
**Stop now cancels the measurement window.** Found while verifying the
deadline: `player_control("stop")` never touched the loudness generation, so
a track stopped during its window could be handed its deferred start a moment
later and begin playing after the user had stopped it. Pre-existing, but the
deadline makes it reliably reachable, so `on_playback_stopped` orphans the
analyzer and the held start.
Not done, and deliberately: true peak and the −1.0 dBTP ceiling (the meter
measures sample peak, so `PEAK_CEILING` is still 0.98 sample), the versioned
measurement cache with real asset identity, ReplayGain/R128 metadata, the
output limiter, track-bound gapless gain, and queue-ahead analysis. Those are
later phases and none of them is needed to stop the over-boost.
Verified live, driving the running app:
quiet intro, first play head −27.4 LUFS → 0.0 dB (was +3.0 dB)
quiet intro, second play track −6.0 LUFS → −8.01 dB from the cache
loud intro, first play head −3.4 LUFS → −10.64 dB cut still applied
Soulseek stream, cold head −18.8 LUFS → 0.0 dB, track measured −9.0
and cached for the next play without touching
the gain in force
stop mid-window no start behind the stop, no cache entry written
The debug overlay now names what the gain was decided from — "whole-track
measurement (cache)", "prefix (cut only)", "unity (nothing measured yet)" —
because the difference is invisible in the numbers alone, which is how this
went unnoticed. Settings no longer claims "boosts never clip", which nothing
in the output path enforces; it describes the actual guarantee instead. The
module header's claims that the gain glides mid-track and that previews are
not normalized were both false and are gone.
150 Rust tests and 85 JS tests pass.
|
||
|
|
6d6968e9b1 |
artist art: a provider's API being up is no promise its CDN is
On 2026-07-28 `webservice.fanart.tv` answered every request normally while `assets.fanart.tv` refused TCP on both :443 and :80. The two are different machines, and the hero chain only ever asked the first one anything. Every gate in that chain tests whether a provider RETURNED URLS, never whether those URLs load. So Fanart.tv handed back its usual eight banners and four logos, `banners.is_empty()` was false, and the two fallbacks that exist for exactly this situation -- TheAudioDB, then Bing -- were both skipped. The page then fell back to a blown-up square portrait. The tell that this was a provider outage rather than a regression in the hero code is that it was artist-shaped: every artist Fanart.tv covers lost its hero, and the ones it does not cover (Tame Impala) were untouched. Worth recording that browsing fanart.tv proves nothing here -- that is a third host, on Cloudflare, and it was up throughout. The check is a one-byte ranged GET, so it is proof the host serves image bytes without pulling a 4K background to find out. Any HTTP answer counts as alive: a 404 on one image is not an outage, so only a transport failure or a 5xx marks a host dead. Cached per host for 5 minutes, which is long enough that a dead CDN costs one probe rather than one per artist, and short enough that the hero comes back within minutes of the host recovering -- no restart, no cache clearing. The probe races the page, hence the 3s ceiling. It has to be applied in three places, and the third is the one that is easy to miss: - Discovery, after Fanart and again after TheAudioDB, so an unreachable CDN reads downstream as "this provider had nothing" -- precisely the state the fallback chain already knows how to handle. - The persisted manifest, because a manifest outlives the CDN behind it. A stored answer short-circuits discovery entirely, so replaying one produces a hero that never loads AND no path to the fallback that would have worked. - The "keep previous banners as a floor" clause. Restoring unreachable banners would undo the whole point of falling through: discovery correctly dropped a dead CDN, and the floor would put it straight back. Note for the next time this looks like a renderer bug: a 1x1 hero canvas is a SYMPTOM, not the cause. It means no image ever decoded, so the GPU renderer never sized itself. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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). |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |