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:
Yun Chan 2026-09-24 20:22:21 +09:00
parent db8d9448a3
commit 39b8e7448e
28 changed files with 827 additions and 217 deletions

View file

@ -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)}`)
}
}

View file

@ -186,6 +186,7 @@ const CONFIG_DEFAULTS: AppConfig = {
activeChainId: null,
captionAudioSource: 'mic',
captionOverlayPosition: null,
captionRefineEnabled: true,
updateChannel: 'latest',
updateDeviceId: '',
skippedUpdateVersion: null,

View 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)
}
}

View file

@ -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')
}
}