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>
82 lines
3.2 KiB
TypeScript
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);
|
|
});
|
|
});
|