-
Notifications
You must be signed in to change notification settings - Fork 41
Description
概述
在 Mac mini M4 (24GB) 上完成 WebRTC Demo 本地部署后,遇到了多个问题并逐一排查修复。目前已可以正常跑通全双工视频通话,但存在明显的延迟卡顿(Token2Mel RTF > 1.0x)。本 Issue 汇总所有发现和修复方案,附带延迟录屏,供社区参考和官方改进。
⚠️ 关于 Issue #74 的重要更新: 之前在 #74 中建议增加心跳注册循环,但深入排查后发现 心跳注册会覆盖 Redis 中的服务锁状态,导致 "No Speech Detected"。请勿使用while true; do curl register; sleep 30; done循环!详见下方根因 3.1。
环境信息
| 项目 | 值 |
|---|---|
| 设备 | Mac mini M4, 24GB |
| macOS | Darwin 25.2.0 |
| Docker Desktop | 4.59.1 |
| Python | 3.14.2 |
| 模型 | MiniCPM-o-4_5 Q4_K_M |
| 部署模式 | duplex(全双工) |
| Docker 镜像 | 原始镜像(linux/amd64,通过 Rosetta 2 运行) |
问题 1:macOS 端口 8021 被系统服务占用
症状
Docker 容器启动后,前端请求后端失败,前端显示 "体验人数已满" 等错误。
根因
macOS 自带的 launchpad 相关服务占用了 8021 端口,与 Docker 的宿主机端口映射冲突。
修复方案
修改 docker-compose.yml,将宿主机端口改为 8025:
# docker-compose.yml
backend:
ports:
- "8025:8021" # 宿主机 8025 -> 容器 8021
frontend:
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8025同时修改 minicpmo_cpp_http_server.py 中的服务注册 URL 端口(或通过环境变量 BACKEND_API_PORT 配置)。
建议:macOS 上部署前,先用
lsof -i :8021检查端口是否被占用。
问题 2:TTS 电流音 — Hamming 窗函数缺陷
症状
TTS 音频有规律的 "嘀嗒/电流" 声,约每秒出现 1 次。
根因
token2wav-impl.cpp 中 ensure_hamming_window_2n() 使用 Hamming 窗 做 overlap-add crossfade,存在两个数学缺陷:
- 边界不归零: Hamming 窗两端值 ≈ 0.08,在 chunk 接缝处产生约 8% 的信号泄漏(幅度跳变)
- 不互补:
w[i] + w[i+n] ≠ 1,crossfade 中间区域能量下降约 8%,产生周期性音量凹陷
修复方案
替换为互补半 Hann 窗(complementary half-Hann),严格保证 fade_in[i] + fade_out[i] = 1.0:
void ensure_hamming_window_2n(int64_t n, std::vector<float> & window_out) {
- // Hamming window for overlap-add crossfade.
+ // Complementary half-Hann window for overlap-add crossfade.
window_out.clear();
if (n <= 0) { return; }
const int64_t N = 2 * n;
- const double denom = (N > 1) ? (double) (N - 1) : 1.0;
const double pi = std::acos(-1.0);
+ const double denom = (n > 1) ? (double) (n - 1) : 1.0;
window_out.resize((size_t) N);
- for (int64_t i = 0; i < N; ++i) {
- const double a = 2.0 * pi * (double) i / denom;
- window_out[(size_t) i] = (float) (0.54 - 0.46 * std::cos(a));
+ for (int64_t i = 0; i < n; ++i) {
+ // fade_in: 0 → 1 平滑上升 (half-Hann)
+ const double fade_in = 0.5 * (1.0 - std::cos(pi * (double) i / denom));
+ window_out[(size_t) i] = (float) fade_in; // 前半段: fade in
+ window_out[(size_t) (i + n)] = (float) (1.0 - fade_in); // 后半段: fade out
}
}涉及文件:
llama.cpp-omni/tools/omni/token2wav/token2wav-impl.cpp,函数ensure_hamming_window_2n()
修改后需重新编译:cmake --build build -j
问题 3:"No Speech Detected" — 三层根因叠加
这是最复杂的问题,由三个独立根因叠加导致。
3.1 根因(致命):心跳注册覆盖 Redis 服务锁状态
这也是 Issue #74 临时方案的副作用。
症状
会话开始约 20 秒后,前端显示 "No speech detected. Ending call..."(检测到长时间未说话,即将结束通话),机器人自动退出 LiveKit 房间。
根因
之前按 #74 的临时方案手动启动的 bash 心跳循环:
while true; do curl -X POST .../api/inference/register ...; sleep 30; done每次调用后端的 register_service() 接口时,后端无条件创建新的 InferenceService 对象(status=AVAILABLE, locked_by=None),直接覆盖 Redis 中已有的 BUSY 状态。后端的 _resource_monitor 线程检测到服务不是 BUSY → 触发 robot_exit → 前端显示 "No speech detected"。
证据链(来自后端日志):
07:17:22.831 - 服务锁定成功 (BUSY)
07:17:41.710 - renew_service_lock 成功 (最后一次成功续期)
07:17:42.150 - /api/inference/register 覆盖状态为 AVAILABLE ← 致命点
07:17:42.689 - renew_service_lock 失败 (服务已变为 AVAILABLE)
07:17:44.412 - _resource_monitor 触发 robot_exit
修复
- 不要运行心跳注册循环
- Python wrapper 启动时只注册一次即可
- 后端的
heartbeat_monitor通过 健康检查端口(port+1,即 9061)检测服务是否存活,不依赖重复注册
建议
register_service() 接口应该判断服务是否已存在并处于 BUSY 状态,如果是则只更新 last_heartbeat 而不覆盖 status 和 locked_by。当前实现是"幂等但不安全的"。
3.2 根因(加重):WAV Scanner 等待时间太短
症状
C++ decode SSE 流结束后,Python wrapper 只等 1 秒就放弃扫描 WAV 文件。
根因
minicpmo_cpp_http_server.py 中 _streaming_generate_duplex 的 final wait 阶段:
no_new_wav_count >= 10配合asyncio.sleep(0.1)= 仅 1 秒耐心- Token2Wav 在 M4 上需要 1.5-3 秒生成一个 chunk
- WAV 文件还没生成就被放弃了
修复
# 修改前
max_final_wait = 3.0 # 最多等 3 秒
if no_new_wav_count >= 10: # 1 秒无新 WAV 就放弃
break
# 修改后
max_final_wait = 15.0 # 最多等 15 秒
if no_new_wav_count >= 60: # 6 秒无新 WAV 才放弃
break
# 增加 generation_done.flag 检查,所有 WAV 生成完毕后提前退出3.3 根因(加重):is_listen 快速检查太短
症状
当 C++ 返回 is_listen=True(模型切换到聆听模式),Python wrapper 只做 50ms 的 quick check 就发送 is_listen 事件。
修复
# 修改前
while (time.time() - quick_check_start) < 0.05: # 仅 50ms
...
# 修改后
while (time.time() - quick_check_start) < 8.0: # 最多等 8 秒
...
if no_wav_count >= 60: # 6 秒无新 WAV
break
# 同样增加 generation_done.flag 提前退出涉及文件:
cpp_server/minicpmo_cpp_http_server.py
问题 4:延迟卡顿 — Token2Mel RTF > 1.0x(未解决)
修复上述问题后,全双工通话可以正常进行,但存在明显的延迟卡顿。
延迟数据(Mac mini M4 24GB)
| 调用 | Token2Mel | Vocoder | 总计 | RTF | 说明 |
|---|---|---|---|---|---|
| call=0 | 1013ms | 992ms | 2005ms | 2.39x | 冷启动 |
| call=5 | 552ms | 453ms | 1005ms | 1.01x | 最佳情况 |
| call=7 | 1888ms | 843ms | 2731ms | 2.73x | GPU 竞争严重 |
| call=14 | 2121ms | 919ms | 3039ms | 3.04x | 最差情况 |
| 平均 | ~1060ms | ~640ms | ~1700ms | ~1.7x | 无法实时 |
- RTF > 1.0 = 非实时(生成 1 秒音频需要 1.7 秒)
- Token2Mel 波动巨大(552ms ~ 2121ms),主要因为 LLM + TTS + Token2Mel 同时竞争 Metal GPU
- Token2Mel 使用
n_timesteps=10(与prompt_cache.gguf绑定,不可随意修改)
与其他设备的对比
| 指标 | M4 Max(官方) | M4 Pro 64GB(社区) | M4 24GB(本机) |
|---|---|---|---|
| Token2Mel | ~235ms | ~1500ms | ~1060ms(平均) |
| RTF | ~0.47x | ~2.0x | ~1.7x |
| 结论 | ✅ 实时 | ❌ 不实时 | ❌ 不实时 |
延迟录屏
录屏展示了实际通话中的延迟卡顿情况:
📹 延迟录屏下载
可能的优化方向
- 降低
n_timesteps: 如果能重新生成prompt_cache.gguf(用n_timesteps=5),理论上可以将 Token2Mel 时间减半 - GPU 调度优化: LLM / TTS / Token2Mel 三个模型竞争 Metal GPU,考虑错峰调度
- 提供 ARM64 Docker 镜像: 当前前端/后端 Docker 镜像为 linux/amd64,通过 Rosetta 2 运行有额外开销
其他注意事项
- macOS 上部署前,用
lsof -i :8021检查端口可用性 n_timesteps受prompt_cache.gguf绑定,不能通过 init 参数随意修改(否则 Token2Wav 初始化失败)- 修改 C++ 代码后必须重新编译 并重启服务进程(旧进程使用内存中的旧代码)
- 健康检查服务器在
port+1(如 9061)独立运行,即使推理繁忙也能正常响应
修复文件汇总
| 文件 | 修改内容 |
|---|---|
docker-compose.yml |
端口 8025:8021,NEXT_PUBLIC_API_URL |
cpp_server/minicpmo_cpp_http_server.py |
WAV Scanner 超时 + is_listen 等待 + 注册端口 |
llama.cpp-omni/tools/omni/token2wav/token2wav-impl.cpp |
Hamming → 互补半 Hann 窗 |