[播报文本净化]:完成TTS表情包过滤,包含emoji清理、上下文净化和回归测试

This commit is contained in:
mkbk
2026-06-18 12:45:31 +08:00
parent 25255f178e
commit 3408a30e25
8 changed files with 285 additions and 22 deletions
+2 -1
View File
@@ -27,7 +27,7 @@ from .llm import MockLlmProvider, OpenAICompatibleLlmProvider
from .pipeline import PipelineResult, VoicePipeline
from .runtime import LiveVoiceRuntime, RuntimeSummary, TerminalRuntimeReporter, TurnResult, build_live_runtime
from .simulation import run_simulated_live
from .tts import CloudTtsProvider, MacSayTtsProvider, SentenceBuffer, SineTtsProvider
from .tts import CloudTtsProvider, MacSayTtsProvider, SentenceBuffer, SineTtsProvider, sanitize_tts_text
from .assets import validate_pet_assets
from .ui import ConsolePetWindow, PetStateController, PetVisualState
@@ -70,6 +70,7 @@ __all__ = [
"MacSayTtsProvider",
"SentenceBuffer",
"SineTtsProvider",
"sanitize_tts_text",
"validate_pet_assets",
"ConsolePetWindow",
"PetStateController",
+21 -6
View File
@@ -47,7 +47,7 @@ from .protocols import (
WakeWordProvider,
)
from .stt import is_valid_transcript_text
from .tts import SentenceBuffer
from .tts import SentenceBuffer, sanitize_tts_text
from .vad import VadRecorder
@@ -389,7 +389,7 @@ class TurnController:
spoken_parts.append(speak_result.spoken_text)
except ProviderError as exc:
return self._recover(exc, turn_id, completed_turns=completed_turns)
if not assistant_text.strip() and not "".join(spoken_parts).strip():
if not assistant_text.strip():
return self._recover(
ProviderError(
ErrorCode.LLM_EMPTY_REPLY,
@@ -402,6 +402,18 @@ class TurnController:
completed_turns=completed_turns,
)
spoken_text = "".join(spoken_parts)
if not spoken_text.strip() and not interrupted:
return self._recover(
ProviderError(
ErrorCode.TTS_EMPTY_AUDIO,
"LLM reply contained no speakable text after TTS sanitization",
True,
"voice-assistant-pipeline",
"tts",
),
turn_id,
completed_turns=completed_turns,
)
if spoken_text.strip():
self.context.append_assistant(spoken_text)
states = list(self._states)
@@ -410,7 +422,7 @@ class TurnController:
return TurnResult(
True,
user_text,
spoken_text or assistant_text,
spoken_text,
states=states,
completed_turns=1,
)
@@ -465,20 +477,23 @@ class TurnController:
return user_text
def _speak(self, sentence: str, turn_id: int) -> SpeakResult:
spoken_sentence = sanitize_tts_text(sentence)
if not spoken_sentence:
return SpeakResult("")
self._event(TTS_STARTED, PipelineState.SPEAKING, "播放中:正在播报回复", turn_id=turn_id)
segment = self.tts.synthesize(sentence)
segment = self.tts.synthesize(spoken_sentence)
if not self._can_interrupt_playback(segment):
playback = self.transport.play_pcm(segment)
if playback.error:
raise playback.error
self._event(PLAYBACK_FINISHED, PipelineState.SPEAKING, "", turn_id=turn_id)
self._drain_input_after_playback()
return SpeakResult(sentence)
return SpeakResult(spoken_sentence)
if self._play_interruptible(segment, turn_id=turn_id):
return SpeakResult("", interrupted=True)
self._event(PLAYBACK_FINISHED, PipelineState.SPEAKING, "", turn_id=turn_id)
self._drain_input_after_playback()
return SpeakResult(sentence)
return SpeakResult(spoken_sentence)
def _can_interrupt_playback(self, segment: AudioSegment) -> bool:
return (
+1 -1
View File
@@ -9,7 +9,7 @@ from .models import Message
@dataclass(slots=True)
class ConversationContext:
system_prompt: str = "你是一个中文桌宠助手,回答要简洁、自然、适合语音播报。"
system_prompt: str = "你是一个中文桌宠助手,回答要简洁、自然、适合语音播报。不要输出 emoji、表情包、Markdown 图片。"
max_messages: int = 12
max_chars: int = 12000
_messages: list[Message] = field(default_factory=list)
+25 -5
View File
@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
from .conversation import ConversationContext
from .models import AudioSegment, ErrorCode, PipelineState, ProviderError
from .protocols import AudioTransport, LlmProvider, SttProvider, TtsProvider, WakeWordProvider
from .tts import SentenceBuffer
from .tts import SentenceBuffer, sanitize_tts_text
from .vad import VadRecorder
@@ -98,27 +98,36 @@ class VoicePipeline:
self.context.append_user(text)
self._state(PipelineState.THINKING)
assistant_text = ""
spoken_parts: list[str] = []
played = 0
try:
for delta in self.llm.stream_reply(self.context.build_llm_messages()):
assistant_text += delta.text_delta
for sentence in self.sentence_buffer.feed(delta.text_delta, bool(delta.finish_reason)):
spoken_sentence = sanitize_tts_text(sentence)
if not spoken_sentence:
continue
self._state(PipelineState.SPEAKING)
self.suppress_input = True
audio = self.tts.synthesize(sentence)
audio = self.tts.synthesize(spoken_sentence)
playback = self.transport.play_pcm(audio)
self.suppress_input = False
if playback.error:
return self._recover(playback.error)
spoken_parts.append(spoken_sentence)
played += 1
for sentence in self.sentence_buffer.flush():
spoken_sentence = sanitize_tts_text(sentence)
if not spoken_sentence:
continue
self._state(PipelineState.SPEAKING)
self.suppress_input = True
audio = self.tts.synthesize(sentence)
audio = self.tts.synthesize(spoken_sentence)
playback = self.transport.play_pcm(audio)
self.suppress_input = False
if playback.error:
return self._recover(playback.error)
spoken_parts.append(spoken_sentence)
played += 1
except ProviderError as exc:
self.suppress_input = False
@@ -133,9 +142,20 @@ class VoicePipeline:
"llm",
)
)
self.context.append_assistant(assistant_text)
spoken_text = "".join(spoken_parts)
if not spoken_text.strip():
return self._recover(
ProviderError(
ErrorCode.TTS_EMPTY_AUDIO,
"LLM reply contained no speakable text after TTS sanitization",
True,
"voice-pipeline",
"tts",
)
)
self.context.append_assistant(spoken_text)
self._state(PipelineState.WAKE_LISTENING)
return PipelineResult(True, text, assistant_text, list(self.states), played_segments=played)
return PipelineResult(True, text, spoken_text, list(self.states), played_segments=played)
def _recover(self, error: ProviderError) -> PipelineResult:
self._state(PipelineState.ERROR_RECOVERING)
+28 -7
View File
@@ -33,7 +33,7 @@ from .models import AudioSegment, ErrorCode, PipelineState, ProviderError
from .protocols import AudioTransport, LlmProvider, RealtimeSttProvider, SttProvider, TtsProvider, WakeWordProvider
from .stt import CloudAsrSttProvider, SherpaOnnxSttProvider, is_valid_transcript_text
from .transport import SoundDeviceAudioTransport
from .tts import CloudTtsProvider, MacSayTtsProvider, SentenceBuffer
from .tts import CloudTtsProvider, MacSayTtsProvider, SentenceBuffer, sanitize_tts_text
from .vad import EnergyVadProvider, HybridVadProvider, PrimarySpeakerVadRecorder, SherpaOnnxVadProvider, VadRecorder
from .wakeword import SherpaOnnxKeywordWakeWordProvider
@@ -267,13 +267,18 @@ class LiveVoiceRuntime:
self.context.append_user(user_text)
self._event(LLM_STARTED, PipelineState.THINKING, "思考中:正在生成回复", turn_id=turn_id)
assistant_text = ""
spoken_parts: list[str] = []
try:
for delta in self.llm.stream_reply(self.context.build_llm_messages()):
assistant_text += delta.text_delta
for sentence in self.sentence_buffer.feed(delta.text_delta, bool(delta.finish_reason)):
self._speak(sentence, turn_id)
spoken = self._speak(sentence, turn_id)
if spoken:
spoken_parts.append(spoken)
for sentence in self.sentence_buffer.flush():
self._speak(sentence, turn_id)
spoken = self._speak(sentence, turn_id)
if spoken:
spoken_parts.append(spoken)
except ProviderError as exc:
return self._recover(exc, turn_id)
if not assistant_text.strip():
@@ -287,18 +292,34 @@ class LiveVoiceRuntime:
),
turn_id,
)
self.context.append_assistant(assistant_text)
spoken_text = "".join(spoken_parts)
if not spoken_text.strip():
return self._recover(
ProviderError(
ErrorCode.TTS_EMPTY_AUDIO,
"LLM reply contained no speakable text after TTS sanitization",
True,
"live-runtime",
"tts",
),
turn_id,
)
self.context.append_assistant(spoken_text)
self._event(STANDBY_RESUMED, PipelineState.WAKE_LISTENING, "恢复待机:可继续唤醒", turn_id=turn_id)
return TurnResult(True, user_text, assistant_text, states=list(self._states))
return TurnResult(True, user_text, spoken_text, states=list(self._states))
def _speak(self, sentence: str, turn_id: int) -> None:
def _speak(self, sentence: str, turn_id: int) -> str:
spoken_sentence = sanitize_tts_text(sentence)
if not spoken_sentence:
return ""
self._event(TTS_STARTED, PipelineState.SPEAKING, "播放中:正在播报回复", turn_id=turn_id)
segment = self.tts.synthesize(sentence)
segment = self.tts.synthesize(spoken_sentence)
playback = self.transport.play_pcm(segment)
if playback.error:
raise playback.error
self._event(PLAYBACK_FINISHED, PipelineState.SPEAKING, "播放完成", turn_id=turn_id)
self._drain_input_after_playback()
return spoken_sentence
def _recover(self, error: ProviderError, turn_id: int) -> TurnResult:
self._event(STAGE_ERROR, PipelineState.ERROR_RECOVERING, error.message, turn_id=turn_id, payload={"error": error})
+126
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import math
import base64
import json
import re
import socket
import struct
import subprocess
@@ -18,6 +19,131 @@ from .config import AppConfig
from .models import AudioSegment, ErrorCode, ProviderError
_MARKDOWN_IMAGE_RE = re.compile(r"!\[[^\]]*]\([^)]*\)")
_SHORTCODE_EMOJI_RE = re.compile(r":(?:[A-Za-z][A-Za-z0-9_+\-]{1,31}):")
_ASCII_KAOMOJI_RE = re.compile(r"(?:\^_?\^|T_T|QAQ|QwQ|qwq|orz)")
_SYMBOL_KAOMOJI_RE = re.compile(r"[\(][^()()]{0,20}[\u00b0\u2500-\u2bff][^()()]{0,20}[\)][^\s,。!?,.!?]{0,10}")
_EMOJI_RANGES = (
(0x1F000, 0x1FAFF),
(0x2600, 0x27BF),
(0x2300, 0x23FF),
)
_EMOJI_JOINERS = {0x200D, 0x20E3}
_BRACKET_PAIRS = {"[": "]", "": "", "(": ")", "": ""}
_BRACKET_EMOTE_WORDS = {
"ok",
"doge",
"emoji",
"一笑",
"偷笑",
"傻笑",
"加油",
"发呆",
"吐舌",
"呲牙",
"",
"哭泣",
"",
"大哭",
"大笑",
"委屈",
"害羞",
"尴尬",
"开心",
"微笑",
"",
"惊讶",
"惊喜",
"惊恐",
"捂脸",
"抱拳",
"擦汗",
"",
"",
"流汗",
"流泪",
"滑稽",
"爱心",
"玫瑰",
"生气",
"白眼",
"点赞",
"破涕为笑",
"",
"笑哭",
"鼓掌",
"比心",
"亲亲",
"调皮",
"难过",
"高兴",
"鼓励",
"狗头",
}
def sanitize_tts_text(text: str) -> str:
clean = text.strip()
if not clean:
return ""
clean = _MARKDOWN_IMAGE_RE.sub("", clean)
clean = _SHORTCODE_EMOJI_RE.sub("", clean)
clean = _strip_bracket_emotes(clean)
clean = _SYMBOL_KAOMOJI_RE.sub("", clean)
clean = _ASCII_KAOMOJI_RE.sub("", clean)
clean = "".join(ch for ch in clean if not _is_emoji_char(ch))
return _normalize_spoken_text(clean)
def _strip_bracket_emotes(text: str) -> str:
result: list[str] = []
index = 0
while index < len(text):
ch = text[index]
close = _BRACKET_PAIRS.get(ch)
if close is None:
result.append(ch)
index += 1
continue
close_index = text.find(close, index + 1)
if close_index == -1 or close_index - index > 12:
result.append(ch)
index += 1
continue
content = text[index + 1 : close_index]
if _is_bracket_emote(content):
index = close_index + 1
continue
result.append(ch)
index += 1
return "".join(result)
def _is_bracket_emote(content: str) -> bool:
token = re.sub(r"\s+", "", content.strip()).lower()
if not token or len(token) > 8:
return False
if token in _BRACKET_EMOTE_WORDS or token.endswith("表情"):
return True
return all(_is_emoji_char(ch) for ch in token)
def _is_emoji_char(ch: str) -> bool:
codepoint = ord(ch)
if codepoint in _EMOJI_JOINERS or 0xFE00 <= codepoint <= 0xFE0F:
return True
return any(start <= codepoint <= end for start, end in _EMOJI_RANGES)
def _normalize_spoken_text(text: str) -> str:
clean = re.sub(r"[ \t]+", " ", text)
clean = re.sub(r"\s+([,。!?,.!?;:])", r"\1", clean)
clean = re.sub(r"([,])\s*([。!?!?])", r"\2", clean)
clean = re.sub(r"([。!?!?]){2,}", r"\1", clean)
clean = re.sub(r"\s{2,}", " ", clean)
return clean.strip(" \t\r\n,;:")
class SentenceBuffer:
def __init__(self, max_chars: int = 80) -> None:
self.max_chars = max_chars