# 音色转换 API 接入教程

> 接口地址：`https://api.hihookeji.com/api/clonevoice/convertvoice`  
> 返回格式：`application/json`  
> 请求方式：`HTTP POST`

---

## 一、接口概述

本接口用 **目标音色**（`source_audio_url`）对 **待变声音频**（`target_audio_url`）做音色转换，异步提交后通过 `notify_url` 以 **wav 文件流** 回调结果。

| 项目 | 说明 |
|------|------|
| 接口地址 | `https://api.hihookeji.com/api/clonevoice/convertvoice` |
| 认证方式 | 请求体字段 `key`（你的 API 密钥，与业务参数一并提交） |
| 请求示例 | `https://api.hihookeji.com/api/clonevoice/convertvoice` |
| Content-Type | `application/json`（推荐）或 `application/x-www-form-urlencoded` |
| 业务判断 | 统一用响应字段 **`code`**：`200` 成功，失败常见为 `500` |
| 时长限制 | 待变声音频（`target_audio_url`）长度 **不超过 5 分钟** |

---

## 二、请求参数

| 参数名 | 类型 | 必填 | 描述 | 示例 |
|--------|------|------|------|------|
| key | string | 是 | API 密钥，放在请求体中与其它参数一起提交 | `你的密钥` |
| source_audio_url | string | 是 | **目标音色**参考音频链接（公网可访问；建议 mp3/wav，URL 以 `.mp3` 或 `.wav` 结尾） | `https://a.b.c/1.mp3` |
| target_audio_url | string | 是 | **需要变声**的音频链接，长度 **不超过 5 分钟** | `https://a.b.c/2.mp3` |
| notify_url | string | 是 | 合成结果通知地址（需公网可达） | 见下方「回调测试」 |

**回调测试**：联调异步回调时，建议先打开 [https://webhook.site/](https://webhook.site/) 获取自己的唯一 URL，将该地址填入 `notify_url`。提交任务后可在网页上直接查看回调字段与返回的 wav 文件，无需先部署自己的回调服务。

> 注意参数含义：`source_audio_url` 是音色参考，`target_audio_url` 是要被改成该音色的内容音频，勿填反。

---

## 三、返回结果说明

### 3.1 任务提交成功

立即返回任务 ID 与扣点信息，处理完成后通过 `notify_url` 回调。请自行保存 `data.taskid`。

| 字段 | 说明 |
|------|------|
| code | `200` 表示任务正确提交 |
| msg | 说明信息，如 `ok` |
| data.taskid | 任务 ID |
| data.consume | 本次扣除点数 |

```json
{
  "code": 200,
  "data": {
    "taskid": "172733860562215209765004",
    "consume": 800
  },
  "msg": "ok"
}
```

### 3.2 任务提交失败

```json
{
  "code": 500,
  "msg": "提取的类型：source_audio_url不能为空"
}
```

### 3.3 异步回调 Notify

服务端合成完成后，向 `notify_url` 以 **multipart/form-data** 推送（表单字段 + **wav 文件流**），**不是** JSON Body。

| 字段 | 说明 |
|------|------|
| errcode | **`0` 成功**；**`1001` 失败** |
| taskid | 任务 ID，与提交返回一致 |
| target_file | 转换后的 **wav** 音频文件流 |

示意（字段层面；文件在 multipart 的 `target_file`）：

```json
{
  "errcode": 0,
  "taskid": "172733860562215209765004"
}
```

> PHP 可用 `$_FILES['target_file']` 接收；Flask 用 `request.files['target_file']`。

---

## 四、接入流程建议

1. 准备两段公网可访问音频：音色参考（`source_audio_url`）+ 待变声内容（`target_audio_url`，≤5 分钟）。
2. 申请 API Key，作为请求体字段 `key` 与其它参数一起提交。
3. 填入 `notify_url`；联调可用 [webhook.site](https://webhook.site/) 唯一地址。
4. 提交成功后保存 `data.taskid`，核对 `data.consume` 扣点。
5. 回调 `errcode=0` 时保存 `target_file` 为本地 wav。

---

## 五、Python 完整代码

依赖：`requests`（`pip install requests`）

```python
# -*- coding: utf-8 -*-
"""
音色转换 API - Python 完整示例
接口: https://api.hihookeji.com/api/clonevoice/convertvoice
"""

import json
import os

import requests

API_URL = "https://api.hihookeji.com/api/clonevoice/convertvoice"
API_KEY = "你的密钥"  # 替换为真实密钥


def convert_voice(
    source_audio_url,
    target_audio_url,
    notify_url,
    timeout=60,
):
    """
    提交音色转换任务（异步）。

    :param source_audio_url: 目标音色参考音频 URL
    :param target_audio_url: 需要变声的音频 URL（≤5 分钟）
    :param notify_url: 结果回调地址
    """
    payload = {
        "key": API_KEY,
        "source_audio_url": source_audio_url,
        "target_audio_url": target_audio_url,
        "notify_url": notify_url,
    }
    resp = requests.post(
        API_URL,
        json=payload,
        headers={"Content-Type": "application/json"},
        timeout=timeout,
    )
    resp.raise_for_status()
    return resp.json()


# ---------------------------------------------------------------------------
# Flask 异步回调接收示例（可选）
# 安装: 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():
        taskid = request.form.get("taskid")
        errcode = request.form.get("errcode")  # 成功 0，失败 1001

        if str(errcode) == "0" and "target_file" in request.files:
            f = request.files["target_file"]
            if f and f.filename:
                ext = os.path.splitext(f.filename)[1] or ".wav"
                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,
            "errcode": errcode,
        })

    return app


if __name__ == "__main__":
    result = convert_voice(
        source_audio_url="https://a.b.c/1.mp3",
        target_audio_url="https://a.b.c/2.mp3",
        notify_url="https://webhook.site/你的唯一ID",
    )
    print("任务提交:", json.dumps(result, ensure_ascii=False, indent=2))
    # 成功时 code==200，记录 data.taskid / data.consume

    # ---------- 启动回调服务（需要时取消注释）----------
    # app = create_notify_app()
    # app.run(host="0.0.0.0", port=8080)
```

---

## 六、PHP 完整代码

需开启 `curl` 扩展。

```php
<?php
/**
 * 音色转换 API - PHP 完整示例
 * 接口: https://api.hihookeji.com/api/clonevoice/convertvoice
 */

define('API_URL', 'https://api.hihookeji.com/api/clonevoice/convertvoice');
define('API_KEY', '你的密钥'); // 替换为真实密钥

/**
 * 提交音色转换任务
 *
 * @param array $params
 * @param int   $timeout
 * @return array
 * @throws Exception
 */
function convertVoice(array $params, $timeout = 60)
{
    $payload = [
        'key'              => API_KEY,
        'source_audio_url' => $params['source_audio_url'],
        'target_audio_url' => $params['target_audio_url'],
        'notify_url'       => $params['notify_url'],
    ];

    $ch = curl_init(API_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;
}

try {
    $result = convertVoice([
        'source_audio_url' => 'https://a.b.c/1.mp3',
        'target_audio_url' => 'https://a.b.c/2.mp3',
        'notify_url'       => 'https://webhook.site/你的唯一ID',
    ]);
    echo "任务提交:\n" . json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
} catch (Exception $e) {
    echo '错误: ' . $e->getMessage() . "\n";
}

/*
======================== notify.php 异步回调接收 ========================
<?php
header('Content-Type: application/json; charset=utf-8');

$saveDir = __DIR__ . '/callback_audio';
if (!is_dir($saveDir)) {
    mkdir($saveDir, 0755, true);
}

$taskid  = isset($_POST['taskid']) ? $_POST['taskid'] : null;
$errcode = isset($_POST['errcode']) ? $_POST['errcode'] : null;

if ((string)$errcode === '0'
    && !empty($_FILES['target_file']['tmp_name'])
    && is_uploaded_file($_FILES['target_file']['tmp_name'])) {
    $ext = pathinfo($_FILES['target_file']['name'], PATHINFO_EXTENSION);
    $ext = $ext ? ('.' . $ext) : '.wav';
    $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,
    'errcode' => $errcode,
], JSON_UNESCAPED_UNICODE);
*/
```

---

## 七、Java 完整代码

依赖：JDK 8+，使用内置 `HttpURLConnection`，无需第三方库。

```java
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;

/**
 * 音色转换 API - Java 完整示例
 * 接口: https://api.hihookeji.com/api/clonevoice/convertvoice
 */
public class ConvertVoiceDemo {

    private static final String API_URL = "https://api.hihookeji.com/api/clonevoice/convertvoice";
    private static final String API_KEY = "你的密钥"; // 替换为真实密钥

    public static class ConvertRequest {
        public String sourceAudioUrl; // 目标音色
        public String targetAudioUrl; // 需要变声的音频（≤5 分钟）
        public String notifyUrl;
    }

    public static String convertVoice(ConvertRequest req, int timeoutMs) throws IOException {
        URL url = new URL(API_URL);
        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 httpCode = conn.getResponseCode();
        InputStream is = (httpCode >= 200 && httpCode < 300)
                ? conn.getInputStream()
                : conn.getErrorStream();
        String resp = readFully(is);
        conn.disconnect();
        return resp;
    }

    private static String toJson(ConvertRequest 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_audio_url\":").append(quote(r.targetAudioUrl)).append(",");
        sb.append("\"notify_url\":").append(quote(r.notifyUrl));
        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 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);
    }

    /*
     * 回调 Servlet：multipart 字段 target_file（wav 文件流）
     * 表单: errcode(0成功/1001失败), taskid
     */

    public static void main(String[] args) throws Exception {
        ConvertRequest req = new ConvertRequest();
        req.sourceAudioUrl = "https://a.b.c/1.mp3";
        req.targetAudioUrl = "https://a.b.c/2.mp3";
        req.notifyUrl = "https://webhook.site/你的唯一ID";

        System.out.println("任务提交: " + convertVoice(req, 60000));
        // 成功时 code==200，记录 data.taskid / data.consume
    }
}
```

---

## 八、常见问题

| 问题 | 处理建议 |
|------|----------|
| 音色没变 / 结果不对 | 确认未填反：`source_audio_url`=目标音色，`target_audio_url`=要变声的内容 |
| 待变声超时/失败 | `target_audio_url` 长度须 **≤ 5 分钟** |
| 音频链接失败 | 两段 URL 均需公网可访问；建议 mp3/wav，且以 `.mp3` / `.wav` 结尾；OSS 鉴权长链无后缀易失败 |
| 只看 HTTP 200 | 不够，必须判断响应体 `code == 200` |
| 异步收不到回调 | 确认 `notify_url` 公网可达、支持 multipart；联调可用 [webhook.site](https://webhook.site/) |
| 回调当成 JSON 解析失败 | 回调是 multipart：`target_file` 文件流 + 表单字段 |
| 回调成功却判失败 | 回调成功 `errcode` 为 **`0`**，失败为 `1001` |
| 点数疑问 | 提交成功响应中的 `data.consume` 为本次扣点 |

---

## 九、快速对照

| 项目 | 说明 |
|------|------|
| 提交 | `key` / `source_audio_url`（目标音色）/ `target_audio_url`（待变声，≤5 分钟）/ `notify_url` |
| 立即响应 | `code:200` + `data.taskid` + `data.consume` |
| 成果获取 | `notify_url` 回调 multipart：`target_file`（wav 文件流） |
