Skip to content

[WebRTC Demo] macOS M4 部署踩坑汇总与修复:端口冲突 / 电流音 / No Speech Detected + 延迟分析 #75

@seasidedog24

Description

@seasidedog24

概述

在 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.cppensure_hamming_window_2n() 使用 Hamming 窗 做 overlap-add crossfade,存在两个数学缺陷:

  1. 边界不归零: Hamming 窗两端值 ≈ 0.08,在 chunk 接缝处产生约 8% 的信号泄漏(幅度跳变)
  2. 不互补: 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 而不覆盖 statuslocked_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
结论 ✅ 实时 ❌ 不实时 ❌ 不实时

延迟录屏

录屏展示了实际通话中的延迟卡顿情况:

📹 延迟录屏下载

可能的优化方向

  1. 降低 n_timesteps: 如果能重新生成 prompt_cache.gguf(用 n_timesteps=5),理论上可以将 Token2Mel 时间减半
  2. GPU 调度优化: LLM / TTS / Token2Mel 三个模型竞争 Metal GPU,考虑错峰调度
  3. 提供 ARM64 Docker 镜像: 当前前端/后端 Docker 镜像为 linux/amd64,通过 Rosetta 2 运行有额外开销

其他注意事项

  • macOS 上部署前,用 lsof -i :8021 检查端口可用性
  • n_timestepsprompt_cache.gguf 绑定,不能通过 init 参数随意修改(否则 Token2Wav 初始化失败)
  • 修改 C++ 代码后必须重新编译 并重启服务进程(旧进程使用内存中的旧代码)
  • 健康检查服务器在 port+1(如 9061)独立运行,即使推理繁忙也能正常响应

修复文件汇总

文件 修改内容
docker-compose.yml 端口 8025:8021NEXT_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 窗

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions