Skip to content

fix(transcription): cap OpenAI diarization chunks at 1400 s - #536

Merged
chhoumann merged 18 commits into
masterfrom
fix-issue-532
Nov 14, 2026
Merged

chhoumann merged 18 commits into
masterfrom
fix-issue-532

Conversation

@chhoumann

@chhoumann chhoumann commented Nov 13, 2026 •

Copy link
Copy Markdown
Owner

Summary

OpenAI diarization failed on every chunk of a long, low-bitrate MP3. The gpt-4o-transcribe-diarize model 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.

  • For diarization, PodNotes now cuts an MP3 at frame boundaries into the fewest equal parts that stay under both 20 MB and 600 s. Frame headers give the exact length, including for VBR files such as the one in the issue. Equal parts avoid a short last chunk whose speaker labels would reset after a few seconds.
  • The 600 s cap is below OpenAI's 1400 s limit on purpose. The model takes up to 0.58 s per second of audio (81 live requests), so a 1,072 s part would take about 10 min, at PodNotes' request timeout. A real 600 s request took 353 s.
  • A request that OpenAI rejects (400, 401 and other 4xx except 408, 409 and 429) is no longer sent three times. Before this change the whole episode went up three times before the error showed.
  • Whisper transcription keeps its size-only chunks.

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.

  • Before: 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.
  • After: Transcription completed and saved. Six uploads of 535.96 to 536.01 s, each sent once. The note was written, and dev:errors reported none.
  • Real OpenAI, outside Obsidian: one 600 s part of the same episode answered in 353 s with 112 segments ending at 599.5 s.
  • After, with the stand-in's limit lowered to 1000 s so every chunk is rejected: the failure notice shows after three uploads, one per chunk.

npm run lint, format:check, typecheck, build, test (2834 passed) and test:tooling (47 passed) pass. I skipped docs:build because no docs changed.

Each npm run plant below was caught by the named test:

Plant Caught by
Diarize call without maxSeconds (the old code) OpenAI diarization parts stay under the model's 1400 s limit and answer inside the 10 min request timeout (#532)
Part cap of 1400 s instead of 600 s same (expected 621.76 to be less than 600)
Duration cap only for files over 20 MB same
Duration cap without the size cap keeps parts under the size limit when it is the tighter one
Greedy cuts, no balancing splits an MP3 under the size limit into equal parts no longer than maxSeconds
No check that a frame is followed by another frame keeps a leading ID3 tag in the first part without counting sync-like bytes in it as audio
Whisper loop retries a rejection Whisper 400 shows the API error after one send
Diarize loop retries a rejection OpenAI diarization 400 shows the API error after one send
429 treated as a rejection Whisper 429 shows the API error after three sends
Whisper failure branch without break Whisper 400 shows the API error after one send

Release impact

None for settings, storage, API or URIs. main.js gzip 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

  • Adds OPENAI_DIARIZE_PART_SECONDS (600 s) and passes it to createChunkFiles so OpenAI diarization parts respect the per-request duration limit in types.ts and TranscriptionService.ts
  • Implements MP3 Layer III frame scanning and boundary planning in audioChunker.ts, so duration-limited chunks cut at frame offsets and use the fewest chunks that satisfy both byte and duration limits
  • Adds canRetry in 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 resending
  • Raises the gzip size budget for main.js from 104,000 to 104,145 bytes in size.mjs
  • Behavioral Change: non-retryable OpenAI errors no longer retry in both the transcription chunk worker and diarizeWithOpenAI; those chunks are marked failed and processing continues

Macroscope 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 createChunkFiles with maxSeconds: 600 (OPENAI_DIARIZE_PART_SECONDS). For MP3s, mp3ChunkEnds parses 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: OpenAIStatusError carries HTTP status; canRetry limits retries to transient cases (408, 409, 429, 5xx, network). Whisper chunk workers and diarizeWithOpenAI stop after one attempt on hard rejections (e.g. 400/401) instead of three.

