Files
Owner/src/owner_voice_pet/tts.py
T

370 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("<h", sample))
return AudioSegment(bytes(pcm), self.sample_rate, 1, 0, duration_ms, {"text": clean})
class MacSayTtsProvider:
def __init__(self, voice: str | None = None) -> 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"})