fix(podcast): the episode played for six seconds
deploy / test (push) Successful in 4m24s
deploy / build (push) Successful in 5m33s

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]>
This commit is contained in:
Omar Sobh
2026-08-18 08:27:38 -07:00
co-authored by Claude Opus 5
parent 656662850d
commit 55b16f25c8
2 changed files with 95 additions and 1 deletions
+95 -1
View File
@@ -109,6 +109,62 @@ pub fn parse_script(md: &str) -> Script {
Script { title, turns }
}
/// MPEG1 Layer III bitrates (kbps) and sample rates, indexed as the frame
/// header encodes them.
const MP3_BITRATES: [u32; 16] = [
0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0,
];
const MP3_RATES: [u32; 4] = [44100, 48000, 32000, 0];
/// Strip a clip's container metadata so clips can be joined into ONE stream.
///
/// This is the difference between an episode and a six-second file. Each TTS
/// clip arrives as a standalone MP3: a small ID3v2 tag, then a first frame
/// carrying an `Info`/`Xing` VBR header that declares THAT CLIP's frame count.
/// Concatenated raw, a player reads the first clip's header, believes the whole
/// file is that long, and stops. Measured on two real clips of 4.86s and 4.68s:
///
/// ```text
/// 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 issue)
/// strip ID3 *and* the Info frame -> 9.53s correct
/// ```
///
/// The ID3 tag is ~45 bytes and harmless; the header FRAME is what lies. Both
/// go, leaving pure audio frames that a player times from the stream itself.
fn strip_container(clip: &[u8]) -> &[u8] {
let mut i = 0usize;
// ID3v2: 10-byte header, then a syncsafe 28-bit size.
if clip.len() > 10 && &clip[..3] == b"ID3" {
let size = ((clip[6] as usize) << 21)
| ((clip[7] as usize) << 14)
| ((clip[8] as usize) << 7)
| (clip[9] as usize);
i = (10 + size).min(clip.len());
}
// A leading Xing/Info frame is metadata, not sound.
if i + 4 < clip.len() && clip[i] == 0xFF && clip[i + 1] & 0xE0 == 0xE0 {
let br = MP3_BITRATES[((clip[i + 2] >> 4) & 0x0F) as usize];
let sr = MP3_RATES[((clip[i + 2] >> 2) & 0x03) as usize];
if br > 0 && sr > 0 {
let pad = ((clip[i + 2] >> 1) & 1) as usize;
let len = (144 * br as usize * 1000 / sr as usize) + pad;
let end = (i + len).min(clip.len());
let frame = &clip[i..end];
if find(frame, b"Xing").is_some() || find(frame, b"Info").is_some() {
i = end;
}
}
}
&clip[i..]
}
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
hay.windows(needle.len()).position(|w| w == needle)
}
/// Anything that can turn a script into audio bytes.
#[async_trait]
pub trait AudioBackend: Send + Sync {
@@ -226,7 +282,9 @@ impl AudioBackend for ElevenLabs {
// "rendering failed" for a 40-turn script.
format!("turn {} ({}): {e}", i + 1, turn.speaker)
})?;
out.extend_from_slice(&clip);
// Join as ONE stream: see `strip_container`. Concatenating whole
// MP3 files yields a file that plays only its first clip.
out.extend_from_slice(strip_container(&clip));
}
Ok(out)
}
@@ -292,6 +350,42 @@ mod tests {
assert_eq!(el.voice_for("CARL", "ANA", Some("BEN")), "HOSTV");
}
/// The bug that produced a six-second "episode".
///
/// Each clip is a standalone MP3 whose first frame carries an Info/Xing
/// header declaring that clip's length. Joined raw, a player reads clip
/// one's header and stops there. Fixtures are REAL ElevenLabs clips, so
/// this pins the actual wire format rather than a hand-built approximation.
#[test]
fn joining_strips_the_header_that_declares_one_clips_length() {
let clip = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/tts-clip.mp3"));
let Ok(clip) = clip else {
eprintln!("fixture absent; skipping");
return;
};
assert_eq!(&clip[..3], b"ID3", "fixture should be a raw TTS clip");
let body = strip_container(&clip);
assert!(body.len() < clip.len(), "something must be stripped");
assert_eq!(body[0], 0xFF, "must start on a frame sync, got {:#04x}", body[0]);
assert!(body[1] & 0xE0 == 0xE0, "frame sync incomplete");
// The lying header must be gone from the head of the stream.
let head = &body[..body.len().min(1024)];
assert!(
find(head, b"Info").is_none() && find(head, b"Xing").is_none(),
"the VBR header frame survived — the join will report one clip's length"
);
}
/// Not an MP3, or a truncated one, must pass through rather than panic:
/// a bad clip should fail the render with a message, not crash the server.
#[test]
fn stripping_is_safe_on_junk() {
for junk in [&b""[..], &b"ID3"[..], &[0xFFu8][..], &b"not audio at all"[..]] {
let out = strip_container(junk);
assert!(out.len() <= junk.len());
}
}
#[test]
fn the_runtime_estimate_is_in_the_right_ballpark() {
let words = "word ".repeat(1500);