v3.5.0: 실시간 음성통화 TTS 엔진을 OmniVoice로 교체 + Wake Lock 추가

XTTS는 한영 혼용 발음이 깨지고(Zoom→쭘 등) 짧은 문장에서 rambling이 발생했음.
Qwen3-TTS로 교체해봤으나 realtime factor 0.3x로 통화에 쓰기엔 너무 느려서 폐기.
최종적으로 OmniVoice(k2-fsa)로 교체 — realtime factor 1.8~2.8x로 XTTS보다도
빠르고 한영 혼용 발음도 자연스러움.

voice-call.js에는 navigator.wakeLock 추가 — 폰 화면이 꺼지면 브라우저가
백그라운드 탭을 스로틀링해서 통화가 끊기는 문제를 방지.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-09 23:14:38 +09:00
co-authored by Claude Sonnet 5
parent 22c692fec3
commit 73967d1f55
3 changed files with 65 additions and 41 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "smallclaw", "name": "smallclaw",
"version": "3.4.0", "version": "3.5.0",
"description": "Local AI agent framework powered by Ollama - OpenClaw alternative", "description": "Local AI agent framework powered by Ollama - OpenClaw alternative",
"main": "dist/index.js", "main": "dist/index.js",
"bin": { "bin": {
+44 -40
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Persistent GPU voice engine: loads faster-whisper (STT) and XTTS-v2 (TTS) Persistent GPU voice engine: loads faster-whisper (STT) and OmniVoice (TTS)
once and serves them over a localhost WebSocket so Node never pays model once and serves them over a localhost WebSocket so Node never pays model
load latency per request. load latency per request.
@@ -56,10 +56,10 @@ MIN_PARTIAL_BYTES = int(16000 * 2 * 0.6) # ~0.6s of 16kHz mono PCM16 before we
def is_unrecoverable_cuda_error(e: Exception) -> bool: def is_unrecoverable_cuda_error(e: Exception) -> bool:
# A CUDA "device-side assert" (seen from XTTS inference_stream on very short # A CUDA "device-side assert" poisons the whole CUDA context for the rest of
# text) poisons the whole CUDA context for the rest of the process — every # the process — every GPU call after it fails identically, whisper included.
# GPU call after it fails identically, whisper included. There is no # There is no in-process recovery; the only fix is a fresh CUDA context, i.e.
# in-process recovery; the only fix is a fresh CUDA context, i.e. a restart. # a restart.
msg = str(e) msg = str(e)
return 'CUDA' in msg and ('assert' in msg.lower() or 'device-side' in msg.lower() or 'failed' in msg.lower()) return 'CUDA' in msg and ('assert' in msg.lower() or 'device-side' in msg.lower() or 'failed' in msg.lower())
@@ -75,15 +75,21 @@ def load_models(args):
stt_model = WhisperModel(args.stt_model, device=args.device, compute_type='float16' if args.device == 'cuda' else 'int8') stt_model = WhisperModel(args.stt_model, device=args.device, compute_type='float16' if args.device == 'cuda' else 'int8')
log.info('faster-whisper ready.') log.info('faster-whisper ready.')
log.info('Loading XTTS-v2 ...') log.info('Transcribing speaker reference %s for the voice-clone prompt text ...', args.speaker_wav)
from TTS.api import TTS segments, _info = stt_model.transcribe(args.speaker_wav, language='ko', beam_size=5, vad_filter=True)
tts_wrapper = TTS('tts_models/multilingual/multi-dataset/xtts_v2').to(args.device) ref_text = ''.join(seg.text for seg in segments).strip()
xtts = tts_wrapper.synthesizer.tts_model # underlying TTS.tts.models.xtts.Xtts — has inference_stream() log.info('Speaker reference text: %r', ref_text)
log.info('Computing speaker conditioning latents from %s ...', args.speaker_wav)
gpt_cond_latent, speaker_embedding = xtts.get_conditioning_latents(audio_path=[args.speaker_wav])
log.info('XTTS-v2 ready.')
return stt_model, tts_wrapper, xtts, gpt_cond_latent, speaker_embedding log.info('Loading OmniVoice (%s) ...', args.tts_model)
import torch
from omnivoice import OmniVoice
tts_model = OmniVoice.from_pretrained(args.tts_model, device_map=args.device, dtype=torch.float16, load_asr=False)
voice_clone_prompt = tts_model.create_voice_clone_prompt(ref_audio=args.speaker_wav, ref_text=ref_text)
log.info('Warming up OmniVoice ...')
tts_model.generate(text='안녕하세요.', language='Korean', voice_clone_prompt=voice_clone_prompt)
log.info('OmniVoice ready (sample_rate=%d).', tts_model.sampling_rate)
return stt_model, tts_model, voice_clone_prompt, tts_model.sampling_rate
def pcm16_from_float(audio: np.ndarray) -> bytes: def pcm16_from_float(audio: np.ndarray) -> bytes:
@@ -99,13 +105,12 @@ class Engine:
def __init__(self, args): def __init__(self, args):
self.args = args self.args = args
self.stt_model = None self.stt_model = None
self.tts_model = None # TTS.api.TTS wrapper (used for the batch .tts() call) self.tts_model = None # omnivoice.OmniVoice
self.xtts = None # underlying Xtts model (used for inference_stream) self.voice_clone_prompt = None # precomputed — avoids re-encoding the speaker ref every call
self.gpt_cond_latent = None self._tts_sample_rate = 24000
self.speaker_embedding = None
def ready(self) -> bool: def ready(self) -> bool:
return self.stt_model is not None and self.xtts is not None return self.stt_model is not None and self.tts_model is not None
# ── Batch STT/TTS (Telegram, single-shot web button) ────────────────── # ── Batch STT/TTS (Telegram, single-shot web button) ──────────────────
def transcribe_full(self, audio_bytes: bytes, fmt: str, language: str): def transcribe_full(self, audio_bytes: bytes, fmt: str, language: str):
@@ -124,15 +129,13 @@ class Engine:
pass pass
def synthesize_full(self, text: str): def synthesize_full(self, text: str):
wav = self.tts_model.tts( audio = self.tts_model.generate(
text=text[:4000], text=text[:4000],
speaker_wav=self.args.speaker_wav, language='Korean',
language='ko', voice_clone_prompt=self.voice_clone_prompt,
) )
audio = np.asarray(wav, dtype=np.float32) pcm = pcm16_from_float(np.asarray(audio[0], dtype=np.float32))
pcm = pcm16_from_float(audio) return pcm, self._tts_sample_rate
sample_rate = int(self.tts_model.synthesizer.output_sample_rate)
return pcm, sample_rate
# ── Streaming STT (partial decode of an accumulating PCM buffer) ────── # ── Streaming STT (partial decode of an accumulating PCM buffer) ──────
def transcribe_pcm(self, pcm: bytes, language: str): def transcribe_pcm(self, pcm: bytes, language: str):
@@ -141,23 +144,23 @@ class Engine:
text = ''.join(seg.text for seg in segments).strip() text = ''.join(seg.text for seg in segments).strip()
return text, (info.language if info else language) return text, (info.language if info else language)
# ── Streaming TTS (XTTS inference_stream, chunk-by-chunk) ───────────── # ── "Streaming" TTS ─────────────────────────────────────────────────
# OmniVoice's public API is a batch call (no token-level generator), so a
# "stream" here is one full synthesis emitted as a single chunk. At ~2x
# real-time it's fast enough that per-sentence batching (the client already
# buffers LLM output into sentence-sized pieces before calling tts_start)
# doesn't feel laggy the way XTTS/Qwen3-TTS did.
def synthesize_stream(self, text: str, is_cancelled): def synthesize_stream(self, text: str, is_cancelled):
for chunk in self.xtts.inference_stream( if is_cancelled() or not text.strip():
text=text[:4000], return
language='ko', pcm, _sample_rate = self.synthesize_full(text)
gpt_cond_latent=self.gpt_cond_latent, if is_cancelled():
speaker_embedding=self.speaker_embedding, return
stream_chunk_size=20, yield pcm
):
if is_cancelled():
return
audio = chunk.squeeze().detach().cpu().numpy().astype(np.float32)
yield pcm16_from_float(audio)
@property @property
def tts_sample_rate(self) -> int: def tts_sample_rate(self) -> int:
return int(self.tts_model.synthesizer.output_sample_rate) return self._tts_sample_rate
class ConnectionState: class ConnectionState:
@@ -300,13 +303,14 @@ async def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('--port', type=int, default=8765) parser.add_argument('--port', type=int, default=8765)
parser.add_argument('--stt-model', default='medium') parser.add_argument('--stt-model', default='medium')
parser.add_argument('--tts-model', default='k2-fsa/OmniVoice')
parser.add_argument('--speaker-wav', default='') parser.add_argument('--speaker-wav', default='')
parser.add_argument('--device', default='cuda') parser.add_argument('--device', default='cuda')
args = parser.parse_args() args = parser.parse_args()
engine = Engine(args) engine = Engine(args)
(engine.stt_model, engine.tts_model, engine.xtts, (engine.stt_model, engine.tts_model, engine.voice_clone_prompt,
engine.gpt_cond_latent, engine.speaker_embedding) = load_models(args) engine._tts_sample_rate) = load_models(args)
async with websockets.serve(lambda ws: handle_connection(ws, engine), '127.0.0.1', args.port, max_size=64 * 1024 * 1024): async with websockets.serve(lambda ws: handle_connection(ws, engine), '127.0.0.1', args.port, max_size=64 * 1024 * 1024):
log.info('voice_engine listening on 127.0.0.1:%d', args.port) log.info('voice_engine listening on 127.0.0.1:%d', args.port)
+20
View File
@@ -30,6 +30,23 @@
let spokenUpTo = 0; let spokenUpTo = 0;
let pendingTts = ''; let pendingTts = '';
let active = false; let active = false;
let wakeLock = null;
// The Wake Lock is auto-released whenever the tab loses visibility (screen off,
// app-switch) — without it, mobile browsers throttle/suspend the background tab
// and the call silently drops. Re-acquire it once the tab is visible again.
async function acquireWakeLock() {
if (!('wakeLock' in navigator)) return;
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => { wakeLock = null; });
} catch (e) {
console.warn('[voice-call] wakeLock request failed:', e.message);
}
}
document.addEventListener('visibilitychange', () => {
if (active && wakeLock === null && document.visibilityState === 'visible') acquireWakeLock();
});
function setStatus(text) { function setStatus(text) {
const el = document.getElementById('voice-call-status'); const el = document.getElementById('voice-call-status');
@@ -54,6 +71,8 @@
try { captureCtx && captureCtx.close(); } catch {} try { captureCtx && captureCtx.close(); } catch {}
try { playCtx && playCtx.close(); } catch {} try { playCtx && playCtx.close(); } catch {}
captureCtx = playCtx = micStream = captureNode = playerNode = null; captureCtx = playCtx = micStream = captureNode = playerNode = null;
try { wakeLock && wakeLock.release(); } catch {}
wakeLock = null;
ttsQueue = []; ttsBusy = false; currentTtsId = null; ttsQueue = []; ttsBusy = false; currentTtsId = null;
const bar = document.getElementById('voice-call-bar'); const bar = document.getElementById('voice-call-bar');
if (bar) bar.style.display = 'none'; if (bar) bar.style.display = 'none';
@@ -72,6 +91,7 @@
} }
active = true; active = true;
acquireWakeLock();
if (btn) { btn.textContent = '📵'; btn.classList.add('recording'); } if (btn) { btn.textContent = '📵'; btn.classList.add('recording'); }
if (bar) bar.style.display = 'flex'; if (bar) bar.style.display = 'flex';
setStatus('통화 연결 중…'); setStatus('통화 연결 중…');