fix: 음성 통화 — 말 끊김(재생 종료 판단·끼어들기)과 표·가격 낭독 누락

- 끊김: 마지막 문장 합성이 끝나는 순간 '듣는 중'으로 돌아가 아직 재생 중인 자기 목소리를 사용자
  발화/끼어들기로 잡던 문제. 플레이어 워클릿이 active/idle 신호를 보내고, 합성·대기열·재생이 모두
  끝나야 listening으로 복귀. 말하는 중 끼어들기는 더 크게(RMS 0.06)·오래(500ms), 재생 직후
  500ms는 잔향 유예
- 낭독 누락: 천 단위 쉼표(9,500원→'구, 오백원', 천 소실) 제거, 표 행에 마침표를 붙여 표 규칙에
  안 걸리던 문제(|---| 그대로 합성), '일만 이천구백원'이 TTS에서 통째로 사라지는 문제
  ('만 이천구백원'으로 띄어 읽기). TTS→STT 왕복으로 가격 5종 정상 확인

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-26 23:49:14 +09:00
co-authored by Claude Sonnet 5
parent 1a32803ef5
commit 85254e5f0d
2 changed files with 48 additions and 14 deletions
+4
View File
@@ -45,6 +45,7 @@ class PcmPlayerProcessor extends AudioWorkletProcessor {
super();
this.queue = []; // array of Float32Array
this.qi = 0; // read offset within queue[0]
this.playing = false; // 실제로 소리를 내보내는 중인지 — 메인 스레드가 "말하기 끝"을 정확히 알 수 있게 신호를 보낸다
this.port.onmessage = (e) => {
const msg = e.data;
if (msg.type === 'push') {
@@ -55,6 +56,7 @@ class PcmPlayerProcessor extends AudioWorkletProcessor {
} else if (msg.type === 'clear') {
this.queue = [];
this.qi = 0;
if (this.playing) { this.playing = false; this.port.postMessage({ type: 'idle' }); }
}
};
}
@@ -63,6 +65,7 @@ class PcmPlayerProcessor extends AudioWorkletProcessor {
const out = outputs[0][0];
if (!out) return true;
let oi = 0;
if (!this.playing && this.queue.length > 0) { this.playing = true; this.port.postMessage({ type: 'active' }); }
while (oi < out.length) {
if (this.queue.length === 0) {
out[oi++] = 0;
@@ -75,6 +78,7 @@ class PcmPlayerProcessor extends AudioWorkletProcessor {
this.qi = 0;
}
}
if (this.playing && this.queue.length === 0) { this.playing = false; this.port.postMessage({ type: 'idle' }); }
return true;
}
}
+44 -14
View File
@@ -15,6 +15,12 @@
// poisons the whole GPU context) on very short standalone text — merge short
// sentence fragments together before sending them off as one TTS request.
const MIN_TTS_CHARS = 15;
// 말하는 동안(assistant_speaking)엔 스피커 소리가 마이크로 새어 들어오므로 끼어들기 기준을 일반 발화보다
// 훨씬 엄격하게 둔다(더 크고 더 오래). 재생이 끝난 직후에도 잔향 유예 시간 동안은 발화 감지를 막는다.
// (09-26 "말이 군데군데 끊어진다": 자기 목소리가 끼어들기로 잡혀 재생이 끊기고 남은 문장이 사라졌다)
const BARGE_RMS_ON = 0.06;
const BARGE_CONFIRM_CHUNKS = 5; // ~500ms 지속
const POST_PLAYBACK_GRACE_MS = 500;
let ws = null;
let captureCtx = null, playCtx = null;
@@ -37,6 +43,8 @@
function sentenceKey(t) { return String(t || '').replace(/[^\p{L}\p{N}]/gu, '').toLowerCase(); }
let active = false;
let wakeLock = null;
let playbackActive = false; // 플레이어 워클릿이 실제로 소리를 내는 중인지 (active/idle 신호)
let graceUntil = 0;
// The Wake Lock is auto-released whenever the tab loses visibility (screen off,
// app-switch) — without it, mobile browsers throttle/suspend the background tab
@@ -90,6 +98,7 @@
try { wakeLock && wakeLock.release(); } catch {}
wakeLock = null;
ttsQueue = []; ttsBusy = false; currentTtsId = null;
playbackActive = false; graceUntil = 0;
const bar = document.getElementById('voice-call-bar');
if (bar) bar.style.display = 'none';
const btn = document.getElementById('voice-call-btn');
@@ -129,6 +138,11 @@
playCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 24000 });
await playCtx.audioWorklet.addModule('/js/voice/voice-call-worklets.js');
playerNode = new AudioWorkletNode(playCtx, 'pcm-player-processor');
playerNode.port.onmessage = (e) => {
const t = e.data && e.data.type;
if (t === 'active') playbackActive = true;
else if (t === 'idle') { playbackActive = false; settleSpeaking(); }
};
// 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
// getUserMedia stream is open, not to this Web Audio output, so the phone's
@@ -156,6 +170,17 @@
}
}
// "말하는 중"에서 "듣는 중"으로 돌아가도 되는 조건: 합성 요청도 대기열도 없고 소리 재생도 끝났을 때.
// (예전엔 마지막 문장의 합성이 끝나는 순간 바로 돌아가 아직 몇 초 재생 중인 자기 목소리를 사용자 발화로 들었다)
function settleSpeaking() {
if (state !== 'assistant_speaking') return;
if (ttsBusy || ttsQueue.length > 0 || playbackActive) return;
state = 'listening';
setStatus('듣는 중…');
graceUntil = Date.now() + POST_PLAYBACK_GRACE_MS;
speechChunks = 0;
}
function handleMicChunk(e) {
if (!active) return;
const { pcm, rms } = e.data;
@@ -163,9 +188,14 @@
if (state === 'listening' || state === 'assistant_speaking') {
preRoll.push(pcm);
if (preRoll.length > PREROLL_CHUNKS) preRoll.shift();
if (rms > RMS_SPEECH_ON) {
const speaking = state === 'assistant_speaking';
const on = speaking ? BARGE_RMS_ON : RMS_SPEECH_ON;
const need = speaking ? BARGE_CONFIRM_CHUNKS : SPEECH_CONFIRM_CHUNKS;
if (Date.now() < graceUntil) {
speechChunks = 0;
} else if (rms > on) {
speechChunks++;
if (speechChunks >= SPEECH_CONFIRM_CHUNKS) onSpeechStart();
if (speechChunks >= need) onSpeechStart();
} else {
speechChunks = 0;
}
@@ -185,6 +215,7 @@
// barge-in: cut playback immediately and cancel whatever is still generating
ttsQueue = [];
if (playerNode) playerNode.port.postMessage({ type: 'clear' });
playbackActive = false;
if (ws && ws.readyState === WebSocket.OPEN && currentTtsId) {
ws.send(JSON.stringify({ type: 'tts_cancel', id: currentTtsId }));
}
@@ -252,10 +283,7 @@
ttsBusy = false;
currentTtsId = null;
pumpTtsQueue();
if (ttsQueue.length === 0) {
state = 'listening';
setStatus('듣는 중…');
}
settleSpeaking();
break;
case 'error':
@@ -267,10 +295,7 @@
ttsBusy = false;
currentTtsId = null;
pumpTtsQueue();
if (ttsQueue.length === 0) {
state = 'listening';
setStatus('듣는 중…');
}
settleSpeaking();
}
break;
}
@@ -314,12 +339,14 @@
const groups = [];
let rem = n;
while (rem > 0) { groups.push(rem % 10000); rem = Math.floor(rem / 10000); }
let result = '';
// 만/억/조 단위는 띄어서 읽고 "일만"이 아니라 "만"으로 읽는다(자연스러운 한국어이기도 하고, OmniVoice가
// "일만이천구백원"을 "맛 구백원"처럼 발음해 "일만 이천"이 통째로 사라졌다 — STT 왕복 실측, 09-26)
const parts = [];
for (let g = groups.length - 1; g >= 0; g--) {
if (groups[g] === 0) continue;
result += fourDigit(groups[g]) + bigUnits[g];
parts.push((g > 0 && groups[g] === 1 ? '' : fourDigit(groups[g])) + bigUnits[g]);
}
return result || '영';
return parts.join(' ') || '영';
}
// "27.7" -> "이십칠점칠" (decimal digits read one at a time, standard
@@ -348,6 +375,9 @@
function sanitizeForSpeech(text) {
return text
// 천 단위 쉼표(9,500 / 1,234,567)는 숫자 낭독 전에 제거한다. 안 그러면 "9"와 "500"이 따로 읽혀
// "9,500원"이 "구, 오백원"이 되어 "천"이 통째로 사라진다(09-26 "단어가 한번씩 빠진다", 표의 가격 열).
.replace(/(?<=\d),(?=\d{3}(?!\d))/g, '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
@@ -485,7 +515,7 @@
// gone by the time this piece reaches it. Put an explicit period
// back so list items / paragraph breaks still read as a pause
// instead of running straight into the next line (2026-07-10).
if (piece && boundaryChar === '\n' && !/[.!?,:;]$/.test(piece)) {
if (piece && boundaryChar === '\n' && !/[.!?,:;|]$/.test(piece)) {
piece += '.';
}
if (piece) sentences.push({ text: piece, isLineBreak: boundaryChar === '\n' });