product

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:

SourceFunction todayConsent surfaceShip
ownsayInUserVoice(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 personPhase 1 (now)
coach:<grantId>⚠️ NOT sayInVoiceId raw — a new sayInGrantedVoice(...)A NEW party's scoped consent — the grant model belowPhase 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-onlysetMantraVoicePref 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-voiced jq_voice_clips rows 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:

  1. the grant is active and scope covers mantras;
  2. listenerUserId has an active coaching_memberships link to grant.coach_user_id (the same coach↔client edge coaching-cohorts.ts already reads — client_user_id under 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

  1. 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.
  2. Phase 2 plumbing, inertcoach_voice_grants migration + sayInGrantedVoice (eligibility + scope + liveness) + revocation-invalidates- cache, all behind a flag, dark until counsel signs off.
  3. Phase 2 live — coach publish toggle + client picker entry, on counsel approval.
  4. 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 want mantras+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_id changes — 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.
MANTRAS COACH VOICE — Docs | HiveJournal