← 文档库
📄 文档docs/plans/2026-06-27-doc-tts-audiobook.md
🌱 创建于2026-08-07 23:29
✏️ 修改于2026-08-07 23:29
👁️ 浏览量—
🔗 短链接/~learn/s/e3a7b51d
🔐 可见性

文档听书模块 (TTS Audiobook) Implementation Plan

> For Claude: Use ${SUPERPOWERS_SKILLS_ROOT}/skills/collaboration/executing-plans/SKILL.md to implement this plan task-by-task.

Goal: 为文档浏览模块添加"听书"功能 —— 底部固定播放器条,用火山引擎大模型 TTS(豆包同源精品音色)朗读文档正文,支持进度跟踪、续听、音色切换、语速调节、段落高亮跟随,跳过代码块/图表/URL 等非正文内容。

Architecture: 后端在 unified_server.py 新增 /api/tts HTTP 接口,调用火山引擎大模型 TTS(https://openspeech.bytedance.com/api/v1/tts,Bearer Token 鉴权,非流式 HTTP 一次性返回 MP3),用 SQLite (tts_cache.db) 做文本→音频缓存。前端在 DOC_PAGE 模板中嵌入悬浮播放器组件,提取清洗正文→按段落分 chunk→逐段请求/播放→进度条+段落高亮+续听(localStorage)。

Tech Stack: Python 3.12 标准库(urllib, sqlite3, json, hashlib, uuid, base64),火山引擎大模型 TTS HTTP API (v1),原生 JavaScript + HTML5 Audio API,CSS 暗色主题(复用现有 CSS_BASE 变量)。

前置条件(需要用户手动完成一次): 在火山引擎控制台 https://console.volcengine.com/speech/app 创建"语音合成"应用,获取 AppID 和 Access Token,填入 api_balance_config.json 的 volcengine.tts.appid 和 volcengine.tts.access_token。开通后有 20,000 字符免费试用额度。

---

文件清单

操作 文件路径 说明
---------------------
新建 adata/aohan/quant/utils/tts_service.py TTS 服务封装类(调用火山API + SQLite缓存)
新建 tests/test_tts_service.py TTS 服务单元测试
修改 api_balance_config.json(可选:example 文件) 增加 tts 子配置
修改 unified_server.py 新增 3 个路由 + 注入播放器 JS/CSS 到 DOC_PAGE

---

Task 1: 配置结构与测试夹具

Files:

Step 1: 在 example 配置里加入 tts 字段

在 api_balance_config.example.json 的 volcengine 对象中加入:

"tts": {
  "enabled": false,
  "appid": "your_appid_here",
  "access_token": "your_access_token_here",
  "default_voice": "BV700_V2_streaming",
  "default_speed": 1.0,
  "cluster": "volcano_tts"
}

Step 2: 创建测试文件骨架

# tests/test_tts_service.py
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))

import unittest
from unittest.mock import patch, MagicMock

Step 3: 运行

cd /Users/aohan/PycharmProjects/adata
python -m pytest tests/test_tts_service.py -v

Expected: 收集到 0 个测试,无报错。

---

Task 2: TTS 服务类 —— 缓存层(先写测试)

Files:

这一步只实现缓存层(SQLite),不实现外部 API 调用。

Step 1: 写测试

在 tests/test_tts_service.py 添加:

import tempfile, sqlite3

class TestTTSServiceCache(unittest.TestCase):
    def setUp(self):
        self.tmpdir = tempfile.mkdtemp()
        self.db_path = os.path.join(self.tmpdir, "tts_cache.db")

    def _make_service(self):
        from adata.aohan.quant.utils.tts_service import TTSService
        return TTSService(
            config={"appid": "test", "access_token": "test",
                    "cluster": "volcano_tts", "default_voice": "BV700_V2_streaming",
                    "default_speed": 1.0},
            db_path=self.db_path,
        )

    def test_cache_key_is_deterministic(self):
        svc = self._make_service()
        k1 = svc._make_cache_key("你好", "BV700_V2_streaming", 1.0)
        k2 = svc._make_cache_key("你好", "BV700_V2_streaming", 1.0)
        k3 = svc._make_cache_key("你好", "BV701_streaming", 1.0)
        self.assertEqual(k1, k2)
        self.assertNotEqual(k1, k3)
        self.assertIsInstance(k1, str)
        self.assertEqual(len(k1), 64)  # sha256 hex

    def test_cache_set_and_get(self):
        svc = self._make_service()
        key = svc._make_cache_key("hello", "v", 1.0)
        self.assertIsNone(svc._cache_get(key))
        fake_mp3 = b"ID3\x03fake_mp3_data"
        svc._cache_set(key, fake_mp3, text_length=5, voice_type="v")
        got = svc._cache_get(key)
        self.assertEqual(got, fake_mp3)

    def test_cache_table_created_on_init(self):
        svc = self._make_service()
        conn = sqlite3.connect(self.db_path)
        cur = conn.execute(
            "SELECT name FROM sqlite_master WHERE type='table' AND name='tts_cache'"
        )
        self.assertIsNotNone(cur.fetchone())
        conn.close()

if __name__ == "__main__":
    unittest.main()

Step 2: 运行测试 → 失败

python -m pytest tests/test_tts_service.py -v

Expected: ImportError / AttributeError(TTSService 还不存在)。

Step 3: 实现缓存层最小代码

创建 adata/aohan/quant/utils/tts_service.py:

import os
import json
import uuid
import base64
import hashlib
import sqlite3
import urllib.request
import urllib.error
from typing import Optional


class TTSError(Exception):
    pass


VOICE_PRESETS = [
    {"id": "BV700_V2_streaming", "name": "灿灿 2.0", "category": "通用/多情感",
     "desc": "自然女声,22种情感,适合通用朗读"},
    {"id": "BV701_V2_streaming", "name": "擎苍 2.0", "category": "有声阅读/旁白",
     "desc": "沉浸旁白男声,10种情感,最适合读书"},
    {"id": "BV406_V2_streaming", "name": "梓梓 2.0", "category": "超自然女声",
     "desc": "自然女声,7种情感"},
    {"id": "BV407_V2_streaming", "name": "燃燃 2.0", "category": "超自然男声",
     "desc": "活力男声"},
    {"id": "BV001_V2_streaming", "name": "通用女声 2.0", "category": "通用",
     "desc": "标准女声,免费"},
    {"id": "BV123_streaming", "name": "阳光青年", "category": "有声阅读",
     "desc": "阳光男声,多情感"},
    {"id": "BV102_streaming", "name": "儒雅青年", "category": "有声阅读",
     "desc": "儒雅男声旁白"},
    {"id": "BV104_streaming", "name": "温柔淑女", "category": "有声阅读",
     "desc": "温柔女声旁白"},
]


class TTSService:
    TTS_URL = "https://openspeech.bytedance.com/api/v1/tts"
    MAX_TEXT_BYTES = 900  # 留余量,API限制1024字节

    def __init__(self, config: dict, db_path: Optional[str] = None):
        self.appid = config.get("appid", "")
        self.token = config.get("access_token", "")
        self.cluster = config.get("cluster", "volcano_tts")
        self.default_voice = config.get("default_voice", "BV700_V2_streaming")
        self.default_speed = float(config.get("default_speed", 1.0))
        self.enabled = bool(self.appid and self.token)
        if db_path is None:
            db_path = os.path.join(
                os.path.dirname(os.path.abspath(__file__)),
                "..", "db", "tts_cache.db"
            )
        self.db_path = os.path.abspath(db_path)
        os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
        self._init_db()

    def _init_db(self):
        conn = sqlite3.connect(self.db_path)
        try:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS tts_cache (
                    cache_key TEXT PRIMARY KEY,
                    audio_blob BLOB NOT NULL,
                    text_length INTEGER,
                    voice_type TEXT,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)
            conn.execute("CREATE INDEX IF NOT EXISTS idx_tts_cache_voice ON tts_cache(voice_type)")
            conn.execute("CREATE INDEX IF NOT EXISTS idx_tts_cache_created ON tts_cache(created_at)")
            conn.commit()
        finally:
            conn.close()

    @staticmethod
    def _make_cache_key(text: str, voice_type: str, speed: float) -> str:
        raw = f"{text}|{voice_type}|{round(speed, 2)}"
        return hashlib.sha256(raw.encode("utf-8")).hexdigest()

    def _cache_get(self, key: str) -> Optional[bytes]:
        conn = sqlite3.connect(self.db_path)
        try:
            cur = conn.execute("SELECT audio_blob FROM tts_cache WHERE cache_key=?", (key,))
            row = cur.fetchone()
            return row[0] if row else None
        finally:
            conn.close()

    def _cache_set(self, key: str, audio: bytes, text_length: int, voice_type: str):
        conn = sqlite3.connect(self.db_path)
        try:
            conn.execute(
                "INSERT OR REPLACE INTO tts_cache (cache_key,audio_blob,text_length,voice_type) VALUES (?,?,?,?)",
                (key, audio, text_length, voice_type)
            )
            conn.commit()
        finally:
            conn.close()

    def get_voices(self):
        return VOICE_PRESETS

    def get_stats(self) -> dict:
        conn = sqlite3.connect(self.db_path)
        try:
            cur = conn.execute("SELECT COUNT(*), COALESCE(SUM(text_length),0) FROM tts_cache")
            cnt, total_chars = cur.fetchone()
            return {"cached_items": cnt, "total_chars_cached": total_chars}
        finally:
            conn.close()

    def synthesize(self, text: str, voice_type: Optional[str] = None,
                   speed: Optional[float] = None) -> bytes:
        raise NotImplementedError("Will be implemented in next task")

Step 4: 运行测试 → 通过

python -m pytest tests/test_tts_service.py -v

Expected: 3 passed.

Step 5: Commit

git add adata/aohan/quant/utils/tts_service.py tests/test_tts_service.py api_balance_config.example.json
git commit -m "feat(tts): TTSService cache layer with SQLite"

---

Task 3: 文本分段工具函数

Files:

Step 1: 写测试

class TestTextChunking(unittest.TestCase):
    def setUp(self):
        from adata.aohan.quant.utils.tts_service import TTSService
        self.svc = TTSService(
            config={"appid":"x","access_token":"x"},
            db_path=os.path.join(tempfile.mkdtemp(),"t.db")
        )

    def test_short_text_single_chunk(self):
        chunks = self.svc._split_text("你好世界")
        self.assertEqual(len(chunks), 1)
        self.assertEqual(chunks[0], "你好世界")

    def test_long_text_split_by_punctuation(self):
        text = "第一句。" * 200  # 约 800 字节
        chunks = self.svc._split_text(text)
        self.assertGreater(len(chunks), 1)
        for c in chunks:
            self.assertLessEqual(len(c.encode("utf-8")), self.svc.MAX_TEXT_BYTES)
        self.assertEqual("".join(chunks), text)

    def test_empty_text_returns_empty(self):
        self.assertEqual(self.svc._split_text(""), [])
        self.assertEqual(self.svc._split_text("   \n  "), [])

    def test_split_respects_paragraph(self):
        text = "第一段内容。" * 50 + "\n\n" + "第二段内容。" * 50
        chunks = self.svc._split_text(text)
        joined = "".join(chunks)
        # 不丢内容
        self.assertEqual(joined.replace("\n",""), text.replace("\n",""))

