Commit Graph
57 Commits
Author SHA1 Message Date
KeelrLeek 8c25f93afe community: one area for the people on both networks, and the hub grows search tabs, a folder column and a queue column 2026-09-02 13:43:16 -05:00
KeelrLeek 6dce4e2ac7 the freenet layer: adapter, persona and profile services, the contracts bundled as commitments 2026-09-02 13:43:16 -05:00
KeelrLeek 63a0357651 the soulseek hub: the whole Qt client as one area, wired to the wire
Kyle: "this is going to be a soulseek menu now — essentially the soulseek
app in our app." Approved against research/soulseek-search/hub2.html, a
one-to-one mock in the real frame, then built in three layers.

THE AREA. Search and Downloads leave the sidebar; one Soulseek entry
(globe) opens src/views/Soulseek.svelte, whose tab row is SoulseekQt's own
anatomy: Transfers, Search, Browse, Rooms, Chat, Users. The old view ids
stay routable — App.svelte aliases "search" and "downloads" to the hub and
the component syncs its tab off them — so the title bar's download ring,
"get on soulseek" hand-offs, history entries and restored sessions all
land on the right tab. The glass bar rides the hub with the connection
line on its right (online as X · sharing N files), the page takes Home's
below-the-bar inset, and the bar arrows join the chrome spec (16px glyphs)
alongside the sidebar toggle's disc. The replaced Search.svelte and
Downloads.svelte are deleted; Transfers carries their data forward in Qt's
own columns (User/File/Progress/Status/ETA/Speed/Size/Place, cancel and
play/reveal/add on the rows, clear-complete and open-folder in the
toolbar, live speed totals in the footer) and Search re-skins the peer
search as the Qt table: results regrouped user -> folder -> file,
slot/queue chips, format filter chips that actually filter, fold-out
files with stream-and-download on hover.

THE WIRE. soulseek-proto learns the rest of the protocol: rooms 13-17
(say/join/leave/user-joined/user-left), private messages 22/23, the
watch/status/stats family 5/6/7/36, the room list 64, and the peer-side
browse pair 4/5 with Directory::read to decode what our own responder
already encoded. Parsers are tolerant where the wire is baroque — roster
sections consume their own declared counts even when they disagree with
the name list, RoomList ignores the private-room tail, truncated stats
default instead of failing the frame. Codec tests ride along, including a
browse round-trip against our own encoder.

soulseek-client turns those into Events (RoomList/RoomJoined/RoomMessage/
PrivateMessage/UserWatched/...), auto-acks every private message the
moment it lands — unacked DMs are redelivered on every login — and grows
a spawn-shaped browse(): the returned future owns an Arc of the internals
so the caller drops the client lock before awaiting the 45s worst case.

THE APP. The event pump forwards everything as soulseek:* events; new
commands cover join/leave/say/message/room-list/browse plus a buddy list
persisted in config and re-watched on every login so status and stats
stream while connected. The frontend slsk store drains it all into the
four tabs: Rooms (joined + popular lists, live roster column, per-user
colored feed, say box — the server echoes our own lines so nothing is
appended locally), Chat (recency-sorted conversations, unread badges, DM
bubbles — our half appended locally since DMs are NOT echoed), Users
(add/remove buddies, status chips, one-click Browse/Chat cross-wiring),
and Browse (type a name, walk their tree, stream or download any file).

And the sharing side finally taps the shoulder: UploadToast announces the
first event of every new upload in the shared toast slot, folding a
same-user burst into one counting pill ("nikac is taking 12 files")
instead of twelve entrances.

Live-verified against the real network: the server handed 703 rooms after
login, and a browse of a live peer returned 3,796 directories / 34,773
files in 929ms with full paths, sizes and attributes. Join/say and DMs
are wired but deliberately unexercised — the first hello in a real room
is Kyle's to say, not a probe's.
2026-08-31 15:13:53 -05:00
KeelrLeek 75a95405a8 the acrylic rides move drags: only a real resize parks the glass at blur
Kyle: "when clicking and dragging the window the window gets light." It
did, by design that no longer had a reason: WM_ENTERSIZEMOVE parked the
accent at plain BLURBEHIND for EVERY modal loop, move drags included, on
the old assumption that a suspended acrylic punches through on moves the
same way it does on resizes. Plain blur ignores the gradient tint and has
none of the acrylic's material dimming — measured over a striped backdrop,
every glass surface stepped ~15% brighter (band luma 45.5 -> 52.2) for the
whole of every titlebar drag. And no compensation was possible: move loops
emit no resize-drag event, the frontend never hears about them.

Measured on this machine (research/glass-drag harness, SC_MOVE posted so
no synthetic mouse goes near the UI, GUI_INMOVESIZE as ground truth): DWM
composites the acrylic straight through a move loop. Re-asserted mid-loop
it restored rest luma EXACTLY (45.5/31.8/29.4), stationary and through 28
frames of real mouse-speed motion — no punch-through, no trailing.

