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
|
|
@ -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"
|
||||
|
|
|
|||
152
packages/core/src/caption-streaming.ts
Normal file
152
packages/core/src/caption-streaming.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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용 영구 기기 식별자 (개인정보 아님, 최초 실행 시 생성) */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue