Skip to content

feat(transcription): one speaker label per voice across the parts of an episode - #542

Open
chhoumann wants to merge 6 commits into
feat/transcription-openrouterfrom
feat/transcription-speaker-merge
Open

chhoumann wants to merge 6 commits into
feat/transcription-openrouterfrom
feat/transcription-speaker-merge

Conversation

@chhoumann

@chhoumann chhoumann commented Nov 14, 2026 •

Copy link
Copy Markdown
Owner

Summary

OpenRouter takes at most 18 MB and 1,800 s a request, so PodNotes uploads a long episode in parts, and each part numbers its speakers on its own. On the 54 min E231 in 3 parts, those labels agreed with a whole-file run 0.344 of the time. With this change, Grok STT 2.0 gives each voice one number across all parts of an episode. Builds on #540.

  • After the parts, PodNotes sends one more request, the reel. It holds one clip per part-local speaker label, from that label's longest turn of at least 2.6 s, starting 0.2 s in and at most 9 s long. PodNotes cuts the clips at MP3 frame starts and joins them with 1.5 s of silent frames. Each speaker in Grok's answer gets credit for the seconds it speaks over each clip. Part labels whose clips go to one speaker become one, and a label with no clip keeps its own number.
  • mergeSpeakers on the model profile turns the reel on. It is on for x-ai/grok-stt-2.0 and off for elevenlabs/scribe-v2 and microsoft/mai-transcribe-2.
  • The reel runs only with speaker labels on, more than one part and MP3 audio. Audio decoded to WAV parts keeps the restart warning. A failed reel request keeps the transcript and the restart warning and never fails the job. A merged transcript has no restart warning.

Speaker accuracy of the lab implementation (diarize-lab/scripts/step0.mjs) through OpenRouter:

Episode Reference Parts as they come With the reel Whole-file Grok
E231, 3 parts at the product's caps whole-file Scribe v3 0.344 0.995
Planet Money, 300 s parts human transcript 0.367 0.949 0.942
Up First, 300 s parts human transcript 0.753 0.830 0.831

Scribe v2's reel heard Planet Money's 10 voices as 4 (0.378 to 0.414), so it stays off. MAI-Transcribe 2 is not measured. This PR's code has not run against the live API, because this session has no key.

Verification

  • npm run test passes 2,888 tests. The new tests send MP3 fixtures whose frames carry a voice and a frame index through the requestUrl seam. The mock provider hears each frame's voice and numbers voices in the order they speak in each request, as a diarizing model does. On the base, the 4 tests for new behavior fail, for example with 1 Ann, 2 Bob, 3 Bob, 4 Ann where 1 Ann, 2 Bob, 2 Bob, 1 Ann is expected.
  • Each plant below failed its test (1 failed) and passed once restored (1 passed), through npm run plant:
Plant Caught by
The note ignores the matched labels Grok STT 2.0 numbers each voice once across two parts... (3 Bob for 2 Bob)
No silence between clips the reel is the stream's own frames...
A label without a clip takes the speaker matched last a voice whose turns are all under 2.6 s... (2 Cy for 3 Cy)
Every turn gets a clip, short ones too the same test (the reel held Cy's frames)
A reel failure ends the job, as it does for a part a reel OpenRouter refuses keeps the parts' labels...
The reel runs for every model the Scribe v2 and MAI-Transcribe 2 tests, one run each
The reel runs with labels off with speaker labels off, Grok STT 2.0 sends no reel...
The reel runs for one part an episode in one part sends no reel
  • lint, format:check, typecheck, build, test:tooling (48 passed) and docs:build pass. npm run test:e2e -- tests/e2e/journey-resilience.test.ts tests/e2e/journey-transcript.test.ts passes 26 tests. npm run size reports 106,873 gzip bytes. The budget rises by the measured 808 bytes, against 106,065 for the base built the same way, and keeps the base's 97 bytes of headroom.
  • Obsidian 1.14.4 on Linux, in this worktree's isolated instance, with the verify skill's fixture provider and no real key. The fixture now decodes Grok's MP3 uploads with Chromium's decoder and hears a 300 Hz tone and a 700 Hz tone as two speakers. The local file Fixtures/Two Voices.mp3 is 70 min of 8 kbps, 8 kHz mono, with the two tones taking turns every 45 s. With Grok and labels on, Transcribe current episode from audio sent three part POSTs of about 1.87 MB each, then one reel POST of 82,291 bytes that Chromium decoded to 61.56 s, which is six 9 s clips and five gaps of 21 silent frames. The second part starts with the high tone and numbers the voices the other way. The note still numbers all 47 low-voice paragraphs 1 and all 47 high-voice paragraphs 2, and the paragraph at each part boundary runs on across it. No notice stayed. On Scribe v2 the same file went as three multipart POSTs with no reel, the note numbered six speakers, and the restart warning stayed. dev:errors reported none.

Grok note at the first part boundary

Scribe v2 note with the restart warning

Real OpenRouter key (lead, after review)

Obsidian 1.14.4, Linux, build of 5eebf37b, Grok STT 2.0 (recommended), speaker labels on, the 54 min, 39 MB local E231 interview (two voices), palette command, a pass-through request log:

  • One catalog GET, three part POSTs (18.1, 17.0 and 16.9 MB of JSON), then one reel POST of 994,643 bytes. Done in 48 s, Transcription completed and saved., no restart warning, dev:errors none.
  • The note has exactly two speakers (60 turns of 1:, 65 of 2:), alternating 1 2 1 2 through all three parts. The same episode on feat(transcription): transcribe with OpenRouter #540 got new labels in each part.
  • Identity holds across parts: the host's introduction in part 1 ("My name is ...") and his sign-off in part 3 ("Amazing ... good to have you on") are both 1:.

Release impact

None for settings, storage, the API or URIs. A Grok transcript of an episode over 18 MB or 1,800 s costs one more OpenRouter request of about a minute of audio. OpenRouter is unreleased, so no released behavior changes.

Note

Merge speaker labels across episode parts via a speaker reel for Grok STT

  • Multi-part episodes transcribed with a mergeSpeakers-enabled model (Grok STT 2.0) now get an extra provider request with an audio reel of each speaker's longest turn, and the reel response is used to map labels across parts into one speaker per voice.
  • speakers.ts builds the reel from turns of at least 2.6 seconds, up to 9 seconds each, ordered by total speaking duration. cues.ts applies the resulting SameSpeaker mapping when numbering speakers in timed transcripts; without a mapping, labels stay distinct per part.
  • split.ts adds cutReel, which assembles clips from the MP3 at frame boundaries with at least 1.5 seconds of generated silence between them.
  • If the reel request fails with a TranscriptionError, the original transcripts are kept with unmatched labels and the multi-part restart warning is shown. Models marked without mergeSpeakers (ElevenLabs Scribe v2, MAI-Transcribe 2) keep per-part numbering.
  • Behavioral Change: gzip bundle budget raised from 106,162 to 106,970 bytes in size.mjs; eligible jobs make one additional provider request per episode.

Macroscope summarized 0531b3c.


Note

Medium Risk
Adds an extra API call and ~808 bytes of production bundle for Grok multi-part jobs; reel failure paths must preserve transcripts without breaking numbering—covered by tests but behavior is new.

Overview
Multi-part OpenRouter transcriptions with speaker labels on can now merge the same voice into one number across parts. For models that opt in (Grok STT 2.0 via mergeSpeakers), PodNotes sends an extra speaker reel after the part uploads: MP3 clips (longest turn per part-local label, frame-aligned, separated by silent frames) are transcribed once more; overlapping word timing maps part labels to a shared identity used when building cues in transcriptFromParts.

ElevenLabs Scribe v2 and MAI-Transcribe 2 keep mergeSpeakers off; they still get the “labels restart in each part” warning. Reel failures do not abort the job—transcript saves with per-part labels and the restart warning. Single-part jobs, labels off, or non-MP3 paths skip the reel.

E2E net.mjs now decodes Grok JSON MP3 uploads (tone-based fake diarization) for verify runs; gzip bundle budget rises by 808 bytes to 106,970.

Reviewed by Cursor Bugbot for commit 0531b3c. Bugbot is set up for automated code reviews on this repo. Configure here.

…isode

Two parts that label Ann and Bob in the other order, read by a provider
fixture that hears the voice each MP3 frame carries. Grok STT 2.0 should
send one reel of 9 s clips between 1.5 s of silent frames, leave a voice
under 2.6 s alone, and keep the parts' labels when the reel fails.
Scribe v2, MAI-Transcribe 2, labels off and one part send no reel.
…an episode

OpenRouter takes at most 18 MB and 1,800 s a request, so a long upload goes
in parts, and each part numbers its speakers on its own. On the 54 min E231
in three parts, those labels agreed with a whole-file run 0.344 of the time.

After the parts, Grok STT 2.0 now gets one more request: a reel with a clip
of each part's every speaker label, from its longest turn of at least 2.6 s,
0.2 s in and at most 9 s, cut at MP3 frame starts and joined by 1.5 s of
silent frames. Each speaker the reel's answer names is credited with the
seconds it speaks over each clip, part labels whose clips go to one speaker
become one, and a label with no clip stays its own. Measured through
OpenRouter with Grok, this took E231 from 0.344 to 0.995 against a
whole-file run, and NPR's Planet Money in 300 s parts from 0.367 to 0.949
against its human transcript, beside 0.942 for whole-file Grok.

A model profile turns the reel on with mergeSpeakers. Scribe v2 stays off,
because its reel heard the 10 voices of a Planet Money episode as 4, and so
does MAI-Transcribe 2, which is not measured. Audio decoded to WAV parts
sends no reel. A reel that fails keeps the parts' labels and the warning
that they restart in each part, which the note no longer gets otherwise.
…measures

The branch this builds on leaves main.js at 106,065 gzip bytes, built with
the same vite command from an export of its head. Cutting the reel, merging
labels from its answer and the mergeSpeakers profile add 808, which takes it
to 106,873 and over the 106,162 budget. The budget rises by those 808 bytes
to 106,970, so the 97 bytes of headroom stay as they were.
… a model would

The fixture provider answered every OpenRouter request with the same two
speakers, so a reel's answer could not say which part labels are one voice.
It now decodes Grok's MP3 uploads with Chromium's decoder and hears a tone
under 500 Hz as one speaker and a higher tone as another, numbered in the
order they sound in each request. The log records each upload's decoded
seconds and the speakers heard, and audio the decoder refuses is answered
with a 400. Multipart and WAV uploads keep the fixed answer.

The transcripts recipe writes a 70 min MP3 of two tones that take turns
every 45 s, whose second part starts with the high tone, and checks three
part requests, one 61.56 s reel and one number per voice in the note.
@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0531b3c
Status: ✅  Deploy successful!
Preview URL: https://62ff5d5f.podnotes.pages.dev
Branch Preview URL: https://feat-transcription-speaker-m.podnotes.pages.dev

View logs

Comment thread src/services/transcription/speakers.ts Resolved

@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 high 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 5eebf37. Configure here.

The reel's answer was folded into turns before each clip was credited, and
a turn runs from a speaker's first word to their last. When the model gave a
clip no words, such as a clip it could not make out, the words of one
speaker in the clips on both sides of it made a single turn over it. That
speaker was then credited with the whole clip, and its part label was merged
into theirs, so two people shared one number in the note.

Each clip is now credited from the answer's words, so seconds no word spans
go to no one, and a clip with no words keeps its own label.
The warning that speaker labels restart in each part was left out whenever
the reel matched any label. A label whose voice never spoke 2.6 s at once
gets no clip, and a clip the reel's answer gives no words gets no match, so
both keep a number of their own that can belong to a voice another part
labels too, and the note said nothing.

The warning now stays unless every label the parts returned was heard in
the reel. A label heard as a voice no earlier clip had stands for itself,
and counts as matched. The wording is unchanged. main.js grows by 29 gzip
bytes, inside the budget.
const turns: Turn[] = [];
for (const { speaker, start, end } of words) {
const last = turns[turns.length - 1];
if (last && last.speaker === speaker) turns[turns.length - 1] = { ...last, end };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium transcription/speakers.ts:18

turnsOf merges same-speaker words across long silences, so longest can rank elapsed silence as speech and select a silent clip instead of a genuine shorter sample, preventing cross-part matching. Split turns at substantial timestamp gaps or choose a speech-containing window before selecting longest.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/services/transcription/speakers.ts around line 18:

`turnsOf` merges same-speaker words across long silences, so `longest` can rank elapsed silence as speech and select a silent clip instead of a genuine shorter sample, preventing cross-part matching. Split turns at substantial timestamp gaps or choose a speech-containing window before selecting `longest`.

This branch has not been deployed

No deployments
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.

1 participant