src/
Deep system diagram. The top-level README.md has the user-facing intro; this is the architecture dive — read it when you need to know "if I touch X, what else moves?"
The two binaries
pipeline generate
│ │
├── ingest ├── concept-only mode → autorater → critique → loop
├── extract (×10) ├── full-sketch mode → produce_audio → audio_eval → critique
├── merge ├── meta mode → run_meta (concept→episode→audio→eval, N iters)
├── analytics └── audio mode → produce_audio on an existing episode
└── calibrate
Both pipeline and generate go through client.DoubleActsClient for every model call. That's the only module that talks to google-genai. Everything else is built on top.
Layered dependency map
schemas.py (Pydantic v2 — single source of truth for every model)
│
▼
paths.py (every filesystem location; honors $DOUBLE_ACTS_DATA_ROOT)
│
▼
client.py (only place google-genai is touched; retry+budget+call-log)
╱ │ │ ╲
╱ │ │ ╲
▼ │ │ ▼
pricing.py │ │ manifest.py (corpus mp3 → sha256/uri/duration)
│ │
┌──────────────────┼──┼──────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
INGEST/EXTRACT AUTORATER STACK AUDIO STACK
(pipeline only) (text evaluation) (generate only)
Layer A — Ingest & extract (the corpus side)
ingest.py ──▶ download mp3 + sha256 + duration
▼
manifest.py ──▶ manifest.json (the corpus index — sha, uri, duration per ep)
▼
extract.py ──▶ 10 extractors run in parallel via google-genai Files API
each writes RAW_EXTRACTIONS_DIR/<ep>/<extractor>.json
▼
merge.py ──▶ reconcile 10 JSONs into one canonical Episode → EPISODES_DIR/<ep>.json
▼
analytics.py ──▶ cross-corpus stats + patterns report
calibrate.py ──▶ corpus vs. contrast separation; writes calibration_report.md
▼
pipeline.py ──▶ the runner — `pipeline --skip_ingest --skip_extract` etc.
Layer B — Generate (the output side)
generate.py ──▶ concept generation, full sketch, scene-tree search, scene-edit-mode
functions: generate_concept, generate_full, generate_full_by_scene_tree,
regenerate_scene, concept_to_episode
modes: iterate, meta, full, audio, iterate_then_audio
flags: --target_duration_min, --spec_decode, --draft_model,
--streaming_tts, --tree_branches, --early_exit_threshold
uses: corpus_exemplar (semantic exemplar lookup)
▼
iterate.py ──▶ the iter loop — concept hill-climb (`run`) + full pipeline (`run_meta`)
writes: <run>/<NN>/concept.json, episode.json, record.json, run_manifest.json
knows about scene-edit-mode, crossbreed, listener panel feedback
prints recommended next invocation (self-pacing)
helper: _synthesize_full_audio (dialogue + announcer + music + foley + mix)
Layer C — Text evaluation (the autorater stack)
autorater.py ──▶ 28 lint rules + 11-axis judge — composite text score
entry: score(client, episode, skip_judge=False)
personas.py the panel personas (writers, producers, listener types)
critique.py basic + segment + diff critique + durable lessons.md addendum
adversarial_critic.py red/green/refactor — attacker → defender → patch
cross_model_judge.py Claude Sonnet 4.6 same-axis ensemble (the second opinion)
calibrate.py drift detector — corpus−contrast separation tripwire
benchmark.py N=20 fixed-seed benchmark + corpus audio benchmark
report_card.py aggregates EVERYTHING for one iteration into a markdown report
Layer D — Audio (the production stack)
produce_audio.py ──▶ Gemini 3.1 Flash TTS multi-speaker (top-2 voices/chunk, ≤20 lines/chunk,
24 kHz mono PCM concat with 10 ms crossfades + 150 ms internal-silence
compression, gender + timbre voice routing, sticky per-chunk assignments,
per-speaker style cue + conversation framing, optional streaming path,
optional voice fingerprint chain across chunks)
entry: synthesize_episode(client, episode, out_dir, ...)
synthesize_announcer(...)
rerender_chunks(...) — surgical re-render around audio anti-pattern timestamps
produce_music.py ──▶ Lyria realtime stream → 48 kHz stereo → downsample to 24 kHz mono
elevenlabs.py ──▶ per-line ElevenLabs SFX (replaces the old foley library)
mix.py ──▶ dialogue + announcer + music + sfx → final_mixed.wav
per-track gains, leading silence for opening music intros,
music sidechain ducking during detected speech (-10 dB, 80 ms ramps)
Layer E — Audio evaluation (audio_eval/)
audio_eval/__init__.py umbrella: AudioEvaluationReport with per-axis sub-reports
├── audio_judge.py 8-axis multimodal judge — uploads wav, asks Gemini to score
│ also: per-chunk evaluator, persona panel, pairwise comparator
├── distinctness.py deterministic — measures pitch/timbre separation between speakers
├── pacing.py deterministic — words per minute vs. corpus target (135)
├── seam_check.py deterministic — chunk-boundary discontinuity detection
├── stt_check.py deterministic — Whisper-style STT roundtrip + WER vs. transcript
└── synthesize_critique.py LLM — turns audio anti-patterns into next-iter critique notes
Layer F — Standalone runners ([project.scripts])
| Command | Module | Purpose |
|---|---|---|
pipeline |
pipeline.py |
ingest+extract+merge+analytics+calibrate |
generate |
generate.py |
concept/full/iterate/meta/audio modes |
compare-audio |
compare_audio.py |
A/B comparison runner over multiple wav variants |
corpus-research |
corpus_research.py |
Gemini Deep Research Max over the 12 corpus MP3s |
report-card |
report_card.py |
Hundreds-of-categories evaluation aggregator |
cross-model-judge |
cross_model_judge.py |
Gemini + Claude ensemble per-axis comparison |
listener |
listen_log.py |
Pairwise candidate dump for ear-vs-ear listening |
Each runner uses absl.flags, calls client.set_run_id() once at startup so all calls in data/calls.jsonl share a run_id, and respects --budget_usd.
Cross-cutting conventions
client.pyis the only placegoogle-genaiis imported. Everything else usesDoubleActsClient.{generate_text, generate_text_multimodal, generate_structured, upload_file}.- Schemas: no
extra="forbid", nodict[str, X]— Geminiresponse_schemarejects both. Seeschemas._Strict(a plainBaseModel). - Audio chunking is the only chunking.
gemini-3.1-flash-tts-previewhas hard caps: exactly 2 voices/call, ~30–60s output.produce_audio.synthesize_episodeis the only place that handles those caps. - Test isolation:
tests/__init__.pyredirectsDOUBLE_ACTS_DATA_ROOTto a tmp dir — unit tests must be invoked withpython -m unittest discover -s tests -t .so the redirection takes effect. - Run IDs: every invocation calls
client.set_run_id()once. Everydata/calls.jsonlrow carries that ID; every audio output filename embeds an 8-char prefix of it.
Where to start reading
- "How does generation work end-to-end?" →
iterate.run_meta(top of the loop),generate.generate_full(one iteration),produce_audio.synthesize_episode(the audio side). - "How is the text scored?" →
autorater.score(combinesscore_rules+evaluate_judge). - "How do I add a new audio analyzer?" →
audio_eval/__init__.py(the umbrella), then a new file underaudio_eval/exposingevaluate(wav_path) → SomeReport. - "How does feedback loop back into the next iteration?" →
critique.evaluate_segment→iterate.run_metabuilds a feedback string → passed back intogenerate.generate_conceptand the TTS prompt.