From 2a87020b48fae6f75ece198e6f894dd8af8bdca2 Mon Sep 17 00:00:00 2001 From: sunruiling Date: Fri, 27 Mar 2026 15:10:58 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=B2=BE=E7=AE=80=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=EF=BC=8C=E5=8E=BB=E6=8E=89=E4=B9=A6=E7=B1=8D=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=8C=E6=A0=B8=E5=BF=83=20TTS=20=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 去掉 books/chapters CRUD、SQLAlchemy、SQLite 依赖 - 核心只剩 /api/tts + 智能分段 + 自动重试 - 新增 API_TOKEN 环境变量,管理接口 Bearer Token 鉴权 - 管理接口精简为 preview + config - 前端重写:TTS 试听 + 配置查看 + 接口文档 - Dockerfile/docker-compose 清理,去掉数据库卷 --- .env.example | 3 + API.md | 449 --------------------------- Dockerfile | 5 +- README.md | 108 +++---- app/config.py | 6 +- app/main.py | 682 +++++++++++------------------------------- app/models.py | 42 --- app/static/index.html | 566 +++++++++-------------------------- deploy.sh | 13 +- docker-compose.yml | 8 +- requirements.txt | 2 - 11 files changed, 381 insertions(+), 1503 deletions(-) delete mode 100644 API.md delete mode 100644 app/models.py diff --git a/.env.example b/.env.example index 278ecb3..e4f8e04 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ # 小米 MiMo TTS API Key(必填) MIMO_API_KEY=your_api_key_here +# API 访问令牌(可选,设置后管理接口需要 Bearer Token) +# API_TOKEN=your_token_here + # 以下为可选配置(有默认值) # MIMO_API_ENDPOINT=https://api.xiaomimimo.com/v1/chat/completions # MIMO_TTS_MODEL=mimo-v2-audio-tts diff --git a/API.md b/API.md deleted file mode 100644 index f763ae6..0000000 --- a/API.md +++ /dev/null @@ -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://: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` 和章节音频生成均自动支持。 diff --git a/Dockerfile b/Dockerfile index ecef2ae..a2ed96b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,6 @@ FROM python:3.11-alpine -# 配置 Alpine 国内镜像源 RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories - -# 安装 ffmpeg RUN apk add --no-cache ffmpeg WORKDIR /app @@ -14,7 +11,7 @@ RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r re COPY app/ . COPY httpTts-mimo.json . -RUN mkdir -p /app/data /app/audio +RUN mkdir -p /app/audio/_tmp /app/audio/_preview EXPOSE 3333 diff --git a/README.md b/README.md index 69b4200..ae0f0ca 100644 --- a/README.md +++ b/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 - │ - ├── SQLite (书籍/章节管理) - └── MP3 文件缓存 +听书 App / curl ──HTTP──▶ 本服务 ──API──▶ 小米 MiMo TTS + │ + ├── 智能文本分段(>2000字自动切分拼接) + ├── TTS 自动重试(5xx 错误最多重试 3 次) + └── MP3 缓存 ``` ## 快速启动 @@ -16,83 +17,69 @@ ```bash # 1. 配置 API Key cp .env.example .env -# 编辑 .env 填入你的 MIMO_API_KEY +# 编辑 .env 填入 MIMO_API_KEY # 2. 启动 docker compose up -d -# 3. 访问管理界面 -# http://your-server:17200 +# 3. 访问 +# 管理界面: http://your-server:3333 +# TTS 接口: http://your-server:3333/api/tts ``` -## 功能 +## 核心功能 -### Web 管理界面 (`/`) -- 📖 书籍管理(添加/删除) -- 📑 章节管理(添加/编辑/删除) -- 🎙️ TTS 试听(支持风格 + 音色设置) -- ⚡ 单章/批量音频生成(自动分段拼接) -- ⚙️ 配置查看 +### POST `/api/tts` — 实时 TTS -### 核心特性 -- **智能文本分段**: 长文本自动在句末/段落边界切分(≤2000字/段),逐段生成后拼接 -- **多风格支持**: 开心、悲伤、东北话、像个大将军... 任意组合 -- **音色切换**: 支持指定不同音色(voice 参数) -- **并发批量生成**: 限制 3 路并发,快速完成整本书 +文本进 → MP3 出。支持 JSON 和 Form 两种格式,长文本自动分段拼接。 -### 听书 App 接口 +```bash +# JSON 格式 +curl -X POST http://localhost:3333/api/tts \ + -H "Content-Type: application/json" \ + -d '{"text": "你好世界", "style": "开心", "voice": ""}' \ + -o output.mp3 + +# Form 格式(百度兼容) +curl -X POST http://localhost:3333/api/tts \ + -d "tex=你好世界" \ + -o output.mp3 +``` + +### GET `/health` — 健康检查 + +### 管理接口(需 Bearer Token) | 接口 | 方法 | 说明 | |------|------|------| -| `/api/book/{book_id}` | GET | 获取书籍信息和章节列表 | -| `/api/book/{book_id}/chapter/{chapter_id}/audio` | GET | 下载章节 MP3 音频 | - -### 管理 API - -| 接口 | 方法 | 说明 | -|------|------|------| -| `/admin/api/books` | GET/POST | 书籍列表/创建 | -| `/admin/api/books/{book_id}` | DELETE | 删除书籍 | -| `/admin/api/books/{book_id}/chapters` | GET/POST | 章节列表/创建 | -| `/admin/api/books/{book_id}/chapters/{id}` | PUT/DELETE | 更新/删除章节 | -| `/admin/api/books/{book_id}/chapters/{id}/generate` | POST | 生成单章音频 | -| `/admin/api/books/{book_id}/generate-all` | POST | 批量生成 | -| `/admin/api/tts/preview` | POST | TTS 试听 | -| `/admin/api/config` | GET | 查看配置 | +| `/admin/api/preview` | POST | TTS 试听,返回音频 URL | +| `/admin/api/config` | GET | 查看当前配置 | ## 接入听书 App -1. 在管理界面添加书籍和章节 -2. 为章节生成音频 -3. 在听书 App 配置: - - **接入方式**: 音频方式 - - **接入地址**: `http://your-server:17200` - - **音频类型**: mp3 - - **书籍地址**: `http://your-server:17200/api/book/{book_id}` +在 App 中配置 TTS 音频源: + +- **URL**: `http://your-server:3333/api/tts` +- **Method**: POST +- **Body**: `{"text": "{{speakText}}"}` +- **Content-Type**: `audio/mpeg` + +App 模板变量:`{{speakText}}`(文本)、`{{speakSpeed}}`(语速 5-50) + +> App 只能动态传文本。voice/style 需在 JSON body 中写死,或通过其他客户端调用时传入。 ## 环境变量 | 变量 | 必填 | 默认值 | 说明 | |------|------|--------|------| -| `MIMO_API_KEY` | ✅ | - | 小米 MiMo TTS API Key | +| `MIMO_API_KEY` | ✅ | - | MiMo TTS API Key | | `MIMO_API_ENDPOINT` | ❌ | `https://api.xiaomimimo.com/v1/chat/completions` | API 地址 | | `MIMO_TTS_MODEL` | ❌ | `mimo-v2-audio-tts` | 模型名称 | | `MIMO_VOICE` | ❌ | `mimo_default` | 默认音色 | -| `SERVER_PORT` | ❌ | `3333` | 服务端口 | - -## 📖 API 文档 - -完整 API 文档见 [**API.md**](./API.md),包含: - -- 所有接口的请求/响应格式 -- 参数说明与错误码 -- MiMo TTS 风格参考大全 -- 文本自动分段机制说明 +| `API_TOKEN` | ❌ | - | 管理接口 Bearer Token(留空则不鉴权) | ## MiMo TTS 风格参考 -在「TTS 试听」中可填写风格,例如: - | 类别 | 示例 | |------|------| | 情感 | 开心 / 悲伤 / 生气 / 平静 | @@ -100,12 +87,11 @@ docker compose up -d | 角色 | 像个大将军 / 像个小孩 / 孙悟空 | | 方言 | 东北话 / 四川话 / 台湾腔 / 粤语 | -## 不使用 Docker 运行 +## 不使用 Docker ```bash pip install -r requirements.txt -# 需要系统安装 ffmpeg +# 系统需安装 ffmpeg export MIMO_API_KEY=your_key -cd app -python main.py +cd app && python main.py ``` diff --git a/app/config.py b/app/config.py index e321555..30fd7b5 100644 --- a/app/config.py +++ b/app/config.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_PORT = int(os.environ.get("SERVER_PORT", "3333")) +# API 访问令牌(可选,设置后管理接口需要 Bearer Token) +API_TOKEN = os.environ.get("API_TOKEN", "") + # 音频存储目录 AUDIO_DIR = os.environ.get("AUDIO_DIR", os.path.join(BASE_DIR, "audio")) - -# 数据库 -DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///./data/tts.db") diff --git a/app/main.py b/app/main.py index 9bf80a7..5080544 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,6 @@ """ -TTS Book Service - 小米 MiMo TTS 转换服务 -为听书 App 提供音频接入接口 +TTS Proxy Service - 小米 MiMo TTS 音频转换代理 +核心功能: /api/tts 实时 TTS + 智能分段 + 自动重试 """ import os @@ -15,13 +15,9 @@ from contextlib import asynccontextmanager from pathlib import Path import httpx -from fastapi import FastAPI, HTTPException, Query, Request -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response +from fastapi import FastAPI, HTTPException, Request, Depends +from fastapi.responses import FileResponse, HTMLResponse, Response from fastapi.staticfiles import StaticFiles -from sqlalchemy import Column, Integer, String, Text, DateTime, func, select, delete -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.orm import DeclarativeBase -from urllib.parse import parse_qs, unquote import config @@ -32,29 +28,24 @@ logging.basicConfig( format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) -logger = logging.getLogger("tts-service") +logger = logging.getLogger("tts-proxy") # ── Text Segmentation ───────────────────────────────────────────────────── -# MiMo TTS 单次请求文本上限(保守值,实际约 5000) MAX_CHUNK_CHARS = 2000 -# 分割优先级: 段落 > 句子 > 逗号/分号 > 按长度硬切 _SEGMENT_PATTERNS = [ "\n\n", # 段落 "\n", # 换行 - "。", "!", "?", "…", # 中文句末 - ".", "!", "?", # 英文句末 - ";", ";", # 分号 - ",", ",", # 逗号 + "。", "!", "?", "…", + ".", "!", "?", + ";", ";", + ",", ",", ] def split_text(text: str, max_chars: int = MAX_CHUNK_CHARS) -> list[str]: - """ - 智能分段:尽量在自然边界(段落/句子/标点)处切分, - 保证每段不超过 max_chars 字符。 - """ + """智能分段:在自然边界切分,每段不超过 max_chars""" text = text.strip() if len(text) <= max_chars: return [text] @@ -67,7 +58,6 @@ def split_text(text: str, max_chars: int = MAX_CHUNK_CHARS) -> list[str]: chunks.append(remaining) break - # 在 max_chars 范围内找最佳切割点 window = remaining[:max_chars] cut_pos = -1 @@ -78,7 +68,6 @@ def split_text(text: str, max_chars: int = MAX_CHUNK_CHARS) -> list[str]: break if cut_pos <= 0: - # 没找到任何分隔符,硬切 cut_pos = max_chars chunk = remaining[:cut_pos].strip() @@ -89,16 +78,35 @@ def split_text(text: str, max_chars: int = MAX_CHUNK_CHARS) -> list[str]: return chunks -# ── Audio Concatenation ─────────────────────────────────────────────────── +# ── Auth ────────────────────────────────────────────────────────────────── + +async def verify_token(request: Request): + """Bearer Token 验证(API_TOKEN 未配置时跳过)""" + if not config.API_TOKEN: + return + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + raise HTTPException(401, "缺少 Authorization: Bearer ") + if auth[7:] != config.API_TOKEN: + raise HTTPException(403, "Token 无效") + + +# ── Audio Utils ─────────────────────────────────────────────────────────── + +def wav_to_mp3(wav_path: str, mp3_path: str): + result = subprocess.run( + ["ffmpeg", "-y", "-i", wav_path, "-codec:a", "libmp3lame", "-qscale:a", "2", mp3_path], + capture_output=True, text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"ffmpeg 转换失败: {result.stderr[:300]}") + def concat_mp3_files(mp3_paths: list[str], output_path: str): - """用 ffmpeg 将多个 MP3 文件拼接为一个""" - # 创建 ffmpeg concat 文件列表 list_path = output_path + ".concat_list.txt" with open(list_path, "w") as f: for p in mp3_paths: f.write(f"file '{p}'\n") - try: result = subprocess.run( ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_path, @@ -111,46 +119,15 @@ def concat_mp3_files(mp3_paths: list[str], output_path: str): os.remove(list_path) -# ── Database ────────────────────────────────────────────────────────────── - -engine = create_async_engine(config.DATABASE_URL, echo=False) -async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - -class Base(DeclarativeBase): - pass - - -class Book(Base): - __tablename__ = "books" - id = Column(Integer, primary_key=True, autoincrement=True) - book_id = Column(String(100), unique=True, nullable=False, index=True) - title = Column(String(500), nullable=False) - author = Column(String(200), default="") - created_at = Column(DateTime, server_default=func.now()) - - -class Chapter(Base): - __tablename__ = "chapters" - id = Column(Integer, primary_key=True, autoincrement=True) - book_id = Column(String(100), nullable=False, index=True) - chapter_id = Column(String(100), nullable=False, index=True) - app_chapter_id = Column(String(100), default="") - title = Column(String(500), default="") - text_content = Column(Text, default="") - audio_file = Column(String(500), default="") - status = Column(String(20), default="pending") - error_msg = Column(Text, default="") - created_at = Column(DateTime, server_default=func.now()) - updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) - - # ── TTS Service ─────────────────────────────────────────────────────────── +MAX_TTS_RETRIES = 3 + + async def call_mimo_tts(text: str, style: str = "", voice: str = "") -> bytes: - """调用小米 MiMo TTS API,返回 WAV 音频字节""" + """调用 MiMo TTS API,返回 WAV 字节。5xx 自动重试最多 3 次""" if not config.MIMO_API_KEY: - raise HTTPException(500, "MIMO_API_KEY 未配置,请设置环境变量") + raise HTTPException(500, "MIMO_API_KEY 未配置") content = f"{text}" if style else text use_voice = voice or config.MIMO_VOICE @@ -160,539 +137,216 @@ async def call_mimo_tts(text: str, style: str = "", voice: str = "") -> bytes: "audio": {"format": "wav", "voice": use_voice}, "messages": [{"role": "assistant", "content": content}], } - headers = { "Content-Type": "application/json", "api-key": config.MIMO_API_KEY, } - t0 = time.time() - logger.info(f"MiMo TTS 请求: text_len={len(text)}, style={style or '(默认)'}") - - 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() - + last_exc = None + for attempt in range(1, MAX_TTS_RETRIES + 1): + t0 = time.time() try: - audio_dir = Path(config.AUDIO_DIR) / chapter.book_id - audio_dir.mkdir(parents=True, exist_ok=True) + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.post(config.MIMO_API_ENDPOINT, json=payload, headers=headers) - mp3_path = str(audio_dir / f"{chapter.chapter_id}.mp3") - chunks = split_text(chapter.text_content) + elapsed = round(time.time() - t0, 2) - if len(chunks) == 1: - # 单段:直接生成 - wav_bytes = await call_mimo_tts(chapter.text_content) - wav_path = str(audio_dir / f"{chapter.chapter_id}.wav") - with open(wav_path, "wb") as f: - f.write(wav_bytes) - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, wav_to_mp3, wav_path, mp3_path) - os.remove(wav_path) - else: - # 多段:逐段生成 → 拼接 - logger.info(f"章节 {chapter_id_str}: 文本 {len(chapter.text_content)} 字, 分 {len(chunks)} 段生成") - tmp_mp3_paths = [] - for i, chunk in enumerate(chunks): - wav_bytes = await call_mimo_tts(chunk) - tmp_id = f"{chapter.chapter_id}_part{i}" - wav_path = str(audio_dir / f"{tmp_id}.wav") - tmp_mp3 = str(audio_dir / f"{tmp_id}.mp3") + if resp.status_code != 200: + logger.error(f"MiMo TTS HTTP {resp.status_code}, {elapsed}s, {resp.text[:200]}") + err = HTTPException(502, f"MiMo TTS API 错误: HTTP {resp.status_code}") + if resp.status_code >= 500 and attempt < MAX_TTS_RETRIES: + last_exc = err + await asyncio.sleep(1.5 * attempt) + continue + raise err - with open(wav_path, "wb") as f: - f.write(wav_bytes) + data = resp.json() + if data.get("error"): + raise HTTPException(502, f"MiMo TTS 错误: {data['error']}") - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, wav_to_mp3, wav_path, tmp_mp3) - os.remove(wav_path) - tmp_mp3_paths.append(tmp_mp3) + audio_b64 = data["choices"][0]["message"]["audio"]["data"] + wav_bytes = base64.b64decode(audio_b64) + logger.info(f"MiMo TTS OK: {len(wav_bytes)} bytes, {elapsed}s (attempt {attempt})") + return wav_bytes - # 拼接 - await loop.run_in_executor(None, concat_mp3_files, tmp_mp3_paths, mp3_path) - for p in tmp_mp3_paths: - os.remove(p) - logger.info(f"章节 {chapter_id_str}: {len(chunks)} 段拼接完成") - - chapter.audio_file = mp3_path - chapter.status = "ready" - chapter.error_msg = "" + except HTTPException: + raise except Exception as e: - chapter.status = "error" - chapter.error_msg = str(e)[:500] - logger.error(f"章节 {chapter_id_str} 生成失败: {e}") + elapsed = round(time.time() - t0, 2) + logger.error(f"MiMo TTS 异常: {e}, {elapsed}s, attempt {attempt}") + last_exc = HTTPException(502, f"MiMo TTS 异常: {e}") + if attempt < MAX_TTS_RETRIES: + await asyncio.sleep(1.5 * attempt) - await db.commit() + raise last_exc -# ── App Lifecycle ────────────────────────────────────────────────────────── +# ── Core: generate MP3 from text ────────────────────────────────────────── + +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 async def lifespan(app: FastAPI): os.makedirs(config.AUDIO_DIR, exist_ok=True) - os.makedirs(os.path.join(config.BASE_DIR, "data"), exist_ok=True) - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + (Path(config.AUDIO_DIR) / "_tmp").mkdir(exist_ok=True) + (Path(config.AUDIO_DIR) / "_preview").mkdir(exist_ok=True) yield -app = FastAPI(title="TTS Book Service", lifespan=lifespan) +app = FastAPI(title="TTS Proxy Service", lifespan=lifespan) +# ── 健康检查 ─────────────────────────────────────────────────────────────── + @app.get("/health") -async def health_check(): - """健康检查""" - return {"status": "ok", "service": "TTS Book Service", "api_key_configured": bool(config.MIMO_API_KEY)} +async def health(): + return { + "status": "ok", + "api_key": bool(config.MIMO_API_KEY), + "token": bool(config.API_TOKEN), + } -# ── 听书 App 音频接入接口 ───────────────────────────────────────────────── - -@app.get("/api/book/{book_id}") -async def get_book_info(book_id: str): - """获取书籍信息及章节列表(听书 App 调用)""" - async with async_session() as db: - book_result = await db.execute(select(Book).where(Book.book_id == book_id)) - book = book_result.scalar_one_or_none() - if not book: - raise HTTPException(404, f"书籍 {book_id} 不存在") - - ch_result = await db.execute( - select(Chapter).where(Chapter.book_id == book_id).order_by(Chapter.id) - ) - chapters = ch_result.scalars().all() - - return { - "book_id": book.book_id, - "title": book.title, - "author": book.author, - "chapters": [ - { - "chapter_id": ch.chapter_id, - "app_chapter_id": ch.app_chapter_id, - "title": ch.title, - "status": ch.status, - "audio_url": f"/api/book/{book_id}/chapter/{ch.chapter_id}/audio" - if ch.status == "ready" else None, - } - for ch in chapters - ], - } - - -@app.get("/api/book/{book_id}/chapter/{chapter_id}/audio") -async def get_chapter_audio(book_id: str, chapter_id: str): - """获取章节音频文件(听书 App 调用)""" - async with async_session() as db: - result = await db.execute( - select(Chapter).where( - Chapter.book_id == book_id, Chapter.chapter_id == chapter_id - ) - ) - chapter = result.scalar_one_or_none() - - if not chapter: - raise HTTPException(404, "章节不存在") - if chapter.status != "ready" or not chapter.audio_file: - raise HTTPException(404, f"音频尚未生成,当前状态: {chapter.status}") - - if not os.path.exists(chapter.audio_file): - raise HTTPException(404, "音频文件丢失") - - return FileResponse(chapter.audio_file, media_type="audio/mpeg", filename=f"{chapter_id}.mp3") - - -# ── 实时 TTS 接口(兼容听书 App 格式)───────────────────────────────────── +# ── 核心接口: 实时 TTS ──────────────────────────────────────────────────── @app.post("/api/tts") async def realtime_tts(request: Request): """ - 实时 TTS 生成接口 - 兼容两种 App 发送格式: - 1. JSON body: {"text": "内容", "style": "开心"} - 2. Form body: tex=内容&spd=5 (百度风格) - 返回: MP3 音频二进制流 (audio/mpeg),失败返回 JSON + 实时 TTS → 返回 MP3 音频流 + + JSON: {"text": "内容", "style": "开心", "voice": ""} + Form: tex=内容 (百度兼容) """ - text = "" - style = "" - voice = "" + text = style = voice = "" content_type = request.headers.get("content-type", "") try: if "json" in content_type: data = await request.json() - text = data.get("text", "").strip() - style = data.get("style", "").strip() - voice = data.get("voice", "").strip() + text = (data.get("text") or "").strip() + style = (data.get("style") or "").strip() + voice = (data.get("voice") or "").strip() else: - # form-urlencoded (百度风格) - body_bytes = await request.body() - params = parse_qs(body_bytes.decode("utf-8")) - text = (params.get("tex", [""])[0]).strip() - # URL 解码(百度会 double-encode) - text = unquote(unquote(text)) + from urllib.parse import parse_qs, unquote + body = await request.body() + params = parse_qs(body.decode("utf-8")) + text = unquote(unquote((params.get("tex", [""])[0]).strip())) except Exception: pass if not text: return Response( - content=json.dumps({"status": 40000001, "message": "text/tex 不能为空"}, ensure_ascii=False), - media_type="application/json", - status_code=400, + content=json.dumps({"status": 40000001, "message": "text 不能为空"}, ensure_ascii=False), + media_type="application/json", status_code=400, ) try: - # 文本分段 - 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) - + mp3_bytes = await generate_mp3(text, style, voice) return Response(content=mp3_bytes, media_type="audio/mpeg") - except Exception as e: return Response( - content=json.dumps({"status": 50000002, "message": str(e)[:300]}, ensure_ascii=False), - media_type="application/json", - status_code=500, + content=json.dumps({"status": 500, "message": str(e)[:300]}, ensure_ascii=False), + media_type="application/json", status_code=500, ) -# ── 管理 API ────────────────────────────────────────────────────────────── +# ── 管理接口 ─────────────────────────────────────────────────────────────── -# --- Books --- - -@app.get("/admin/api/books") -async def list_books(): - async with async_session() as db: - result = await db.execute(select(Book).order_by(Book.id.desc())) - books = result.scalars().all() - return [{"book_id": b.book_id, "title": b.title, "author": b.author} for b in books] - - -@app.post("/admin/api/books") -async def create_book(request: Request): +@app.post("/admin/api/preview") +async def preview(request: Request, _auth=Depends(verify_token)): + """TTS 试听,返回音频 URL""" data = await request.json() - book_id = data.get("book_id", "").strip() - title = data.get("title", "").strip() - author = data.get("author", "").strip() - - if not book_id or not title: - raise HTTPException(400, "book_id 和 title 不能为空") - - async with async_session() as db: - existing = await db.execute(select(Book).where(Book.book_id == book_id)) - if existing.scalar_one_or_none(): - raise HTTPException(409, f"书籍 {book_id} 已存在") - - book = Book(book_id=book_id, title=title, author=author) - db.add(book) - await db.commit() - return {"ok": True, "book_id": book_id} - - -@app.delete("/admin/api/books/{book_id}") -async def delete_book(book_id: str): - async with async_session() as db: - await db.execute(delete(Chapter).where(Chapter.book_id == book_id)) - await db.execute(delete(Book).where(Book.book_id == book_id)) - await db.commit() - return {"ok": True} - - -# --- Chapters --- - -@app.get("/admin/api/books/{book_id}/chapters") -async def list_chapters(book_id: str): - async with async_session() as db: - result = await db.execute( - select(Chapter).where(Chapter.book_id == book_id).order_by(Chapter.id) - ) - chapters = result.scalars().all() - return [ - { - "chapter_id": ch.chapter_id, - "app_chapter_id": ch.app_chapter_id, - "title": ch.title, - "text_content": ch.text_content[:200] + "..." if len(ch.text_content) > 200 else ch.text_content, - "text_length": len(ch.text_content), - "status": ch.status, - "error_msg": ch.error_msg, - "has_audio": ch.status == "ready", - } - for ch in chapters - ] - - -@app.post("/admin/api/books/{book_id}/chapters") -async def create_chapter(book_id: str, request: Request): - data = await request.json() - chapter_id = data.get("chapter_id", "").strip() - title = data.get("title", "").strip() - app_chapter_id = data.get("app_chapter_id", "").strip() - text_content = data.get("text_content", "").strip() - - if not chapter_id: - raise HTTPException(400, "chapter_id 不能为空") - - async with async_session() as db: - existing = await db.execute( - select(Chapter).where(Chapter.book_id == book_id, Chapter.chapter_id == chapter_id) - ) - if existing.scalar_one_or_none(): - raise HTTPException(409, f"章节 {chapter_id} 已存在") - - chapter = Chapter( - book_id=book_id, - chapter_id=chapter_id, - app_chapter_id=app_chapter_id or chapter_id, - title=title, - text_content=text_content, - ) - db.add(chapter) - await db.commit() - return {"ok": True, "chapter_id": chapter_id} - - -@app.put("/admin/api/books/{book_id}/chapters/{chapter_id}") -async def update_chapter(book_id: str, chapter_id: str, request: Request): - data = await request.json() - async with async_session() as db: - result = await db.execute( - select(Chapter).where(Chapter.book_id == book_id, Chapter.chapter_id == chapter_id) - ) - chapter = result.scalar_one_or_none() - if not chapter: - raise HTTPException(404, "章节不存在") - - if "text_content" in data: - chapter.text_content = data["text_content"] - if "title" in data: - chapter.title = data["title"] - if "app_chapter_id" in data: - chapter.app_chapter_id = data["app_chapter_id"] - - await db.commit() - return {"ok": True} - - -@app.delete("/admin/api/books/{book_id}/chapters/{chapter_id}") -async def delete_chapter(book_id: str, chapter_id: str): - async with async_session() as db: - await db.execute( - delete(Chapter).where(Chapter.book_id == book_id, Chapter.chapter_id == chapter_id) - ) - await db.commit() - return {"ok": True} - - -# --- TTS --- - -@app.post("/admin/api/books/{book_id}/chapters/{chapter_id}/generate") -async def generate_audio(book_id: str, chapter_id: str): - """手动生成单章音频""" - await generate_chapter_audio(chapter_id) - async with async_session() as db: - result = await db.execute( - select(Chapter).where(Chapter.book_id == book_id, Chapter.chapter_id == chapter_id) - ) - ch = result.scalar_one_or_none() - return {"ok": True, "status": ch.status, "error_msg": ch.error_msg} - - -@app.post("/admin/api/books/{book_id}/generate-all") -async def generate_all_chapters(book_id: str): - """批量生成书籍所有章节音频(并发,限制 3 路)""" - async with async_session() as db: - result = await db.execute( - select(Chapter).where(Chapter.book_id == book_id, Chapter.status != "ready") - ) - chapters = result.scalars().all() - - chapter_ids = [ch.chapter_id for ch in chapters] - if not chapter_ids: - return {"ok": True, "total": 0, "chapter_ids": [], "message": "没有需要生成的章节"} - - # 并发生成,限制同时 3 个请求避免过载 - sem = asyncio.Semaphore(3) - - async def _gen(cid: str): - async with sem: - await generate_chapter_audio(cid) - - tasks = [_gen(cid) for cid in chapter_ids] - results = await asyncio.gather(*tasks, return_exceptions=True) - - # 统计结果 - errors = [str(r) for r in results if isinstance(r, Exception)] - success_count = len(chapter_ids) - len(errors) - - return { - "ok": True, - "total": len(chapter_ids), - "success": success_count, - "failed": len(errors), - "errors": errors[:10] if errors else [], - "chapter_ids": chapter_ids, - } - - -# --- TTS 试听 --- - -@app.post("/admin/api/tts/preview") -async def tts_preview(request: Request): - """试听 TTS 效果""" - data = await request.json() - text = data.get("text", "").strip() - style = data.get("style", "").strip() - voice = data.get("voice", "").strip() - + text = (data.get("text") or "").strip() + style = (data.get("style") or "").strip() + voice = (data.get("voice") or "").strip() if not text: raise HTTPException(400, "文本不能为空") - wav_bytes = await call_mimo_tts(text, style, voice) - audio_dir = Path(config.AUDIO_DIR) / "_preview" - audio_dir.mkdir(parents=True, exist_ok=True) - + mp3_bytes = await generate_mp3(text, style, voice) + preview_dir = Path(config.AUDIO_DIR) / "_preview" filename = f"{uuid.uuid4().hex}.mp3" - wav_path = str(audio_dir / f"{uuid.uuid4().hex}.wav") - mp3_path = str(audio_dir / filename) - - with open(wav_path, "wb") as f: - f.write(wav_bytes) - - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, wav_to_mp3, wav_path, mp3_path) - os.remove(wav_path) - + with open(preview_dir / filename, "wb") as f: + f.write(mp3_bytes) return {"ok": True, "url": f"/audio/_preview/{filename}"} @app.get("/admin/api/config") -async def get_config(): +async def config_info(_auth=Depends(verify_token)): return { "endpoint": config.MIMO_API_ENDPOINT, "model": config.MIMO_TTS_MODEL, "voice": config.MIMO_VOICE, - "api_key_masked": config.MIMO_API_KEY[:6] + "****" if config.MIMO_API_KEY else "未配置", - "max_chunk_chars": MAX_CHUNK_CHARS, + "api_key": config.MIMO_API_KEY[:6] + "****" if config.MIMO_API_KEY else "未配置", + "max_chunk": MAX_CHUNK_CHARS, + "token_set": bool(config.API_TOKEN), } # ── 配置文件下载 ─────────────────────────────────────────────────────────── @app.get("/httpTts.json") -async def serve_http_tts_config(): - """提供 App 导入用的音频源配置文件""" - config_path = os.path.join(config.BASE_DIR, "httpTts-mimo.json") - if os.path.exists(config_path): - return FileResponse(config_path, media_type="application/json") - raise HTTPException(404, "配置文件不存在") +async def serve_config(): + path = os.path.join(config.BASE_DIR, "httpTts-mimo.json") + if os.path.exists(path): + return FileResponse(path, media_type="application/json") + raise HTTPException(404) -# ── 静态文件 & 前端 ────────────────────────────────────────────────────── +# ── 静态 & 前端 ─────────────────────────────────────────────────────────── app.mount("/audio", StaticFiles(directory=config.AUDIO_DIR), name="audio") @app.get("/", response_class=HTMLResponse) async def frontend(): - html_path = os.path.join(config.BASE_DIR, "static", "index.html") - with open(html_path, "r", encoding="utf-8") as f: + with open(os.path.join(config.BASE_DIR, "static", "index.html"), "r", encoding="utf-8") as f: return HTMLResponse(f.read()) diff --git a/app/models.py b/app/models.py deleted file mode 100644 index 6bfd13d..0000000 --- a/app/models.py +++ /dev/null @@ -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) diff --git a/app/static/index.html b/app/static/index.html index 413235f..5ef10ec 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -3,485 +3,217 @@ -TTS Book Service +TTS Proxy Service -
-

📚 TTS Book Service

-

小米 MiMo TTS 听书音频转换服务

+
+

🎙️ TTS Proxy Service

+

小米 MiMo TTS 代理服务 · 智能分段 · 自动重试

- - - + + +
- -
+ +
-
-
📖 书籍列表
- -
-
-
-

加载中...

-
-
- - -
-
-
🎙️ TTS 试听
-
-
- - +
🎙️ TTS 试听
+
+
+ +
-
- - +
+ +
-
+
- +
- -
- -
+ +
-
⚙️ 当前配置
+
⚙️ 当前配置
- - - - - + + + + + + +
配置项
TTS API-
模型-
默认音色-
API Key-
项目
TTS Endpoint-
模型-
默认音色-
API Key-
分段上限-
访问令牌-
-

通过环境变量配置:MIMO_API_KEY、MIMO_API_ENDPOINT、MIMO_TTS_MODEL、MIMO_VOICE

+

通过环境变量配置:MIMO_API_KEY、MIMO_VOICE、MIMO_TTS_MODEL、API_TOKEN 等

-
- -