Commit Graph
16 Commits
Author SHA1 Message Date
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 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 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 e536e96180 discography: drop guest appearances, but only where MusicBrainz can arbitrate
Deezer's /artist/{id}/albums returns records the artist merely contributed to,
alongside their own. Mid-Air Thief's page carried four Rooby Jeantal lo-fi EPs
that way -- his real discography is two albums.

This is not the namesake bug that was hardened in July. Every identity step here
is correct: MusicBrainz links the artist to Deezer 51082452, that is the id we
open, and the MB supplement injects both real albums. The leak is credit ROLE,
which is why an identity fix never touched it.

Deezer cannot answer it alone. Album details expose the primary artist, so a
guest spot is visible -- but every contributor is marked role "Main", so Kanye
West on "Watch the Throne" (primary artist JAY-Z) is byte-for-byte the same
shape as a guest EP. A "not primary, therefore drop" rule deletes a real album.

MusicBrainz separates them, because it credits a genuine collaboration under
BOTH artists: Kanye's own MBID returns "Watch the Throne", Mid-Air Thief's
returns only his two albums. So the backend only FLAGS (`appears_on`, set from
the details pass where the release's primary artist is finally visible) and the
frontend drops a flagged row at the identity merge only when MB answered for
this artist and does not credit the row. No MB albums means no arbiter and
every row is kept -- thin-coverage artists never silently lose real releases.

EPs now enter the album-details pass, which is what made this invisible: the
pass was album-only, and all four guest releases are typed "ep". They already
sort ahead of singles for the details cap.

Replay recipe bumped to v2: pages cached before this are stamped
identity_merged and would skip the pass that drops the rows.

Verified live -- Mid-Air Thief 6 albums -> 2 (무너지기/Crumbling, 공중도덕);
Kanye West keeps "Watch The Throne (Deluxe)", flagged appears_on but MB-credited,
with all 58 rows intact.
2026-07-25 17:15:39 -05:00
KeelrLeek 9cd03a6ecc hero: tell a square press crop from a sleeve by hashing the artist's releases
The aspect floor rejected everything below 1.15 because a square raster is
usually album art, and album art clears every other gate. That proxy also threw
away square PRESS photography -- Nikki Nair's entire usable corpus is 1:1 crops
from Rinse FM, Corsica Studios and 529 Atlanta, so he had no route to a hero at
all.

Shape cannot separate those two cases, but the catalogue can: a sleeve IS one
of the artist's covers. A square candidate is now perceptually hashed against
up to 25 of the artist's own releases and admitted only when it matches none,
reusing the dhash and distance threshold already used for cover resolution.
With no hashes available -- no Deezer id, or the lookup failed -- it falls back
to the old blanket rejection rather than guessing.

The covers are fetched alongside Bing discovery rather than after it, at 250px
and mostly from the shared image cache, so it costs no wall clock next to the
multi-megabyte hero candidates. The six-second seed path passes no hashes: it
exists to paint something safe quickly, and a square candidate can wait for the
exhaustive pass to adjudicate it.

Nikki Nair goes from BANNER NONE to 2 -- a withguitars promo shot and a 529
Atlanta press photo, verified rendering correctly. Camping in Alaska is
unchanged with its three banners, so the guard that this gate was originally
added for still holds: its square "lost and found" sleeve stays out.

Tests cover the fork directly: a square matching a release is rejected, a
square matching none is admitted, a square with no catalogue knowledge is
rejected, portrait is admitted, and a portrait too narrow to crop is not.
2026-07-25 16:46:07 -05:00
KeelrLeek d79fe2f28a hero: admit portrait sources, the crop was built for them
The aspect floor is really an ALBUM-ART guard -- its own comment says so: a
square sleeve passes every other gate, and Camping in Alaska's square "lost and
found" artwork once reached a live hero slot that way. But it was written as a
blanket "wide only" rule, so it also discarded portrait press photography one
crate before the machinery built to handle it.

The app already crops tall sources to 16:9 around detected faces: hero_crop.rs
keeps full source width, slides the window vertically, and applies headroom
rules above the topmost face (recipe vertical-yoloxnano-...-faces-first).
maximal_hero_rect exists specifically for sources taller than 16:9. Meanwhile
the selection gate in the metadata crate rejected everything below 1.15, so
those images never arrived.

Sleeves are square; press portraits are not. Admitting ratio <= 0.92 with at
least 1000px of width keeps the guard exactly where release artwork lives while
opening the one route to a hero for artists whose entire press corpus is
portrait. Posters and flyers cannot enter this way either: the text, flat and
collage gates all run before this one.

Hana Vu goes from BANNER NONE to 2 -- the LA Times 2000x2500 portrait and an
Our Culture press shot -- and renders correctly, face placed with headroom.
Camping in Alaska is unchanged, same three banners. Square 1:1 press crops
(Nikki Nair's Rinse FM and Corsica shots) stay rejected, which is the guard
still doing its job.
2026-07-25 16:35:39 -05:00
KeelrLeek 9536ff23a9 hero search: stop reading " x " as a co-bill, and reject explicit results
Two follow-ups to reading both Bing buckets.

The multi-artist guard treated " x " as a co-bill between two acts. In practice
it far more often joins a venue and a festival: "Nikki Nair | Boiler Room x
Bass Coast 2022". Because every YouTube thumbnail is named maxresdefault.jpg,
image_names_artist is structurally false for that whole class, so the rule was
rejecting Boiler Room and festival set photography -- the richest source of
hero-shaped images that exists for DJs. Genuine collaboration graphics are
still caught by named_collaboration, which requires the image itself to name
the other subject, so nothing is lost. Nikki Nair's multi= counter drops from 1
to 0 with his identity accepts intact.

And explicit results are now rejected before the identity gate. Reading the
unfiltered bucket is necessary for coverage but it puts pornography in the
candidate pool: one artist's unfiltered wave returned 24 hardcore cards. An
audit found zero of ~67 such cards could clear bing_identity_confidence,
because porn pages do not name obscure musicians -- but that is an empirical
accident rather than a guarantee, and reading only the strict bucket used to
provide an independent second layer. This restores one. The vocabulary is
deliberately narrow so "adult contemporary", XXX as a roman numeral, and band
names like Nude Beach or Pornography stay eligible.

Canary unchanged: Fishmans still accepted=12 with the same three banners.
2026-07-25 16:25:17 -05:00
KeelrLeek 8b2aadce2b hero search: read both Bing safesearch buckets, and the bare artist name
Bing's safesearch preference selects which cached result set answers a query,
and either bucket can be serving a DIFFERENT query's results. Measured on one
machine minutes apart:

  Fishmans     strict 32/35 relevant | unsignalled  0/35 (porn, a chord page,
                                                          a printable calendar)
  Nikki Nair   strict  0/35          | unsignalled 35/35 (Resident Advisor,
                                                          Rinse FM, Boiler Room)

So there is no correct global setting. Shipping strict alone in ee25095 fixed
Fishmans, Partiboi69 and Camping in Alaska while silently hiding artists Bing
can see perfectly well. Every wave now reads both buckets and pools the cards,
deduped by media url, and the identity gate picks the winner -- it rejected
~3,000 cards in the audit behind this with no observed false rejects.

The query ladder also gains the bare artist name. Decorated queries collapse a
rare artist onto a popular entity sharing a word: every "Hana Vu ... photo"
phrasing returns the Japanese group HANA, quoting included, while the bare name
returns her real NME and LA Times press shots. Every accept on the three newly
fixed artists came from this query.

A 1-2 card page now counts as a degraded answer rather than only an empty one
-- a single junk card was enough to blank an artist for CATALOG_TTL, and that
was 15 of 30 waves for MSPAINT and Billy Cobb in one audit. The richer of the
two reads wins so a thin retry cannot lose a good first answer.

And fanart.tv publishes newer uploads under artist4kbackground, not the legacy
artistbackground the code asked for. MSPAINT's ONLY background is a 3840x2160
band photo under the new key -- fetched and discarded on every page load while
the artist showed no hero at all.

Measured across 21 artists, every winning image inspected: 8 -> 10 with a
correct hero, no wrong-artist hero anywhere, canaries (Fishmans, Partiboi69,
Camping in Alaska) unchanged. Newly working: Jane Remover, They Are Gutting a
Body of Water, MSPAINT.
2026-07-25 16:22:08 -05:00
KeelrLeek 7ce9fd0efc artist art: keep a known-good hero instead of rediscovering it daily
An artist's hero was re-discovered every 24 hours through a tier that fails
per-name, and the stale manifest was deleted on the way in -- before the
replacement lookup had run. So one bad discovery day did not merely fail to
refresh a hero, it destroyed the good one first and left the artist blank.
On this machine that took the persisted store from 26 artists down to 3;
Crazy P, MSPAINT, Sword II, Hermeth, Billy Cobb, Pond, Weezer, Daft Punk and
King Krule all had correct banners on disk that nothing would ever read again.

Three changes:

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

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

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

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

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

Verified live: Crazy P, MSPAINT, Sword II and Hermeth all render heroes again.
2026-07-25 14:50:55 -05:00
KeelrLeek ee2509538a metadata: send safesearch=strict on Bing image searches
Our client keeps no cookie jar, so every Bing read arrived with the
adult-content preference undefined -- and in that state Bing answers with
wholly unrelated cached result sets. Four identical back-to-back reads of
"Fishmans band photo" returned porn, an Indonesian guitar-chord page, porn
again, and a printable 2026 calendar, each internally coherent with honest
titles and page urls.

Measured 27/27 reads with zero relevant cards without the flag and 27/27
correct with it, across three artists, three waves and three repeats. Only
strict works; moderate and off behave exactly like sending nothing. Strict
costs no relevance (Partiboi69 scores 34/35) and it also keeps explicit
imagery out of the candidate pool, which until now only the identity gate
was stopping.

This failure was misdiagnosed twice -- first as a poisoned IP, then as Bing
rate limiting -- because with no cookie our requests are byte-identical
every time, so all the variability lived in Bing's cache. That is why it
appeared to break and heal on its own with no code change.

Camping in Alaska, Fishmans and Partiboi69 now resolve real heroes again.
2026-07-25 12:49:25 -05:00
KeelrLeek 3718dad667 metadata: say why the Bing hero tier found nothing
The Bing hero tier currently returns no banner for artists it used to cover.
Every gate in the candidate loop is a silent `continue`, so a tier that throws
away all 525 parsed results looks identical to Bing returning nothing at all —
which is why this has now read as a fresh regression four times without anything
being able to confirm or deny it.

Emit HERO_GATES alongside HERO_WAVE: what was parsed, what was accepted, and the
count discarded by each gate, plus the first few identity rejections in full.
That immediately separates "our filter broke" from "Bing sent us junk".

It says the second one. For Camping in Alaska: parsed=525, accepted=0,
identity=510 — and the rejected cards are campground photos from recreation.gov
and thedyrt.com. Partiboi69 returns Bruce Lee wallpapers, Fishmans returns Tommy
Fury boxing coverage, Hermeth returns aesthetic quote wallpapers. The results are
unrelated to the query and to each other, so the identity gate is doing exactly
its job: refusing to hang another person's face on the artist.

Confirmed outside the app, straight from curl. Zero query-relevant result cards
on any variant — with and without qft/form/first/cc/setlang/tsc, with full
browser client-hint and sec-fetch headers, and against the /images/async grid
endpoint the page itself uses. The async endpoint answers "Fishmans band photo"
with AI-course ads and "Partiboi69 band photo" with celebrity photoshoots. Our
url is well formed and the parser is fine: `q` is properly encoded, and cards
still parse (252-525 of them). Bing is simply no longer honouring the query for
this client.

Also stop one bad answer from sticking. Bing serves the SAME url a card-less
page on one read and 25-80 cards on the next, and browser_html caches whatever
came back for CATALOG_TTL — so a single degraded response left an artist
heroless for fifteen minutes. A wave that parses to nothing now drops the cached
page and asks once more. This does not address the wrong-results failure above;
it removes a compounding one found while chasing it.

No hero behaviour is changed by this commit.
2026-07-24 18:38:01 -05:00
KeelrLeek 86e0d86604 fix: preview crossfade no longer strands the main volume (silent playback)
Clicking a track could play silently: the store showed status=playing with
the position advancing and no error, but the sink was at volume 0. The
open/close crossfade ramped the main player's USER VOLUME as its fade
channel and left it at 0 on an ack timeout (or when a track change
suppressed the close resume). User volume persists across tracks and a new
Play doesn't reset it, so every later click played at 0 while the slider
still read 0.7.

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

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

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

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

131 JS + player + app tests green; every path verified live over CDP.
2026-07-23 16:05:14 -05:00
KeelrLeek 8cb3cd593b Phase 2A+2B: native preview backend and renderer glue
Rust (2A):
- metadata: deezer_track_uncached (fresh mint, cache-updating, paced) and
  fetch_preview_media — dedicated client whose custom redirect policy
  re-validates every hop against the media allowlist; bounded body read
  (cap enforced on received bytes, not Content-Length's promise); Date
  header captured for provider-clock lease math. httpdate dep added.
- src-tauri/preview.rs: second Player instance + soulstream:preview event
  channel; preview_resolve (fresh mint → host gate → MIME gate → ID3v2
  shell strip → MPEG sync check → clip file + opaque lease id → safe_for_ms
  mirroring lease.js); preview_load with start_paused via the
  desired-paused latch; preview_control/dispose; player_pause_acked
  exposing the Phase 0.4 ack on the MAIN player. 9 unit tests.

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

70 JS tests passing (npm test); metadata 46 + app preview 9 in cargo.
2026-07-22 18:45:29 -05:00
KeelrLeek 6dcc62a2fd Phase 0.4: silence-ack primitive — pause_acked + desired-paused latch
Player::pause_acked() returns a generation-bound PauseAck applied on the
audio thread; a desired-paused latch makes an in-flight decoder come up
paused instead of auto-starting, closing the window where a pause lands
during decode setup. Proven live by examples/focus_ack_probe.rs:
ack-while-playing freezes position; ack-while-buffering never becomes
audible; resume clears the latch; plain pause() during buffering is
demonstrated to auto-start (pre-existing hazard, filed separately).

Transport ADR recorded (native), Phase 0 marked complete in the plan.
2026-07-22 18:17:37 -05:00
KeelrLeek a667b4b28e Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
2026-07-22 17:57:00 -05:00