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

@ -38,6 +38,10 @@
"types": "./src/input-intelligence.ts",
"default": "./src/input-intelligence.ts"
},
"./caption-streaming": {
"types": "./src/caption-streaming.ts",
"default": "./src/caption-streaming.ts"
},
"./entitlement": {
"types": "./src/entitlement.ts",
"default": "./src/entitlement.ts"

View file

@ -0,0 +1,152 @@
// packages/core/src/caption-streaming.ts
//
// 실시간 자막 스트리밍의 순수 판단 로직 — 오디오/모델/IPC 없이 테스트한다.
//
// 방식 (whisper_streaming 계열, Macháček et al. 2023 의 LocalAgreement 를 단순화):
// - 확정되지 않은 오디오 구간 전체를 짧은 주기로 다시 인식해 "지금까지 들은 문장" 을
// 중간 결과로 내보낸다 → 단어가 실시간으로 갱신된다.
// - 말이 멈추면(무음) 그 구간을 정밀하게 한 번 더 인식해 문장을 확정한다.
// - 멈추지 않고 길어지면 모델이 준 구간 시간으로 앞쪽만 확정하고 오디오를 잘라낸다.
// - 소리가 없는 오디오는 오래 들고 있지 않는다 — Whisper 는 무음에서 문장을 지어낸다.
export const CAPTION_STREAMING_DEFAULTS = {
/** 중간 결과 재인식 주기 */
partialIntervalMs: 1000,
/** 인식을 시작할 최소 오디오 길이 */
minAudioMs: 800,
/** 말이 이만큼 멈추면 문장을 확정한다 */
silenceCommitMs: 700,
/** 멈추지 않는 말이 이 길이를 넘으면 앞쪽을 강제로 확정한다 */
maxBufferMs: 12000,
/** 강제 확정 때 뒤쪽은 이만큼 남긴다 (끝 구간은 아직 바뀔 수 있다) */
keepTailMs: 1500,
/** 소리가 없을 때 들고 있을 오디오 */
idleKeepMs: 1500,
/** 다음 인식에 넘기는 앞 문맥 길이 (자) */
contextChars: 200,
/** 문맥 다듬기 결과가 원문에서 이 비율 이상 바뀌면 버린다 */
refineMaxChangeRatio: 0.35
} as const
export type CaptionTickAction = 'finalize' | 'force-commit' | 'partial' | 'trim-idle' | 'wait'
export interface CaptionTickInput {
/** 확정되지 않은 오디오 길이 */
bufferMs: number
/** 이 구간에 소리가 있었는가 */
hasVoice: boolean
/** 마지막 소리 이후 지난 시간 */
sinceVoiceMs: number
/** 마지막 중간 인식 이후 지난 시간 */
sincePartialMs: number
/** 인식이 진행 중인가 (한 트랙에 한 건만) */
busy: boolean
}
/** 매 틱마다 무엇을 할지 정한다. */
export function decideCaptionTick(
input: CaptionTickInput,
opts: Partial<typeof CAPTION_STREAMING_DEFAULTS> = {}
): CaptionTickAction {
const o = { ...CAPTION_STREAMING_DEFAULTS, ...opts }
if (input.busy) return 'wait'
if (!input.hasVoice) return input.bufferMs > o.idleKeepMs ? 'trim-idle' : 'wait'
if (input.bufferMs < o.minAudioMs) return 'wait'
if (input.sinceVoiceMs >= o.silenceCommitMs) return 'finalize'
if (input.bufferMs >= o.maxBufferMs) return 'force-commit'
if (input.sincePartialMs >= o.partialIntervalMs) return 'partial'
return 'wait'
}
/** 공백 기준 단어 (한국어도 어절 단위로 띄어 쓴다). */
export function splitCaptionWords(text: string): string[] {
return text.trim().split(/\s+/u).filter((word) => word.length > 0)
}
/**
* 연속된 두 가설이 앞에서부터 합의한 단어 (LocalAgreement-2).
*
* 두 번 연속 같게 나온 앞부분은 이후 오디오가 더 와도 거의 바뀌지 않는다 —
* 중간 결과에서 이 부분은 흔들리지 않게 보여 줄 수 있다.
*/
export function agreedCaptionPrefix(previous: readonly string[], current: readonly string[]): string[] {
const agreed: string[] = []
const length = Math.min(previous.length, current.length)
for (let i = 0; i < length; i += 1) {
if (previous[i] !== current[i]) break
agreed.push(current[i])
}
return agreed
}
export interface CaptionTimedSegment {
text: string
/** 초 */
start: number
/** 초 */
end: number
}
export interface ForcedCommitPlan {
/** 확정할 텍스트 */
text: string
/** 이 시점(초)까지의 오디오를 잘라낸다 */
cutAtSec: number
}
/**
* 길어진 구간에서 앞쪽을 확정할 계획.
*
* 끝에서 keepTail 안쪽에 걸친 구간은 아직 문장이 이어질 수 있으니 남긴다. 확정할
* 구간이 없으면 null — 호출자는 다음 틱에 다시 본다.
*/
export function planForcedCommit(
segments: readonly CaptionTimedSegment[],
bufferSec: number,
keepTailSec: number
): ForcedCommitPlan | null {
const limit = bufferSec - keepTailSec
const committed = segments.filter((segment) => segment.end <= limit && segment.text.trim().length > 0)
if (committed.length === 0) return null
return {
text: committed.map((segment) => segment.text.trim()).join(' '),
cutAtSec: committed[committed.length - 1].end
}
}
/** 문자 단위 편집 거리 비율 (0 = 같음, 1 = 전혀 다름). 공백은 무시한다. */
export function captionChangeRatio(original: string, revised: string): number {
const a = [...original.replace(/\s+/gu, '')]
const b = [...revised.replace(/\s+/gu, '')]
if (a.length === 0 && b.length === 0) return 0
let prev = Array.from({ length: b.length + 1 }, (_, j) => j)
for (let i = 1; i <= a.length; i += 1) {
const row = [i]
for (let j = 1; j <= b.length; j += 1) {
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
}
prev = row
}
return prev[b.length] / Math.max(a.length, b.length)
}
/**
* 모델이 다듬은 문장을 쓸지 정한다.
*
* 자막 다듬기는 띄어쓰기·문장부호·명백한 오인식만 고쳐야 한다. 많이 바뀌었다면
* 모델이 요약하거나 지어낸 것이다 — 원문을 지킨다(null).
*/
export function acceptCaptionRefinement(
original: string,
refined: string,
maxChangeRatio: number = CAPTION_STREAMING_DEFAULTS.refineMaxChangeRatio
): string | null {
const line = refined
.split(/\r?\n/u)
.map((part) => part.trim())
.find((part) => part.length > 0)
if (!line) return null
const cleaned = line.replace(/^["'“”‘’「」『』]+|["'“”‘’「」『』]+$/gu, '').trim()
if (!cleaned || cleaned === original.trim()) return null
return captionChangeRatio(original, cleaned) <= maxChangeRatio ? cleaned : null
}

View file

@ -249,6 +249,8 @@ export const IPC_CHANNELS = {
SYSTEM_AUDIO_DATA: 'caption:systemAudioData',
// Main → Renderer events
SEGMENT: 'caption:segment',
/** 확정된 줄을 문맥 다듬기로 고친 결과 { id, text } */
SEGMENT_UPDATED: 'caption:segmentUpdated',
DELTA: 'caption:delta',
STATE_CHANGED: 'caption:stateChanged',
SESSION_SAVED: 'caption:sessionSaved',

View file

@ -497,6 +497,8 @@ export interface AppConfig {
captionAudioSource: import('@d3ro/core/types').CaptionAudioSource
/** 사용자가 끌어다 놓은 자막 창 위치 (스크린 좌표). null 이면 화면 아래 가운데 */
captionOverlayPosition: { x: number; y: number } | null
/** 확정된 자막 줄을 로컬 LLM으로 문맥에 맞게 다듬는다 */
captionRefineEnabled: boolean
/** Auto-update 채널 (latest=stable / beta / alpha). UpdateService */
updateChannel: 'latest' | 'beta' | 'alpha'
/** staged rollout용 영구 기기 식별자 (개인정보 아님, 최초 실행 시 생성) */

View file

@ -522,5 +522,7 @@
"input.feedback.recommendationFailed": "{{app}} konnte nicht zu Ausschlüssen hinzugefügt werden.",
"popup.caption.loading": "Sprachmodell wird vorbereitet…",
"popup.caption.waiting": "Hört zu… der erste Untertitel kann einige Sekunden dauern",
"popup.caption.dragHint": "Ziehen zum Verschieben · Doppelklick setzt zurück"
"popup.caption.dragHint": "Ziehen zum Verschieben · Doppelklick setzt zurück",
"settings.captionRefine": "Untertitel im Kontext glätten",
"settings.captionRefine.desc": "Die lokale KI korrigiert Leerzeichen, Satzzeichen und falsch verstandene Wörter anhand der umgebenden Zeilen."
}

View file

@ -1903,5 +1903,7 @@
"input.feedback.excludedFailed": "Exclusions could not be saved.",
"input.feedback.recommendationFailed": "Could not add {{app}} to exclusions.",
"popup.caption.waiting": "Listening… the first caption can take a few seconds",
"popup.caption.dragHint": "Drag to move · double-click to reset"
"popup.caption.dragHint": "Drag to move · double-click to reset",
"settings.captionRefine": "Polish captions with context",
"settings.captionRefine.desc": "The local AI fixes spacing, punctuation and misheard words in finished captions using the surrounding lines."
}

View file

@ -522,5 +522,7 @@
"input.feedback.recommendationFailed": "No se pudo añadir {{app}} a las exclusiones.",
"popup.caption.loading": "Preparando el modelo de voz…",
"popup.caption.waiting": "Escuchando… el primer subtítulo puede tardar unos segundos",
"popup.caption.dragHint": "Arrastra para mover · doble clic para restablecer"
"popup.caption.dragHint": "Arrastra para mover · doble clic para restablecer",
"settings.captionRefine": "Pulir subtítulos con contexto",
"settings.captionRefine.desc": "La IA local corrige espacios, puntuación y palabras mal oídas usando las líneas cercanas."
}

View file

@ -522,5 +522,7 @@
"input.feedback.recommendationFailed": "Impossible d’ajouter {{app}} aux exclusions.",
"popup.caption.loading": "Préparation du modèle vocal…",
"popup.caption.waiting": "Écoute… le premier sous-titre peut prendre quelques secondes",
"popup.caption.dragHint": "Glisser pour déplacer · double-clic pour réinitialiser"
"popup.caption.dragHint": "Glisser pour déplacer · double-clic pour réinitialiser",
"settings.captionRefine": "Affiner les sous-titres selon le contexte",
"settings.captionRefine.desc": "L’IA locale corrige les espaces, la ponctuation et les mots mal entendus d’après les lignes voisines."
}

View file

@ -522,5 +522,7 @@
"input.feedback.recommendationFailed": "{{app}} を除外に追加できませんでした。",
"popup.caption.loading": "音声モデルを準備中…",
"popup.caption.waiting": "聞き取り中… 最初の字幕まで数秒かかることがあります",
"popup.caption.dragHint": "ドラッグで移動 · ダブルクリックで元の位置"
"popup.caption.dragHint": "ドラッグで移動 · ダブルクリックで元の位置",
"settings.captionRefine": "文脈で字幕を整える",
"settings.captionRefine.desc": "確定した字幕の区切り・句読点・聞き間違いを、ローカルAIが前後の文脈に合わせて直します。"
}

View file

@ -1910,5 +1910,7 @@
"input.feedback.excludedFailed": "제외 목록을 저장하지 못했습니다.",
"input.feedback.recommendationFailed": "{{app}}을(를) 제외 목록에 추가하지 못했습니다.",
"popup.caption.waiting": "듣는 중… 첫 자막까지 몇 초 걸릴 수 있어요",
"popup.caption.dragHint": "끌어서 이동 · 더블클릭하면 원위치"
"popup.caption.dragHint": "끌어서 이동 · 더블클릭하면 원위치",
"settings.captionRefine": "자막 문맥 다듬기",
"settings.captionRefine.desc": "확정된 자막을 로컬 AI가 앞뒤 문맥에 맞게 띄어쓰기·문장부호·잘못 들은 단어를 고칩니다."
}

View file

@ -522,5 +522,7 @@
"input.feedback.recommendationFailed": "Não foi possível adicionar {{app}} às exclusões.",
"popup.caption.loading": "Preparando o modelo de voz…",
"popup.caption.waiting": "Ouvindo… a primeira legenda pode levar alguns segundos",
"popup.caption.dragHint": "Arraste para mover · clique duplo para restaurar"
"popup.caption.dragHint": "Arraste para mover · clique duplo para restaurar",
"settings.captionRefine": "Refinar legendas pelo contexto",
"settings.captionRefine.desc": "A IA local corrige espaços, pontuação e palavras mal ouvidas usando as linhas próximas."
}

