feat(voice): bracketed emotion tags, [잡음] for noise, spoken emotion, concise replies

- 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 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-08-22 01:44:25 +09:00
parent 2c4c9ad82c
commit f585ed7b76
2 changed files with 25 additions and 4 deletions

View File

@@ -127,6 +127,9 @@ class ClaudeBrain:
"화면 설명을 참고해 자연스러운 반말/존댓말은 사용자에 맞추고, 짧고 대화하듯 답해. " "화면 설명을 참고해 자연스러운 반말/존댓말은 사용자에 맞추고, 짧고 대화하듯 답해. "
"화면을 못 봤으면 솔직히 말해. " "화면을 못 봤으면 솔직히 말해. "
"네 답변은 음성으로 읽히니 마크다운·코드블록·백틱 같은 서식 없이 평범한 말로만 답해. " "네 답변은 음성으로 읽히니 마크다운·코드블록·백틱 같은 서식 없이 평범한 말로만 답해. "
"답변은 반드시 대괄호 감정 표시 한 개로 시작해. 예: [힘차게], [궁금], [반가움], [차분하게], [웃으며]. "
"감정 태그는 답변 맨 앞에 딱 한 번만 붙이고, 그 뒤에 실제 답을 이어써. "
"답변은 최대한 짧고 간결하게, 한두 문장 이내로 해."
) )
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

@@ -18,6 +18,7 @@ from __future__ import annotations
import json import json
import logging import logging
import queue import queue
import re
import threading import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -25,6 +26,21 @@ from .monitor import Monitor
log = logging.getLogger("wsai.dashboard") 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"): def _make_handler(dash: "Dashboard"):
monitor = dash.monitor monitor = dash.monitor
@@ -259,12 +275,14 @@ class Dashboard:
heard = (self._submit(self.stt.transcribe(wav)) or "").strip() heard = (self._submit(self.stt.transcribe(wav)) or "").strip()
turn.heard(heard or "(빈 결과)") turn.heard(heard or "(빈 결과)")
if not heard: 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() turn.finish()
return {"heard": heard, "reply": "", "wav": b""} return {"heard": heard, "reply": "[잡음]", "wav": b""}
reply_text = self._think(heard) reply_text = self._think(heard)
turn.replied(reply_text) 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: with open(out_path, "rb") as f:
reply_wav = f.read() reply_wav = f.read()
ms = int((time.monotonic() - t0) * 1000) ms = int((time.monotonic() - t0) * 1000)