Repository navigation
fix(transcription): cap OpenAI diarization chunks at 1400 s - #536
Conversation
gpt-4o-transcribe-diarize rejects requests with more than 1400 s of audio, but MP3 chunks were split by size alone. Any MP3 under about 120 kbps put more than 1400 s in a 20 MB chunk, so every chunk failed. For diarization, split MP3 at frame boundaries into the fewest equal parts that stay under both 20 MB and 1400 s. Whisper keeps its size-only chunks. Fixes #532
A chunk that OpenAI rejected with a status such as 400 or 401 was sent three times, although each send fails the same way. A long diarized episode went up three times before the error showed. Retry only the statuses openai-node retries: 408, 409, 429 and 5xx, plus network errors. Refs #532
Deploying podnotes with
|
| Latest commit: |
7572d7d
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://e276ec30.podnotes.pages.dev |
| Branch Preview URL: | https://fix-issue-532.podnotes.pages.dev |
gpt-4o-transcribe-diarize takes up to 0.58 s per second of audio, measured over 81 requests, so the 1,072 s parts a 54 min episode got would take about 10 min each, at the request timeout. Parts are now at most 600 s, which the model answers in under 6 min. Refs #532
|
FAIL
|
…s cut off Summing frame durations drifts past the cap: 25,000 frames of 24 ms sum to 600.0000000000218 s, so a 600 s cap cut each chunk a frame early and a 49,999-frame episode went as 24,999 + 24,999 + 1 frames. A one-frame chunk cannot be decoded. Chunks are now counted in frames, since every frame in a stream lasts as long. Refs #532
|
FAIL
|
… binds The planner fixed a frame count per chunk and checked the size limit separately. When a leading tag or a variable bitrate put an equal split over the limit, the size check cut a chunk short and the next kept the old frame count, leaving a one-frame chunk that cannot be decoded: 43,690 frames of a 320 kbps MP3 with a 512-byte tag went as 21,844 + 21,845 + 1. The planner now takes the fewest equal splits that fit both limits, adding a chunk when an equal split does not fit. Refs #532
|
FAIL
|
…g one Equal splits put the odd frame first, so a 512-byte tag and 43,689 frames of a 320 kbps MP3 put the first of two chunks 192 bytes over 20 MiB, and the planner sent three where two fit. Each cut now takes an equal share of the frames still to place and steps back while the chunk is over the size limit, so 21,844 + 21,845 goes in two. A last frame followed by an ID3v1 tag was not counted, so 25,001 frames went whole at 600.024 s. A frame that continues the frames before it now counts. Refs #532
|
FAIL
|
Cuts only moved back from an even split, so where the bitrate rose a chunk was added although moving the cut forward fit: 21,847 low-bitrate frames then 21,845 at 320 kbps and an ID3v1 tag went as three chunks, not two. The planner now finds the fewest chunks by filling each as far as it fits, then places each cut as close to an equal share as the chunks before and after it allow. A property test checks the count against a brute-force minimum on 300 random variable-bitrate streams. Refs #532
|
FAIL
|
Frame-like bytes inside a leading tag set the first frame's duration, and counting every chunk in that frame's length sent a 1,728 s episode of 72 ms frames up in one request. Chunks now fit by the summed duration of their own frames, with a microsecond of slack for float drift.
|
Fixed in 4227538. The planner assumed every frame lasts as long as the first one, so two frame-like headers in the PRIV tag set a 24 ms cap for 72 ms audio. Chunks now fit by the summed duration of their own frames (prefix sums, with 1 µs of slack for float drift), and the furthest and earliest cuts are binary searches.
|
|
FAIL
|
Audio frames inside a leading tag's declared size were counted and could be cut through, sending an undecodable first chunk, and frame-like bytes in a tag pushed an episode that fills the time limit exactly into two chunks.
|
Fixed in the next commit on this branch. The scanner now skips an ID3v2 tag by its declared size wherever a frame could start: at byte 0, or right after a frame. That keeps both audio and fake headers inside a tag out of the frame list. "ID3" bytes elsewhere, in junk where no frame could start, are ignored, so they can't hide real audio.
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit db2835d. Configure here.
A second leading tag was scanned for frames, so audio inside it was counted and could be cut through.
|
FAIL
|
The declared size leaves out the 10-byte footer that flag 0x10 adds, so a tag after a footed tag was scanned for frames and could be cut through.
|
Fixed in 7572d7d. When flag 0x10 is set, a tag now advances by its 10-byte header plus its declared size plus the 10-byte footer, so the next tag starts at the boundary and gets skipped.
|
|
PASS+NOTES
|

