接口概述
IndexTTS-2 是一款先进的语音合成服务,采用深度学习技术,能够将文字转换为自然流畅的语音。本服务支持多种情感控制方式,让您的语音内容更具表现力,本接口新增了故障转移机制:当前算力服务因故障而不可用时,允许切换至其他算力,以确保服务持续可用。
核心特性
- 任务队列管理:支持全局并发限制、用户并发限制、用户排队限制
- 自动轮询支持:客户端可通过轮询获取任务状态
- 自动退款机制:任务失败时自动退还费用
- 情感控制:支持音频参考、情绪向量、文本控制 三种情感控制方式
接口地址
Base URL: https://www.yuntts.com/api/v1
| 接口 | 请求方式 | 路径 |
|---|---|---|
| 生成任务 | POST | /text-to-speech/generate |
| 查询状态 | POST | /text-to-speech/status |
| 取消任务 | POST | /text-to-speech/cancel |
完整示例:
- 生成任务:
https://www.yuntts.com/api/v1/text-to-speech/generate - 查询状态:
https://www.yuntts.com/api/v1/text-to-speech/status - 取消任务:
https://www.yuntts.com/api/v1/text-to-speech/cancel
认证方式
所有接口均采用 Bearer Token 认证机制。
请求头格式
Authorization: Bearer {your_api_key}
Content-Type: application/json
1. 生成任务接口
请求地址
POST /api/v1/text-to-speech/generate
请求参数
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| input | string | 是 | 要合成的文本内容 |
| prompt_audio_url | string | 是 | 说话人音色参考音频URL |
| prompt_text | string | 否 | 参考音频对应的文字内容(用于语义对齐) |
| emo_audio_prompt_url | string | 否 | 情感参考音频URL(模式1) |
| emo_vector | array | 否 | 情感向量数组,8个0-1之间的数值(模式2) |
| use_emo_text | boolean | 否 | 是否使用文本情感控制(模式3) |
| emo_text | string | 否 | 情感参考文本(模式3) |
| emo_alpha | float | 否 | 情感强度,0-1之间,默认0.5 |
情感控制模式说明
| 模式 | 参数组合 | 说明 |
|---|---|---|
| 0 - 不使用 | 无 | 不使用情感控制 |
| 1 - 音频控制 | emo_audio_prompt_url + emo_alpha |
使用音频控制情感 |
| 2 - 向量控制 | emo_vector + emo_alpha |
使用8维情感向量 |
| 3 - 文本控制 | use_emo_text=true + emo_text + emo_alpha |
使用文本描述情感 |
emo_vector 向量定义(按顺序):
- 高兴 (happy)
- 生气 (angry)
- 悲伤 (sad)
- 害怕 (afraid)
- 厌恶 (disgusted)
- 忧郁 (melancholic)
- 惊讶 (surprised)
- 平静 (calm)
请求示例
基础请求(无情感控制)
{
"input": "欢迎使用IndexTTS语音合成系统,这是一段测试文本。",
"prompt_audio_url": "https://example.com/speaker.wav"
}
音频情感控制
{
"input": "今天真是太开心了!",
"prompt_audio_url": "https://example.com/speaker.wav",
"emo_audio_prompt_url": "https://example.com/happy_emotion.wav",
"emo_alpha": 0.7
}
向量情感控制
{
"input": "这是一个平静的测试。",
"prompt_audio_url": "https://example.com/speaker.wav",
"emo_vector": [0, 0, 0, 0, 0, 0, 0, 1],
"emo_alpha": 0.5
}
文本情感控制
{
"input": "这真是一个令人兴奋的消息!",
"prompt_audio_url": "https://example.com/speaker.wav",
"use_emo_text": true,
"emo_text": "非常开心和激动",
"emo_alpha": 0.8
}
响应参数
成功响应
{
"code": 0,
"message": "任务提交成功",
"data": {
"task_id": "indextts2_pa_xxx_xxx",
"status": "pending",
"progress": 0,
"char_count": 24,
"cost": 0.01,
"message": "任务已提交,正在排队处理中"
}
}
错误响应
{
"code": 1003,
"message": "余额不足,需要 0.01 元,当前余额 0.00 元",
"data": {}
}
错误码说明
| 错误码 | 说明 | 处理建议 |
|---|---|---|
| 0 | 成功 | - |
| 1001 | 参数错误 | 检查必填参数是否完整 |
| 1002 | 未授权 | 检查API Key是否有效 |
| 1003 | 余额不足 | 充值账户余额 |
| 1004 | 全局任务数超限 | 系统繁忙,稍后重试 |
| 1005 | 用户任务数超限 | 等待部分任务完成 |
| 1006 | 用户排队数超限 | 等待排队任务处理 |
| 1007 | 扣费失败 | 稍后重试 |
| 1008 | 创建任务失败 | 联系管理员 |
| 1009 | 调用失败 | 稍后重试 |
| 9999 | 系统错误 | 联系管理员 |
2. 查询任务状态接口
请求地址
POST /api/v1/text-to-speech/status
请求参数
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| task_id | string | 是 | 任务ID |
请求示例
{
"task_id": "indextts2_pa_xxx_xxx"
}
响应参数
任务处理中
{
"code": 0,
"message": "查询成功",
"data": {
"task_id": "indextts2_pa_xxx_xxx",
"status": "running",
"progress": 45,
"char_count": 24,
"cost": 0.01,
"created_at": "2026-04-12 10:00:00",
"started_at": "2026-04-12 10:00:05",
"completed_at": null,
"message": "任务正在处理中"
}
}
任务完成
{
"code": 0,
"message": "查询成功",
"data": {
"task_id": "indextts2_pa_xxx_xxx",
"status": "completed",
"progress": 100,
"char_count": 24,
"cost": 0.01,
"created_at": "2026-04-12 10:00:00",
"started_at": "2026-04-12 10:00:05",
"completed_at": "2026-04-12 10:00:30",
"audio_url": "https://example.com/audio/xxx.wav",
"message": "语音合成成功"
}
}
任务失败(已退款)
{
"code": 0,
"message": "查询成功",
"data": {
"task_id": "indextts2_pa_xxx_xxx",
"status": "refunded",
"progress": 0,
"char_count": 24,
"cost": 0.01,
"created_at": "2026-04-12 10:00:00",
"started_at": "2026-04-12 10:00:05",
"completed_at": "2026-04-12 10:00:30",
"error_message": "错误: 运行任务数超限",
"message": "任务失败,已自动退款: 运行任务数超限"
}
}
任务状态说明
| 状态 | 说明 | 是否终端状态 |
|---|---|---|
| pending | 等待中 | 否 |
| running | 处理中 | 否 |
| completed | 已完成 | 是 |
| failed | 失败 | 是 |
| cancelled | 已取消 | 是 |
| refunded | 已退款 | 是 |
终端状态:任务到达此状态后不再变化,可停止轮询。
3. 取消任务接口
重要说明
取消限制:取消功能有以下限制:
| 任务状态 | 是否可取消 | 说明 |
|---|---|---|
pending(等待中) |
✅ 可以取消 | 任务还在本地队列 |
running(处理中) |
❌ 无法取消 | 已提交到执行 |
completed(已完成) |
❌ 无法取消 | 任务已完成 |
failed(失败) |
❌ 无法取消 | 任务已失败 |
cancelled(已取消) |
❌ 无法取消 | 任务已取消 |
refunded(已退款) |
❌ 无法取消 | 任务已退款 |
建议:如需取消任务,请在提交后尽快操作。一旦任务进入 running 状态,只能等待完成或失败后自动退款。
请求地址
POST /api/v1/text-to-speech/cancel
请求参数
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| task_id | string | 是 | 任务ID |
请求示例
{
"task_id": "indextts2_pa_xxx_xxx"
}
响应参数
成功响应(pending 状态取消)
{
"code": 0,
"message": "任务已取消,费用已退还",
"data": {
"task_id": "indextts2_pa_xxx_xxx",
"status": "cancelled",
"refunded_amount": 0.01
}
}
错误响应(running 状态无法取消)
{
"code": 1011,
"message": "任务正在处理中,不支持中途取消。请等待任务完成或失败后自动退款。",
"data": {}
}
错误响应(已完成/已取消)
{
"code": 1011,
"message": "任务已完成或已取消,无法取消",
"data": {}
}
客户端轮询实现指南
轮询流程
1. 提交任务 → 获取 task_id
2. 立即返回给用户"任务提交成功"
3. 开始轮询查询任务状态(每2秒一次)
4. 任务到达终端状态 → 停止轮询 → 显示结果
JavaScript 实现示例
// 提交任务
async function submitTask() {
const response = await fetch('/api/v1/text-to-speech/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer your_api_key'
},
body: JSON.stringify({
input: '要合成的文本',
prompt_audio_url: 'https://example.com/speaker.wav'
})
});
const data = await response.json();
if (data.code === 0) {
// 任务提交成功,开始轮询
console.log('任务提交成功:', data.data.task_id);
startPolling(data.data.task_id);
} else {
console.error('提交失败:', data.message);
}
}
// 轮询任务状态
function startPolling(taskId) {
const pollInterval = 2000; // 2秒轮询一次
const maxPollTime = 10 * 60 * 1000; // 最多轮询10分钟
const startTime = Date.now();
const intervalId = setInterval(async () => {
// 检查是否超时
if (Date.now() - startTime > maxPollTime) {
clearInterval(intervalId);
console.log('轮询超时');
return;
}
try {
const response = await fetch('/api/v1/text-to-speech/status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer your_api_key'
},
body: JSON.stringify({ task_id: taskId })
});
const data = await response.json();
if (data.code === 0) {
const task = data.data;
console.log('任务状态:', task.status, '进度:', task.progress);
// 更新UI显示状态
updateTaskStatus(task);
// 检查是否到达终端状态
if (['completed', 'failed', 'cancelled', 'refunded'].includes(task.status)) {
clearInterval(intervalId);
if (task.status === 'completed') {
console.log('任务完成,音频URL:', task.audio_url);
// 播放音频或提供下载
} else {
console.log('任务结束:', task.message);
}
}
}
} catch (error) {
console.error('轮询失败:', error);
}
}, pollInterval);
}
// 更新UI显示
function updateTaskStatus(task) {
const statusTexts = {
'pending': '等待中',
'running': '处理中',
'completed': '已完成',
'failed': '失败',
'cancelled': '已取消',
'refunded': '已退款'
};
document.getElementById('status').textContent = statusTexts[task.status];
document.getElementById('progress').textContent = task.progress + '%';
}
限制说明
并发限制
| 限制类型 | 默认值 | 说明 |
|---|---|---|
| 全局并发 | 10 | 系统同时处理的最大任务数 |
| 用户并发 | 5 | 单个用户同时运行的最大任务数 |
| 用户排队 | 10 | 单个用户pending状态的最大任务数 |
费用计算
费用 = 字符数 × 单价 × 会员折扣
字符数计算规则:
- 中文字符:2个字符
- 英文字符:1个字符
会员折扣:
- 普通用户:无折扣
- VIP用户:9折
- SVIP用户:8折
最低扣费:0.01元
测试工具
项目提供了前端测试页面:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IndexTTS-2 伪异步接口测试</title>
<!-- Bootstrap 5 CSS - 国内CDN -->
<link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.3.1/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons - 国内CDN -->
<link href="https://cdn.bootcdn.net/ajax/libs/bootstrap-icons/1.10.5/font/bootstrap-icons.css" rel="stylesheet">
<style>
body {
background: #f8f9fa;
padding-bottom: 50px;
}
.navbar-brand {
font-weight: bold;
}
.card {
border: none;
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.075);
margin-bottom: 1.5rem;
}
.card-header {
background: #fff;
border-bottom: 2px solid #e9ecef;
font-weight: 600;
}
.nav-pills .nav-link {
color: #6c757d;
font-weight: 500;
}
.nav-pills .nav-link.active {
background: #0d6efd;
color: #fff;
}
.nav-pills .nav-link:hover:not(.active) {
background: #e9ecef;
color: #495057;
}
.result-box {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.875rem;
max-height: 400px;
overflow-y: auto;
}
.task-card {
transition: all 0.3s ease;
}
.task-card:hover {
box-shadow: 0 0.5rem 1rem rgba(0,0,0,0.15);
}
.status-badge {
font-size: 0.75rem;
}
.emo-vector-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
}
@media (max-width: 768px) {
.emo-vector-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.loading-spinner {
display: inline-block;
width: 1.5rem;
height: 1.5rem;
vertical-align: middle;
}
.audio-player {
width: 100%;
margin-top: 0.5rem;
}
.section-content {
display: none;
}
.section-content.active {
display: block;
}
</style>
</head>
<body>
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">
<div class="container">
<a class="navbar-brand" href="#">
<i class="bi bi-mic-fill me-2"></i>IndexTTS-2 伪异步接口测试
</a>
</div>
</nav>
<div class="container">
<!-- 标签导航 -->
<div class="row mb-4">
<div class="col-12">
<ul class="nav nav-pills nav-fill">
<li class="nav-item">
<a class="nav-link active" href="#" onclick="switchTab('generate', this)">
<i class="bi bi-plus-circle me-1"></i>生成任务
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" onclick="switchTab('query', this)">
<i class="bi bi-search me-1"></i>查询任务
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" onclick="switchTab('list', this)">
<i class="bi bi-list-task me-1"></i>任务列表
</a>
</li>
</ul>
</div>
</div>
<!-- 生成任务 -->
<div id="section-generate" class="section-content active">
<!-- API配置 -->
<div class="card">
<div class="card-header">
<i class="bi bi-gear me-2"></i>API 配置
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">API Base URL</label>
<input type="text" class="form-control" id="apiBaseUrl"
value="https://www.yuntts.com/api/v1"
placeholder="https://www.yuntts.com/api/v1">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">API Key</label>
<input type="text" class="form-control" id="apiKey" value="sk-2e426fd2729ce09***5065f18f2db" placeholder="输入你的API Key">
</div>
</div>
</div>
</div>
<!-- 合成参数 -->
<div class="card">
<div class="card-header">
<i class="bi bi-file-text me-2"></i>合成参数
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">要合成的文本 <span class="text-danger">*</span></label>
<textarea class="form-control" id="inputText" rows="3"
placeholder="请输入要合成的文本">欢迎使用IndexTTS语音合成系统。</textarea>
</div>
<div class="mb-3">
<label class="form-label">说话人音色参考音频URL <span class="text-danger">*</span></label>
<input type="url" class="form-control" id="promptAudioUrl"
placeholder="https://example.com/speaker.wav">
</div>
<div class="mb-3">
<label class="form-label">参考音频文字内容</label>
<input type="text" class="form-control" id="promptText"
placeholder="参考音频对应的文字内容(用于语义对齐)">
</div>
</div>
</div>
<!-- 情感控制 -->
<div class="card">
<div class="card-header">
<i class="bi bi-emoji-smile me-2"></i>情感控制(可选)
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">情感控制模式</label>
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check" name="emoMode" id="emoMode0" value="0" checked onchange="toggleEmoMode()">
<label class="btn btn-outline-secondary" for="emoMode0">不使用</label>
<input type="radio" class="btn-check" name="emoMode" id="emoMode1" value="1" onchange="toggleEmoMode()">
<label class="btn btn-outline-secondary" for="emoMode1">音频控制</label>
<input type="radio" class="btn-check" name="emoMode" id="emoMode2" value="2" onchange="toggleEmoMode()">
<label class="btn btn-outline-secondary" for="emoMode2">向量控制</label>
<input type="radio" class="btn-check" name="emoMode" id="emoMode3" value="3" onchange="toggleEmoMode()">
<label class="btn btn-outline-secondary" for="emoMode3">文本控制</label>
</div>
</div>
<!-- 音频控制 -->
<div id="emoAudioSection" class="mb-3" style="display:none;">
<label class="form-label">情感参考音频URL</label>
<input type="url" class="form-control" id="emoAudioUrl"
placeholder="https://example.com/emotion.wav">
</div>
<!-- 向量控制 -->
<div id="emoVectorSection" style="display:none;">
<label class="form-label">情感向量 (0-1)</label>
<div class="emo-vector-grid mb-3">
<div>
<label class="form-label small">高兴 happy</label>
<input type="number" class="form-control form-control-sm" id="emoHappy"
min="0" max="1" step="0.1" value="0">
</div>
<div>
<label class="form-label small">生气 angry</label>
<input type="number" class="form-control form-control-sm" id="emoAngry"
min="0" max="1" step="0.1" value="0">
</div>
<div>
<label class="form-label small">悲伤 sad</label>
<input type="number" class="form-control form-control-sm" id="emoSad"
min="0" max="1" step="0.1" value="0">
</div>
<div>
<label class="form-label small">害怕 afraid</label>
<input type="number" class="form-control form-control-sm" id="emoAfraid"
min="0" max="1" step="0.1" value="0">
</div>
<div>
<label class="form-label small">厌恶 disgusted</label>
<input type="number" class="form-control form-control-sm" id="emoDisgusted"
min="0" max="1" step="0.1" value="0">
</div>
<div>
<label class="form-label small">忧郁 melancholic</label>
<input type="number" class="form-control form-control-sm" id="emoMelancholic"
min="0" max="1" step="0.1" value="0">
</div>
<div>
<label class="form-label small">惊讶 surprised</label>
<input type="number" class="form-control form-control-sm" id="emoSurprised"
min="0" max="1" step="0.1" value="0">
</div>
<div>
<label class="form-label small">平静 calm</label>
<input type="number" class="form-control form-control-sm" id="emoCalm"
min="0" max="1" step="0.1" value="1">
</div>
</div>
</div>
<!-- 文本控制 -->
<div id="emoTextSection" class="mb-3" style="display:none;">
<label class="form-label">情感参考文本</label>
<input type="text" class="form-control" id="emoText"
placeholder="例如:非常开心和激动">
</div>
<div class="mb-3" id="emoAlphaSection" style="display:none;">
<label class="form-label">情感强度 (0-1)</label>
<input type="range" class="form-range" id="emoAlphaRange" min="0" max="1" step="0.1" value="0.5" oninput="updateEmoAlphaValue()">
<div class="d-flex justify-content-between">
<small class="text-muted">弱</small>
<span id="emoAlphaValue" class="fw-bold">0.5</span>
<small class="text-muted">强</small>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="card">
<div class="card-body">
<button class="btn btn-primary me-2" onclick="submitTask()">
<i class="bi bi-send me-1"></i>提交任务
</button>
<button class="btn btn-secondary" onclick="clearResult()">
<i class="bi bi-trash me-1"></i>清空结果
</button>
<div id="submitLoading" class="loading-spinner spinner-border text-primary ms-2" style="display:none;" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div id="submitResult" class="card-footer result-box" style="display:none;"></div>
</div>
</div>
<!-- 查询任务 -->
<div id="section-query" class="section-content">
<div class="card">
<div class="card-header">
<i class="bi bi-search me-2"></i>查询任务状态
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">任务ID</label>
<div class="input-group">
<input type="text" class="form-control" id="queryTaskId" placeholder="输入任务ID">
<button class="btn btn-primary" onclick="queryTask(false)">
<i class="bi bi-search me-1"></i>查询
</button>
<button class="btn btn-info" onclick="queryTask(true)">
<i class="bi bi-arrow-repeat me-1"></i>查询并轮询
</button>
<button class="btn btn-danger" onclick="cancelTask()">
<i class="bi bi-x-circle me-1"></i>取消
</button>
</div>
</div>
<div id="queryLoading" class="loading-spinner spinner-border text-primary" style="display:none;" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<div id="queryResult" class="result-box mt-3" style="display:none;"></div>
</div>
</div>
</div>
<!-- 任务列表 -->
<div id="section-list" class="section-content">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-list-task me-2"></i>任务列表</span>
<div>
<button class="btn btn-sm btn-primary me-1" onclick="loadTaskList()">
<i class="bi bi-arrow-clockwise me-1"></i>刷新
</button>
<button class="btn btn-sm btn-success me-1" onclick="startAutoRefresh()">
<i class="bi bi-play-circle me-1"></i>自动刷新
</button>
<button class="btn btn-sm btn-danger" onclick="stopAutoRefresh()">
<i class="bi bi-stop-circle me-1"></i>停止
</button>
</div>
</div>
<div class="card-body">
<div id="listLoading" class="text-center py-4" style="display:none;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div id="taskList" class="row g-3"></div>
</div>
</div>
</div>
</div>
<!-- Bootstrap 5 JS - 国内CDN -->
<script src="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.3.1/js/bootstrap.bundle.min.js"></script>
<script>
// 存储任务列表
let taskList = JSON.parse(localStorage.getItem('indextts2_tasks') || '[]');
let autoRefreshInterval = null;
// 轮询状态管理
const pollingTasks = new Map(); // taskId -> { intervalId, startTime, resultElement }
// 切换标签
function switchTab(tab, element) {
// 更新导航状态
document.querySelectorAll('.nav-link').forEach(el => el.classList.remove('active'));
element.classList.add('active');
// 显示对应内容
document.querySelectorAll('.section-content').forEach(el => el.classList.remove('active'));
document.getElementById(`section-${tab}`).classList.add('active');
if (tab === 'list') {
loadTaskList();
}
}
// 切换情感模式
function toggleEmoMode() {
const mode = document.querySelector('input[name="emoMode"]:checked').value;
document.getElementById('emoAudioSection').style.display = mode === '1' ? 'block' : 'none';
document.getElementById('emoVectorSection').style.display = mode === '2' ? 'block' : 'none';
document.getElementById('emoTextSection').style.display = mode === '3' ? 'block' : 'none';
document.getElementById('emoAlphaSection').style.display = mode !== '0' ? 'block' : 'none';
}
// 更新情感强度显示值
function updateEmoAlphaValue() {
document.getElementById('emoAlphaValue').textContent = document.getElementById('emoAlphaRange').value;
}
// 获取请求头
function getHeaders() {
const apiKey = document.getElementById('apiKey').value;
return {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + apiKey
};
}
// 获取API基础URL
function getBaseUrl() {
return document.getElementById('apiBaseUrl').value.replace(/\/$/, '');
}
// 提交任务
async function submitTask() {
const loading = document.getElementById('submitLoading');
const result = document.getElementById('submitResult');
loading.style.display = 'inline-block';
result.style.display = 'none';
try {
// 构建请求参数
const params = {
input: document.getElementById('inputText').value,
prompt_audio_url: document.getElementById('promptAudioUrl').value,
prompt_text: document.getElementById('promptText').value || undefined
};
// 情感控制参数
const emoMode = document.querySelector('input[name="emoMode"]:checked').value;
if (emoMode === '1') {
params.emo_audio_prompt_url = document.getElementById('emoAudioUrl').value;
params.emo_alpha = parseFloat(document.getElementById('emoAlphaRange').value);
} else if (emoMode === '2') {
params.emo_vector = [
parseFloat(document.getElementById('emoHappy').value),
parseFloat(document.getElementById('emoAngry').value),
parseFloat(document.getElementById('emoSad').value),
parseFloat(document.getElementById('emoAfraid').value),
parseFloat(document.getElementById('emoDisgusted').value),
parseFloat(document.getElementById('emoMelancholic').value),
parseFloat(document.getElementById('emoSurprised').value),
parseFloat(document.getElementById('emoCalm').value)
];
params.emo_alpha = parseFloat(document.getElementById('emoAlphaRange').value);
} else if (emoMode === '3') {
params.use_emo_text = true;
params.emo_text = document.getElementById('emoText').value;
params.emo_alpha = parseFloat(document.getElementById('emoAlphaRange').value);
}
const response = await fetch(`${getBaseUrl()}/text-to-speech/generate`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(params)
});
const data = await response.json();
result.style.display = 'block';
if (data.code === 0) {
// 立即显示提交成功,不等待处理
result.className = 'card-footer result-box bg-success bg-opacity-10 text-success';
result.innerHTML = '<i class="bi bi-check-circle-fill me-2"></i><strong>任务提交成功</strong><br>' +
'<small class="text-muted">任务已提交到队列,正在处理中...</small><br>' +
'<pre class="mt-2 mb-0">' + JSON.stringify(data.data, null, 2) + '</pre>' +
'<div id="pollStatus-' + data.data.task_id + '" class="mt-3"></div>';
// 保存到任务列表
taskList.unshift({
task_id: data.data.task_id,
input_text: params.input,
status: data.data.status,
created_at: new Date().toISOString()
});
localStorage.setItem('indextts2_tasks', JSON.stringify(taskList));
// 填充查询框
document.getElementById('queryTaskId').value = data.data.task_id;
// 自动开始轮询状态
startPolling(data.data.task_id, document.getElementById('pollStatus-' + data.data.task_id));
} else {
result.className = 'card-footer result-box bg-danger bg-opacity-10 text-danger';
result.innerHTML = '<i class="bi bi-x-circle-fill me-2"></i><strong>提交失败</strong><br>错误码: ' + data.code + '<br>' + data.message;
}
} catch (error) {
result.style.display = 'block';
result.className = 'card-footer result-box bg-danger bg-opacity-10 text-danger';
result.innerHTML = '<i class="bi bi-exclamation-triangle-fill me-2"></i><strong>请求错误</strong><br>' + error.message;
} finally {
loading.style.display = 'none';
}
}
// 开始轮询任务状态
function startPolling(taskId, statusElement, onComplete = null) {
if (pollingTasks.has(taskId)) {
return; // 已经在轮询中
}
const pollInterval = 2000; // 2秒轮询一次
const maxPollTime = 10 * 60 * 1000; // 最多轮询10分钟
const intervalId = setInterval(async () => {
// 检查是否超时
const pollInfo = pollingTasks.get(taskId);
if (Date.now() - pollInfo.startTime > maxPollTime) {
stopPolling(taskId);
if (statusElement) {
statusElement.innerHTML = '<div class="alert alert-warning mt-2"><i class="bi bi-clock-history me-2"></i>轮询超时,任务可能仍在处理中</div>';
}
return;
}
try {
const response = await fetch(`${getBaseUrl()}/text-to-speech/status`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ task_id: taskId })
});
const data = await response.json();
if (data.code === 0) {
const task = data.data;
const statusTexts = {
'pending': '等待中',
'running': '处理中',
'completed': '已完成',
'failed': '失败',
'cancelled': '已取消',
'refunded': '已退款'
};
const statusColors = {
'pending': 'warning',
'running': 'info',
'completed': 'success',
'failed': 'danger',
'cancelled': 'secondary',
'refunded': 'dark'
};
// 更新轮询状态显示
if (statusElement) {
let statusHtml = '<div class="d-flex align-items-center">';
statusHtml += '<span class="badge bg-' + statusColors[task.status] + ' me-2">' + statusTexts[task.status] + '</span>';
if (task.status === 'running') {
statusHtml += '<div class="spinner-border spinner-border-sm text-primary me-2" role="status"></div>';
statusHtml += '<small class="text-muted">轮询中...</small>';
}
statusHtml += '</div>';
if (task.progress !== undefined) {
statusHtml += '<div class="progress mt-2" style="height: 6px;">' +
'<div class="progress-bar bg-info" style="width: ' + task.progress + '%"></div>' +
'</div>';
}
if (task.message) {
statusHtml += '<small class="text-muted mt-1">' + task.message + '</small>';
}
statusElement.innerHTML = statusHtml;
}
// 终端状态,停止轮询
if (['completed', 'failed', 'cancelled', 'refunded'].includes(task.status)) {
stopPolling(taskId);
// 更新本地任务列表
updateLocalTaskStatus(taskId, task.status);
// 执行完成回调
if (onComplete) {
onComplete(task);
}
// 显示最终结果
if (statusElement) {
let finalHtml = '<div class="alert alert-' + (task.status === 'completed' ? 'success' : 'secondary') + ' mt-2">';
finalHtml += '<i class="bi bi-' + (task.status === 'completed' ? 'check-circle' : 'info-circle') + ' me-2"></i>';
finalHtml += '<strong>任务' + statusTexts[task.status] + '</strong>';
if (task.audio_url) {
finalHtml += '<div class="mt-2"><audio controls class="audio-player" src="' + task.audio_url + '"></audio></div>';
}
finalHtml += '<pre class="mt-2 mb-0" style="font-size: 0.75rem;">' + JSON.stringify(task, null, 2) + '</pre>';
finalHtml += '</div>';
statusElement.innerHTML = finalHtml;
}
}
} else {
console.error('轮询查询失败:', data.message);
}
} catch (error) {
console.error('轮询请求失败:', error);
}
}, pollInterval);
// 记录轮询任务
pollingTasks.set(taskId, {
intervalId: intervalId,
startTime: Date.now(),
statusElement: statusElement
});
}
// 停止轮询任务状态
function stopPolling(taskId) {
const pollInfo = pollingTasks.get(taskId);
if (pollInfo) {
clearInterval(pollInfo.intervalId);
pollingTasks.delete(taskId);
}
}
// 更新本地任务列表中的任务状态
function updateLocalTaskStatus(taskId, status) {
const taskIndex = taskList.findIndex(t => t.task_id === taskId);
if (taskIndex !== -1) {
taskList[taskIndex].status = status;
localStorage.setItem('indextts2_tasks', JSON.stringify(taskList));
}
}
// 查询任务 - 支持自动轮询
async function queryTask(autoPoll = false) {
const taskId = document.getElementById('queryTaskId').value;
if (!taskId) {
alert('请输入任务ID');
return;
}
const loading = document.getElementById('queryLoading');
const result = document.getElementById('queryResult');
loading.style.display = 'inline-block';
result.style.display = 'none';
try {
const response = await fetch(`${getBaseUrl()}/text-to-speech/status`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ task_id: taskId })
});
const data = await response.json();
result.style.display = 'block';
if (data.code === 0) {
const task = data.data;
const statusTexts = {
'pending': '等待中',
'running': '处理中',
'completed': '已完成',
'failed': '失败',
'cancelled': '已取消',
'refunded': '已退款'
};
const statusColors = {
'pending': 'warning',
'running': 'info',
'completed': 'success',
'failed': 'danger',
'cancelled': 'secondary',
'refunded': 'dark'
};
// 根据状态选择颜色
const bgColor = task.status === 'completed' ? 'success' :
task.status === 'failed' ? 'danger' :
task.status === 'running' ? 'info' : 'secondary';
result.className = 'result-box bg-' + bgColor + ' bg-opacity-10 text-' + (task.status === 'completed' || task.status === 'running' ? 'dark' : 'danger') + ' p-3 rounded';
let html = '<div class="d-flex align-items-center mb-2">';
html += '<i class="bi bi-check-circle-fill me-2"></i><strong>查询成功</strong>';
html += '<span class="badge bg-' + statusColors[task.status] + ' ms-2">' + statusTexts[task.status] + '</span>';
// 如果是处理中,显示轮询指示器
if (task.status === 'pending' || task.status === 'running') {
html += '<div class="spinner-border spinner-border-sm text-primary ms-2" role="status"></div>';
html += '<small class="text-muted ms-2">处理中...</small>';
}
html += '</div>';
// 显示进度条
if (task.progress !== undefined) {
html += '<div class="progress mb-2" style="height: 8px;">' +
'<div class="progress-bar bg-' + statusColors[task.status] + '" style="width: ' + task.progress + '%"></div>' +
'</div>';
}
// 显示详细信息
html += '<pre class="mt-2 mb-0" style="font-size: 0.8rem;">' + JSON.stringify(data, null, 2) + '</pre>';
// 如果有音频URL,添加播放器
if (task.audio_url) {
html += '<div class="mt-3"><i class="bi bi-music-note-beamed me-2"></i><strong>音频预览:</strong><audio controls class="audio-player" src="' + task.audio_url + '"></audio></div>';
}
// 如果有消息
if (task.message) {
html += '<div class="mt-2"><small class="text-muted">' + task.message + '</small></div>';
}
result.innerHTML = html;
// 终端状态,停止轮询
if (['completed', 'failed', 'cancelled', 'refunded'].includes(task.status)) {
stopPolling(taskId);
// 更新本地任务列表
updateLocalTaskStatus(taskId, task.status);
} else if (autoPoll && !pollingTasks.has(taskId)) {
// 如果是自动模式且任务还在处理中,开始轮询
startPolling(taskId, null, (finalTask) => {
// 轮询完成后刷新显示
queryTask(false);
});
}
} else {
result.className = 'result-box bg-danger bg-opacity-10 text-danger p-3 rounded';
result.innerHTML = '<i class="bi bi-x-circle-fill me-2"></i><strong>查询失败</strong><br>错误码: ' + data.code + '<br>' + data.message;
}
} catch (error) {
result.style.display = 'block';
result.className = 'result-box bg-danger bg-opacity-10 text-danger p-3 rounded';
result.innerHTML = '<i class="bi bi-exclamation-triangle-fill me-2"></i><strong>请求错误</strong><br>' + error.message;
} finally {
loading.style.display = 'none';
}
}
// 取消任务
async function cancelTask() {
const taskId = document.getElementById('queryTaskId').value;
if (!taskId) {
alert('请输入任务ID');
return;
}
if (!confirm('确定要取消这个任务吗?')) {
return;
}
const loading = document.getElementById('queryLoading');
const result = document.getElementById('queryResult');
loading.style.display = 'inline-block';
result.style.display = 'none';
try {
const response = await fetch(`${getBaseUrl()}/text-to-speech/cancel`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ task_id: taskId })
});
const data = await response.json();
result.style.display = 'block';
if (data.code === 0) {
result.className = 'result-box bg-success bg-opacity-10 text-success p-3 rounded';
result.innerHTML = '<i class="bi bi-check-circle-fill me-2"></i><strong>取消成功</strong><pre class="mt-2 mb-0">' + JSON.stringify(data, null, 2) + '</pre>';
} else {
result.className = 'result-box bg-danger bg-opacity-10 text-danger p-3 rounded';
result.innerHTML = '<i class="bi bi-x-circle-fill me-2"></i><strong>取消失败</strong><br>错误码: ' + data.code + '<br>' + data.message;
}
} catch (error) {
result.style.display = 'block';
result.className = 'result-box bg-danger bg-opacity-10 text-danger p-3 rounded';
result.innerHTML = '<i class="bi bi-exclamation-triangle-fill me-2"></i><strong>请求错误</strong><br>' + error.message;
} finally {
loading.style.display = 'none';
}
}
// 加载任务列表
async function loadTaskList() {
const container = document.getElementById('taskList');
const loading = document.getElementById('listLoading');
loading.style.display = 'block';
container.innerHTML = '';
// 显示本地存储的任务列表
if (taskList.length === 0) {
container.innerHTML = '<div class="col-12"><div class="alert alert-info"><i class="bi bi-info-circle me-2"></i>暂无任务</div></div>';
loading.style.display = 'none';
return;
}
// 查询每个任务的最新状态
for (const task of taskList.slice(0, 10)) {
try {
const response = await fetch(`${getBaseUrl()}/text-to-speech/status`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ task_id: task.task_id })
});
const data = await response.json();
if (data.code === 0) {
renderTaskItem(container, data.data);
}
} catch (error) {
console.error('查询任务失败:', task.task_id, error);
}
}
loading.style.display = 'none';
}
// 渲染任务项
function renderTaskItem(container, task) {
const statusColors = {
'pending': 'warning',
'running': 'info',
'completed': 'success',
'failed': 'danger',
'cancelled': 'secondary',
'refunded': 'dark'
};
const statusTexts = {
'pending': '等待中',
'running': '处理中',
'completed': '已完成',
'failed': '失败',
'cancelled': '已取消',
'refunded': '已退款'
};
const col = document.createElement('div');
col.className = 'col-md-6 col-lg-4';
let html = `
<div class="card task-card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<small class="text-muted font-monospace" style="font-size: 0.75rem;">${task.task_id}</small>
<span class="badge bg-${statusColors[task.status] || 'secondary'} status-badge">${statusTexts[task.status] || task.status}</span>
</div>
<p class="card-text small text-muted mb-2">
<i class="bi bi-fonts me-1"></i>字符数: ${task.char_count} |
<i class="bi bi-currency-dollar me-1"></i>费用: ${task.cost}元
</p>
`;
if (task.progress !== undefined && task.progress > 0) {
html += `
<div class="progress mb-2" style="height: 6px;">
<div class="progress-bar bg-${statusColors[task.status] || 'primary'}" style="width: ${task.progress}%"></div>
</div>
`;
}
if (task.message) {
html += `<p class="card-text small">${task.message}</p>`;
}
if (task.audio_url) {
html += `<audio controls class="audio-player" src="${task.audio_url}"></audio>`;
}
html += `
</div>
<div class="card-footer bg-transparent">
<button class="btn btn-sm btn-outline-primary" onclick="document.getElementById('queryTaskId').value='${task.task_id}';switchTab('query', document.querySelectorAll('.nav-link')[1]);queryTask();">
<i class="bi bi-search me-1"></i>查询
</button>
`;
if (['pending', 'running'].includes(task.status)) {
html += `
<button class="btn btn-sm btn-outline-danger ms-1" onclick="document.getElementById('queryTaskId').value='${task.task_id}';cancelTask();">
<i class="bi bi-x-circle me-1"></i>取消
</button>
`;
}
html += `
</div>
</div>
`;
col.innerHTML = html;
container.appendChild(col);
}
// 开始自动刷新
function startAutoRefresh() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
}
loadTaskList();
autoRefreshInterval = setInterval(loadTaskList, 3000);
}
// 停止自动刷新
function stopAutoRefresh() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
}
}
// 清空结果
function clearResult() {
document.getElementById('submitResult').style.display = 'none';
}
// 格式化日期
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString('zh-CN');
}
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', function() {
// 从localStorage加载配置
const savedUrl = localStorage.getItem('api_base_url');
const savedKey = localStorage.getItem('api_key');
if (savedUrl) document.getElementById('apiBaseUrl').value = savedUrl;
if (savedKey) document.getElementById('apiKey').value = savedKey;
// 保存配置到localStorage
document.getElementById('apiBaseUrl').addEventListener('change', function() {
localStorage.setItem('api_base_url', this.value);
});
document.getElementById('apiKey').addEventListener('change', function() {
localStorage.setItem('api_key', this.value);
});
});
</script>
</body>
</html>
使用方法
- 打开测试页面
- 配置API Base URL和API Key
- 填写合成参数
- 点击"提交任务"
- 页面会自动轮询并显示任务状态
功能特性
- 支持三种情感控制模式
- 自动轮询任务状态
- 实时显示进度
- 支持音频预览
- 任务历史记录
常见问题
Q1: 任务提交后多久能完成?
A: 取决于当前系统负载和文本长度,通常在几秒到几十秒之间。建议客户端实现轮询机制。
Q2: 任务失败会退款吗?
A: 是的,任务失败(包括执行错误、保存失败等)会自动退款到用户账户。
Q3: 如何获取任务结果?
A: 通过查询状态接口获取,当 status 为 completed 时,audio_url 字段包含音频下载链接。
Q4: 可以取消正在处理的任务吗?
A: 可以,但只有处于 pending 或 running 状态的任务可以取消,取消后会自动退款。
Q5: 任务ID会过期吗?
A: 任务记录会长期保存,但音频文件可能有存储期限,建议及时下载。
更新日志
| 版本 | 日期 | 更新内容 |
|---|---|---|
| 1.0.0 | 2026-04-12 | 初始版本,支持基础语音合成和情感控制 |
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。


评论(0)