// tests/main/services/caption-streaming.test.ts // 실시간 자막 스트리밍: 판단 로직(core) + 트랙(오디오 버퍼·인식 호출) 흐름. import { describe, it, expect } from 'vitest' import { CAPTION_STREAMING_DEFAULTS, acceptCaptionRefinement, agreedCaptionPrefix, captionChangeRatio, decideCaptionTick, planForcedCommit } from '@d3ro/core/caption-streaming' import { StreamingCaptionTrack, type CaptionTranscription } from '../../../src/main/services/caption/StreamingCaptionTrack' const BYTES_PER_MS = 32 function chunk(ms: number, voiced: boolean): Buffer { const buffer = Buffer.alloc(ms * BYTES_PER_MS) buffer[0] = voiced ? 1 : 0 return buffer } describe('decideCaptionTick', () => { const base = { bufferMs: 2000, hasVoice: true, sinceVoiceMs: 100, sincePartialMs: 1500, busy: false } it('말하는 중이면 1초마다 중간 결과를 만든다', () => { expect(decideCaptionTick(base)).toBe('partial') expect(decideCaptionTick({ ...base, sincePartialMs: 400 })).toBe('wait') }) it('말이 멈추면 문장을 확정한다', () => { expect(decideCaptionTick({ ...base, sinceVoiceMs: CAPTION_STREAMING_DEFAULTS.silenceCommitMs })).toBe('finalize') }) it('멈추지 않고 길어지면 앞쪽을 강제로 확정한다', () => { expect(decideCaptionTick({ ...base, bufferMs: CAPTION_STREAMING_DEFAULTS.maxBufferMs })).toBe('force-commit') }) it('소리가 없는 오디오는 들고 있지 않는다', () => { expect(decideCaptionTick({ ...base, hasVoice: false, bufferMs: 5000 })).toBe('trim-idle') expect(decideCaptionTick({ ...base, hasVoice: false, bufferMs: 500 })).toBe('wait') }) it('인식 중이거나 오디오가 짧으면 기다린다', () => { expect(decideCaptionTick({ ...base, busy: true })).toBe('wait') expect(decideCaptionTick({ ...base, bufferMs: 300 })).toBe('wait') }) }) describe('LocalAgreement / 강제 확정 / 다듬기 검사', () => { it('두 가설이 앞에서부터 같은 단어만 합의한다', () => { expect(agreedCaptionPrefix(['오늘', '회의는', '세시에'], ['오늘', '회의는', '네시에', '해요'])).toEqual(['오늘', '회의는']) expect(agreedCaptionPrefix([], ['오늘'])).toEqual([]) }) it('끝부분(keepTail) 안쪽 구간은 남기고 앞쪽만 확정한다', () => { const plan = planForcedCommit( [ { text: '첫 문장', start: 0, end: 4 }, { text: '둘째 문장', start: 4, end: 9 }, { text: '진행 중', start: 9, end: 12 } ], 12, 1.5 ) expect(plan).toEqual({ text: '첫 문장 둘째 문장', cutAtSec: 9 }) expect(planForcedCommit([{ text: '긴 한 문장', start: 0, end: 12 }], 12, 1.5)).toBeNull() }) it('띄어쓰기·문장부호 수준의 수정은 받고, 뜻을 바꾼 문장은 버린다', () => { expect(acceptCaptionRefinement('오늘회의는 세시에 시작합니다', '오늘 회의는 세 시에 시작합니다.')).toBe( '오늘 회의는 세 시에 시작합니다.' ) expect(acceptCaptionRefinement('오늘 회의는 세 시에 시작합니다', '회의 일정이 변경되었다는 공지입니다')).toBeNull() expect(acceptCaptionRefinement('그대로', '"그대로"')).toBeNull() expect(captionChangeRatio('abc', 'abc')).toBe(0) }) }) describe('StreamingCaptionTrack', () => { function setup(results: CaptionTranscription[]) { let now = 1_000 const partials: Array<{ text: string; stable: string }> = [] const finals: string[] = [] const calls: Array<{ bytes: number; partial: boolean; prompt: string }> = [] const track = new StreamingCaptionTrack( { transcribe: async (audio, opts) => { calls.push({ bytes: audio.length, partial: opts.partial, prompt: opts.initialPrompt }) return results.shift() ?? { text: '', segments: [] } }, isVoiced: (c) => c[0] === 1, onPartial: (text, stable) => partials.push({ text, stable }), onFinal: (text) => finals.push(text), onError: (error) => { throw error }, now: () => now }, '앞 문맥' ) return { track, partials, finals, calls, advance: (ms: number) => { now += ms } } } it('말하는 동안 단어가 갱신되고, 멈추면 한 문장으로 확정된다', async () => { const t = setup([ { text: '오늘 회의는', segments: [] }, { text: '오늘 회의는 세시에', segments: [] }, { text: '오늘 회의는 세 시에 시작합니다', segments: [] } ]) t.track.push(chunk(1000, true)) await t.track.tick() t.advance(1000) t.track.push(chunk(1000, true)) await t.track.tick() expect(t.partials.map((p) => p.text)).toEqual(['오늘 회의는', '오늘 회의는 세시에']) expect(t.partials[1].stable).toBe('오늘 회의는') expect(t.calls.every((c) => c.partial)).toBe(true) t.advance(800) t.track.push(chunk(800, false)) await t.track.tick() expect(t.finals).toEqual(['오늘 회의는 세 시에 시작합니다']) expect(t.calls[2].partial).toBe(false) expect(t.calls[2].prompt).toBe('앞 문맥') expect(t.track.bufferMs).toBe(0) }) it('확정된 문장은 다음 인식의 앞 문맥이 된다', async () => { const t = setup([{ text: '첫 문장', segments: [] }, { text: '둘째', segments: [] }]) t.track.push(chunk(1000, true)) t.advance(800) await t.track.tick() t.track.push(chunk(1000, true)) await t.track.tick() expect(t.calls[1].prompt).toBe('앞 문맥 첫 문장') }) it('끊기지 않는 긴 소리는 앞쪽만 확정하고 그만큼 오디오를 잘라낸다', async () => { const t = setup([ { text: '첫 문장 둘째 문장 진행 중', segments: [ { text: '첫 문장', start: 0, end: 5 }, { text: '둘째 문장', start: 5, end: 9 }, { text: '진행 중', start: 9, end: 12 } ] } ]) t.track.push(chunk(CAPTION_STREAMING_DEFAULTS.maxBufferMs, true)) await t.track.tick() expect(t.finals).toEqual(['첫 문장 둘째 문장']) expect(t.track.bufferMs).toBe(CAPTION_STREAMING_DEFAULTS.maxBufferMs - 9000) }) it('소리가 없으면 오디오를 짧게만 남기고 인식하지 않는다', async () => { const t = setup([]) t.track.push(chunk(5000, false)) await t.track.tick() expect(t.calls).toHaveLength(0) expect(t.track.bufferMs).toBe(CAPTION_STREAMING_DEFAULTS.idleKeepMs) }) it('종료할 때 말하던 문장을 확정한다', async () => { const t = setup([{ text: '마지막 말', segments: [] }]) t.track.push(chunk(1200, true)) await t.track.flush() expect(t.finals).toEqual(['마지막 말']) }) })