# Voiceclone — Backlog / To-do

Things noted to come back to later.

## ⏰ REMINDERS — come back to these (parked 2026-07-17)
- [ ] **Ear-test the redesigned Broadcast polish.** Rebuild "Engineered" (project 68)
      on **Broadcast level** — should sound like the raw voice at proper loudness,
      zero underwater processing. If clean → it's the safe default for launch.
- [ ] **Ear-test De-noise on the ElevenLabs voice with audible take-joins**
      (e.g. "DK eleven 812pm"). Does it hide the seams without dulling? Report back
      so the strength gets tuned by verdict, not guesswork.
- [ ] **Promo producer parked** — beds/SFX not good enough for clients yet
      (see Promo v3 item: real sample packs are likely the answer).
- [ ] **Launch target moved (was week of 2026-07-20, no new date).** Before launch:
      pick polish default, Stripe payments decision.
      ~~Hide lab links from client nav~~ → DONE 2026-07-23: internal links
      (⚡ Clone Voice shortcut + 🧪/🎚/🗣/📻 labs) now show only for accounts in
      config `app.internal_emails` (currently test@voiceclone.dev +
      dave@davekirwan.com.au) — clients never see them in any environment.

## Quality / polish
- [ ] **Emotion & whisper are too subtle** (raised 2026-07-08). ElevenLabs v3 audio
      tags (`[whispers]`, `[excited]`, `[sad]`, …) work but the effect is weak.
      Ideas to strengthen later:
      - Tune ElevenLabs `voice_settings` per generation (lower `stability`,
        raise `style`) — more expressive but less consistent.
      - Try stronger/edgier tag wording and placement (e.g. `[whispering quietly]`).
      - Compare voices — some ElevenLabs voices emote far more than others.
      - Revisit once we're on a higher tier / newer model.

## Delivery / naturalness
- [x] **Audio tags + punctuation surfaced to the client** (2026-07-10). Added the
      "Shape the delivery" cheatsheet on the editor + a one-click **✨ Perform it…**
      palette (whisper/softly/slowly/sigh/warmly/excited/laugh/trail-off) that writes
      the tag into the line via edit-word. Relabelled the old "Pause after" →
      "Trail off …" (it already injects an ellipsis into the ElevenLabs text).
      Everything typed (`…`, `[tags]`, CAPS, `—`) passes through verbatim (verified).
- [x] **AI Auto-Director** (2026-07-10) — BUILT. `public/auto-direct.php` +
      `src/anthropic.php` send the whole script + preset to Claude (config
      `anthropic.model`, default claude-sonnet-5) and return a directed version of
      each line (tags + `…` + emphasis added, words untouched — verified by a
      word-equality guard that rejects any line where the model changed the words).
      Editor button "✨ Auto-Direct my script" → review modal → apply via edit-word.
      Free (no credits). **Needs `config.anthropic.api_key` set to switch on.**
      TODO later: meter/limit usage (real Claude cost); optional per-use credit
      charge; let the client tweak intensity (subtle ↔ theatrical).
- [x] **Director intensity + wider tags** (2026-07-10). Director has a
      Subtle/Balanced/Theatrical chooser (setup step in the modal, each clearly
      explained) passed as `intensity` to auto-direct. Cheatsheet + director
      vocabulary expanded with breaths/laughs/etc. Working live.
- [x] **Removed the "what the voice receives" preview** (2026-07-10) — it named
      ElevenLabs to clients; user doesn't want the provider exposed. Feature fully
      stripped (toggle, per-line preview, `body` returns). See no-expose-provider rule.
- [x] **Inline line editing (document-style)** (2026-07-10). Each line has an
      "✏️ Edit" button → swaps the rendered `<p>` for a textarea of the raw text so
      the client can type freely (add `…`, CAPS, `[tags]`, delete director tags they
      don't want). Toolbar: "Add …", "CAPS" (uppercase selection), Save/Cancel,
      Ctrl+Enter=save, Esc=cancel. Saves via edit-word (full-line replace); edit-word
      + nuance now return `raw` so the edit-box stays in sync. `.seg__text` carries
      `data-raw`. Root cause the user hit: cheatsheet said "type into a line" but lines
      were never editable. Also click-to-edit: a plain click on the text opens the
      editor with the caret at the click point (caretRangeFromPoint); drag-selecting
      still opens the highlight menu instead. ✏️ Edit button kept for discoverability.
- [x] **Director mood guardrail + preserve client markup** (2026-07-10). (Correction:
      the `[laughs]`-in-meditation was the USER testing manual tags, not a director
      mistake.) Prompt now (a) preserves any tags/…/CAPS the client typed themselves —
      never removes or moves them, and (b) forbids the DIRECTOR from adding laughter/
      playful sounds to calm/meditative/sleep/serious/sad/corporate/luxury content.
      Verified live: user tags kept in place, additions stay mood-appropriate.
- [ ] **Go deeper on delivery** (later): per-line "ending" quick-set; curated tag
      groups per use-case; director intensity could become a saved per-project default.
- [x] **Lead-in/out fades were too heavy** (2026-07-10) — big volume ramps sounded
      like swelling/ducking. Cut to subtle click-guards; naturalness now comes from
      punctuation (`…`) not volume. See `src/presets.php` fade_in/fade_out.

## Audio
- [x] **Rebuild ignored edits + build history** (2026-07-10). Root cause: rebuild
      re-stitched the OLD takes — editing a line never regenerated its audio, so
      rebuilds sounded identical. Now: builds are numbered and all kept
      (`project_builds` table, files `project-{id}-build-{n}.mp3`, UI list newest-first
      with LATEST pill + per-build download `download.php?build=n`). Stale detection:
      `text_marked IS NULL` + selected take = line changed since generated (edit-word
      already nulls it; nuance.php now nulls it too). export.php returns
      `needs_generation {missing, changed, lines[]}`; JS flow is server-driven
      (computeMissing/missingSegs removed); confirm box offers "Build for N cr"
      or "Build with old audio" (`force_stale`). Verified live: BUILD 1→3 on proj 23,
      stale flag message, force-stale path.
- [x] **End-of-clip cut-off** — fixed by the custom player (plays to true `ended`).
- [x] **Clicks at joins + "digital" quality** (raised 2026-07-10) — fixed:
      switched takes to **uncompressed pcm_24000** (v3 native, no MP3 artifacts at
      source), stitch now does a single 192k MP3 encode with 12ms edge-fades per
      line. Old MP3 takes need re-generating to benefit.
- [x] **Pro tier + pcm_44100** (2026-07-13) — user upgraded to ElevenLabs Pro;
      `output_format` bumped to `pcm_44100` and verified live (WAV header = 44100 Hz).
      Takes are now full-bandwidth uncompressed end-to-end until the final 192k MP3.
      Old takes are still 24k — regenerate lines to upgrade them (stitch mixes both fine).
- [ ] Offer a **lossless WAV download** option for final builds (chain is lossless
      up to the MP3 encode now — stitch could emit a WAV alongside).
- [ ] **Per-line ending inflection control** (raised 2026-07-10). v3 doesn't accept
      surrounding-line context (`previous_text`/`next_text` → HTTP 400), so delivery
      varies line to line. Give the client an easy per-line "Line ending" dropdown
      (Statement `.` / Trail off `…` / Question `?` / Continue `,`) that adjusts the
      trailing punctuation sent to ElevenLabs — the main lever for rising vs falling
      inflection on v3. Pairs with the new preset lead-in/out fades.

## Phase 3 — remaining
- [x] **Stitch-to-download** — DONE. `public/export.php` + `src/stitch.php` stitch
      each segment's selected take with its pause (ffmpeg at C:\Users\davte\ffmpeg),
      `public/download.php` serves it named after the project. Falls back to plain
      concat if ffmpeg is unavailable.
- [ ] Word-level pronunciation is single-word only; add multi-word phrase support.
- [x] AI Auto-Director — DONE (see Delivery/naturalness section). Working live.

## Client voice cloning (built 2026-07-10)
- [x] **Client voice cloning — BUILT.** Client reads a range-covering script,
      records (MediaRecorder) or uploads, consents, and we Instant-Voice-Clone it.
      Files: `voice-new.php` (page + read-aloud script), `assets/js/clone.js`
      (record/upload), `clone-create.php` (convert→`/v1/voices/add`→store),
      `clone-delete.php`, `src/clone.php` (ffmpeg convert + guards),
      `elevenlabs_add_voice`/`elevenlabs_delete_voice`. Schema: `voices.owner_client_id`
      + `voice_type='client_clone'` (migration applied + schema.sql). voices.php shows
      "Your voices" (cap 3) + delete; project-new ownership-scoped. Free to clone;
      generation uses CLONE_PRICE_MULTIPLIER (1.5×). No provider name shown to clients.
- [ ] **Cloning needs a fully-permissioned key + cloning-capable plan.** Current
      ElevenLabs key looks TTS-scoped (401 on `/v1/user/subscription`) — the live
      clone call (`/v1/voices/add`) needs a key with **Voices write** permission AND a
      plan with Instant Voice Cloning (Starter+/Pro). Untested end-to-end until then.
- [x] **Project archive → delete** (2026-07-10, needed to free cloned-voice slots).
      Two-step safety: dashboard rows have an "Archive" button (reversible, keeps all
      audio, no confirm) → project moves to an "Archived" section below with "Restore"
      and "✕ Delete forever" (confirm). `project-archive.php` archives/restores
      (restore derives status: completed/in_progress/draft); `project-delete.php`
      REFUSES non-archived projects (?err=notarchived), then cascades
      segments/takes/builds, clears moderation_flags (no FK), audit-logs, and unlinks
      all take/build/final audio files from disk. Dashboard lists ALL projects (12-row
      cap removed). Clone-delete "in use" flash links to the dashboard. NOTE: archived
      projects still reference their voice — a clone frees up only after its projects
      are deleted forever. Verified end-to-end on a throwaway project.
- [x] **Studio-quality recording input** (2026-07-10, user's Sennheiser sounded
      awful). Root cause: browser defaults (echoCancellation/noiseSuppression/
      autoGainControl) mangle good mics + MediaRecorder compressed to webm/opus.
      Now: clone.js captures RAW via WebAudio (all processing off), encodes lossless
      16-bit WAV in-browser; mic picker (enumerateDevices), INPUT GAIN slider
      (GainNode, post-gain metered), live INPUT LEVEL meter (green/yellow/red +
      peak-hold line), clearly labelled. Server converts to mono 44.1k WAV (not mp3)
      and sends lossless to the cloner; `silenceremove` trims leading silence keeping
      0.5s (verified: 3s silence+2s tone → 2.5s). Custom aplayer replaces the browser
      audio element (shows length); Record button no-wrap.
- [x] **Accent drift fix** (2026-07-13, Australian clone read American then drifted).
      Three levers: (1) clone creation now sends `labels` {accent, gender} + a
      `description` to the provider — anchors identity so the model stops guessing
      per line; clearly-labelled Accent (default Australian) + gender selects on
      voice-new (stored in voices.accent/gender). (2) generation with `client_clone`
      voices sends `stability: 1.0` (Robust — closest to reference; v3 accepts only
      0/0.5/1; trade-off: audio tags slightly subtler on clones). (3) guidance now
      says 60–90s ideal, read the script twice. Existing unlabelled clones must be
      deleted + re-cloned to gain the anchor.
- [x] **Professional Voice Cloning** (2026-07-13, Pro tier live) — BUILT. Wizard at
      `voice-pro.php` (+ `assets/js/pvc.js`, endpoint `public/pvc.php`, provider fns in
      elevenlabs.php): create (accent/gender labels + consent) → upload 30min–3h of
      samples (200MB/file; php.ini now 200M/220M — NEEDS SITE RESTART) → speaker
      verification (provider captcha image read aloud, raw-WAV recorder) → train →
      poll `fine_tuning.state` until `fine_tuned` (status action flips is_active=1).
      `voices.provider_state`: pvc_draft→pvc_verified→pvc_training→NULL. Voices page
      shows in-progress cards ("Setup incomplete"/"Training…") + ⭐ Professional clone
      CTA; one PVC in progress per client. Smoke-tested live: provider create + card +
      delete round-trip OK on the Pro key. Untested with real audio: sample upload at
      scale, captcha verify, training completion — user will run the first real one.
      NOTE: provider may not have PVC fully optimised for the v3 model yet — first
      real generation will tell; Robust stability already applies (client_clone).
- [x] **Browser-side 44.1k downsample before upload** (2026-07-13). User's interface
      records at 96/192kHz → huge WAVs kept tripping upload limits (his 1:24 take
      failed INI_SIZE because his site window predates the 200M ini bump). Both
      recorders (clone.js + pvc.js createRecorder) now resample captured PCM to
      44.1kHz via OfflineAudioContext before WAV encode — ~4× smaller, zero audible
      loss (44.1k is the delivery rate anyway), immune to server limits. Verified in
      engine: 192k→44.1k exact frame count, RMS + 440Hz preserved. Mic hint now says
      "saved as lossless 44.1 kHz WAV".
- [x] **Instant-clone script doubled + 7th style** (2026-07-13, user spec). Each
      read-aloud line ~2× longer (~27–35 words each, ~226 words ≈ 90s total) and a
      new "natural storytelling" style added for connected-speech coverage. Recording
      caps raised to match: clone.js MAX_SEC 90→180, CLONE_MAX_SEC 120→180. Hint now
      says one read ≈ 90s = the sweet spot; fluffed lines: pause and re-read the line.
- [x] **PVC audio range: 1 min – 1 hr** (2026-07-13, user spec). Step 2 gates
      "Continue to verification" until ≥60s total; hard 1-hour ceiling disables
      record/upload with a clear message; progress bar full point stays at the
      30-min "ideal". Resume now honest: new `pvc.php?action=samples` lists
      already-uploaded samples from the provider (file_name + duration) and
      repopulates the total on reload. Verified live (draft resumes at step 2,
      "0 sec", gate text shown, cleanup OK).
- [x] **20MB cap fix + PVC browser recording** (2026-07-13). The "too large (max
      20 MB)" error was the app-level CLONE_MAX_BYTES cap — sized for 48kHz WAV, but
      pro interfaces record at 96/192kHz (90s @ 192k ≈ 35MB). Cap raised to 200MB
      everywhere (clone.php const, clone-create messages, clone.js pre-check).
      PVC step 2 now records in-browser too: "● Record a take" (raw WAV, 10-min
      takes, auto-uploads as browser-take-N.wav) alongside file upload; recorder
      refactored into createRecorder() factory shared with the verification step.
- [x] **Clone-faithful rendering + PVC slot recovery** (2026-07-14). (a) v3 is
      unfaithful to instant clones (user's Australian voice rendered British despite
      a verified-good 62s sample + correct labels) → cloned voices now synthesize on
      `eleven_multilingual_v2` (`clone_faithful` flag in generation → tts.php;
      config `elevenlabs.clone_model_id`), [tags] stripped on that path, fidelity
      settings (stability .65 / similarity .9 / speaker boost). (b) Provider REFUSES
      API deletion of professional voices (403) → deleting the local card orphaned
      "DK pro" which held the 1/1 PVC slot → re-adopted as local voice 158
      (pvc_draft); wizard verified resuming at step 2 with the 62s sample.
      elevenlabs_delete_voice now logs non-2xx loudly.
- [ ] PVC deletes: surface "the provider kept the professional voice (support
      ticket needed)" to the client instead of a clean 'deleted' flash; offer
      re-adopt automatically if an orphaned PVC is detected at create time.
- [ ] Cloning polish later: charge credits per clone (currently free);
      show "in use by N projects" before delete; "voice match strength" control
      (Robust vs Expressive) per project; PVC sample management (list/delete at
      provider); manual-verification fallback if captcha keeps failing.

- [x] **EL library voice → roster + drift fix** (2026-07-15). Added "Australian Male
      Voice - Dave" (BxwgGCmcalNbSzNt0b2l) via /v1/voices/add/{owner}/{id}; voices
      row 166. Preview was AMERICAN: sample.php synthesizes via v3 (drift!) — replaced
      cached sample with the library's official recorded preview_url and reclassified
      the voice as actor_clone; generation.php clone_faithful now covers actor_clone
      too (v2 rendering). voices.php roster shows stock + actor_clone (owner NULL).
- [x] **Third delivery dialect: "plain" for faithful clones** (2026-07-15, user hit
      it: [tags] on Dave = normal read). EL client/actor clones render on the
      accent-locked engine which strips [tags], so projects on those voices now get:
      punctuation-only cheatsheet ("directed with pacing and punctuation"), no
      ✨ Perform-it menu, emotion dropdowns disabled (inert there), and the
      Auto-Director prompt forbids all bracketed/parenthetical cues (pacing only).
      $dialect = fish | eleven | plain, computed in project.php + auto-direct.php.
      Verified: Dave project = plain UI; Fish project unchanged.
- [ ] Library-add polish: when adding future EL library voices, auto-download the
      official preview_url at add time (never synthesize previews for clones).

- [x] **Voice isolation lab** (2026-07-15). Internal page /isolate-test.php (header
      "🎚 Isolate"): upload a mix (voice + music/effects, ≤200MB/≤30min) →
      ElevenLabs /v1/audio-isolation (elevenlabs_isolate in src/elevenlabs.php,
      ~1000 chars quota per audio-minute) → A/B players (original vs voice-only)
      + download. Endpoint /isolate-api.php keeps originals + results under
      /media/isolate/. VERIFIED with real API: loud synth pad over speech —
      music-only tail −24.7dB → −49dB (≈24dB kill) with speech intact.
      If it becomes a client feature: charge site credits per minute + queue
      long files.

- [x] **Speaker separation lab** (2026-07-15). Internal /separate-test.php (header
      "🗣 Speakers"): upload interview/podcast → Scribe diarized STT
      (elevenlabs_transcribe_diarized, model scribe_v1, diarize=true, word-level
      speaker_ids) → per-speaker segments (0.6s join-gap, 60ms pad, cap 6 speakers /
      400 cuts) → ffmpeg atrim+concat one MP3 track per speaker + transcript
      snippet so you can tell who's who. Timeline-based (not un-mixing — overlap
      goes to the dominant speaker). Test conversation at /media/separate/test-conv.mp3
      (Dave↔Lily alternating). ⚠ BLOCKED on key scope: the EL key needs the
      **speech_to_text permission** enabled in their dashboard (isolation + TTS +
      voices already work) — error surfaces cleanly until then.

- [x] **Radio promo producer lab** (2026-07-15, user request: dry VO over 90s
      zaps/zings). /promo-test.php (header "📻 Promo"): source = any project build
      (dropdown) OR uploaded dry VO; 3 styles (90s Power Promo / FM Sweeper / Chart
      Show); src/promo.php synthesizes ALL SFX with ffmpeg lavfi (aevalsrc chirp
      zaps/zings, noise boom/whoosh, sine-chord bed with tremolo pump) + VO chain
      (compress, presence EQ, slapback aecho) + bed DUCKED under voice via
      sidechaincompress (key must be apad-ed or it truncates the bed!) + alimiter +
      fade. Local + free (no provider quota). Verified end-to-end: build → 33s
      promo in 2.1s, all 3 styles measure correct (hot intro/ducked bed/fade to
      −60dB).
- [x] **Promo producer: 8 formats + echo control** (2026-07-15, user: 30s read had
      way too much echo). Styles now data-driven in src/promo.php: CHR/Top 40,
      Soft AC, 90s Power Promo, FM Sweeper, Chart Show, Rock Station, Urban/Hip-Hop,
      News/Talk — each with its own bed chord/pump + intro/outro SFX. "Echo on the
      voice" select (No/Light/Medium/Big, default Light, plain-English descriptions
      + hint that long reads want light/none). Echo gains fixed constant (in/out
      0.9) so the VOICE level no longer changes with the echo setting — only the
      repeats do (verified by null-testing renders; ladder ordering correct, level
      differences huge between light and big). All 8×4 combinations render.
- [x] **ROOT CAUSE of "echo the whole way through"** (2026-07-15). The promo
      filtergraph referenced `[outro]` in the final amix but NEVER DEFINED it
      ($outro built, never concatenated into $fc). ffmpeg does **not error** on a
      dangling label — it silently mis-wires the graph, so the RAW un-ducked,
      un-volumed bed leaked into the mix. Symptoms: "Music none" and "Music loud"
      measured identical (440Hz in a pause: −27.3 vs −27.1dB); a silent pause read
      −15.5dB vs the source's −77.3dB. Fix = add `$outro . ';'` to $fc. After:
      pause = −74.6dB (≈ source), bed ladder none/quiet/balanced/loud =
      −91/−48/−44/−41dB. LESSON: every label referenced in the final amix must be
      defined in the graph — verify by dumping $fc, not by trusting exit code 0.
- [x] **Selective echo (send/return)** (2026-07-15, user: echo should be sparing,
      on key words/lines only). Echo is now discrete taps [ms,gain] mixed ON TOP of
      the untouched dry voice — dry level/timing never change. `promo_echo_places()`:
      Ending only (default) / Opener + ending / Whole voiceover. Verified by region
      nulls: ending-only → mid-read −42dB (dry) vs tagline −0.9dB (echo);
      whole-VO mid-read −7.7dB. Earlier concat approach caused timing drift
      (opener mid-read nulled 0.0dB) — replaced.
- [x] **"No echo still echoes" investigated (round 1)** (2026-07-15). Null-tests
      PROVED echo=off is honored (page render vs CLI off = −91dB in voice region,
      bit-identical; noise SFX are unseeded so whole-file nulls mislead). The
      perceived "echo the whole way through" = the BED swelling back after every
      word (sidechain release 350ms → voice-shaped swells). Fixed: release 900ms +
      attack 6 (bed stays down through phrases) and a new "Music volume" select
      (quiet .5 / balanced .8 / loud 1.15 post-duck gain; ladder verified by
      region null-tests). UI: third select on /promo-test.php, JS sends `bed`.
- [x] **Promo engine v2: real drums + arrangement + 3 takes** (2026-07-15, user:
      "too basic, no pumping bed, needs punch, be SUPER CREATIVE"). Beds are now
      RHYTHMIC — synthesized kick/snare/hat/bass via `mod()`+`exp()` envelopes in
      aevalsrc (verified: 90s kick pulses −13dB on-beat → −27dB off-beat every
      0.5s). Per-genre patterns: CHR 128bpm 4-on-floor+claps+stabs, power90s 120bpm
      kick/snare/16th-hats, rock 100bpm heavy, urban 808+trap hats, chart fast,
      news ticker, softac/sweeper padded (no drums). SFX vocabulary per style
      (zap/zing/riser/impact/crash/whoosh/chime/sting/stab) placed THROUGHOUT the
      read by `_promo_arrange()` — opener hit, 2-3 mid-read punctuations (risers
      pre-empt the moment they build into), impact under the last word + ring-out.
      **Each generate renders 3 VARIANTS** with different arrangements (verified
      distinct); API returns `mixes[]`, UI lists TAKE 1/2/3 each with player +
      download. All 8 styles × 3 variants render; live E2E ~6.8s for 3 takes.
- [ ] Promo producer v3: user-uploaded bed/SFX packs (real broadcast imaging
      sounds), client-facing version with credit pricing ("Produce as radio promo"
      button on project builds), maybe per-take "regenerate this one".

## 🎬 Casting Call (launch flagship, built 2026-07-17)
- [x] **The 60-Second Casting Call.** /casting.php (nav "🎬 Casting Call"): paste a
      script → first 250 chars auditioned across 6 diverse roster voices (actor
      clones first, then gender-alternating stock). Protection stack per user spec:
      FREE but 1/day per account (slot claimed pre-render, returned on total
      failure); audible watermark looped over every audition (15s unit:
      chime@0.5 · chime@5.5 · spoken "Audition." @10.5 — asset
      public/assets/audio/wm-loop.mp3, mixed at 0.4); lo-fi render 22.05kHz mono
      48kbps; files in private/auditions/ OUTSIDE web root, streamed via
      audition-audio.php (session + ownership check, Range/206, no download
      disposition, anonymous = 401). Star-to-shortlist board (starred float up);
      "Use this voice →" → project-new.php?cast=ID prefills the FULL script.
      Tables: casting_calls (script + results JSON; reload restores today's board).
      VERIFIED on the user's own real casting call (6 voices, watermark measured
      in-file, limit enforced, anon blocked).
- [ ] **Casting Call v2 — PRIORITY (user, 2026-07-17): extra casting calls for
      credits.** After the free daily call, let clients run more by spending
      credits — user wants this designed/built "in the next few days".
- [ ] Casting Call v2 (rest): shareable read-only board link for the client's
      client (agency approval flow); audition credits roll into the project as a
      discount; pick-your-panel (choose which 6 voices); per-voice "regenerate".
- [x] Casting Call filters + watermark polish (2026-07-17): gender (mixed panel /
      female-only / male-only) + language selects (roster-driven, lang_info
      labels; filters checked BEFORE the daily slot is claimed); watermark loop
      rebuilt — clean first ~2.5s, then strictly ONE mark every 5s
      (chime@2.5 · chime@7.5 · spoken@12.5, verified −91dB between marks);
      unmissable amber disclosure box on the form + note on the board.

## Broadcast quality (raised 2026-07-15: output not broadcast-clean; audible take joins)
- [x] **Broadcast polish (mastering stage on the build)** (2026-07-15). Root cause of
      "you can hear the sentences join": each line is a separate AI take with its own
      faint baked-in room tone; stitched, that ambience blinks on/off at each boundary.
      Fix = master the WHOLE assembled program in one pass (not per-take) in src/stitch.php
      after concat: highpass → afftdn denoise (kills varying room tone → uniform floor →
      joins vanish) → deesser → gentle acompressor → loudnorm I=-16 LUFS (EBU R128) → TP
      limit. Strength select Off/Light/Standard/Strong on the build bar (default Standard);
      `polish` param through export.php. Safe on clean voices (denoise only acts on noise
      present; loudnorm+comp is universal). VERIFIED: 4 levels render 0.7–1.4s in the real
      build path; loudness off=-13.9 vs light/standard/strong = -16.2/-16.4/-16.3 LUFS;
      speech intact. Denoise sweet-spot is an EAR call → that's what the control is for.
- [x] **Broadcast polish REDESIGN — was over-processed** (2026-07-17). User: Standard
      "sounds TERRIBLE" on a clean Fish clone. Root cause: I stacked aggressive afftdn
      (nr=15) + heavy acompressor + deesser on already-clean AI voices → "underwater"/
      dull (old chain dropped 7kHz HF to -38dB via the deesser). LESSON: AI/clone voices
      are already clean+full-band — they need LOUDNESS, not scrubbing. New levels:
      Off / **Broadcast level (default)** = loudnorm-only, transparent, -16 LUFS /
      Level+polish = + gentle glue comp (ratio 2, soft knee) / De-noise = opt-in gentle
      afftdn nr=9 for genuinely noisy voices only. Verified: HF preserved (level=-33.7 vs
      raw -35.0, i.e. not dulled), renders 0.8-1.5s. Default key changed to 'level' in
      export.php + project.php + editor.js. User to re-test by ear.
- [ ] Broadcast polish v2: per-take loudness MATCH before stitch (kills line-to-line level
      steps at joins — the other half); optional arnndn/RNNoise model for higher-quality
      denoise (needs a .rnnn model file); expose LUFS target (-16 stream vs -23 broadcast).

