Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

xil-pipeline

Show-agnostic audio production pipeline that turns a markdown script into a podcast-ready MP3.

A project.json file sets the show name; every command derives file paths from it via a shared slug. The pipeline parses scripts, generates voices and SFX, assembles a rough master, exports isolated WAV layers for DAW mixing, and produces a final master MP3. Supporting utilities handle voice discovery, SFX generation, stem migration on script revisions, stale cleanup, and Studio import/export. Every command that calls a paid API supports --dry-run to preview costs before spending quota.

xil is a single Rust binary. The only Python left is the three optional ML workers (Chatterbox Turbo, Whisper, MMAudio), each in its own venv.

Installation

git clone https://github.com/xilcmd/rxil-pipeline
cd rxil-pipeline
cargo build --release            # the binary: target/release/xil

On WSL, clone into the Linux filesystem (for example ~/src/rust/), not under /mnt/c: builds and tests there are many times slower.

Requirements:

  • ffmpeg on PATH — every decode and encode goes through it.
  • XIL_PROJECTROOT — the workspace (scripts, configs, stems, SFX). Defaults to the current directory.
  • XIL_CODEROOT — a checkout of xil-pipeline, only for the ML workers: their scripts ship in its src/xil_pipeline/, and their venvs live beside it.

xil status --toolchain shows which worker scripts and venvs it found.

The xil-<command> names used in older notes still work when the binary is linked under that name (ln -s xil xil-parse); xil <command> is the same.

Optional: local GPU TTS (Chatterbox Turbo)

The default dialogue backend is the ElevenLabs API. For a free, local, GPU-accelerated alternative with per-character voice cloning, set up a dedicated venv-chatterbox/ at your code root (XIL_CODEROOT). It hosts Chatterbox Turbo, which natively renders 19 paralinguistic tags (emotion, delivery style, and vocal gestures — see the pipeline reference).

One command builds and checks it:

xil setup chatterbox             # CUDA wheels if nvidia-smi works, else CPU
xil setup chatterbox --dry-run   # show the commands without running them

It creates the venv in --dir, else $XIL_CODEROOT, else the workspace root (the places xil produce looks). It uses uv when it is on PATH, otherwise python3.13 -m venv and pip. Force a build with --device cuda|cpu, pick another CUDA wheel set with --cuda-index (default cu124), and rebuild with --force. Running it on a working venv does nothing.

xil setup builds the other local-model venvs the same way:

TargetVenvUsed byDefault Python
chatterboxvenv-chatterbox/xil produce --backend chatterbox-turbo3.13
whispervenv-whisper/xil stem-verify (transcripts)3.13
mmaudiovenv-mmaudio/--sfx-backend mmaudio (below)3.12

Override the Python version with --python. If that version is not on PATH, setup warns and falls back to python3; install uv to have it fetch the right one.

To do the same by hand:

# CUDA 12.4 wheels shown; use .../whl/cpu for CPU only
uv venv venv-chatterbox --python 3.13
uv pip install --python venv-chatterbox/bin/python \
    'torch==2.6.0' 'torchaudio==2.6.0' \
    --index-url https://download.pytorch.org/whl/cu124
uv pip install --python venv-chatterbox/bin/python chatterbox-tts pydub

Model weights download from Hugging Face on the first render. If the Turbo repo (ResembleAI/chatterbox-turbo) is gated for your account, authenticate first with export HF_TOKEN=hf_... or huggingface-cli login.

Then place a per-character reference clip at voice_refs/<speaker_key>.wav (Chatterbox Turbo requires clips longer than 5 seconds) and select the backend:

xil produce --episode S01E01 --backend chatterbox-turbo

--chatterbox-python PATH overrides the auto-detected venv Python. --backend chatterbox is a deprecated alias that warns and uses Turbo.

Under chatterbox-turbo you can write cues straight into dialogue — [angry] [fear] [surprised] [happy] [crying] [sarcastic] [whispering] [dramatic] [narration] [advertisement] [laugh] [chuckle] [sigh] [gasp] [groan] [cough] [sniff] [shush] [clear throat]. Spelling is exact (no plurals: [laugh], not [laughs]); any other bracketed tag is stripped, so ElevenLabs-only tags can safely stay in a shared script. Lines longer than about 250 characters are rendered in sentence-sized chunks, because Turbo caps each generation at 40 seconds.

Optional: local SFX generation (MMAudio) — non-commercial only

--sfx-backend mmaudio generates sound effects locally instead of calling the ElevenLabs API. It runs in a dedicated venv-mmaudio/, needs ~6 GB of VRAM, and installs from a git clone rather than PyPI. One command does every step below:

xil setup mmaudio --dry-run   # show the commands first
xil setup mmaudio

It clones MMAudio (pinned to commit 974010a) next to the venv unless a clone is already there, so it needs git. MMAudio pins numpy<2.1, which has no Python 3.13 wheels, so this venv uses Python 3.12. The weights (~6 GB) download on the first run into weights/ and ext_weights/ next to venv-mmaudio, whatever folder you run from. Set $XIL_MMAUDIO_WEIGHTS to keep them somewhere else.

By hand:

uv venv venv-mmaudio --python 3.12
git clone https://github.com/hkchengrex/MMAudio
uv pip install --python venv-mmaudio/bin/python -e MMAudio

# MMAudio declares `torch >= 2.5.1` with no upper bound, so the line above pulls
# the newest torch — currently a CUDA 13 build. On a CUDA 12.x driver that
# silently falls back to CPU ("NVIDIA driver on your system is too old") and
# breaks torchaudio. Re-pin a driver-matched stack AFTERWARDS, not before:
uv pip install --python venv-mmaudio/bin/python \
    'torch==2.6.0' 'torchaudio==2.6.0' 'torchvision==0.21.0' \
    --index-url https://download.pytorch.org/whl/cu124

# Confirm the GPU is actually visible before generating anything:
venv-mmaudio/bin/python -c "import torch; print(torch.cuda.is_available())"   # must print True

⚠️ MMAudio’s model weights are CC BY-NC 4.0 — non-commercial use only. The code is MIT licensed; the checkpoints are not. Audio generated here must not appear in a monetised production. --mmaudio-accept-noncommercial is required or the backend refuses to start, and every generated asset is tagged .mmaudio in its filename and carries the licence notice in its ID3 comment so it stays identifiable later.

xil sfx --episode S01E01 --gen-sfx --sfx-backend mmaudio --mmaudio-accept-noncommercial

MMAudio is trained at 8 seconds, so cues are generated at that length and trimmed to each cue’s duration_seconds — this produces better audio than asking the model for a short clip directly.

Quick Start

See the Tech Deep Dive sample for an example of the markdown script format the pipeline expects. It demonstrates dialogue, acting directions, SFX/ambience/music cues, beats, sections, and scenes.

# Scaffold a new project workspace (creates a copy of the sample script)
xil init my-show --show "My Podcast"
cd my-show

# Scan the sample script (pre-flight check)
# Always run scan before parse when onboarding a new episode — it will catch
# unrecognized speakers before they silently disappear from the parsed output.
xil scan scripts/sample_S01E01.md --speakers configs/my-show/speakers.json

# Parse into structured JSON
xil parse scripts/sample_S01E01.md --episode S01E01 --speakers configs/my-show/speakers.json

# Preview TTS character cost (no API calls)
xil produce --episode S01E01 --dry-run

# Generate voice and SFX stems (requires ELEVENLABS_API_KEY — see Environment below)
xil produce --episode S01E01

# Export DAW layers for mixing in Audacity
xil daw --episode S01E01

# Produce final master MP3
xil master --episode S01E01

xil gui opens the same workflow as a web dashboard at http://localhost:7860. It binds 127.0.0.1; to reach it from another machine, forward the port (ssh -L 7860:127.0.0.1:7860 <host>).

Commands

xil --help lists every command in pipeline order with its XILP/XILU reference number; xil <command> --help (or man xil-<command>) shows its options. The pipeline reference documents each stage in depth, and the programmer reference documents the code.

Configuration

  • project.json — show name (derives all file paths via slug)
  • speakers.json — speaker names the parser recognizes (optional, built-in defaults for sample)
  • cast_<TAG>.json — voice assignments, speaker settings
  • sfx_<TAG>.json — sound effect mappings and API parameters

All commands accept --show to override the show name. Resolution order: --show flag > project.json > default "sample".

Episode tag formats

The <TAG> portion of all file and directory names supports any string. Use --episode for standard episodic content or --tag for non-episodic formats:

Content typeTag formatExamplesNotes
Podcast episodeS01E04S02E11, S03E01Default — derived from script header
Audiobook chapterV01C03V01C01–V01C20Volume + Chapter; use --tag V01C03 with xil parse
Drama shortS01D01S01D03Season + Drama number; or just use S01E01
Standalone one-shotE01E01–E99No season prefix; standard --episode E01 works
Bonus / specialBONUS01TRAILER, BONUS02Any string via --tag; use uppercase by convention

Episodic tags (S01E04, E01) are derived automatically from the script header. All other formats require --tag on xil parse:

xil parse scripts/gatsby_V01C03.md --tag V01C03
xil produce --episode V01C03 --dry-run
xil daw --episode V01C03

Stems are stored under stems/<slug>/<TAG>/, so multiple shows and tag formats coexist safely in one workspace.

See the SFX Reuse Guide for workflows that minimize ElevenLabs API credit usage by referencing existing assets in the SFX/ library.

Environment

API keys

CommandsNeeds
xil produce, xil sfx, xil studio-onboard, xil sample, xil voices, xil cues --generateELEVENLABS_API_KEY
xil publishANTHROPIC_API_KEY
All other commands (xil scan, xil parse, xil daw, xil master, …)nothing

Obtain an ElevenLabs key: https://elevenlabs.io → Profile → API Keys

export ELEVENLABS_API_KEY=your_key_here                        # this shell
echo 'export ELEVENLABS_API_KEY=your_key_here' >> ~/.bashrc    # persist

Always use --dry-run first to preview character cost before making API calls.

Man pages

export MANPATH="/path/to/rxil-pipeline/man:$(manpath 2>/dev/null)"
man xil
man xil-produce

License

AGPL-3.0.

Cast Config Reference — cast_<slug>_<TAG>.json

The cast config file assigns ElevenLabs voices to characters, configures stereo positioning and audio effects, and optionally defines broadcast preamble and postamble blocks read by a host. One file covers an entire episode; it is read by xil produce, xil daw, xil mix, and xil studio.

Filename pattern: cast_<slug>_<TAG>.json (e.g. cast_the413_S03E02.json, cast_nightowls_V01C03.json)


Top-level Structure

{
  "show":         "The 4 1 3",
  "season":       3,
  "episode":      2,
  "title":        "The Weight of the Letters",
  "season_title": "The Architect",
  "artist":       "XIL Pipeline",
  "preamble":     { ... },
  "postamble":    { ... },
  "cast":         { ... }
}
FieldTypeRequiredDescription
showstringyesShow name (informational; slug derived from project.json)
seasonintnoSeason number; used in preamble {season} placeholder and ID3 metadata
episodeintyes*Episode number; used in preamble {episode} placeholder
tag_overridestringnoRaw non-episodic tag (e.g. V01C03, D01). When set, overrides season/episode for tag derivation
titlestringnoEpisode title; used in preamble {title} placeholder and ID3 metadata
season_titlestringnoSeason arc title; used in preamble {season_title} placeholder
artiststringnoArtist credit written to MP3/WAV ID3 tags. Default: "XIL Pipeline"
preambleobjectnoBroadcast intro block (see below)
postambleobjectnoBroadcast outro block (see below)
castobjectyesMap of speaker key → voice config (see below)

* episode is required unless tag_override is set.


cast Block

Each key must match a speaker key used in the production script (all lowercase, no spaces — e.g. adam, mr_patterson). Keys are matched case-sensitively against the speaker field of parsed dialogue entries.

"cast": {
  "adam": { ... },
  "maya": { ... }
}

Cast Member Fields

"adam": {
  "full_name":        "Adam Santos",
  "voice_id":         "6wrQFQPrvgNyJY74SxwG",
  "pan":              0.0,
  "filter":           false,
  "role":             "Host/Narrator. American male, early 20s. Warm, intimate...",
  "stability":        0.5,
  "similarity_boost": 0.75,
  "style":            0.0,
  "use_speaker_boost": true,
  "language_code":    "en"
}
FieldTypeRequiredDescription
full_namestringyesCharacter display name (used in ID3 tags and voice sample filenames)
voice_idstringyesElevenLabs voice ID. Set to "TBD" when not yet assigned — pipeline will skip TTS for this speaker
panfloat −1.0 to 1.0yesStereo position. 0.0 = centre, −1.0 = full left, 1.0 = full right
filterbool or stringyesVoice treatment. false/null = none. See the Voice Filters table
rolestringyesCharacter description passed as previous_text context to ElevenLabs
stabilityfloat 0–1noVoice stability. 0.0 = expressive/variable, 1.0 = monotone/consistent. null uses the voice’s ElevenLabs default
similarity_boostfloat 0–1noAdherence to the original cloned voice. 1.0 = strict match. null uses the voice default
stylefloat 0–1noStyle exaggeration relative to the original speaker. 0.0 = neutral. null uses the voice default
use_speaker_boostboolnoBoosts similarity to the original speaker at the cost of slightly higher latency. null uses the voice default
language_codestringnoISO 639-1 code for ElevenLabs text normalisation (e.g. "en", "de", "uk" for British English). null = auto-detect

Voice Filters

The filter field names a voice treatment applied to that speaker’s dialogue stems during assembly and DAW export. Pick by how it should sound:

ValueSounds likeUse for
false / nulluntreatedeveryone in the room
true / "phone"narrow and gritty — steep 300 Hz–3.4 kHz skirts, earpiece presence, hard AGC and real GSM codec artifactsa mobile caller, heard down the line
"vintage"dark, mono, mid-forward, no saturationaged tape or a record player; period flashbacks
"film"warm and reflective, rolled-off top, recessed presence, audible graindialogue heard from a film print (1990s indie register)
"speakerphone"narrow, crunchy, hard-levelled, with a room slapa phone on a table, on speaker

For a character who is on the phone in one scene and in the room in another, prefer the script span markers over a second cast entry — the span decides line by line. Use [PHONE FILTER: ENGAGES <speaker>] for an ordinary call and [SPEAKERPHONE ENGAGES] for a phone on speaker in the room. Use the cast field when a voice is only ever heard through a phone.

Comma-separated values chain left to right, so "vintage,phone" and "phone,vintage" give different results. Unknown names are ignored with a warning in the log.

"vintage" and "film" are easy to confuse: both are “old and warm”. vintage is darker and collapses to mono; film keeps stereo, keeps low-end body, and adds compression and grain. When in doubt, film is the one that still sounds like a modern recording of something old.

phone and speakerphone are independent — speakerphone is not a stronger phone, it is a separate chain with saturation, hard AGC and a short delay. phone is a voice against an ear; speakerphone is a small loudspeaker in a room.

phone, film and speakerphone are rendered through ffmpeg. If ffmpeg is unavailable the stem passes through untreated and a warning is logged; set XIL_STRICT_FX=1 to make that a hard error instead.

phone additionally round-trips the audio through the GSM 06.10 codec at 8 kHz — the actual 2G mobile codec — which is where its grit and its hard 4 kHz ceiling come from. If that encoder is missing but ffmpeg is present, the treatment keeps its filtering and loses only the codec character.

libgsm is not in every ffmpeg build. It has to be compiled in (--enable-libgsm); ffmpeg has a native GSM decoder but no native encoder, and installing the library afterwards does not help a pre-built binary. Debian and Ubuntu ship it. Homebrew’s default ffmpeg formula does not, and neither the default formula nor the homebrew-ffmpeg tap offers it as an option — a macOS render needs ffmpeg built from source to match.

A render that engages phone without the encoder warns before writing its first stem. The audio is usable, but it will not match a render from a machine that has the encoder, so masters of the same episode can differ by which machine made them. Check ffmpeg -encoders | grep libgsm if you are unsure.

libgsm is not in every ffmpeg build. Debian’s has it; the stock macOS and Windows builds generally do not. Where it is missing, phone still band-limits properly (~20 dB down at 6 kHz instead of ~47 dB) but sounds smoother and less like a mobile. Render an episode on the same machine you auditioned it on, or two masters of the same episode will not match. Check with ffmpeg -hide_banner -encoders | grep libgsm, or in Python:

from xil_pipeline.audio_fx import encoder_available
encoder_available("libgsm")

This treatment was rebuilt in a later release and sounds materially different. It used to be a pydub one-liner whose single-pole filters left 80 Hz only ~11 dB down and 8 kHz only ~9 dB down — a slightly muffled voice rather than a phone. It is now ~37 dB and ~47 dB respectively. The old version also added a flat +5 dB, making the caller the loudest thing in the scene; the new one lands ~1.4 dB under an untreated stem. Episodes rendered with the old filter need re-rendering to pick this up.

Note that a speaker whose filter names a treatment and who speaks inside a matching script span (see FILM AUDIO in the direction types reference) receives that treatment twice. This has always been the behaviour for VINTAGE FILTER; it is more audible for film, which compounds compression and grain.

phone is the one exception. A phone span treatment is skipped when the speaker’s own filter already applied it. Applying it twice band-limits twice and runs the voice through GSM twice, compounding the codec artifacts into mush rather than doubling an effect. The exception is deliberately narrow: only phone is deduped, and only against an identical cast filter. A vintage speaker inside a PHONE FILTER span still gets both.

Pan Guide

ValuePositionTypical use
0.0CentreHost/narrator, solo speaker
−0.15 to −0.3LeftFemale character A
0.15 to 0.3RightMale character B
−0.5 / 0.5Hard L/RDistinct telephone or off-screen voice

Avoid values beyond ±0.4 for dialogue — extreme panning fatigues listeners on headphones.

Voice Settings Guide

ParameterLowerHigherSuggested starting point
stabilityMore emotional variationPredictable, consistent0.5
similarity_boostLooser, more creativeStrict clone adherence0.75
styleNeutral deliveryExaggerated expressiveness0.0

Omitting any of these fields (or setting to null) inherits the ElevenLabs voice default. Use xil voice-sample --episode TAG to audition voice changes before committing them.


preamble and postamble Blocks

Both blocks are optional and share an identical schema. They control TTS speaking rate only — the spoken text lives in the production script as PREAMBLE and POSTAMBLE section blocks, not in the cast config.

"preamble":  { "speaker": "tina", "speed": 0.85 },
"postamble": { "speaker": "tina", "speed": 0.85 }
FieldTypeRequiredDescription
speakerstringyesCast key of the reader (must exist in cast)
speedfloat 0.7–1.2noTTS speaking rate override for this section. 1.0 = normal speed; 0.85 = slightly slowed for broadcast clarity

xil produce applies the speed override when generating stems for entries whose section field is "preamble" or "postamble". All other voice settings (voice ID, stability, pan) come from the speaker’s cast entry as normal.

The spoken text, music cues ([INTRO MUSIC], [OUTRO MUSIC]), and section placement are written in the production script:

===

PREAMBLE

TINA  Welcome to the show. Season 1, Episode 1, Pilot.

[INTRO MUSIC]

===

COLD OPEN
…

===

POSTAMBLE

[OUTRO MUSIC]

TINA  That was the show. Thank you for listening.

===

END OF EPISODE

Preamble/postamble voice stems follow the same {seq:03d}_preamble_{speaker}.mp3 naming convention as all other dialogue stems. Music stems are generated by xil sfx via the standard SFX pipeline using INTRO MUSIC / OUTRO MUSIC entries in the sfx config.


Non-episodic Tags

For content that isn’t organised as S01E01 (audiobooks, shorts, one-offs), use tag_override instead of season/episode:

{
  "show":         "Signals",
  "tag_override": "V01C01",
  "title":        "The Signal",
  "cast": { ... }
}

tag_override is used verbatim for all file path derivations (stems/signals/V01C01/, parsed/parsed_signals_V01C01.json, etc.). It must match ^[A-Za-z0-9_-]+$.


Auto-generated Skeleton

When you run xil parse with --episode TAG and no cast file exists yet, a skeleton is written automatically:

{
  "show": "THE 413", "season": 1, "episode": 1, "title": "...",
  "cast": {
    "adam": { "voice_id": "TBD", "pan": 0.0, "filter": false, "role": "" },
    ...
  }
}

Fill in each voice_id using xil discover-voices to browse available ElevenLabs voices, then audition assignments with xil voice-sample --episode TAG.


Field Reference Summary

Top-level

FieldTypeDefaultNotes
showstring—Required
seasonintnullOptional; used in tag and placeholders
episodeint—Required unless tag_override is set
tag_overridestringnullNon-episodic tag; overrides season/episode
titlestringnullEpisode title for metadata and placeholders
season_titlestringnullArc title for {season_title} placeholder
artiststring"XIL Pipeline"ID3 Artist tag on all audio outputs
preambleobjectnullBroadcast intro
postambleobjectnullBroadcast outro
castobject—Required

Cast Member

FieldTypeDefaultNotes
full_namestring—Required
voice_idstring—Required; "TBD" defers generation
panfloat −1–1—Required
filterbool—Required
rolestring—Required; used as TTS context
stabilityfloat 0–1voice defaultOptional
similarity_boostfloat 0–1voice defaultOptional
stylefloat 0–1voice defaultOptional
use_speaker_boostboolvoice defaultOptional
language_codestringautoOptional; ISO 639-1

Preamble / Postamble

FieldTypeDefaultNotes
speakerstring—Required; must be a key in cast
speedfloat 0.7–1.2voice defaultOptional TTS rate override for this section

Complete Minimal Example

{
  "show": "Night Owls",
  "season": 1,
  "episode": 3,
  "title": "The Long Shift",
  "cast": {
    "jordan": {
      "full_name": "Jordan Blake",
      "voice_id": "abc123ElevenLabsVoiceID",
      "pan": 0.0,
      "filter": false,
      "role": "Host. American, warm, late-night energy."
    },
    "caller": {
      "full_name": "The Caller",
      "voice_id": "xyz456ElevenLabsVoiceID",
      "pan": 0.35,
      "filter": true,
      "role": "Nervous, mid-30s, calls in anonymously."
    }
  }
}

Complete Preamble/Postamble Example

Cast config (cast_nightowls_S01E03.json):

{
  "show": "Night Owls",
  "season": 1,
  "episode": 3,
  "title": "The Long Shift",
  "season_title": "Season of Static",
  "preamble":  { "speaker": "host", "speed": 0.9 },
  "postamble": { "speaker": "host", "speed": 0.9 },
  "cast": {
    "host": {
      "full_name": "Jordan Blake",
      "voice_id": "abc123ElevenLabsVoiceID",
      "pan": 0.0,
      "filter": false,
      "role": "Host. American, warm, late-night energy.",
      "stability": 0.5,
      "similarity_boost": 0.75,
      "style": 0.0,
      "use_speaker_boost": true,
      "language_code": "en"
    }
  }
}

Script (scripts/nightowls/S01E03_the_long_shift.md, beginning and end):

===

PREAMBLE

HOST  You're listening to Night Owls. [pause] Season of Static [pause] Episode 3 [pause] "The Long Shift".

[INTRO MUSIC]

===

COLD OPEN
…

===

POSTAMBLE

[OUTRO MUSIC]

HOST  That was Night Owls, Episode 3, "The Long Shift". [pause] Thank you for listening.

===

END OF EPISODE

See Also

  • SFX Config Reference — intro/outro music lives in the sfx config under INTRO MUSIC / OUTRO MUSIC keys, not in the cast config
  • xil discover-voices — browse and search available ElevenLabs voices
  • xil voice-sample --episode TAG — generate short TTS samples to audition voice assignments
  • xil produce --dry-run --episode TAG — preview TTS character cost before committing

SFX Config Reference — sfx_<slug>_<TAG>.json

The SFX config file maps every sound direction in a parsed episode script to its audio source and playback parameters. One file covers an entire episode; it is read by xil produce, xil sfx, xil mix, and xil daw.

Filename pattern: sfx_<slug>_<TAG>.json (e.g. sfx_the413_S03E02.json, sfx_nightowls_V01C03.json)


Top-level Structure

{
  "show":    "The 4 1 3",
  "season":  3,
  "episode": 2,
  "defaults": { ... },
  "effects":  { ... }
}
FieldTypeDescription
showstringShow name (informational; slug is derived from project.json)
seasonintSeason number
episodeintEpisode number
defaultsobjectFallback values applied to all effects unless overridden per-entry
effectsobjectMap of direction text → effect config

defaults Block

All keys are optional. Per-entry values override these.

"defaults": {
  "prompt_influence":          0.3,
  "volume_percentage":         20,
  "ramp_in_seconds":           1.0,
  "ramp_out_seconds":          1.0,
  "ambience_volume_percentage": 30,
  "ambience_ramp_in_seconds":   1.0,
  "ambience_ramp_out_seconds":  1.0
}
KeyTypeDescription
prompt_influencefloat 0–1How closely the API follows the prompt vs. free creativity. Default 0.3.
volume_percentagefloatPlayback volume for SFX and MUSIC entries. 100 = unity gain.
ramp_in_secondsfloatFade-in duration in seconds for SFX and MUSIC entries.
ramp_out_secondsfloatFade-out duration in seconds for SFX and MUSIC entries.
ambience_volume_percentagefloatVolume for AMBIENCE entries specifically.
ambience_ramp_in_secondsfloatFade-in for AMBIENCE entries.
ambience_ramp_out_secondsfloatFade-out for AMBIENCE entries.

Category-specific keys (ambience_*) take precedence over the generic ones for AMBIENCE entries. If no category-specific key exists, the generic key is the fallback.


effects Keys

Each key must exactly match the text field of a parsed direction entry. The parser emits direction text verbatim from the script, so capitalisation and punctuation matter.

"AMBIENCE: RADIO BOOTH — SOFT EQUIPMENT HUM": { ... }

Effect Entry Fields

type

Controls how audio is produced. Most entries don’t need this field — the default is inferred from the key prefix (AMBIENCE:, MUSIC:, SFX:, BEAT, etc.) by the direction classifier.

ValueBehaviour
"sfx"(default) Generate via ElevenLabs Sound Effects API or copy from source.
"silence"Generate local silent audio. No API call.

source

Path to an existing audio file (relative to the project root or absolute).

"SFX: RADIO STATIC — BRIEF TUNING": {
  "source": "SFX/sfx_radio-static-tuning-transition.mp3",
  "duration_seconds": 1.5
}
  • Resolved with os.path.realpath() before use (symlinks are followed; path traversal is blocked).
  • If present and the file exists → the file is copied to the shared SFX library. No API credit is spent.
  • If present but the file is missing and a prompt is also set → falls back to API generation with a warning.
  • If present but the file is missing and no prompt is set → raises FileNotFoundError.

prompt

Text sent to the ElevenLabs Sound Effects API when no usable source file is found.

"MUSIC: STING OUT": {
  "prompt": "MUSIC: STING OUT",
  "duration_seconds": 15.0
}
  • If source is absent → API is always called using this prompt.
  • prompt_influence (from this entry or defaults) controls creativity vs. adherence.

duration_seconds

Meaning depends on context:

ContextMeaning
Entry has source, no play_durationClip the source file to this many seconds at mix time
Entry has no source (API generation)Requested generation length sent to ElevenLabs API
type: "silence"Duration of the generated silence
type: "silence", value 0.0Stop-marker — inserts a boundary in the timeline with no audio
"AMBIENCE: RADIO BOOTH — SOFT EQUIPMENT HUM": {
  "source": "SFX/ambience_radio-booth.mp3",
  "duration_seconds": 30.0,
  "loop": true
}

Watch this one on source cues. xil parse writes a default of 5.0 into every skeleton entry, so a long music bed dropped into a hinted cue is clipped to five seconds unless you change it. To see every cue this is affecting across the workspace, run xil sfx-impact — it grades each one by how much audio is being cut and names the edit that would restore it, without modifying anything.

To un-clip a cue, set play_duration: 100 rather than duration_seconds: 0. Both play the whole file, but duration_seconds is not in SFX_EDIT_FIELDS, so it is not replayed by the timeline edit journal — a duration_seconds edit reverts to 5.0 the next time the config is rebuilt from a skeleton, silently re-clipping the cue. play_duration survives regeneration.

Better still, fix it in the script: a play_duration_pct pipe-hint on the direction sets play_duration and removes duration_seconds from source-backed cues, so the contradiction never gets written in the first place.

play_duration

Percentage of the source file to use (0–100). Applied at stem copy time (XILP002), so the resulting stem file is already trimmed — all downstream tools see the correct duration.

"OUTRO MUSIC": {
  "source": "SFX/The Porch Light.mp3",
  "volume_percentage": 40,
  "play_duration": 25
}
  • 25 = use the first 25% of “The Porch Light.mp3”.
  • Only valid when source is set.
  • Mutually exclusive with duration_seconds for music entries; use one or the other.
  • Ignored on looped layers (AMBIENCE, VINTAGE FILTER) — they tile to fill the scene.
  • Best for music where the natural length is unknown and you want a proportional excerpt rather than a hard-coded second count.

It can also come straight from the script. A play_duration_pct pipe-hint on the direction sets this field, and the script wins over whatever the config holds:

[INTRO MUSIC | The Porch Light.mp3 | play_duration_pct=35%]

On a cue that has a source, the hint also removes duration_seconds, since the two are mutually exclusive and the skeleton’s default 5.0 would otherwise sit in the config contradicting the hint. On a generated cue (no source) duration_seconds is kept — there it is the requested generation length sent to the SFX API, not a trim.

A hint on an AMBIENCE or VINTAGE FILTER cue is dropped with a warning at parse time; a play_volume_pct on the same direction still applies.

loop

Boolean. Controls whether the audio tiles to fill the scene duration.

ValueBehaviour
true (default for AMBIENCE)Tile the file repeatedly to cover the full scene
falsePlay the file once; silence fills the remainder of the scene
"AMBIENCE: OUTDOOR SPRING": {
  "source": "SFX/AMBPark-Outdoor_spring_ambie-Elevenlabs.mp3",
  "duration_seconds": 30.0,
  "loop": true
}

volume_percentage

Per-entry volume override. Overrides the defaults category key for this entry only.

"INTRO MUSIC": {
  "source": "SFX/The Porch Light.mp3",
  "volume_percentage": 40,
  "play_duration": 30
}
  • 100 = unity gain (no change).
  • 40 = 40% of full volume (roughly −8 dB).
  • Applies to SFX, MUSIC, and AMBIENCE entries.

It can also come straight from the script. A play_volume_pct pipe-hint on the direction sets this field, and the script wins over whatever the config holds:

[OUTRO MUSIC | sundy3M4_v3.mp3 | play_volume_pct=20%]

Reparsing (xil parse) or xil sfx-hydrate applies it; xil regen writes it back into the markdown, so the annotation survives a full round-trip. Hand-editing this field is still fine — just remember a later re-parse will restore the script’s value if the direction still carries the hint.

The edit journal beats the script hint on a full re-parse. xil parse writes the skeleton from the script, then replays sfx_<TAG>.json’s edit journal on top. So a volume_percentage or play_duration you set in the xil-gui Timeline wins over a fresh hint in the script. xil sfx-hydrate has the opposite precedence — there the script always wins. If a hint appears not to take, check sfx_<TAG>_edits.jsonl (see xil sfx-restore).

prompt_influence

Per-entry override for ElevenLabs API creativity control (0.0–1.0).

  • 0.0 = maximum creative freedom; ignores prompt text almost entirely.
  • 1.0 = strict adherence to the prompt.
  • Falls back to defaults.prompt_influence when absent.

Reserved Keys

Two effect keys have special pipeline behaviour beyond normal playback:

KeyBehaviour
"INTRO MUSIC"XILP002 reads source and copies it to n001_preamble_sfx.mp3. The play_duration trim is applied at copy time. No API call.
"OUTRO MUSIC"Same as INTRO MUSIC but written as the postamble SFX stem.

Stop Markers

Use type: "silence" with duration_seconds: 0.0 to insert an ambience boundary without generating audio. The mixer treats this as an instruction to stop the current ambience loop at that timeline position.

"AMBIENCE: STOP": {
  "type": "silence",
  "duration_seconds": 0.0
},
"MUSIC: THEME FADES OUT": {
  "type": "silence",
  "duration_seconds": 0.0
}

The source field is ignored when duration_seconds is 0.0.


How Fields Combine — Decision Tree

effect entry
│
├── source present and file exists?
│   ├── play_duration set?  → trim to % of file at copy time (stem file is pre-trimmed)
│   └── duration_seconds set? → clip to N seconds at mix time
│
├── source absent (or file missing) and prompt set?
│   └── call ElevenLabs API; duration_seconds = requested length
│
└── type = "silence"?
    └── duration_seconds = 0.0 → stop marker (no audio)
        duration_seconds > 0   → generate local silence

Field Reference Summary

FieldTypeDefaultWhen used
type"sfx" | "silence""sfx"All entries
sourcestring (path)—Source-based entries
promptstring—API-generated entries
duration_secondsfloat—Clip/generate/silence length
play_durationfloat (0–100)—Source music % trim (script hint: play_duration_pct)
loopbooltrue for AMBIENCETile vs. one-shot
volume_percentagefloatfrom defaultsPer-entry volume (script hint: play_volume_pct)
prompt_influencefloat 0–1from defaultsAPI creativity control

Complete Entry Examples

Silence Beat

"BEAT": {
  "type": "silence",
  "duration_seconds": 1.0
}

Looping Ambience (source)

"AMBIENCE: RADIO BOOTH — SOFT EQUIPMENT HUM, SLIGHT STATIC, INTIMATE": {
  "source": "SFX/ambience_radio-booth-soft-equipment-hum-slight-static-intimate.mp3",
  "duration_seconds": 30.0,
  "loop": true
}

One-shot SFX (source, clipped)

"SFX: RADIO STATIC — BRIEF TUNING": {
  "source": "SFX/sfx_radio-static-tuning-transition.mp3",
  "duration_seconds": 1.5
}

Music Sting (source, clipped)

"MUSIC: THEME — BRIEF STING, RETURNS SOFT": {
  "source": "SFX/MUSCStngr-Short_medieval_melod-Elevenlabs.mp3",
  "duration_seconds": 5.0
}

Music with Percentage Trim (source, play_duration)

"OUTRO MUSIC": {
  "source": "SFX/The Porch Light.mp3",
  "volume_percentage": 40,
  "play_duration": 25
}

API-Generated Music

"MUSIC: STING OUT": {
  "prompt": "MUSIC: STING OUT",
  "duration_seconds": 15.0
}

Ambience Stop Marker

"MUSIC: UNDERSCORE FADES OUT": {
  "type": "silence",
  "duration_seconds": 0.0
}

See Also

  • SFX Reuse Guide — how to minimise ElevenLabs credit spend
  • DIRECTION_TYPES — how the parser classifies direction text
  • xil sfx --dry-run — preview EXISTS / CACHED / NEW status before generating
  • xil produce --dry-run — full voice + SFX cost estimate

DIRECTION_TYPES — Reference and Pipeline Impact

Definition

Defined in XILP001_script_parser.py at module level:

DIRECTION_TYPES = ["SFX", "MUSIC", "AMBIENCE", "BEAT", "VINTAGE FILTER", "FILM AUDIO", "SPEAKERPHONE"]

These are the recognized sound-category prefixes for stage directions enclosed in brackets ([...]) in the production script markdown. The values classify direction entries and are stored on ScriptEntry as direction_type.


Effect in XILP001

Role 1: Prefix scanner in classify_direction() (lines 84–86)

for dt in DIRECTION_TYPES:
    if text.strip().startswith(dt):
        return dt

Called with the bracket-interior text only (e.g., "SFX: DOOR SLAMS", not the full "[SFX: DOOR SLAMS]"). The first prefix match wins and is returned as the direction_type value. Order in the list matters if any prefixes were overlapping, but the current seven are disjoint.

is_stage_direction() fires on any [...] line regardless — it does not consult DIRECTION_TYPES. Classification happens after detection, not before.

Role 2: direction_type field on direction entries only (line 429)

entries.append({
    "type": "direction",
    ...
    "direction_type": classify_direction(bracket_text),
})

All other entry types hardcode "direction_type": None:

Entry typedirection_type
section_headerAlways None
scene_headerAlways None
dialogueAlways None
directionclassify_direction() result — one of the five values, or None

The BEAT quirk (lines 87–88)

if text.strip() == "BEAT" or text.strip() == "LONG BEAT":
    return "BEAT"

"BEAT" appears both in DIRECTION_TYPES (loop at line 84) and as an explicit exact-match fallback here. The loop handles [BEAT: some qualifier] variants; the fallback handles bare [BEAT] and [LONG BEAT]. Without the fallback, bare [BEAT] would be caught by the loop (since "BEAT".startswith("BEAT") is true), but [LONG BEAT] would return None. The duplication is intentional coverage for both forms.


Effect in XILP002 — None

Zero references. load_production() filters to entry["type"] == "dialogue" only. All direction entries (the only entries that can have a non-None direction_type) are discarded before voice generation begins. direction_type never influences which stems are generated, what text is sent to ElevenLabs, or any filename.


Effect in XILP003 — None

Zero references. Assembly reads only the speaker suffix from stem filenames and the config dict (pan/filter). direction_type is not present in stems or in the cast configuration.


Effect in XILP005 and XILP011 — span markers

"VINTAGE FILTER" and "FILM AUDIO" are span markers: an ENGAGES entry opens a span, the next DISENGAGES (or end of episode) closes it, and every dialogue stem inside receives the corresponding voice treatment. The mapping lives in mix_common.SPAN_DIRECTION_TREATMENTS:

Direction typeTreatment applied to enclosed dialogue
VINTAGE FILTERvintage — mono, dark, mid-forward
FILM AUDIOfilm — warm, soft top, gentle compression and grain
SPEAKERPHONEspeakerphone — narrow-band, hard AGC, crunch, room slap
PHONE FILTERphone — mobile call: steep 300 Hz–3.4 kHz skirts, earpiece presence, hard AGC and real GSM codec grit. The same treatment as the cast config filter: phone

Spans of different types may overlap; treatments then stack in SPAN_DIRECTION_TREATMENTS declaration order. PHONE FILTER is declared last because a band-limit reads best applied after any tone shaping.

Narrowing a span to one speaker

By default a span treats every dialogue line it encloses. Naming a speaker on the ENGAGES marker narrows it to that speaker’s lines:

[PHONE FILTER: ENGAGES DEZ]

This matters for call scenes. A writer naturally wraps the whole conversation in one marker pair, but only the remote character belongs on the filter — a blanket span would band-limit the in-room voice too. The speaker key is the cast config key (matched case-insensitively against the stem filename suffix), the scope applies to every span type, and the closing marker may repeat it or not. A bare ENGAGES keeps the original treat-everything behaviour.

Naming a speaker who has no lines inside the span treats nothing — the span is still opened and closed, it just matches no dialogue.

Treated dialogue stays in the dialogue layer. The separate {TAG}_layer_vintage_filter.wav holds record-player crackle audio for the span, which is a different mechanism that happens to share the name — it is not where filtered voices go.

No stems are generated for FILM AUDIO, SPEAKERPHONE or PHONE FILTER markers: generate_sfx_config() writes them as {"type": "silence", "duration_seconds": 0.0}, which load_sfx_entries() skips, so they cost no API credits and produce no files. VINTAGE FILTER ENGAGES differs — it binds to a real crackle asset, and only its DISENGAGES marker is a pure boundary.

Marker spelling: the scanner accepts both [FILM AUDIO ENGAGES] and [FILM AUDIO: ENGAGES], and likewise for SPEAKERPHONE and PHONE FILTER. For VINTAGE FILTER only the colon-free form is validated, for backward compatibility with scripts already in production; the mixer itself tolerates either.


The Pydantic Constraint (models.py line 46)

direction_type: Literal[
    "SFX", "MUSIC", "AMBIENCE", "BEAT", "VINTAGE FILTER", "FILM AUDIO",
    "SPEAKERPHONE", "PHONE FILTER",
] | None = Field(default=None, description="Sound category for direction entries")

DIRECTION_TYPES in the parser and the Literal in ScriptEntry are independently maintained — they are not derived from each other. A new sound category requires updates in both places:

  1. Add to DIRECTION_TYPES in XILP001_script_parser.py
  2. Add to the Literal in ScriptEntry in models.py

Updating only DIRECTION_TYPES without updating the Literal will cause parse_script() to raise a ValidationError at parse time when the new category is returned by classify_direction().


Comparison with SECTION_MAP

SECTION_MAPDIRECTION_TYPES
Controls entry detectionYes — unknown headers are silently droppedNo — [...] brackets always create direction entries
Propagates to stem filenamesYes — slug appears in every stem MP3 nameNo
Used downstream in XILP002/XILP003Indirectly (via stem filenames)Not at all
Silent failure modeHigh risk — mislabels all subsequent contentLow risk — unrecognized directions get direction_type: None, entry still created
Pydantic mirror requiredNoYes — Literal in ScriptEntry must match

Adding a New Direction Type

To add "FOLEY" as a recognized category:

  1. Add to DIRECTION_TYPES in XILP001_script_parser.py:
    DIRECTION_TYPES = ["SFX", "MUSIC", "AMBIENCE", "BEAT", "VINTAGE FILTER", "FOLEY"]
    
  2. Add to the Literal in ScriptEntry in models.py:
    direction_type: Literal["SFX", "MUSIC", "AMBIENCE", "BEAT", "VINTAGE FILTER", "FOLEY"] | None
    
  3. Re-run XILP001 to regenerate the parsed JSON with "direction_type": "FOLEY" on matching entries.

No changes required in XILP002 or XILP003 — direction types do not affect voice generation or audio assembly.

SECTION_MAP — Reference and Pipeline Impact

Definition

Defined in XILP001_script_parser.py at module level:

SECTION_MAP = {
    "COLD OPEN":                        "cold-open",
    "OPENING CREDITS":                  "opening-credits",
    "ACT ONE":                          "act1",
    "ACT 1":                            "act1",              # numeral variant
    "ACT TWO":                          "act2",
    "ACT 2":                            "act2",              # numeral variant
    "ACT THREE":                        "act3",
    "ACT 3":                            "act3",              # S02E02 three-act structure
    "ACT FOUR":                         "act4",
    "ACT 4":                            "act4",
    "MID-EPISODE BREAK":                "mid-break",
    "CLOSING":                          "closing",
    "CLOSING — RADIO STATION":          "closing",           # S02E01 variant
    "CLOSING — ADAM'S SIGN-OFF":        "closing",           # S02E02 straight apostrophe
    "CLOSING — ADAM\u2019S SIGN-OFF":   "closing",           # S02E02 curly apostrophe
    "POST-INTERVIEW":                   "post-interview",
    "POST-INTERVIEW: ADAM & TINA":      "post-interview",    # S02E02 variant
    "POST-CREDITS SCENE":              "post-credits",       # S01E03
    "DEZ'S CLOSING NARRATION":          "dez-closing",       # S02E03 straight apostrophe
    "DEZ\u2019S CLOSING NARRATION":     "dez-closing",       # S02E03 curly apostrophe
    "PRODUCTION NOTES":                 "production-notes",  # S02E03 preamble
    "PREAMBLE":                          "preamble",
    "POSTAMBLE":                         "postamble",
}

Keys are the exact strings that appear in the production script (after markdown formatting is stripped). Values are URL-safe slugs stamped onto every parsed entry and embedded in every stem filename.

Multiple keys may map to the same slug (e.g. "ACT ONE" and "ACT 1" both produce "act1"; several CLOSING variants all produce "closing"). This handles script-to-script variation without requiring authors to use a single canonical form.

