feat(caption): stream live captions and polish finished lines in context
Replaces the fixed six-second batches with a streaming track per audio source: the uncommitted audio is re-recognised every second and sent as a partial with its agreed (stable) prefix, a short pause finalises the line, and long unbroken speech is committed at Whisper segment boundaries. Idle audio is trimmed so silence cannot produce invented sentences. Finished lines are corrected by the local model against the previous lines and replaced in place; edits that change too much are rejected. The behaviour can be switched off in Settings.
This commit is contained in:
parent
db8d9448a3
commit
39b8e7448e
28 changed files with 827 additions and 217 deletions
184
apps/desktop/tests/main/services/caption-streaming.test.ts
Normal file
184
apps/desktop/tests/main/services/caption-streaming.test.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// 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(['마지막 말'])
|
||||
})
|
||||
})
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
SUGGESTION_NO_THINK_PREFIX,
|
||||
SUGGESTION_SYSTEM_PROMPT,
|
||||
} from '../../../src/main/services/llm-prompts'
|
||||
import { buildCaptionRefinePrompt } from '../../../src/main/services/llm-prompts'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
|
@ -248,3 +249,21 @@ describe('buildSuggestionPrompt', () => {
|
|||
expect(text).not.toContain('이미 제안한 문장')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCaptionRefinePrompt', () => {
|
||||
it('지시문은 시스템 프롬프트에만 두고, 본문에는 앞 문맥 2줄과 다듬을 자막만 넣는다', () => {
|
||||
const { systemPrompt, text } = buildCaptionRefinePrompt({
|
||||
text: '오늘회의는 세시에',
|
||||
previous: ['첫 줄', '둘째 줄', '셋째 줄']
|
||||
})
|
||||
expect(systemPrompt).toContain('뜻을 바꾸거나')
|
||||
expect(text).not.toContain('규칙')
|
||||
expect(text).toContain(['둘째 줄', '셋째 줄'].join('\n'))
|
||||
expect(text).not.toContain('첫 줄')
|
||||
expect(text.endsWith('오늘회의는 세시에')).toBe(true)
|
||||
})
|
||||
|
||||
it('앞 문맥이 없으면 다듬을 자막만 넣는다', () => {
|
||||
expect(buildCaptionRefinePrompt({ text: '안녕', previous: [] }).text).toBe(['[다듬을 자막]', '안녕'].join('\n'))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue