Skip to content

feat(voices): the audition room - #586

Open
rosscado wants to merge 20 commits into
mainfrom
feat/voices-audition-room
Open

feat(voices): the audition room#586
rosscado wants to merge 20 commits into
mainfrom
feat/voices-audition-room

Conversation

@rosscado

Copy link
Copy Markdown
Contributor

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.

goal actions
hear a voice 1 — Space, focus already sits on your current voice
hear a second 2 —
A/B them 3 — ⇧Space, and every press after flips
hear all 22 hands-free 1 — Play all

What 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.

⇧Space plays 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 ↓ ⇧Space is 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. VoiceIdentity loses 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. HD survives as a row badge and a filter, and hdVoicesAllowanceNote moves 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:

metric design pass replication
Everyday × Everyday envelope corr +0.653 +0.593
HD × HD +0.365 +0.313
control (vs reversed) −0.249 −0.176

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. The AuditionItem seam 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.
  • Level matching. Voiced-RMS spans 9.8 dB across the catalog. 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.
  • Heard state is a flat additive map, global not per-host (a voice is the same file on both), local never sync, cap-and-evict, and never pruned against the live catalog — fetchHost catches failures into an empty catalog, so pruning would be a data-loss trap.
  • TabController.onHidden is 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

  1. VoiceOver / NVDA pass. Focus-triggered playback is central here. A bad result flips arrow-audition from opt-out to opt-in — one constant, not a redesign.
  2. Layer-4 CDP look, for two things: 22 rows at real size before you accept the look, and sticky autoplay activation on a chrome-extension:// document. Layer 3 structurally cannot prove the latter — launch-args.ts passes --autoplay-policy=no-user-gesture-required, so a green assertion there is a guaranteed false pass.
  3. Two saypi-api asks, with numbers attached: re-render the 10 HD clips on the line the Everyday clips already share (the ask is 10 assets, not 22 — worth getting right), and fix Sage's ~7 dB deficit.

Deferred by design: slice 5 (virtualization + search) until the catalog crosses ~40.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K5tQbn1thvBTD3eM3bUAMc

rosscado and others added 18 commits July 31, 2026 10:13
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
rosscado force-pushed the feat/voices-audition-room branch from beb96fe to ef84d8c Compare July 31, 2026 09:17
rosscado and others added 2 commits July 31, 2026 10:43
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant