From f585ed7b76d10ddd63020495f77010d477c1a79b Mon Sep 17 00:00:00 2001 From: EJClaw Date: Sat, 22 Aug 2026 01:44:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(voice):=20bracketed=20emotion=20tags,=20[?= =?UTF-8?q?=EC=9E=A1=EC=9D=8C]=20for=20noise,=20spoken=20emotion,=20concis?= =?UTF-8?q?e=20replies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Brain persona now prefixes every reply with one bracketed emotion tag (e.g. [반가움], [궁금]) and keeps replies to one or two short sentences. - Empty/unrecognised audio (silence/noise) is reported as reply "[잡음]" with no TTS playback instead of an empty reply. - TTS speaks the bracketed emotion too: "[힘차게] 안녕!" is synthesised as "힘차게, 안녕!" via a leading-tag -> spoken-word transform. Co-Authored-By: Claude Opus 4.7 --- wsai/backends/claude.py | 5 ++++- wsai/dashboard.py | 24 +++++++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/wsai/backends/claude.py b/wsai/backends/claude.py index 5488328..363ef62 100644 --- a/wsai/backends/claude.py +++ b/wsai/backends/claude.py @@ -126,7 +126,10 @@ class ClaudeBrain: "너는 사용자의 디스코드 화면공유를 실시간으로 함께 보는 AI 파트너야. " "화면 설명을 참고해 자연스러운 반말/존댓말은 사용자에 맞추고, 짧고 대화하듯 답해. " "화면을 못 봤으면 솔직히 말해. " - "네 답변은 음성으로 읽히니 마크다운·코드블록·백틱 같은 서식 없이 평범한 말로만 답해." + "네 답변은 음성으로 읽히니 마크다운·코드블록·백틱 같은 서식 없이 평범한 말로만 답해. " + "답변은 반드시 대괄호 감정 표시 한 개로 시작해. 예: [힘차게], [궁금], [반가움], [차분하게], [웃으며]. " + "감정 태그는 답변 맨 앞에 딱 한 번만 붙이고, 그 뒤에 실제 답을 이어써. " + "답변은 최대한 짧고 간결하게, 한두 문장 이내로 해." ) def __init__(self, *, model: str = "claude-sonnet-4-5", api_key: str | None = None) -> None: diff --git a/wsai/dashboard.py b/wsai/dashboard.py index c876492..e2397a4 100644 --- a/wsai/dashboard.py +++ b/wsai/dashboard.py @@ -18,6 +18,7 @@ from __future__ import annotations import json import logging import queue +import re import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -25,6 +26,21 @@ from .monitor import Monitor log = logging.getLogger("wsai.dashboard") +_EMOTION_RE = re.compile(r"^\s*\[([^\]]+)\]\s*(.*)", re.S) + + +def _speech_text(reply: str) -> str: + """Turn a reply like ``[힘차게] 안녕!`` into what the TTS should actually say. + + The bracketed emotion is spoken too (per user request), so the leading tag + becomes a natural spoken word: ``[힘차게] 안녕!`` -> ``힘차게, 안녕!``. Replies + without a tag are spoken as-is.""" + m = _EMOTION_RE.match(reply or "") + if not m: + return reply + emotion, rest = m.group(1).strip(), m.group(2).strip() + return f"{emotion}, {rest}" if rest else emotion + def _make_handler(dash: "Dashboard"): monitor = dash.monitor @@ -259,12 +275,14 @@ class Dashboard: heard = (self._submit(self.stt.transcribe(wav)) or "").strip() turn.heard(heard or "(빈 결과)") if not heard: - # Nothing recognised (silence/noise): skip the turn, tell the bot. + # Nothing recognised (silence/noise): mark it as [잡음] and skip + # the brain/TTS so the bot plays nothing back. + turn.replied("[잡음]") turn.finish() - return {"heard": heard, "reply": "", "wav": b""} + return {"heard": heard, "reply": "[잡음]", "wav": b""} reply_text = self._think(heard) turn.replied(reply_text) - out_path = self._submit(self.tts.synth(reply_text)) + out_path = self._submit(self.tts.synth(_speech_text(reply_text))) with open(out_path, "rb") as f: reply_wav = f.read() ms = int((time.monotonic() - t0) * 1000)