fix: 실시간 음성 통화 — 워클릿 경로 깨짐·시작 실패 무안내·답변 반복 낭독

- addModule('voice-call-worklets.js') 상대경로가 07-25 web-ui 폴더 정리 뒤 HTML(catch-all)을 돌려줘
  통화가 시작 직후 멈췄다 → 절대경로 /js/voice/voice-call-worklets.js
- 시작 단계 예외 시 버튼이 📵에 갇히던 문제: try/catch로 정리 후 오류 안내
- 최종 답변(finalAnswer)이 토큰 스트림 뒤에 통째로 다시 들어와 읽은 위치가 0으로 리셋되면
  같은 문장을 다시 읽던 문제: 턴 단위 문장 키로 중복 낭독 방지
- 통화 종료 사유를 콘솔에 기록(사용자/서버/네트워크 구분)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-26 23:31:18 +09:00
co-authored by Claude Sonnet 5
parent 57cf65fd7e
commit 7c7ca45e44
+29 -7
View File
@@ -29,6 +29,12 @@
let currentTtsId = null;
let spokenUpTo = 0;
let pendingTts = '';
// 이번 턴에 이미 읽은(대기열에 넣은) 문장의 정규화 키. 최종 답변(finalAnswer)이 토큰 스트림 뒤에
// 통째로 다시 들어오거나 가드가 답변을 다시 쓰게 해도, 같은 문장을 두 번 읽지 않는다.
// (09-26 "말을 계속 반복한다": _voiceCallResetSpoken이 읽은 위치를 0으로 되돌려 처음부터 재낭독됐다)
const spokenKeys = new Set();
const MIN_DEDUP_KEY_CHARS = 6; // "네." 같은 짧은 추임새는 반복돼도 자연스러우니 제외
function sentenceKey(t) { return String(t || '').replace(/[^\p{L}\p{N}]/gu, '').toLowerCase(); }
let active = false;
let wakeLock = null;
@@ -69,7 +75,10 @@
if (gainNode) gainNode.gain.value = vol;
};
window.voiceCallHangup = function () {
window.voiceCallHangup = function (reason) {
// 종료 사유를 콘솔에 남긴다 — 사용자가 누른 건지, 서버/네트워크가 끊은 건지 구분하기 위함
// (09-26 "대답하다가 끊어졌다" 신고: 엔진 로그만으로는 어느 쪽인지 알 수 없었다)
if (active) console.info('[voice-call] hangup:', typeof reason === 'string' ? reason : 'user/button', 'state=' + state);
active = false;
state = 'idle';
try { ws && ws.close(); } catch {}
@@ -105,17 +114,20 @@
setCaption('');
state = 'listening';
speechChunks = 0; silenceMs = 0; preRoll = [];
spokenUpTo = 0; pendingTts = ''; ttsQueue = []; ttsBusy = false; currentTtsId = null;
spokenUpTo = 0; pendingTts = ''; spokenKeys.clear(); ttsQueue = []; ttsBusy = false; currentTtsId = null;
// 설정 단계(워클릿 로드·WebSocket) 어디서든 실패하면 버튼이 📵에 갇혀 "연결 중…"으로 멈추던 문제 —
// (2026-09-26 실측: 워클릿 상대경로가 HTML을 돌려줘 addModule이 예외를 던졌는데 아무 안내가 없었다)
try {
captureCtx = new (window.AudioContext || window.webkitAudioContext)();
await captureCtx.audioWorklet.addModule('voice-call-worklets.js');
await captureCtx.audioWorklet.addModule('/js/voice/voice-call-worklets.js');
const source = captureCtx.createMediaStreamSource(micStream);
captureNode = new AudioWorkletNode(captureCtx, 'mic-capture-processor');
captureNode.port.onmessage = handleMicChunk;
source.connect(captureNode);
playCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 24000 });
await playCtx.audioWorklet.addModule('voice-call-worklets.js');
await playCtx.audioWorklet.addModule('/js/voice/voice-call-worklets.js');
playerNode = new AudioWorkletNode(playCtx, 'pcm-player-processor');
// A GainNode we control from the in-app slider — on some mobile browsers
// the hardware volume rocker maps to the call/mic audio session while a
@@ -135,8 +147,13 @@
ws.binaryType = 'arraybuffer';
ws.onopen = () => setStatus('듣는 중…');
ws.onmessage = handleWsMessage;
ws.onerror = () => setStatus('연결 오류');
ws.onclose = () => { if (active) voiceCallHangup(); };
ws.onerror = (ev) => { console.warn('[voice-call] ws error', ev && ev.type); setStatus('연결 오류'); };
ws.onclose = (ev) => { if (active) voiceCallHangup('ws closed code=' + (ev && ev.code) + ' reason=' + (ev && ev.reason || '-') + ' clean=' + (ev && ev.wasClean)); };
} catch (e) {
console.error('[voice-call] start failed:', e);
voiceCallHangup();
alert('음성 통화를 시작하지 못했습니다: ' + (e && e.message ? e.message : e));
}
}
function handleMicChunk(e) {
@@ -210,7 +227,7 @@
setCaption('');
const text = (msg.text || '').trim();
if (!text) { state = 'listening'; setStatus('듣는 중…'); break; }
spokenUpTo = 0; pendingTts = '';
spokenUpTo = 0; pendingTts = ''; spokenKeys.clear();
const input = document.getElementById('chat-input');
if (input) {
input.value = text;
@@ -426,6 +443,11 @@
// silently absorbed the next bullet line and the pause between them
// vanished (reported 2026-07-10).
function bufferSentence(sentence, isLineBreak) {
const key = sentenceKey(sentence);
if (key.length >= MIN_DEDUP_KEY_CHARS) {
if (spokenKeys.has(key)) return;
spokenKeys.add(key);
}
if (isLineBreak) {
flushPendingTts();
enqueueSentence(sentence);