Step 2: 运行测试 → 失败(_split_text 不存在)

Step 3: 实现 _split_text

在 TTSService 类中添加:

_SENTENCE_ENDINGS = "。!?!?;;…\n"

def _split_text(self, text: str) -> list:
    text = text.strip()
    if not text:
        return []
    chunks, buf, buf_bytes = [], [], 0
    for ch in text:
        buf.append(ch)
        buf_bytes += len(ch.encode("utf-8"))
        if ch in self._SENTENCE_ENDINGS and buf_bytes > 100:
            if buf_bytes >= self.MAX_TEXT_BYTES:
                chunks.append("".join(buf))
                buf, buf_bytes = [], 0
        elif buf_bytes >= self.MAX_TEXT_BYTES:
            chunks.append("".join(buf))
            buf, buf_bytes = [], 0
    if buf:
        if chunks and buf_bytes < 200:
            chunks[-1] += "".join(buf)
        else:
            chunks.append("".join(buf))
    return [c.strip() for c in chunks if c.strip()]

Step 4: 运行测试 → 通过

python -m pytest tests/test_tts_service.py -v

Expected: 7 passed.

Step 5: Commit

git add adata/aohan/quant/utils/tts_service.py tests/test_tts_service.py
git commit -m "feat(tts): add text chunking by sentence boundary"

---

Task 4: TTS 合成 API 调用(使用 mock 测试,不真实扣费)

Files:

Step 1: 写测试(mock 网络层)

from unittest.mock import patch, MagicMock
import base64, json as _json

class TestTTSSynthesize(unittest.TestCase):
    def setUp(self):
        from adata.aohan.quant.utils.tts_service import TTSService
        self.tmpdir = tempfile.mkdtemp()
        self.svc = TTSService(
            config={"appid":"myapp","access_token":"mytoken","cluster":"volcano_tts",
                    "default_voice":"BV700_V2_streaming"},
            db_path=os.path.join(self.tmpdir,"t.db")
        )

    @patch("adata.aohan.quant.utils.tts_service.urllib.request.urlopen")
    def test_synthesize_calls_api_and_returns_mp3(self, mock_urlopen):
        fake_mp3 = b"ID3fake_mp3_binary_data"
        fake_resp = MagicMock()
        fake_resp.read.return_value = _json.dumps({
            "code": 3000,
            "message": "success",
            "data": base64.b64encode(fake_mp3).decode("ascii"),
            "reqid": "abc123"
        }).encode("utf-8")
        fake_resp.__enter__ = lambda s: s
        fake_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = fake_resp

        result = self.svc.synthesize("你好世界")
        self.assertEqual(result, fake_mp3)

        call_args = mock_urlopen.call_args
        req = call_args[0][0]
        self.assertEqual(req.full_url, "https://openspeech.bytedance.com/api/v1/tts")
        self.assertIn("Authorization", req.headers)
        self.assertTrue(req.headers["Authorization"].startswith("Bearer;"))
        body = _json.loads(req.data.decode("utf-8"))
        self.assertEqual(body["app"]["appid"], "myapp")
        self.assertEqual(body["app"]["cluster"], "volcano_tts")
        self.assertEqual(body["audio"]["voice_type"], "BV700_V2_streaming")
        self.assertEqual(body["audio"]["encoding"], "mp3")
        self.assertEqual(body["request"]["operation"], "query")
        self.assertEqual(body["request"]["text"], "你好世界")

    @patch("adata.aohan.quant.utils.tts_service.urllib.request.urlopen")
    def test_synthesize_caches_result(self, mock_urlopen):
        fake_mp3 = b"ID3cached"
        fake_resp = MagicMock()
        fake_resp.read.return_value = _json.dumps({
            "code": 3000, "data": base64.b64encode(fake_mp3).decode()
        }).encode("utf-8")
        fake_resp.__enter__ = lambda s: s
        fake_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = fake_resp

        self.svc.synthesize("测试缓存")
        self.svc.synthesize("测试缓存")
        self.assertEqual(mock_urlopen.call_count, 1)

    @patch("adata.aohan.quant.utils.tts_service.urllib.request.urlopen")
    def test_synthesize_api_error_raises(self, mock_urlopen):
        from adata.aohan.quant.utils.tts_service import TTSError
        fake_resp = MagicMock()
        fake_resp.read.return_value = _json.dumps({
            "code": 4001, "message": "invalid token"
        }).encode("utf-8")
        fake_resp.__enter__ = lambda s: s
        fake_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = fake_resp
        with self.assertRaises(TTSError):
            self.svc.synthesize("err test")

    def test_synthesize_disabled_raises(self):
        from adata.aohan.quant.utils.tts_service import TTSError
        svc = TTSService(config={}, db_path=os.path.join(self.tmpdir,"t2.db"))
        with self.assertRaises(TTSError):
            svc.synthesize("x")

Step 2: 运行测试 → 失败

Step 3: 实现 synthesize 和 _call_api

