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

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

View file

@ -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;