# 声音克隆 API V3 接入教程

> 在线效果体验：https://ai.hihookeji.com  
> 接口地址：`https://api.hihookeji.com/api/tts/clonevoicev3`  
> 返回格式：`application/json`  
> 请求方式：`HTTP POST`

---

## 一、接口概述

本接口根据一段 **5～14 秒** 的参考音频（建议 MP3）与指定文案，克隆音色并合成目标文本语音。

| 项目 | 说明 |
|------|------|
| 接口地址 | `https://api.hihookeji.com/api/tts/clonevoicev3` |
| 认证方式 | 请求体字段 `key`（你的 API 密钥，与业务参数一并提交） |
| 请求示例 | `https://api.hihookeji.com/api/tts/clonevoicev3` |
| Content-Type | `application/json`（推荐）或 `application/x-www-form-urlencoded` |
| 业务判断 | 统一用响应字段 **`code`**：`200` 成功 |
| HTTP 状态码 | 请以响应体 `code` 为准 |



---

## 二、请求参数

| 参数名 | 类型 | 必填 | 描述 | 示例 |
|--------|------|------|------|------|
| key | string | 是 | API 密钥，放在请求体中与其它参数一起提交 | `你的密钥` |
| source_audio_url | string | 是 | 参考音频公网 URL，须为标准 **mp3 / wav**，且 URL **以 `.mp3` 或 `.wav` 结尾**；时长 **5～14 秒**；音频中读的文案必须为下方指定文案 | `https://a.b.c/1.mp3` |
| target_text | string | 是 | 要合成的目标文本，单次不超过 **2000** 字；超过 **1000** 字建议用异步（type=1） | `我是克隆的文字` |
| notify_url | string | 是 | 合成结果通知地址（异步回调接收地址，需公网可达） | 见下方「回调测试」 |
| ref_text | string | 是 | 参考音频文案，必须为下方两种之一 | 见下方说明 |
| speed | float | 否 | 语速，默认 `1.0` | `1` |
| pitch | float | 否 | 语调，默认 `1.0` | `1` |
| type | int | 否 | 结果返回类型，默认异步：`1` 异步文件回调；`2` 同步返回 Base64 音频 | `1` |
| mtype | int | 否 | `1` 单人；`2` 多角色 | `1` |

**回调测试**：联调异步回调时，建议先打开 [https://webhook.site/](https://webhook.site/) 获取自己的唯一 URL，将该地址填入 `notify_url`。提交任务后可在网页上直接查看服务端推送的表单字段与 `target_file` 文件，无需先部署自己的回调服务。

---

## 三、参考音频文案（必须严格一致）

`source_audio_url` 对应音频中念出的内容，以及参数 `ref_text`，**必须**与下列文案完全一致，否则克隆会失败：

1. 中文：`我的声音将用于平台克隆，并合法使用，为自己的行为负责`
2. 英文：`My voice will be used for platform cloning , and I take responsibility for my actions`

---

## 四、文本标签能力

可在 `target_text` 中使用下列标签。

### 4.1 停顿标签

支持秒（s）与毫秒（ms），单次停顿 **最大不超过 10 秒**，否则失败。

```text
如果<break time="5s"/>每天都是全新的一天。
<break time="50ms"/>表示停顿50毫秒
```

### 4.2 多音字 / 发音矫正

```text
每天<phoneme ph="gan 4">早</phoneme>起照镜子都崩溃。
多音字：<phoneme ph="chong 3">重</phoneme>要处理。
```

> `ph` 中音标与声调之间有空格，如 `wan 3`、`gan 4`、`chong 3`。

### 4.3 读法标签（say-as）

| interpret-as | 含义 |
|--------------|------|
| number | 数字读法 |
| time | 时间读法 |
| date | 日期读法 |
| value | 数值读法 |
| telephone | 手机号 / 电话读法 |

### 4.4 完整示例

```text
金额<say-as interpret-as='value'>123436511.562254</say-as>，
数值是<say-as interpret-as='number'>-1204455</say-as>，
停顿5秒<break time="5s"/>，
多音字：<phoneme ph='chong 3'>重</phoneme>要处理。
现在的时间是：<say-as interpret-as='time'>14:05</say-as>，
Today is <say-as interpret-as='date'>25-02-01</say-as>.
我的电话: <say-as interpret-as='telephone'>(888) 555-1212</say-as>
```

---

## 五、返回结果说明

### 5.1 异步提交成功（type=1）

立即返回任务受理结果；`data.audio_base64` 为 `null`，音频通过 `notify_url` 回调。

| 字段 | 说明 |
|------|------|
| code | 业务状态码，`200` 成功 |
| msg | 说明信息 |
| data.taskid | 任务 ID，回调关联用 |
| data.consume | 消耗点数 |
| data.audio_base64 | 异步为 `null` |
| exec_time | 接口耗时（秒） |
| ip | 请求方 IP |

```json
{
  "code": 200,
  "msg": "success",
  "data": {
    "taskid": "1784017039696108",
    "consume": 20,
    "audio_base64": null
  },
  "exec_time": 0.097585,
  "ip": "14.19.93.116"
}
```

### 5.2 同步返回（type=2）

同步成功时 `data.audio_base64` 为音频 Base64 字符串。**必须先 Base64 解码再保存为文件。**

```json
{
  "code": 200,
  "msg": "success",
  "data": {
    "taskid": "1784017039913816",
    "consume": 20,
    "audio_base64": "SUQzBAAAAAALFVRTo......="
  },
  "exec_time": 0.936593,
  "ip": "14.19.93.116"
}
```

### 5.3 异步回调 Notify（type=1）

服务端合成完成后，以 **表单 POST** 向 `notify_url` 推送（成功带文件时为 `multipart/form-data`，失败多为 `application/x-www-form-urlencoded`），**不是** JSON Body。

| 字段 | 说明 |
|------|------|
| errcode | **`0` 成功**；**`1001` 失败**（与接口提交响应的 `code: 200` 不同，回调请按此处判断） |
| taskid | 任务 ID |
| msg | 说明文案 |
| target_file | 音频文件（mp3/wav），PHP 可用 `$_FILES['target_file']` 接收；失败时无此字段 |

---

## 六、接入流程建议

1. 准备合规参考音频（5～14 秒，MP3），念上述指定文案之一，公网可访问 URL。
2. 申请 API Key，作为请求体字段 `key` 与其它参数一起提交。
3. 字数 ≤1000 且需立即拿结果：可用 `type=2` 同步；字数较多或需稳定投递：用 `type=1` 异步。联调回调时可先用 [webhook.site](https://webhook.site/) 获取唯一接收地址填入 `notify_url`。
4. 异步场景下用 `data.taskid` 关联提交与回调结果。
5. 同步场景对 `data.audio_base64` 解码后落盘。

---

## 七、Python 完整代码

依赖：`requests`（`pip install requests`）

```python
# -*- coding: utf-8 -*-
"""
声音克隆 API V3 - Python 完整示例
接口: https://api.hihookeji.com/api/tts/clonevoicev3
"""

import os
import base64
import json
from datetime import datetime

import requests

API_URL = "https://api.hihookeji.com/api/tts/clonevoicev3"
API_KEY = "你的密钥"  # 替换为真实密钥

# 参考音频文案（必须与音频内容一致，二选一）
REF_TEXT_CN = "我的声音将用于平台克隆，并合法使用，为自己的行为负责"
REF_TEXT_EN = (
    "My voice will be used for platform cloning , "
    "and I take responsibility for my actions"
)


def clone_voice(
    source_audio_url,
    target_text,
    notify_url,
    ref_text=REF_TEXT_CN,
    speed=1.0,
    pitch=1.0,
    result_type=1,
    mtype=1,
    timeout=120,
):
    """
    调用声音克隆 V3 接口。

    :param source_audio_url: 参考音频公网 URL（mp3，5~14 秒）
    :param target_text: 目标合成文本（≤2000 字）
    :param notify_url: 异步回调地址（type=1 必填且需可公网访问）
    :param ref_text: 参考音频文案（必须为指定文案）
    :param speed: 语速，默认 1.0
    :param pitch: 语调，默认 1.0
    :param result_type: 1=异步回调，2=同步 Base64
    :param mtype: 1=单人，2=多角色
    :param timeout: HTTP 超时秒数（同步建议调大）
    :return: 接口 JSON 字典
    """
    url = API_URL
    payload = {
        "key": API_KEY,
        "source_audio_url": source_audio_url,
        "target_text": target_text,
        "notify_url": notify_url,
        "ref_text": ref_text,
        "speed": speed,
        "pitch": pitch,
        "type": result_type,
        "mtype": mtype,
    }


    resp = requests.post(
        url,
        json=payload,
        headers={"Content-Type": "application/json"},
        timeout=timeout,
    )
    resp.raise_for_status()
    return resp.json()


def save_base64_audio(result, save_dir="./output"):
    """
    将 type=2 同步返回的 Base64 音频解码并保存为 mp3。
    """
    if int(result.get("code") or 0) != 200:
        raise RuntimeError(f"合成失败: {result.get('msg')}")

    data = result.get("data") or {}
    audio_b64 = data.get("audio_base64")
    if not audio_b64:
        raise RuntimeError("响应中缺少 data.audio_base64")

    os.makedirs(save_dir, exist_ok=True)
    audio_bytes = base64.b64decode(audio_b64)
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    taskid = data.get("taskid") or timestamp
    save_path = os.path.join(save_dir, f"{taskid}.mp3")
    with open(save_path, "wb") as f:
        f.write(audio_bytes)
    return save_path


# ---------------------------------------------------------------------------
# Flask 异步回调接收示例（可选，部署到公网后作为 notify_url）
# 安装: pip install flask
# ---------------------------------------------------------------------------
def create_notify_app(save_dir="./callback_audio"):
    from flask import Flask, request, jsonify

    app = Flask(__name__)
    os.makedirs(save_dir, exist_ok=True)

    @app.route("/Notify", methods=["POST"])
    def notify():
        # 回调为表单字段（有文件时是 multipart），不是 JSON Body
        taskid = request.form.get("taskid")
        msg = request.form.get("msg")
        errcode = request.form.get("errcode")  # 成功 0，失败 1001

        if "target_file" in request.files:
            f = request.files["target_file"]
            if f and f.filename:
                ext = os.path.splitext(f.filename)[1] or ".mp3"
                path = os.path.join(save_dir, f"{taskid}{ext}")
                f.save(path)
                return jsonify({"ok": True, "taskid": taskid, "saved": path, "errcode": errcode})

        return jsonify({"ok": False, "taskid": taskid, "msg": msg, "errcode": errcode})

    return app


if __name__ == "__main__":
    # ---------- 示例 1：异步提交（type=1）----------
    async_result = clone_voice(
        source_audio_url="https://a.b.c/1.mp3",
        target_text='你好，这是克隆语音。<break time="1s"/>今天天气不错。',
        notify_url="https://webhook.site/你的唯一ID",
        ref_text=REF_TEXT_CN,
        result_type=1,
        mtype=1,
    )
    print("异步提交:", json.dumps(async_result, ensure_ascii=False, indent=2))
    # 成功时 code==200，记录 data.taskid；回调可在 webhook.site 页面查看

    # ---------- 示例 2：同步返回 Base64（type=2）----------
    sync_result = clone_voice(
        source_audio_url="https://a.b.c/1.mp3",
        target_text="我是克隆的文字",
        notify_url="https://webhook.site/你的唯一ID",  # 同步模式仍按文档传 notify_url
        ref_text=REF_TEXT_CN,
        result_type=2,
        timeout=180,
    )
    print("同步响应 code:", sync_result.get("code"), "type:", type(sync_result.get("code")), "msg:", sync_result.get("msg"))
    print("data keys:", list((sync_result.get("data") or {}).keys()))
    print("audio_base64 是否有值:", bool((sync_result.get("data") or {}).get("audio_base64")))
    if int(sync_result.get("code") or 0) == 200:
        try:
            path = save_base64_audio(sync_result)
            print("音频已保存:", path)
        except Exception as e:
            print("保存失败:", repr(e))
            print("完整 data:", json.dumps(sync_result.get("data"), ensure_ascii=False)[:500])

    # ---------- 启动回调服务（需要时取消注释）----------
    # app = create_notify_app()
    # app.run(host="0.0.0.0", port=8080)
```

---

## 八、PHP 完整代码

需开启 `curl` 扩展。回调示例可直接作为独立 `notify.php` 部署。

```php
<?php
/**
 * 声音克隆 API V3 - PHP 完整示例
 * 接口: https://api.hihookeji.com/api/tts/clonevoicev3
 */

define('API_URL', 'https://api.hihookeji.com/api/tts/clonevoicev3');
define('API_KEY', '你的密钥'); // 替换为真实密钥

define('REF_TEXT_CN', '我的声音将用于平台克隆，并合法使用，为自己的行为负责');
define('REF_TEXT_EN', 'My voice will be used for platform cloning , and I take responsibility for my actions');

/**
 * 调用声音克隆 V3
 *
 * @param array $params 业务参数
 * @param int   $timeout 超时秒数
 * @return array
 * @throws Exception
 */
function cloneVoice(array $params, $timeout = 120)
{
    $url = API_URL;

    $payload = [
        'key'              => API_KEY,
        'source_audio_url' => $params['source_audio_url'],
        'target_text'      => $params['target_text'],
        'notify_url'       => $params['notify_url'],
        'ref_text'         => isset($params['ref_text']) ? $params['ref_text'] : REF_TEXT_CN,
        'speed'            => isset($params['speed']) ? $params['speed'] : 1.0,
        'pitch'            => isset($params['pitch']) ? $params['pitch'] : 1.0,
        'type'             => isset($params['type']) ? (int)$params['type'] : 1,
        'mtype'            => isset($params['mtype']) ? (int)$params['mtype'] : 1,
    ];


    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
        CURLOPT_POSTFIELDS     => json_encode($payload, JSON_UNESCAPED_UNICODE),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => $timeout,
        CURLOPT_SSL_VERIFYPEER => true,
    ]);

    $body = curl_exec($ch);
    if ($body === false) {
        $err = curl_error($ch);
        curl_close($ch);
        throw new Exception('请求失败: ' . $err);
    }
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $result = json_decode($body, true);
    if (!is_array($result)) {
        throw new Exception("响应非 JSON，HTTP={$httpCode}, body={$body}");
    }
    return $result;
}

/**
 * 保存 type=2 返回的 Base64 音频
 *
 * @param array  $result 接口响应
 * @param string $saveDir 目录
 * @return string 文件路径
 * @throws Exception
 */
function saveBase64Audio(array $result, $saveDir = './output')
{
    if (!isset($result['code']) || (int)$result['code'] !== 200) {
        $msg = isset($result['msg']) ? $result['msg'] : 'unknown';
        throw new Exception('合成失败: ' . $msg);
    }
    if (empty($result['data']['audio_base64'])) {
        throw new Exception('响应中缺少 data.audio_base64');
    }

    if (!is_dir($saveDir) && !mkdir($saveDir, 0755, true)) {
        throw new Exception('无法创建目录: ' . $saveDir);
    }

    $bytes = base64_decode($result['data']['audio_base64'], true);
    if ($bytes === false) {
        throw new Exception('Base64 解码失败');
    }

    $name = !empty($result['data']['taskid']) ? $result['data']['taskid'] : date('Ymd_His');
    $path = rtrim($saveDir, '/\\') . DIRECTORY_SEPARATOR . $name . '.mp3';
    if (file_put_contents($path, $bytes) === false) {
        throw new Exception('写入文件失败: ' . $path);
    }
    return $path;
}

// ======================== 调用示例 ========================

try {
    // 示例 1：异步提交 type=1
    $asyncResult = cloneVoice([
        'source_audio_url' => 'https://a.b.c/1.mp3',
        'target_text'      => '你好，这是克隆语音。<break time="1s"/>今天天气不错。',
        'notify_url'       => 'https://webhook.site/你的唯一ID',
        'ref_text'         => REF_TEXT_CN,
        'type'             => 1,
        'mtype'            => 1,
    ]);
    echo "异步提交:\n" . json_encode($asyncResult, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";

    // 示例 2：同步 Base64 type=2
    $syncResult = cloneVoice([
        'source_audio_url' => 'https://a.b.c/1.mp3',
        'target_text'      => '我是克隆的文字',
        'notify_url'       => 'https://webhook.site/你的唯一ID',
        'ref_text'         => REF_TEXT_CN,
        'type'             => 2,
        'mtype'            => 1,
    ], 180);

    echo "同步 code: " . $syncResult['code'] . ", msg: " . $syncResult['msg'] . "\n";
    if ((int)$syncResult['code'] === 200) {
        $path = saveBase64Audio($syncResult);
        echo "音频已保存: {$path}\n";
    }
} catch (Exception $e) {
    echo '错误: ' . $e->getMessage() . "\n";
}

/*
======================== notify.php 异步回调接收 ========================
将下方代码单独保存为 notify.php，部署到公网，地址填入 notify_url。

<?php
header('Content-Type: application/json; charset=utf-8');

$saveDir = __DIR__ . '/callback_audio';
if (!is_dir($saveDir)) {
    mkdir($saveDir, 0755, true);
}

// 回调为表单字段（有文件时是 multipart），不是 JSON Body
$taskid  = isset($_POST['taskid']) ? $_POST['taskid'] : null;
$msg     = isset($_POST['msg']) ? $_POST['msg'] : null;
$errcode = isset($_POST['errcode']) ? $_POST['errcode'] : null; // 成功 0，失败 1001

if (!empty($_FILES['target_file']['tmp_name']) && is_uploaded_file($_FILES['target_file']['tmp_name'])) {
    $name = $_FILES['target_file']['name'];
    $ext  = pathinfo($name, PATHINFO_EXTENSION);
    $ext  = $ext ? ('.' . $ext) : '.mp3';
    $path = $saveDir . '/' . ($taskid ?: uniqid('task_', true)) . $ext;
    move_uploaded_file($_FILES['target_file']['tmp_name'], $path);
    echo json_encode(['ok' => true, 'taskid' => $taskid, 'saved' => $path, 'errcode' => $errcode], JSON_UNESCAPED_UNICODE);
    exit;
}

echo json_encode([
    'ok'      => false,
    'taskid'  => $taskid,
    'msg'     => $msg,
    'errcode' => $errcode,
], JSON_UNESCAPED_UNICODE);
*/
```

---

## 九、Java 完整代码

依赖：JDK 8+，使用内置 `HttpURLConnection`，无需第三方库。  
若使用 Maven，也可自行替换为 OkHttp / Apache HttpClient。

```java
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Base64;

/**
 * 声音克隆 API V3 - Java 完整示例
 * 接口: https://api.hihookeji.com/api/tts/clonevoicev3
 *
 * 说明：为减少依赖，本示例用字符串拼 JSON，并用简单方式解析 code / audio_base64。
 * 生产环境建议改用 Jackson / Gson 等库。
 */
public class CloneVoiceV3Demo {

    private static final String API_URL = "https://api.hihookeji.com/api/tts/clonevoicev3";
    private static final String API_KEY = "你的密钥"; // 替换为真实密钥

    public static final String REF_TEXT_CN =
            "我的声音将用于平台克隆，并合法使用，为自己的行为负责";
    public static final String REF_TEXT_EN =
            "My voice will be used for platform cloning , and I take responsibility for my actions";

    public static class CloneRequest {
        public String sourceAudioUrl;
        public String targetText;
        public String notifyUrl;
        public String refText = REF_TEXT_CN;
        public double speed = 1.0;
        public double pitch = 1.0;
        public int type = 1;      // 1 异步，2 同步 Base64
        public int mtype = 1;     // 1 单人，2 多角色
    }

    /**
     * 调用声音克隆 V3
     */
    public static String cloneVoice(CloneRequest req, int timeoutMs) throws IOException {
        String urlStr = API_URL;
        URL url = new URL(urlStr);
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod("POST");
        conn.setConnectTimeout(15000);
        conn.setReadTimeout(timeoutMs);
        conn.setDoOutput(true);
        conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
        conn.setRequestProperty("Accept", "application/json");

        String json = toJson(req);
        byte[] body = json.getBytes(StandardCharsets.UTF_8);
        conn.setRequestProperty("Content-Length", String.valueOf(body.length));
        try (OutputStream os = conn.getOutputStream()) {
            os.write(body);
        }

        int code = conn.getResponseCode();
        InputStream is = (code >= 200 && code < 300)
                ? conn.getInputStream()
                : conn.getErrorStream();
        String resp = readFully(is);
        conn.disconnect();
        return resp;
    }

    /**
     * 将 type=2 的 audio_base64 解码保存为 mp3
     */
    public static Path saveBase64Audio(String responseJson, String saveDir) throws IOException {
        int code = extractInt(responseJson, "code");
        if (code != 200) {
            String msg = extractString(responseJson, "msg");
            throw new IOException("合成失败: " + msg);
        }
        String audioB64 = extractNestedString(responseJson, "audio_base64");
        if (audioB64 == null || audioB64.isEmpty() || "null".equals(audioB64)) {
            throw new IOException("响应中缺少 data.audio_base64");
        }

        Path dir = Paths.get(saveDir);
        if (!Files.exists(dir)) {
            Files.createDirectories(dir);
        }
        byte[] audioBytes = Base64.getDecoder().decode(audioB64);
        String taskid = extractNestedString(responseJson, "taskid");
        String name = (taskid != null && !taskid.isEmpty())
                ? taskid + ".mp3"
                : LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + ".mp3";
        Path path = dir.resolve(name);
        Files.write(path, audioBytes);
        return path;
    }

    // --------------------- JSON 构造 / 简易解析 ---------------------

    private static String toJson(CloneRequest r) {
        StringBuilder sb = new StringBuilder();
        sb.append("{");
        sb.append("\"key\":").append(quote(API_KEY)).append(",");
        sb.append("\"source_audio_url\":").append(quote(r.sourceAudioUrl)).append(",");
        sb.append("\"target_text\":").append(quote(r.targetText)).append(",");
        sb.append("\"notify_url\":").append(quote(r.notifyUrl)).append(",");
        sb.append("\"ref_text\":").append(quote(r.refText)).append(",");
        sb.append("\"speed\":").append(r.speed).append(",");
        sb.append("\"pitch\":").append(r.pitch).append(",");
        sb.append("\"type\":").append(r.type).append(",");
        sb.append("\"mtype\":").append(r.mtype);
        
        sb.append("}");
        return sb.toString();
    }

    private static String quote(String s) {
        if (s == null) {
            return "null";
        }
        String escaped = s
                .replace("\\", "\\\\")
                .replace("\"", "\\\"")
                .replace("\n", "\\n")
                .replace("\r", "\\r")
                .replace("\t", "\\t");
        return "\"" + escaped + "\"";
    }

    private static String urlEncode(String s) {
        try {
            return java.net.URLEncoder.encode(s, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            return s;
        }
    }

    private static String readFully(InputStream is) throws IOException {
        if (is == null) {
            return "";
        }
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        byte[] buf = new byte[4096];
        int n;
        while ((n = is.read(buf)) != -1) {
            bos.write(buf, 0, n);
        }
        return new String(bos.toByteArray(), StandardCharsets.UTF_8);
    }

    private static int extractInt(String json, String key) {
        String v = extractString(json, key);
        try {
            return Integer.parseInt(v);
        } catch (Exception e) {
            return -1;
        }
    }

    private static String extractString(String json, String key) {
        String pattern = "\"" + key + "\"";
        int i = json.indexOf(pattern);
        if (i < 0) {
            return null;
        }
        int colon = json.indexOf(':', i + pattern.length());
        if (colon < 0) {
            return null;
        }
        int start = colon + 1;
        while (start < json.length() && Character.isWhitespace(json.charAt(start))) {
            start++;
        }
        if (start >= json.length()) {
            return null;
        }
        if (json.charAt(start) == '"') {
            int end = start + 1;
            StringBuilder sb = new StringBuilder();
            while (end < json.length()) {
                char c = json.charAt(end);
                if (c == '\\' && end + 1 < json.length()) {
                    sb.append(json.charAt(end + 1));
                    end += 2;
                    continue;
                }
                if (c == '"') {
                    break;
                }
                sb.append(c);
                end++;
            }
            return sb.toString();
        }
        int end = start;
        while (end < json.length()) {
            char c = json.charAt(end);
            if (c == ',' || c == '}' || c == ']') {
                break;
            }
            end++;
        }
        return json.substring(start, end).trim();
    }

    private static String extractNestedString(String json, String key) {
        return extractString(json, key);
    }

    // --------------------- 异步回调 Servlet 示例（可选） ---------------------
    /*
     * 若使用 Servlet 容器，可参考下列伪代码：
     *
     * @WebServlet("/Notify")
     * public class NotifyServlet extends HttpServlet {
     *   protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
     *     // 表单字段: taskid / msg / errcode(成功0,失败1001)
     *     // multipart: Part file = req.getPart("target_file");
     *   }
     * }
     */

    public static void main(String[] args) throws Exception {
        // 示例 1：异步 type=1
        CloneRequest asyncReq = new CloneRequest();
        asyncReq.sourceAudioUrl = "https://a.b.c/1.mp3";
        asyncReq.targetText = "你好，这是克隆语音。<break time=\"1s\"/>今天天气不错。";
        asyncReq.notifyUrl = "https://webhook.site/你的唯一ID";
        asyncReq.refText = REF_TEXT_CN;
        asyncReq.type = 1;
        asyncReq.mtype = 1;

        String asyncResp = cloneVoice(asyncReq, 60000);
        System.out.println("异步提交: " + asyncResp);

        // 示例 2：同步 type=2
        CloneRequest syncReq = new CloneRequest();
        syncReq.sourceAudioUrl = "https://a.b.c/1.mp3";
        syncReq.targetText = "我是克隆的文字";
        syncReq.notifyUrl = "https://webhook.site/你的唯一ID";
        syncReq.refText = REF_TEXT_CN;
        syncReq.type = 2;
        syncReq.mtype = 1;

        String syncResp = cloneVoice(syncReq, 180000);
        System.out.println("同步响应: " + syncResp);
        if (extractInt(syncResp, "code") == 200) {
            Path path = saveBase64Audio(syncResp, "./output");
            System.out.println("音频已保存: " + path.toAbsolutePath());
        }
    }
}
```

---

## 十、常见问题

| 问题 | 处理建议 |
|------|----------|
| 克隆效果异常 / 失败 | 检查参考音频是否念指定文案，且 `ref_text` 与音频内容完全一致 |
| 参考音频格式不对 | `source_audio_url` 必须是标准 **mp3** 或 **wav**；提示失败时可先用 ffmpeg 转码后再上传，例如：`ffmpeg -i 输入文件 -acodec libmp3lame -ar 44100 输出.mp3` |
| 参考音频链接后缀不对 | URL 必须以 **`.mp3` 或 `.wav` 结尾**；常见 OSS 带鉴权参数的长链接（不以 `.mp3`/`.wav` 结尾）会失败，需换成可访问且后缀正确的直链，或转存后再提交 |
| 参考音频时长 | 必须在 5～14 秒；URL 需公网可访问 |
| 长文本 | 超过 1000 字建议 `type=1` 异步；单次最多 2000 字 |
| 停顿过长 | `<break>` 单次最大 10 秒 |
| 只看 HTTP 200 | 不够，必须判断响应体 `code == 200` |
| 同步音频无法播放 | 对 `data.audio_base64` **解码后再写文件**，不要直接把 Base64 字符串当二进制保存 |
| 异步收不到回调 | 确认 `notify_url` 公网可达、支持 POST；联调可先用 [webhook.site](https://webhook.site/) 唯一地址验证是否已推送；正式环境再换成自己的接收服务 |
| 回调当成 JSON 解析失败 | 回调是表单 POST，用 form/`$_POST` 取值，不要按 JSON Body 解析 |
| 回调成功却判失败 | 回调成功 `errcode` 为 **`0`**（不是提交接口的 `code: 200`），失败为 `1001` |
| 旧接口标签无效 | 请改用本教程新地址 `.../clonevoicev3` |

---

## 十一、快速对照

| 模式 | type | 立即响应 | 音频获取方式 |
|------|------|----------|--------------|
| 异步 | 1（默认） | `code:200`，`data.taskid`，`audio_base64: null` | `notify_url` 回调 multipart 文件 `target_file` |
| 同步 | 2 | `code:200`，`data.audio_base64` 有值 | 本地 Base64 解码落盘 |
