1678452a93aa9a306da23638c22b98b41d52139f
4
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f4adc8d0f9 |
fix(podcast): stop reading identifiers aloud, and pitch the episode at a teenager
Two things the operator found by listening to a real episode.
**1. Identifiers were spoken as digit soup.** The script genuinely said
"arxiv 2608.12888", which the voice reads as "two six zero eight point one two
eight eight eight". Same for three-decimal values: "0.506" and "0.004" became
long strings of spoken digits. A listener on a treadmill cannot write an
identifier down and does not need a third decimal place.
`speakable()` strips arXiv references and bare identifier-shaped numbers, and
rounds decimals to two places — with a carve-out that matters: 0.004 rounds to
0.00, which would claim the value was ZERO when the whole point was that it
collapsed to nearly nothing, so it says "under 0.01" instead.
Deliberately narrow: it removes identifiers and shortens over-precise decimals,
and does not paraphrase, reorder or summarise. The agents' words are still the
episode. It also preserves the sentence's full stop — swallowing it turned
"…financial retrieval, arxiv 2608.00183. This one's a catch." into one run-on
sentence, and the pause is how a listener knows a thought ended.
Note that `podcast-dialogue-writing.md` ALREADY said "no arXiv ids" and the
writer included them anyway. That is this project's recurring lesson restated:
an instruction is a request, and a listener deserves a guarantee. The prose asks
and the code enforces.
**2. It was written for someone who already knew the field.** The skill and the
script phase's task now target a bright sixteen-year-old: define an acronym in
the sentence that first uses it, describe the mechanism rather than naming it
("a road map with motorways and side streets" instead of "a hierarchical
navigable small world graph"), one idea per sentence. The test offered is
whether the listener could explain the finding to a friend afterwards.
That is not dumbing down — it is the constraint that forces a writer to say what
a thing actually does rather than what it is called.
Tested against the exact lines from the episode that was listened to.
366 tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
1f39f642a3 |
feat(podcast): render finished missions into episodes, and serve them as a feed
The renderer existed but nothing called it. This wires it to the missions and puts the result somewhere a phone can reach. **A sweep, not a phase step.** Rendering is not the agents' work and must not be able to fail a phase that succeeded; a transient API error simply retries next tick, and a mission already rendered is skipped because its episode row exists. `podcast_episodes` is that record — without it the sweep would re-render on every pass and re-bill for it, the same lesson `corpus_items` taught for papers. **It is racing a reaper.** script.md lives in the mission checkout, and `mission_runtime`'s sweeper deletes that tree 30 minutes after the mission reaches a terminal state. So the sweep runs every 2 minutes, leaving ~15 attempts inside the window. When it does lose — as it did for three missions that had completed hours before this shipped — it now SAYS so and records a marker rather than skipping in silence, which is how a feed ends up quietly missing a day. The feed filters those markers out: a zero-byte enclosure shows a broken episode in a podcast app, where showing nothing is honest. **Duration is read from the audio, not estimated from the script.** The feed advertises a length and that length should be the real one — and it is the check that catches a 6 MB file playing for six seconds. **The feed authenticates by query-string token**, because no podcast app can set headers. That is a real trade: the token lands in the app's database and any proxy log. It reuses `AuthService::authenticate`, so revoking the session revokes the feed with it rather than creating a second secret to forget to rotate. Titles are XML-escaped — one raw ampersand makes a client reject the WHOLE feed, not one episode. 363 tests pass. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
55b16f25c8 |
fix(podcast): the episode played for six seconds
Concatenating whole MP3 files does not make a longer MP3. Each TTS clip is a
standalone file: a small ID3v2 tag, then a first frame carrying an `Info`/`Xing`
VBR header that declares THAT CLIP's frame count. Joined raw, a player reads
clip one's header, believes the file is that long, and stops. The 6.1 MB
"episode" played for 6.9 seconds.
Caught by the operator listening to it. I had verified the byte count, the ID3
magic and a >100 KB size floor — every proxy for "this is audio" — and never
that it plays. The assertion I needed was duration, and none of the ones I wrote
could fail on this bug.
Measured on two real clips of 4.86s and 4.68s:
raw concat -> 4.86s only clip one plays
strip second clip's ID3 -> 4.86s
strip both clips' ID3 -> 4.86s the tag was never the problem
strip ID3 *and* the Info frame -> 9.53s correct
The ID3 tag is ~45 bytes and harmless. The header FRAME is what lies, and my
first attempt at a fix — scanning the joined file for `ID3` — was worse than
useless: it matched those bytes inside audio data and silently deleted half the
stream.
`strip_container` removes both from every clip, leaving pure frames a player
times from the stream itself. Real ElevenLabs output is committed as a fixture
so the test pins the actual wire format, not an approximation of it, and a
junk-input case proves a malformed clip fails the render rather than panicking.
Re-rendered the same script: 380.8s, up from 6.9s.
361 tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
656662850d |
feat(podcast): render the episode from the agents' own script
GenFM is unreachable. `GET /v1/studio/projects` and `POST /v1/studio/podcasts` both return 403 — "Access to the Studio API requires your account to be explicitly whitelisted to use it. Please contact our sales team." Measured with two different keys on the account, so it is an account restriction, not a key scope. Plain text-to-speech on the same key returns a valid MP3. That suits the operator's choice better than GenFM would have. GenFM always runs its own LLM over the source, so the agents' script would have been REWRITTEN; rendering each line ourselves speaks it verbatim. The agents did the reading and the judging, and the episode says what they wrote. `AudioBackend` is a trait because every candidate has a different shape: NotebookLM documents no programmatic retrieval at all, GenFM needs a sales conversation, Gemini TTS is a third form. The renderer hands over a `Script` and gets bytes. `parse_script` is a parser rather than a `read_to_string` because structure must not be spoken: headings, rules and block quotes are skipped, a wrapped paragraph stays ONE turn (splitting per line would stutter at the seam), and a colon mid sentence does not start a new speaker — "The finding: recall dropped" would otherwise be truncated to everything after the colon. Voices are assigned by order of appearance, so a script using names instead of HOST/GUEST still alternates, and an unexpected third speaker falls back rather than failing. `from_env` returns None without a key, so a deployment with none produces no audio instead of failing a mission that otherwise succeeded. Proven end to end on the real script this morning's mission wrote: 25 turns, 887 words, 5,614 billable characters, 6.1 MB of MP3 in 33 seconds. The live test drives `parse_script` + `ElevenLabs::render` — the production path — and is `#[ignore]`d because it spends credits. 359 tests pass. Co-Authored-By: Claude Opus 5 <[email protected]> |