Repository navigation
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
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
The published transcript renderer is the reader of the format that stays when the diarization folder goes. The default template now lives only in DEFAULT_SETTINGS, so src/constants.ts imports no service code.
src/services/transcription/ turns an episode into a transcript with any provider. A provider is one file: its models, its request, and how to read its answers. OpenAI (gpt-transcribe) and Deepgram (nova-3) are the first two. The engine owns what providers share: - audioSource.ts decides whether a provider may be given the episode's address. Only an address that a saved public feed lists, that carries no credential, and that names a public host passes. All other audio is uploaded. - split.ts is audioChunker.ts with the caps as arguments and a start time on each part. - http.ts sends a request at most three times, and only after a 429, a 5xx, or a lost connection. Its failure sentences never include the provider's own text, which can quote a private address. - cues.ts turns words or text into the shapes of a published transcript. Nothing calls the engine yet. Lint rejects an import of the folder's files from outside it, except its index.
TranscriptionService keeps its queue, the published-transcript path, the progress notice, and note saving. It calls the engine for audio. - OpenAI transcribes with gpt-transcribe. whisper-1 and gpt-4o-transcribe-diarize, which OpenAI shuts down on 2027-02-26, are gone. A tooling test searches the built main.js for them. - With speaker labels on and OpenAI selected, the transcript is saved without labels. A notice that stays until clicked names the providers that label speakers. - Deepgram transcripts go through renderTranscriptBody, so each paragraph has a timestamp. Speakers are numbered in the order they first speak. - Deepgram gets the address of a public episode and downloads it itself. - A failure notice stays until clicked. Parts go out one at a time, and a 401, 402, 404, or 400 stops the job before the next part. Deletes src/services/diarization/, openaiTranscription.ts, the Whisper worker pool, and createBinaryChunkFiles. getEpisodeAudioBuffer no longer returns a basename, because uploads are named for their format only.
net.mjs answers OpenAI with { text } and Deepgram with the words of two
speakers, and logs the content type of each request. fixtures.mjs serves a
feed at priv3.supercast.com, an address that PodNotes keeps private. The
transcripts recipe covers OpenAI in parts, and Deepgram by address and by
upload.
The marketplace review test called TranscriptionService.waitForRetry, which the engine replaced. An aborted run now rejects through waitForLifecycle, so the test aborts a pending wait there and expects the same Error.
An aborted job no longer downloads the episode or decodes it into parts. Until now only its requests were refused. A 429 or a 5xx that asks to wait over a minute is sent once. A sooner send would only upload the audio to be refused again.
Deploying podnotes with
|
| Latest commit: |
3641fb8
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://c7568cfa.podnotes.pages.dev |
| Branch Preview URL: | https://feat-transcription-engine.podnotes.pages.dev |
…unted last frame Parts were counted from rounded seconds, so an episode a frame over a whole number of capped parts got equal parts one frame too long, and the time cap then split off a final part of a single frame. Parts are now counted in frames. A last frame followed by metadata, such as an ID3v1 tag, failed the next-frame check and was not counted, so the last part could run one frame over maxSeconds. A frame that continues the frames before it now counts.
…ame parts Summing frame durations drifts past the cap: 25,000 frames of 24 ms sum to 600.0000000000218 s, so the per-frame seconds check cut each part a frame early and a 49,999-frame episode went as 24,999 + 24,999 + 1 frames. Parts are already counted in frames, which keeps them under maxSeconds, so the check goes.
The planner fixed a frame count per part and checked the size cap separately. When a leading tag or a variable bitrate put an equal split over the cap, the size check cut a part short and the next kept the old frame count, leaving a one-frame part 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 caps, adding a part when an equal split does not fit.
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 parts 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 part is over the size cap, so 21,844 + 21,845 goes in two.
Cuts only moved back from an even split, so where the bitrate rose a part 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 parts, not two. The planner now finds the fewest parts by filling each as far as it fits, then places each cut as close to an equal share as the parts before and after it allow. A property test checks the count against a brute-force minimum on 300 random variable-bitrate streams.
Frame-like bytes inside a leading tag set the first frame's duration, and counting every part in that frame's length sent a 1,728 s episode of 72 ms frames up in one request. Parts now fit by the summed duration of their own frames, and each part's start is the sum of the frames before it.
Audio frames inside a tag's declared size were counted and could be cut through, sending an undecodable first part, and frame-like bytes in a trailing tag added a part.
…engine # Conflicts: # src/services/TranscriptionService.test.ts # src/services/TranscriptionService.ts # src/services/audioChunker.test.ts # src/services/audioChunker.ts # src/services/diarization/openaiProvider.ts # src/services/diarization/types.ts # src/services/openaiTranscription.test.ts # src/services/openaiTranscription.ts # tests/mocks/openaiFailures.ts
|
Retargeted to
|
|
FAIL
|
…h cap A file in a format other than MP3 under the size cap went whole whatever its length, so a long .m4a passed a model's maxSeconds. Its length now comes from its metadata through a media element, without decoding it, and audio over the cap is decoded into WAV parts that fit both caps. Audio of an unknown length still goes whole.
A public feed's enclosure with AWS or Google Cloud signing parameters passed the feed credential check, so Deepgram got the whole signed address. A query parameter whose name holds signature, credential or token, or Akamai's hdnts, now uploads the audio instead.
|
Fixed in 7b64684.
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high 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 37fdc08. Configure here.
…nload A WebView may load no metadata for media it does not play, so the length probe could wait forever, and an unload could not stop it before a decode. The probe now reads the length as unknown after 5 s or when the job is aborted, and an aborted job decodes nothing.
|
PASS+NOTES
|