CI/docs: root main.js gzip budget rises 104,000 → 104,145 in size.mjs, tests, and AGENTS.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.

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
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7572d7d
Status: ✅  Deploy successful!
Preview URL: https://e276ec30.podnotes.pages.dev
Branch Preview URL: https://fix-issue-532.podnotes.pages.dev

View logs

Comment thread src/services/audioChunker.ts Resolved
Comment thread src/services/audioChunker.ts Resolved
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
@chhoumann

Copy link
Copy Markdown
Owner Author

FAIL
Verified head 50765eb by an independent verifier (GPT 6 Astra).

  • Used isolated Obsidian 1.14.4 on Linux, OpenAI diarization, a fake key, and the issue's real e231.mp3. Drove Play with PodNotes and the palette's Transcribe current episode from audio with .obsidian-e2e-artifacts/issue532/repro.sh. The stand-in decoded uploads with ffmpeg; no live OpenAI calls were made.
  • Under fnm exec --using 22.23.2, all requested head checks passed: npm run lint, npm run format:check, npm run typecheck, npm run build, npm run size, and npm run test (2,834 tests). Bundle size is 320,085 raw / 103,954 gzip bytes, with 46 gzip bytes remaining.
  • Base 46e62183f1a512c8b4375f10ce9685470e513bc5 reproduces OpenAI diarization fails for every chunk on long episodes (audio longer than 1400 seconds) #532. Uploads decode to 1695.40 s and 1520.59 s, each sent three times. The notice ends 400 audio duration 1520.59 seconds is longer than 1400 seconds which is the maximum for this model; no transcript is written.
  • Head fixes that episode. Six uploads decode to 535.96–536.01 s, each sent once. The note contains all six responses and the notice reads Transcription completed and saved. With LIMIT=500 on the same driver, each of the six rejected chunks is sent once, and no note is written.
  • P2 regression in audioChunker.ts:163–184. Adding 0.024 s 25,000 times produces 600.0000000000218, so the strict comparison cuts at 24,999 frames. A valid 49,999-frame MP3 becomes 24,999 + 24,999 + 1 frames. The extra 288-byte tail cannot be decoded. Duration accounting needs to avoid this rounding error and preserve balanced parts.
  • Real-audio reproduction: ffmpeg -v error -i e231.mp3 -t 1199.952 -map_metadata -1 -write_xing 0 -id3v2_version 0 -c:a copy boundary-one-frame.mp3. This yields 49,999 frames / 15,038,496 bytes. Base uploads it once at 1199.97 s and succeeds. Head uploads two 599.97 s parts plus the tail; ffmpeg -v info -i <tail> -f null - exits 187 with could not find codec parameters.
  • Confirmed through the same Obsidian menu/palette path. When the stand-in returns 400 for that decode failure, head saves **?:** [Error diarizing chunk 3] while announcing Transcription completed and saved. The decoder failure is real; the API rejection is simulated. Read the complete diff; the disclosed non-MP3 limitation is not the reason for FAIL.
  • npm run obsidian:e2e -- dev:errors reported no captured runtime errors. Evidence remains in this verifier's .obsidian-e2e-artifacts/issue532/. Restored the requested head and changed no tracked files. Ran npm run stop:e2e-obsidian; npm run stop:e2e-obsidian -- --dry-run reports pids: none.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/services/audioChunker.ts Resolved
…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

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/services/audioChunker.ts Resolved
@chhoumann

Copy link
Copy Markdown
Owner Author

FAIL
Verified head c93cad3 by an independent verifier (GPT 6 Astra).

  • Used isolated Obsidian 1.14.4 on Linux, OpenAI diarization, a fake key, Play with PodNotes, and the palette's Transcribe current episode from audio. The stand-in decoded every upload with ffmpeg and returned 400 above 1400 s or on decode failure. No live OpenAI calls. Read the complete diff and all PR comments, including the earlier FAIL.
  • Under fnm exec --using 22.23.2, npm run lint, npm run format:check, npm run typecheck, npm run build, npm run size, and npm run test all passed. Tests total 2,840. Bundle size is 319,724 raw / 103,885 gzip bytes, leaving 115 gzip bytes.
  • Base origin/master at d53bd5c705c498199fd4c3d44799a523bb317ede reproduces OpenAI diarization fails for every chunk on long episodes (audio longer than 1400 seconds) #532 with the supplied e231.mp3: 1695.40 s and 1520.59 s uploads, each sent three times, then failure and no note. Head sends six parts of 535.96 to 536.01 s once each and saves all six responses. With LIMIT=500, head sends each rejected part once and writes no note.
  • The earlier 49,999-frame failure is fixed. Base sends the boundary file once at 1199.97 s; head sends two decodable parts at 600.00 and 599.97 s. A 50,002-frame file just over 1200 s, with and without an 8 KiB leading ID3 tag, succeeds on both revisions; head uses three roughly 400 s parts.
  • P2 regression remains in audioChunker.ts:173-184. A byte-limit cut can occur before framesPerChunk, but subsequent parts retain that original target, leaving a one-frame tail. A valid 48 kHz mono, 320 kbps MP3 with 43,690 frames and a 512-byte leading ID3v2.4 tag is 41,942,912 bytes. Head splits it into 21,844 + 21,845 + 1 frames, with sizes 20,970,752 / 20,971,200 / 960 bytes. Rebalance after byte-limit cuts so every resulting part is independently decodable.
  • Confirmed that regression through the same Obsidian menu/palette path. The whole fixture decodes successfully; the actual final upload fails ffmpeg with Failed to find two consecutive MPEG audio frames. On the simulated 400, head saves **?:** [Error diarizing chunk 3] while announcing Transcription completed and saved. Base sends two decodable 524.28 s uploads and saves both responses. A VBR fixture also produces a 288-byte tail, through both the palette and direct podnotes:podnotes-transcribe-audio command. The decoder failure is real; the API rejection is simulated.
  • Reproduction scripts, exact fixture construction, logs, screenshots, notes, and preserved failing uploads are under /home/christian/.t3/worktrees/PodNotes/230bd6c9-402a47523081e870/.obsidian-e2e-artifacts/issue532/; start with verification.md, head-id3-size.log, and head-id3-size-uploads/. Joining those uploads matches the input byte for byte. This verdict concerns the byte-limit tail, not the disclosed non-MP3 limitation.
  • dev:errors reported no captured runtime errors. Restored the requested head without tracked changes. Stopped this worktree's Obsidian instance; the final dry run reports pids: none.

… 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
Comment thread src/services/audioChunker.ts Resolved
@chhoumann

Copy link
Copy Markdown
Owner Author

FAIL
Verified head 29b3a8b by an independent verifier (GPT 6 Astra).

  • Ran 24 user-path comparisons in isolated Obsidian 1.14.4 on Linux against origin/master at d53bd5c705c498199fd4c3d44799a523bb317ede. Used OpenAI diarization, a fake key, Play with PodNotes, and the palette's Transcribe current episode from audio. The stand-in decoded every upload with ffmpeg and returned 400 above 1400 s or on decode failure. Read the full diff and every comment, including both earlier FAILs. No live OpenAI calls.
  • All requested head checks passed under fnm exec --using 22.23.2: npm run lint, npm run format:check, npm run typecheck, npm run build, npm run size, and npm run test (2,841 tests). Bundle size is 319,762 raw / 103,896 gzip bytes, leaving 104 gzip bytes.
  • The original episode reproduces OpenAI diarization fails for every chunk on long episodes (audio longer than 1400 seconds) #532 on master, with 1695.40 s and 1520.59 s uploads sent three times each and no note. Head sends six decodable 535.96–536.01 s parts and saves every response. The direct command agrees. With LIMIT=500, head sends each rejected part once and writes no note.
  • Both earlier tail regressions are fixed. Ran all six supplied fixtures on both revisions. Head's duration-boundary file has 25,000 + 24,999 frames; the tagged size-boundary file has 14,564 + 14,563 + 14,563. Added VBR with both limits binding and files barely over each limit. All head uploads decode, preserve the source bytes, and save notes without error placeholders.
  • Remaining defect in audioChunker.ts:180–185: three requests when two equal parts fit. Remove the last 960-byte frame from id3-size-one-frame.mp3. The resulting odd-size-id3.mp3 has a 512-byte leading tag and 43,689 real 48 kHz frames, totaling 41,941,952 bytes. Through the same menu/palette path, head sends three 14,563-frame parts; master sends two decodable parts. This is an unnecessary request and speaker-label boundary, not a transcription failure.
  • A valid two-part split has 21,844 + 21,845 frames, differing by one as required. Including the tag, sizes are 20,970,752 / 20,971,200 bytes, and durations are 524.256 / 524.280 s. Both candidate uploads decode successfully and join byte-for-byte to the source. The code tries only the reverse rounding, whose first part exceeds the byte cap by 192 bytes, then adds a third chunk. Consider the remainder frame's placement before increasing the count.
  • The previously discussed trailing-ID3v1 undercount also remains. A 25,001-frame file is sent whole at 600.024 s because only 25,000 frames count. It transcribes successfully below OpenAI's 1400 s limit. This small overrun is separate from the minimum-part-count verdict.
  • Evidence, actual uploads, screenshots, persisted notes, generate-edges.py, repro-source.sh, results.json, and verification.md are in /home/christian/.t3/worktrees/PodNotes/327719e5-35bd93f1eb77b3e9/.obsidian-e2e-artifacts/issue532/. The counterexample is in head-odd-size-id3.log, base-odd-size-id3.log, and odd-two-parts/. Every run reported no captured runtime errors. Restored and rebuilt the requested head, changed no tracked files, and stopped this instance; the final dry run reports pids: none.

…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
Comment thread src/services/audioChunker.ts Resolved
@chhoumann

Copy link
Copy Markdown
Owner Author

FAIL
Verified head f088015 by an independent verifier (GPT 6 Astra).

  • Ran 34 comparisons in isolated Obsidian 1.14.4 on Linux against master d53bd5c7. Used Play with PodNotes, the transcription palette command, OpenAI speaker labels and a fake key. The stand-in decoded actual uploads with ffmpeg and returned 400 above 1400 s or on decode failure. Read the full diff and every prior verdict/comment. No live API calls.
  • All six requested checks pass: npm run lint, format:check, typecheck, build, size, and test (2,843 tests). Bundle size is 319,989 raw / 103,979 gzip bytes. Used fnm exec --using 22.23.2; that installation's binary reports Node 22.23.4.
  • The original episode fails on master with 1695.40 s and 1520.59 s uploads retried three times each. Head saves six decodable 535.96 to 536.01 s responses. The direct command agrees. Forced 400s send each chunk once and save no note.
  • All earlier fixtures were rerun on both revisions. The prior tiny tails, odd leading-tag split, and trailing-ID3v1 undercount are fixed. All head uploads preserve the source bytes and fit both caps. Successful notes have no error placeholders.
  • Minimum-count defect remains at audioChunker.ts:184-191. A valid 43,692-frame VBR MP3 with a trailing ID3v1 tag gets three 14,564-frame uploads. Moving the proposed two-part cut forward by one frame produces 21,847 + 21,845 frames, 7,220,520 / 20,970,848 bytes, and 524.328 / 524.280 s. Both parts decode without errors and join exactly to the input. Their durations differ by only 48 ms. The loop only moves cuts backward, then adds a request.
  • Confirmed through the same Obsidian menu/palette flow. Master sends two decodable uploads; head sends three and saves three responses. A second rising-bitrate fixture also gets three requests despite a verified two-part split below both caps. These are unnecessary requests and speaker-label boundaries, not transcription failures.
  • Reproduction, uploads and candidate two-part files are under /home/christian/.t3/worktrees/PodNotes/859ab321-c3acc90ff852ea0f/.obsidian-e2e-artifacts/issue532/. Start with verification.md, generate-new.py, prove-two-parts.py, and head-vbr-trailing-id3v1.log. All runs report no captured runtime errors. No tracked changes, commits or pushes. Stopped this instance; the final dry run reports pids: none.

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
@chhoumann