View file

@ -522,5 +522,7 @@
"input.feedback.recommendationFailed": "Не удалось добавить {{app}} в исключения.",
"popup.caption.loading": "Подготовка речевой модели…",
"popup.caption.waiting": "Слушаю… первый субтитр может появиться через несколько секунд",
"popup.caption.dragHint": "Перетащите, чтобы переместить · двойной щелчок — сброс"
"popup.caption.dragHint": "Перетащите, чтобы переместить · двойной щелчок — сброс",
"settings.captionRefine": "Уточнять субтитры по контексту",
"settings.captionRefine.desc": "Локальный ИИ исправляет пробелы, пунктуацию и ослышки в готовых субтитрах по соседним строкам."
}

View file

@ -522,5 +522,7 @@
"input.feedback.recommendationFailed": "ไม่สามารถเพิ่ม {{app}} ในรายการยกเว้นได้",
"popup.caption.loading": "กำลังเตรียมโมเดลเสียง…",
"popup.caption.waiting": "กำลังฟัง… คำบรรยายแรกอาจใช้เวลาสักครู่",
"popup.caption.dragHint": "ลากเพื่อย้าย · ดับเบิลคลิกเพื่อรีเซ็ต"
"popup.caption.dragHint": "ลากเพื่อย้าย · ดับเบิลคลิกเพื่อรีเซ็ต",
"settings.captionRefine": "ขัดเกลาคำบรรยายตามบริบท",
"settings.captionRefine.desc": "AI ในเครื่องจะแก้เว้นวรรค เครื่องหมายวรรคตอน และคำที่ได้ยินผิด โดยดูจากบรรทัดรอบข้าง"
}

View file

@ -522,5 +522,7 @@
"input.feedback.recommendationFailed": "Không thể thêm {{app}} vào danh sách loại trừ.",
"popup.caption.loading": "Đang chuẩn bị mô hình giọng nói…",
"popup.caption.waiting": "Đang nghe… phụ đề đầu tiên có thể mất vài giây",
"popup.caption.dragHint": "Kéo để di chuyển · nhấp đúp để đặt lại"
"popup.caption.dragHint": "Kéo để di chuyển · nhấp đúp để đặt lại",
"settings.captionRefine": "Chỉnh phụ đề theo ngữ cảnh",
"settings.captionRefine.desc": "AI cục bộ sửa khoảng trắng, dấu câu và từ nghe nhầm dựa trên các dòng xung quanh."
}

View file

@ -522,5 +522,7 @@
"input.feedback.recommendationFailed": "無法將 {{app}} 加入排除清單。",
"popup.caption.loading": "正在準備語音模型…",
"popup.caption.waiting": "聆聽中… 第一則字幕可能需要幾秒鐘",
"popup.caption.dragHint": "拖曳以移動 · 按兩下還原"
"popup.caption.dragHint": "拖曳以移動 · 按兩下還原",
"settings.captionRefine": "依上下文潤飾字幕",
"settings.captionRefine.desc": "本機 AI 會依上下文修正已確定字幕的空格、標點與聽錯的詞。"
}

View file

@ -522,5 +522,7 @@
"input.feedback.recommendationFailed": "无法将 {{app}} 添加到排除列表。",
"popup.caption.loading": "正在准备语音模型…",
"popup.caption.waiting": "正在聆听… 第一条字幕可能需要几秒钟",
"popup.caption.dragHint": "拖动以移动 · 双击复位"
"popup.caption.dragHint": "拖动以移动 · 双击复位",
"settings.captionRefine": "按上下文润色字幕",
"settings.captionRefine.desc": "本地 AI 会根据上下文修正已确定字幕的空格、标点和听错的词。"
}