Skip to content

Consume the /voices manifest's unused properties: availability, sibling_id downshift, chars_per_minute #568

Description

@rosscado

Why now

Between 2026-07-24 21:05 UTC and 2026-07-27 08:45 UTC the ElevenLabs subscription was past_due and every TTS call returned 401. For 36 hours the API kept serving Paola — an ElevenLabs voice — as recommended and as half the featured HD pair, so every new user was handed a voice that could not speak.

saypi-api #321 (merged + deployed) fixes the steering half server-side: /voices now carries a live availability per voice, and a hard-down provider is excluded from featured/recommended. Because PreferenceModule.maybeAdoptDefaultVoice (src/prefs/PreferenceModule.ts:917) already adopts the server's recommended voice, that path is fixed with no client change — a fresh install during an outage now lands on a working voice automatically.

What the server cannot fix is the user who already has a dead voice saved. Their stored id still resolves, /speak still routes to the dead provider, and they get an error with no path out. Rescuing them needs the client, because server-side substitution at synthesis time is the cross-provider failover the API deliberately rejects (a voice must not switch mid-stream, mid-conversation).

sibling_id was designed for exactly this and has been shipped and unused since #293.

Current state

SpeechModel.ts:283-292 is candid about it: "featured/section/deprecated are consumed today; recommended, sibling_id, language and chars_per_minute are preserved for later phases." That's now partly out of date — recommended and sample_url are both consumed. Verified across src/:

field refs outside the type status
recommended 14 consumed (default adoption)
sample_url 4 consumed (▶ preview)
sibling_id 0 declared, unused
chars_per_minute 0 declared, unused
availability 0 not yet in the type (new in #321)

Proposed work

1. availability — add to the type and act on it. Values: "available" | "degraded" | "unavailable", absent/null when unknown. Suggested treatment:

  • unavailable — don't offer it in the menu, but keep rendering it if it's the user's current selection (the grandfathering invariant already in VoiceCuration.visibleCatalog). Silently vanishing the voice someone is using is worse than showing it with a note.
  • degraded — no menu change; at most a subtle affordance. The server deliberately does not de-feature these, so the client shouldn't either.
  • absent/null — treat exactly as today. The server sends null whenever health is unknown, on purpose: never infer trouble from silence.

2. sibling_id — the downshift rescue. When the current voice is unavailable and has a sibling_id, offer the sibling. Worth deciding explicitly: automatic switch with a toast, or a prompt? Automatic gets the user talking again immediately; a prompt respects that voice choice is personal. My weak preference is a prompt on first occurrence, remembered thereafter — but this is a UX call, not a technical one.

3. chars_per_minute — absolute duration figures. Lowest priority, unblocks "≈N min of speech" instead of relative framing. Note the server still sends null (unmeasured), so it stays a no-op until that data lands.

Two pre-existing mismatches worth folding in

Both found while tracing the above; they cause silent divergence between what the server curates and what users see.

  • Menu cap. CLAUDE_MENU_CAP/PI_MENU_CAP are 4 (VoiceCuration.ts:28-29) while the server caps at pi=5 / claude=6. The server marks 5 voices featured for Pi and the client renders 4, so the 5th is never visible — today that's Marin. Either the client cap rises or the server's should drop; they shouldn't disagree silently.
  • EVERYDAY_RANK drift. The client's fallback list (VoiceCuration.ts:45) still lacks cedar and marin, added server-side in saypi-api test(layer4): detect stale dev builds (build-identity stamp + stalled-hot-reload warning) #312. Only affects the manifest-blind fallback path, so it's latent rather than broken — but it's the path that runs if the server ever stops sending featured.

Testing

The scenario to pin: catalog where the user's stored voice is unavailable and has a sibling_id → menu offers the sibling, stored voice still rendered, no empty menu. Plus the fail-open case — every voice availability: null must produce today's exact behaviour.

Filed from saypi-api after shipping #321. Server-side design doc: docs/superpowers/specs/2026-07-27-voices-availability-design.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions