05e173b293
Replace the WebView shell with SwiftUI screens, add account-scoped Wiki and TTS APIs with adaptive review and photo scan support, and keep web/iOS pages usable while data loads asynchronously. Co-authored-by: Cursor <cursoragent@cursor.com>
160 lines
5.3 KiB
Python
160 lines
5.3 KiB
Python
from __future__ import annotations
|
||
|
||
import base64
|
||
import hashlib
|
||
import json
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
from pathlib import Path
|
||
|
||
from config import get_settings
|
||
|
||
_CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "tts_cache"
|
||
_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||
_TTS_USER_AGENT = "WordLoop-TTS/1.0"
|
||
|
||
|
||
def _open_tts_request(
|
||
request: urllib.request.Request,
|
||
*,
|
||
timeout: int,
|
||
):
|
||
# 统一绕过系统代理;公网网关会拒绝 Python-urllib 默认 UA(403)
|
||
return _NO_PROXY_OPENER.open(request, timeout=timeout)
|
||
|
||
|
||
class TtsServiceError(Exception):
|
||
pass
|
||
|
||
|
||
class TtsService:
|
||
def is_configured(self) -> bool:
|
||
settings = get_settings()
|
||
return settings.tkmind_tts_enabled and bool(settings.tkmind_tts_base_url.strip())
|
||
|
||
def synthesize(self, text: str) -> tuple[bytes, str]:
|
||
cleaned = text.strip()
|
||
if not cleaned:
|
||
raise TtsServiceError("朗读文本不能为空")
|
||
if len(cleaned) > 200:
|
||
raise TtsServiceError("朗读文本过长")
|
||
|
||
settings = get_settings()
|
||
if not settings.tkmind_tts_enabled:
|
||
raise TtsServiceError("单词朗读未启用")
|
||
base_url = settings.tkmind_tts_base_url.strip().rstrip("/")
|
||
if not base_url:
|
||
raise TtsServiceError("TTS 服务地址未配置")
|
||
|
||
cache_key = self._cache_key(cleaned, settings.tkmind_tts_spk_id)
|
||
cached = self._read_cache(cache_key)
|
||
if cached is not None:
|
||
return cached, "audio/wav"
|
||
|
||
audio_bytes, content_type = self._request_upstream(
|
||
base_url=base_url,
|
||
text=cleaned,
|
||
spk_id=settings.tkmind_tts_spk_id.strip(),
|
||
timeout=settings.tkmind_tts_timeout,
|
||
)
|
||
self._write_cache(cache_key, audio_bytes)
|
||
return audio_bytes, content_type
|
||
|
||
@staticmethod
|
||
def _cache_key(text: str, spk_id: str) -> str:
|
||
raw = f"{text.lower()}|{spk_id}".encode("utf-8")
|
||
return hashlib.sha256(raw).hexdigest()
|
||
|
||
@staticmethod
|
||
def _read_cache(cache_key: str) -> bytes | None:
|
||
path = _CACHE_DIR / f"{cache_key}.wav"
|
||
if not path.is_file():
|
||
return None
|
||
return path.read_bytes()
|
||
|
||
@staticmethod
|
||
def _write_cache(cache_key: str, audio_bytes: bytes) -> None:
|
||
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||
path = _CACHE_DIR / f"{cache_key}.wav"
|
||
path.write_bytes(audio_bytes)
|
||
|
||
def _request_upstream(
|
||
self,
|
||
*,
|
||
base_url: str,
|
||
text: str,
|
||
spk_id: str,
|
||
timeout: int,
|
||
) -> tuple[bytes, str]:
|
||
payload: dict[str, str] = {"tts_text": text}
|
||
if spk_id:
|
||
payload["spk_id"] = spk_id
|
||
|
||
request = urllib.request.Request(
|
||
f"{base_url}/inference_sft",
|
||
data=urllib.parse.urlencode(payload).encode("utf-8"),
|
||
headers={
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
"User-Agent": _TTS_USER_AGENT,
|
||
},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with _open_tts_request(request, timeout=timeout) as response:
|
||
content_type = response.headers.get_content_type() or "application/octet-stream"
|
||
body = response.read()
|
||
except urllib.error.HTTPError as exc:
|
||
detail = exc.read().decode("utf-8", errors="replace")
|
||
raise TtsServiceError(f"TTS 服务异常(HTTP {exc.code})") from exc
|
||
except urllib.error.URLError as exc:
|
||
raise TtsServiceError(f"TTS 服务不可达:{exc.reason}") from exc
|
||
|
||
if content_type.startswith("audio/"):
|
||
if not body:
|
||
raise TtsServiceError("TTS 返回空音频")
|
||
return body, content_type
|
||
|
||
return self._parse_json_response(body)
|
||
|
||
@staticmethod
|
||
def _parse_json_response(body: bytes) -> tuple[bytes, str]:
|
||
try:
|
||
payload = json.loads(body.decode("utf-8"))
|
||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||
if body[:4] == b"RIFF":
|
||
return body, "audio/wav"
|
||
raise TtsServiceError("TTS 响应格式无效") from exc
|
||
|
||
code = payload.get("code", 0)
|
||
if code not in (0, "0", None):
|
||
message = str(payload.get("message") or "TTS 合成失败")
|
||
raise TtsServiceError(message)
|
||
|
||
data = payload.get("data")
|
||
audio_b64 = TtsService._extract_audio_base64(data)
|
||
if not audio_b64:
|
||
raise TtsServiceError("TTS 响应缺少音频数据")
|
||
|
||
try:
|
||
audio_bytes = base64.b64decode(audio_b64)
|
||
except (ValueError, TypeError) as exc:
|
||
raise TtsServiceError("TTS 音频解码失败") from exc
|
||
if not audio_bytes:
|
||
raise TtsServiceError("TTS 返回空音频")
|
||
return audio_bytes, "audio/wav"
|
||
|
||
@staticmethod
|
||
def _extract_audio_base64(data: object) -> str | None:
|
||
if isinstance(data, str) and data.strip():
|
||
return data.strip()
|
||
if isinstance(data, dict):
|
||
for key in ("audio", "wav", "speech", "data"):
|
||
value = data.get(key)
|
||
if isinstance(value, str) and value.strip():
|
||
return value.strip()
|
||
return None
|
||
|
||
|
||
tts_service = TtsService()
|