mirror of
https://github.com/RVC-Boss/GPT-SoVITS.git
synced 2026-07-06 14:48:46 +08:00
feat: add waveform trimming UI, Swagger docs, enhanced API annotations
- Frontend: add wavesurfer.js v7 waveform visualization with region-based audio trimming - Frontend: add export trimmed audio button, OfflineAudioContext-based client-side trimming - API: add OpenAPI tags, descriptions, and summaries for all endpoints - API: enhance /health endpoint with PID, memory, and GPU info (optional psutil/torch) - API: bump version to 1.1.0, enable /docs and /redoc - Docs: rewrite simple_api.md as comprehensive API reference - Docs: update simple_api_quickstart.md with Swagger/ReDoc links - Docs: update README with endpoint table and feature list - Tests: fix DummyFastAPI mock to accept **kwargs (tags, summary, etc.) - All 7 tests pass, compile check OK
This commit is contained in:
parent
735b2e3554
commit
a47b87bb7b
30
README.md
30
README.md
@ -1,4 +1,4 @@
|
||||
<div align="center">
|
||||
<div align="center">
|
||||
|
||||
<h1>GPT-SoVITS-WebUI</h1>
|
||||
A Powerful Few-shot Voice Conversion and Text-to-Speech WebUI.<br><br>
|
||||
@ -31,17 +31,37 @@ A Powerful Few-shot Voice Conversion and Text-to-Speech WebUI.<br><br>
|
||||
|
||||
本工作区新增了一个用于 MVP 调用的中间层接口和测试前端:
|
||||
|
||||
| 页面 | 地址 | 说明 |
|
||||
|------|------|------|
|
||||
| 测试前端 | http://127.0.0.1:9881/test/ | 带波形裁剪、视频转音频的测试 UI |
|
||||
| Swagger UI | http://127.0.0.1:9881/docs | 交互式 API 文档 |
|
||||
| ReDoc | http://127.0.0.1:9881/redoc | 可读式 API 文档 |
|
||||
|
||||
文档:
|
||||
|
||||
- 快速启动:[docs/simple_api_quickstart.md](./docs/simple_api_quickstart.md)
|
||||
- 完整教程:[docs/simple_api.md](./docs/simple_api.md)
|
||||
- 后端入口:simple_api.py
|
||||
- 测试前端:启动后访问 http://127.0.0.1:9881/test/
|
||||
|
||||
核心功能:
|
||||
|
||||
- `/api/tts` — 上传参考音频/视频 + 文字,直接返回生成的音频
|
||||
- 前端支持视频上传,自动提取音频
|
||||
- 前端波形裁剪工具,可选择 3-10 秒片段
|
||||
- 5 种情绪预设(neutral/happy/calm/sad/angry)
|
||||
- 7 个契约测试,无需 GPU 即可运行
|
||||
|
||||
启动命令:
|
||||
|
||||
`powershell
|
||||
```
|
||||
cd D:\tts\GPT-SoVITS
|
||||
.\go-simple-api.ps1
|
||||
`
|
||||
```
|
||||
|
||||
运行测试:
|
||||
|
||||
```
|
||||
python -m unittest tests.test_simple_api_contract -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,116 +1,128 @@
|
||||
# GPT-SoVITS 简化接口教程
|
||||
# GPT-SoVITS 简化接口文档
|
||||
|
||||
本项目原本已经有 `api_v2.py`,但调用时需要传很多 GPT-SoVITS 参数。新增的 `simple_api.py` 是一个中间层,目标是让前端或业务方用更简单的方式调用。
|
||||
本项目新增 `simple_api.py` 作为中间层,封装 GPT-SoVITS 推理引擎,提供更简洁的调用方式。
|
||||
|
||||
当前 MVP 推荐使用:
|
||||
## 快速开始
|
||||
|
||||
```http
|
||||
POST /api/tts
|
||||
```bash
|
||||
# 安装依赖
|
||||
python -m pip install -r requirements.txt
|
||||
|
||||
# 启动
|
||||
python simple_api.py -c simple_api.yaml
|
||||
|
||||
# 访问
|
||||
Swagger UI: http://127.0.0.1:9881/docs
|
||||
ReDoc: http://127.0.0.1:9881/redoc
|
||||
测试前端: http://127.0.0.1:9881/test/
|
||||
```
|
||||
|
||||
## 接口总览
|
||||
|
||||
| 方法 | 路径 | 说明 | 标签 |
|
||||
|------|------|------|------|
|
||||
| GET | `/health` | 健康检查(含 GPU 信息) | System |
|
||||
| GET | `/voices` | 列出 voice profiles | System |
|
||||
| **POST** | **`/api/tts`** | **核心 TTS 接口(MVP)** | **MVP** |
|
||||
| GET | `/speak` | voice profile TTS (GET) | Profile |
|
||||
| POST | `/speak` | voice profile TTS (POST) | Profile |
|
||||
| POST | `/v1/tts` | OpenAI 兼容格式 TTS | Profile |
|
||||
| POST | `/speak/base64` | 返回 Base64 音频 | Profile |
|
||||
| POST | `/admin/reload-config` | 热加载配置 | Admin |
|
||||
| POST | `/admin/weights` | 切换模型权重 | Admin |
|
||||
|
||||
---
|
||||
|
||||
## 1. POST /api/tts — 核心 TTS 接口
|
||||
|
||||
**推荐使用此接口**。上传参考音频和文字,直接返回生成的音频。
|
||||
|
||||
### 请求格式
|
||||
|
||||
```
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
适合你的流程:
|
||||
### 字段说明
|
||||
|
||||
1. 前端从视频中提取音频(或直接上传已裁剪的 3-10 秒音频)。
|
||||
2. 用户人工裁剪主参考音频到 3-10 秒。
|
||||
3. 前端把主参考音频、要生成的文字、可选辅助音频提交给后端。
|
||||
4. 后端调用 GPT-SoVITS 生成音频并返回。
|
||||
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `text` | string | **是** | — | 需要生成的文字 |
|
||||
| `ref_audio` | file | **是** | — | 主参考音频,3-10 秒(支持 wav/flac/ogg/mp3/m4a/aac) |
|
||||
| `aux_ref_audio` | file[] | 否 | — | 辅助参考音频,可上传多个 |
|
||||
| `prompt_text` | string | 否 | `""` | 主参考音频对应文字(v2 可留空;v3/v4 必填) |
|
||||
| `text_lang` | string | 否 | `zh` | 生成文字语言:zh/en/ja/ko/yue/auto |
|
||||
| `prompt_lang` | string | 否 | `zh` | 参考音频语言:zh/en/ja/ko/yue/auto |
|
||||
| `format` | string | 否 | `wav` | 返回格式:wav/ogg/aac/raw |
|
||||
| `emotion` | string | 否 | `neutral` | 情绪预设:neutral/happy/calm/sad/angry |
|
||||
| `speed` | float | 否 | — | 语速(0.5-2.0),覆盖情绪预设中的语速 |
|
||||
| `seed` | int | 否 | `-1` | 随机种子,-1 为随机 |
|
||||
|
||||
## 1. 安装依赖
|
||||
### 情绪预设参数映射
|
||||
|
||||
在 GPT-SoVITS 使用的 Python 环境里运行:
|
||||
| 情绪 | temperature | top_p | top_k | speed_factor | repetition_penalty |
|
||||
|------|-------------|-------|-------|--------------|-------------------|
|
||||
| neutral | — | — | — | — | — |
|
||||
| happy | 1.1 | 0.95 | — | — | — |
|
||||
| calm | 0.8 | 0.85 | — | 0.92 | — |
|
||||
| sad | 0.75 | 0.85 | — | 0.9 | — |
|
||||
| angry | 1.2 | — | 20 | — | 1.25 |
|
||||
|
||||
> 显式传入 `speed` 会覆盖情绪预设中的 `speed_factor`。
|
||||
|
||||
### curl 示例
|
||||
|
||||
**基础调用:**
|
||||
|
||||
```powershell
|
||||
curl.exe -X POST http://127.0.0.1:9881/api/tts `
|
||||
-F "text=你好,欢迎使用这个声音。" `
|
||||
-F "ref_audio=@D:\audio\ref.wav" `
|
||||
--output output.wav
|
||||
```
|
||||
|
||||
**带辅助参考音频和情绪:**
|
||||
|
||||
```powershell
|
||||
curl.exe -X POST http://127.0.0.1:9881/api/tts `
|
||||
-F "text=你好,欢迎使用这个声音。" `
|
||||
-F "ref_audio=@D:\audio\ref.wav" `
|
||||
-F "aux_ref_audio=@D:\audio\aux1.wav" `
|
||||
-F "emotion=happy" `
|
||||
-F "speed=1.1" `
|
||||
--output output.wav
|
||||
```
|
||||
|
||||
**Linux/macOS:**
|
||||
|
||||
```bash
|
||||
python -m pip install -r requirements.txt
|
||||
curl -X POST http://127.0.0.1:9881/api/tts \
|
||||
-F "text=你好,欢迎使用这个声音。" \
|
||||
-F "ref_audio=@/path/to/ref.wav" \
|
||||
-F "emotion=calm" \
|
||||
--output output.wav
|
||||
```
|
||||
|
||||
本中间层额外需要:
|
||||
### 返回
|
||||
|
||||
- `python-multipart`:用于接收前端上传的音频文件。
|
||||
- `soundfile`:用于读取音频信息和校验参考音频时长。
|
||||
- 成功:音频二进制流(Content-Type: `audio/wav` 等)
|
||||
- 失败:JSON 错误信息
|
||||
|
||||
这两个依赖已经写入 `requirements.txt`。
|
||||
|
||||
## 2. 检查配置
|
||||
|
||||
配置文件:
|
||||
|
||||
```text
|
||||
simple_api.yaml
|
||||
```json
|
||||
{"message": "tts failed", "exception": "..."}
|
||||
```
|
||||
|
||||
常用配置:
|
||||
### 常见错误
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: 127.0.0.1
|
||||
port: 9881
|
||||
tts_config: GPT_SoVITS/configs/tts_infer.yaml
|
||||
| HTTP 状态码 | 原因 |
|
||||
|------------|------|
|
||||
| 400 | text 为空 / ref_audio 缺失 / 音频时长不在 3-10 秒 / 不支持的 format / v3/v4 时 prompt_text 为空 |
|
||||
| 404 | voice profile 不存在(仅 /speak 接口) |
|
||||
| 503 | TTS pipeline 未就绪(模型未加载) |
|
||||
|
||||
upload:
|
||||
dir: runtime/uploads
|
||||
min_ref_seconds: 3
|
||||
max_ref_seconds: 10
|
||||
max_upload_mb: 80
|
||||
```
|
||||
---
|
||||
|
||||
说明:
|
||||
|
||||
- `server.host`:后端监听地址。
|
||||
- `server.port`:后端端口。
|
||||
- `server.tts_config`:GPT-SoVITS 推理配置文件。
|
||||
- `upload.dir`:临时上传目录。
|
||||
- `upload.min_ref_seconds`:主参考音频最短秒数。
|
||||
- `upload.max_ref_seconds`:主参考音频最长秒数。
|
||||
- `upload.max_upload_mb`:单个上传音频最大体积。
|
||||
|
||||
当前默认后端地址:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:9881
|
||||
```
|
||||
|
||||
## 3. 启动后端
|
||||
|
||||
进入项目目录:
|
||||
|
||||
```powershell
|
||||
cd D:\tts\GPT-SoVITS
|
||||
```
|
||||
|
||||
方式一:
|
||||
|
||||
```powershell
|
||||
python simple_api.py -c simple_api.yaml
|
||||
```
|
||||
|
||||
方式二,Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
.\go-simple-api.ps1
|
||||
```
|
||||
|
||||
方式三,Windows 批处理:
|
||||
|
||||
```bat
|
||||
go-simple-api.bat
|
||||
```
|
||||
|
||||
启动成功后,后端会监听:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:9881
|
||||
```
|
||||
|
||||
## 4. 健康检查
|
||||
|
||||
后端启动后,可以访问:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:9881/health
|
||||
```
|
||||
|
||||
或者命令行测试:
|
||||
## 2. GET /health — 健康检查
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:9881/health
|
||||
@ -123,198 +135,268 @@ curl http://127.0.0.1:9881/health
|
||||
"status": "ok",
|
||||
"tts_config": "GPT_SoVITS/configs/tts_infer.yaml",
|
||||
"version": "v2",
|
||||
"languages": ["auto", "auto_yue", "en", "zh"]
|
||||
"languages": ["auto", "en", "zh"],
|
||||
"pid": 12345,
|
||||
"memory_mb": 2048.5,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 3080",
|
||||
"memory_used_mb": 4096.2,
|
||||
"memory_total_mb": 10240.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 打开测试前端
|
||||
---
|
||||
|
||||
后端启动后,推荐直接打开:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:9881/test/
|
||||
```
|
||||
|
||||
这个页面已经挂载在后端服务里,不需要额外启动前端服务。
|
||||
|
||||
也可以直接打开本地文件:
|
||||
|
||||
```text
|
||||
test_frontend/index.html
|
||||
```
|
||||
|
||||
Windows 下也可以运行:
|
||||
|
||||
```powershell
|
||||
.\open-test-frontend.ps1
|
||||
```
|
||||
|
||||
测试前端里有一个“后端接口地址”输入框。
|
||||
|
||||
默认值:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:9881/api/tts
|
||||
```
|
||||
|
||||
如果你修改了 `server.host` 或 `server.port`,记得同步修改页面里的接口地址。
|
||||
|
||||
## 6. MVP 接口
|
||||
|
||||
接口地址:
|
||||
|
||||
```http
|
||||
POST /api/tts
|
||||
```
|
||||
|
||||
请求类型:
|
||||
|
||||
```http
|
||||
multipart/form-data
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
```text
|
||||
text 必填。需要生成的文字。
|
||||
ref_audio 必填。主参考音频(支持上传视频,前端会自动提取音频),要求 3-10 秒。
|
||||
aux_ref_audio 可选。辅助参考音频,可以上传多个。
|
||||
prompt_text 可选。主参考音频对应文字,可以留空。
|
||||
text_lang 可选。生成文字语言,默认 zh。
|
||||
prompt_lang 可选。参考音频语言,默认 zh。即使 prompt_text 为空,也需要传给 GPT-SoVITS 内部。
|
||||
format 可选。返回格式,默认 wav。
|
||||
emotion 可选。情绪 preset:neutral、happy、calm、sad、angry。
|
||||
speed 可选。语速,对应 GPT-SoVITS 的 speed_factor。
|
||||
seed 可选。随机种子,默认 -1。
|
||||
```
|
||||
|
||||
## 7. curl 调用示例
|
||||
|
||||
PowerShell 示例:
|
||||
|
||||
```powershell
|
||||
curl.exe -X POST http://127.0.0.1:9881/api/tts `
|
||||
-F "text=你好,欢迎使用这个声音。" `
|
||||
-F "ref_audio=@D:\audio\ref.wav" `
|
||||
-F "prompt_text=" `
|
||||
-F "text_lang=zh" `
|
||||
-F "prompt_lang=zh" `
|
||||
-F "emotion=neutral" `
|
||||
--output output.wav
|
||||
```
|
||||
|
||||
如果有辅助参考音频:
|
||||
|
||||
```powershell
|
||||
curl.exe -X POST http://127.0.0.1:9881/api/tts `
|
||||
-F "text=你好,欢迎使用这个声音。" `
|
||||
-F "ref_audio=@D:\audio\ref.wav" `
|
||||
-F "aux_ref_audio=@D:\audio\aux1.wav" `
|
||||
-F "aux_ref_audio=@D:\audio\aux2.wav" `
|
||||
-F "prompt_text=" `
|
||||
-F "text_lang=zh" `
|
||||
-F "prompt_lang=zh" `
|
||||
--output output.wav
|
||||
```
|
||||
|
||||
## 8. 重要规则
|
||||
|
||||
- 主参考音频必须是 3-10 秒。
|
||||
- `aux_ref_audio` 是可选项。
|
||||
- `prompt_text` 可以为空,但当前主要针对 GPT-SoVITS v2。
|
||||
- 如果切到 GPT-SoVITS v3/v4,空 `prompt_text` 会被中间层直接返回 400。
|
||||
- 生成文字固定使用 `cut5`,也就是按照标点符号切句。
|
||||
- `emotion` 目前是轻量 preset,本质是映射到采样和语速参数;更稳定的情绪控制仍然依赖带情绪的参考音频。
|
||||
|
||||
## 9. 测试前端使用步骤
|
||||
|
||||
1. 启动后端。
|
||||
2. 打开 `http://127.0.0.1:9881/test/`。
|
||||
3. 检查“后端接口地址”是否为:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:9881/api/tts
|
||||
```
|
||||
|
||||
4. 填写“需要生成的文字”。
|
||||
5. 上传主参考音频。
|
||||
6. 可选上传辅助参考音频。
|
||||
7. 可选填写参考音频文字。
|
||||
8. 选择语言、情绪、语速。
|
||||
9. 点击“生成音频”。
|
||||
10. 页面右侧会显示返回结果,可以在线播放和下载。
|
||||
|
||||
## 10. 其他接口
|
||||
|
||||
除了 MVP 上传接口,还保留了 profile 调用接口:
|
||||
|
||||
```http
|
||||
GET /voices
|
||||
GET /speak?text=hello&voice=default
|
||||
POST /speak
|
||||
POST /speak/base64
|
||||
POST /v1/tts
|
||||
POST /admin/reload-config
|
||||
POST /admin/weights
|
||||
```
|
||||
|
||||
这些接口适合后续做固定音色 profile,不是当前 MVP 的主流程。
|
||||
|
||||
## 11. Base64 返回示例
|
||||
## 3. GET /voices — 列出 voice profiles
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:9881/speak/base64 ^
|
||||
-H "Content-Type: application/json" ^
|
||||
-d "{\"text\":\"hello\",\"voice\":\"default\"}"
|
||||
curl http://127.0.0.1:9881/voices
|
||||
```
|
||||
|
||||
返回格式:
|
||||
返回示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"default_voice": "default",
|
||||
"voices": [
|
||||
{
|
||||
"name": "default",
|
||||
"description": "Replace this profile with your reference voice.",
|
||||
"text_lang": "zh",
|
||||
"prompt_lang": "zh",
|
||||
"ref_audio_path": "reference.wav",
|
||||
"ready": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. POST /speak — voice profile TTS
|
||||
|
||||
基于 `simple_api.yaml` 中配置的 voice profile 调用 TTS。
|
||||
|
||||
### 请求体(JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "hello world",
|
||||
"voice": "default",
|
||||
"text_lang": "zh",
|
||||
"format": "wav",
|
||||
"speed": 1.0
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `text` | string | **是** | 需要生成的文字 |
|
||||
| `voice` | string | 否 | voice profile 名称,不传则使用 default |
|
||||
| `text_lang` | string | 否 | 生成文字语言 |
|
||||
| `format` | string | 否 | 返回格式 |
|
||||
| `stream` | bool | 否 | 是否流式返回 |
|
||||
| `speed` | float | 否 | 语速 |
|
||||
|
||||
### curl 示例
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:9881/speak \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text":"你好世界","voice":"default"}' \
|
||||
--output output.wav
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. GET /speak — voice profile TTS (GET)
|
||||
|
||||
与 POST /speak 相同,但通过 URL 参数传递。
|
||||
|
||||
```
|
||||
GET /speak?text=hello&voice=default&format=wav
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. POST /speak/base64 — 返回 Base64 音频
|
||||
|
||||
返回 Base64 编码的音频,适合 Web 前端直接使用。
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:9881/speak/base64 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text":"hello","voice":"default"}'
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"media_type": "audio/wav",
|
||||
"audio_base64": "..."
|
||||
"audio_base64": "UklGRi..."
|
||||
}
|
||||
```
|
||||
|
||||
## 12. 添加固定音色 profile
|
||||
---
|
||||
|
||||
如果后续要做固定音色,可以编辑 `simple_api.yaml`:
|
||||
## 7. POST /v1/tts — OpenAI 兼容格式
|
||||
|
||||
```yaml
|
||||
voices:
|
||||
default:
|
||||
ref_audio_path: reference.wav
|
||||
prompt_text: exact transcript
|
||||
prompt_lang: zh
|
||||
text_lang: zh
|
||||
请求格式与 POST /speak 相同,路径兼容 OpenAI TTS API 风格。
|
||||
|
||||
narrator:
|
||||
ref_audio_path: voices/narrator.wav
|
||||
prompt_text: exact transcript of narrator.wav
|
||||
prompt_lang: zh
|
||||
text_lang: zh
|
||||
```
|
||||
---
|
||||
|
||||
编辑后调用:
|
||||
## 8. POST /admin/reload-config — 热加载配置
|
||||
|
||||
重新加载 `simple_api.yaml`,无需重启服务。
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:9881/admin/reload-config
|
||||
```
|
||||
|
||||
## 13. 契约测试
|
||||
返回:`{"message": "success", "default_voice": "default"}`
|
||||
|
||||
这个测试使用 mock,不会加载 GPT-SoVITS 模型:
|
||||
---
|
||||
|
||||
## 9. POST /admin/weights — 切换模型权重
|
||||
|
||||
运行时切换 GPT-SoVITS 模型权重文件。
|
||||
|
||||
```bash
|
||||
python -m unittest tests.test_simple_api_contract
|
||||
curl -X POST http://127.0.0.1:9881/admin/weights \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"gpt_weights_path":"path/to/gpt.pt","sovits_weights_path":"path/to/sovits.pt"}'
|
||||
```
|
||||
|
||||
用于确认:
|
||||
---
|
||||
|
||||
- `/api/tts` 路由存在。
|
||||
- 上传接口能构造正确参数。
|
||||
- 主参考音频 3-10 秒校验正常。
|
||||
- 空 `prompt_text` 在 v2 可用。
|
||||
- v3/v4 空 `prompt_text` 会返回 400。
|
||||
- 临时上传目录会被清理。
|
||||
## 配置文件
|
||||
|
||||
`simple_api.yaml`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: 127.0.0.1
|
||||
port: 9881
|
||||
tts_config: GPT_SoVITS/configs/tts_infer.yaml
|
||||
|
||||
cors_allow_origins:
|
||||
- "*"
|
||||
|
||||
upload:
|
||||
dir: runtime/uploads
|
||||
min_ref_seconds: 3
|
||||
max_ref_seconds: 10
|
||||
max_upload_mb: 80
|
||||
|
||||
defaults:
|
||||
text_lang: zh
|
||||
prompt_lang: zh
|
||||
media_type: wav
|
||||
text_split_method: cut5
|
||||
batch_size: 1
|
||||
speed_factor: 1.0
|
||||
seed: -1
|
||||
|
||||
emotion_presets:
|
||||
neutral: {}
|
||||
happy:
|
||||
temperature: 1.1
|
||||
top_p: 0.95
|
||||
calm:
|
||||
temperature: 0.8
|
||||
top_p: 0.85
|
||||
speed_factor: 0.92
|
||||
sad:
|
||||
temperature: 0.75
|
||||
top_p: 0.85
|
||||
speed_factor: 0.9
|
||||
angry:
|
||||
temperature: 1.2
|
||||
top_k: 20
|
||||
repetition_penalty: 1.25
|
||||
|
||||
voices:
|
||||
default:
|
||||
description: Replace this profile with your reference voice.
|
||||
ref_audio_path: reference.wav
|
||||
prompt_text: Replace this with the exact text spoken in reference.wav.
|
||||
prompt_lang: zh
|
||||
text_lang: zh
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| `server.host` | 监听地址 |
|
||||
| `server.port` | 监听端口 |
|
||||
| `server.tts_config` | GPT-SoVITS 推理配置文件路径 |
|
||||
| `upload.dir` | 临时上传目录 |
|
||||
| `upload.min_ref_seconds` | 主参考音频最短秒数 |
|
||||
| `upload.max_ref_seconds` | 主参考音频最长秒数 |
|
||||
| `upload.max_upload_mb` | 单个上传文件最大体积 (MB) |
|
||||
| `defaults.*` | 所有接口的默认参数 |
|
||||
| `emotion_presets.*` | 情绪预设参数映射 |
|
||||
| `voices.*` | 固定音色 profile |
|
||||
|
||||
---
|
||||
|
||||
## 添加自定义音色
|
||||
|
||||
编辑 `simple_api.yaml`,在 `voices` 下添加:
|
||||
|
||||
```yaml
|
||||
voices:
|
||||
narrator:
|
||||
description: "男声旁白"
|
||||
ref_audio_path: voices/narrator.wav
|
||||
prompt_text: "旁白参考音频的逐字稿"
|
||||
prompt_lang: zh
|
||||
text_lang: zh
|
||||
```
|
||||
|
||||
然后热加载:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:9881/admin/reload-config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试
|
||||
|
||||
### 契约测试(无需 GPU)
|
||||
|
||||
```bash
|
||||
python -m unittest tests.test_simple_api_contract -v
|
||||
```
|
||||
|
||||
覆盖:
|
||||
|
||||
- `/api/tts` 路由注册
|
||||
- 上传接口参数构造
|
||||
- 主参考音频 3-10 秒校验
|
||||
- v2 空 prompt_text 允许 / v3/v4 空 prompt_text 拒绝
|
||||
- 临时上传目录清理
|
||||
- 情绪预设应用与 speed 覆盖
|
||||
|
||||
### 前端测试
|
||||
|
||||
1. 启动后端
|
||||
2. 访问 `http://127.0.0.1:9881/test/`
|
||||
3. 上传音频或视频(视频会自动提取音频)
|
||||
4. 使用波形裁剪工具选择 3-10 秒片段
|
||||
5. 填写文字,选择情绪和语速
|
||||
6. 点击生成
|
||||
|
||||
---
|
||||
|
||||
## 启动脚本
|
||||
|
||||
| 脚本 | 平台 | 说明 |
|
||||
|------|------|------|
|
||||
| `go-simple-api.ps1` | Windows PowerShell | 自动检测 runtime\python.exe |
|
||||
| `go-simple-api.bat` | Windows CMD | 同上 |
|
||||
| `open-test-frontend.ps1` | Windows PowerShell | 直接打开测试前端 HTML |
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
## 一句话流程
|
||||
|
||||
启动后端,打开测试页,上传 3-10 秒参考音频或视频(视频会自动提取音频),填写生成文字,点击生成。
|
||||
启动后端,打开测试页,上传 3-10 秒参考音频或视频(视频自动提取音频),填写生成文字,点击生成。
|
||||
|
||||
## 1. 启动后端
|
||||
|
||||
@ -26,19 +26,13 @@ go-simple-api.bat
|
||||
http://127.0.0.1:9881
|
||||
```
|
||||
|
||||
## 2. 打开测试前端
|
||||
## 2. 打开页面
|
||||
|
||||
后端启动后访问:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:9881/test/
|
||||
```
|
||||
|
||||
测试页面里的默认接口地址是:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:9881/api/tts
|
||||
```
|
||||
| 页面 | 地址 | 说明 |
|
||||
|------|------|------|
|
||||
| 测试前端 | http://127.0.0.1:9881/test/ | 带波形裁剪的测试 UI |
|
||||
| Swagger UI | http://127.0.0.1:9881/docs | 交互式 API 文档 |
|
||||
| ReDoc | http://127.0.0.1:9881/redoc | 可读式 API 文档 |
|
||||
|
||||
## 3. 调用接口
|
||||
|
||||
@ -53,7 +47,7 @@ Content-Type: multipart/form-data
|
||||
|
||||
```text
|
||||
text 要生成的文字
|
||||
ref_audio 3-10 秒主参考音频(支持视频,前端自动提取音频)
|
||||
ref_audio 3-10 秒主参考音频(或视频,前端自动提取)
|
||||
```
|
||||
|
||||
常用可选字段:
|
||||
@ -83,6 +77,8 @@ curl.exe -X POST http://127.0.0.1:9881/api/tts `
|
||||
## 5. 注意事项
|
||||
|
||||
- 主参考音频必须是 3-10 秒。
|
||||
- 前端支持上传视频文件,会自动提取音频。
|
||||
- 前端提供波形裁剪工具,可直接选择 3-10 秒片段。
|
||||
- `prompt_text` 在当前 v2 配置下可以为空。
|
||||
- 如果切换到 v3/v4,空 `prompt_text` 会返回 400。
|
||||
- 生成文字固定按标点符号切句。
|
||||
|
||||
@ -151,8 +151,26 @@ infer_lock = threading.Lock()
|
||||
|
||||
APP = FastAPI(
|
||||
title="GPT-SoVITS Simple API",
|
||||
description="Profile-based API layer that hides GPT-SoVITS request details.",
|
||||
version="1.0.0",
|
||||
description=(
|
||||
"简化接口层,封装 GPT-SoVITS 推理引擎。\n\n"
|
||||
"## 核心流程\n"
|
||||
"1. 上传 3-10 秒参考音频(或视频,前端自动提取音频)\n"
|
||||
"2. 填写需要生成的文字\n"
|
||||
"3. 调用 `/api/tts` 获取生成的音频\n\n"
|
||||
"## 其他接口\n"
|
||||
"- `/speak` — 基于 voice profile 的调用方式\n"
|
||||
"- `/v1/tts` — OpenAI 兼容格式\n"
|
||||
"- `/admin/*` — 管理接口(热加载配置、切换模型)"
|
||||
),
|
||||
version="1.1.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_tags=[
|
||||
{"name": "MVP", "description": "核心 TTS 接口,上传参考音频直接生成"},
|
||||
{"name": "Profile", "description": "基于 voice profile 的调用方式"},
|
||||
{"name": "Admin", "description": "管理接口:热加载配置、切换模型权重"},
|
||||
{"name": "System", "description": "健康检查与信息查询"},
|
||||
],
|
||||
)
|
||||
APP.add_middleware(
|
||||
CORSMiddleware,
|
||||
@ -584,33 +602,51 @@ def startup() -> None:
|
||||
tts_pipeline = TTS(tts_config)
|
||||
|
||||
|
||||
@APP.get("/")
|
||||
@APP.get("/", tags=["System"])
|
||||
def index() -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "GPT-SoVITS Simple API",
|
||||
"endpoints": [
|
||||
"/health",
|
||||
"/voices",
|
||||
"/api/tts",
|
||||
"/speak",
|
||||
"/speak/base64",
|
||||
"/admin/weights",
|
||||
"/admin/reload-config",
|
||||
],
|
||||
"version": "1.1.0",
|
||||
"docs": "/docs",
|
||||
"endpoints": {
|
||||
"system": ["/health", "/voices"],
|
||||
"mvp": ["/api/tts"],
|
||||
"profile": ["/speak", "/speak/base64", "/v1/tts"],
|
||||
"admin": ["/admin/reload-config", "/admin/weights"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@APP.get("/health")
|
||||
@APP.get("/health", tags=["System"], summary="健康检查")
|
||||
def health() -> Dict[str, Any]:
|
||||
return {
|
||||
import os
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"status": "ok" if tts_pipeline is not None else "starting",
|
||||
"tts_config": tts_config_path,
|
||||
"version": getattr(tts_config, "version", None),
|
||||
"languages": getattr(tts_config, "languages", []),
|
||||
"pid": os.getpid(),
|
||||
}
|
||||
try:
|
||||
import psutil
|
||||
result["memory_mb"] = round(psutil.Process(os.getpid()).memory_info().rss / 1024 / 1024, 1)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
result["gpu"] = {
|
||||
"name": torch.cuda.get_device_name(0),
|
||||
"memory_used_mb": round(torch.cuda.memory_allocated(0) / 1024 / 1024, 1),
|
||||
"memory_total_mb": round(torch.cuda.get_device_properties(0).total_mem / 1024 / 1024, 1),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
@APP.get("/voices")
|
||||
@APP.get("/voices", tags=["System"], summary="列出可用 voice profiles")
|
||||
def list_voices() -> Dict[str, Any]:
|
||||
voices = simple_config.get("voices", {}) or {}
|
||||
return {
|
||||
@ -619,7 +655,17 @@ def list_voices() -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@APP.post("/api/tts")
|
||||
@APP.post(
|
||||
"/api/tts",
|
||||
tags=["MVP"],
|
||||
summary="核心 TTS 接口",
|
||||
description=(
|
||||
"上传参考音频和需要生成的文字,返回生成的音频。\n\n"
|
||||
"**主参考音频要求**:3-10 秒,支持 wav/flac/ogg/mp3/m4a/aac 格式。\n\n"
|
||||
"**文字切句**:固定使用 `cut5`(按标点符号切句)。\n\n"
|
||||
"**情绪预设**:neutral / happy / calm / sad / angry,本质是映射到采样和语速参数。"
|
||||
),
|
||||
)
|
||||
async def mvp_tts(
|
||||
text: str = Form(...),
|
||||
ref_audio: UploadFile = File(...),
|
||||
@ -666,7 +712,7 @@ async def mvp_tts(
|
||||
shutil.rmtree(request_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@APP.get("/speak")
|
||||
@APP.get("/speak", tags=["Profile"], summary="GET 方式调用 voice profile TTS")
|
||||
def speak_get(
|
||||
text: str,
|
||||
voice: Optional[str] = None,
|
||||
@ -686,17 +732,17 @@ def speak_get(
|
||||
return synthesize_response({k: v for k, v in payload.items() if v is not None})
|
||||
|
||||
|
||||
@APP.post("/speak")
|
||||
@APP.post("/speak", tags=["Profile"], summary="POST 方式调用 voice profile TTS")
|
||||
def speak_post(request: SpeakRequest) -> Response:
|
||||
return synthesize_response(request_to_dict(request))
|
||||
|
||||
|
||||
@APP.post("/v1/tts")
|
||||
@APP.post("/v1/tts", tags=["Profile"], summary="OpenAI 兼容格式 TTS")
|
||||
def openai_style_tts(request: SpeakRequest) -> Response:
|
||||
return synthesize_response(request_to_dict(request))
|
||||
|
||||
|
||||
@APP.post("/speak/base64")
|
||||
@APP.post("/speak/base64", tags=["Profile"], summary="返回 Base64 编码的音频")
|
||||
def speak_base64(request: SpeakRequest) -> Dict[str, Any]:
|
||||
audio_bytes, media_type = synthesize_once(request_to_dict(request))
|
||||
return {
|
||||
@ -705,14 +751,14 @@ def speak_base64(request: SpeakRequest) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@APP.post("/admin/reload-config")
|
||||
@APP.post("/admin/reload-config", tags=["Admin"], summary="热加载 simple_api.yaml 配置")
|
||||
def reload_config() -> Dict[str, Any]:
|
||||
global simple_config
|
||||
simple_config = load_yaml_config(args.config)
|
||||
return {"message": "success", "default_voice": get_default_voice_name()}
|
||||
|
||||
|
||||
@APP.post("/admin/weights")
|
||||
@APP.post("/admin/weights", tags=["Admin"], summary="运行时切换 GPT-SoVITS 模型权重")
|
||||
def set_weights(request: WeightsRequest) -> Dict[str, Any]:
|
||||
if tts_pipeline is None:
|
||||
raise HTTPException(status_code=503, detail="TTS pipeline is not ready")
|
||||
|
||||
@ -282,6 +282,81 @@
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.waveform-wrap {
|
||||
display: none;
|
||||
margin-top: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfcfb;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.waveform-wrap.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.waveform-wrap h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
#waveform {
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #e9efeb;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.trim-controls {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.trim-controls.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.trim-controls label {
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.trim-controls input[type="number"] {
|
||||
width: 80px;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trim-time-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.trim-time-group span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.trim-separator {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.btn-export-trim {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
header,
|
||||
.workspace,
|
||||
@ -304,7 +379,7 @@
|
||||
<header>
|
||||
<div>
|
||||
<h1>GPT-SoVITS 接口测试台</h1>
|
||||
<p class="sub">选择 3-10 秒参考音频或视频(视频会自动提取音频),填写后端接口地址和生成文本,直接调用中间层 <code>/api/tts</code>。</p>
|
||||
<p class="sub">选择 3-10 秒参考音频或视频(视频会自动提取音频),在波形图上拖拽选取裁剪区域,填写后端接口地址和生成文本,直接调用中间层 <code>/api/tts</code>。</p>
|
||||
</div>
|
||||
<div class="status" id="statusBox">
|
||||
<strong>未检测</strong>
|
||||
@ -332,6 +407,22 @@
|
||||
<input id="refAudio" name="ref_audio" type="file" accept="audio/*,video/*" required>
|
||||
<div class="file-line" id="refInfo">请选择 3-10 秒音频或视频(视频会自动提取音频)</div>
|
||||
<div class="file-line" id="extractInfo" style="display:none;"></div>
|
||||
<div class="waveform-wrap" id="waveformWrap">
|
||||
<h3>波形预览 & 裁剪</h3>
|
||||
<div id="waveform"></div>
|
||||
<div class="trim-controls" id="trimControls">
|
||||
<div class="trim-time-group">
|
||||
<label for="trimStart">起始</label>
|
||||
<input id="trimStart" type="number" min="0" step="0.01" value="0">
|
||||
</div>
|
||||
<span class="trim-separator">—</span>
|
||||
<div class="trim-time-group">
|
||||
<label for="trimEnd">结束</label>
|
||||
<input id="trimEnd" type="number" min="0" step="0.01" value="0">
|
||||
</div>
|
||||
<button type="button" class="ghost btn-export-trim" id="exportTrimBtn">导出裁剪音频</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -425,6 +516,8 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://unpkg.com/wavesurfer.js@7/dist/wavesurfer.min.js"></script>
|
||||
<script src="https://unpkg.com/wavesurfer.js@7/dist/plugins/regions.min.js"></script>
|
||||
<script>
|
||||
const form = document.querySelector("#ttsForm");
|
||||
const endpoint = document.querySelector("#endpoint");
|
||||
@ -441,6 +534,12 @@
|
||||
const statusBox = document.querySelector("#statusBox");
|
||||
const elapsed = document.querySelector("#elapsed");
|
||||
const fileSize = document.querySelector("#fileSize");
|
||||
const waveformWrap = document.querySelector("#waveformWrap");
|
||||
const waveformEl = document.querySelector("#waveform");
|
||||
const trimControls = document.querySelector("#trimControls");
|
||||
const trimStartInput = document.querySelector("#trimStart");
|
||||
const trimEndInput = document.querySelector("#trimEnd");
|
||||
const exportTrimBtn = document.querySelector("#exportTrimBtn");
|
||||
let resultUrl = null;
|
||||
|
||||
if (location.protocol === "http:" || location.protocol === "https:") {
|
||||
@ -489,51 +588,14 @@
|
||||
}
|
||||
|
||||
let extractedAudioBlob = null;
|
||||
let wavesurfer = null;
|
||||
let wsRegions = null;
|
||||
let currentAudioDuration = 0;
|
||||
|
||||
function isVideoFile(file) {
|
||||
return file && file.type && file.type.startsWith("video/");
|
||||
}
|
||||
|
||||
async function extractAudioFromVideo(file) {
|
||||
const extractInfo = document.querySelector("#extractInfo");
|
||||
extractInfo.style.display = "block";
|
||||
extractInfo.textContent = "正在从视频中提取音频...";
|
||||
extractInfo.className = "file-line";
|
||||
|
||||
try {
|
||||
const video = document.createElement("video");
|
||||
video.preload = "auto";
|
||||
const videoUrl = URL.createObjectURL(file);
|
||||
video.src = videoUrl;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
video.onloadeddata = resolve;
|
||||
video.onerror = () => reject(new Error("无法加载视频文件"));
|
||||
});
|
||||
|
||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const response = await fetch(videoUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
|
||||
|
||||
const wavBlob = audioBufferToWav(audioBuffer);
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
audioCtx.close();
|
||||
|
||||
extractedAudioBlob = wavBlob;
|
||||
const duration = audioBuffer.duration;
|
||||
const ok = Number.isFinite(duration) && duration >= 3 && duration <= 10;
|
||||
extractInfo.textContent = `已提取音频 · ${duration.toFixed(2)}s · ${bytesLabel(wavBlob.size)}${ok ? " ✓" : " ⚠ 建议裁剪到 3-10 秒"}`;
|
||||
extractInfo.className = `file-line ${ok ? "duration-ok" : "duration-warn"}`;
|
||||
return true;
|
||||
} catch (err) {
|
||||
extractInfo.textContent = `提取失败:${err.message}`;
|
||||
extractInfo.className = "file-line duration-warn";
|
||||
extractedAudioBlob = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function audioBufferToWav(buffer) {
|
||||
const numChannels = buffer.numberOfChannels;
|
||||
const sampleRate = buffer.sampleRate;
|
||||
@ -580,6 +642,196 @@
|
||||
return new Blob([arrayBuffer], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
async function trimAudioBuffer(audioBuffer, startTime, endTime) {
|
||||
const offlineCtx = new OfflineAudioContext(
|
||||
audioBuffer.numberOfChannels,
|
||||
Math.ceil((endTime - startTime) * audioBuffer.sampleRate),
|
||||
audioBuffer.sampleRate
|
||||
);
|
||||
const source = offlineCtx.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
|
||||
const startSample = Math.floor(startTime * audioBuffer.sampleRate);
|
||||
const endSample = Math.floor(endTime * audioBuffer.sampleRate);
|
||||
const length = endSample - startSample;
|
||||
|
||||
const trimmedBuffer = offlineCtx.createBuffer(
|
||||
audioBuffer.numberOfChannels,
|
||||
length,
|
||||
audioBuffer.sampleRate
|
||||
);
|
||||
|
||||
for (let ch = 0; ch < audioBuffer.numberOfChannels; ch++) {
|
||||
const sourceData = audioBuffer.getChannelData(ch);
|
||||
const destData = trimmedBuffer.getChannelData(ch);
|
||||
for (let i = 0; i < length; i++) {
|
||||
destData[i] = sourceData[startSample + i] || 0;
|
||||
}
|
||||
}
|
||||
|
||||
source.buffer = trimmedBuffer;
|
||||
source.connect(offlineCtx.destination);
|
||||
source.start(0);
|
||||
|
||||
return await offlineCtx.startRendering();
|
||||
}
|
||||
|
||||
async function exportTrimmedAudio() {
|
||||
const sourceBlob = extractedAudioBlob || refAudio.files[0];
|
||||
if (!sourceBlob) return;
|
||||
|
||||
const start = parseFloat(trimStartInput.value) || 0;
|
||||
const end = parseFloat(trimEndInput.value) || currentAudioDuration;
|
||||
if (end <= start) {
|
||||
log("裁剪结束时间必须大于起始时间。", true);
|
||||
return;
|
||||
}
|
||||
|
||||
exportTrimBtn.disabled = true;
|
||||
exportTrimBtn.textContent = "处理中...";
|
||||
|
||||
try {
|
||||
const arrayBuffer = await sourceBlob.arrayBuffer();
|
||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
|
||||
audioCtx.close();
|
||||
|
||||
const trimmedBuffer = await trimAudioBuffer(audioBuffer, start, end);
|
||||
const wavBlob = audioBufferToWav(trimmedBuffer);
|
||||
const url = URL.createObjectURL(wavBlob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `trimmed_${start.toFixed(2)}-${end.toFixed(2)}s.wav`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
log(`导出失败:${err.message}`, true);
|
||||
} finally {
|
||||
exportTrimBtn.disabled = false;
|
||||
exportTrimBtn.textContent = "导出裁剪音频";
|
||||
}
|
||||
}
|
||||
|
||||
exportTrimBtn.addEventListener("click", exportTrimmedAudio);
|
||||
|
||||
function initWaveform(arrayBuffer) {
|
||||
if (wavesurfer) {
|
||||
wavesurfer.destroy();
|
||||
wavesurfer = null;
|
||||
wsRegions = null;
|
||||
}
|
||||
|
||||
waveformWrap.classList.add("visible");
|
||||
waveformEl.innerHTML = "";
|
||||
|
||||
wavesurfer = WaveSurfer.create({
|
||||
container: waveformEl,
|
||||
waveColor: "#b8d4c8",
|
||||
progressColor: "#19745f",
|
||||
cursorColor: "#0f5f4c",
|
||||
height: 60,
|
||||
responsive: true,
|
||||
barWidth: 2,
|
||||
barGap: 1,
|
||||
barRadius: 2,
|
||||
});
|
||||
|
||||
wsRegions = wavesurfer.registerPlugin(WaveSurfer.Regions.create());
|
||||
|
||||
const blob = new Blob([arrayBuffer], { type: "audio/wav" });
|
||||
wavesurfer.loadBlob(blob);
|
||||
|
||||
wavesurfer.on("ready", () => {
|
||||
currentAudioDuration = wavesurfer.getDuration();
|
||||
trimEndInput.max = currentAudioDuration;
|
||||
trimStartInput.max = currentAudioDuration;
|
||||
|
||||
const regionEnd = Math.min(10, currentAudioDuration);
|
||||
trimStartInput.value = 0;
|
||||
trimEndInput.value = regionEnd.toFixed(2);
|
||||
|
||||
wsRegions.addRegion({
|
||||
start: 0,
|
||||
end: regionEnd,
|
||||
color: "rgba(25, 116, 95, 0.18)",
|
||||
drag: true,
|
||||
resize: true,
|
||||
});
|
||||
|
||||
trimControls.classList.add("visible");
|
||||
});
|
||||
|
||||
wsRegions.on("region-updated", (region) => {
|
||||
trimStartInput.value = region.start.toFixed(2);
|
||||
trimEndInput.value = region.end.toFixed(2);
|
||||
});
|
||||
}
|
||||
|
||||
trimStartInput.addEventListener("change", () => {
|
||||
if (!wsRegions) return;
|
||||
const regions = wsRegions.getRegions();
|
||||
if (regions.length === 0) return;
|
||||
const r = regions[0];
|
||||
const s = parseFloat(trimStartInput.value) || 0;
|
||||
const e = parseFloat(trimEndInput.value) || currentAudioDuration;
|
||||
r.setOptions({ start: Math.min(s, e), end: e });
|
||||
});
|
||||
|
||||
trimEndInput.addEventListener("change", () => {
|
||||
if (!wsRegions) return;
|
||||
const regions = wsRegions.getRegions();
|
||||
if (regions.length === 0) return;
|
||||
const r = regions[0];
|
||||
const s = parseFloat(trimStartInput.value) || 0;
|
||||
const e = parseFloat(trimEndInput.value) || currentAudioDuration;
|
||||
r.setOptions({ start: s, end: Math.max(s, e) });
|
||||
});
|
||||
|
||||
async function extractAudioFromVideo(file) {
|
||||
const extractInfo = document.querySelector("#extractInfo");
|
||||
extractInfo.style.display = "block";
|
||||
extractInfo.textContent = "正在从视频中提取音频...";
|
||||
extractInfo.className = "file-line";
|
||||
|
||||
try {
|
||||
const video = document.createElement("video");
|
||||
video.preload = "auto";
|
||||
const videoUrl = URL.createObjectURL(file);
|
||||
video.src = videoUrl;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
video.onloadeddata = resolve;
|
||||
video.onerror = () => reject(new Error("无法加载视频文件"));
|
||||
});
|
||||
|
||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const response = await fetch(videoUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
|
||||
|
||||
const wavBlob = audioBufferToWav(audioBuffer);
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
audioCtx.close();
|
||||
|
||||
extractedAudioBlob = wavBlob;
|
||||
const duration = audioBuffer.duration;
|
||||
const ok = Number.isFinite(duration) && duration >= 3 && duration <= 10;
|
||||
extractInfo.textContent = `已提取音频 · ${duration.toFixed(2)}s · ${bytesLabel(wavBlob.size)}${ok ? " ✓" : " ⚠ 建议裁剪到 3-10 秒"}`;
|
||||
extractInfo.className = `file-line ${ok ? "duration-ok" : "duration-warn"}`;
|
||||
|
||||
const wavArrayBuffer = await wavBlob.arrayBuffer();
|
||||
initWaveform(wavArrayBuffer);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
extractInfo.textContent = `提取失败:${err.message}`;
|
||||
extractInfo.className = "file-line duration-warn";
|
||||
extractedAudioBlob = null;
|
||||
waveformWrap.classList.remove("visible");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function inspectDuration(file, target) {
|
||||
extractedAudioBlob = null;
|
||||
const extractInfo = document.querySelector("#extractInfo");
|
||||
@ -588,6 +840,8 @@
|
||||
if (!file) {
|
||||
target.textContent = "请选择 3-10 秒音频或视频";
|
||||
target.className = "file-line";
|
||||
waveformWrap.classList.remove("visible");
|
||||
if (wavesurfer) { wavesurfer.destroy(); wavesurfer = null; wsRegions = null; }
|
||||
return;
|
||||
}
|
||||
|
||||
@ -607,11 +861,14 @@
|
||||
const ok = Number.isFinite(duration) && duration >= 3 && duration <= 10;
|
||||
target.textContent = `${file.name} · ${duration.toFixed(2)}s · ${bytesLabel(file.size)}`;
|
||||
target.className = `file-line ${ok ? "duration-ok" : "duration-warn"}`;
|
||||
|
||||
file.arrayBuffer().then((buf) => initWaveform(buf));
|
||||
};
|
||||
audio.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
target.textContent = `${file.name} · 无法读取时长 · ${bytesLabel(file.size)}`;
|
||||
target.className = "file-line duration-warn";
|
||||
waveformWrap.classList.remove("visible");
|
||||
};
|
||||
audio.src = url;
|
||||
}
|
||||
@ -644,7 +901,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
resetBtn.addEventListener("click", clearResult);
|
||||
resetBtn.addEventListener("click", () => {
|
||||
clearResult();
|
||||
waveformWrap.classList.remove("visible");
|
||||
trimControls.classList.remove("visible");
|
||||
if (wavesurfer) { wavesurfer.destroy(); wavesurfer = null; wsRegions = null; }
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
@ -665,10 +927,41 @@
|
||||
const data = new FormData();
|
||||
data.append("text", document.querySelector("#text").value.trim());
|
||||
|
||||
if (extractedAudioBlob) {
|
||||
data.append("ref_audio", extractedAudioBlob, "extracted_audio.wav");
|
||||
const useTrimmed = waveformWrap.classList.contains("visible") && wsRegions;
|
||||
if (useTrimmed) {
|
||||
const regions = wsRegions.getRegions();
|
||||
if (regions.length > 0) {
|
||||
const region = regions[0];
|
||||
const trimStartTime = region.start;
|
||||
const trimEndTime = region.end;
|
||||
|
||||
if (trimEndTime - trimStartTime < currentAudioDuration - 0.01) {
|
||||
try {
|
||||
const sourceBlob = extractedAudioBlob || file;
|
||||
const arrayBuffer = await sourceBlob.arrayBuffer();
|
||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
|
||||
audioCtx.close();
|
||||
|
||||
const trimmedBuffer = await trimAudioBuffer(audioBuffer, trimStartTime, trimEndTime);
|
||||
const trimmedBlob = audioBufferToWav(trimmedBuffer);
|
||||
data.append("ref_audio", trimmedBlob, "trimmed_audio.wav");
|
||||
} catch (err) {
|
||||
log(`裁剪失败,使用原始音频:${err.message}`, true);
|
||||
data.append("ref_audio", extractedAudioBlob || file);
|
||||
}
|
||||
} else {
|
||||
data.append("ref_audio", extractedAudioBlob || file);
|
||||
}
|
||||
} else {
|
||||
data.append("ref_audio", extractedAudioBlob || file);
|
||||
}
|
||||
} else {
|
||||
data.append("ref_audio", file);
|
||||
if (extractedAudioBlob) {
|
||||
data.append("ref_audio", extractedAudioBlob, "extracted_audio.wav");
|
||||
} else {
|
||||
data.append("ref_audio", file);
|
||||
}
|
||||
}
|
||||
|
||||
for (const aux of auxAudio.files) data.append("aux_ref_audio", aux);
|
||||
|
||||
@ -28,10 +28,10 @@ class DummyFastAPI:
|
||||
def mount(self, path, app, name=None):
|
||||
self.routes.append(types.SimpleNamespace(path=path, endpoint=app, name=name))
|
||||
|
||||
def get(self, path):
|
||||
def get(self, path, **kwargs):
|
||||
return self._route(path)
|
||||
|
||||
def post(self, path):
|
||||
def post(self, path, **kwargs):
|
||||
return self._route(path)
|
||||
|
||||
def on_event(self, event):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user