接入文档

鉴权方式、参数约束、错误码与多语言示例都在这里。控制台的 接入测试 页用同一份数据生成可以直接复制的示例。

鉴权

REST 请求带头:Authorization: Bearer <平台 API Key>; WebSocket 用查询参数:?token=<平台 API Key>。 密钥在控制台「密钥管理」生成,明文只在生成那一次显示。

错误码

codeHTTP含义
0200成功
1001401鉴权失败 / 密钥无效
1002402余额不足
1003429限流(QPS/并发超限)
1004404音色不存在
1005409音色状态不可用(克隆中 / 复刻失败 / 更新中 / 已删除)
1006400参数错误
1007502语音服务暂时不可用,可稍后重试;每次调用都有 X-Request-Id,凭它可以找我们查
1009409能力未开通:这条链路在平台上存在,但当前不对外开放(实时对话)
1010409音色额度不足:这个账户还没买额度(去 POST /v1/voices/quota,充值解决不了它)
1011503平台复刻资源已售罄,需要运营补充库存,客户重试无益
1012409幂等键冲突:同一个 Idempotency-Key 已经用过(内容不同),或上一张单还在处理中。钱没有扣第二次
1013503该能力暂未开放,请联系运营
2001500系统内部错误

判断失败要同时看 HTTP 状态码和响应体里的 code:异常时可能只有状态码、没有响应体。 音色相关的两个码分界清楚——voice_id 不存在或已删除回 1004;存在但当前不可用(克隆中 / 复刻失败 / 重训中)回 1005。

计量口径

  • 语音合成 / 流式合成(含双向流式 WebSocket):字数(char);以本次调用回执的字数为准(标点计入);没有回执时按去掉 Unicode 空白后的码点数计算
  • 流式识别:时长(ms);以识别结果回执的音频时长为准;没有回执时按已转发音频字节与声明参数折算
  • 录音文件识别:时长(ms);以转写结果回执的音频时长为准(不足 1 秒按 1 秒);提交、排队与失败都不计费
  • 语音转写(上传文件):时长(ms);以响应回执 X-Usage-Duration-Ms 为准(上游按整秒报回);回执缺失时由平台按容器自算并标 computed
  • 声音复刻:个数(count);按购买的音色额度计:购买那一次按份数计量,克隆与重训本身不再产生计费

语音合成 · 非流式 /v1/tts

POST · HTTP · 非流式合成,成功返回音频二进制,用量在响应头里回执。

字段类型必填说明
voice_idstring是平台音色 ID
textstring是待合成文本,字数口径见上方「计量口径」
formatstring否mp3 / wav / pcm,默认 mp3(先转小写再校验)
sample_rateint否8000 / 16000 / 24000,默认 24000
speed_ratiofloat否默认 1.0,范围 [0.1, 2]
volume_ratiofloat否默认 1.0,范围 [0.5, 2]
  • 响应头 X-Word-Count 是计费字数,与账单同源,客户端不要自行重算。
  • 语速与音量两维可调;没有音调参数,发 pitch_ratio 会被忽略。
  • 超范围一律 1006;音色不存在 1004;音色克隆中/失败/更新中 1005。
curl
curl -s /v1/tts \
  -H "Authorization: Bearer sk_live_你的平台密钥" \
  -H "Content-Type: application/json" \
  -d '{ "voice_id": "voice_xxxx", "text": "今天天气不错,适合出门走走。", "format": "mp3", "sample_rate": 24000, "speed_ratio": 1.0, "volume_ratio": 1.0 }' \
  --output speech.mp3 -w '%{http_code}\n'
Python
import requests

resp = requests.post(
    "/v1/tts",
    headers={"Authorization": "Bearer sk_live_你的平台密钥"},
    json={
  "voice_id": "voice_xxxx",
  "text": "今天天气不错,适合出门走走。",
  "format": "mp3",
  "sample_rate": 24000,
  "speed_ratio": 1.0,
  "volume_ratio": 1.0
},
    timeout=30,
)
resp.raise_for_status()
print("计费字数:", resp.headers.get("X-Word-Count"))
open("speech.mp3", "wb").write(resp.content)
Go
package main

import (
	"bytes"
	"io"
	"net/http"
	"os"
)

