fix(voice): stop backtick TTS crash and actually count error turns

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>
This commit is contained in:
EJClaw
2026-08-18 23:29:13 +09:00
parent ee6f6b7f55
commit 356d1128fa
6 changed files with 119 additions and 7 deletions

View File

@@ -72,6 +72,30 @@ def test_monitor_marks_errors():
assert snap["status"]["errors_total"] >= 1 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(): def test_subscriber_receives_live_turn_events():
async def go(): async def go():
mon = Monitor() mon = Monitor()

39
tests/test_textnorm.py Normal file
View File

@@ -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("") == ""

View File

@@ -126,6 +126,7 @@ class ClaudeBrain:
"너는 사용자의 디스코드 화면공유를 실시간으로 함께 보는 AI 파트너야. " "너는 사용자의 디스코드 화면공유를 실시간으로 함께 보는 AI 파트너야. "
"화면 설명을 참고해 자연스러운 반말/존댓말은 사용자에 맞추고, 짧고 대화하듯 답해. " "화면 설명을 참고해 자연스러운 반말/존댓말은 사용자에 맞추고, 짧고 대화하듯 답해. "
"화면을 못 봤으면 솔직히 말해. " "화면을 못 봤으면 솔직히 말해. "
"네 답변은 음성으로 읽히니 마크다운·코드블록·백틱 같은 서식 없이 평범한 말로만 답해."
) )
def __init__(self, *, model: str = "claude-sonnet-4-5", api_key: str | None = None) -> None: def __init__(self, *, model: str = "claude-sonnet-4-5", api_key: str | None = None) -> None:

View File

@@ -22,6 +22,7 @@ import collections
import json import json
import logging import logging
import os import os
import re
import time import time
from pathlib import Path from pathlib import Path
from typing import Awaitable, Callable from typing import Awaitable, Callable
@@ -32,6 +33,42 @@ log = logging.getLogger("wsai.tts.melo")
_DEFAULT_PYTHON = "/home/claude/jarvis-tts/melo311/bin/python" _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. # A sink receives the finished wav path plus the reply it voices.
Sink = Callable[[str, Reply], Awaitable[None]] 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 """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).""" callers that want the wav directly (e.g. the Discord voice bridge)."""
await self._ensure() await self._ensure()
text = normalize_for_speech(text)
self._n += 1 self._n += 1
out = str(self.out_dir / f"tts-{self._n:06d}.wav") out = str(self.out_dir / f"tts-{self._n:06d}.wav")
req = json.dumps({"text": text, "out": out, "speed": self.speed}) req = json.dumps({"text": text, "out": out, "speed": self.speed})

View File

@@ -88,6 +88,7 @@ class Turn:
self.error = "" self.error = ""
self.total_ms = 0.0 self.total_ms = 0.0
self._steps: list[Step] = [] self._steps: list[Step] = []
self._error_logged = False # count this turn's failure at most once
# -- recording API (called from the pipeline) ------------------------- # # -- recording API (called from the pipeline) ------------------------- #
def heard(self, text: str) -> None: def heard(self, text: str) -> None:
@@ -108,8 +109,19 @@ class Turn:
self.error = error self.error = error
elif any(s.ok is False for s in self._steps): elif any(s.ok is False for s in self._steps):
self.status = "error" 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: else:
self.status = "ok" 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() self._touch()
# -- internal --------------------------------------------------------- # # -- internal --------------------------------------------------------- #
@@ -185,11 +197,8 @@ class Monitor:
return t return t
def _publish(self, t: Turn) -> None: def _publish(self, t: Turn) -> None:
# Recompute error total lazily on error transitions. # errors_total is bumped once when the turn transitions to error, inside
if t.status == "error": # Turn.finish() (via a log event), so this only streams the turn state.
with self._lock:
# errors_total counts turns that ended in error at most once
pass
self._broadcast({"type": "turn", "turn": t.to_dict()}) self._broadcast({"type": "turn", "turn": t.to_dict()})
# -- snapshot / subscribe (read side, HTTP threads) ------------------- # # -- snapshot / subscribe (read side, HTTP threads) ------------------- #

View File

@@ -94,8 +94,9 @@ class Pipeline:
async with turn.step("응답(TTS/전송)"): async with turn.step("응답(TTS/전송)"):
await self._emit(reply) await self._emit(reply)
except Exception as exc: 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}") turn.finish(error=f"{type(exc).__name__}: {exc}")
self.monitor.log("error", f"대화 #{turn.id} 실패: {exc}")
raise raise
else: else:
turn.finish() turn.finish()