Summary
PodNotes transcribed audio with two models that OpenAI shuts down on 2027-02-26, through code in which each provider was wired by hand. This PR moves audio transcription onto one engine in
src/services/transcription/, where a provider is one file, and onto current models. It is step 2 of the provider plan and stacks on #536.gpt-transcribe.whisper-1andgpt-4o-transcribe-diarizeare gone from the bundle.gpt-transcribereturns text only. With speaker labels on and OpenAI selected, the transcript is saved without labels, and a notice that stays until clicked names the providers that label speakers.Retry-After. A timeout, and an answer that asks to wait over a minute, are sent once.Not in this PR: new settings, a provider or model picker, ElevenLabs, OpenRouter, and the docs, which describe the released version until this ships.
Verification
Node 22.23.2, Linux. All pass:
npm run lint,npm run format:check,npm run typecheck,npm run build,npm run size,npm run test(2,796 unit tests),npm run test:tooling(48 tests), andnpm run docs:build.Lint rejects an import of the folder's files from outside it. A probe file that imported
./transcription/http,src/services/transcription/split, and two files underproviders/got fourno-restricted-importserrors, and none for the folder's index.E2E. The whole suite passed 253 of 254 on
989185fc. On the head, which adds one small fix commit,npm run test:e2e -- -t "transcript|normalizes raw async rejection"passed its 20 tests. The one failure is inquickadd-capture.test.ts("Import settings brings back the choice that Export settings wrote, and captures run it"), and the base commita9a3b07dfails it the same way in the whole suite: the test readsdata.jsonwhile PodNotes writes it and getsUnexpected end of JSON input. The test passes when its file runs alone. An earlier run of the suite also failed a marketplace review test that calledTranscriptionService.waitForRetry, which the engine replaced. That test now aborts a wait inwaitForLifecycleinstead (plant 33).Bundle.
main.jsis 104,290 gzip bytes. The engine alone measured 104,060, under master's 104,145. On top of it, 170 bytes check the length of audio in other formats against each model's cap, for at most 5 s and only while the job runs, and 60 keep a signed enclosure URL from reaching a provider. The budget is raised to exactly 104,290.Since review. These fixes came from the independent verifiers, Bugbot and Macroscope, and each has tests and caught plants:
split.ts..m4aor.oggunder the size cap is split under the length cap instead of sent whole.hdnts/hdnea) is uploaded, so its address reaches no provider.Runtime. Obsidian 1.14.4 on Linux (headless), in this worktree's isolated vault, through the palette command
Transcribe current episode from audio. The verify skill's fixture provider (net.mjs watch) answered both providers, so no real key was used. These runs are from989185fc. The commit after it changes only what a job does after an abort and after aRetry-Afterof over a minute.Fixture Episode 1of a saved public feedapplication/json, 50 bytes. No request reached the feed serverpriv3.supercast.comGET /p/episode-1.wav, then one POST,audio/wav, 960,044 bytesaudio/wav, 960,044 bytes, and no GETGET /episode-2.wav, then one POST of 960,438 bytesMore screenshots: the OpenAI note from three parts, the Deepgram note for the private feed's episode, and the provider option in Settings.
Not verified. No request reached a real provider. Two answers are read from descriptions, not from a live response: Deepgram's
REMOTE_CONTENT_ERRORfor an address it cannot fetch, and OpenAI'sinsufficient_quotafor a used-up quota. If either differs, a fetch failure shows as "could not transcribe this audio" with no upload, and a used-up quota shows as a rate limit.Planted defects (36, each caught by the test named, with
npm run plant)TranscriptionService.test.ts: a private feed's episode is uploaded, and no request holds its address or its feed'sTranscriptionService.test.ts: an episode whose address carries a credential is uploaded, and no request holds the addressTranscriptionService.test.ts: an episode on a private network is uploaded, and no request holds its addressTranscriptionService.test.ts: an episode its saved feed does not list is uploaded, and no request holds its addressTranscriptionService.test.ts: a local file is uploaded, and no request holds its pathTranscriptionService.test.ts: a public episode goes by its address, and its audio is never loadedTranscriptionService.test.ts: a 401 for a request by address is not followed by an uploadTranscriptionService.test.ts: an address Deepgram could not fetch is followed by one uploadTranscriptionService.test.ts: has a timestamp on each paragraph and numbers the speakers in the order they first speakTranscriptionService.test.ts: has a timestamp on each paragraph and numbers the speakers in the order they first speakTranscriptionService.test.ts: has a timestamp on each paragraph and numbers the speakers in the order they first speakcues.test.ts: times each word from the start of the episode, and never shares a speaker between partscues.test.ts: times each word from the start of the episode, and never shares a speaker between partssplit.test.ts: under the size cap is cut into equal parts no longer than maxSeconds, each starting where the last endedsplit.test.ts: a .ogg file without MP3 frames is decoded into WAV parts under the cap, each with its start timeTranscriptionService.test.ts: sends OpenAI the model, the episode file and the JSON formattranscriptionModels.test.ts: main.js names gpt-transcribe and no model OpenAI shuts down on 2027-02-26TranscriptionService.test.ts: sends OpenAI the model, the episode file and the JSON formatTranscriptionService.test.ts: goes to OpenAI in parts no longer than 2,070 s, one at a time, and reads in orderTranscriptionService.test.ts: goes to OpenAI in parts no longer than 2,070 s, one at a time, and reads in orderTranscriptionService.test.ts: with speaker labels on is saved without them, and the notice names the providers that label speakers and staysTranscriptionService.test.ts: with speaker labels on is saved without them, and the notice names the providers that label speakers and staysTranscriptionService.test.ts: stays until clicked after a failureTranscriptionService.test.ts: stops at a 401 on the first part and sends no second partTranscriptionService.test.ts: writes no note when one part fails and the other holds no speechTranscriptionService.test.ts: 401 saves no note after 1 of 3 sendsTranscriptionService.test.ts: timeout saves no note after 1 of 3 sendsTranscriptionService.test.ts: 429 for a used-up quota saves no note after 1 of 3 sendsTranscriptionService.test.ts: 503 is sent again after the wait and the note is savedTranscriptionService.test.ts: 429 with Retry-After is sent again after the wait and the note is savedTranscriptionService.test.ts: shows and logs none of the provider's own words, which can quote a private addressTranscriptionService.test.ts: sends nothing for audio that arrives after unloadmarketplace-review-runtime.test.ts(e2e): normalizes raw async rejection reasons to Error objects in the live bundleTranscriptionService.test.ts: 429 that asks to wait over a minute saves no note after 1 of 3 sendsTranscriptionService.test.ts: decodes no audio that arrives after unloadTranscriptionService.test.ts: downloads no audio when unloaded while it looks the episode up in its feedRelease impact
gpt-transcribe. Same key. Sentences are grouped into paragraphs of about 500 characters, where each sentence had its own paragraph. An upload holds at most 24 MB and 2,070 s.transcript.diarization.provider: "openai"stays valid, and the run says in a lasting notice that the transcript has no labels. The provider option in Settings readsOpenAI (no speaker labels).[Error transcribing chunk N], now counted from 1 in every transcript.api.transcriptandgetTranscript()return the note's text, so Deepgram transcripts read through them now carry timestamps.Note
High Risk
Large rewrite of paid transcription paths with external API calls, audio URL vs upload decisions, and credential/private-feed handling; mistakes could leak signed URLs or bill users incorrectly despite extensive tests.
Overview
Replaces the hand-wired Whisper/diarization stack with a unified engine under
src/services/transcription/.TranscriptionServicenow delegates audio jobs totranscribe(), which picks OpenAI vs Deepgram viatranscriptionChoice(), sends HTTP through a shared retry layer, and merges multi-part results into the same published-transcript renderer.OpenAI moves to
gpt-transcribe(upload-only, no timestamps or speaker labels); long audio is split under 24 MB / 2,070 s, sent one part at a time. With diarization enabled but OpenAI selected, saves still complete but show a lasting notice that labels require Deepgram.Deepgram uses
nova-3: public episodes listed on a saved feed go out as a JSON URL request; private feeds, signed URLs, local files, and unlisted episodes upload bytes with no credential-bearing URLs in the payload. Fetch failures on URL mode trigger a single upload fallback.The old
diarization/andopenaiTranscriptionmodules are removed;getEpisodeAudioBufferno longer returnsbasename. Tooling adds aprivateverify fixture, richernet.mjsmocks, import boundaries for the transcription folder, and a higher gzip bundle budget (104,290).Reviewed by Cursor Bugbot for commit 3641fb8. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Move transcription onto a single provider-engine architecture with
gpt-transcribeand Deepgram Nova-3TranscriptionServicewith a unified provider layer in src/services/transcription/. A registry selects the provider viatranscriptionChoice: OpenAIgpt-transcribeby default, Deepgram Nova-3 when diarization is enabled and selectedgetEpisodeAudioBufferno longer returns abasenamefieldMacroscope summarized 3641fb8.