Summary
OpenAI diarization failed on every chunk of a long, low-bitrate MP3. The
gpt-4o-transcribe-diarizemodel takes at most 1400 s of audio per request, but PodNotes split MP3s by size only (20 MB). Below about 120 kbps, a 20 MB chunk holds more than 1400 s, so each request got a 400 and no transcript was saved.Not covered: a non-MP3 file (such as m4a) under 20 MB that runs longer than 1400 s still goes up whole and gets the same 400. Fixing that needs that format's duration without decoding the whole file. I left it out to keep this change small.
The transcripts docs page ("Sent in parts of about 20 MB") describes the latest release, so it gets updated when this ships.
Fixes #532
Verification
Obsidian 1.14.4, Linux (isolated worktree instance). The episode is the one from the issue: "The Techbro Looting of Science Fiction w/ Ali Rıza Taşkale (E231)" from https://anchor.fm/s/163d8374/podcast/rss, 38,951,448 bytes, 96.9 kbps VBR mono at 48 kHz. I played it as a vault file through Play with PodNotes and ran Transcribe current episode from audio from the palette with OpenAI diarization on. A stand-in for OpenAI decoded each upload with ffmpeg and returned OpenAI's 400 body above 1400 s.
Transcription failed: OpenAI diarization failed for every chunk: 400 audio duration 1520.59 seconds is longer than 1400 seconds which is the maximum for this model. Six uploads (1695.4 s and 1520.59 s, each sent three times) and no note.Transcription completed and saved.Six uploads of 535.96 to 536.01 s, each sent once. The note was written, anddev:errorsreported none.npm run lint,format:check,typecheck,build,test(2834 passed) andtest:tooling(47 passed) pass. I skippeddocs:buildbecause no docs changed.Each
npm run plantbelow was caught by the named test:maxSeconds(the old code)expected 621.76 to be less than 600)breakRelease impact
None for settings, storage, API or URIs.
main.jsgzip grows by 583 bytes (103,371 to 103,954 of the 104,000 budget), which leaves 46 bytes.Note
Cap OpenAI diarization chunks at 600 s with MP3 frame-aware chunking
OPENAI_DIARIZE_PART_SECONDS(600 s) and passes it tocreateChunkFilesso OpenAI diarization parts respect the per-request duration limit in types.ts and TranscriptionService.tscanRetryin openaiTranscription.ts: OpenAI status errors 408, 409, 429, and 500+ are retried; other status errors (e.g. 400, 401) fail the chunk immediately and report the API message instead of resendingdiarizeWithOpenAI; those chunks are marked failed and processing continuesMacroscope summarized 7572d7d.
Note
Medium Risk
Touches transcription upload chunking and OpenAI retry behavior for Whisper and diarization; wrong MP3 boundaries or retry rules could fail or mis-split long episodes, but scope is limited and heavily tested.
Overview
Fixes long low-bitrate MP3 episodes failing OpenAI diarization with 400 when a 20 MB chunk exceeded the model’s duration cap.
OpenAI diarization now calls
createChunkFileswithmaxSeconds: 600(OPENAI_DIARIZE_PART_SECONDS). For MP3s,mp3ChunkEndsparses Layer III frames (skipping ID3v2), chooses the fewest balanced cuts that stay under 20 MB and 600 s, and avoids one-frame tails. Whisper and size-only splitting are unchanged.Retries:
OpenAIStatusErrorcarries HTTP status;canRetrylimits retries to transient cases (408, 409, 429, 5xx, network). Whisper chunk workers anddiarizeWithOpenAIstop after one attempt on hard rejections (e.g. 400/401) instead of three.CI/docs: root
main.jsgzip budget rises 104,000 → 104,145 insize.mjs, tests, andAGENTS.md/size-baseline.md, with large MP3 chunking and rejection-path test coverage and MP3/OpenAI failure fixtures.Reviewed by Cursor Bugbot for commit 7572d7d. Bugbot is set up for automated code reviews on this repo. Configure here.