把 synthesize 方法改为:

def synthesize(self, text: str, voice_type: Optional[str] = None,
               speed: Optional[float] = None) -> bytes:
    if not self.enabled:
        raise TTSError("TTS 未启用:请在 api_balance_config.json 配置 volcengine.tts.appid 和 access_token")
    voice_type = voice_type or self.default_voice
    speed = float(speed) if speed is not None else self.default_speed
    text = text.strip()
    if not text:
        raise TTSError("文本为空")
    if len(text.encode("utf-8")) > self.MAX_TEXT_BYTES:
        raise TTSError(f"文本过长({len(text.encode('utf-8'))}字节),请分段调用")
    key = self._make_cache_key(text, voice_type, speed)
    cached = self._cache_get(key)
    if cached is not None:
        return cached
    audio = self._call_api(text, voice_type, speed)
    self._cache_set(key, audio, text_length=len(text), voice_type=voice_type)
    return audio

def _call_api(self, text: str, voice_type: str, speed: float) -> bytes:
    payload = {
        "app": {
            "appid": self.appid,
            "token": "tts_token",
            "cluster": self.cluster,
        },
        "user": {"uid": "aohan_quant"},
        "audio": {
            "voice_type": voice_type,
            "encoding": "mp3",
            "speed_ratio": round(speed, 1),
            "rate": 24000,
        },
        "request": {
            "reqid": str(uuid.uuid4()),
            "text": text,
            "operation": "query",
        },
    }
    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
    req = urllib.request.Request(
        self.TTS_URL,
        data=body,
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer;{self.token}",
        },
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            resp_data = json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        err_body = e.read().decode("utf-8", errors="ignore")
        raise TTSError(f"TTS HTTP {e.code}: {err_body[:200]}") from e
    except Exception as e:
        raise TTSError(f"TTS 网络错误: {e}") from e
    code = resp_data.get("code", -1)
    if code != 3000:
        raise TTSError(f"TTS API 错误 code={code}: {resp_data.get('message','')}")
    audio_b64 = resp_data.get("data", "")
    if not audio_b64:
        raise TTSError("TTS 返回音频为空")
    return base64.b64decode(audio_b64)

Step 4: 运行测试 → 通过

python -m pytest tests/test_tts_service.py -v

Expected: 10 passed。

Step 5: Commit

git add adata/aohan/quant/utils/tts_service.py tests/test_tts_service.py
git commit -m "feat(tts): implement Volcengine TTS API call with caching"

---

Task 5: unified_server.py 注册 TTS 路由

Files:

Step 1: 找到 DOC_PAGE 路由处理函数

在 unified_server.py 中搜索 def do_GET 和处理 /~learn/ 的代码段(约 L4297-L4326)。在同文件中添加路由逻辑。

Step 2: 在 import 区(文件顶部)添加

from adata.aohan.quant.utils.tts_service import TTSService, TTSError

(若 tts_service 导入失败则 TTSService = None,保持容错)

Step 3: 添加配置加载辅助函数

靠近文件顶部或工具函数区,添加:

def _get_tts_service():
    global _tts_svc
    if '_tts_svc' in globals() and _tts_svc is not None:
        return _tts_svc
    try:
        from adata.aohan.quant.utils.api_balance_monitor import load_config
        cfg = load_config().get("volcengine", {}).get("tts", {})
    except Exception:
        cfg = {}
    if not cfg.get("enabled", False) or not cfg.get("appid"):
        return None
    _tts_svc = TTSService(cfg)
    return _tts_svc

Step 4: 在 do_GET 中添加 3 个路由

在合适位置(文档相关路由附近)添加:

# /api/tts/voices
elif path == "/api/tts/voices":
    svc = _get_tts_service()
    self._send_json(200, {
        "enabled": svc is not None,
        "voices": svc.get_voices() if svc else [],
        "default_voice": svc.default_voice if svc else None,
        "default_speed": svc.default_speed if svc else 1.0,
    })

# /api/tts?text=xxx&voice=xxx&speed=1.0
elif path.startswith("/api/tts?") or path == "/api/tts":
    svc = _get_tts_service()
    if svc is None:
        self._send_json(503, {"error": "TTS 未配置,请联系管理员在 api_balance_config.json 中配置 volcengine.tts"})
        return
    from urllib.parse import urlparse, parse_qs
    qs = parse_qs(urlparse(self.path).query)
    text = qs.get("text", [""])[0]
    voice = qs.get("voice", [None])[0]
    speed = float(qs.get("speed", [1.0])[0])
    try:
        audio = svc.synthesize(text, voice_type=voice, speed=speed)
    except TTSError as e:
        self._send_json(500, {"error": str(e)})
        return
    self.send_response(200)
    self.send_header("Content-Type", "audio/mpeg")
    self.send_header("Content-Length", str(len(audio)))
    self.send_header("Cache-Control", "public, max-age=31536000, immutable")
    self.send_header("Access-Control-Allow-Origin", "*")
    self.end_headers()
    self.wfile.write(audio)

# /api/tts/stats
elif path == "/api/tts/stats":
    svc = _get_tts_service()
    stats = svc.get_stats() if svc else {"cached_items": 0, "total_chars_cached": 0}
    self._send_json(200, {"enabled": svc is not None, **stats})

注意:_send_json 如果项目中不存在,需要确认现有的 JSON 响应写法。在现有代码中搜索 "application/json" 看看怎么发送的,复用同样模式。

Step 5: 手动测试路由

