refactor: 精简架构,去掉书籍管理,核心 TTS 代理
- 去掉 books/chapters CRUD、SQLAlchemy、SQLite 依赖 - 核心只剩 /api/tts + 智能分段 + 自动重试 - 新增 API_TOKEN 环境变量,管理接口 Bearer Token 鉴权 - 管理接口精简为 preview + config - 前端重写:TTS 试听 + 配置查看 + 接口文档 - Dockerfile/docker-compose 清理,去掉数据库卷
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
# 小米 MiMo TTS API Key(必填)
|
# 小米 MiMo TTS API Key(必填)
|
||||||
MIMO_API_KEY=your_api_key_here
|
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_API_ENDPOINT=https://api.xiaomimimo.com/v1/chat/completions
|
||||||
# MIMO_TTS_MODEL=mimo-v2-audio-tts
|
# MIMO_TTS_MODEL=mimo-v2-audio-tts
|
||||||
|
|||||||
449
API.md
449
API.md
@@ -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` 和章节音频生成均自动支持。
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
FROM python:3.11-alpine
|
FROM python:3.11-alpine
|
||||||
|
|
||||||
# 配置 Alpine 国内镜像源
|
|
||||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||||
|
|
||||||
# 安装 ffmpeg
|
|
||||||
RUN apk add --no-cache ffmpeg
|
RUN apk add --no-cache ffmpeg
|
||||||
|
|
||||||
WORKDIR /app
|
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 app/ .
|
||||||
COPY httpTts-mimo.json .
|
COPY httpTts-mimo.json .
|
||||||
|
|
||||||
RUN mkdir -p /app/data /app/audio
|
RUN mkdir -p /app/audio/_tmp /app/audio/_preview
|
||||||
|
|
||||||
EXPOSE 3333
|
EXPOSE 3333
|
||||||
|
|
||||||
|
|||||||
108
README.md
108
README.md
@@ -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 (书籍/章节管理)
|
├── 智能文本分段(>2000字自动切分拼接)
|
||||||
└── MP3 文件缓存
|
├── TTS 自动重试(5xx 错误最多重试 3 次)
|
||||||
|
└── MP3 缓存
|
||||||
```
|
```
|
||||||
|
|
||||||
## 快速启动
|
## 快速启动
|
||||||
@@ -16,83 +17,69 @@
|
|||||||
```bash
|
```bash
|
||||||
# 1. 配置 API Key
|
# 1. 配置 API Key
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑 .env 填入你的 MIMO_API_KEY
|
# 编辑 .env 填入 MIMO_API_KEY
|
||||||
|
|
||||||
# 2. 启动
|
# 2. 启动
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# 3. 访问管理界面
|
# 3. 访问
|
||||||
# http://your-server:17200
|
# 管理界面: http://your-server:3333
|
||||||
|
# TTS 接口: http://your-server:3333/api/tts
|
||||||
```
|
```
|
||||||
|
|
||||||
## 功能
|
## 核心功能
|
||||||
|
|
||||||
### Web 管理界面 (`/`)
|
### POST `/api/tts` — 实时 TTS
|
||||||
- 📖 书籍管理(添加/删除)
|
|
||||||
- 📑 章节管理(添加/编辑/删除)
|
|
||||||
- 🎙️ TTS 试听(支持风格 + 音色设置)
|
|
||||||
- ⚡ 单章/批量音频生成(自动分段拼接)
|
|
||||||
- ⚙️ 配置查看
|
|
||||||
|
|
||||||
### 核心特性
|
文本进 → MP3 出。支持 JSON 和 Form 两种格式,长文本自动分段拼接。
|
||||||
- **智能文本分段**: 长文本自动在句末/段落边界切分(≤2000字/段),逐段生成后拼接
|
|
||||||
- **多风格支持**: 开心、悲伤、东北话、像个大将军... 任意组合
|
|
||||||
- **音色切换**: 支持指定不同音色(voice 参数)
|
|
||||||
- **并发批量生成**: 限制 3 路并发,快速完成整本书
|
|
||||||
|
|
||||||
### 听书 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 | 获取书籍信息和章节列表 |
|
| `/admin/api/preview` | POST | TTS 试听,返回音频 URL |
|
||||||
| `/api/book/{book_id}/chapter/{chapter_id}/audio` | GET | 下载章节 MP3 音频 |
|
| `/admin/api/config` | GET | 查看当前配置 |
|
||||||
|
|
||||||
### 管理 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 | 查看配置 |
|
|
||||||
|
|
||||||
## 接入听书 App
|
## 接入听书 App
|
||||||
|
|
||||||
1. 在管理界面添加书籍和章节
|
在 App 中配置 TTS 音频源:
|
||||||
2. 为章节生成音频
|
|
||||||
3. 在听书 App 配置:
|
- **URL**: `http://your-server:3333/api/tts`
|
||||||
- **接入方式**: 音频方式
|
- **Method**: POST
|
||||||
- **接入地址**: `http://your-server:17200`
|
- **Body**: `{"text": "{{speakText}}"}`
|
||||||
- **音频类型**: mp3
|
- **Content-Type**: `audio/mpeg`
|
||||||
- **书籍地址**: `http://your-server:17200/api/book/{book_id}`
|
|
||||||
|
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_API_ENDPOINT` | ❌ | `https://api.xiaomimimo.com/v1/chat/completions` | API 地址 |
|
||||||
| `MIMO_TTS_MODEL` | ❌ | `mimo-v2-audio-tts` | 模型名称 |
|
| `MIMO_TTS_MODEL` | ❌ | `mimo-v2-audio-tts` | 模型名称 |
|
||||||
| `MIMO_VOICE` | ❌ | `mimo_default` | 默认音色 |
|
| `MIMO_VOICE` | ❌ | `mimo_default` | 默认音色 |
|
||||||
| `SERVER_PORT` | ❌ | `3333` | 服务端口 |
|
| `API_TOKEN` | ❌ | - | 管理接口 Bearer Token(留空则不鉴权) |
|
||||||
|
|
||||||
## 📖 API 文档
|
|
||||||
|
|
||||||
完整 API 文档见 [**API.md**](./API.md),包含:
|
|
||||||
|
|
||||||
- 所有接口的请求/响应格式
|
|
||||||
- 参数说明与错误码
|
|
||||||
- MiMo TTS 风格参考大全
|
|
||||||
- 文本自动分段机制说明
|
|
||||||
|
|
||||||
## MiMo TTS 风格参考
|
## MiMo TTS 风格参考
|
||||||
|
|
||||||
在「TTS 试听」中可填写风格,例如:
|
|
||||||
|
|
||||||
| 类别 | 示例 |
|
| 类别 | 示例 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 情感 | 开心 / 悲伤 / 生气 / 平静 |
|
| 情感 | 开心 / 悲伤 / 生气 / 平静 |
|
||||||
@@ -100,12 +87,11 @@ docker compose up -d
|
|||||||
| 角色 | 像个大将军 / 像个小孩 / 孙悟空 |
|
| 角色 | 像个大将军 / 像个小孩 / 孙悟空 |
|
||||||
| 方言 | 东北话 / 四川话 / 台湾腔 / 粤语 |
|
| 方言 | 东北话 / 四川话 / 台湾腔 / 粤语 |
|
||||||
|
|
||||||
## 不使用 Docker 运行
|
## 不使用 Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
# 需要系统安装 ffmpeg
|
# 系统需安装 ffmpeg
|
||||||
export MIMO_API_KEY=your_key
|
export MIMO_API_KEY=your_key
|
||||||
cd app
|
cd app && python main.py
|
||||||
python main.py
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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_HOST = os.environ.get("SERVER_HOST", "0.0.0.0")
|
||||||
SERVER_PORT = int(os.environ.get("SERVER_PORT", "3333"))
|
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"))
|
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")
|
|
||||||
|
|||||||
682
app/main.py
682
app/main.py
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
TTS Book Service - 小米 MiMo TTS 转换服务
|
TTS Proxy Service - 小米 MiMo TTS 音频转换代理
|
||||||
为听书 App 提供音频接入接口
|
核心功能: /api/tts 实时 TTS + 智能分段 + 自动重试
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -15,13 +15,9 @@ from contextlib import asynccontextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, HTTPException, Query, Request
|
from fastapi import FastAPI, HTTPException, Request, Depends
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
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
|
import config
|
||||||
|
|
||||||
@@ -32,29 +28,24 @@ logging.basicConfig(
|
|||||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
)
|
)
|
||||||
logger = logging.getLogger("tts-service")
|
logger = logging.getLogger("tts-proxy")
|
||||||
|
|
||||||
# ── Text Segmentation ─────────────────────────────────────────────────────
|
# ── Text Segmentation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
# MiMo TTS 单次请求文本上限(保守值,实际约 5000)
|
|
||||||
MAX_CHUNK_CHARS = 2000
|
MAX_CHUNK_CHARS = 2000
|
||||||
|
|
||||||
# 分割优先级: 段落 > 句子 > 逗号/分号 > 按长度硬切
|
|
||||||
_SEGMENT_PATTERNS = [
|
_SEGMENT_PATTERNS = [
|
||||||
"\n\n", # 段落
|
"\n\n", # 段落
|
||||||
"\n", # 换行
|
"\n", # 换行
|
||||||
"。", "!", "?", "…", # 中文句末
|
"。", "!", "?", "…",
|
||||||
".", "!", "?", # 英文句末
|
".", "!", "?",
|
||||||
";", ";", # 分号
|
";", ";",
|
||||||
",", ",", # 逗号
|
",", ",",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def split_text(text: str, max_chars: int = MAX_CHUNK_CHARS) -> list[str]:
|
def split_text(text: str, max_chars: int = MAX_CHUNK_CHARS) -> list[str]:
|
||||||
"""
|
"""智能分段:在自然边界切分,每段不超过 max_chars"""
|
||||||
智能分段:尽量在自然边界(段落/句子/标点)处切分,
|
|
||||||
保证每段不超过 max_chars 字符。
|
|
||||||
"""
|
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
if len(text) <= max_chars:
|
if len(text) <= max_chars:
|
||||||
return [text]
|
return [text]
|
||||||
@@ -67,7 +58,6 @@ def split_text(text: str, max_chars: int = MAX_CHUNK_CHARS) -> list[str]:
|
|||||||
chunks.append(remaining)
|
chunks.append(remaining)
|
||||||
break
|
break
|
||||||
|
|
||||||
# 在 max_chars 范围内找最佳切割点
|
|
||||||
window = remaining[:max_chars]
|
window = remaining[:max_chars]
|
||||||
cut_pos = -1
|
cut_pos = -1
|
||||||
|
|
||||||
@@ -78,7 +68,6 @@ def split_text(text: str, max_chars: int = MAX_CHUNK_CHARS) -> list[str]:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if cut_pos <= 0:
|
if cut_pos <= 0:
|
||||||
# 没找到任何分隔符,硬切
|
|
||||||
cut_pos = max_chars
|
cut_pos = max_chars
|
||||||
|
|
||||||
chunk = remaining[:cut_pos].strip()
|
chunk = remaining[:cut_pos].strip()
|
||||||
@@ -89,16 +78,35 @@ def split_text(text: str, max_chars: int = MAX_CHUNK_CHARS) -> list[str]:
|
|||||||
return chunks
|
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):
|
def concat_mp3_files(mp3_paths: list[str], output_path: str):
|
||||||
"""用 ffmpeg 将多个 MP3 文件拼接为一个"""
|
|
||||||
# 创建 ffmpeg concat 文件列表
|
|
||||||
list_path = output_path + ".concat_list.txt"
|
list_path = output_path + ".concat_list.txt"
|
||||||
with open(list_path, "w") as f:
|
with open(list_path, "w") as f:
|
||||||
for p in mp3_paths:
|
for p in mp3_paths:
|
||||||
f.write(f"file '{p}'\n")
|
f.write(f"file '{p}'\n")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_path,
|
["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)
|
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 ───────────────────────────────────────────────────────────
|
# ── TTS Service ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
MAX_TTS_RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
async def call_mimo_tts(text: str, style: str = "", voice: str = "") -> bytes:
|
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:
|
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
|
content = f"<style>{style}</style>{text}" if style else text
|
||||||
use_voice = voice or config.MIMO_VOICE
|
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},
|
"audio": {"format": "wav", "voice": use_voice},
|
||||||
"messages": [{"role": "assistant", "content": content}],
|
"messages": [{"role": "assistant", "content": content}],
|
||||||
}
|
}
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"api-key": config.MIMO_API_KEY,
|
"api-key": config.MIMO_API_KEY,
|
||||||
}
|
}
|
||||||
|
|
||||||
t0 = time.time()
|
last_exc = None
|
||||||
logger.info(f"MiMo TTS 请求: text_len={len(text)}, style={style or '(默认)'}")
|
for attempt in range(1, MAX_TTS_RETRIES + 1):
|
||||||
|
t0 = time.time()
|
||||||
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]}")
|
|
||||||
|
|
||||||
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")
|
|
||||||
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:
|
try:
|
||||||
audio_dir = Path(config.AUDIO_DIR) / chapter.book_id
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
audio_dir.mkdir(parents=True, exist_ok=True)
|
resp = await client.post(config.MIMO_API_ENDPOINT, json=payload, headers=headers)
|
||||||
|
|
||||||
mp3_path = str(audio_dir / f"{chapter.chapter_id}.mp3")
|
elapsed = round(time.time() - t0, 2)
|
||||||
chunks = split_text(chapter.text_content)
|
|
||||||
|
|
||||||
if len(chunks) == 1:
|
if resp.status_code != 200:
|
||||||
# 单段:直接生成
|
logger.error(f"MiMo TTS HTTP {resp.status_code}, {elapsed}s, {resp.text[:200]}")
|
||||||
wav_bytes = await call_mimo_tts(chapter.text_content)
|
err = HTTPException(502, f"MiMo TTS API 错误: HTTP {resp.status_code}")
|
||||||
wav_path = str(audio_dir / f"{chapter.chapter_id}.wav")
|
if resp.status_code >= 500 and attempt < MAX_TTS_RETRIES:
|
||||||
with open(wav_path, "wb") as f:
|
last_exc = err
|
||||||
f.write(wav_bytes)
|
await asyncio.sleep(1.5 * attempt)
|
||||||
loop = asyncio.get_event_loop()
|
continue
|
||||||
await loop.run_in_executor(None, wav_to_mp3, wav_path, mp3_path)
|
raise err
|
||||||
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:
|
data = resp.json()
|
||||||
f.write(wav_bytes)
|
if data.get("error"):
|
||||||
|
raise HTTPException(502, f"MiMo TTS 错误: {data['error']}")
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
audio_b64 = data["choices"][0]["message"]["audio"]["data"]
|
||||||
await loop.run_in_executor(None, wav_to_mp3, wav_path, tmp_mp3)
|
wav_bytes = base64.b64decode(audio_b64)
|
||||||
os.remove(wav_path)
|
logger.info(f"MiMo TTS OK: {len(wav_bytes)} bytes, {elapsed}s (attempt {attempt})")
|
||||||
tmp_mp3_paths.append(tmp_mp3)
|
return wav_bytes
|
||||||
|
|
||||||
# 拼接
|
except HTTPException:
|
||||||
await loop.run_in_executor(None, concat_mp3_files, tmp_mp3_paths, mp3_path)
|
raise
|
||||||
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 Exception as e:
|
except Exception as e:
|
||||||
chapter.status = "error"
|
elapsed = round(time.time() - t0, 2)
|
||||||
chapter.error_msg = str(e)[:500]
|
logger.error(f"MiMo TTS 异常: {e}, {elapsed}s, attempt {attempt}")
|
||||||
logger.error(f"章节 {chapter_id_str} 生成失败: {e}")
|
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 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async def generate_mp3(text: str, style: str = "", voice: str = "") -> bytes:
|
||||||
|
"""文本 → MP3 字节。长文本自动分段拼接"""
|
||||||
|
chunks = split_text(text)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
os.makedirs(config.AUDIO_DIR, exist_ok=True)
|
os.makedirs(config.AUDIO_DIR, exist_ok=True)
|
||||||
os.makedirs(os.path.join(config.BASE_DIR, "data"), exist_ok=True)
|
(Path(config.AUDIO_DIR) / "_tmp").mkdir(exist_ok=True)
|
||||||
async with engine.begin() as conn:
|
(Path(config.AUDIO_DIR) / "_preview").mkdir(exist_ok=True)
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="TTS Book Service", lifespan=lifespan)
|
app = FastAPI(title="TTS Proxy Service", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 健康检查 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health():
|
||||||
"""健康检查"""
|
return {
|
||||||
return {"status": "ok", "service": "TTS Book Service", "api_key_configured": bool(config.MIMO_API_KEY)}
|
"status": "ok",
|
||||||
|
"api_key": bool(config.MIMO_API_KEY),
|
||||||
|
"token": bool(config.API_TOKEN),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── 听书 App 音频接入接口 ─────────────────────────────────────────────────
|
# ── 核心接口: 实时 TTS ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@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")
|
@app.post("/api/tts")
|
||||||
async def realtime_tts(request: Request):
|
async def realtime_tts(request: Request):
|
||||||
"""
|
"""
|
||||||
实时 TTS 生成接口
|
实时 TTS → 返回 MP3 音频流
|
||||||
兼容两种 App 发送格式:
|
|
||||||
1. JSON body: {"text": "内容", "style": "开心"}
|
JSON: {"text": "内容", "style": "开心", "voice": ""}
|
||||||
2. Form body: tex=内容&spd=5 (百度风格)
|
Form: tex=内容 (百度兼容)
|
||||||
返回: MP3 音频二进制流 (audio/mpeg),失败返回 JSON
|
|
||||||
"""
|
"""
|
||||||
text = ""
|
text = style = voice = ""
|
||||||
style = ""
|
|
||||||
voice = ""
|
|
||||||
content_type = request.headers.get("content-type", "")
|
content_type = request.headers.get("content-type", "")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if "json" in content_type:
|
if "json" in content_type:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
text = data.get("text", "").strip()
|
text = (data.get("text") or "").strip()
|
||||||
style = data.get("style", "").strip()
|
style = (data.get("style") or "").strip()
|
||||||
voice = data.get("voice", "").strip()
|
voice = (data.get("voice") or "").strip()
|
||||||
else:
|
else:
|
||||||
# form-urlencoded (百度风格)
|
from urllib.parse import parse_qs, unquote
|
||||||
body_bytes = await request.body()
|
body = await request.body()
|
||||||
params = parse_qs(body_bytes.decode("utf-8"))
|
params = parse_qs(body.decode("utf-8"))
|
||||||
text = (params.get("tex", [""])[0]).strip()
|
text = unquote(unquote((params.get("tex", [""])[0]).strip()))
|
||||||
# URL 解码(百度会 double-encode)
|
|
||||||
text = unquote(unquote(text))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
return Response(
|
return Response(
|
||||||
content=json.dumps({"status": 40000001, "message": "text/tex 不能为空"}, ensure_ascii=False),
|
content=json.dumps({"status": 40000001, "message": "text 不能为空"}, ensure_ascii=False),
|
||||||
media_type="application/json",
|
media_type="application/json", status_code=400,
|
||||||
status_code=400,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 文本分段
|
mp3_bytes = await generate_mp3(text, style, voice)
|
||||||
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")
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
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 Response(content=mp3_bytes, media_type="audio/mpeg")
|
return Response(content=mp3_bytes, media_type="audio/mpeg")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response(
|
return Response(
|
||||||
content=json.dumps({"status": 50000002, "message": str(e)[:300]}, ensure_ascii=False),
|
content=json.dumps({"status": 500, "message": str(e)[:300]}, ensure_ascii=False),
|
||||||
media_type="application/json",
|
media_type="application/json", status_code=500,
|
||||||
status_code=500,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── 管理 API ──────────────────────────────────────────────────────────────
|
# ── 管理接口 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# --- Books ---
|
@app.post("/admin/api/preview")
|
||||||
|
async def preview(request: Request, _auth=Depends(verify_token)):
|
||||||
@app.get("/admin/api/books")
|
"""TTS 试听,返回音频 URL"""
|
||||||
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):
|
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
book_id = data.get("book_id", "").strip()
|
text = (data.get("text") or "").strip()
|
||||||
title = data.get("title", "").strip()
|
style = (data.get("style") or "").strip()
|
||||||
author = data.get("author", "").strip()
|
voice = (data.get("voice") or "").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()
|
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
raise HTTPException(400, "文本不能为空")
|
raise HTTPException(400, "文本不能为空")
|
||||||
|
|
||||||
wav_bytes = await call_mimo_tts(text, style, voice)
|
mp3_bytes = await generate_mp3(text, style, voice)
|
||||||
audio_dir = Path(config.AUDIO_DIR) / "_preview"
|
preview_dir = Path(config.AUDIO_DIR) / "_preview"
|
||||||
audio_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
filename = f"{uuid.uuid4().hex}.mp3"
|
filename = f"{uuid.uuid4().hex}.mp3"
|
||||||
wav_path = str(audio_dir / f"{uuid.uuid4().hex}.wav")
|
with open(preview_dir / filename, "wb") as f:
|
||||||
mp3_path = str(audio_dir / filename)
|
f.write(mp3_bytes)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
return {"ok": True, "url": f"/audio/_preview/{filename}"}
|
return {"ok": True, "url": f"/audio/_preview/{filename}"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/api/config")
|
@app.get("/admin/api/config")
|
||||||
async def get_config():
|
async def config_info(_auth=Depends(verify_token)):
|
||||||
return {
|
return {
|
||||||
"endpoint": config.MIMO_API_ENDPOINT,
|
"endpoint": config.MIMO_API_ENDPOINT,
|
||||||
"model": config.MIMO_TTS_MODEL,
|
"model": config.MIMO_TTS_MODEL,
|
||||||
"voice": config.MIMO_VOICE,
|
"voice": config.MIMO_VOICE,
|
||||||
"api_key_masked": config.MIMO_API_KEY[:6] + "****" if config.MIMO_API_KEY else "未配置",
|
"api_key": config.MIMO_API_KEY[:6] + "****" if config.MIMO_API_KEY else "未配置",
|
||||||
"max_chunk_chars": MAX_CHUNK_CHARS,
|
"max_chunk": MAX_CHUNK_CHARS,
|
||||||
|
"token_set": bool(config.API_TOKEN),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── 配置文件下载 ───────────────────────────────────────────────────────────
|
# ── 配置文件下载 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/httpTts.json")
|
@app.get("/httpTts.json")
|
||||||
async def serve_http_tts_config():
|
async def serve_config():
|
||||||
"""提供 App 导入用的音频源配置文件"""
|
path = os.path.join(config.BASE_DIR, "httpTts-mimo.json")
|
||||||
config_path = os.path.join(config.BASE_DIR, "httpTts-mimo.json")
|
if os.path.exists(path):
|
||||||
if os.path.exists(config_path):
|
return FileResponse(path, media_type="application/json")
|
||||||
return FileResponse(config_path, media_type="application/json")
|
raise HTTPException(404)
|
||||||
raise HTTPException(404, "配置文件不存在")
|
|
||||||
|
|
||||||
|
|
||||||
# ── 静态文件 & 前端 ──────────────────────────────────────────────────────
|
# ── 静态 & 前端 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.mount("/audio", StaticFiles(directory=config.AUDIO_DIR), name="audio")
|
app.mount("/audio", StaticFiles(directory=config.AUDIO_DIR), name="audio")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def frontend():
|
async def frontend():
|
||||||
html_path = os.path.join(config.BASE_DIR, "static", "index.html")
|
with open(os.path.join(config.BASE_DIR, "static", "index.html"), "r", encoding="utf-8") as f:
|
||||||
with open(html_path, "r", encoding="utf-8") as f:
|
|
||||||
return HTMLResponse(f.read())
|
return HTMLResponse(f.read())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -3,485 +3,217 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>TTS Book Service</title>
|
<title>TTS Proxy Service</title>
|
||||||
<style>
|
<style>
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
*{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}
|
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}
|
.c{max-width:800px;margin:0 auto;padding:24px}
|
||||||
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}
|
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}
|
||||||
.subtitle{color:var(--text-dim);font-size:.85rem;margin-bottom:24px}
|
.sub{color:var(--dim);font-size:.82rem;margin-bottom:28px}
|
||||||
|
|
||||||
/* 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 */
|
|
||||||
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px;margin-bottom:16px}
|
.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}
|
.card-t{font-size:1rem;font-weight:600;margin-bottom:14px;display:flex;align-items:center;gap:8px}
|
||||||
|
.fg{margin-bottom:12px}
|
||||||
/* Forms */
|
.fg label{display:block;font-size:.78rem;color:var(--dim);margin-bottom:5px;text-transform:uppercase;letter-spacing:.5px}
|
||||||
.form-group{margin-bottom:14px}
|
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}
|
||||||
.form-group label{display:block;font-size:.8rem;color:var(--text-dim);margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
|
input:focus,textarea:focus{border-color:var(--primary)}
|
||||||
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}
|
textarea{resize:vertical;min-height:120px;font-family:inherit}
|
||||||
input:focus,textarea:focus,select:focus{border-color:var(--primary)}
|
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||||
textarea{resize:vertical;min-height:100px;font-family:inherit}
|
.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}
|
||||||
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
.btn-p{background:var(--primary);color:#fff}.btn-p:hover{background:var(--primary-hover);transform:translateY(-1px)}
|
||||||
|
|
||||||
/* 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}
|
|
||||||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||||
|
.preview{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px;margin-top:12px}
|
||||||
/* Table */
|
audio{width:100%;margin-top:6px}
|
||||||
.table-wrap{overflow-x:auto}
|
.dim{color:var(--dim)}
|
||||||
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}
|
|
||||||
.mt-2{margin-top:8px}
|
.mt-2{margin-top:8px}
|
||||||
.mt-4{margin-top:16px}
|
.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}
|
||||||
.mb-2{margin-bottom:8px}
|
.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}}
|
||||||
/* Scrollable text preview */
|
table{width:100%;border-collapse:collapse}
|
||||||
.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}
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="c">
|
||||||
<h1>📚 TTS Book Service</h1>
|
<h1>🎙️ TTS Proxy Service</h1>
|
||||||
<p class="subtitle">小米 MiMo TTS 听书音频转换服务</p>
|
<p class="sub">小米 MiMo TTS 代理服务 · 智能分段 · 自动重试</p>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab active" onclick="switchTab('books')">📖 书籍管理</button>
|
<button class="tab on" onclick="sw('tts',this)">🎙️ TTS 试听</button>
|
||||||
<button class="tab" onclick="switchTab('preview')">🎙️ TTS 试听</button>
|
<button class="tab" onclick="sw('cfg',this)">⚙️ 配置</button>
|
||||||
<button class="tab" onclick="switchTab('settings')">⚙️ 配置</button>
|
<button class="tab" onclick="sw('api',this)">📖 接口说明</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab: Books -->
|
<!-- TTS 试听 -->
|
||||||
<div id="tab-books" class="tab-panel active">
|
<div id="p-tts" class="panel on">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex-between">
|
<div class="card-t">🎙️ TTS 试听</div>
|
||||||
<div class="card-title" style="margin-bottom:0">📖 书籍列表</div>
|
<div class="row">
|
||||||
<button class="btn btn-primary" onclick="showAddBook()">+ 添加书籍</button>
|
<div class="fg">
|
||||||
</div>
|
<label>音色(可选,留空用默认)</label>
|
||||||
</div>
|
<input id="pv-voice" placeholder="mimo_default">
|
||||||
<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>
|
||||||
<div class="form-group">
|
<div class="fg">
|
||||||
<label>音色(可选)</label>
|
<label>风格(可选)</label>
|
||||||
<input id="preview-voice" placeholder="留空使用默认音色 mimo_default">
|
<input id="pv-style" placeholder="开心、语速慢、东北话...">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="fg">
|
||||||
<label>文本内容</label>
|
<label>文本内容</label>
|
||||||
<textarea id="preview-text" rows="4" placeholder="输入要合成的文本..."></textarea>
|
<textarea id="pv-text" rows="5" placeholder="输入要合成的文本...长文本自动分段生成"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" onclick="doPreview()" id="preview-btn">🔊 生成试听</button>
|
<button class="btn btn-p" onclick="preview()" id="pv-btn">🔊 生成试听</button>
|
||||||
<div id="preview-result" class="preview-box" style="display:none">
|
<div id="pv-result" class="preview" style="display:none">
|
||||||
<audio id="preview-audio" controls></audio>
|
<audio id="pv-audio" controls></audio>
|
||||||
|
<p class="dim mt-2" id="pv-info"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab: Settings -->
|
<!-- 配置 -->
|
||||||
<div id="tab-settings" class="tab-panel">
|
<div id="p-cfg" class="panel">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">⚙️ 当前配置</div>
|
<div class="card-t">⚙️ 当前配置</div>
|
||||||
<table>
|
<table>
|
||||||
<tr><th style="width:180px">配置项</th><th>值</th></tr>
|
<tr><th style="width:140px">项目</th><th>值</th></tr>
|
||||||
<tr><td>TTS API</td><td id="cfg-endpoint">-</td></tr>
|
<tr><td>TTS Endpoint</td><td id="c-ep">-</td></tr>
|
||||||
<tr><td>模型</td><td id="cfg-model">-</td></tr>
|
<tr><td>模型</td><td id="c-md">-</td></tr>
|
||||||
<tr><td>默认音色</td><td id="cfg-voice">-</td></tr>
|
<tr><td>默认音色</td><td id="c-vc">-</td></tr>
|
||||||
<tr><td>API Key</td><td id="cfg-apikey">-</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>
|
</table>
|
||||||
<p class="text-dim mt-4" style="font-size:.8rem">通过环境变量配置:MIMO_API_KEY、MIMO_API_ENDPOINT、MIMO_TTS_MODEL、MIMO_VOICE</p>
|
<p class="hint mt-2">通过环境变量配置:MIMO_API_KEY、MIMO_VOICE、MIMO_TTS_MODEL、API_TOKEN 等</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add Book Modal -->
|
<!-- 接口说明 -->
|
||||||
<div id="modal-add-book" class="modal-overlay">
|
<div id="p-api" class="panel">
|
||||||
<div class="modal">
|
<div class="card">
|
||||||
<div class="modal-title">📖 添加书籍</div>
|
<div class="card-t">📖 核心接口</div>
|
||||||
<div class="form-group">
|
<table>
|
||||||
<label>书籍 ID(听书 App 中的 book_id)</label>
|
<tr><th>接口</th><th>方法</th><th>说明</th></tr>
|
||||||
<input id="new-book-id" placeholder="如:book_9">
|
<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>
|
||||||
<div class="form-group">
|
<div class="card">
|
||||||
<label>书名</label>
|
<div class="card-t">🔧 管理接口 <span class="dim" style="font-weight:400;font-size:.78rem">(需 Bearer Token)</span></div>
|
||||||
<input id="new-book-title" placeholder="书籍名称">
|
<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>
|
||||||
<div class="form-group">
|
<div class="card">
|
||||||
<label>作者</label>
|
<div class="card-t">📤 /api/tts 请求格式</div>
|
||||||
<input id="new-book-author" placeholder="作者(可选)">
|
<p class="hint">JSON 格式(推荐):</p>
|
||||||
</div>
|
<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
|
||||||
<div class="modal-actions">
|
Content-Type: application/json
|
||||||
<button class="btn" style="background:var(--border)" onclick="closeModal('modal-add-book')">取消</button>
|
|
||||||
<button class="btn btn-primary" onclick="addBook()">确认添加</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chapter Modal -->
|
{
|
||||||
<div id="modal-chapters" class="modal-overlay">
|
"text": "要合成的文本",
|
||||||
<div class="modal">
|
"style": "开心", // 可选
|
||||||
<div class="flex-between">
|
"voice": "mimo_default" // 可选
|
||||||
<div class="modal-title" id="chapter-modal-title" style="margin-bottom:0">章节管理</div>
|
}</code></pre>
|
||||||
<button class="btn btn-sm btn-primary" onclick="showAddChapter()">+ 添加章节</button>
|
<p class="hint">Form 格式(兼容百度风格):</p>
|
||||||
</div>
|
<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
|
||||||
<div id="chapter-list" class="mt-4">
|
Content-Type: application/x-www-form-urlencoded
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Add Chapter Modal -->
|
tex=要合成的文本</code></pre>
|
||||||
<div id="modal-add-chapter" class="modal-overlay">
|
<p class="hint mt-2">
|
||||||
<div class="modal">
|
<b>特性:</b>长文本自动分段(≤2000字/段)+ TTS 失败自动重试(最多 3 次)<br>
|
||||||
<div class="modal-title" id="add-chapter-title">添加章节</div>
|
<b>听书 App 接入:</b>在 App 中配置 TTS 源 URL 为 <code class="code">http://服务器:端口/api/tts</code>
|
||||||
<div class="form-row">
|
</p>
|
||||||
<div class="form-group">
|
|
||||||
<label>章节 ID</label>
|
|
||||||
<input id="new-chapter-id" placeholder="如:chapter_1">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>App 章节 ID</label>
|
|
||||||
<input id="new-chapter-app-id" placeholder="如:chapter1">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="card">
|
||||||
<label>章节标题</label>
|
<div class="card-t">🎭 MiMo TTS 风格参考</div>
|
||||||
<input id="new-chapter-title" placeholder="章节名称">
|
<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">
|
<div class="card">
|
||||||
<label>文本内容(TTS 输入)</label>
|
<div class="card-t">📱 听书 App 模板变量</div>
|
||||||
<textarea id="new-chapter-text" rows="8" placeholder="粘贴本章节的文本内容..."></textarea>
|
<table>
|
||||||
</div>
|
<tr><th>变量</th><th>说明</th></tr>
|
||||||
<div class="modal-actions">
|
<tr><td><code class="code">{{speakText}}</code></td><td>朗读文本</td></tr>
|
||||||
<button class="btn" style="background:var(--border)" onclick="closeModal('modal-add-chapter')">取消</button>
|
<tr><td><code class="code">{{speakSpeed}}</code></td><td>语速,范围 5-50</td></tr>
|
||||||
<button class="btn btn-primary" onclick="addChapter()">添加</button>
|
</table>
|
||||||
</div>
|
<p class="hint mt-2">
|
||||||
</div>
|
App 只能动态传文本和语速。voice/style 需在 JSON 配置中写死,或通过其他客户端调用 /api/tts 时传入。
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<!-- 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let currentBookId = null;
|
function sw(name, el) {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('on'));
|
||||||
// ── Tab ──
|
document.querySelectorAll('.panel').forEach(p => p.classList.remove('on'));
|
||||||
function switchTab(name) {
|
el.classList.add('on');
|
||||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
document.getElementById('p-' + name).classList.add('on');
|
||||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
if (name === 'cfg') loadCfg();
|
||||||
event.target.classList.add('active');
|
|
||||||
document.getElementById('tab-' + name).classList.add('active');
|
|
||||||
if (name === 'settings') loadSettings();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Toast ──
|
function toast(msg, ok = true) {
|
||||||
function toast(msg, type = 'success') {
|
|
||||||
const t = document.createElement('div');
|
const t = document.createElement('div');
|
||||||
t.className = 'toast toast-' + type;
|
t.className = 'toast ' + (ok ? 'toast-ok' : 'toast-err');
|
||||||
t.textContent = msg;
|
t.textContent = msg;
|
||||||
document.body.appendChild(t);
|
document.body.appendChild(t);
|
||||||
setTimeout(() => t.remove(), 3000);
|
setTimeout(() => t.remove(), 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Modal ──
|
async function preview() {
|
||||||
function showModal(id) { document.getElementById(id).classList.add('show'); }
|
const text = document.getElementById('pv-text').value.trim();
|
||||||
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
|
const style = document.getElementById('pv-style').value.trim();
|
||||||
|
const voice = document.getElementById('pv-voice').value.trim();
|
||||||
// ── Books ──
|
if (!text) { toast('请输入文本', false); return; }
|
||||||
async function loadBooks() {
|
const btn = document.getElementById('pv-btn');
|
||||||
|
btn.disabled = true; btn.textContent = '⏳ 生成中...';
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/admin/api/books');
|
const res = await fetch('/admin/api/preview', {
|
||||||
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', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({text, style, voice})
|
body: JSON.stringify({text, style, voice})
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
document.getElementById('preview-result').style.display = 'block';
|
document.getElementById('pv-result').style.display = 'block';
|
||||||
document.getElementById('preview-audio').src = data.url;
|
document.getElementById('pv-audio').src = data.url;
|
||||||
document.getElementById('preview-audio').play();
|
document.getElementById('pv-audio').play();
|
||||||
toast('试听生成成功');
|
document.getElementById('pv-info').textContent = data.chunks > 1 ? `已自动分 ${data.chunks} 段生成并拼接` : '';
|
||||||
} else {
|
toast('生成成功');
|
||||||
toast('生成失败', 'error');
|
} else { toast('生成失败', false); }
|
||||||
}
|
} catch(e) { toast('生成失败: ' + e.message, false); }
|
||||||
} catch(e) { toast('生成失败: ' + e.message, 'error'); }
|
btn.disabled = false; btn.textContent = '🔊 生成试听';
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = '🔊 生成试听';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Settings ──
|
async function loadCfg() {
|
||||||
async function loadSettings() {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/admin/api/config');
|
const res = await fetch('/admin/api/config');
|
||||||
const cfg = await res.json();
|
const c = await res.json();
|
||||||
document.getElementById('cfg-endpoint').textContent = cfg.endpoint || '-';
|
document.getElementById('c-ep').textContent = c.endpoint || '-';
|
||||||
document.getElementById('cfg-model').textContent = cfg.model || '-';
|
document.getElementById('c-md').textContent = c.model || '-';
|
||||||
document.getElementById('cfg-voice').textContent = cfg.voice || '-';
|
document.getElementById('c-vc').textContent = c.voice || '-';
|
||||||
document.getElementById('cfg-apikey').textContent = cfg.api_key_masked || '未配置';
|
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) {
|
} catch(e) {
|
||||||
document.getElementById('cfg-apikey').textContent = '加载失败';
|
toast('加载配置失败: ' + e.message, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Init ──
|
|
||||||
loadBooks();
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
13
deploy.sh
13
deploy.sh
@@ -1,14 +1,13 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "🚀 TTS Book Service 一键部署"
|
echo "🎙️ TTS Proxy Service 一键部署"
|
||||||
echo "=============================="
|
echo "=============================="
|
||||||
|
|
||||||
# 1. 配置 Docker 镜像加速
|
# 1. Docker 镜像加速
|
||||||
DAEMON_JSON="/etc/docker/daemon.json"
|
DAEMON_JSON="/etc/docker/daemon.json"
|
||||||
if [ -f "$DAEMON_JSON" ]; then
|
if [ -f "$DAEMON_JSON" ]; then
|
||||||
echo "⚠️ $DAEMON_JSON 已存在,跳过镜像加速配置"
|
echo "⚠️ $DAEMON_JSON 已存在,跳过镜像加速配置"
|
||||||
echo " 如需配置,请手动添加 registry-mirrors"
|
|
||||||
else
|
else
|
||||||
echo "📦 配置 Docker 镜像加速..."
|
echo "📦 配置 Docker 镜像加速..."
|
||||||
sudo tee "$DAEMON_JSON" > /dev/null << 'EOF'
|
sudo tee "$DAEMON_JSON" > /dev/null << 'EOF'
|
||||||
@@ -36,7 +35,7 @@ if [ ! -f .env ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. 启动服务
|
# 3. 启动
|
||||||
echo ""
|
echo ""
|
||||||
echo "🐳 启动服务..."
|
echo "🐳 启动服务..."
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
@@ -44,5 +43,7 @@ docker compose up -d --build
|
|||||||
echo ""
|
echo ""
|
||||||
echo "=============================="
|
echo "=============================="
|
||||||
echo "✅ 部署完成!"
|
echo "✅ 部署完成!"
|
||||||
echo " 管理界面: http://$(hostname -I | awk '{print $1}'):3333"
|
IP=$(hostname -I | awk '{print $1}')
|
||||||
echo " TTS 接口: http://$(hostname -I | awk '{print $1}'):3333/api/tts"
|
echo " 管理界面: http://${IP}:3333"
|
||||||
|
echo " TTS 接口: http://${IP}:3333/api/tts"
|
||||||
|
echo " 健康检查: http://${IP}:3333/health"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
tts-book-service:
|
tts-proxy:
|
||||||
build: .
|
build: .
|
||||||
container_name: tts-book-service
|
container_name: tts-proxy
|
||||||
ports:
|
ports:
|
||||||
- "3333:3333"
|
- "3333:3333"
|
||||||
environment:
|
environment:
|
||||||
@@ -11,9 +11,8 @@ services:
|
|||||||
- MIMO_API_ENDPOINT=${MIMO_API_ENDPOINT:-https://api.xiaomimimo.com/v1/chat/completions}
|
- MIMO_API_ENDPOINT=${MIMO_API_ENDPOINT:-https://api.xiaomimimo.com/v1/chat/completions}
|
||||||
- MIMO_TTS_MODEL=${MIMO_TTS_MODEL:-mimo-v2-audio-tts}
|
- MIMO_TTS_MODEL=${MIMO_TTS_MODEL:-mimo-v2-audio-tts}
|
||||||
- MIMO_VOICE=${MIMO_VOICE:-mimo_default}
|
- MIMO_VOICE=${MIMO_VOICE:-mimo_default}
|
||||||
- SERVER_PORT=3333
|
- API_TOKEN=${API_TOKEN:-}
|
||||||
volumes:
|
volumes:
|
||||||
- tts-data:/app/data
|
|
||||||
- tts-audio:/app/audio
|
- tts-audio:/app/audio
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
@@ -23,5 +22,4 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
tts-data:
|
|
||||||
tts-audio:
|
tts-audio:
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
fastapi==0.115.0
|
fastapi==0.115.0
|
||||||
uvicorn[standard]==0.30.0
|
uvicorn[standard]==0.30.0
|
||||||
sqlalchemy==2.0.35
|
|
||||||
aiosqlite==0.20.0
|
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
python-multipart==0.0.12
|
python-multipart==0.0.12
|
||||||
|
|||||||
Reference in New Issue
Block a user