From 3f4d356b8102304c784fd354a25459135d0da37d Mon Sep 17 00:00:00 2001 From: Ross Cadogan Date: Thu, 2 Jul 2026 09:34:39 +0100 Subject: [PATCH] feat(voices): capped in-host voice menus, HD tier signalling, settings voice catalog (Patch A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the client-only slice of doc/plans/2026-07-02-voice-selection-ux.md: the catalog scales, the in-host menus never do. - New src/tts/VoiceCuration.ts: tier derivation from powered_by + price (no API change needed), popularity-ranked featured set (Coral/Nova/Ash/Onyx), gender-diverse HD pair, pin-current-first capped shortlists (Claude 6, Pi 5). - ClaudeVoiceMenu: shortlist cap + "More voices…" door (deep-links to the settings AI Chat tab), single [HD] chip replacing the raw credit chip, one muted footer line when tiers coexist, persisted pin so a stored voice never vanishes (and never tears down an open menu). - Pi menu: SayPi block capped with quiet HD suffix + door; Pi's built-in rows and PiVoiceSettings grid untouched; idempotent door; pin survives the addMissingPiVoices partial populate. - Settings AI Chat tab: full per-host voice catalog (HD/Everyday shelves, per-host selection, auth-aware empty state) — the door's destination. - openSettings(tab?) one-shot deep link via chrome.storage.local. Dormant-safe pre-flip: with today's single-tier catalogs there are no HD chips/footers; the only visible changes are the door rows and the settings catalog. Flip-day (20 voices on claude.ai) renders 6 rows + door instead of 20. Pre-release follow-up: run `npm run translate` for the 11 new en keys (requires translate-cli + OPENAI_API_KEY; English fallback until then). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012DZR4Vp9B1RiKVrkfYiM1o --- _locales/en/messages.json | 40 ++ doc/plans/2026-06-21-openai-voices-rollout.md | 96 +++++ doc/plans/2026-07-02-voice-selection-ux.md | 382 ++++++++++++++++++ entrypoints/settings/index.ts | 19 +- entrypoints/settings/tabs/chat/ChatPanel.tsx | 31 ++ entrypoints/settings/tabs/chat/chat.css | 82 ++++ entrypoints/settings/tabs/chat/index.ts | 12 +- .../settings/tabs/chat/voices-controller.ts | 223 ++++++++++ src/chatbots/ChatbotIdentifier.ts | 2 +- src/chatbots/ClaudeVoiceMenu.ts | 141 +++++-- src/chatbots/PiVoiceMenu.ts | 8 + src/popup/popupopener.ts | 24 +- src/styles/voices.scss | 12 + src/tts/VoiceCuration.ts | 162 ++++++++ src/tts/VoiceMenu.ts | 150 ++++++- .../chatbots/ClaudeVoiceMenu-curation.spec.ts | 210 ++++++++++ test/chatbots/PiVoiceMenu-curation.spec.ts | 204 ++++++++++ test/data/Voices.ts | 58 ++- test/popup/popupopener.spec.ts | 25 ++ test/settings/tabs/ChatPanel.spec.tsx | 14 + test/settings/tabs/voices-controller.spec.tsx | 134 ++++++ test/tts/VoiceCuration.spec.ts | 193 +++++++++ 22 files changed, 2173 insertions(+), 49 deletions(-) create mode 100644 doc/plans/2026-06-21-openai-voices-rollout.md create mode 100644 doc/plans/2026-07-02-voice-selection-ux.md create mode 100644 entrypoints/settings/tabs/chat/voices-controller.ts create mode 100644 src/tts/VoiceCuration.ts create mode 100644 test/chatbots/ClaudeVoiceMenu-curation.spec.ts create mode 100644 test/chatbots/PiVoiceMenu-curation.spec.ts create mode 100644 test/popup/popupopener.spec.ts create mode 100644 test/settings/tabs/voices-controller.spec.tsx create mode 100644 test/tts/VoiceCuration.spec.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 1e450b017a..e3b3bcb09c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -972,6 +972,46 @@ "message": "Voice off", "description": "Label for when text-to-speech is disabled" }, + "moreVoices": { + "message": "More voices…", + "description": "Menu row linking from the in-page voice menu to the full voice catalog in the extension settings" + }, + "voicesSectionTitle": { + "message": "Voices", + "description": "Heading for the voice catalog section in the settings AI Chat tab" + }, + "voicesSectionDescription": { + "message": "Choose the voice that reads replies aloud on each site.", + "description": "Subheading for the voice catalog section in the settings AI Chat tab" + }, + "voicesShelfHd": { + "message": "HD — richest sound", + "description": "Group heading for premium (HD) voices in the settings voice catalog" + }, + "voicesShelfEveryday": { + "message": "Everyday — sounds great, lasts longer", + "description": "Group heading for value-tier voices in the settings voice catalog" + }, + "voicesShelfEverydayBlurb": { + "message": "Great quality — your monthly allowance lasts about 20× longer.", + "description": "One-line note under the Everyday voices group heading in the settings voice catalog" + }, + "voicesUse": { + "message": "Use this voice", + "description": "Button that selects a voice from the settings voice catalog" + }, + "voicesCurrent": { + "message": "In use", + "description": "Badge marking the currently selected voice in the settings voice catalog" + }, + "voicesNoneAvailable": { + "message": "Voices couldn't be loaded right now. Please try again later.", + "description": "Shown in the settings voice catalog when the voice list is empty for a signed-in user (e.g. a network problem)" + }, + "hdVoicesAllowanceNote": { + "message": "HD voices use your monthly allowance about 20× faster.", + "description": "One-line note shown in voice menus when premium (HD) and value voices are both available" + }, "ttsVoice": { "message": "TTS voice", "description": "Label describing a text-to-speech voice option in the menu" diff --git a/doc/plans/2026-06-21-openai-voices-rollout.md b/doc/plans/2026-06-21-openai-voices-rollout.md new file mode 100644 index 0000000000..411608c28b --- /dev/null +++ b/doc/plans/2026-06-21-openai-voices-rollout.md @@ -0,0 +1,96 @@ +# OpenAI TTS voices — activation & rollout plan + +> Coordinates with **saypi-api #215** (client-side contract dependency for #92, +> "Add OpenAI TTS provider"). This doc is the userscript-side record of the agreed +> sequencing and the deferred UI work. Founder-gated; not an autonomous task. + +**Status as of 2026-06-21:** client regression fix is **merged to `main`** +(PR #284, commit `6abcd5d`), dormant. API kill-switch `OPENAI_TTS_ENABLED` is +**forced `false`** in production (Heroku `saypi-api` v256), so `/voices` returns +only the 3 ElevenLabs voices today. Nothing is live to users yet. saypi-api #215 +stays **open** until the build ships and the switch flips. + +## Decision (founder, 2026-06-21) + +Ship the regression fix in the next client release. Then turn OpenAI voices on in +production, observe the real UX, and roll any UI polish into the **following patch +release**. We accept a cosmetically-imperfect first impression in exchange for +faster activation and real production visuals to design against. The only hard +requirement before activation is "no crash," and that is already satisfied. + +## What's already done (the only must-ship piece) + +The blocking bug: `src/tts/SpeechModel.ts` → `retrieveProviderByEngine` mapped a +voice to an audio provider by its `powered_by` string and **threw** on anything it +didn't recognise. When the API served `powered_by="OpenAI"`, selecting such a voice +hit an uncaught throw in the synchronous `setVoice` / `notifyAudioVoiceSelection` +paths — crashing voice selection on the released extension. + +Fixed (PR #284): +- `case "OpenAI": return audioProviders.SayPi;` — OpenAI voices stream from + `api.saypi.ai` via the same `?voice_id=` contract as ElevenLabs, so they're + SayPi-served client-side. +- `default:` now **warns and falls back to SayPi** instead of throwing, so a future + API-added provider can't crash an old build the same way. +- Unit tests: `test/tts/AudioProviders.spec.ts` pins the OpenAI mapping and the + no-longer-throwing default. + +The change is additive and **dormant** — the new case is never exercised until the +API actually serves OpenAI voices. Safe to release ahead of the API flip. + +Contract (confirmed against live API in #215): exact string is `powered_by="OpenAI"` +(case-sensitive). There is **no** `tier`/`value_tier` field and none is planned; +value tier is derived from `powered_by` + `price_per_thousand_chars_in_credits` +(OpenAI = 50 cr/1k vs ElevenLabs ~1000 cr/1k). OpenAI voices carry an extra `model` +field, ignored by the client's structural typing. + +## Rollout sequence (strict order — API flip MUST be last) + +1. [x] Client provider mapping merged to `main` (PR #284). +2. [ ] **Release the client build to the stores** (Chrome / Edge / Firefox) — uses + the `release-extension` skill. This is the current gate. +3. [ ] **Confirm rollout** has propagated to users' browsers (store review + update). +4. [ ] **Flip `OPENAI_TTS_ENABLED=true`** on saypi-api (currently forced `false`). + OpenAI voices now appear in `/voices`. Observe live UX. +5. [ ] Close saypi-api #215. +6. [ ] **Following patch release** — UI polish (see below), informed by the live look. + +Why the order matters in exactly one direction: if the API flips before the fixed +build is live, users still on the old build crash on OpenAI voice selection. The +reverse is harmless — the new client code stays dormant while the switch is off. + +## What the live UX will look like (so we know what we're observing) + +10 OpenAI voices, server-defined (`alloy, ash, ballad, coral, echo, fable, nova, +onyx, sage, shimmer`). They appear in the **existing** voice menus, not a separate +section. Per host: + +- **Claude / ChatGPT** (`src/chatbots/ClaudeVoiceMenu.ts`): each voice is a row with + name + optional chips (accent flag, gender icon, **price**) + description. The + price chip appears only when prices vary across the set (`showPrice = prices.size + > 1`) — which becomes true once OpenAI (50 cr/1k) and ElevenLabs coexist, so OpenAI + rows show `50/1k cr` and read as the cheaper "value" option. **No provider + logo/badge in the menu rows.** +- **Pi.ai** (`src/tts/VoiceMenu.ts`): simpler. Voices split into "default" vs + "custom" by the server `voice.default` flag (NOT by provider); custom voices get a + generic SayPi flair logo. **No OpenAI-vs-ElevenLabs distinction, no price chip.** + Most likely to look off — watch this menu first. +- **Message playback controls** (`src/tts/TTSControlsModule.ts` `createTtsLogo`) DO + draw the per-provider logo from `icons/logos/.svg`; `openai.svg` exists, so + the OpenAI mark shows under a spoken message — just not in the picker. + +## Deferred UI work (follow-up patch — confirm against live look first) + +- Provider badge/logo in the menu rows (the `openai.svg` asset already exists). +- Explicit value-tier styling/labelling rather than relying on the price chip alone. +- Pi.ai menu grouping/badging for OpenAI voices (the `default`/`custom` split is the + weak spot there). + +## Test coverage gap (known, accepted for now) + +Covered: the provider-mapping crash fix (unit). NOT covered: any menu render with an +OpenAI voice — every fixture (`test/data/Voices.ts`) is ElevenLabs-only; no `/voices` +fixture contains OpenAI entries. The "won't crash" claim is well-tested; the "looks +and plays correctly" claim is not. If we want to close this before step 4, add an +OpenAI `/voices` fixture + a Claude-menu render test (price chip appears, selection +routes to SayPi provider). Founder opted to observe in production instead. diff --git a/doc/plans/2026-07-02-voice-selection-ux.md b/doc/plans/2026-07-02-voice-selection-ux.md new file mode 100644 index 0000000000..36c4960cc9 --- /dev/null +++ b/doc/plans/2026-07-02-voice-selection-ux.md @@ -0,0 +1,382 @@ +# Voice selection at scale — design for growing 3 → 20+ voices without cluttering the UI + +> Companion to [2026-06-21-openai-voices-rollout.md](2026-06-21-openai-voices-rollout.md) +> (this doc **is** the "following patch release UI polish" that plan reserved, designed +> ahead of the live look rather than after it). Coordinates with saypi-api #92/#215 +> (OpenAI TTS, merged server-side as saypi-api PR #162) and userscript PR #305 +> (contributor request: 60dB TTS, notably strong Hindi voices). +> +> Provenance: synthesized 2026-07-01 from a 16-agent research/design exercise — +> 8 research sweeps (ChatGPT, ElevenLabs, reading apps, companions, OS pickers, +> choice-architecture science, tier-pricing UX, novel personalization), 4 independent +> design proposals, 3 judge lenses, 1 completeness critic — cross-checked against +> this codebase. Founder-gated: the open decisions in §7 block parts of the rollout. + +## 1. The problem + +Today SayPi serves 3 curated ElevenLabs voices (~1000 credits/1k chars). Dormant +behind the API kill-switch sit 10 OpenAI voices at 50 credits/1k — 20x cheaper — and +a contributor wants a third provider (60dB) with cheap Hindi voices. The catalog is +about to grow 4–7x across a 20x price range and multiple languages. + +The founding constraints, verbatim from the founder: + +- Voices are **manually curated and intentionally few** — no ElevenLabs-style + bewildering library. +- **Don't shift decision burden onto customers** by abdicating choices we could + have made well on their behalf — but individual taste and budget deserve real + choice. +- **Seamless native integration** is a core principle: the voice menus live inside + the host's page and borrow its look. A voice interface should feel natural, not + configurable. + +The question: how do we maximise user choice while minimising user confusion? + +## 2. The answer in one line + +**The catalog scales; the menu never does.** Keep the in-host menu permanently +capped at ~5–6 curated rows plus one "More voices…" door; put the full catalog one +click deeper on SayPi-owned surfaces; make choice happen *by ear* (free canned +previews); describe tiers with one word ("HD") and budgets in minutes of speech, +never provider names or credit arithmetic; and let a server-side manifest carry the +founder's curation so the menu can be retuned without a client release. + +This is not a compromise position — it's the pattern every surveyed product +converged on (§8). OpenAI auditioned 400+ actors and ships **nine** voices. +ElevenLabs never shows its own 10,000-voice library at the moment of use. The +science agrees: defaults are one of the largest reliable effects in behavioral +science (d=0.68, ~95% of users never change them), and choice overload bites +exactly when options are hard to compare and preferences are unformed — a voice +picker ticks every risk moderator. + +## 3. The three-layer surface architecture + +Depth increases with distance from the conversation. The host page never gets +heavier than today; it gets lighter. + +### Layer 1 — in-host menus (the shortlist) + +**claude.ai** (`src/chatbots/ClaudeVoiceMenu.ts`, existing dropdown, same row +anatomy — name, description, accent flag, gender icon): + +1. The user's **current voice pinned first** with a checkmark — always, even if it + is no longer in the featured set. This is the returning-user invariant; it kills + the most predictable support ticket ("where did my voice go?"). +2. Two flagship ElevenLabs rows, each carrying a small **[HD]** chip — the only + tier mark, rendered via the existing `showPrice = prices.size > 1` gate + (`ClaudeVoiceMenu.ts:702`), i.e. only when tiers actually coexist. +3. Two-to-three curated OpenAI rows, chip-less. Descriptions shrink to 2–3-word + vibe descriptors ("bright and friendly") — the ChatGPT vocabulary, served in the + voice's `description` field. +4. One muted footer line, shown only when tiers coexist: *"HD voices use your + allowance about 20x faster."* This is the entire in-menu economics story. +5. Final muted row: **"More voices… ›"** → opens extension settings → AI Chat tab. + `openSettings()` is already imported and wired in this file + (`ClaudeVoiceMenu.ts:10`, `:565`), so the door is nearly free. + +The raw credit chip (`50/1k cr` via `formatPrice`, `ClaudeVoiceMenu.ts:344`) is +**retired from the picker**. Raw credits and per-option multipliers are the +documented confusion pattern (Cursor's 2025 credit-pool backlash and public +apology); credits remain the dashboard ledger's unit only. + +**pi.ai** (`src/tts/VoiceMenu.ts` / `PiVoiceMenu`): Pi's native 8 buttons untouched. +Below the existing divider, max 4–5 SayPi rows keeping the current flair mark, a +quiet `HD` suffix on ElevenLabs rows as the only added metadata, and one "More +voices →" row styled like Pi's own settings links. No price chips, no headers with +taglines — Pi is the host that most prizes minimalism, and its anonymous "Pi 1–8" +ontology must not be muddied. + +**chatgpt.com**: explicit non-goal. ChatGPT uses native read-aloud; there is no +SayPi picker to grow. The server default and settings surface carry that host alone +until/unless SayPi TTS ships there. + +Every row gets a **play affordance** (see §4). Interaction rule: play and select +are *separate* targets. A "first tap previews, second tap selects" scheme violates +the universal dropdown expectation that click = select; first-time users would +click, hear a clip, see nothing change, and conclude the menu is broken. + +### Layer 2 — extension settings, AI Chat tab (the shelf) + +The "More voices…" destination. Today the AI Chat panel +(`entrypoints/settings/tabs/chat/ChatPanel.tsx`) has zero voice UI; it gains a +"Voices" section: + +- **"Your voice" card**: current voice, descriptor, play button, and its stable + outcome line ("Nova · ≈1 hour of speech each month on Free"). +- **The full host catalog** as 2–3 labeled shelves — never a flat list (mere + categorization measurably improves satisfaction even with barely-informative + labels): **HD** (3 ElevenLabs; header: "richest sound — uses your allowance ~20x + faster"), **Everyday** (all 10 OpenAI, including the ones not featured in-host; + header: "great quality — lasts ~20x longer"), and a collapsed **Languages** shelf + (60dB Hindi et al. — always present here regardless of locale, so deliberate + seekers can always find it). +- Each row: ▶ free preview, name, descriptor, flag/gender chips, plan-aware static + rate ("≈60 min/mo on Free" vs "≈3 min/mo on Free"), and "Use this voice". +- Deferred until demand shows: a "Show in chat menu" pin (the ElevenLabs My-Voices + mechanic at 1/500th scale). Ship the door first; add pins only if "More voices…" + click-through demonstrates appetite. + +### Layer 3 — saypi.ai dashboard (the library + budget view) + +- The usage meter **re-denominated in minutes** with an exchange-rate view: + "Voice time left this month: ≈41 min in Nova · ≈2 min in Paola (HD)". +- The full cross-host catalog with full-length samples for unhurried browsing. +- Deprecation/migration notices (§5) and any voice-of-the-month archive. +- Live "remaining" countdowns live **only** here — never in the picker (Apple + removed the macOS battery time-remaining estimate for good reason; pickers get + stable rates, Netflix-GB/hr style). + +## 4. Choice by ear — the canned-preview pipeline + +A voice is an experience good: no name, flag, or descriptor substitutes for two +seconds of audio. Every surveyed product that does voice choice well (ChatGPT, +Siri, Google Assistant, ElevenLabs, Speechify) previews instantly and free. + +**Mechanic**: `/voices` gains a `sample_url` per voice — a ~2s pre-rendered, +volume-normalized, CDN-static clip ("Hi, I'm Nova."), recorded per-language for +language-shelf voices. The client renders the play affordance **only when +`sample_url` is present**, so the client patch ships ahead of the API asset +pipeline with zero coordination. + +**This also fixes an existing defect**: today `introduceVoice()` +(`src/tts/VoiceMenu.ts:278`) previews SayPi voices by synthesizing through the +metered live-TTS path (`createCompletedSpeechStream`, `:311`) — auditioning voices +burns the user's own quota. Free previews must feel free or nobody explores; on the +free tier one browse of a 13-voice menu could otherwise consume a meaningful slice +of the month. + +**Wiring constraint**: sample playback must route through the existing +audio-output state machine so a preview never double-talks over live TTS or an +active call. This is XState v5 work, not a detail — read +`src/state-machines/README.md` first. Tap-to-play only; no hover-autoplay (browser +autoplay policy + surprise audio + touch devices). + +**Asset discipline**: clips are versioned with the catalog; provider model drift +can make a stale sample mismatch the live voice, which is worse than no sample. + +## 5. Curation-as-API — the menu manifest + +Promote `GET /voices` from a flat list to a **menu manifest**. Additive fields, all +optional (old clients ignore them via the structural typing that already tolerates +OpenAI's extra `model` field): + +| Field | On | Meaning | +|---|---|---| +| `featured` | voice | In the in-host shortlist for this app | +| `section` | voice | Shelf label key (hd / everyday / language) | +| `recommended` | voice | The default for this (host, locale, plan) cohort — server-computed per request, exactly one | +| `sample_url` | voice | Free canned preview clip (§4) | +| `language` | voice | BCP-47 tag driving the gated shelf (§6) | +| `sibling_id` | voice | Founder-curated "everyday sibling" of an HD voice — the Downshift target **and** the deprecation successor | +| `deprecated` | voice | Retirement flag; client remaps only with a visible notice | + +This turns the founder's editorial judgment into a runtime object: re-shelve, +re-default, feature a dormant voice for a month, or retire a flop — all +server-side, no store release, and every user's menu stays capped by construction. +It is "curation, not abdication" expressed as the system's data model. + +Two client-side invariants the manifest can never override: + +1. A user's stored selection always keeps rendering and working, even after + delisting (grandfathering — scope server-side as "renders for prior selectors + only", which is far cheaper than per-user price terms). +2. A deprecation remap always shows a one-line notice; **never a silent voice + swap**. Voice attachment is identity-level (OpenAI learned this reversing the + Standard Voice retirement under user revolt; Waze's silent mid-drive fallback is + the same lesson). + +**Crucially, the first UI patch needs none of this**: tier is derivable +client-side from `powered_by` + price (confirmed in the rollout plan doc), and the +door uses the already-wired `openSettings()`. The manifest moves the heuristics +server-side *after* value ships. + +## 6. Defaults, budget rails, and language + +### The default is the product + +~95% of users will only ever hear the `recommended` voice. The client adopts it +once, at first TTS activation, persists the selection (existing per-chatbot +`voicePreferences` map in `PreferenceModule`), and never re-adopts on later catalog +changes. The one choice moment is a single dismissible line via the existing Preact +notice surface, after the first spoken reply: *"You're hearing Nova — Say Pi's pick +for you. Hear other voices →"*. No modal, no forced carousel. Existing users are +grandfathered unconditionally to their current selection. + +Which voice the free-tier `recommended` points at is **the open founder decision** +(§7.1) — the lever is server-side precisely so the menu work isn't blocked on it. + +### The burn-the-month rails + +The failure mode to engineer away: a free user (3,000 chars/month) picks an HD +voice without understanding, and one long reply consumes their month. + +1. **At choice**: the [HD] chip + the single footer sentence (§3.1). +2. **At commitment**: a free user selecting an HD voice gets the voice instantly + (no gate, no modal) plus a one-time toast with undo: *"Paola sounds richest — + your free time runs ~20x faster (≈3 min this month). Keep her · Back to Nova."* +3. **At exhaustion**: the **Downshift**. At ~85% of quota with projected shortfall, + one notice per cycle: *"You've used most of this month's voice time. Paola: + ≈40s left · Ash, her everyday sibling: ≈13 min. [Switch to Ash] [Keep Paola] + [Get more time]"* — with *"Return to Paola when your time renews"* pre-checked. + The sibling is explicitly a stand-in; the user's chosen voice remains their + voice. Metering, never gating: every voice stays selectable on every plan + (ChatGPT tiers by minutes, never by voice list). + +Minutes math: "1,000 chars ≈ 1 minute" varies by voice and speaking rate — the +research proposals' own constants disagreed. **Measure chars-per-minute per voice +before printing any absolute number**; rates are static per plan (recomputed only +on plan change), rounded conservatively, always "≈". When plan is unknown (logged +out), fall back to the relative framing ("lasts ~20x longer") — wrong absolute +numbers mislead at the exact moment of trust. + +### Language is a shelf, not a tier + +Voices carry a `language` tag; the Hindi shelf renders **in-host only when a +relevance signal fires**: browser locale `hi-*`, the user's saved STT/dictation +language (a signal SayPi uniquely already has), or explicit opt-in in settings. +Hindi-locale users get a Hindi `recommended` default and a launch notice written in +Hindi; English-locale users see nothing in-host (the settings Languages shelf and a +"Show all languages" hatch cover diaspora users). The mechanism is generic — a +future Spanish or Tamil provider slots in with zero menu redesign. + +### New voices are events, not list growth + +The OpenAI flip ships with one one-time notice — *"New voices — your free allowance +goes up to 20x further"* — plus an expiring "New" badge; the menu does not get 4x +longer. Same playbook for any future provider, targeted to the users it's relevant +to (the ChatGPT Advanced-Voice/Santa pattern). A server-optional expiring +"spotlight" slot can later cycle non-featured catalog voices through the menu one +at a time; keep it server-optional, not a public monthly commitment — a stale +spotlight is worse than none. + +## 7. Open founder decisions (block parts of the sequence) + +1. **The free-tier default voice — the strongest tension in the design.** + Everyday-voice default maximises speech time (3,000 chars ≈ an hour vs ≈3 min) + and cuts SayPi's serving COGS ~20x — but with d=0.68 default stickiness it makes + a commodity voice ("the ChatGPT voice") the sonic identity of SayPi for nearly + every new user, on a product differentiated by premium curation. Options: + best-voice-first, most-voice-first, or a hybrid nobody proposed (HD for the + first N replies, then a transparent step-down). Prerequisites either way: the + founder ear-picks the single default voice, and **switch-away telemetry exists + before the default flips** (it mostly doesn't today), or a brand-eroding pick + goes undetected. +2. **Whether to onboard 60dB at all.** PR #305 is a client routing shim + a spec + asking us to build the backend. Missing: vendor due diligence (latency, SLA, + licensing), full-loop Hindi quality (STT accuracy, host-LLM Hindi), and any + telemetry on Hindi-locale user counts. A third provider is an operational + commitment, not a menu row; the Alexa lesson is "never list a voice that does + less." The language-shelf architecture is worth building regardless — it's + provider-agnostic. +3. **Whether to re-skin OpenAI voice names.** alloy/nova/shimmer are + ChatGPT-recognizable, and this extension runs on chatgpt.com. Renaming (as + Paola/Joey already do for ElevenLabs) makes the curation feel like SayPi's and + enables invisible provider swaps later (Matter swapped its whole TTS vendor and + users only saw "better voices are here") — but needs an OpenAI ToS check and + complicates grandfathering. + +## 8. What the industry does (evidence base, condensed) + +- **ChatGPT**: 400+ auditions → 9 voices; two-word vibe descriptors; instant + tap-preview; ask-once at first voice use, then the picker hides in settings; + tiers by minutes/quality, never by catalog; new voices as events (Advanced Voice + launch, Santa mode). Reversed the Standard-Voice retirement under user grief. +- **ElevenLabs** (the library we don't want to be): moment-of-use picker shows only + "My Voices"; the 10,000-voice library is a separate Explore destination fronted + by staff-curated Handpicked Collections + locale-based recommendations; price is + a filter facet; even they prune (Default voices retiring with named successors). +- **Reading apps** (Speechify/NaturalReader/Matter): one unified list, premium = + a single glyph (diamond/HD), no per-voice price math; "Recommended" tab first, + full catalog demoted a level; metered tastes of premium rather than hard locks; + provider taxonomy invisible. Anti-pattern: Voice Dream re-gated purchased voices + → community revolt → reversal. +- **Companions** (Sesame: 2 voices, no picker; Pi: 8 anonymous variants of one + persona; Character.ai: creator picks, user may veto): voice = persona/brand; + small rosters with strong identity beat catalogs. +- **OS pickers** (Siri: accent → 2–5 voices, tap = hear+select, active choice at + iOS 14.5 setup; Google: non-ranking color names): two-level disclosure keyed on + a dimension the user already knows their answer to. +- **Science**: choice overload is conditional but voice pickers hit every + moderator (Chernev et al. 2015); defaults d=0.68 / ~95% acceptance (Jachimowicz + et al. 2019); mere categorization improves satisfaction even with uninformative + labels (Mogilner et al. 2008); "HD" is the one tier word that survives across + vendors (Google's six coexisting tier names are the anti-pattern); price in + stable outcome units (Netflix GB/hr), never raw credits (Cursor apology). +- **Anti-patterns**: Alexa's $4.99 celebrity voices (retired 2023, refunded — voices + don't sell as SKUs); Waze's expiring promo voices + silent mid-task fallback; + exposing technology generations or provider names to users. + +Unverified claims flagged by the critic (directionally plausible, don't cite as +fact without checking): ElevenLabs' specific Default-voice expiry dates; Fish +Audio's "10% competitor engine" blind A/B; the precise dates/drama of ChatGPT's +voice-screen retirement and Standard-Voice reversal; celebrity rosters. + +## 9. Novel ideas — adopt, park, reject + +**Adopt** (novel; no surveyed product does these): the curation manifest (§5); the +temporary-by-default Downshift with founder-curated siblings (§6); minutes +re-denomination of the quota meter (§3, Layer 3). + +**Park** (build later, off the host page, instrumented): **Voice Match** — a +30-second blind taste test (hear two voices say the same line, tap the preferred, +3 rounds, meet your match). Genuine white space: blind voice A/B ships only as +eval tooling (Vapi Humanness Index, Fish Audio routing), never as consumer +onboarding. Mechanically cheap (~8 canned clips of one shared sentence per locale). +But two of three judges flagged that advertising it in first-run shifts the +decision burden back onto users — dashboard/settings feature only, never in-host. +Also park: the spotlight slot (server-optional semantics only), settings pins +(await door click-through data). + +**Reject**: per-voice purchases (Alexa), per-row credit chips or multipliers, a +"voice budget" policy radio (Best sound / Balanced / Most speech — a new abstract +knob, exactly the configuration burden the philosophy forbids), provider names or +logos in any picker (the per-message logo in `TTSControlsModule.createTtsLogo` +stays — attribution after the fact, not a choosing dimension), rotating voices +that silently vanish, and any silent voice swap for any reason. + +## 10. Sequencing + +Builds on the rollout plan's strict order (API flip still gated on Edge v1.11.0+ +adoption — see the rollout doc). + +1. **(In flight, decided)** Ship the release; flip `OPENAI_TTS_ENABLED`; observe + the raw 13-voice menus. Add only the one-time "new voices" notice. +2. **Patch A — client-only, no API dependency**: cap + pin-current-first + "More + voices…" door on Claude (via existing `openSettings()`); [HD] chip replacing + the credit chip (tier derived locally from `powered_by` + price); footer + sentence; Pi divider + cap. Settings AI Chat tab gains the shelved catalog + (relative budget framing until minute-rates are measured). Flat-list renderer + kept as fallback for host DOM drift; chips/headers reuse host-compiled classes + or SayPi-owned SCSS (claude.ai compiles only its own Tailwind utilities — known + repo gotcha). +3. **API workstream (saypi-api)**: `sample_url` asset pipeline (the load-bearing + unbuilt piece — clips recorded, normalized, CDN-static, versioned); manifest + fields (`featured`/`section`/`recommended`/`language`/`sibling_id`); measured + per-voice chars-per-minute rates. Client renders previews/sections from the + manifest as fields appear (each affordance hidden when its field is absent). +4. **Patch B — defaults + rails**: cohort `recommended` adoption at first TTS + activation (gated on decision §7.1 + switch-away telemetry existing); + first-listen notice; commitment toast; Downshift; minutes on settings rows and + the dashboard meter. +5. **Patch C — language shelf**: gated Hindi shelf + Hindi-targeted launch notice, + if and when §7.2 resolves to yes. + +Every step is independently shippable and additive; no existing user's selected +voice ever changes. + +## 11. Cross-cutting requirements the proposals missed + +- **Accessibility** (absent from all four proposals; TTS products' most loyal + constituency): injected menus need ARIA roles + keyboard navigation inside host + DOMs; no hover-only affordances (touch + screen readers); previews must not + collide with screen readers; and speech-**rate** control — the #1 TTS + accessibility feature — is worth its own backlog item. +- **Telemetry vs. extension privacy**: switch-away/click-through instrumentation + is assumed by every decision loop above and mostly doesn't exist; new tracking + in a browser extension triggers store-review scrutiny and privacy-policy + updates. Scope a minimal event counter deliberately. +- **Latency as a curation gate**: time-to-first-audio matters more than timbre in + conversation; measure per provider and let curation (not users) absorb it. +- **Shortlist composition**: the curated 5–6 need deliberate gender/accent balance + (the issue that forced Apple's iOS 14.5 Siri change); current example shortlists + read US-English-default. diff --git a/entrypoints/settings/index.ts b/entrypoints/settings/index.ts index dc11864a2f..bae6eddaad 100644 --- a/entrypoints/settings/index.ts +++ b/entrypoints/settings/index.ts @@ -10,6 +10,7 @@ import { DictationTab } from "./tabs/dictation"; import { AboutTab } from "./tabs/about"; import { replaceI18n } from "./shared/i18n"; import type { TabController } from "./shared/types"; +import { SETTINGS_DEEP_LINK_KEY } from "../../src/popup/popupopener"; // Styles import "./styles/base.css"; // reset + utilities (replaces the 2.9 MB Tailwind v2 dump) @@ -70,8 +71,22 @@ class SettingsApp { this.tabs.set('dictation', new DictationTab(document.querySelector('#tab-dictation')!)); this.tabs.set('about', new AboutTab(document.querySelector('#tab-about')!)); - // Determine which tab to show initially - const initialTab = localStorage.getItem('saypi.settings.selectedTab') || 'general'; + // Determine which tab to show initially: a one-shot deep link (set by + // openSettings(tab) from a content script, e.g. the voice menus' "More + // voices…" row) wins over the last-viewed tab. + let initialTab = localStorage.getItem('saypi.settings.selectedTab') || 'general'; + try { + const stored = await chrome.storage.local.get(SETTINGS_DEEP_LINK_KEY); + const deepLink = stored?.[SETTINGS_DEEP_LINK_KEY]; + if (typeof deepLink === 'string' && this.tabs.has(deepLink)) { + initialTab = deepLink; + } + if (deepLink !== undefined) { + await chrome.storage.local.remove(SETTINGS_DEEP_LINK_KEY); + } + } catch (e) { + // Deep link is best-effort; fall back to the last-viewed tab + } // Load only the initial tab await this.loadTab(initialTab); diff --git a/entrypoints/settings/tabs/chat/ChatPanel.tsx b/entrypoints/settings/tabs/chat/ChatPanel.tsx index 8f3e75cf12..9955bcda6b 100644 --- a/entrypoints/settings/tabs/chat/ChatPanel.tsx +++ b/entrypoints/settings/tabs/chat/ChatPanel.tsx @@ -153,6 +153,37 @@ export function ChatPanel() { Autoplay during voice calls. No effect when typing. + + {/* Full voice catalog — the destination of the in-page menus' "More + voices…" door. Static skeleton only; VoicesController renders the + per-host catalog into #voice-catalog after mount. */} +
+ + Voices + +
+ Choose the voice that reads replies aloud on each site. +
+
+ + +
+
+
); } diff --git a/entrypoints/settings/tabs/chat/chat.css b/entrypoints/settings/tabs/chat/chat.css index b05a86cd25..43dcb1701d 100644 --- a/entrypoints/settings/tabs/chat/chat.css +++ b/entrypoints/settings/tabs/chat/chat.css @@ -1,2 +1,84 @@ /* Chat tab specific styles */ +/* Voices section — the full per-host catalog behind the in-page menus' + "More voices…" door */ +#voice-host-pills { + display: flex; + gap: 0.5rem; + margin: 0.5rem 0 0.75rem; +} +.voice-host-pill { + border: 1px solid #d1d5db; + border-radius: 9999px; + background: transparent; + padding: 0.25rem 0.9rem; + font-size: 0.85rem; + cursor: pointer; +} +.voice-host-pill.active { + background: #e5e7eb; + font-weight: 600; +} +.voice-shelf { + margin-bottom: 0.75rem; +} +.voice-shelf-header { + display: flex; + flex-direction: column; + margin-bottom: 0.25rem; +} +.voice-shelf-title { + font-weight: 600; + font-size: 0.9rem; +} +.voice-shelf-blurb { + font-size: 0.8rem; +} +.voice-list { + list-style: none; + margin: 0; + padding: 0; +} +.voice-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.4rem 0.25rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); +} +.voice-row:last-child { + border-bottom: none; +} +.voice-row-main { + display: flex; + flex-direction: column; + min-width: 0; +} +.voice-row-name { + font-weight: 500; +} +.voice-row-desc { + font-size: 0.8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.voice-use { + flex-shrink: 0; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + background: transparent; + padding: 0.2rem 0.6rem; + font-size: 0.8rem; + cursor: pointer; +} +.voice-use:hover { + background: #f3f4f6; +} +.voice-current-badge { + flex-shrink: 0; + font-size: 0.8rem; + font-weight: 600; + color: #16a34a; +} diff --git a/entrypoints/settings/tabs/chat/index.ts b/entrypoints/settings/tabs/chat/index.ts index 59d63a8423..a34908e99c 100644 --- a/entrypoints/settings/tabs/chat/index.ts +++ b/entrypoints/settings/tabs/chat/index.ts @@ -3,15 +3,17 @@ import { TabController } from '../../shared/types'; import { getStoredValue, setStoredValue } from '../../shared/storage'; import { sendMessageToActiveTab } from '../../shared/messaging'; import { SubmitModeController } from './submit-mode-controller'; +import { VoicesController } from './voices-controller'; import { mountInto, unmountFrom } from '../../../../src/ui/preact/mount'; import { ChatPanel } from './ChatPanel'; import './chat.css'; export class ChatTab implements TabController { private submitModeController: SubmitModeController | null = null; - + private voicesController: VoicesController | null = null; + constructor(public container: HTMLElement) {} - + async init(): Promise { // Render the panel with Preact, then wire the controls imperatively — // behaviour unchanged: the setup methods and SubmitModeController operate @@ -22,6 +24,11 @@ export class ChatTab implements TabController { await this.setupInterruptions(); await this.setupAutoReadAloud(); await this.setupSubmitMode(); + // Voice catalog loads over the network — don't block tab init on it. + this.voicesController = new VoicesController(this.container); + this.voicesController.init().catch((error) => { + console.error('Failed to load the voice catalog:', error); + }); } /** @@ -30,6 +37,7 @@ export class ChatTab implements TabController { */ destroy(): void { this.submitModeController = null; + this.voicesController = null; unmountFrom(this.container); } diff --git a/entrypoints/settings/tabs/chat/voices-controller.ts b/entrypoints/settings/tabs/chat/voices-controller.ts new file mode 100644 index 0000000000..ec7b107c4d --- /dev/null +++ b/entrypoints/settings/tabs/chat/voices-controller.ts @@ -0,0 +1,223 @@ +import getMessage from "../../../../src/i18n"; +import { SpeechSynthesisVoiceRemote } from "../../../../src/tts/SpeechModel"; +import { getVoiceTier } from "../../../../src/tts/VoiceCuration"; +import { SpeechSynthesisModule } from "../../../../src/tts/SpeechSynthesisModule"; +import { UserPreferenceModule } from "../../../../src/prefs/PreferenceModule"; +import { getJwtManagerSync } from "../../../../src/JwtManager"; +import type { ChatbotId } from "../../../../src/chatbots/ChatbotIdentifier"; + +/** + * The hosts with a SayPi voice picker. ChatGPT is a deliberate non-goal (it + * uses OpenAI's native read-aloud — doc/plans/2026-07-02-voice-selection-ux.md §3). + */ +export type VoiceHostId = Extract; + +export interface VoiceCatalogDeps { + getVoices(host: VoiceHostId): Promise; + getVoice(host: VoiceHostId): Promise; + setVoice(voice: SpeechSynthesisVoiceRemote, host: VoiceHostId): Promise; + isAuthenticated(): boolean; +} + +// The settings page runs outside any host tab, so every preference call MUST +// carry an explicit chatbot id — the no-arg default resolves to "web" here. +function defaultDeps(): VoiceCatalogDeps { + const speech = SpeechSynthesisModule.getInstance(); + const prefs = UserPreferenceModule.getInstance(); + return { + getVoices: (host) => speech.getVoices(host), + getVoice: (host) => prefs.getVoice(host) as Promise, + setVoice: (voice, host) => prefs.setVoice(voice, host).then(() => {}), + isAuthenticated: () => getJwtManagerSync().isAuthenticated(), + }; +} + +/** + * Renders the full per-host voice catalog in the settings AI Chat tab — the + * "shelf" layer of the voice-selection architecture: HD and Everyday groups, + * every catalog voice listed, selection per host. The in-page menus stay + * capped; this surface absorbs the catalog's growth. + */ +export class VoicesController { + private deps!: VoiceCatalogDeps; + private readonly injectedDeps?: VoiceCatalogDeps; + private host: VoiceHostId = "pi"; + private renderToken = 0; + + constructor(private container: HTMLElement, deps?: VoiceCatalogDeps) { + this.injectedDeps = deps; + } + + async init(): Promise { + // Resolved lazily so constructing the controller is side-effect-free — + // the default deps touch app config, and any failure lands in init()'s + // rejection (which the tab catches) rather than tab construction. + this.deps = this.injectedDeps ?? defaultDeps(); + const pills: Array<[string, VoiceHostId]> = [ + ["#voice-host-pi", "pi"], + ["#voice-host-claude", "claude"], + ]; + for (const [selector, host] of pills) { + this.container + .querySelector(selector) + ?.addEventListener("click", () => { + void this.selectHost(host); + }); + } + await this.selectHost(this.host); + } + + private async selectHost(host: VoiceHostId): Promise { + this.host = host; + const token = ++this.renderToken; + this.container + .querySelectorAll(".voice-host-pill") + .forEach((pill) => { + pill.classList.toggle("active", pill.id === `voice-host-${host}`); + }); + + const [voices, current] = await Promise.all([ + this.deps.getVoices(host), + this.deps.getVoice(host), + ]); + if (token !== this.renderToken) return; // a newer selection superseded us + this.renderCatalog(voices, current); + } + + private renderCatalog( + voices: SpeechSynthesisVoiceRemote[], + current: SpeechSynthesisVoiceRemote | null + ): void { + const catalog = this.container.querySelector("#voice-catalog"); + if (!catalog) return; + catalog.innerHTML = ""; + + if (voices.length === 0) { + // Signed out → /voices legitimately returns [] (401): prompt sign-in. + // Signed in with an empty catalog means the fetch failed — telling an + // authenticated user to sign in would be wrong and confusing. + const emptyKey = this.deps.isAuthenticated() + ? "voicesNoneAvailable" + : "signInForTTS"; + const empty = document.createElement("div"); + empty.classList.add("voice-catalog-empty", "description"); + empty.setAttribute("data-i18n", emptyKey); + empty.textContent = getMessage(emptyKey); + catalog.appendChild(empty); + return; + } + + const hd = voices.filter((voice) => getVoiceTier(voice) === "hd"); + const everyday = voices.filter( + (voice) => getVoiceTier(voice) === "everyday" + ); + + if (hd.length > 0 && everyday.length > 0) { + catalog.appendChild( + this.renderShelf("hd", "voicesShelfHd", "hdVoicesAllowanceNote", hd, current) + ); + catalog.appendChild( + this.renderShelf( + "everyday", + "voicesShelfEveryday", + "voicesShelfEverydayBlurb", + everyday, + current + ) + ); + } else { + // Single-tier catalog (today's state): a flat list, no shelf chrome. + catalog.appendChild(this.renderList(voices, current)); + } + } + + private renderShelf( + tierKey: string, + titleKey: string, + blurbKey: string, + voices: SpeechSynthesisVoiceRemote[], + current: SpeechSynthesisVoiceRemote | null + ): HTMLElement { + const shelf = document.createElement("div"); + shelf.classList.add("voice-shelf", `voice-shelf-${tierKey}`); + + const header = document.createElement("div"); + header.classList.add("voice-shelf-header"); + const title = document.createElement("span"); + title.classList.add("voice-shelf-title"); + title.setAttribute("data-i18n", titleKey); + title.textContent = getMessage(titleKey); + header.appendChild(title); + const blurb = document.createElement("span"); + blurb.classList.add("voice-shelf-blurb", "description"); + blurb.setAttribute("data-i18n", blurbKey); + blurb.textContent = getMessage(blurbKey); + header.appendChild(blurb); + shelf.appendChild(header); + + shelf.appendChild(this.renderList(voices, current)); + return shelf; + } + + private renderList( + voices: SpeechSynthesisVoiceRemote[], + current: SpeechSynthesisVoiceRemote | null + ): HTMLElement { + const list = document.createElement("ul"); + list.classList.add("voice-list"); + voices.forEach((voice) => { + list.appendChild(this.renderRow(voice, current?.id === voice.id)); + }); + return list; + } + + private renderRow( + voice: SpeechSynthesisVoiceRemote, + isCurrent: boolean + ): HTMLElement { + const row = document.createElement("li"); + row.classList.add("voice-row"); + row.dataset.voiceId = voice.id; + + const main = document.createElement("div"); + main.classList.add("voice-row-main"); + const name = document.createElement("span"); + name.classList.add("voice-row-name"); + name.textContent = voice.name; + main.appendChild(name); + if (voice.description) { + const description = document.createElement("span"); + description.classList.add("voice-row-desc", "description"); + description.textContent = voice.description; + main.appendChild(description); + } + row.appendChild(main); + + if (isCurrent) { + row.classList.add("current"); + const badge = document.createElement("span"); + badge.classList.add("voice-current-badge"); + badge.setAttribute("data-i18n", "voicesCurrent"); + badge.textContent = getMessage("voicesCurrent"); + row.appendChild(badge); + } else { + const use = document.createElement("button"); + use.type = "button"; + use.classList.add("voice-use"); + use.setAttribute("data-i18n", "voicesUse"); + use.textContent = getMessage("voicesUse"); + use.addEventListener("click", () => { + void this.useVoice(voice); + }); + row.appendChild(use); + } + return row; + } + + private async useVoice(voice: SpeechSynthesisVoiceRemote): Promise { + const host = this.host; + await this.deps.setVoice(voice, host); + if (host !== this.host) return; // host switched while persisting + await this.selectHost(host); // one render path; re-reads the stored voice + } +} diff --git a/src/chatbots/ChatbotIdentifier.ts b/src/chatbots/ChatbotIdentifier.ts index 7d5d9b7279..e9f94f33fc 100644 --- a/src/chatbots/ChatbotIdentifier.ts +++ b/src/chatbots/ChatbotIdentifier.ts @@ -2,7 +2,7 @@ * A lightweight module for identifying the current chatbot without circular dependencies. * This module doesn't import any specific chatbot implementations to avoid dependency cycles. */ -type ChatbotId = "claude" | "pi" | "chatgpt" | "web"; +export type ChatbotId = "claude" | "pi" | "chatgpt" | "web"; export class ChatbotIdentifier { /** diff --git a/src/chatbots/ClaudeVoiceMenu.ts b/src/chatbots/ClaudeVoiceMenu.ts index 7e7fd149af..f688c487b2 100644 --- a/src/chatbots/ClaudeVoiceMenu.ts +++ b/src/chatbots/ClaudeVoiceMenu.ts @@ -13,6 +13,11 @@ import { getResourceUrl } from "../ResourceModule"; import marsSvgContent from "../icons/lucide-mars.svg?raw"; import venusSvgContent from "../icons/lucide-venus.svg?raw"; import { logger } from "../LoggingModule"; +import { + curateShortlist, + getVoiceTier, + CLAUDE_MENU_CAP, +} from "../tts/VoiceCuration"; export class ClaudeVoiceMenu extends VoiceSelector { private menuButton: HTMLButtonElement; @@ -20,7 +25,7 @@ export class ClaudeVoiceMenu extends VoiceSelector { // Heuristics to avoid clutter – computed per dataset in populateVoices private showAccent: boolean = false; private showGender: boolean = false; - private showPrice: boolean = false; + private showTier: boolean = false; constructor( chatbot: Chatbot, @@ -288,12 +293,15 @@ export class ClaudeVoiceMenu extends VoiceSelector { chips.appendChild(genderChip); } } - // Price, if there are variations - if (this.showPrice) { - const priceChip = document.createElement("span"); - priceChip.classList.add("inline-flex", "items-center", "opacity-80"); - priceChip.textContent = this.formatPrice(voice); - chips.appendChild(priceChip); + // Tier chip: a single "HD" mark on premium rows, only while tiers + // coexist in the catalog. Raw prices/credits never render in the picker + // (doc/plans/2026-07-02-voice-selection-ux.md §3). + if (this.showTier && getVoiceTier(voice) === "hd") { + const tierChip = document.createElement("span"); + tierChip.classList.add("inline-flex", "items-center", "opacity-80"); + tierChip.textContent = "HD"; + tierChip.title = getMessage("hdVoicesAllowanceNote"); + chips.appendChild(tierChip); } } if (chips.childElementCount > 0) { @@ -340,21 +348,6 @@ export class ClaudeVoiceMenu extends VoiceSelector { return item; } - // Short dollar display; fall back to credits if USD missing - private formatPrice(voice: SpeechSynthesisVoiceRemote): string { - const usd = voice.price_per_thousand_chars_in_usd ?? voice.price; - if (typeof usd === "number" && !isNaN(usd) && usd > 0) { - // Keep it compact: $0.3/1k - const rounded = Math.round(usd * 100) / 100; - return `$${rounded}/1k`; - } - const credits = voice.price_per_thousand_chars_in_credits; - if (typeof credits === "number" && credits > 0) { - return `${Math.round(credits)}/1k cr`; - } - return getMessage("free") || "Free"; - } - // If accent looks like a BCP‑47 tag (e.g., en-US), treat it as the accent locale private getVoiceLocale(voice: SpeechSynthesisVoiceRemote): string | undefined { const tag = voice.accent; @@ -687,7 +680,10 @@ export class ClaudeVoiceMenu extends VoiceSelector { }); } - override populateVoices(voices: SpeechSynthesisVoiceRemote[], voiceSelector: HTMLElement): boolean { + override populateVoices( + voices: SpeechSynthesisVoiceRemote[], + voiceSelector: HTMLElement + ): boolean { // Compute heuristics to decide what to show across this dataset try { const accents = new Set( @@ -696,18 +692,16 @@ export class ClaudeVoiceMenu extends VoiceSelector { .filter(Boolean) ); const genders = new Set(voices.map(v => (v.gender || '').toString().toUpperCase()).filter(Boolean)); - const prices = new Set(voices.map(v => v.price_per_thousand_chars_in_usd ?? v.price).filter((p): p is number => typeof p === 'number')); this.showAccent = accents.size > 1; // only show if helpful to differentiate this.showGender = genders.size > 1; // only show if varies - this.showPrice = prices.size > 1; // show only when price differs } catch (_) { // Fail safe: don't show extra metadata if anything goes wrong - this.showAccent = this.showGender = this.showPrice = false; + this.showAccent = this.showGender = false; } // Get the currently selected voice before recreating the menu let currentSelectedVoice: SpeechSynthesisVoiceRemote | null = null; - + // Try to get from the current button if it exists if (this.menuButton && this.menuButton.parentElement === voiceSelector) { const voiceNameElement = this.menuButton.querySelector(".voice-name"); @@ -719,11 +713,16 @@ export class ClaudeVoiceMenu extends VoiceSelector { } } } - - // If we couldn't get it from the button, try to get it from user preferences - if (!currentSelectedVoice) { - // This will be handled asynchronously below - } + + // The menu shows a constant-size shortlist however large the catalog + // grows; the full catalog lives behind the "More voices…" door in the + // extension settings (doc/plans/2026-07-02-voice-selection-ux.md §3). + const curated = curateShortlist( + voices, + currentSelectedVoice?.id ?? this.pinnedCustomVoiceId, + CLAUDE_MENU_CAP + ); + this.showTier = curated.tiersCoexist; // Comprehensive cleanup to prevent duplicates this.cleanupExistingElements(voiceSelector); @@ -745,12 +744,34 @@ export class ClaudeVoiceMenu extends VoiceSelector { const voiceOffItem = this.createMenuItem(null, noVoicesAvailable); this.menuContent.appendChild(voiceOffItem); - // Add available voices - voices.forEach((voice) => { + // Add the shortlisted voices + curated.voices.forEach((voice) => { const menuItem = this.createMenuItem(voice); this.menuContent.appendChild(menuItem); }); + // One muted line carries the whole in-menu economics story, only while + // premium and value tiers coexist. + if (curated.tiersCoexist) { + const footnote = document.createElement("div"); + footnote.classList.add( + "saypi-voice-footnote", + "py-1", + "px-2", + "text-xs", + "text-text-500", + "select-none" + ); + footnote.setAttribute("role", "note"); + footnote.textContent = getMessage("hdVoicesAllowanceNote"); + this.menuContent.appendChild(footnote); + } + + // The door to the full catalog, only when the shortlist hides voices. + if (curated.hiddenCount > 0) { + this.menuContent.appendChild(this.createMoreVoicesItem()); + } + // Add subtle separators for easier scanning this.applyItemSeparators(); @@ -761,12 +782,64 @@ export class ClaudeVoiceMenu extends VoiceSelector { // Fall back to getting the voice from preferences asynchronously this.userPreferences.getVoice(this.chatbot).then((voice) => { this.updateSelectedVoice(voice); + // The stored voice must never vanish from the menu: if the shortlist + // hid it, remember it as pinned. The pin persists on the instance so + // every later populate includes it synchronously. + const storedId = + voice && voices.some((v) => v.id === voice.id) ? voice.id : null; + if (!storedId) { + this.pinnedCustomVoiceId = null; + return; + } + if (curated.voices.some((v) => v.id === storedId)) { + // Visible without help; drop a pin left over from a previous voice. + if ( + this.pinnedCustomVoiceId && + this.pinnedCustomVoiceId !== storedId + ) { + this.pinnedCustomVoiceId = null; + } + return; + } + this.pinnedCustomVoiceId = storedId; + // Never rebuild a menu the user has open — the teardown would close + // it mid-interaction. The pin applies on the next populate instead. + if (this.menuButton.getAttribute("aria-expanded") !== "true") { + this.populateVoices(voices, voiceSelector); + } }); } return !noVoicesAvailable; } + /** + * The muted final row linking to the full voice catalog in the extension + * settings (AI Chat tab). Rendered only when the shortlist hides voices. + */ + private createMoreVoicesItem(): HTMLDivElement { + const item = document.createElement("div"); + item.classList.add( + "py-1", + "px-2", + "rounded-md", + "cursor-pointer", + "select-none", + "text-sm", + "text-text-500", + "hover:bg-bg-300" + ); + item.setAttribute("role", "menuitem"); + item.setAttribute("tabindex", "-1"); + item.dataset.action = "more-voices"; + item.textContent = getMessage("moreVoices"); + item.addEventListener("click", () => { + this.toggleMenu(); + openSettings("chat"); + }); + return item; + } + // Add a faint bottom divider to each menu item except the last one private applyItemSeparators(): void { const items = Array.from(this.menuContent.querySelectorAll('[role="menuitem"]')) as HTMLElement[]; diff --git a/src/chatbots/PiVoiceMenu.ts b/src/chatbots/PiVoiceMenu.ts index 0852a59d35..abbcb274c8 100644 --- a/src/chatbots/PiVoiceMenu.ts +++ b/src/chatbots/PiVoiceMenu.ts @@ -3,6 +3,7 @@ import EventBus from "../events/EventBus"; import { audioProviders } from "../tts/SpeechModel"; import { SpeechSynthesisModule } from "../tts/SpeechSynthesisModule"; import { VoiceSelector } from "../tts/VoiceMenu"; +import { PI_MENU_CAP } from "../tts/VoiceCuration"; import { Chatbot } from "./Chatbot"; import { UserPreferenceModule } from "../prefs/PreferenceModule"; @@ -24,6 +25,13 @@ export class PiVoiceMenu extends VoiceSelector { return "saypi-voice-menu"; } + // The in-chat dropdown is Pi's most minimal surface: cap the SayPi block + // and route the rest through "More voices" (Pi's own settings-page grid, + // PiVoiceSettings, stays uncapped). + protected override getCustomVoiceCap(): number { + return PI_MENU_CAP; + } + getButtonClasses(): string[] { return [ "mb-1", diff --git a/src/popup/popupopener.ts b/src/popup/popupopener.ts index 9e3877f1c6..ad71c11d92 100644 --- a/src/popup/popupopener.ts +++ b/src/popup/popupopener.ts @@ -1,8 +1,28 @@ +/** + * Storage key carrying a one-shot deep-link target for the settings page. + * Written here (content-script context) and consumed + cleared by the + * settings page on boot — content scripts can't reach the settings page's + * localStorage, but both share chrome.storage.local. + */ +export const SETTINGS_DEEP_LINK_KEY = "saypi.settings.deepLinkTab"; + /** * Opens the extension's settings popup by sending a message to the background script. * The background script will handle opening the popup in the native way. + * @param tab Optional settings tab to open on (e.g. "chat" for the AI Chat + * tab's voice catalog); defaults to the user's last-viewed tab. */ -export function openSettings(): void { +export function openSettings(tab?: string): void { + if (tab) { + try { + const stored = chrome.storage.local.set({ [SETTINGS_DEEP_LINK_KEY]: tab }); + if (stored && typeof (stored as Promise).catch === "function") { + (stored as Promise).catch(() => {}); + } + } catch (e) { + // Non-fatal: settings still opens, just on the default tab + } + } try { const result = chrome.runtime.sendMessage({ action: 'openPopup' }); // In some browsers, sendMessage returns a Promise; handle failures @@ -25,4 +45,4 @@ export function openSettings(): void { // ignore } } -} +} diff --git a/src/styles/voices.scss b/src/styles/voices.scss index 11814ea475..d45221c108 100644 --- a/src/styles/voices.scss +++ b/src/styles/voices.scss @@ -17,6 +17,18 @@ height: 20px; padding-left: 0.5rem; } +// Quiet tier suffix on premium rows (shown only while HD and value voices coexist) +.saypi-voice-button .voice-tier { + opacity: 0.6; + font-size: 0.75em; + margin-left: 0.35em; + vertical-align: middle; +} +// The muted door row to the full catalog in the extension settings +button.saypi-more-voices { + opacity: 0.7; + font-size: 0.9em; +} #saypi-voice-settings button.selected.paola { background-color: rgb(200, 200, 200); border-color: rgb(200, 200, 200); diff --git a/src/tts/VoiceCuration.ts b/src/tts/VoiceCuration.ts new file mode 100644 index 0000000000..55b7e067a6 --- /dev/null +++ b/src/tts/VoiceCuration.ts @@ -0,0 +1,162 @@ +import { SpeechSynthesisVoiceRemote } from "./SpeechModel"; + +/** + * Client-side voice curation (doc/plans/2026-07-02-voice-selection-ux.md §3, §5). + * + * The in-host voice menus stay capped at a constant size while the server + * catalog grows; the full catalog lives one click deeper (extension settings). + * Until GET /voices carries curation metadata (featured/section/recommended), + * tier and featured-set are derived locally from fields the API already serves + * (powered_by + price), per the rollout plan's confirmed contract. + */ + +export type VoiceTier = "hd" | "everyday"; + +/** Max voice rows in the in-host menus ("Voice off" and "More voices…" excluded). */ +export const CLAUDE_MENU_CAP = 6; +export const PI_MENU_CAP = 5; + +/** + * Credits/1k chars boundary between value voices (OpenAI: 50) and premium + * voices (ElevenLabs: 1000+). Sits far from both so neither drifts across. + */ +const HD_CREDITS_THRESHOLD = 300; + +/** How many premium voices the shortlist features ahead of the value picks. */ +const HD_FEATURED_COUNT = 2; + +/** + * Featured order for value-tier voices, most-preferred first (2026-07 + * popularity research: Coral fronts OpenAI's own demos, Nova is the enduring + * community favourite, Ash/Onyx are the standout male voices; Alloy reads as + * the bland default and is deliberately last). + */ +const EVERYDAY_RANK = [ + "coral", + "nova", + "ash", + "onyx", + "sage", + "echo", + "shimmer", + "ballad", + "fable", + "alloy", +]; + +export function getVoiceTier(voice: SpeechSynthesisVoiceRemote): VoiceTier { + const credits = voice.price_per_thousand_chars_in_credits; + if (typeof credits === "number" && !isNaN(credits)) { + return credits >= HD_CREDITS_THRESHOLD ? "hd" : "everyday"; + } + return voice.powered_by === "ElevenLabs" ? "hd" : "everyday"; +} + +export interface CuratedShortlist { + /** The rows to render, in order: current voice first, then featured picks. */ + voices: SpeechSynthesisVoiceRemote[]; + /** Catalog voices not shown — drives the "More voices…" door row. */ + hiddenCount: number; + /** Both tiers exist in the full catalog — drives the HD chip + footer. */ + tiersCoexist: boolean; +} + +/** + * Rank by id first (stable across server re-skins of display names — plan + * §7.3), falling back to name for catalogs whose ids aren't the voice names. + * Curation must never throw on a malformed catalog entry, so both reads are + * null-safe. + */ +function everydayRank(voice: SpeechSynthesisVoiceRemote): number { + const byId = EVERYDAY_RANK.indexOf(String(voice.id ?? "").toLowerCase()); + if (byId !== -1) return byId; + const byName = EVERYDAY_RANK.indexOf(String(voice.name ?? "").toLowerCase()); + return byName === -1 ? EVERYDAY_RANK.length : byName; +} + +/** + * Featured premium pair: first in server order, then the next voice of a + * different gender (server order is already founder-curated; gender diversity + * keeps the pair from reading as one flavour). Duplicate display names are + * skipped (the Pi catalog carries two "Paola" variants), and any shortfall + * fills from the front in server order. + */ +function pickHdFeatured( + hdVoices: SpeechSynthesisVoiceRemote[] +): SpeechSynthesisVoiceRemote[] { + const featured: SpeechSynthesisVoiceRemote[] = []; + const genders = new Set(); + const names = new Set(); + + const nameOf = (voice: SpeechSynthesisVoiceRemote) => + String(voice.name ?? "").toLowerCase(); + + const add = (voice: SpeechSynthesisVoiceRemote) => { + featured.push(voice); + genders.add(String(voice.gender ?? "").toUpperCase()); + names.add(nameOf(voice)); + }; + + for (const voice of hdVoices) { + if (featured.length >= HD_FEATURED_COUNT) break; + if (names.has(nameOf(voice))) continue; + const gender = String(voice.gender ?? "").toUpperCase(); + if (featured.length === 0 || !genders.has(gender)) { + add(voice); + } + } + for (const voice of hdVoices) { + if (featured.length >= HD_FEATURED_COUNT) break; + if (names.has(nameOf(voice))) continue; + add(voice); + } + return featured; +} + +/** + * Order and cap a voice catalog for an in-host menu: pin the user's current + * voice first (it must never vanish from the menu), then the featured premium + * pair, then value voices by popularity rank, then fill any spare capacity in + * server order (covers single-tier catalogs). + */ +export function curateShortlist( + voices: SpeechSynthesisVoiceRemote[], + currentVoiceId: string | null, + cap: number +): CuratedShortlist { + const tiersCoexist = new Set(voices.map(getVoiceTier)).size > 1; + + const shortlist: SpeechSynthesisVoiceRemote[] = []; + const taken = new Set(); + const take = (voice: SpeechSynthesisVoiceRemote) => { + if (shortlist.length < cap && !taken.has(voice.id)) { + shortlist.push(voice); + taken.add(voice.id); + } + }; + + const current = currentVoiceId + ? voices.find((voice) => voice.id === currentVoiceId) + : undefined; + if (current) take(current); + + pickHdFeatured(voices.filter((v) => getVoiceTier(v) === "hd")).forEach(take); + + voices + .map((voice, serverIndex) => ({ voice, serverIndex })) + .filter(({ voice }) => getVoiceTier(voice) === "everyday") + .sort( + (a, b) => + everydayRank(a.voice) - everydayRank(b.voice) || + a.serverIndex - b.serverIndex + ) + .forEach(({ voice }) => take(voice)); + + voices.forEach(take); + + return { + voices: shortlist, + hiddenCount: voices.length - shortlist.length, + tiersCoexist, + }; +} diff --git a/src/tts/VoiceMenu.ts b/src/tts/VoiceMenu.ts index 9eaf6b0859..d283a48d18 100644 --- a/src/tts/VoiceMenu.ts +++ b/src/tts/VoiceMenu.ts @@ -9,6 +9,8 @@ import { UserPreferenceModule } from "../prefs/PreferenceModule"; import { SpeechSynthesisVoiceRemote } from "./SpeechModel"; import { SpeechSynthesisModule } from "./SpeechSynthesisModule"; import { getJwtManagerSync } from "../JwtManager"; +import { openSettings } from "../popup/popupopener"; +import { curateShortlist, getVoiceTier } from "./VoiceCuration"; /** * A chatbot that ships its own set of built-in voices, with introduction audio @@ -38,6 +40,8 @@ export abstract class VoiceSelector { protected userPreferences: UserPreferenceModule; protected element: HTMLElement; protected selectedVoiceButton: HTMLButtonElement | null = null; + /** Set when the stored voice would otherwise be hidden by the shortlist cap. */ + protected pinnedCustomVoiceId: string | null = null; constructor( chatbot: Chatbot, @@ -123,10 +127,20 @@ export abstract class VoiceSelector { } protected isBuiltInVoiceButton(button: HTMLButtonElement): boolean { - return !( - button.classList.contains("saypi-custom-voice") || - button.classList.contains("saypi-restored-voice") - ); + // Every SayPi-injected row carries the positive marker; anything without + // it is a host-native (built-in) button. Safer than enumerating SayPi + // row types — new rows are excluded by construction. + return !button.classList.contains("saypi-voice-button"); + } + + /** + * Max SayPi (custom) voice rows this surface shows before tucking the rest + * behind a "More voices" door. null = uncapped (the default — e.g. Pi's own + * settings-page grid); in-host menus override + * (doc/plans/2026-07-02-voice-selection-ux.md §3). + */ + protected getCustomVoiceCap(): number | null { + return null; } async refreshMenu(): Promise { @@ -162,11 +176,125 @@ export abstract class VoiceSelector { const customVoices = voices.filter((voice) => !voice.default); this.populateDefaultVoices(defaultVoices, voiceSelector); - this.populateCustomVoices(customVoices, voiceSelector); + + const cap = this.getCustomVoiceCap(); + if (cap === null) { + this.populateCustomVoices(customVoices, voiceSelector); + return true; + } + + // Capped surface: show a curated shortlist of SayPi voices (built-in host + // voices above are never capped), with a door to the full catalog. + const curated = curateShortlist( + customVoices, + this.pinnedCustomVoiceId, + cap + ); + this.populateCustomVoices( + curated.voices, + voiceSelector, + curated.tiersCoexist + ); + if (curated.hiddenCount > 0) { + this.addMoreVoicesDoor(voiceSelector); + } + + // The stored voice must never vanish from the menu: if the cap hid it, + // pin it and re-render the SayPi block once. Runs only when this call + // could actually hide something — partial lists (addMissingPiVoices + // passes Pi's built-in top-ups alone) must not disturb the pin state. + if (customVoices.length > cap) { + this.userPreferences.getVoice(this.chatbot).then((voice) => { + const storedCustomId = + voice && customVoices.some((v) => v.id === voice.id) + ? voice.id + : null; + if (!storedCustomId) { + this.pinnedCustomVoiceId = null; + return; + } + if (curated.voices.some((v) => v.id === storedCustomId)) { + // Visible without help; drop a pin left over from a previous voice. + if ( + this.pinnedCustomVoiceId && + this.pinnedCustomVoiceId !== storedCustomId + ) { + this.pinnedCustomVoiceId = null; + } + return; + } + if (this.pinnedCustomVoiceId !== storedCustomId) { + this.pinnedCustomVoiceId = storedCustomId; + this.removeCustomVoiceRows( + voiceSelector, + new Set(customVoices.map((v) => v.id)) + ); + this.populateVoices(voices, voiceSelector); + } + }); + } return true; } + /** + * Remove this catalog's SayPi voice rows (and the door) ahead of a + * re-render. Scoped to the given ids so rows from other sources — Pi's + * extra built-ins arrive via a separate populate call — survive. + */ + private removeCustomVoiceRows( + voiceSelector: HTMLElement, + catalogIds: Set + ): void { + voiceSelector + .querySelectorAll("button.saypi-custom-voice") + .forEach((button) => { + const id = button.dataset.voiceId; + if (id && catalogIds.has(id)) { + button.remove(); + } + }); + voiceSelector + .querySelectorAll("button.saypi-more-voices") + .forEach((button) => button.remove()); + } + + /** + * The muted final row of the SayPi block, linking to the full voice catalog + * in the extension settings. Styled like the host's own rows. Idempotent: + * an existing door is moved back into place rather than duplicated. + */ + private addMoreVoicesDoor(voiceSelector: HTMLElement): void { + let door = voiceSelector.querySelector( + "button.saypi-more-voices" + ); + if (!door) { + door = document.createElement("button"); + door.type = "button"; + // saypi-voice-button keeps it under the menu's expand/collapse visibility + // rules (voices.scss); saypi-more-voices exempts it from voice-selection + // handling (isBuiltInVoiceButton). + door.classList.add( + ...this.getButtonClasses(), + "saypi-voice-button", + "saypi-more-voices" + ); + door.textContent = getMessage("moreVoices"); + door.addEventListener("click", () => { + openSettings("chat"); + }); + } + const customButtons = voiceSelector.querySelectorAll( + "button.saypi-custom-voice" + ); + const lastCustom = customButtons[customButtons.length - 1]; + if (lastCustom) { + lastCustom.insertAdjacentElement("afterend", door); + } else { + voiceSelector.appendChild(door); + } + } + populateDefaultVoices( defaultVoices: SpeechSynthesisVoiceRemote[], voiceSelector: HTMLElement @@ -220,7 +348,8 @@ export abstract class VoiceSelector { populateCustomVoices( customVoices: SpeechSynthesisVoiceRemote[], - voiceSelector: HTMLElement + voiceSelector: HTMLElement, + showTier: boolean = false ): void { const customVoiceButtons = Array(customVoices.length); @@ -244,6 +373,15 @@ export abstract class VoiceSelector { name.classList.add("voice-name"); name.innerText = voice.name; button.appendChild(name); + // Quiet tier suffix on premium rows, only while tiers coexist + // (doc/plans/2026-07-02-voice-selection-ux.md §3 — no chips, no prices on Pi). + if (showTier && getVoiceTier(voice) === "hd") { + const tier = document.createElement("span"); + tier.classList.add("voice-tier"); + tier.textContent = "HD"; + tier.title = getMessage("hdVoicesAllowanceNote"); + button.appendChild(tier); + } const flair = document.createElement("img"); flair.classList.add("flair"); flair.src = getResourceUrl("icons/logos/saypi.png"); diff --git a/test/chatbots/ClaudeVoiceMenu-curation.spec.ts b/test/chatbots/ClaudeVoiceMenu-curation.spec.ts new file mode 100644 index 0000000000..4b04526850 --- /dev/null +++ b/test/chatbots/ClaudeVoiceMenu-curation.spec.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ConfigModule reads injected env at import time; stub it (mirrors other specs). +vi.mock("../../src/ConfigModule", () => ({ + config: { + appServerUrl: "https://app.example.com", + apiServerUrl: "https://api.saypi.ai", + GA_MEASUREMENT_ID: "x", + GA_API_SECRET: "x", + GA_ENDPOINT: "x", + }, +})); + +vi.mock("../../src/JwtManager", () => ({ + getJwtManagerSync: () => ({ + isAuthenticated: () => true, + getClaims: () => ({ ttsQuotaRemaining: 1000 }), + }), +})); + +const openSettingsMock = vi.fn(); +vi.mock("../../src/popup/popupopener", () => ({ + openSettings: (...args: unknown[]) => openSettingsMock(...args), +})); + +import { ClaudeVoiceMenu } from "../../src/chatbots/ClaudeVoiceMenu"; +import { CLAUDE_MENU_CAP } from "../../src/tts/VoiceCuration"; +import { claudeMockVoices, openAiMockVoices } from "../data/Voices"; +import { SpeechSynthesisVoiceRemote } from "../../src/tts/SpeechModel"; + +const flipDayCatalog: SpeechSynthesisVoiceRemote[] = [ + ...claudeMockVoices, + ...openAiMockVoices, +]; // 20 voices — the claude.ai payload once OPENAI_TTS_ENABLED flips + +// Bypass the heavy constructor; populateVoices only needs prototype methods +// plus the fields it reads (pattern from VoiceMenu.spec.ts). +function makeMenu(currentVoice: SpeechSynthesisVoiceRemote | null = null): any { + const menu = Object.create(ClaudeVoiceMenu.prototype); + menu.chatbot = {} as any; + menu.userPreferences = { + getVoice: vi.fn(async () => currentVoice), + setVoice: vi.fn(async () => {}), + unsetVoice: vi.fn(async () => {}), + }; + menu.element = document.createElement("div"); + menu.element.id = "claude-voice-selector"; + document.body.appendChild(menu.element); + menu.menuButton = document.createElement("button"); + menu.menuContent = document.createElement("div"); + menu.toggleMenu = vi.fn(); + return menu; +} + +function voiceRows(menu: any): HTMLElement[] { + return Array.from( + menu.menuContent.querySelectorAll("[role='menuitem']") + ).filter( + (el) => + (el as HTMLElement).dataset.voiceName && + (el as HTMLElement).dataset.voiceName !== "voice-off" && + !(el as HTMLElement).dataset.action + ) as HTMLElement[]; +} + +function flushAsync(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +beforeEach(() => { + openSettingsMock.mockReset(); + document.body.innerHTML = ""; +}); + +describe("ClaudeVoiceMenu shortlist cap + door (flip-day catalog)", () => { + it("renders at most CLAUDE_MENU_CAP voice rows from a 20-voice catalog", () => { + const menu = makeMenu(); + menu.populateVoices(flipDayCatalog, menu.element); + expect(voiceRows(menu).length).toBe(CLAUDE_MENU_CAP); + }); + + it("renders a 'More voices…' door row when voices are hidden", () => { + const menu = makeMenu(); + menu.populateVoices(flipDayCatalog, menu.element); + const door = menu.menuContent.querySelector("[data-action='more-voices']"); + expect(door).not.toBeNull(); + }); + + it("opens the extension settings when the door row is clicked", () => { + const menu = makeMenu(); + menu.populateVoices(flipDayCatalog, menu.element); + const door = menu.menuContent.querySelector( + "[data-action='more-voices']" + ) as HTMLElement; + door.click(); + expect(openSettingsMock).toHaveBeenCalled(); + }); + + it("omits the door row when the whole catalog fits the cap", () => { + const menu = makeMenu(); + menu.populateVoices(flipDayCatalog.slice(0, 3), menu.element); + const door = menu.menuContent.querySelector("[data-action='more-voices']"); + expect(door).toBeNull(); + }); + + it("keeps the Voice off item", () => { + const menu = makeMenu(); + menu.populateVoices(flipDayCatalog, menu.element); + const off = menu.menuContent.querySelector("[data-voice-name='voice-off']"); + expect(off).not.toBeNull(); + }); +}); + +describe("ClaudeVoiceMenu tier signalling", () => { + it("marks HD rows with an HD chip when tiers coexist, and never shows raw prices", () => { + const menu = makeMenu(); + menu.populateVoices(flipDayCatalog, menu.element); + const rows = voiceRows(menu); + const jarnathan = rows.find((r) => r.dataset.voiceName === "Jarnathan")!; + const coral = rows.find((r) => r.dataset.voiceName === "Coral")!; + expect(jarnathan.textContent).toContain("HD"); + expect(coral.textContent).not.toContain("HD"); + expect(menu.menuContent.textContent).not.toMatch(/1k/); + }); + + it("shows a single footer note about HD allowance burn when tiers coexist", () => { + const menu = makeMenu(); + menu.populateVoices(flipDayCatalog, menu.element); + const footer = menu.menuContent.querySelector(".saypi-voice-footnote"); + expect(footer).not.toBeNull(); + }); + + it("shows no HD chips and no footer for a single-tier catalog (pre-flip)", () => { + const menu = makeMenu(); + menu.populateVoices(claudeMockVoices, menu.element); + expect(menu.menuContent.textContent).not.toContain("HD"); + expect(menu.menuContent.querySelector(".saypi-voice-footnote")).toBeNull(); + }); + + it("still caps and offers the door for the single-tier pre-flip catalog", () => { + const menu = makeMenu(); + menu.populateVoices(claudeMockVoices, menu.element); // 10 ElevenLabs voices + expect(voiceRows(menu).length).toBe(CLAUDE_MENU_CAP); + expect( + menu.menuContent.querySelector("[data-action='more-voices']") + ).not.toBeNull(); + }); +}); + +describe("ClaudeVoiceMenu current-voice pinning", () => { + it("re-renders with the stored voice pinned first when it was not in the shortlist", async () => { + const lucy = claudeMockVoices.find((v) => v.name === "Lucy")!; + const menu = makeMenu(lucy); + menu.populateVoices(flipDayCatalog, menu.element); + await flushAsync(); + const rows = voiceRows(menu); + expect(rows[0].dataset.voiceName).toBe("Lucy"); + expect(rows.length).toBe(CLAUDE_MENU_CAP); + }); + + it("does not re-render when the stored voice is already featured", async () => { + const jarnathan = claudeMockVoices.find((v) => v.name === "Jarnathan")!; + const menu = makeMenu(jarnathan); + menu.populateVoices(flipDayCatalog, menu.element); + await flushAsync(); + const rows = voiceRows(menu); + const jarnathans = rows.filter((r) => r.dataset.voiceName === "Jarnathan"); + expect(jarnathans.length).toBe(1); + expect(rows.length).toBe(CLAUDE_MENU_CAP); + }); + + it("persists the pin so the next populate renders the stored voice synchronously", async () => { + const lucy = claudeMockVoices.find((v) => v.name === "Lucy")!; + const menu = makeMenu(lucy); + menu.populateVoices(flipDayCatalog, menu.element); + await flushAsync(); // async pin + one re-render + // A fresh populate (e.g. menu reopened) must include Lucy immediately, + // without waiting for another async round-trip. + menu.populateVoices(flipDayCatalog, menu.element); + const rows = voiceRows(menu); + expect(rows[0].dataset.voiceName).toBe("Lucy"); + }); + + it("defers the pin re-render while the menu is open instead of tearing it down", async () => { + const lucy = claudeMockVoices.find((v) => v.name === "Lucy")!; + const menu = makeMenu(lucy); + menu.populateVoices(flipDayCatalog, menu.element); + // Simulate the menu being open when the async pin lands. + menu.menuButton.setAttribute("aria-expanded", "true"); + const openMenuContent = menu.menuContent; + await flushAsync(); + // The open menu must not be destroyed out from under the user… + expect(menu.menuContent).toBe(openMenuContent); + // …but the pin is remembered for the next populate. + menu.menuButton.setAttribute("aria-expanded", "false"); + menu.populateVoices(flipDayCatalog, menu.element); + const rows = voiceRows(menu); + expect(rows[0].dataset.voiceName).toBe("Lucy"); + }); + + it("passes the AI Chat tab as the door's settings destination", () => { + const menu = makeMenu(); + menu.populateVoices(flipDayCatalog, menu.element); + const door = menu.menuContent.querySelector( + "[data-action='more-voices']" + ) as HTMLElement; + door.click(); + expect(openSettingsMock).toHaveBeenCalledWith("chat"); + }); +}); diff --git a/test/chatbots/PiVoiceMenu-curation.spec.ts b/test/chatbots/PiVoiceMenu-curation.spec.ts new file mode 100644 index 0000000000..9987b5f65f --- /dev/null +++ b/test/chatbots/PiVoiceMenu-curation.spec.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ConfigModule reads injected env at import time; stub it (mirrors other specs). +vi.mock("../../src/ConfigModule", () => ({ + config: { + appServerUrl: "https://app.example.com", + apiServerUrl: "https://api.saypi.ai", + GA_MEASUREMENT_ID: "x", + GA_API_SECRET: "x", + GA_ENDPOINT: "x", + }, +})); + +vi.mock("../../src/JwtManager", () => ({ + getJwtManagerSync: () => ({ + isAuthenticated: () => true, + getClaims: () => ({ ttsQuotaRemaining: 1000 }), + }), +})); + +const openSettingsMock = vi.fn(); +vi.mock("../../src/popup/popupopener", () => ({ + openSettings: (...args: unknown[]) => openSettingsMock(...args), +})); + +import { PiVoiceMenu } from "../../src/chatbots/PiVoiceMenu"; +import { PI_MENU_CAP } from "../../src/tts/VoiceCuration"; +import { ElevenLabsVoice, OpenAIVoice, openAiMockVoices } from "../data/Voices"; +import { SpeechSynthesisVoiceRemote } from "../../src/tts/SpeechModel"; + +// The Pi catalog: Paola/Joey (flash) + the pricier Paola v3 variant, all +// custom (default=false), plus the OpenAI value voices on flip day. +const piElevenLabs = [ + new ElevenLabsVoice("ig1TeITnnNlsJtfHxJlW", "Paola"), + new ElevenLabsVoice("bWJPewAagbymiJXZcxnh", "Joey"), + new ElevenLabsVoice("paola-v3", "Paola", "F"), +]; +const piFlipDay: SpeechSynthesisVoiceRemote[] = [ + ...piElevenLabs, + ...openAiMockVoices, +]; // 13 custom voices + +// A couple of Pi built-ins (voice.default=true) — these are native rows the +// cap must never touch. +function builtInVoice(id: string, name: string): SpeechSynthesisVoiceRemote { + const voice = new OpenAIVoice(id, name) as any; + voice.powered_by = "inflection.ai"; + voice.default = true; + return voice; +} +const piBuiltIns = [builtInVoice("voice1", "Pi 1"), builtInVoice("voice2", "Pi 2")]; + +// Bypass the heavy constructor (DOM observers); populateVoices only needs +// prototype methods plus the fields it reads. +function makeMenu(currentVoice: SpeechSynthesisVoiceRemote | null = null): any { + const menu = Object.create(PiVoiceMenu.prototype); + menu.chatbot = {} as any; + menu.userPreferences = { + getVoice: vi.fn(async () => currentVoice), + setVoice: vi.fn(async () => {}), + unsetVoice: vi.fn(async () => {}), + }; + menu.element = document.createElement("div"); + return menu; +} + +function customRows(selector: HTMLElement): HTMLButtonElement[] { + return Array.from(selector.querySelectorAll("button.saypi-custom-voice")); +} + +function flushAsync(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +beforeEach(() => { + openSettingsMock.mockReset(); + document.body.innerHTML = ""; +}); + +describe("PiVoiceMenu shortlist cap + door", () => { + it("caps SayPi (custom) rows at PI_MENU_CAP on the flip-day catalog", () => { + const menu = makeMenu(); + const selector = document.createElement("div"); + menu.populateVoices([...piBuiltIns, ...piFlipDay], selector); + expect(customRows(selector).length).toBe(PI_MENU_CAP); + }); + + it("never caps Pi's own built-in voice rows", () => { + const menu = makeMenu(); + const selector = document.createElement("div"); + menu.populateVoices([...piBuiltIns, ...piFlipDay], selector); + const builtInRows = Array.from( + selector.querySelectorAll("button.saypi-restored-voice") + ); + expect(builtInRows.length).toBe(piBuiltIns.length); + }); + + it("adds a muted 'More voices' button after the SayPi block when voices are hidden", () => { + const menu = makeMenu(); + const selector = document.createElement("div"); + menu.populateVoices([...piBuiltIns, ...piFlipDay], selector); + const door = selector.querySelector( + "button.saypi-more-voices" + ) as HTMLButtonElement; + expect(door).not.toBeNull(); + door.click(); + expect(openSettingsMock).toHaveBeenCalled(); + }); + + it("omits the door and shows everything when the catalog fits the cap (today's 3 voices)", () => { + const menu = makeMenu(); + const selector = document.createElement("div"); + menu.populateVoices([...piBuiltIns, ...piElevenLabs], selector); + expect(customRows(selector).length).toBe(piElevenLabs.length); + expect(selector.querySelector("button.saypi-more-voices")).toBeNull(); + }); +}); + +describe("PiVoiceMenu tier badge", () => { + it("suffixes premium rows with a quiet HD badge only when tiers coexist", () => { + const menu = makeMenu(); + const selector = document.createElement("div"); + menu.populateVoices([...piBuiltIns, ...piFlipDay], selector); + const rows = customRows(selector); + const paola = rows.find( + (r) => r.dataset.voiceId === "ig1TeITnnNlsJtfHxJlW" + )!; + const coral = rows.find((r) => r.dataset.voiceId === "coral")!; + expect(paola.querySelector(".voice-tier")?.textContent).toBe("HD"); + expect(coral.querySelector(".voice-tier")).toBeNull(); + }); + + it("shows no badge for today's single-tier catalog", () => { + const menu = makeMenu(); + const selector = document.createElement("div"); + menu.populateVoices([...piBuiltIns, ...piElevenLabs], selector); + expect(selector.querySelector(".voice-tier")).toBeNull(); + }); +}); + +describe("PiVoiceMenu current-voice pinning", () => { + it("re-renders with the stored voice visible when the cap would have hidden it", async () => { + const shimmer = openAiMockVoices.find((v) => v.name === "Shimmer")!; + const menu = makeMenu(shimmer); + const selector = document.createElement("div"); + menu.populateVoices([...piBuiltIns, ...piFlipDay], selector); + await flushAsync(); + const rows = customRows(selector); + const ids = rows.map((r) => r.dataset.voiceId); + expect(ids).toContain("shimmer"); + expect(rows.length).toBe(PI_MENU_CAP); + }); + + it("does not duplicate the door row when populateVoices runs again on the same menu", async () => { + const menu = makeMenu(); + const selector = document.createElement("div"); + menu.populateVoices([...piBuiltIns, ...piFlipDay], selector); + await flushAsync(); + menu.populateVoices([...piBuiltIns, ...piFlipDay], selector); + await flushAsync(); + expect(selector.querySelectorAll("button.saypi-more-voices").length).toBe(1); + }); + + it("keeps the pin when a partial built-ins-only populate runs (addMissingPiVoices path)", async () => { + const shimmer = openAiMockVoices.find((v) => v.name === "Shimmer")!; + const menu = makeMenu(shimmer); + const selector = document.createElement("div"); + menu.populateVoices([...piBuiltIns, ...piFlipDay], selector); + await flushAsync(); // pin applied, Shimmer visible + // Pi tops the menu up with its own extra voices (voice7/voice8 are + // default=false, so they arrive as a customs-only partial list). + const extras = [builtInVoice("voice7", "Pi 7"), builtInVoice("voice8", "Pi 8")]; + extras.forEach((v) => ((v as any).default = false)); + menu.populateVoices(extras, selector); + await flushAsync(); + menu.populateVoices([...piBuiltIns, ...piFlipDay], selector); + await flushAsync(); + const ids = customRows(selector).map((r) => r.dataset.voiceId); + expect(ids).toContain("shimmer"); + // ...and the pin re-render must not have deleted the extra built-ins. + expect(ids).toContain("voice7"); + expect(ids).toContain("voice8"); + }); + + it("clears a stale pin after the user switches to a featured voice", async () => { + const shimmer = openAiMockVoices.find((v) => v.name === "Shimmer")!; + const coral = openAiMockVoices.find((v) => v.name === "Coral")!; + let stored: any = shimmer; + const menu = makeMenu(); + menu.userPreferences.getVoice = vi.fn(async () => stored); + const selector = document.createElement("div"); + menu.populateVoices([...piBuiltIns, ...piFlipDay], selector); + await flushAsync(); // pin = shimmer + stored = coral; // user picked a featured voice + menu.populateVoices([...piBuiltIns, ...piFlipDay], selector); + await flushAsync(); // stale pin detected and cleared + const rebuilt = document.createElement("div"); + menu.populateVoices([...piBuiltIns, ...piFlipDay], rebuilt); + await flushAsync(); + const ids = customRows(rebuilt).map((r) => r.dataset.voiceId); + expect(ids).not.toContain("shimmer"); + expect(ids).toContain("coral"); + }); +}); diff --git a/test/data/Voices.ts b/test/data/Voices.ts index 5190b9c418..8a417a9537 100644 --- a/test/data/Voices.ts +++ b/test/data/Voices.ts @@ -35,9 +35,35 @@ class ElevenLabsVoice extends Voice implements SpeechSynthesisVoiceRemote { price_per_thousand_chars_in_credits: number = 1000; // price in credits per 1k characters powered_by: string = "ElevenLabs"; lang: string = "en"; + gender?: string; + accent?: string; + description?: string; - constructor(id: string, name: string) { + constructor(id: string, name: string, gender?: string, accent?: string, description?: string) { super(id, name, 0.3, 1000, "ElevenLabs"); + this.gender = gender; + this.accent = accent; + this.description = description; + } + default: boolean = false; + localService: boolean = false; + voiceURI: string = `https://api.saypi.ai/voices/${this.id}`; +} + +// Mirrors the saypi-api OpenAIVoice serialization (tts/models.py): value tier, +// 50 credits/1k, powered_by "OpenAI", lowercase id doubling as the provider +// voice name. +class OpenAIVoice extends Voice implements SpeechSynthesisVoiceRemote { + lang: string = "en"; + gender?: string; + accent?: string; + description?: string; + + constructor(id: string, name: string, gender?: string, accent?: string, description?: string) { + super(id, name, 0.015, 50, "OpenAI"); + this.gender = gender; + this.accent = accent; + this.description = description; } default: boolean = false; localService: boolean = false; @@ -48,4 +74,32 @@ const mockVoices: ElevenLabsVoice[] = [ new ElevenLabsVoice("ig1TeITnnNlsJtfHxJlW", "Paola"), ]; -export { ElevenLabsVoice, mockVoices }; +// The live Claude catalog (server order per saypi-api tts/claude.json). +const claudeMockVoices: ElevenLabsVoice[] = [ + new ElevenLabsVoice("c6SfcYrb2t09NHXiT80T", "Jarnathan", "M", "en-US", "Warm, versatile; confident and expressive."), + new ElevenLabsVoice("gs0tAILXbY5DNrJrsM6F", "Jeff", "M", "en-US", "Classy and resonant."), + new ElevenLabsVoice("1SM7GgM6IMuvQlz2BwM3", "Mark", "M", "en-US", "Casual and natural."), + new ElevenLabsVoice("DTKMou8ccj1ZaWGBiotd", "Jamahal", "M", "en-US", "Chill, youthful; relaxed tone."), + new ElevenLabsVoice("vBKc2FfBKJfcZNyEt1n6", "Finn", "M", "en-US", "Upbeat and friendly."), + new ElevenLabsVoice("aMSt68OGf4xUZAnLpTU8", "Juniper", "F", "en-US", "Grounded and professional."), + new ElevenLabsVoice("56AoDkrOh6qfVPDXZ7Pt", "Cassidy", "F", "en-US", "Confident with polished presence."), + new ElevenLabsVoice("eR40ATw9ArzDf9h3v7t7", "Addison", "F", "en-AU", "Relaxed and easygoing."), + new ElevenLabsVoice("g6xIsTj2HwM6VR4iXFCw", "Jessica", "F", "en-US", "Friendly and articulate."), + new ElevenLabsVoice("lcMyyd2HUfFzxdCaC4Ta", "Lucy", "F", "en-GB", "Energetic, natural."), +]; + +// The 10 OpenAI voices served once OPENAI_TTS_ENABLED flips (saypi-api tts/voice.py). +const openAiMockVoices: OpenAIVoice[] = [ + new OpenAIVoice("alloy", "Alloy", "F", "en-US", "Warm, balanced and conversational"), + new OpenAIVoice("ash", "Ash", "M", "en-US", "Clear and confident with an upbeat energy"), + new OpenAIVoice("ballad", "Ballad", "M", "en-US", "Calm, melodic and expressive"), + new OpenAIVoice("coral", "Coral", "F", "en-US", "Bright, friendly and animated"), + new OpenAIVoice("echo", "Echo", "M", "en-US", "Measured and articulate"), + new OpenAIVoice("fable", "Fable", "Other", "en-GB", "Story-telling tone with a British accent"), + new OpenAIVoice("nova", "Nova", "F", "en-US", "Energetic, youthful and engaging"), + new OpenAIVoice("onyx", "Onyx", "M", "en-US", "Deep, authoritative and resonant"), + new OpenAIVoice("sage", "Sage", "F", "en-US", "Soft-spoken, thoughtful and soothing"), + new OpenAIVoice("shimmer", "Shimmer", "F", "en-US", "Crisp, cheerful and expressive"), +]; + +export { ElevenLabsVoice, OpenAIVoice, mockVoices, claudeMockVoices, openAiMockVoices }; diff --git a/test/popup/popupopener.spec.ts b/test/popup/popupopener.spec.ts new file mode 100644 index 0000000000..ce74ad183c --- /dev/null +++ b/test/popup/popupopener.spec.ts @@ -0,0 +1,25 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { openSettings, SETTINGS_DEEP_LINK_KEY } from "../../src/popup/popupopener"; + +describe("openSettings deep-linking", () => { + beforeEach(() => { + (globalThis as any).chrome.runtime.sendMessage = vi.fn(() => Promise.resolve()); + (globalThis as any).chrome.storage.local.set = vi.fn(() => Promise.resolve()); + }); + + it("stashes the requested tab so the settings page can open on it", () => { + openSettings("chat"); + expect(chrome.storage.local.set).toHaveBeenCalledWith({ + [SETTINGS_DEEP_LINK_KEY]: "chat", + }); + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + action: "openPopup", + }); + }); + + it("stashes nothing when no tab is requested", () => { + openSettings(); + expect(chrome.storage.local.set).not.toHaveBeenCalled(); + expect(chrome.runtime.sendMessage).toHaveBeenCalled(); + }); +}); diff --git a/test/settings/tabs/ChatPanel.spec.tsx b/test/settings/tabs/ChatPanel.spec.tsx index 4ef5a5b4b9..00331df46c 100644 --- a/test/settings/tabs/ChatPanel.spec.tsx +++ b/test/settings/tabs/ChatPanel.spec.tsx @@ -50,4 +50,18 @@ describe("ChatPanel", () => { container.querySelector("input#chatgpt-auto-read-aloud[type='checkbox']"), ).toBeTruthy(); }); + + it("renders the Voices section with host pills and the catalog container VoicesController targets", () => { + const { container } = render(); + expect(container.querySelector("#voices-preference")).toBeTruthy(); + expect( + container + .querySelector("#voices-preference [data-i18n='voicesSectionTitle']"), + ).toBeTruthy(); + const pills = [ + ...container.querySelectorAll("#voice-host-pills button.voice-host-pill"), + ]; + expect(pills.map((p) => p.id)).toEqual(["voice-host-pi", "voice-host-claude"]); + expect(container.querySelector("#voice-catalog")).toBeTruthy(); + }); }); diff --git a/test/settings/tabs/voices-controller.spec.tsx b/test/settings/tabs/voices-controller.spec.tsx new file mode 100644 index 0000000000..350d4de0c3 --- /dev/null +++ b/test/settings/tabs/voices-controller.spec.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, cleanup } from "@testing-library/preact"; +import { ChatPanel } from "../../../entrypoints/settings/tabs/chat/ChatPanel"; +import { VoicesController } from "../../../entrypoints/settings/tabs/chat/voices-controller"; +import { + claudeMockVoices, + openAiMockVoices, + mockVoices, +} from "../../data/Voices"; +import { SpeechSynthesisVoiceRemote } from "../../../src/tts/SpeechModel"; + +const flipDayClaude: SpeechSynthesisVoiceRemote[] = [ + ...claudeMockVoices, + ...openAiMockVoices, +]; + +function makeDeps(overrides: Partial> = {}) { + return { + getVoices: vi.fn(async (host: string) => + host === "claude" ? flipDayClaude : mockVoices + ), + getVoice: vi.fn(async () => null as SpeechSynthesisVoiceRemote | null), + setVoice: vi.fn(async () => {}), + isAuthenticated: vi.fn(() => true), + ...overrides, + }; +} + +async function mount(deps = makeDeps()) { + const { container } = render(); + const controller = new VoicesController(container as HTMLElement, deps as any); + await controller.init(); + return { container: container as HTMLElement, controller, deps }; +} + +function flushAsync(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +beforeEach(() => { + document.body.innerHTML = ""; +}); +afterEach(() => cleanup()); + +describe("VoicesController", () => { + it("loads the Pi catalog by default and renders a row per voice", async () => { + const { container, deps } = await mount(); + expect(deps.getVoices).toHaveBeenCalledWith("pi"); + const rows = container.querySelectorAll("#voice-catalog .voice-row"); + expect(rows.length).toBe(mockVoices.length); + }); + + it("switches host when the Claude pill is clicked", async () => { + const { container, deps } = await mount(); + (container.querySelector("#voice-host-claude") as HTMLElement).click(); + await flushAsync(); + expect(deps.getVoices).toHaveBeenCalledWith("claude"); + const rows = container.querySelectorAll("#voice-catalog .voice-row"); + expect(rows.length).toBe(flipDayClaude.length); + }); + + it("groups a mixed-tier catalog into HD and Everyday shelves", async () => { + const { container } = await mount(); + (container.querySelector("#voice-host-claude") as HTMLElement).click(); + await flushAsync(); + const shelves = [ + ...container.querySelectorAll("#voice-catalog .voice-shelf-title"), + ].map((el) => el.getAttribute("data-i18n")); + expect(shelves).toEqual(["voicesShelfHd", "voicesShelfEveryday"]); + const hdShelf = container.querySelector("#voice-catalog .voice-shelf-hd")!; + expect(hdShelf.querySelector("[data-voice-id='c6SfcYrb2t09NHXiT80T']")).toBeTruthy(); + const everydayShelf = container.querySelector( + "#voice-catalog .voice-shelf-everyday" + )!; + expect(everydayShelf.querySelector("[data-voice-id='coral']")).toBeTruthy(); + }); + + it("renders a flat list without shelf headers for a single-tier catalog", async () => { + const { container } = await mount(); + expect( + container.querySelector("#voice-catalog .voice-shelf-title") + ).toBeNull(); + expect( + container.querySelector("#voice-catalog .voice-row") + ).toBeTruthy(); + }); + + it("selects a voice for the active host when its Use button is clicked", async () => { + const { container, deps } = await mount(); + (container.querySelector("#voice-host-claude") as HTMLElement).click(); + await flushAsync(); + const coralRow = container.querySelector( + "#voice-catalog [data-voice-id='coral']" + ) as HTMLElement; + (coralRow.querySelector("button.voice-use") as HTMLElement).click(); + await flushAsync(); + expect(deps.setVoice).toHaveBeenCalledWith( + expect.objectContaining({ id: "coral" }), + "claude" + ); + }); + + it("marks the stored voice's row as current instead of offering Use", async () => { + const jessica = claudeMockVoices.find((v) => v.name === "Jessica")!; + const deps = makeDeps({ getVoice: vi.fn(async () => jessica) }); + const { container } = await mount(deps); + (container.querySelector("#voice-host-claude") as HTMLElement).click(); + await flushAsync(); + const row = container.querySelector( + "#voice-catalog [data-voice-id='g6xIsTj2HwM6VR4iXFCw']" + ) as HTMLElement; + expect(row.classList.contains("current")).toBe(true); + expect(row.querySelector("button.voice-use")).toBeNull(); + }); + + it("shows a sign-in prompt when the catalog comes back empty while signed out", async () => { + const deps = makeDeps({ + getVoices: vi.fn(async () => []), + isAuthenticated: vi.fn(() => false), + }); + const { container } = await mount(deps); + const empty = container.querySelector("#voice-catalog .voice-catalog-empty"); + expect(empty).toBeTruthy(); + expect(empty?.getAttribute("data-i18n")).toBe("signInForTTS"); + }); + + it("does NOT tell a signed-in user to sign in when the catalog is empty (fetch failure)", async () => { + const deps = makeDeps({ getVoices: vi.fn(async () => []) }); // authenticated + const { container } = await mount(deps); + const empty = container.querySelector("#voice-catalog .voice-catalog-empty"); + expect(empty).toBeTruthy(); + expect(empty?.getAttribute("data-i18n")).toBe("voicesNoneAvailable"); + }); +}); diff --git a/test/tts/VoiceCuration.spec.ts b/test/tts/VoiceCuration.spec.ts new file mode 100644 index 0000000000..72d20881f2 --- /dev/null +++ b/test/tts/VoiceCuration.spec.ts @@ -0,0 +1,193 @@ +import { describe, it, expect } from "vitest"; +import { + getVoiceTier, + curateShortlist, + CLAUDE_MENU_CAP, + PI_MENU_CAP, +} from "../../src/tts/VoiceCuration"; +import { SpeechSynthesisVoiceRemote } from "../../src/tts/SpeechModel"; + +// Minimal voice factory matching the GET /voices serialization (saypi-api +// tts/models.py Voice): only the fields curation reads, plus required +// SpeechSynthesisVoice members. +function voice( + id: string, + name: string, + poweredBy: string, + credits: number | undefined, + gender?: string +): SpeechSynthesisVoiceRemote { + return { + id, + name, + powered_by: poweredBy, + price: credits !== undefined ? credits / 3333 : (undefined as any), + price_per_thousand_chars_in_usd: credits !== undefined ? credits / 3333 : (undefined as any), + price_per_thousand_chars_in_credits: credits as any, + gender, + lang: "en", + default: false, + localService: false, + voiceURI: `https://api.saypi.ai/voices/${id}`, + } as SpeechSynthesisVoiceRemote; +} + +// The live Claude catalog (10 ElevenLabs voices, server order per +// saypi-api tts/claude.json) — abbreviated to the fields curation reads. +const claudeElevenLabs = [ + voice("c6SfcYrb2t09NHXiT80T", "Jarnathan", "ElevenLabs", 1000, "M"), + voice("gs0tAILXbY5DNrJrsM6F", "Jeff", "ElevenLabs", 1000, "M"), + voice("1SM7GgM6IMuvQlz2BwM3", "Mark", "ElevenLabs", 1000, "M"), + voice("DTKMou8ccj1ZaWGBiotd", "Jamahal", "ElevenLabs", 1000, "M"), + voice("vBKc2FfBKJfcZNyEt1n6", "Finn", "ElevenLabs", 1000, "M"), + voice("aMSt68OGf4xUZAnLpTU8", "Juniper", "ElevenLabs", 1000, "F"), + voice("56AoDkrOh6qfVPDXZ7Pt", "Cassidy", "ElevenLabs", 1000, "F"), + voice("eR40ATw9ArzDf9h3v7t7", "Addison", "ElevenLabs", 1000, "F"), + voice("g6xIsTj2HwM6VR4iXFCw", "Jessica", "ElevenLabs", 1000, "F"), + voice("lcMyyd2HUfFzxdCaC4Ta", "Lucy", "ElevenLabs", 1000, "F"), +]; + +// The 10 OpenAI voices (server order per saypi-api tts/voice.py). +const openAiVoices = [ + voice("alloy", "Alloy", "OpenAI", 50, "F"), + voice("ash", "Ash", "OpenAI", 50, "M"), + voice("ballad", "Ballad", "OpenAI", 50, "M"), + voice("coral", "Coral", "OpenAI", 50, "F"), + voice("echo", "Echo", "OpenAI", 50, "M"), + voice("fable", "Fable", "OpenAI", 50, "Other"), + voice("nova", "Nova", "OpenAI", 50, "F"), + voice("onyx", "Onyx", "OpenAI", 50, "M"), + voice("sage", "Sage", "OpenAI", 50, "F"), + voice("shimmer", "Shimmer", "OpenAI", 50, "F"), +]; + +// The Pi catalog: Paola/Joey (flash) + the pricier Paola v3 variant. +const piElevenLabs = [ + voice("ig1TeITnnNlsJtfHxJlW", "Paola", "ElevenLabs", 1000), + voice("bWJPewAagbymiJXZcxnh", "Joey", "ElevenLabs", 1000), + voice("paola-v3", "Paola", "ElevenLabs", 2000, "F"), +]; + +const claudeFlipDay = [...claudeElevenLabs, ...openAiVoices]; // 20 voices +const piFlipDay = [...piElevenLabs, ...openAiVoices]; // 13 voices + +describe("getVoiceTier", () => { + it("classifies premium-priced voices as hd (ElevenLabs at 1000+ credits/1k)", () => { + expect(getVoiceTier(claudeElevenLabs[0])).toBe("hd"); + expect(getVoiceTier(piElevenLabs[2])).toBe("hd"); // 2000 cr v3 variant + }); + + it("classifies value-priced voices as everyday (OpenAI at 50 credits/1k)", () => { + expect(getVoiceTier(openAiVoices[0])).toBe("everyday"); + }); + + it("falls back to provider when price is missing: ElevenLabs → hd, others → everyday", () => { + expect(getVoiceTier(voice("x", "X", "ElevenLabs", undefined))).toBe("hd"); + expect(getVoiceTier(voice("y", "Y", "OpenAI", undefined))).toBe("everyday"); + expect(getVoiceTier(voice("z", "Z", "60dB", undefined))).toBe("everyday"); + }); + + it("classifies a future cheap provider as everyday by price alone", () => { + expect(getVoiceTier(voice("h1", "Asha", "60dB", 10))).toBe("everyday"); + }); +}); + +describe("curateShortlist", () => { + it("shows the whole catalog with no overflow when it fits the cap (today's Pi set)", () => { + const result = curateShortlist(piElevenLabs, null, PI_MENU_CAP); + expect(result.voices.map((v) => v.id)).toEqual(piElevenLabs.map((v) => v.id)); + expect(result.hiddenCount).toBe(0); + }); + + it("caps the flip-day Claude catalog and reports the overflow", () => { + const result = curateShortlist(claudeFlipDay, null, CLAUDE_MENU_CAP); + expect(result.voices.length).toBe(CLAUDE_MENU_CAP); + expect(result.hiddenCount).toBe(claudeFlipDay.length - CLAUDE_MENU_CAP); + }); + + it("pins the current voice first even when it is not featured", () => { + // Lucy is last in server order and not an HD-featured pick + const result = curateShortlist( + claudeFlipDay, + "lcMyyd2HUfFzxdCaC4Ta", + CLAUDE_MENU_CAP + ); + expect(result.voices[0].name).toBe("Lucy"); + expect(result.voices.length).toBe(CLAUDE_MENU_CAP); + }); + + it("does not duplicate the current voice when it is also a featured pick", () => { + const result = curateShortlist( + claudeFlipDay, + "c6SfcYrb2t09NHXiT80T", // Jarnathan, also the first HD pick + CLAUDE_MENU_CAP + ); + const jarnathans = result.voices.filter((v) => v.name === "Jarnathan"); + expect(jarnathans.length).toBe(1); + expect(result.voices[0].name).toBe("Jarnathan"); + }); + + it("features a gender-diverse pair of HD voices in server order", () => { + const result = curateShortlist(claudeFlipDay, null, CLAUDE_MENU_CAP); + const hdNames = result.voices + .filter((v) => getVoiceTier(v) === "hd") + .map((v) => v.name); + // First male in server order + first female in server order — not Jarnathan+Jeff + expect(hdNames).toEqual(["Jarnathan", "Juniper"]); + }); + + it("features everyday voices by popularity rank (Coral, Nova, Ash, Onyx — never Alloy)", () => { + const result = curateShortlist(claudeFlipDay, null, CLAUDE_MENU_CAP); + const everydayNames = result.voices + .filter((v) => getVoiceTier(v) === "everyday") + .map((v) => v.name); + expect(everydayNames).toEqual(["Coral", "Nova", "Ash", "Onyx"].slice(0, everydayNames.length)); + expect(everydayNames).not.toContain("Alloy"); + }); + + it("fills to the cap in server order when the catalog is single-tier (pre-flip Claude)", () => { + const result = curateShortlist(claudeElevenLabs, null, CLAUDE_MENU_CAP); + expect(result.voices.length).toBe(CLAUDE_MENU_CAP); + // Gender-diverse featured pair first, then server order fill + expect(result.voices[0].name).toBe("Jarnathan"); + expect(result.voices[1].name).toBe("Juniper"); + expect(result.hiddenCount).toBe(claudeElevenLabs.length - CLAUDE_MENU_CAP); + }); + + it("reports tiersCoexist only when both tiers are in the full catalog", () => { + expect(curateShortlist(claudeElevenLabs, null, CLAUDE_MENU_CAP).tiersCoexist).toBe(false); + expect(curateShortlist(claudeFlipDay, null, CLAUDE_MENU_CAP).tiersCoexist).toBe(true); + // even when the everyday rows dominate the shortlist, coexistence is a catalog fact + expect(curateShortlist(piFlipDay, null, PI_MENU_CAP).tiersCoexist).toBe(true); + }); + + it("ranks value voices by id, so a server re-skin of display names keeps the order", () => { + // §7.3 of the plan holds open renaming OpenAI voices; ids are the stable key. + const reskinned = [ + voice("alloy", "Neutral", "OpenAI", 50, "F"), + voice("coral", "Sunny", "OpenAI", 50, "F"), + ]; + const result = curateShortlist(reskinned, null, 2); + expect(result.voices[0].id).toBe("coral"); + }); + + it("tolerates a malformed catalog entry with a missing name without throwing", () => { + const malformed = voice("broken", undefined as unknown as string, "OpenAI", 50); + const result = curateShortlist([...openAiVoices, malformed], null, 4); + expect(result.voices.length).toBe(4); + }); + + it("never features two HD voices with the same display name (Paola flash + Paola v3)", () => { + const result = curateShortlist(piFlipDay, null, PI_MENU_CAP); + const hdNames = result.voices + .filter((v) => getVoiceTier(v) === "hd") + .map((v) => v.name); + expect(hdNames).toEqual(["Paola", "Joey"]); + }); + + it("ignores a current voice id that is not in the catalog (e.g. a Pi built-in)", () => { + const result = curateShortlist(piFlipDay, "voice3", PI_MENU_CAP); + expect(result.voices.length).toBe(PI_MENU_CAP); + expect(result.voices.map((v) => v.id)).not.toContain("voice3"); + }); +});