So the swap is now gated on SIZING_LATCH: a titlebar move keeps the
acrylic, a latched resize parks at Blur exactly as before (DWM genuinely
suspends acrylic in live resizes — that half is load-bearing and stays).
The WM_SIZING belt-and-braces arm swaps the accent too, so a resize the
hit-test latch misses still degrades one mouse-move late instead of never.

Verified after: entering a move loop changes nothing to the decimal;
entering a resize loop still swaps (+6.7 band luma, the intended trade)
and restores on release.
2026-08-31 11:45:46 -05:00
KeelrLeek 385b598170 glass survives the window gestures: the accent rides the modal loop and the maximize animation gets a bracket
Kyle reported the blur "being masked by just going opeque" on maximize, and
separately the desktop punching through a maximized window. Both were the same
transition, and the old guard covered neither honestly.

THE GUARD'S SIGNAL IS NOW THE MODAL LOOP ITSELF. thumbbar.rs already
subclasses the main window for the taskbar transport, so the size loop is three
arms on that existing proc: WM_NCLBUTTONDOWN carrying a sizing hit-test (or
SC_SIZE, the keyboard path) latches "this loop is a RESIZE", WM_ENTERSIZEMOVE
arms, WM_EXITSIZEMOVE releases, and WM_SIZING arms as a second net because this
window is undecorated and tao does its own NCHITTEST. Emitted as
soulseek:resize-drag, POSTED to ourselves rather than emitted inline: emitting
from inside DefWindowProc's modal loop reenters the wry runtime's window store
while that loop holds it.

The signal and the failure are then the same object, which is what the two
previous attempts were not. The first grounded on any DOM `resize` — one event
fires for a maximize, so the settled window sat flat black for ~70ms hiding
glass that was already back. The second discriminated on event CADENCE, and
that is the one worth remembering: on this 4-core laptop, re-rasterising every
backdrop-filter at each new size pushes `resize` deliveries far apart, and
measured 2026-08-28 events 150ms apart never grounded AT ALL — a real drag
stayed see-through, shipping the exact artifact the guard exists to prevent.
Cadence is not a proxy for the loop.

DWM SUSPENDS THE ACRYLIC INSIDE THE LOOP but keeps compositing the plain
BLURBEHIND, so the accent swaps down to Blur for the gesture and back on
release (window_glass refactored into apply_accent + an Accent enum; GLASS_ON
tracks the main window so the subclass can re-assert). The window stays glassy
through a drag instead of going black — which is what Kyle actually wanted, and
GROUND_DRAG is off on his verification by hand.

THE MAXIMIZE ANIMATION SCALES A SNAPSHOT the accent cannot ride: measured at
43ms/frame, desktop icons read sharply THROUGH the maximized window until
~613ms after the call. Disabling the DWM zoom animation removed the
punch-through outright and every frame of a ground-suppressed maximize showed
the blur soft and present — but that also removed the animation, and Kyle chose
the animation back with the canvas grounded across the recomposition. So
transitions are forced ON, the subclass brackets the jump (active at WM_SIZE, a
re-assert immediately and again at 650ms, release with the second), and
GROUND_DISCRETE paints the canvas for exactly that bracket. Both policies are
one-line JS constants in glass.js — flipping between "animation + dark bracket"
and "instant snap + live glass" needs no rebuild.

The DOM resize listener survives as the fallback for a build with no subclass,
reverting to the original behaviour; it stands down for good the moment one
native event is heard. Measured clean: 3 maximize/restore gestures, 3 brackets,
canvas never opaque outside them; a 9-second modal loop held with zero resize
events in it.

