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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user