Copy link
Copy Markdown
Owner Author

FAIL
Verified head 3568940 by an independent verifier (GPT 6 Astra).

  • Ran 42 comparisons in isolated Obsidian 1.14.4 on Linux against master d53bd5c7. Used Play with PodNotes, the transcription palette command, OpenAI speaker labels and a fake key. The stand-in decoded actual uploads with ffmpeg and returned 400 above 1400 s or on decode failure. Read the full diff and every comment, including all four prior FAIL verdicts. No live API calls.
  • All requested head checks pass under fnm exec --using 22.23.2: npm run lint, npm run format:check, npm run typecheck, npm run build, npm run size, and npm run test. There are 2,845 passing tests. Bundle size is 320,047 raw / 104,036 gzip bytes, leaving 97 gzip bytes.
  • The original episode fails on master with 1695.40 s and 1520.59 s uploads sent three times each. Head saves six responses from 535.96 to 536.01 s uploads. The direct command agrees. Forced 400s send each head chunk once and save no note.
  • All earlier fixtures were rerun on both revisions. The previous tiny tails, odd leading-tag split, trailing-frame undercount and rising-bitrate extra requests are fixed. Added falling and alternating bitrates. An independent 10,000-case check passes minimum count, both caps, coverage and feasible equal-share cuts, supplementing the PR's 300-case test.
  • P2 remaining defect in audioChunker.ts:214-220. The scanner counts frame-like bytes inside declared ID3 payloads. A valid 8,192-byte ID3v2.4 PRIV tag containing ff fb 74 c4 at offsets 100 and 388 makes line 169 use 24 ms per frame for real 72 ms audio. Skip the declared tag payload before recognizing audio frames.
  • Confirmed through the real menu/palette flow with id3-private-headers.mp3, a 1,736,264-byte file containing 24,001 real 8 kHz frames. ffmpeg decodes it cleanly, but head uploads all 1728.07 s in one request, receives 400 and writes no note. Changing only those eight metadata bytes to zero makes head send three parts of 576.07 / 576.00 / 576.00 s and save the transcript. The audio bytes are identical. Master rejects both files after three sends; this is a remaining defect in the new duration guarantee, not a claimed base regression.
  • Evidence is in /home/christian/.t3/worktrees/PodNotes/0e5006fb-f862684098312282/.obsidian-e2e-artifacts/issue532/. Start with verification.md, generate-adversarial.py, head-id3-private-headers.log, its preserved upload, and the matching control log. All uploads preserve source bytes; successful head notes have no error placeholders. All runs report no captured runtime errors. Restored and rebuilt the requested head, changed no tracked files, and stopped the instance; the final dry run reports pids: none.

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.
@chhoumann

Copy link
Copy Markdown
Owner Author

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.

  • New test frame-like bytes in a leading tag do not set the duration of every frame reproduces this case: an 8,192-byte ID3v2.4 tag with ff fb 74 c4 at 100 and 388, then 24,001 frames of 72 ms. It fails on the old frame count with expected [ 1728.072 ] to have a length of 3 but got 1.
  • The property test now mixes 72 ms MPEG-2.5 frames with 24 ms MPEG-1 frames, and its brute-force oracle sums seconds. Each chunk is checked against both limits.
  • Plants caught: the old frame-count limit (both tests), no float slack (one frame short of two full chunks), and a search that cannot reach the last frame (property test).
  • In Obsidian 1.14.4 your id3-private-headers.mp3 now goes up as 3 parts of 575.92, 576.07 and 576.07 s, measured by ffmpeg, and the transcript saves. The other 13 fixtures (e231, boundary-one-frame, id3-size-one-frame, odd-size-id3, trailing-id3v1, vbr-rising, vbr-trailing-id3v1, both-limits-vbr, both-tags, low8k, vbr-alternating, vbr-falling, id3-private-control) all stay under 600 s and 20 MiB.
  • lint, format:check, typecheck, build, test (2,846 passed). Size is 104,063 against the 104,133 gzip budget.

