Files
transcribe/sidecar/conversation.test.ts
pastilhasandClaude Fable 5 dd3dbb611b conversations come back as conversations
A probe on the torch stage worker (~/dockers/torch-worker) runs beside every
transcription and decides what the recording is: solo dictation returns as
paragraphs exactly as before, several voices return speaker-labeled with
conversation: true. Nobody declares which — the caller cannot know.

Transcription and the worker each get a serial FIFO queue on their own
resource, so the probe costs a solo memo nothing and multi-user traffic
stays fair. The worker is optional and best-effort throughout: unreachable
or failing, everything still transcribes, just never labeled.

Speaker attribution is segment-granularity by maximal turn overlap —
whisper.cpp's own timestamps, no alignment stage. If boundaries prove
sloppy, wav2vec2 alignment returns as a worker endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 04:27:14 +00:00

82 lines
3.2 KiB
TypeScript

import { describe, expect, it } from 'bun:test';
import { toConversation } from './conversation';
import type { WhisperSegment } from './transcribe';
import type { SpeakerTurn } from './worker';
const seg = (text: string, start: number, end: number, extra: Partial<WhisperSegment> = {}): WhisperSegment => ({
text,
start,
end,
...extra,
});
const turn = (start: number, end: number, speaker: string): SpeakerTurn => ({ start, end, speaker });
describe('toConversation', () => {
it('attributes each segment to the speaker whose turns cover it most', () => {
const out = toConversation(
[seg(' How are you?', 0, 2), seg(' Fine, thanks.', 2.5, 4)],
[turn(0, 2.2, 'SPEAKER_00'), turn(2.3, 4.5, 'SPEAKER_01')],
);
expect(out.text).toBe('Speaker 1: How are you?\n\nSpeaker 2: Fine, thanks.');
expect(out.speakerCount).toBe(2);
});
it('numbers speakers by first appearance, not by pyannote label order', () => {
// SPEAKER_01 talks first — they are "Speaker 1" in the transcript.
const out = toConversation(
[seg(' Me first.', 0, 2), seg(' Me second.', 3, 5)],
[turn(0, 2, 'SPEAKER_01'), turn(3, 5, 'SPEAKER_00')],
);
expect(out.text).toBe('Speaker 1: Me first.\n\nSpeaker 2: Me second.');
});
it('joins consecutive segments by the same speaker into one block', () => {
const out = toConversation(
[seg(' First sentence.', 0, 2), seg(' Second sentence.', 2.2, 4), seg(' A reply.', 5, 6)],
[turn(0, 4.5, 'SPEAKER_00'), turn(4.8, 6.5, 'SPEAKER_01')],
);
expect(out.text).toBe('Speaker 1: First sentence. Second sentence.\n\nSpeaker 2: A reply.');
});
// Whisper and pyannote disagree about boundaries all the time; a segment neither turn covers
// must stay with whoever was already talking, not open a phantom block.
it('keeps an uncovered segment with the previous speaker', () => {
const out = toConversation(
[seg(' Covered.', 0, 2), seg(' In the gap.', 2.5, 3), seg(' Reply.', 4, 6)],
[turn(0, 2.2, 'SPEAKER_00'), turn(3.8, 6, 'SPEAKER_01')],
);
expect(out.text).toBe('Speaker 1: Covered. In the gap.\n\nSpeaker 2: Reply.');
});
it('attributes a transcript that starts before the first turn to the first speaker', () => {
const out = toConversation([seg(' Early words.', 0, 1)], [turn(1.5, 3, 'SPEAKER_00')]);
expect(out.text).toBe('Speaker 1: Early words.');
expect(out.speakerCount).toBe(1);
});
it('drops hallucinated-silence segments like the solo path does', () => {
const out = toConversation(
[seg(' Real.', 0, 2), seg(' Thanks for watching!', 2.1, 3, { no_speech_prob: 0.95, avg_logprob: -1.8 })],
[turn(0, 3, 'SPEAKER_00')],
);
expect(out.text).toBe('Speaker 1: Real.');
});
it('attaches stranded punctuation without a space, inside a block', () => {
const out = toConversation(
[seg(' See you tomorrow', 0, 2), seg(' .', 2.1, 2.2)],
[turn(0, 3, 'SPEAKER_00')],
);
expect(out.text).toBe('Speaker 1: See you tomorrow.');
});
it('reports one speaker for a monologue so the caller can fall back to paragraphs', () => {
const out = toConversation(
[seg(' Just me talking.', 0, 2)],
[turn(0, 2, 'SPEAKER_00'), turn(3, 4, 'SPEAKER_00')],
);
expect(out.speakerCount).toBe(1);
});
});