## Provider strategy (raised 2026-07-14: need 100+ clone slots, all languages)
- [x] **Fish Audio test lab** — internal bake-off page at `/fish-test.php` (not in
      any nav): record (studio mode → 44.1k WAV) or upload a sample → instant Fish
      clone (`POST /model`, train_mode fast, private) → generate takes (`POST
      /v1/tts`, engine header s1/s2-pro/s2.1-pro, wav 44.1k) stacked with latency +
      size for side-by-side listening; refresh/delete voices. `src/fishaudio.php` +
      `public/fish-api.php` + `assets/js/fish-test.js`; key in config `fish.api_key`
      (blank → setup card). Slot economics: ElevenLabs Pro/Scale/Business = 1/3/10
      PVCs (100 ⇒ Enterprise); Resemble Flex ≈ $5/mo per pro clone; Fish = unlimited
      instant slots on $75/mo Pro, 15 pro clones on Max. ElevenLabs leads languages
      (70+ on v3) — "all languages" cuts against leaving entirely.
- [x] **Fish won the listening test → integrated into the main site** (2026-07-14).
      `FishTtsProvider` in tts.php (emotions → S1 markers: whispering/excited/sad/
      confident/delighted; [tags] stripped; prosody.speed from preset; engine via
      config `fish.engine` = s1). Generation routes PER VOICE (`v.provider` — fish →
      Fish, else config default; stock roster stays ElevenLabs). NEW instant clones
      go to Fish via config `tts.clone_provider = 'fish'` (clone-create branches;
      description carries accent/gender). clone-delete branches per provider.
      Instant-clone cap now excludes the in-progress PVC. Existing Fish model
      "Dave AU test" adopted as voice 160, usable immediately. Live-verified:
      whisper marker + 0.8 speed → 44.1k WAV.
- [x] **Delivery dialect per provider** (2026-07-14, user spotted the cheatsheet
      still taught [square tags] on a Fish project). Everything now switches on the
      project voice's provider: cheatsheet (Fish version teaches (whispering)/(soft
      tone)/(sighing)/(laughing)/(serious)… from the official S1 marker list, with
      "use (round brackets) with this voice"); ✨ Perform-it palette inserts (markers)
      for Fish ([tags] for EL; fish swaps slow-down→serious since S1 has no pace
      marker); Auto-Director prompt has a Fish variant (parenthetical vocabulary,
      "never square brackets"); _director_words strips (markers) too (verified);
      markDirection highlights Fish markers. FishTtsProvider emotion map corrected
      to official markers: serious→(serious), warm_smile→(comforting).
      Verified live: project 50 (Fish) shows round-bracket UI; project 23 (EL)
      unchanged. window.VOICE_PROVIDER injected by project.php.
- [x] **Fish upload TLS failures fixed** (2026-07-15). "bad record mac" on clone
      uploads: this network kills TLS uploads between 6–9MB (https-inspection AV;
      HTTP/1.1 + TLS1.2 caps + retries all in place and insufficient — measured
      with probes). Fix = `fish_prepare_upload()` in src/fishaudio.php: every Fish
      upload >5.8MB or >265s is transcoded to mono MP3 (bitrate 96–192k picked to
      fit budget) and trimmed to 265s — Fish hard-rejects references over 270s
      ("Total duration of audios is too long... less than 270 seconds").
      Verified: a 40MB WAV that failed 100% now clones in ~60s end-to-end.
      NB: "record/upload MORE for Fish" advice is wrong beyond 4.5 min — fast
      clones use ≤270s; extra audio is trimmed (first 265s kept).
- [ ] Fish polish: tune the emotion→marker map by ear; decide DK pro (ElevenLabs
      PVC, still training) role vs Fish clones; consider moving stock roster if Fish
      keeps winning; Resemble adapter still available for a third opinion.

## Later phases (from BUILD-PLAN.md)
- [ ] Phase 2 — Stripe checkout + credit purchase (currently credits are granted by admin).
- [ ] Phase 4 — actor clones, onboarding, payouts.
- [ ] Password reset (email).
