from __future__ import annotations import math import base64 import json import re import socket import struct import subprocess import tempfile import urllib.error import urllib.request import wave from collections.abc import Callable from pathlib import Path from typing import Any 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 self._buffer = "" def feed(self, text: str, force: bool = False) -> list[str]: if text: self._buffer += text chunks: list[str] = [] while self._should_emit(force): chunks.append(self._pop_chunk(force)) force = False return [chunk for chunk in chunks if chunk.strip()] def flush(self) -> list[str]: return self.feed("", force=True) def _should_emit(self, force: bool) -> bool: stripped = self._buffer.strip() if not stripped: return False return force or stripped.endswith(("。", "!", "?", ".", "!", "?", "\n")) or len(stripped) >= self.max_chars def _pop_chunk(self, force: bool) -> str: stripped = self._buffer.strip() if force or len(stripped) <= self.max_chars: self._buffer = "" return stripped chunk = stripped[: self.max_chars] self._buffer = stripped[self.max_chars :] return chunk class SineTtsProvider: def __init__(self, sample_rate: int = 16000) -> None: self.sample_rate = sample_rate self.loaded = False def load(self) -> None: self.loaded = True def synthesize(self, text: str) -> AudioSegment: if not self.loaded: raise ProviderError( ErrorCode.TTS_SYNTHESIS_FAILED, "TTS provider is not loaded", False, "sine-tts", "tts", ) clean = text.strip() if not clean: raise ProviderError( ErrorCode.TTS_EMPTY_AUDIO, "cannot synthesize empty text", True, "sine-tts", "tts", ) duration_ms = max(120, min(1200, len(clean) * 45)) samples = int(self.sample_rate * duration_ms / 1000) pcm = bytearray() for idx in range(samples): sample = int(math.sin(2 * math.pi * 440 * idx / self.sample_rate) * 12000) pcm.extend(struct.pack(" None: self.voice = voice self.loaded = False def load(self) -> None: self.loaded = True def synthesize(self, text: str) -> AudioSegment: clean = text.strip() if not clean: raise ProviderError( ErrorCode.TTS_EMPTY_AUDIO, "cannot synthesize empty text", True, "macos-say", "tts", ) with tempfile.TemporaryDirectory() as tmp: aiff_output = Path(tmp) / "speech.aiff" wav_output = Path(tmp) / "speech.wav" command = ["say", "-o", str(aiff_output)] if self.voice: command.extend(["-v", self.voice]) command.append(clean) try: subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) except (FileNotFoundError, subprocess.CalledProcessError) as exc: raise ProviderError( ErrorCode.TTS_SYNTHESIS_FAILED, f"macOS say failed: {exc}", False, "macos-say", "tts", ) from exc try: subprocess.run( ["afconvert", "-f", "WAVE", "-d", "LEI16", "-c", "1", str(aiff_output), str(wav_output)], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) with wave.open(str(wav_output), "rb") as handle: sample_rate = handle.getframerate() channels = handle.getnchannels() data = handle.readframes(handle.getnframes()) metadata = {"text": clean} except (FileNotFoundError, subprocess.CalledProcessError, wave.Error): data = aiff_output.read_bytes() sample_rate = 16000 channels = 1 metadata = {"text": clean, "format": "aiff"} if not data: raise ProviderError( ErrorCode.TTS_EMPTY_AUDIO, "macOS say produced empty audio", True, "macos-say", "tts", ) duration_ms = int(len(data) / max(1, sample_rate * channels * 2) * 1000) return AudioSegment(data, sample_rate, channels, 0, max(120, duration_ms, len(clean) * 45), metadata) class CloudTtsProvider: def __init__( self, config: AppConfig, timeout_s: float = 60.0, urlopen: Callable[..., Any] | None = None, ) -> None: self.config = config self.timeout_s = timeout_s self.urlopen = urlopen or urllib.request.urlopen self.loaded = False def load(self) -> None: self.config.require_llm_credentials() self.loaded = True def synthesize(self, text: str) -> AudioSegment: if not self.loaded: raise ProviderError( ErrorCode.TTS_SYNTHESIS_FAILED, "cloud TTS provider is not loaded", False, "newapi-tts", "tts", ) clean = text.strip() if not clean: raise ProviderError( ErrorCode.TTS_EMPTY_AUDIO, "cannot synthesize empty text", True, "newapi-tts", "tts", ) payload = { "model": self.config.tts_model, "messages": [{"role": "assistant", "content": clean}], "audio": {"format": "wav", "voice": self.config.tts_voice}, "stream": False, } request = urllib.request.Request( self.config.api_url("/v1/chat/completions"), data=json.dumps(payload).encode("utf-8"), headers={ "Authorization": f"Bearer {self.config.llm_api_key}", "Content-Type": "application/json", }, method="POST", ) try: with self.urlopen(request, timeout=self.timeout_s) as response: payload = json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: raise ProviderError( ErrorCode.TTS_SYNTHESIS_FAILED, f"cloud TTS HTTP error {exc.code}", exc.code >= 500, "newapi-tts", "tts", ) from exc except (urllib.error.URLError, TimeoutError, socket.timeout, json.JSONDecodeError) as exc: raise ProviderError( ErrorCode.TTS_SYNTHESIS_FAILED, f"cloud TTS request failed: {exc}", True, "newapi-tts", "tts", ) from exc choices = payload.get("choices") or [] message = (choices[0].get("message") if choices else {}) or {} audio = message.get("audio") or {} encoded = audio.get("data") if not encoded: raise ProviderError( ErrorCode.TTS_EMPTY_AUDIO, "cloud TTS returned empty audio", True, "newapi-tts", "tts", ) try: data = base64.b64decode(encoded) except (TypeError, ValueError) as exc: raise ProviderError( ErrorCode.TTS_SYNTHESIS_FAILED, f"cloud TTS returned invalid base64 audio: {exc}", True, "newapi-tts", "tts", ) from exc return AudioSegment(data, 24000, 1, 0, max(120, len(clean) * 45), {"text": clean, "format": "wav"})