func main() {
	payload := []byte(`{
  "voice_id": "voice_xxxx",
  "text": "今天天气不错,适合出门走走。",
  "format": "mp3",
  "sample_rate": 24000,
  "speed_ratio": 1.0,
  "volume_ratio": 1.0
}`)
	req, _ := http.NewRequest("POST", "/v1/tts", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer sk_live_你的平台密钥")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		b, _ := io.ReadAll(res.Body)
		panic(string(b)) // {"code":1002,"message":"余额不足"} 之类的信封
	}
	audio, _ := io.ReadAll(res.Body)
	_ = os.WriteFile("speech.mp3", audio, 0o600)
	_, _ = io.WriteString(os.Stderr, "word_count="+res.Header.Get("X-Word-Count")+"\n")
}
Node / 浏览器
const res = await fetch('/v1/tts', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: 'Bearer sk_live_你的平台密钥',
  },
  body: JSON.stringify({ "voice_id": "voice_xxxx", "text": "今天天气不错,适合出门走走。", "format": "mp3", "sample_rate": 24000, "speed_ratio": 1.0, "volume_ratio": 1.0 }),
});
if (!res.ok) throw new Error(await res.text());
console.log('计费字数:', res.headers.get('x-word-count'));
await writeFile('speech.mp3', Buffer.from(await res.arrayBuffer()));

语音合成 · OpenAI 兼容入口 /v1/audio/speech

POST · HTTP · 这是什么:同一条语音合成链路的另一种「形状」——字段名换成 OpenAI 官方那四个(model / input / voice / response_format),成功响应照样是裸音频字节。已经写着 OpenAI TTS 的客户端(AstrBot、LangChain、SillyTavern 那批)改两行配置就能接,不需要我们教它新字段。它不是另一个引擎、也不是另一套价:与上面的 /v1/tts 同一条链路、同一个音色库、同一个计费分子。注意这条兼容路只有合成,识别那两款没有兼容入口。

字段类型必填说明
inputstring是待合成文本(= text)
voicestring是填本平台的音色 ID,不是 OpenAI 的 alloy / nova 那批名字
response_formatstring否只开放 mp3 / wav / pcm,默认 mp3;opus / aac / flac 会明确报错,不会悄悄换成别的格式
speedfloat否默认 1.0,范围 [0.1, 2];超出回 400,并在错误体里指出这个参数名
modelstring否收下但不参与路由与计费:我们按音色计费,不按模型计费
streambool否与不传等价(回同一份完整音频)。要边合成边播用 /v1/tts/stream
  • 计费与 /v1/tts 完全一致:同样按字数、同样回 X-Word-Count / X-Word-Count-Source,换端点不换分子。
  • 没有 sample_rate 与音量参数:采样率固定 24000,音量取默认 1.0。
  • 错误体是 OpenAI 的 {"error":{"message","type","param","code"}},但 HTTP 状态码与上面的错误码表一致(402/404/409/502);鉴权失败仍是统一信封。
curl(OpenAI 形状)
curl -s /v1/audio/speech \
  -H "Authorization: Bearer sk_live_你的平台密钥" \
  -H "Content-Type: application/json" \
  -d '{"model":"tts-1","input":"今天天气不错,适合出门走走。","voice":"voice_xxxx","response_format":"mp3","speed":1.0}' \
  --output speech.mp3

# 回执头与 /v1/tts 同源:curl -i 能看到 X-Word-Count / X-Word-Count-Source / X-Request-Id
Python(openai SDK,只改两行)
from openai import OpenAI

client = OpenAI(
    api_key="sk_live_你的平台密钥",          # 平台密钥,不是 OpenAI 的 sk-
    base_url="https://你的域名/v1",   # SDK 自己拼出 /audio/speech
)

with client.audio.speech.with_streaming_response.create(
    model="tts-1",                          # 收下但不参与计费与路由
    voice="voice_xxxx",                     # 平台音色 ID,不是 alloy / nova
    input="今天天气不错,适合出门走走。",
    response_format="mp3",                  # 只支持 mp3 / wav / pcm
) as r:
    r.save("speech.mp3")
AstrBot(内置 provider,零代码接入)
# AstrBot 的 TTS 里选内置的 openai_tts_api(源码里读的键名就是这几个),填:
#
#   api_base:  https://你的域名/v1     # SDK 拼成 {api_base}/audio/speech
#   api_key:   sk_live_你的平台密钥
#   model:     tts-1                  # 我们按音色计费,这个值不参与任何判断
#   openai-tts-voice:  voice_xxxx     # 注意:这里填平台音色 ID,不是 alloy / nova
#
# 它固定用 response_format="wav" 落盘,本服务的 wav 是真 wav(RIFF 头完整),直接播即可。
# 想换密钥或音色只改这两行;不必为它写任何适配代码。

语音合成 · 流式(SSE) /v1/tts/stream