Note: Section headers may appear as plain text (COLD OPEN) in S01E01-style scripts or as markdown headings (## **COLD OPEN**) in S01E02+ scripts. The strip_markdown_formatting() pass normalizes both to bare text before is_section_header() checks against these keys.

Known slugs by episode

SlugEpisodesDisplay variants
cold-openS01E01–S02E03COLD OPEN
opening-creditsS01E01OPENING CREDITS
act1S01E01–S02E03ACT ONE, ACT 1
act2S01E01–S02E03ACT TWO, ACT 2
act3S02E02–S02E03ACT THREE, ACT 3
act4(reserved)ACT FOUR, ACT 4
mid-breakS01E01MID-EPISODE BREAK
closingS01E01–S02E03CLOSING, CLOSING — RADIO STATION, CLOSING — ADAM’S SIGN-OFF
post-interviewS02E02–S02E03POST-INTERVIEW, POST-INTERVIEW: ADAM & TINA
post-creditsS01E03POST-CREDITS SCENE
dez-closingS02E03DEZ’S CLOSING NARRATION
production-notesS02E03PRODUCTION NOTES
preambleallPREAMBLE
postambleallPOSTAMBLE

Pre-Flight Detection — XILP000

XILP000_script_scanner.py imports SECTION_MAP directly from XILP001 and scans the raw script for recognized vs unrecognized section headers before parsing. This is the primary defence against the silent failure risk described below.

  • Exit code 0 = all sections recognized (safe to run XILP001)
  • Exit code 1 = unrecognized section headers found (action needed: add to SECTION_MAP)
  • No side effects — reads only the script file
python XILP000_script_scanner.py "scripts/<script>.md"

Effect in XILP001 — Three Roles

1. Gate-keeping

def is_section_header(line: str) -> bool:
    return line.strip() in SECTION_MAP

A line is only recognized as a section header if its stripped text exactly matches a key. Unrecognized headers fall through the classifier loop and are silently skipped — they generate no entry and do not update the section state.

2. State machine reset

if is_section_header(line):
    current_section = SECTION_MAP[line.strip()]
    current_scene = None

When a section header is matched:

  • current_section is set to the slug value
  • current_scene is reset to None

Every subsequent entry inherits current_section until the next section header is encountered. Lines appearing before the first section header carry "section": None.

3. Slug stamped on every entry

All four entry types receive the current section slug:

Entry typeSection field set?
section_headerYes (the header itself)
scene_headerYes
directionYes
dialogueYes

The stats collector uses this to build stats["sections"] — a sorted list of unique section slugs seen in the script.


Effect in XILP002 — Stem Filename Composition

stem_name = f"{entry['seq']:03d}_{entry['section']}"
if entry.get("scene"):
    stem_name += f"-{entry['scene']}"
stem_name += f"_{entry['speaker']}"

The section slug becomes the middle segment of every stem filename:

003_cold-open_adam.mp3
028_act1-scene-1_rian.mp3
102_act2-scene-5_mr_patterson.mp3
285_closing_sfx.mp3
310_dez-closing_sfx.mp3

This has a critical consequence for duplicate stem prevention: generate_voices() skips a stem if its file already exists on disk. If SECTION_MAP is changed after stems have been generated, the path changes and existing stems will not be found — XILP002 will re-generate them and consume additional TTS quota.


Effect in XILP003 — None (Indirect Only)

XILP003 loads stems alphabetically via sorted(glob(...)). Since stems are prefixed with the zero-padded sequence number (003_, 028_), the section slug in the middle does not affect sort order. XILP003 never reads section information directly — it extracts only the speaker from the filename suffix via rsplit("_", 1)[-1].


Effect in XILP005 — Label Files and Timeline

XILP005 (DAW layer export) uses collect_stem_plans() from mix_common.py, which cross-references stem filenames against the parsed JSON entries index. The section slug in the filename is used for stale stem detection: when a stem’s filename section segment doesn’t match the parsed entry’s section, it may be flagged as stale or as a duplicate.

Label track files ({TAG}_labels_dialogue.txt, etc.) and the timeline HTML use speaker and direction text from the parsed JSON — not the section slug directly — but the section slug determines which stems are loaded.


Effect in XILP007 — Stem Migration

XILP007 (stem migrator) copies unchanged stems to new seq-numbered filenames when a script is revised. The new filename includes the new section slug from the revised parsed JSON. If a section boundary shifted (e.g. a line moved from act1 to act2), the migrated stem gets the updated section slug in its filename automatically.


Effect in XILP008 — Stale Stem Cleanup

XILP008 builds an expected filename for each parsed entry using {seq}_{section}[-{scene}]_{speaker|sfx}. When multiple stems exist for the same seq number, it keeps only the one whose basename matches the expected pattern — stems with a stale section slug are deleted.


Effect in XILP009 — Reverse Script Generator

XILP009 reverses the section slug back to a display header using an inverted SECTION_MAP. When multiple display keys map to the same slug, the longest key is preferred (e.g. act1 → ACT ONE rather than ACT 1). The reverse mapping is built at import time from XILP001’s SECTION_MAP.


Adding a New Section

To add a new section (e.g., for an episode with an "EPILOGUE"):

  1. Add the entry to SECTION_MAP in XILP001_script_parser.py:
    "EPILOGUE": "epilogue",
    
  2. Run XILP000_script_scanner.py to verify the new section is now recognized.
  3. Re-run XILP001 to regenerate the parsed JSON.
  4. Re-run XILP002 — stems for the new section will have new filenames and will be generated fresh.

Do not rename existing section slugs after stems have been generated without also deleting the affected stems from stems/, or you will pay double TTS quota for those lines. Use XILP007 (stem migrator) if the script has been revised and section boundaries shifted.


Silent Failure Risk

An unrecognized section header causes silent mislabeling: all entries after it carry the slug of the previous section. This is invisible in normal output — print_summary() only shows which sections were found, not which lines were expected to be in which section.

Mitigation: Always run XILP000 (pre-flight scanner) before XILP001 when onboarding a new script. XILP000 explicitly reports unrecognized section headers and exits with code 1 if any are found.

# Pre-flight scan — catches unrecognized sections before parsing
python XILP000_script_scanner.py "scripts/<script>.md"

# If all sections pass, parse
python XILP001_script_parser.py "scripts/<script>.md" --episode S01E01

After parsing, you can also verify stats["sections"] contains all expected slugs:

python -c "import json; d=json.load(open('parsed/parsed_sample_S01E01.json')); print(d['stats']['sections'])"

SFX Reuse Guide – Minimizing ElevenLabs API Credit Usage

Every second of ElevenLabs SFX generation costs approximately 40 credits. A 30-second ambience asset costs ~1,200 credits. The SFX/ library already contains 250+ shared assets – reusing them costs zero credits.

The pipeline’s SFX engine (sfx_common.py:ensure_shared_sfx()) follows a 3-tier priority:

  1. Cached – shared asset already exists on disk in SFX/ –> return immediately (0 credits)
  2. Source – source field in sfx config points to a file –> copy it (0 credits)
  3. API – no cache, no source –> call ElevenLabs API (~40 credits/second)
MethodCreditsWhen to use
Cached (already generated)0Same effect reused across episodes
source field (existing file)0Asset exists in library under a different name
API generation~40/secondTruly new sound needed

Discovering Available Assets

xil-sfx-lib                      # list all assets with duration and size
xil-sfx-lib --search "diner"     # filter by keyword
xil-sfx-lib --search "coffee"    # find coffee-related effects
xil-sfx-lib --json               # machine-readable output
xil-sfx-lib -v                   # verbose: all metadata fields

Filesystem scan

ls SFX/*.mp3 | head -20          # quick browse
ls -la SFX/ | wc -l              # count assets

Naming conventions

The library contains two naming styles:

  • Cue-sheet asset IDs: sfx-boots-stamp-01.mp3, amb-quarry-winter-01.mp3
  • Descriptive filenames: FOLYProp-Pouring_hot_liquid_i-Elevenlabs.mp3, BELLDoor-Bright_entrance_door-Elevenlabs.mp3

Both work identically with the source field.

Using source in sfx Config JSON

Add a source field to any entry in sfx_<slug>_<TAG>.json to skip API generation entirely. The file is copied to the episode stem directory at generation time.

Examples from production

{
  "effects": {
    "INTRO MUSIC": {
      "source": "SFX/The Porch Light.mp3",
      "volume_percentage": 40,
      "play_duration": 10
    },
    "SFX: COFFEE BEING POURED INTO CERAMIC MUG": {
      "prompt": "Coffee being poured into ceramic mug",
      "duration_seconds": 2.0,
      "source": "SFX/FOLYProp-Pouring_hot_liquid_i-Elevenlabs.mp3"
    },
    "SFX: DINER DOOR OPENS, BELL CHIMES": {
      "prompt": "Classic diner door opening with small bell chime",
      "duration_seconds": 5.0,
      "source": "SFX/BELLDoor-Bright_entrance_door-Elevenlabs.mp3"
    },
    "AMBIENCE: RADIO BOOTH - SOFT EQUIPMENT HUM, SLIGHT STATIC, INTIMATE": {
      "source": "SFX/Invitation to Action.mp3",
      "prompt": "Radio booth ambience, soft equipment hum, slight static",
      "duration_seconds": 30.0,
      "loop": false
    }
  }
}

Rules

  • Path: always relative to project root (starts with SFX/)
  • prompt: keep it for documentation even when source is set – it has no effect on generation
  • Mixing fields: volume_percentage, loop, play_duration, ramp_in_seconds, ramp_out_seconds all work the same with source as with API-generated assets
  • Duration: duration_seconds is used for mixing calculations; it is not validated against the actual file length when source is set

Writing Cues Sheets with REUSE Markers

In the cues markdown file, mark assets that already exist in SFX/ as (REUSE) so xil-cues --generate skips them.

Heading format (MUSIC CUES and AMBIENCE sections)

### **MUS-THEME-MAIN-01 (REUSE)**
**Prompt:** Eerie indie folk theme, acoustic guitar **Duration:** 60 seconds **Used:** Cold open

### **MUS-STING-NEW-01 (NEW)**
**Prompt:** Brief hopeful musical release **Duration:** 5 seconds **Used:** Scene 1

Table format (SOUND EFFECTS section)

| Asset Name | Prompt | Placement |
| ----- | ----- | ----- |
| SFX-DOOR-BELL-01 (REUSE) | Diner door opening with bell chime | Karen's entrance |
| SFX-BOOTS-STAMP-01 (NEW) | Snow being stamped off boots on doormat | Karen entering |

Best practice: run xil-sfx-lib --search "keyword" before writing the cues sheet to check what already exists in the library.

Script Directions and sfx Config Keys

The parser (XILP001) extracts direction text verbatim as the text field in parsed JSON. This text becomes the key in the sfx config effects dict:

Script direction:     [SFX: DINER DOOR OPENS, BELL CHIMES]
                          |
Parsed JSON text:     "SFX: DINER DOOR OPENS, BELL CHIMES"
                          |
sfx config key:       "SFX: DINER DOOR OPENS, BELL CHIMES": { "source": "SFX/..." }

When writing a new episode script, reuse the exact same direction text from previous episodes to match existing sfx config entries. This lets you copy source fields across episode configs without modification.

New Episode Workflow Checklist

  1. Audit the library

    xil-sfx-lib --search "keyword"
    

    Check for each sound effect you plan to use.

  2. Write the cues sheet – mark every asset that exists in SFX/ as (REUSE).

  3. Dry-run the cues ingester

    xil-cues --episode S0xExx --dry-run
    

    Review the audit report. Check credit estimate for NEW assets.

  4. Write the production script – use direction text matching existing sfx config keys where possible.

  5. Parse the script

    xil-parse scripts/script.md --episode S0xExx
    

    This auto-generates a skeleton sfx config if one doesn’t exist.

  6. Add source fields – for each effect that exists in SFX/, add a "source": "SFX/filename.mp3" entry to the sfx config JSON.

  7. Dry-run SFX generation

    xil-sfx --episode S0xExx --dry-run
    

    Verify reused assets show CACHED (0 credits) not NEW.

  8. Generate only what is truly new

    xil-sfx --episode S0xExx
    

Quick Reference

GoalCommand / Action
List all SFX assetsxil-sfx-lib
Search for a soundxil-sfx-lib --search "keyword"
Machine-readable asset listxil-sfx-lib --json
Skip API for an effectAdd "source": "SFX/filename.mp3" to sfx config
Mark cue as reuseAppend (REUSE) to asset ID in cues sheet
Preview SFX credit costxil-sfx --episode TAG --dry-run
Preview cues credit costxil-cues --episode TAG --dry-run
Enrich sfx config from cuesxil-cues --episode TAG --enrich-sfx-config

eleven_v3 Audio Events vs. SFX Stems

Certain sounds that previously required a [SFX: ...] direction and a generated stem can now be handled inline by the TTS model using v3 audio tags embedded in dialogue text:

Old approach (SFX stem, costs credits)v3 inline tag (free, in voice stem)
[SFX: ADAM SIGHS HEAVILY][sighs] embedded in Adam’s dialogue text
[SFX: NERVOUS LAUGH][chuckles] or [laughs] in dialogue
[SFX: SHARP INTAKE OF BREATH][gasps] in dialogue

When to still use SFX stems: for sounds between speakers, overlapping with ambience, or effects that need precise mix placement (volume, timing relative to other tracks). Inline tags render inside the single voice stem and cannot be mixed independently in the DAW layers.

See claude-scriptwriter-reference.md for the full v3 tag vocabulary.

xil-pipeline Script & Cues Writing Reference

You are helping write production scripts and sound cues sheets for an audio drama podcast. The scripts you produce will be processed by the xil-pipeline toolset, which generates voice audio via ElevenLabs TTS and sound effects via the ElevenLabs Sound Effects API.

Critical constraint: Every second of SFX generation costs ~40 API credits. The project has an existing SFX library with 250+ pre-generated assets. You MUST reuse these assets whenever possible instead of inventing new sounds. The SFX inventory is provided as a companion JSON file.

Production Script Format

The pipeline parser expects this exact markdown format:

Show Name Season N: Episode N: "Episode Title" Arc: "Season Arc Title"

CAST:
* CHARACTER_NAME — brief description
* ANOTHER_CHARACTER — brief description

===

COLD OPEN

SCENE 1: LOCATION NAME

[AMBIENCE: Description of environmental sound]

CHARACTER_NAME
Dialogue line here.

[SFX: Description of sound effect]

CHARACTER_NAME (acting direction)
More dialogue here.

[BEAT]

[BEAT — 3 SECONDS]

===

ACT ONE

SCENE 2: ANOTHER LOCATION

[AMBIENCE: New environment description]

...

===

END OF EPISODE

Key format rules

  • Header line: Show Name Season N: Episode N: "Title" Arc: "Season Arc Title" (first line, no markup; Arc: is optional but enables {season_title} in preamble/postamble)

  • Cast block: * NAME — description (one per line, before first ===)

  • Section dividers: === on its own line

  • Section headers (on own line, no brackets) — must be one of the recognised names below; unrecognised headers are silently ignored (run xil scan <script.md> to catch them):

    HeaderNotes
    PREAMBLEBroadcast intro — place before COLD OPEN; seq numbers are contiguous with the episode
    COLD OPEN
    OPENING CREDITS
    ACT ONE / ACT 1Numeral variants accepted
    ACT TWO / ACT 2
    ACT THREE / ACT 3
    ACT FOUR / ACT 4
    MID-EPISODE BREAK
    CLOSINGVariants: CLOSING — RADIO STATION, CLOSING — ADAM'S SIGN-OFF
    POST-INTERVIEWBonus commentary after the episode closes
    POST-CREDITS SCENE
    PRODUCTION NOTESInternal notes; included in parsed output
    POSTAMBLEBroadcast outro — place after the last section, before END OF EPISODE
    CHAPTER ONE / CHAPTER 1Audiobook format
    CHAPTER TWO / CHAPTER 2Audiobook format
    CHAPTER THREE / CHAPTER 3Audiobook format
  • Scene headers: SCENE N: LOCATION NAME (on own line)

  • Dialogue: Speaker name on one line, dialogue text on the next

  • Acting directions: In parentheses after speaker name: CHARACTER (whispering)

  • Directions: In square brackets: [SFX: ...], [AMBIENCE: ...], [MUSIC: ...], [BEAT], [BEAT — N SECONDS], [VINTAGE FILTER ENGAGES], [VINTAGE FILTER DISENGAGES], [FILM AUDIO ENGAGES], [FILM AUDIO DISENGAGES], [SPEAKERPHONE ENGAGES], [SPEAKERPHONE DISENGAGES], [PHONE FILTER: ENGAGES <speaker>], [PHONE FILTER: DISENGAGES]

  • End marker: END OF EPISODE (stops parsing)

  • Ambience stop: [AMBIENCE: STOP] or [AMBIENCE: description FADES OUT] to end a looping ambience

Direction Text Is an SFX Config Key

Every [SFX: ...], [MUSIC: ...], [AMBIENCE: ...], and [BEAT] direction becomes the exact key in sfx_<slug>_TAG.json. Matching is case-sensitive, punctuation-sensitive, and Unicode-sensitive.

Rules:

  • Reuse text verbatim across episodes. If [SFX: DINER DOOR OPENS, BELL CHIMES] appeared in S02E01, use that exact string in every subsequent episode — the shared asset in SFX/ is reused automatically, costing zero API credits.
  • Use commas, not em dashes, as separators in MUSIC/AMBIENCE descriptions. Write [MUSIC: SHOW THEME, FADES UNDER ADAM] — not [MUSIC: SHOW THEME — FADES UNDER ADAM]. Em dashes (—) and hyphens (-) look similar in many editors but produce different config keys, silently orphaning the stem so the old file is never regenerated.
  • Avoid trailing punctuation differences — [SFX: DOOR OPENS] and [SFX: DOOR OPENS.] are different keys.
  • Capitalisation matters — [SFX: PHONE BUZZ] ≠ [SFX: phone buzz].

Audit after writing a new episode:

xil csv-join --episode S03E02

The annotated CSV shows which direction entries matched an SFX config key and which did not. Blank SFX prompt columns indicate a key mismatch — fix the script text or the config before running xil produce.

eleven_v3 Audio Tags (Inline Dialogue Modifiers)

The pipeline runs voice generation through ElevenLabs eleven_v3, which supports an inline tag system for controlling emotion, pacing, and vocal performance. These tags are embedded inside dialogue text — they are not stage directions.

Critical distinction: Stage directions use square brackets on their own line:

[SFX: DOOR OPENS]          ← stage direction, parsed separately, triggers SFX stem

Audio tags use square brackets within the spoken text on the dialogue line:

ADAM
I've been here all night. [exhausted] Every. Single. Night.

Pause tags (v3 equivalent of [BEAT])

TagEffect
[pause]Standard beat pause
[short pause]Brief hesitation
[long pause]Extended silence

Use these in preference to [BEAT — N SECONDS] stage directions when the pause is character-driven rather than a production beat. They render inline within the voice stem rather than creating a separate silence stem.

Audio events (non-speech sounds)

TagEffect
[laughs]Laughter
[chuckles]Soft laugh
[sighs]Audible sigh
[gasps]Sudden intake of breath
[gulps]Nervous swallow
[coughs]Single cough
[crying]Crying or tearful quality

These inject a rendered audio event at the exact position in the spoken line — no separate SFX stem needed, no API credits consumed from the SFX budget.

Emotional delivery

TagEffect
[excited]Energised, enthusiastic
[nervous]Anxious, hesitant quality
[frustrated]Irritated, clipped
[exhausted]Tired, heavy delivery
[sorrowful]Sad, weighted
[calm]Measured, quiet
[resigned tone]Accepting defeat

Tone and attitude

TagEffect
[whispers]Hushed delivery
[deadpan]Flat, dry
[sarcastic]Ironic, knowing
[cheerfully]Bright, upbeat
[playfully]Light, teasing
[curious]Questioning, open

Delivery and pacing

TagEffect
[rushed]Faster cadence
[drawn out]Stretched, deliberate
[hesitates]Momentary stumble
[stammers]Stuttering delivery

Usage rules

  • Tags affect all following text until the next tag overrides them
  • Tags can be stacked: [nervous][hushed] applies both qualities
  • Place tags at the natural transition point: I'm fine. [resigned tone] Totally fine.
  • Do not put a v3 tag alone on its own line — the parser reads a bare [tag] line as an unknown stage direction and skips it. Keep tags embedded mid-text or at the very start of the dialogue text line, not on a line by themselves.
  • Punctuation amplifies tag effects — ellipses and commas create natural breath points

Example

ADAM (exhausted)
Everything is fine. [long pause] I keep telling myself that. [sighs] Maybe one day
I'll believe it.

JESS
[curious] What happened to you tonight?

ADAM
[laughs] Nothing. [pause] [resigned tone] Everything.

Using Existing SFX Assets in Scripts

When writing a direction that matches an existing asset in the SFX library, include a filename hint using a pipe separator:

[SFX: DINER DOOR OPENS, BELL CHIMES | BELLDoor-Bright_entrance_door-Elevenlabs.mp3]

[SFX: COFFEE BEING POURED INTO CERAMIC MUG | FOLYProp-Pouring_hot_liquid_i-Elevenlabs.mp3]

[AMBIENCE: RADIO BOOTH - SOFT EQUIPMENT HUM, SLIGHT STATIC, INTIMATE | ambience_radio-booth-soft-equipment-hum-slight-static-intimate.mp3]

[MUSIC: EERIE INDIE FOLK THEME, FADES UNDER | music_mus-theme-main-01-eerie-indie-folk-fades-under.mp3]

The format is: [TYPE: DESCRIPTION | filename.mp3]

  • The description before the pipe becomes the sfx config key
  • The filename after the pipe tells the operator which file from SFX/ to assign as the source
  • If no matching asset exists, omit the pipe and filename — it will be generated via API

Playback hints — volume and duration

Two optional key=value segments let you set playback from the script, so the operator does not have to fix it by hand after every parse. Add them as extra pipe segments, in any order, with or without a filename. The % sign is optional.

HintRangeWhat it does
play_volume_pct=20%0–200Playback level. 100 is unity; 20 is quiet under dialogue.
play_duration_pct=35%0–100How much of the file to play, from the start. 35 plays the first third.
[OUTRO MUSIC | The Porch Light.mp3 | play_volume_pct=20% | play_duration_pct=35%]

[MUSIC: STING | play_volume_pct=40%]

[INTRO MUSIC | The Porch Light.mp3 | play_duration_pct=35%]

Use play_duration_pct on any music bed you drop in by filename. The pipeline gives a new cue a default length of 5 seconds, which will cut a long track short. A play_duration_pct hint replaces that default, and it is the setting that survives a later re-parse.

Notes:

  • play_duration_pct does nothing on AMBIENCE: or VINTAGE FILTER cues — those loop to fill the scene. It is dropped with a warning. play_volume_pct still works on them.
  • BEAT cues are silence, so they take neither hint.
  • A value outside the range, or a non-number, is warned about and ignored. The parse does not fail.

How to find matching assets — VERBATIM FILENAMES ONLY

⛔ Never construct or guess a filename. Even an obvious-seeming name like sfx_radio-static-click-off.mp3 may be wrong — the real file is sfx_radio-click-off.mp3. Invented filenames cause production failures (“SFX source file missing” errors).

Workflow — use this order:

  1. Open sfx_pipe_hints.md (included in this kit) and search for the sound you need. Each line is a ready-to-paste pipe-hint: [DIRECTION TEXT | filename.mp3]
  2. Copy the matching line verbatim — both the direction text and the filename. Do not paraphrase.
  3. If no match in sfx_pipe_hints.md: check sfx_inventory.json by searching the filename and prompt fields for keywords.
  4. If still no match: write the direction without a pipe-hint. Mark the asset as (NEW) in the cues sheet. Do NOT guess at a filename.

sfx_pipe_hints.md is the primary reference. Use the JSON only for untitled assets (those that appear in the “Untitled Assets” section of the cheatsheet).

When to reuse vs. generate new

Always reuse when:

  • The same type of sound exists (door opening, coffee pour, footsteps, phone buzz)
  • An ambience for the same location type exists (diner, radio booth, outdoor)
  • A music theme or sting has been established for recurring use

Generate new only when:

  • No similar sound exists in the library
  • The scene requires a very specific sound not covered by existing assets
  • A unique musical cue is needed for a new emotional moment

Cues Sheet Format

The cues sheet is a separate markdown document that catalogs all sound assets needed for an episode. It has three sections:

MUSIC CUES (heading blocks)

## **MUSIC CUES**

### **MUS-THEME-MAIN-01 (REUSE)**
**Prompt:** Eerie indie folk theme, acoustic guitar with subtle synth, mysterious but warm, late-night radio feel **Duration:** 60 seconds **Used:** Cold open, closing

### **MUS-STING-NEW-01 (NEW)**
**Prompt:** Brief hopeful musical release, tension dissolving into warmth **Duration:** 5 seconds **Used:** Scene 1 resolution

AMBIENCE (heading blocks)

## **AMBIENCE**

### **AMB-DINER-MORNING-01 (REUSE)**
**Prompt:** Morning diner ambience, coffee machine hissing, occasional plate clink, subdued atmosphere **Duration:** Loop **Used:** Scene 1

### **AMB-QUARRY-WINTER-01 (REUSE)**
**Prompt:** Winter wind moaning through marble canyon, vast outdoor space, twilight **Duration:** Loop **Used:** Scene 3

SOUND EFFECTS (tables per scene)

## **SOUND EFFECTS**

### Scene 1: Morrison's Diner

| Asset Name | Prompt | Placement |
| ----- | ----- | ----- |
| SFX-DOOR-BELL-01 (REUSE) | Classic diner door opening with small bell chiming | Karen's entrance |
| SFX-BOOTS-STAMP-01 (REUSE) | Snow being stamped off boots on doormat | Karen entering |
| SFX-COFFEE-POUR-01 (REUSE) | Coffee being poured into ceramic mug | Waitress refilling |
| SFX-WHISPER-ECHO-01 (NEW) | Ethereal whispered voice with unnatural reverb | The anomaly begins |

Cues sheet rules

  • Asset ID format: TYPE-DESCRIPTION-NN (e.g., SFX-DOOR-BELL-01, MUS-THEME-MAIN-01, AMB-DINER-MORNING-01)
  • (REUSE): Asset exists in the SFX library — will NOT be generated via API
  • (NEW): Asset needs to be generated — will cost API credits
  • Prompt: The ElevenLabs generation prompt (kept for documentation even on REUSE assets)
  • Duration: Seconds for one-shot effects, Loop for ambience that tiles continuously
  • Used: Where in the episode this asset appears
  • API limit: Maximum generation duration is 30 seconds. Assets longer than 30s will be capped

Common Reusable Asset Categories

These categories of sounds are well-represented in the existing library. Always check the inventory before writing a (NEW) entry:

Ambience

  • Diner/cafe (morning, evening, quiet, busy)
  • Radio booth/studio (various intimacy levels)
  • Outdoor winter (wind, quarry, walking)
  • Indoor (sitting room, fireplace, old building settling)
  • City street (morning, rain, distant traffic)

Foley / SFX

  • Coffee (pouring, mug set down, machine gurgling)
  • Doors (diner door with bell, wooden door, soft close, key turning)
  • Footsteps (wood floor, snow, linoleum, stairs, heels, boots)
  • Paper (rustling, sliding, envelope tear, unfolding)
  • Phone (buzzing, vibrating, different tones)
  • Furniture (chair creak, booth sliding, couch sitting)
  • Fire (crackling, popping, continuous)
  • Clock ticking
  • Clothing (coat rustling, parka, fabric)

Music

  • Main theme variations (full, brief sting, fades under, warm version)
  • Emotional swells (strings, piano)
  • Contemplative underscore
  • Tension/relief stings
  • Cosmic/ambient tones
  • Intro/outro themes

Utility

  • beat.mp3 — standard 1-second silence/transition
  • long-beat.mp3 — extended silence

Preamble and Postamble

The episode open and close announcements are written directly in the production script as PREAMBLE and POSTAMBLE section blocks. Place PREAMBLE before COLD OPEN and POSTAMBLE after the final episode section, before END OF EPISODE.

===

PREAMBLE

TINA  This is the Berkshire Talking Chronicle…
TINA  Today on The 4 1 3, Season 4, Episode 4, Porch.
TINA  Thank you for listening.

[INTRO MUSIC]

===

COLD OPEN
…

===

POSTAMBLE

[OUTRO MUSIC]

TINA  This is Tina Brissette, your host for The 4 1 3…

===

END OF EPISODE

The cast config preamble/postamble blocks control TTS speed only — no text is stored there. The block must specify speaker (a cast key) and optionally speed (default 1.0):

"preamble": { "speaker": "tina", "speed": 0.85 },
"postamble": { "speaker": "tina", "speed": 0.85 }

Use native v3 audio tags ([pause], [long pause]) inline in the dialogue text within the script section. SSML (<break time="1s"/>) is not supported in eleven_v3.

Vintage Filter

Use [VINTAGE FILTER: ENGAGES] and [VINTAGE FILTER: DISENGAGES] span markers to route a character’s voice through the vintage filter DAW layer (telephone / radio effect):

[VINTAGE FILTER: ENGAGES]

DISPATCH
(through radio, clipped)
Marlowe. Second floor found something. You'll want to come up.

[VINTAGE FILTER: DISENGAGES]

In the cast config, mark the character with "filter": "vintage":

"dispatch": {
  "full_name": "Dispatch",
  "voice_id": "...",
  "pan": 0.0,
  "filter": "vintage",
  "role": "Voice only, heard through Marlowe's radio — VINTAGE FILTER"
}

Treated dialogue stays in the dialogue layer. The separate vintage_filter WAV layer written by XILP005 / XILP011 carries record-player crackle for the span, not the voices.

Note: the pairing validator matches [VINTAGE FILTER ENGAGES] without the colon, which is the spelling every production script uses. The colon form above is accepted by the mixer but is invisible to xil scan, so prefer the colon-free spelling in new scripts.

Film Audio

Use [FILM AUDIO ENGAGES] and [FILM AUDIO DISENGAGES] span markers for dialogue heard from a film print — warm and reflective, with a rolled-off top end and an audible grain:

[FILM AUDIO ENGAGES]

MARGARET
(warm, at peace)
It was a good year. The best one, maybe.

[FILM AUDIO DISENGAGES]

Or mark a character whose every line is film audio with "filter": "film" in the cast config, which needs no span markers at all:

"margaret": {
  "full_name": "Margaret Ellis",
  "voice_id": "...",
  "pan": 0.0,
  "filter": "film",
  "role": "Heard only through the restoration print — FILM AUDIO"
}

Both spellings ([FILM AUDIO ENGAGES] and [FILM AUDIO: ENGAGES]) are accepted. Markers must be paired — an unclosed span fails xil scan. They generate no audio and cost no API credits.

Avoid doing both for the same character: a speaker with "filter": "film" speaking inside a FILM AUDIO span is treated twice, which doubles the compression and grain.

Speakerphone

Narrow-band with saturation, hard levelling and a short room slap — a handset lying on a table. It is a separate chain from phone, not a stronger version of it.

Use [SPEAKERPHONE ENGAGES] / [SPEAKERPHONE DISENGAGES] around the lines coming out of the phone. The span treats every line it encloses, so in a call scene bracket each remote line rather than the whole call — otherwise the people in the room get filtered too:

[SPEAKERPHONE ENGAGES]

KAREN
Where are you? I've been calling.

[SPEAKERPHONE DISENGAGES]

ADAM
(to Maya, off the phone)
Don't say anything.

[SPEAKERPHONE ENGAGES]

KAREN
Adam. I can hear you.

[SPEAKERPHONE DISENGAGES]

This is the reason to prefer markers over the cast config: a character who is on the phone in one scene and in the room in another needs no second cast entry. The script alone decides, line by line.

Setting "filter": "speakerphone" on the character still works and is the better choice when a voice is only ever heard through a phone for the whole episode.

Both spellings ([SPEAKERPHONE ENGAGES] and [SPEAKERPHONE: ENGAGES]) are accepted. Markers must be paired — an unclosed span fails xil scan. They generate no audio and cost no API credits.

Phone Calls — PHONE FILTER

Use this for an ordinary phone call: a voice heard down the line. It is a mobile call — narrow, gritty, levelled hard, and run through the real GSM codec, so it reads as a phone immediately rather than as a muffled voice. It sits slightly under the person in the room, which is what sells the distance.

Reach for SPEAKERPHONE instead when the call is on a speaker in the room — that one adds saturation and a tabletop slap. PHONE FILTER is a voice against an ear; SPEAKERPHONE is a small loudspeaker on a table.

[PHONE FILTER: ENGAGES DEZ]

DEZ
(filtered, through phone)
Rían. It's midnight.

RÍAN
I know. You at the diner still?

[PHONE FILTER: DISENGAGES]

Name the remote speaker on the ENGAGES marker. A call alternates the voice on the phone with the voice in the room, and without a name the span filters every line it encloses — including the in-room half of the conversation. Naming the speaker lets you wrap the whole call in one marker pair, which is how a call actually reads on the page:

MarkerEffect
[PHONE FILTER: ENGAGES DEZ]only DEZ’s lines inside the span are filtered
[PHONE FILTER: ENGAGES]every line inside the span is filtered

The speaker name is the cast key, and it works on SPEAKERPHONE, FILM AUDIO and VINTAGE FILTER spans too. The closing marker may repeat the name or not.

Both spellings are accepted, markers must be paired, and like the other span markers they generate no audio and cost no API credits.

Setting "filter": "phone" on the character still works and is the better choice when a voice is only ever heard through a phone. If you do both, the treatment is applied once, not twice.

Consistency Guidelines

  1. Reuse the same asset ID when the same sound recurs within an episode (e.g., every diner scene uses AMB-DINER-MORNING-01)
  2. Reuse asset IDs across episodes for recurring locations and effects — this is the primary credit-saving mechanism
  3. Keep direction text consistent — [SFX: DINER DOOR OPENS, BELL CHIMES] should be the same text every time that sound is needed, across all episodes
  4. Match existing sfx config keys — when a previous episode used a specific direction text, reuse it exactly so the source mapping carries over
  5. Prefer specific descriptions — [SFX: CERAMIC MUG SET DOWN - GENTLE] is better than [SFX: sound of a cup] because it maps to a specific asset

Pipeline Reference

Section names keep the XILP/XILU numbers of the Python modules each command was ported from; xil --help prints the same tags. Every command here runs as xil <command>.

Documentation of the nine-stage automated podcast production pipeline, including the cues sheet ingester pre-processing step, stem migration punch-in workflow, and stale stem cleanup.


1. End-to-End Overview

flowchart TD
    S["`📄 scripts/*.md
    Production script markdown`"]
    C["`📋 cast_sample_S01E01.json
    Voice ID + pan + filter per character`"]
    P1["XILP001_script_parser.py"]
    J["`📦 parsed/parsed_sample_S01E01.json
    127 dialogue entries + stats`"]

    CQ["`📋 cues/*.md
    Sound cues & music prompts`"]
    P6["XILP006_cues_ingester.py"]
    SFXCFG["`📋 sfx_sample_S01E01.json
    SFX config (prompts + durations)`"]
    SFXLIB["`🎵 SFX/*.mp3
    Shared SFX asset library`"]
    MNFST6["`📦 cues/cues_manifest_*.json
    Structured asset catalog`"]
    DRY6["`--dry-run
    Audit report, no API calls
    Manifest always written`"]

    P2["XILP002_producer.py"]
    P3["XILP003_audio_assembly.py"]
    DRY["`--dry-run
    Preview lines + TTS cost
    No API calls`"]
    ST["`🎙️ stems/S01E01/*.mp3
    001_cold-open_adam.mp3 …`"]
    OUT["🎧 sample_S01E01_master.mp3"]
    MIX["mix_common.py"]

    S --> P1 --> J
    CQ --> P6
    P6 --> MNFST6
    P6 -->|"--generate"| SFXLIB
    P6 -->|"--enrich-sfx-config"| SFXCFG
    P6 -->|"--dry-run"| DRY6
    SFXCFG --> P2
    SFXLIB --> ST

    XILU004["XILU004_sample_voices_T2S.py"]
    VSAMPLES["`🎙️ voice_samples/<TAG>/<backend>/
    <actor>.mp3 — audition samples`"]
    C --> XILU004
    XILU004 --> VSAMPLES

    XILU005["XILU005_discover_SFX.py"]
    SFXLIB --> XILU005

    XILU006["XILU006_splice_parsed.py"]
    JSPLICE["`📋 parsed/pre_splice_parsed_*.json
    Backup before splice`"]
    J --> XILU006
    XILU006 --> J
    XILU006 --> JSPLICE

    XILU003["XILU003_csv_sfx_join.py"]
    ANNOT["`📋 parsed/annotated_*.csv
    Script + SFX/cast metadata joined`"]
    J --> XILU003
    SFXCFG --> XILU003
    C --> XILU003
    XILU003 --> ANNOT

    P7["XILP007_stem_migrator.py"]
    JORIG["`📦 parsed/orig_parsed_*.json
    Previous parsed version`"]
    MIGR["`stems/<TAG>/*.mp3
    unchanged stems copied to new seq names`"]
    JORIG --> P7
    J --> P7
    P7 --> MIGR

    P8["XILP008_stale_stem_cleanup.py"]
    CLEAN["`Delete stale stems
    seq/type mismatches removed`"]
    MIGR --> P8
    J --> P8
    P8 --> CLEAN
    CLEAN --> P2

    C --> P2
    J --> P2
    P2 -->|"--dry-run"| DRY
    P2 --> ST
    C --> P3
    ST --> P3
    J --> P3
    MIX --> P3
    P3 --> OUT

    P4["XILP004_studio_onboard.py"]
    STUDIO["`🎬 ElevenLabs Studio Project
    Chapters with voice-tagged nodes`"]
    DRY4["`--dry-run
    Preview chapters + voice map
    No API calls`"]

    J --> P4
    C --> P4
    P4 -->|"--dry-run"| DRY4
    P4 --> STUDIO

    P5["XILP005_daw_export.py"]
    VIZ["timeline_viz.py"]
    DAW["`🎚️ daw/S01E01/
    layer_dialogue.wav + labels
    layer_ambience.wav + labels
    layer_music.wav + labels
    layer_sfx.wav + labels
    (ID3 metadata tagged)`"]
    DRY5["`--dry-run
    Show stem counts + paths
    No files written`"]
    MACRO["`--macro → Audacity macro
    SAMPLE_S01E01.txt
    (WAV import only, Audacity 3)`"]
    TL5["`--timeline
    ASCII timeline to stdout`"]
    TLHTML5["`--timeline-html
    S01E01_timeline.html
    (interactive, self-contained)`"]

    ST --> P5
    J --> P5
    C --> P5
    MIX --> P5
    VIZ --> P5
    P5 -->|"--dry-run"| DRY5
    P5 --> DAW
    P5 -->|"--macro"| MACRO
    P5 -->|"--timeline"| TL5
    P5 -->|"--timeline-html"| TLHTML5

2. XILP001 — Script Parser Internals

flowchart TD
    IN["📄 Production script .md"]
    ESC["`strip_markdown_escapes()
    Removes backslash escapes: bracket, equals, period`"]
    FMT["`strip_markdown_formatting()
    Removes ## headings, **bold**, trailing breaks`"]
    LINES["Split into lines"]
    SKIP["`Skip CAST section
    Skip title line
    Skip === / --- dividers`"]

    LINES --> SKIP --> LOOP

    subgraph LOOP["Line-by-line state machine"]
        direction TB
        PEND{"`pending_speaker?
        multi-line dialogue`"}
        PDIR["`(direction) line
        update pending direction`"]
        PTXT["`Spoken text line
        create dialogue entry`"]
        CHK{"Classify line"}
        SEC["`Section header
        COLD OPEN / OPENING CREDITS / ACT ONE
        update current_section`"]
        SCN["`Scene header
        SCENE N:
        update current_scene`"]
        DIR["`Stage direction
        SFX / MUSIC / AMBIENCE / BEAT / VINTAGE FILTER
        direction entry`"]
        DLG["`SPEAKER text
        dialogue entry (single-line)
        or set pending_speaker (multi-line)`"]
        CONT["`Bare continuation text
        append to previous dialogue
        filter standalone (parentheticals)`"]
        STOP["`END OF EPISODE
        END OF PRODUCTION SCRIPT
        or PRODUCTION NOTES — break`"]

        PEND -->|"(dir)"| PDIR
        PEND -->|text| PTXT
        CHK -->|section header| SEC
        CHK -->|scene header| SCN
        CHK -->|bracket line| DIR
        CHK -->|known speaker| DLG
        CHK -->|bare text| CONT
        CHK -->|metadata or end| STOP
    end

    IN --> ESC --> FMT --> LINES
    LOOP --> ENTRIES

    subgraph ENTRIES["Output entries list"]
        direction LR
        E1["`seq · type · section · scene
        speaker · direction · text
        direction_type`"]
    end

    ENTRIES --> STATS["`Compute stats
    total_entries · dialogue_lines
    characters_for_tts · speakers`"]
    STATS --> JSON["📦 parsed_sample_S01E01.json"]

Direction pipe-hints

Scriptwriters can annotate any bracketed direction with pipe-separated hints, which _parse_direction_hint() strips from the direction text and carries into sfx_<TAG>.json:

[<DIRECTION TEXT> | <file>.mp3|.wav | <key>=<value> | …]
SegmentEffect
ends .mp3 / .wavBecomes the cue’s source (SFX/<slug>/<filename>). First filename wins.
play_volume_pct=20%Becomes the cue’s volume_percentage (0–200; the % is optional).
play_duration_pct=35%Becomes the cue’s play_duration (0–100; the % is optional).
anything elseNot consumed — re-joined onto the direction text, so a prose note survives verbatim.

Segments are classified independently, so order does not matter and any part may appear alone:

[SFX: RADIO STATIC — BRIEF TUNING | sfx_radio-static-tuning-transition.mp3]
[OUTRO MUSIC | sundy3M4_v3_(tech)_1.05mn_125bpm_-_TAGEO.mp3 | play_volume_pct=20%]
[MUSIC: STING | play_volume_pct=40%]
[INTRO MUSIC | The Porch Light.mp3 | play_duration_pct=35%]

The hints land on the parsed entry as sfx_source and sfx_overrides, then flow into the SFX config through generate_sfx_config() (fresh config) or backfill_sfx_sources() / xil sfx-hydrate (existing config). Precedence differs between the two: a source hint never replaces one already in the config, while attribute hints overwrite it — the script is authoritative for playback settings. A malformed or out-of-range value logs a warning and is dropped rather than failing the parse, and silence cues (BEAT) ignore attribute hints entirely.

New attributes are added by extending HINT_ATTRS in XILP001_script_parser.py; the rest of the chain is generic.

Speaker normalization

flowchart LR
    RAW["`speakers.json / built-in list
    Ordered longest-first
    Compound names before simple`"]
    RAW --> MATCH{"`startswith match
    space, paren, or end follows?`"}
    MATCH -->|yes| KEY["`SPEAKER_KEYS lookup
    ADAM → adam
    MR. PATTERSON → mr_patterson
    FILM AUDIO (MARGARET'S VOICE) → film_audio
    STRANGER (MALE VOICE, FLAT) → stranger
    KAREN → karen · SARAH → sarah`"]
    MATCH -->|no| SKIP2["try next speaker"]
    KEY --> MODE{"`spoken_text empty?`"}
    MODE -->|yes| PEND["`pending_speaker state
    await direction/text on next lines`"]
    MODE -->|no| ENTRY["`dialogue entry (single-line)
    speaker = normalized key`"]

3. XILP002 — Voice Generation

sequenceDiagram
    actor User
    participant M as main
    participant LP as load_production
    participant SFX as sfx_config
    participant QG as Quota Guard
    participant API as ElevenLabs API
    participant FS as stems directory
    participant PJ as parsed JSON

    User->>M: xil produce --episode S02E03 [--gen-sfx / --gen-music / --gen-ambience]
    M->>LP: load cast_sample_S02E03.json + parsed script
    LP-->>M: config dict, dialogue_entries list
    M->>QG: get_best_model_for_budget
    QG-->>M: eleven_v3 or eleven_flash_v2_5

    loop each dialogue entry from start_from up to stop_at (inclusive)
        M->>FS: stem file exists?
        alt already on disk
            FS-->>M: skip, no API call
        else voice_id is TBD
            M->>M: skip, warn user
        else
            M->>QG: has_enough_characters(text)
            alt quota exhausted
                QG-->>M: False, halt with message
            else quota OK
                M->>API: text_to_speech.convert(text, voice_id, model)
                API-->>M: audio_stream chunks
                M->>FS: write {seq:03d}_{section}_{speaker}.mp3
                M->>FS: tag_mp3 (Album, Genre, Year, Title, Artist, Lyrics)
            end
        end
    end

    M-->>User: Generation complete, N new stems

Range control: --start-from N resumes an interrupted run by skipping entries with seq < N. --stop-at N halts after seq N (inclusive). Combine them (--start-from 50 --stop-at 80) to regenerate a specific scene without touching the rest of the episode.

Draft mode: --backend gtts routes all dialogue voice stems through Google Translate TTS at no cost — all characters use the same flat voice, useful for checking episode duration before spending ElevenLabs credits. No API key required. eleven_v3 inline tags are stripped automatically. SFX/music/ambience generation is unaffected. Needs network access to Google Translate.

Local voice clones: --backend chatterbox-turbo runs local GPU TTS in venv-chatterbox/ (driven by chatterbox_turbo_worker.py), cloning each character from voice_refs/<key>.wav. Build that venv with xil setup chatterbox (XILU023), which picks CUDA or CPU PyTorch wheels. It natively renders 19 paralinguistic cues (see below) and strips every other bracketed tag. Requires reference clips >5 s, and caches conditionals as voice_refs/<key>.turbo.conds.pt.

Classic chatterbox was removed in #62; --backend chatterbox is a deprecated alias that warns and generates with Turbo. The .turbo. cache suffix is deliberately kept distinct: Turbo conditionals are not interchangeable with the classic .conds.pt files still on disk. Historical stems and logs recording backend: chatterbox remain valid and are still parsed by xil-stem-log.

CPU fallback: --device cuda|cpu (default cuda) is rarely needed — chatterbox_turbo_worker.py auto-detects a missing/broken CUDA install and falls back to cpu on its own (slower, but functional), logging the fallback and reporting the actual device in its ready signal. Pass --device cpu explicitly only to force CPU on a machine where a GPU is available (e.g. it’s busy with another job) — an explicit cpu request is never overridden back to cuda.

Chatterbox Turbo paralinguistic tags

ResembleAI/chatterbox-turbo carries dedicated tokens for exactly these 19 cues (IDs 50257–50275 in the model’s added_tokens.json). Write them inline in dialogue; chatterbox_turbo_worker.py keeps them and strips every other bracketed token before generation.

CategoryTags
Emotion[angry] [fear] [surprised] [happy] [crying] [sarcastic]
Delivery style[whispering] [dramatic] [narration] [advertisement]
Vocal gesture[laugh] [chuckle] [sigh] [gasp] [groan] [cough] [sniff] [shush] [clear throat]
ADAM
[sarcastic] Oh, that went perfectly. [sigh]

Spelling is exact — there are no plural forms. [laugh] is a token; [laughs], [chuckles], and [coughs] are not and get stripped. [clear throat] keeps the space; [clears throat] and [throat clearing] are stripped. Matching is case-insensitive. Because unknown tags are removed, ElevenLabs-only tags such as [exhausted] and [pause] can stay in a shared script — honoured under --backend elevenlabs, dropped under chatterbox-turbo.

xil scan catches these mistakes before any audio is generated — its PARALINGUISTIC TAG NEAR-MISSES section flags [laughs], [clears throat], [surprise] and similar with the correct token to use. The check is advisory and never changes the exit code, since the same script may be produced through ElevenLabs where those tags mean something else.

Audition a cue without a full run:

xil sample --episode S01E01 --backend chatterbox-turbo \
    --sample-text "[sarcastic] I am {name}, and this is fine."

SFX backend (independent of dialogue): --sfx-backend elevenlabs|mmaudio (default elevenlabs) selects the generator for SFX/MUSIC/AMBIENCE, orthogonal to the dialogue --backend. Generation is delegated through the SfxBackend adapter in sfx_backends.py, which both xil-produce and xil-sfx share.

mmaudio runs MMAudio locally in venv-mmaudio via a persistent JSON-over-stdio worker (mmaudio_worker.py, the same pattern as Chatterbox) — free, GPU-accelerated, ~6 GB VRAM, 44.1 kHz. It uses MMAudio’s text-to-audio path; the model is primarily video-to-audio, so this is a pilot rather than a proven SFX generator.

⚠️ MMAudio’s weights are CC BY-NC 4.0 — non-commercial use only (its code is MIT; the checkpoints are not). --mmaudio-accept-noncommercial is required or the backend refuses to start, the constraint is logged every session, and each generated asset carries it in its ID3 comment alongside the .mmaudio filename infix — so a clip stays identifiable if a show is later monetised.

Duration: MMAudio is trained at 8 s and warns that large deviation degrades quality. Generation therefore runs at --mmaudio-duration (default 8.0) and the backend trims the result to the cue’s duration_seconds. The trim cannot be deferred to the mixer: for a prompt-generated cue duration_seconds is the generation length and there is no mix-time clip — that applies only to source= cues.

Install (not on PyPI) with xil setup mmaudio. It clones MMAudio @974010a beside the venv, builds the venv on Python 3.12 (MMAudio pins numpy<2.1, which has no 3.13 wheels), and re-pins torch last. The worker anchors MMAudio’s relative ./weights / ./ext_weights paths to the folder holding venv-mmaudio (or $XIL_MMAUDIO_WEIGHTS), so the ~6 GB never downloads into the current directory. By hand:

python3.12 -m venv venv-mmaudio
git clone https://github.com/hkchengrex/MMAudio
venv-mmaudio/bin/pip install -e MMAudio
# Re-pin torch AFTER: MMAudio's `torch >= 2.5.1` has no upper bound and pulls a
# CUDA 13 build, which falls back to CPU on a CUDA 12.x driver and breaks torchaudio.
venv-mmaudio/bin/pip install 'torch==2.6.0' 'torchaudio==2.6.0' 'torchvision==0.21.0' \
    --index-url https://download.pytorch.org/whl/cu124
venv-mmaudio/bin/python -c "import torch; print(torch.cuda.is_available())"   # must be True

Two local diffusion backends — AudioLDM 2 Large and Stable Audio Open 1.0, each driven by a persistent worker subprocess in a venv-audioldm2/ — were removed in #62 after both trials produced unusable audio. elevenlabs is the only remaining choice; the adapter and factory are kept as the seam a future backend plugs into.

shared_sfx_path() still appends a .<backend> infix for non-ElevenLabs backends, because assets generated during those trials (e.g. SFX/<slug>.audioldm2.mp3) may still exist on disk and remain playable when referenced as a cue source.


4. XILP003 — Audio Assembly (Two-Pass Multi-Track Mix)

flowchart TD
    C2["`📋 cast_sample_S01E01.json
    pan + filter per character`"]
    J2["`📦 parsed_sample_S01E01.json
    direction_type per entry`"]
    ST2["`stems/S01E01/*.mp3
    sorted by seq prefix`"]

    C2 --> CFG_LOAD["`CastConfiguration model
    build config dict`"]
    J2 --> IDX["`load_entries_index()
    {seq → entry} dict`"]
    ST2 --> PLANS["`collect_stem_plans()
    classify each stem by direction_type`"]
    IDX --> PLANS

    PLANS --> BRANCH{"parsed JSON\navailable?"}

    BRANCH -->|no| SEQ["`assemble_audio()
    sequential concat (fallback)`"]

    BRANCH -->|yes| FG

    subgraph FG["Foreground Pass — build_foreground()"]
        direction TB
        FG1["`Dialogue + SFX + BEAT stems
        concatenated with configurable gaps
        (--gap-ms, default 600ms)`"]
        FG2["`timeline dict
        {seq → start_ms}`"]
        FG1 --> FG2
    end

    subgraph BG["Background Pass"]
        direction TB
        AMB["`build_ambience_layer()
        loop each AMBIENCE stem to next cue
        −10 dB`"]
        MUS["`build_music_layer()
        overlay each MUSIC sting at cue
        −6 dB`"]
        AMB --> BGMIX["ambience.overlay(music)"]
        MUS --> BGMIX
    end

    FG2 --> BG
    FG1 --> OVERLAY["foreground.overlay(background)"]
    BGMIX --> OVERLAY

    OVERLAY --> EXPORT2["export sample_S01E01_master.mp3"]
    SEQ --> EXPORT2
    EXPORT2 --> PLAY2["os.system mpg123 — WSL playback"]

    CFG_LOAD --> FG
    CFG_LOAD --> SEQ

Vintage filter (scene-scoped): Add "vintage_scenes": ["scene-3", "scene-4"] to the SFX config to apply a 1960s-era audio filter (HF roll-off + −1 dB) to all dialogue in those scenes. The scene label must match the scene field in the parsed JSON. Tape hiss or other ambient texture for the flashback is handled separately as a looped AMBIENCE entry — no code change needed.

Restartability: XILP003 has no ElevenLabs dependency. Re-running assembly after adjusting effects or adding missing stems requires no API key and carries no TTS quota risk.

Runtime control: --gap-ms N sets the silence between foreground stems (default 600ms). With 294 stems in S02E03, reducing to 300ms saves ~1.5 min; to 200ms saves ~2 min.


5. XILP004 — Studio Project Onboarding

flowchart TD
    PARSED["`📦 parsed_sample_S01E02.json
    Dialogue + section + scene entries`"]
    CAST["`📋 cast_sample_S01E02.json
    voice_id per character`"]

    LOAD["`load_episode()
    Validate no TBD voice_ids`"]
    BUILD["`build_content_json()
    Transform entries → chapters/blocks/nodes`"]

    PARSED --> LOAD
    CAST --> LOAD
    LOAD --> BUILD

    subgraph MAPPING["Content Mapping Rules"]
        direction TB
        SEC["`section_header
        → new chapter (name)`"]
        SCN["`scene_header
        → h2 block (narrator voice)`"]
        DLG["`dialogue
        → p block with speaker's voice_id`"]
        DIR["`direction
        → skipped (not voiced)`"]
    end

    BUILD --> MAPPING
    MAPPING --> MODE{"--dry-run?"}
    MODE -->|yes| DRY["`dry_run()
    Print chapter summary
    Show voice assignments`"]
    MODE -->|no| API["`create_project()
    client.studio.projects.create()
    from_content_json payload`"]
    API --> PROJ["`🎬 Studio Project
    project_id returned`"]

Speaker-name problem solved: Each tts_node carries its own voice_id — speaker names never appear in the text, so TTS won’t voice them. No manual post-creation cleanup needed.


6. Stem File Naming Convention

Standard stems (seq ≥ 1)

flowchart LR
    SEQ["`seq
    003`"]
    SEP1["_"]
    SEC["`section
    cold-open`"]
    SEP2["-"]
    SCN["`scene
    scene-1`"]
    SEP3["_"]
    SPK["`speaker
    adam`"]
    EXT[".mp3"]

    SEQ --> SEP1 --> SEC --> SEP2 --> SCN --> SEP3 --> SPK --> EXT

    style SEQ fill:#d4e6f1
    style SEC fill:#d5f5e3
    style SCN fill:#fdebd0
    style SPK fill:#f9ebea

Example: 003_cold-open_adam.mp3, 028_act1-scene-1_rian.mp3, 102_act2-scene-5_mr_patterson.mp3

Preamble and postamble stems

Preamble and postamble entries appear in the production script as PREAMBLE and POSTAMBLE section blocks. Because they are parsed like any other section, their seq numbers are contiguous with the episode — no special prefix. Stems follow the standard naming pattern:

001_preamble_tina.mp3      ← dialogue stem (broadcast intro voice)
002_preamble_sfx.mp3       ← SFX stem (INTRO MUSIC direction)
…
306_postamble_sfx.mp3      ← SFX stem (OUTRO MUSIC direction)
307_postamble_tina.mp3     ← dialogue stem (broadcast outro voice)

TTS speed for preamble/postamble stems is taken from the preamble/postamble block in the cast config (speed field); all other voice settings come from the speaker’s cast entry. The INTRO MUSIC and OUTRO MUSIC stems are generated by xil sfx via the standard SFX pipeline.


7. API Cost Guard Flow

flowchart TD
    START["Before each API call"]
    CHK["`has_enough_characters(text)
    client.user.get()`"]
    ERR{"API error?"}
    SKIP_GUARD["`Skip guard
    no user_read permission
    return True`"]
    CALC["`remaining = limit - count
    required = len(text)`"]
    CMP{"remaining >= required?"}
    OK["✅ Proceed to API call"]
    HALT["`🛑 Halt generation
    Log chars needed vs remaining`"]

    START --> CHK --> ERR
    ERR -->|yes| SKIP_GUARD
    ERR -->|no| CALC --> CMP
    CMP -->|yes| OK
    CMP -->|no| HALT

    BUDGET["`get_best_model_for_budget()
    always eleven_v3`"]
    V3["`eleven_v3
    standard quality`"]

    BUDGET --> V3

8. XILP005 — DAW Layer Export

flowchart TD
    C5["`📋 cast_sample_S01E01.json`"]
    J5["`📦 parsed_sample_S01E01.json`"]
    ST5["`stems/S01E01/*.mp3`"]

    C5 --> L5["`load cast config
    build speaker effects dict
    + show/season/episode metadata`"]
    J5 --> IDX5["`load_entries_index()
    {seq → entry}`"]
    ST5 --> PLANS5["`collect_stem_plans()
    classify by direction_type`"]
    IDX5 --> PLANS5

    PLANS5 --> TL5

    TL5["`build_foreground()
    foreground track + {seq → ms} timeline`"]
    L5 --> TL5

    TL5 --> DLG5["`build_dialogue_layer()
    dialogue stems at timeline positions
    audio filter chain + pan applied per speaker`"]
    TL5 --> AMB5["`build_ambience_layer(level_db=0)
    AMBIENCE looped to next cue
    no ducking — producer controls level`"]
    TL5 --> MUS5["`build_music_layer(level_db=0)
    MUSIC stings at cue positions`"]
    TL5 --> SFX5["`build_sfx_layer()
    SFX + BEAT at timeline positions`"]

    TL5 --> VF5["`build_vintage_filter_layer()
    dialogue stems within VINTAGE FILTER spans
    only written when markers present`"]

    DLG5 --> WAV1["`daw/S01E01/
    S01E01_layer_dialogue.wav`"]
    AMB5 --> WAV2["S01E01_layer_ambience.wav"]
    MUS5 --> WAV3["S01E01_layer_music.wav"]
    SFX5 --> WAV4["S01E01_layer_sfx.wav"]
    VF5 --> WAV5["`S01E01_layer_vintage_filter.wav
    (only when VINTAGE FILTER markers present)`"]

    WAV1 --> TAG5["`tag_wav()
    ID3 metadata: Album, Genre,
    Year, Title, Artist`"]
    WAV2 --> TAG5
    WAV3 --> TAG5
    WAV4 --> TAG5
    WAV5 --> TAG5

    DLG5 --> LBL1["S01E01_labels_dialogue.txt"]
    AMB5 --> LBL2["S01E01_labels_ambience.txt"]
    MUS5 --> LBL3["S01E01_labels_music.txt"]
    SFX5 --> LBL4["S01E01_labels_sfx.txt"]

    TAG5 --> SCRIPT5["`S01E01_open_in_audacity.py
    Manual import instructions
    (WAVs + optional labels)`"]
    TAG5 --> MACRO5["`--macro → SAMPLE_S01E01.txt
    Audacity macro (WAVs only, Audacity 3)
    written to %APPDATA%/audacity/Macros/`"]

    DLG5 --> TLVIZ["`timeline_viz.py
    build_timeline_data()`"]
    AMB5 --> TLVIZ
    MUS5 --> TLVIZ
    SFX5 --> TLVIZ
    TLVIZ -->|"--timeline"| ASCII5["`ASCII timeline → stdout
    render_terminal_timeline()`"]
    TLVIZ -->|"--timeline-html"| HTML5["`S01E01_timeline.html
    render_html_timeline()
    (hover tooltips + zoom)`"]

Audacity alignment: All generated WAV layer files are exactly the same duration (full episode length). Importing them into Audacity at t=0 produces perfectly aligned tracks — no repositioning or time-offset metadata required.

Audio metadata: Each WAV layer is tagged with ID3 metadata (Album = show name, Genre = “Podcast”, Year, Title = e.g. “S02E03 Dialogue”, Artist = season title) via tag_wav() from sfx_common.py.

Label tracks: Audacity-format label files (tab-separated start/end/text) are generated alongside each WAV layer. Import labels separately via File > Import > Labels... in Audacity. This still works in Audacity 4, but v4 does not name the imported track after the file — import them in the order the helper script lists and rename each track, or they are indistinguishable.

Audacity macro: --macro writes a one-click macro (<SLUG>_<TAG>.txt) to the Audacity Macros directory. The macro imports the WAV layer files only (labels are imported manually). Access via Tools > Macros in Audacity 3.

Audacity 4.0 removed Macro Manager, so the macro cannot be run there. The macro is still written — v3 is often installed alongside — but export_daw_layers() warns when detect_audacity_generations() reports a v4 config. Detection reads %APPDATA%\audacity\ (Audacity4.ini/Audacity4/ for v4, Macros/ for v3), so it means “has been run here”, not “is installed now”.

Preamble/postamble support: Preamble and postamble dialogue stems are generated by the standard generate_voices() loop — no special handling. The preamble/postamble blocks in the cast config supply a speed override for those sections only. Music stems (INTRO MUSIC, OUTRO MUSIC) are generated by xil sfx and have foreground_override = True so they play sequentially rather than as background overlays.

Timeline visualization: --timeline prints an ASCII multitrack view to stdout; --timeline-html writes a self-contained HTML file with color-coded swim lanes, hover tooltips, and Ctrl+scroll zoom. Both work with --dry-run — the dry-run path uses build_foreground_timeline_only() (mutagen header reads, no audio decoding) and the compute_*_labels() helpers in mix_common.py.

Note on mod-script-pipe: The generated helper script includes pipe automation code, but Audacity 3.7.x does not reliably initialise mod-script-pipe on Windows. The Audacity macro (--macro) is the recommended automation path there.

Neither is available in Audacity 4.0 — it removed both Macro Manager and the scripting pipe (the changelog says they return in a later release). On v4 the pipe open fails, the helper script falls through to print_instructions(), and import is manual. That fallback already existed; only the wording changed.

Auto-save: Add --save-aup3 to append a SaveProject2 command at the end of {TAG}_open_in_audacity.py. This requires mod-script-pipe to be active and will save the project as an .aup3 file immediately after import. Only useful when pipe automation is confirmed working; otherwise omit this flag and save manually. Inert on Audacity 4, which has no pipe and offers .aup4 as well as .aup3 on save.


9. XILP006 — Cues Sheet Ingester

Pre-processing step that bridges a human-authored sound cues & music prompts document into the automated pipeline. Sits after XILP001 and before XILU002 / XILP002 — enriching the SFX config and populating the shared asset library before stem generation begins.

9a. Overall flow

flowchart TD
    CQ["`📋 cues/*.md
    Sound cues & music prompts
    (MUSIC / AMBIENCE / SFX sections)`"]
    PARSE["parse_cues_markdown()"]
    ASSETS["`Asset list
    asset_id · category · reuse
    prompt · duration_seconds
    loop · scene`"]
    MANIFEST["`📦 cues/cues_manifest_<TAG>.json
    Always written — structured catalog`"]
    AUDIT["dry_run_report()"]

    CQ --> PARSE --> ASSETS
    ASSETS --> MANIFEST
    ASSETS --> AUDIT

    ASSETS --> GEN_BRANCH{"--generate?"}
    GEN_BRANCH -->|"yes, not dry-run"| GEN["generate_new_assets()"]
    GEN_BRANCH -->|"--dry-run"| SKIP_GEN["`Skip API calls
    Show credit estimate`"]
    GEN --> SFXLIB["`🎵 SFX/mus-theme-main-01.mp3
    SFX/sfx-boots-stamp-01.mp3 …
    Named by asset ID (lowercase)`"]

    ASSETS --> ENR_BRANCH{"--enrich-sfx-config?"}
    ENR_BRANCH -->|"yes, not dry-run"| ENR["enrich_sfx_config()"]
    ENR_BRANCH -->|"--dry-run"| DIFF["`Show prompt + duration diff
    No file written`"]
    ENR --> SFXCFG["`📋 sfx_<slug>_<TAG>.json
    Updated prompts + durations
    loop flag set for ambience`"]

9b. Cues markdown parsing

flowchart TD
    MD["cues/*.md"]
    SEC{"`## heading?`"}

    MD --> LINES["Read line by line"]
    LINES --> SEC

    SEC -->|"MUSIC CUES"| MUSIC_LOOP
    SEC -->|"AMBIENCE"| AMB_LOOP
    SEC -->|"SOUND EFFECTS"| SFX_LOOP
    SEC -->|"other"| NULL["section = None\nskip lines"]

    subgraph MUSIC_LOOP["MUSIC / AMBIENCE section"]
        direction TB
        H3["`### ASSET-ID (REUSE|NEW)
        → pending asset dict`"]
        PLINE["`**Prompt:** … **Duration:** … **Used:** …
        → fill pending, append to list`"]
        H3 --> PLINE
    end

    subgraph SFX_LOOP["SOUND EFFECTS section"]
        direction TB
        SCENE_H["`### Scene N: Name
        → current_scene label`"]
        ROW["`| ASSET-ID (REUSE|NEW) | Prompt | Placement |
        → append asset dict with scene`"]
        SCENE_H --> ROW
    end

    MUSIC_LOOP --> OUT2["asset list"]
    AMB_LOOP --> OUT2
    SFX_LOOP --> OUT2

9c. Library audit status codes

StatusMeaning
EXISTSSFX/<asset-id>.mp3 is present and non-empty
REUSEAsset is marked (REUSE) in the cues sheet but not yet in SFX/ — must be sourced or regenerated
NEWAsset is marked (NEW) — needs ElevenLabs API generation via --generate

9d. SFX config enrichment matching

flowchart LR
    AID["`asset_id
    e.g. MUS-THEME-MAIN-01`"]
    KEYS["`sfx config keys
    (direction text)`"]
    MATCH{"`asset_id substring
    found in key?`"}
    UPDATE["`Update entry:
    prompt ← cues sheet prompt
    duration_seconds ← min(dur, 30s)
    loop ← True (ambience only)`"]
    SKIP["No match — skip"]

    AID --> MATCH
    KEYS --> MATCH
    MATCH -->|yes| UPDATE
    MATCH -->|no| SKIP

Duration cap: ElevenLabs Sound Effects API accepts at most 30 seconds per call. Assets with longer cues-sheet durations (e.g. 3-minute underscore) are generated at 30s and flagged [CAPPED] in the audit report. Looping in XILP003/XILP005 handles extension.

# 1. Parse script and generate skeleton configs
xil scan "scripts/<script>.md"          # pre-flight: catch unknown speakers
xil parse "scripts/<script>.md" --episode S02E03

# 1b. (Optional) Review full episode structure before any API spend
xil csv-join --episode S02E03                 # annotated CSV: SFX + cast columns

# 2. Ingest cues sheet — enrich sfx config + audit (no API calls yet)
xil cues --episode S02E03 \
    --cues "cues/<cues-file>.md" --enrich-sfx-config

# 3. Preview what needs generating
xil cues --episode S02E03 \
    --cues "cues/<cues-file>.md" --generate --dry-run

# 4. Generate new SFX/music assets into SFX/ library
xil cues --episode S02E03 \
    --cues "cues/<cues-file>.md" --generate

# 5. Generate voice stems (sfx config already enriched)
#    Preamble: ensure sfx_<slug>_S02E03.json contains an "INTRO MUSIC" entry with a "source" path
#    Preamble/postamble text lives in the script PREAMBLE/POSTAMBLE sections; sfx config needs INTRO MUSIC + OUTRO MUSIC entries
xil produce --episode S02E03 --dry-run
xil produce --episode S02E03
# Generate SFX/music/ambience stems by category (omit flags to generate all):
xil sfx --episode S02E03 --gen-sfx --dry-run
xil sfx --episode S02E03 --gen-music --dry-run
xil sfx --episode S02E03 --gen-ambience --dry-run
xil sfx --episode S02E03

# 6. Assemble master MP3 or export DAW layers
xil assemble --episode S02E03
xil daw --episode S02E03 --macro

# 7. Inspect asset placement (no audio decode needed with --dry-run)
xil daw --episode S02E03 --dry-run --timeline
xil daw --episode S02E03 --timeline --timeline-html

9f. Punch-in run order (script revised after full generation)

# 1. Re-parse the revised script (preserves orig_ as the old reference)
xil parse "scripts/<revised>.md" --episode S02E03

# 2. Migrate unchanged stems to new seq-numbered filenames
xil migrate --episode S02E03 --dry-run   # preview first
xil migrate --episode S02E03

# 2b. Clean up stale stems left behind by migration
xil cleanup --episode S02E03 --dry-run  # preview first
xil cleanup --episode S02E03

# 3. Generate only the gaps (XILP002 skips files already on disk)
xil produce --episode S02E03 --dry-run
xil produce --episode S02E03

# 4. Reassemble
xil assemble --episode S02E03
xil daw --episode S02E03 --macro

10. Timeline Visualization (timeline_viz.py)

Shared module that renders asset placement across all DAW layers without any pydub dependency. Consumed by XILP005 via --timeline and --timeline-html.

10a. Data model

timeline_viz.py is built on two dataclasses — full field listing in §28e.

ClassRole
TimelineDataEpisode container — tag, total duration, four named LayerSpan lists keyed by layer
LayerSpanOne asset placement — start/end time, label, optional ramp-in/out, volume, play-duration, tooltip snippet

10b. Rendering paths

flowchart TD
    DLG_L["`dialogue labels
    list of (start_s, end_s, speaker)`"]
    AMB_L["`ambience labels`"]
    MUS_L["`music labels`"]
    SFX_L["`sfx labels`"]

    BUILD["`build_timeline_data()
    Wraps four label lists → TimelineData`"]

    DLG_L --> BUILD
    AMB_L --> BUILD
    MUS_L --> BUILD
    SFX_L --> BUILD

    BUILD --> TERM["`render_terminal_timeline()
    Unicode ASCII — time ruler + layer bars
    auto-scales to terminal width (shutil)`"]
    BUILD --> HTML["`render_html_timeline()
    Self-contained HTML — no CDN
    color-coded swim lanes
    hover tooltips · Ctrl+scroll zoom`"]

    TERM --> STDOUT["stdout"]
    HTML --> FILE["`daw/{TAG}/{TAG}_timeline.html`"]

10c. Dry-run label path (no audio decoding)

flowchart LR
    PLANS["`stem_plans
    (StemPlan list)`"]
    FT["`build_foreground_timeline_only()
    mutagen header reads only
    → (total_ms, timeline)`"]
    PLANS --> FT

    FT --> DLG2["`compute_dialogue_labels()`"]
    FT --> AMB2["`compute_ambience_labels()`"]
    FT --> MUS2["`compute_music_labels()`"]
    FT --> SFX2["`compute_sfx_labels()`"]

    DLG2 --> BTD["`build_timeline_data()`"]
    AMB2 --> BTD
    MUS2 --> BTD
    SFX2 --> BTD

    BTD --> RENDER["`render_terminal_timeline()
    render_html_timeline()`"]

Fast dry-run: build_foreground_timeline_only() uses mutagen.mp3.MP3(path).info.length for header-only duration reads — orders of magnitude faster than AudioSegment.from_file() for a full episode. The compute_*_labels() helpers apply the same boundary logic as the audio-loading layer builders (build_ambience_layer etc.) but return label tuples only.


11. Ambience Stop Markers

Script-side directives that end an ambience loop without starting a new one.

Recognized patterns

  • [AMBIENCE: STOP] — explicit stop
  • [AMBIENCE: DINER FADES OUT], [AMBIENCE: B&B FADES OUT] — any FADES OUT suffix

How they work

  1. XILP001 auto-generates type: "silence", duration_seconds: 0.0 entries in the sfx config — no audio asset is created
  2. mix_common collect_stem_plans() injects a synthetic StemPlan(filepath="") for each stop marker found in the entries index — they never have a stem file on disk
  3. build_ambience_layer() uses stop markers as bg_cues boundary markers: the preceding ambience loop’s end_ms is set to the stop marker’s timeline position
  4. Stop marker plans are skipped when loading audio (empty filepath) and generate no label in the timeline

loop: false vs stop markers

loop: falseStop marker
Controlled insfx configscript
EffectPlays file once (no tiling)Ends loop at cue position
Audio generatedYesNo
Timeline labelYesNo

12. XILP007 — Stem Migrator (Punch-In Workflow)

Migrates existing stems when a parsed script is revised. Compares old and new parsed JSONs, copies unchanged stems to their new seq-numbered filenames, and produces a report of what still needs TTS/SFX generation. Run XILP002 afterwards — it skips stems already on disk, so only the gaps get API calls.

When to use

  • Script text corrections after a full TTS run
  • Character renames / speaker reassignments
  • Lines deleted or added (seq numbers shift for the remaining entries)
  • Episode trimming (cutting scenes to meet runtime)

Workflow

# 1. Edit & re-parse the revised script
xil parse "scripts/<revised>.md" --episode S02E03

# 2. Preview the migration plan (no file changes)
xil migrate --episode S02E03 --dry-run

# 3. Copy unchanged stems into new seq-numbered filenames
xil migrate --episode S02E03

# 4. Generate only the missing/changed/new stems
xil produce --episode S02E03 --dry-run
xil produce --episode S02E03

Matching modes

ModeFlagEm-dash / ellipsis variantsUse when
Fuzzy (default)(omit)Treated as identicalPunctuation-only edits
Strict--strictMust match exactlyVerify every character

Status codes

CodeMeaningAction needed
COPYText + speaker unchangedFile copied to new seq name; no TTS
SPEAKERSame text, different speakerRegen — different voice
NEWNo matching old entryGenerate fresh
MISSINGMatch found, old file absentGenerate fresh
SKIPSection/scene header — no stemNone

Two-phase match algorithm

  1. Exact: (normalized_text, speaker) — safe COPY or MISSING
  2. Text-only fallback (dialogue only): text matches but speaker differs → SPEAKER

The two-phase approach lets the tool distinguish “punctuation edit on same speaker” (COPY in fuzzy mode) from “line reassigned to a different character” (SPEAKER).

13. XILU003 — CSV Annotation Utility

Read-only utility that joins a parsed episode CSV with the SFX JSON and cast JSON, producing a single annotated review spreadsheet. Useful for verifying that all direction entries have SFX config entries, all speakers are assigned voices, and reviewing the full episode structure before committing to a TTS run.

xil csv-join --episode S02E03
xil csv-join --episode S02E03 --output review/S02E03_annotated.csv

Inputs / outputs

FileDefault pathOverride flag
Input CSVparsed/parsed_<slug>_{TAG}.csv--csv
SFX configsfx_<slug>_{TAG}.json--sfx
Cast configcast_<slug>_{TAG}.json--cast
Output CSVparsed/annotated_<slug>_{TAG}.csv--output

Output columns appended

The output CSV keeps all original parsed columns (seq, type, section, scene, speaker, direction, text, direction_type) and appends:

ColumnSourceNotes
sfx_promptSFX config promptEmpty for dialogue
sfx_durationSFX config duration_secondsEmpty for dialogue / silence
sfx_typeSFX config typesfx / silence / source
cast_full_nameCast config full_nameEmpty for non-dialogue
cast_voice_idCast config voice_idTBD if not yet assigned
cast_roleCast config roleEmpty if unset

No API key required — read-only join, no audio generated.

14. XILP008 — Stale Stem Cleanup

Removes stale stems left behind after a parsed script revision and stem migration. After XILP007 copies unchanged stems to new seq-numbered filenames, old stems whose seq numbers now map to a different entry type remain on disk and cause warnings in XILP005. This script finds and deletes them.

When to use

  • After running XILP007 (stem migrator) and before XILP002 (voice generation)
  • When XILP005 reports [W] Stale stem skipped warnings

Stale detection rules

ConditionReason
Parsed entry is a header (section_header / scene_header)Header entries never have stems — any stem at that seq is stale
Filename ends with _sfx but parsed entry at that seq is dialogueType mismatch — old SFX stem, now a spoken line
Filename ends with a speaker name but parsed entry is directionType mismatch — old dialogue stem, now a stage direction
Dialogue stem whose speaker suffix doesn’t match the parsed speakerSpeaker mismatch — line reassigned to a different character
Multiple stems share the same seq numberDuplicate — only the one matching the expected basename survives
Seq number not present in the parsed JSON at allOrphaned stem — entry was deleted or seq range changed

Flow

flowchart TD
    PARSED["`📦 parsed/parsed_sample_S02E03.json
    Current parsed script`"]
    STEMS["`stems/S02E03/*.mp3
    All stems on disk`"]

    LOAD["`load_entries_index()
    {seq → entry} dict`"]
    SCAN["`find_stale_stems()
    Cross-check filename suffix
    vs parsed entry type`"]

    PARSED --> LOAD --> SCAN
    STEMS --> SCAN

    SCAN --> RESULT{"Stale stems found?"}
    RESULT -->|no| CLEAN["No stale stems — directory is clean"]
    RESULT -->|yes| MODE{"--dry-run?"}
    MODE -->|yes| LIST["`List stale stems
    Show count + reasons`"]
    MODE -->|no| DELETE["`os.remove() each stale stem
    Report count deleted`"]

Relationship to XILP005 warnings: Both XILP008 and collect_stem_plans() in mix_common.py detect stale stems via type mismatch, speaker mismatch, and seq deduplication. Running XILP008 after migration eliminates the [W] Stale stem skipped warnings from XILP005. XILP008 additionally catches stems whose seq is not present in the parsed JSON at all (orphaned stems), which XILP005 does not warn about.

15. XILU008 — Stem Log Report

Parses daily pipeline log files to reconstruct a chronological stem generation history. Useful for auditing what was generated, when, with which backend, and confirming SHA256 checksums.

xil-stem-log --episode S03E03
xil-stem-log --episode S03E03 --since 2026-04-01 --output stem_log.csv
xil-stem-log --slug the413
xil-stem-log --logs-dir /path/to/logs

Flow

flowchart TD
    LOGS["`📂 logs/xil_v2_YYYY-MM-DD_HOST.log
    Structured: ts|LEVEL|host|stage|msg
    one file per host; v1 transcripts also read`"]
    PARSE["`Parse log lines
    Regex patterns per backend:
    elevenlabs / gtts / chatterbox / chatterbox-turbo`"]
    STATE["`State machine
    generation line → saved → SHA256`"]
    RUNIDX["`run_index
    increments per 'Phase 1' marker`"]
    FILTER["`Optional filters:
    --episode TAG · --slug SLUG
    --since DATE`"]
    RECORDS["`Records:
    log_date · run_index · seq · speaker
    backend · char_count · sha256
    stem_path · stem_filename`"]
    CSV["`📊 stem_log_report.csv
    Chronological stem history`"]

    LOGS --> PARSE --> STATE --> RUNIDX --> FILTER --> RECORDS --> CSV

--episode TAG filters records to a specific episode tag (e.g. S03E03). --slug SLUG filters records to a specific show slug (e.g. the413). --since DATE filters to logs on or after the given date (YYYY-MM-DD format). No API key required — reads local log files only.


16. XILU009 — Workspace Migration

Moves pre-0.1.8 workspace files to the normalized layout introduced in 0.1.8. Idempotent — re-running skips files already at their target path. Run once per existing workspace after upgrading; new workspaces created by xil-init use the normalized layout automatically.

xil migrate-workspace --dry-run    # preview what would move
xil migrate-workspace              # execute moves
xil migrate-workspace --workspace /path/to/workspace

Layout change summary

AssetPre-0.1.8 (legacy)0.1.8+ (normalized)
Cast configcast_{slug}_{tag}.json (root)configs/{slug}/cast_{tag}.json
SFX configsfx_{slug}_{tag}.json (root)configs/{slug}/sfx_{tag}.json
Parsed JSONparsed/parsed_{slug}_{tag}.jsonparsed/{slug}/parsed_{tag}.json
DAW layersdaw/{tag}/daw/{slug}/{tag}/
Mastersmasters/{slug}_{tag}_master.mp3masters/{slug}/{tag}_master.mp3
Cuescues/cues_{slug}_{tag}.mdcues/{slug}/cues_{tag}.md
Cues manifestcues/cues_manifest_{tag}.jsoncues/{slug}/cues_manifest_{tag}.json
Stemsstems/{slug}/{tag}/unchanged

Flow

flowchart TD
    SCAN["`Scan workspace
    Regex patterns per asset type`"]
    DISCO["`_discover_moves()
    Build (src → dst) list`"]
    INFER["`_infer_slug_from_tag()
    Cross-ref cast configs for
    daw/ and cues_manifest/ moves`"]
    DRY{dry_run?}
    EXEC["`_execute_moves()
    os.makedirs + shutil.move`"]
    REPORT["`Print summary:
    N files moved / skipped`"]

    SCAN --> DISCO --> INFER --> DRY
    DRY -- yes --> REPORT
    DRY -- no --> EXEC --> REPORT

Backward compatibility: derive_paths() automatically detects the legacy layout (root cast config present) and returns legacy paths, so existing workspaces continue to work without migration. Run xil migrate-workspace when ready to adopt the new layout. No API key required — local filesystem operations only.


17. XILP009 — Reverse Script Generator

Reconstructs a readable markdown production script from a parsed JSON, using cast config for speaker display names. Serves as a verification tool and produces a clean “revised” version reflecting any post-parse edits.

xil regen --episode S02E03
xil regen --episode S02E03 --output scripts/revised_S02E03.md

# With SFX config — source-backed direction entries gain a pipe-hint filename suffix
xil regen --episode S02E03 --sfx configs/sample/sfx_S02E03.json

Parameters

ParameterDefaultDescription
--episode TAG—Episode tag (e.g. S02E03). Mutually exclusive with --tag.
--tag TAG—Raw non-episodic tag (e.g. V01C03). Mutually exclusive with --episode.
--parsed PATHparsed/<slug>/parsed_<slug>_<TAG>.jsonOverride parsed JSON input path.
--cast PATHconfigs/<slug>/cast_<TAG>.jsonOverride cast config path.
--sfx PATHconfigs/<slug>/sfx_<TAG>.jsonOverride SFX config path. When the file exists, direction entries are emitted with a pipe-hint suffix ([SFX: TEXT | filename.mp3 | play_volume_pct=20% | play_duration_pct=35%]) for any entry whose SFX config key has a source, a volume_percentage, and/or a play_duration. Bare-prompt and silence entries are unaffected.
--output PATHscripts/revised_<slug>_<TAG>.mdOverride output markdown path.
--show NAMEfrom project.jsonShow name override for slug derivation.
--speakers PATHauto-detect → built-inPath to speakers.json for speaker key → display name mapping.

Pipe-hint behaviour

When --sfx is supplied (or sfx_<TAG>.json exists at the default path), direction entries that resolve to a source-backed asset are emitted in pipe-hint format:

[SFX: PAPER LETTER FOLDED, SET DOWN ON TABLE | PAPRImpt-A_realistic_sound_of-Elevenlabs.mp3]
[AMBIENCE: RADIO BOOTH - SOFT EQUIPMENT HUM, SLIGHT STATIC, INTIMATE | ambience_radio-booth-soft-equipment-hum-slight-static-intimate.mp3]

Cues carrying a volume_percentage or a play_duration also emit the matching attribute hint, with or without a filename, so a regenerated script round-trips losslessly:

[OUTRO MUSIC | sundy3M4_v3_(tech)_1.05mn_125bpm_-_TAGEO.mp3 | play_volume_pct=20%]
[MUSIC: STING | play_volume_pct=40%]
[INTRO MUSIC | The Porch Light.mp3 | play_volume_pct=40% | play_duration_pct=35%]

Entries with only a prompt key (API-generated) or "type": "silence" emit without a hint:

[MUSIC: STING OUT]
[BEAT]
[VINTAGE FILTER ENGAGES]

This makes the regenerated script immediately usable as a new episode template — the pipe-hints allow xil to resolve assets from the library without re-generating them.

Flow

flowchart TD
    PARSED["`📦 parsed/parsed_sample_S02E03.json
    Entries with seq, type, speaker, text`"]
    CAST["`📋 cast_sample_S02E03.json
    Speaker key → display name`"]
    SFX["`📋 sfx_sample_S02E03.json
    Direction text → source basename
    + volume override (optional)`"]

    LOAD["`Load parsed JSON + cast config
    Build reverse mappings from XILP001
    Build SFX source lookup`"]
    CAST_BLOCK["`CAST block
    Emitted from cast config characters
    Follows title line`"]
    FILTER["`Filter entries
    seq >= 1 only`"]
    EMIT["`Emit markdown
    === + plain text per section_header
    scene_header → plain text
    direction → [TEXT], [TEXT | file.mp3], + | play_volume_pct=N% | play_duration_pct=N%
    dialogue → SPEAKER (dir) + text
    postamble section included`"]

    PARSED --> LOAD
    CAST --> LOAD
    SFX -.->|optional| LOAD
    LOAD --> CAST_BLOCK --> FILTER --> EMIT
    EMIT --> OUTPUT["`📄 scripts/revised_sample_S02E03.md
    Reconstructed production script`"]

Round-trip verification: Parse the regenerated script with XILP001 and compare entry counts against the original parsed JSON. Dialogue and direction counts should match exactly, including PREAMBLE and POSTAMBLE sections.

No API key required — read-only transformation, no audio generated.

18. XILP010 — Studio Export Importer

Extracts dialogue stems from an ElevenLabs Studio export ZIP and renames them to the pipeline’s stem naming convention ({seq:03d}_{section}[-{scene}]_{speaker}.mp3).

This provides an alternative to XILP002 voice generation: instead of calling the ElevenLabs TTS API per-line, an entire episode can be generated via ElevenLabs Studio (onboarded by XILP004), exported as a ZIP, and imported back into the pipeline with correct filenames.

xil import --episode S02E02 \
    --zip "ElevenLabs_exports/ElevenLabs_Working_with_Gen_S02E02_What_We_Carry_!.zip" --dry-run
xil import --episode S02E02 \
    --zip "ElevenLabs_exports/ElevenLabs_Working_with_Gen_S02E02_What_We_Carry_!.zip"

Data flow

flowchart TD
    ZIP["`📦 ElevenLabs Studio ZIP
    NNN_Chapter N.mp3 per entry`"]
    PARSED["`📄 parsed/parsed_sample_S02E02.json
    seq → type, section, scene, speaker`"]
    FILTER{"`Filter by type
    dialogue → extract
    direction → skip (or --all)
    header → always skip`"}
    RENAME["`Rename via make_stem_name()
    NNN_Chapter N.mp3 →
    {seq}_{section}[-{scene}]_{speaker}.mp3`"]
    STEMS["`📂 stems/S02E02/
    Pipeline-ready dialogue stems`"]

    ZIP --> FILTER
    PARSED --> FILTER
    FILTER --> RENAME --> STEMS

No API key required — extraction only, no API calls made. After import, run XILU002 for SFX stems and XILP002 for voice stems (preamble/postamble sections included).

19. XILP011 — Final Master MP3 Export

Overlays the DAW layer WAV files produced by XILP005 into a single stereo MP3 file suitable for podcast distribution.

xil master --episode S02E03 --dry-run
xil master --episode S02E03
xil master --episode S02E03 --show "Night Owls"

Data flow

flowchart TD
    DIALOGUE["`🎙️ daw/S02E03/
    S02E03_layer_dialogue.wav`"]
    AMBIENCE["`🌿 daw/S02E03/
    S02E03_layer_ambience.wav`"]
    MUSIC["`🎵 daw/S02E03/
    S02E03_layer_music.wav`"]
    SFX["`💥 daw/S02E03/
    S02E03_layer_sfx.wav`"]
    VF["`📻 daw/S02E03/
    S02E03_layer_vintage_filter.wav
    (optional)`"]
    MIX["XILP011_master_export.py
    pydub overlay (unity gain)"]
    CAST["`📋 cast_sample_S02E03.json
    Show name, title, artist`"]
    ART["`🖼️ configs/sample/
    cover_art.PNG (optional)`"]
    MASTER["`🎧 masters/
    S02E03_sample_2026-03-24.mp3
    Stereo · 48 kHz · VBR ~145–185 kbps
    ID3 tags + APIC cover art`"]

    DIALOGUE --> MIX
    AMBIENCE --> MIX
    MUSIC --> MIX
    SFX --> MIX
    VF -.->|if present| MIX
    CAST --> MIX
    ART -.->|if present| MIX
    MIX --> MASTER

No API key required — local audio processing only. Mix balance is handled by XILP005; XILP011 overlays all present layers at unity gain. Output filename includes the run date: {TAG}_{slug}_{YYYY-MM-DD}.mp3. Cover art: _find_cover_art(slug) checks configs/<slug>/cover_art.PNG|png|jpg|jpeg. When found, the image is embedded as an APIC front-cover frame via tag_mp3(cover_art_path=...) — no extra step required. Silently skipped when absent so shows without cover art are unaffected.

20. XILP012 — Social Media Post Draft Generator

Reads a parsed episode JSON, builds a structured episode summary (cold open excerpt, cast list, section arc, runtime), and calls the Claude API (Haiku) to produce three ready-to-edit post variants. Output is an editable markdown file the producer reviews and pastes.

xil publish --episode S04E01 --dry-run
xil publish --episode S04E01
xil publish --episode S04E01 --platform instagram
xil publish --all

Post variants per episode:

VariantDescription
HypeNew episode announcement, teaser tone, no spoilers past cold open. Mentions show name, episode title, and Berkshire Talking Chronicle.
QuotePulls a memorable line from the cold open dialogue. Formatted as a blockquote with a tune-in call to action.
SpotlightFeatures one cast member. Cycles by (episode_number − 1) % cast_count so each episode highlights a different character.

Data flow

flowchart TD
    PARSED["`📄 parsed/the413/
    parsed_S04E01.json
    (show, title, entries, stats)`"]
    CAST["`📋 configs/the413/
    cast_S04E01.json
    (full_name, role)`"]
    MASTER["`🎧 masters/the413/
    S04E01_master.mp3
    (runtime, optional)`"]
    EXTRACT["extract_episode_summary()
    cold open · cast · section arc"]
    PROMPT["build_user_message()
    structured episode brief"]
    CLAUDE["Claude API
    claude-haiku-4-5-20251001
    system prompt cached"]
    POSTS["`📝 posts/the413/
    S04E01_posts.md
    3 variants: Hype · Quote · Spotlight`"]

    PARSED --> EXTRACT
    CAST --> EXTRACT
    MASTER -.->|optional runtime| EXTRACT
    EXTRACT --> PROMPT
    PROMPT --> CLAUDE
    CLAUDE --> POSTS

CLI flags

FlagDefaultDescription
--episode / --tag—Episode tag (required unless --all)
--showproject.jsonShow name override
--platformfacebookfacebook or instagram — affects prompt style
--dry-runoffPrint prompt + token estimate; no API call, no file written
--alloffBatch-generate for every parsed episode under the current slug
--modelclaude-haiku-4-5-20251001Override Claude model ID

ANTHROPIC_API_KEY required for non-dry-run mode. Prompt caching (cache_control: ephemeral) on the static system prompt reduces cost on --all batch runs. Output path: posts/{slug}/{tag}_posts.md

21. XILU014 — Episode Summary CSV

Scans all parsed_<tag>.json files under the workspace parsed/ directory and writes a one-row-per-episode summary CSV. Useful for tracking episode word counts, dialogue line counts, and TTS character spend across a season.

xil episode-summary                          # writes episode_summary.csv in workspace root
xil episode-summary --output summary.csv     # custom output path
xil episode-summary --show "THE 413"         # filter to one show
xil episode-summary --stdout                 # write CSV to stdout (no banner)

Output columns

ColumnSourceNotes
showparsed JSON showShow name (e.g. THE 413)
tagfilenameEpisode tag (e.g. S03E02)
seasonparsed JSON seasonInteger season number
episodeparsed JSON episodeInteger episode number
titleparsed JSON titleEpisode title
season_titleparsed JSON season_titleArc/season title
dialogue_linesparsed JSON stats.dialogue_linesTotal voiced lines
wordscounted from entriesWord count (dialogue entries only)
tts_charsparsed JSON stats.characters_for_ttsTTS character budget

Data flow

flowchart TD
    PARSED["`📂 parsed/{slug}/
    parsed_<tag>.json (all episodes)`"]
    COLLECT["`_collect_files()
    Glob parsed_*.json recursively
    Skip roundtrip_ and pre_splice_ prefixes`"]
    BUILD["`build_summary()
    Extract show / tag / season / episode
    title / season_title / stats
    Count words from dialogue entries`"]
    SORT["`Sort by show → season → episode → tag`"]
    CSV["`📊 episode_summary.csv
    One row per episode`"]

    PARSED --> COLLECT --> BUILD --> SORT --> CSV

--show filter is case-insensitive. Use --show "THE 413" to limit output to one show when the workspace contains multiple shows. --stdout suppresses the run banner — safe to pipe to other tools. No API key required — reads local JSON files only.


22. XILU015 — Stem Verifier

Scans a stems folder and produces a JSON report with file attributes and optional Faster-Whisper transcriptions. Useful for verifying that generated MP3s contain the expected dialogue and for auditing file integrity after a production run.

xil-stem-verify --episode S01E01 --no-transcribe          # attributes only (fast)
xil-stem-verify --show the413 --episode S01E01             # attributes + transcription
xil-stem-verify --show the413 --episode S01E01 --model small --language en
xil-stem-verify --stems-dir /custom/path --output /tmp/report.json --no-transcribe

CLI flags

FlagDefaultDescription
--show / -sfrom project.jsonShow slug override
--episode / -e—Episode tag (required)
--stems-dir DIR<workspace>/stems/<slug>/<episode>/Override stems directory
--output / -o<workspace>/parsed/<slug>/stem_verify_<episode>.jsonOutput JSON path
--whisper-python PATHauto-detectedPath to venv-whisper/bin/python3
--model SIZElarge-v3-turboWhisper model: tiny/base/small/medium/large-v3/large-v3-turbo
--language LANGenLanguage hint; use auto for detection
--beam-size N5Whisper beam size
--devicecudacuda or cpu; auto-falls back to CPU if CUDA libraries missing
--no-transcribeoffSkip Whisper; output file attributes only

Output JSON structure

{
  "show": "the413",
  "episode": "S01E01",
  "generated": "2026-06-06T13:31:41",
  "stems_dir": "/abs/path/stems/the413/S01E01",
  "whisper_model": "large-v3-turbo",
  "file_count": 166,
  "total_duration_seconds": 743.23,
  "files": [
    {
      "filename": "003_cold-open_adam.mp3",
      "path": "/abs/path/003_cold-open_adam.mp3",
      "seq": 3,
      "scene": "cold-open",
      "speaker": "adam",
      "size_bytes": 164978,
      "duration_seconds": 10.188,
      "bitrate_kbps": 128,
      "sha256": "a5f3a27a…",
      "transcript": {
        "text": "full transcribed text",
        "language": "en",
        "language_probability": 0.9999,
        "segments": [{"start": 0.0, "end": 2.5, "text": "…"}]
      }
    }
  ]
}

transcript is null for all entries when --no-transcribe is used. SFX stems naturally produce empty or near-empty transcripts.

Data flow

flowchart TD
    STEMS["`📂 stems/<slug>/<episode>/
    *.mp3 files`"]
    MUTAGEN["`mutagen.mp3.MP3()
    duration_seconds · bitrate_kbps`"]
    HASH["`hash_file() from XILU007
    SHA-256 digest`"]
    PARSE["`_parse_stem_filename()
    seq · scene · speaker from filename`"]
    WHISPER{"`--no-transcribe?`"}
    WORKER["`_WhisperClient
    venv-whisper subprocess
    whisper_worker.py JSON protocol`"]
    NOOP["`transcript: null`"]
    JSON["`📊 parsed/<slug>/
    stem_verify_<episode>.json`"]

    STEMS --> MUTAGEN
    STEMS --> HASH
    STEMS --> PARSE
    STEMS --> WHISPER
    WHISPER -->|no| WORKER
    WHISPER -->|yes| NOOP
    MUTAGEN --> JSON
    HASH --> JSON
    PARSE --> JSON
    WORKER --> JSON
    NOOP --> JSON

venv-whisper setup: Create once with xil setup whisper (or by hand: python3 -m venv venv-whisper && venv-whisper/bin/pip install faster-whisper). Place at workspace root or repo root — auto-detected in that order. CUDA fallback: whisper_worker.py probes CUDA at startup (silent 1-second test transcription). If libcublas or other GPU libraries are missing (common in WSL2), it silently reinitializes on cpu/int8 before reporting ready. No ElevenLabs API key required — reads local files only.


23. XILU016 — Stem Compare

Cross-references a stem_verify Whisper transcript report against the parsed script to flag dialogue stems where TTS produced audio that differs significantly from the scripted line. Uses difflib.SequenceMatcher (stdlib) — no extra dependencies beyond the main venv.

xil-stem-compare --episode S01E01                                        # auto-derives both JSON paths
xil-stem-compare --episode S01E01 --threshold 0.70                       # stricter threshold
xil-stem-compare --episode S01E01 --output compare_S01E01.json           # also write JSON report
xil-stem-compare --stem-verify report.json --parsed parsed_S01E01.json   # explicit paths
xil-stem-compare --episode S01E01 --csv                                  # CSV output to stdout

CLI flags

FlagDefaultDescription
--show / -sfrom project.jsonShow slug override
--episode / -e—Episode tag; derives both JSON paths
--stem-verify FILE<workspace>/parsed/<slug>/stem_verify_<episode>.jsonExplicit stem_verify JSON path
--parsed FILE<workspace>/parsed/<slug>/parsed_<episode>.jsonExplicit parsed script JSON path
--threshold FLOAT0.75Similarity below this → garbled
--output / -ononeWrite full JSON report to file (terminal summary always shown)
--csvoffPrint flagged entries as CSV to stdout instead of banner summary

Status codes

CodeCondition
oksimilarity ≥ threshold — not written to output
garbledsimilarity < threshold and transcript is non-empty
silenttranscript.text is empty/whitespace — Whisper heard nothing
no_stemdialogue entry exists in parsed but has no matching seq in stem_verify
not_transcribedstem exists but transcript is null (verify ran with --no-transcribe)

SFX stems (speaker == "sfx") are always excluded. Direction-only entries (direction_type not null) are also excluded.

JSON output (--output)

{
  "show": "the413",
  "episode": "S01E01",
  "generated": "2026-06-06T16:00:00",
  "threshold": 0.75,
  "stem_verify_path": "/abs/path/stem_verify_S01E01.json",
  "parsed_path": "/abs/path/parsed_S01E01.json",
  "summary": {
    "total_dialogue": 127,
    "ok": 125,
    "garbled": 2,
    "silent": 0,
    "no_stem": 0,
    "not_transcribed": 0
  },
  "flags": [
    {
      "seq": 156,
      "section": "act-1",
      "scene": null,
      "speaker": "mr_patterson",
      "status": "garbled",
      "similarity": 0.71,
      "original": "That's because she had.",
      "transcript": "That's because Shihan."
    }
  ]
}

Data flow

flowchart TD
    SV["`📊 stem_verify_<episode>.json
    Whisper transcripts per stem`"]
    PS["`📄 parsed_<episode>.json
    Scripted dialogue entries`"]
    JOIN["`join on seq
    dialogue entries only`"]
    NORM["`_normalize()
    lowercase + strip punctuation`"]
    SIM["`SequenceMatcher.ratio()
    character-level similarity`"]
    FLAGS["`flags list
    garbled · silent · no_stem · not_transcribed`"]
    OUT["`📊 compare_<episode>.json
    (--output, optional)`"]

    SV --> JOIN
    PS --> JOIN
    JOIN --> NORM
    NORM --> SIM
    SIM --> FLAGS
    FLAGS --> OUT

Workflow: Run xil-stem-verify with Whisper transcription first, then xil-stem-compare to find garbled lines. Adjust --threshold to taste — 0.75 catches clear substitutions; 0.90 surfaces minor discrepancies like “forty” vs “40”. No ElevenLabs API key required — reads local files only.


24. XILU017 — Remove Show

Removes all workspace files for a given show. Useful when retiring a show from the workspace or starting a production over from scratch.

xil remove-show mypodcast --dry-run
xil remove-show mypodcast --yes
xil remove-show "My Podcast" --dry-run
xil remove-show mypodcast --include-scripts --yes

Artifacts removed

PathNotes
configs/{slug}/All cast + SFX configs
parsed/{slug}/All parsed JSONs and CSVs
stems/{slug}/All TTS/SFX stems
daw/{slug}/All DAW layer WAVs
masters/{slug}/All master MP3s
cues/{slug}/Cues sheets + manifests
posts/{slug}/Social media post drafts
cast_{slug}_*.json, sfx_{slug}_*.json (root)Legacy layout configs
parsed/parsed_{slug}_*.json etc. (root)Legacy flat parsed files
.active_showCleared if it points to the removed show
scripts/*_{slug}_*.mdOnly with --include-scripts

SFX/ and logs/ are never touched.

CLI flags

FlagDescription
SHOW (positional)Show name or slug (mypodcast or "My Podcast")
--dry-run / -nShow what would be removed without deleting
--yes / -ySkip the confirmation prompt
--include-scriptsAlso remove matching scripts/*.md files (caution: source material)

Flow

flowchart TD
    INPUT["`SHOW
    name or slug`"]
    RESOLVE["`_resolve_slug()
    Accept name or slug
    Validate configs/{slug}/ exists`"]
    COLLECT["`_collect()
    Build removal list:
    dirs + files (normalized + legacy)`"]
    REPORT["`_report()
    Print [DIR] / [FILE] rows
    with file count + size`"]
    DRY{"--dry-run?"}
    CONFIRM{"--yes?"}
    PROMPT["`Type slug to confirm
    (or Ctrl-C to abort)`"]
    DELETE["`_delete()
    shutil.rmtree dirs
    Path.unlink files`"]
    DONE["`✓ N file(s) removed for show`"]

    INPUT --> RESOLVE --> COLLECT --> REPORT --> DRY
    DRY -->|yes| EXIT["Exit 0 (nothing deleted)"]
    DRY -->|no| CONFIRM
    CONFIRM -->|yes| DELETE
    CONFIRM -->|no| PROMPT --> DELETE
    DELETE --> DONE

Confirmation: Without --yes, the command requires typing the slug exactly before anything is deleted. Ctrl-C or a wrong input aborts cleanly. Scripts excluded by default: Production scripts are source material — they are never removed unless --include-scripts is passed explicitly. No API key required — local filesystem operations only.


25. XILU018 — Remove Episode

Removes all workspace artifacts for a single episode while leaving the source production script and shared assets untouched.

xil remove-episode S01E01 --dry-run
xil remove-episode S01E01 --yes
xil remove-episode S01E01 --show "Night Owls" --dry-run

Artifacts removed

PathNotes
configs/{slug}/cast_{tag}.jsonCast config
configs/{slug}/sfx_{tag}.jsonSFX config
parsed/{slug}/parsed_{tag}.json (+ .csv, orig_, pre_splice_, stem_verify_, annotated)All parsed variants
cues/{slug}/cues_{tag}.mdCues sheet
cues/{slug}/cues_manifest_{tag}.jsonCues manifest
stems/{slug}/{tag}/All stems for this episode
daw/{slug}/{tag}/All DAW layer WAVs
masters/{slug}/{tag}_*.mp3 + masters/{tag}_{slug}_{date}.mp3All master variants
posts/{slug}/{tag}_posts.mdSocial media post drafts
voice_samples/{tag}/Voice audition samples
Legacy root + parsed filescast_{slug}_{tag}.json, sfx_{slug}_{tag}.json, flat parsed variants

SFX/, logs/, and scripts/ are never touched.

CLI flags

FlagDefaultDescription
TAG (positional)—Episode tag to remove (e.g. S01E01)
--show / -sfrom project.jsonShow name or slug override
--dry-run / -noffShow what would be removed without deleting
--yes / -yoffSkip the confirmation prompt

Flow

flowchart TD
    INPUT["`TAG + optional SHOW`"]
    RESOLVE["`resolve_slug()
    Derive slug from project.json
    or --show override`"]
    COLLECT["`_collect()
    Enumerate normalized + legacy paths
    Deduplicate by path`"]
    REPORT["`_report()
    Print [DIR] / [FILE] rows
    with file count + size`"]
    DRY{"--dry-run?"}
    CONFIRM{"--yes?"}
    PROMPT["`Type TAG to confirm
    (or Ctrl-C to abort)`"]
    DELETE["`_delete()
    shutil.rmtree dirs
    Path.unlink files`"]
    DONE["`✓ N file(s) removed for episode`"]

    INPUT --> RESOLVE --> COLLECT --> REPORT --> DRY
    DRY -->|yes| EXIT["Exit 0 (nothing deleted)"]
    DRY -->|no| CONFIRM
    CONFIRM -->|yes| DELETE
    CONFIRM -->|no| PROMPT --> DELETE
    DELETE --> DONE

Script always preserved: scripts/ is excluded from removal — the source production script is never at risk. Flat masters scoped to slug: The masters/{tag}_{slug}_{date}.mp3 glob is scoped by slug so two shows sharing the same tag (e.g. both having an S01E01) cannot accidentally delete the wrong master. No API key required — local filesystem operations only.


26. XILU019 — Episode Status

Make-style staleness checker for the episode pipeline. Walks the artifact chain from Google Drive source through to master MP3, reporting per stage whether outputs are up to date with their inputs, and prints the exact xil commands needed to refresh anything stale. Nothing is ever rebuilt.

xil status --episode S01E01
xil status S01E01 --show "Night Owls"
xil status --all
xil status --episode S01E01 --json
xil status --episode S01E01 --verbose

Pipeline chain

Google Drive .gdoc → scripts/*.md → parsed/{slug}/parsed_{tag}.json
  → stems/{slug}/{tag}/*.mp3 → daw/{slug}/{tag}/*.wav → masters/{slug}/{tag}_*.mp3

Stage status values

StatusMeaning
OKStage has run at or after its newest input
STALENewest input is newer than newest output — stage has not re-run since input changed
MISSINGNo output files exist yet
-Informational (source stage only) — gdoc dir absent or no source doc found

STALE decision rule: max(inputs) > max(outputs). The newest output (not the oldest) is compared — older dedup-reused outputs from incremental builds do not trigger false staleness. What matters is whether the stage ran after its input last changed.

CLI flags

FlagDefaultDescription
TAG (positional) or --episode / -e—Episode tag to check (e.g. S01E01)
--show / -sfrom project.jsonShow name or slug override
--all / -aoffCheck all episodes for the show (one summary row per episode)
--gdoc-dir$XIL_GDOC_DIR or /mnt/i/My DriveGoogle Drive directory holding the source .gdoc
--jsonoffEmit results as JSON (single-episode mode only)
--verbose / -voffList each output file with its mtime below the stage row

Flow (single-episode mode)

flowchart TD
    INPUT["`TAG + optional SHOW`"]
    SLUG["`resolve_slug()`"]
    GDOC["`_gdoc_files()
    Glob {tag}*.gdoc in gdoc_dir
    warn if dir not mounted`"]
    SCRIPT["`_script_files()
    scripts/*{tag}*.md
    excluding revised_*`"]
    PARSED["`parsed/{slug}/parsed_{tag}.json`"]
    STEMS["`stems/{slug}/{tag}/*.mp3
    + *_stem_manifest.json`"]
    DAW["`daw/{slug}/{tag}/*.wav`"]
    MASTERS["`_master_files()
    masters/{slug}/{tag}_*.mp3
    masters/{tag}_{slug}_{date}.mp3
    legacy root {slug}_{tag}_master.mp3`"]
    EVAL["`_evaluate_stage() × 6
    compute OK / STALE / MISSING
    per stage`"]
    PRINT["`_print_episode()
    STAGE · STATUS · NEWEST INPUT
    NEWEST OUTPUT · FILES`"]
    REJECTED["`_rejected_sfx()
    SFX effects graded 'rejected'
    in shared pool — warn if any`"]
    REFRESH["`Print xil commands
    for stale/missing stages`"]

    INPUT --> SLUG --> GDOC
    GDOC --> EVAL
    SCRIPT --> EVAL
    PARSED --> EVAL
    STEMS --> EVAL
    DAW --> EVAL
    MASTERS --> EVAL
    EVAL --> PRINT --> REJECTED --> REFRESH

Flow (–all mode)

flowchart TD
    SLUG2["`resolve_slug()`"]
    DISCOVER["`_discover_tags()
    Glob parsed/ stems/ daw/ masters/
    for tags matching S\d+E\d+`"]
    LOOP["`For each tag:
    evaluate_episode()`"]
    WORST["`_worst()
    worst status across non-source stages`"]
    REJECTED2["`_rejected_sfx()
    count rejected SFX per episode`"]
    TABLE["`Print summary row:
    EPISODE · STATUS · NEXT STEP`"]

    SLUG2 --> DISCOVER --> LOOP --> WORST --> TABLE
    LOOP --> REJECTED2 --> TABLE

JSON output (--json, single-episode only)

{
  "show": "the413",
  "episode": "S01E01",
  "overall": "OK",
  "rejected_sfx": [],
  "stages": [
    {
      "name": "source",
      "status": "-",
      "newest_input": null,
      "newest_output": null,
      "oldest_output": null,
      "output_count": 0,
      "note": "no gdoc dir",
      "refresh": ""
    }
  ]
}

Stems stage: manifest inclusion

The stems stage is judged against *.mp3 files plus *_stem_manifest.json. The manifest is rewritten on every produce run — including no-op runs where all stems are dedup-reused — so it is the only file that advances when re-producing an unchanged episode. Without it, a stems stage made stale by a re-parse could never be cleared. The manifest is not included in the daw stage’s inputs: a no-op manifest bump must not invalidate an up-to-date daw layer.

Exit codes

CodeMeaning
0All stages OK
1Any stage is STALE or MISSING
2Usage or resolution error

Google Drive source: The gdoc stage is informational — a missing or unmounted drive dir warns and continues rather than failing. Override the default mount with --gdoc-dir or the XIL_GDOC_DIR environment variable. Rejected SFX: Effects graded rejected in xil-gui are flagged in the status output — these are omitted from production until re-graded or replaced. Use --json to machine-read the rejected_sfx list. No API key required — reads local files and mtimes only.


27. XILU021 — SFX Clipping Impact Report

Inventories every source= cue in the workspace and reports how much of each source file is actually reaching the mix. Read-only: no config is ever modified.

xil sfx-impact                                  # every show
xil sfx-impact --show thewoonsocketwonders      # one show
xil sfx-impact --episode S01E01 --show the413   # one episode
xil sfx-impact --tier 3-review --html           # worst cues + review page
xil sfx-impact --output - --quiet               # CSV to stdout for piping

Why it exists

duration_seconds means two different things. For an API-generated effect it is the requested generation length. For a source= cue it clips the file at mix time — and xil parse writes a default of 5.0 into every skeleton entry. A 120-second outro dropped into a hinted cue therefore plays for five seconds, silently, until somebody listens closely.

Precedence (mirrors the mixer)

The arithmetic follows mix_common.collect_stem_plans exactly, so the report cannot drift from what the audience hears:

Cue shapePlaysTier
loop: truefull file, tiled to fill the spanEXCLUDED
explicit play_durationthat percentage — a deliberate trimEXCLUDED
duration_seconds > 0clipped to that many secondsgraded by loss
duration_seconds: 0 or absentfull fileEXCLUDED
source unreadable—MISSING

Tiers

TierAudio lostMeaning
1-nochange< 0.1 sclipping only in the arithmetic; the budget exceeds the file
2-minor< 3 sjudgement call
3-review≥ 3 sthe creative should hear this cue

Each graded row carries a remediation column naming the concrete edit that would restore full length: play_duration: 100. Applying it is a human decision; this tool never writes.

Why play_duration: 100 and not duration_seconds: 0

Both play the whole file — either satisfies the precedence table above. But only play_duration appears in sfx_common.SFX_EDIT_FIELDS, the set of fields the timeline edit journal replays:

SFX_EDIT_FIELDS = ("volume_percentage", "ramp_in_seconds",
                   "ramp_out_seconds", "play_duration")

A duration_seconds edit is not journaled, so the next time that config is rebuilt from a fresh skeleton (xil parse with the config absent) it reverts to the parser’s 5.0 default and the cue silently re-clips — undoing a decision the creative already signed off. Setting play_duration: 100 leaves duration_seconds in place but inert, and survives regeneration.

tests/test_sfx_impact.py::TestRemediation::test_remediation_field_is_journaled asserts the recommended field stays inside SFX_EDIT_FIELDS, so this cannot silently regress if the journal’s field list changes.

Outputs

  • reports/sfx_impact_<date>.csv — one row per source-backed cue (override with --output)
  • --html — a standalone, self-contained review page (no external assets, so it can be mailed or dropped on a file share as-is), sorted worst-first
  • console — per-show tier tally plus the ten worst offenders, unless --quiet

Flow

flowchart TD
    CFG["`📋 configs/*/sfx_*.json
    every show, every episode`"]
    DISC["`discover_configs()
    --show / --episode narrow the sweep`"]
    SRC{"`entry has source=?`"}
    PROBE["`_mp3_duration_ms()
    mutagen header read — same
    helper the mixer uses`"]
    PREC["`measure_cue()
    loop > play_duration > duration_seconds`"]
    TIER["`grade by seconds lost
    + remediation`"]
    OUT["`📄 reports/sfx_impact_<date>.csv
    📄 optional .html review page
    console tier tally`"]

    CFG --> DISC --> SRC
    SRC -->|no| SKIP["skip (generated / silence)"]
    SRC -->|yes| PROBE --> PREC --> TIER --> OUT

Never writes. The report is a decision sheet. Pair it with xil sfx-restore if a config was already changed and you need the timeline editor’s journal replayed back. The journal (configs/{slug}/sfx_{tag}_edits.jsonl) holds both per-cue edits (POST /xil/update-sfx) and category-defaults edits (POST /xil/update-sfx-defaults, scope: "defaults" records) from the Timeline tab’s double-click modal — xil sfx-restore replays both kinds.


28. XILU022 — SFX Library Reconciliation

Finds existing library assets for cues whose declared source is not on disk — the cues that make xil produce refuse to start and that xil sfx-impact grades MISSING.

xil sfx-match --show deadair                        # report only
xil sfx-match --show deadair --emit-hints           # paste-ready script block
xil sfx-match --show deadair --apply --dry-run      # inspect every write
xil sfx-match --show deadair --apply                # EXACT + STRONG
xil sfx-match --show deadair --apply --accept-review

Why it exists

A missing source is usually not a missing sound — it is a misnamed reference. Two forces pull a reference away from its file:

  • the scriptwriter writes loose, near-duplicate direction text across iterations, and when it cannot find a cheatsheet line it invents a "NEW STEM NEEDED: <slug>.mp3" placeholder; and
  • the creative edits direction text after generation, before the pipeline sees it.

xil discover-sfx --export-kit publishes the [TITLE | file.mp3] cheatsheet so a human can consolidate those duplicates going forward, and xil sfx-hydrate writes the hints the scriptwriter did pick. Nothing went back and asked whether the sound already existed under another name. This does.

What it matches on

Assets written by tag_mp3() carry their originating cue text in ID3 TIT2, which makes the library self-describing. That title is far better evidence than a filename — in practice the two have drifted apart, and an asset titled SFX: CHAIR SCRAPING — ELENA STANDING sits in a file called SFX-_CHAIRS_—_SOFT_SCRAPING,_SITTING_DOWN.mp3. Match text per asset is title → generation prompt (USLT) → filename, and the report shows the title, because a filename alone makes a correct match look wrong.

Scoring

TermDefinitionRole
coverage`cue ∩ candidate
jaccard`cue ∩ candidate

Coverage leads because library titles are much more verbose than cue text; a symmetric measure punishes the right answers for their extra words. On real data jaccard alone found 5 of 30 recoverable cues, coverage found 21.

TierRule--apply
EXACTslugify_effect_key(cue).mp3 is in the libraryyes
STRONGcoverage ≥ 0.8, jaccard ≥ 0.4, and ≥ 0.15 score margin over the runner-upyes
REVIEWcoverage ≥ 0.5only with --accept-review
NONEbelow the floor — needs generationnever

Two guards stop a plausible-looking wrong answer from being applied automatically:

  • a category gate — classify_placement() must agree between cue and candidate, so an AMBIENCE: cue is never answered by a MUSIC asset; and
  • a margin rule — a leader with no daylight over its runner-up is a review case. SFX: FOOTSTEPS APPROACHING really does have three distinct candidates at coverage 1.00, and only a human can choose. Without the margin term one would be picked arbitrarily.

The regression that motivated both: SFX: COFFEE CUP BEING SET DOWN ranks bag_being_set_down.mp3 above the correct coffee-mug asset on raw token count.

The exact index

Exact-slug lookup runs against every asset, not the deduplicated pool. Dedupe collapses copies by (match text, duration) — and the copy it discards may be the one carrying the slug name the cue resolves to. SFX: PHONE SCREEN TAP has an exact sfx_phone-screen-tap.mp3 on disk, but it shares a title with SFX-_PHONE_SCREEN_TAP,_SEND_TONE.mp3 in a more preferred scope and loses the group; searching the pool alone reported REVIEW for a cue the library answers exactly.

What --apply does

  1. Copies the matched asset into the show’s own pool at shared_sfx_path(sfx_dir(show), cue) — named for the cue, not the source asset.
  2. Retitles the copy’s TIT2 to the cue text and records provenance in COMM. Only those two frames are touched, so the album, artist, generation prompt and grade frame survive the copy.
  3. Journals the new source via append_sfx_edit, so the assignment survives a rebuild from a fresh script .md — the drift being repaired.

Naming the copy after the cue is what makes the repair permanent. Afterwards the exact-slug rule holds, so a re-run reports EXACT, and the next xil discover-sfx --export-kit lists the asset under the cue text — the scriptwriter finds it unaided instead of inventing another placeholder.

The library scan is deferred. Reading ID3 and MP3 headers across a workspace-sized library takes minutes over a network mount, so it happens only once, and only after a cue with an unresolvable source is actually found.


29. Data Model Reference

All data classes used across the pipeline, grouped by layer. Pydantic BaseModel subclasses (all in models.py) carry validation and are serialized to/from JSON. @dataclass instances are in-memory runtime state only.

28a. Script parsing models — XILP001 output

classDiagram
    class ParsedScript {
        <<Pydantic>>
        +string show
        +int? season
        +int episode
        +string title
        +string? season_title
        +string source_file
        +List~ScriptEntry~ entries
        +ScriptStats stats
        +tag() string
    }
    class ScriptEntry {
        <<Pydantic>>
        +int seq
        +string type
        +string? section
        +string? scene
        +string? speaker
        +string? direction
        +string text
        +string? direction_type
        +string? sfx_source
        +Dict~string,float~? sfx_overrides
    }
    class ScriptStats {
        <<Pydantic>>
        +int total_entries
        +int dialogue_lines
        +int direction_lines
        +int characters_for_tts
        +List~string~ speakers
        +List~string~ sections
    }
    ParsedScript "1" *-- "many" ScriptEntry : entries
    ParsedScript "1" *-- "1" ScriptStats : stats

type values: dialogue · direction · section_header · scene_header direction_type values: SFX · MUSIC · AMBIENCE · BEAT · VINTAGE FILTER


28b. Cast configuration models

classDiagram
    class CastConfiguration {
        <<Pydantic>>
        +string show
        +int? season
        +int? episode
        +string? tag_override
        +string? title
        +string? season_title
        +string artist
        +Preamble? preamble
        +Preamble? postamble
        +Dict~string,CastMember~ cast
        +tag() string
    }
    class CastMember {
        <<Pydantic>>
        +string full_name
        +string voice_id
        +float pan
        +string|bool? filter
        +string role
        +float? stability
        +float? similarity_boost
        +float? style
        +bool? use_speaker_boost
        +string? language_code
        +float? speed
    }
    class Preamble {
        <<Pydantic>>
        +string? text
        +List~PreambleSegment~? segments
        +string speaker
        +float? speed
    }
    class PreambleSegment {
        <<Pydantic>>
        +string text
        +string? shared_key
    }
    class VoiceConfig {
        <<Pydantic>>
        +string id
        +float pan
        +string|bool? filter
    }
    CastConfiguration "1" *-- "many" CastMember : cast
    CastConfiguration "1" o-- "0..1" Preamble : preamble / postamble
    Preamble "1" o-- "many" PreambleSegment : segments
    CastMember ..> VoiceConfig : flattened into at load time

filter values: false (none) · "phone" · "vintage" · "vintage,phone" pan range: −1.0 (full left) → 0.0 (centre) → 1.0 (full right) VoiceConfig is the stripped-down view built by load_production() for use inside XILP002/XILP003.


28c. SFX configuration models

classDiagram
    class SfxConfiguration {
        <<Pydantic>>
        +string show
        +int? season
        +int? episode
        +string? tag_override
        +dict defaults
        +Dict~string,SfxEntry~ effects
        +List~string~ vintage_scenes
        +tag() string
    }
    class SfxEntry {
        <<Pydantic>>
        +string? prompt
        +string type
        +float duration_seconds
        +float? prompt_influence
        +bool loop
        +string? source
        +float? volume_percentage
        +float? ramp_in_seconds
        +float? ramp_out_seconds
        +float? play_duration
    }
    SfxConfiguration "1" *-- "many" SfxEntry : effects

SfxEntry.type values: sfx (API-generated) · silence (stop marker — no audio file) source path bypasses API generation and reads the named file from SFX/. duration_seconds is capped at 30 s for API-generated entries (ElevenLabs limit).


28d. Production runtime models

classDiagram
    class ProjectConfig {
        <<Pydantic>>
        +string show
        +string type
        +int? season
        +string? season_title
        +string? tag_format
    }
    class DialogueEntry {
        <<Pydantic>>
        +string speaker
        +string text
        +string stem_name
        +int seq
        +string? section
        +string? direction
    }
    class StemPlan {
        <<dataclass>>
        +int seq
        +string filepath
        +string? direction_type
        +string? entry_type
        +string? text
        +string? scene
        +bool foreground_override
        +float? volume_percentage
        +float? ramp_in_seconds
        +float? ramp_out_seconds
        +float? play_duration
        +string? tts_model
        +bool pre_trimmed
        +bool loop
    }

ProjectConfig.type values: podcast · audiobook · drama · special DialogueEntry is produced by load_production() from ScriptEntry rows; stem_name is the output filename without extension. StemPlan is built by collect_stem_plans() in mix_common.py from the stems directory and is consumed by XILP003 and XILP005.


28e. Pipeline utility models

classDiagram
    class MigrationAction {
        <<dataclass>>
        +string status
        +int new_seq
        +string new_stem
        +int? old_seq
        +string? old_stem
        +string reason
        +string entry_type
        +string? speaker
        +string new_text
        +string old_text
    }
    class StageStatus {
        <<dataclass>>
        +string name
        +string status
        +float? newest_input
        +float? newest_output
        +float? oldest_output
        +int output_count
        +string note
        +string refresh
        +bool inputs_present
        +List~Path~ output_files
    }
    class TimelineData {
        <<dataclass>>
        +string tag
        +float total_duration_s
        +Dict~string,List~ layers
    }
    class LayerSpan {
        <<dataclass>>
        +float start_s
        +float end_s
        +string label
        +float? ramp_in_s
        +float? ramp_out_s
        +float? play_duration
        +string? snippet
        +float? volume_pct
        +int? seq
        +string? tts_model
    }
    class CommandSpec {
        <<frozen dataclass>>
        +string module
        +string description
        +string group
        +string hint
    }
    class RemovalItem {
        <<union _Dir | _File>>
        +Path path
        +string label
        +file_count() int
        +total_bytes() int
    }
    class _Dir {
        <<dataclass>>
    }
    class _File {
        <<dataclass>>
    }
    TimelineData "1" *-- "many" LayerSpan : layers
    RemovalItem <|-- _Dir
    RemovalItem <|-- _File

MigrationAction.status values: COPY · SPEAKER · NEW · MISSING · SKIP (see §12) StageStatus.status values: OK · STALE · MISSING · - (see §26) CommandSpec is frozen=True — instances are immutable entries in the XIL_SCRIPT_COMMANDS registry in xil.py. RemovalItem is the union type _Dir | _File used by XILU017 and XILU018; both share the same interface.


Man Pages

Every command has a man page, generated from its option definitions and committed under man/man1/ in the repository.

man -l man/man1/xil-parse.1      # read one in place
export MANPATH="$PWD/man:$(manpath 2>/dev/null)"
man xil-produce
man xil                          # dispatcher overview (lists all commands)

Regenerate them after changing any command’s options; CI fails when the committed pages are stale:

xil --generate-man man/man1

NAS Impact Assessment — XIL_PROJECTROOT on a Network Mount

History. Written for the Python dashboard, which served files through Gradio’s /gradio_api/file= endpoint. The Rust xil gui serves the workspace under /ws/; the relative-URL and local-cache findings below still apply.

Written 2026-07-11, when XIL_PROJECTROOT was being moved from local disk (/mnt/c/Users/shaba/xil-projects) to a NAS mount (/mnt/cloudsy/...). Every workspace read becomes a network round-trip; every stat becomes an SMB metadata call. This doc ranks where that hurts, what has already been mitigated in code, and what to watch during and after migration.

The code root (XIL_CODEROOT, the repo itself) stays local — only content workspace I/O is affected.


Ranked pain points

0. Timeline artifacts baked absolute XIL_PROJECTROOT paths — 403 after the root moved

Status: fixed (root-agnostic relative paths, 2026-07-11)

This was the first thing that actually broke on the NAS. The generated timeline HTML embedded absolute workspace paths in LAYER_AUDIO and CLIPS (e.g. /mnt/c/Users/shaba/xil-projects/daw/.../layer_dialogue.wav). The GUI serves the timeline in an iframe via /gradio_api/file=<abs>, whose allowed_paths is the current workspace root. Once XIL_PROJECTROOT moved to /mnt/cloudsy/..., every embedded /mnt/c/... path fell outside allowed_paths and the browser got HTTP 403 “File not allowed” for all five layer WAVs and every clip — playback with no sound and (under the old pre-prefetch code) no visible error. Regenerating on the NAS didn’t help either, because parsed/ had not been synced there yet, so xil daw bailed with “Parsed JSON not found.”

Root cause: an artifact that hard-codes the absolute root is invalidated by any root move, and re-generating it requires the full source tree (parsed/, cast configs) present at the new root.

Fix: render_html_timeline now stores every audio URL relative to the timeline file’s own directory (os.path.relpath, POSIX separators, keeping the ?v={mtime} cache-buster). Layer WAVs sit beside the timeline → S07E01_layer_dialogue.wav?v=…; stems resolve as ../../../stems/<slug>/<TAG>/<seq>_….mp3?v=…. The browser resolves these against the iframe’s document URL (/gradio_api/file=<abs timeline>), so the same artifact works under any root — local or NAS — with no regeneration. A plain file copy to the NAS is now sufficient; the prefetch fetches the relative path directly (no /gradio_api/file= concatenation). Guarded by test_paths_are_root_agnostic_relative.

Migration action: timeline HTML files copied from the old local tree still contain the old absolute paths — re-sync or regenerate them so they carry the relative form. (Done for the 26 episodes already present on the NAS on 2026-07-11; the413/S07E03 was not yet synced.)

1. Episodes-tab scan — every browser connect walked the whole workspace

Status: mitigated (GUI cache, 2026-07)

xil-gui’s Episodes tab calls the XILU019 episode-status engine, which rglobs + stats every stems/daw file and the full scripts/ tree per episode. On local disk that is milliseconds; over SMB it is thousands of serial metadata round-trips — and it fired on every browser connect (demo.load).

Mitigation: _refresh_episodes results are now cached per workspace root with a 5-minute TTL (_EPISODES_CACHE in xil_gui.py). New browser tabs get cached rows instantly; the ⟳ Refresh button forces a rescan.

2. Timeline full-mix playback — 5 WAVs (~1–1.7 GB) streamed blind

Status: mitigated (browser prefetch with progress, 2026-07)

The timeline transport played five layer WAVs streamed straight off /gradio_api/file=<NAS path>. Over a NAS the browser buffers stalled with zero feedback — the “hang” that motivated this assessment.

Mitigation: the generated timeline HTML now prefetches all layers to Blobs with an aggregate Loading audio… N% (X / Y MB) readout, per-layer MB rows, and a Cancel button (second click on ▶ also cancels). Loaded blobs are cached by URL + mtime cache-buster, so replays are instant. Single-clip preview and the sound-profile workflow got the same treatment (Loading… N% in the player strip). Remember: the timeline HTML is a build artifact — the fix reaches an episode only after xil daw --episode <TAG> --dry-run --timeline-html regenerates it (done for all existing artifacts on 2026-07-11).

3. GUI audio tabs — every selection re-read the file from the NAS

Status: mitigated (local read-through cache, 2026-07)

The Audio Preview and Audio Grading tabs handed NAS paths directly to gr.Audio, and “play all” concatenation decoded every stem from the mount on every click (and leaked a temp file per click).

Mitigation: _cached_audio_path() copies workspace audio to a bounded local cache (~/.cache/xil-gui/audio/, 2 GB budget, oldest-mtime eviction) with a Copying <name>… X / Y MB progress bar; keys are sha1(path|size|mtime) so they self-invalidate. Concatenated “play all” products are cached the same way (fixing the temp-file leak). Any OSError degrades to direct streaming — playback never breaks because the cache did.

4. No I/O timeouts anywhere — a stalled mount is an indefinite hang

Status: open — mount configuration + startup probe recommended

Nothing in the pipeline or GUI sets I/O deadlines. A wedged NAS mount (sleep, network blip, SMB reconnect) turns every stage and every GUI handler into an indefinite hang, indistinguishable from “slow.”

Recommendations:

  • Mount with timeouts rather than hard-hanging defaults — for CIFS/SMB: soft, sensible echo_interval, and actimeo to batch attribute reads; for NFS: soft,timeo=...,retrans=....
  • Add a coarse liveness probe at GUI/CLI startup (e.g. os.stat of the workspace root in a thread with a 5 s deadline) and print a clear banner (“workspace on NAS is not responding”) instead of hanging silently.

5. Master export reads everything, every run

Status: open — acceptable cost, watch it

XILP011 (master export) full-loads all five layer WAVs each run, and daw/assembly stages do per-stem pydub/ffmpeg decodes of full files. That is 1–1.7 GB pulled across the network per export, per attempt. Unlike the GUI there is progress logging, so it is slow-but-visible rather than a hang.

Future option: a read-through staging copy for pipeline stages, reusing the same key scheme as the GUI audio cache.

6. Atomic renames and journal appends over SMB

Status: open — audit during migration

The pipeline leans on os.replace/rename for atomic writes and appends to JSONL journals (sfx_*_edits.jsonl). On SMB, rename onto a target that another process holds open (e.g. Gradio streaming a WAV while a re-export replaces it) can fail with sharing violations, and append atomicity is not guaranteed across clients.

Recommendation: wrap workspace renames in a small retry helper (3–5 attempts, short backoff) during migration; keep single-writer discipline for journals.

7. sync_to_NAS.sh — now a full-tree mirror (was a partial subset)

Status: fixed 2026-07-11 — rewritten to mirror the whole workspace

The casing concern originally noted here (DAW/ vs daw/) was already resolved (the script uses lowercase daw/). The real problem, found while running a sync for the cutover, was coverage: the old script pushed only configs/ masters/ scripts/ voice_refs/ .regen* + root *.sh, and synced daw/+stems/ only under an explicit --stems flag. parsed/ was never synced at all — which is exactly why xil daw --timeline-html could not regenerate a timeline natively on the NAS (“Parsed JSON not found”).

Fix: rewritten to mirror the entire $XIL_PROJECTROOT in one rsync -av --delete --partial ./ "$NAS"/, excluding only transient caches/junk (.ruff_cache/, __pycache__/, *.pyc, .DS_Store, Thumbs.db). This now includes parsed/, all content dirs, and the loose root reports/logs. --dry-run is preserved.

Direction caveat (important): this is a local → NAS mirror with --delete — correct only while the local disk is the source of truth (i.e. during the cutover). Once XIL_PROJECTROOT points at the NAS and you begin writing there (grades, sfx edits, new renders), re-running it would erase those NAS-side changes. After cutover, retire the script or reverse src/dst for a NAS→local backup.

Slowness note: even a --dry-run of the full tree is minutes-long — just building the file list for daw/+stems/ is thousands of SMB stat round-trips (a live instance of the metadata cost in #1/#4). Run the real mirror in the background; the first pass moves ~24 GB, later passes are incremental (only changed files transfer, but the traversal cost remains).

8. SFX grade scan — 842 serial ID3 reads

Status: mitigated (persistent grade cache, 2026-07)

The Grading tab’s _scan_sfx_grades opened every SFX MP3 to read its ID3 grade tag on each scan. Over a NAS: ~842 file opens per refresh.

Mitigation: grades persist in <sfx>/.xil_grade_cache.json keyed by relative path + size + mtime; a rescan re-reads only new/changed files, and grading a file patches the cache in place. Corrupt/missing cache degrades to a clean full rescan.

9. Miscellaneous frictions

Status: open — low individual cost

  • derive_paths() performs double exists() checks per call; in hot loops each becomes an SMB round-trip. Future: memoize layout detection per workspace root per process.
  • The staleness engine compares mtimes; SMB servers may truncate mtime granularity (commonly to 1–2 s). Same-second regenerations can be judged “not newer.” Low risk given multi-second stage runtimes, but if phantom staleness appears post-migration, check timestamp granularity first.
  • WSL drvfs already made birthtime/ctime unreliable for produce-time detection (stem-manifest mtime is used instead); verify that signal survives the NAS move too.

Migration checklist

  1. Fix the DAW/ vs daw/ casing in sync_to_NAS.sh before first sync (#7).
  2. Mount with soft/timeout options, not hang-forever defaults (#4).
  3. Sync the whole source tree, not just daw/ — in particular parsed/ and configs/. Without parsed/, xil daw --timeline-html cannot regenerate a timeline on the NAS (“Parsed JSON not found”).
  4. Re-sync or regenerate every timeline HTML so it carries the new relative audio paths (#0); a timeline copied before 2026-07-11 still has absolute /mnt/c/... paths and will 403 on the NAS.
  5. First GUI session on the NAS: expect the first Episodes scan and SFX grade scan to be slow (cold caches); subsequent ones should be fast. If they are not, the caches are not persisting — investigate before blaming the NAS.
  6. Spot-check that a re-exported master replaces cleanly while the GUI is open (#6).
  7. Confirm playback: pick an episode, press ▶, watch for the “Loading audio… N%” strip and per-layer MB counters, then sound. If the progress strip completes but no audio plays, see the note below on post-await autoplay.

Post-await autoplay — verified working

The mix/clip prefetch calls .play() after await fetchAudioBlob(...). Browsers gate media playback on user activation; a click on ▶ sets sticky activation for the (same-origin) iframe document, which survives the await, so play proceeds. Confirmed 2026-07-11 in the browser against the NAS: S07E01 pressed ▶ → hourglass + “Loading audio…” strip → full-mix playback started on completion. Recorded here only as the escape hatch if a future browser/policy change blocks it: start each element muted and unmute once playing, or fall back to native streaming with a buffered-progress overlay instead of full prefetch.

Programmer Reference

The API reference is generated by cargo doc from the doc comments in the source, and published with this guide on every push to main. Private items are included, because every crate here is internal to the xil binary.

CrateWhat
xilthe xil binary: command table and one module per stage (crates/xil-cli)
xil_coreworkspace paths, config models, edit journal, logging
xil_audioPCM math matching pydub/audioop, ffmpeg bridge, ID3/WAV tags
xil_apiElevenLabs / Anthropic / gTTS clients
xil_workersJSON-over-stdio client for the Python ML workers
xil_webxil gui: the axum + htmx dashboard

To build it locally:

cargo doc --workspace --no-deps --document-private-items --open

Sample Episode S01E01

My Podcast Season 1: Episode 1: “Pilot”

CAST:

  • HOST — the radio host
  • CALLER — a mysterious caller
  • GUEST — a local expert

===

PREAMBLE

HOST Welcome to My Podcast. This is Season 1, Episode 1, Pilot. HOST Thank you for listening.

[INTRO MUSIC]

===

COLD OPEN

SCENE 1: THE STUDIO

[AMBIENCE: Radio station studio, low hum of equipment, muffled music in background]

HOST Good evening, and welcome to the show.

[SFX: Phone ringing]

HOST (picking up phone) We have our first caller of the night. You’re on the air.

CALLER (distorted, nervous) Hi… I wasn’t sure I should call, but I saw something last night that I can’t explain.

HOST Take your time. Tell us what happened.

CALLER I was driving home on Route 9 when the radio cut out. Just static. And then… a voice. Not from any station.

[BEAT]

HOST What did the voice say?

CALLER It said my name. And then it said… “Don’t go home.”

[BEAT — 3 SECONDS]

===

ACT ONE

SCENE 2: THE INTERVIEW

[AMBIENCE: Same studio, quieter now]

HOST Joining us in the studio tonight is Dr. Alex Reeves. Doctor, you’ve studied phenomena like this before.

GUEST That’s right. What your caller described is consistent with a pattern we’ve documented across several counties.

HOST A pattern?

GUEST (leaning in) Electromagnetic interference followed by anomalous audio. We’ve recorded over thirty incidents in the past year alone.

[SFX: Paper rustling]

GUEST I brought some of the recordings with me, if you’d like to hear them.

HOST Absolutely. Let’s play one for our listeners.

[MUSIC: Eerie ambient drone, building tension]

[BEAT]

HOST Well… that’s certainly unsettling. We’ll be right back after this break.

===

POSTAMBLE

[OUTRO MUSIC]

HOST That’s all for this episode of My Podcast. Season 1, Episode 1, Pilot. HOST Thank you for listening.

===

END OF EPISODE

Tech Deep Dive S01E04

Tech Deep Dive Season 1: Episode 4: “The Rise of Open Source AI” Arc: “The Architect”

CAST:

MAYA CHEN — host and tech journalist, skeptically optimistic DARIUS OKAFOR — open source advocate and ML engineer

===

PREAMBLE

MAYA Welcome to Tech Deep Dive. Season 1, Episode 4: The Rise of Open Source A I.

[INTRO MUSIC | INTRO_MUSIC.mp3 | play_volume_pct=26%]

===

COLD OPEN SCENE 1: PODCAST STUDIO — PRE-SHOW [AMBIENCE: Quiet studio hum, occasional keyboard clicks in background] MAYA (checking notes) Okay. Three minutes to air. DARIUS (relaxed) I’ve been waiting for someone to ask these questions out loud. MAYA Then let’s make it worth it. [BEAT]

===

ACT TWO

SCENE 6: PODCAST STUDIO

[AMBIENCE: Quiet studio hum, occasional keyboard clicks in background]

[MUSIC: SHOW INTRO — FPS - TECH and LOUNGE]

MAYA So when Meta dropped LLaMA, that was kind of a watershed moment, right? Nobody really saw it coming.

[BEAT]

DARIUS Nobody in the closed-source world, maybe. The open community had been pushing for this for years. What changed was that the economics finally forced the conversation. MAYA (leaning in) Meaning the cost of training came down enough that hoarding the weights stopped making sense? DARIUS Exactly. And once LLaMA was out — even the way it got out — you had thousands of people fine-tuning it within weeks. That’s not something OpenAI can replicate internally. [SFX: Coffee mug set down on desk] MAYA But there’s a real tension here. Openness accelerates capability. And capability without guardrails… DARIUS (finishing the thought) …is the thing everyone’s quietly terrified about. Yeah.

[BEAT]

MAYA We’ll get into the safety implications right after the break.

===

POSTAMBLE

[OUTRO MUSIC | OUTRO_MUSIC.mp3 | play_volume_pct=13%]

MAYA That was Tech Deep Dive, Season 1, Episode 4: The Rise of Open Source A I. MAYA Thanks for listening.

===

END OF EPISODE

Audiobook Sample: “Signals” Chapter 1: “The Signal”

CAST:

NARRATOR — measured, unhurried; the voice carrying the story ELARA VOSS — protagonist, radio engineer, mid-thirties

===

PREAMBLE

NARRATOR Signals. An audiobook. Chapter 1: The Signal.

[INTRO MUSIC]

===

CHAPTER ONE SCENE 1: REMOTE RESEARCH STATION — INTERIOR [AMBIENCE: Wind against insulated walls, low electrical hum of equipment racks] NARRATOR The station had been silent for eleven days before Elara stopped counting. [BEAT] NARRATOR Not silent the way a library is silent. Silent the way a held breath is silent. [SFX: A desk chair rolling across concrete floor] ELARA (quietly, to herself) There it is again. [SFX: Static crackle from a receiver, then — beneath it — a faint rhythmic pulse] NARRATOR It wasn’t in any of the logs. She’d checked three times. Whatever was riding the 1.4 gigahertz band had no business being there — that frequency was protected. Reserved. Theoretically empty. [BEAT — 3 SECONDS] ELARA (almost a whisper) You’re not supposed to exist. [SFX: Pen scratching across paper as she begins writing] NARRATOR She wrote down the time. Then the frequency. Then, after a long moment, a single word she couldn’t quite explain. [BEAT] NARRATOR Alive.

===

POSTAMBLE

[OUTRO MUSIC]

NARRATOR That was Signals, Chapter 1: The Signal.

===

END OF EPISODE

About

Who is the author of this mess

John Brissette is the author of this mess.

Does a frog bump its butt when it jumps?

Why did he write this mess

Partly to expand Python knowledge, partly to help his wife’s endeavors, and partly to annoy his son who is a far superior programmer.

See his son’s impressive work at: https://github.com/Haidra-Org/AI-Horde