Mantras in another voice — stock presets now, a coach's consented clone next
Status: SPEC / not built (2026-07-30). Extends the shipped Mantras product
surface (#1695–#1699) so a mantra playlist can render in a voice other than
your own — first a stock preset (shippable now, zero consent surface), then a
coach's or loved one's consented clone (the moat, gated behind consent +
counsel). Grounded in the real voice seams in
jq-voice.ts and the coaching
cohort model in coaching-cohorts.ts.
Inherits the Living Voice roadmap load-bearing wall verbatim: consent v2 (broaden-at-clone-time), the read-only-vs-generative bright line, and cost/dignity guardrails. This doc is where "coach-clone-to- client, DEFERRED behind consent+counsel" from that roadmap gets a concrete plan.
Why the honest framing on /mantras today is a promise, not a lie
The public page says mantras play in your voice now, and a coach's/loved one's is coming — "only ever from a consented voice clone, never a scrape." This spec is how we keep that promise without ever crossing into a scrape or a non-consented render. The whole differentiator is a voice you actually trust; faking one would destroy the exact thing that makes this better than every other affirmation app.
The three voice sources
A mantra playlist renders each line as audio. Today the source is hardcoded to the listener's own clone. We generalize to a voice source with three kinds:
| Source | Function today | Consent surface | Ship |
|---|---|---|---|
own | sayInUserVoice(userId, …) — clone, gated at JQ scope v2 (NoVoiceError/NeedsConsentError) | Already handled (the listener's own v2 consent) | Shipped |
stock:<voiceId> | sayInVoiceId(userId, text, voiceId, …) — any VOICE_BANK preset (season-voices.ts, e.g. Joseph/British Zlb1dXrM653N07WRdFW3) | None — stock voice, not a real person | Phase 1 (now) |
coach:<grantId> | ⚠️ NOT sayInVoiceId raw — a new sayInGrantedVoice(...) | A NEW party's scoped consent — the grant model below | Phase 2 (gated) |
The landmine that shapes the whole design
sayInVoiceId deliberately has no consent gate — its doc comment says so,
because it was built for stock voices ("a British butler"). Point it at a real
coach's elevenlabs_voice_id and you've built a way to render a real person's
voice with zero authorization check. That's the one thing we must not do.
The coach path therefore gets its own gated function; it never reuses the stock
path just because the underlying TTS call looks identical.
Phase 1 — stock preset on mantras (BUILT 2026-07-30, migration 601)
The immediate, safe win: let a listener pick a stock voice for their mantra playlist. Not the coach specifically — but "hear it in a warm narrator / a British butler / a calm woman's voice" instead of only their own. Proves the dispatch seam and ships with zero governance risk. It also unlocks the playlist for users with no clone at all — pick a stock voice and Play all works with no voice setup, no 409.
Shipped: migration 601 adds mantra_voice_source (own|stock) +
mantra_voice_id to downstream_prefs. getMantraPlaylist resolves the stored
pref by default (so glasses honor it with no glasses change) and dispatches per
line: own → sayInUserVoice, stock → sayInVoiceId. The stock path is
whitelist-only — setMantraVoicePref rejects any voiceId not in VOICE_BANK
(isStockVoiceId), so an arbitrary voiceId — i.e. a real person's clone — can
never be stored here. That guard is what keeps the coach/loved-one clone firmly
in the gated Phase 2 grant path below. Endpoints: GET/PUT /api/downstream/mantras/voice. Picker on MantrasPanel.
The original design sketch (kept for reference):
Backend — generalize the playlist renderer:
// getMantraPlaylist(userId, { daily?, voiceSource? })
// voiceSource: { kind: 'own' } | { kind: 'stock', voiceId } | { kind: 'coach', grantId }
async function renderTrack(userId, text, source) {
switch (source.kind) {
case 'own': return sayInUserVoice(userId, text, { featureKey: 'downstream.mantras' })
case 'stock': return sayInVoiceId(userId, text, source.voiceId, { featureKey: 'downstream.mantras' })
case 'coach': return sayInGrantedVoice(userId, text, source.grantId) // Phase 2
}
}
Persist the choice — a downstream_mantra_voice preference (new nullable
column on a small prefs row, or downstream_mantras-adjacent settings row):
{ user_id, source_kind, source_ref }. Default own. Glasses fetchMantras
already hits the same playlist endpoint, so honoring the stored preference makes
"play my mantras" use the chosen voice with no glasses code change.
Frontend — a small picker on MantrasPanel.tsx:
Hear in: [My voice ▾] → own + the VOICE_BANK presets. Same pattern as the
Family Wall "Hear Today" voice picker.
Cost — unchanged: jq_voice_clips is keyed (user_id, voice_id, text_hash),
so a stock-voice line caches per (listener, voice, text) exactly like own-voice
does. No new cost model.
Phase 2 — a coach's consented clone (the moat, gated)
This is the differentiated thing: a client on a coach's roster hears their mantras in that coach's voice, because the coach chose to offer it. Ship the plumbing INERT behind a flag and keep the coach-clone path dark until counsel signs off (per the Living Voice roadmap). Everything below is the design for when that gate opens.
The grant model — a scoped, revocable authorization
The coach's own JQ consent (v2) authorizes JQ speaking as them, to them. It does not authorize a third party hearing their voice. That's a different, narrower, explicit act — a grant:
coach_voice_grants (migration NNN)
id uuid pk
coach_user_id uuid -- whose voice
elevenlabs_voice_id text -- snapshot of the clone id at grant time
scope text -- 'mantras' (start narrow; never a blanket grant)
consent_version int -- the coach re-affirmed consent FOR THIS use
active bool -- revocable at any time
created_at, revoked_at
Rules:
- Narrow scope, not blanket. A grant is
scope='mantras', not "my voice for anything." A coach offering mantra encouragement has not agreed to have their voice read a client's shopping list. - Re-affirmed consent, separately versioned. Publishing a grant requires the
coach to explicitly agree to this use ("clients on my roster may hear my
voice speak their mantras"), recorded as its own
consent_version— the broaden-at-clone-time principle applied to a new listener class. - Revocable, and revocation bites the cache. On
revoked_at, stop serving clips rendered under the grant: either delete the coach-voicedjq_voice_clipsrows for grant listeners, or gate playback on grant liveness. A revoked voice goes silent everywhere, including already-cached lines.
Eligibility — only the coach's real clients
sayInGrantedVoice(listenerUserId, text, grantId) verifies, server-side, before
any render:
- the grant is
activeandscopecovers mantras; listenerUserIdhas an activecoaching_membershipslink togrant.coach_user_id(the same coach↔client edgecoaching-cohorts.tsalready reads —client_user_idunder the DreamPro Coaching org).
No membership → no render. A client who leaves the roster loses the voice.
Read-only, always attributed
- Read-only playback of fixed text. The client's mantra text is theirs; the coach's voice only speaks already-written lines. No generative impersonation, no new sentences in the coach's voice — squarely on the read-only side of the bright line.
- Never disguised. The UI and the glasses always name it: "in Coach John's voice." A client must never think a line is their own recording, and must never believe the coach personally recorded these specific words for them (they didn't — it's TTS in the coach's clone; say so).
Cost & attribution
jq_voice_clips keys (user_id=listener, voice_id=coach's, text_hash) — the
render cost attributes to the listener/client while the voice belongs to the
coach, both legible in one row. Feature key downstream.mantras.coach. Fits the
existing recordGenerationCost/quota model; a coach publishing a grant does not
open an unbounded spend hole (each client's plays cache per line like any other).
Coach-side surface
A "Offer my voice to my clients (mantras only)" toggle in the coach dashboard (Workout Window / DreamPro coaching), behind a consent re-affirmation modal. On → creates the grant. Off → revokes (with the cache consequence above). Shows how many clients have it enabled.
Phase 3 — a loved one's voice (same grant, different relationship)
The identical grant model with a non-coach relationship: a grandparent grants a grandchild, a partner grants a partner. Eligibility swaps the roster check for a Family-Wall / explicit-invite link. This is the Mantras entry point into the broader Living Voice enduring-presence track — and it is doubly counsel-gated wherever a minor or a deceased grantor is involved.
Build order
- Phase 1 now — voice source on the playlist + stock presets + the picker + the stored preference. Real user value ("hear it in a different voice"), zero consent surface, and it lands the dispatch seam every later phase reuses.
- Phase 2 plumbing, inert —
coach_voice_grantsmigration +sayInGrantedVoice(eligibility + scope + liveness) + revocation-invalidates- cache, all behind a flag, dark until counsel signs off. - Phase 2 live — coach publish toggle + client picker entry, on counsel approval.
- Phase 3 — loved-one grants via the Family-Wall link.
Open questions for sign-off
- Revocation mechanism: delete the cached coach-voiced clips on revoke, or gate playback on a live grant check? (Delete = simpler + provably silent; gate = cheaper but a liveness check on every play.) Lean delete.
- Grant scope taxonomy: start
mantras-only. Do we ever wantmantras+nudges, or is each use its own grant? Lean: one grant per scope, no bundles. - Coach churn: when a coach rotates/re-clones their voice,
elevenlabs_voice_idchanges — does the grant follow the coach or pin to the snapshotted id? (Pin = the client keeps hearing the voice they consented to; follow = always current.) Lean pin, re-affirm on rotation. - Counsel scope: confirm whether Phase 1 (stock only) needs any review at all (it shouldn't — no real person's voice) so it can ship independently.