POST · SSE · 请求体同语音合成,format 只支持 mp3 / pcm(wav 走非流式)。

字段类型必填说明
event: audiodata—{"chunk_id": 1, "data": "<base64>"}
event: donedata—{"status":"ok","word_count":42,"word_count_source":"upstream"}
event: errordata—{"code":1007,"message":"语音服务暂时不可用,请稍后重试"},之后不会再发 done
  • 计费按 done 事件的 word_count。
  • 出错以 error 收尾,已下发的分片仍计入用量。
curl(SSE)
curl -N -s /v1/tts/stream \
  -H "Authorization: Bearer sk_live_你的平台密钥" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -d '{"voice_id":"voice_xxxx","text":"流式合成一段话","format":"mp3","sample_rate":24000}'

# event: audio
# data: {"chunk_id":1,"data":"<base64>"}
# ...
# event: done
# data: {"status":"ok","word_count":11,"word_count_source":"upstream"}

语音合成 · 双向流式(WebSocket) /v1/tts/stream/ws?token=<平台 API Key>

GET(升级) · WebSocket · 这是什么:一边发文本、一边收音频的全双工合成。文本不必一次给全,可以分多段持续送;平台按句往下推音频分片,适合「文本本身在逐步产生」(大模型流式输出)或「第一句先播、后面的还在合成」的场景。一条连接只服务一次合成:finish / cancel / 断线即结束,要第二段请重连。时序固定:建连 → 5 秒内发 config → 发若干 text → 发 finish → 收完剩余音频与 done → 关闭。

字段类型必填说明
① 握手帧 {"type":"config","voice_id":"…","format":"mp3","sample_rate":24000}文本帧(UTF-8 JSON)是连接后 5 秒内必须到达,且必须是第一帧。voice_id 必填;缺 type / voice_id → 平台回 error(1006) 后关闭
formatstring否只开放 mp3 / pcm,默认 mp3;wav 请走非流式(1006)
sample_rateint否默认 24000,可填 8000 / 16000 / 22050 / 24000 / 32000 / 44100 / 48000,其它值 1006
speed_ratio / volume_ratiofloat否默认 1.0,范围 [0.1, 2] / [0.5, 2],与 REST 合成同口径
② {"type":"text","text":"…"}文本帧(上行)是(至少一段)可以发多段,平台按到达顺序转给上游。空文本直接 1006 并断开;finish / cancel 之后再发 text 同样 1006
② {"type":"finish"}文本帧(上行)否,但强烈建议表示「文本发完了」。平台停止接收文本,等上游把剩余音频与用量送完后回 done 再关闭。不发而直接断开也照样按已下发音频计费
② {"type":"cancel"}文本帧(上行)否中断本次合成。已经下发给客户端的音频照常计费,未下发的部分不计
③ 音频分片Binary(下行)—原始音频字节,不是 base64。按到达顺序直接写播放器 / 文件即可;分片边界不等于句子边界
③ {"type":"sentence_start","seq":1,"text":"…"}文本帧(下行)—一句开始合成。seq 在一条连接内递增,可用来发现丢帧;句级事件只用于展示进度,不影响计费
③ {"type":"sentence_end","seq":2}文本帧(下行)—同一句的音频已经推完(seq 紧接着 sentence_start)
③ {"type":"done","word_count":42,"word_count_source":"upstream","canceled":false}文本帧(下行)—正常结束。这三个字段永远显式存在(0 字、未取消也照样输出)。word_count 就是账单上的分子
③ {"type":"error","code":1007,"message":"…"}文本帧(下行)—错误之后连接关闭,不会再补 done。已经下发的音频仍按用量计入
  • 钱按字数(char),与 SSE 流式合成同一条价目行、同一套结算;建连时文本还没到,所以只做「付得起 1 个字」的余额闸,最终字数取 done.word_count,来源在 word_count_source(upstream / computed)。
  • 一个音频字节都没交给客户端就不计费;中途断开但已经下发过音频的,照常计费(客户端主动 cancel 也一样)。
  • 并发名额:每把密钥同时 4 条(ws.max_conns_per_key),与流式识别分开数 —— 多开合成不会把识别挡在门外。名额满了在协议升级之前返回 429 + 1003,不会先连上再踢。
  • 四条时限:config 握手 5 秒、两帧上行之间最长静默 60 秒、单帧上行 64 KiB 上限、finish 之后最多再等 5 秒收尾。客户端不发 finish 就沉默,60 秒后连接会被判超时关闭(已下发的音频照常计费)。
  • token 走查询参数(?token=),与 REST 的 Authorization: Bearer 同权。token 缺失 / 无效 / 被禁用:在握手完成前直接关闭,一帧都不会下发 —— 排查先看 token。
  • 和另外三条合成怎么挑:一次一段短文本、要整段音频 → 非流式;要边合成边播、文本已知 → SSE;文本在逐步产生、需要边发边播 → 这条;已有 OpenAI TTS 代码改两行 → OpenAI 兼容。
浏览器 WebSocket
// 一条连接 = 一次合成;文本可以分多段送,音频边收边播。
const ws = new WebSocket('ws://localhost:8080/v1/tts/stream/ws?token=sk_live_你的平台密钥');
ws.binaryType = 'arraybuffer';
const chunks = [];                       // 收完 done 后拼起来就是整段音频

ws.onopen = () => {
  // config 必须第一帧、5 秒内到达;format 只开放 mp3 / pcm
  ws.send(JSON.stringify({ type: 'config', voice_id: 'voice_xxxx', format: 'mp3', sample_rate: 24000 }));
  ws.send(JSON.stringify({ type: 'text', text: '第一句先合成。' }));
  ws.send(JSON.stringify({ type: 'text', text: '第二句接着来。' }));
  ws.send(JSON.stringify({ type: 'finish' }));   // 文本发完了,接下来只收
};

ws.onmessage = (ev) => {
  if (typeof ev.data !== 'string') {              // 二进制 = 原始音频分片(不是 base64)
    chunks.push(ev.data);
    return;
  }
  const f = JSON.parse(ev.data);
  if (f.type === 'sentence_start' || f.type === 'sentence_end') console.log(f.type, f.seq, f.text);
  if (f.type === 'done') console.log('计费字数', f.word_count, f.word_count_source, '取消', f.canceled);
  if (f.type === 'error') console.error(f.code, f.message);
};
Python(websockets)
# tts_duplex.py —— 边发文本边收音频;依赖:pip install websockets
import asyncio, json
import websockets

WS = "ws://localhost:8080/v1/tts/stream/ws?token=sk_live_你的平台密钥"

async def main():
    async with websockets.connect(WS) as ws:
        # config 必须第一帧、5 秒内到达
        await ws.send(json.dumps({
            "type": "config", "voice_id": "voice_xxxx", "format": "mp3", "sample_rate": 24000,
        }))
        for text in ["第一句先合成。", "第二句接着来。"]:
            await ws.send(json.dumps({"type": "text", "text": text}))
        await ws.send(json.dumps({"type": "finish"}))   # 文本发完了,接下来只收

        audio = bytearray()
        async for msg in ws:
            if isinstance(msg, bytes):       # 原始音频分片,直接追加
                audio.extend(msg)
                continue
            f = json.loads(msg)
            if f["type"] == "sentence_start":
                print("开始合成第", f["seq"], "句:", f.get("text", ""))
            elif f["type"] == "done":
                print("计费字数", f["word_count"], f["word_count_source"], "取消", f["canceled"])
                break
            elif f["type"] == "error":
                print("合成失败:", f["code"], f["message"])
                break
        open("speech.mp3", "wb").write(audio)

asyncio.run(main())

语音识别 · 双向流式(WebSocket) /v1/asr/stream?token=<平台 API Key>

GET(升级) · WebSocket · 这是什么:一边发音频、一边收结果的全双工识别。一条连接上同时有两个方向 —— 上行是二进制音频帧(客户端持续推),下行是识别结果帧(平台随时回)。适合麦克风实时采集、通话/直播转写、以及"手里只有一段本地音频、没有公网地址"的场景(按帧推到底就行)。时序固定:建连 → 5 秒内发 config → 连续推二进制帧(其间随时收 partial)→ 发 finish → 收 final → 关闭。

