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>
79 lines
3.1 KiB
TypeScript
79 lines
3.1 KiB
TypeScript
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.');
|
|
});
|
|
});
|