98/98 tests.
2026-08-31 05:52:43 -05:00
KeelrLeek 93271aceec release: 1.5.0-alpha 2026-08-27 17:25:19 -05:00
KeelrLeek 547cee6649 dark glass gets a surface ladder, and the 200-750ms stalls are dead - two sessions of uncommitted work landing together
SURFACES: panes add black, surfaces INSIDE panes add white. Every pane stands
on 0.7075 black over the acrylic (.content 0.35 over .app 0.55), so a
translucent black card nets 0.81 - darker than its own pane and inside the
~0.85 band already rejected once on the artist page. Solid mode's ladder is
measured UPWARD from #121212 (+6/+18/+24), so glass elevates with WHITE: new
--glass-surface/-hover/-press/-hairline/-well/-ring under :root.glass
(0.03/0.08/0.11/0.10/0.18), which is independently the vocabulary
.btn-ghost:hover and .ax-ghost:hover already shipped. The four solid tokens are
NEVER redefined - they are consumed inside color-mix() and gradient stops where
an alpha multiplies through and kills the call site (0.74 x 0.03 = an invisible
chip; the album row's text-overflow fade works by BEING opaque), and they paint
modal chrome that has to stay solid. 129 additive :global(:root.glass)
declarations across 18 files, all 129 verified inside a glass block by walking
the diff - solid mode byte-identical, confirmed structurally, by computed style
with the class off, and visually.

Three of them are not colour swaps: the album row's .rowbtns shim was OCCLUDING
the last ~19px of the listeners bar and nothing translucent can occlude, so
.tcount masks out just before the buttons instead; Playlist's banner scrim and
the discography's pinned ambience both landed on opaque --bg-base and now
dissolve like the album banner; and the 0.9 black hero tint on
Home/CatalogSearch/Discography paints straight onto the pane when there is no
artwork, so :first-child (the {#if} renders no element) flips it to white.
Menus, dialogs, toasts and the import surfaces stay opaque deliberately, and
the Settings copy for the mode now says so instead of claiming cards do.
Measured with a known ground substituted behind the transparent window, since
CDP never sees the acrylic: library toolbar chroma +2 as shipped, +16 with a
white fill AND its backdrop-filter dropped, +30 for the bare pane beside it.

RESIZE: Windows drops the legacy accent-policy acrylic for the whole of a live
resize and the window stays transparent through it, so the desktop punches
through sharp - measured by toggling window_glass alone, desktop icon labels
readable through the titlebar. The blur cannot be kept (documented since Win10
2004, and the modern DWM backdrop was already rejected for the
overlay-promotion blink), so armGlassResizeGuard grounds the canvas to
--bg-sidebar for the length of the gesture.

STALLS: "choppy but the FPS says 144" was 200-750ms MAIN-THREAD TASKS, not
frame pacing - idle-scheduled work fires at scroll pauses by definition. Twin
bake moved to display resolution and off the ready task (was a same-task 4K
WebGL readback plus software blur, 100-170ms per rotation AND per artist
entry); gl.finish() -> gl.flush() in buildMaster, which had been draining the
whole 4K EASU/RCAS/grade chain on the main thread; copyCanvas realloc guarded;
one app-wide scroll clock in scrollbar.js (markAppScroll/appScrollIdleFor) so
all three periscopes stop cloning mid-gesture on ANY pane rather than only
their own; bounded reclone with the querySelectorAll census deleted, the gate
widened 250->1000ms, and the build/attach split across an idle callback and a
rAF behind a generation guard; the hero rotation tick gated on scroll recency
and its payload cache sized to the working set (2->4 entries, 24->32MiB,
32->64MiB); sessionTick no longer force-rewrites every 12s while merely PAUSED.
Scroll storm went from 109-616ms long tasks to zero. Hover recalc and GC were
measured innocent - do not re-chase them.

98/98 tests throughout.
2026-08-27 17:24:20 -05:00
KeelrLeek 6ba1864498 dark glass, the whole app: frost on every pane, and the periscope pattern for every glass bar the frost sits under - backdrop-filter black-flattens over see-through ground (measured 3-vs-40), so bars over frost blur a scroll-synced clone behind a static band cut instead; the bar moved out of the scroller (pane shell, inner .content keeping the closest-contract, the old negative-margin flow replayed by a spacer after the artist page's sticky physics named the scrollport padding sacred); painted pages declare data-painted and keep the real backdrop bar, which is how the artist hero runs under the glass again; np wash rides the scroll away and dissolves to frost like the album banner; perf measured to vsync - 142px blur wraps (27.9ms frames down to 6.9), idle scroll-gated reclones, clip-path band, content-visibility on library rows, async cover decode; mini frosts over the mini:glass channel and the rest-frame flags ride window config because the env-var lane dies on some webview2 runtimes 2026-08-26 14:55:25 -05:00
KeelrLeek 225c9139aa release: 1.4.0-alpha 2026-08-26 07:57:03 -05:00
KeelrLeek dc7ea16b22 mini player + dark glass: eight shapes run by the hidden main as the one brain, dissolving covers and accents, drag from anywhere, and the space between the panes turned to real acrylic - with the webview flags that keep rest frames honest 2026-08-26 07:56:00 -05:00
KeelrLeek 7dfba58ea7 release: 1.3.0-alpha 2026-08-25 08:05:07 -05:00
KeelrLeek 6a7a5c49c0 overlay + library: the preloader working on glass, album names that land on the right self-titled record, and covers warmed before you scroll to them 2026-08-25 08:05:07 -05:00
KeelrLeek ad2c912b71 release: 1.2.0-alpha 2026-08-24 18:12:02 -05:00
KeelrLeek 50414dae5c playlists + player: every service's list through one door with its names matched for real, and shuffle that readies exactly what it promises 2026-08-24 18:10:34 -05:00
KeelrLeek 30eb6fe025 release: 1.1.0-alpha 2026-08-24 10:49:11 -05:00
KeelrLeek 1dd8573808 dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice 2026-08-24 10:49:10 -05:00
KeelrLeek 4efe85b6bf release: 0.1.0-alpha.4 2026-08-17 18:06:04 -05:00
KeelrLeek df3d15553a playlists + lyrics: setlists with pages of their own, and the words timed to the voice 2026-08-17 18:05:31 -05:00
KeelrLeek c4ff6269a3 import: records named by their own tags, and covers resampled where the pixels are
Two threads from one session, both of them the same shape: something was
being decided from the wrong source.

THE PROVIDERS WERE ASKED ABOUT A FOLDER. The picker sent the directory name
to Deezer and MusicBrainz — the ripper's naming, carrying catalogue numbers,
years and initials. "[WARP CD 156] Battles - Mirrored - 2007" matches
nothing anywhere; "Mirrored" matches instantly. Across the reference library
501 of 515 records carry a tag album and 315 disagree with their folder, and
no amount of cleaning turns "ACPP" into "Prospect Park, Brooklyn NY". The
record's own claim now leads and the folder is the fallback, in one place
(src/lib/import-title.js) that apply and the picker both read — apply
already resolved it this way, and only the picker did not. The verdict judge
had the same split: it compared candidates against the folder too, so an
exact answer scored as mere containment and a clean match wore a warning.

Identification also stops searching when searching cannot work. A folder
named "1989 Pauls Boutique" is a hopeless query and an easy comparison, so
after Deezer and MusicBrainz come back empty the artist's own catalogue is
ranked by title similarity. Scoring rather than stripping is the point: 1989,
1984 and 2001 are real albums, and any cleaner aggressive enough to rescue
Paul's Boutique would have destroyed them. Words split on whitespace with
punctuation folded inside them, so "Paul's" stays one token; pressing
vocabulary is ignored for scoring only; coverage is measured from both sides.
15 assertions pin the cases that would break it.

Cached verdicts now carry the engine that produced them. A cached match is
good forever, a cached "nothing found" only as good as the thing that
looked — and nothing ever re-asked. Negatives from an older engine, or from
a query that has since changed, are dropped and asked again.

Soulseek was asked the same wrong question, with the artist said twice:
"battles battles - mirrored - 2007". The server matches on ALL terms, so
every leftover token narrows the search — that copy was only found because
the peer happened to have 2007 in its path. The ladder now leads with the
record's name and keeps the folder as the narrower rung behind it.

AVAILABILITY OUTRANKS ENCODING. Candidate copies were ranked with avgBitrate
above hasFreeSlot, and queue depth — captured on every candidate — was never
read at all. A peer with no free slot and 277 people ahead beat a free peer
with a queue of two, because its files reported a bitrate and the others
reported none, and `?? 0` scores "didn't say" as the worst possible answer.
Bitrate is now ignored between two fully-lossless copies, where it describes
the material rather than the quality.

And nothing ever gave up. Track 1 is requested alone so it is not queued
behind eleven of our own files, the rest follow once the peer proves it will
serve us, and a copy that makes no progress for 45s is abandoned for the next
ranked one — cancelling the transfers it leaves, which used to trickle on
forever. A refusal falls through to the next peer instead of ending the
record. "Try another peer" is reachable while one is loading, which it never
was.

RE-IMPORTING BUILT A DUPLICATE FACTORY. Apply decided a track was already
filed by comparing the destination's size to its source — but apply itself
rewrites that file, embedding the chosen cover, so it is 582 KB larger from
the moment it is written and the sizes can never match again. Every run laid
down another " (n)" copy with its own library row: three runs left three of
everything, 2.16 GB. A file at the destination that this run did not write is
now simply an earlier import; within one run a repeated name is still a
collision, which is what `kept` is for.

ARTWORK FOLLOWS THE RECORD, ONLY THE AUDIO FOLLOWS THE CHOICE. Apply read
art from whichever folder the audio came from, which for a kept found copy is
the pen — and a downloaded album often carries no picture, so three records
imported with no cover while their own files held a 1500px one. It also only
ever looked at tags, never at the Folder.jpg the review had just offered as
"yours".

COVERS ARE RESAMPLED BY RUST, NOT BY THE COMPOSITOR. The webview was handed
2000px masters to paint 132px tiles — a 15x single-step reduction that reads
as aliasing on photographic covers, and carries megabytes of base64 to draw a
thumbnail. Artist portraits already had this exact fix; album covers never
got it. getArt is now bounded by default and getArtMaster is the deliberate
act, because ~60 places paint a cover and only two want real pixels: with the
polarity the other way round, every surface anyone forgets is silently wrong.
The fullscreen viewer also framed non-square art inside a square element, so
its rounded corners and shadow wrapped 96px of nothing; it takes the
artwork's own ratio now, bounded on both axes.

Smaller, from the same passes: the preview play button is a real transport
rather than a restart, and leaving a record silences its own preview without
touching music started elsewhere; bucket chips navigate with per-lens memory
instead of overwriting the saved resume position; Next counts from the
record's real place in a queue that re-sorts under it, which was skipping one
record for every one reviewed under a filter; a cover URL with nothing behind
it is shown as the empty answer it is rather than a broken image in a
pickable frame; write_tags returns early when there is nothing to change,
instead of rewriting the whole file to write values it already had; and the
import window has a Start over, which says what it costs before it costs it.
2026-08-10 18:08:58 -05:00
KeelrLeek e7d0971de2 import: the whole feature, and releases put back together
Everything the import grew over the last week, landing in one go — the
window, the picker, the audition pen, apply, and the review that stands
between them.

The fold is the new part. A release filed as a container of parts arrived
as N albums, because the scan groups audio by its immediate parent and the
container holds none of its own: Analord's eleven volumes were eleven
records, and six more releases split the same way. Records now fold into
the release above them, and apply is handed an explicit part list — never a
recursive walk, which is what let one stray file swallow 288 descendants
and duplicate 9 GB.

Where releases live is read off the collection rather than assumed, so a
genre-nested library moves the answer instead of breaking; below a dominant
majority at one depth the pass refuses entirely, because no folding beats
wrong folding. Three vetoes independent of depth catch a misread level.
Every fold is listed in the review with a way out.

research/import/fold-audit.mjs imports the shipping module, so the audited
code is the running code: 9 folds, 0 swallows across four library shapes,
and none of the 148 artist folders touched. It was that harness that killed
the tempting version of this rule — matching a part against its container
folded 40 artist folders, Mac Demarco's 8 albums into one, because album
folders carry the artist's name.

The regression test found a live bug on the way: parts number from 01 each,
so flattening two discs collided, and "same name, same size" read the
second file as already-imported and dropped it while still counting it.
That reuse now also requires that this run has not already written the
path.

Also here: the picker's accent crossfades between records instead of
cutting through the app's blue (ownership moved to the window, which
survives the per-record re-key), the fullscreen viewer wears the album's
accent instead of the one it left behind in the portal, Skip defers a
record without deciding it, and Keep mine is gone — with the cards picked
field by field it was Next under another name.
2026-08-10 07:55:59 -05:00
KeelrLeek 185c29fc33 library: the page lit by its own records, and portraits that prove out
The expander reserves once on the page root instead of standing min-heights
on the grid; shelf heads carry artist portraits keyed by path; every record
tints its own card, expander and glow from its cover; the artwork opens the
shared viewer; the banner wash reads the playing record and actually paints
(isolation, the artist page's own fix); descriptions animate on both pages
via partAnim. Portrait resolution rejects placeholder rows and never stores
a silhouette (pick_artist_portrait, 3 tests); repair re-resolves the missing.
2026-08-06 17:21:23 -05:00
KeelrLeek f55052506a library: three views of the collection, and what every file actually is
Kyle picked this off an interactive prototype (research/library/) over a
long evening of passes, and the settings he landed on are baked in rather
than left as choices: records no catalogue knows about are ordinary
citizens with no marks at all, artwork is generated from a record's own
name when there is none, the albums you do not have are one collapsed
line that opens into full-size dimmed cards, an artist's shelf splits by
release type, the toolbar is a floating card, the index rail is always
on, the List view's column header is welded to that card, the entrance is
a wave of records, and cards are the artist page's own 150px.

The artists accordion is gone. In its place: three views (Shelves, Grid,
List), six groupings (Artist, Decade, Format, Type, Added, None), thirteen
sorts that are independent of the grouping, a live filter that moves every
number in the header, and a stat row that is derived rather than written
down.

A library page has to say what the files ARE, and library_albums only
carries id/artist/title/year/art/count/added. So the library crate gained
album_stats: one pass over tracks per call, aggregating length, bytes,
average bitrate, bit depth, sample rate and the dominant file extension
per album, plus the newest play that met a seconds floor the caller
passes (the same 45s the recents list uses — under it a play is a skip,
and a page that sorts by "never played first" must not count skips as
listening). Aggregated in Rust rather than SQL because the dominant
format needs the path's extension counted per album, which SQLite cannot
do without string gymnastics, and a library is a few thousand rows.

Filing a record as an album, an EP, a single, a live set, a compilation,
a soundtrack, a remix record or bonus material is not a new opinion:
localReleaseType.js carries CatalogArtist's own title heuristics verbatim
— the regexes are the tuned artifact — reduced to the branches whose
evidence a LOCAL record can carry. A file on disk has no MusicBrainz
release group, no editorial lead sentence and usually no record_type, so
the MB and description-hint branches are absent because their inputs
cannot exist. Under it sits the floor the prototype settled on: when the
classifier lands on "other" and the row carries no provider type, the two
facts a file always has decide — three tracks or under ten minutes is a
single, seven or under twenty-five an EP — with the session/demo check
re-run inside it so a five-track practice tape stays bonus material. It
is a copy, and it is marked as one at every function; unifying it with
the artist page is follow-up work, not something to smuggle into this.

Clicking a record opens CatalogArtist's inline expander, class for class,
with its numbers unchanged: the 460ms slide whose height is read live per
frame and kept monotonic (a captured height animates to a stale number
once the tracklist lands), the staggered waterfall that fades each part
to its OWN resting opacity, per-row entrances at min(i*16, 180), and a
same-row switch that keeps both panels alive while the stage's height
eases between them. What differs is the content, because a library knows
different facts: where the artist page shows Last.fm plays and listeners,
the rail shows size on disk and bitrate, where it offers Save an owned
record offers its folder, and each track carries what its file is.

Three things about the chrome are measured rather than typed, and each
one is a bug the prototype found first. The bar's height is read off the
bar, because a typed 50px parks anything pinned below it behind a bar
whose controls have wrapped; the bar's own minimum is a constant, because
publishing a measurement into the property that decides it is a ratchet.
The index rail's 34px gutter is reserved whenever the rail is on, drawn
or not, so no click on Group can change the width of the content column.
And the table's column template lives on the page rather than on the rows,
because a 1fr column floors at min-content and RECORD is wider than an
ellipsized title, so identical declarations on two elements resolve to
different widths; columns drop by tier from the page's measured width so
a record's name is never squeezed out.

The entrance is timed by POSITION, not by index. Indexing the document
spends the whole stagger on sections nobody is looking at — 38 shelves
means every visible one hits the cap and they all arrive in the same
frame, which has no relative motion in it and reads as no animation at
all. Delay comes from how far down and slightly across the pane a thing
sits, normalized to the topmost thing that is both moving and on screen,
and the delays are pulled forward over the next few frames as
content-visibility geometry lands (only forward, except while a thing is
still hidden, where a correction is invisible). The hero, the toolbar and
the column header never move: they are the page's furniture, and dealing
them in animated the whole window instead of the part that changed.

A rail click corrects until its target stops moving. content-visibility
makes the height of everything unpainted an estimate, so one scroll to a
section overshoots — measured on the prototype at up to 1143px, with the
page's own height moving 2260px during the jump.

Two bugs fixed on the way, both the same mistake: `>>` on an unsigned
32-bit hash goes negative above 2^31, so a generated track name indexed
past its word list and a generated cover's second hue produced an invalid
gradient. Both are `>>>`.

Deliberately not here. There is no Import button because there is no
backend command to import a folder — Rescan re-reads the library and its
stats, and a real importer is a backend feature. The "N of M released"
count and the missing-albums line appear only for artists you FOLLOW: a
follow carries an anchored artist id, and matching a local artist to a
provider by name is exactly what the identity rules forbid, so an
unfollowed shelf says what it holds and nothing more.

Verified against the running app on real files: 7 albums, 72 tracks,
1.8 GB on disk off the new command, and Maladroit opening to 13 tracks,
33:37, 279 MB, 1019k, FLAC 16/44.1 with per-track rates, its notch under
the card it belongs to and its row landing 10px under the pinned bar.
Verified headless against the mock library at scale: all eighteen
view-by-grouping combinations at one content width, the toolbar pinned
with zero drift, the joined header's columns matching the rows on both
edges, the expander opening from a card and from a row, morphing in place
within a row and closing without leaving its reserve behind, and the wave
moving 74 things with 34 distinct arrival times across 391ms while the
chrome stayed still.
2026-08-05 18:02:40 -05:00
KeelrLeek 034eac9569 home: a landing page built from the app's own parts
Approved on research/home-redesign after four passes: the Desk layout,
the Showcase polish, the welcome card, a 6-month feed window, news with
reviews filtered, and all three of the sections I proposed.

Nothing here is a new design. The release cards, their grid, the date
chip and its accent-glass recipe, the upcoming wording and the missing
play button on an unreleased record are the artist page's. The section
heads, the featured card, the artist cards and the list rows are
ArtistExtras'. The entrance is CatalogDiscography's edition deal with the
same four approved numbers, and the arrival rule with it — a null from
consumeScroll means a fresh arrival, which is the only thing that deals;
coming Back is a return to something already read. Only the rails, the
aside panels and the news list are new vocabulary, and those came off the
mock verbatim. The styles are copies rather than imports for the same
reason the discography page carries copies of the album page's: a page
that reaches into another page's stylesheet couples them forever.

The feed is what "new" honestly means. Deezer's release list merged with
the MusicBrainz identity supplement — A ? of WHEN is a Panda Bear record
that exists only in the supplement, and Weezer's upcoming album only
carries its real name (Gold Album) there — then deduped per artist by
cover and by base title, which collapses Weezer's BBC triplet and
Röyksopp's four same-day remix drops to one card each. Upcoming first,
then newest, with the age riding the artwork so the reader never has to
work out whether "new" means this week or last spring. Jump back in is
deliberately NOT deduped against it: a record can be new and also what
you were just listening to, and it belongs in both.

Everything reads caches. Assembling this feed cold costs one
catalog_artist call per followed artist — 48 seconds for nine, measured —
so home_warm walks the follows in the background through the same entry
points the pages use and emits per artist as it goes. The page fills in
progressively instead of waiting for the last one, and a follow change
re-walks it.

Three sections were mine to propose and are now in: the back catalogue
(albums older than the window, one per artist first so a deep discography
cannot own the row), the library rail, and Complete the set — you own two
of sixteen Weezer albums and a new one lands in seventeen days.
2026-08-04 19:08:58 -05:00
KeelrLeek 8ef730848a news: headlines about the artists you follow, anchored to the artist
No per-artist music news API exists — every product that does this
aggregates and filters, so this does too. Google News RSS is the source:
free, keyless, no pagination, and no contract at all, which is why it
gets the most conservative pacer in the file (4 requests per 10 seconds)
and a 6-hour cache on disk as well as in memory. Nothing user-facing
waits on it; the home page asks after it is already drawn.

The query is the QUOTED artist name plus the word "music", and the
quoting is the whole design. Measured 2026-08-04 against the real feed: a
bare ambiguous name returned 0 of 10 relevant items — searching "Girls"
gives Indigo Girls, The Deli Girls and a school production of Mean Girls
— while the same name with a corroborating token returned 8 of 8, and an
unambiguous one 10 of 10. That is the identity-anchor rule the rest of
the app already lives by, applied to a provider that only speaks in
names: never trust the name alone.

The parser is deliberately not an XML library. One provider, four flat
tags inside <item> blocks, exactly the judgment the Bing scrape made.
What it does handle is what Google actually emits: entity-encoded titles
including numeric forms, a " - Publication" suffix on every headline
(stripped when the tail matches the <source> tag, so the title reads as a
headline rather than a citation), and RFC 2822 pubDates converted to unix
seconds by a hand-rolled civil-date conversion — nothing else in the
workspace needs a date crate for one field.

Freshness and relevance stay OUT of here. The 45-day window and the
review filter are display policy and belong to the surface that shows
them; this returns what the feed said, newest first, capped at a dozen.
2026-08-04 19:08:35 -05:00
KeelrLeek 50afa6a6a9 branding: the falcon replaces the waveform everywhere the app is seen
Kyle's pick — the deep-navy-glow rounded icon. Regenerated from the 1024
master through Tauri's own generator, so every surface comes off the same
art rather than a hand-resized copy per platform: icon.ico (16/24/32/48/
64/256 frames), icon.icns, the Square*Logo tiles Windows uses for the
Start menu and taskbar jump lists, and the Android and iOS sets. icon.svg
is the source SVG itself, so the vector beside the rasters is the one
they were cut from and not a stale ancestor.

public/logo.png is the app's own titlebar mark, drawn at 20px. It gets
the full-resolution master rather than a downscale — it is one asset, the
size costs nothing next to the artwork this app already holds, and there
is then no second file to keep in step when the icon changes again.

Verified in the running app: the titlebar draws the falcon, and the icon
extracted back out of the built exe is the new one, not a cached
resource.
2026-08-04 16:42:38 -05:00
KeelrLeek f0c2bb5ed3 taskbar: transport buttons under the window preview
Hovering the taskbar button pops a preview, and an app may put buttons
under it. Every media player uses them for the transport, and now so do
we: previous, play/pause, next, with the middle one swapping its glyph as
playback starts and stops.

Clicks are emitted as `soulseek:media-button` — the same event the OS
media card sends — so the frontend's existing dispatch handles them with
no new code and the two surfaces cannot drift apart. Verified by posting
the exact WM_COMMAND/THBN_CLICKED the shell posts: next advanced the
queue, toggle paused, toggle resumed.

Three Win32 facts shaped the implementation. The buttons can only be
added ONCE per window and only after the shell has created the taskbar
button, which it announces with a registered message — but Tauri shows
the window before the setup hook runs, so that announcement is usually
already gone by the time we could subclass, and the first build put no
buttons up at all. They are added directly at init now, with the first
playback as a retry for the case where the taskbar button genuinely does
not exist yet. Clicks arrive as WM_COMMAND, so hearing them means
subclassing Tauri's window — SetWindowSubclass chains rather than
replacing the window procedure, so wry still receives everything it
expects. And ITaskbarList3 is apartment-threaded, so it lives in a
thread-local touched only on the UI thread, with updates from the
player's event thread marshalled through run_on_main_thread.

The glyphs are drawn at runtime rather than shipped: they are four flat
shapes, and a .ico per glyph per theme would be files to keep in step
with a transport that already draws itself in the app. Coverage is
sampled on a 4x4 grid per pixel, which is what stops a 16px triangle
looking like a staircase. They follow SystemUsesLightTheme — the
thumbnail flyout rides the system theme, not the app's — and are rebuilt
on WM_SETTINGCHANGE when that flips, so they do not end up dark-on-dark.
2026-08-04 16:08:26 -05:00
KeelrLeek f6eb9ff02f media card: Windows knows SoulStream is a music player now
Registering with the System Media Transport Controls is what makes an app
a media app to the OS rather than a window that happens to make noise.
The card that the media-key overlay, the taskbar media flyout and the
lock screen all draw now carries our cover, title, artist and album; the
hardware media keys reach us with the window unfocused; and the transport
buttons on that card drive this app. Verified against the OS's own
session list: title/artist/album/albumArtist, type Music, a live
scrubber, a present thumbnail, and play disabled while playing.

Where each half of it comes from follows where the truth already lives.
Status and position are pushed by the backend's player event pump, which
already sees every transport change and every position tick — the card
is driven by the same events the UI is, so it cannot drift from the
window, and it costs no IPC at all. Metadata goes the other way, once per
track, published by the player bar: the queue lives in the frontend and
so does the derivation the bar displays, and publishing exactly what the
bar shows means the two can never disagree. Deriving it a second time in
Rust would guarantee that one day they do.

Button presses are forwarded as `soulseek:media-button` rather than
handled in Rust, because nextTrack knows about shuffle, repeat, the album
fill and re-sourcing a track restored from the last session, and none of
that is knowable from that side. They run the same functions the on-screen
transport runs — the play/pause branch moved into togglePlayPause() so
there is one implementation, not two that drift. Proven by driving the
app from the OS session: next advanced the queue, pause and play moved
the status, previous restarted the track on the >3s rule the app already
had.

Stop is deliberately disabled: this transport has no stop, only pause,
and a dead button on the card is worse than a missing one. Buffering maps
to Changing, which is what "finding best source…" honestly is. The
thumbnail is the bar's own data URL decoded into an in-memory stream —
no temp files — and it is explicitly cleared when a track has no cover,
because the previous track's art outliving it is worse than none.

The debug handle gains the local playback entry points beside the
streaming ones it already had. That is not only convenience: verifying
this end to end needs a track playing, and every run that reaches for
Soulseek to get one costs a login the server starts ignoring after a
burst.
2026-08-04 16:08:10 -05:00
KeelrLeek f72b12a892 release: 0.1.0-alpha.3 2026-08-04 14:49:28 -05:00
KeelrLeek 520e2ad4e4 follow: the artist page's heart becomes a real thing, stored and celebrated
Following an artist now writes a row: followed_artists (artist_id, name,
portrait, followed_at) beside the playlists in the library db, reached
through follow_artist / unfollow_artist / followed_artists, each emitting
soulseek:follows-changed. Re-following refreshes the name and portrait —
identity improves as pages are visited and a later portrait is usually
the better one — but keeps the original followed_at, so "following since"
stays honest. Name and portrait are stored on purpose rather than looked
up: the home page's "new releases by artists you follow" can then draw a
card from the row alone, with no catalog request per artist.

The store is the UI truth and the chip never keeps its own copy. It is
optimistic — the chip flips at the click and reverts if the write fails,
rather than leaving the screen telling a lie.

The chip itself (FB4) leads the stat row and wears the accent as ground
with dark text, the Play pill's own trick, so its on-state is legible
across the room. The lead slot is a fixed 14x14 in which the +, the
equalizer bars and the check swap places, so nothing about the chip's box
moves except the word — and the word morphs between its measured widths
instead of snapping. Clicking runs the Encore: the + collapses, three
bars dance three beats with a note rising per beat, they bow, and the
check springs in as "Following" grows into place. The db write and the
toast fire at the CLICK; the ceremony is presentation only, so
navigating away mid-show still follows. Unfollow is an undo — the shrink
morphs, nothing celebrates, and the toast says so.

Every effect is template-owned with scoped keyframes. The first build
spawned the glow and the notes imperatively and referenced -global-
keyframes, which silently did not resolve: the glow ran no animation and
sat at full opacity, the notes held opacity 0 and never appeared. The
bars worked throughout for exactly the reason the others did not.

The row around it is shorter too, at Kyle's call: the library and
listening chips (progress meter, the disk popover, format, hours, last
played, top track, genre) come out, leaving the identity trio and the
socials. With nothing left to shed, the socials' scroll-driven fade and
their layout drop at the park boundary come out with them, and the
divider's clear space moves from the follow heart by name to whichever
chip now closes the run. The JS that fed the removed chips stays — it is
cheap, local, and the numbers still have a home coming.
2026-08-04 14:43:27 -05:00
KeelrLeek 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.
2026-08-03 16:08:51 -05:00
KeelrLeek 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.
2026-08-03 16:08:26 -05:00
KeelrLeekandClaude Opus 5 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>
2026-08-03 07:03:20 -05:00
KeelrLeekandClaude Opus 5 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>
2026-08-03 07:03:03 -05:00
KeelrLeek 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.
2026-08-01 07:11:17 -05:00
KeelrLeek eba29824c9 sidebar and artist pipeline: one list in the rail, recents anchored to ids, and popular takes its own lane 2026-07-31 18:21:04 -05:00
KeelrLeek 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.
2026-07-30 07:39:44 -05:00
KeelrLeek 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 5682dfc touched. Stripped with Node rather
than PowerShell, whose default Out-File/Set-Content encoding is what wrote
them in the first place — the files are byte-identical but for the three
leading bytes.

Cargo.lock carries the 0.1.0-alpha.1 -> alpha.2 propagation the release
commit left behind in the working tree.
2026-07-30 07:39:30 -05:00
KeelrLeek 5682dfc22f release: 0.1.0-alpha.2 2026-07-29 18:46:38 -05:00
KeelrLeek 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.
2026-07-29 18:45:43 -05:00
KeelrLeek 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.
2026-07-29 08:11:49 -05:00
KeelrLeek 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.
2026-07-28 13:00:09 -05:00
KeelrLeek 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.
2026-07-28 09:39:14 -05:00
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