feat: 佛经诵读站点初版(心经/金刚经 + 千问TTS)
- 仿故宫词条页视觉:宣纸底、朱砂印章、横竖排、分页阅读 - 千问 qwen3-tts-flash 服务端代理 + 磁盘/会话双缓存 - 整文合并为单 WAV 播放,段间静音内嵌,锁屏后台不断 - 循环开关、点击段落起读、跟读高亮自动滚动翻页 - 移动端折叠控制栏,部署于 fj.all8ai.top Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""心经诵读站点后端:静态文件托管 + 千问 TTS 代理。"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
CACHE_DIR = BASE_DIR / "cache"
|
||||
ENV_PATH = BASE_DIR / ".env"
|
||||
|
||||
|
||||
def load_env(path: Path) -> None:
|
||||
"""读取 .env 文件并注入环境变量(不覆盖已有变量)。"""
|
||||
if not path.exists():
|
||||
return
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
|
||||
load_env(ENV_PATH)
|
||||
|
||||
API_KEY = os.environ.get("DASHSCOPE_API_KEY", "")
|
||||
DASHSCOPE_BASE = os.environ.get(
|
||||
"DASHSCOPE_BASE",
|
||||
"https://llm-znfsxsdp6uwik7ad.cn-beijing.maas.aliyuncs.com/api/v1",
|
||||
).rstrip("/")
|
||||
TTS_MODEL = os.environ.get("TTS_MODEL", "qwen3-tts-flash")
|
||||
PORT = int(os.environ.get("PORT", "8000"))
|
||||
|
||||
GENERATION_URL = f"{DASHSCOPE_BASE}/services/aigc/multimodal-generation/generation"
|
||||
|
||||
|
||||
def http_post_json(url: str, payload: dict, headers: dict, timeout: int = 60) -> dict:
|
||||
"""发送 JSON POST 请求并返回解析后的响应。"""
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json", **headers},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def synthesize(text: str, voice: str) -> bytes:
|
||||
"""调用千问 TTS 合成语音,返回 wav 音频字节流。"""
|
||||
payload = {
|
||||
"model": TTS_MODEL,
|
||||
"input": {"text": text, "voice": voice},
|
||||
}
|
||||
data = http_post_json(GENERATION_URL, payload, {"Authorization": f"Bearer {API_KEY}"})
|
||||
audio_url = (data.get("output") or {}).get("audio", {}).get("url")
|
||||
if not audio_url:
|
||||
raise RuntimeError(f"TTS 响应缺少音频地址:{json.dumps(data, ensure_ascii=False)[:300]}")
|
||||
with urllib.request.urlopen(audio_url, timeout=60) as resp:
|
||||
return resp.read()
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
"""静态资源 + /api/tts 合成接口。"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=str(BASE_DIR), **kwargs)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
"""处理 POST 请求,目前仅支持 /api/tts。"""
|
||||
if self.path != "/api/tts":
|
||||
self._send_json({"error": "not found"}, status=404)
|
||||
return
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
self._send_json({"error": "请求体不是合法 JSON"}, status=400)
|
||||
return
|
||||
|
||||
text = (body.get("text") or "").strip()
|
||||
voice = (body.get("voice") or "").strip()
|
||||
if not text or not voice:
|
||||
self._send_json({"error": "缺少 text 或 voice 参数"}, status=400)
|
||||
return
|
||||
if len(text) > 600:
|
||||
self._send_json({"error": "文本超过 600 字上限"}, status=400)
|
||||
return
|
||||
if not API_KEY:
|
||||
self._send_json({"error": "服务端未配置 DASHSCOPE_API_KEY"}, status=500)
|
||||
return
|
||||
|
||||
cache_key = hashlib.sha256(f"{TTS_MODEL}|{voice}|{text}".encode("utf-8")).hexdigest()[:24]
|
||||
cache_file = CACHE_DIR / f"{cache_key}.wav"
|
||||
try:
|
||||
if cache_file.exists():
|
||||
audio = cache_file.read_bytes()
|
||||
else:
|
||||
audio = synthesize(text, voice)
|
||||
CACHE_DIR.mkdir(exist_ok=True)
|
||||
cache_file.write_bytes(audio)
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")[:300]
|
||||
self._send_json({"error": f"TTS 服务返回 {exc.code}:{detail}"}, status=502)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 - 统一兜底,向前端返回可读错误
|
||||
self._send_json({"error": f"语音合成失败:{exc}"}, status=502)
|
||||
return
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "audio/wav")
|
||||
self.send_header("Content-Length", str(len(audio)))
|
||||
self.end_headers()
|
||||
self.wfile.write(audio)
|
||||
|
||||
def _send_json(self, obj: dict, status: int = 200) -> None:
|
||||
"""以 JSON 形式返回响应。"""
|
||||
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def end_headers(self) -> None:
|
||||
"""本地开发环境禁用静态资源缓存,避免页面拿到旧版 JS/CSS。"""
|
||||
if not self.path.startswith("/api/"):
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
super().end_headers()
|
||||
|
||||
def log_message(self, fmt, *args) -> None:
|
||||
"""精简请求日志输出。"""
|
||||
if "/api/" in self.path:
|
||||
super().log_message(fmt, *args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
|
||||
print(f"心经诵读站点已启动:http://127.0.0.1:{PORT}")
|
||||
print(f"TTS 模型:{TTS_MODEL} | 上游:{DASHSCOPE_BASE}")
|
||||
if not API_KEY:
|
||||
print("警告:未配置 DASHSCOPE_API_KEY,请复制 .env.example 为 .env 并填入密钥")
|
||||
server.serve_forever()
|
||||
Reference in New Issue
Block a user