Claude replies with markdown/backticks by default; MeloTTS's Korean text
normaliser has no entry for '`' and dies with KeyError: '`', so any reply
mentioning a command/code block crashed the whole voice turn (500 on
/api/voice-turn). Fix at the shared synth() choke point with
normalize_for_speech(), which flattens code fences/inline code/links/markdown
and guarantees no backtick reaches the worker — covering both the dashboard
voice turn and the Discord speak() bridge. Also add a PERSONA line asking the
model to avoid markdown (belt-and-suspenders; the code strip is the real fix).
errors_total never moved for turn-level failures: it was only bumped by
log("error") events, and the dashboard voice path calls turn.finish(error=...)
without logging. Emit one error-level log event from Turn.finish() when a turn
ends in error, so both the server counter and the browser SSE mirror stay
consistent, guarded to count at most once. Drop the now-redundant pipeline
log("error") to avoid double counting and remove the dead _publish stub.
Verified: raw backtick -> worker KeyError '`' reproduced; after fix real
MeloTTS synth of a backtick+fenced reply succeeds; /api/voice-turn returns 200
with a wav body on a backtick reply and errors_total stays 0, and an induced
synth failure returns 500 with errors_total incrementing to exactly 1. Full
suite 18 passed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
197 lines
7.6 KiB
Python
197 lines
7.6 KiB
Python
"""Orchestrator: wires the perception loop and the conversation loop together."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
from .interfaces import (
|
|
Brain,
|
|
FrameSource,
|
|
Reply,
|
|
SpeechToText,
|
|
TextChannel,
|
|
TextToSpeech,
|
|
Utterance,
|
|
VisionBackend,
|
|
)
|
|
from .monitor import Monitor
|
|
from .state import SharedScreenContext
|
|
|
|
log = logging.getLogger("wsai.pipeline")
|
|
|
|
|
|
class Pipeline:
|
|
"""Runs two concurrent loops:
|
|
|
|
* perception: FrameSource -> VisionBackend -> SharedScreenContext
|
|
* conversation: (SpeechToText | TextChannel) -> Brain -> (TextToSpeech | TextChannel)
|
|
|
|
Any half can be omitted. With no source/vision it runs eyes-free as a pure
|
|
voice loop (STT -> Brain -> TTS); with no stt/tts it runs text-only; with no
|
|
conversation it is a headless "just watch" configuration.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
source: FrameSource | None = None,
|
|
vision: VisionBackend | None = None,
|
|
brain: Brain,
|
|
stt: SpeechToText | None = None,
|
|
tts: TextToSpeech | None = None,
|
|
text_channel: TextChannel | None = None,
|
|
history_turns: int = 12,
|
|
monitor: Monitor | None = None,
|
|
) -> None:
|
|
self.source = source
|
|
self.vision = vision
|
|
self.brain = brain
|
|
self.stt = stt
|
|
self.tts = tts
|
|
self.text_channel = text_channel
|
|
self.monitor = monitor
|
|
self.context = SharedScreenContext()
|
|
self._history: list[tuple[str, str]] = []
|
|
self._history_turns = history_turns
|
|
|
|
# -- perception -------------------------------------------------------- #
|
|
async def _perceive(self) -> None:
|
|
if self.source is None or self.vision is None:
|
|
return # eyes-free (voice-only) configuration
|
|
async for frame in self.source.frames():
|
|
try:
|
|
obs = await self.vision.describe(frame)
|
|
except Exception as exc: # a single bad frame must not kill the loop
|
|
log.exception("vision.describe failed")
|
|
if self.monitor is not None:
|
|
self.monitor.log("error", f"화면 이해 실패: {exc}")
|
|
continue
|
|
await self.context.update(obs)
|
|
log.debug("screen: %s", obs.text[:120])
|
|
|
|
# -- conversation ------------------------------------------------------ #
|
|
async def _handle(self, utt: Utterance) -> None:
|
|
if self.monitor is None:
|
|
screen = await self.context.latest()
|
|
reply = await self.brain.respond(utt.text, screen, self._history)
|
|
self._remember(utt.text, reply.text)
|
|
await self._emit(reply)
|
|
return
|
|
|
|
# Same work, but each stage is timed and streamed to the dashboard so a
|
|
# viewer can see what was heard, what the brain answered, how long each
|
|
# step took, and whether anything errored.
|
|
turn = self.monitor.turn(source=utt.source)
|
|
turn.heard(utt.text)
|
|
try:
|
|
async with turn.step("화면 맥락"):
|
|
screen = await self.context.latest()
|
|
async with turn.step("두뇌(생각)"):
|
|
reply = await self.brain.respond(utt.text, screen, self._history)
|
|
turn.replied(reply.text)
|
|
self._remember(utt.text, reply.text)
|
|
async with turn.step("응답(TTS/전송)"):
|
|
await self._emit(reply)
|
|
except Exception as exc:
|
|
# finish() records the error and emits the single error-level log
|
|
# event that bumps errors_total, so don't log the same failure twice.
|
|
turn.finish(error=f"{type(exc).__name__}: {exc}")
|
|
raise
|
|
else:
|
|
turn.finish()
|
|
|
|
def _remember(self, user: str, ai: str) -> None:
|
|
self._history.append((user, ai))
|
|
if len(self._history) > self._history_turns:
|
|
self._history = self._history[-self._history_turns :]
|
|
|
|
async def _emit(self, reply: Reply) -> None:
|
|
tasks = []
|
|
if self.tts is not None:
|
|
tasks.append(self.tts.speak(reply))
|
|
if self.text_channel is not None:
|
|
tasks.append(self.text_channel.send(reply))
|
|
if not tasks:
|
|
log.info("AI: %s", reply.text)
|
|
else:
|
|
await asyncio.gather(*tasks)
|
|
|
|
async def _listen_voice(self) -> None:
|
|
if self.stt is None:
|
|
return
|
|
if self.monitor is not None:
|
|
self.monitor.set_status(listening=True)
|
|
self.monitor.log("info", "음성 수신 시작 — 발화 대기 중")
|
|
try:
|
|
async for utt in self.stt.utterances():
|
|
await self._handle(utt)
|
|
finally:
|
|
if self.monitor is not None:
|
|
self.monitor.set_status(listening=False)
|
|
|
|
async def _listen_text(self) -> None:
|
|
if self.text_channel is None:
|
|
return
|
|
async for utt in self.text_channel.messages():
|
|
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
|
|
# failing loop propagated while the siblings kept running detached, and
|
|
# aclose() in the finally then closed a source/stt out from under a
|
|
# still-live loop (close-during-use).
|
|
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())
|
|
tg.create_task(self._listen_voice())
|
|
tg.create_task(self._listen_text())
|
|
except* Exception as eg:
|
|
if self.monitor is not None:
|
|
for exc in eg.exceptions:
|
|
self.monitor.log("error", f"루프 예외: {type(exc).__name__}: {exc}")
|
|
raise
|
|
finally:
|
|
if self.monitor is not None:
|
|
self.monitor.set_status(running=False, listening=False)
|
|
self.monitor.log("info", "파이프라인 종료")
|
|
await self.aclose()
|
|
|
|
async def aclose(self) -> None:
|
|
# tts is included because a real TTS (e.g. MeloTTS) owns a worker
|
|
# subprocess that must be torn down; mock backends have no aclose.
|
|
for closer in (self.source, self.stt, self.text_channel, self.tts):
|
|
if closer is not None and hasattr(closer, "aclose"):
|
|
try:
|
|
await closer.aclose()
|
|
except Exception:
|
|
log.exception("error closing %s", closer)
|