字段类型必填说明
① 握手帧 {"type":"config","audio":{…},"language":"zh"}文本帧(UTF-8 JSON)是连接后 5 秒内必须到达。晚到或字段非法 → 平台回一帧 error(1006) 然后关闭。这一帧声明的格式整条连接期间不可变更
audio.formatstring是pcm / wav / mp3 / ogg_opus。选 pcm 就是裸 16bit 小端字节流,最常见
audio.rateint是本期只承诺 16000,其它值 1006。IM 语音常见的 24k / 48k 请先重采样成 16k 单声道 16bit 再推
audio.bits / channel / codecint / int / string否默认 16 / 1 / raw(ogg_opus 配 codec:"opus")
languagestring否如 "zh"
② 上行音频帧Binary是(若干帧)只装音频字节。单帧不超过 65536 字节(超了连接直接被断,不是报错返回)。建议每帧 100–200ms:16k/16bit/单声道下 100ms = 3200 字节
② {"type":"finish"}文本帧否表示"我说完了"。平台据此给上游置末帧,收完最后一段结果后回 final 再关闭。不发就直接断开也可以,见下面的计费那条
③ {"type":"partial","seq":1,"text":"…"}文本帧(下行)—中间结果,会被后续覆盖。seq 是平台侧递增序号,客户端可用它发现丢帧
③ {"type":"final","seq":2,"text":"…","duration_ms":8040,"duration_source":"upstream"}文本帧(下行)—正常结束。`duration_ms` 就是账单上那个数,别自己按发送字节猜
③ {"type":"error","code":1007,"message":"…"}文本帧(下行)—错误之后连接关闭,已经识别出来的内容不再补发 `final`。客户端要把 error 当终态处理
  • 钱按音频时长:优先取上游回的时长(duration_source="upstream");上游没给(异常中断、没发 finish)就按已转发字节折算 duration_ms = 字节数 × 1000 / (rate × bits/8 × channel),此时 duration_source="computed"。两个来源金额完全一样,按秒向上取整、不足 1 秒按 1 秒(见「计量口径」)。
  • 客户端中途断开不是免费的:只要已经把音频转发出去过,那部分照常计入用量(一般落成 computed);一个字节都没转发的连接不计费。反过来说,把长长的静音段推上去也会照收 —— 静音前先停手。
  • 四条时限都是平台的会话参数(写死在配置里,不是"网络默认值"):config 握手 5 秒、两帧上行之间最长静默 60 秒、单帧下行写入 30 秒(客户端读得太慢会被算成断开)、finish 之后最多再等 5 秒拿最终结果。所以"实时节奏"得由客户端自己保住。
  • 并发名额:每把密钥同时 4 条长连接;控制台那张票按登录账户另数一份(也是 4)。满了在协议升级之前就返回 429 + 1003,不会先让你连上再踢 —— 客户端按"稍后重连"处理即可。
  • token 走查询参数(?token=),与 REST 的 Authorization: Bearer 同权,因此不做 Origin 白名单校验。token 缺失 / 无效 / 被禁用 / 账户被限制:在握手完成前直接关闭连接,一帧结果都不下发 —— 这就是"连上了但一秒都没收到帧"那种失败的样子,排查先看 token。
  • 一条协议两张票:/v1/asr/stream?token=<平台密钥>(扣密钥所属账户)与控制台的 /api/v1/try/asr/stream?token=<登录令牌>(扣当前登录账户,打点行 api_key_id=0)。帧格式、下行三种帧、时长口径逐字节一致(后端是同一个 handler),但两个 token 互不认账。
  • 三条识别怎么挑:麦克风/通话要边说边出字 → 这条;已经录好的整段长音频、还要字级时间戳 → 「录音文件识别」(异步、要一个我们能拉取的公网地址);只想"一段文件换一版全文"、不想自己写 WebSocket → 「语音转写 · 上传音频文件」。这条不需要公网地址(实测 6.312 秒音频按 100ms 一帧连续推到底,4.2 秒拿到 final,duration_ms=6312 / upstream,账单按 7 秒)。
  • 平台不转码、不重采样:音频字节原样搬运,只重写上游消息的封装头。所以 config 里声明的 rate/bits/channel 与实际字节必须对得上 —— 声明 16000 却推 48000 的数据,识别结果会是乱的而连接不会报错。
浏览器 WebSocket
const ws = new WebSocket('ws://localhost:8080/v1/asr/stream?token=sk_live_你的平台密钥');

ws.addEventListener('open', () => {
  // 5 秒内必须发 config,否则平台下发 error(1006) 后关闭连接
  ws.send(JSON.stringify({
    type: 'config',
    audio: { format: 'pcm', rate: 16000, bits: 16, channel: 1, codec: 'raw' },
    language: 'zh',
  }));
});

// 上行:每帧 100–200ms 的裸音频字节
// ws.send(pcmChunk);
// 结束:ws.send(JSON.stringify({ type: 'finish' }));

ws.addEventListener('message', (ev) => {
  const frame = JSON.parse(String(ev.data));
  if (frame.type === 'partial') console.log('中间结果', frame.text);
  if (frame.type === 'final') console.log('最终结果', frame.text, frame.duration_ms, frame.duration_source);
  if (frame.type === 'error') console.error(frame.code, frame.message);
});
Go(服务端最常见)
// 依赖只这一个:go get github.com/gorilla/websocket
package main

import (
	"fmt"
	"log"
	"os"
	"time"

	"github.com/gorilla/websocket"
)

// 下行三种帧(partial / final / error)共用一套字段,用 type 区分。
type frame struct {
	Type           string `json:"type"`
	Text           string `json:"text"`
	Seq            int    `json:"seq"`
	DurationMS     int64  `json:"duration_ms"`
	DurationSource string `json:"duration_source"`
	Code           int    `json:"code"`
	Message        string `json:"message"`
}

func main() {
	// token 走查询参数,与 REST 的 Authorization: Bearer 同权。
	conn, _, err := websocket.DefaultDialer.Dial(
		"ws://localhost:8080/v1/asr/stream?token=sk_live_你的平台密钥", nil)
	if err != nil {
		// token 缺失/无效/账户被限制时,连接在握手完成前就被关掉,看到的正是这一条
		log.Fatal("建连失败: ", err)
	}
	defer conn.Close()

	// 1) 5 秒内发 config;这里声明的格式整条连接期间不可变更。
	if err := conn.WriteJSON(map[string]any{
		"type": "config",
		"audio": map[string]any{
			"format": "pcm", "rate": 16000, "bits": 16, "channel": 1, "codec": "raw",
		},
		"language": "zh",
	}); err != nil {
		log.Fatal("发 config 失败: ", err)
	}

	// 先转成 16k/16bit/单声道:ffmpeg -i in.wav -f s16le -ar 16000 -ac 1 voice.pcm
	pcm, err := os.ReadFile("voice.pcm")
	if err != nil {
		log.Fatal(err)
	}

	go func() {
		// 2) 每帧 3200 字节 = 100ms;单帧上限 65536 字节。
		for off := 0; off < len(pcm); off += 3200 {
			end := off + 3200
			if end > len(pcm) {
				end = len(pcm)
			}
			if err := conn.WriteMessage(websocket.BinaryMessage, pcm[off:end]); err != nil {
				return
			}
			time.Sleep(20 * time.Millisecond) // 略快于实时就行;两帧之间最长静默 60 秒
		}
		conn.WriteJSON(map[string]string{"type": "finish"}) // 3) 说「我说完了」
	}()

	// 4) 一路读到 final 或 error。error 之后不会再补 final,连接随即关闭。
	for {
		var f frame
		if err := conn.ReadJSON(&f); err != nil {
			log.Println("连接结束: ", err)
			return
		}
		switch f.Type {
		case "partial":
			fmt.Println("中间结果:", f.Text)
		case "final":
			// duration_ms 就是账单上那个数,别自己按发送字节猜
			fmt.Printf("最终结果: %s\n计费时长: %dms(来源 %s)\n", f.Text, f.DurationMS, f.DurationSource)
			return
		case "error":
			log.Fatalf("识别失败: %d %s", f.Code, f.Message)
		}
	}
}
本地音频推流(Python,IM 机器人这类只有文件的客户端)
# push_asr.py —— 把一份本地 PCM 当流推给识别,拿回整句文本。
# 依赖:pip install websockets
# 音频先转成 16k / 16bit / 单声道的裸 PCM(QQ 的 silk、Telegram 的 ogg 都走这一步,在 shell 里执行):
#   ffmpeg -i voice.wav -f s16le -ar 16000 -ac 1 voice.pcm
import asyncio, json, sys
import websockets

WS = "ws://localhost:8080/v1/asr/stream?token=sk_live_你的平台密钥"

async def main():
    data = open(sys.argv[1], "rb").read()
    async with websockets.connect(WS) as ws:
        await ws.send(json.dumps({
            "type": "config",
            "audio": {"format": "pcm", "rate": 16000, "bits": 16, "channel": 1, "codec": "raw"},
            "language": "zh",
        }))

        async def pump():
            for i in range(0, len(data), 3200):        # 3200 字节 = 100ms
                await ws.send(data[i:i + 3200])
            await ws.send(json.dumps({"type": "finish"}))

        async def read():
            while True:
                frame = json.loads(await ws.recv())
                if frame["type"] == "final":
                    print(frame["text"], frame["duration_ms"], frame["duration_source"])
                    return
                if frame["type"] == "error":
                    print("识别失败:", frame["code"], frame["message"])
                    return

        await asyncio.gather(pump(), read())

asyncio.run(main())
# python push_asr.py voice.pcm

语音识别 · 录音文件(异步) /v1/asr/file · /v1/asr/file/{task_id}

POST / GET · HTTP · 异步整段转写:提交一个公网可达的音频地址拿任务号,再用任务号轮询到终态。适合会议、通话录音这类长音频;实时或边录边出用上面那条流式识别。

字段类型必填说明
audio_urlstring是http / https,识别侧要能直接拉到;平台不下载、不转存这份音频
formatstring是mp3 / wav / ogg / raw(无头 PCM)
Idempotency-Keyheader否,但强烈建议带任务号就由它生成:同一把键重发只会得到同一个任务,不会受理两次、也不会扣两次钱
task_idstring(路径)查询时必填就是提交回执里那个 task_id
  • 这条只认地址:audio_url 必须是识别侧能直接拉到的 http/https。程序里只有一段本地文件的(IM 机器人最常见)用上面那条流式识别推流,不要在这里绕。
  • 提交只回 status: pending,这时候还没有产生任何费用;结果与时长都在轮询那条里。
  • 查询返回 status:pending / processing / succeeded / silent / failed。silent(整段没人说话)是成功的终态而不是失败,并且不计费。
  • 结果带分句与字级时间戳(utterances[].words[]),原样透出,平台不做二次加工。
  • 钱在完成那一刻按识别结果报回的时长产生;不足 1 秒按 1 秒。受理、排队、失败都不计费。
  • 地址过期或改成私有读会让任务失败 —— 那一次同样不扣钱,但也不会替你重试。
curl(提交 + 轮询)
# ① 提交:只拿任务号,这一步不产生费用
curl -s /v1/asr/file \
  -H "Authorization: Bearer sk_live_你的平台密钥" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: my-meeting-001" \
  -d '{"audio_url":"https://example.com/meeting.mp3","format":"mp3"}'

# {"code":0,"message":"ok","data":{"task_id":"my-meeting-001","status":"pending","replayed":false}}
#   ↑ 任务号就是上面那把键(后端拿它当发给识别侧的请求号)。
#     同一个键重发只会得到同一个任务,不会受理两次、也不会扣两次钱。

# ② 轮询:跑完才有 text 与计费时长
curl -s /v1/asr/file/my-meeting-001 \
  -H "Authorization: Bearer sk_live_你的平台密钥"

# {"code":0,"data":{"task_id":"my-meeting-001","status":"succeeded",
#  "duration_ms":6312,"text":"……",
#  "utterances":[{"start_time":480,"end_time":5920,"text":"……",
#                 "words":[{"start_time":480,"end_time":600,"text":"…"}]}]}}
#   status: pending / processing / succeeded / silent(整段没人说话,成功但不计费)/ failed
#   账单上的时长取这里回传的 duration_ms,不足 1 秒按 1 秒;受理与失败都不计费。

语音转写 · 上传音频文件 /v1/audio/transcriptions

POST · HTTP · 这是什么:把一段音频文件本身交给我们,一次请求拿回整段文本(OpenAI 的 audio/transcriptions 形状,写着它的客户端改一个 base_url 就能用)。为什么单独有一条:另外两款都要不到这个形状 —— 录音文件识别要一个我们能拉取的公网地址,流式识别要你自己写 WebSocket 且吃实时连接名额。IM 机器人收到的语音、手机录的音、本地文件,都走这条。

字段类型必填说明
fileform(二进制)是只支持 wav 与 mp3;单个文件不超过 10 MB;采样率不限(48k / 24k / 16k 都直接收,平台不转码)
modelstring否收下但不参与路由与计费:我们按音频时长计费,不按模型计费
languagestring否auto / zh / en,默认 auto(自动检测)。明确语种时建议填上,识别更准
streambool否true 时以 SSE 逐段回增量(data: {"delta":"…"}),最后一条 event: done 带全文与时长回执,再以 data: [DONE] 收尾
response_formatstring否只开放 json(默认,{"text":"…"})与 text;verbose_json / srt / vtt 明确 400 —— 那几种要字级时间戳,这条给不出来,不会悄悄换成 json
  • 计费按音频时长,与流式识别同一个能力、同一行价目、同一条结算;分子取响应回执 X-Usage-Duration-Ms(来源在 X-Usage-Duration-Source),不要自己按文件大小猜。
  • 这条不收 `Idempotency-Key`:一次请求一次结果,重发就是重新识别一次、重新扣一次钱。需要「同一笔只扣一次」的语义请用录音文件识别那条(任务号即幂等键)。
  • 音频经过平台内存(与录音文件识别「平台不下载不转存」相反),所以体积有硬上限;超了当场 400,不会先收下来再去问上游。
  • 长音频(几十分钟以上)请用录音文件识别那条异步链路:这条是同步等待,占的是你自己一个请求。
  • 实时边说边出字用流式识别(WebSocket);这条是「已经录完了再转」。
