Messages
{
"type": "start",
"model": "senseaudio-realtime-1.0",
"voice": "f_y_0035_c",
"instructions": "你是SenseAudio助手",
"greeting": "",
"audio_setting": {
"sample_rate": 16000,
"format": "pcm_s16le",
"channel": 1
}
}{
"type": "update",
"voice": "f_y_0035_c",
"instructions": "你是SenseAudio助手"
}{
"type": "commit"
}{
"type": "cancel"
}{
"type": "end"
}{}{
"type": "tool.result",
"call_id": "call_weather_1",
"result": {
"city": "上海",
"temperature": 31,
"unit": "celsius"
}
}{
"type": "ready",
"session_id": "session_50173901836217346"
}{
"type": "speech.started"
}{
"type": "user.transcript.delta",
"text": "今天天气怎么样?"
}{
"type": "user.transcript.done",
"text": "今天天气怎么样?"
}{
"type": "assistant.text.delta",
"text": "今天"
}{
"type": "assistant.text.done",
"text": "今天的天气情况可以打开天气应用查看。"
}{
"type": "assistant.audio.start",
"response_id": "tts_3",
"sample_rate": 24000,
"format": "pcm_s16le"
}{
"type": "assistant.audio.done",
"response_id": "tts_3"
}{
"type": "turn.done"
}{
"type": "error",
"code": "bad_request",
"message": "客户端发送的 start 消息配置无效"
}{}{
"type": "tool.call",
"batch_id": "event_12",
"call_id": "call_weather_1",
"name": "query_weather",
"arguments": {
"city": "上海",
"unit": "celsius"
},
"timeout_ms": 30000
}{
"type": "tool.cancelled",
"call_id": "call_weather_1",
"reason": "timeout"
}{
"type": "command.ack",
"command": "update",
"status": "ignored",
"reason": "tools_busy"
}{
"type": "session.action.completed",
"action": "announce_and_close",
"call_id": "call_action_1"
}端到端实时语音模型
端到端实时语音模型
基于 WebSocket 的实时语音对话协议参考
WSS
/
ws
/
v1
/
realtime
/
voice-dialog
说明
基于 WebSocket 的实时语音对话接口,支持客户端持续发送语音输入,服务端实时返回用户语音转写、模型文本回复与模型语音回复。文本帧均为 JSON,二进制帧用于传输 PCM 音频数据。- 接入地址:
wss://api.senseaudio.cn/ws/v1/realtime/voice-dialog - 鉴权方式:Bearer Token,格式
Authorization: Bearer SENSEAUDIO_API_KEY - 客户端文本帧:JSON 文本帧,必须包含
type字段 - 客户端音频帧:
pcm_s16le、采样率16000Hz、单声道;建议每40ms发送一帧(1280字节) - 服务端音频帧:
pcm_s16le、采样率24000Hz、单声道 - 计费单位:按二进制帧传输的 PCM 音频总长度计费,不足 1 秒按 1 秒计
start必须作为连接后的第一个客户端消息发送,且同一连接内不能重复发送。- 客户端必须等待服务端返回
ready后,才能开始发送二进制音频帧。 update不能修改model和audio_setting。greeting非空时会产生独立的开场白轮次及其turn.done。客户端不能把该事件误认为用户语音轮次结束。
请求头
| 参数名 | 必填 | 说明 | 示例 |
|---|---|---|---|
| Authorization | 是 | 鉴权 Token。格式:Bearer SENSEAUDIO_API_KEY | Bearer sk-123456… |
本文完整示例面向 Python 服务端程序。浏览器原生
WebSocket API 不能设置自定义 Authorization 请求头,也不应把长期 API Key 暴露在浏览器代码中;浏览器场景应通过受控后端代理或专用的短期鉴权机制接入。接入流程
1. 客户端建立 WebSocket 连接
↓
2. 客户端发送 start 事件,初始化模型与对话配置
↓
3. 服务端返回 ready 事件,表示模型连接建立完成
↓
4. greeting 非空时,服务端先返回开场白音频及对应的 turn.done
↓
5. 客户端按实时节奏发送 PCM 二进制音频帧
↓
6. 客户端发送 commit 手动提交本轮音频,或由服务端自动判断语音结束
↓
7. 服务端返回 user.transcript.done、模型文本回复与模型语音
↓
8. 服务端返回 turn.done;模型回复期间客户端可发送 cancel 打断回复
↓
9. 客户端发送 end 结束会话,服务端以关闭码 1000 正常关闭连接
客户端事件
1. start - 开始语音对话
start 用于初始化实时语音对话,必须作为第一个消息发送。同一连接内重复发送 start 会触发 bad_request,服务端随后以 1008 断开连接。
请求参数
| 参数名 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
type | string | 是 | 固定值:start | start |
model | string | 是 | 模型 ID,当前仅支持 senseaudio-realtime-1.0 | senseaudio-realtime-1.0 |
voice | string | 否 | 对话音色 ID;不传时由服务端选择默认音色,字面值 default 不是有效音色 ID | f_y_0035_c |
instructions | string | 否 | 系统提示词 | 你是SenseAudio助手 |
greeting | string | 否 | 开场白,可以为空;非空时会产生独立的开场白轮次 | 你好,我是SenseAudio语音助手 |
audio_setting | object | 否 | 输入音频设置,不传时使用默认配置 | - |
audio_setting.sample_rate | integer | 否 | 采样率,当前仅支持 16000 | 16000 |
audio_setting.format | string | 否 | 音频格式,当前仅支持 pcm_s16le | pcm_s16le |
audio_setting.channel | integer | 否 | 声道数,当前仅支持 1 | 1 |
tools | array | 否 | 完整的函数工具架构数组,最多包含 32 个工具。省略或传空数组表示不启用函数调用 | [] |
{
"type": "start",
"model": "senseaudio-realtime-1.0",
"voice": "f_y_0035_c",
"instructions": "你是SenseAudio助手",
"greeting": "",
"audio_setting": {
"sample_rate": 16000,
"format": "pcm_s16le",
"channel": 1
},
"tools": [
{
"type": "function",
"name": "query_weather",
"description": "查询指定城市的当前天气",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,例如上海"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位"
}
},
"required": ["city"],
"additionalProperties": false
}
}
]
}
greeting 非空时,服务端会在 ready 后返回开场白音频,并以一个独立的 turn.done 结束该轮。若客户端需要等开场白播放完再上传用户语音,应先等待这个 turn.done;完整代码示例将 greeting 设为空,以便只演示用户语音轮次。2. update - 更新对话配置
update 用于在已经开始的对话中修改配置。除 type 外,至少需要包含一个可更新字段;未包含的字段保持不变。无效音色等可恢复错误不会断开连接。发送 model、audio_setting 或其他不支持的字段会触发致命的 bad_request,服务端随后以 1008 断开连接。
update 不支持修改 model 和 audio_setting。| 参数名 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
type | string | 是 | 固定值:update | update |
voice | string | 否 | 更新对话音色 ID,必须使用“音色列表”中的值;字面值 default 无效 | f_y_0035_c |
instructions | string | 否 | 更新系统提示词 | 你是SenseAudio助手 |
{
"type": "update",
"voice": "f_y_0035_c",
"instructions": "你是SenseAudio助手"
}
3. 音频流传输(二进制消息)
客户端必须在收到服务端ready 后发送二进制音频帧。
- 格式:
pcm_s16le - 采样率:
16000Hz - 声道数:
1 - 推荐帧长:
40ms,即640个采样点、1280字节 - 发送节奏:按音频实际时长发送,不要将整个文件作为一个大二进制消息突发上传
- 计费规则:按二进制帧传输的 PCM 音频总长度计费,不足 1 秒按 1 秒计
4. commit - 手动提交本轮音频
commit 用于手动提交当前已缓冲的音频,表示此轮用户音频输入结束。后续二进制帧不属于已提交的这一轮,而会进入新的输入缓冲。
{ "type": "commit" }
commit 不会关闭 WebSocket 或持续麦克风流。实时客户端通常会继续发送麦克风帧,以支持下一轮输入和打断。5. cancel - 打断模型回复
cancel 用于打断并取消当前模型回复。
{ "type": "cancel" }
6. tool.result - 工具调用结果
客户端收到tool.call 并执行完成后回传。call_id 必须原样回传 tool.call.call_id。result 必须可序列化为 JSON,大小不能超过 32 KiB。
{
"type": "tool.result",
"call_id": "call_weather_1",
"result": {
"city": "上海",
"temperature": 31,
"unit": "celsius"
}
}
tool.result 中附加通用动作 session_action。工具名称和业务含义不写死,客户端按自身业务决定何时附加该动作。
{
"type": "tool.result",
"call_id": "call_action_1",
"result": {
"status": "accepted",
"reason": "用户明确要求人工客服"
},
"session_action": {
"type": "announce_and_close",
"text": "好的,正在为您转接人工中……"
}
}
session_action 只能附加在包含 result 的帧上。session_action.text 必须是非空字符串,最长 500 个字符。无效的 session_action 会触发致命 error 并关闭连接。
7. end - 结束对话
end 用于结束当前对话。服务端收到后会以 1000 关闭 WebSocket 连接。
{ "type": "end" }
函数调用
在start 中通过 tools 注册客户端可执行的函数。省略 tools 或传空数组表示不启用函数调用。
tools 字段
| 参数名 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
tools | array | 否 | 完整的函数工具架构数组,最多包含 32 个工具 | [] |
函数工具架构
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
type | string | 是 | 固定为 function |
name | string | 是 | 工具名称,最长 64 个字符,只能包含字母、数字、_、-,且不能有首尾空格 |
description | string | 否 | 帮助模型判断何时调用工具,最长 2048 个字符 |
parameters | object | 是 | Draft 2020-12 JSON Schema,顶层 type 必须为 object |
请求示例
{
"type": "start",
"model": "senseaudio-realtime-1.0",
"voice": "f_y_0035_c",
"instructions": "你是SenseAudio助手",
"greeting": "",
"audio_setting": {
"sample_rate": 16000,
"format": "pcm_s16le",
"channel": 1
},
"tools": [
{
"type": "function",
"name": "query_weather",
"description": "查询指定城市的当前天气",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,例如上海"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位"
}
},
"required": ["city"],
"additionalProperties": false
}
}
]
}
调用流程
- 在
start中传入tools。 - 模型触发工具调用后,服务端下发
tool.call。 - 客户端执行对应的工具处理器,并用同一
call_id回传tool.result。 - 若客户端发送
cancel,服务端会下发tool.cancelled。
服务端事件
tool.call - 工具调用请求
模型触发函数调用时,服务端会下发该事件。客户端收到后应执行本地处理器,并回传同一call_id 的 tool.result。
{
"type": "tool.call",
"batch_id": "event_12",
"call_id": "call_weather_1",
"name": "query_weather",
"arguments": {
"city": "上海",
"unit": "celsius"
},
"timeout_ms": 30000
}
tool.cancelled - 工具调用取消
当客户端显式发送cancel,或工具调用超时,服务端会下发该事件。
{
"type": "tool.cancelled",
"call_id": "call_weather_1",
"reason": "timeout"
}
session.action.completed - 会话动作完成
announce_and_close 会依次产生 assistant.text.done、assistant.audio.start、二进制音频帧和 assistant.audio.done。播报完成后,服务端发送 session.action.completed 并关闭 WebSocket。此流程不返回 turn.done。
{
"type": "session.action.completed",
"action": "announce_and_close",
"call_id": "call_action_1",
"message": "好的,正在为您转接人工中……"
}
ready - 连接就绪
表示服务端到模型的连接已经建立,可以开始发送音频数据。若start.greeting 非空,ready 后还会收到一轮开场白事件;该轮也会以 turn.done 结束。
{
"type": "ready",
"session_id": "session_50173901836217346"
}
speech.started - 检测到用户开口
表示服务端检测到用户已经开始说话。{ "type": "speech.started" }
user.transcript.delta - 用户转写增量
用户语音转写的增量结果。该结果会随用户语音输入逐渐修正,前端渲染时建议使用最新值替换展示。{
"type": "user.transcript.delta",
"text": "今天天气怎么样?"
}
user.transcript.done - 用户转写最终稿
用户语音结束后返回的最终转写结果。语音结束包括服务端自动判断结束和客户端手动commit 提交。
{
"type": "user.transcript.done",
"text": "今天天气怎么样?"
}
assistant.text.delta - 模型回复文本增量
模型回复文本的增量片段。客户端需要将本字段追加到之前收到的文本后,得到当前模型回复内容。{
"type": "assistant.text.delta",
"text": "今天"
}
assistant.text.done - 模型回复文本全文
本轮模型回复文本的完整内容。{
"type": "assistant.text.done",
"text": "今天的天气情况可以打开天气应用查看。如果你告诉我所在城市,我可以帮你整理出行建议。"
}
assistant.audio.start - 模型回复音频开始
表示模型回复音频开始。之后收到的二进制帧均为本轮模型回复音频。当前服务端输出为单声道 PCM。{
"type": "assistant.audio.start",
"response_id": "tts_3",
"sample_rate": 24000,
"format": "pcm_s16le"
}
assistant.audio.done - 模型回复音频结束
表示本轮模型回复音频结束,response_id 与 assistant.audio.start 中的 response_id 一致。
{
"type": "assistant.audio.done",
"response_id": "tts_3"
}
turn.done - 本轮结束
表示一个对话轮次结束。非空greeting 的开场白轮次和每个用户语音轮次都会分别返回 turn.done;客户端应维护会话状态,不能无条件在第一个 turn.done 后结束连接。
{ "type": "turn.done" }
error - 错误通知
发生错误时,服务端返回error。错误分为致命错误和非致命错误;致命错误会在服务端发送该消息后断开 WebSocket,非致命错误不会断开连接。
{
"type": "error",
"code": "bad_request",
"message": "客户端发送的 start 消息配置无效"
}
使用示例
以下示例直接采集系统麦克风,并把模型返回的音频实时播放到扬声器。建议使用耳机,避免扬声器声音被麦克风重新采集后触发误识别或错误打断。 运行前先设置 API Key:export SENSEAUDIO_API_KEY='YOUR_API_KEY'
ready 后持续上传 16000Hz / 单声道 / pcm_s16le 麦克风帧,由服务端 VAD 自动判断每轮语音结束,因此不会主动发送 commit。模型音频按服务端声明的 24000Hz / 单声道 / pcm_s16le 实时播放;普通 turn.done 只表示一轮完成,连接会继续用于下一轮。正常使用时按 Ctrl+C 发送 end。当服务端在模型播报期间返回 speech.started 时,示例会发送一次 cancel 并清除尚未播放的旧回复。若 greeting 非空,示例会先播完开场白再开启麦克风,避免开场白回采形成错误的用户轮次。
依赖:Python 3.9+、websocket-client 和 sounddevice
sounddevice 基于 PortAudio。Windows 和 macOS 的 pip 包通常包含 PortAudio;Linux 如果安装或运行时报 PortAudio 错误,需要先通过系统包管理器安装运行库,例如 Ubuntu / Debian 可执行 sudo apt-get install libportaudio2。
示例中的 query_weather 返回固定的模拟天气。接入真实工具时,请替换 handle_tool_call 中的业务逻辑,并保留参数校验、超时和取消处理。
realtime_voice.py
import json
import os
import queue
import sys
import threading
import time
import sounddevice as sd
import websocket
def parse_device(name):
value = os.environ.get(name)
if not value:
return None
try:
return int(value)
except ValueError:
return value
if "--list-devices" in sys.argv:
print(sd.query_devices())
raise SystemExit(0)
API_KEY = os.environ.get("SENSEAUDIO_API_KEY")
WS_URL = os.environ.get(
"SENSEAUDIO_WS_URL",
"wss://api.senseaudio.cn/ws/v1/realtime/voice-dialog",
)
GREETING = os.environ.get("SENSEAUDIO_GREETING", "")
VOICE = os.environ.get("SENSEAUDIO_VOICE", "")
INPUT_DEVICE = parse_device("SENSEAUDIO_INPUT_DEVICE")
OUTPUT_DEVICE = parse_device("SENSEAUDIO_OUTPUT_DEVICE")
INPUT_SAMPLE_RATE = 16000
INPUT_BLOCK_FRAMES = 640 # 40ms
OUTPUT_SAMPLE_RATE = 24000
OUTPUT_BLOCK_FRAMES = 480 # 20ms,降低打断后的播放尾音
OUTPUT_BLOCK_BYTES = OUTPUT_BLOCK_FRAMES * 2
MIC_QUEUE_BLOCKS = 25 # 最多缓存约 1 秒上行音频
PLAY_QUEUE_BLOCKS = 3000 # 最多缓存约 60 秒下行音频
if not API_KEY:
raise RuntimeError("请先设置 SENSEAUDIO_API_KEY 环境变量")
class RealtimeVoiceClient:
def __init__(self):
self.stop_event = threading.Event()
self.closed_event = threading.Event()
self.mic_enabled = threading.Event()
self.start_sent = threading.Event()
self.fatal_event = threading.Event()
self.mic_dropped = threading.Event()
self.input_warning = threading.Event()
self.playback_backlog = threading.Event()
self.output_busy = threading.Event()
self.mic_queue = queue.Queue(maxsize=MIC_QUEUE_BLOCKS)
self.play_queue = queue.Queue(maxsize=PLAY_QUEUE_BLOCKS)
self.state_lock = threading.Lock()
self.send_lock = threading.Lock()
self.output_stream_lock = threading.Lock()
self.waiting_greeting = bool(GREETING)
self.response_active = False
self.downlink_audio_active = False
self.cancel_sent = False
self.drop_current_response = False
self.assistant_text_started = False
self.playback_epoch = 0
self.downlink_buffer = bytearray()
self.ending = False
self.fatal_reason = None
self.pending_tool_calls = {}
self.input_stream = None
self.output_stream = None
self.uplink_thread = None
self.output_thread = None
self.websocket_thread = None
self.ws = websocket.WebSocketApp(
WS_URL,
header=[f"Authorization: Bearer {API_KEY}"],
on_open=self.on_open,
on_message=self.on_message,
on_close=self.on_close,
on_error=self.on_error,
)
def set_fatal(self, reason):
with self.state_lock:
if self.fatal_reason is None:
self.fatal_reason = reason
self.fatal_event.set()
def send_json(self, payload):
message = json.dumps(payload, ensure_ascii=False)
with self.send_lock:
self.ws.send(message)
def send_binary(self, payload):
with self.send_lock:
if self.stop_event.is_set():
return False
self.ws.send(payload, opcode=websocket.ABNF.OPCODE_BINARY)
return True
def dispatch_tool_call(self, event):
call_id = event.get("call_id")
if not isinstance(call_id, str) or not call_id:
print("\n忽略缺少 call_id 的工具调用")
return
cancel_event = threading.Event()
with self.state_lock:
if call_id in self.pending_tool_calls:
print(f"\n忽略重复的工具调用:{call_id}")
return
self.pending_tool_calls[call_id] = cancel_event
threading.Thread(
target=self.handle_tool_call,
args=(event, cancel_event),
name=f"senseaudio-tool-{call_id}",
daemon=True,
).start()
def handle_tool_call(self, event, cancel_event):
call_id = event["call_id"]
timeout_ms = event.get("timeout_ms", 30000)
if not isinstance(timeout_ms, int) or timeout_ms <= 0:
timeout_ms = 30000
deadline = time.monotonic() + timeout_ms / 1000
try:
name = event.get("name")
arguments = event.get("arguments")
if name != "query_weather":
result = {
"error": {
"code": "unsupported_tool",
"message": f"不支持工具 {name}",
}
}
elif not isinstance(arguments, dict):
result = {
"error": {
"code": "invalid_arguments",
"message": "arguments 必须是 JSON 对象",
}
}
else:
city = arguments.get("city")
unit = arguments.get("unit", "celsius")
if not isinstance(city, str) or not city.strip():
result = {
"error": {
"code": "invalid_arguments",
"message": "city 必须是非空字符串",
}
}
elif unit not in {"celsius", "fahrenheit"}:
result = {
"error": {
"code": "invalid_arguments",
"message": "unit 必须是 celsius 或 fahrenheit",
}
}
else:
# 模拟结果。真实工具 I/O 也应在此工作线程中执行。
result = {
"city": city,
"temperature": 31,
"unit": unit,
}
if (
cancel_event.is_set()
or self.stop_event.is_set()
or time.monotonic() >= deadline
):
return
self.send_json(
{
"type": "tool.result",
"call_id": call_id,
"result": result,
}
)
except websocket.WebSocketConnectionClosedException:
pass
except Exception as error:
print(f"\n工具调用失败:{error}")
finally:
with self.state_lock:
if self.pending_tool_calls.get(call_id) is cancel_event:
del self.pending_tool_calls[call_id]
def handle_tool_cancelled(self, event):
call_id = event.get("call_id")
with self.state_lock:
cancel_event = self.pending_tool_calls.pop(call_id, None)
if cancel_event is not None:
cancel_event.set()
print(
f"\n工具调用已取消:{call_id or 'unknown'} "
f"({event.get('reason', '')})"
)
# PortAudio 实时回调中不能执行网络请求、打印或其他阻塞操作。
def input_callback(self, indata, frames, time_info, status):
if status:
self.input_warning.set()
if self.stop_event.is_set() or not self.mic_enabled.is_set():
return
# indata 的内存在回调返回后会被复用,必须复制。
payload = bytes(indata)
try:
self.mic_queue.put_nowait(payload)
except queue.Full:
# 实时对话优先保留最新音频,避免积压带来数秒延迟。
self.mic_dropped.set()
try:
self.mic_queue.get_nowait()
self.mic_queue.task_done()
except queue.Empty:
pass
try:
self.mic_queue.put_nowait(payload)
except queue.Full:
pass
def uplink_worker(self):
try:
next_send_at = time.monotonic()
while not self.stop_event.is_set():
try:
payload = self.mic_queue.get(timeout=0.1)
except queue.Empty:
continue
try:
if self.mic_enabled.is_set():
# 即使虚拟设备突发产帧,也按 PCM 实际时长发送。
now = time.monotonic()
if next_send_at < now - 1:
next_send_at = now
if self.stop_event.wait(max(0, next_send_at - now)):
return
if not self.send_binary(payload):
return
next_send_at = max(next_send_at, now) + (
len(payload) / (INPUT_SAMPLE_RATE * 2)
)
finally:
self.mic_queue.task_done()
except websocket.WebSocketConnectionClosedException:
pass
except Exception as error:
self.set_fatal(f"发送麦克风音频失败:{error}")
def output_worker(self):
try:
while not self.stop_event.is_set():
try:
epoch, payload = self.play_queue.get(timeout=0.1)
except queue.Empty:
continue
try:
with self.output_stream_lock:
with self.state_lock:
valid = (
epoch == self.playback_epoch
and not self.drop_current_response
)
if valid and not self.stop_event.is_set():
self.output_busy.set()
try:
self.output_stream.write(payload)
finally:
self.output_busy.clear()
finally:
self.play_queue.task_done()
except Exception as error:
if not self.stop_event.is_set():
self.set_fatal(f"播放模型音频失败:{error}")
def clear_playback_queue(self):
while True:
try:
self.play_queue.get_nowait()
except queue.Empty:
return
else:
self.play_queue.task_done()
def enqueue_playback(self, epoch, payload):
try:
self.play_queue.put_nowait((epoch, payload))
except queue.Full:
self.playback_backlog.set()
try:
self.play_queue.get_nowait()
self.play_queue.task_done()
except queue.Empty:
pass
try:
self.play_queue.put_nowait((epoch, payload))
except queue.Full:
self.set_fatal("扬声器播放队列持续堆积,请检查输出设备或降低播放负载")
def handle_binary_audio(self, message):
payload = bytes(message)
with self.state_lock:
if (
not self.downlink_audio_active
or self.drop_current_response
or self.ending
):
return
epoch = self.playback_epoch
self.downlink_buffer.extend(payload)
while len(self.downlink_buffer) >= OUTPUT_BLOCK_BYTES:
block = bytes(self.downlink_buffer[:OUTPUT_BLOCK_BYTES])
del self.downlink_buffer[:OUTPUT_BLOCK_BYTES]
self.enqueue_playback(epoch, block)
def finish_downlink_audio(self):
with self.state_lock:
valid = (
self.downlink_audio_active
and not self.drop_current_response
and not self.ending
)
epoch = self.playback_epoch
self.downlink_audio_active = False
if valid and self.downlink_buffer:
if len(self.downlink_buffer) % 2:
self.set_fatal("服务端返回了长度不是 16-bit 对齐的 PCM 音频")
else:
self.enqueue_playback(epoch, bytes(self.downlink_buffer))
self.downlink_buffer.clear()
def interrupt_assistant(self):
with self.state_lock:
had_audio = (
self.downlink_audio_active
or not self.play_queue.empty()
or self.output_busy.is_set()
)
self.playback_epoch += 1
should_cancel = (
self.response_active
and not self.cancel_sent
and not self.ending
)
self.response_active = False
self.downlink_audio_active = False
if should_cancel:
self.cancel_sent = True
self.drop_current_response = True
self.downlink_buffer.clear()
self.clear_playback_queue()
if had_audio and self.output_stream is not None:
try:
with self.output_stream_lock:
self.output_stream.abort(ignore_errors=False)
if not self.stop_event.is_set():
self.output_stream.start()
except Exception as error:
self.set_fatal(f"重置扬声器失败:{error}")
if should_cancel:
print("\n检测到用户打断,已取消当前模型回复")
try:
self.send_json({"type": "cancel"})
except websocket.WebSocketConnectionClosedException:
pass
def enable_mic_after_greeting(self):
# turn.done 到达时开场白二进制帧已经全部入队,排空后再开麦避免回声。
self.play_queue.join()
if self.stop_event.is_set():
return
try:
with self.output_stream_lock:
# stop() 会等待 PortAudio 和硬件中的待播缓冲全部完成。
self.output_stream.stop(ignore_errors=False)
if self.stop_event.is_set():
return
self.output_stream.start()
except Exception as error:
self.set_fatal(f"等待开场白播放完成失败:{error}")
return
self.mic_enabled.set()
print("开场白播放完成,可以开始说话;按 Ctrl+C 结束会话")
def request_end(self, reason):
with self.state_lock:
if self.ending:
return
self.ending = True
self.playback_epoch += 1
tool_cancellations = list(self.pending_tool_calls.values())
self.pending_tool_calls.clear()
print(f"\n结束会话:{reason}")
for cancel_event in tool_cancellations:
cancel_event.set()
self.mic_enabled.clear()
self.stop_event.set()
self.downlink_buffer.clear()
self.clear_playback_queue()
if self.start_sent.is_set():
threading.Thread(
target=self.send_end,
name="senseaudio-end",
daemon=True,
).start()
else:
self.abort_websocket()
def send_end(self):
try:
self.send_json({"type": "end"})
except Exception:
self.abort_websocket()
def abort_websocket(self):
# 低层 abort 可唤醒卡在 send()/recv() 中的线程。
sock = self.ws.sock
if sock is not None:
try:
sock.abort()
except Exception:
pass
def on_open(self, ws):
if self.stop_event.is_set():
self.abort_websocket()
return
start = {
"type": "start",
"model": "senseaudio-realtime-1.0",
"instructions": "你是SenseAudio助手",
"greeting": GREETING,
"audio_setting": {
"sample_rate": INPUT_SAMPLE_RATE,
"format": "pcm_s16le",
"channel": 1,
},
"tools": [
{
"type": "function",
"name": "query_weather",
"description": "查询指定城市的当前天气",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,例如上海",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位",
},
},
"required": ["city"],
"additionalProperties": False,
},
},
],
}
if VOICE:
start["voice"] = VOICE
try:
self.send_json(start)
self.start_sent.set()
except Exception as error:
self.set_fatal(f"发送 start 失败:{error}")
def on_message(self, ws, message):
if self.stop_event.is_set():
return
if isinstance(message, (bytes, bytearray, memoryview)):
self.handle_binary_audio(message)
return
try:
event = json.loads(message)
except json.JSONDecodeError as error:
self.set_fatal(f"服务端返回了无效 JSON:{error}")
return
event_type = event.get("type")
if event_type == "ready":
if self.waiting_greeting:
print("连接已就绪,正在播放开场白")
else:
self.mic_enabled.set()
print("可以开始说话;按 Ctrl+C 结束会话")
return
if event_type == "speech.started":
self.interrupt_assistant()
return
if event_type == "user.transcript.delta":
print(f"\r用户转写中:{event.get('text', '')}", end="", flush=True)
return
if event_type == "user.transcript.done":
with self.state_lock:
# 新用户轮次开始生成回复,解除上一轮 cancel 的丢弃状态。
self.cancel_sent = False
self.drop_current_response = False
self.response_active = True
self.assistant_text_started = False
print(f"\n你:{event.get('text', '')}")
return
if event_type == "assistant.text.delta":
with self.state_lock:
if self.drop_current_response:
return
self.response_active = True
first_delta = not self.assistant_text_started
self.assistant_text_started = True
if first_delta:
print("\n助手:", end="", flush=True)
print(event.get("text", ""), end="", flush=True)
return
if event_type == "assistant.text.done":
with self.state_lock:
suppressed = self.drop_current_response
text_started = self.assistant_text_started
if not suppressed:
self.response_active = True
self.assistant_text_started = False
if suppressed:
return
if text_started:
print()
elif event.get("text"):
print(f"\n助手:{event['text']}")
return
if event_type == "assistant.audio.start":
if (
event.get("sample_rate") != OUTPUT_SAMPLE_RATE
or event.get("format") != "pcm_s16le"
):
self.set_fatal("服务端输出音频不是预期的 pcm_s16le / 24000Hz")
return
self.downlink_buffer.clear()
with self.state_lock:
self.downlink_audio_active = True
if not self.drop_current_response:
self.response_active = True
return
if event_type == "assistant.audio.done":
self.finish_downlink_audio()
return
if event_type == "tool.call":
self.dispatch_tool_call(event)
return
if event_type == "tool.cancelled":
self.handle_tool_cancelled(event)
return
if event_type == "session.action.completed":
print(f"\n会话动作完成:{event.get('action', '')}")
return
if event_type == "turn.done":
# 正常情况下 audio.done 已先到达;这里也做一次幂等收尾。
self.finish_downlink_audio()
with self.state_lock:
greeting_done = self.waiting_greeting
text_incomplete = self.assistant_text_started
self.waiting_greeting = False
self.response_active = False
self.downlink_audio_active = False
self.cancel_sent = False
self.drop_current_response = False
self.assistant_text_started = False
if text_incomplete:
print()
if greeting_done:
threading.Thread(
target=self.enable_mic_after_greeting,
name="senseaudio-greeting-drain",
daemon=True,
).start()
else:
print("本轮结束,继续监听麦克风")
return
if event_type == "error":
print(
"\n服务端错误:"
f"{event.get('code', 'unknown')} - {event.get('message', '')}"
)
if event.get("code") == "no_active_response":
with self.state_lock:
self.cancel_sent = False
self.drop_current_response = False
self.response_active = False
self.assistant_text_started = False
return
print("收到未知服务端事件:", event)
def on_close(self, ws, code, reason):
self.mic_enabled.clear()
self.stop_event.set()
self.closed_event.set()
print(f"\n连接关闭:{code} {reason or ''}")
def on_error(self, ws, error):
if not self.ending:
print(f"\n连接错误:{error}")
def open_audio_devices(self):
try:
sd.check_input_settings(
device=INPUT_DEVICE,
channels=1,
dtype="int16",
samplerate=INPUT_SAMPLE_RATE,
)
sd.check_output_settings(
device=OUTPUT_DEVICE,
channels=1,
dtype="int16",
samplerate=OUTPUT_SAMPLE_RATE,
)
self.input_stream = sd.RawInputStream(
device=INPUT_DEVICE,
samplerate=INPUT_SAMPLE_RATE,
channels=1,
dtype="int16",
blocksize=INPUT_BLOCK_FRAMES,
latency="low",
callback=self.input_callback,
)
self.output_stream = sd.RawOutputStream(
device=OUTPUT_DEVICE,
samplerate=OUTPUT_SAMPLE_RATE,
channels=1,
dtype="int16",
blocksize=OUTPUT_BLOCK_FRAMES,
latency="low",
)
except Exception as error:
if self.input_stream is not None:
self.input_stream.close()
self.input_stream = None
if self.output_stream is not None:
self.output_stream.close()
self.output_stream = None
raise RuntimeError(
"无法打开 16kHz 麦克风或 24kHz 扬声器;"
"请运行 uv run --with sounddevice python -m sounddevice 检查设备"
) from error
def run(self):
self.open_audio_devices()
self.uplink_thread = threading.Thread(
target=self.uplink_worker,
name="senseaudio-uplink",
daemon=True,
)
self.output_thread = threading.Thread(
target=self.output_worker,
name="senseaudio-playback",
daemon=True,
)
self.websocket_thread = threading.Thread(
target=lambda: self.ws.run_forever(
ping_interval=20,
ping_timeout=10,
),
name="senseaudio-websocket",
daemon=True,
)
try:
self.output_stream.start()
self.input_stream.start()
self.uplink_thread.start()
self.output_thread.start()
self.websocket_thread.start()
while self.websocket_thread.is_alive():
if self.mic_dropped.is_set():
self.mic_dropped.clear()
print("\n警告:网络发送较慢,已丢弃过期麦克风帧")
if self.input_warning.is_set():
self.input_warning.clear()
print("\n警告:麦克风发生丢帧或输入溢出")
if self.playback_backlog.is_set():
self.playback_backlog.clear()
print("\n警告:播放队列已满,已丢弃最早的音频块")
if self.fatal_event.is_set():
self.request_end(self.fatal_reason or "本地音频错误")
break
self.websocket_thread.join(0.1)
except KeyboardInterrupt:
self.request_end("用户按下 Ctrl+C")
finally:
if self.websocket_thread.is_alive() and not self.closed_event.wait(2):
self.abort_websocket()
try:
self.ws.close()
except Exception:
pass
self.stop_event.set()
self.mic_enabled.clear()
self.clear_playback_queue()
for stream in (self.input_stream, self.output_stream):
if stream is not None:
try:
stream.abort()
except Exception:
pass
if self.uplink_thread is not None and self.uplink_thread.ident is not None:
self.uplink_thread.join(timeout=1)
if self.output_thread is not None and self.output_thread.ident is not None:
self.output_thread.join(timeout=1)
for stream in (self.input_stream, self.output_stream):
if stream is not None:
try:
stream.close()
except Exception:
pass
if (
self.websocket_thread is not None
and self.websocket_thread.ident is not None
):
self.websocket_thread.join(timeout=1)
if __name__ == "__main__":
RealtimeVoiceClient().run()
realtime_voice.py,然后查看设备或直接运行:
uv run --with sounddevice python -m sounddevice
export SENSEAUDIO_INPUT_DEVICE='输入设备名称或编号'
export SENSEAUDIO_OUTPUT_DEVICE='输出设备名称或编号'
uv run --with websocket-client --with sounddevice realtime_voice.py
示例没有实现声学回声消除(AEC),请优先使用耳机。
sounddevice 能否打开目标采样率取决于操作系统和设备。若初始化失败,请选择支持重采样的系统设备或音频后端,不要直接修改协议规定的输入 16000Hz 与当前输出 24000Hz。示例会在终端输出用户转写和工具事件。生产环境中请对日志脱敏,不要记录 API Key、完整转写、工具参数或会话标识。
错误码说明
| 错误码 | 类型 | 说明 | 关闭码 |
|---|---|---|---|
bad_request | 致命错误 | JSON 格式错误、type 无效、start 配置错误(包括音色无效)、重复发送 start、update 修改了 model 或 audio_setting、tool.result 缺少 call_id 或 result,或 commit / cancel / end 携带额外字段 | 1008 |
model_not_found | 致命错误 | start 中传入的模型错误 | 1008 |
insufficient_funds | 致命错误 | 账户没有足够配额完成接下来的语音对话 | 1008 |
internal_error | 致命错误 | 服务端没有正确连接到模型服务 | 1011 |
fatal | 致命错误 | 服务端发生其他错误,包括无效的 session_action | 1011 |
empty_audio_buffer | 非致命错误 | 没有上传音频,或上次手动提交后没有新的音频上传时发送了 commit,服务端会忽略本次提交 | 不断开 |
no_active_response | 非致命错误 | 模型没有回复时发送了 cancel,服务端会忽略本次打断 | 不断开 |
invalid_voice | 非致命错误 | update 修改的音色不存在,服务端忽略本次更新并继续使用之前的音色 | 不断开 |
鉴权发生在 WebSocket Upgrade 阶段。缺少 Token、Token 无效或鉴权格式错误时,服务端返回 HTTP
401,不会先建立 WebSocket 再发送 JSON error。音色列表
| 音色ID | 音色名 | 情感 |
|---|---|---|
m_c_0001 | 可爱萌娃 | 开心 |
f_m_0009_a | 气质学姐 | 开心 |
f_y_0035_a | 清风少女 | 傲娇 |
f_y_0035_c | 清风少女 | 平稳 |
f_y_0039_c | 知心少女 | 广告中插 |
f_y_0039_d | 知心少女 | 轻松铺陈 |
f_y_0037_f | 青春女声 | 主题升华 |
f_y_0041_c | 亲切女孩 | 致歉安慰 |
m_y_0022 | 沙哑青年 | 深情 |
m_y_0021 | 撒娇青年 | 平稳 |
m_y_0027 | 粘人男友 | 平稳 |
m_y_0027_a | 粘人男友 | 深情 |
m_y_0028 | 温柔霸总 | 平稳 |
m_y_0034_d | 可靠青叔 | 细心提问 |
m_y_0034_e | 可靠青叔 | 主题升华 |
m_y_0036_e | 利落青年 | 细心提问 |
注意事项
- 首条消息要求:
start必须作为第一个消息发送,且同一连接内只能发送一次。 - 音频发送时机:必须等待
ready后再发送二进制音频帧。 - 开场白轮次:
greeting非空时,应区分开场白的turn.done与用户语音轮次的turn.done。 - 文本帧格式:客户端与服务端的文本帧均为 JSON,且必须包含
type字段。 - 音频帧格式:客户端上传音频必须为裸
pcm_s16le / 16000Hz / 单声道,建议按1280字节 /40ms分帧发送。 - 持续上行与超时:长连接客户端应持续处理麦克风流,并设置应用层连接与设备超时;结束、关闭或出错时必须停止采集和发送。
- 服务端音频解析:模型返回的二进制音频帧按
assistant.audio.start中的sample_rate与format解析,当前为单声道;实时播放应使用有界队列,避免阻塞 WebSocket 接收回调。 - 打断处理:用户在模型播报期间开口时,可在收到
speech.started后发送一次cancel,同时丢弃已取消回复中尚未播放以及仍在途的音频帧。 - 回声控制:示例没有实现 AEC,建议使用耳机,避免模型播报被麦克风回采并触发新的用户轮次。
- 结束连接:如需主动结束对话,请发送
end,服务端会以关闭码1000正常关闭连接。
相关资源
语音合成 WebSocket
基于 WebSocket 的实时文本转语音合成协议。
语音识别 WebSocket
基于 WebSocket 的实时语音识别协议。
Messages
{
"type": "start",
"model": "senseaudio-realtime-1.0",
"voice": "f_y_0035_c",
"instructions": "你是SenseAudio助手",
"greeting": "",
"audio_setting": {
"sample_rate": 16000,
"format": "pcm_s16le",
"channel": 1
}
}{
"type": "update",
"voice": "f_y_0035_c",
"instructions": "你是SenseAudio助手"
}{
"type": "commit"
}{
"type": "cancel"
}{
"type": "end"
}{}{
"type": "tool.result",
"call_id": "call_weather_1",
"result": {
"city": "上海",
"temperature": 31,
"unit": "celsius"
}
}{
"type": "ready",
"session_id": "session_50173901836217346"
}{
"type": "speech.started"
}{
"type": "user.transcript.delta",
"text": "今天天气怎么样?"
}{
"type": "user.transcript.done",
"text": "今天天气怎么样?"
}{
"type": "assistant.text.delta",
"text": "今天"
}{
"type": "assistant.text.done",
"text": "今天的天气情况可以打开天气应用查看。"
}{
"type": "assistant.audio.start",
"response_id": "tts_3",
"sample_rate": 24000,
"format": "pcm_s16le"
}{
"type": "assistant.audio.done",
"response_id": "tts_3"
}{
"type": "turn.done"
}{
"type": "error",
"code": "bad_request",
"message": "客户端发送的 start 消息配置无效"
}{}{
"type": "tool.call",
"batch_id": "event_12",
"call_id": "call_weather_1",
"name": "query_weather",
"arguments": {
"city": "上海",
"unit": "celsius"
},
"timeout_ms": 30000
}{
"type": "tool.cancelled",
"call_id": "call_weather_1",
"reason": "timeout"
}{
"type": "command.ack",
"command": "update",
"status": "ignored",
"reason": "tools_busy"
}{
"type": "session.action.completed",
"action": "announce_and_close",
"call_id": "call_action_1"
}bearerAuth
type:http
Bearer Token 鉴权,格式 Authorization: Bearer <SENSEAUDIO_API_KEY>
method
type:string
GET
headers
type:object
Authorization
type:string
必填
Bearer Token,格式:Bearer <SENSEAUDIO_API_KEY>。
start - 开始语音对话
type:object
初始化模型与对话配置。
update - 更新对话配置
type:object
更新后续轮次使用的音色或系统提示词。
commit - 提交本轮音频
type:object
提交当前用户音频缓冲。
cancel - 打断模型回复
type:object
打断并取消当前模型回复。
end - 结束会话
type:object
结束对话并请求正常关闭 WebSocket。
PCM 音频二进制帧
type:string
原始 WebSocket Binary Message,不是 JSON 或 Base64 文本。
tool.result - 工具调用结果
type:object
客户端回传 tool.call 的执行结果。
ready - 连接就绪
type:object
模型连接已建立;非空 greeting 会先产生独立开场白轮次。
speech.started - 检测到用户开口
type:object
服务端检测到用户开始说话。
user.transcript.delta - 用户转写增量
type:object
返回当前最新的用户语音转写。
user.transcript.done - 用户转写最终稿
type:object
返回本轮用户语音的最终转写。
assistant.text.delta - 模型文本增量
type:object
返回模型回复文本的增量片段。
assistant.text.done - 模型文本全文
type:object
返回本轮模型回复文本全文。
assistant.audio.start - 模型音频开始
type:object
后续二进制帧为本轮模型回复音频。
assistant.audio.done - 模型音频结束
type:object
本轮模型回复音频发送完成。
turn.done - 轮次结束
type:object
一个开场白或用户语音轮次结束。
error - 错误通知
type:object
返回致命或非致命错误。
PCM 音频二进制帧
type:string
原始 WebSocket Binary Message,不是 JSON 或 Base64 文本。
tool.call - 工具调用请求
type:object
服务端转发豆包发起的工具调用;客户端需执行本地 handler 并回传 tool.result。
tool.cancelled - 工具调用取消
type:object
工具调用等待被取消或超时。
command.ack - 指令回执
type:object
服务端返回非致命指令处理结果,例如 update.tools 在 tools_busy 时被忽略。
session.action.completed - 会话动作完成
type:object
announce_and_close 动作完成后发送,随后正常关闭 WebSocket。
⌘I