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:
@@ -136,6 +136,28 @@ class Pipeline:
|
||||
await self._handle(utt)
|
||||
|
||||
# -- lifecycle --------------------------------------------------------- #
|
||||
async def _prewarm(self) -> None:
|
||||
"""Load slow-to-start backends before the loops accept input.
|
||||
|
||||
Real STT/TTS backends (faster-whisper, MeloTTS) load a model into a
|
||||
persistent worker on first use — several seconds on CPU. Warming them
|
||||
here means the first real utterance is answered warm (~1s) instead of
|
||||
paying the cold model load mid-conversation."""
|
||||
warmers = []
|
||||
for comp, name in ((self.tts, "tts"), (self.stt, "stt")):
|
||||
warmup = getattr(comp, "warmup", None)
|
||||
if callable(warmup):
|
||||
warmers.append((name, warmup))
|
||||
if not warmers:
|
||||
return
|
||||
for name, warmup in warmers:
|
||||
try:
|
||||
await warmup()
|
||||
except Exception as exc: # a warm failure must not abort startup
|
||||
log.warning("prewarm %s failed: %s", name, exc)
|
||||
if self.monitor is not None:
|
||||
self.monitor.log("error", f"{name} 예열 실패: {exc}")
|
||||
|
||||
async def run(self) -> None:
|
||||
# A TaskGroup (not bare gather) so that if ONE loop raises, the others
|
||||
# are cancelled and awaited before teardown. With plain gather the
|
||||
@@ -145,6 +167,7 @@ class Pipeline:
|
||||
if self.monitor is not None:
|
||||
self.monitor.set_status(running=True)
|
||||
self.monitor.log("info", "파이프라인 시작")
|
||||
await self._prewarm()
|
||||
try:
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(self._perceive())
|
||||
|
||||
Reference in New Issue
Block a user