@chhoumann

Copy link
Copy Markdown
Owner Author

FAIL
Verified head 4227538 by an independent verifier (GPT 6 Astra).

  • Compared 32 fixtures on master d53bd5c7 and head in isolated Obsidian 1.14.4 on Linux. Used Play with PodNotes, the transcription palette command, OpenAI speaker labels and a fake key. The stand-in decoded each upload with ffmpeg and returned 400 above 1400 s or on decode failure. Read the full diff, every comment, all five earlier verdicts and the replies. No live API calls.
  • All required head checks pass under fnm exec --using 22.23.2: npm run lint, npm run format:check, npm run typecheck, npm run build, npm run size, and npm run test (2,846 tests). Bundle size is 320,128 raw / 104,063 gzip bytes, leaving 70 gzip bytes.
  • The original episode fails on master with 1695.40 s and 1520.59 s uploads sent three times each. Head saves six responses from 535.96 to 536.01 s uploads. The direct command agrees; forced 400s send each head chunk once and save no note. All previous failure fixtures pass, including the 1728 s PRIV-tag example.
  • Independent checks pass 10,000 mixed-rate/bitrate cases against an integer-duration, exhaustive minimum-count oracle, including both caps and feasible equal-share cuts. Another 10,000 VBR cases pass. Six-hour audio succeeds through Obsidian with 37 decodable head uploads; a 24-hour chunker stress check produces 145 parts satisfying both caps.
  • P2 regression remains in audioChunker.ts:222-234. The scanner still treats declared ID3 payloads as audio. tag-embedded-audio.mp3 is a cleanly decodable 60-second, 1,503,581-byte MP3 with frame sequences inside a 600,077-byte ID3v2.4 PRIV tag. Master sends it once. Head cuts at byte 390,053, inside that tag, producing an undecodable first upload. Skip declared metadata before recognizing audio frames or choosing cuts.
  • Confirmed through the same menu/palette flow. The actual first upload fails ffmpeg with Failed to find two consecutive MPEG audio frames; the stand-in returns 400. Head then saves **?:** [Error diarizing chunk 1] while announcing Transcription completed and saved. Its second upload decodes tag payload as audio and lasts 270.16 s. Zeroing only that private payload preserves the real audio and produces one successful 60.00 s upload on both revisions.
  • A smaller paired case also violates minimum count. With exactly 25,000 real 48 kHz frames, tag-boundary.mp3 fits both caps at 600 s / 8,105,720 bytes, but head sends two parts. Zeroing only the eight tag bytes at offsets 100-103 and 388-391 makes head send one. Master sends both files once; both whole files decode cleanly and their audio hashes match.
  • Reproduction, uploads, notes, screenshots and results are in /home/christian/.t3/worktrees/PodNotes/60bbd9dd-8e39eb48ab826b84/.obsidian-e2e-artifacts/issue532/. Start with verification.md, generate-tags.py, metadata-proof.json, and head-tag-embedded-audio.log. No captured runtime errors. Restored and rebuilt the pinned head without tracked changes, and stopped this worktree's instance; the final dry run reports pids: none.

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.
@chhoumann

