feat(stt): real Korean STT via persistent faster-whisper worker
Step 3 (귀): add WhisperSTT + whisper_worker, a warm out-of-venv worker mirroring the MeloTTS shape (whisper312 venv, small/int8 on CPU). transcribe() closes the voice round trip (MeloTTS wav -> whisper text); utterances() turns an injected audio_source into Utterances (Discord voice feed pending). Wired into factory as WSAI_STT=whisper. Also address the arbiter's TTS follow-ups: - melo worker error handling: capture stderr (drained in a bounded background task so the pipe can't fill), surface the real failure cause, and defend against an empty/invalid ready line instead of dying on JSONDecodeError. - pipeline pre-warm: load slow backends (warmup()) at startup so the first utterance is answered warm; a warmup failure is logged, not fatal. Verified: real TTS->STT round trip recovers the sentence near-perfectly; warm transcribe ~1.2s (CPU). 12 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ Env:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -59,6 +60,37 @@ class MeloTTS:
|
||||
self._lock = asyncio.Lock()
|
||||
self._n = 0
|
||||
self.load_ms: int | None = None
|
||||
# Keep the worker's most recent stderr lines so a crash reports its real
|
||||
# cause instead of a bare JSONDecodeError. Bounded so it can't grow.
|
||||
self._stderr_tail: collections.deque[str] = collections.deque(maxlen=40)
|
||||
self._stderr_task: asyncio.Task | None = None
|
||||
|
||||
async def _drain_stderr(self, stream: asyncio.StreamReader) -> None:
|
||||
# The worker redirects fd1 -> fd2, so ALL library chatter lands on
|
||||
# stderr. If we PIPE stderr but never read it, the OS pipe buffer fills
|
||||
# and the worker blocks forever. So we continuously drain it and keep
|
||||
# only the last few lines for diagnostics.
|
||||
try:
|
||||
while True:
|
||||
line = await stream.readline()
|
||||
if not line:
|
||||
return
|
||||
self._stderr_tail.append(line.decode(errors="replace").rstrip())
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # draining must never crash the caller
|
||||
return
|
||||
|
||||
def _stderr_hint(self) -> str:
|
||||
tail = "\n".join(self._stderr_tail)
|
||||
return f" worker stderr tail:\n{tail}" if tail else " (worker produced no stderr)"
|
||||
|
||||
async def warmup(self) -> None:
|
||||
"""Start and load the worker now so the first real utterance is warm.
|
||||
|
||||
Called at pipeline startup so users don't wait ~7s (CPU model load) for
|
||||
the very first spoken reply."""
|
||||
await self._ensure()
|
||||
|
||||
async def _ensure(self) -> None:
|
||||
if self._proc is not None and self._proc.returncode is None:
|
||||
@@ -72,12 +104,29 @@ class MeloTTS:
|
||||
cwd=repo_root, env=env,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
self._stderr_tail.clear()
|
||||
assert self._proc.stderr is not None
|
||||
self._stderr_task = asyncio.create_task(self._drain_stderr(self._proc.stderr))
|
||||
ready = await self._proc.stdout.readline()
|
||||
info = json.loads(ready.decode())
|
||||
if not ready: # worker died before signalling ready
|
||||
await self._proc.wait()
|
||||
raise RuntimeError(
|
||||
f"melo worker exited before ready (code {self._proc.returncode})."
|
||||
f"{self._stderr_hint()}"
|
||||
)
|
||||
try:
|
||||
info = json.loads(ready.decode())
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(
|
||||
f"melo worker sent invalid ready line {ready!r}: {exc}."
|
||||
f"{self._stderr_hint()}"
|
||||
) from exc
|
||||
if not info.get("ready"):
|
||||
raise RuntimeError(f"melo worker failed to start: {info}")
|
||||
raise RuntimeError(
|
||||
f"melo worker failed to start: {info}.{self._stderr_hint()}"
|
||||
)
|
||||
self.load_ms = info.get("ms")
|
||||
log.info("melo worker ready in %s ms on %s", self.load_ms, info.get("device"))
|
||||
|
||||
@@ -93,7 +142,7 @@ class MeloTTS:
|
||||
await self._proc.stdin.drain()
|
||||
resp = await self._proc.stdout.readline()
|
||||
if not resp:
|
||||
raise RuntimeError("melo worker closed unexpectedly")
|
||||
raise RuntimeError(f"melo worker closed unexpectedly.{self._stderr_hint()}")
|
||||
res = json.loads(resp.decode())
|
||||
if not res.get("ok"):
|
||||
raise RuntimeError(f"melo synth failed: {res.get('error')}")
|
||||
@@ -107,4 +156,11 @@ class MeloTTS:
|
||||
await asyncio.wait_for(self._proc.wait(), timeout=5)
|
||||
except (ProcessLookupError, asyncio.TimeoutError):
|
||||
pass
|
||||
if self._stderr_task is not None:
|
||||
self._stderr_task.cancel()
|
||||
try:
|
||||
await self._stderr_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
self._stderr_task = None
|
||||
self._proc = None
|
||||
|
||||
Reference in New Issue
Block a user