feat(voices): the audition room - #586
Open
rosscado wants to merge 20 commits into
Open
Conversation
Settings > Voices rebuilt as a listening room: one pitch-ordered rail of soundprints drawn from each voice's own sample clip, registered against a shared frequency line. Space plays, arrows walk, Shift+Space switches back. Appendix B records an independent replication of the script-sharing measurement that justified cutting own-words audition: all 12 Everyday clips pause at 0.35-0.42 of span, HD clips scatter. That resolves design open question #1 without a listening test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Onyx (deepest, 92 Hz), Addison (brightest, 260 Hz) and Sage (the quiet outlier) decoded from the live api.saypi.ai clips to 8 kHz mono int16. jsdom cannot decodeAudioData, so the extractor's DSP is provable only against pre-decoded PCM; decodeAudioData stays the browser-only seam. Sage measures -24.3 dBFS voiced RMS against -15.4 (Onyx) and -14.7 (Addison), peak 0.28 vs ~0.80 — independently confirming the ~7 dB deficit the design attributes to a server asset bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Voices controller carried a DOM-free half — host scope (VOICE_HOSTS, resolveInitialHost, LAST_HOST_KEY), the derived StudioViewModel, and the #474 twin-name strategy — inside a class whose other job is painting. It was always pure, but living in the controller meant every assertion about pin resolution, menu seating or twin-name strategy had to be made through rendered DOM. Moved verbatim to voices-view-model.ts (design §14). `viewModel` becomes a free function; the controller re-exports VoiceHostId so its public surface is unchanged. No behaviour change — the existing 53 controller tests pass untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n snapshot Mechanical, on its own so the substantive change that follows is readable. `VoiceStudioDeps.playPreview`'s `(playing: boolean)` callback becomes a subscription over `AuditionState` snapshots (design §5.1): running, which voice is playing, which is loading, position in the sequence, and a discriminated error. The player behind the seam is untouched — still the module-global single-element `playPreviewClip` — so both latent defects it carries are still present and still reproducible. Why the shape changes: a per-voice boolean can only say "this voice, on or off". That is why a superseded clip's terminal event can write into the new voice's UI, and why a repaint has nothing to restore playback state from. A snapshot names the voice, so the studio paints from it wholesale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DEFECT (a). The shipped player calls `previewAudio.pause()` — which QUEUES a
pause event, per the HTML spec's "queue a media element task" — and then
reassigns the module-global `previewOnState` before that task runs. The
superseded clip's terminal handler therefore writes into the NEW voice's UI.
Cosmetic with one clip; a skipped voice the moment a sequence depends on it.
Fail-first, against the shipped algorithm transliterated into the class:
FAIL previewSequencer.spec.ts > never lets a superseded clip's terminal
handler write into the voice that replaced it
AssertionError: expected null to be 'coral'
PreviewSequencer replaces it. A monotonic session token is the entire
cancellation model: `play()`/`stop()` bump it, and every asynchronous
continuation — the play() promise, the inter-clip timer, and via a per-buffer
{session, index} stamp every media event — returns immediately if stale. The
controller already owns this idiom as `renderToken`, so the file now has one
cancellation vocabulary.
Also here, because they are the same object:
- two <audio> elements, preload="auto", alternating, so N+1 loads while N
plays (with one element, setting src kills the current clip);
- a deadline-scheduled beat — on ended(N), start N+1 at
max(now + 320 ms, whenReady(N+1)), and if it isn't buffered the gap
stretches visibly via loadingVoiceId rather than mysteriously;
- error discrimination in place of a blanket catch: AbortError is a
legitimate supersede and is ignored, NotAllowedError becomes an actionable
"blocked" state, a failed clip is marked and the sequence carries on after
the beat, two consecutive failures stop it;
- `qualifiesAsHeard`, pure — `ended` OR
`currentTime >= min(1.4 s, 0.65 × duration)`, with an unknown (jsdom: always
NaN) duration falling back to the 1.4 s branch. The sequencer is the only
honest emitter of "heard": `onState(false)` fires identically for pause,
ended and error, so a caller cannot tell why playback stopped.
23 new tests drive it through an injected element factory that models the
three spec behaviours the design leans on — queued pause events, a pending
play() promise rejecting with AbortError, and readyState gating the beat.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DEFECT (b). Audition state lives outside the DOM — the sequencer owns it — so
a repaint that rebuilds the body from scratch orphans it. `useVoice` does
exactly that (`body.innerHTML = ""`), and choosing the voice you are currently
listening to must NOT stop the audio (design §5.2): the clip goes on sounding
while every mark of it has just been destroyed.
Fail-first:
FAIL voices-controller.spec.tsx > keeps the playing mark across a repaint,
because the audio keeps playing
AssertionError: marin's clip is still sounding, so it must still read as
playing: expected 0 to be greater than 0
`paintBody` now ends by re-deriving the marks from the last snapshot. It is
the snapshot that makes this possible at all — a per-voice boolean line had
nothing for a fresh DOM to ask.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects found by review of the sequencer slice, each reproduced by a failing test first. A 404 reports itself TWICE — per the HTML spec's "failed with elements" steps the element fires `error` AND the pending `play()` promise rejects with `NotSupportedError`. Both landed in `fail()`, so the very first bad asset tripped the "two consecutive failures" stop and killed a sweep that was supposed to note it and carry on. `fail()` is now idempotent per item: an index is the whole identity of a failure, since each item is attempted at most once per session. A failed PRELOAD then condemned the voice AFTER it. `preloadFailed` was cleared only by `preload()` (reachable solely from `startItem`), which the fail-and-advance path never runs — so the next voice was reported failed by name without ever being fetched, and the sweep halted blaming it. `fail()` now re-points the spare buffer at the item it is advancing to; and `preload()` no longer clears the flag on a no-op re-point, because forgetting that the clip in that buffer has already errored would leave the beat waiting forever for a `canplay` the element will never fire again. Relatedly, an `error` on the active buffer now blames what that buffer is CARRYING rather than `this.index`, which a preceding failure may have already advanced. Finally, `refreshCuration` rebuilds the menu-slot section — orbs and all — so pinning a voice mid-clip destroyed the playing mark of the voice you were listening to. Same orphaning as a body repaint, same one-line fix: re-apply the audition snapshot afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 22 gradient orbs were djb2-derived. They looked like they encoded something about a voice and encoded nothing, which is worse than plain. This replaces them with a soundprint: a pitch trace read out of the voice's own sample clip, drawn on a shared logarithmic frequency axis with one faint reference line at 155 Hz through every mark. Vertical position now means the same thing everywhere — a deep voice is drawn low, a fast voice's print is short, the gaps are the consonants — so the page starts becoming one chart instead of 22 unrelated marks. The same single decode pass pays for itself three times. It produces the mark, the pitch ordering slice 2 sorts on, and the per-clip level match: `<audio>.volume = clamp(10^((-17 - measured)/20), 0.25, 1)`, which collapses the catalog's 4.1 dB non-outlier loudness spread to ~0. It is attenuate-only by construction and therefore cannot rescue Sage, which ships ~7 dB quiet — a server asset bug, filed with the numbers, not something worth attaching a permanently-suspended AudioContext to every playback to compensate for. The voicing gate is RELATIVE — max(0.12 * p95(rms), 1e-4). An absolute threshold throws away a fifth of Sage's frames precisely because Sage is quiet, and prints it sparse and broken beside the neighbours it is meant to be compared with. test/tts/voicePrint.spec.ts guards this two ways: Sage's real fixture must stay as dense as the loud clips, and Onyx attenuated by 7 dB must produce a byte-identical print. Measured against the committed 8 kHz PCM fixtures: Onyx 92 Hz / 1.17 s / -15.5 dBFS, Addison 259 Hz / 0.77 s, Sage 186 Hz / 1.75 s / -24.3 dBFS. The seed table in VoicePitch.ts agrees with the shipped extractor to within 1.5 semitones on all three, which is what keeps the rail from reordering under the reader on first paint. Marks are measured at concurrency 4 inside requestIdleCallback, driven by an IntersectionObserver so only near-viewport rows decode, skipped entirely under navigator.connection.saveData (a voice the user actually plays still earns its print, since playing it fetches the clip anyway), and cached in chrome.storage.local under `voicePrints` keyed "<voiceId>@<vHash>" from the ?v= content hash the server already puts on every sample_url — so invalidation is free. Cap 300, oldest evicted. Layout is untouched: the print drops into the existing stage, slot and card in place of the orb it replaces, at 300/72/118 px. The rail is slice 2, deliberately after this, so the aesthetic bet can be judged before anything is demolished. Also gone with the orbs: VoiceIdentity's colour half (the 28-entry table and djb2), ~115 lines of orb CSS, the innerHTML sink from a privileged extension page, and the stage's --stage-from/-to color-mix fallback stack, which existed only to keep white text readable on a hash-chosen hue and now collapses to the two colours it always resolved to. Two knowing deviations from the design, both noted for review: - No playhead rect yet. §6.1 draws one, but it needs the sequencer's currentTime, which is not exposed until the rail; a static bar at x=0 would be a dead indicator. - `amp[]` is stored pre-normalised to the clip's p95 — exactly the loudNorm §6.1 asks the renderer for — so a cached print is self-contained and the renderer stays pure geometry. Design: doc/plans/2026-07-31-voices-audition-room-design.md §6, §7 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four defects from review of the slice-1 soundprint. Each is the print either measuring less than it should, or being unable to show what it did. The voicing floor was pitch-dependent. `corr[lag] = sum / zero` divides a correlation summed over `frameLength - lag` products by the WHOLE frame's energy, so the reachable correlation collapses as the lag grows: over a 32 ms frame a perfectly periodic tone can only reach 0.52 at 100 Hz, 0.35 at 80 Hz and 0.14 at 60 Hz. Against a flat 0.30 a deep voice therefore failed the voicing test far more often than a bright one -- Onyx drew 49 of its 73 gated frames (0.67) beside Addison's 55 of 57 (0.96), and the misses clustered in Onyx's lowest stretches, so the catalog's deepest voice went moth-eaten exactly where it went deepest, on a page that invites you to read those prints against each other on one axis. That is the VOICING_GATE_RATIO lesson unlearned on the pitch axis: an absolute threshold against a quantity whose scale varies per voice. The floor is now a fraction of what the window can reach at the winning lag (`voicingCeiling`), bounded below at 0.15 so the 60 Hz end cannot be talked down into calling noise a voice. Onyx 0.67 -> 0.95, Sage 0.87 -> 0.94, Addison 0.96 -> 0.98, with the ordering axis unmoved: medians 92 -> 91, 186, 259, every one inside a quarter-semitone of the seed. It changes only which frames are ACCEPTED, never which lag WINS -- the argmax still runs on the lag-0 normalisation whose bias against long lags is the octave-down guard the three-setting validation rests on. A grandfathered current voice never got measured in a real browser. The IntersectionObserver callback re-derived the voice from its DOM id through the catalog, but the stage renders the STORED preference when the catalog has no entry for it, so that one mark stayed a bare reference line forever. jsdom could not see it: with no IntersectionObserver it takes the branch that is handed the voice object directly. The observer now closes over the voice (a WeakMap keyed by mark), `voiceById` is gone, and the spec stubs an IntersectionObserver so the path users actually take is covered at all. The HD chip painted over the print. It is absolutely positioned at the card's top-left, and the 118px print starts at x~17 where the 56px orb it replaced started clear at x~49. Measured in the built extension: 19.7 x 10.5px of the print's top-left was covered on 8 of the 10 HD cards -- everything above ~176 Hz in the first ~7 frames, so the brighter the voice the more of its mark vanished, which is the exact inverse of what a shared axis is for. The card now reserves the chip's band, uniformly, so prints stay on one baseline. Playing and hover were one declaration. Clicking a mark changed nothing under the cursor, and a merely-hovered voice painted identically to the one that was actually sounding -- a net loss against the orb's animated equaliser. Hover now stops at 0.82 so full ink means exactly one thing. The playhead that carries this in the finished design stays deferred: it needs the sequencer's currentTime, which AuditionState does not carry until the rail. Also here: the stage's 300px print was flex-shrink:0 inside a flex row, so below ~950px of window it ate the text beside it (at 800px the eyebrow, the tagline and the play pill all wrapped while ~110px of the print was empty reference line) and overflowed the box once the 560px query stacked it. It scales now, which the viewBox makes free. And `ruleBody` in the layout spec is anchored to a line start -- `.voice-print-mark:hover` was resolving to `.voice-stage .voice-print-mark:hover`, which turns "this rule is missing" into a silent pass on a different rule. Design: doc/plans/2026-07-31-voices-audition-room-design.md §6.1, §6.2, §8 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stage, menu-slots section, tier shelves and the card grid are gone. In their place: one host-scoped role="listbox" of 42px rows ordered deepest to brightest by measured pitch, a sticky 44px control bar, a heading row carrying the host switcher, and one menu summary line. The current voice is NOT pinned to the top — it keeps its pitch position with an IN USE marker and an accent left rule, because pinning it would break the chart the pitch order creates. Focus lands there on first paint, and survives every repaint: choosing a voice used to dump the reader back at the deepest row. The keyboard is the primary interface. Space plays the focused row (and stops it on a second press), arrows walk clamped with no wrap, Home/End go deepest/brightest, Enter commits, Esc stops and disarms, A-Z is type-ahead, and Tab crosses the whole rail in ONE stop before reaching the focused row's Menu and Use. Shift+Space plays the other half of the compare pair without moving focus — the pair is the last two distinct voices auditioned, seeded with the incumbent so the first arrow-then- Shift+Space is incumbent-vs-challenger with zero setup. Arrows move focus SILENTLY until the reader has explicitly played something this session. One rule buys three things: a screen-reader user is never ambushed by audio, the first play always descends from a real gesture (sticky activation), and a tab that opens never makes noise. The chip that lights when arrows are auditioning is also its permanent escape hatch, persisted to chrome.storage.local as voicesArrowAudition. Also here: - aria-live moves off #voice-studio onto a visually-hidden #voice-status. On a static catalog the container was right; with a player running, every repaint of a 22-row rail would be announced in full. - refreshCuration no longer reconstructs a voice's name from DOM textContent. That read-back meant any badge inside the name element silently corrupted the pin button's accessible label — passing tsc, passing every test, wrong only for screen-reader users. Names come from the view model, and badges live in sibling nodes regardless. - Voices with no sample_url are collected into a labelled group at the end, excluded from End (which lands on the brightest VOICE) and from the group's own count, so no count can lie. Host built-ins share that group's rule and note — same case, nothing to list. - The playhead is real rather than removed under reduced motion: it travels the drawn trace over the clip's own measured span, both published by paintPrintTrace, and steps in 8 instead of sweeping. - Signed out, the rail renders in full. The catalog is public; the signInForTTS state stays only as the genuinely-empty fallback. Invariants kept: renderToken + host recheck before any async paint, per-host cache isolation and the three empty states, optimistic pin with revert, applyPinToggleState as the one patcher, vm.menu === null as the one menu-less signal, grandfathering, no dead controls, no substituted string on a data-i18n element, and no Voices-only width rule on the content column. Design: doc/plans/2026-07-31-voices-audition-room-design.md (slice 2). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5tQbn1thvBTD3eM3bUAMc
Four independent reviews of the rail found eleven defects that pass tsc and pass the DOM assertions, and are wrong only when a person uses the page. The playhead was not a clock. `extractVoicePrint` trims the trace to the speech span, so trace x=0 is the first VOICED frame — 0.76s into Onyx's 2.71s file, measured from this branch's own fixture — but the animation started when `.playing` landed, at clip t=0. The head was two thirds across before the voice was audible and parked at the end for the last 1.5s. Prints now carry the `lead` they trimmed, and the head gets `animation-delay`: it leaves x=0 as the voice starts and arrives as it stops. The rail re-sorted under the reader. "Measured wins" is the naive reading of §7, and the seed and the extractor never agree exactly — this repo's own voicingFloor change moved Onyx 92.2 → 91Hz, and the fixtures put Sage at 186Hz, which is EXACTLY Ballad's seeded 186.0, so the alphabetical tiebreak swaps two rows ~2s after the tab opens on every fresh profile. `pitchOf` now keeps the seed when the measurement agrees within a semitone, takes the measurement when it genuinely moved, and keeps the seed (warning once) past a perfect fifth — §12's octave-error guard, which was never implemented. The soundprints were illegible. 0.22 ink is 1.60:1 on the card, under the 1.28:1 reference line it hangs on; it is §8's "never heard" step of a three-state ink whose other steps arrive with heard memory, so until then it is not a state, it is the page. Resting ink is 0.48 — clear of WCAG 1.4.11's 3:1 on both the white card and the warm ground. Arriving on the tab scrolled the settings document ~287px, taking the heading, the subtitle and the host switcher §9 deliberately kept top-level off-screen before the user touched anything. First paint places focus without travelling to it; every later scroll is `nearest`, never `center`. Also: the ⇄ readout is patched instead of rebuilt, so pressing it no longer drops focus to <body> mid-gesture; the compare pair refuses to seed an incumbent with no clip, which was a dead ⇄ over a silent `switchBack()`; `Use` focuses the row it was pressed on, so a hovered-row commit stops scrolling the page back to the old one; the live region is written only when it changes, so an unrelated pin no longer re-announces "Playing Ash"; the IN USE badge gets its own key instead of reading "SPEAKING NOW" on a silent row while another one sounds; a menu-less host gets a built-ins note that does not promise it a menu it retired (#573); and the overflow line comes back, because "✓ In menu" on a pin with no seat is a lying label. Not changed, and why: the full-width reference line is §6.1's exact geometry (x2=300) and its symptom — dominating the row — was the trace's contrast, fixed above; and the two buttons inside `role="option"` keep their role, name and aria-pressed, because ARIA's presentational-roles conflict resolution exempts focusable descendants from children-presentational. APG's listbox-should-be-a-grid guidance stands as a design question, not a fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5tQbn1thvBTD3eM3bUAMc
The rail demotes taglines to the focused row (design §9). That is the right call for a persona blurb and the page is calmer for it — but it swallowed the one line that is not a blurb. Pi's catalog ships two voices both named "Paola", and #585 told them apart by giving duplicate-named voices a disambiguating subtitle in parallel language ("Speaks 33 languages" / "Speaks 74 languages"). Demoted, that subtitle is invisible at rest, so the rail drew two rows reading only "Paola" and a user scanning the list had to focus each one to find out which was which. Worse than the cards it replaced, and a silent undo of a fix that shipped hours earlier. Duplicate names are the exception the demotion needs, so a twin's subtitle now always renders and always joins the row's accessible name — which is the half that actually mattered: a screen reader walking the listbox was announcing two options called "Paola" with nothing to choose between. Keyed off the existing vm.dupNames map, not a second path, so it generalises to whatever pair the server grows into next; this file never names Paola. Quiet by construction: one modifier class that lifts the line out of the focus-only reveal and drops it to ink 38 — the HD-badge step — rising to the normal ink 62 when the row is the one you are looking at. Same 12 px, same slot, no reflow, and the twins do not become the loudest thing on the page. Fail-first: three of the four new tests failed against the rail as built (two identical aria-labels for two distinct voice ids, no persistent subtitle, and a current twin whose label carried IN USE but no disambiguator). The fourth passed from the start and stays as the guard against over-reach — a uniquely named voice keeps a bare name and a focus-only tagline. Not addressed, and deliberately: the compare readout and the "Playing …" announcement still say "Paola" alone. The payload has no short disambiguator to put there — "Onyx ⟷ Paola (Speaks 33 languages)" would not fit the control bar — and the rows are where the choice is actually made. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…first Slice 3 of the audition room (design §4, §5.2, §10). The rail could already be walked one voice at a time; now it can walk itself, and it can be made shorter before it does. `▶ Play all (N)` hands the whole CURRENTLY FILTERED, currently ordered queue to the sequencer at its fixed 320 ms beat — once, never looping, never persisting, never auto-starting. That last part is the design's opening-gesture rule: the page invites you to hear ONE voice, and this is the option beside that invitation rather than in front of it. While it runs the bar shows `6 of 22` and a `Stop`; `Esc` stops it; and touching any row cancels it and plays that row, because direct manipulation gets exactly one meaning. Focus, the compare pair and the queue index stay three independent things, which is what "without losing your place" means concretely. Handing over the whole queue is also what finally exercises the N+1 prefetch the sequencer has carried since slice 0 — one-item sequences never reached it. A new test walks a four-clip queue to prove the two buffers keep alternating past the first handover, an off-by-one that is invisible at two items and audible as a stall on every step of a real sweep. When a clip is not buffered in time the beat stretches, and `loadingVoiceId` now draws that on the late voice's own print rather than leaving the page looking stopped. The `Show:` filter narrows the rail and the sweep together, and only offers options that would select something. `hdVoicesAllowanceNote` moves onto it as the HD option's helper line — same sentence, different job: on the retired shelves it sat above a heading nobody asked for, and here it exists only while HD is what you chose to look at, directly under the control that undoes the choice. Above 25 voices `Play all` refuses rather than greys out, because a disabled button explains nothing and the sentence that does explain it points at the filter one row above. The refusal, a blocked page and a failed clip all land in the bar's one non-modal line, which is the keyboard hint the rest of the time. THE ONE SHARED CHANGE: `TabController.onHidden?()`, called by SettingsApp for the OUTGOING tab. Tabs are never destroyed — they stay mounted for the life of the page — so a tab that owns something ongoing has had no way to learn nobody is looking at it. Today a preview started on Voices keeps playing while you read About with no control on screen: a curiosity with one clip, a defect with a minute of audio. Optional, no arguments, wrapped so one tab's teardown cannot block the next tab from opening, and implemented by exactly one of the five tabs. Alongside it: `visibilitychange → hidden` stops the SEQUENCE and lets a lone clip finish (the control bar is still on screen there), `pagehide` stops everything, and window `blur` deliberately does nothing — on macOS the settings popup blurs on almost any click, including clicks back into the chat window you are choosing a voice for. `voicesTooManyToPlay` says "min" rather than the design's "minutes": the refusal fires above 25 voices and 26 voices is about one minute, so the plural-less case is the common one and Chrome i18n has no plural forms. STILL UNVERIFIED: sticky autoplay activation on a chrome-extension:// document. Layer 3 structurally cannot prove it — e2e/fixtures/launch-args.ts passes --autoplay-policy=no-user-gesture-required to every Layer-3 Chrome, so a green assertion there would be a guaranteed false pass. Needs a Layer-4 CDP check. The failure mode is explained rather than silent: NotAllowedError surfaces as `Click any voice to enable sound.` for every item, not just the first. Gate: typecheck clean, 2500 vitest (+63), jest, i18n-validate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5tQbn1thvBTD3eM3bUAMc
Slice 4 of the audition-room design (§8). The page stops asking you to keep your own notes: it knows which voices you have heard, says so, and offers to play only the ones you have not. `src/tts/VoiceHeard.ts` is a sibling of `VoicePins.ts` in discipline — one exported key constant, pure helpers under an injectable `KeyValueStorage`, coercion at the untrusted boundary — and deliberately not in shape. Heard is a flat, additive `voiceId → epoch ms` map and GLOBAL rather than per-host, because a voice is literally the same sample_url file on both hosts and there is nothing to un-hear; pins' two-sided overlay exists only because the server owns a `featured` default set, and it has no opinion about your ears. `chrome.storage.local`, never `sync`, whose 8KB per-item cap a 400-entry map would silently blow and whose write quota is designed to throttle exactly this stream. Cap 400, oldest evicted, and never a prune against the live catalog — `fetchHost` catches a failure into an EMPTY catalog, so that cleanup would wipe the map on one bad network moment. The sequencer's `onHeard` emitter, built in slice 0 and unlistened until now, is the only honest source: it knows a clip PLAYED rather than that a button was clicked, and it serves single auditions and sweep steps through one path. A failed write is swallowed — no revert, no console.error — deliberately the inverse of `togglePinFor`, which correctly does revert, because a pin is a promise about another surface while a heard mark asserts only that the user's ears were in the room. The payoff design §8 calls the half worth shipping if only one ships: a `9 of 22 heard` counter in tabular figures, a `Not yet heard` filter option that appears once it would narrow something and disables itself once everything is heard, and `▶ Play new (13)` in place of `▶ Play all (22)` on a return visit. ONE DEVIATION, settled by looking rather than by argument. Design §8's never-heard ink of 0.22 does not survive the live catalog: it measures 1.60:1 against the ground while the reference line it hangs on measures 1.23:1, so the data barely out-ranks the gridline — and because the trace is 1.4px bars with gaps against a continuous line, equal contrast is not equal presence. At 1:1 it reads as dust, and it fails WCAG 1.4.11's 3:1 for a graphic you need in order to understand the content. The floor stays at 0.48 (3.20:1, the value slice 2 shipped and codified a test for) and heard darkens from there to 0.72 (7.10:1), still more than twice the contrast. "Develops as you listen" is only a good idea while the undeveloped state still reads as a drawing. Hover and focus leave the ink alone now, so it is exactly the three-state scale the design describes: a fourth density would either sit below `heard` — dimming a print the moment you point at it — or above it, at which point pointing at a voice looks like playing it. Verified against the live public catalog in the built extension at 1120×900: 15 rows on Pi, `Play new (9)`, `6 of 15 heard`, the filter carrying all/unheard/hd/everyday, and 0.48 → 0.72 → 1.0 ink. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven review findings against slices 3 and 4, each reproduced with a fail-first test before it was touched. The rail could delete a mark the reader had just earned. `settleOrder` compared what it WOULD paint against what it DID by running the whole filtered list — and `Not yet heard` makes that list a function of the heard store, so a clip passing the heard threshold read as a pitch re-sort. Any print landing afterwards rebuilt the rail without the voice being auditioned: the row vanished mid-listen, focus fell back to the top, and the counter went backwards from `1 of 3` to `0 of 2`. The reorder test now asks about pitch order alone, over the rows already painted; the filter still applies at the next real repaint, which is the rule design §8 states for the rail itself. A sweep started with the mouse could not be stopped from the keyboard. The rail's keydown is the only keydown and it early-returns unless the listbox is the target — but `Play all` is a sibling of the rail, so clicking it leaves focus on the button (or nowhere at all) and Escape never traverses the listbox. The page now listens too, narrowly: only while something is sounding, and only when the rail has not already claimed the press. `playing`, not `play`. The HTML spec queues `play` the instant `play()` is called whatever the readyState, so a cold clip read as playing while still silent — full ink, playhead clock running, "Playing X" announced ahead of the audio, and the loading pulse §5.1 exists for never shown. `waiting` now says so when a clip stalls mid-play, and the failure run is cleared only once a clip actually sounds, so a clip that buffers and then dies still counts toward the two-failures stop. The `⇄` compare control survived the filter that removed its target, leaving the exact dead control `seedPair` guards its own entry point against — "Switch back to Onyx" over a `switchBack()` that bails. Both halves of the pair are now checked against the painted rail; the pair itself is untouched, so widening the filter brings the offer back. `Play all` never scrolled, so on the 22-voice Claude catalog the last five voices sounded below the fold with no row lit and no playhead. The sweep now follows its own voice with `block: "nearest"` — a rail that fits never moves, focus still does not move, and a single audition still moves nothing at all. Two smaller ones: the `Show:` filter shipped as `Show :`, because the bare colon text node was its own item in a `gap: 4px` flex container (the colon and the translated word are now one span, still outside the [data-i18n] element); and the heard ink moves 0.72 → 0.82. Raising the resting ink to clear WCAG 1.4.11 had left the develop step (0.24) smaller than the playing step (0.28) — inverting design §8, where it is nearly twice as large — and on a 1.4px sparse trace heard rows could not be told apart by eye at 1×. The floor is fixed by contrast and the top is pinned at 1.0, so `heard` was the only free variable. Also: the controller spec now destroys every studio it mounts. The studio installs page-level listeners, and a controller left mounted goes on hearing the next test's events. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The founder's read on the shipped rail: "oh dear, what's this about? It feels
a bit cold and intimidating… it's quite effective functionally, and it works
for the reasons that you intended. Can we make it warmer and more beautiful
somehow, in keeping with our brand?"
The diagnosis was that the page had borrowed the visual language of an AUDIO
EDITOR — 1.4px hairlines at high density, monochrome ink, a middot-separated
keyboard cheat sheet — while Say, Pi's own language is soft, rounded and
pastel (see the settings sidebar's mint and lavender icon tiles). Nothing here
changes what the page DOES. It changes its temperature.
The mark becomes a ribbon. The bar goes 1.4 → 2.1 on an unchanged 2.6 pitch:
an 81% duty cycle, so neighbouring bars very nearly touch and a sustained
passage resolves into one soft band while the per-frame heights still read as
rhythm. The pitch deliberately does NOT move with it — widening the spacing to
4.2 for the same ratio would drop a full-width trace from 115 frames to 71, and
a thinner trace reads dottier, which is the opposite of softer. Amplitude
scales 1.25× so a 2.1px bar is a stroke rather than a dot.
Colour comes back as ONE ORDERED RAMP keyed to pitch — amber #A66A2B at 80 Hz
to the shell's own green #2C5A42 at 288 Hz — on the SAME fixed axis that
already decides vertical position, so colour and height agree by construction.
This is not the per-voice colour the design bans and the distinction is the
whole argument: the 22 gradient orbs this page deleted were djb2 HASHES of the
voice id, which looked like they encoded something and encoded nothing. A
monotonic function of measured pitch encodes exactly what the row order already
encodes, so the rail reads as one gradient down the page rather than as
confetti — and it gives the ordering a redundant, non-positional encoding,
which is an accessibility gain. Keyed to the fixed axis rather than to the
catalog's min/max for the same reason MAX_SPAN is a constant: otherwise adding
one deeper voice silently re-colours every other row.
The contrast problem the ramp creates is solved properly rather than papered
over. Heard state used to be three ALPHAS of one near-black ink, and a ramp
does not survive being alpha-scaled: full amber measures 4.2:1 against this
ground where full green measures 7.6:1, so one alpha scale would have made a
deep unheard voice fainter than a bright unheard voice — the hue would have
corrupted the heard reading sitting beside it. Each state now fixes a CONTRAST
RATIO and lets the ramp choose the hue, so hue carries pitch, weight carries
memory, and every row is at equal presence in equal state. Measured at both
ends and the middle:
never heard heard playing
80 Hz #C07B33 3.21:1 #7F501F 6.41:1 #663F17 8.59:1
155 Hz #948C51 3.21:1 #615C33 6.37:1 #4D4927 8.56:1
288 Hz #4F9973 3.20:1 #32644A 6.43:1 #26503A 8.58:1
Every one clears WCAG 1.4.11's 3:1 floor — the 3.2:1 resting floor is the same
one the monochrome rail settled on by eye, now held at BOTH ends of the ramp by
construction rather than by one alpha. The develop step stays far the larger of
the two (19 points of CIE L* against 8, design §8's "more than twice"), because
"play one voice and its print inks in" is the whole of heard state on the rail
while what marks the playing row is the green playhead crossing its trace. The
old test compared alphas; alphas are gone, so it compares L* — and the ordering
holds in raw contrast ratio too, so the assertion is robust either way.
Ground and furniture: cream #FBF7F0 on the studio CARD (a cream panel inside
the shell's white card would read as a box in a box), warm near-black #241D14
for names, a softer warmer rule #EADFCC, a 12px radius on the row so standing
on one is a soft tile rather than a scanline, and the Voices sidebar tile off
the default grey onto amber so it stops being the one cold tile in a pastel
column. The shell around this card is cool grey, so the cream is chosen to keep
the relationship the white card already had — one warm sheet of paper on a cool
desk — and was checked in situ at 1120×900, not against itself.
Copy: "Space to play · ↑↓ to walk · ⇧Space to switch back" is three tokens
separated by middots, the register of a terminal, and it is the first thing the
eye lands on. It is now "Press Space to listen, ↑↓ to move between voices,
⇧Space to switch back." — same keys, same length, plain verbs, one sentence.
"Walk" was a word about the implementation, not about you. And the arming chip
went from "Arrow keys play" (the mechanism) to "Play as you move" (what happens
to you), which reads straight into the hint line beneath it. Both keys are
English-only additions, so no locale went stale.
Verified in the built extension against the live public catalog: the shared
reference line still lands at y=12.6 on every one of 22 rows, pitch order, the
compare pair, Play all, the heard counter, the filters and the twin-name
disambiguation are untouched, there is still no Voices-only width rule on the
content column (the sidebar does not move on tab switch), and the console is
clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of the warmth pass found the ramp solved its 3:1 floor against the wrong ground, and two smaller things it broke on the way. THE FLOOR. The alpha ink this replaced got something for free that a fixed colour does not: rgba() COMPOSITES against whatever it is painted over, so it self-corrected wherever the ground changed. The rail has two grounds — the card #FBF7F0 and the warmer tile a hovered/focused row takes, #F3EADA — and the controller focuses a row unconditionally at first paint, so at least one print is always drawn on the darker of the two. That tile is 1.12x darker, worth about 11% of every ratio. printInk() re-exposed every hue to exactly 3.2:1 against the CARD, which put the focused row at 2.85:1 (worst 2.846 at ~201 Hz) — under WCAG 1.4.11, on the page's only data, on the one row the reader is looking at. The monochrome rail it replaced cleared it: rgba(21,23,26,0.48) over the old #F1EFE9 focus ground measured 3.13:1. Both new tests missed it because both measured only against PRINT_GROUND. The floor is now stated where it is worst. PRINT_GROUND_FOCUS is exported beside PRINT_GROUND, the resting target goes 3.2 -> 3.6 so the focused row still reads 3.2:1, and heard/playing follow it to 6.8/9.0 so the develop step stays more than twice the play step (17.5 points of CIE L* against 7.5, design section 8). Measured on the live catalog, on the focus ground: worst rest 3.205, worst heard 6.052, worst playing 8.011. Both grounds are now asserted, at both ends AND every 1 Hz across the ramp — the worst point is mid-ramp, which is exactly where three sample points miss. THE TAGLINE. .voice-row-desc is opacity:0 at rest and revealed only by hover/focus, so #F3EADA is the ONLY ground it is ever read on — and warming the ink and that ground in the same direction had taken it from 4.78:1 to 4.41:1, under WCAG 1.4.3's 4.5 for 12px normal text. It takes ink 66 instead of the page-wide 62, which is 4.98:1 where it is actually read; four points of alpha is invisible beside the control bar, being under the floor on the row you are standing on is not. The test moved with it: it was measuring a state that never renders. THE PLAYHEAD. The ramp's bright anchor IS the accent green, so at the top of the axis the head was green crossing green — ΔE 3.4 in Oklab, one colour to the eye, exactly where "one green means now" is load-bearing. That is a regression the monochrome rail did not have, since a green head on near-black ink read at every pitch. The head now carries a 1px keyline in the paper colour: invisible on paper (it IS the paper), and separation wherever it crosses a trace, at every pitch rather than only at the amber end. The rect goes 1.5 -> 2.5 so the green core stays the hairline it always was. THE MUD. Amber and forest green sit on opposite sides of the chroma axis, so the straight sRGB mix passed through their average: five of the fifteen visible rows (Fable, Alloy, Shimmer, Cedar, Nova) landed near #918D52, army-drab, in the band the eye rests on, on a page whose whole brief was warmth. The ramp now rotates HUE through Oklch from amber's 64 degrees to green's 159 and carries chroma across, which holds the middle at C~0.10 instead of 0.075 — olive gold rather than khaki. Same anchors, same monotonic ordering, different path between them; the guard is a chroma floor rather than a swatch list, so it cannot be satisfied by picking new mid colours by hand. Re-verified in the built extension against the live public catalog at 1120x900: the shared reference line is still y=12.6 on all 15 rows (one unique value), 15 prints, pitch order, the compare pair, Play all, the heard counter, the filters and the twin-name disambiguation unchanged, no Voices-only width rule on the content column, and the console is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ists #584 moved settings into a browser tab and #587 deleted the popup window-sizing machinery, so the design doc's escalation path pointed at a file that is gone. The rail's width is now whatever the tab's content column gives it, capped by #voice-studio's own max-width. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rosscado
force-pushed
the
feat/voices-audition-room
branch
from
July 31, 2026 09:17
beb96fe to
ef84d8c
Compare
The rail was drawn and tuned inside a 1120x900 settings window. #584 moved settings into a browser TAB and #587 deleted the window-sizing machinery, so the pane is now a fixed 756px content column -- measured identical in the built extension at viewports 1100, 1280, 1600 and 1920 -- which leaves the rail 692px, not the ~900 the row was budgeted against. At a 300px print and a 108px name the description was left 95px, and 13 of the live catalog's 15 taglines ellipsised: "Deep, resonan...", "Steady and rea...", and worst, both twin-Paola disambiguators degrading to "Speaks 33 l..." / "Speaks 75 l...". #585 gave the two Paolas that subtitle precisely because it is the only thing telling the rows apart; truncated, it does not differentiate and two rows become one row read twice. Narrower still (a 592px rail) the description column collapsed to literally 0px. Widening the column for this tab is not the fix -- that is the #582/#583 regression class, and the pane's width is a settings-shell decision now. So the row gives up space in a stated order instead: print, then name, then, last, the description. - Print 300 -> 192, and it steps with the PAGE (a container query on .voice-rail at 648/608px), never with the row. A flex-shrink here would make its width a per-row negotiation, and rows are not identical: a badge costs its row 29px, so under pressure the HD rows drew their traces ~15% shorter than the rows beside them (measured 143.9px against 169.8px). Trace length IS clip length, so that is not a smaller chart, it is a chart that lies about half its rows. - The svg takes preserveAspectRatio="none" and width:100%, so a stepped box scales x alone. The shared reference line stays on y=12.6 and the pitch band keeps its 26px -- verified rendered at 12.6 on every row at every step. The duty cycle is scale-invariant, and at 692px drawn = rendered, so the ribbon is still 2.1px bars on a 2.6 pitch. - Name 108 -> 96 with a 64px floor (the longest name in the catalog draws at 63px), and it may flex-shrink: a name ending 4px short costs nothing, a trace ending 4px short is claiming a shorter clip. - Description flex 1 1 auto -> 1 1 0 with a 152px floor. Basing at 0 is what makes the print's width a page decision: at auto, one 40-character tagline puts its own row into deficit and takes the space back out of that row's print. The floor clears the widest disambiguator (121px) with room for a longer locale. Truncated descriptions, live catalog, built extension: 13 of 15 -> 1 of 15 at both 1100 and 1920 (and 15 -> 2 at a 900px viewport). The one remaining is Marin's 40-character flourish at 253px against a 227px column -- 1.75x the catalog median, and sizing the chart to it would cost every row a further 28px of print forever. Both Paolas now read whole. Design doc corrected in the same commit: §1 and §11 described the 1120x900 window and a 900px cap as the binding constraint; §11 now carries the as-built column spec and the yield order, and §6.1 the drawn width and why preserveAspectRatio is load-bearing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5tQbn1thvBTD3eM3bUAMc
The rail's description column is 227px in a settings tab. Marin's "Warm and grounded — a morning-radio calm" needed 253px, so it was the one tagline still ellipsising after the column fit — 40 chars against a 21-char median, and the only one carrying an em-dash sub-clause. Sizing the chart to that one string would have cost every row ~28px of print forever, so the tagline yields instead: "Warm, morning-radio calm" keeps the image that made it memorable and drops the clause. The layout budget is a px measurement of ONE tagline, so it could only stand in for the set while that tagline was the longest — and it had already gone stale: the test asserting "leaves the catalog's taglines whole" passed green while Marin visibly truncated in the UI. It now re-derives the longest tagline from the shipped messages and fails if it outgrows the one the constant was measured from. Confirmed fail-first against the old string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Settings → Voices rebuilt around listening rather than browsing. Design doc committed as
doc/plans/2026-07-31-voices-audition-room-design.md.Not for merge yet — two gates below need a human, and the aesthetic direction is yours to accept or reject.
Why
The tab was a well-organised directory, and a voice can't be evaluated by reading. Every element on it — names, taglines, gradients, HD chips — was a proxy for the one fact that decides the choice: what it sounds like. 22 voices, each needing a separate click, no memory of what you'd heard, no way to hear two back to back. By card #7 you've forgotten card #1.
So: time-to-ear and comparison, as the two things the page optimises.
Space, focus already sits on your current voice↓⇧Space, and every press after flipsPlay allWhat it is
One pitch-ordered rail, deepest to brightest. Each voice is drawn as a soundprint from its own sample clip — a pitch trace on a shared logarithmic axis, with one faint reference line running through every row at the same y. That line is the whole move: it registers 22 independent traces into a single chart.
⇧Spaceplays the other of the last two voices you heard, without moving focus or scroll — that's what "without losing your place" means concretely. The pair seeds with your current voice, so the first↓ ⇧Spaceis incumbent-vs-challenger with zero setup.The arming rule is load-bearing: arrows move focus silently until you've explicitly played something. Then focus auditions. One rule buys screen-reader safety, autoplay sticky-activation, and no surprise audio on tab open — with a persisted off-toggle as a permanent escape hatch.
What it deletes
The stage, the menu-slots section, both tier shelves, the cards, and all 22 gradient orbs. Those orbs were
djb2-hash-derived: they looked like they encoded something and encoded nothing.VoiceIdentityloses its colour half. Net −2025 lines against +10815, most of the addition being tests.Price is the vendor's axis; pitch is the listener's.
HDsurvives as a row badge and a filter, andhdVoicesAllowanceNotemoves to that filter's helper line where it's actionable rather than decorative.Own-words audition is cut, and that overrides the brief
The strongest argument for typing your own sentence was that the canned clips can't support a fair head-to-head. That turns out to be false for the 12 Everyday voices — the shelf that's free-tier default and where the choice actually happens.
Measured against the live clips, twice, independently:
The decisive test is pause structure: all 12 Everyday clips pause at 0.35–0.42 of their span; HD clips scatter (0.28 → 0.93) or have none at all (3 of 10). Twelve out of twelve inside a 0.07-wide band doesn't happen with twelve independently-written scripts. They share a line.
The rest of the case:
BillingModule.quote()rounds to 0 credits under ~20 characters, so we cannot render an honest price for short typed text; the 20× HD asymmetry makes the comparison users most want the most expensive gesture on the page; and cutting it means the whole tab has no cost model to explain. TheAuditionItemseam is preserved — reinstating it is a one-line change. Full reasoning and the two falsifiable conditions that reverse it are in the design doc's Appendix A and B.Notable engineering
previewSequencer.ts— a monotonic session token is the entire cancellation model, replacing a module-global callback that had a live race:pause()queues its event, so a superseded clip's terminal handler wrote into the next voice's UI. Two<audio>elements alternate so N+1 preloads while N plays, deadline-scheduled to a consistent 320 ms beat.gainFor()attenuates to −17 dBFS, collapsing the non-outlier 4.1 dB spread to ~0. It cannot lift Sage, which measures −24.3 dBFS against a pack average of ~−15.5 (peak 0.28 vs ~0.80) — Sage was losing every comparison it entered for a reason that has nothing to do with Sage. That's a server asset bug, see below.localneversync, cap-and-evict, and never pruned against the live catalog —fetchHostcatches failures into an empty catalog, so pruning would be a data-loss trap.TabController.onHiddenis the one piece of shared infrastructure touched: tabs are never destroyed, so a tab owning something ongoing had no signal that nobody's looking. Exactly one of five tabs implements it, and a test asserts the other four don't.Verification
Gate green:
tsc --noEmit, 2566 vitest (was 2266 on main), jest,i18n-validate. Fail-first throughout.Driven in the built extension against the live public catalog: the rail renders signed out (the catalog needs no credentials), the reference line lands at y=12.6 on every row, pitch order is correct with no octave errors, and the studio sits at 812px inside its 900px cap.
Unheard ink was specced at 0.22 and not shipped at that value — measured, it gives the trace 1.60:1 against the ground while its own gridline gets 1.23:1, so the data barely out-ranks the rule it's drawn against, and it fails WCAG 1.4.11. Shipped at 0.48 (3.20:1), heard at 0.82, with a CSS contract test asserting the develop step stays larger than the play step.
Outstanding — needs you
chrome-extension://document. Layer 3 structurally cannot prove the latter —launch-args.tspasses--autoplay-policy=no-user-gesture-required, so a green assertion there is a guaranteed false pass.Deferred by design: slice 5 (virtualization + search) until the catalog crosses ~40.
🤖 Generated with Claude Code
https://claude.ai/code/session_01K5tQbn1thvBTD3eM3bUAMc