From 932aacef6e91bbf526b729cd113a38fcd8176564 Mon Sep 17 00:00:00 2001 From: javis-bot Date: Fri, 12 Jun 2026 21:59:50 +0900 Subject: [PATCH] feat(bot): delay login until MeloTTS voice is warm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bot, bridge and melo worker boot together, but the MeloTTS model takes tens of seconds to load. If the bot logged in and auto-joined the voice channel before the voice was warm, the first reply synthesised to nothing and was silently dropped. - bridge /health now reports `tts_ready`. For MeloTTS this pings the worker, which only binds its HTTP port AFTER the model is loaded (main() warms before serve_forever()), so a successful ping is a precise "voice is warm" signal. - The bot polls /health and waits for `tts_ready` before logging in. It does not wait on brain_ready (the reply engine / Whisper load lazily on the first turn — a slow first turn is fine, a silent one is the bug). After a 180s cap it proceeds anyway so a TTS load failure degrades to text-only. Live-verified: startup logs show "⏳ MeloTTS 준비 대기 중" then "✓ 음성(MeloTTS) 준비 완료 — 로그인 진행" then "✓ 유저봇 로그인", in that order. --- bot/src/index.ts | 33 +++++++++++++++++++++++++++++++++ bridge/server.py | 24 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+) 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(), } )