370 lines
11 KiB
Python
370 lines
11 KiB
Python
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"})
|