Ship native iOS app, Wiki/TTS backend, and skeleton loading UX.
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>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user