diff --git a/bot/src/index.ts b/bot/src/index.ts index fe96549..f72eefd 100644 --- a/bot/src/index.ts +++ b/bot/src/index.ts @@ -184,10 +184,43 @@ async function handleStatus(i: ChatInputCommandInteraction) { ); } +// Block until the brain bridge reports the TTS voice (MeloTTS worker) is warm. +// The bot, bridge and melo worker all boot together; the voice model takes tens +// of seconds to load. If we log in and auto-join the voice channel before +// MeloTTS is ready, the first reply synthesises to nothing and is silently +// dropped. Gating login on TTS readiness guarantees the first spoken turn has +// audio. We deliberately do NOT wait on brain_ready: the reply engine / Whisper +// load lazily on the first turn (so brain_ready stays false on an idle boot) — +// a slow first turn is fine, a silent one is the bug. After `maxMs` we proceed +// anyway so a TTS load failure degrades to text-only rather than taking the +// whole bot offline. +async function waitForBridgeReady(maxMs = 180_000): Promise { + const start = Date.now(); + let announced = false; + while (Date.now() - start < maxMs) { + try { + const h = await health(); + if (h.tts_ready) { + console.log("✓ 음성(MeloTTS) 준비 완료 — 로그인 진행"); + return; + } + if (!announced) { + console.log("⏳ MeloTTS 준비 대기 중 (음성 모델 로딩)..."); + announced = true; + } + } catch { + /* bridge not listening yet — keep polling */ + } + await new Promise((r) => setTimeout(r, 2000)); + } + console.warn("⚠️ MeloTTS 준비 시간 초과 — 음성(TTS) 없이 진행합니다."); +} + // Mode select: a USER account is the only kind Discord lets Go Live, so when // there's no normal-bot token but a selfbot token is present, run as a userbot // (voice + broadcast on one user account). Otherwise run the legacy normal bot. (async () => { + await waitForBridgeReady(); if (!config.botToken && config.selfbotToken) { const { runUserbot } = await import("./userbot.ts"); await runUserbot(); diff --git a/bridge/server.py b/bridge/server.py index 367ab0b..4e80e9b 100644 --- a/bridge/server.py +++ b/bridge/server.py @@ -255,6 +255,29 @@ def _piper_synthesize(text: str) -> Optional[bytes]: return buf.getvalue() +def _tts_ready() -> bool: + """Whether the configured TTS voice can synthesise right now. + + The bot polls this before logging in so the very first spoken reply is not + silently dropped while the voice is still warming up. For MeloTTS the worker + only binds its HTTP port AFTER the model is loaded (``main()`` warms the + model before ``serve_forever()``), so a successful /health ping is a precise + "voice is warm" signal. Piper loads on first synth and was never gated, so + it reports ready. TTS disabled means there is nothing to wait for. + """ + if not TTS_ENABLED: + return True + if TTS_ENGINE == "melo": + import urllib.request + + try: + with urllib.request.urlopen(f"{MELO_WORKER_URL}/health", timeout=2) as resp: + return resp.status == 200 + except Exception: + return False + return True + + def synthesize(text: str) -> Optional[bytes]: """Synthesize text to a 16-bit PCM WAV. The primary voice is MeloTTS (Korean speaker, speed 1.5) served by the warm melo worker; Piper is a @@ -286,6 +309,7 @@ def health(): "brain_error": _brain_error, "tts_enabled": TTS_ENABLED, "tts_engine": TTS_ENGINE, + "tts_ready": _tts_ready(), } )