refactor: 精简架构,去掉书籍管理,核心 TTS 代理

- 去掉 books/chapters CRUD、SQLAlchemy、SQLite 依赖
- 核心只剩 /api/tts + 智能分段 + 自动重试
- 新增 API_TOKEN 环境变量,管理接口 Bearer Token 鉴权
- 管理接口精简为 preview + config
- 前端重写:TTS 试听 + 配置查看 + 接口文档
- Dockerfile/docker-compose 清理,去掉数据库卷
This commit is contained in:
sunruiling
2026-03-27 15:10:58 +08:00
parent 30544f7f42
commit 2a87020b48
11 changed files with 381 additions and 1503 deletions

View File

@@ -1,6 +1,9 @@
# 小米 MiMo TTS API Key必填
MIMO_API_KEY=your_api_key_here
# API 访问令牌(可选,设置后管理接口需要 Bearer Token
# API_TOKEN=your_token_here
# 以下为可选配置(有默认值)
# MIMO_API_ENDPOINT=https://api.xiaomimimo.com/v1/chat/completions
# MIMO_TTS_MODEL=mimo-v2-audio-tts

449
API.md
View File

@@ -1,449 +0,0 @@
# TTS Book Service API 文档
> 基于小米 MiMo TTS 的听书音频转换服务 API 参考
## 概览
| 分类 | 前缀 | 说明 |
|------|------|------|
| 听书 App 接口 | `/api/` | 供听书 App 调用的音频接入接口 |
| 实时 TTS | `/api/tts` | 实时生成并返回音频流 |
| 管理接口 | `/admin/api/` | Web 管理界面使用的 CRUD 接口 |
| 配置文件 | `/httpTts.json` | 听书 App 导入用配置 |
**Base URL**: `http://<your-server>:3333`
---
## 一、实时 TTS 接口
### POST `/api/tts`
实时调用 MiMo TTS 生成音频,直接返回 MP3 二进制流。
> 长文本会自动分段生成并拼接(每段 ≤ 2000 字符,在句末/段落边界智能切分)。
#### 请求格式
支持两种格式:
**1. JSON 格式**(推荐)
```http
POST /api/tts
Content-Type: application/json
{
"text": "",
"style": "",
"voice": ""
}
```
**2. Form-urlencoded 格式**(兼容百度风格)
```http
POST /api/tts
Content-Type: application/x-www-form-urlencoded
tex=%E8%A6%81%E5%90%88%E6%88%90%E7%9A%84%E6%96%87%E6%9C%AC
```
#### 参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `text` / `tex` | string | ✅ | 要合成的文本JSON 用 `text`Form 用 `tex` |
| `style` | string | ❌ | 说话风格,如 `开心``语速慢``东北话``像个大将军` 等(见风格参考) |
| `voice` | string | ❌ | 音色名称,留空使用默认音色(`mimo_default` |
#### 响应
- **成功**: `200 OK``Content-Type: audio/mpeg`,响应体为 MP3 二进制数据
- **失败**: `400` / `500``Content-Type: application/json`
```json
{
"status": 40000001,
"message": "text/tex 不能为空"
}
```
#### 示例
```bash
# cURL - JSON 格式
curl -X POST http://localhost:3333/api/tts \
-H "Content-Type: application/json" \
-d '{"text": "你好,今天天气真好!", "style": "开心"}' \
-o output.mp3
# cURL - 带音色指定
curl -X POST http://localhost:3333/api/tts \
-H "Content-Type: application/json" \
-d '{"text": "从前有座山,山里有座庙。", "voice": "mimo_male_01"}' \
-o output.mp3
```
---
## 二、听书 App 音频接入接口
### GET `/api/book/{book_id}`
获取书籍信息及章节列表,供听书 App 调用。
#### 路径参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `book_id` | string | 书籍唯一标识 |
#### 响应
```json
{
"book_id": "book_9",
"title": "三体",
"author": "刘慈欣",
"chapters": [
{
"chapter_id": "chapter_1",
"app_chapter_id": "chapter1",
"title": "第一章 疯狂年代",
"status": "ready",
"audio_url": "/api/book/book_9/chapter/chapter_1/audio"
},
{
"chapter_id": "chapter_2",
"app_chapter_id": "chapter2",
"title": "第二章 寂静的春天",
"status": "pending",
"audio_url": null
}
]
}
```
| 字段 | 说明 |
|------|------|
| `status` | 章节音频状态:`pending`(待生成) / `generating`(生成中) / `ready`(就绪) / `error`(失败) |
| `audio_url` | 音频下载地址,仅 `status=ready` 时有值 |
#### 错误响应
```json
{"detail": "书籍 book_9 不存在"}
```
`404 Not Found`
---
### GET `/api/book/{book_id}/chapter/{chapter_id}/audio`
下载章节 MP3 音频文件。
#### 路径参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `book_id` | string | 书籍 ID |
| `chapter_id` | string | 章节 ID |
#### 响应
- **成功**: `200 OK``Content-Type: audio/mpeg`MP3 文件流
- **未生成**: `404``{"detail": "音频尚未生成,当前状态: pending"}`
- **不存在**: `404``{"detail": "章节不存在"}`
---
## 三、管理接口
### 书籍管理
#### GET `/admin/api/books`
获取所有书籍列表。
```json
[
{"book_id": "book_9", "title": "三体", "author": "刘慈欣"},
{"book_id": "book_12", "title": "活着", "author": "余华"}
]
```
#### POST `/admin/api/books`
创建新书籍。
```http
Content-Type: application/json
{
"book_id": "book_9",
"title": "",
"author": ""
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `book_id` | ✅ | 唯一标识 |
| `title` | ✅ | 书名 |
| `author` | ❌ | 作者 |
**响应**: `{"ok": true, "book_id": "book_9"}`
**错误**: `409 Conflict`book_id 已存在)、`400`(缺少必填字段)
#### DELETE `/admin/api/books/{book_id}`
删除书籍及其所有章节。
**响应**: `{"ok": true}`
---
### 章节管理
#### GET `/admin/api/books/{book_id}/chapters`
获取书籍下的章节列表。
```json
[
{
"chapter_id": "chapter_1",
"app_chapter_id": "chapter1",
"title": "第一章 疯狂年代",
"text_content": "这是章节文本的前200个字符...",
"text_length": 15000,
"status": "ready",
"error_msg": "",
"has_audio": true
}
]
```
> 注意: `text_content` 只返回前 200 个字符用于预览。完整文本需通过 PUT 接口编辑时获取。
#### POST `/admin/api/books/{book_id}/chapters`
创建新章节。
```json
{
"chapter_id": "chapter_1",
"app_chapter_id": "chapter1",
"title": "第一章",
"text_content": "章节正文内容..."
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `chapter_id` | ✅ | 章节唯一标识 |
| `app_chapter_id` | ❌ | 听书 App 中的章节 ID默认同 chapter_id |
| `title` | ❌ | 章节标题 |
| `text_content` | ❌ | TTS 文本内容 |
#### PUT `/admin/api/books/{book_id}/chapters/{chapter_id}`
更新章节信息(部分更新)。
```json
{
"text_content": "更新后的文本内容...",
"title": "新标题"
}
```
所有字段均为可选,只更新传入的字段。
#### DELETE `/admin/api/books/{book_id}/chapters/{chapter_id}`
删除单个章节。
---
### 音频生成
#### POST `/admin/api/books/{book_id}/chapters/{chapter_id}/generate`
为单个章节生成音频。
> 长文本自动分段生成并拼接(每段 ≤ 2000 字符)。
**响应**:
```json
{"ok": true, "status": "ready", "error_msg": ""}
```
或生成失败时:
```json
{"ok": true, "status": "error", "error_msg": "MiMo TTS API 错误: HTTP 502"}
```
#### POST `/admin/api/books/{book_id}/generate-all`
批量生成书籍下所有**未就绪**章节的音频。
> 按顺序逐章生成,可能需要较长时间。
**响应**:
```json
{"ok": true, "total": 5, "chapter_ids": ["ch_1", "ch_2", "ch_3", "ch_4", "ch_5"]}
```
---
### TTS 试听
#### POST `/admin/api/tts/preview`
试听 TTS 效果,返回生成的音频 URL。
```json
{
"text": "你好,世界!",
"style": "开心",
"voice": ""
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `text` | ✅ | 试听文本 |
| `style` | ❌ | 说话风格 |
| `voice` | ❌ | 音色名称 |
**响应**:
```json
{"ok": true, "url": "/audio/_preview/a1b2c3d4.mp3"}
```
---
### 配置查看
#### GET `/admin/api/config`
查看当前服务配置。
```json
{
"endpoint": "https://api.xiaomimimo.com/v1/chat/completions",
"model": "mimo-v2-audio-tts",
"voice": "mimo_default",
"api_key_masked": "sk-mi****",
"max_chunk_chars": 2000
}
```
---
## 四、配置文件
### GET `/httpTts.json`
提供听书 App 导入用的音频源配置文件MiMo TTS 单条目)。
用户可直接在听书 App 中通过 URL 导入此配置。
---
## 五、MiMo TTS 风格参考
`style` 参数中填写风格关键词MiMo TTS 支持以下类别:
### 情感类
| 风格 | 示例文本 |
|------|----------|
| 开心 | 今天真是太棒了!|
| 悲伤 | 他默默地离开了... |
| 生气 | 你怎么能这样做!|
| 平静 | 让我们慢慢来。|
| 惊讶 | 什么?这不可能!|
| 温柔 | 没关系,我在这里。|
### 语速类
| 风格 | 效果 |
|------|------|
| 语速慢 | 适合冥想、教学 |
| 语速快 | 适合新闻、解说 |
| 悄悄话 | 轻声细语 |
### 角色类
| 风格 | 效果 |
|------|------|
| 像个大将军 | 威严有力 |
| 像个小孩 | 稚嫩可爱 |
| 孙悟空 | 经典猴王 |
| 像个诗人 | 文艺优雅 |
| 像个老人 | 沧桑稳重 |
### 方言类
| 风格 | 说明 |
|------|------|
| 东北话 | 东北方言 |
| 四川话 | 四川方言 |
| 台湾腔 | 台湾口音 |
| 粤语 | 广东话 |
| 河南话 | 河南方言 |
### 组合使用
风格可以组合,例如:
```json
{
"text": "今天天气真好",
"style": "开心 语速快"
}
```
---
## 六、错误码参考
| HTTP 状态码 | status 字段 | 含义 |
|-------------|-------------|------|
| 400 | 40000001 | 请求参数缺失或无效 |
| 404 | - | 资源不存在(书籍/章节/音频文件) |
| 409 | - | 资源冲突ID 已存在) |
| 500 | 50000002 | 服务端错误(未配置 API Key 等) |
| 502 | - | MiMo TTS API 调用失败 |
---
## 七、文本自动分段机制
当文本超过 **2000 字符**时,服务自动进行智能分段:
### 分段策略
按优先级尝试在以下位置切分:
1. **段落边界**`\n\n`
2. **换行符**`\n`
3. **中文句末标点**`。!?…`
4. **英文句末标点**`.!?`
5. **分号**`;`
6. **逗号**`,`
7. **硬切** — 以上都不匹配时按长度截断
### 处理流程
```
原始文本 (10000字)
→ 智能分段 → [段1(1800字), 段2(1950字), 段3(2000字), 段4(1900字), 段5(2350字)]
→ 逐段调用 MiMo TTS → [wav1, wav2, wav3, wav4, wav5]
→ ffmpeg 拼接 → 最终 MP3
```
此过程对用户完全透明,`/api/tts` 和章节音频生成均自动支持。

View File

@@ -1,9 +1,6 @@
FROM python:3.11-alpine
# 配置 Alpine 国内镜像源
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 安装 ffmpeg
RUN apk add --no-cache ffmpeg
WORKDIR /app
@@ -14,7 +11,7 @@ RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r re
COPY app/ .
COPY httpTts-mimo.json .
RUN mkdir -p /app/data /app/audio
RUN mkdir -p /app/audio/_tmp /app/audio/_preview
EXPOSE 3333

106
README.md
View File

@@ -1,14 +1,15 @@
# 📚 TTS Book Service
# 🎙️ TTS Proxy Service
基于**小米 MiMo TTS**的听书音频转换服务,提供 Web 管理界面和听书 App 音频接入接口
小米 MiMo TTS 音频转换代理服务,提供实时 TTS 接口和 Web 管理界面。
## 架构
```
听书 App ──HTTP──▶ 本服务 ──API──▶ 小米 MiMo TTS
听书 App / curl ──HTTP──▶ 本服务 ──API──▶ 小米 MiMo TTS
├── SQLite (书籍/章节管理)
└── MP3 文件缓存
├── 智能文本分段(>2000字自动切分拼接
├── TTS 自动重试5xx 错误最多重试 3 次)
└── MP3 缓存
```
## 快速启动
@@ -16,83 +17,69 @@
```bash
# 1. 配置 API Key
cp .env.example .env
# 编辑 .env 填入你的 MIMO_API_KEY
# 编辑 .env 填入 MIMO_API_KEY
# 2. 启动
docker compose up -d
# 3. 访问管理界面
# http://your-server:17200
# 3. 访问
# 管理界面: http://your-server:3333
# TTS 接口: http://your-server:3333/api/tts
```
## 功能
## 核心功能
### Web 管理界面 (`/`)
- 📖 书籍管理(添加/删除)
- 📑 章节管理(添加/编辑/删除)
- 🎙️ TTS 试听(支持风格 + 音色设置)
- ⚡ 单章/批量音频生成(自动分段拼接)
- ⚙️ 配置查看
### POST `/api/tts` — 实时 TTS
### 核心特性
- **智能文本分段**: 长文本自动在句末/段落边界切分≤2000字/段),逐段生成后拼接
- **多风格支持**: 开心、悲伤、东北话、像个大将军... 任意组合
- **音色切换**: 支持指定不同音色voice 参数)
- **并发批量生成**: 限制 3 路并发,快速完成整本书
文本进 → MP3 出。支持 JSON 和 Form 两种格式,长文本自动分段拼接。
### 听书 App 接口
```bash
# JSON 格式
curl -X POST http://localhost:3333/api/tts \
-H "Content-Type: application/json" \
-d '{"text": "你好世界", "style": "开心", "voice": ""}' \
-o output.mp3
# Form 格式(百度兼容)
curl -X POST http://localhost:3333/api/tts \
-d "tex=你好世界" \
-o output.mp3
```
### GET `/health` — 健康检查
### 管理接口(需 Bearer Token
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/book/{book_id}` | GET | 获取书籍信息和章节列表 |
| `/api/book/{book_id}/chapter/{chapter_id}/audio` | GET | 下载章节 MP3 音频 |
### 管理 API
| 接口 | 方法 | 说明 |
|------|------|------|
| `/admin/api/books` | GET/POST | 书籍列表/创建 |
| `/admin/api/books/{book_id}` | DELETE | 删除书籍 |
| `/admin/api/books/{book_id}/chapters` | GET/POST | 章节列表/创建 |
| `/admin/api/books/{book_id}/chapters/{id}` | PUT/DELETE | 更新/删除章节 |
| `/admin/api/books/{book_id}/chapters/{id}/generate` | POST | 生成单章音频 |
| `/admin/api/books/{book_id}/generate-all` | POST | 批量生成 |
| `/admin/api/tts/preview` | POST | TTS 试听 |
| `/admin/api/config` | GET | 查看配置 |
| `/admin/api/preview` | POST | TTS 试听,返回音频 URL |
| `/admin/api/config` | GET | 查看当前配置 |
## 接入听书 App
1. 在管理界面添加书籍和章节
2. 为章节生成音频
3. 在听书 App 配置:
- **接入方式**: 音频方式
- **接入地址**: `http://your-server:17200`
- **音频类型**: mp3
- **书籍地址**: `http://your-server:17200/api/book/{book_id}`
在 App 中配置 TTS 音频源:
- **URL**: `http://your-server:3333/api/tts`
- **Method**: POST
- **Body**: `{"text": "{{speakText}}"}`
- **Content-Type**: `audio/mpeg`
App 模板变量:`{{speakText}}`(文本)、`{{speakSpeed}}`(语速 5-50
> App 只能动态传文本。voice/style 需在 JSON body 中写死,或通过其他客户端调用时传入。
## 环境变量
| 变量 | 必填 | 默认值 | 说明 |
|------|------|--------|------|
| `MIMO_API_KEY` | ✅ | - | 小米 MiMo TTS API Key |
| `MIMO_API_KEY` | ✅ | - | MiMo TTS API Key |
| `MIMO_API_ENDPOINT` | ❌ | `https://api.xiaomimimo.com/v1/chat/completions` | API 地址 |
| `MIMO_TTS_MODEL` | ❌ | `mimo-v2-audio-tts` | 模型名称 |
| `MIMO_VOICE` | ❌ | `mimo_default` | 默认音色 |
| `SERVER_PORT` | ❌ | `3333` | 服务端口 |
## 📖 API 文档
完整 API 文档见 [**API.md**](./API.md),包含:
- 所有接口的请求/响应格式
- 参数说明与错误码
- MiMo TTS 风格参考大全
- 文本自动分段机制说明
| `API_TOKEN` | ❌ | - | 管理接口 Bearer Token留空则不鉴权 |
## MiMo TTS 风格参考
在「TTS 试听」中可填写风格,例如:
| 类别 | 示例 |
|------|------|
| 情感 | 开心 / 悲伤 / 生气 / 平静 |
@@ -100,12 +87,11 @@ docker compose up -d
| 角色 | 像个大将军 / 像个小孩 / 孙悟空 |
| 方言 | 东北话 / 四川话 / 台湾腔 / 粤语 |
## 不使用 Docker 运行
## 不使用 Docker
```bash
pip install -r requirements.txt
# 需要系统安装 ffmpeg
# 系统安装 ffmpeg
export MIMO_API_KEY=your_key
cd app
python main.py
cd app && python main.py
```

View File

@@ -14,8 +14,8 @@ MIMO_VOICE = os.environ.get("MIMO_VOICE", "mimo_default")
SERVER_HOST = os.environ.get("SERVER_HOST", "0.0.0.0")
SERVER_PORT = int(os.environ.get("SERVER_PORT", "3333"))
# API 访问令牌(可选,设置后管理接口需要 Bearer Token
API_TOKEN = os.environ.get("API_TOKEN", "")
# 音频存储目录
AUDIO_DIR = os.environ.get("AUDIO_DIR", os.path.join(BASE_DIR, "audio"))
# 数据库
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///./data/tts.db")

View File

@@ -1,6 +1,6 @@
"""
TTS Book Service - 小米 MiMo TTS 转换服务
为听书 App 提供音频接入接口
TTS Proxy Service - 小米 MiMo TTS 音频转换代理
核心功能: /api/tts 实时 TTS + 智能分段 + 自动重试
"""
import os
@@ -15,13 +15,9 @@ from contextlib import asynccontextmanager
from pathlib import Path
import httpx
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi import FastAPI, HTTPException, Request, Depends
from fastapi.responses import FileResponse, HTMLResponse, Response
from fastapi.staticfiles import StaticFiles
from sqlalchemy import Column, Integer, String, Text, DateTime, func, select, delete
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from urllib.parse import parse_qs, unquote
import config
@@ -32,29 +28,24 @@ logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("tts-service")
logger = logging.getLogger("tts-proxy")
# ── Text Segmentation ─────────────────────────────────────────────────────
# MiMo TTS 单次请求文本上限(保守值,实际约 5000
MAX_CHUNK_CHARS = 2000
# 分割优先级: 段落 > 句子 > 逗号/分号 > 按长度硬切
_SEGMENT_PATTERNS = [
"\n\n", # 段落
"\n", # 换行
"", "", "", "", # 中文句末
".", "!", "?", # 英文句末
"", ";", # 分号
"", ",", # 逗号
"", "", "", "",
".", "!", "?",
"", ";",
"", ",",
]
def split_text(text: str, max_chars: int = MAX_CHUNK_CHARS) -> list[str]:
"""
智能分段:尽量在自然边界(段落/句子/标点)处切分,
保证每段不超过 max_chars 字符。
"""
"""智能分段:在自然边界切分,每段不超过 max_chars"""
text = text.strip()
if len(text) <= max_chars:
return [text]
@@ -67,7 +58,6 @@ def split_text(text: str, max_chars: int = MAX_CHUNK_CHARS) -> list[str]:
chunks.append(remaining)
break
# 在 max_chars 范围内找最佳切割点
window = remaining[:max_chars]
cut_pos = -1
@@ -78,7 +68,6 @@ def split_text(text: str, max_chars: int = MAX_CHUNK_CHARS) -> list[str]:
break
if cut_pos <= 0:
# 没找到任何分隔符,硬切
cut_pos = max_chars
chunk = remaining[:cut_pos].strip()
@@ -89,16 +78,35 @@ def split_text(text: str, max_chars: int = MAX_CHUNK_CHARS) -> list[str]:
return chunks
# ── Audio Concatenation ───────────────────────────────────────────────────
# ── Auth ──────────────────────────────────────────────────────────────────
async def verify_token(request: Request):
"""Bearer Token 验证API_TOKEN 未配置时跳过)"""
if not config.API_TOKEN:
return
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(401, "缺少 Authorization: Bearer <token>")
if auth[7:] != config.API_TOKEN:
raise HTTPException(403, "Token 无效")
# ── Audio Utils ───────────────────────────────────────────────────────────
def wav_to_mp3(wav_path: str, mp3_path: str):
result = subprocess.run(
["ffmpeg", "-y", "-i", wav_path, "-codec:a", "libmp3lame", "-qscale:a", "2", mp3_path],
capture_output=True, text=True,
)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg 转换失败: {result.stderr[:300]}")
def concat_mp3_files(mp3_paths: list[str], output_path: str):
"""用 ffmpeg 将多个 MP3 文件拼接为一个"""
# 创建 ffmpeg concat 文件列表
list_path = output_path + ".concat_list.txt"
with open(list_path, "w") as f:
for p in mp3_paths:
f.write(f"file '{p}'\n")
try:
result = subprocess.run(
["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_path,
@@ -111,46 +119,15 @@ def concat_mp3_files(mp3_paths: list[str], output_path: str):
os.remove(list_path)
# ── Database ──────────────────────────────────────────────────────────────
engine = create_async_engine(config.DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
class Book(Base):
__tablename__ = "books"
id = Column(Integer, primary_key=True, autoincrement=True)
book_id = Column(String(100), unique=True, nullable=False, index=True)
title = Column(String(500), nullable=False)
author = Column(String(200), default="")
created_at = Column(DateTime, server_default=func.now())
class Chapter(Base):
__tablename__ = "chapters"
id = Column(Integer, primary_key=True, autoincrement=True)
book_id = Column(String(100), nullable=False, index=True)
chapter_id = Column(String(100), nullable=False, index=True)
app_chapter_id = Column(String(100), default="")
title = Column(String(500), default="")
text_content = Column(Text, default="")
audio_file = Column(String(500), default="")
status = Column(String(20), default="pending")
error_msg = Column(Text, default="")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# ── TTS Service ───────────────────────────────────────────────────────────
MAX_TTS_RETRIES = 3
async def call_mimo_tts(text: str, style: str = "", voice: str = "") -> bytes:
"""调用小米 MiMo TTS API返回 WAV 音频字节"""
"""调用 MiMo TTS API返回 WAV 字节。5xx 自动重试最多 3 次"""
if not config.MIMO_API_KEY:
raise HTTPException(500, "MIMO_API_KEY 未配置,请设置环境变量")
raise HTTPException(500, "MIMO_API_KEY 未配置")
content = f"<style>{style}</style>{text}" if style else text
use_voice = voice or config.MIMO_VOICE
@@ -160,539 +137,216 @@ async def call_mimo_tts(text: str, style: str = "", voice: str = "") -> bytes:
"audio": {"format": "wav", "voice": use_voice},
"messages": [{"role": "assistant", "content": content}],
}
headers = {
"Content-Type": "application/json",
"api-key": config.MIMO_API_KEY,
}
last_exc = None
for attempt in range(1, MAX_TTS_RETRIES + 1):
t0 = time.time()
logger.info(f"MiMo TTS 请求: text_len={len(text)}, style={style or '(默认)'}")
try:
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(config.MIMO_API_ENDPOINT, json=payload, headers=headers)
elapsed = round(time.time() - t0, 2)
if resp.status_code != 200:
logger.error(f"MiMo TTS 错误: HTTP {resp.status_code}, 耗时 {elapsed}s, 响应: {resp.text[:200]}")
raise HTTPException(502, f"MiMo TTS API 错误: HTTP {resp.status_code} - {resp.text[:300]}")
logger.error(f"MiMo TTS HTTP {resp.status_code}, {elapsed}s, {resp.text[:200]}")
err = HTTPException(502, f"MiMo TTS API 错误: HTTP {resp.status_code}")
if resp.status_code >= 500 and attempt < MAX_TTS_RETRIES:
last_exc = err
await asyncio.sleep(1.5 * attempt)
continue
raise err
data = resp.json()
if data.get("error"):
logger.error(f"MiMo TTS 业务错误: {data['error']}, 耗时 {elapsed}s")
raise HTTPException(502, f"MiMo TTS 错误: {data['error']}")
try:
audio_b64 = data["choices"][0]["message"]["audio"]["data"]
wav_bytes = base64.b64decode(audio_b64)
logger.info(f"MiMo TTS 成功: wav_size={len(wav_bytes)} bytes, 耗时 {elapsed}s")
logger.info(f"MiMo TTS OK: {len(wav_bytes)} bytes, {elapsed}s (attempt {attempt})")
return wav_bytes
except (KeyError, IndexError, TypeError) as e:
logger.error(f"MiMo TTS 响应解析失败: {e}, 耗时 {elapsed}s")
raise HTTPException(502, f"MiMo TTS 响应解析失败: {e}")
def wav_to_mp3(wav_path: str, mp3_path: str):
"""用 ffmpeg 将 WAV 转为 MP3"""
result = subprocess.run(
["ffmpeg", "-y", "-i", wav_path, "-codec:a", "libmp3lame", "-qscale:a", "2", mp3_path],
capture_output=True, text=True,
)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg 转换失败: {result.stderr[:300]}")
async def generate_chapter_audio(chapter_id_str: str):
"""为指定章节生成音频(支持长文本自动分段拼接)"""
async with async_session() as db:
result = await db.execute(select(Chapter).where(Chapter.chapter_id == chapter_id_str))
chapter = result.scalar_one_or_none()
if not chapter:
return
if not chapter.text_content.strip():
chapter.status = "error"
chapter.error_msg = "文本内容为空"
await db.commit()
return
chapter.status = "generating"
await db.commit()
try:
audio_dir = Path(config.AUDIO_DIR) / chapter.book_id
audio_dir.mkdir(parents=True, exist_ok=True)
mp3_path = str(audio_dir / f"{chapter.chapter_id}.mp3")
chunks = split_text(chapter.text_content)
if len(chunks) == 1:
# 单段:直接生成
wav_bytes = await call_mimo_tts(chapter.text_content)
wav_path = str(audio_dir / f"{chapter.chapter_id}.wav")
with open(wav_path, "wb") as f:
f.write(wav_bytes)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, wav_to_mp3, wav_path, mp3_path)
os.remove(wav_path)
else:
# 多段:逐段生成 → 拼接
logger.info(f"章节 {chapter_id_str}: 文本 {len(chapter.text_content)} 字, 分 {len(chunks)} 段生成")
tmp_mp3_paths = []
for i, chunk in enumerate(chunks):
wav_bytes = await call_mimo_tts(chunk)
tmp_id = f"{chapter.chapter_id}_part{i}"
wav_path = str(audio_dir / f"{tmp_id}.wav")
tmp_mp3 = str(audio_dir / f"{tmp_id}.mp3")
with open(wav_path, "wb") as f:
f.write(wav_bytes)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, wav_to_mp3, wav_path, tmp_mp3)
os.remove(wav_path)
tmp_mp3_paths.append(tmp_mp3)
# 拼接
await loop.run_in_executor(None, concat_mp3_files, tmp_mp3_paths, mp3_path)
for p in tmp_mp3_paths:
os.remove(p)
logger.info(f"章节 {chapter_id_str}: {len(chunks)} 段拼接完成")
chapter.audio_file = mp3_path
chapter.status = "ready"
chapter.error_msg = ""
except HTTPException:
raise
except Exception as e:
chapter.status = "error"
chapter.error_msg = str(e)[:500]
logger.error(f"章节 {chapter_id_str} 生成失败: {e}")
elapsed = round(time.time() - t0, 2)
logger.error(f"MiMo TTS 异常: {e}, {elapsed}s, attempt {attempt}")
last_exc = HTTPException(502, f"MiMo TTS 异常: {e}")
if attempt < MAX_TTS_RETRIES:
await asyncio.sleep(1.5 * attempt)
await db.commit()
raise last_exc
# ── App Lifecycle ──────────────────────────────────────────────────────────
# ── Core: generate MP3 from text ──────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
os.makedirs(config.AUDIO_DIR, exist_ok=True)
os.makedirs(os.path.join(config.BASE_DIR, "data"), exist_ok=True)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
app = FastAPI(title="TTS Book Service", lifespan=lifespan)
@app.get("/health")
async def health_check():
"""健康检查"""
return {"status": "ok", "service": "TTS Book Service", "api_key_configured": bool(config.MIMO_API_KEY)}
# ── 听书 App 音频接入接口 ─────────────────────────────────────────────────
@app.get("/api/book/{book_id}")
async def get_book_info(book_id: str):
"""获取书籍信息及章节列表(听书 App 调用)"""
async with async_session() as db:
book_result = await db.execute(select(Book).where(Book.book_id == book_id))
book = book_result.scalar_one_or_none()
if not book:
raise HTTPException(404, f"书籍 {book_id} 不存在")
ch_result = await db.execute(
select(Chapter).where(Chapter.book_id == book_id).order_by(Chapter.id)
)
chapters = ch_result.scalars().all()
return {
"book_id": book.book_id,
"title": book.title,
"author": book.author,
"chapters": [
{
"chapter_id": ch.chapter_id,
"app_chapter_id": ch.app_chapter_id,
"title": ch.title,
"status": ch.status,
"audio_url": f"/api/book/{book_id}/chapter/{ch.chapter_id}/audio"
if ch.status == "ready" else None,
}
for ch in chapters
],
}
@app.get("/api/book/{book_id}/chapter/{chapter_id}/audio")
async def get_chapter_audio(book_id: str, chapter_id: str):
"""获取章节音频文件(听书 App 调用)"""
async with async_session() as db:
result = await db.execute(
select(Chapter).where(
Chapter.book_id == book_id, Chapter.chapter_id == chapter_id
)
)
chapter = result.scalar_one_or_none()
if not chapter:
raise HTTPException(404, "章节不存在")
if chapter.status != "ready" or not chapter.audio_file:
raise HTTPException(404, f"音频尚未生成,当前状态: {chapter.status}")
if not os.path.exists(chapter.audio_file):
raise HTTPException(404, "音频文件丢失")
return FileResponse(chapter.audio_file, media_type="audio/mpeg", filename=f"{chapter_id}.mp3")
# ── 实时 TTS 接口(兼容听书 App 格式)─────────────────────────────────────
@app.post("/api/tts")
async def realtime_tts(request: Request):
"""
实时 TTS 生成接口
兼容两种 App 发送格式:
1. JSON body: {"text": "内容", "style": "开心"}
2. Form body: tex=内容&spd=5 (百度风格)
返回: MP3 音频二进制流 (audio/mpeg),失败返回 JSON
"""
text = ""
style = ""
voice = ""
content_type = request.headers.get("content-type", "")
try:
if "json" in content_type:
data = await request.json()
text = data.get("text", "").strip()
style = data.get("style", "").strip()
voice = data.get("voice", "").strip()
else:
# form-urlencoded (百度风格)
body_bytes = await request.body()
params = parse_qs(body_bytes.decode("utf-8"))
text = (params.get("tex", [""])[0]).strip()
# URL 解码(百度会 double-encode
text = unquote(unquote(text))
except Exception:
pass
if not text:
return Response(
content=json.dumps({"status": 40000001, "message": "text/tex 不能为空"}, ensure_ascii=False),
media_type="application/json",
status_code=400,
)
try:
# 文本分段
async def generate_mp3(text: str, style: str = "", voice: str = "") -> bytes:
"""文本 → MP3 字节。长文本自动分段拼接"""
chunks = split_text(text)
logger.info(f"实时 TTS: text_len={len(text)}, chunks={len(chunks)}, style={style or '(默认)'}, voice={voice or '(默认)'}")
tmp_dir = Path(config.AUDIO_DIR) / "_tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
if len(chunks) == 1:
# 单段:直接生成
wav_bytes = await call_mimo_tts(text, style, voice)
tmp_id = uuid.uuid4().hex
wav_path = str(tmp_dir / f"{tmp_id}.wav")
mp3_path = str(tmp_dir / f"{tmp_id}.mp3")
uid = uuid.uuid4().hex
wav_path = str(tmp_dir / f"{uid}.wav")
mp3_path = str(tmp_dir / f"{uid}.mp3")
with open(wav_path, "wb") as f:
f.write(wav_bytes)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, wav_to_mp3, wav_path, mp3_path)
with open(mp3_path, "rb") as f:
mp3_bytes = f.read()
os.remove(wav_path)
os.remove(mp3_path)
else:
# 多段:逐段生成 → 拼接
mp3_paths = []
for i, chunk in enumerate(chunks):
wav_bytes = await call_mimo_tts(chunk, style, voice)
chunk_id = uuid.uuid4().hex
wav_path = str(tmp_dir / f"{chunk_id}.wav")
mp3_path = str(tmp_dir / f"{chunk_id}.mp3")
return mp3_bytes
# 多段
logger.info(f"文本 {len(text)} 字, 分 {len(chunks)} 段生成")
mp3_paths = []
for chunk in chunks:
wav_bytes = await call_mimo_tts(chunk, style, voice)
uid = uuid.uuid4().hex
wav_path = str(tmp_dir / f"{uid}.wav")
mp3_path = str(tmp_dir / f"{uid}.mp3")
with open(wav_path, "wb") as f:
f.write(wav_bytes)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, wav_to_mp3, wav_path, mp3_path)
os.remove(wav_path)
mp3_paths.append(mp3_path)
# 拼接所有 MP3
merged_id = uuid.uuid4().hex
merged_path = str(tmp_dir / f"{merged_id}.mp3")
await loop.run_in_executor(None, concat_mp3_files, mp3_paths, merged_path)
with open(merged_path, "rb") as f:
mp3_bytes = f.read()
# 清理
for p in mp3_paths:
os.remove(p)
os.remove(merged_path)
return mp3_bytes
# ── App ───────────────────────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
os.makedirs(config.AUDIO_DIR, exist_ok=True)
(Path(config.AUDIO_DIR) / "_tmp").mkdir(exist_ok=True)
(Path(config.AUDIO_DIR) / "_preview").mkdir(exist_ok=True)
yield
app = FastAPI(title="TTS Proxy Service", lifespan=lifespan)
# ── 健康检查 ───────────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {
"status": "ok",
"api_key": bool(config.MIMO_API_KEY),
"token": bool(config.API_TOKEN),
}
# ── 核心接口: 实时 TTS ────────────────────────────────────────────────────
@app.post("/api/tts")
async def realtime_tts(request: Request):
"""
实时 TTS → 返回 MP3 音频流
JSON: {"text": "内容", "style": "开心", "voice": ""}
Form: tex=内容 (百度兼容)
"""
text = style = voice = ""
content_type = request.headers.get("content-type", "")
try:
if "json" in content_type:
data = await request.json()
text = (data.get("text") or "").strip()
style = (data.get("style") or "").strip()
voice = (data.get("voice") or "").strip()
else:
from urllib.parse import parse_qs, unquote
body = await request.body()
params = parse_qs(body.decode("utf-8"))
text = unquote(unquote((params.get("tex", [""])[0]).strip()))
except Exception:
pass
if not text:
return Response(
content=json.dumps({"status": 40000001, "message": "text 不能为空"}, ensure_ascii=False),
media_type="application/json", status_code=400,
)
try:
mp3_bytes = await generate_mp3(text, style, voice)
return Response(content=mp3_bytes, media_type="audio/mpeg")
except Exception as e:
return Response(
content=json.dumps({"status": 50000002, "message": str(e)[:300]}, ensure_ascii=False),
media_type="application/json",
status_code=500,
content=json.dumps({"status": 500, "message": str(e)[:300]}, ensure_ascii=False),
media_type="application/json", status_code=500,
)
# ── 管理 API ──────────────────────────────────────────────────────────────
# ── 管理接口 ───────────────────────────────────────────────────────────────
# --- Books ---
@app.get("/admin/api/books")
async def list_books():
async with async_session() as db:
result = await db.execute(select(Book).order_by(Book.id.desc()))
books = result.scalars().all()
return [{"book_id": b.book_id, "title": b.title, "author": b.author} for b in books]
@app.post("/admin/api/books")
async def create_book(request: Request):
@app.post("/admin/api/preview")
async def preview(request: Request, _auth=Depends(verify_token)):
"""TTS 试听,返回音频 URL"""
data = await request.json()
book_id = data.get("book_id", "").strip()
title = data.get("title", "").strip()
author = data.get("author", "").strip()
if not book_id or not title:
raise HTTPException(400, "book_id 和 title 不能为空")
async with async_session() as db:
existing = await db.execute(select(Book).where(Book.book_id == book_id))
if existing.scalar_one_or_none():
raise HTTPException(409, f"书籍 {book_id} 已存在")
book = Book(book_id=book_id, title=title, author=author)
db.add(book)
await db.commit()
return {"ok": True, "book_id": book_id}
@app.delete("/admin/api/books/{book_id}")
async def delete_book(book_id: str):
async with async_session() as db:
await db.execute(delete(Chapter).where(Chapter.book_id == book_id))
await db.execute(delete(Book).where(Book.book_id == book_id))
await db.commit()
return {"ok": True}
# --- Chapters ---
@app.get("/admin/api/books/{book_id}/chapters")
async def list_chapters(book_id: str):
async with async_session() as db:
result = await db.execute(
select(Chapter).where(Chapter.book_id == book_id).order_by(Chapter.id)
)
chapters = result.scalars().all()
return [
{
"chapter_id": ch.chapter_id,
"app_chapter_id": ch.app_chapter_id,
"title": ch.title,
"text_content": ch.text_content[:200] + "..." if len(ch.text_content) > 200 else ch.text_content,
"text_length": len(ch.text_content),
"status": ch.status,
"error_msg": ch.error_msg,
"has_audio": ch.status == "ready",
}
for ch in chapters
]
@app.post("/admin/api/books/{book_id}/chapters")
async def create_chapter(book_id: str, request: Request):
data = await request.json()
chapter_id = data.get("chapter_id", "").strip()
title = data.get("title", "").strip()
app_chapter_id = data.get("app_chapter_id", "").strip()
text_content = data.get("text_content", "").strip()
if not chapter_id:
raise HTTPException(400, "chapter_id 不能为空")
async with async_session() as db:
existing = await db.execute(
select(Chapter).where(Chapter.book_id == book_id, Chapter.chapter_id == chapter_id)
)
if existing.scalar_one_or_none():
raise HTTPException(409, f"章节 {chapter_id} 已存在")
chapter = Chapter(
book_id=book_id,
chapter_id=chapter_id,
app_chapter_id=app_chapter_id or chapter_id,
title=title,
text_content=text_content,
)
db.add(chapter)
await db.commit()
return {"ok": True, "chapter_id": chapter_id}
@app.put("/admin/api/books/{book_id}/chapters/{chapter_id}")
async def update_chapter(book_id: str, chapter_id: str, request: Request):
data = await request.json()
async with async_session() as db:
result = await db.execute(
select(Chapter).where(Chapter.book_id == book_id, Chapter.chapter_id == chapter_id)
)
chapter = result.scalar_one_or_none()
if not chapter:
raise HTTPException(404, "章节不存在")
if "text_content" in data:
chapter.text_content = data["text_content"]
if "title" in data:
chapter.title = data["title"]
if "app_chapter_id" in data:
chapter.app_chapter_id = data["app_chapter_id"]
await db.commit()
return {"ok": True}
@app.delete("/admin/api/books/{book_id}/chapters/{chapter_id}")
async def delete_chapter(book_id: str, chapter_id: str):
async with async_session() as db:
await db.execute(
delete(Chapter).where(Chapter.book_id == book_id, Chapter.chapter_id == chapter_id)
)
await db.commit()
return {"ok": True}
# --- TTS ---
@app.post("/admin/api/books/{book_id}/chapters/{chapter_id}/generate")
async def generate_audio(book_id: str, chapter_id: str):
"""手动生成单章音频"""
await generate_chapter_audio(chapter_id)
async with async_session() as db:
result = await db.execute(
select(Chapter).where(Chapter.book_id == book_id, Chapter.chapter_id == chapter_id)
)
ch = result.scalar_one_or_none()
return {"ok": True, "status": ch.status, "error_msg": ch.error_msg}
@app.post("/admin/api/books/{book_id}/generate-all")
async def generate_all_chapters(book_id: str):
"""批量生成书籍所有章节音频(并发,限制 3 路)"""
async with async_session() as db:
result = await db.execute(
select(Chapter).where(Chapter.book_id == book_id, Chapter.status != "ready")
)
chapters = result.scalars().all()
chapter_ids = [ch.chapter_id for ch in chapters]
if not chapter_ids:
return {"ok": True, "total": 0, "chapter_ids": [], "message": "没有需要生成的章节"}
# 并发生成,限制同时 3 个请求避免过载
sem = asyncio.Semaphore(3)
async def _gen(cid: str):
async with sem:
await generate_chapter_audio(cid)
tasks = [_gen(cid) for cid in chapter_ids]
results = await asyncio.gather(*tasks, return_exceptions=True)
# 统计结果
errors = [str(r) for r in results if isinstance(r, Exception)]
success_count = len(chapter_ids) - len(errors)
return {
"ok": True,
"total": len(chapter_ids),
"success": success_count,
"failed": len(errors),
"errors": errors[:10] if errors else [],
"chapter_ids": chapter_ids,
}
# --- TTS 试听 ---
@app.post("/admin/api/tts/preview")
async def tts_preview(request: Request):
"""试听 TTS 效果"""
data = await request.json()
text = data.get("text", "").strip()
style = data.get("style", "").strip()
voice = data.get("voice", "").strip()
text = (data.get("text") or "").strip()
style = (data.get("style") or "").strip()
voice = (data.get("voice") or "").strip()
if not text:
raise HTTPException(400, "文本不能为空")
wav_bytes = await call_mimo_tts(text, style, voice)
audio_dir = Path(config.AUDIO_DIR) / "_preview"
audio_dir.mkdir(parents=True, exist_ok=True)
mp3_bytes = await generate_mp3(text, style, voice)
preview_dir = Path(config.AUDIO_DIR) / "_preview"
filename = f"{uuid.uuid4().hex}.mp3"
wav_path = str(audio_dir / f"{uuid.uuid4().hex}.wav")
mp3_path = str(audio_dir / filename)
with open(wav_path, "wb") as f:
f.write(wav_bytes)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, wav_to_mp3, wav_path, mp3_path)
os.remove(wav_path)
with open(preview_dir / filename, "wb") as f:
f.write(mp3_bytes)
return {"ok": True, "url": f"/audio/_preview/{filename}"}
@app.get("/admin/api/config")
async def get_config():
async def config_info(_auth=Depends(verify_token)):
return {
"endpoint": config.MIMO_API_ENDPOINT,
"model": config.MIMO_TTS_MODEL,
"voice": config.MIMO_VOICE,
"api_key_masked": config.MIMO_API_KEY[:6] + "****" if config.MIMO_API_KEY else "未配置",
"max_chunk_chars": MAX_CHUNK_CHARS,
"api_key": config.MIMO_API_KEY[:6] + "****" if config.MIMO_API_KEY else "未配置",
"max_chunk": MAX_CHUNK_CHARS,
"token_set": bool(config.API_TOKEN),
}
# ── 配置文件下载 ───────────────────────────────────────────────────────────
@app.get("/httpTts.json")
async def serve_http_tts_config():
"""提供 App 导入用的音频源配置文件"""
config_path = os.path.join(config.BASE_DIR, "httpTts-mimo.json")
if os.path.exists(config_path):
return FileResponse(config_path, media_type="application/json")
raise HTTPException(404, "配置文件不存在")
async def serve_config():
path = os.path.join(config.BASE_DIR, "httpTts-mimo.json")
if os.path.exists(path):
return FileResponse(path, media_type="application/json")
raise HTTPException(404)
# ── 静态文件 & 前端 ──────────────────────────────────────────────────────
# ── 静态 & 前端 ───────────────────────────────────────────────────────────
app.mount("/audio", StaticFiles(directory=config.AUDIO_DIR), name="audio")
@app.get("/", response_class=HTMLResponse)
async def frontend():
html_path = os.path.join(config.BASE_DIR, "static", "index.html")
with open(html_path, "r", encoding="utf-8") as f:
with open(os.path.join(config.BASE_DIR, "static", "index.html"), "r", encoding="utf-8") as f:
return HTMLResponse(f.read())

