Compare commits

...
Author SHA1 Message Date
mobaiandmultica-agent 6a5e155258 feat(hel715): LiveTalking+MuseTalk verification kit (report, material spec, e2e scripts)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-20 23:24:31 +08:00
10 changed files with 1038 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
# HEL-715 素材包规范与施工方案(可交施工员)
依据 CG-029(日常用成品视频、交谈用同一人物实时口型)、CG-005/006(换外观保留人格/记忆/关系)、CG-030(首批素材小队制作)。原则:单图不自动生成整套;每份素材可独立替换、可校验、可回滚。
## 1. 素材包目录规范
```
assets/personas/<persona>/ # persona=外观方案,如 wanwan-v2;换外观=新目录
manifest.json # 见下
daily/ # 日常成品视频(CG-029 主路线)
<scene-id>.mp4 # H.264 25fps ≥6s 可无缝循环;按时段/天气/活动命名
talk/ # 交谈底板(近景,同房间同脸同服装)
<talk-id>.mp4 # H.264 25fps 5~15s,人物目视镜头、口部微闭或自然微动
voice/ # CosyVoice 音色登记名(.12 voices.json 中的 voice 名)
ref/ # 定妆照/场景照 PNG(身份对照用)
```
`manifest.json` 必填字段:`persona_id`、`identity_ref`(ref/ 内主定妆图)、`daily_clips[{id, timeofday, weather, activity, loop_points}]`、`talk_clips[{id, avatar_id, framing}]`、`created_by`、`created_at`、`pipeline_version`(如 hel715-v1)。
## 2. 身份/服装/光线一致性标记(导入校验项)
- 同一 persona 内所有 daily/talk 片段必须与 `identity_ref` 同人物、同发型、同套服装;换服装=新 persona 目录。
- 每段导入时人工对照 ref 图打勾(首版人工,不做自动人脸embedding校验——留待后续)。
- 光线:同一 persona 的交谈底板光线需与常用日常场景一致(近景底板从日常成品裁切放大可天然保证,如本轮 talk-close = loop 1.75× 裁切)。
## 3. 可切节点与缺素材回退
- 日常片循环点:`loop_points` 记录首尾衔接帧(硬循环跳变处需美术确认或选首尾近同帧的段)。
- 交谈进入点:任意时刻可切(预连接流,实测切换 0~3ms);退出点同。
- 缺素材回退顺序:请求场景无 daily 片 → 用同 persona 默认 daily 片;无 talk 底板 → 禁用交谈入口并提示,不得用其他人物素材顶替;TTS 失败 → 显示重试按钮,不静默。
## 4. 导入与回滚流程(本轮实测命令)
```
# 1) 预处理:任意源 → H.264 25fps(示例见 scripts/,PyAV,秒级)
# 2) 生成交谈 avatar(47~60s GPU/份,独立目录,无硬编码):
cd /opt/hel715-verify/LiveTalking
venv/bin/python -m avatars.musetalk.genavatar --file <talk-clip.mp4> --avatar_id <persona>-<talk-id>
# 3) 校验:data/avatars/<id>/ 含 avator_info.json/coords.pkl/latents.pt/mask;avator_info.json 的 video_path 指向素材包内文件
# 4) 服务挂载:app.py --avatar_id <id>(或 custom_config 多 avatar 预载)
```
回滚:素材包按目录整体保留旧版本(`<persona>.bak-<date>`),avatar 目录同理;manifest 记录版本,恢复=改回引用。模型权重不入 Git,assets 二进制走附件/对象存储,仓库只放 manifest 与脚本。
## 5. 换一套外观的实测成本(CG-005/006 输入)
| 项 | 实测耗时 | 说明 |
| --- | --- | --- |
| 交谈底板由既有成品裁切 | <1min(PyAV 转码)+ 47~60s genavatar | 本轮 talk-close 路线,推荐 |
| 交谈底板由 Wan2.2 全新生成 | ~57s/3s 片段 + 挑选重试(3~5 倍余量) | HEL-712 实测单次推理 |
| 日常成品视频生成 | 同上量级/段 | 小队批量制作 |
| 照片需求 | 定妆/场景各 ≥1 张 | 用于身份对照与 i2v 起点 |
| 预处理 | 自动,无人工参数 | bbox_shift=0 默认可用 |
## 6. 首版最小验收矩阵(交谈链路)
| 项 | 通过标准 |
| --- | --- |
| 日常循环播放 | ≥3 周期无肉眼跳变(或美术确认衔接) |
| 进交谈 | 切换 ≤200ms,同人物同服装目视确认 |
| 口型 | 说话段口部动作肉眼可辨,静息段无明显异常 |
| 打断 | interrupt 后 ≤1s 静音,不等播完 |
| 再开口 | 打断后可立即再次发话 |
| 回日常 | 切回 ≤200ms,日常从暂停点续播 |
| 持续 | 单次会话 ≥10 分钟不崩、显存不涨 |
| 失败路径 | TTS 断连时有可见错误提示,可恢复 |
## 7. 职责划分(衔接 HEL-714 施工)
- 客户端(web):日常选片/循环、交谈视图挂载(预连接 WebRTC)、打断信号(先按钮/快捷键,VAD 后置)、失败回退 UI。
- 服务端:LiveTalking(交谈渲染 + TTS 适配,本分支 patch);日常视频静态文件由现有 web 服务出。
- 素材:美术/施工按第 1 节规范产出,总工验收导入。
+42
View File
@@ -0,0 +1,42 @@
# HEL-715 实验复现说明
实验环境(.12,全部隔离在 `/opt/hel715-verify/`,未改系统 Python/CUDA/ASR/TTS):
- LiveTalking c726943(官方 GitHub)+ MuseTalk v1.5 权重 3.8GB(`LiveTalking/models/`)
- venv:`/opt/hel715-verify/venv`(torch 2.8.0+cu128、aiortc 1.15.0、PyAV 17.1.0)
- 既有服务复用:CosyVoice TTS `http://127.0.0.1:9880`(音色 xiaoyun)、LLM `http://192.168.200.11:8731`(halogen-qwen3.8-flash-next)
- 对上游的两处改动:`livetalking-patch/cosyvoice.py`(新增,对接 .12 CosyVoice)、`livetalking-patch/enable-cosyvoice-registration.diff`(base_avatar.py 启用注册)
## 启动
```bash
cd /opt/hel715-verify/LiveTalking
nohup /opt/hel715-verify/venv/bin/python app.py --model musetalk \
--avatar_id hel715-talk-close --transport webrtc \
--tts cosyvoice --REF_FILE xiaoyun \
--TTS_SERVER http://127.0.0.1:9880 --listenport 8010 \
> /opt/hel715-verify/livetalking.log 2>&1 &
```
注意:仓库内 `config.yaml` 的 `TTS_SERVER: ''` 会覆盖 argparse 默认值,必须显式传 `--TTS_SERVER`(本轮曾因此空跑过一次)。
已生成 avatar(`LiveTalking/data/avatars/`):`hel715-loop-wide`(loop-25fps.mp4,196 帧全景)、`hel715-talk-close`(talk-close.mp4,196 帧近景=loop 1.75× 裁切)、`hel715-return`(return-25fps.mp4,77 帧独立源)。
## 测试脚本
- `scripts/e2e_client.py`:aiortc 收流客户端,四句时间线(含打断),产出 mp4+stats.json+events.json+contact-sheet.png。在 .12 上运行:`E2E_OUT=... venv/bin/python e2e_client.py`。
- `scripts/llm_chain2.py`:LLM→TTS→数字人单链路采样。
- `scripts/hel715-chain.html`:日常→交谈→打断→回日常→再交谈演示页(部署于 LiveTalking `web/`,`?auto=1` 自动演示;依赖 `web/hel715-daily.webm`)。
- `scripts/record_chain.py`:本机(192.168.200.13)Playwright+Chromium 录制上述页面,需 LD_LIBRARY_PATH 指向解包的运行库(脚本内已写死 /tmp 路径,迁移时改)。
## 清理
```bash
# 停实验服务(先对 PID:pgrep -af 'app.py --model musetalk')
pkill -f 'app.py --model musetalk'
# 删除整个实验目录(含 venv/权重/素材,约 12GB)
rm -rf /opt/hel715-verify
# 演示页随目录一起消失;18080/18081 旧演示与 ASR/TTS 未动
```
本轮收尾状态:LiveTalking 已停止;目录与权重保留供 HEL-714 复用。
+73
View File
@@ -0,0 +1,73 @@
# HEL-715 技术验证报告:本地真人交谈与日常视频衔接
日期:2026-10-20 | 环境:192.168.200.12(RTX 5090D 32GB,torch 2.8.0+cu128)
候选:LiveTalking @ c726943(官方 GitHub)+ MuseTalk v1.5 + .12 既有 CosyVoice(9880) + .11 LLM(halogen-qwen3.8-flash-next)
## 一句话结论
**有条件可用。** 日常↔交谈切换、打断、25fps 连续口型视频、素材流水线复用均实测通过;条件是接受 ①发话到出声约 4.5~5.5s 的起步延迟,②TTS 流式按标点分句带来的句内 0.4~1.1s 停顿,③交谈驱动只改口型(表情/目光来自底板视频)。三项都需要在首版验收时向用户如实展示,其中 ①② 可在施工阶段继续压。
## 四个问题的实测回答
### 1. 主候选 LiveTalking+MuseTalk 实际跑通了吗?——跑通
- 安装:官方仓库 clone 至 `/opt/hel715-verify/LiveTalking`,独立 venv(不改系统 Python/CUDA/ASR/TTS);权重 musetalkV15/face-parse/sd-vae/whisper 共约 3.8GB,已就位。
- 适配:仅两处改动(见 `livetalking-patch/`):`tts/cosyvoice.py` 新增(对接 .12 既有 CosyVoice 的 POST /tts 24kHz PCM 流)+ `avatars/base_avatar.py` 启用该 TTS 注册。其余代码未动。
- 能力边界(CG-028 相关,明确来源):
- **口型**:实时音频驱动(whisper 特征→unet 逐帧生成下半脸)——实测(口部区差分:静息中位 0.74 vs 说话峰值 13~14)。
- **表情/目光/头姿/身体**:全部来自底板视频循环(预处理时逐帧 landmark+face-parse 固化),**不是**实时生成——来源:代码结构+录像复核。交谈底板选片时必须挑"目视镜头、微动作"段。
- 不要求也不提供逐句新肩部手势;与 CG-027/028 的预期一致,未发现必须换 Ditto 等替代的实证。
### 2. 同一人物实时交谈可否成立?——可以,起步延迟与句内停顿需接受
实测(aiortc 客户端走真实 WebRTC 通道,含网络传输;e2e-session.mp4 / stats.json):
| 指标 | 实测值 |
| --- | --- |
| 连接→首帧视频 | 3.6s(含 ICE 协商 1.7s) |
| 文本→首次出声 | 4.6~5.5s(CosyVoice 首包 3.4s + LiveTalking 音频缓冲约 1.2s) |
| 连续帧率 | 25.05fps 恒定(27s 全程,说话与静息一致) |
| 音画同步 | 容器内音视频齐备;口型动作较出声晚约 0.5~1.3s(MuseTalk 音频前瞻缓冲所致)。自动互相关度量噪声大,最终以录像人工复核为准 |
| 句内停顿 | TTS 按标点分句合成,句内间隙 0.45~1.05s("抱歉"与"你刚想说什么"之间 0.66s)——CosyVoice 流式模式固有,非口型管线问题 |
| 打断→静音 | **0.35s**(interrupt_talk 后队列即清) |
| 打断后再开口 | 正常(S3/S4 均正常播出) |
| 峰值资源 | 总显存 18.9GB(基线 5.8GB + CosyVoice 4.2GB + LiveTalking 空载 11.2GB/说话峰值约 13.1GB);GPU 利用率瞬时 100% 但帧率不跌 |
| 失败行为 | 本轮未触发服务崩溃;语音名不存在时 TTS 适配层回退 default 音色 |
LLM 链路(llm-chain.mp4):.11 真实模型 halogen-qwen3.8-flash-next 回复 1.33s → 递给数字人出声共 4.63s,全程录音正常。
### 3. 日常→交谈→打断→回日常衔接?——信号级链路实测通过
两份证据:
- **浏览器真实链路**(chain/page@*.webm,Playwright+Chromium 走 LAN WebRTC):日常成品视频循环 → 切交谈视图(预连接流,切换 0~3ms)→ 发文本 → 语音进行中注入 interrupt_talk(HTTP 2~5ms)→ 立即切回日常(不等她说完、不等日常片尾)→ 再次进入交谈并发第二句 → 回日常。全程 33.5s,页面零 JS 错误。注:Playwright 录屏不含音轨,音频证据见下条。
- **媒体级连续音画**(e2e-session.mp4):同一 WebRTC 通道收到的连续音视频原样落盘,覆盖 静息→说话→打断→静默→再说话 全程,有声有画。
边界说明:麦克风/VAD 未接入,打断由信号注入触发,**只是信号级验证**,不冒充自然语音抢话已通过。转场为视图级切换(同人物同房间同服装的近景底板 vs 日常全景),自然转场(如淡入淡出)属施工项,不计入本轮结论。
### 4. 素材能否持续制作/替换/维护?——流水线已验证可复用
- 同一流水线已跑通 **3 份素材、3 个 avatar**:loop 全景(196 帧)、面部近景(196 帧,经核实为 loop 素材 1.75× 放大裁切,同人物同房间同服装)、回程片段(77 帧,独立源文件)。
- 新素材仅需:转码 25fps mp4 → `python -m avatars.musetalk.genavatar --file X --avatar_id Y`,47~60s/份(GPU),零代码改动、目录隔离、无硬编码人脸。
- 成本实测:交谈底板若由 Wan2.2 生成约 57s/3s 片段(HEL-712 实测);由既有成品裁切仅需秒级(PyAV)。详见 MATERIAL-SPEC.md。
- 限制:本轮三份素材同属一个人物;跨人物泛化未实测(离线环境无第二真人素材可用),算法层面为通用 landmark/face-parse 管线,无按脸硬编码逻辑。
## CG-029/028 能力矩阵(来源/实测/未测)
| 能力 | 状态 | 依据 |
| --- | --- | --- |
| 成品日常视频按时段/活动选片播放 | 实测(浏览器循环播放) | chain webm |
| 同人物近景实时口型交谈 | 实测 | e2e-session.mp4 / llm-chain.mp4 |
| 口型由音频实时驱动 | 实测 | 口部差分 15~19 倍 |
| 表情/目光实时生成 | 未测(来源=底板视频) | 代码结构 |
| 语音打断(信号级) | 实测 0.35s 停止 | events.json |
| 自然语音抢话(VAD) | 未测 | 麦克风未接 |
| 日常↔交谈视图切换 | 实测 0~3ms | chain webm + 日志 |
| LLM→TTS→口型全链 | 实测 | llm-chain.mp4 |
| 素材替换保留人格记忆 | 设计已验证路径(素材目录隔离) | genavatar 复用 |
## 风险与建议(给总管的决策输入)
1. **起步延迟 4.5~5.5s** 是当前体验最大短板:可在施工时用"思考中"微动作/状态提示掩盖,或后续换更低首包延迟的 TTS 配置(CosyVoice 首包 3.4s 占大头)。无已确认延迟门槛,不编造 SLA;建议向用户展示实测录像后再定验收线。
2. **句内停顿**可通过 TTS 侧整句缓冲或标点预处理缓解,代价是首包更慢——二者权衡留施工阶段。
3. LiveTalking 空载即占 11.2GB 显存,与 ComfyUI 重生成任务并存时需调度(先交谈后生成的队列约定)。
@@ -0,0 +1,82 @@
import time
import numpy as np
import resampy
import requests
from io import BytesIO
from typing import Iterator
from utils.logger import logger
from .base_tts import BaseTTS, State
from registry import register
@register("tts", "cosyvoice")
class CosyVoiceTTS(BaseTTS):
"""适配本机既有 CosyVoice3 流式 TTS (POST /tts -> 24kHz int16 PCM 流)."""
def txt_to_audio(self, msg: tuple[str, dict]):
text, textevent = msg
voice = textevent.get('tts', {}).get('voice', self.opt.REF_FILE or 'default')
self.stream_tts(self.cosyvoice(text, voice), msg)
def cosyvoice(self, text: str, voice: str) -> Iterator[bytes]:
start = time.perf_counter()
try:
res = requests.post(
f"{self.opt.TTS_SERVER}/tts",
json={"text": text, "voice": voice},
stream=True,
timeout=(5, 300),
)
if res.status_code != 200:
logger.error("cosyvoice http %s: %s", res.status_code, res.text[:200])
return
first = True
for chunk in res.iter_content(chunk_size=4800): # 100ms @24k int16
if first:
logger.info("cosyvoice time to first chunk: %.3fs", time.perf_counter() - start)
first = False
if chunk and self.state == State.RUNNING:
yield chunk
elif self.state != State.RUNNING:
logger.info("cosyvoice interrupted, stop consuming")
res.close()
return
except Exception:
logger.exception('cosyvoice tts error')
def stream_tts(self, audio_stream: Iterator[bytes], msg: tuple[str, dict]):
text, textevent = msg
first = True
for chunk in audio_stream:
if not chunk:
continue
pcm24 = np.frombuffer(chunk, dtype=np.int16).astype(np.float32) / 32767.0
stream = resampy.resample(pcm24, sr_orig=24000, sr_new=self.sample_rate)
streamlen = stream.shape[0]
idx = 0
while streamlen >= self.chunk:
eventpoint = {}
if first:
eventpoint = {'status': 'start', 'text': text}
first = False
eventpoint.update(**textevent)
self.parent.put_audio_frame(stream[idx:idx + self.chunk], eventpoint)
streamlen -= self.chunk
idx += self.chunk
eventpoint = {'status': 'end', 'text': text}
eventpoint.update(**textevent)
self.parent.put_audio_frame(np.zeros(self.chunk, np.float32), eventpoint)
diff --git a/avatars/base_avatar.py b/avatars/base_avatar.py
index 28c826a..a9744e6 100644
--- a/avatars/base_avatar.py
+++ b/avatars/base_avatar.py
@@ -90,7 +90,7 @@ class BaseAvatar:
'edgetts': 'tts.edge',
'gpt-sovits': 'tts.sovits',
'xtts': 'tts.xtts',
- # 'cosyvoice': 'tts.cosyvoice',
+ 'cosyvoice': 'tts.cosyvoice',
# 'fishtts': 'tts.fish',
'tencent': 'tts.tencent',
'doubao': 'tts.doubao',
@@ -0,0 +1,69 @@
import time
import numpy as np
import resampy
import requests
from io import BytesIO
from typing import Iterator
from utils.logger import logger
from .base_tts import BaseTTS, State
from registry import register
@register("tts", "cosyvoice")
class CosyVoiceTTS(BaseTTS):
"""适配本机既有 CosyVoice3 流式 TTS (POST /tts -> 24kHz int16 PCM 流)."""
def txt_to_audio(self, msg: tuple[str, dict]):
text, textevent = msg
voice = textevent.get('tts', {}).get('voice', self.opt.REF_FILE or 'default')
self.stream_tts(self.cosyvoice(text, voice), msg)
def cosyvoice(self, text: str, voice: str) -> Iterator[bytes]:
start = time.perf_counter()
try:
res = requests.post(
f"{self.opt.TTS_SERVER}/tts",
json={"text": text, "voice": voice},
stream=True,
timeout=(5, 300),
)
if res.status_code != 200:
logger.error("cosyvoice http %s: %s", res.status_code, res.text[:200])
return
first = True
for chunk in res.iter_content(chunk_size=4800): # 100ms @24k int16
if first:
logger.info("cosyvoice time to first chunk: %.3fs", time.perf_counter() - start)
first = False
if chunk and self.state == State.RUNNING:
yield chunk
elif self.state != State.RUNNING:
logger.info("cosyvoice interrupted, stop consuming")
res.close()
return
except Exception:
logger.exception('cosyvoice tts error')
def stream_tts(self, audio_stream: Iterator[bytes], msg: tuple[str, dict]):
text, textevent = msg
first = True
for chunk in audio_stream:
if not chunk:
continue
pcm24 = np.frombuffer(chunk, dtype=np.int16).astype(np.float32) / 32767.0
stream = resampy.resample(pcm24, sr_orig=24000, sr_new=self.sample_rate)
streamlen = stream.shape[0]
idx = 0
while streamlen >= self.chunk:
eventpoint = {}
if first:
eventpoint = {'status': 'start', 'text': text}
first = False
eventpoint.update(**textevent)
self.parent.put_audio_frame(stream[idx:idx + self.chunk], eventpoint)
streamlen -= self.chunk
idx += self.chunk
eventpoint = {'status': 'end', 'text': text}
eventpoint.update(**textevent)
self.parent.put_audio_frame(np.zeros(self.chunk, np.float32), eventpoint)
@@ -0,0 +1,13 @@
diff --git a/avatars/base_avatar.py b/avatars/base_avatar.py
index 28c826a..a9744e6 100644
--- a/avatars/base_avatar.py
+++ b/avatars/base_avatar.py
@@ -90,7 +90,7 @@ class BaseAvatar:
'edgetts': 'tts.edge',
'gpt-sovits': 'tts.sovits',
'xtts': 'tts.xtts',
- # 'cosyvoice': 'tts.cosyvoice',
+ 'cosyvoice': 'tts.cosyvoice',
# 'fishtts': 'tts.fish',
'tencent': 'tts.tencent',
'doubao': 'tts.doubao',
+375
View File
@@ -0,0 +1,375 @@
#!/usr/bin/env python
"""HEL-715 end-to-end verification client for LiveTalking(MuseTalk) @ 127.0.0.1:8010.
Connects as a WebRTC receiver (same path a browser takes), drives the avatar via
/human + /interrupt_talk, records the received A/V to mp4 and logs timing stats.
"""
import asyncio
import json
import os
import time
import aiohttp
import av
import numpy as np
from aiortc import RTCPeerConnection, RTCSessionDescription
BASE = os.environ.get("LT_BASE", "http://127.0.0.1:8010")
OUT = os.environ.get("E2E_OUT", "/opt/hel715-verify/e2e")
os.makedirs(OUT, exist_ok=True)
S1 = "在呢。你回来啦,今天在外面累不累?"
S2 = "还有,我跟你说,今天下午我在窗边看了好久的天空,云跑得特别快,有一朵特别像小兔子,我还给它拍了张照片,等下拿给你看。"
S3 = "抱歉,你刚想说什么?我在听。"
S4 = "嗯,我在。"
# approx mouth region (lower third of face bbox 218,203,324,333 from avatar prep)
MOUTH = (222, 268, 320, 331)
log_lines = []
def log(msg):
line = f"[{time.strftime('%H:%M:%S')}] {msg}"
print(line, flush=True)
log_lines.append(line)
class Recorder:
def __init__(self, path, w, h):
self.container = av.open(path, "w")
self.vstream = self.container.add_stream("libx264", rate=25)
self.vstream.width = w
self.vstream.height = h
self.vstream.pix_fmt = "yuv420p"
self.vstream.options = {"crf": "20", "preset": "veryfast"}
self.astream = self.container.add_stream("aac", rate=48000)
self.astream.layout = "stereo"
def write_video(self, img_bgr):
frame = av.VideoFrame.from_ndarray(img_bgr, format="bgr24")
for p in self.vstream.encode(frame):
self.container.mux(p)
def write_audio(self, pcm_i16, sr, ch):
arr = pcm_i16.astype(np.int16)
if ch == 1:
arr = np.repeat(arr[:, None], 2, axis=1).reshape(-1)
elif ch == 2:
arr = arr.reshape(-1)
else:
arr = arr[: len(arr) // ch * ch].reshape(-1, ch)[:, :2].reshape(-1)
if sr != 48000:
n = len(arr) // 2
idx = (np.arange(n) * (sr / 48000.0)).astype(int)
idx = idx[idx < len(arr) // 2]
arr = arr.reshape(-1, 2)[idx].reshape(-1)
frame = av.AudioFrame.from_ndarray(
np.ascontiguousarray(arr.reshape(1, -1)), format="s16", layout="stereo"
)
frame.sample_rate = 48000
for p in self.astream.encode(frame):
self.container.mux(p)
def close(self):
for p in self.vstream.encode():
self.container.mux(p)
for p in self.astream.encode():
self.container.mux(p)
self.container.close()
class Session:
def __init__(self):
self.events = {"video": [], "audio": [], "speak": [], "actions": []}
self.imgs_sample = []
self.sid = None
self.http = None
async def post(self, path, payload):
t0 = time.perf_counter()
async with self.http.post(BASE + path, json=payload) as r:
body = await r.json()
return time.perf_counter() - t0, body
async def poll_speaking(self, stop_evt):
while not stop_evt.is_set():
try:
dt, body = await self.post("/is_speaking", {"sessionid": self.sid})
self.events["speak"].append(
[round(time.perf_counter(), 3), body.get("data", False)]
)
except Exception:
pass
await asyncio.sleep(0.25)
def mouth_motion(prev, cur):
if prev is None or cur is None:
return 0.0
x0, y0, x1, y1 = MOUTH
d = np.abs(
cur[y0:y1, x0:x1].astype(np.int16) - prev[y0:y1, x0:x1].astype(np.int16)
).mean()
return float(d)
async def main():
stats = {"sentences": {}, "meta": {}}
rec = None
pc = RTCPeerConnection()
sess = Session()
t_start = time.perf_counter()
done = asyncio.Event()
@pc.on("track")
def on_track(track):
log(f"track: {track.kind}")
if track.kind == "video":
asyncio.ensure_future(video_loop(track))
else:
asyncio.ensure_future(audio_loop(track))
async def video_loop(track):
nonlocal rec
prev = None
while True:
try:
frame = await track.recv()
except Exception:
break
img = frame.to_ndarray(format="bgr24")
if rec is None:
rec = Recorder(os.path.join(OUT, "e2e-session.mp4"), img.shape[1], img.shape[0])
log(f"recorder ready {img.shape[1]}x{img.shape[0]}")
t = time.perf_counter()
m = mouth_motion(prev, img)
sess.events["video"].append([round(t, 3), round(m, 2)])
prev = img
rec.write_video(img)
if len(sess.imgs_sample) < 400:
sess.imgs_sample.append((round(t - t_start, 2), img))
async def audio_loop(track):
while True:
try:
frame = await track.recv()
except Exception:
break
t = time.perf_counter()
arr = frame.to_ndarray()
if arr.dtype != np.int16:
if arr.dtype in (np.float32, np.float64):
arr = (np.clip(arr, -1, 1) * 32767).astype(np.int16)
else:
arr = arr.astype(np.int16)
ch = len(frame.layout.channels)
if arr.ndim == 2 and arr.shape[0] == 1:
pcm = arr.reshape(-1) # packed s16
elif arr.ndim == 2 and arr.shape[0] == ch:
pcm = arr.T.reshape(-1) # planar
else:
pcm = arr.reshape(-1)
ch = 1
rms = float(np.sqrt((pcm.astype(np.float64) ** 2).mean()) / 32767.0)
sess.events["audio"].append([round(t, 3), round(rms, 5)])
if rec is not None:
rec.write_audio(pcm, frame.sample_rate, ch)
async with aiohttp.ClientSession() as http:
sess.http = http
pc.addTransceiver("video", direction="recvonly")
pc.addTransceiver("audio", direction="recvonly")
offer = await pc.createOffer()
await pc.setLocalDescription(offer)
while pc.iceGatheringState != "complete":
await asyncio.sleep(0.1)
t0 = time.perf_counter()
async with http.post(
BASE + "/offer", json={"sdp": pc.localDescription.sdp, "type": "offer"}
) as r:
answer = await r.json()
log(f"offer round-trip {time.perf_counter()-t0:.3f}s sessionid={answer.get('sessionid')}")
sess.sid = answer["sessionid"]
await pc.setRemoteDescription(
RTCSessionDescription(sdp=answer["sdp"], type=answer["type"])
)
stats["meta"]["sessionid"] = sess.sid
stop_evt = asyncio.Event()
poller = asyncio.ensure_future(sess.poll_speaking(stop_evt))
def mark(name):
t = time.perf_counter()
sess.events["actions"].append([round(t, 3), name])
log(f"ACTION {name}")
return t
# wait for first video frame / idle
for _ in range(200):
if sess.events["video"]:
break
await asyncio.sleep(0.1)
first_v = sess.events["video"][0][0] if sess.events["video"] else -1
first_a = sess.events["audio"][0][0] if sess.events["audio"] else -1
stats["meta"]["first_video_s"] = round(first_v - t_start, 3)
stats["meta"]["first_audio_s"] = round(first_a - t_start, 3)
log(f"first video {stats['meta']['first_video_s']}s audio {stats['meta']['first_audio_s']}s")
await asyncio.sleep(3.0) # idle baseline
async def speak(name, text, wait_end=True, interrupt_after=None, next_line=None):
t_send = mark(f"send:{name}")
dt, body = await sess.post(
"/human", {"sessionid": sess.sid, "type": "echo", "text": text}
)
log(f"/human {name} ack {dt:.3f}s {body}")
t_interrupt = None
if interrupt_after is not None:
await asyncio.sleep(interrupt_after)
t_interrupt = mark(f"interrupt:{name}")
dt2, b2 = await sess.post("/interrupt_talk", {"sessionid": sess.sid})
log(f"/interrupt_talk ack {dt2:.3f}s {b2}")
if wait_end:
# wait until speaking False and audio silent
silent = 0.0
last_speak_true = time.perf_counter()
while True:
await asyncio.sleep(0.1)
now = time.perf_counter()
sp = sess.events["speak"][-1][1] if sess.events["speak"] else None
if sp:
last_speak_true = now
recent = [a for a in sess.events["audio"] if a[0] > now - 0.6]
loud = any(a[1] > 0.02 for a in recent)
if not sp and not loud:
silent = now - last_speak_true
if silent > 1.2:
break
else:
last_speak_true = max(last_speak_true, now)
return t_send, t_interrupt
# S1 short
s, _ = await speak("S1", S1)
stats["sentences"]["S1"] = {"text": S1, "sent_at": round(s - t_start, 2)}
await asyncio.sleep(1.5)
# S2 long with interrupt 3s after send
s2, ti = await speak("S2", S2, wait_end=True, interrupt_after=3.0)
stats["sentences"]["S2"] = {
"text": S2, "sent_at": round(s2 - t_start, 2),
"interrupt_at": round(ti - t_start, 2),
}
await asyncio.sleep(2.0)
# S3 re-open after interrupt
s3, _ = await speak("S3", S3)
stats["sentences"]["S3"] = {"text": S3, "sent_at": round(s3 - t_start, 2)}
await asyncio.sleep(3.0) # pause test
s4, _ = await speak("S4", S4)
stats["sentences"]["S4"] = {"text": S4, "sent_at": round(s4 - t_start, 2)}
mark("end")
await asyncio.sleep(2.0)
stop_evt.set()
poller.cancel()
await pc.close()
if rec:
rec.close()
log("recording closed")
# analysis
def rel(ts):
return round(ts - t_start, 3)
ev_a = sess.events["audio"]
ev_v = sess.events["video"]
for name, info in stats["sentences"].items():
sent = info.get("sent_at", 0) + t_start
aud = [a for a in ev_a if a[0] >= sent and a[1] > 0.02]
info["first_audible_rel_s"] = rel(aud[0][0]) if aud else None
if aud:
last = sent
for a in aud:
if a[0] - last < 1.5:
last = a[0]
info["last_audible_rel_s"] = rel(last)
if "interrupt_at" in info:
it = info["interrupt_at"] + t_start
after = [a for a in ev_a if a[0] >= it and a[1] > 0.02]
info["audible_after_interrupt_s"] = rel(after[0][0]) if after else None
tail = [a for a in ev_a if a[0] >= it]
tl = [a for a in tail if a[1] > 0.02]
info["last_audible_after_interrupt_rel_s"] = rel(tl[-1][0]) if tl else None
# video motion around each sentence
for name, info in stats["sentences"].items():
sent = info.get("sent_at", 0) + t_start
window = [v for v in ev_v if sent - 1.0 <= v[0] <= sent + 12]
base = [v for v in ev_v if v[0] < sent - 1.0][-60:]
if window and base:
b = np.median([v[1] for v in base])
info["mouth_motion_idle"] = round(float(b), 2)
peak = max(v[1] for v in window)
info["mouth_motion_peak"] = round(float(peak), 2)
# fps over speech windows
if ev_v:
dur = ev_v[-1][0] - ev_v[0][0]
stats["meta"]["video_frames"] = len(ev_v)
stats["meta"]["video_span_s"] = round(dur, 2)
stats["meta"]["avg_recv_fps"] = round(len(ev_v) / dur, 2) if dur > 0 else None
if ev_a:
dur = ev_a[-1][0] - ev_a[0][0]
stats["meta"]["audio_frames"] = len(ev_a)
stats["meta"]["audio_span_s"] = round(dur, 2)
stats["meta"]["wall_total_s"] = round(time.perf_counter() - t_start, 2)
with open(os.path.join(OUT, "stats.json"), "w") as f:
json.dump(stats, f, ensure_ascii=False, indent=1)
with open(os.path.join(OUT, "events.json"), "w") as f:
json.dump(
{
"t_start": t_start,
"video": sess.events["video"],
"audio": sess.events["audio"],
"speak": sess.events["speak"],
"actions": sess.events["actions"],
},
f,
)
with open(os.path.join(OUT, "run.log"), "w") as f:
f.write("\n".join(log_lines))
# contact sheet: pick frames around S1 onset, S2 mid, interrupt, S3
try:
from PIL import Image
picks = []
s1 = stats["sentences"]["S1"].get("first_audible_rel_s") or 5
s2i = stats["sentences"]["S2"].get("interrupt_at")
picks += [max(0, s1 - 1.5), s1 + 0.5, s1 + 2.5]
if s2i:
picks += [s2i - 1.0, s2i + 0.3, s2i + 1.5]
picks += [stats["sentences"]["S3"]["sent_at"] + 2.0]
tiles = []
for p in picks:
cand = min(sess.imgs_sample, key=lambda x: abs(x[0] - p))
im = Image.fromarray(cand[1][:, :, ::-1]).resize((276, 276))
tiles.append((cand[0], im))
sheet = Image.new("RGB", (276 * len(tiles), 276 + 18), "black")
from PIL import ImageDraw
d = ImageDraw.Draw(sheet)
for i, (ts, im) in enumerate(tiles):
sheet.paste(im, (i * 276, 18))
d.text((i * 276 + 4, 2), f"t={ts:.1f}s", fill="white")
sheet.save(os.path.join(OUT, "contact-sheet.png"))
except Exception as e:
log(f"contact sheet failed: {e}")
log("DONE")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8"/>
<title>HEL-715 日常→交谈→打断→日常 链路演示</title>
<style>
html,body{margin:0;height:100%;background:#000;color:#eee;font-family:"Noto Sans CJK SC",sans-serif;overflow:hidden}
#stage{position:relative;height:100%;display:flex;align-items:center;justify-content:center}
video{position:absolute;height:92%;top:2%;left:50%;transform:translateX(-50%);background:#000}
#daily{display:block}
#talk{display:none}
#hud{position:absolute;left:0;right:0;bottom:0;padding:10px 14px;background:rgba(0,0,0,.72);font-size:15px;line-height:1.5}
#phase{font-size:19px;font-weight:700;color:#ffd166}
#timer{position:absolute;top:8px;right:14px;font-family:monospace;font-size:18px;background:rgba(0,0,0,.6);padding:2px 8px;border-radius:4px}
#log{position:absolute;top:8px;left:14px;font-family:monospace;font-size:12px;background:rgba(0,0,0,.6);padding:6px 10px;border-radius:4px;max-height:46%;overflow:hidden;white-space:pre}
</style>
</head>
<body>
<div id="stage">
<video id="daily" autoplay loop muted playsinline>
<source src="/hel715-daily.webm" type="video/webm"/>
<source src="/hel715-daily.mp4" type="video/mp4"/>
</video>
<video id="talk" autoplay playsinline></video>
<div id="timer">0.0s</div>
<div id="log"></div>
<div id="hud"><div id="phase">初始化…</div><div id="note">成品日常视频循环播放 · 交谈流后台预连接</div></div>
</div>
<script>
const BASE = location.origin;
const $ = id => document.getElementById(id);
const t0 = performance.now();
const logs = [];
function now(){ return (performance.now()-t0)/1000; }
function log(msg){
const line = `${now().toFixed(2)}s ${msg}`;
logs.push(line);
$('log').textContent = logs.slice(-14).join('\n');
console.log('[chain]', line);
}
function phase(p, note){ $('phase').textContent = p; $('note').textContent = note||''; }
setInterval(()=>{ $('timer').textContent = now().toFixed(1)+'s'; }, 100);
/* ---------- WebRTC pre-connect (same path as LiveTalking client.js) ---------- */
let pc = null, sessionid = null, connected = false, firstTalkFrameAt = null;
async function connectTalk(){
pc = new RTCPeerConnection({sdpSemantics:'unified-plan'});
pc.addTransceiver('video',{direction:'recvonly'});
pc.addTransceiver('audio',{direction:'recvonly'});
pc.addEventListener('track', ev=>{
if(ev.track.kind==='video'){
firstTalkFrameAt = firstTalkFrameAt || now();
$('talk').srcObject = ev.streams[0];
log('交谈视频轨到达');
} else {
$('talk').srcObject = $('talk').srcObject || ev.streams[0];
}
});
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
while(pc.iceGatheringState!=='complete') await new Promise(r=>setTimeout(r,50));
const res = await fetch(BASE+'/offer',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({sdp:pc.localDescription.sdp,type:pc.localDescription.type})});
const answer = await res.json();
sessionid = answer.sessionid;
await pc.setRemoteDescription(answer);
log(`交谈流已连接 session=${sessionid.slice(0,8)}(预连接,用户尚未进入)`);
connected = true;
}
async function say(text){
const t=now();
const r = await fetch(BASE+'/human',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({sessionid, type:'echo', text})});
log(`发送对话文本(${text.slice(0,10)}…)HTTP ${(now()-t)*1000|0}ms`);
}
async function interrupt(){
const t=now();
await fetch(BASE+'/interrupt_talk',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({sessionid})});
log(`打断信号 interrupt_talk HTTP ${(now()-t)*1000|0}ms`);
}
/* ---------- view switching ---------- */
function showDaily(){ $('daily').style.display='block'; $('talk').style.display='none'; }
function showTalk(){ $('daily').style.display='none'; $('talk').style.display='block'; }
window.__chainLog = logs;
/* ---------- auto timeline ---------- */
const auto = new URLSearchParams(location.search).has('auto');
if(auto){
(async ()=>{
await connectTalk().catch(e=>log('连接失败: '+e));
phase('① 日常播放','成品日常原片(HEL-712 同人物素材)循环播放');
await sleep(7000);
const sw = now(); showTalk();
phase('② 进入交谈','同一人物·同一房间·同一套衣服 · 实时口型(MuseTalk)');
log(`切换到交谈视图(预连接流,切换耗时 ${(now()-sw)*1000|0}ms)`);
await say('你回来啦!我在窗边等你呢,快过来坐,我跟你说今天下午的云。');
await sleep(6500); // speech ongoing (~1s in voice)
phase('③ 用户开口→打断','interrupt_talk 信号注入 · 不等她说完、不等日常片尾');
const ti = now();
await interrupt();
showDaily(); // 回日常/倾听,不等待
log(`打断后立即切回日常视图(+${((now()-ti)*1000)|0}ms)`);
await sleep(4000);
showTalk();
phase('④ 再次交谈','打断后可立即重新开口(清空待播队列后)');
log('再次切换到交谈视图');
await say('嗯,你说,我在听。');
await sleep(8000);
showDaily();
phase('⑤ 回到日常','交谈结束 → 成品日常继续');
log('回到日常播放');
await sleep(4000);
phase('演示结束','HEL-715 链路取样:日常→交谈→打断→回日常→再交谈');
log('AUTO-DEMO-END');
})();
} else {
phase('手动模式','D=日常 T=进入交谈并发送 I=打断并回日常');
addEventListener('keydown', async ev=>{
if(ev.key==='d'){ showDaily(); log('手动: 日常'); }
if(ev.key==='t'){ showTalk(); log('手动: 交谈'); if(connected) await say('你回来啦!我在窗边等你呢。'); }
if(ev.key==='i'){ await interrupt(); showDaily(); log('手动: 打断→日常'); }
});
connectTalk().catch(e=>log('连接失败: '+e));
}
function sleep(ms){ return new Promise(r=>setTimeout(r,ms)); }
</script>
</body>
</html>
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python
"""LLM-chained conversation sample: .11 LLM -> .12 CosyVoice -> MuseTalk avatar.
Same receiver architecture as e2e_client.py (proven to capture A/V)."""
import asyncio, json, os, time
import aiohttp, av, numpy as np
from aiortc import RTCPeerConnection, RTCSessionDescription
BASE = "http://127.0.0.1:8010"
LLM = "http://192.168.200.11:8731/v1/chat/completions"
OUT = "/opt/hel715-verify/e2e"
os.makedirs(OUT, exist_ok=True)
class Recorder:
def __init__(self, path, w, h):
self.container = av.open(path, "w")
self.v = self.container.add_stream("libx264", rate=25)
self.v.width, self.v.height, self.v.pix_fmt = w, h, "yuv420p"
self.v.options = {"crf": "20", "preset": "veryfast"}
self.a = self.container.add_stream("aac", rate=48000)
self.a.layout = "stereo"
def video(self, img):
for p in self.v.encode(av.VideoFrame.from_ndarray(img, format="bgr24")):
self.container.mux(p)
def audio(self, frame):
arr = frame.to_ndarray()
if arr.dtype != np.int16:
arr = (np.clip(arr, -1, 1) * 32767).astype(np.int16)
ch = len(frame.layout.channels)
if arr.ndim == 2 and arr.shape[0] == 1:
pcm = arr.reshape(-1)
elif arr.ndim == 2 and arr.shape[0] == ch:
pcm = arr.T.reshape(-1)
else:
pcm = arr.reshape(-1)
fr = av.AudioFrame.from_ndarray(np.ascontiguousarray(pcm.reshape(1, -1)), format="s16", layout="stereo")
fr.sample_rate = 48000
for p in self.a.encode(fr):
self.container.mux(p)
def close(self):
for p in self.v.encode(): self.container.mux(p)
for p in self.a.encode(): self.container.mux(p)
self.container.close()
async def main():
t0 = time.perf_counter()
result = {}
async with aiohttp.ClientSession() as http:
async with http.post(LLM, json={
"model": "halogen-qwen3.8-flash-next",
"messages": [{"role": "user", "content": "你是住在高层公寓卧室里的伴侣晚晚,用一两句自然中文回应:我回来了,今天好累啊。"}],
"max_tokens": 80,
}) as r:
body = await r.json()
reply = body["choices"][0]["message"]["content"]
result["llm_model"] = body["model"]
result["llm_reply"] = reply
result["llm_latency_s"] = round(time.perf_counter() - t0, 2)
print(f"[{result['llm_latency_s']}s] LLM: {reply}", flush=True)
pc = RTCPeerConnection()
rec = {"r": None}
audio_marks = []
@pc.on("track")
def on_track(track):
async def drain():
while True:
try:
f = await track.recv()
except Exception:
break
t = time.perf_counter()
if track.kind == "video":
img = f.to_ndarray(format="bgr24")
if rec["r"] is None:
rec["r"] = Recorder(f"{OUT}/llm-chain.mp4", img.shape[1], img.shape[0])
rec["r"].video(img)
else:
arr = f.to_ndarray()
if arr.dtype != np.int16:
arr = (np.clip(arr, -1, 1) * 32767).astype(np.int16)
rms = float(np.sqrt((arr.astype(np.float64) ** 2).mean()) / 32767)
audio_marks.append([round(t - t0, 3), round(rms, 5)])
if rec["r"] is not None:
rec["r"].audio(f)
asyncio.ensure_future(drain())
pc.addTransceiver("video", direction="recvonly")
pc.addTransceiver("audio", direction="recvonly")
offer = await pc.createOffer()
await pc.setLocalDescription(offer)
while pc.iceGatheringState != "complete":
await asyncio.sleep(0.1)
async with http.post(BASE + "/offer", json={"sdp": pc.localDescription.sdp, "type": "offer"}) as r:
answer = await r.json()
await pc.setRemoteDescription(RTCSessionDescription(sdp=answer["sdp"], type=answer["type"]))
sid = answer["sessionid"]
await asyncio.sleep(3)
t_send = time.perf_counter() - t0
async with http.post(BASE + "/human", json={"sessionid": sid, "type": "echo", "text": reply}) as r:
await r.json()
print(f"[{t_send:.2f}s] sent to avatar", flush=True)
await asyncio.sleep(15)
await pc.close()
if rec["r"]:
rec["r"].close()
loud = [m for _, m in audio_marks if _ and m > 0.02]
first_loud = next((t for t, m in audio_marks if m > 0.02), None)
result["sent_at_s"] = round(t_send, 2)
result["first_audible_s"] = round(first_loud, 2) if first_loud else None
result["send_to_audible_s"] = round(first_loud - t_send, 2) if first_loud else None
with open(f"{OUT}/llm-chain-stats.json", "w") as f:
json.dump(result, f, ensure_ascii=False, indent=1)
print(json.dumps(result, ensure_ascii=False), flush=True)
print("DONE", flush=True)
asyncio.run(main())
@@ -0,0 +1,59 @@
import asyncio
import json
import os
import pathlib
os.environ["LD_LIBRARY_PATH"] = (
"/tmp/multica-task-4070005680/debs/root/usr/lib/x86_64-linux-gnu"
+ (":" + os.environ["LD_LIBRARY_PATH"] if os.environ.get("LD_LIBRARY_PATH") else "")
)
from playwright.async_api import async_playwright
OUT = pathlib.Path("/tmp/multica-task-4070005680/opencode/chain")
OUT.mkdir(exist_ok=True)
URL = "http://192.168.200.12:8010/hel715-chain.html?auto=1"
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(
args=["--autoplay-policy=no-user-gesture-required", "--no-sandbox"]
)
ctx = await browser.new_context(
viewport={"width": 1280, "height": 800},
record_video_dir=str(OUT),
record_video_size={"width": 1280, "height": 800},
)
page = await ctx.new_page()
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
await page.goto(URL, wait_until="domcontentloaded")
# wait for demo end marker (auto demo ~37s)
try:
await page.wait_for_function(
"() => window.__chainLog && window.__chainLog.some(l => l.includes('AUTO-DEMO-END'))",
timeout=60000,
)
except Exception as e:
errors.append(f"wait_for_function: {e}")
await asyncio.sleep(1.5)
logs = await page.evaluate("() => window.__chainLog")
metrics = {
"url": URL,
"chain_log": logs,
"pageerrors": errors,
}
(OUT / "chain-browser-log.json").write_text(
json.dumps(metrics, ensure_ascii=False, indent=1)
)
video = page.video
await ctx.close()
path = await video.path()
await browser.close()
print("video:", path)
print("errors:", errors)
print(json.dumps(logs, ensure_ascii=False, indent=1))
asyncio.run(main())