启动服务:

cd /Users/aohan/PycharmProjects/adata
.venv/bin/python unified_server.py

然后在另一个终端:

curl -s http://localhost:8765/api/tts/voices | head -c 500

Expected: 返回 JSON,包含 enabled: false(因为还没配置 appid)和空 voices 或默认 voices。

Step 6: Commit

git add unified_server.py
git commit -m "feat(tts): add /api/tts, /api/tts/voices, /api/tts/stats routes"

---

Task 6: 前端播放器 CSS(注入 DOC_PAGE 模板)

Files:

Step 1: 在 DOC_PAGE 的 </style> 之前(响应式 @media 之前)插入播放器 CSS

将以下 CSS 添加到 DOC_PAGE 模板字符串中,在 /* ── 响应式优化 ── */ 注释之前:

/* ── TTS 听书播放器 ── */
body{{padding-bottom:72px;}}
.tts-player{{
  position:fixed;left:0;right:0;bottom:0;z-index:999;
  background:rgba(13,18,37,.92);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
  border-top:1px solid var(--border);
  padding:10px 16px;display:flex;align-items:center;gap:12px;
  font-size:13px;color:var(--text);
  transform:translateY(100%);transition:transform .3s ease;
}}
.tts-player.show{{transform:translateY(0);}}
.tts-play-btn{{
  width:40px;height:40px;border-radius:50%;flex-shrink:0;
  background:linear-gradient(135deg,#5b9cf5,#a78bfa);border:none;color:#fff;
  font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;
  transition:transform .15s;
}}
.tts-play-btn:hover{{transform:scale(1.08);}}
.tts-play-btn:disabled{{opacity:.5;cursor:not-allowed;}}
.tts-info{{flex:1;min-width:0;}}
.tts-time{{font-family:monospace;font-size:11px;color:var(--muted);margin-bottom:4px;white-space:nowrap;}}
.tts-bar{{
  height:4px;background:var(--border);border-radius:2px;cursor:pointer;position:relative;
}}
.tts-bar-fill{{height:100%;background:linear-gradient(90deg,#5b9cf5,#a78bfa);border-radius:2px;width:0%;transition:width .2s;}}
.tts-chunk-title{{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:300px;}}
.tts-settings-btn{{
  background:none;border:1px solid var(--border);color:var(--muted);border-radius:8px;
  padding:6px 10px;cursor:pointer;font-size:16px;transition:all .2s;
}}
.tts-settings-btn:hover{{border-color:var(--accent);color:var(--accent);}}
.tts-panel{{
  position:fixed;right:16px;bottom:72px;background:var(--bg2);border:1px solid var(--border);
  border-radius:12px;padding:14px 16px;width:280px;display:none;z-index:1000;
  box-shadow:0 8px 32px rgba(0,0,0,.5);
}}
.tts-panel.show{{display:block;}}
.tts-panel h4{{margin:0 0 10px;font-size:13px;color:var(--muted);font-weight:600;}}
.tts-panel label{{display:block;font-size:12px;color:var(--muted);margin:8px 0 4px;}}
.tts-panel select,.tts-panel input[type=range]{{
  width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);
  border-radius:6px;padding:6px 8px;font-size:12px;box-sizing:border-box;
}}
.tts-panel .tts-speed-val{{font-size:11px;color:var(--accent);float:right;}}
.tts-error{{color:#f87171;font-size:11px;margin-top:6px;}}
.tts-loading{{color:var(--accent);font-size:11px;}}
.tts-toggle-btn{{
  display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:8px;
  background:rgba(91,156,245,.15);border:1px solid rgba(91,156,245,.3);color:var(--accent);
  font-size:12px;font-weight:600;cursor:pointer;transition:all .2s;
}}
.tts-toggle-btn:hover{{background:rgba(91,156,245,.25);}}
.tts-highlight{{
  background:rgba(91,156,245,.18);border-radius:4px;padding:2px 0;
  transition:background-color .3s;
}}
@media(max-width:640px){{
  body{{padding-bottom:64px;}}
  .tts-player{{padding:8px 10px;gap:8px;font-size:12px;}}
  .tts-play-btn{{width:36px;height:36px;font-size:16px;}}
  .tts-chunk-title{{display:none;}}
  .tts-panel{{right:8px;left:8px;width:auto;bottom:64px;}}
}}

Step 2: 在 DOC_PAGE 的 toolbar 里加启动按钮

把 .doc-toolbar 里的 share 按钮旁边加一个听书按钮。找到:

<button class="share-btn" id="shareBtn" onclick="shareAsImage()">📷 分享为图片</button>

在它前面加:

<button class="tts-toggle-btn" id="ttsToggleBtn" onclick="ttsToggle()">🔊 听书</button>

Step 3: 在 DOC_PAGE 的 </div><!-- end .page --> 之前(watermark 之后)插入播放器 HTML

找到 .doc-watermark 的 </div> 后面(约 L1651 附近),加入:

<div class="tts-panel" id="ttsPanel">
  <h4>🔊 播放设置</h4>
  <label>🎙️ 音色</label>
  <select id="ttsVoiceSelect"></select>
  <label>🏃 语速 <span class="tts-speed-val" id="ttsSpeedVal">1.0x</span></label>
  <input type="range" id="ttsSpeedRange" min="0.6" max="1.8" step="0.1" value="1.0">
  <div class="tts-error" id="ttsError" style="display:none;"></div>
  <div class="tts-loading" id="ttsLoading" style="display:none;">⏳ 合成中...</div>
</div>
<div class="tts-player" id="ttsPlayer">
  <button class="tts-play-btn" id="ttsPlayBtn" onclick="ttsPlayPause()">▶</button>
  <div class="tts-info">
    <div class="tts-time">
      <span id="ttsCurTime">00:00</span> / <span id="ttsTotalTime">00:00</span>
      <span class="tts-chunk-title" id="ttsChunkTitle"></span>
    </div>
    <div class="tts-bar" id="ttsBar" onclick="ttsSeek(event)"><div class="tts-bar-fill" id="ttsBarFill"></div></div>
  </div>
  <button class="tts-settings-btn" id="ttsSettingsBtn" onclick="ttsTogglePanel()">⚙️</button>
</div>

Step 4: 验证无语法错误

.venv/bin/python -c "import py_compile; py_compile.compile('unified_server.py', doraise=True); print('OK')"

Expected: OK

Step 5: Commit

git add unified_server.py
git commit -m "feat(tts): add player CSS and HTML skeleton to DOC_PAGE"

---

Task 7: 前端播放器 JavaScript 核心逻辑

Files:

Step 1: 在现有 </script> 之前插入 TTS JS 代码

找到 DOC_PAGE 模板末尾(约 L1749 附近 })(); 后面),加入以下完整 JS 代码。注意模板里所有 { 和 } 需要双写为 {{ 和 }}(除了 {title}、{back_url} 等原本就用 format 替换的占位符)。

⚠️ 重点注意:写在 Python 模板字符串里的 JS,需要把 JS 里的所有 { → {{,} → }},否则 Python str.format() 会报错。代码块以 /* TTS-START */ 标记开始、/* TTS-END */ 标记结束,便于后续维护。

/* TTS-START */
var tts = {{
  audio: null,
  chunks: [],          // [{text, el, duration, url}]
  chunkIndex: 0,
  voice: null,
  speed: 1.0,
  enabled: false,
  playing: false,
  progressKey: 'tts_progress:' + location.pathname,
}};

function ttsInit() {{
  fetch('/api/tts/voices').then(function(r){{return r.json();}}).then(function(cfg){{
    tts.enabled = cfg.enabled;
    var btn = document.getElementById('ttsToggleBtn');
    if (!btn) return;
    if (!cfg.enabled) {{
      btn.textContent = '🔊 听书(未配置)';
      btn.disabled = true;
      btn.title = 'TTS 未配置';
      return;
    }}
    tts.voice = cfg.default_voice;
    tts.speed = cfg.default_speed;
    var sel = document.getElementById('ttsVoiceSelect');
    cfg.voices.forEach(function(v){{
      var opt = document.createElement('option');
      opt.value = v.id; opt.textContent = v.name + '(' + v.category + ')';
      if (v.id === cfg.default_voice) opt.selected = true;
      sel.appendChild(opt);
    }});
    sel.onchange = function(){{ tts.voice = this.value; ttsClearCache(); ttsSaveProgress(); }};
    var sr = document.getElementById('ttsSpeedRange');
    var sv = document.getElementById('ttsSpeedVal');
    sr.value = cfg.default_speed;
    sr.oninput = function(){{
      tts.speed = parseFloat(this.value);
      sv.textContent = tts.speed.toFixed(1) + 'x';
      if (tts.audio) tts.audio.playbackRate = tts.speed;
      ttsSaveProgress();
    }};
    // load progress
    try {{
      var p = JSON.parse(localStorage.getItem(tts.progressKey) || '{{}}');
      if (p.voice) {{ tts.voice = p.voice; sel.value = p.voice; }}
      if (p.speed) {{ tts.speed = p.speed; sr.value = p.speed; sv.textContent = p.speed.toFixed(1)+'x'; }}
      if (p.chunk_index || p.position_sec) {{
        btn.textContent = '🔊 续听 ' + (p.title || '');
      }}
    }} catch(e){{}}
    // extract chunks after mermaid renders
    setTimeout(ttsExtractChunks, 500);
  }})['catch'](function(){{
    var btn = document.getElementById('ttsToggleBtn');
    if (btn){{ btn.textContent = '🔊 听书(不可用)'; btn.disabled = true; }}
  }});
}}

function ttsExtractChunks(){{
  var body = document.getElementById('docBody');
  if (!body) return;
  tts.chunks = [];
  var els = body.querySelectorAll('h2,h3,h4,h5,p,blockquote,li');
  els.forEach(function(el){{
    // skip code blocks
    if (el.closest('pre') || el.closest('code') || el.closest('.mermaid')) return;
    // clone and clean
    var clone = el.cloneNode(true);
    clone.querySelectorAll('pre,code,.mermaid,img,table,script,style').forEach(function(n){{n.remove();}});
    clone.querySelectorAll('a').forEach(function(a){{
      a.replaceWith(document.createTextNode(a.textContent));
    }});
    var text = clone.textContent.trim();
    text = text.replace(/https?:\/\/[^\s]+/g, '');
    text = text.replace(/\s+/g, ' ').trim();
    if (!text || text.length < 2) return;
    tts.chunks.push({{text: text, el: el, duration: 0, url: null, audioLoaded: false}});
  }});
}}

function ttsClearHighlight(){{
  document.querySelectorAll('.tts-highlight').forEach(function(el){{
    el.classList.remove('tts-highlight');
  }});
}}

function ttsHighlight(idx){{
  ttsClearHighlight();
  var c = tts.chunks[idx];
  if (c && c.el){{
    c.el.classList.add('tts-highlight');
    c.el.scrollIntoView({{behavior:'smooth', block:'center'}});
    var t = document.getElementById('ttsChunkTitle');
    if (t) t.textContent = c.el.tagName.match(/H[2-5]/) ? c.text.slice(0,20) : '';
  }}
}}

function ttsToggle(){{
  var p = document.getElementById('ttsPlayer');
  if (!p) return;
  if (p.classList.contains('show')){{
    ttsStop(); p.classList.remove('show');
  }} else {{
    p.classList.add('show');
    if (tts.chunks.length === 0) ttsExtractChunks();
    // resume
    try {{
      var prog = JSON.parse(localStorage.getItem(tts.progressKey) || '{{}}');
      if (typeof prog.chunk_index === 'number' && prog.chunk_index < tts.chunks.length){{
        tts.chunkIndex = prog.chunk_index;
      }}
    }}catch(e){{}}
  }}
}}

function ttsTogglePanel(){{
  document.getElementById('ttsPanel').classList.toggle('show');
}}

function ttsShowError(msg){{
  var e = document.getElementById('ttsError');
  e.textContent = msg; e.style.display = 'block';
  setTimeout(function(){{ e.style.display='none'; }}, 5000);
}}
function ttsSetLoading(on){{
  document.getElementById('ttsLoading').style.display = on ? 'block' : 'none';
}}

function ttsPlayPause(){{
  if (tts.playing) {{ ttsPause(); }} else {{ ttsPlay(); }}
}}

function ttsPause(){{
  if (tts.audio) tts.audio.pause();
  tts.playing = false;
  document.getElementById('ttsPlayBtn').textContent = '▶';
}}

function ttsStop(){{
  if (tts.audio){{ tts.audio.pause(); tts.audio.src=''; tts.audio=null; }}
  tts.playing = false;
  document.getElementById('ttsPlayBtn').textContent = '▶';
  document.getElementById('ttsBarFill').style.width = '0%';
  ttsClearHighlight();
}}

function ttsPlay(){{
  if (!tts.chunks.length) return;
  tts.playing = true;
  document.getElementById('ttsPlayBtn').textContent = '⏸';
  ttsPlayChunk(tts.chunkIndex);
}}

function ttsPlayChunk(idx){{
  if (idx >= tts.chunks.length) {{ ttsStop(); return; }}
  tts.chunkIndex = idx;
  ttsHighlight(idx);
  var chunk = tts.chunks[idx];
  if (chunk.audioLoaded) {{
    ttsPlayAudio(chunk.url);
    return;
  }}
  ttsSetLoading(true);
  var url = '/api/tts?text=' + encodeURIComponent(chunk.text) + '&voice=' + encodeURIComponent(tts.voice) + '&speed=' + tts.speed;
  fetch(url).then(function(r){{
    if (!r.ok) {{ return r.json().then(function(j){{ throw new Error(j.error || '合成失败'); }}); }}
    return r.blob();
  }}).then(function(blob){{
    chunk.url = URL.createObjectURL(blob);
    chunk.audioLoaded = true;
    ttsSetLoading(false);
    ttsPlayAudio(chunk.url);
    // prefetch next
    if (idx+1 < tts.chunks.length && !tts.chunks[idx+1].audioLoaded){{
      var next = tts.chunks[idx+1];
      var nu = '/api/tts?text=' + encodeURIComponent(next.text) + '&voice=' + encodeURIComponent(tts.voice) + '&speed=' + tts.speed;
      fetch(nu).then(function(r){{ if(r.ok) return r.blob(); else throw 0; }}).then(function(b){{
        next.url = URL.createObjectURL(b); next.audioLoaded = true;
      }})['catch'](function(){{}});
    }}
  }})['catch'](function(e){{
    ttsSetLoading(false);
    ttsShowError(e.message || '合成失败');
    tts.chunkIndex++;
    if (tts.chunkIndex < tts.chunks.length) ttsPlayChunk(tts.chunkIndex);
  }});
}}

function ttsPlayAudio(url){{
  if (tts.audio) {{ try{{ tts.audio.pause(); }}catch(e){{}} }}
  tts.audio = new Audio(url);
  tts.audio.playbackRate = tts.speed;
  try {{
    var prog = JSON.parse(localStorage.getItem(tts.progressKey) || '{{}}');
    if (prog.chunk_index === tts.chunkIndex && prog.position_sec > 0){{
      tts.audio.currentTime = prog.position_sec;
    }}
  }}catch(e){{}}
  tts.audio.ontimeupdate = function(){{
    var pct = (tts.audio.currentTime / (tts.audio.duration || 1)) * 100;
    document.getElementById('ttsBarFill').style.width = Math.min(100,pct) + '%';
    document.getElementById('ttsCurTime').textContent = ttsFmtTime(tts.totalOffset() + tts.audio.currentTime);
    ttsSaveProgress();
  }};
  tts.audio.onloadedmetadata = function(){{
    document.getElementById('ttsTotalTime').textContent = ttsFmtTime(ttsTotalDuration());
  }};
  tts.audio.onended = function(){{
    tts.chunkIndex++;
    if (tts.chunkIndex < tts.chunks.length) {{
      ttsPlayChunk(tts.chunkIndex);
    }} else {{
      ttsStop();
      localStorage.removeItem(tts.progressKey);
    }}
  }};
  tts.audio.onerror = function(){{
    ttsShowError('播放失败');
    tts.chunkIndex++;
    if (tts.chunkIndex < tts.chunks.length) ttsPlayChunk(tts.chunkIndex);
  }};
  tts.audio.play()['catch'](function(e){{ ttsShowError('播放失败:'+e.message); }});
}}

function ttsTotalOffset(){{
  var t = 0;
  for (var i=0;i<tts.chunkIndex;i++){{ if(tts.chunks[i].audio && tts.chunks[i].audio.duration) t += tts.chunks[i].audio.duration; }}
  return t;
}}
function ttsTotalDuration(){{
  var t = 0;
  tts.chunks.forEach(function(c){{ if(c.audioLoaded && tts.audio) {{ if(c===tts.chunks[tts.chunkIndex]) t += tts.audio.duration||0; else if(c.audio && c.audio.duration) t += c.audio.duration; }} }});
  if (tts.audio && tts.audio.duration) return tts.audio.duration;
  return tts.chunks.length * 8; // rough estimate ~4.5 chinese chars/sec
}}
function ttsFmtTime(s){{
  s = Math.floor(s||0);
  var m = Math.floor(s/60); s = s%60;
  return (m<10?'0':'')+m+':'+(s<10?'0':'')+s;
}}
function ttsSeek(e){{
  var bar = document.getElementById('ttsBar');
  var pct = (e.clientX - bar.getBoundingClientRect().left) / bar.offsetWidth;
  // 简化:跳到按比例估算的 chunk
  var idx = Math.floor(pct * tts.chunks.length);
  idx = Math.max(0, Math.min(tts.chunks.length-1, idx));
  tts.chunkIndex = idx;
  ttsPlayChunk(idx);
}}
function ttsSaveProgress(){{
  if (!tts.audio) return;
  var data = {{
    chunk_index: tts.chunkIndex,
    position_sec: tts.audio.currentTime || 0,
    voice: tts.voice,
    speed: tts.speed,
    title: document.getElementById('srcTitle')?.textContent?.slice(0,10) || '',
    saved_at: Date.now()
  }};
  localStorage.setItem(tts.progressKey, JSON.stringify(data));
}}
function ttsClearCache(){{
  // 清除已加载但同voice的缓存(简化:重载后新voice才生效)
  tts.chunks.forEach(function(c){{ c.audioLoaded=false; c.url=null; }});
}}
setTimeout(ttsInit, 300);
/* TTS-END */

⚠️ 关键提醒:上面代码在放入 DOC_PAGE 模板字符串时,必须把所有 { 和 } 双写(已在上面示例里做了)。否则 Python 的 .format() 会把它们当作模板占位符,导致 KeyError。

Step 2: 编译验证

.venv/bin/python -c "import py_compile; py_compile.compile('unified_server.py', doraise=True); print('SYNTAX OK')"

Expected: SYNTAX OK。如果报 KeyError,说明有 { 没有双写。

Step 3: 启动服务验证

./restart.sh
# 或手动启动
.venv/bin/python unified_server.py

打开一篇文档,应该看到右上角有"🔊 听书"按钮。如果 TTS 未配置,按钮会显示"🔊 听书(未配置)"且禁用。

Step 4: Commit

git add unified_server.py
git commit -m "feat(tts): add frontend audiobook player JS with chunks/progress/highlight"

---

Task 8: 配置 TTS 凭证 + 端到端测试

前置条件:需要用户先手动去火山引擎控制台操作:

Step 1: 编辑 api_balance_config.json

在 volcengine 字段下添加:

"tts": {
  "enabled": true,
  "appid": "你从控制台获取的APPID",
  "access_token": "你从控制台获取的Access_Token",
  "default_voice": "BV700_V2_streaming",
  "default_speed": 1.0,
  "cluster": "volcano_tts"
}

Step 2: 重启并端到端测试

./restart.sh

浏览器访问任意一篇文档,例如 https://quant.aohan.net/~learn/... 或本地 http://localhost:8765/~learn/...:

Step 3: 发布到公网(根据项目铁律)

./run_web_daemon.sh restart
# 确认 cloudflared tunnel 在线

访问 https://quant.aohan.net 验证线上功能。

Step 4: 最终 Commit

git add api_balance_config.json  # 注意:如果有真实密钥,确保在.gitignore里
git commit -m "feat(tts): enable TTS with volcengine credentials"

---

Task 9(可选): Web Speech API 降级方案

如果 TTS 未配置或 API 调用失败,自动降级到浏览器原生语音合成,保证基本可用:

实现思路:

``javascript var u = new SpeechSynthesisUtterance(tts.chunks[idx].text); u.lang = 'zh-CN'; u.rate = tts.speed; u.onend = function(){ tts.chunkIndex++; ttsPlayChunk(tts.chunkIndex); }; window.speechSynthesis.speak(u); `

  • 按钮标签变化:已配置时为"🔊 听书",降级时为"🔊 听书(本地语音)"

这个功能可以放到 Task 8 完成后作为增量,如果不需要可跳过。

---

风险与注意事项

---

验收 Checklist

▶ 📜 变更历史(1 次提交)

a7a12042026-08-07 23:29fix: 网站稳定性修复 - 守护进程接管已有实例 + 隧道readiness健康检查 + 经验文档沉淀
🍃quant 知识库
by 伊甸园和枫

🔊 播放设置

勾选音源后调节配比,总和自动归一化
已选 0 个 · 合计:0%
合成中...
00:00/00:00
1.0x

分享图片

生成中...