🎙️ HANDOFF — der M4-Renderer (Sprach-Stems)

Was der M4 macht: aus einem Episoden-Skript geklonte Sprach-Stems erzeugen —
offline, lokal, ohne Netz, ohne Key. Das ist der einzige Schritt der Pipeline, der
Apple Silicon braucht (mlx-audio / OmniVoice). Alles danach läuft auf crumbdesktop (Linux).

Was der M4 NICHT macht: normalisieren, Betten synthetisieren, mastern, der systemd-Timer.
Das ist crumcast_autorender.sh auf dem Linux-Host (siehe AUTORENDER.md).


🧭 Die Pipeline auf einen Blick — wo der M4 sitzt

  ✍️  episode_<id>.md  (Skript, ausformuliert)          ← Mensch/Claude, im Repo
        │  ⟶ von Hand nach script.json extrahieren  (der fehlende Schritt, s.u.)
        ▼
  📄  raw_stems/episode_<id>[_lang]/script.json         ← Eingabe für den M4
        │
   ┌────┴─────────────────────────────────────────┐
   │  🍎  M4:  tts_stems.py  (mlx-audio/OmniVoice) │   ← DU BIST HIER
   │      script.json + Referenz-WAVs              │
   │        ⟶  raw_stems/episode_<id>/*.wav        │   (geklonte Sprach-Stems)
   └────┬─────────────────────────────────────────┘
        │  git push  →  Linux-Host zieht
        ▼
  🐧  crumbdesktop:  crumcast_autorender.sh (systemd-Timer, alle 10 min)
        normalize_stems.sh  →  render_episode.py
        ▼
  🎧  episodes/episode_<id>[_lang]_master.wav

🍎 Voraussetzungen auf dem M4

  • Apple Silicon (M4), Python-venv ($CRUMCAST_PY, z.B. ~/workspace/crumcast/venv/bin/python3).
  • mlx-audio installiert (OmniVoice TTS und STT). Ref: github.com/Blaizzy/mlx-audio.
  • ffmpeg (für spätere Schritte; der reine TTS braucht ihn nicht zwingend).
  • Env (Defaults in tts_stems.py):
  • MLX_TTS_MODEL — Default mlx-community/OmniVoice-bfloat16.
  • MIN_REF_SECONDS — Default 3.0 (kürzere Referenz → Warnung, kein Abbruch).
  • Zero-shot Cloning, kein Training. Die eingesprochenen Referenz-Takes sind die Stimme.
  • OmniVoice braucht zur Referenz auch den Referenz-Text: tts_stems.py transkribiert die
    Referenz-WAV automatisch per mlx-audio-STT (gecached). Also muss auch das STT-Modell da sein.

📄 Die Eingabe: script.json (Vertrag)

Ein Manifest pro Episode und Sprache, in raw_stems/episode_<id>[_lang]/script.json.
Beispiel (echt, S00-Zyklus — als Formatvorlage): raw_stems/episode_02/script.json.

{
  "prefix": "ep6",              // Datei-Prefix der Stems
  "lang": "",                   // "" | "_en" | "_fr"  → Suffix am Dateinamen
  "voices": {                   // Charakter → Referenz (Ordner ⟶ 1. Clip, oder Einzel-WAV)
    "erzaehler": "speaker_roles_stem/SE00EP00/erzaehler",
    "deepbit":   "speaker_roles_stem/SE00EP00/deepbit",
    "kruemel":   "speaker_roles_stem/…/kruemel",     // ⚠️ existiert noch nicht — s. Blocker 2
    "eule":      "speaker_roles_stem/SE00EP00/eule"
  },
  "lines": [
    {"seq": 1, "character": "erzaehler", "slug": "grund-ohne-wald", "text": "…"},
    {"seq": 2, "character": "kruemel",   "slug": "ist-hier-wer",    "text": "…"}
  ]
}
  • Dateiname je Zeile: {prefix}_{seq:02d}_{character}_{slug}{lang}.wav
    → z.B. ep6_02_kruemel_ist-hier-wer.wav
  • Reihenfolge = seq. Der Erzähler-Text und jede Figurenzeile werden je zu einem Stem.
  • Preflight / fail-fast: vor dem ersten Synth prüft tts_stems.py ALLE Referenzen
    (da? lesbar? lang genug?). Fehlt eine, bricht ein echter Lauf ab, bevor etwas erzeugt wird.

▶️ Lauf-Kommandos (auf dem M4)

# 0) Verdrahtung testen OHNE mlx-audio — erzeugt stille Platzhalter-WAVs
python3 tts_stems.py raw_stems/episode_06 --dry-run

# 1) Echt synthetisieren (script.json im Ordner)
python3 tts_stems.py raw_stems/episode_06

# explizites Manifest / alles neu / anderes Modell
python3 tts_stems.py raw_stems/episode_06 mein.json
python3 tts_stems.py raw_stems/episode_06 --force
MLX_TTS_MODEL=… python3 tts_stems.py raw_stems/episode_06

Danach git add raw_stems/episode_06 && git commit && git push — der Linux-Timer zieht und
rendert beim nächsten Lauf von selbst. (Oder auf dem Host einmal bash crumcast_autorender.sh.)


⚠️ Zwei Blocker, bevor S02 (ep06–10) durch den M4 kann

Blocker 1 — die .md-Skripte sind noch kein script.json

Die ausformulierten S02-Folgen liegen als episodes/episode_06…10_*.md (Regie-Skript mit
Timecodes, Sound-Cues, Erzähler + Dialog). Der M4 braucht aber raw_stems/episode_06…10/script.json.
Zu tun: je Folge die gesprochenen Zeilen (Erzähler + Figuren) aus dem .md in ein script.json
übertragen — Regieanweisungen und *(Musik: …)*-Zeilen weglassen, nur Text. seq in Reihenfolge,
slug aus dem Szenen-/Sinn-Kürzel. Achtung Kollision: raw_stems/episode_02 ist der ALTE
Wald-Zyklus (ep2 „Der Schlüssel"), nicht das neue Nullfeld. Die neuen Folgen sind ep06–10 und
haben noch gar keine raw_stems/-Ordner.

Charakter→Referenz-Mapping für S02 (die Rollen kommen alle aus SE00EP00/, bis auf Krümel):

Charakter Referenz S02-Folgen
erzaehler speaker_roles_stem/SE00EP00/erzaehler alle
deepbit speaker_roles_stem/SE00EP00/deepbit ep06, ep10
bugsy speaker_roles_stem/SE00EP00/bugsy ep07, ep09, ep10
eule speaker_roles_stem/SE00EP00/eule ep07, ep09, ep10
funkfox (=Vektor) speaker_roles_stem/SE00EP00/funkfox (oder /vektor) ep08, ep09, ep10
kruemel — fehlt — alle ⚠️

Blocker 2 — die Stimme „Krümel" existiert noch nicht

Der Krümel ist in S02 zum ersten Mal eine eigene Sprechrolle (in S00 sprach er nicht selbst).
Es gibt keine Referenz-Aufnahme. Zu tun: einen Krümel-Take einsprechen (≥ MIN_REF_SECONDS,
gern 2–3 kurze Emotionen: neugierig, ängstlich, staunend) und unter
speaker_roles_stem/SE00EP00/kruemel/ ablegen — dann greift das Mapping oben.
Bis dahin: --dry-run erzeugt stille Platzhalter, damit man Naming/Timeline schon testen kann.


🐧 Was danach auf dem Linux-Host noch offen ist (nicht M4, aber wissenswert)

  • render_episode.py hat pro Episode eine handgebaute Timeline (build_timeline,
    aktuell für ep01: Liste aus (dauer_s, voice_stem, bed_fn, bed_gain)). Für ep06–10 muss diese
    Timeline neu geschrieben werden — mit den Stem-Namen aus dem script.json und den passenden
    Betten. Die Betten sind prozedurale Synth-Funktionen (bed_moos, bed_atmo05,
    bed_funkfox_code/score …), nicht direkt die Strudel-Snippets.
  • Neue Betten für S02: nullfeld_pulse und sternen_chime gibt es als
    crumbstrudel/snippets/*.strudel, aber noch nicht als bed_*-Funktion im Renderer.
    Zwei Wege: (a) als bed_*-Funktion nachbauen (wie bed_atmo05), oder (b) das Strudel-Snippet
    zu einer WAV rendern und per bed_from_wav(path) einbinden.
  • Mehrsprachig: je Sprache ein eigenes script.json (_en/_fr) und ein eigener Render mit
    passendem suffix — genau wie raw_stems/episode_02_en|fr/ es vormachen.

✅ Checkliste „S02 renderbar machen"

  • [ ] Krümel-Referenz einsprechen → speaker_roles_stem/SE00EP00/kruemel/
  • [ ] raw_stems/episode_06…10/script.json aus den .md-Skripten extrahieren (Mapping s.o.)
  • [ ] M4: tts_stems.py … --dry-run (Verdrahtung), dann echt; push
  • [ ] Host: build_timeline für ep06–10 + Betten nullfeld_pulse/sternen_chime
  • [ ] Host: crumcast_autorender.shepisodes/episode_06…10_master.wav
  • [ ] EN/FR: eigene script.json je Sprache, Render mit Suffix