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:
EJClaw
2026-08-18 18:29:32 +09:00
parent 6a138eff3a
commit 63fcfb7ba2
10 changed files with 452 additions and 11 deletions

View File

@@ -108,3 +108,29 @@ def test_history_is_bounded():
pipe._remember(f"u{i}", f"a{i}")
assert len(pipe._history) == 3
assert pipe._history[-1] == ("u9", "a9")
def test_prewarm_warms_backends_and_survives_failure():
"""Backends exposing warmup() are preloaded at startup; a warmup that
raises is logged but must not abort the run (first-utterance latency is an
optimization, not a hard requirement)."""
warmed: list[str] = []
class WarmTTS(MockTTS):
async def warmup(self):
warmed.append("tts")
class BoomWarmSTT(MockSTT):
async def warmup(self):
warmed.append("stt")
raise RuntimeError("model unavailable")
pipe = Pipeline(
brain=MockBrain(),
stt=BoomWarmSTT(script=["안녕"], interval=0.01),
tts=WarmTTS(),
)
asyncio.run(asyncio.wait_for(pipe.run(), timeout=5))
assert "tts" in warmed and "stt" in warmed, "warmup() not called on backends"