View File

@@ -1,42 +0,0 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, func
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
import config
engine = create_async_engine(config.DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
class Book(Base):
__tablename__ = "books"
id = Column(Integer, primary_key=True, autoincrement=True)
book_id = Column(String(100), unique=True, nullable=False, index=True) # 平台书籍ID
title = Column(String(500), nullable=False) # 书名
author = Column(String(200), default="") # 作者
created_at = Column(DateTime, server_default=func.now())
class Chapter(Base):
__tablename__ = "chapters"
id = Column(Integer, primary_key=True, autoincrement=True)
book_id = Column(String(100), nullable=False, index=True) # 关联的书籍ID
chapter_id = Column(String(100), nullable=False, index=True) # 平台音频ID
app_chapter_id = Column(String(100), default="") # App章节ID
title = Column(String(500), default="") # 章节标题
text_content = Column(Text, default="") # TTS 文本内容
audio_file = Column(String(500), default="") # 生成的音频文件路径
status = Column(String(20), default="pending") # pending / generating / ready / error
error_msg = Column(Text, default="")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

View File

@@ -3,485 +3,217 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TTS Book Service</title>
<title>TTS Proxy Service</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0f1117;--card:#1a1d27;--border:#2a2d3a;--primary:#6c5ce7;--primary-hover:#7c6df7;--success:#00b894;--error:#ff6b6b;--warn:#fdcb6e;--text:#e8e8e8;--text-dim:#8b8fa3;--text-bright:#fff}
:root{--bg:#0f1117;--card:#1a1d27;--border:#2a2d3a;--primary:#6c5ce7;--primary-hover:#7c6df7;--success:#00b894;--error:#ff6b6b;--warn:#fdcb6e;--text:#e8e8e8;--dim:#8b8fa3;--bright:#fff}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
.container{max-width:1200px;margin:0 auto;padding:20px}
h1{font-size:1.6rem;font-weight:700;margin-bottom:8px;background:linear-gradient(135deg,var(--primary),#a29bfe);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.subtitle{color:var(--text-dim);font-size:.85rem;margin-bottom:24px}
/* Tabs */
.tabs{display:flex;gap:4px;margin-bottom:24px;border-bottom:1px solid var(--border);padding-bottom:0}
.tab{padding:10px 20px;cursor:pointer;color:var(--text-dim);font-size:.9rem;border:none;background:none;transition:all .2s;position:relative;border-bottom:2px solid transparent}
.tab:hover{color:var(--text)}
.tab.active{color:var(--primary);border-bottom-color:var(--primary)}
.tab-panel{display:none}
.tab-panel.active{display:block}
/* Cards */
.c{max-width:800px;margin:0 auto;padding:24px}
h1{font-size:1.5rem;font-weight:700;margin-bottom:6px;background:linear-gradient(135deg,var(--primary),#a29bfe);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.sub{color:var(--dim);font-size:.82rem;margin-bottom:28px}
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px;margin-bottom:16px}
.card-title{font-size:1.1rem;font-weight:600;margin-bottom:16px;display:flex;align-items:center;gap:8px}
/* Forms */
.form-group{margin-bottom:14px}
.form-group label{display:block;font-size:.8rem;color:var(--text-dim);margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
input,textarea,select{width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:.9rem;outline:none;transition:border .2s}
input:focus,textarea:focus,select:focus{border-color:var(--primary)}
textarea{resize:vertical;min-height:100px;font-family:inherit}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
/* Buttons */
.btn{padding:8px 18px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem;font-weight:500;transition:all .2s;display:inline-flex;align-items:center;gap:6px}
.btn-primary{background:var(--primary);color:#fff}
.btn-primary:hover{background:var(--primary-hover);transform:translateY(-1px)}
.btn-success{background:var(--success);color:#fff}
.btn-success:hover{opacity:.9}
.btn-danger{background:var(--error);color:#fff}
.btn-danger:hover{opacity:.9}
.btn-sm{padding:5px 12px;font-size:.78rem}
.card-t{font-size:1rem;font-weight:600;margin-bottom:14px;display:flex;align-items:center;gap:8px}
.fg{margin-bottom:12px}
.fg label{display:block;font-size:.78rem;color:var(--dim);margin-bottom:5px;text-transform:uppercase;letter-spacing:.5px}
input,textarea{width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:.88rem;outline:none;transition:border .2s}
input:focus,textarea:focus{border-color:var(--primary)}
textarea{resize:vertical;min-height:120px;font-family:inherit}
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.btn{padding:9px 20px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem;font-weight:500;transition:all .2s;display:inline-flex;align-items:center;gap:6px}
.btn-p{background:var(--primary);color:#fff}.btn-p:hover{background:var(--primary-hover);transform:translateY(-1px)}
.btn:disabled{opacity:.5;cursor:not-allowed}
/* Table */
.table-wrap{overflow-x:auto}
table{width:100%;border-collapse:collapse}
th{text-align:left;padding:10px 12px;font-size:.75rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--border)}
td{padding:10px 12px;border-bottom:1px solid var(--border);font-size:.85rem;vertical-align:middle}
tr:hover{background:rgba(108,92,231,.05)}
/* Status badges */
.badge{display:inline-block;padding:3px 10px;border-radius:20px;font-size:.72rem;font-weight:600;text-transform:uppercase}
.badge-ready{background:rgba(0,184,148,.15);color:var(--success)}
.badge-pending{background:rgba(253,203,110,.15);color:var(--warn)}
.badge-generating{background:rgba(108,92,231,.15);color:var(--primary);animation:pulse 1.5s infinite}
.badge-error{background:rgba(255,107,107,.15);color:var(--error)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
/* Modal */
.modal-overlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.6);z-index:100;justify-content:center;align-items:center}
.modal-overlay.show{display:flex}
.modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:24px;width:90%;max-width:700px;max-height:80vh;overflow-y:auto}
.modal-title{font-size:1.1rem;font-weight:600;margin-bottom:16px}
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
/* Toast */
.toast{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:10px;font-size:.85rem;z-index:200;animation:slideIn .3s ease;max-width:400px}
.toast-success{background:var(--success);color:#fff}
.toast-error{background:var(--error);color:#fff}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
/* Preview */
.preview-box{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:16px;margin-top:12px}
audio{width:100%;margin-top:8px}
.text-dim{color:var(--text-dim)}
.flex{display:flex;gap:8px;align-items:center}
.flex-between{display:flex;justify-content:space-between;align-items:center}
.preview{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px;margin-top:12px}
audio{width:100%;margin-top:6px}
.dim{color:var(--dim)}
.mt-2{margin-top:8px}
.mt-4{margin-top:16px}
.mb-2{margin-bottom:8px}
/* Scrollable text preview */
.text-preview{max-height:120px;overflow-y:auto;font-size:.82rem;color:var(--text-dim);line-height:1.5;white-space:pre-wrap;word-break:break-all}
.toast{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:10px;font-size:.85rem;z-index:200;animation:slideIn .3s ease;max-width:380px}
.toast-ok{background:var(--success);color:#fff}.toast-err{background:var(--error);color:#fff}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
table{width:100%;border-collapse:collapse}
th{text-align:left;padding:8px 10px;font-size:.73rem;color:var(--dim);text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--border)}
td{padding:8px 10px;border-bottom:1px solid var(--border);font-size:.84rem}
.code{font-family:'SF Mono',Menlo,monospace;font-size:.8rem;color:var(--primary)}
.tabs{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid var(--border)}
.tab{padding:10px 18px;cursor:pointer;color:var(--dim);font-size:.88rem;border:none;background:none;transition:all .2s;border-bottom:2px solid transparent}
.tab:hover{color:var(--text)}.tab.on{color:var(--primary);border-bottom-color:var(--primary)}
.panel{display:none}.panel.on{display:block}
.hint{font-size:.78rem;color:var(--dim);margin-top:6px;line-height:1.5}
</style>
</head>
<body>
<div class="container">
<h1>📚 TTS Book Service</h1>
<p class="subtitle">小米 MiMo TTS 听书音频转换服务</p>
<div class="c">
<h1>🎙️ TTS Proxy Service</h1>
<p class="sub">小米 MiMo TTS 代理服务 · 智能分段 · 自动重试</p>
<div class="tabs">
<button class="tab active" onclick="switchTab('books')">📖 书籍管理</button>
<button class="tab" onclick="switchTab('preview')">🎙 TTS 试听</button>
<button class="tab" onclick="switchTab('settings')">⚙️ 配置</button>
<button class="tab on" onclick="sw('tts',this)">🎙️ TTS 试听</button>
<button class="tab" onclick="sw('cfg',this)"> 配置</button>
<button class="tab" onclick="sw('api',this)">📖 接口说明</button>
</div>
<!-- Tab: Books -->
<div id="tab-books" class="tab-panel active">
<!-- TTS 试听 -->
<div id="p-tts" class="panel on">
<div class="card">
<div class="flex-between">
<div class="card-title" style="margin-bottom:0">📖 书籍列表</div>
<button class="btn btn-primary" onclick="showAddBook()">+ 添加书籍</button>
<div class="card-t">🎙️ TTS 试听</div>
<div class="row">
<div class="fg">
<label>音色(可选,留空用默认)</label>
<input id="pv-voice" placeholder="mimo_default">
</div>
<div class="fg">
<label>风格(可选)</label>
<input id="pv-style" placeholder="开心、语速慢、东北话...">
</div>
</div>
<div id="book-list" class="card">
<p class="text-dim">加载中...</p>
</div>
</div>
<!-- Tab: Preview -->
<div id="tab-preview" class="tab-panel">
<div class="card">
<div class="card-title">🎙️ TTS 试听</div>
<div class="form-row">
<div class="form-group">
<label>说话风格(可选)</label>
<input id="preview-style" placeholder="如:开心、语速慢、东北话、像个大将军...">
</div>
<div class="form-group">
<label>音色(可选)</label>
<input id="preview-voice" placeholder="留空使用默认音色 mimo_default">
</div>
</div>
<div class="form-group">
<div class="fg">
<label>文本内容</label>
<textarea id="preview-text" rows="4" placeholder="输入要合成的文本..."></textarea>
<textarea id="pv-text" rows="5" placeholder="输入要合成的文本...长文本自动分段生成"></textarea>
</div>
<button class="btn btn-primary" onclick="doPreview()" id="preview-btn">🔊 生成试听</button>
<div id="preview-result" class="preview-box" style="display:none">
<audio id="preview-audio" controls></audio>
<button class="btn btn-p" onclick="preview()" id="pv-btn">🔊 生成试听</button>
<div id="pv-result" class="preview" style="display:none">
<audio id="pv-audio" controls></audio>
<p class="dim mt-2" id="pv-info"></p>
</div>
</div>
</div>
<!-- Tab: Settings -->
<div id="tab-settings" class="tab-panel">
<!-- 配置 -->
<div id="p-cfg" class="panel">
<div class="card">
<div class="card-title">⚙️ 当前配置</div>
<div class="card-t">⚙️ 当前配置</div>
<table>
<tr><th style="width:180px">配置</th><th></th></tr>
<tr><td>TTS API</td><td id="cfg-endpoint">-</td></tr>
<tr><td>模型</td><td id="cfg-model">-</td></tr>
<tr><td>默认音色</td><td id="cfg-voice">-</td></tr>
<tr><td>API Key</td><td id="cfg-apikey">-</td></tr>
<tr><th style="width:140px"></th><th></th></tr>
<tr><td>TTS Endpoint</td><td id="c-ep">-</td></tr>
<tr><td>模型</td><td id="c-md">-</td></tr>
<tr><td>默认音色</td><td id="c-vc">-</td></tr>
<tr><td>API Key</td><td id="c-ak">-</td></tr>
<tr><td>分段上限</td><td id="c-ch">-</td></tr>
<tr><td>访问令牌</td><td id="c-tk">-</td></tr>
</table>
<p class="text-dim mt-4" style="font-size:.8rem">通过环境变量配置MIMO_API_KEY、MIMO_API_ENDPOINT、MIMO_TTS_MODEL、MIMO_VOICE</p>
</div>
<p class="hint mt-2">通过环境变量配置MIMO_API_KEY、MIMO_VOICE、MIMO_TTS_MODEL、API_TOKEN 等</p>
</div>
</div>
<!-- Add Book Modal -->
<div id="modal-add-book" class="modal-overlay">
<div class="modal">
<div class="modal-title">📖 添加书籍</div>
<div class="form-group">
<label>书籍 ID听书 App 中的 book_id</label>
<input id="new-book-id" placeholder="如book_9">
</div>
<div class="form-group">
<label>书名</label>
<input id="new-book-title" placeholder="书籍名称">
</div>
<div class="form-group">
<label>作者</label>
<input id="new-book-author" placeholder="作者(可选)">
</div>
<div class="modal-actions">
<button class="btn" style="background:var(--border)" onclick="closeModal('modal-add-book')">取消</button>
<button class="btn btn-primary" onclick="addBook()">确认添加</button>
</div>
<!-- 接口说明 -->
<div id="p-api" class="panel">
<div class="card">
<div class="card-t">📖 核心接口</div>
<table>
<tr><th>接口</th><th>方法</th><th>说明</th></tr>
<tr><td><code class="code">/api/tts</code></td><td>POST</td><td>实时 TTS返回 MP3 音频流</td></tr>
<tr><td><code class="code">/health</code></td><td>GET</td><td>健康检查</td></tr>
</table>
</div>
<div class="card">
<div class="card-t">🔧 管理接口 <span class="dim" style="font-weight:400;font-size:.78rem">(需 Bearer Token</span></div>
<table>
<tr><th>接口</th><th>方法</th><th>说明</th></tr>
<tr><td><code class="code">/admin/api/preview</code></td><td>POST</td><td>TTS 试听,返回音频 URL</td></tr>
<tr><td><code class="code">/admin/api/config</code></td><td>GET</td><td>查看配置</td></tr>
</table>
</div>
<div class="card">
<div class="card-t">📤 /api/tts 请求格式</div>
<p class="hint">JSON 格式(推荐):</p>
<pre style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;margin:8px 0;font-size:.82rem;overflow-x:auto"><code>POST /api/tts
Content-Type: application/json
<!-- Chapter Modal -->
<div id="modal-chapters" class="modal-overlay">
<div class="modal">
<div class="flex-between">
<div class="modal-title" id="chapter-modal-title" style="margin-bottom:0">章节管理</div>
<button class="btn btn-sm btn-primary" onclick="showAddChapter()">+ 添加章节</button>
</div>
<div id="chapter-list" class="mt-4">
<p class="text-dim">加载中...</p>
</div>
<div class="mt-4" id="bulk-actions" style="display:none">
<button class="btn btn-success" onclick="generateAll()">⚡ 批量生成所有未就绪章节</button>
</div>
<div class="modal-actions">
<button class="btn" style="background:var(--border)" onclick="closeModal('modal-chapters')">关闭</button>
</div>
</div>
</div>
{
"text": "要合成的文本",
"style": "开心", // 可选
"voice": "mimo_default" // 可选
}</code></pre>
<p class="hint">Form 格式(兼容百度风格):</p>
<pre style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;margin:8px 0;font-size:.82rem"><code>POST /api/tts
Content-Type: application/x-www-form-urlencoded
<!-- Add Chapter Modal -->
<div id="modal-add-chapter" class="modal-overlay">
<div class="modal">
<div class="modal-title" id="add-chapter-title">添加章节</div>
<div class="form-row">
<div class="form-group">
<label>章节 ID</label>
<input id="new-chapter-id" placeholder="如chapter_1">
tex=要合成的文本</code></pre>
<p class="hint mt-2">
<b>特性:</b>长文本自动分段≤2000字/段)+ TTS 失败自动重试(最多 3 次)<br>
<b>听书 App 接入:</b>在 App 中配置 TTS 源 URL 为 <code class="code">http://服务器:端口/api/tts</code>
</p>
</div>
<div class="form-group">
<label>App 章节 ID</label>
<input id="new-chapter-app-id" placeholder="如chapter1">
<div class="card">
<div class="card-t">🎭 MiMo TTS 风格参考</div>
<table>
<tr><th>类别</th><th>示例</th></tr>
<tr><td>情感</td><td>开心 / 悲伤 / 生气 / 平静 / 惊讶</td></tr>
<tr><td>语速</td><td>语速慢 / 语速快 / 悄悄话</td></tr>
<tr><td>角色</td><td>像个大将军 / 像个小孩 / 孙悟空</td></tr>
<tr><td>方言</td><td>东北话 / 四川话 / 台湾腔 / 粤语</td></tr>
</table>
<p class="hint mt-2">可组合使用:<code class="code">"style": "开心 语速快"</code></p>
</div>
</div>
<div class="form-group">
<label>章节标题</label>
<input id="new-chapter-title" placeholder="章节名称">
</div>
<div class="form-group">
<label>文本内容TTS 输入)</label>
<textarea id="new-chapter-text" rows="8" placeholder="粘贴本章节的文本内容..."></textarea>
</div>
<div class="modal-actions">
<button class="btn" style="background:var(--border)" onclick="closeModal('modal-add-chapter')">取消</button>
<button class="btn btn-primary" onclick="addChapter()">添加</button>
</div>
</div>
</div>
<!-- Edit Chapter Modal -->
<div id="modal-edit-chapter" class="modal-overlay">
<div class="modal">
<div class="modal-title">编辑章节文本</div>
<input type="hidden" id="edit-chapter-id">
<div class="form-group">
<label>文本内容</label>
<textarea id="edit-chapter-text" rows="12" placeholder="修改章节文本..."></textarea>
</div>
<div class="modal-actions">
<button class="btn" style="background:var(--border)" onclick="closeModal('modal-edit-chapter')">取消</button>
<button class="btn btn-primary" onclick="saveChapterText()">保存</button>
<div class="card">
<div class="card-t">📱 听书 App 模板变量</div>
<table>
<tr><th>变量</th><th>说明</th></tr>
<tr><td><code class="code">{{speakText}}</code></td><td>朗读文本</td></tr>
<tr><td><code class="code">{{speakSpeed}}</code></td><td>语速,范围 5-50</td></tr>
</table>
<p class="hint mt-2">
App 只能动态传文本和语速。voice/style 需在 JSON 配置中写死,或通过其他客户端调用 /api/tts 时传入。
</p>
</div>
</div>
</div>
<script>
let currentBookId = null;
// ── Tab ──
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
if (name === 'settings') loadSettings();
function sw(name, el) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('on'));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('on'));
el.classList.add('on');
document.getElementById('p-' + name).classList.add('on');
if (name === 'cfg') loadCfg();
}
// ── Toast ──
function toast(msg, type = 'success') {
function toast(msg, ok = true) {
const t = document.createElement('div');
t.className = 'toast toast-' + type;
t.className = 'toast ' + (ok ? 'toast-ok' : 'toast-err');
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
// ── Modal ──
function showModal(id) { document.getElementById(id).classList.add('show'); }
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
// ── Books ──
async function loadBooks() {
async function preview() {
const text = document.getElementById('pv-text').value.trim();
const style = document.getElementById('pv-style').value.trim();
const voice = document.getElementById('pv-voice').value.trim();
if (!text) { toast('请输入文本', false); return; }
const btn = document.getElementById('pv-btn');
btn.disabled = true; btn.textContent = '⏳ 生成中...';
try {
const res = await fetch('/admin/api/books');
const books = await res.json();
const el = document.getElementById('book-list');
if (!books.length) {
el.innerHTML = '<p class="text-dim">暂无书籍,点击右上角「添加书籍」开始</p>';
return;
}
el.innerHTML = `<table>
<tr><th>书籍ID</th><th>书名</th><th>作者</th><th>操作</th></tr>
${books.map(b => `<tr>
<td><code>${b.book_id}</code></td>
<td>${b.title}</td>
<td>${b.author || '-'}</td>
<td class="flex">
<button class="btn btn-sm btn-primary" onclick="openChapters('${b.book_id}','${b.title}')">管理章节</button>
<button class="btn btn-sm btn-danger" onclick="deleteBook('${b.book_id}')">删除</button>
</td>
</tr>`).join('')}
</table>`;
} catch(e) { toast('加载失败: ' + e.message, 'error'); }
}
function showAddBook() { showModal('modal-add-book'); }
async function addBook() {
const book_id = document.getElementById('new-book-id').value.trim();
const title = document.getElementById('new-book-title').value.trim();
const author = document.getElementById('new-book-author').value.trim();
if (!book_id || !title) { toast('书籍ID和书名不能为空', 'error'); return; }
try {
await fetch('/admin/api/books', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({book_id, title, author})
});
closeModal('modal-add-book');
document.getElementById('new-book-id').value = '';
document.getElementById('new-book-title').value = '';
document.getElementById('new-book-author').value = '';
toast('书籍添加成功');
loadBooks();
} catch(e) { toast(e.message, 'error'); }
}
async function deleteBook(book_id) {
if (!confirm(`确定删除书籍 ${book_id} 及其所有章节?`)) return;
await fetch(`/admin/api/books/${book_id}`, {method: 'DELETE'});
toast('已删除');
loadBooks();
}
// ── Chapters ──
async function openChapters(book_id, title) {
currentBookId = book_id;
document.getElementById('chapter-modal-title').textContent = `📖 ${title} (${book_id})`;
showModal('modal-chapters');
await loadChapters(book_id);
}
async function loadChapters(book_id) {
try {
const res = await fetch(`/admin/api/books/${book_id}/chapters`);
const chapters = await res.json();
const el = document.getElementById('chapter-list');
const bulkEl = document.getElementById('bulk-actions');
if (!chapters.length) {
el.innerHTML = '<p class="text-dim">暂无章节</p>';
bulkEl.style.display = 'none';
return;
}
bulkEl.style.display = 'block';
el.innerHTML = `<table>
<tr><th>章节ID</th><th>App ID</th><th>标题</th><th>字数</th><th>状态</th><th>操作</th></tr>
${chapters.map(ch => `<tr>
<td><code>${ch.chapter_id}</code></td>
<td>${ch.app_chapter_id}</td>
<td>${ch.title || '-'}</td>
<td>${ch.text_length}</td>
<td><span class="badge badge-${ch.status}">${ch.status}</span></td>
<td class="flex" style="flex-wrap:wrap">
<button class="btn btn-sm btn-primary" onclick="editChapter('${book_id}','${ch.chapter_id}')">编辑</button>
<button class="btn btn-sm btn-success" onclick="generateOne('${book_id}','${ch.chapter_id}')">生成</button>
<button class="btn btn-sm btn-danger" onclick="deleteChapter('${book_id}','${ch.chapter_id}')">删除</button>
</td>
</tr>`).join('')}
</table>`;
} catch(e) { toast('加载失败: ' + e.message, 'error'); }
}
function showAddChapter() {
document.getElementById('add-chapter-title').textContent = '添加章节';
showModal('modal-add-chapter');
}
async function addChapter() {
const chapter_id = document.getElementById('new-chapter-id').value.trim();
const app_chapter_id = document.getElementById('new-chapter-app-id').value.trim();
const title = document.getElementById('new-chapter-title').value.trim();
const text_content = document.getElementById('new-chapter-text').value.trim();
if (!chapter_id) { toast('章节ID不能为空', 'error'); return; }
try {
await fetch(`/admin/api/books/${currentBookId}/chapters`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({chapter_id, app_chapter_id, title, text_content})
});
closeModal('modal-add-chapter');
['new-chapter-id','new-chapter-app-id','new-chapter-title','new-chapter-text'].forEach(id => document.getElementById(id).value = '');
toast('章节添加成功');
loadChapters(currentBookId);
} catch(e) { toast(e.message, 'error'); }
}
async function editChapter(book_id, chapter_id) {
document.getElementById('edit-chapter-id').value = chapter_id;
// Fetch full chapter text
const res = await fetch(`/admin/api/books/${book_id}/chapters`);
const chapters = await res.json();
const ch = chapters.find(c => c.chapter_id === chapter_id);
document.getElementById('edit-chapter-text').value = ch ? ch.text_content : '';
showModal('modal-edit-chapter');
}
async function saveChapterText() {
const chapter_id = document.getElementById('edit-chapter-id').value;
const text_content = document.getElementById('edit-chapter-text').value;
try {
await fetch(`/admin/api/books/${currentBookId}/chapters/${chapter_id}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text_content})
});
closeModal('modal-edit-chapter');
toast('文本已保存');
loadChapters(currentBookId);
} catch(e) { toast(e.message, 'error'); }
}
async function deleteChapter(book_id, chapter_id) {
if (!confirm(`确定删除章节 ${chapter_id}`)) return;
await fetch(`/admin/api/books/${book_id}/chapters/${chapter_id}`, {method: 'DELETE'});
toast('已删除');
loadChapters(book_id);
}
// ── TTS Generation ──
async function generateOne(book_id, chapter_id) {
const btn = event.target;
btn.disabled = true;
btn.textContent = '生成中...';
try {
const res = await fetch(`/admin/api/books/${book_id}/chapters/${chapter_id}/generate`, {method: 'POST'});
const data = await res.json();
if (data.status === 'ready') toast('音频生成成功!');
else toast('生成失败: ' + (data.error_msg || '未知错误'), 'error');
loadChapters(book_id);
} catch(e) { toast('生成失败: ' + e.message, 'error'); }
btn.disabled = false;
btn.textContent = '生成';
}
async function generateAll() {
if (!confirm('确定批量生成所有未就绪章节的音频?这可能需要较长时间。')) return;
try {
toast('开始批量生成...');
const res = await fetch(`/admin/api/books/${currentBookId}/generate-all`, {method: 'POST'});
const data = await res.json();
toast(`批量生成完成,共 ${data.total}`);
loadChapters(currentBookId);
} catch(e) { toast('批量生成失败: ' + e.message, 'error'); }
}
// ── Preview ──
async function doPreview() {
const text = document.getElementById('preview-text').value.trim();
const style = document.getElementById('preview-style').value.trim();
const voice = document.getElementById('preview-voice').value.trim();
if (!text) { toast('请输入文本', 'error'); return; }
const btn = document.getElementById('preview-btn');
btn.disabled = true;
btn.textContent = '⏳ 生成中...';
try {
const res = await fetch('/admin/api/tts/preview', {
const res = await fetch('/admin/api/preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text, style, voice})
});
const data = await res.json();
if (data.ok) {
document.getElementById('preview-result').style.display = 'block';
document.getElementById('preview-audio').src = data.url;
document.getElementById('preview-audio').play();
toast('试听生成成功');
} else {
toast('生成失败', 'error');
}
} catch(e) { toast('生成失败: ' + e.message, 'error'); }
btn.disabled = false;
btn.textContent = '🔊 生成试听';
document.getElementById('pv-result').style.display = 'block';
document.getElementById('pv-audio').src = data.url;
document.getElementById('pv-audio').play();
document.getElementById('pv-info').textContent = data.chunks > 1 ? `已自动分 ${data.chunks} 段生成并拼接` : '';
toast('生成成功');
} else { toast('生成失败', false); }
} catch(e) { toast('生成失败: ' + e.message, false); }
btn.disabled = false; btn.textContent = '🔊 生成试听';
}
// ── Settings ──
async function loadSettings() {
async function loadCfg() {
try {
const res = await fetch('/admin/api/config');
const cfg = await res.json();
document.getElementById('cfg-endpoint').textContent = cfg.endpoint || '-';
document.getElementById('cfg-model').textContent = cfg.model || '-';
document.getElementById('cfg-voice').textContent = cfg.voice || '-';
document.getElementById('cfg-apikey').textContent = cfg.api_key_masked || '未配置';
const c = await res.json();
document.getElementById('c-ep').textContent = c.endpoint || '-';
document.getElementById('c-md').textContent = c.model || '-';
document.getElementById('c-vc').textContent = c.voice || '-';
document.getElementById('c-ak').textContent = c.api_key || '-';
document.getElementById('c-ch').textContent = c.max_chunk + ' 字符';
document.getElementById('c-tk').textContent = c.token_set ? '✅ 已配置' : '❌ 未配置(接口公开访问)';
} catch(e) {
document.getElementById('cfg-apikey').textContent = '加载失败';
toast('加载配置失败: ' + e.message, false);
}
}
// ── Init ──
loadBooks();
</script>
</body>
</html>

View File

@@ -1,14 +1,13 @@
#!/bin/bash
set -e
echo "🚀 TTS Book Service 一键部署"
echo "🎙️ TTS Proxy Service 一键部署"
echo "=============================="
# 1. 配置 Docker 镜像加速
# 1. Docker 镜像加速
DAEMON_JSON="/etc/docker/daemon.json"
if [ -f "$DAEMON_JSON" ]; then
echo "⚠️ $DAEMON_JSON 已存在,跳过镜像加速配置"
echo " 如需配置,请手动添加 registry-mirrors"
else
echo "📦 配置 Docker 镜像加速..."
sudo tee "$DAEMON_JSON" > /dev/null << 'EOF'
@@ -36,7 +35,7 @@ if [ ! -f .env ]; then
exit 1
fi
# 3. 启动服务
# 3. 启动
echo ""
echo "🐳 启动服务..."
docker compose up -d --build
@@ -44,5 +43,7 @@ docker compose up -d --build
echo ""
echo "=============================="
echo "✅ 部署完成!"
echo " 管理界面: http://$(hostname -I | awk '{print $1}'):3333"
echo " TTS 接口: http://$(hostname -I | awk '{print $1}'):3333/api/tts"
IP=$(hostname -I | awk '{print $1}')
echo " 管理界面: http://${IP}:3333"
echo " TTS 接口: http://${IP}:3333/api/tts"
echo " 健康检查: http://${IP}:3333/health"

View File

@@ -1,9 +1,9 @@
version: "3.8"
services:
tts-book-service:
tts-proxy:
build: .
container_name: tts-book-service
container_name: tts-proxy
ports:
- "3333:3333"
environment:
@@ -11,9 +11,8 @@ services:
- MIMO_API_ENDPOINT=${MIMO_API_ENDPOINT:-https://api.xiaomimimo.com/v1/chat/completions}
- MIMO_TTS_MODEL=${MIMO_TTS_MODEL:-mimo-v2-audio-tts}
- MIMO_VOICE=${MIMO_VOICE:-mimo_default}
- SERVER_PORT=3333
- API_TOKEN=${API_TOKEN:-}
volumes:
- tts-data:/app/data
- tts-audio:/app/audio
deploy:
resources:
@@ -23,5 +22,4 @@ services:
restart: unless-stopped
volumes:
tts-data:
tts-audio:

View File

@@ -1,6 +1,4 @@
fastapi==0.115.0
uvicorn[standard]==0.30.0
sqlalchemy==2.0.35
aiosqlite==0.20.0
httpx==0.27.0
python-multipart==0.0.12