diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 0c9c930..a19d5e7 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -72,6 +72,30 @@ def test_monitor_marks_errors(): assert snap["status"]["errors_total"] >= 1 +def test_error_turn_increments_errors_total_once(): + """The dashboard voice turn (dashboard.voice_turn) records a turn and calls + turn.finish(error=...) directly — it never goes through the pipeline's + log("error") path. That failure must still land in errors_total, and exactly + once no matter how many times the turn is re-published.""" + mon = Monitor() + turn = mon.turn(source="discord") + turn.heard("`코드` 얘기") # touches/publishes again + turn.replied("답변") # and again + assert mon.status_snapshot()["errors_total"] == 0 + + turn.finish(error="melo synth failed: KeyError '`'") + assert mon.status_snapshot()["errors_total"] == 1 + + # A stray re-finish/re-publish must not double count. + turn.finish(error="melo synth failed: KeyError '`'") + turn._touch() + assert mon.status_snapshot()["errors_total"] == 1 + + # The failure is also visible as an error-level event for the live feed. + events = mon.snapshot()["events"] + assert any(e["type"] == "log" and e["level"] == "error" for e in events) + + def test_subscriber_receives_live_turn_events(): async def go(): mon = Monitor() diff --git a/tests/test_textnorm.py b/tests/test_textnorm.py new file mode 100644 index 0000000..3982041 --- /dev/null +++ b/tests/test_textnorm.py @@ -0,0 +1,39 @@ +"""TTS input normalisation. Claude speaks in markdown/backticks; MeloTTS's +Korean normaliser crashes on a bare backtick (``KeyError: '`'``), which used to +take down the whole voice turn. normalize_for_speech() must strip formatting and +above all guarantee no backtick reaches the synthesiser.""" + +from wsai.backends.melo import normalize_for_speech + + +def test_backticks_are_always_removed(): + # The exact crash trigger: inline code, a fenced block, and a stray backtick. + reply = "`ls -la` 를 써봐. 예시:\n```python\nprint('hi')\n```\n그리고 ` 이건 홀로 남은 백틱" + out = normalize_for_speech(reply) + assert "`" not in out # the character that crashes MeloTTS is gone + assert "ls -la" in out # inner words are kept, just unwrapped + assert "print('hi')" in out # fenced code content survives as spoken text + + +def test_markdown_structure_flattened(): + reply = "# 제목\n- 첫째 항목\n- 둘째 항목\n**굵게** 그리고 _기울임_\n> 인용문" + out = normalize_for_speech(reply) + assert "#" not in out + assert "**" not in out and "_" not in out + assert not out.lstrip().startswith(("-", ">")) + assert "첫째 항목" in out and "굵게" in out and "인용문" in out + + +def test_links_reduced_to_label(): + out = normalize_for_speech("자세히는 [문서](https://example.com/docs) 참고해") + assert "문서" in out + assert "http" not in out and "]" not in out and "(" not in out + + +def test_plain_text_is_left_intact(): + plain = "안녕, 지금 화면 잘 보고 있어. 뭐 도와줄까?" + assert normalize_for_speech(plain) == plain + + +def test_empty_is_safe(): + assert normalize_for_speech("") == "" diff --git a/wsai/backends/claude.py b/wsai/backends/claude.py index 9ab71fc..5488328 100644 --- a/wsai/backends/claude.py +++ b/wsai/backends/claude.py @@ -125,7 +125,8 @@ class ClaudeBrain: PERSONA = ( "너는 사용자의 디스코드 화면공유를 실시간으로 함께 보는 AI 파트너야. " "화면 설명을 참고해 자연스러운 반말/존댓말은 사용자에 맞추고, 짧고 대화하듯 답해. " - "화면을 못 봤으면 솔직히 말해." + "화면을 못 봤으면 솔직히 말해. " + "네 답변은 음성으로 읽히니 마크다운·코드블록·백틱 같은 서식 없이 평범한 말로만 답해." ) def __init__(self, *, model: str = "claude-sonnet-4-5", api_key: str | None = None) -> None: diff --git a/wsai/backends/melo.py b/wsai/backends/melo.py index 7b0a249..776bac7 100644 --- a/wsai/backends/melo.py +++ b/wsai/backends/melo.py @@ -22,6 +22,7 @@ import collections import json import logging import os +import re import time from pathlib import Path from typing import Awaitable, Callable @@ -32,6 +33,42 @@ log = logging.getLogger("wsai.tts.melo") _DEFAULT_PYTHON = "/home/claude/jarvis-tts/melo311/bin/python" +_FENCE_RE = re.compile(r"```[^\n`]*\n?(.*?)```", re.DOTALL) +_LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]*\)") +_INLINE_CODE_RE = re.compile(r"`+([^`]*)`+") + + +def normalize_for_speech(text: str) -> str: + """Flatten Claude's markdown/code formatting into plain prose before TTS. + + MeloTTS's Korean text normaliser has no dictionary entry for characters + like the backtick and dies with ``KeyError: '`'`` — which crashes the whole + voice turn the moment the model mentions a command or shows a code block. + Code/markdown also reads terribly aloud. So strip the formatting and keep + the words. Every spoken path (dashboard voice turn and the Discord speak() + bridge) funnels through ``synth()``, so normalising there covers them both. + """ + if not text: + return text + # Fenced code block -> keep its inner text as spoken words, drop the fences. + text = _FENCE_RE.sub(lambda m: " " + m.group(1) + " ", text) + # [label](url) -> label + text = _LINK_RE.sub(r"\1", text) + # `code` -> code + text = _INLINE_CODE_RE.sub(r"\1", text) + # Any stray/unbalanced backtick that survived -> gone. This is the exact + # character that crashes MeloTTS, so guarantee none remain. + text = text.replace("`", "") + # Markdown structure markers -> plain text. + text = re.sub(r"(?m)^\s{0,3}#{1,6}\s*", "", text) # ATX headings + text = re.sub(r"(?m)^\s{0,3}>\s?", "", text) # blockquotes + text = re.sub(r"(?m)^\s{0,3}[-*+]\s+", "", text) # bullet list markers + text = re.sub(r"[*_]{1,3}", "", text) # bold/italic emphasis + # Collapse the whitespace the stripping leaves behind. + text = re.sub(r"[ \t]+", " ", text) + text = re.sub(r"\n{2,}", "\n", text) + return text.strip() + # A sink receives the finished wav path plus the reply it voices. Sink = Callable[[str, Reply], Awaitable[None]] @@ -135,6 +172,7 @@ class MeloTTS: """Synthesize `text` to a wav and return its path (no sink). Reusable by callers that want the wav directly (e.g. the Discord voice bridge).""" await self._ensure() + text = normalize_for_speech(text) self._n += 1 out = str(self.out_dir / f"tts-{self._n:06d}.wav") req = json.dumps({"text": text, "out": out, "speed": self.speed}) diff --git a/wsai/monitor.py b/wsai/monitor.py index 1382469..8b89e5e 100644 --- a/wsai/monitor.py +++ b/wsai/monitor.py @@ -88,6 +88,7 @@ class Turn: self.error = "" self.total_ms = 0.0 self._steps: list[Step] = [] + self._error_logged = False # count this turn's failure at most once # -- recording API (called from the pipeline) ------------------------- # def heard(self, text: str) -> None: @@ -108,8 +109,19 @@ class Turn: self.error = error elif any(s.ok is False for s in self._steps): self.status = "error" + if not self.error: + failed = next((s for s in self._steps if s.ok is False), None) + self.error = (failed.error if failed else "") or "step failed" else: self.status = "ok" + # A turn that ended in error must be reflected in errors_total. That + # counter is driven by error-level log events on BOTH the server + # (Monitor.log) and the browser (dashboard SSE handler), so emit one + # log event here rather than bumping a counter the client won't mirror. + # Guarded so the repeated _touch()/finish() calls can't double-count. + if self.status == "error" and not self._error_logged: + self._error_logged = True + self._monitor.log("error", f"대화 #{self.id} 실패: {self.error}") self._touch() # -- internal --------------------------------------------------------- # @@ -185,11 +197,8 @@ class Monitor: return t def _publish(self, t: Turn) -> None: - # Recompute error total lazily on error transitions. - if t.status == "error": - with self._lock: - # errors_total counts turns that ended in error at most once - pass + # errors_total is bumped once when the turn transitions to error, inside + # Turn.finish() (via a log event), so this only streams the turn state. self._broadcast({"type": "turn", "turn": t.to_dict()}) # -- snapshot / subscribe (read side, HTTP threads) ------------------- # diff --git a/wsai/pipeline.py b/wsai/pipeline.py index 884639d..0c40cd3 100644 --- a/wsai/pipeline.py +++ b/wsai/pipeline.py @@ -94,8 +94,9 @@ class Pipeline: 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}") - self.monitor.log("error", f"대화 #{turn.id} 실패: {exc}") raise else: turn.finish()