LPL Script Writer (v2.0)
The LLM that writes the whole radio drama
- script_text
- script_json
- news_used
- estimated_minutes
- technical_model
This is where OldTimeRadio's story actually gets written. OTR_LedgerScriptWriter is the LLM-powered writer: it turns a story source into a complete multi-act radio-drama script - outline, cast, dialogue, music beats - as a structured JSON ledger. Everything downstream (voices, music, video) consumes that ledger. It's the biggest node in the pack, and the one with the most knobs, so let's separate the knobs that matter from the ones you can ignore for weeks.
Start with the two that shape the episode: episode_title (leave blank to let the outline supply one) and num_characters (default 2, up to 10, 1 = monologue/diary mode). Note the tooltip's warning: this is a request, not a cap - a story that genuinely needs another voice may use one. The real ceiling is the voice stock, because two characters never share a voice. act_count (default 3) is the only knob that shapes episode length - more acts, more story; length is reported afterwards, never requested up front.
The inputs that matter
source_bank- which story lane runs. Defaultscifi_news_pro(sci-fi drawn from a live science feed). Also:media_archive,public_domain,shakespeare(CC BY-NC - inherits a noncommercial restriction on the source text),original(entropy-seeded fiction), androll (any eligible bank). There's a "custom_source_bank" row that fails loud until you actually author one - that's by design.custom_premise- empty means the RSS fetcher pulls a real science headline as the seed. Non-empty means your text verbatim, skipping RSS entirely. This is the "I want THIS story" override, and it's also how you work offline.creative_writing_model/technical_model- both default to Mistral-Nemo (the C7 byte-identical audio baseline). Two-slot routing: keep the technical slot smaller if you need VRAM headroom. Gemma options and GGUF quants are in the dropdown; OpenRouter/Google/Comfy Credits lanes activate only when you set the env key.creativity- curated presets:safe & tight(temp 0.60) throughmaximum chaos(0.95 - deliberately capped below 1.0 because higher caused format collapse).min_p(0.05) andrepetition_penalty(1.03) are the small-LLM tail-cut and loop-guard defaults.lemmy_cameo- the easter egg. Roll (~11%), always, or never. A grizzled Cockney comms officer may join your cast. It consumes a character slot.visual_styleandstory_scaffoldcontrol the look and the radio-drama shape grammar; both support "roll".
Outputs and gotchas
Outputs: script_text, script_json (the ledger - feed to OTR_LedgerFreezeCascade), news_used, estimated_minutes, and technical_model. Install is pack-level (ComfyUI Manager → ComfyUI-OldTimeRadio, restart). The writer needs a local LLM - the canonical workflow ships Gemma-4-12B, and llm_vram_ceiling_gb (14.5) gates downloads. transformers>=5.10.4 and accelerate>=1.1.0 are the finicky requirements; a "cannot load a model" error usually means one of those was missing on a fresh install. And know the pack's honest limitation: character drift - some episodes hand a line to the wrong character, the detector that would fix it ships disabled because it wasn't reliable. It doesn't break a render; it's just the cost of a 12B writer holding a whole cast in mind.
Inputs (39)
| Name | Type | Default | Description |
|---|---|---|---|
| source_bank | COMBO | roll (any eligible bank) | Story-path SOURCE BANK (multi-modal story schema). Selects which registered story pack supplies the pack-routed creative prompts and which lane the episode runs. scifi_news_pro = the local default sci-fi bank, an LLM-first multipass lane using the configured model slots. Each lane is an INDEPENDENT bank (own pack + bank metadata). The only non-runnable row is '+ Add Your Own' (custom_source_bank) -- picking it FAILS LOUD before any story work (no fallback), with its guide_ref naming the real path: author a bundle under user_packs/source_banks/, run 'otr_check bank <path> --activate', restart, and your bank joins this list as its own entry (contract: apple/EXTENDING.md, which ships with the pack; apple/EXTENDING_OTR.md is the longer version in the git tree). A bank's own default_story_model picks its story pack -- there is no separate pack widget. ROLL: pick 'roll (any eligible bank)' to let the run choose for you, uniformly, from every runnable bank whose lane can build the requested shape. This is INDEPENDENT of the visual_style roll -- rolling one does not roll the other. The choice is recorded in the ledger at meta.bank_roll (selected id, seed, and the exact pool it drew from); set OTR_BANK_SEED to replay a past roll. A pinned source_ref cannot be combined with the roll -- a pinned source belongs to one bank. |
| source_refopt | STRING | Optional source reference for source-bank lanes (for example a public-domain URL/id/title). Blank uses the bank's default source selection. This is not a fallback; unsupported nonblank references must fail loud in the consuming bank. | |
| visual_styleopt | COMBO | roll (any style) | NO VISIBLE EFFECT ON THE SHIPPED GRAPH. It styles the still and video prompts, and the canonical's three video lanes are procedural -- they draw from the audio and read no still, so this changes the filename and the receipt and nothing you can see. Switch a video lane to a still or diffusion engine first, then it is one of the strongest controls here. Story content is never touched either way. 'roll (any style)' picks one for you, recorded at meta.style_roll. |
| episode_titleopt | STRING | Optional episode title override. Stamped at ledger.meta.episode_title so SignalLostVideo picks it up directly without title-chain fallback. Leave blank to let the outline supply a title. | |
| custom_premiseopt | STRING | For My Story, enter your idea here and optionally add characters, plot, setting and who it is by. Leave every creative field blank and the standing premise that ships with the pack is written instead, so a rolled My Story run never refuses. Other banks keep their own source and premise rules. | |
| story_charactersopt | STRING | MY STORY ONLY. Character names and notes, in ordinary words: 'Ada, the lighthouse keeper. Her brother Tom, who does not believe her.' Names you write here are kept exactly as you type them. If you say someone is a man or a woman, that is honoured; if you do not say, it is a free choice and is never guessed from the name. Someone merely mentioned does not become a speaking part. Leave it empty and the cast is invented for you. | |
| story_plotopt | STRING | MY STORY ONLY. Plot ideas: events, the conflict, a twist, an ending you want. Rough notes are fine. What you ask for here outranks anything the model would rather invent. What you leave out is invented to fit. | |
| story_settingopt | STRING | MY STORY ONLY. Setting: place, era, weather, mood, the world it happens in. This is the story's setting, not its LOOK -- the visual style dropdown chooses the look, and changing it never rewrites your setting. | |
| story_authoropt | STRING | MY STORY ONLY. Who the story is by. Put your own name here, or a pen name, or anyone you want it credited to. The announcer says it out loud and the closing credits print it. Leave it empty and the episode credits 'one of our listeners' -- no name is ever filled in for you. | |
| act_countopt | COMBO | 3 | Number of acts, 1-7. This is the only knob that shapes episode length, and your pick is always honoured. More acts means a story with more turns in it -- each act gets its own beat skeleton and its own pass. The episode ends up as long as the story needs; length is reported afterwards, never requested up front. 1 -> a single scene (trial length) 2 -> setup, resolution 3 -> setup, complication, resolution 7 -> the full arc, through crisis and climax |
| include_act_breaksopt | BOOLEAN | true | When ON (default), the outline LLM is told to plan music_inter beats between acts so the episode breathes between scenes. When OFF, the outline LLM is told the episode is one continuous flow with no music_inter beats. Outline schema (Beat.speaker_role) supports music_inter either way; this widget just tells the LLM whether to use it. Wired into the outline prompt via the `Target episode shape` line 2026-05-10. |
| num_charactersopt | INT | 21–10 | REQUESTED number of speaking characters (plus ANNOUNCER bookends). 1 = monologue/diary mode. This is a request, not a cap: a story that genuinely needs another voice may use one. The real ceiling is the voice stock, because two characters never share a voice. |
| lemmy_cameoopt | COMBO | roll (~11% chance) | LEMMY easter-egg cameo -- the genial Cockney communications officer who occasionally joins the cast. roll (~11% chance) -- default; LEMMY may appear at random. The roll uses OS entropy and is NOT tied to the seed (BUG-LOCAL-260), so a fixed seed no longer pins him on or off. always include -- force LEMMY into the cast this run. never include -- keep LEMMY out this run. 'always' consumes one of the num_characters slots, exactly as a natural roll does; 'never' leaves every slot to the cast. |
| story_scaffoldopt | COMBO | auto | How much the radio-drama SCAFFOLD shapes the story. off = a story drawn straight from the news seed (the base prompt only -- no style catalog, no climax-shape grammar, no grounding gate; the writer's own take). on = the news story shaped by ONE of the ~100 radio-drama styles (varied climax + ending + the premise-grounding body gate). auto (default) = follow the OTR_ENABLE_STYLE_GRAMMAR env / its default (ON). on/off override that env for THIS run. |
| min_popt | FLOAT | 0.050–0.5 | min_p sampling threshold (HuggingFace transformers). 0.05 (default) cuts the long tail of low-probability tokens that produce the occasional off-key word in an otherwise good line on 7B-14B small local LLMs (Mistral-Nemo, Gemma-2, Qwen2.5). Tier 2 fix #17 (2026-05-11) flipped this from 0.0 — preserving an unvalidated baseline is not preservation. 0.0 = disabled. Aggressive: 0.10. Pairs with the model's own top_p — when both are active the tail cut is the union. |
| repetition_penaltyopt | FLOAT | 1.031–1.2 | Repetition penalty for HuggingFace transformers generate. 1.03 (default) is gentle and helps small local LLMs avoid looping on character names / high-frequency tokens in short outputs. Tier 2 fix #17 (2026-05-11) flipped this from 1.0 — preserving an unvalidated baseline is not preservation. 1.0 = disabled. Values above 1.08 commonly damage short generations on the 7B-14B class. |
| max_new_tokens_capopt | INT | 20040–400 | Per-line max_new_tokens ceiling on the composer hot-path. Default 200 preserves current behavior. The Attempt-1 uses this cap directly; attempt-2 retry uses the full cap. It is a per-CALL decode budget, not a length target -- it was scaled from target_words until 2026-08-14, which made it a token ceiling derived from a word request. |
| creative_writing_modelopt | COMBO | comfy_native:gemma4-e2b-it-int8-convrot (4.8 GB download) | LLM for the creative/narrative passes (outline, cast, dialogue composer, polish, style picker invention). Mistral-Nemo is the C7 byte-identical audio baseline. Suffix tags like [LOCAL HF] and [NOT DOWNLOADED] are stripped before HF lookup. To use a remote OpenRouter model, set OPENROUTER_API_KEY and pick OpenRouter A/B (see https://github.com/jbrick2070/ComfyUI-OldTimeRadio/blob/main/docs/openrouter-setup.md). |
| technical_modelopt | COMBO | comfy_native:gemma4-e2b-it-int8-convrot (4.8 GB download) | LLM for the technical/structured passes (JSON validators, GBNF grammar output, reviewer verdicts, cast contract checks, format normalization, news interpreter). Profile/platform-owned baseline: your hardware profile pins it, and a direct headless -Set override is sanctioned and wins over the profile when supplied. Pick a smaller model here when you want Slot 1 != Slot 2 routing for VRAM headroom. To use a remote OpenRouter model, set OPENROUTER_API_KEY and pick OpenRouter A/B (see https://github.com/jbrick2070/ComfyUI-OldTimeRadio/blob/main/docs/openrouter-setup.md). |
| openrouter_slot_a_modelopt | COMBO | (enable OpenRouter) | OpenRouter model slug bound to the 'openrouter:slot-a' handle (the creative slot). Passive: only used when creative_writing_model is set to 'openrouter:slot-a'. Choices are the curated '~family-latest' aliases (which resolve upstream, so they never go stale) plus your favourites from the cached catalog; run the refresh script or set OTR_OPENROUTER_FULL_CATALOG=1 to browse every cached slug. Shows '(enable OpenRouter)' until OPENROUTER_API_KEY is set. A saved slug is preserved even if absent from a stale cache. See https://github.com/jbrick2070/ComfyUI-OldTimeRadio/blob/main/docs/openrouter-setup.md. |
| openrouter_slot_b_modelopt | COMBO | (enable OpenRouter) | OpenRouter model slug bound to the 'openrouter:slot-b' handle (the technical slot). Passive: only used when technical_model is set to 'openrouter:slot-b'. Choices are the curated '~family-latest' aliases plus your favourites from the cached catalog; shows '(enable OpenRouter)' until remote is enabled. OTR_OPENROUTER_SLOT_B_REQUIRE_JSON=1 limits the CACHED CATALOG rows to structured-output models; the curated aliases are policy, not catalog discovery, so they are still offered -- check the family before binding one here. See https://github.com/jbrick2070/ComfyUI-OldTimeRadio/blob/main/docs/openrouter-setup.md. |
| comfy_slot_a_modelopt | COMBO | (enable Comfy Credits) | Comfy Credits model slug bound to the 'comfy:slot-a' handle (the creative slot). Passive: only used when creative_writing_model is set to 'comfy:slot-a'. Choices are the pinned ComfyUI partner-node catalog (Sonnet 5, Luna and Sol are always listed so a saved cloud graph loads). The enable-sentinel remains choices[0]. Credit-billed at generate() when a Comfy API key is present. See https://github.com/jbrick2070/ComfyUI-OldTimeRadio/blob/main/docs/comfy-credits-setup.md. |
| comfy_slot_b_modelopt | COMBO | (enable Comfy Credits) | Comfy Credits model slug bound to the 'comfy:slot-b' handle (the technical slot). Passive: only used when technical_model is set to 'comfy:slot-b'. Choices are the pinned ComfyUI partner-node catalog (Sonnet 5, Luna and Sol are always listed so a saved cloud graph loads). The enable-sentinel remains choices[0]. Credit-billed at generate() when a Comfy API key is present. See https://github.com/jbrick2070/ComfyUI-OldTimeRadio/blob/main/docs/comfy-credits-setup.md. |
| google_api_slot_a_modelopt | COMBO | (select Google API model) | Google Gemini API model bound to 'google_api:slot-a' (creative slot). Env-only auth: OTR_GOOGLE_API_KEY, GEMINI_API_KEY, or GOOGLE_API_KEY. Passive until the main model dropdown selects google_api:slot-a. No local fallback. |
| google_api_slot_b_modelopt | COMBO | (select Google API model) | Google Gemini API model bound to 'google_api:slot-b' (technical slot). Use a structured-output capable text model for JSON passes. Env-only auth, no local fallback. |
| llm_deviceopt | COMBO | default | LLM device. 'default' asks ComfyUI what this machine has and RECORDS what it chose; an explicit device is never second-guessed and still fails loud if it is not there. |
| llm_attn_implopt | COMBO | sdpa | Attention implementation for the transformers lane (the FA2 auto-probe is gone). sdpa = the proven baseline. |
| llm_quant_policyopt | COMBO | bnb_nf4 | Quantization for the transformers lane. bnb lanes are OFF on ROCm/MPS/CPU tiers (missing bitsandbytes fails loud). |
| llm_vram_ceiling_gbopt | FLOAT | 14.50–96 | Pre-download VRAM-fit ceiling (GB). 0 DISABLES the gate (cpu tier only). |
| use_exchangeopt | BOOLEAN | false | Build 4 grouped-exchange dialogue. OFF (default) keeps the per-beat composer; PD1 byte-identity holds. ON groups 2-3 consecutive voiced beats and renders each as one exchange (compose_exchange) with the Build 3 contracts + Build 2 Tier-A check; one block per slot, one repair-by-group, then legacy fallback. ANNOUNCER/MUSIC + trailing singletons keep their pass. Any failure falls back to legacy per beat -- audio is never blocked. Validate VRAM <= 14.5 GB + zero slot drift on a live N=3 run. |
| enable_production_stage3_validatorsopt | BOOLEAN | false | OFF (default) preserves PD1 byte-identity on the legacy path -- no validators run. ON wires Stage 3 validators (speaker-leak, banned-phrase, length, pronoun, on-beat) into the production compose_line for every character dialogue beat. TELEMETRY ONLY: findings are stamped on meta.lines[].validation_findings and NOTHING is regenerated, rerolled or rejected -- an audit may never fail a story for length, language, style or quality. Costs no extra LLM call at any severity. Flip ON for production smokes; OFF for the byte-identity regression run. |
| news_briefs_requiredopt | BOOLEAN | true | ON (default): typed non-quality source-interpreter failures stay fail-loud. OFF: the legacy branch may degrade to raw news_seed. Rejected/malformed LLM briefs do not reach this switch: they rotate through fresh technical/creative repair passes and end at a validated bank-specific source floor. |
| replay_fromopt | STRING | CANONICAL REPLAY (2026-09-02). Empty = normal authorship. A frozen replay bundle directory (scripts/otr_freeze_replay_bundle.py) = re-render THAT episode's ledger through the whole canonical graph as a new episode: no writer, no TTS, no music, no stills minted; same seeds, same audio, only the video phase runs, on the engine the bundle froze (derive a bundle with --derive-engine to replay on another lane). The A/A null. | |
| episode_languageopt | COMBO | English | The language the EPISODE is in -- spoken dialogue, the announcer, the title card, the captions and the closing credits. ComfyUI's own knobs stay English, the music prompts stay English, and the machine readouts in the credits (VRAM, CUDA, model ids, seeds) stay English serial numbers. Anything you type yourself is kept as you typed it: your premise, your title, your story fields. Off is not a language -- it is off: nothing is stamped and the episode runs exactly as it did before this control existed. English does the same thing and says so on the ledger. Every language here speaks through Kokoro, and Google TTS (your own Google API key) voices every one of them too. The Shakespeare and Public Domain banks perform the author's own words: in English as written; in another language, a real translator's public-domain text when the corpus holds that scene (config/source_banks/<bank>/translations -- the manifest there is the list), and otherwise the writer model's own translation of the selected passage, which the ledger says so. |
| asset_cleanupopt | COMBO | full (keep only the published video) | What to delete from this episode's folder once it has been published. off keeps everything. partial deletes the sound and the pictures (video, audio, images) and keeps every text file: the ledger, the canon, the treatment, the manifests, the captions and the QA reports. full deletes the whole episode folder. All three leave otr/obs alone: the published video there is never touched, and nothing is deleted unless that published copy is on disk. An episode whose publication was withheld is never cleaned. A partial or full episode can no longer be frozen into a replay bundle (the freeze needs the master WAV and the stills). Freeze first, or run it off. |
| gate_inopt | STRING | Ordering/validation signal (wire OTR_WorkflowValidator.validation_report). | |
| style_roll_poolopt | STRING | Read only when Visual style is 'roll (any style)'. Tick the styles to roll among (or type them, separated by commas); none rolls among every style, one is simply that style. Choose from: anime, archival_documentary, cartoon, paper_origami, recur_frac, sci_fi_radio, shakespeare_stage_realism, storybook_engraving, video_art, visual_storybased. Recorded at meta.style_roll. | |
| language_roll_poolopt | STRING | Read only when Language is 'roll (any language)'. Tick the languages to roll among (or type them, separated by commas); none rolls among every language, one is simply that language. A language this run's voices cannot speak on this machine is left out when you press Run. Choose from: English, Spanish, Portuguese, Italian, French, Hindi, Japanese, Mandarin. Off is never rolled. Recorded at meta.language_roll. | |
| bank_roll_poolopt | STRING | Read only when Story bank is 'roll (any eligible bank)'. Tick the banks to roll among (or type them, separated by commas); none rolls among every eligible bank, one is simply that bank. Choose from: media_archive, my_story, original, public_domain, scifi_news_pro, shakespeare. Recorded at meta.bank_roll. |
Outputs (5)
| Name | Type | Description |
|---|---|---|
| script_text | STRING | — |
| script_json | STRING | — |
| news_used | STRING | — |
| estimated_minutes | INT | — |
| technical_model | STRING | — |