feat(bot): delay login until MeloTTS voice is warm

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.
This commit is contained in:
javis-bot
2026-06-12 21:59:50 +09:00
parent ccbaed9030
commit 932aacef6e
2 changed files with 57 additions and 0 deletions

View File

@@ -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<void> {
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();

View File

@@ -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(),
}
)