Copy link
Copy Markdown
Owner Author

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.

  • New tests, each red on 4227538:
    • audio inside a leading tag's declared size is not cut or counted: a tag holding 480 s of frames before 720 s of audio. Head cut inside the tag.
    • an MP3 that fills the time limit exactly goes whole despite frame-like bytes in its tag: your tag-boundary.mp3 shape.
    • ...in a trailing tag: your tag-trailing-headers.mp3 shape, which head sent as 2 × 300 s.
  • The guard test tag-like bytes where no frame could start do not hide the audio after them is green on 4227538 and red if the skip ignores position.
  • Plants caught: no skip, leading-only skip, boundary-only skip, a skip at any position, and 8-bit size decoding.
  • Obsidian 1.14.4, all 32 of your fixtures through Play with PodNotes and the palette command with the ffmpeg stand-in:
    • tag-embedded-audio: 1 × 60.00 s, same as its control.
    • tag-boundary: 1 × 600.00 s.
    • tag-trailing-headers: 1 × 600.04 s, where ffmpeg decodes the fake frames inside the tag.
    • Every note saved with no error markers.
    • Largest part 20,971,328 bytes. Longest part 600.04 s, with the 6 h file at 37 × ≤583.84 s.
  • The [Error diarizing chunk N] placeholder with a success notice comes from master's openaiProvider.ts. feat(transcription): move transcription onto one engine and current models #538's engine replaces it with a "Transcription saved, but N of M chunk(s) failed" warning.
  • lint, format:check, typecheck, build, test (2,850). Size is 104,131 against the 104,133 gzip budget.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread src/services/audioChunker.ts Resolved
A second leading tag was scanned for frames, so audio inside it was counted
and could be cut through.
@chhoumann

Copy link
Copy Markdown
Owner Author

FAIL
Verified head c085a11 by an independent verifier (GPT 6 Astra).

  • Compared 48 fixtures on master d53bd5c7 and head in isolated Obsidian 1.14.4 on Linux. Used Play with PodNotes, the transcription palette, OpenAI speaker labels, a fake key, and an ffmpeg stand-in returning 400 above 1400 s or on decode failure. Read the full diff, every review thread, all six prior FAIL verdicts and the replies. No live API calls.
  • All six head checks pass under fnm exec --using 22.23.2: npm run lint, npm run format:check, npm run typecheck, npm run build, npm run size, and npm run test with 2,851 passing tests. Bundle size is 320,250 raw / 104,133 gzip bytes, exactly the gzip budget.
  • The original episode fails on master with 1695.40 s and 1520.59 s uploads sent three times each. Head saves six responses from 535.96 to 536.01 s uploads. The direct command agrees; forced 400s send each chunk once and save no note.
  • Previous counterexamples and the author's stacked-tag fixtures pass at head. Two independent 10,000-case checks pass minimum count, both caps, coverage and feasible balance. Six-hour audio succeeds in Obsidian with 37 parts; a 24-hour chunker check yields 145 capped parts. Also exercised extended headers, unsynchronisation, middle/trailing tags and malformed sizes.
  • P2 regression at audioChunker.ts:237. ID3v2.4's declared size excludes its optional ten-byte footer. Advancing only by 10 + size leaves boundary at the footer start, so a following tag's payload is scanned as audio. Include the footer when advancing the tag boundary.
  • footer-title-tag-embedded-audio.mp3 has a valid nonempty TIT2 tag with a footer, then a PRIV tag, then 60 s of real audio. The 1,503,618-byte file decodes cleanly. Master uploads it once; head cuts at byte 390,090 inside the second tag, which ends at 600,114. ffmpeg rejects the actual first upload; the second decodes metadata as audio for 270.16 s. Its control has identical decoded audio and succeeds as one request. The resulting error marker/success notice is the acknowledged pre-existing behavior, not the defect attributed here.
  • The same bug adds a request for footer-title-tag-boundary.mp3, a clean 600 s / 8,105,757-byte file. Master sends it once; head sends two parts of 299.97 and 300.02 s. Zeroing only eight private metadata bytes restores one request, with identical decoded audio.
  • Evidence is in /home/christian/.t3/worktrees/PodNotes/32bc170d-f163dac67acf4754/.obsidian-e2e-artifacts/issue532/. Start with verification.md, generate-review-cases.py, prove-footer.mjs, footer-proof.json, and the matching head-footer-title-* / base-footer-title-* logs and uploads. Completed 98 user-flow runs, checked byte-for-byte preservation and saved notes, restored the pinned head without tracked changes, and stopped the instance. Final dry run reports pids: none.

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.
@chhoumann

