from typing import Iterator, Optional
format="%(asctime)s %(levelname)s %(filename)s:%(funcName)s:%(lineno)s: %(message)s",
StartLiveHeader = "|CTL|00|"
CloseLiveHeader = "|CTL|01|"
EndStreamingAudioHeader = "|CTL|12|"
StreamingAudioHeader = "|DAT|02|"
StreamingstructurationAudioHeader = "|DAT|04|"
PromptHeader = "|DAT|05|"
StartLiveAckHeader = "|MSG|00|"
PlayStatusHeader = "|DAT|02|"
PCM_BYTES_PER_SECOND = 16000 * 2
MIN_PCM_CHUNK_BYTES = 1280
FIXED_ACTION_INTERVAL_SECONDS = 2.5
MANUAL_PROMPT_PAUSE_SECONDS = 5
SEND_LOCK = threading.Lock()
AUDIO_TASK_LOCK = threading.Lock()
FIXED_ACTION_LOCK = threading.Lock()
ACTIVE_AUDIO_THREAD = None
ACTIVE_FIXED_ACTION_THREAD = None
FIXED_ACTION_ENABLED = False
FIXED_ACTION_PROMPT = ""
FIXED_ACTION_PAUSED_UNTIL = 0.0
def init(startLiveInfo: dict) -> Optional[websocket.WebSocket]:
logging.info("[init] 开始初始化WebSocket连接")
ws = websocket.create_connection(
"wss://openspeech.bytedance.com/virtual_human/avatar_live/live",
logging.info(f"[init] 准备发送开播信息: {startLiveInfo}")
ws.send(StartLiveHeader + json.dumps(startLiveInfo))
logging.info(f"[init] 收到开播响应: {ack}")
ackInfo = json.loads(ack[8:])
if ackInfo["code"] != 1000:
raise Exception(f"[init] [{ackInfo['code']}] {ackInfo['message']}")
t = threading.Thread(target=heartbeat, args=(ws,), daemon=True)
logging.error(f"[init] 初始化WebSocket连接失败: {e}")
logging.info("[init] 初始化失败,已关闭WebSocket连接")
except Exception as close_e:
logging.error(f"[init] 初始化失败,关闭连接异常: {close_e}")
:param ws: WebSocket连接对象
logging.info("[heartbeat] 发送心跳包成功")
logging.error(f"[heartbeat] Heartbeat failed: {e}")
def safeSend(ws: websocket.WebSocket, payload, opcode=websocket.ABNF.OPCODE_TEXT):
:param ws: WebSocket连接对象
:param payload: 要发送的消息内容
:param opcode: 消息类型,默认文本消息
ws.send(payload, opcode=opcode)
def startAsyncAudioTask(ws: websocket.WebSocket, send_func, audio_file_path: str, task_name: str):
:param ws: WebSocket连接对象
:param send_func: 发送音频文件的函数,参数为 (ws, audio_file_path)
:param audio_file_path: 音频文件路径(WAV格式)
:param task_name: 任务名称,用于日志记录和异常处理
global ACTIVE_AUDIO_THREAD
if ACTIVE_AUDIO_THREAD and ACTIVE_AUDIO_THREAD.is_alive():
logging.info("[startAsyncAudioTask] 当前已有音频任务在发送,请等待完成后再试")
global ACTIVE_AUDIO_THREAD
send_func(ws, audio_file_path)
logging.error(f"[{task_name}] 后台发送失败: {e}")
ACTIVE_AUDIO_THREAD = None
ACTIVE_AUDIO_THREAD = threading.Thread(target=runner, name=task_name, daemon=True)
ACTIVE_AUDIO_THREAD.start()
def setFixedActionPrompt(prompt: str):
global FIXED_ACTION_PROMPT, FIXED_ACTION_ENABLED
normalized_prompt = prompt.strip()
FIXED_ACTION_PROMPT = normalized_prompt
if not normalized_prompt:
FIXED_ACTION_ENABLED = False
logging.info(f"[setFixedActionPrompt] 已更新固定动作提示词: {normalized_prompt}")
logging.info("[setFixedActionPrompt] 已关闭固定动作")
def startFixedActions(ws: websocket.WebSocket):
global ACTIVE_FIXED_ACTION_THREAD, FIXED_ACTION_ENABLED
if not FIXED_ACTION_PROMPT:
print("未设置固定动作提示词,无法启动固定动作")
if ACTIVE_FIXED_ACTION_THREAD and ACTIVE_FIXED_ACTION_THREAD.is_alive():
logging.info("[startFixedActions] 固定动作已在运行中")
FIXED_ACTION_ENABLED = True
ACTIVE_FIXED_ACTION_THREAD = threading.Thread(target=fixedActions, args=(ws,), daemon=True)
ACTIVE_FIXED_ACTION_THREAD.start()
global FIXED_ACTION_ENABLED
FIXED_ACTION_ENABLED = False
def pauseFixedActions(duration_seconds: int = MANUAL_PROMPT_PAUSE_SECONDS):
global FIXED_ACTION_PAUSED_UNTIL
FIXED_ACTION_PAUSED_UNTIL = max(FIXED_ACTION_PAUSED_UNTIL, time.time() + duration_seconds)
:param ws: WebSocket连接对象
global ACTIVE_FIXED_ACTION_THREAD, FIXED_ACTION_ENABLED
enabled = FIXED_ACTION_ENABLED
prompt = FIXED_ACTION_PROMPT
paused_until = FIXED_ACTION_PAUSED_UNTIL
if time.time() < paused_until:
sendPrompt(ws, prompt, pause_fixed_action=False)
time.sleep(FIXED_ACTION_INTERVAL_SECONDS)
logging.error(f"[fixedActions] fixedActions failed: {e}")
FIXED_ACTION_ENABLED = False
ACTIVE_FIXED_ACTION_THREAD = None
def sendPrompt(ws: websocket.WebSocket, prompt: str, pause_fixed_action: bool = True):
:param ws: WebSocket连接对象
:param pause_fixed_action: 是否暂停固定动作线程
raise ValueError("[sendPrompt] prompt 不能为空")
payload = json.dumps({"prompt": prompt}, ensure_ascii=False)
logging.info(f"[sendPrompt] 准备发送指令内容: {payload}")
safeSend(ws, PromptHeader + payload)
def sleepUntilNextAudioTick(next_send_at: float):
sleep_seconds = next_send_at - time.monotonic()
time.sleep(sleep_seconds)
def sendStreamingAudio(ws: websocket.WebSocket, audio_file_path: str):
发送流式音频文件到数字人直播服务(DAT|02| + Binary PCM)
:param ws: WebSocket连接对象
:param audio_file_path: 音频文件路径(WAV格式)
logging.info(f"[sendStreamingAudio] 开始发送音频文件: {audio_file_path}")
start_at = time.monotonic()
for pkg_index, pcm_chunk in enumerate(iterPcmChunks(audio_file_path), start=1):
message = StreamingAudioHeader.encode("utf-8") + pcm_chunk
safeSend(ws, message, opcode=websocket.ABNF.OPCODE_BINARY)
total_bytes += len(pcm_chunk)
sleepUntilNextAudioTick(start_at + total_bytes / PCM_BYTES_PER_SECOND)
safeSend(ws, EndStreamingAudioHeader)
logging.info("[sendStreamingAudio] 音频发送完成,已发送结束指令")
raise Exception(f"[sendStreamingAudio] 音频文件解析失败: {e}")
raise Exception(f"[sendStreamingAudio] 发送音频失败: {e}")
def sendStructurationAudio(ws: websocket.WebSocket, audio_file_path: str):
发送结构化流式音频文件到数字人直播服务(DAT|04| + JSON)
:param ws: WebSocket连接对象
:param audio_file_path: 音频文件路径(WAV格式)
logging.info(f"[sendStructurationAudio] 开始发送音频文件: {audio_file_path}")
start_at = time.monotonic()
for pkg_index, pcm_chunk in enumerate(iterPcmChunks(audio_file_path), start=1):
{"audio": base64.b64encode(pcm_chunk).decode("utf-8")},
safeSend(ws, StreamingstructurationAudioHeader + payload)
total_bytes += len(pcm_chunk)
sleepUntilNextAudioTick(start_at + total_bytes / PCM_BYTES_PER_SECOND)
safeSend(ws, EndStreamingAudioHeader)
logging.info("[sendStructurationAudio] 音频发送完成,已发送结束指令")
raise Exception(f"[sendStructurationAudio] 音频文件解析失败: {e}")
raise Exception(f"[sendStructurationAudio] 发送音频失败: {e}")
def iterPcmChunks(audio_file_path: str, chunk_bytes: int = MIN_PCM_CHUNK_BYTES) -> Iterator[bytes]:
if not os.path.exists(audio_file_path):
raise FileNotFoundError(f"音频文件不存在: {audio_file_path}")
with wave.open(audio_file_path, "rb") as wf:
if wf.getframerate() != 16000:
raise ValueError(f"音频采样率必须为16000Hz,当前为{wf.getframerate()}Hz")
if wf.getnchannels() != 1:
raise ValueError(f"音频必须为单声道,当前为{wf.getnchannels()}声道")
if wf.getsampwidth() != 2:
raise ValueError(f"音频位深必须为16bit,当前为{wf.getsampwidth() * 8}bit")
frame_count = max(1, chunk_bytes // wf.getsampwidth())
pcm_chunk = wf.readframes(frame_count)
if len(pcm_chunk) < MIN_PCM_CHUNK_BYTES:
pcm_chunk = pcm_chunk.ljust(MIN_PCM_CHUNK_BYTES, b"\x00")
def close(ws: websocket.WebSocket):
:param ws: WebSocket连接对象
if not ws or not ws.connected:
logging.info("[close] WebSocket连接已关闭,无需重复操作")
safeSend(ws, CloseLiveHeader)
logging.info("[close] 已发送关播指令")
except Exception as send_e:
logging.warning(f"[close] 发送关播指令失败: {send_e}")
logging.info("[close] WebSocket连接已正常关闭")
except Exception as close_e:
logging.error(f"[close] 关闭WebSocket异常: {close_e}")
if __name__ == "__main__":
audio_path = "/.../xxx.wav"
"live": {"live_id": "xxx_" + str(uuid.uuid4())},
"avatar_type": "flowact-r1",
"role": "https://xxx.png",
print("正在连接 WebSocket 服务器...")
ws = init(startLiveInfo)
print("4. 设置固定动作提示词 (设置后数字人会一直保持固定动作)")
print("0. 关播退出 (close)")
choice = input("请输入选项 (0/1/2/3/4/5): ").strip()
content = input("请输入指令内容: ").strip()
startAsyncAudioTask(ws, sendStreamingAudio, audio_path, "sendStreamingAudio")
startAsyncAudioTask(ws, sendStructurationAudio, audio_path, "sendStructurationAudio")
fixed_prompt = input("请输入固定动作提示词(留空则关闭): ").strip()
setFixedActionPrompt(fixed_prompt)
print(f"固定动作提示词已更新为: {fixed_prompt}")
logging.error(f"程序执行异常: {e}")
logging.error(f"异常堆栈: {traceback.format_exc()}")