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
|
|
@ -1,6 +1,8 @@
|
|||
// src/main/services/CaptionService.ts
|
||||
// Phase 10.1: Live Caption — 실시간 자막 서비스
|
||||
// 3초 청크 기반 스트리밍 전사. 싱글톤 + EventEmitter 패턴.
|
||||
// 스트리밍 전사: 1초마다 중간 결과를 갱신하고, 말이 멈추면 문장을 확정한다
|
||||
// (StreamingCaptionTrack). 확정된 줄은 로컬 LLM이 문맥에 맞게 다듬는다.
|
||||
// 싱글톤 + EventEmitter 패턴.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { app } from 'electron'
|
||||
|
|
@ -8,9 +10,12 @@ import { getLogger } from './LoggerService'
|
|||
import { getAudioCaptureService, calculateRMS } from './AudioCaptureService'
|
||||
import { getSoundEffectService } from './SoundEffectService'
|
||||
import { getLocalSTTService } from './LocalSTTService'
|
||||
import { getSTTManager } from './stt/STTManager'
|
||||
import { getLocalLLMService } from './LocalLLMService'
|
||||
import { getHistoryService } from './HistoryService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { buildCaptionRefinePrompt } from './llm-prompts'
|
||||
import { StreamingCaptionTrack, type CaptionTranscription } from './caption/StreamingCaptionTrack'
|
||||
import { acceptCaptionRefinement } from '@d3ro/core/caption-streaming'
|
||||
import {
|
||||
showCaptionOverlay,
|
||||
hideCaptionOverlay,
|
||||
|
|
@ -28,17 +33,16 @@ import { getMainWindow } from '../windows/WindowManager'
|
|||
|
||||
const logger = getLogger('CaptionService')
|
||||
|
||||
/** 청크 수집 간격 (ms) — 6초로 충분한 컨텍스트 확보 */
|
||||
const CHUNK_INTERVAL_MS = 6000
|
||||
/** 트랙 판단 주기 (ms) — 인식 주기는 트랙이 정한다(중간 결과 1초) */
|
||||
const TICK_INTERVAL_MS = 250
|
||||
|
||||
/** RMS 무음 임계값 — 이하면 무음으로 판정 (SoX 캡처 레벨이 낮으므로 0.003 사용) */
|
||||
const SILENCE_RMS_THRESHOLD = 0.003
|
||||
|
||||
/** 유성음 프레임 비율 — 이 비율 미만이면 청크 스킵 (환각 방지) */
|
||||
const VOICED_FRAME_RATIO = 0.03
|
||||
|
||||
/** initialPrompt 컨텍스트 윈도우 (자) */
|
||||
const CONTEXT_WINDOW_SIZE = 300
|
||||
/** 문맥 다듬기 대기열 상한 — 밀리면 오래된 줄은 다듬지 않고 넘긴다 */
|
||||
const REFINE_QUEUE_LIMIT = 3
|
||||
const REFINE_TIMEOUT_MS = 6000
|
||||
const REFINE_KEEP_ALIVE = '10m'
|
||||
|
||||
/** 기본 자막 설정 */
|
||||
const DEFAULT_CONFIG: CaptionConfig = {
|
||||
|
|
@ -63,21 +67,18 @@ class CaptionService extends EventEmitter {
|
|||
private _sessionId: string | null = null
|
||||
private _sessionStartedAt: number | null = null
|
||||
private _segments: CaptionSegment[] = []
|
||||
private _audioBuffers: Buffer[] = []
|
||||
private _chunkTimer: ReturnType<typeof setInterval> | null = null
|
||||
private _isProcessingChunk = false
|
||||
private _previousContext = ''
|
||||
private _tickTimer: ReturnType<typeof setInterval> | null = null
|
||||
private _disposed = false
|
||||
/** 현재 청크 내 유성음 프레임 수 */
|
||||
private _voicedFrameCount = 0
|
||||
/** 현재 청크 내 총 프레임 수 */
|
||||
private _totalFrameCount = 0
|
||||
/** 마이크 트랙 (audioSource='mic' 또는 'both') */
|
||||
private _micTrack: StreamingCaptionTrack | null = null
|
||||
/** 시스템 소리 트랙 (audioSource='system' 또는 'both') */
|
||||
private _systemTrack: StreamingCaptionTrack | null = null
|
||||
/** 'auto' 언어일 때 첫 확정 결과로 고정한다 — 중간 결과마다 언어가 흔들리지 않게 */
|
||||
private _sessionLanguage: string | null = null
|
||||
private _refineQueue: CaptionSegment[] = []
|
||||
private _refining = false
|
||||
|
||||
private _audioDataHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null
|
||||
/** 시스템 오디오용 별도 버퍼 (audioSource='system' 또는 'both') */
|
||||
private _systemAudioBuffers: Buffer[] = []
|
||||
private _systemVoicedFrameCount = 0
|
||||
private _systemTotalFrameCount = 0
|
||||
|
||||
// ── 공개 접근자 ──
|
||||
|
||||
|
|
@ -130,13 +131,11 @@ class CaptionService extends EventEmitter {
|
|||
this._sessionId = crypto.randomUUID()
|
||||
this._sessionStartedAt = Date.now()
|
||||
this._segments = []
|
||||
this._audioBuffers = []
|
||||
this._refineQueue = []
|
||||
this._sessionLanguage = null
|
||||
// 초기 컨텍스트 힌트 — Whisper 첫 청크 환각 방지
|
||||
const lang = configGet('sttLanguage') as string | undefined
|
||||
this._previousContext = lang === 'ko' ? '다음은 한국어 대화입니다.' : ''
|
||||
this._isProcessingChunk = false
|
||||
this._voicedFrameCount = 0
|
||||
this._totalFrameCount = 0
|
||||
const initialContext = lang === 'ko' ? '다음은 한국어 대화입니다.' : ''
|
||||
|
||||
// ConfigService에서 저장된 오디오 소스 읽기
|
||||
const savedSource = configGet('captionAudioSource' as keyof import('@d3ro/core/types').AppConfig) as unknown as string
|
||||
|
|
@ -148,6 +147,7 @@ class CaptionService extends EventEmitter {
|
|||
|
||||
// 마이크 캡처 (mic 또는 both)
|
||||
if (audioSource === 'mic' || audioSource === 'both') {
|
||||
this._micTrack = this._createTrack('mic', initialContext)
|
||||
const audioCaptureService = getAudioCaptureService()
|
||||
this._audioDataHandler = (payload) => {
|
||||
this._onAudioData(payload.buffer)
|
||||
|
|
@ -158,45 +158,26 @@ class CaptionService extends EventEmitter {
|
|||
|
||||
// 시스템 오디오 캡처 요청 (system 또는 both) — 렌더러에 시작 요청
|
||||
if (audioSource === 'system' || audioSource === 'both') {
|
||||
this._systemTrack = this._createTrack('system', initialContext)
|
||||
this._sendToMainWindow(IPC_CHANNELS.CAPTION.START_SYSTEM_AUDIO, {})
|
||||
this._systemAudioBuffers = []
|
||||
this._systemVoicedFrameCount = 0
|
||||
this._systemTotalFrameCount = 0
|
||||
}
|
||||
|
||||
// 청크 타이머 시작
|
||||
this._chunkTimer = setInterval(() => {
|
||||
const promises: Promise<void>[] = []
|
||||
|
||||
// 마이크 청크 처리
|
||||
if (audioSource === 'mic' || audioSource === 'both') {
|
||||
promises.push(
|
||||
this._processChunk().catch((err: unknown) => {
|
||||
logger.error(`마이크 청크 처리 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// 시스템 오디오 청크 처리
|
||||
if (audioSource === 'system' || audioSource === 'both') {
|
||||
promises.push(
|
||||
this._processSystemChunk().catch((err: unknown) => {
|
||||
logger.error(`시스템 오디오 청크 처리 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
Promise.all(promises).catch(() => { /* 개별 에러는 이미 로깅됨 */ })
|
||||
}, CHUNK_INTERVAL_MS)
|
||||
// 트랙 판단 타이머 — 각 트랙은 한 번에 인식 한 건만 돌린다
|
||||
this._tickTimer = setInterval(() => {
|
||||
void this._micTrack?.tick()
|
||||
void this._systemTrack?.tick()
|
||||
}, TICK_INTERVAL_MS)
|
||||
|
||||
this._setState('active')
|
||||
getSoundEffectService().play('recording-start')
|
||||
logger.info(`Live Caption 시작: sessionId=${this._sessionId}`)
|
||||
} catch (err) {
|
||||
if (this._chunkTimer) {
|
||||
clearInterval(this._chunkTimer)
|
||||
this._chunkTimer = null
|
||||
if (this._tickTimer) {
|
||||
clearInterval(this._tickTimer)
|
||||
this._tickTimer = null
|
||||
}
|
||||
this._micTrack = null
|
||||
this._systemTrack = null
|
||||
if (this._audioDataHandler) {
|
||||
const audioCaptureService = getAudioCaptureService()
|
||||
audioCaptureService.off('audio-data', this._audioDataHandler)
|
||||
|
|
@ -228,19 +209,15 @@ class CaptionService extends EventEmitter {
|
|||
this._setState('stopping')
|
||||
|
||||
// 타이머 정리
|
||||
if (this._chunkTimer) {
|
||||
clearInterval(this._chunkTimer)
|
||||
this._chunkTimer = null
|
||||
if (this._tickTimer) {
|
||||
clearInterval(this._tickTimer)
|
||||
this._tickTimer = null
|
||||
}
|
||||
|
||||
// 잔여 오디오 처리
|
||||
try {
|
||||
await this._processChunk()
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`잔여 오디오 처리 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
// 잔여 오디오 처리 — 말하던 중이면 마지막 문장을 확정한다
|
||||
await Promise.all([this._micTrack?.flush(), this._systemTrack?.flush()])
|
||||
this._micTrack = null
|
||||
this._systemTrack = null
|
||||
|
||||
// 마이크 캡처 정리
|
||||
if (this._audioDataHandler) {
|
||||
|
|
@ -252,7 +229,6 @@ class CaptionService extends EventEmitter {
|
|||
|
||||
// 시스템 오디오 캡처 중지 요청
|
||||
this._sendToMainWindow(IPC_CHANNELS.CAPTION.STOP_SYSTEM_AUDIO, {})
|
||||
this._systemAudioBuffers = []
|
||||
|
||||
// 오버레이 숨김
|
||||
hideCaptionOverlay()
|
||||
|
|
@ -262,8 +238,8 @@ class CaptionService extends EventEmitter {
|
|||
|
||||
this._sessionId = null
|
||||
this._sessionStartedAt = null
|
||||
this._audioBuffers = []
|
||||
this._previousContext = ''
|
||||
this._refineQueue = []
|
||||
this._sessionLanguage = null
|
||||
|
||||
this._setState('inactive')
|
||||
getSoundEffectService().play('recording-stop')
|
||||
|
|
@ -293,14 +269,7 @@ class CaptionService extends EventEmitter {
|
|||
|
||||
private _onAudioData(buffer: Buffer): void {
|
||||
if (this._state !== 'active') return
|
||||
this._audioBuffers.push(buffer)
|
||||
|
||||
// RMS 기반 유성음 감지 (환각 방지)
|
||||
this._totalFrameCount++
|
||||
const rms = calculateRMS(buffer)
|
||||
if (rms >= SILENCE_RMS_THRESHOLD) {
|
||||
this._voicedFrameCount++
|
||||
}
|
||||
this._micTrack?.push(buffer)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -309,147 +278,128 @@ class CaptionService extends EventEmitter {
|
|||
*/
|
||||
onSystemAudioData(buffer: Buffer): void {
|
||||
if (this._state !== 'active') return
|
||||
this._systemAudioBuffers.push(buffer)
|
||||
this._systemTrack?.push(buffer)
|
||||
}
|
||||
|
||||
this._systemTotalFrameCount++
|
||||
const rms = calculateRMS(buffer)
|
||||
if (rms >= SILENCE_RMS_THRESHOLD) {
|
||||
this._systemVoicedFrameCount++
|
||||
// ── 내부: 스트리밍 트랙 ──
|
||||
|
||||
private _createTrack(source: 'mic' | 'system', initialContext: string): StreamingCaptionTrack {
|
||||
return new StreamingCaptionTrack(
|
||||
{
|
||||
transcribe: (audio, opts) => this._transcribe(audio, opts),
|
||||
isVoiced: (chunk) => calculateRMS(chunk) >= SILENCE_RMS_THRESHOLD,
|
||||
onPartial: (text, stable) => this._emitPartial(text, stable),
|
||||
onFinal: (text) => this._emitFinal(text, source),
|
||||
onError: (err) => {
|
||||
const d3roErr =
|
||||
err instanceof D3ROError
|
||||
? err
|
||||
: new D3ROError(
|
||||
ErrorCode.CaptionSTTFailed,
|
||||
`자막 전사 실패(${source}): ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
logger.error(d3roErr.message)
|
||||
this.emit('error', d3roErr)
|
||||
},
|
||||
},
|
||||
initialContext,
|
||||
)
|
||||
}
|
||||
|
||||
/** 자막은 항상 로컬 엔진으로 인식한다 — 1초마다 다시 인식하므로 클라우드로는 보낼 수 없다. */
|
||||
private async _transcribe(
|
||||
audio: Buffer,
|
||||
opts: { initialPrompt: string; partial: boolean },
|
||||
): Promise<CaptionTranscription> {
|
||||
const configured = (configGet('sttLanguage') as string | undefined) ?? 'auto'
|
||||
const language = configured === 'auto' ? (this._sessionLanguage ?? 'auto') : configured
|
||||
const result = await getLocalSTTService().transcribe(audio, {
|
||||
language,
|
||||
initialPrompt: opts.initialPrompt,
|
||||
vadFilter: true,
|
||||
partial: opts.partial,
|
||||
})
|
||||
if (!opts.partial && configured === 'auto' && !this._sessionLanguage && result.text.trim() && result.language) {
|
||||
this._sessionLanguage = result.language
|
||||
}
|
||||
return {
|
||||
text: result.text,
|
||||
segments: result.segments.map((segment) => ({
|
||||
text: segment.text,
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 내부: 3초 청크 처리 ──
|
||||
private _emitPartial(text: string, stable: string): void {
|
||||
if (this._state !== 'active') return
|
||||
const delta = { text, stable, isFinal: false }
|
||||
this.emit('delta', delta)
|
||||
sendToCaptionOverlay(IPC_CHANNELS.CAPTION.DELTA, delta)
|
||||
this._sendToMainWindow(IPC_CHANNELS.CAPTION.DELTA, delta)
|
||||
}
|
||||
|
||||
private async _processChunk(): Promise<void> {
|
||||
if (this._audioBuffers.length === 0) return
|
||||
if (this._isProcessingChunk) return
|
||||
private _emitFinal(text: string, source: 'mic' | 'system'): void {
|
||||
const segment: CaptionSegment = {
|
||||
id: crypto.randomUUID(),
|
||||
text,
|
||||
timestamp: Date.now(),
|
||||
isFinal: true,
|
||||
}
|
||||
this._segments.push(segment)
|
||||
this.emit('segment', segment)
|
||||
sendToCaptionOverlay(IPC_CHANNELS.CAPTION.SEGMENT, segment)
|
||||
this._sendToMainWindow(IPC_CHANNELS.CAPTION.SEGMENT, segment)
|
||||
logger.debug(`자막 확정(${source}): "${segment.text.substring(0, 50)}"`)
|
||||
this._enqueueRefine(segment)
|
||||
}
|
||||
|
||||
this._isProcessingChunk = true
|
||||
// ── 내부: 문맥 다듬기 ──
|
||||
|
||||
// VAD 카운터 리셋 (로깅용)
|
||||
const voicedRatio = this._totalFrameCount > 0
|
||||
? this._voicedFrameCount / this._totalFrameCount
|
||||
: 0
|
||||
this._voicedFrameCount = 0
|
||||
this._totalFrameCount = 0
|
||||
logger.debug(`청크 처리: 유성음 비율 ${(voicedRatio * 100).toFixed(1)}%`)
|
||||
|
||||
// VAD 필터링은 faster-whisper 사이드카에서 처리 (vadFilter: true)
|
||||
// SoX의 마이크 캡처 레벨이 매우 낮아 로컬 RMS 게이트는 신뢰 불가
|
||||
private _enqueueRefine(segment: CaptionSegment): void {
|
||||
if (configGet('captionRefineEnabled') === false) return
|
||||
if (!getLocalLLMService().isAvailable()) return
|
||||
this._refineQueue.push(segment)
|
||||
// 모델이 밀리면 오래된 줄은 포기한다 — 이미 지나간 자막을 늦게 고쳐 봐야 소용없다.
|
||||
while (this._refineQueue.length > REFINE_QUEUE_LIMIT) this._refineQueue.shift()
|
||||
if (!this._refining) void this._drainRefineQueue()
|
||||
}
|
||||
|
||||
private async _drainRefineQueue(): Promise<void> {
|
||||
this._refining = true
|
||||
try {
|
||||
const merged = Buffer.concat(this._audioBuffers)
|
||||
this._audioBuffers = []
|
||||
|
||||
// 최소 오디오 크기 확인 (100ms 분량 이상)
|
||||
const minBytes = 16000 * 2 * 0.1 // 100ms @ 16kHz 16bit mono
|
||||
if (merged.length < minBytes) {
|
||||
return
|
||||
for (let next = this._refineQueue.shift(); next; next = this._refineQueue.shift()) {
|
||||
await this._refine(next)
|
||||
}
|
||||
|
||||
const sttService = getSTTManager()
|
||||
const language = configGet('sttLanguage') as string | undefined
|
||||
|
||||
const result = await sttService.transcribe(merged, {
|
||||
language: language ?? 'auto',
|
||||
initialPrompt: this._previousContext,
|
||||
vadFilter: true,
|
||||
})
|
||||
|
||||
if (!result.text || result.text.trim().length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const segment: CaptionSegment = {
|
||||
id: crypto.randomUUID(),
|
||||
text: result.text.trim(),
|
||||
timestamp: Date.now(),
|
||||
isFinal: true,
|
||||
}
|
||||
|
||||
this._segments.push(segment)
|
||||
|
||||
// 다음 청크를 위한 컨텍스트 업데이트 (연속성 유지)
|
||||
this._previousContext = result.text.trim().slice(-CONTEXT_WINDOW_SIZE)
|
||||
|
||||
// 이벤트 emit
|
||||
this.emit('segment', segment)
|
||||
|
||||
// 오버레이 윈도우에 전송
|
||||
sendToCaptionOverlay(IPC_CHANNELS.CAPTION.SEGMENT, segment)
|
||||
|
||||
// 메인 윈도우에 전송 (Dashboard 등에서 사용)
|
||||
this._sendToMainWindow(IPC_CHANNELS.CAPTION.SEGMENT, segment)
|
||||
|
||||
logger.debug(`자막 세그먼트: "${segment.text.substring(0, 50)}"`)
|
||||
} catch (err) {
|
||||
const d3roErr =
|
||||
err instanceof D3ROError
|
||||
? err
|
||||
: new D3ROError(
|
||||
ErrorCode.CaptionSTTFailed,
|
||||
`자막 전사 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
logger.error(`자막 전사 에러: ${d3roErr.message}`)
|
||||
this.emit('error', d3roErr)
|
||||
} finally {
|
||||
this._isProcessingChunk = false
|
||||
this._refining = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 내부: 시스템 오디오 청크 처리 ──
|
||||
|
||||
private async _processSystemChunk(): Promise<void> {
|
||||
if (this._systemAudioBuffers.length === 0) return
|
||||
|
||||
// VAD 게이트
|
||||
const voicedRatio = this._systemTotalFrameCount > 0
|
||||
? this._systemVoicedFrameCount / this._systemTotalFrameCount
|
||||
: 0
|
||||
this._systemVoicedFrameCount = 0
|
||||
this._systemTotalFrameCount = 0
|
||||
|
||||
if (voicedRatio < VOICED_FRAME_RATIO) {
|
||||
this._systemAudioBuffers = []
|
||||
logger.debug(`시스템 오디오 청크 스킵: 유성음 비율 ${(voicedRatio * 100).toFixed(1)}%`)
|
||||
return
|
||||
}
|
||||
|
||||
const merged = Buffer.concat(this._systemAudioBuffers)
|
||||
this._systemAudioBuffers = []
|
||||
|
||||
const minBytes = 16000 * 2 * 0.1
|
||||
if (merged.length < minBytes) return
|
||||
|
||||
private async _refine(segment: CaptionSegment): Promise<void> {
|
||||
const index = this._segments.findIndex((item) => item.id === segment.id)
|
||||
if (index === -1) return
|
||||
const previous = this._segments.slice(Math.max(0, index - 2), index).map((item) => item.text)
|
||||
const { systemPrompt, text } = buildCaptionRefinePrompt({ text: segment.text, previous })
|
||||
try {
|
||||
const sttService = getLocalSTTService()
|
||||
const language = configGet('sttLanguage') as string | undefined
|
||||
|
||||
const result = await sttService.transcribe(merged, {
|
||||
language: language ?? 'auto',
|
||||
initialPrompt: this._previousContext,
|
||||
vadFilter: true,
|
||||
const result = await getLocalLLMService().generate(text, {
|
||||
systemPrompt,
|
||||
temperature: 0,
|
||||
maxTokens: Math.min(256, segment.text.length * 2 + 32),
|
||||
timeoutMs: REFINE_TIMEOUT_MS,
|
||||
keepAlive: REFINE_KEEP_ALIVE,
|
||||
})
|
||||
|
||||
if (!result.text || result.text.trim().length === 0) return
|
||||
|
||||
const segment: CaptionSegment = {
|
||||
id: crypto.randomUUID(),
|
||||
text: result.text.trim(),
|
||||
timestamp: Date.now(),
|
||||
isFinal: true,
|
||||
}
|
||||
|
||||
this._segments.push(segment)
|
||||
this._previousContext = result.text.trim().slice(-CONTEXT_WINDOW_SIZE)
|
||||
|
||||
this.emit('segment', segment)
|
||||
sendToCaptionOverlay(IPC_CHANNELS.CAPTION.SEGMENT, segment)
|
||||
this._sendToMainWindow(IPC_CHANNELS.CAPTION.SEGMENT, segment)
|
||||
|
||||
logger.debug(`시스템 자막: "${segment.text.substring(0, 50)}"`)
|
||||
const refined = acceptCaptionRefinement(segment.text, result.text)
|
||||
if (!refined) return
|
||||
const current = this._segments.find((item) => item.id === segment.id)
|
||||
if (!current) return
|
||||
current.text = refined
|
||||
const update = { id: segment.id, text: refined }
|
||||
sendToCaptionOverlay(IPC_CHANNELS.CAPTION.SEGMENT_UPDATED, update)
|
||||
this._sendToMainWindow(IPC_CHANNELS.CAPTION.SEGMENT_UPDATED, update)
|
||||
} catch (err) {
|
||||
logger.error(`시스템 오디오 전사 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
logger.debug(`자막 다듬기 건너뜀: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -186,6 +186,7 @@ const CONFIG_DEFAULTS: AppConfig = {
|
|||
activeChainId: null,
|
||||
captionAudioSource: 'mic',
|
||||
captionOverlayPosition: null,
|
||||
captionRefineEnabled: true,
|
||||
updateChannel: 'latest',
|
||||
updateDeviceId: '',
|
||||
skippedUpdateVersion: null,
|
||||
|
|
|
|||
160
apps/desktop/src/main/services/caption/StreamingCaptionTrack.ts
Normal file
160
apps/desktop/src/main/services/caption/StreamingCaptionTrack.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// src/main/services/caption/StreamingCaptionTrack.ts
|
||||
//
|
||||
// 오디오 한 줄기(마이크 또는 시스템 소리)의 실시간 자막 트랙.
|
||||
// 판단은 core `decideCaptionTick` 이 하고, 이 클래스는 오디오 버퍼와 인식 호출만 맡는다.
|
||||
|
||||
import {
|
||||
CAPTION_STREAMING_DEFAULTS,
|
||||
agreedCaptionPrefix,
|
||||
decideCaptionTick,
|
||||
planForcedCommit,
|
||||
splitCaptionWords,
|
||||
type CaptionTimedSegment
|
||||
} from '@d3ro/core/caption-streaming'
|
||||
|
||||
/** 16kHz · 16bit · mono PCM → 1ms = 32 bytes */
|
||||
const BYTES_PER_MS = 32
|
||||
|
||||
export interface CaptionTranscription {
|
||||
text: string
|
||||
segments: CaptionTimedSegment[]
|
||||
}
|
||||
|
||||
export interface StreamingCaptionTrackDeps {
|
||||
transcribe: (audio: Buffer, opts: { initialPrompt: string; partial: boolean }) => Promise<CaptionTranscription>
|
||||
/** 이 조각에 소리가 있는가 (RMS 등) */
|
||||
isVoiced: (chunk: Buffer) => boolean
|
||||
/** 확정되지 않은 현재 문장 — stable 은 두 번 연속 같게 나온 앞부분 */
|
||||
onPartial: (text: string, stable: string) => void
|
||||
onFinal: (text: string) => void
|
||||
onError: (error: unknown) => void
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export class StreamingCaptionTrack {
|
||||
private _buffer: Buffer = Buffer.alloc(0)
|
||||
private _busy = false
|
||||
private _lastVoiceAt = 0
|
||||
private _lastPartialAt = 0
|
||||
private _previousWords: string[] = []
|
||||
private _context = ''
|
||||
/** 버퍼를 자를 때마다 바뀐다 — 자르기 전에 시작한 인식 결과는 버린다 */
|
||||
private _epoch = 0
|
||||
private readonly _now: () => number
|
||||
|
||||
constructor(private readonly _deps: StreamingCaptionTrackDeps, initialContext = '') {
|
||||
this._now = _deps.now ?? Date.now
|
||||
this._context = initialContext
|
||||
}
|
||||
|
||||
get bufferMs(): number {
|
||||
return this._buffer.length / BYTES_PER_MS
|
||||
}
|
||||
|
||||
push(chunk: Buffer): void {
|
||||
this._buffer = this._buffer.length === 0 ? chunk : Buffer.concat([this._buffer, chunk])
|
||||
if (this._deps.isVoiced(chunk)) this._lastVoiceAt = this._now()
|
||||
}
|
||||
|
||||
/** 주기적으로 부른다. 한 번에 인식 한 건만 돈다. */
|
||||
async tick(): Promise<void> {
|
||||
const now = this._now()
|
||||
const action = decideCaptionTick({
|
||||
bufferMs: this.bufferMs,
|
||||
hasVoice: this._lastVoiceAt > 0,
|
||||
sinceVoiceMs: this._lastVoiceAt > 0 ? now - this._lastVoiceAt : Number.MAX_SAFE_INTEGER,
|
||||
sincePartialMs: now - this._lastPartialAt,
|
||||
busy: this._busy
|
||||
})
|
||||
|
||||
if (action === 'trim-idle') {
|
||||
this._keepTail(CAPTION_STREAMING_DEFAULTS.idleKeepMs)
|
||||
return
|
||||
}
|
||||
if (action === 'partial') return this._run(() => this._partial())
|
||||
if (action === 'finalize') return this._run(() => this._finalize())
|
||||
if (action === 'force-commit') {
|
||||
// 말이 끊기지 않고 너무 길어지면(배경 음악 등으로 무음이 없는 경우 포함) 앞쪽만 확정한다.
|
||||
// 확정할 구간 경계가 끝내 안 나오면 두 배 길이에서 통째로 확정한다.
|
||||
const hardLimit = this.bufferMs >= CAPTION_STREAMING_DEFAULTS.maxBufferMs * 2
|
||||
return this._run(() => (hardLimit ? this._finalize() : this._forceCommit()))
|
||||
}
|
||||
}
|
||||
|
||||
/** 세션 종료 — 남은 말을 확정한다. */
|
||||
async flush(): Promise<void> {
|
||||
if (this._lastVoiceAt === 0 || this.bufferMs < CAPTION_STREAMING_DEFAULTS.minAudioMs) return
|
||||
while (this._busy) await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
await this._run(() => this._finalize())
|
||||
}
|
||||
|
||||
private async _run(job: () => Promise<void>): Promise<void> {
|
||||
this._busy = true
|
||||
try {
|
||||
await job()
|
||||
} catch (error) {
|
||||
this._deps.onError(error)
|
||||
} finally {
|
||||
this._busy = false
|
||||
}
|
||||
}
|
||||
|
||||
private async _partial(): Promise<void> {
|
||||
this._lastPartialAt = this._now()
|
||||
const epoch = this._epoch
|
||||
const result = await this._deps.transcribe(this._buffer, { initialPrompt: this._context, partial: true })
|
||||
if (epoch !== this._epoch) return
|
||||
const words = splitCaptionWords(result.text)
|
||||
if (words.length === 0) return
|
||||
const stable = agreedCaptionPrefix(this._previousWords, words)
|
||||
this._previousWords = words
|
||||
this._deps.onPartial(words.join(' '), stable.join(' '))
|
||||
}
|
||||
|
||||
private async _finalize(): Promise<void> {
|
||||
const snapshotBytes = this._buffer.length
|
||||
const takenAt = this._now()
|
||||
const result = await this._deps.transcribe(this._buffer.subarray(0, snapshotBytes), {
|
||||
initialPrompt: this._context,
|
||||
partial: false
|
||||
})
|
||||
this._cut(snapshotBytes)
|
||||
// 인식하는 동안 들어온 오디오에 소리가 있었으면 그 소리는 다음 문장이다.
|
||||
if (this._lastVoiceAt <= takenAt) this._lastVoiceAt = 0
|
||||
this._emitFinal(result.text)
|
||||
}
|
||||
|
||||
private async _forceCommit(): Promise<void> {
|
||||
const snapshotBytes = this._buffer.length
|
||||
const result = await this._deps.transcribe(this._buffer.subarray(0, snapshotBytes), {
|
||||
initialPrompt: this._context,
|
||||
partial: false
|
||||
})
|
||||
const plan = planForcedCommit(
|
||||
result.segments,
|
||||
snapshotBytes / BYTES_PER_MS / 1000,
|
||||
CAPTION_STREAMING_DEFAULTS.keepTailMs / 1000
|
||||
)
|
||||
if (!plan) return
|
||||
this._cut(Math.min(snapshotBytes, Math.round(plan.cutAtSec * 1000) * BYTES_PER_MS))
|
||||
this._emitFinal(plan.text)
|
||||
}
|
||||
|
||||
private _emitFinal(raw: string): void {
|
||||
const text = raw.trim()
|
||||
this._previousWords = []
|
||||
if (!text) return
|
||||
this._context = `${this._context} ${text}`.trim().slice(-CAPTION_STREAMING_DEFAULTS.contextChars)
|
||||
this._deps.onFinal(text)
|
||||
}
|
||||
|
||||
private _cut(bytes: number): void {
|
||||
this._buffer = this._buffer.subarray(bytes - (bytes % 2))
|
||||
this._epoch += 1
|
||||
}
|
||||
|
||||
private _keepTail(ms: number): void {
|
||||
const keep = ms * BYTES_PER_MS
|
||||
if (this._buffer.length > keep) this._buffer = this._buffer.subarray(this._buffer.length - keep)
|
||||
}
|
||||
}
|
||||
|
|
@ -253,3 +253,32 @@ export function buildSuggestionPrompt(input: SuggestionPromptInput): {
|
|||
}
|
||||
|
||||
export { SUGGESTION_SYSTEM_PROMPT }
|
||||
|
||||
/**
|
||||
* 실시간 자막 문맥 다듬기 — 확정된 자막 한 줄을 앞 문맥에 맞게 고친다.
|
||||
*
|
||||
* 받아 적기의 오류(띄어쓰기·문장부호·잘못 들은 단어)만 고친다. 요약·의역·추가는
|
||||
* 금지이며, 호출자는 `acceptCaptionRefinement` 로 변화량을 다시 검사한다.
|
||||
*/
|
||||
const CAPTION_REFINE_SYSTEM_PROMPT = `실시간 음성 인식으로 받아 적은 자막 한 줄을 다듬습니다.
|
||||
|
||||
규칙:
|
||||
- 띄어쓰기, 문장부호, 앞 문맥으로 볼 때 명백하게 잘못 들은 단어만 고치세요.
|
||||
- 뜻을 바꾸거나, 요약하거나, 말을 더하거나 빼지 마세요.
|
||||
- 원문과 같은 언어, 같은 말투(존댓말/반말, 구어체)를 유지하세요.
|
||||
- 고친 문장 한 줄만 출력하세요. 설명, 따옴표, 번호를 붙이지 마세요.
|
||||
- 고칠 것이 없으면 원문을 그대로 출력하세요.`
|
||||
|
||||
export function buildCaptionRefinePrompt(input: {
|
||||
text: string
|
||||
previous: readonly string[]
|
||||
}): { systemPrompt: string; text: string } {
|
||||
const sections: string[] = []
|
||||
const previous = input.previous.filter((line) => line.trim().length > 0).slice(-2)
|
||||
if (previous.length > 0) sections.push('[앞 문맥]', ...previous, '')
|
||||
sections.push('[다듬을 자막]', input.text.trim())
|
||||
return {
|
||||
systemPrompt: `${SUGGESTION_NO_THINK_PREFIX}\n${CAPTION_REFINE_SYSTEM_PROMPT}`,
|
||||
text: sections.join('\n')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -520,7 +520,9 @@ const electronAPI = {
|
|||
getConfig: () => invoke<CaptionConfig>(IPC_CHANNELS.CAPTION.GET_CONFIG),
|
||||
onSegment: (cb: (data: CaptionSegment) => void): Unsubscribe =>
|
||||
on(IPC_CHANNELS.CAPTION.SEGMENT, cb),
|
||||
onDelta: (cb: (data: { text: string }) => void): Unsubscribe =>
|
||||
onSegmentUpdated: (cb: (data: { id: string; text: string }) => void): Unsubscribe =>
|
||||
on(IPC_CHANNELS.CAPTION.SEGMENT_UPDATED, cb),
|
||||
onDelta: (cb: (data: { text: string; stable: string; isFinal: boolean }) => void): Unsubscribe =>
|
||||
on(IPC_CHANNELS.CAPTION.DELTA, cb),
|
||||
onStateChanged: (cb: (data: { state: CaptionState }) => void): Unsubscribe =>
|
||||
on(IPC_CHANNELS.CAPTION.STATE_CHANGED, cb),
|
||||
|
|
|
|||
|
|
@ -481,6 +481,27 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
|||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControlLabel
|
||||
sx={{ mr: 0, alignItems: 'flex-start' }}
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.captionRefineEnabled !== false}
|
||||
onChange={(e) => updateConfig('captionRefineEnabled', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box sx={{ pt: '2px' }}>
|
||||
<Typography sx={{ fontSize: d3roTypo.body.size, color: d3roPalette.text.primary }}>
|
||||
{t('settings.captionRefine')}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: d3roTypo.meta.size, color: d3roPalette.text.secondary }}>
|
||||
{t('settings.captionRefine.desc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@
|
|||
return
|
||||
}
|
||||
|
||||
// 첫 중간 결과가 오면 준비 안내를 걷는다 — 이제 실제로 듣고 있다.
|
||||
removeStatusLine()
|
||||
|
||||
if (!deltaLine) {
|
||||
deltaLine = document.createElement('div')
|
||||
deltaLine.className = 'caption-line delta'
|
||||
|
|
@ -82,7 +85,35 @@
|
|||
linesContainer.appendChild(deltaLine)
|
||||
}
|
||||
|
||||
deltaLine.textContent = data.text
|
||||
// 두 번 연속 같게 들린 앞부분은 또렷하게, 아직 바뀔 수 있는 뒷부분은 흐리게.
|
||||
var text = data.text || ''
|
||||
var stable = data.stable || ''
|
||||
if (stable && text.indexOf(stable) !== 0) stable = ''
|
||||
deltaLine.textContent = ''
|
||||
if (stable) deltaLine.appendChild(document.createTextNode(stable))
|
||||
var rest = text.slice(stable.length)
|
||||
if (rest) {
|
||||
var tentative = document.createElement('span')
|
||||
tentative.className = 'tentative'
|
||||
tentative.textContent = rest
|
||||
deltaLine.appendChild(tentative)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문맥 다듬기로 고쳐진 줄을 제자리에서 바꾼다.
|
||||
* @param {{id: string, text: string}} update
|
||||
*/
|
||||
function updateSegment(update) {
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (lines[i].id !== update.id) continue
|
||||
lines[i].el.textContent = update.text
|
||||
lines[i].el.classList.remove('refined')
|
||||
// 다시 그려야 애니메이션이 재시작된다
|
||||
void lines[i].el.offsetWidth
|
||||
lines[i].el.classList.add('refined')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -163,6 +194,11 @@
|
|||
updateDelta(data)
|
||||
})
|
||||
|
||||
// 문맥 다듬기 결과
|
||||
window.popupAPI.on('caption:segmentUpdated', function (update) {
|
||||
updateSegment(update)
|
||||
})
|
||||
|
||||
// 설정 업데이트
|
||||
window.popupAPI.on('caption:config', function (newConfig) {
|
||||
applyConfig(newConfig)
|
||||
|
|
|
|||
|
|
@ -144,6 +144,21 @@ html, body {
|
|||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* 아직 바뀔 수 있는 뒷부분 (중간 결과) */
|
||||
.caption-line .tentative {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 문맥 다듬기로 고쳐진 줄 — 잠깐 강조 */
|
||||
.caption-line.refined {
|
||||
animation: refined-flash 0.9s ease-out;
|
||||
}
|
||||
|
||||
@keyframes refined-flash {
|
||||
0% { border-color: var(--d3-accent-main); box-shadow: 0 0 0 2px var(--d3-accent-glow-dim); }
|
||||
100% { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); }
|
||||
}
|
||||
|
||||
/* 로딩 중 표시 — 점멸 애니메이션 */
|
||||
.caption-line.loading {
|
||||
opacity: 0.6;
|
||||
|
|
|
|||
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