curl(multipart 上传)
curl -s /v1/audio/transcriptions \
  -H "Authorization: Bearer sk_live_你的平台密钥" \
  -F "file=@voice.wav" \
  -F "language=zh"

# {"text":"刚刚还在想你……"}
#   回执头:X-Usage-Duration-Ms / X-Usage-Duration-Source / X-Request-Id
#   只要纯文本:加一个 -F "response_format=text"
#   逐段出字(长音频想看进度):加一个 -F "stream=true",响应变成 SSE
Python(requests,后端最常见)
import requests

with open("voice.wav", "rb") as f:
    r = requests.post(
        "/v1/audio/transcriptions",
        headers={"Authorization": "Bearer sk_live_你的平台密钥"},
        files={"file": ("voice.wav", f, "audio/wav")},
        data={"language": "zh"},
        timeout=60,
    )
r.raise_for_status()
print(r.json()["text"])
# 账单上的时长从这里取,别按文件大小自己算:
print(r.headers["X-Usage-Duration-Ms"], r.headers["X-Usage-Duration-Source"])
AstrBot(内置 openai_whisper_api,零代码)
# AstrBot 的 STT 提供商选内置的 openai_whisper_api,配置项就是它源码里读的那几个:
#
#   provider_type:  openai_whisper_api
#   api_base:  https://你的域名/v1     # 它自己拼出 {api_base}/audio/transcriptions
#   api_key:   sk_live_你的平台密钥
#   model:     任意值即可(本服务按音频时长计费,这个值不参与路由与计费)
#   timeout:   60
#
# 然后在「STT 设置」里把 provider_id 指到上面这条、enable 打开即可。
# 它收到语音后自己落成本地 wav(QQ 的 silk、Telegram 的 ogg 都由它转),
# 再把字节 multipart 传给我们 —— 采样率不用你管,wav / mp3 直接收。

声音复刻 · 克隆音色 /v1/voices/clone · /v1/voices/quota · /v1/voices/{id} · /v1/voices · DELETE /v1/voices/{id}

POST / GET / DELETE · HTTP · 先买音色额度(POST /v1/voices/quota),再上传参考音频换音色 ID;状态机 cloning → active,到期重训为 updating(旧版仍可用)。

字段类型必填说明
countint(JSON)买额度必填买几个音色额度,1~20;这一笔按下单时刻生效的单价计入用量
Idempotency-Keyheader(1~64 位 [A-Za-z0-9-_:.])否,但强烈建议带买额度是全平台唯一「点一下就真扣钱」的写操作。同一把键只成交一次:重放会回原单的 count / total_milli 并带 replayed=true;同键换数量或换人回 409/1012
namestring(form)是音色名称(重克隆那个端点不收这个字段)
audiofile(form)是wav / mp3 / m4a,约 10 秒 ~ 1 分钟,单人清晰无背景音
  • 请求只收 name 与 audio 两个字段,多余的字段回 1006。
  • 克隆的前置条件是额度:没有可用额度时 /v1/voices/clone 当场回 1010,音色不会被创建。
  • POST /v1/voices/{voice_id}/reclone 用一份新音频原地覆盖同一个音色:不占新额度、不收钱,消耗的是这个音色的重克隆次数;期间音色是 updating(旧版仍可合成),失败则旧版本继续服务。
  • GET /v1/voices/quota 给 purchased / available / in_use 和每条音色还剩几次重克隆(attempts_left 以平台最近一次同步为准)。
  • 买额度的回执里 total_milli 是这一单的钱(厘),unit_price_cents 是当时的单价(分 / 个)——报价口径没变,记账单位下沉了一位。
  • 超时、双击、网关重发都可能让同一个意图到达两次。带 Idempotency-Key 时第二次是同一张单的回放(replayed=true,不再收钱);不带键时两次都会成交,所以买额度务必带上这把键。
  • 一份额度换一个专属音色:克隆失败不再追加计费;删掉音色后额度仍归你,下次克隆落回同一个槽位。
  • 已删除的音色不再出现在列表与详情中,对其调用一律 1004。
curl(multipart 复刻 + 查进度)
curl -s /v1/voices/clone \
  -H "Authorization: Bearer sk_live_你的平台密钥" \
  -F "name=我的播报音色" \
  -F "audio=@reference.wav"

# {"code":0,"message":"ok","data":{"voice_id":"voice_xxxx","status":"cloning","created_at":"..."}}
curl -s /v1/voices/voice_xxxx -H "Authorization: Bearer sk_live_你的平台密钥"