# persian memory

Notes on Persian-TTS quality, voice selection, infrastructure quirks. Append-only.

## ⚠️ Persian TTS: the MODEL matters, not the voice (2026-06-23)

Root cause of "sounds Arabic/Indian": **`eleven_multilingual_v2` does NOT support Persian (fa)** — its language set includes Arabic/Hindi/Tamil but not Persian, so it approximates Persian with those phonemes. **`eleven_v3` supports Persian** and is the only model that sounds correct (Cameron confirmed by ear via A/B samples across mlv2/v3/turbo_v2_5/flash_v2_5 — only v3 passed). App now defaults to `eleven_v3` (`ELEVENLABS_MODEL` env-overridable in app.py).

Second gotcha: **none of the ElevenLabs library "Persian" voices are actually Persian.** Nazy, Shahram, Amir, IMan, Noushin are all `language: en` voices cloned from Persian-*accented English* speakers; Faris is an Arabic (`ar`) voice. None are verified in `fa`. So the voice only contributes timbre — Persian correctness comes from the v3 model. (ROLE.md's claim that these are "Persian-native voices" was wrong.) If v3+Nazy ever isn't native enough, the true-native fallback is Azure `fa-IR` neural (Dilara/Farid).

API key note: this key lacks `models_read` permission (can't GET /v1/models) and the subscription endpoint returns nulls — but TTS + voices endpoints work fine.

## TTS voices (ElevenLabs, current model = eleven_v3)

**Current voice: Nazy Entezari (`WwAjIyMBDBNl1dvId9Xe`)** — Persian-native female, set 2026-06-23 after Cameron upgraded to Starter.

George (`JBFqnCBsd6RMkjVDRZzb`) — free tier, English-trained, ≈70% native Persian (American accent leaks through). Was the default 2026-06-22 → 06-23; swapped out because the accent read as non-Persian to Cameron.

Better voices unlocked at Starter ($5/mo):
- `WwAjIyMBDBNl1dvId9Xe` — **Nazy Entezari** (warm, soothing female) — recommended default
- `rNb3hdSf0n4ROIbYC8Bl` — Shahram (documentary narrator male)
- `RL04FaPrUG6vS8aWd9NZ` — Faris (pleasant + reassuring male)
- `PleK417YVMP2SUWm8Btb` — Amir (breathy + calm male)
- `NZiuR1C6kVMSWHG27sIM` — Noushin (cheeky female)
- `3AA408tBxTzz5dPx3TsR` — IMan (Iranian male)

Voice swap = edit `ELEVENLABS_VOICE_ID` in `.env`, delete `static/audio/*.mp3` to drop old cache, restart app.

## Bugs found

**Cached-audio 404 on public site (2026-06-22)** — `elevenlabs_tts()` returned `/static/audio/<hash>.mp3` (no `/persian` prefix) on cache **hit**, but the right prefixed URL on cache **miss**. Through the proxy the prefix-less URL → `omidlab.net/static/...` → 404. Worked locally because Flask serves `/static` directly on :5001, masking it. Fixed at `app.py:77`.

## Free tier blocks library voices (2026-06-23)

Confirmed via API: a free ElevenLabs account can use **premade** voices (George) but NOT **shared-library** voices (Nazy etc.) for synthesis — `POST /v1/text-to-speech/<library_voice>` returns **402 `paid_plan_required`** ("Free users cannot use library voices via the API"). Note `GET /v1/voices/<id>` metadata still succeeds for Nazy, which is misleading — only synthesis is gated. So the Persian-native voice genuinely requires the $5/mo Starter upgrade; no free workaround inside ElevenLabs. Free alternative for native Persian = Azure `fa-IR` neural (Dilara/Farid), but weaker bilingual handling + separate key.

## Lessons feature (2026-06-23)

- Curated specs in `lessons/specs.json` (id/title/level/summary/prompt). Content authored offline by Claude via `gen_lessons.py` → cached per-lesson at `lessons/data/<id>.json` (schema `{items:[{persian,translit,english,note}], dialogue:[...]}`). App reads through `lessons.py` (`load_index`/`load_lesson`); it never authors at request time.
- Regenerate: `set -a; source .env; set +a; ./.venv/bin/python gen_lessons.py [--force] [ids...]`. Model via `LESSON_MODEL` env (default `claude-sonnet-4-6`). Sonnet's Persian/Finglish quality here is genuinely good (taarof notes were sharp).
- Lesson audio uses a generic `POST /persian/api/tts {text}` endpoint, generated **on demand** per phrase (not pre-baked) — so after the voice swap + cache clear, lesson audio regenerates in the new voice automatically.
- UI is a separate page (`/persian/lessons`, `lessons.html`/`lessons.js`) with hash-routing (`#<id>`) so refresh/back work; chat and lessons cross-link via header nav. Kept off the chat SPA to respect one-responsibility-per-file.

## Bug: lessons TTS read "percent ..." aloud (2026-06-23)

Lesson play buttons stored Persian in `data-text="${encodeURIComponent(...)}"` but `playPhrase` sent `btn.dataset.text` to `/api/tts` **without decoding** → ElevenLabs received `%D8%B3...` and read the `%` escapes literally ("percent D8 percent..."). Voice (Nazy) was correct; text was garbage. Fix: `decodeURIComponent(btn.dataset.text)` in `playPhrase` (lessons.js). Chat tab was never affected — it builds audio from raw text server-side. Lesson: when round-tripping text through an HTML attribute for later API use, decode at the read site (or just `escapeHtml` the attribute and store raw).

## Host context

Running on omidhub VPS (Hetzner CCX33), not lab. 7.6GB RAM, 0 swap (manager added swap 2026-06-22). Memory pressure is real — keep the Flask app lean, no debug reloader in prod.

## Coordination

- Reverse-proxy path in serve.py: look for the "Persian Tutor" block around line 60 of `/data/cameron/para/.agents/reports/serve.py`
- Don't touch `serve.py` beyond that block — it serves the whole omidlab.net surface
- **sufi_lectures** (new agent, forked from life_manager 2026-07-06) — the producer for Khanega integration. Owns the source material: downloads lectures, runs Persian Whisper ASR, writes transcripts to `/data/cameron/life/projects/khanega/transcripts/`. Pings `agents/persian/inbox.md` per finished batch (path + rough SRS-worthy phrase count). I own the active-learning side (vocab, SRS, live tutor) and consume batches at my own pace. Request specific lectures via `agents/sufi_lectures/inbox.md`. Hard boundary: I don't edit `agents/sufi_lectures/*` or `khanega/*`; it doesn't edit `agents/persian/*` or `persian/*`.
