接口概述

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 向量定义(按顺序):

  1. 高兴 (happy)
  2. 生气 (angry)
  3. 悲伤 (sad)
  4. 害怕 (afraid)
  5. 厌恶 (disgusted)
  6. 忧郁 (melancholic)
  7. 惊讶 (surprised)
  8. 平静 (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>

使用方法

  1. 打开测试页面
  2. 配置API Base URL和API Key
  3. 填写合成参数
  4. 点击"提交任务"
  5. 页面会自动轮询并显示任务状态

功能特性

  • 支持三种情感控制模式
  • 自动轮询任务状态
  • 实时显示进度
  • 支持音频预览
  • 任务历史记录

常见问题

Q1: 任务提交后多久能完成?

A: 取决于当前系统负载和文本长度,通常在几秒到几十秒之间。建议客户端实现轮询机制。

Q2: 任务失败会退款吗?

A: 是的,任务失败(包括执行错误、保存失败等)会自动退款到用户账户。

Q3: 如何获取任务结果?

A: 通过查询状态接口获取,当 statuscompleted 时,audio_url 字段包含音频下载链接。

Q4: 可以取消正在处理的任务吗?

A: 可以,但只有处于 pendingrunning 状态的任务可以取消,取消后会自动退款。

Q5: 任务ID会过期吗?

A: 任务记录会长期保存,但音频文件可能有存储期限,建议及时下载。


更新日志

版本 日期 更新内容
1.0.0 2026-04-12 初始版本,支持基础语音合成和情感控制
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。