Copy link
Copy Markdown
Owner Author

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.

  • New test audio inside a tag after a tag with a footer is not cut or counted is red on c085a11: [ 7200030, 7200000 ] instead of [ 10080030, 4320000 ].
  • Plants caught:
    • dropping the footer
    • checking the wrong flag bit
    • always adding 10 bytes, caught by audio inside a leading tag's declared size is not cut or counted
  • In Obsidian 1.14.4 all four of your footer-title-* files go up as one request (60 s or 600 s), the same as their controls, and so do the four footer-stacked-* files. 47 of your 48 fixtures pass, with every note saved and no error markers. The remaining one is tag-truncated-size.mp3: ffmpeg refuses to open it (Invalid data found when processing input), so I left it out. My older stand-in hangs on an undecodable upload instead of answering 400. Master sends that file whole as well.
  • tag-middle-audio.mp3 goes as one upload that ffmpeg decodes as 660.04 s, because ffmpeg also plays the audio hidden inside the middle tag. That is well under the 1400 s limit.
  • lint, format:check, typecheck, build, test (2,852). The footer check costs 12 bytes, so the gzip budget entry this PR adds now records the measured 104,145.

@chhoumann

Copy link
Copy Markdown
Owner Author

PASS+NOTES
Verified head 7572d7d by an independent verifier (GPT 6 Astra).

  • Read the full diff, all seven earlier FAIL verdicts, author replies and review threads. Compared 50 fixtures against master d53bd5c7 in isolated Obsidian 1.14.4 on Linux. Completed 102 runs through Play with PodNotes, the transcription palette and direct command, with OpenAI diarization, a fake key and the ffmpeg stand-in. No live API calls.
  • All six requested checks pass under fnm exec --using 22.23.2: npm run lint, npm run format:check, npm run typecheck, npm run build, npm run size, and npm run test with 2,852 passing tests. Bundle size is 320,267 raw bytes and exactly 104,145 gzip bytes. Gzip headroom is zero.
  • The original episode fails on master with 1695.40 s and 1520.59 s uploads sent three times each. Head saves six responses from 535.96 to 536.01 s uploads. The direct command agrees. Forced 400s send each of the six chunks once and save no note.
  • All previous counterexamples pass at head. The eight footer-title and footer-stacked fixtures, including controls, each upload once at 60 or 600 seconds, matching master. No cut falls inside their tags. Extended headers, unsynchronisation, mixed sample rates, rising/falling/alternating bitrates and barely-over cases also pass.
  • Independent oracles pass 21,906 feasible generated streams for minimum count, both caps and closest feasible equal-frame-share cuts. These include 1,907 metadata cases and 252 MPEG header variants. Six-hour audio succeeds in Obsidian with 37 parts. Additional 24-hour planner checks pass, including 3.6 million frames split into exactly 144 full 600-second parts.
  • Every saved upload set rejoins its source byte-for-byte. Every head upload fits 20 MiB. Successful head notes contain no error placeholders, and all runs report no captured runtime errors.
  • Scope notes remain. The malformed truncated-tag file fails decoding on both revisions. ffmpeg plays metadata payloads in the middle/trailing-tag fixtures, yielding 660.04/600.04 seconds despite the defined frame-duration cap. The single-footer player's media-load error also occurs on master. None establishes a new in-scope defect.
  • Evidence is under /home/christian/.t3/worktrees/PodNotes/8fd47fb0-c519e1d7f2faf99f/.obsidian-e2e-artifacts/issue532/, starting with verification.md, audit.json, results.json and compare.sh. Inspected all 102 screenshots and saved notes. No tracked changes, commits or pushes. Stopped this worktree's instance; the final dry run reports pids: none.

@chhoumann
chhoumann merged commit 3987258 into master Nov 14, 2026
18 checks passed
@chhoumann
chhoumann deleted the fix-issue-532 branch November 14, 2026 18:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenAI diarization fails for every chunk on long episodes (audio longer than 1400 seconds)

1 participant