AI 语音通话接口
提供两种 AI 语音对话模式:
- WebRTC 实时语音模式:基于 WebRTC 协议的实时音视频通话,低延迟双向对话,适合长时间连续语音对话场景
- REST 模式:基于 HTTP 的单轮对话,每次上传一段音频,返回 AI 语音回复,适合非连续的对话场景
认证方式
所有接口均通过请求头中的 X-API-Key 进行认证。
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| X-API-Key | string | 是 | 应用密钥 |
注意:X-API-Key 用于验证账户信息,进入 控制台 → 项目空间,选择对应项目,左侧导航进入「API Key 管理」即可创建/查看。
WebRTC 实时语音模式
接入流程:
客户端 ──HTTP──► /v1/audio/luna/start ──► 拿到 livekit_url + token + bot_identity
│
▼
客户端 ──WebRTC── livekit-client(SDK) ────► wss://rtc.vuilabs.cn 入会
│
▼
AI Bot 自动入会 + 双向音频流
│
▼
客户端 ──每 2s POST──► /v1/audio/luna/heartbeat (保活 + 探测应否结束)
│
通话结束 ──POST──► /v1/audio/luna/stop (结算扣费)
1. 开启对话
创建实时语音房间并获取入会 Token,AI Bot 会自动加入房间。
并发支持:同一 API Key 支持多路并发通话,可同时为多个用户提供 AI 服务。每路通话具有独立的 session_id 和 RTC 房间,分别计费,互不干扰。默认不限制并发数,请根据应用需要控制并发。
- 接口路径:
/v1/audio/luna/start - 请求方式:
POST
请求参数
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| timbre | string | 否 | 角色 ID(默认 default)(联系运营获取可用音色 ID 列表) |
| client_nonce | string | 是 | 客户端生成的唯一标识(UUID),用于幂等去重(30 秒内同 nonce 返回同一会话) |
| language | string | 否 | 语言,zh(默认)或 en |
| sample_rate | int | 否 | 上传音频的采样率,默认自动协商 |
| previous_session_id | string | 否 | 上次会话 ID,带上则尝试接续历史对话上下文(网络断开重连场景) |
请求示例
curl -X POST "https://api.vuilabs.cn/v1/audio/luna/start" \
-H "X-API-Key: your-secret-key-here" \
-H "Content-Type: application/json" \
-d '{
"timbre": "qinyao",
"client_nonce": "550e8400-e29b-41d4-a716-446655440000",
"language": "zh"
}'
响应参数
| 参数名 | 类型 | 说明 |
|---|---|---|
| error_code | number | 错误码,0 表示成功 |
| error_message | string | 错误信息,成功时为空 |
| succeed | boolean | 请求是否成功 |
| data.session_id | string | 会话 ID,后续 heartbeat / stop 接口必传 |
| data.livekit_url | string | 实时语音服务地址(wss://...),客户端 SDK 入会用 |
| data.room_name | string | 房间名称 |
| data.identity | string | 当前用户在房间中的身份标识 |
| data.token | string | 入会 Token(JWT,2 小时有效期) |
| data.bot_identity | string | AI Bot 在房间中的身份标识(用于客户端识别 Bot 音轨) |
| data.expire_at | number | Token 过期时间(Unix 秒) |
成功响应示例
{
"error_code": 0,
"error_message": "",
"succeed": true,
"data": {
"session_id": "8f918356376b4fd2b2e0fe22965c1d0c",
"livekit_url": "wss://rtc.vuilabs.cn",
"room_name": "8f918356376b4fd2b2e0fe22965c1d0c",
"identity": "user_f267d71682ac49f2",
"token": "eyJhbGciOiJIUzI1NiIs...",
"bot_identity": "agent-luna-AJ_exnxZ",
"expire_at": 1778167601
}
}
2. 心跳保活
通话期间请每 2 秒发送一次心跳以保持会话,并通过响应判断是否需要结束通话(如余额耗尽或超时)。
- 接口路径:
/v1/audio/luna/heartbeat - 请求方式:
POST
重要:心跳停止超过 30 秒,会话将自动结束,并按已通话时长扣费。
请求参数
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| session_id | string | 是 | start 接口返回的会话 ID |
请求示例
curl -X POST "https://api.vuilabs.cn/v1/audio/luna/heartbeat" \
-H "X-API-Key: your-secret-key-here" \
-H "Content-Type: application/json" \
-d '{
"session_id": "8f918356376b4fd2b2e0fe22965c1d0c"
}'
响应参数
| 参数名 | 类型 | 说明 |
|---|---|---|
| error_code | number | 错误码,0 表示成功 |
| error_message | string | 错误信息,成功时为空 |
| succeed | boolean | 请求是否成功 |
| data.should_close | boolean | 是否需要结束通话(余额耗尽 / 超时 / 异常) |
| data.remaining_seconds | number | 剩余可通话秒数(基于余额估算) |
| data.elapsed_seconds | number | 已通话秒数 |
成功响应示例
{
"error_code": 0,
"error_message": "",
"succeed": true,
"data": {
"should_close": false,
"remaining_seconds": 1503,
"elapsed_seconds": 45
}
}
客户端处理建议:
should_close=true时立刻调用/stop接口结束会话并向用户提示原因(余额不足等)。
3. 结束对话
通话结束时调用此接口以关闭房间、结束与 AI Bot 的通话,并按通话时长结算扣费。接口幂等 — 重复调用返回同一结果,不会重复扣费。
- 接口路径:
/v1/audio/luna/stop - 请求方式:
POST
通话结束后请务必调用此接口。如果应用异常退出未调用,会话将在心跳超时(30 秒)后自动结算。
请求参数
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| session_id | string | 是 | start 接口返回的会话 ID |
| reason | string | 否 | 结束原因,如 user_stop(用户主动)、tab_closed(关闭页面)、balance_exhausted(余额耗尽)。仅用于日志/统计 |
请求示例
curl -X POST "https://api.vuilabs.cn/v1/audio/luna/stop" \
-H "X-API-Key: your-secret-key-here" \
-H "Content-Type: application/json" \
-d '{
"session_id": "8f918356376b4fd2b2e0fe22965c1d0c",
"reason": "user_stop"
}'
响应参数
| 参数名 | 类型 | 说明 |
|---|---|---|
| error_code | number | 错误码,0 表示成功 |
| error_message | string | 错误信息,成功时为空 |
| succeed | boolean | 请求是否成功 |
| data.total_sec | number | 本次通话总秒数 |
| data.total_billed | number | 实际扣费(微元,1 元 = 1,000,000 微元)。免费池抵扣的部分计入 0 |
成功响应示例
{
"error_code": 0,
"error_message": "",
"succeed": true,
"data": {
"total_sec": 45,
"total_billed": 0
}
}
4. Web SDK 接入(livekit-client)
拿到 start 接口返回的 livekit_url + token 后,使用官方 livekit-client 加入房间。
安装依赖
npm install livekit-client
# 或 pnpm / yarn
pnpm add livekit-client
完整接入示例
<!DOCTYPE html>
<html>
<head>
<title>VUI Labs Luna 实时对话</title>
</head>
<body>
<button id="startBtn">开始通话</button>
<button id="stopBtn" disabled>结束通话</button>
<div id="status"></div>
<script type="module">
import { Room, RoomEvent, Track } from 'https://cdn.skypack.dev/livekit-client@2.5.0';
const API_KEY = 'your-secret-key-here';
const API_BASE = 'https://api.vuilabs.cn';
let room = null;
let sessionId = null;
let heartbeatTimer = null;
const statusEl = document.getElementById('status');
const setStatus = (s) => { statusEl.textContent = s; };
document.getElementById('startBtn').onclick = async () => {
setStatus('正在开启会话...');
// 1. 调用 start 接口拿入会 URL + token
const startResp = await fetch(`${API_BASE}/v1/audio/luna/start`, {
method: 'POST',
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
timbre: 'qinyao',
client_nonce: crypto.randomUUID(),
language: 'zh',
}),
}).then(r => r.json());
if (!startResp.succeed) {
setStatus(`失败: ${startResp.error_message}`);
return;
}
const { livekit_url, token, bot_identity } = startResp.data;
sessionId = startResp.data.session_id;
// 2. 创建 Room
room = new Room({ adaptiveStream: true, dynacast: true });
// 监听 AI Bot 的音频(根据 bot_identity 过滤)
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind !== Track.Kind.Audio) return;
if (participant.identity !== bot_identity) return;
const audioEl = track.attach();
audioEl.autoplay = true;
document.body.appendChild(audioEl);
});
room.on(RoomEvent.Disconnected, () => {
setStatus('已断开');
cleanup();
});
// 3. 入会 + 开麦
await room.connect(livekit_url, token);
await room.localParticipant.setMicrophoneEnabled(true);
setStatus('通话中...');
document.getElementById('startBtn').disabled = true;
document.getElementById('stopBtn').disabled = false;
// 4. 心跳(每 2s)
heartbeatTimer = setInterval(async () => {
const hb = await fetch(`${API_BASE}/v1/audio/luna/heartbeat`, {
method: 'POST',
headers: { 'X-API-Key': API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId }),
}).then(r => r.json());
if (hb.data?.should_close) {
setStatus('通话需要结束(余额不足或超时)');
await endCall('balance_exhausted');
}
}, 2000);
};
document.getElementById('stopBtn').onclick = () => endCall('user_stop');
// 关闭页面时尝试 stop(避免会话泄漏)
// 用 fetch + keepalive 而非 sendBeacon — keepalive 允许跨 unload 发请求且支持 X-API-Key header
// (sendBeacon 不能加自定义 header,会丢 API Key 鉴权)
window.addEventListener('pagehide', () => {
if (!sessionId) return;
fetch(`${API_BASE}/v1/audio/luna/stop`, {
method: 'POST',
headers: { 'X-API-Key': API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, reason: 'tab_closed' }),
keepalive: true,
}).catch(() => { /* 页面卸载时请求失败,会话将在心跳超时后自动结束并结算 */ });
});
async function endCall(reason) {
if (!sessionId) return;
const sid = sessionId;
cleanup();
setStatus('正在结束...');
const resp = await fetch(`${API_BASE}/v1/audio/luna/stop`, {
method: 'POST',
headers: { 'X-API-Key': API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sid, reason }),
}).then(r => r.json());
setStatus(`已结束,通话 ${resp.data?.total_sec ?? 0}s,扣费 ${resp.data?.total_billed ?? 0} 微元`);
}
function cleanup() {
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
if (room) { room.disconnect(); room = null; }
sessionId = null;
document.getElementById('startBtn').disabled = false;
document.getElementById('stopBtn').disabled = true;
}
</script>
</body>
</html>
关键注意事项
- 浏览器自动播放限制:首次连接前必须有用户点击事件,否则
audio.play()会被拦截。本示例的"开始通话"按钮已满足此要求。 - 心跳间隔:推荐 2 秒一次,心跳停止超过 30 秒会自动结束会话。
- stop 必调:页面关闭/刷新前用
fetch+keepalive: true发送 stop 请求(允许跨页面卸载且支持自定义 header),不要用navigator.sendBeacon— sendBeacon 不能加自定义 header,会丢 X-API-Key 鉴权。未调用 stop 时,会话将在心跳超时(30 秒)后自动结算。为及时结束通话并结算,请主动调用。 - AI 音轨识别:start 接口返回的
bot_identity是 AI Bot 在房间中的标识,客户端通过它过滤出 Bot 的音频流(避免播放回声等其他流)。 - 媒体面端口:实时语音媒体面优先走 UDP
50000-60000,严格 NAT 网络下 SDK 自动 fallback 到 TCP7881,无需额外配置。
REST 模式
REST 模式提供流式对话:
- 流式对话:WebSocket 双向通信,边录边传,首字响应 < 3 秒(推荐实时对话使用)
推荐:实时语音通话场景优先选 WebRTC 实时语音模式;REST 流式对话适合不需要持续双工的场景(如一问一答、长输入推理)。
5. 创建对话会话
创建一个 REST 对话会话,返回 session_id。每次对话请求需携带 session_id 以维持上下文连续性。
- 接口路径:
/api/v1/audio/sessions - 请求方式:
POST
请求参数
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| voice_id | string | 是 | 角色 ID(联系运营获取可用音色 ID 列表) |
请求示例
curl -X POST "https://api.vuilabs.cn/api/v1/audio/sessions" \
-H "X-API-Key: your-secret-key-here" \
-H "Content-Type: application/json" \
-d '{
"voice_id": "qinyao"
}'
响应参数
| 参数名 | 类型 | 说明 |
|---|---|---|
| error_code | number | 错误码,0 表示成功 |
| error_message | string | 错误信息,成功时为空 |
| succeed | boolean | 请求是否成功 |
| data.session_id | string | 会话 ID,后续对话请求中使用 |
| data.voice_id | string | 角色 ID |
| data.created_at | number | 会话创建时间(Unix 秒) |
成功响应示例
{
"error_code": 0,
"error_message": "",
"succeed": true,
"data": {
"session_id": "sess_xxxxxxxx",
"voice_id": "qinyao",
"created_at": 1743696000
}
}
6. 删除对话会话
删除指定会话。接口幂等,会话不存在时同样返回成功。
- 接口路径:
/api/v1/audio/sessions/:session_id - 请求方式:
DELETE
请求示例
curl -X DELETE "https://api.vuilabs.cn/api/v1/audio/sessions/sess_xxxxxxxx" \
-H "X-API-Key: your-secret-key-here"
响应参数
| 参数名 | 类型 | 说明 |
|---|---|---|
| error_code | number | 错误码,0 表示成功 |
| error_message | string | 错误信息,成功时为空 |
| succeed | boolean | 请求是否成功 |
| data.deleted | boolean | 是否已删除(幂等,会话不存在时也返回 true) |
成功响应示例
{
"error_code": 0,
"error_message": "",
"succeed": true,
"data": {
"deleted": true
}
}
7. 流式对话
通过 WebSocket 建立双向流式通信,边录音边发送音频,并实时接收识别结果和语音回复,首字响应 < 3 秒。适合实时语音通话、AI 助手对话等场景。
- 接口路径:
/api/v1/audio/conversation/stream - 通信协议:WebSocket
- 鉴权方式:URL 参数
api_key
连接示例
// 建立 WebSocket 连接(api_key 通过 URL 参数传递)
const ws = new WebSocket(
"wss://api.vuilabs.cn/api/v1/audio/conversation/stream?api_key=your-secret-key-here"
);
通信流程
1. 发送元信息(JSON 文本消息)
连接建立后,首先发送会话元信息:
ws.send(JSON.stringify({
meta: {
session_id: "sess_xxxxxxxx", // 可选,不传则自动创建
voice_id: "qinyao", // 必填,角色 ID
audio_format: "pcm16", // 音频格式:pcm16(16kHz, 16bit, 单声道)
language: "zh" // 语言,默认 zh
}
}));
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| session_id | string | 否 | 会话 ID,不传则自动创建 |
| voice_id | string | 是 | 角色 ID(联系运营获取可用音色 ID 列表) |
| audio_format | string | 否 | 音频格式,固定为 pcm16(16kHz, 16bit, 单声道) |
| language | string | 否 | 语言,zh(默认)或 en |
2. 发送音频流(二进制消息)
客户端边录边传,实时发送音频片段:
// 录音并发送音频片段(每 100ms 发送一次)
navigator.mediaDevices.getUserMedia({ audio: true })
.then(stream => {
const mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
ws.send(e.data); // 发送二进制音频片段
}
};
mediaRecorder.start(100); // 每 100ms 触发一次
});
输入音频格式:PCM 16bit, 16kHz, 单声道(通过 WebSocket BinaryMessage 发送原始 PCM 数据)
3. 发送结束标记(可选)
用户停止录音时发送结束标记:
ws.send(JSON.stringify({ is_final: true }));
推送事件
通过 WebSocket 接收以下事件:
session_created — 会话创建成功
| 字段 | 类型 | 说明 |
|---|---|---|
| type | string | "session_created" |
| session_id | string | 会话 ID(若未传则自动创建) |
{"type":"session_created","session_id":"sess_auto_12345"}
asr_partial — 实时语音识别中间结果
边说话边推送识别中间文本,实时反馈用户输入。
| 字段 | 类型 | 说明 |
|---|---|---|
| type | string | "asr_partial" |
| asr_text | string | 当前识别到的文本(可能不完整) |
{"type":"asr_partial","asr_text":"你好"}
asr_final — 语音识别最终结果
用户说完一句话后推送最终识别文本。
| 字段 | 类型 | 说明 |
|---|---|---|
| type | string | "asr_final" |
| asr_text | string | 最终识别文本 |
{"type":"asr_final","asr_text":"你好,我是"}
llm_first_token — LLM 首字生成
大模型开始生成回复时推送首字,标志着 AI 开始回答。
| 字段 | 类型 | 说明 |
|---|---|---|
| type | string | "llm_first_token" |
| first_token | string | 首个生成的字符 |
{"type":"llm_first_token","first_token":"我"}
audio — 语音回复音频片段(二进制消息)
AI 回复的音频片段以二进制消息推送,客户端可直接播放。
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
// 二进制音频消息,直接播放
playAudioChunk(event.data);
}
};
音频格式:MP3 格式(原始二进制数据,非 Base64 编码,可直接使用 Audio 元素播放)
done — 对话完成
包含完整的统计信息,特别是首字响应、首音频响应时间和音频时长。
| 字段 | 类型 | 说明 |
|---|---|---|
| type | string | "done" |
| reply_text | string | AI 回复完整文本 |
| first_token_ms | number | LLM 首字响应时间(毫秒) |
| first_audio_ms | number | 首音频响应时间(毫秒) |
| asr_ms | number | 语音识别总耗时(毫秒) |
| llm_ms | number | 大模型推理耗时(毫秒) |
| tts_ms | number | 语音合成总耗时(毫秒) |
| total_ms | number | 本轮对话总耗时(毫秒) |
| audio_duration_sec | number | AI 回复音频时长(秒),用于计费 |
{
"type":"done",
"reply_text":"你好,我是AI助手沁瑶,有什么可以帮助你的吗?",
"first_token_ms":1200,
"first_audio_ms":2500,
"asr_ms":800,
"llm_ms":1500,
"tts_ms":1000,
"total_ms":3300,
"audio_duration_sec":5
}
error — 错误事件
| 字段 | 类型 | 说明 |
|---|---|---|
| type | string | "error" |
| code | string | 错误码,如 "grpc_error" |
| message | string | 错误描述 |
{"type":"error","code":"grpc_error","message":"创建连接失败"}
完整示例(JavaScript)
<!DOCTYPE html>
<html>
<head>
<title>全双工语音对话示例</title>
</head>
<body>
<button id="startBtn">开始对话</button>
<button id="stopBtn">停止对话</button>
<div id="status"></div>
<div id="asrText"></div>
<div id="replyText"></div>
<script>
let ws;
let mediaRecorder;
document.getElementById('startBtn').onclick = async () => {
ws = new WebSocket('wss://api.vuilabs.cn/api/v1/audio/conversation/stream?api_key=your-secret-key-here');
ws.onopen = async () => {
document.getElementById('status').textContent = 'WebSocket 已连接';
ws.send(JSON.stringify({
meta: { voice_id: 'qinyao', audio_format: 'pcm16', language: 'zh' }
}));
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0 && ws.readyState === WebSocket.OPEN) {
ws.send(e.data);
}
};
mediaRecorder.start(100);
};
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
const msg = JSON.parse(event.data);
if (msg.type === 'asr_partial') {
document.getElementById('asrText').textContent = `实时识别: ${msg.asr_text}`;
} else if (msg.type === 'asr_final') {
document.getElementById('asrText').textContent = `最终识别: ${msg.asr_text}`;
} else if (msg.type === 'done') {
document.getElementById('replyText').textContent = `AI回复: ${msg.reply_text}`;
document.getElementById('status').textContent =
`完成 - 首字响应: ${msg.first_token_ms}ms, 首音频: ${msg.first_audio_ms}ms`;
stopConversation();
} else if (msg.type === 'error') {
document.getElementById('status').textContent = `错误: ${msg.code} - ${msg.message}`;
}
} else if (event.data instanceof ArrayBuffer) {
playAudio(event.data);
}
};
ws.onclose = () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
};
};
document.getElementById('stopBtn').onclick = stopConversation;
function stopConversation() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ is_final: true }));
ws.close();
}
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
}
function playAudio(audioData) {
const blob = new Blob([audioData], { type: 'audio/mpeg' });
const audioUrl = URL.createObjectURL(blob);
const audio = new Audio(audioUrl);
audio.play();
}
</script>
</body>
</html>
计费说明
具体计费规则、价格和免费额度请参见 产品计费。
错误码
| 错误码 | 说明 |
|---|---|
| 0 | 成功 |
| 20001 | 未授权,请检查 X-API-Key |
| 20003 | 参数错误 |
| 40011 | 余额不足,无法开始通话 |
| 40012 | 通话进行中,请先结束当前通话(仅 Web JWT 单路场景) |
| 40013 | 并发数已达上限(默认 API Key 不限制;如需启用上限请联系运营) |
| 50000 | 服务异常 |