transcription: tune whisper for single-speaker audio, and paragraph the output

Whisper feeds its own decoded output forward as the prompt for the next 30s
window, which is what makes it loop once it slips. max_context is the budget for
that carried text; setting it to 0 stops the loops but also disables the initial
prompt, since whisper.cpp gates both on n_max_text_ctx > 0. carry_initial_prompt
puts the prompt in a static slot that is filled first and in full, so making the
prompt longer than the budget leaves nothing over for decoded text — the prompt
reaches every window and none of the model's own output does.

The prompt is a style exemplar, not an instruction: whisper imitates what it is
primed with. A first version described the format ("Commas separate clauses")
and a character surname came back as "Commas", so both prompts are now ordinary
conversational prose with no meta-language, one per language.

Also switch to verbose_json for segment timestamps and rejoin the segments into
paragraphs on pause length, rather than emitting one line per utterance with a
leading space; decode with beam search instead of greedy; widen the VAD segments
so they break at real pauses instead of at breaths; and stop verbose_json from
re-running a full language auto-detect that step 1 already answered.

Measured on a 43 minute episode against the previous output: redundant repeated
sentences 18 -> 9 (the long distinctive loops are gone, what remains is a
catchphrase), paragraphs 1 -> 93, lone-punctuation lines 3 -> 0, lines with a
leading space 1021 -> 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
brunorezio
2026-07-26 17:26:42 +01:00
co-authored by Claude Opus 5
parent e46b28e14e
commit f4054193ae
2 changed files with 227 additions and 3 deletions
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'bun:test';
import { toParagraphs } from './transcribe';
import type { WhisperSegment } from './transcribe';
// Whisper's own shape: leading space on every segment, times in seconds.
const seg = (text: string, start: number, end: number, extra: Partial<WhisperSegment> = {}): WhisperSegment => ({
text,
start,
end,
...extra,
});
describe('toParagraphs', () => {
it('joins segments spoken without a real pause into one paragraph', () => {
const out = toParagraphs([seg(' I went to the shop.', 0, 2), seg(' It was closed.', 2.2, 4)]);
expect(out).toBe('I went to the shop. It was closed.');
});
it('starts a new paragraph after a long silence', () => {
const out = toParagraphs([seg(' First thought.', 0, 2), seg(' Second thought.', 5, 7)]);
expect(out).toBe('First thought.\n\nSecond thought.');
});
it('does not break on a breath-length pause', () => {
const out = toParagraphs([seg(' Hang on,', 0, 1), seg(' let me think.', 1.5, 3)]);
expect(out).toBe('Hang on, let me think.');
});
// The lone "." lines in the old response_format=text output came from segments like this.
it('attaches stranded punctuation without a space in front of it', () => {
const out = toParagraphs([seg(' See you tomorrow morning', 0, 2), seg(' .', 2.1, 2.2)]);
expect(out).toBe('See you tomorrow morning.');
});
it('drops segments that are probably silence decoded with low confidence', () => {
const out = toParagraphs([
seg(' Real speech here.', 0, 2),
seg(' Thanks for watching!', 2.1, 3, { no_speech_prob: 0.95, avg_logprob: -1.8 }),
seg(' More real speech.', 3.1, 5),
]);
expect(out).toBe('Real speech here. More real speech.');
});
it('keeps a confident segment even when it looks like silence', () => {
const out = toParagraphs([seg(' Definitely said.', 0, 2, { no_speech_prob: 0.95, avg_logprob: -0.2 })]);
expect(out).toBe('Definitely said.');
});
it('keeps segments that report neither probability', () => {
const out = toParagraphs([seg(' No metadata at all.', 0, 2)]);
expect(out).toBe('No metadata at all.');
});
it('measures the pause from the last kept segment, not the dropped one', () => {
const out = toParagraphs([
seg(' Before.', 0, 2),
seg(' Hallucinated.', 2.1, 9, { no_speech_prob: 0.9, avg_logprob: -2 }),
seg(' After.', 9.1, 11),
]);
// The gap that matters is 2 -> 9.1, so these are separate thoughts.
expect(out).toBe('Before.\n\nAfter.');
});
it('collapses internal whitespace and ignores empty segments', () => {
const out = toParagraphs([seg(' Too many\n spaces. ', 0, 2), seg(' ', 2.1, 2.2)]);
expect(out).toBe('Too many spaces.');
});
it('returns an empty string when every segment is filtered out', () => {
expect(toParagraphs([])).toBe('');
expect(toParagraphs([seg(' ', 0, 1)])).toBe('');
});
it('falls back to no break when segments carry no timings', () => {
const out = toParagraphs([{ text: ' One.' }, { text: ' Two.' }]);
expect(out).toBe('One. Two.');
});
});