Files
watch_sceen_ai/tests/test_pipeline.py
EJClaw 63fcfb7ba2 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>
2026-08-18 18:29:32 +09:00

137 lines
4.0 KiB
Python

"""Smoke test: the mock pipeline must run end-to-end and route screen context
into the brain's replies."""
import asyncio
import pytest
from wsai.backends.mock import (
MockBrain,
MockFrameSource,
MockSTT,
MockTTS,
MockVision,
)
from wsai.interfaces import Frame
from wsai.pipeline import Pipeline
def test_mock_pipeline_runs_and_replies(capsys):
replies: list[str] = []
class CapturingTTS(MockTTS):
async def speak(self, reply):
replies.append(reply.text)
pipe = Pipeline(
source=MockFrameSource(interval=0.05, limit=3),
vision=MockVision(),
brain=MockBrain(),
stt=MockSTT(script=["화면에 뭐 보여?"], interval=0.1),
tts=CapturingTTS(),
)
asyncio.run(asyncio.wait_for(pipe.run(), timeout=5))
assert replies, "brain produced no reply"
# The reply must embed the screen observation → context reached the brain.
assert "화면:" in replies[0]
def test_voice_only_pipeline_runs_without_eyes():
"""Eyes-free config (no source/vision) still runs STT -> Brain -> TTS."""
replies: list[str] = []
class CapturingTTS(MockTTS):
async def speak(self, reply):
replies.append(reply.text)
pipe = Pipeline(
brain=MockBrain(),
stt=MockSTT(script=["안녕", "잘 있어"], interval=0.05),
tts=CapturingTTS(),
)
asyncio.run(asyncio.wait_for(pipe.run(), timeout=5))
assert len(replies) == 2, "voice loop did not reply to every utterance"
# No eyes → the brain must report it has not seen a screen.
assert "아직 화면을 못 읽었어요" in replies[0]
def test_error_in_one_loop_cancels_siblings_and_closes():
"""If the conversation loop raises, the perception loop must be cancelled
(not left running detached) and every source must still be closed — i.e. no
close-during-use and no orphaned task."""
closed = {"source": False, "stt": False}
class ForeverSource:
async def frames(self):
while True:
await asyncio.sleep(0.01)
yield Frame(data=b"", width=1, height=1, ts=0.0)
async def aclose(self):
closed["source"] = True
class BoomSTT(MockSTT):
async def aclose(self):
closed["stt"] = True
class BoomBrain(MockBrain):
async def respond(self, user_text, screen, history):
raise RuntimeError("boom")
pipe = Pipeline(
source=ForeverSource(),
vision=MockVision(),
brain=BoomBrain(),
stt=BoomSTT(script=["hi"], interval=0.01),
tts=MockTTS(),
)
with pytest.raises(BaseException): # TaskGroup raises an ExceptionGroup
asyncio.run(asyncio.wait_for(pipe.run(), timeout=5))
assert closed["source"] is True, "perception source was not closed (orphaned loop)"
assert closed["stt"] is True, "stt was not closed"
def test_history_is_bounded():
pipe = Pipeline(
source=MockFrameSource(limit=0),
vision=MockVision(),
brain=MockBrain(),
history_turns=3,
)
for i in range(10):
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"