From e59b20f60fff034a7268febd3348b15fc767edc4 Mon Sep 17 00:00:00 2001
From: cai <nb666@nbcai.cc>
Date: Sun, 28 Jun 2026 16:18:44 +0800
Subject: [PATCH] feat: support streaming livekit helper
---
.env.example | 7
docs/runtime-contract.md | 206 ++
.cargo/config.toml | 16
src/audio.rs | 1037 +++++++---
.dockerignore | 3
.gitignore | 1
tools/validate-turn-stream-fixture.mjs | 147 +
generate-local-fixture.sh | 32
src/main.rs | 2646 ++++++++++++++++++++++++++-
Cargo.lock | 17
README.md | 259 ++
Dockerfile | 38
fixtures/turn-stream-mp3-chunks.ndjson | 6
run-local.sh | 387 ++-
Cargo.toml | 12
fixtures/turn-stream-happy.ndjson | 5
src/service.rs | 908 +++++++++
17 files changed, 5,021 insertions(+), 706 deletions(-)
diff --git a/.cargo/config.toml b/.cargo/config.toml
index 28aeac3..dfba410 100644
--- a/.cargo/config.toml
+++ b/.cargo/config.toml
@@ -1,8 +1,8 @@
-[source.crates-io]
-replace-with = "rsproxy"
-
-[source.rsproxy]
-registry = "sparse+https://rsproxy.cn/index/"
-
-[net]
-retry = 5
+[source.crates-io]
+replace-with = "rsproxy"
+
+[source.rsproxy]
+registry = "sparse+https://rsproxy.cn/index/"
+
+[net]
+retry = 5
diff --git a/.dockerignore b/.dockerignore
index 1f5535f..7daf6c2 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,5 +1,2 @@
target/
.local/
-.tmp/
-.git/
-.env
diff --git a/.env.example b/.env.example
index 8b02633..19f0f6f 100644
--- a/.env.example
+++ b/.env.example
@@ -27,3 +27,10 @@
# Local wrapper behavior.
COMBRABO_VOICE_RUNTIME_HELPER_MODE=auto
COMBRABO_VOICE_RUNTIME_HELPER_IMAGE=combrabo-voice-runtime-helper:local
+
+# Service mode control plane. Values below are placeholders only.
+# lmrobot-app calls helper with Authorization: Bearer {CV_HELPER_AUTH_TOKEN}.
+CV_HELPER_SERVICE_ENABLED=false
+CV_HELPER_SERVICE_BIND=127.0.0.1:18080
+CV_HELPER_AUTH_TOKEN=replace-with-local-helper-token
+CV_RUNTIME_TURN_BRIDGE_TOKEN=replace-with-local-turn-bridge-token
diff --git a/.gitignore b/.gitignore
index 7074eb8..1b373d3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,2 @@
.local/
-.tmp/
target/
diff --git a/Cargo.lock b/Cargo.lock
index b5e60c6..03c9ec2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -231,6 +231,8 @@
version = "0.1.0"
dependencies = [
"anyhow",
+ "base64 0.22.1",
+ "futures-util",
"hound",
"libwebrtc",
"livekit",
@@ -2296,12 +2298,14 @@
"sync_wrapper",
"tokio",
"tokio-rustls",
+ "tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
+ "wasm-streams",
"web-sys",
"webpki-roots",
]
@@ -3314,6 +3318,19 @@
]
[[package]]
+name = "wasm-streams"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index a2b6ff9..a6d823d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,13 +4,15 @@
edition = "2024"
license = "MIT"
-[dependencies]
-anyhow = "1.0.100"
-hound = "3.5.1"
-libwebrtc = "0.3.36"
+[dependencies]
+anyhow = "1.0.100"
+base64 = "0.22.1"
+futures-util = "0.3.31"
+hound = "3.5.1"
+libwebrtc = "0.3.36"
livekit = "0.7.45"
minimp3 = "0.5.1"
-reqwest = { version = "0.12.24", default-features = false, features = ["rustls-tls"] }
+reqwest = { version = "0.12.24", default-features = false, features = ["rustls-tls", "json", "stream"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "signal", "time"] }
diff --git a/Dockerfile b/Dockerfile
index 3d55983..7024ec5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,21 +1,21 @@
-# syntax=docker/dockerfile:1.7
-
-FROM rust:1.88-bookworm AS builder
-
-WORKDIR /app
-ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
-ENV CARGO_HTTP_TIMEOUT=120
-ENV CARGO_NET_RETRY=5
-
-COPY Cargo.toml Cargo.lock ./
-COPY .cargo ./.cargo
-COPY src ./src
-
-RUN --mount=type=cache,target=/usr/local/cargo/registry \
- --mount=type=cache,target=/app/target \
- cargo fetch --locked \
- && cargo build --release --locked \
- && cp /app/target/release/combrabo-voice-runtime-helper /usr/local/bin/combrabo-voice-runtime-helper
+# syntax=docker/dockerfile:1.7
+
+FROM rust:1.88-bookworm AS builder
+
+WORKDIR /app
+ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
+ENV CARGO_HTTP_TIMEOUT=120
+ENV CARGO_NET_RETRY=5
+
+COPY Cargo.toml Cargo.lock ./
+COPY .cargo ./.cargo
+COPY src ./src
+
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+ --mount=type=cache,target=/app/target \
+ cargo fetch --locked \
+ && cargo build --release --locked \
+ && cp /app/target/release/combrabo-voice-runtime-helper /usr/local/bin/combrabo-voice-runtime-helper
FROM debian:bookworm-slim
@@ -23,6 +23,6 @@
&& apt-get install -y --no-install-recommends ca-certificates libstdc++6 libgcc-s1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
-COPY --from=builder /usr/local/bin/combrabo-voice-runtime-helper /usr/local/bin/combrabo-voice-runtime-helper
+COPY --from=builder /usr/local/bin/combrabo-voice-runtime-helper /usr/local/bin/combrabo-voice-runtime-helper
ENTRYPOINT ["/usr/local/bin/combrabo-voice-runtime-helper"]
diff --git a/README.md b/README.md
index 020355c..9ea88e6 100644
--- a/README.md
+++ b/README.md
@@ -1,100 +1,269 @@
-# lm-livekit-helper
+# Combrabo Voice Runtime Helper
-`lm-livekit-helper` 是 Combrabo Voice 的 LiveKit 媒体 worker。它从 `lmrobot-app` 接收每次通话的 `CV_*` 运行参数,加入 LiveKit 房间,发布 bot 音轨,播放主动问候音频,并在 S7 阶段承接用户语音 turn 的媒体输入输出。
+这是 `lmrobot-app` Combrabo Voice 一阶段的最小 external runtime helper。它的长期定位是 LiveKit media worker:连接房间、发布 bot 音轨、订阅用户音轨、搬运 / 观测 PCM、输出脱敏诊断。
-它不是业务服务,不承担鉴权、数据库、订单、ASR/LLM/TTS 权威、消息落库或 diagnostics 聚合。业务权威仍在 `lmrobot-app`。
+当前职责分两层:
-## 当前职责
+一阶段固定主动问候职责有 4 件事:
-1. 读取 `lmrobot-app` 注入的 `CV_*` 环境变量;
-2. 使用 bot token 连接 LiveKit room;
+1. 读取 `CombraboVoiceRuntimeServiceImpl` 注入的 `CV_*` 环境变量;
+2. 使用 bot token 连接 local LiveKit room;
3. 发布 bot 本地音轨;
-4. 播放 WAV / MP3 主动问候音频;
-5. 在 smoke 模式下通过 LiveKit reliable Data Message 下发 `device_output`;
-6. S7 阶段通过 Java runtime turn bridge 复用后端 ASR / TextChat / TTS / Message 能力。
+4. 播放一段固定问候音频,并保持连接直到 `calls/end` 触发 stop。
+
+2026-06-24 起进入 helper 下一阶段入口:默认开启用户上行音频观测,先做远端用户音轨订阅和音频帧摘要日志,不做 ASR/LLM/TTS。
+
+它**不承担**鉴权、数据库、订单、旧 TRTC、角色 / 提示词、正式 ASR/LLM/TTS 编排、消息写入、计费或 diagnostics 聚合。上述业务能力继续由 `lmrobot-app` Java 后端复用现有体系承接。
+
+本定位已按 `cb-sdk` 真实链路校准:服务端 worker 负责 LiveKit 用户音频输入与 bot 音频输出,后端 speech-runtime 负责 ASR / Agent / TTS / turn 状态。`lmrobot` 当前 helper 只对齐 media worker / rtc 边界,不新建第二套业务后端。
## 本机构建
-```bash
-cargo build --release
+helper 编译固定分为“开发调试线”和“部署验证线”。详细口径见:
+
+```text
+doc/task/202606/0615-nativesdk-combrabo-voice-migration/29-helper编译与验证线路说明.md
```
-helper 目录内带 `.cargo/config.toml`,本机构建建议通过本仓库根目录执行,确保使用同一套 registry / retry 配置。
+本机开发调试优先复用已下载的 LiveKit WebRTC 预编译缓存:
+
+```bash
+export LK_CUSTOM_WEBRTC="$HOME/.cache/combrabo/livekit-webrtc/mac-arm64-release-webrtc-51ef663"
+```
+
+然后执行:
+
+```bash
+cargo fmt --manifest-path tools/combrabo-voice-runtime-helper/Cargo.toml --check
+cargo check --manifest-path tools/combrabo-voice-runtime-helper/Cargo.toml
+cargo test --manifest-path tools/combrabo-voice-runtime-helper/Cargo.toml
+```
+
+`LK_CUSTOM_WEBRTC` 指向的是 macOS host 开发调试缓存,不能直接用于 Linux Docker 镜像构建。
+
+```bash
+cargo build --manifest-path tools/combrabo-voice-runtime-helper/Cargo.toml --release
+```
+
+helper 目录内带 `.cargo/config.toml`,本机构建建议通过 `run-local.sh` 或进入 helper 目录执行,确保使用同一套 registry / retry 配置。
+
+## Turn stream 快速校验
+
+TTS streaming 相关改动优先跑一条不依赖 LiveKit / WebRTC native 编译链路的快速线:
+
+```bash
+node tools/validate-turn-stream-fixture.mjs fixtures/turn-stream-happy.ndjson
+node tools/validate-turn-stream-fixture.mjs fixtures/turn-stream-mp3-chunks.ndjson
+```
+
+这条快速线只校验 NDJSON contract、事件顺序和 `pcm_s16le` / `mp3` chunk 基本约束,用于提前发现 `replyPlaybackMode`、`reply_state`、`reply_audio_chunk`、`turn_completed` 等字段破坏。它不能替代 Docker 镜像构建、真实 LiveKit smoke 或 iPhone 真机验收。
## 本机运行
推荐通过包装脚本启动:
```bash
-./run-local.sh
+./tools/combrabo-voice-runtime-helper/run-local.sh
```
-如果本地还没有 release 二进制,脚本会先执行构建。macOS 默认优先使用 Docker 模式,避免 host 侧 WebRTC native 依赖链路反复阻塞。
+如果本地还没有 release 二进制,脚本会先执行一次构建。
-可预先准备运行模式:
+开发调试时建议显式带上本机 WebRTC 缓存:
```bash
-./run-local.sh --prepare
-./run-local.sh --prepare --rebuild
+LK_CUSTOM_WEBRTC="$HOME/.cache/combrabo/livekit-webrtc/mac-arm64-release-webrtc-51ef663" \
+COMBRABO_VOICE_RUNTIME_HELPER_MODE=host \
+./tools/combrabo-voice-runtime-helper/run-local.sh --prepare
```
+
+## Service 模式
+
+`lmrobot-app` 线上形态不再适合每次通话直接用命令行拉起 helper。当前 helper 支持 service 外壳:
+
+```bash
+CV_HELPER_SERVICE_ENABLED=true \
+CV_HELPER_SERVICE_BIND=127.0.0.1:18080 \
+CV_HELPER_AUTH_TOKEN=local-helper-token \
+CV_RUNTIME_TURN_BRIDGE_TOKEN=local-turn-bridge-token \
+./target/release/combrabo-voice-runtime-helper
+```
+
+service 模式下,helper 只暴露控制面接口:
+
+- `GET /health`
+- `GET /internal/combrabo-voice/health`
+- `POST /internal/combrabo-voice/sessions/start`
+- `GET /internal/combrabo-voice/sessions/{callId}`
+- `POST /internal/combrabo-voice/sessions/{callId}/stop`
+
+`sessions/start` 收到 Java 传入的 LiveKit bot 入房材料后,会拉起现有 worker 子进程承接媒体链路。helper service 本身不做 ASR / LLM / TTS / 消息 / 计费,也不持久化业务数据。
+
+鉴权口径:
+
+- Java 调 helper 控制面使用 `Authorization: Bearer {helperAuthToken}`。
+- `authProfile` 只是非敏感 alias,首版允许 `default / local-dev / dev`。
+- helper 根据自身部署配置读取同名 profile 的 `turnBridgeToken`,再用于调用 Java internal turn bridge。
+- token 只能来自本机私有 `.env`、Jenkins credentials 或服务器 ENC 配置,不写入 Git。
+
+Docker service mode 本地启动时,`run-local.sh` 会把 `CV_HELPER_SERVICE_BIND=127.0.0.1:18080`
+映射为容器内 `0.0.0.0:18080` 并发布到宿主 `127.0.0.1:18080`。因此本机探活使用:
+
+```bash
+CV_HELPER_SERVICE_ENABLED=true \
+CV_HELPER_SERVICE_BIND=127.0.0.1:18080 \
+CV_HELPER_AUTH_TOKEN=local-helper-token \
+CV_RUNTIME_TURN_BRIDGE_TOKEN=local-turn-bridge-token \
+COMBRABO_VOICE_RUNTIME_HELPER_MODE=docker \
+./run-local.sh
+
+curl http://127.0.0.1:18080/health
+```
+
+`sessions/start` 会等待 worker 回报真实 readiness:worker 在 LiveKit connect 后输出
+`bot_participant_joined`,在 bot audio track 发布后输出 `bot_track_ready`。service 捕获这些
+结构化事件后才返回 `STARTED + botTrackReady=true`;如果 worker 失败或超时,则返回结构化
+`RUNTIME_START_FAILED` / `RUNTIME_START_TIMEOUT`。
## 本机固定问候音频
-helper 支持 WAV / MP3 音频自动识别。主动问候 `prepare` 生成的 MP3 可以直接进入 helper;本地 smoke 仍可使用 WAV fixture 作为固定兜底样本。
+helper 当前 MVP 支持 **WAV / MP3** 音频自动识别。主动问候 `prepare` 生成的 MP3 可以直接进入 helper;本地 smoke 仍可使用 WAV fixture 作为固定兜底样本。
-在 macOS local 环境,可先生成一份本机固定问候 WAV fixture:
+在 macOS local 环境,推荐先生成一份本机固定问候 WAV fixture:
```bash
-./generate-local-fixture.sh
+./tools/combrabo-voice-runtime-helper/generate-local-fixture.sh
```
默认输出:
```text
-.local/greeting-local.wav
+tools/combrabo-voice-runtime-helper/.local/greeting-local.wav
```
-## lmrobot-app 接线示例
+## 推荐 launch-command
+
+`lmrobot-app` local 推荐设置:
```bash
-export COMBRABO_VOICE_RUNTIME_WORKDIR=/opt/lmrobot/lm-livekit-helper
-export COMBRABO_VOICE_RUNTIME_LAUNCH_COMMAND=/opt/lmrobot/lm-livekit-helper/run-local.sh
-export COMBRABO_VOICE_RUNTIME_HELPER_MODE=docker
-export COMBRABO_VOICE_RUNTIME_HELPER_IMAGE=registry.example.com/lm-livekit-helper:git-sha
-export COMBRABO_VOICE_RUNTIME_FALLBACK_GREETING_AUDIO_PATH=/opt/lmrobot/lm-livekit-helper/.local/greeting-local.wav
+export COMBRABO_VOICE_RUNTIME_WORKDIR=/Users/ar/Applications/combrabo/wwww
+export COMBRABO_VOICE_RUNTIME_LAUNCH_COMMAND=./tools/combrabo-voice-runtime-helper/run-local.sh
+export COMBRABO_VOICE_RUNTIME_FALLBACK_GREETING_AUDIO_PATH=/Users/ar/Applications/combrabo/wwww/tools/combrabo-voice-runtime-helper/.local/greeting-local.wav
```
-`CV_LIVEKIT_URL`、`CV_LIVEKIT_ROOM_ID`、`CV_LIVEKIT_BOT_TOKEN`、`CV_LIVEKIT_BOT_PARTICIPANT_IDENTITY` 等连接材料由 `lmrobot-app` 在每次 `calls/start` 时生成并注入,不写入仓库。
+## 主动问候音频质量诊断
-## LiveKit Data 设备输出 smoke
+helper 会在播放主动问候前输出脱敏音质摘要,覆盖:
-需要验证 NativeSDK 是否能收到设备输出 Data Message 时,在启动 `lmrobot-app` 前打开:
+- 源格式、源字节数、源采样率、源声道数;
+- 解码样本数、解码时长、目标采样率、目标声道数;
+- 20ms frame 数、理论播放时长、实际推帧 wall duration、推帧漂移;
+- RMS、peak、削波样本数、静音比例;
+- MP3 `SkippedData` / `InsufficientData` 计数。
+
+本地排查音质问题时,可以额外设置 debug dump 目录:
```bash
-export CV_DEVICE_OUTPUT_SMOKE_ENABLED=true
+export CV_AUDIO_DEBUG_DUMP_DIR=/tmp/combrabo-voice-audio-debug
```
-helper 会在 bot 进房并发布音轨后,向 `CV_LIVEKIT_USER_PARTICIPANT_IDENTITY` 指向的 SDK client 发送 reliable Data Message:
+开启后 helper 会保留两类文件:
-- `topic`: `device_output`
-- `type`: `device_output`
-- `schemaVersion`: `1.0`
-- `commandCode`: 例如 `vibration.start`
-- `params`: 设备参数
+```text
+<callId>-greeting-source.<wav|mp3>
+<callId>-greeting-target.wav
+```
-如需指定接收方,可使用英文逗号分隔:
+其中 `source` 是原始主动问候音频,`target` 是推送给 LiveKit 前的 `48kHz/mono/16-bit` WAV。文件只用于本地回听排查,不进入 Git、不写入协作事件正文,不上传到线上环境。
+
+## 用户上行音频观测
+
+helper 默认开启下一阶段入口观测:
```bash
-export CV_DEVICE_OUTPUT_DESTINATION_IDENTITIES=client-identity-a,client-identity-b
+export CV_ENABLE_USER_AUDIO_OBSERVER=true
```
-如果没有指定接收方且没有 `CV_LIVEKIT_USER_PARTICIPANT_IDENTITY`,helper 会广播到 room。设备控制建议继续使用 reliable/ordered Data Message;ACK / 执行结果首版建议走 HTTP,便于落库、重试和排查。
+如需临时回退为纯固定问候播放器,可关闭:
-## 文档
+```bash
+export CV_ENABLE_USER_AUDIO_OBSERVER=false
+```
-- [Runtime Contract](docs/runtime-contract.md)
-- [Jenkins Build And Deploy Runbook](docs/jenkins-build-deploy.md)
+开启后 helper 会监听 LiveKit `TrackSubscribed` 事件,并对远端用户音频轨道输出脱敏日志:
-## 安全要求
+- `runtime helper user_track_subscribe_requested`
+- `runtime helper user_track_subscribed`
+- `runtime helper user_audio_frame_received`
+- `runtime helper user_audio_frame_summary`
+- `runtime helper user_audio_stream_ended`
-仓库内只允许提交 `.env.example` 这类脱敏样例。禁止提交真实 LiveKit token、API key、secret、roomId、participantIdentity、用户语音内容、ASR 文本、LLM 回复全文或完整 prompt。
+这些日志只记录 `call_id`、`trace_id`、脱敏 participant alias、脱敏 track sid、采样率、声道、帧数和时间摘要。禁止记录 token、room secret、真实 participantIdentity、完整 roomId、音频内容、ASR 文本、用户语音内容或 AI 回复文本。
+
+## 轻量 VAD / turn detection
+
+在确认 helper 能收到用户上行音频帧后,helper 默认开启轻量 VAD:
+
+```bash
+export CV_ENABLE_SIMPLE_VAD=true
+```
+
+首版 VAD 不引入外部模型,只基于上行 PCM frame 的 RMS / peak 做保守阈值判断。配置了 Java runtime turn bridge 后,helper 会在有效 `vad_speech_end` 后把本轮 PCM 写成 `user.wav` artifact,并调用 Java 内部 bridge;helper 仍不做 ASR / LLM / TTS / 消息落库。
+
+可调参数:
+
+```bash
+export CV_VAD_RMS_THRESHOLD=0.012
+export CV_VAD_PEAK_THRESHOLD=0.08
+export CV_VAD_START_FRAMES=5
+export CV_VAD_END_SILENCE_MS=700
+export CV_VAD_MIN_SPEECH_MS=300
+export CV_VAD_MAX_TURN_MS=10000
+export CV_VAD_INITIAL_IGNORE_MS=500
+export CV_VAD_GATE_UNTIL_GREETING_DONE=true
+export CV_VAD_POST_GREETING_DELAY_MS=800
+```
+
+Java turn bridge 相关配置:
+
+```bash
+export CV_RUNTIME_TURN_BRIDGE_URL=http://127.0.0.1:11102/internal/sdk/combrabo-voice/runtime/turns
+export CV_RUNTIME_TURN_BRIDGE_TOKEN=local-dev-token
+export CV_RUNTIME_TURN_ARTIFACT_DIR=/tmp/combrabo-voice-runtime/turn-artifacts
+export CV_RUNTIME_SESSION_NONCE=runtime-session-nonce
+```
+
+这些值由 `lmrobot-app` 启动 runtime helper 时注入。Docker 模式会透传 env,并把 `CV_RUNTIME_TURN_ARTIFACT_DIR` 挂载进容器。日志不得打印 `CV_RUNTIME_TURN_BRIDGE_TOKEN`。
+
+本机 Docker 模式下,`run-local.sh` 会自动把 `CV_RUNTIME_TURN_BRIDGE_URL` 中的 `127.0.0.1` / `localhost` 改写为 `host.docker.internal`,避免 helper 容器把 Java internal turn bridge 误解析为容器自身。
+
+开启后新增脱敏事件:
+
+- `runtime helper vad_disabled_greeting`
+- `runtime helper vad_enable_scheduled`
+- `runtime helper vad_enabled`
+- `runtime helper vad_ignored_before_enabled`
+- `runtime helper vad_speech_start`
+- `runtime helper vad_speech_end`
+- `runtime helper vad_speech_too_short`
+- `runtime helper vad_no_speech_summary`
+- `runtime helper turn_artifact_written`
+- `runtime helper turn_bridge_completed`
+- `runtime helper turn_bridge_failed`
+- `runtime helper turn_bridge_skipped`
+
+这些事件只记录 `call_id`、`trace_id`、脱敏 participant alias、脱敏 track sid、turn 序号、起止时间、帧数、样本数、RMS / peak 摘要、结束原因、artifact 字节数、bridge HTTP / reasonCode 摘要。它们不记录用户音频内容、ASR 文本、LLM 回复、TTS URL、bridge token 或本机绝对 artifact 路径。
+
+默认启用 `CV_VAD_GATE_UNTIL_GREETING_DONE=true`,即主动问候播放完成前不产生 `vad_speech_start/end`。问候播放结束后,helper 会按 `CV_VAD_POST_GREETING_DELAY_MS` 延迟开启 VAD;门禁期间收到的用户音频帧只输出 `vad_ignored_before_enabled` 摘要,用于证明上行音频仍在,但不会形成用户 turn。
+
+当前 local 真机复测默认 `CV_VAD_POST_GREETING_DELAY_MS=800`,用于在主动问候实际写完后保留短尾缓冲,避免 15 秒固定禁听窗口吞掉用户首句。若需要做受控回声隔离实验,可以临时调大该值,但不得作为产品化默认值。
+
+## 当前边界
+
+- `CV_GREETING_AUDIO_FILE`:优先读取本地 WAV / MP3 文件;
+- `CV_GREETING_AUDIO_URL`:支持下载后按 WAV / MP3 解析;
+- 没有问候音频时,helper 仍会发布 bot track 并保持连接,但不会主动播放音频;
+- 如果传入了问候音频路径/URL,但内容不是可解析的 WAV / MP3,helper 会启动失败并把错误返回给 `calls/start`。
+- 用户上行音频观测只证明 helper 能订阅用户音轨并收到帧,不代表 ASR/LLM/TTS 动态对话已经完成。
+- 轻量 VAD 在 bridge 配置完整时会生成 `user.wav` artifact 并调用 Java internal turn bridge;bridge 配置缺失时只记录 `turn_bridge_skipped`,不阻断 helper 进程。
+- 当前阶段只完成用户 turn artifact 和 Java bridge 请求;第二段 AI 回复音频写回 bot track 属于下一阶段。
+- 主动问候、固定 TTS 和后续动态回复必须统一写入 helper 发布的 LiveKit bot 音轨;不允许另起本地播放旁路。
diff --git a/docs/runtime-contract.md b/docs/runtime-contract.md
index d4446f3..1ac5424 100644
--- a/docs/runtime-contract.md
+++ b/docs/runtime-contract.md
@@ -20,6 +20,212 @@
`lmrobot-app` remains the business authority for call state, role permission, greeting prepare/consume, ASR, prompt/history, LLM, TTS, message persistence, activity, diagnostics and reasonCode.
+## Turn stream fixture fast path
+
+For TTS streaming contract changes, run the fixture validator before Docker or iPhone smoke:
+
+```bash
+node tools/validate-turn-stream-fixture.mjs fixtures/turn-stream-happy.ndjson
+node tools/validate-turn-stream-fixture.mjs fixtures/turn-stream-mp3-chunks.ndjson
+```
+
+The fixture validates only the protocol shape and fast-path invariants:
+
+- `reply_playback_mode_selected` appears before audio chunks.
+- `reply_playback_started` appears before the first audio chunk.
+- `reply_audio_chunk.audioChunk.format` is `pcm_s16le`, `mp3`, `mpeg` or `wav`.
+- `pcm_s16le` chunks are `48000Hz` mono and 16-bit aligned.
+- Encoded chunks such as `mp3` must have non-empty payload bytes. This validates contract shape only; decoder and LiveKit write behavior still require runtime smoke.
+- A terminal event exists.
+
+This is not a media smoke. Docker build, LiveKit room join and iPhone `first_reply_remote_audio` still remain the runtime acceptance path.
+
+## LiveKit Data Message contract
+
+The helper publishes reply playback state through LiveKit reliable Data Message:
+
+```text
+topic = combrabo_voice.reply_state
+```
+
+The topic is the LiveKit channel. The payload `type` is the business message type and must not reuse the topic value:
+
+```json
+{
+ "type": "reply_state",
+ "schemaVersion": "1.0",
+ "callId": "cv_xxx",
+ "traceId": "trace_xxx",
+ "turnId": "turn_xxx",
+ "replyPlaybackMode": "streaming_tts",
+ "state": "reply_playback_started",
+ "seq": 3,
+ "tsMs": 1234567890
+}
+```
+
+## Runtime modes
+
+The helper has two runtime modes:
+
+- `worker` mode: the original one-call process. It reads `CV_*` variables and joins one LiveKit room.
+- `service` mode: a long-running control-plane wrapper. It exposes internal HTTP endpoints and spawns one worker child process per session.
+
+Service mode is enabled by either:
+
+```bash
+CV_HELPER_SERVICE_ENABLED=true ./combrabo-voice-runtime-helper
+./combrabo-voice-runtime-helper service
+```
+
+Service mode does not change the media worker boundary. It only replaces `lmrobot-app -> ProcessBuilder` with `lmrobot-app -> helper HTTP control plane`.
+
+## Service mode endpoints
+
+All session endpoints require:
+
+```http
+Authorization: Bearer {helperAuthToken}
+X-Voice-Trace-Id: {traceId}
+```
+
+### Health
+
+```http
+GET /internal/combrabo-voice/health
+```
+
+For Jenkins and ops probes, `/health` is also supported and returns the same body.
+
+Response:
+
+```json
+{
+ "code": 0,
+ "msg": "",
+ "data": {
+ "status": "UP",
+ "version": "0.1.0",
+ "mode": "service"
+ }
+}
+```
+
+### Start session
+
+```http
+POST /internal/combrabo-voice/sessions/start
+Idempotency-Key: {callId}
+Content-Type: application/json
+```
+
+Request body follows the `lmrobot-app` service-mode contract:
+
+```json
+{
+ "callId": "cv_xxx",
+ "traceId": "trace_xxx",
+ "runtimeSessionNonce": "nonce_xxx",
+ "authProfile": "local-dev",
+ "livekit": {
+ "url": "ws://127.0.0.1:7880",
+ "roomId": "room_xxx",
+ "botToken": "dynamic_bot_token",
+ "botParticipantIdentity": "bot_xxx",
+ "userParticipantIdentity": "user_xxx"
+ },
+ "turnBridge": {
+ "url": "http://127.0.0.1:19102/internal/sdk/combrabo-voice/runtime/turns/stream"
+ },
+ "audio": {
+ "firstAudioSource": "fixed_greeting_tts",
+ "greetingAudio": {
+ "type": "local_file",
+ "pathRef": "greeting/cv_xxx.mp3",
+ "format": "mp3"
+ }
+ },
+ "runtime": {
+ "turnArtifactDir": "/tmp/combrabo-voice/artifacts",
+ "audioDebugDumpEnabled": false
+ }
+}
+```
+
+`botToken` is dynamic LiveKit connection material. It is accepted only through this internal control plane and must not be logged.
+
+Success response:
+
+```json
+{
+ "code": 0,
+ "msg": "",
+ "data": {
+ "callId": "cv_xxx",
+ "status": "STARTED",
+ "runtimeSessionId": "rt_cv_xxx",
+ "botParticipantJoined": true,
+ "botTrackReady": true,
+ "firstAudioSource": "fixed_greeting_tts"
+ }
+}
+```
+
+`sessions/start` must not treat a spawned process as ready. In service mode the worker emits
+`cv_activity` lines on stdout. The service updates its session registry from these events and
+waits for:
+
+- `bot_participant_joined`
+- `bot_track_ready`
+
+Only after both are observed can `botParticipantJoined=true` and `botTrackReady=true` be returned.
+If the worker exits first, return `RUNTIME_START_FAILED`; if the ready window expires, return
+`RUNTIME_START_TIMEOUT`.
+
+### Query session
+
+```http
+GET /internal/combrabo-voice/sessions/{callId}
+```
+
+The response contains only state aliases, never token, roomId or participantIdentity.
+
+### Stop session
+
+```http
+POST /internal/combrabo-voice/sessions/{callId}/stop
+Content-Type: application/json
+```
+
+```json
+{
+ "reason": "client_end",
+ "runtimeSessionNonce": "nonce_xxx"
+}
+```
+
+Repeated stop returns `code=0` with `alreadyStopped=true`. If nonce mismatches, helper returns `RUNTIME_SESSION_MISMATCH`.
+
+## Service mode auth profiles
+
+`authProfile` is an alias, not a token. The first version allows `default`, `local-dev` and `dev`.
+
+For local smoke, helper accepts these environment variables:
+
+```bash
+CV_HELPER_AUTH_TOKEN=local-helper-token
+CV_RUNTIME_TURN_BRIDGE_TOKEN=local-turn-bridge-token
+```
+
+Profile-specific override is supported by suffix:
+
+```bash
+CV_HELPER_AUTH_TOKEN_LOCAL_DEV=local-helper-token
+CV_TURN_BRIDGE_TOKEN_LOCAL_DEV=local-turn-bridge-token
+```
+
+The helper never receives `turnBridgeToken` in the `sessions/start` body. It resolves the token from its own deployment config, using `authProfile`.
+
## Required environment variables
| Name | Source | Notes |
diff --git a/fixtures/turn-stream-happy.ndjson b/fixtures/turn-stream-happy.ndjson
new file mode 100644
index 0000000..b5f48b3
--- /dev/null
+++ b/fixtures/turn-stream-happy.ndjson
@@ -0,0 +1,5 @@
+{"event":"reply_playback_mode_selected","callId":"call_fixture","traceId":"trace_fixture","turnId":"turn_fixture_001","seq":1,"replyPlaybackMode":"streaming_tts"}
+{"event":"reply_state","callId":"call_fixture","traceId":"trace_fixture","turnId":"turn_fixture_001","seq":2,"replyPlaybackMode":"streaming_tts","state":"reply_output_pending"}
+{"event":"reply_state","callId":"call_fixture","traceId":"trace_fixture","turnId":"turn_fixture_001","seq":3,"replyPlaybackMode":"streaming_tts","state":"reply_playback_started"}
+{"event":"reply_audio_chunk","callId":"call_fixture","traceId":"trace_fixture","turnId":"turn_fixture_001","seq":4,"replyPlaybackMode":"streaming_tts","audioChunk":{"chunkSeq":1,"format":"pcm_s16le","sampleRate":48000,"channels":1,"payloadBase64":"AAAAAAAAAAA=","last":true}}
+{"event":"turn_completed","callId":"call_fixture","traceId":"trace_fixture","turnId":"turn_fixture_001","seq":5,"replyPlaybackMode":"streaming_tts","completion":{"messageId":"msg_fixture_alias","audioChunkCount":1}}
diff --git a/fixtures/turn-stream-mp3-chunks.ndjson b/fixtures/turn-stream-mp3-chunks.ndjson
new file mode 100644
index 0000000..1c4064a
--- /dev/null
+++ b/fixtures/turn-stream-mp3-chunks.ndjson
@@ -0,0 +1,6 @@
+{"event":"reply_playback_mode_selected","callId":"call_fixture","traceId":"trace_fixture","turnId":"turn_fixture_mp3_001","seq":1,"replyPlaybackMode":"streaming_tts","diagnostics":{"ttsProvider":"elevenlabs","providerStreamingSupported":true,"streamBridgeMode":"stream"}}
+{"event":"reply_state","callId":"call_fixture","traceId":"trace_fixture","turnId":"turn_fixture_mp3_001","seq":2,"replyPlaybackMode":"streaming_tts","state":"reply_output_pending"}
+{"event":"reply_state","callId":"call_fixture","traceId":"trace_fixture","turnId":"turn_fixture_mp3_001","seq":3,"replyPlaybackMode":"streaming_tts","state":"reply_playback_started"}
+{"event":"reply_audio_chunk","callId":"call_fixture","traceId":"trace_fixture","turnId":"turn_fixture_mp3_001","seq":4,"replyPlaybackMode":"streaming_tts","audioChunk":{"chunkSeq":1,"format":"mp3","sampleRate":44100,"channels":1,"payloadBase64":"SUQzBAAAAAAA","last":false}}
+{"event":"reply_audio_chunk","callId":"call_fixture","traceId":"trace_fixture","turnId":"turn_fixture_mp3_001","seq":5,"replyPlaybackMode":"streaming_tts","audioChunk":{"chunkSeq":2,"format":"mp3","sampleRate":44100,"channels":1,"payloadBase64":"//uQZAAAAAAAAAA=","last":true}}
+{"event":"turn_completed","callId":"call_fixture","traceId":"trace_fixture","turnId":"turn_fixture_mp3_001","seq":6,"replyPlaybackMode":"streaming_tts","completion":{"messageId":"msg_fixture_alias","audioChunkCount":2}}
diff --git a/generate-local-fixture.sh b/generate-local-fixture.sh
index 07b6fb0..1b209f1 100755
--- a/generate-local-fixture.sh
+++ b/generate-local-fixture.sh
@@ -1,16 +1,16 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-OUTPUT_DIR="${SCRIPT_DIR}/.local"
-TEXT="${1:-こんにちは、来てくれてうれしいです。今日はゆっくりお話ししましょう。}"
-TMP_AIFF="${OUTPUT_DIR}/greeting-local.aiff"
-OUTPUT_WAV="${OUTPUT_DIR}/greeting-local.wav"
-
-mkdir -p "${OUTPUT_DIR}"
-
-/usr/bin/say -v Kyoko -o "${TMP_AIFF}" "${TEXT}"
-/usr/bin/afconvert -f WAVE -d LEI16@16000 -c 1 "${TMP_AIFF}" "${OUTPUT_WAV}"
-rm -f "${TMP_AIFF}"
-
-echo "Generated local greeting fixture: ${OUTPUT_WAV}"
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+OUTPUT_DIR="${SCRIPT_DIR}/.local"
+TEXT="${1:-こんにちは、来てくれてうれしいです。今日はゆっくりお話ししましょう。}"
+TMP_AIFF="${OUTPUT_DIR}/greeting-local.aiff"
+OUTPUT_WAV="${OUTPUT_DIR}/greeting-local.wav"
+
+mkdir -p "${OUTPUT_DIR}"
+
+/usr/bin/say -v Kyoko -o "${TMP_AIFF}" "${TEXT}"
+/usr/bin/afconvert -f WAVE -d LEI16@16000 -c 1 "${TMP_AIFF}" "${OUTPUT_WAV}"
+rm -f "${TMP_AIFF}"
+
+echo "Generated local greeting fixture: ${OUTPUT_WAV}"
diff --git a/run-local.sh b/run-local.sh
index 2e304e7..337bd55 100755
--- a/run-local.sh
+++ b/run-local.sh
@@ -1,159 +1,246 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
+#!/usr/bin/env bash
+set -euo pipefail
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-WORKSPACE_ROOT="${COMBRABO_VOICE_RUNTIME_HELPER_WORKDIR:-${SCRIPT_DIR}}"
+WORKSPACE_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
MANIFEST_PATH="${SCRIPT_DIR}/Cargo.toml"
BINARY_PATH="${SCRIPT_DIR}/target/release/combrabo-voice-runtime-helper"
-DOCKERFILE_PATH="${SCRIPT_DIR}/Dockerfile"
-IMAGE_NAME="${COMBRABO_VOICE_RUNTIME_HELPER_IMAGE:-combrabo-voice-runtime-helper:local}"
-MODE="${COMBRABO_VOICE_RUNTIME_HELPER_MODE:-auto}"
-
-usage() {
- cat <<'EOF'
-Usage:
- run-local.sh [--prepare] [--rebuild]
-
-Options:
- --prepare Prepare the current helper runtime mode without starting the helper.
- --rebuild Force docker image rebuild in docker mode.
-EOF
-}
-
-PREPARE_ONLY=false
-FORCE_REBUILD=false
-
-while [[ $# -gt 0 ]]; do
- case "$1" in
- --prepare)
- PREPARE_ONLY=true
- shift
- ;;
- --rebuild)
- FORCE_REBUILD=true
- shift
- ;;
- -h|--help)
- usage
- exit 0
- ;;
- *)
- echo "Unknown argument: $1" >&2
- usage >&2
- exit 1
- ;;
- esac
-done
-
-resolve_mode() {
- local requested="$1"
- if [[ "${requested}" != "auto" ]]; then
- printf '%s' "${requested}"
- return 0
- fi
- if [[ "$(uname -s)" == "Darwin" ]] && command -v docker >/dev/null 2>&1; then
- printf '%s' "docker"
- return 0
- fi
- printf '%s' "host"
-}
-
-prepare_host() {
- (cd "${SCRIPT_DIR}" && cargo build --release)
-}
-
-prepare_docker() {
- if ! command -v docker >/dev/null 2>&1; then
- echo "Docker is required for docker helper mode, but docker is not installed." >&2
- exit 1
- fi
- if [[ "${FORCE_REBUILD}" == "true" ]] || ! docker image inspect "${IMAGE_NAME}" >/dev/null 2>&1; then
- docker build -t "${IMAGE_NAME}" "${SCRIPT_DIR}"
- fi
-}
-
-rewrite_livekit_url_for_docker() {
- local url="$1"
- if [[ "${url}" =~ ^(ws|wss)://(127\.0\.0\.1|localhost)([:/].*)?$ ]]; then
- printf '%s://host.docker.internal%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[3]:-}"
- return 0
- fi
- printf '%s' "${url}"
-}
-
-run_host() {
- if [[ ! -x "${BINARY_PATH}" ]]; then
- prepare_host
- fi
- exec "${BINARY_PATH}"
-}
-
-run_docker() {
- prepare_docker
- local livekit_url="${CV_LIVEKIT_URL:-}"
- local -a docker_args
- docker_args=(
- --rm
- --name "cv-helper-${CV_CALL_ID:-manual}"
+DOCKERFILE_PATH="${SCRIPT_DIR}/Dockerfile"
+IMAGE_NAME="${COMBRABO_VOICE_RUNTIME_HELPER_IMAGE:-combrabo-voice-runtime-helper:local}"
+MODE="${COMBRABO_VOICE_RUNTIME_HELPER_MODE:-auto}"
+
+usage() {
+ cat <<'EOF'
+Usage:
+ run-local.sh [--prepare] [--rebuild]
+
+Options:
+ --prepare Prepare the current helper runtime mode without starting the helper.
+ --rebuild Force docker image rebuild in docker mode.
+EOF
+}
+
+PREPARE_ONLY=false
+FORCE_REBUILD=false
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --prepare)
+ PREPARE_ONLY=true
+ shift
+ ;;
+ --rebuild)
+ FORCE_REBUILD=true
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+resolve_mode() {
+ local requested="$1"
+ if [[ "${requested}" != "auto" ]]; then
+ printf '%s' "${requested}"
+ return 0
+ fi
+ if [[ "$(uname -s)" == "Darwin" ]] && command -v docker >/dev/null 2>&1; then
+ printf '%s' "docker"
+ return 0
+ fi
+ printf '%s' "host"
+}
+
+prepare_host() {
+ (cd "${SCRIPT_DIR}" && cargo build --release)
+}
+
+prepare_docker() {
+ if ! command -v docker >/dev/null 2>&1; then
+ echo "Docker is required for docker helper mode, but docker is not installed." >&2
+ exit 1
+ fi
+ if [[ "${FORCE_REBUILD}" == "true" ]] || ! docker image inspect "${IMAGE_NAME}" >/dev/null 2>&1; then
+ docker build -t "${IMAGE_NAME}" "${SCRIPT_DIR}"
+ fi
+}
+
+rewrite_livekit_url_for_docker() {
+ local url="$1"
+ if [[ "${url}" =~ ^(ws|wss)://(127\.0\.0\.1|localhost)([:/].*)?$ ]]; then
+ printf '%s://host.docker.internal%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[3]:-}"
+ return 0
+ fi
+ printf '%s' "${url}"
+}
+
+rewrite_http_url_for_docker() {
+ local url="$1"
+ if [[ "${url}" =~ ^(http|https)://(127\.0\.0\.1|localhost)([:/].*)?$ ]]; then
+ printf '%s://host.docker.internal%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[3]:-}"
+ return 0
+ fi
+ printf '%s' "${url}"
+}
+
+is_truthy() {
+ case "${1:-}" in
+ true|TRUE|1|yes|YES|on|ON)
+ return 0
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+
+docker_service_host() {
+ local bind_addr="${1:-127.0.0.1:18080}"
+ if [[ "${bind_addr}" =~ ^([^:]+):([0-9]+)$ ]]; then
+ printf '%s' "${BASH_REMATCH[1]}"
+ return 0
+ fi
+ printf '%s' "127.0.0.1"
+}
+
+docker_service_port() {
+ local bind_addr="${1:-127.0.0.1:18080}"
+ if [[ "${bind_addr}" =~ ^([^:]+):([0-9]+)$ ]]; then
+ printf '%s' "${BASH_REMATCH[2]}"
+ return 0
+ fi
+ printf '%s' "18080"
+}
+
+run_host() {
+ if [[ ! -x "${BINARY_PATH}" ]]; then
+ prepare_host
+ fi
+ exec "${BINARY_PATH}"
+}
+
+run_docker() {
+ prepare_docker
+ local livekit_url="${CV_LIVEKIT_URL:-}"
+ local -a docker_args
+ docker_args=(
+ --rm
+ --name "cv-helper-${CV_CALL_ID:-manual}"
-v "${WORKSPACE_ROOT}:${WORKSPACE_ROOT}"
- -w "${WORKSPACE_ROOT}"
- -e CV_CALL_ID
- -e CV_TRACE_ID
- -e CV_GAME_MATCH_ID
- -e CV_LIVEKIT_URL
- -e CV_LIVEKIT_ROOM_ID
- -e CV_LIVEKIT_BOT_TOKEN
- -e CV_LIVEKIT_BOT_PARTICIPANT_IDENTITY
- -e CV_LIVEKIT_USER_PARTICIPANT_IDENTITY
- -e CV_FIRST_AUDIO_SOURCE
+ -w "${WORKSPACE_ROOT}"
+ -e CV_CALL_ID
+ -e CV_TRACE_ID
+ -e CV_GAME_MATCH_ID
+ -e CV_LIVEKIT_URL
+ -e CV_LIVEKIT_ROOM_ID
+ -e CV_LIVEKIT_BOT_TOKEN
+ -e CV_LIVEKIT_BOT_PARTICIPANT_IDENTITY
+ -e CV_LIVEKIT_USER_PARTICIPANT_IDENTITY
+ -e CV_FIRST_AUDIO_SOURCE
-e CV_GREETING_SOURCE
-e CV_GREETING_AUDIO_FILE
-e CV_GREETING_AUDIO_URL
-e CV_GREETING_TEXT
- -e CV_ROLE_ID
- -e CV_DEVICE_OUTPUT_SMOKE_ENABLED
- -e CV_DEVICE_OUTPUT_DESTINATION_IDENTITIES
+ -e CV_AUDIO_DEBUG_DUMP_DIR
+ -e CV_ENABLE_USER_AUDIO_OBSERVER
+ -e CV_ENABLE_SIMPLE_VAD
+ -e CV_VAD_RMS_THRESHOLD
+ -e CV_VAD_PEAK_THRESHOLD
+ -e CV_VAD_START_FRAMES
+ -e CV_VAD_END_SILENCE_MS
+ -e CV_VAD_MIN_SPEECH_MS
+ -e CV_VAD_MAX_TURN_MS
+ -e CV_VAD_INITIAL_IGNORE_MS
+ -e CV_VAD_GATE_UNTIL_GREETING_DONE
+ -e CV_VAD_POST_GREETING_DELAY_MS
+ -e CV_RUNTIME_TURN_BRIDGE_URL
+ -e CV_RUNTIME_TURN_BRIDGE_TOKEN
+ -e CV_RUNTIME_TURN_ARTIFACT_DIR
+ -e CV_RUNTIME_SESSION_NONCE
+ -e CV_HELPER_SERVICE_ENABLED
+ -e CV_HELPER_SERVICE_BIND
+ -e CV_HELPER_AUTH_TOKEN
+ -e CV_HELPER_AUTH_TOKEN_LOCAL_DEV
+ -e CV_TURN_BRIDGE_TOKEN_LOCAL_DEV
+ -e CV_HELPER_AUTH_TOKEN_DEV
+ -e CV_TURN_BRIDGE_TOKEN_DEV
)
- if [[ -n "${livekit_url}" ]]; then
- export CV_LIVEKIT_URL="$(rewrite_livekit_url_for_docker "${livekit_url}")"
- fi
- if [[ -n "${CV_GREETING_AUDIO_FILE:-}" && -f "${CV_GREETING_AUDIO_FILE}" ]]; then
- local audio_parent
- audio_parent="$(cd "$(dirname "${CV_GREETING_AUDIO_FILE}")" && pwd)"
- if [[ "${audio_parent}" != "${WORKSPACE_ROOT}" && "${audio_parent}" != "${WORKSPACE_ROOT}/"* ]]; then
- docker_args+=(-v "${audio_parent}:${audio_parent}:ro")
- fi
- fi
- exec docker run "${docker_args[@]}" "${IMAGE_NAME}"
-}
-
-RUNTIME_MODE="$(resolve_mode "${MODE}")"
-
-if [[ "${PREPARE_ONLY}" == "true" ]]; then
- case "${RUNTIME_MODE}" in
- host)
- prepare_host
- ;;
- docker)
- prepare_docker
- ;;
- *)
- echo "Unsupported helper mode: ${RUNTIME_MODE}" >&2
- exit 1
- ;;
- esac
- exit 0
-fi
-
-case "${RUNTIME_MODE}" in
- host)
- run_host
- ;;
- docker)
- run_docker
- ;;
- *)
- echo "Unsupported helper mode: ${RUNTIME_MODE}" >&2
- exit 1
- ;;
-esac
+ if is_truthy "${CV_HELPER_SERVICE_ENABLED:-}"; then
+ local service_bind service_host service_port
+ service_bind="${CV_HELPER_SERVICE_BIND:-127.0.0.1:18080}"
+ service_host="$(docker_service_host "${service_bind}")"
+ service_port="$(docker_service_port "${service_bind}")"
+ export CV_HELPER_SERVICE_BIND="0.0.0.0:${service_port}"
+ docker_args+=(-p "${service_host}:${service_port}:${service_port}")
+ fi
+ if [[ -n "${livekit_url}" ]]; then
+ export CV_LIVEKIT_URL="$(rewrite_livekit_url_for_docker "${livekit_url}")"
+ fi
+ if [[ -n "${CV_RUNTIME_TURN_BRIDGE_URL:-}" ]]; then
+ export CV_RUNTIME_TURN_BRIDGE_URL="$(rewrite_http_url_for_docker "${CV_RUNTIME_TURN_BRIDGE_URL}")"
+ fi
+ if [[ -n "${CV_GREETING_AUDIO_FILE:-}" && -f "${CV_GREETING_AUDIO_FILE}" ]]; then
+ local audio_parent
+ audio_parent="$(cd "$(dirname "${CV_GREETING_AUDIO_FILE}")" && pwd)"
+ if [[ "${audio_parent}" != "${WORKSPACE_ROOT}" && "${audio_parent}" != "${WORKSPACE_ROOT}/"* ]]; then
+ docker_args+=(-v "${audio_parent}:${audio_parent}:ro")
+ fi
+ fi
+ if [[ -n "${CV_AUDIO_DEBUG_DUMP_DIR:-}" ]]; then
+ mkdir -p "${CV_AUDIO_DEBUG_DUMP_DIR}"
+ local debug_dump_dir
+ debug_dump_dir="$(cd "${CV_AUDIO_DEBUG_DUMP_DIR}" && pwd)"
+ export CV_AUDIO_DEBUG_DUMP_DIR="${debug_dump_dir}"
+ if [[ "${debug_dump_dir}" != "${WORKSPACE_ROOT}" && "${debug_dump_dir}" != "${WORKSPACE_ROOT}/"* ]]; then
+ docker_args+=(-v "${debug_dump_dir}:${debug_dump_dir}")
+ fi
+ fi
+ if [[ -n "${CV_RUNTIME_TURN_ARTIFACT_DIR:-}" ]]; then
+ mkdir -p "${CV_RUNTIME_TURN_ARTIFACT_DIR}"
+ local turn_artifact_dir
+ turn_artifact_dir="$(cd "${CV_RUNTIME_TURN_ARTIFACT_DIR}" && pwd)"
+ export CV_RUNTIME_TURN_ARTIFACT_DIR="${turn_artifact_dir}"
+ if [[ "${turn_artifact_dir}" != "${WORKSPACE_ROOT}" && "${turn_artifact_dir}" != "${WORKSPACE_ROOT}/"* ]]; then
+ docker_args+=(-v "${turn_artifact_dir}:${turn_artifact_dir}")
+ fi
+ fi
+ exec docker run "${docker_args[@]}" "${IMAGE_NAME}"
+}
+
+RUNTIME_MODE="$(resolve_mode "${MODE}")"
+
+if [[ "${PREPARE_ONLY}" == "true" ]]; then
+ case "${RUNTIME_MODE}" in
+ host)
+ prepare_host
+ ;;
+ docker)
+ prepare_docker
+ ;;
+ *)
+ echo "Unsupported helper mode: ${RUNTIME_MODE}" >&2
+ exit 1
+ ;;
+ esac
+ exit 0
+fi
+
+case "${RUNTIME_MODE}" in
+ host)
+ run_host
+ ;;
+ docker)
+ run_docker
+ ;;
+ *)
+ echo "Unsupported helper mode: ${RUNTIME_MODE}" >&2
+ exit 1
+ ;;
+esac
diff --git a/src/audio.rs b/src/audio.rs
index 01d3a28..1aadcb4 100644
--- a/src/audio.rs
+++ b/src/audio.rs
@@ -1,317 +1,720 @@
-use std::{fs, io::Cursor, path::Path};
-
-use anyhow::{Context, Result, anyhow};
-use hound::{SampleFormat, WavReader};
-use minimp3::{Decoder as Mp3Decoder, Error as Mp3Error, Frame as Mp3Frame};
-use reqwest::Client;
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct PcmFrame {
- pub data: Vec<i16>,
- pub sample_rate: u32,
- pub num_channels: u32,
- pub samples_per_channel: u32,
-}
-
-impl PcmFrame {
- pub fn new(
- data: Vec<i16>,
- sample_rate: u32,
- num_channels: u32,
- samples_per_channel: u32,
- ) -> Self {
- Self {
- data,
- sample_rate,
- num_channels,
- samples_per_channel,
- }
- }
-}
-
-pub async fn load_pre_recorded_frames(
- http: &Client,
- audio_file: Option<&str>,
- audio_url: Option<&str>,
- target_sample_rate_hz: u32,
- target_num_channels: u16,
-) -> Result<Option<Vec<PcmFrame>>> {
- if let Some(path) = audio_file.filter(|value| !value.trim().is_empty()) {
- let audio_bytes = fs::read(Path::new(path))
- .with_context(|| format!("failed to read greeting audio file {path}"))?;
- return decode_audio_frames(&audio_bytes, target_sample_rate_hz, target_num_channels)
- .map(Some);
- }
- if let Some(url) = audio_url.filter(|value| !value.trim().is_empty()) {
- let response = http
- .get(url)
- .send()
- .await
- .with_context(|| format!("failed to fetch greeting audio {url}"))?;
- if !response.status().is_success() {
- return Err(anyhow!(
- "greeting audio fetch failed for {url} with status {}",
- response.status()
- ));
- }
- let bytes = response
- .bytes()
- .await
- .with_context(|| format!("failed to read greeting audio body {url}"))?;
- return decode_audio_frames(&bytes, target_sample_rate_hz, target_num_channels).map(Some);
- }
- Ok(None)
-}
-
-fn decode_audio_frames(
- audio_bytes: &[u8],
- target_sample_rate_hz: u32,
- target_num_channels: u16,
-) -> Result<Vec<PcmFrame>> {
- if is_wav(audio_bytes) {
- return decode_wav_frames(audio_bytes, target_sample_rate_hz, target_num_channels);
- }
- if is_mp3(audio_bytes) {
- return decode_mp3_frames(audio_bytes, target_sample_rate_hz, target_num_channels);
- }
-
- let wav_result = decode_wav_frames(audio_bytes, target_sample_rate_hz, target_num_channels);
- if wav_result.is_ok() {
- return wav_result;
- }
- let wav_error = wav_result.err();
- decode_mp3_frames(audio_bytes, target_sample_rate_hz, target_num_channels).map_err(
- |mp3_error| {
- anyhow!(
- "failed to decode greeting audio as wav or mp3: wav={}, mp3={}",
- wav_error
- .map(|error| error.to_string())
- .unwrap_or_else(|| "unknown".to_string()),
- mp3_error
- )
- },
- )
-}
-
-fn decode_wav_frames(
- wav_bytes: &[u8],
- target_sample_rate_hz: u32,
- target_num_channels: u16,
-) -> Result<Vec<PcmFrame>> {
- let cursor = Cursor::new(wav_bytes.to_vec());
- let mut reader = WavReader::new(cursor).context("failed to open greeting wav bytes")?;
- let spec = reader.spec();
- let src_channels = spec.channels.max(1);
- let mut samples = match (spec.sample_format, spec.bits_per_sample) {
- (SampleFormat::Int, 16) => reader
- .samples::<i16>()
- .collect::<std::result::Result<Vec<_>, _>>()
- .context("failed to decode 16-bit wav samples")?,
- (SampleFormat::Float, 32) => reader
- .samples::<f32>()
- .map(|sample| sample.map(|value| (value.clamp(-1.0, 1.0) * i16::MAX as f32) as i16))
- .collect::<std::result::Result<Vec<_>, _>>()
- .context("failed to decode float wav samples")?,
- _ => {
- return Err(anyhow!(
- "unsupported greeting wav format: {:?} {}-bit",
- spec.sample_format,
- spec.bits_per_sample
- ));
- }
- };
-
- samples = remap_channels(samples, src_channels, target_num_channels);
- samples = resample_linear(
- samples,
- spec.sample_rate,
- target_sample_rate_hz,
- target_num_channels,
- );
-
- Ok(chunk_pcm_samples(
- samples,
- target_sample_rate_hz,
- target_num_channels,
- ))
-}
-
-fn decode_mp3_frames(
- mp3_bytes: &[u8],
- target_sample_rate_hz: u32,
- target_num_channels: u16,
-) -> Result<Vec<PcmFrame>> {
- let cursor = Cursor::new(mp3_bytes.to_vec());
- let mut decoder = Mp3Decoder::new(cursor);
- let mut samples = Vec::new();
-
- loop {
- match decoder.next_frame() {
- Ok(Mp3Frame {
- data,
- sample_rate,
- channels,
- ..
- }) => {
- let src_rate = u32::try_from(sample_rate)
- .map_err(|_| anyhow!("unsupported mp3 sample rate {sample_rate}"))?;
- let src_channels = u16::try_from(channels)
- .map_err(|_| anyhow!("unsupported mp3 channel count {channels}"))?;
- let mut frame_samples = remap_channels(data, src_channels, target_num_channels);
- frame_samples = resample_linear(
- frame_samples,
- src_rate,
- target_sample_rate_hz,
- target_num_channels,
- );
- samples.extend(frame_samples);
- }
- Err(Mp3Error::Eof) => break,
- Err(Mp3Error::SkippedData) | Err(Mp3Error::InsufficientData) => continue,
- Err(error) => return Err(anyhow!("failed to decode mp3 frame: {error:?}")),
- }
- }
-
- if samples.is_empty() {
- return Err(anyhow!("decoded mp3 contains no audio samples"));
- }
-
- Ok(chunk_pcm_samples(
- samples,
- target_sample_rate_hz,
- target_num_channels,
- ))
-}
-
-fn chunk_pcm_samples(
- samples: Vec<i16>,
- target_sample_rate_hz: u32,
- target_num_channels: u16,
-) -> Vec<PcmFrame> {
- const CHUNK_MS: u32 = 20;
- let samples_per_chunk = ((target_sample_rate_hz / 1000) * CHUNK_MS).max(1) as usize
- * usize::from(target_num_channels);
- let mut frames = Vec::new();
- for chunk in samples.chunks(samples_per_chunk) {
- let mut frame_data = chunk.to_vec();
- if frame_data.len() < samples_per_chunk {
- frame_data.resize(samples_per_chunk, 0);
- }
- let samples_per_channel =
- (frame_data.len() / usize::from(target_num_channels.max(1))) as u32;
- frames.push(PcmFrame::new(
- frame_data,
- target_sample_rate_hz,
- u32::from(target_num_channels),
- samples_per_channel,
- ));
- }
- frames
-}
-
-fn is_wav(bytes: &[u8]) -> bool {
- bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE"
-}
-
-fn is_mp3(bytes: &[u8]) -> bool {
- bytes.starts_with(b"ID3") || (bytes.len() >= 2 && bytes[0] == 0xff && bytes[1] & 0xe0 == 0xe0)
-}
-
-fn remap_channels(samples: Vec<i16>, src_channels: u16, dst_channels: u16) -> Vec<i16> {
- if src_channels == dst_channels {
- return samples;
- }
-
- let src_channels = usize::from(src_channels.max(1));
- let dst_channels = usize::from(dst_channels.max(1));
- let frames = samples.chunks(src_channels);
- let mut output = Vec::new();
- for frame in frames {
- match (src_channels, dst_channels) {
- (1, 2) => {
- let sample = frame.first().copied().unwrap_or_default();
- output.push(sample);
- output.push(sample);
- }
- (2, 1) => {
- let left = frame.first().copied().unwrap_or_default() as i32;
- let right = frame.get(1).copied().unwrap_or_default() as i32;
- output.push(((left + right) / 2) as i16);
- }
- _ => {
- for channel in 0..dst_channels {
- output.push(
- frame
- .get(channel % src_channels)
- .copied()
- .unwrap_or_default(),
- );
- }
- }
- }
- }
- output
-}
-
-fn resample_linear(samples: Vec<i16>, src_rate: u32, dst_rate: u32, channels: u16) -> Vec<i16> {
- if src_rate == dst_rate {
- return samples;
- }
-
- let channels = usize::from(channels.max(1));
- let src_frames = samples.len() / channels;
- if src_frames <= 1 {
- return samples;
- }
- let ratio = dst_rate as f64 / src_rate as f64;
- let dst_frames = ((src_frames as f64) * ratio).round().max(1.0) as usize;
- let mut output = Vec::with_capacity(dst_frames * channels);
-
- for dst_frame in 0..dst_frames {
- let src_pos = (dst_frame as f64) / ratio;
- let src_index = src_pos.floor() as usize;
- let next_index = (src_index + 1).min(src_frames - 1);
- let frac = (src_pos - src_index as f64) as f32;
- for channel in 0..channels {
- let a = samples[src_index * channels + channel] as f32;
- let b = samples[next_index * channels + channel] as f32;
- let mixed = a + (b - a) * frac;
- output.push(mixed.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16);
- }
- }
-
- output
-}
-
-#[cfg(test)]
-mod tests {
- use super::load_pre_recorded_frames;
-
- #[tokio::test]
- async fn load_pre_recorded_frames_reads_local_wav() {
- let dir = std::env::temp_dir().join("cv-runtime-helper-tests");
- std::fs::create_dir_all(&dir).expect("create temp dir");
- let path = dir.join("fixture.wav");
- let spec = hound::WavSpec {
- channels: 1,
- sample_rate: 16_000,
- bits_per_sample: 16,
- sample_format: hound::SampleFormat::Int,
- };
- let mut writer = hound::WavWriter::create(&path, spec).expect("create wav");
- for _ in 0..16_000 {
- writer.write_sample(512_i16).expect("write sample");
- }
- writer.finalize().expect("finalize wav");
-
- let http = reqwest::Client::new();
- let frames = load_pre_recorded_frames(&http, path.to_str(), None, 48_000, 1)
- .await
- .expect("load frames")
- .expect("frames");
-
- assert!(!frames.is_empty());
- assert_eq!(frames[0].sample_rate, 48_000);
- assert_eq!(frames[0].num_channels, 1);
- }
-}
+use std::{
+ fs,
+ io::Cursor,
+ path::{Path, PathBuf},
+};
+
+use anyhow::{Context, Result, anyhow};
+use hound::{SampleFormat, WavReader, WavSpec, WavWriter};
+use minimp3::{Decoder as Mp3Decoder, Error as Mp3Error, Frame as Mp3Frame};
+use reqwest::Client;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PcmFrame {
+ pub data: Vec<i16>,
+ pub sample_rate: u32,
+ pub num_channels: u32,
+ pub samples_per_channel: u32,
+}
+
+impl PcmFrame {
+ pub fn new(
+ data: Vec<i16>,
+ sample_rate: u32,
+ num_channels: u32,
+ samples_per_channel: u32,
+ ) -> Self {
+ Self {
+ data,
+ sample_rate,
+ num_channels,
+ samples_per_channel,
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct LoadedAudio {
+ pub frames: Vec<PcmFrame>,
+ pub diagnostics: AudioDiagnostics,
+}
+
+#[derive(Clone, Debug)]
+pub struct AudioDiagnostics {
+ pub source_kind: &'static str,
+ pub source_format: &'static str,
+ pub source_bytes: usize,
+ pub source_sample_rate_hz: u32,
+ pub source_num_channels: u16,
+ pub decoded_sample_count: usize,
+ pub decoded_duration_ms: u64,
+ pub target_sample_rate_hz: u32,
+ pub target_num_channels: u16,
+ pub target_sample_count: usize,
+ pub target_duration_ms: u64,
+ pub frame_count: usize,
+ pub rms: f64,
+ pub peak: f64,
+ pub clipped_sample_count: usize,
+ pub silence_ratio: f64,
+ pub mp3_skipped_data_count: usize,
+ pub mp3_insufficient_data_count: usize,
+ pub debug_source_path: Option<String>,
+ pub debug_pcm_wav_path: Option<String>,
+}
+
+struct DecodeResult {
+ samples: Vec<i16>,
+ diagnostics: AudioDiagnostics,
+}
+
+pub async fn load_pre_recorded_frames(
+ http: &Client,
+ audio_file: Option<&str>,
+ audio_url: Option<&str>,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+ debug_dump_dir: Option<&str>,
+ call_id: &str,
+ debug_label: &str,
+) -> Result<Option<LoadedAudio>> {
+ if let Some(path) = audio_file.filter(|value| !value.trim().is_empty()) {
+ let audio_bytes = fs::read(Path::new(path))
+ .with_context(|| format!("failed to read greeting audio file {path}"))?;
+ return decode_audio_frames(
+ &audio_bytes,
+ "local_file",
+ target_sample_rate_hz,
+ target_num_channels,
+ debug_dump_dir,
+ call_id,
+ debug_label,
+ )
+ .map(Some);
+ }
+ if let Some(url) = audio_url.filter(|value| !value.trim().is_empty()) {
+ let response = http
+ .get(url)
+ .send()
+ .await
+ .with_context(|| format!("failed to fetch greeting audio {url}"))?;
+ if !response.status().is_success() {
+ return Err(anyhow!(
+ "greeting audio fetch failed for {url} with status {}",
+ response.status()
+ ));
+ }
+ let bytes = response
+ .bytes()
+ .await
+ .with_context(|| format!("failed to read greeting audio body {url}"))?;
+ return decode_audio_frames(
+ &bytes,
+ "downloaded_url",
+ target_sample_rate_hz,
+ target_num_channels,
+ debug_dump_dir,
+ call_id,
+ debug_label,
+ )
+ .map(Some);
+ }
+ Ok(None)
+}
+
+pub fn decode_audio_bytes_to_frames(
+ audio_bytes: &[u8],
+ source_kind: &'static str,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+ debug_dump_dir: Option<&str>,
+ call_id: &str,
+ debug_label: &str,
+) -> Result<LoadedAudio> {
+ decode_audio_frames(
+ audio_bytes,
+ source_kind,
+ target_sample_rate_hz,
+ target_num_channels,
+ debug_dump_dir,
+ call_id,
+ debug_label,
+ )
+}
+
+fn decode_audio_frames(
+ audio_bytes: &[u8],
+ source_kind: &'static str,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+ debug_dump_dir: Option<&str>,
+ call_id: &str,
+ debug_label: &str,
+) -> Result<LoadedAudio> {
+ if is_wav(audio_bytes) {
+ return finalize_decode_result(
+ audio_bytes,
+ decode_wav_samples(
+ audio_bytes,
+ source_kind,
+ target_sample_rate_hz,
+ target_num_channels,
+ )?,
+ target_sample_rate_hz,
+ target_num_channels,
+ debug_dump_dir,
+ call_id,
+ debug_label,
+ );
+ }
+ if is_mp3(audio_bytes) {
+ return finalize_decode_result(
+ audio_bytes,
+ decode_mp3_samples(
+ audio_bytes,
+ source_kind,
+ target_sample_rate_hz,
+ target_num_channels,
+ )?,
+ target_sample_rate_hz,
+ target_num_channels,
+ debug_dump_dir,
+ call_id,
+ debug_label,
+ );
+ }
+
+ let wav_result = decode_wav_samples(
+ audio_bytes,
+ source_kind,
+ target_sample_rate_hz,
+ target_num_channels,
+ );
+ if wav_result.is_ok() {
+ return finalize_decode_result(
+ audio_bytes,
+ wav_result?,
+ target_sample_rate_hz,
+ target_num_channels,
+ debug_dump_dir,
+ call_id,
+ debug_label,
+ );
+ }
+ let wav_error = wav_result.err();
+ let mp3_result = decode_mp3_samples(
+ audio_bytes,
+ source_kind,
+ target_sample_rate_hz,
+ target_num_channels,
+ );
+ match mp3_result {
+ Ok(result) => finalize_decode_result(
+ audio_bytes,
+ result,
+ target_sample_rate_hz,
+ target_num_channels,
+ debug_dump_dir,
+ call_id,
+ debug_label,
+ ),
+ Err(mp3_error) => Err(anyhow!(
+ "failed to decode greeting audio as wav or mp3: wav={}, mp3={}",
+ wav_error
+ .map(|error| error.to_string())
+ .unwrap_or_else(|| "unknown".to_string()),
+ mp3_error
+ )),
+ }
+}
+
+fn decode_wav_samples(
+ wav_bytes: &[u8],
+ source_kind: &'static str,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+) -> Result<DecodeResult> {
+ let cursor = Cursor::new(wav_bytes.to_vec());
+ let mut reader = WavReader::new(cursor).context("failed to open greeting wav bytes")?;
+ let spec = reader.spec();
+ let src_channels = spec.channels.max(1);
+ let decoded_samples = match (spec.sample_format, spec.bits_per_sample) {
+ (SampleFormat::Int, 16) => reader
+ .samples::<i16>()
+ .collect::<std::result::Result<Vec<_>, _>>()
+ .context("failed to decode 16-bit wav samples")?,
+ (SampleFormat::Float, 32) => reader
+ .samples::<f32>()
+ .map(|sample| sample.map(|value| (value.clamp(-1.0, 1.0) * i16::MAX as f32) as i16))
+ .collect::<std::result::Result<Vec<_>, _>>()
+ .context("failed to decode float wav samples")?,
+ _ => {
+ return Err(anyhow!(
+ "unsupported greeting wav format: {:?} {}-bit",
+ spec.sample_format,
+ spec.bits_per_sample
+ ));
+ }
+ };
+
+ let decoded_sample_count = decoded_samples.len();
+ let mut samples = remap_channels(decoded_samples, src_channels, target_num_channels);
+ samples = resample_linear(
+ samples,
+ spec.sample_rate,
+ target_sample_rate_hz,
+ target_num_channels,
+ );
+
+ Ok(DecodeResult {
+ diagnostics: build_diagnostics(
+ source_kind,
+ "wav",
+ wav_bytes.len(),
+ spec.sample_rate,
+ src_channels,
+ decoded_sample_count,
+ &samples,
+ target_sample_rate_hz,
+ target_num_channels,
+ 0,
+ 0,
+ ),
+ samples,
+ })
+}
+
+fn decode_mp3_samples(
+ mp3_bytes: &[u8],
+ source_kind: &'static str,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+) -> Result<DecodeResult> {
+ let cursor = Cursor::new(mp3_bytes.to_vec());
+ let mut decoder = Mp3Decoder::new(cursor);
+ let mut samples = Vec::new();
+ let mut decoded_sample_count = 0usize;
+ let mut source_sample_rate_hz = 0u32;
+ let mut source_num_channels = 0u16;
+ let mut skipped_data_count = 0usize;
+ let mut insufficient_data_count = 0usize;
+
+ loop {
+ match decoder.next_frame() {
+ Ok(Mp3Frame {
+ data,
+ sample_rate,
+ channels,
+ ..
+ }) => {
+ let src_rate = u32::try_from(sample_rate)
+ .map_err(|_| anyhow!("unsupported mp3 sample rate {sample_rate}"))?;
+ let src_channels = u16::try_from(channels)
+ .map_err(|_| anyhow!("unsupported mp3 channel count {channels}"))?;
+ if source_sample_rate_hz == 0 {
+ source_sample_rate_hz = src_rate;
+ }
+ if source_num_channels == 0 {
+ source_num_channels = src_channels;
+ }
+ decoded_sample_count += data.len();
+ let mut frame_samples = remap_channels(data, src_channels, target_num_channels);
+ frame_samples = resample_linear(
+ frame_samples,
+ src_rate,
+ target_sample_rate_hz,
+ target_num_channels,
+ );
+ samples.extend(frame_samples);
+ }
+ Err(Mp3Error::Eof) => break,
+ Err(Mp3Error::SkippedData) => {
+ skipped_data_count += 1;
+ continue;
+ }
+ Err(Mp3Error::InsufficientData) => {
+ insufficient_data_count += 1;
+ continue;
+ }
+ Err(error) => return Err(anyhow!("failed to decode mp3 frame: {error:?}")),
+ }
+ }
+
+ if samples.is_empty() {
+ return Err(anyhow!("decoded mp3 contains no audio samples"));
+ }
+
+ Ok(DecodeResult {
+ diagnostics: build_diagnostics(
+ source_kind,
+ "mp3",
+ mp3_bytes.len(),
+ source_sample_rate_hz,
+ source_num_channels.max(1),
+ decoded_sample_count,
+ &samples,
+ target_sample_rate_hz,
+ target_num_channels,
+ skipped_data_count,
+ insufficient_data_count,
+ ),
+ samples,
+ })
+}
+
+fn finalize_decode_result(
+ audio_bytes: &[u8],
+ mut decode_result: DecodeResult,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+ debug_dump_dir: Option<&str>,
+ call_id: &str,
+ debug_label: &str,
+) -> Result<LoadedAudio> {
+ if let Some(dir) = debug_dump_dir.filter(|value| !value.trim().is_empty()) {
+ let paths = dump_debug_audio(
+ dir,
+ call_id,
+ debug_label,
+ decode_result.diagnostics.source_format,
+ audio_bytes,
+ &decode_result.samples,
+ target_sample_rate_hz,
+ target_num_channels,
+ )?;
+ decode_result.diagnostics.debug_source_path = paths.0;
+ decode_result.diagnostics.debug_pcm_wav_path = paths.1;
+ }
+
+ let frames = chunk_pcm_samples(
+ decode_result.samples,
+ target_sample_rate_hz,
+ target_num_channels,
+ );
+ decode_result.diagnostics.frame_count = frames.len();
+
+ Ok(LoadedAudio {
+ frames,
+ diagnostics: decode_result.diagnostics,
+ })
+}
+
+fn build_diagnostics(
+ source_kind: &'static str,
+ source_format: &'static str,
+ source_bytes: usize,
+ source_sample_rate_hz: u32,
+ source_num_channels: u16,
+ decoded_sample_count: usize,
+ target_samples: &[i16],
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+ mp3_skipped_data_count: usize,
+ mp3_insufficient_data_count: usize,
+) -> AudioDiagnostics {
+ let target_sample_count = target_samples.len();
+ let decoded_duration_ms = duration_ms(
+ decoded_sample_count,
+ source_sample_rate_hz,
+ source_num_channels,
+ );
+ let target_duration_ms = duration_ms(
+ target_sample_count,
+ target_sample_rate_hz,
+ target_num_channels,
+ );
+ let (rms, peak, clipped_sample_count, silence_ratio) = quality_stats(target_samples);
+ AudioDiagnostics {
+ source_kind,
+ source_format,
+ source_bytes,
+ source_sample_rate_hz,
+ source_num_channels,
+ decoded_sample_count,
+ decoded_duration_ms,
+ target_sample_rate_hz,
+ target_num_channels,
+ target_sample_count,
+ target_duration_ms,
+ frame_count: 0,
+ rms,
+ peak,
+ clipped_sample_count,
+ silence_ratio,
+ mp3_skipped_data_count,
+ mp3_insufficient_data_count,
+ debug_source_path: None,
+ debug_pcm_wav_path: None,
+ }
+}
+
+fn duration_ms(sample_count: usize, sample_rate_hz: u32, channels: u16) -> u64 {
+ if sample_count == 0 || sample_rate_hz == 0 || channels == 0 {
+ return 0;
+ }
+ let frames = sample_count as f64 / f64::from(channels.max(1));
+ ((frames / f64::from(sample_rate_hz)) * 1000.0).round() as u64
+}
+
+fn quality_stats(samples: &[i16]) -> (f64, f64, usize, f64) {
+ if samples.is_empty() {
+ return (0.0, 0.0, 0, 0.0);
+ }
+ const SILENCE_THRESHOLD: i32 = 256;
+ let mut sum_squares = 0f64;
+ let mut peak = 0i32;
+ let mut clipped = 0usize;
+ let mut silence = 0usize;
+ for &sample in samples {
+ let abs = i32::from(sample).abs();
+ peak = peak.max(abs);
+ if abs >= i32::from(i16::MAX) {
+ clipped += 1;
+ }
+ if abs <= SILENCE_THRESHOLD {
+ silence += 1;
+ }
+ let normalized = f64::from(sample) / f64::from(i16::MAX);
+ sum_squares += normalized * normalized;
+ }
+ let rms = (sum_squares / samples.len() as f64).sqrt();
+ let peak = f64::from(peak) / f64::from(i16::MAX);
+ let silence_ratio = silence as f64 / samples.len() as f64;
+ (round4(rms), round4(peak), clipped, round4(silence_ratio))
+}
+
+fn round4(value: f64) -> f64 {
+ (value * 10_000.0).round() / 10_000.0
+}
+
+fn dump_debug_audio(
+ dir: &str,
+ call_id: &str,
+ debug_label: &str,
+ source_format: &str,
+ source_bytes: &[u8],
+ target_samples: &[i16],
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+) -> Result<(Option<String>, Option<String>)> {
+ let base_dir = PathBuf::from(dir);
+ fs::create_dir_all(&base_dir)
+ .with_context(|| format!("failed to create audio debug dump dir {dir}"))?;
+ let safe_call_id = sanitize_file_segment(call_id);
+ let safe_label = sanitize_file_segment(debug_label);
+ let source_path = base_dir.join(format!(
+ "{safe_call_id}-{safe_label}-source.{source_format}"
+ ));
+ fs::write(&source_path, source_bytes).with_context(|| {
+ format!(
+ "failed to write audio debug source {}",
+ source_path.display()
+ )
+ })?;
+
+ let pcm_path = base_dir.join(format!("{safe_call_id}-{safe_label}-target.wav"));
+ write_debug_wav(
+ &pcm_path,
+ target_samples,
+ target_sample_rate_hz,
+ target_num_channels,
+ )?;
+
+ Ok((
+ Some(source_path.to_string_lossy().to_string()),
+ Some(pcm_path.to_string_lossy().to_string()),
+ ))
+}
+
+fn write_debug_wav(path: &Path, samples: &[i16], sample_rate_hz: u32, channels: u16) -> Result<()> {
+ let spec = WavSpec {
+ channels,
+ sample_rate: sample_rate_hz,
+ bits_per_sample: 16,
+ sample_format: SampleFormat::Int,
+ };
+ let mut writer = WavWriter::create(path, spec)
+ .with_context(|| format!("failed to create audio debug wav {}", path.display()))?;
+ for &sample in samples {
+ writer
+ .write_sample(sample)
+ .with_context(|| format!("failed to write audio debug wav {}", path.display()))?;
+ }
+ writer
+ .finalize()
+ .with_context(|| format!("failed to finalize audio debug wav {}", path.display()))
+}
+
+pub fn write_pcm_wav(
+ path: &Path,
+ samples: &[i16],
+ sample_rate_hz: u32,
+ channels: u16,
+) -> Result<()> {
+ write_debug_wav(path, samples, sample_rate_hz, channels)
+}
+
+fn sanitize_file_segment(value: &str) -> String {
+ let sanitized: String = value
+ .chars()
+ .map(|ch| {
+ if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
+ ch
+ } else {
+ '_'
+ }
+ })
+ .collect();
+ if sanitized.is_empty() {
+ "unknown".to_string()
+ } else {
+ sanitized
+ }
+}
+
+fn chunk_pcm_samples(
+ samples: Vec<i16>,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+) -> Vec<PcmFrame> {
+ const CHUNK_MS: u32 = 20;
+ let samples_per_chunk = ((target_sample_rate_hz / 1000) * CHUNK_MS).max(1) as usize
+ * usize::from(target_num_channels);
+ let mut frames = Vec::new();
+ for chunk in samples.chunks(samples_per_chunk) {
+ let mut frame_data = chunk.to_vec();
+ if frame_data.len() < samples_per_chunk {
+ frame_data.resize(samples_per_chunk, 0);
+ }
+ let samples_per_channel =
+ (frame_data.len() / usize::from(target_num_channels.max(1))) as u32;
+ frames.push(PcmFrame::new(
+ frame_data,
+ target_sample_rate_hz,
+ u32::from(target_num_channels),
+ samples_per_channel,
+ ));
+ }
+ frames
+}
+
+fn is_wav(bytes: &[u8]) -> bool {
+ bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE"
+}
+
+fn is_mp3(bytes: &[u8]) -> bool {
+ bytes.starts_with(b"ID3") || (bytes.len() >= 2 && bytes[0] == 0xff && bytes[1] & 0xe0 == 0xe0)
+}
+
+fn remap_channels(samples: Vec<i16>, src_channels: u16, dst_channels: u16) -> Vec<i16> {
+ if src_channels == dst_channels {
+ return samples;
+ }
+
+ let src_channels = usize::from(src_channels.max(1));
+ let dst_channels = usize::from(dst_channels.max(1));
+ let frames = samples.chunks(src_channels);
+ let mut output = Vec::new();
+ for frame in frames {
+ match (src_channels, dst_channels) {
+ (1, 2) => {
+ let sample = frame.first().copied().unwrap_or_default();
+ output.push(sample);
+ output.push(sample);
+ }
+ (2, 1) => {
+ let left = frame.first().copied().unwrap_or_default() as i32;
+ let right = frame.get(1).copied().unwrap_or_default() as i32;
+ output.push(((left + right) / 2) as i16);
+ }
+ _ => {
+ for channel in 0..dst_channels {
+ output.push(
+ frame
+ .get(channel % src_channels)
+ .copied()
+ .unwrap_or_default(),
+ );
+ }
+ }
+ }
+ }
+ output
+}
+
+fn resample_linear(samples: Vec<i16>, src_rate: u32, dst_rate: u32, channels: u16) -> Vec<i16> {
+ if src_rate == dst_rate {
+ return samples;
+ }
+
+ let channels = usize::from(channels.max(1));
+ let src_frames = samples.len() / channels;
+ if src_frames <= 1 {
+ return samples;
+ }
+ let ratio = dst_rate as f64 / src_rate as f64;
+ let dst_frames = ((src_frames as f64) * ratio).round().max(1.0) as usize;
+ let mut output = Vec::with_capacity(dst_frames * channels);
+
+ for dst_frame in 0..dst_frames {
+ let src_pos = (dst_frame as f64) / ratio;
+ let src_index = src_pos.floor() as usize;
+ let next_index = (src_index + 1).min(src_frames - 1);
+ let frac = (src_pos - src_index as f64) as f32;
+ for channel in 0..channels {
+ let a = samples[src_index * channels + channel] as f32;
+ let b = samples[next_index * channels + channel] as f32;
+ let mixed = a + (b - a) * frac;
+ output.push(mixed.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16);
+ }
+ }
+
+ output
+}
+
+#[cfg(test)]
+mod tests {
+ use super::load_pre_recorded_frames;
+
+ #[tokio::test]
+ async fn load_pre_recorded_frames_reads_local_wav() {
+ let dir = std::env::temp_dir().join("cv-runtime-helper-tests");
+ std::fs::create_dir_all(&dir).expect("create temp dir");
+ let path = dir.join("fixture.wav");
+ let spec = hound::WavSpec {
+ channels: 1,
+ sample_rate: 16_000,
+ bits_per_sample: 16,
+ sample_format: hound::SampleFormat::Int,
+ };
+ let mut writer = hound::WavWriter::create(&path, spec).expect("create wav");
+ for _ in 0..16_000 {
+ writer.write_sample(512_i16).expect("write sample");
+ }
+ writer.finalize().expect("finalize wav");
+
+ let http = reqwest::Client::new();
+ let loaded = load_pre_recorded_frames(
+ &http,
+ path.to_str(),
+ None,
+ 48_000,
+ 1,
+ Some(dir.to_string_lossy().as_ref()),
+ "test-call",
+ "greeting",
+ )
+ .await
+ .expect("load frames")
+ .expect("frames");
+
+ assert!(!loaded.frames.is_empty());
+ assert_eq!(loaded.frames[0].sample_rate, 48_000);
+ assert_eq!(loaded.frames[0].num_channels, 1);
+ assert_eq!(loaded.diagnostics.source_format, "wav");
+ assert_eq!(loaded.diagnostics.source_sample_rate_hz, 16_000);
+ assert_eq!(loaded.diagnostics.target_sample_rate_hz, 48_000);
+ assert!(loaded.diagnostics.debug_source_path.is_some());
+ assert!(loaded.diagnostics.debug_pcm_wav_path.is_some());
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 07f4e9f..9e99d6c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,30 +1,53 @@
mod audio;
+mod service;
-use std::{env, sync::Arc, time::Duration};
+use std::{
+ env, fs,
+ path::{Path, PathBuf},
+ sync::{
+ Arc,
+ atomic::{AtomicBool, Ordering},
+ },
+ time::{Duration, Instant, SystemTime, UNIX_EPOCH},
+};
use anyhow::{Context, Result, anyhow};
-use audio::load_pre_recorded_frames;
+use audio::{AudioDiagnostics, PcmFrame, load_pre_recorded_frames};
+use base64::{Engine as _, engine::general_purpose};
+use futures_util::StreamExt;
use libwebrtc::{
audio_source::native::NativeAudioSource,
+ audio_stream::native::NativeAudioStream,
prelude::{AudioFrame, AudioSourceOptions, RtcAudioSource},
};
use livekit::{
options::TrackPublishOptions,
- prelude::{DataPacket, LocalAudioTrack, LocalTrack, ParticipantIdentity, Room, RoomOptions},
+ prelude::{
+ DataPacket, LocalAudioTrack, LocalTrack, ParticipantIdentity, RemoteAudioTrack,
+ RemoteTrack, Room, RoomEvent, RoomOptions,
+ },
};
use reqwest::Client;
-use serde::Serialize;
-use tokio::time::sleep;
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+use tokio::time::{sleep, sleep_until};
+use tokio::{sync::mpsc::UnboundedReceiver, task::JoinHandle};
use tracing::{info, warn};
const TARGET_SAMPLE_RATE_HZ: u32 = 48_000;
const TARGET_NUM_CHANNELS: u16 = 1;
const TRACK_NAME: &str = "bot-main-audio";
-const DEVICE_OUTPUT_TOPIC: &str = "device_output";
#[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<()> {
init_tracing();
+ if service::service_mode_enabled() {
+ return service::run_service().await;
+ }
+ run_worker().await
+}
+
+async fn run_worker() -> Result<()> {
let config = Config::from_env()?;
let http = Client::builder()
.use_rustls_tls()
@@ -37,10 +60,13 @@
config.greeting_audio_url.as_deref(),
TARGET_SAMPLE_RATE_HZ,
TARGET_NUM_CHANNELS,
+ config.audio_debug_dump_dir.as_deref(),
+ &config.call_id,
+ "greeting",
)
.await?;
- let (room, _events) = Room::connect(
+ let (room, events) = Room::connect(
config.livekit_url.as_str(),
config.bot_token.as_str(),
RoomOptions::default(),
@@ -52,42 +78,134 @@
info!(
call_id = %config.call_id,
trace_id = %config.trace_id,
- room_id = %config.room_id,
+ room_alias = %redact(&config.room_id),
participant_alias = %redact(&config.bot_participant_identity),
greeting_source = %config.greeting_source,
"combrabo voice runtime helper connected"
);
+ emit_activity(
+ &config.call_id,
+ &config.trace_id,
+ None,
+ "bot_participant_joined",
+ "ok",
+ None,
+ None,
+ json!({"participantAlias": redact(&config.bot_participant_identity)}),
+ );
+
+ let vad_enabled_gate = Arc::new(AtomicBool::new(
+ !config.simple_vad_enabled || !config.simple_vad_gate_until_greeting_done,
+ ));
+ if config.simple_vad_enabled && config.simple_vad_gate_until_greeting_done {
+ info!(
+ call_id = %config.call_id,
+ trace_id = %config.trace_id,
+ post_greeting_delay_ms = config.simple_vad_post_greeting_delay_ms,
+ "runtime helper vad_disabled_greeting"
+ );
+ } else if config.simple_vad_enabled {
+ info!(
+ call_id = %config.call_id,
+ trace_id = %config.trace_id,
+ "runtime helper vad_enabled"
+ );
+ emit_activity(
+ &config.call_id,
+ &config.trace_id,
+ None,
+ "vad_enabled",
+ "ok",
+ None,
+ None,
+ json!({"reason": "gate_disabled"}),
+ );
+ }
let sink = BotAudioOutputSink::publish(
room.clone(),
&config.room_id,
&config.bot_participant_identity,
+ &config.call_id,
+ &config.trace_id,
TRACK_NAME,
TARGET_SAMPLE_RATE_HZ,
u32::from(TARGET_NUM_CHANNELS),
+ config.user_participant_identity.clone(),
)
.await?;
+ let sink = Arc::new(sink);
- if config.device_output_smoke_enabled {
- publish_device_output_smoke(room.as_ref(), &config).await?;
- }
+ let user_audio_observer = spawn_user_audio_observer(
+ events,
+ &config,
+ vad_enabled_gate.clone(),
+ http.clone(),
+ sink.clone(),
+ );
- if let Some(frames) = greeting_frames {
+ if let Some(loaded_audio) = greeting_frames {
+ let diagnostics = loaded_audio.diagnostics;
+ let frames = loaded_audio.frames;
+ log_audio_diagnostics(&config, &diagnostics);
info!(
call_id = %config.call_id,
frame_count = frames.len(),
+ theoretical_duration_ms = diagnostics.target_duration_ms,
greeting_source = %config.greeting_source,
"runtime helper starting greeting playback"
);
- for frame in &frames {
+ emit_activity(
+ &config.call_id,
+ &config.trace_id,
+ None,
+ "greeting_write_started",
+ "ok",
+ None,
+ None,
+ json!({
+ "frameCount": frames.len(),
+ "greetingTheoreticalDurationMs": diagnostics.target_duration_ms,
+ }),
+ );
+ let playback_started_at = Instant::now();
+ let pacing_started_at = tokio::time::Instant::now();
+ for (index, frame) in frames.iter().enumerate() {
sink.write_pcm_frame(frame).await?;
- sleep(Duration::from_millis(20)).await;
+ sleep_until(pacing_started_at + Duration::from_millis(((index + 1) as u64) * 20)).await;
}
+ let playback_wall_ms = playback_started_at.elapsed().as_millis() as i64;
+ let drift_ms = playback_wall_ms - diagnostics.target_duration_ms as i64;
sink.clear_buffer();
info!(
call_id = %config.call_id,
greeting_source = %config.greeting_source,
+ frame_count = frames.len(),
+ theoretical_duration_ms = diagnostics.target_duration_ms,
+ push_wall_duration_ms = playback_wall_ms,
+ push_drift_ms = drift_ms,
"runtime helper finished greeting playback"
+ );
+ emit_activity(
+ &config.call_id,
+ &config.trace_id,
+ None,
+ "greeting_write_finished",
+ "ok",
+ None,
+ None,
+ json!({
+ "frameCount": frames.len(),
+ "greetingTheoreticalDurationMs": diagnostics.target_duration_ms,
+ "greetingPushWallDurationMs": playback_wall_ms,
+ "greetingPushDriftMs": drift_ms,
+ }),
+ );
+ schedule_vad_gate_enable(
+ vad_enabled_gate,
+ &config,
+ config.simple_vad_post_greeting_delay_ms,
+ "greeting_finished",
);
} else {
warn!(
@@ -95,9 +213,12 @@
greeting_source = %config.greeting_source,
"runtime helper started without greeting audio; keeping published track alive"
);
+ schedule_vad_gate_enable(vad_enabled_gate, &config, 0, "no_greeting_audio");
}
wait_for_shutdown_signal().await?;
+ user_audio_observer.abort();
+ let _ = user_audio_observer.await;
if let Err(error) = sink.close().await {
warn!(
@@ -133,12 +254,70 @@
room_id: String,
bot_token: String,
bot_participant_identity: String,
+ user_participant_identity: Option<String>,
greeting_source: String,
greeting_audio_file: Option<String>,
greeting_audio_url: Option<String>,
- role_id: String,
- device_output_smoke_enabled: bool,
- device_output_destination_identities: Vec<ParticipantIdentity>,
+ audio_debug_dump_dir: Option<String>,
+ runtime_turn_bridge_url: Option<String>,
+ runtime_turn_bridge_token: Option<String>,
+ runtime_turn_bridge_mode: String,
+ runtime_turn_artifact_dir: Option<String>,
+ runtime_session_nonce: Option<String>,
+ user_audio_observer_enabled: bool,
+ simple_vad_enabled: bool,
+ simple_vad_gate_until_greeting_done: bool,
+ simple_vad_post_greeting_delay_ms: u64,
+ simple_vad_config: SimpleVadConfig,
+}
+
+#[derive(Clone)]
+struct TurnBridgeConfig {
+ bridge_url: Option<String>,
+ bridge_token: Option<String>,
+ bridge_mode: String,
+ artifact_dir: Option<String>,
+ runtime_session_nonce: Option<String>,
+ audio_debug_dump_dir: Option<String>,
+}
+
+impl TurnBridgeConfig {
+ fn from_config(config: &Config) -> Self {
+ Self {
+ bridge_url: config.runtime_turn_bridge_url.clone(),
+ bridge_token: config.runtime_turn_bridge_token.clone(),
+ bridge_mode: config.runtime_turn_bridge_mode.clone(),
+ artifact_dir: config.runtime_turn_artifact_dir.clone(),
+ runtime_session_nonce: config.runtime_session_nonce.clone(),
+ audio_debug_dump_dir: config.audio_debug_dump_dir.clone(),
+ }
+ }
+
+ fn is_ready(&self) -> bool {
+ self.bridge_url
+ .as_ref()
+ .is_some_and(|value| !value.is_empty())
+ && self
+ .bridge_token
+ .as_ref()
+ .is_some_and(|value| !value.is_empty())
+ && self
+ .artifact_dir
+ .as_ref()
+ .is_some_and(|value| !value.is_empty())
+ && self
+ .runtime_session_nonce
+ .as_ref()
+ .is_some_and(|value| !value.is_empty())
+ }
+
+ fn is_stream_mode(&self) -> bool {
+ self.bridge_mode.eq_ignore_ascii_case("stream")
+ || self
+ .bridge_url
+ .as_deref()
+ .is_some_and(|value| value.trim_end_matches('/').ends_with("/stream"))
+ }
}
impl Config {
@@ -150,18 +329,115 @@
room_id: required_env("CV_LIVEKIT_ROOM_ID")?,
bot_token: required_env("CV_LIVEKIT_BOT_TOKEN")?,
bot_participant_identity: required_env("CV_LIVEKIT_BOT_PARTICIPANT_IDENTITY")?,
+ user_participant_identity: optional_env("CV_LIVEKIT_USER_PARTICIPANT_IDENTITY"),
greeting_source: env::var("CV_GREETING_SOURCE")
.unwrap_or_else(|_| "no_audio".to_string()),
greeting_audio_file: optional_env("CV_GREETING_AUDIO_FILE"),
greeting_audio_url: optional_env("CV_GREETING_AUDIO_URL"),
- role_id: env::var("CV_ROLE_ID").unwrap_or_else(|_| "90".to_string()),
- device_output_smoke_enabled: bool_env("CV_DEVICE_OUTPUT_SMOKE_ENABLED"),
- device_output_destination_identities: device_output_destinations(
- optional_env("CV_DEVICE_OUTPUT_DESTINATION_IDENTITIES"),
- optional_env("CV_LIVEKIT_USER_PARTICIPANT_IDENTITY"),
- ),
+ audio_debug_dump_dir: optional_env("CV_AUDIO_DEBUG_DUMP_DIR"),
+ runtime_turn_bridge_url: optional_env("CV_RUNTIME_TURN_BRIDGE_URL"),
+ runtime_turn_bridge_token: optional_env("CV_RUNTIME_TURN_BRIDGE_TOKEN"),
+ runtime_turn_bridge_mode: env::var("CV_RUNTIME_TURN_BRIDGE_MODE")
+ .unwrap_or_else(|_| "json".to_string()),
+ runtime_turn_artifact_dir: optional_env("CV_RUNTIME_TURN_ARTIFACT_DIR"),
+ runtime_session_nonce: optional_env("CV_RUNTIME_SESSION_NONCE"),
+ user_audio_observer_enabled: bool_env("CV_ENABLE_USER_AUDIO_OBSERVER", true),
+ simple_vad_enabled: bool_env("CV_ENABLE_SIMPLE_VAD", true),
+ simple_vad_gate_until_greeting_done: bool_env("CV_VAD_GATE_UNTIL_GREETING_DONE", true),
+ simple_vad_post_greeting_delay_ms: u64_env("CV_VAD_POST_GREETING_DELAY_MS", 800),
+ simple_vad_config: SimpleVadConfig::from_env(),
})
}
+}
+
+fn schedule_vad_gate_enable(
+ gate: Arc<AtomicBool>,
+ config: &Config,
+ delay_ms: u64,
+ reason: &'static str,
+) {
+ if !config.simple_vad_enabled || !config.simple_vad_gate_until_greeting_done {
+ return;
+ }
+
+ let call_id = config.call_id.clone();
+ let trace_id = config.trace_id.clone();
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ delay_ms,
+ reason,
+ "runtime helper vad_enable_scheduled"
+ );
+ emit_activity(
+ &call_id,
+ &trace_id,
+ None,
+ "vad_enable_scheduled",
+ "ok",
+ None,
+ None,
+ json!({
+ "vadEnableDelayMs": delay_ms,
+ "reason": reason,
+ }),
+ );
+
+ tokio::spawn(async move {
+ if delay_ms > 0 {
+ sleep(Duration::from_millis(delay_ms)).await;
+ }
+ gate.store(true, Ordering::Release);
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ delay_ms,
+ reason,
+ "runtime helper vad_enabled"
+ );
+ emit_activity(
+ &call_id,
+ &trace_id,
+ None,
+ "vad_enabled",
+ "ok",
+ None,
+ None,
+ json!({
+ "vadEnableDelayMs": delay_ms,
+ "reason": reason,
+ }),
+ );
+ });
+}
+
+fn log_audio_diagnostics(config: &Config, diagnostics: &AudioDiagnostics) {
+ info!(
+ call_id = %config.call_id,
+ trace_id = %config.trace_id,
+ greeting_source = %config.greeting_source,
+ source_kind = diagnostics.source_kind,
+ source_format = diagnostics.source_format,
+ source_bytes = diagnostics.source_bytes,
+ source_sample_rate_hz = diagnostics.source_sample_rate_hz,
+ source_num_channels = diagnostics.source_num_channels,
+ decoded_sample_count = diagnostics.decoded_sample_count,
+ decoded_duration_ms = diagnostics.decoded_duration_ms,
+ target_sample_rate_hz = diagnostics.target_sample_rate_hz,
+ target_num_channels = diagnostics.target_num_channels,
+ target_sample_count = diagnostics.target_sample_count,
+ target_duration_ms = diagnostics.target_duration_ms,
+ frame_count = diagnostics.frame_count,
+ rms = diagnostics.rms,
+ peak = diagnostics.peak,
+ clipped_sample_count = diagnostics.clipped_sample_count,
+ silence_ratio = diagnostics.silence_ratio,
+ mp3_skipped_data_count = diagnostics.mp3_skipped_data_count,
+ mp3_insufficient_data_count = diagnostics.mp3_insufficient_data_count,
+ debug_source_path = diagnostics.debug_source_path.as_deref().unwrap_or(""),
+ debug_pcm_wav_path = diagnostics.debug_pcm_wav_path.as_deref().unwrap_or(""),
+ "runtime helper greeting audio quality diagnostics"
+ );
}
fn required_env(key: &str) -> Result<String> {
@@ -183,34 +459,37 @@
})
}
-fn bool_env(key: &str) -> bool {
- matches!(
- env::var(key)
- .unwrap_or_default()
- .trim()
- .to_ascii_lowercase()
- .as_str(),
- "1" | "true" | "yes" | "y" | "on"
- )
+fn bool_env(key: &str, default_value: bool) -> bool {
+ match env::var(key) {
+ Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
+ "1" | "true" | "yes" | "on" => true,
+ "0" | "false" | "no" | "off" => false,
+ _ => default_value,
+ },
+ Err(_) => default_value,
+ }
}
-fn device_output_destinations(
- configured: Option<String>,
- user_identity: Option<String>,
-) -> Vec<ParticipantIdentity> {
- let identities = configured
- .filter(|value| !value.trim().is_empty())
- .map(|value| {
- value
- .split(',')
- .map(str::trim)
- .filter(|identity| !identity.is_empty())
- .map(ToOwned::to_owned)
- .collect::<Vec<_>>()
- })
- .or_else(|| user_identity.map(|identity| vec![identity]))
- .unwrap_or_default();
- identities.into_iter().map(Into::into).collect()
+fn f64_env(key: &str, default_value: f64) -> f64 {
+ env::var(key)
+ .ok()
+ .and_then(|value| value.trim().parse::<f64>().ok())
+ .filter(|value| value.is_finite() && *value >= 0.0)
+ .unwrap_or(default_value)
+}
+
+fn u64_env(key: &str, default_value: u64) -> u64 {
+ env::var(key)
+ .ok()
+ .and_then(|value| value.trim().parse::<u64>().ok())
+ .unwrap_or(default_value)
+}
+
+fn u32_env(key: &str, default_value: u32) -> u32 {
+ env::var(key)
+ .ok()
+ .and_then(|value| value.trim().parse::<u32>().ok())
+ .unwrap_or(default_value)
}
fn redact(value: &str) -> String {
@@ -220,118 +499,2017 @@
format!("{}***{}", &value[..4], &value[value.len() - 4..])
}
-#[derive(Serialize)]
-struct DeviceOutputMessage {
- #[serde(rename = "type")]
- message_type: &'static str,
- #[serde(rename = "schemaVersion")]
- schema_version: &'static str,
- #[serde(rename = "callId")]
+fn safe_error(value: &str) -> String {
+ let sanitized = value.replace(['\r', '\n'], " ");
+ let trimmed = sanitized.trim();
+ let mut output: String = trimmed.chars().take(180).collect();
+ if trimmed.chars().count() > 180 {
+ output.push_str("...");
+ }
+ output
+}
+
+fn current_time_millis() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|value| value.as_millis() as u64)
+ .unwrap_or_default()
+}
+
+fn emit_activity(
+ call_id: &str,
+ trace_id: &str,
+ turn_id: Option<&str>,
+ event_name: &str,
+ result: &str,
+ reason_code: Option<&str>,
+ retryable: Option<bool>,
+ extension: serde_json::Value,
+) {
+ let payload = json!({
+ "type": "cv_activity",
+ "callId": call_id,
+ "traceId": trace_id,
+ "turnId": turn_id,
+ "eventName": event_name,
+ "result": result,
+ "reasonCode": reason_code,
+ "retryable": retryable,
+ "extension": extension,
+ });
+ println!("{payload}");
+}
+
+fn spawn_user_audio_observer(
+ events: UnboundedReceiver<RoomEvent>,
+ config: &Config,
+ vad_enabled_gate: Arc<AtomicBool>,
+ http: Client,
+ sink: Arc<BotAudioOutputSink>,
+) -> JoinHandle<()> {
+ let call_id = config.call_id.clone();
+ let trace_id = config.trace_id.clone();
+ let enabled = config.user_audio_observer_enabled;
+ let simple_vad_enabled = config.simple_vad_enabled;
+ let simple_vad_config = config.simple_vad_config.clone();
+ let turn_bridge_config = TurnBridgeConfig::from_config(config);
+
+ tokio::spawn(async move {
+ if !enabled {
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ "runtime helper user audio observer disabled"
+ );
+ return;
+ }
+ observe_user_audio_events(
+ events,
+ call_id,
+ trace_id,
+ simple_vad_enabled,
+ simple_vad_config,
+ vad_enabled_gate,
+ turn_bridge_config,
+ http,
+ sink,
+ )
+ .await;
+ })
+}
+
+async fn observe_user_audio_events(
+ mut events: UnboundedReceiver<RoomEvent>,
call_id: String,
- #[serde(rename = "roleId")]
- role_id: String,
- #[serde(rename = "traceId")]
trace_id: String,
- #[serde(rename = "ackMode")]
- ack_mode: &'static str,
- #[serde(rename = "sensorInstructions")]
- sensor_instructions: Vec<SensorInstruction>,
+ simple_vad_enabled: bool,
+ simple_vad_config: SimpleVadConfig,
+ vad_enabled_gate: Arc<AtomicBool>,
+ turn_bridge_config: TurnBridgeConfig,
+ http: Client,
+ sink: Arc<BotAudioOutputSink>,
+) {
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ simple_vad_enabled,
+ vad_rms_threshold = simple_vad_config.rms_threshold,
+ vad_peak_threshold = simple_vad_config.peak_threshold,
+ vad_start_frames = simple_vad_config.start_frames,
+ vad_end_silence_ms = simple_vad_config.end_silence_ms,
+ vad_min_speech_ms = simple_vad_config.min_speech_ms,
+ vad_max_turn_ms = simple_vad_config.max_turn_ms,
+ vad_initial_ignore_ms = simple_vad_config.initial_ignore_ms,
+ vad_gate_enabled = vad_enabled_gate.load(Ordering::Acquire),
+ "runtime helper user_track_subscribe_requested"
+ );
+
+ while let Some(event) = events.recv().await {
+ match event {
+ RoomEvent::TrackSubscribed {
+ track: RemoteTrack::Audio(track),
+ publication: _,
+ participant,
+ } => {
+ let participant_alias = redact(&participant.identity().to_string());
+ let track_sid_alias = redact(&track.sid().to_string());
+ let track_name = track.name();
+ let track_source = format!("{:?}", track.source());
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ participant_alias = %participant_alias,
+ track_sid_alias = %track_sid_alias,
+ track_name = %track_name,
+ track_source = %track_source,
+ "runtime helper user_track_subscribed"
+ );
+ spawn_user_audio_frame_observer(
+ track,
+ call_id.clone(),
+ trace_id.clone(),
+ participant_alias,
+ track_sid_alias,
+ simple_vad_enabled,
+ simple_vad_config.clone(),
+ vad_enabled_gate.clone(),
+ turn_bridge_config.clone(),
+ http.clone(),
+ sink.clone(),
+ );
+ }
+ RoomEvent::TrackSubscribed {
+ track: RemoteTrack::Video(track),
+ publication: _,
+ participant,
+ } => {
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ participant_alias = %redact(&participant.identity().to_string()),
+ track_sid_alias = %redact(&track.sid().to_string()),
+ "runtime helper ignored non-audio subscribed track"
+ );
+ }
+ RoomEvent::TrackSubscriptionFailed {
+ participant,
+ error,
+ track_sid,
+ } => {
+ warn!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ participant_alias = %redact(&participant.identity().to_string()),
+ track_sid_alias = %redact(&track_sid.to_string()),
+ error = %error,
+ "runtime helper user_track_subscription_failed"
+ );
+ }
+ RoomEvent::Disconnected { reason } => {
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ reason = ?reason,
+ "runtime helper room event stream disconnected"
+ );
+ break;
+ }
+ _ => {}
+ }
+ }
}
-#[derive(Serialize)]
-struct SensorInstruction {
- #[serde(rename = "commandId")]
- command_id: String,
- #[serde(rename = "sensorType")]
- sensor_type: &'static str,
- #[serde(rename = "operationType")]
- operation_type: &'static str,
- step: i32,
- #[serde(rename = "durationSec", skip_serializing_if = "Option::is_none")]
- duration_sec: Option<i32>,
- extension: String,
+async fn handle_finished_turn(
+ http: &Client,
+ bridge_config: &TurnBridgeConfig,
+ sink: &BotAudioOutputSink,
+ call_id: &str,
+ trace_id: &str,
+ turn: FinishedSpeechTurn,
+) {
+ let turn_pipeline_started_at = Instant::now();
+ if !bridge_config.is_ready() {
+ warn!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ turn_index = turn.turn_index,
+ duration_ms = turn.duration_ms,
+ "runtime helper turn_bridge_skipped"
+ );
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(&turn.turn_id),
+ "turn_bridge_skipped",
+ "skipped",
+ Some("TURN_BRIDGE_NOT_CONFIGURED"),
+ Some(true),
+ json!({
+ "durationMs": turn.duration_ms,
+ "frameCount": turn.frame_count,
+ "sampleCount": turn.sample_count,
+ }),
+ );
+ return;
+ }
+
+ let artifact_root = PathBuf::from(bridge_config.artifact_dir.as_deref().unwrap_or_default());
+ match write_user_turn_artifact(&artifact_root, call_id, &turn) {
+ Ok((path_ref, byte_size)) => {
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ turn_index = turn.turn_index,
+ duration_ms = turn.duration_ms,
+ frame_count = turn.frame_count,
+ sample_count = turn.sample_count,
+ byte_size,
+ end_reason = %turn.end_reason,
+ "runtime helper turn_artifact_written"
+ );
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(&turn.turn_id),
+ "turn_bridge_requested",
+ "ok",
+ None,
+ None,
+ json!({
+ "turnDurationMs": turn.duration_ms,
+ "turnArtifactBytes": byte_size,
+ "frameCount": turn.frame_count,
+ "sampleCount": turn.sample_count,
+ "endReason": turn.end_reason,
+ }),
+ );
+ if bridge_config.is_stream_mode() {
+ match request_turn_bridge_stream(
+ http,
+ bridge_config,
+ sink,
+ call_id,
+ trace_id,
+ &turn,
+ &path_ref,
+ byte_size,
+ turn_pipeline_started_at,
+ )
+ .await
+ {
+ Ok(outcome) => {
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ audio_chunk_count = outcome.audio_chunk_count,
+ device_output_count = outcome.device_output_count,
+ "runtime helper turn_stream_completed"
+ );
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(&turn.turn_id),
+ "turn_completed",
+ "ok",
+ None,
+ None,
+ json!({
+ "replyTotalAfterVadEndMs": turn_pipeline_started_at.elapsed().as_millis() as u64,
+ "audioChunkCount": outcome.audio_chunk_count,
+ "deviceOutputCount": outcome.device_output_count,
+ "replyPlaybackMode": outcome.reply_playback_mode,
+ }),
+ );
+ }
+ Err(error) => {
+ let safe = safe_error(&error.to_string());
+ warn!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ error = %safe,
+ "runtime helper turn_stream_failed"
+ );
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(&turn.turn_id),
+ "turn_failed",
+ "failed",
+ Some("TURN_STREAM_FAILED"),
+ Some(true),
+ json!({
+ "stage": "turn_bridge_stream",
+ "error": safe,
+ }),
+ );
+ }
+ }
+ return;
+ }
+ match request_turn_bridge(
+ http,
+ bridge_config,
+ call_id,
+ trace_id,
+ &turn,
+ &path_ref,
+ byte_size,
+ turn_pipeline_started_at,
+ )
+ .await
+ {
+ Ok(outcome) => {
+ for output in &outcome.device_outputs {
+ if let Err(error) = sink
+ .publish_device_output(call_id, trace_id, &turn.turn_id, output)
+ .await
+ {
+ warn!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ reason_code = "BOT_DATA_WRITE_FAILED",
+ error = %safe_error(&error.to_string()),
+ "runtime helper device_output_failed"
+ );
+ }
+ }
+ let Some(reply_audio_artifact) = outcome.reply_audio_artifact else {
+ warn!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ reason_code = "REPLY_AUDIO_MISSING",
+ "runtime helper turn_failed"
+ );
+ return;
+ };
+ if let Err(error) = write_reply_audio_artifact(
+ http,
+ bridge_config,
+ sink,
+ call_id,
+ trace_id,
+ &turn,
+ &reply_audio_artifact,
+ turn_pipeline_started_at,
+ )
+ .await
+ {
+ warn!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ reason_code = "BOT_AUDIO_WRITE_FAILED",
+ error = %safe_error(&error.to_string()),
+ "runtime helper turn_failed"
+ );
+ return;
+ }
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ "runtime helper turn_completed"
+ );
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(&turn.turn_id),
+ "turn_completed",
+ "ok",
+ None,
+ None,
+ json!({
+ "replyTotalAfterVadEndMs": turn_pipeline_started_at.elapsed().as_millis() as u64,
+ }),
+ );
+ }
+ Err(error) => warn!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ error = %safe_error(&error.to_string()),
+ "runtime helper turn_bridge_failed"
+ ),
+ }
+ }
+ Err(error) => warn!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ error = %safe_error(&error.to_string()),
+ "runtime helper turn_artifact_write_failed"
+ ),
+ }
}
-async fn publish_device_output_smoke(room: &Room, config: &Config) -> Result<()> {
- let payload = build_device_output_smoke_payload(config);
- let payload_json =
- serde_json::to_vec(&payload).context("failed to serialize device output smoke payload")?;
- let destination_count = config.device_output_destination_identities.len();
- room.local_participant()
- .publish_data(DataPacket {
- reliable: true,
- payload: payload_json,
- topic: Some(DEVICE_OUTPUT_TOPIC.to_string()),
- destination_identities: config.device_output_destination_identities.clone(),
- })
+fn write_user_turn_artifact(
+ artifact_root: &Path,
+ call_id: &str,
+ turn: &FinishedSpeechTurn,
+) -> Result<(String, u64)> {
+ require_safe_segment(call_id)?;
+ require_safe_segment(&turn.turn_id)?;
+ if turn.samples.is_empty() {
+ return Err(anyhow!("empty turn samples"));
+ }
+ let path_ref = format!("{}/{}/user.wav", call_id, turn.turn_id);
+ let output_path = normalize_path_lexically(&artifact_root.join(&path_ref));
+ let root = normalize_path_lexically(artifact_root);
+ if !output_path.starts_with(&root) {
+ return Err(anyhow!("turn artifact path escapes root"));
+ }
+ if let Some(parent) = output_path.parent() {
+ fs::create_dir_all(parent).context("failed to create turn artifact dir")?;
+ }
+ audio::write_pcm_wav(
+ &output_path,
+ &turn.samples,
+ TARGET_SAMPLE_RATE_HZ,
+ TARGET_NUM_CHANNELS,
+ )?;
+ let byte_size = fs::metadata(&output_path)
+ .context("failed to stat turn artifact")?
+ .len();
+ Ok((path_ref, byte_size))
+}
+
+async fn request_turn_bridge_stream(
+ http: &Client,
+ bridge_config: &TurnBridgeConfig,
+ sink: &BotAudioOutputSink,
+ call_id: &str,
+ trace_id: &str,
+ turn: &FinishedSpeechTurn,
+ path_ref: &str,
+ byte_size: u64,
+ turn_pipeline_started_at: Instant,
+) -> Result<RuntimeTurnStreamOutcome> {
+ let bridge_started_at = Instant::now();
+ let request = RuntimeTurnRequest {
+ call_id: call_id.to_string(),
+ trace_id: trace_id.to_string(),
+ turn_id: turn.turn_id.clone(),
+ audio_artifact: RuntimeTurnAudioArtifact {
+ artifact_type: "local_file".to_string(),
+ path_ref: path_ref.to_string(),
+ format: "wav".to_string(),
+ sample_rate: TARGET_SAMPLE_RATE_HZ,
+ channels: u32::from(TARGET_NUM_CHANNELS),
+ duration_ms: turn.duration_ms,
+ byte_size,
+ },
+ };
+ let response = http
+ .post(bridge_config.bridge_url.as_deref().unwrap_or_default())
+ .header(
+ "X-CV-Runtime-Token",
+ bridge_config.bridge_token.as_deref().unwrap_or_default(),
+ )
+ .header("X-CV-Call-Id", call_id)
+ .header("X-CV-Trace-Id", trace_id)
+ .header(
+ "X-CV-Runtime-Session-Nonce",
+ bridge_config
+ .runtime_session_nonce
+ .as_deref()
+ .unwrap_or_default(),
+ )
+ .json(&request)
+ .send()
.await
- .map_err(|error| anyhow!("failed to publish livekit device output data: {error}"))?;
+ .context("failed to post turn stream bridge")?;
+ let status = response.status();
+ if !status.is_success() {
+ let body_len = response.text().await.map(|body| body.len()).unwrap_or(0);
+ warn!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ http_status = status.as_u16(),
+ body_len,
+ "runtime helper turn_stream_http_failed"
+ );
+ return Err(anyhow!("turn stream bridge http failed"));
+ }
info!(
- call_id = %config.call_id,
- trace_id = %config.trace_id,
- topic = DEVICE_OUTPUT_TOPIC,
- reliable = true,
- command_count = payload.sensor_instructions.len(),
- destination_count,
- "runtime helper published device output smoke data"
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ "runtime helper turn_stream_connected"
+ );
+ let mut byte_stream = response.bytes_stream();
+ let mut line_buffer: Vec<u8> = Vec::new();
+ let mut state = RuntimeTurnStreamState::default();
+ while let Some(chunk) = byte_stream.next().await {
+ let chunk = chunk.context("failed to read turn stream chunk")?;
+ line_buffer.extend_from_slice(&chunk);
+ while let Some(newline_index) = line_buffer.iter().position(|value| *value == b'\n') {
+ let line: Vec<u8> = line_buffer.drain(..=newline_index).collect();
+ if let Some(event) = parse_turn_stream_event_line(&line)? {
+ handle_turn_stream_event(
+ bridge_config,
+ sink,
+ call_id,
+ trace_id,
+ turn,
+ event,
+ &mut state,
+ turn_pipeline_started_at,
+ )
+ .await?;
+ }
+ }
+ }
+ if !line_buffer.is_empty() {
+ if let Some(event) = parse_turn_stream_event_line(&line_buffer)? {
+ handle_turn_stream_event(
+ bridge_config,
+ sink,
+ call_id,
+ trace_id,
+ turn,
+ event,
+ &mut state,
+ turn_pipeline_started_at,
+ )
+ .await?;
+ }
+ }
+ if !state.completed {
+ warn!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ audio_chunk_count = state.audio_chunk_count,
+ "runtime helper turn_stream_completed_without_final_event"
+ );
+ return Err(anyhow!("turn stream ended without turn_completed"));
+ }
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(&turn.turn_id),
+ "turn_bridge_completed",
+ "ok",
+ None,
+ None,
+ json!({
+ "bridgeWallDurationMs": bridge_started_at.elapsed().as_millis() as u64,
+ "replyTotalAfterVadEndMs": turn_pipeline_started_at.elapsed().as_millis() as u64,
+ "audioChunkCount": state.audio_chunk_count,
+ "deviceOutputCount": state.device_output_count,
+ "replyPlaybackMode": state.reply_playback_mode.as_str(),
+ }),
+ );
+ Ok(RuntimeTurnStreamOutcome {
+ reply_playback_mode: state.reply_playback_mode,
+ audio_chunk_count: state.audio_chunk_count,
+ device_output_count: state.device_output_count,
+ })
+}
+
+fn parse_turn_stream_event_line(line: &[u8]) -> Result<Option<RuntimeTurnStreamEvent>> {
+ let line = trim_ascii_whitespace(line);
+ if line.is_empty() {
+ return Ok(None);
+ }
+ serde_json::from_slice(line)
+ .context("failed to parse turn stream event")
+ .map(Some)
+}
+
+fn diagnostic_str<'a>(diagnostics: Option<&'a serde_json::Value>, key: &str) -> Option<&'a str> {
+ diagnostics?.get(key)?.as_str()
+}
+
+fn diagnostic_bool(diagnostics: Option<&serde_json::Value>, key: &str) -> Option<bool> {
+ diagnostics?.get(key)?.as_bool()
+}
+
+fn diagnostic_u64(diagnostics: Option<&serde_json::Value>, key: &str) -> Option<u64> {
+ diagnostics?.get(key)?.as_u64()
+}
+
+async fn handle_turn_stream_event(
+ bridge_config: &TurnBridgeConfig,
+ sink: &BotAudioOutputSink,
+ call_id: &str,
+ trace_id: &str,
+ turn: &FinishedSpeechTurn,
+ event: RuntimeTurnStreamEvent,
+ state: &mut RuntimeTurnStreamState,
+ turn_pipeline_started_at: Instant,
+) -> Result<()> {
+ let event_type = event.event_type();
+ match event_type.as_deref() {
+ Some("reply_playback_mode_selected") => {
+ if let Some(reply_playback_mode) = event.reply_playback_mode.as_deref() {
+ state.reply_playback_mode = reply_playback_mode.to_string();
+ }
+ let diagnostics = event.diagnostics.as_ref();
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ reply_playback_mode = %state.reply_playback_mode,
+ streaming_enabled = ?diagnostic_bool(diagnostics, "streamingEnabled"),
+ provider_streaming_supported = ?diagnostic_bool(diagnostics, "providerStreamingSupported"),
+ first_chunk_received = ?diagnostic_bool(diagnostics, "firstChunkReceived"),
+ stream_chunk_count = ?diagnostic_u64(diagnostics, "streamChunkCount"),
+ fallback_reason = %diagnostic_str(diagnostics, "fallbackReason").unwrap_or("none"),
+ fallback_stage = %diagnostic_str(diagnostics, "fallbackStage").unwrap_or("none"),
+ stream_bridge_mode = %diagnostic_str(diagnostics, "streamBridgeMode").unwrap_or("unknown"),
+ tts_provider = %diagnostic_str(diagnostics, "ttsProvider").unwrap_or("unknown"),
+ "runtime helper reply_playback_mode_selected"
+ );
+ }
+ Some("reply_state") => {
+ if let Some(reply_state) = event.state.as_deref() {
+ publish_reply_state_from_stream_event(
+ sink,
+ call_id,
+ trace_id,
+ &turn.turn_id,
+ &state.reply_playback_mode,
+ reply_state,
+ event.seq,
+ )
+ .await?;
+ if reply_state == "reply_playback_started" {
+ state.playback_started_sent = true;
+ }
+ }
+ }
+ Some("reply_audio_chunk") => {
+ let audio_chunk = event
+ .audio_chunk
+ .as_ref()
+ .ok_or_else(|| anyhow!("reply_audio_chunk event missing audioChunk"))?;
+ if !state.playback_started_sent {
+ state.reply_state_seq = state.reply_state_seq.saturating_add(1);
+ sink.publish_reply_state(
+ call_id,
+ trace_id,
+ &turn.turn_id,
+ &state.reply_playback_mode,
+ "reply_playback_started",
+ state.reply_state_seq,
+ )
+ .await?;
+ state.playback_started_sent = true;
+ }
+ let written_frames = write_stream_audio_chunk(
+ bridge_config,
+ sink,
+ call_id,
+ trace_id,
+ turn,
+ audio_chunk,
+ state,
+ turn_pipeline_started_at,
+ )
+ .await?;
+ if written_frames > 0 {
+ state.audio_chunk_count = state.audio_chunk_count.saturating_add(1);
+ }
+ }
+ Some("device_output") => {
+ if let Some(output) = event.device_output.as_ref() {
+ sink.publish_device_output(call_id, trace_id, &turn.turn_id, output)
+ .await?;
+ state.device_output_count = state.device_output_count.saturating_add(1);
+ }
+ }
+ Some("turn_completed") => {
+ state.completed = true;
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ message_id_alias = %event.completion.as_ref().and_then(|value| value.message_id.as_deref()).map(redact).unwrap_or_else(|| "none".to_string()),
+ audio_chunk_count = state.audio_chunk_count,
+ "runtime helper turn_stream_final_received"
+ );
+ }
+ Some("turn_failed") => {
+ let error = event.error.as_ref();
+ let reason_code = error
+ .and_then(|value| value.reason_code.as_deref())
+ .unwrap_or("TURN_STREAM_FAILED");
+ let stage = error
+ .and_then(|value| value.stage.as_deref())
+ .unwrap_or("turn_bridge_stream");
+ let retryable = error.and_then(|value| value.retryable).unwrap_or(false);
+ warn!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ reason_code = %reason_code,
+ stage = %stage,
+ retryable = retryable,
+ "runtime helper turn_stream_failed_event"
+ );
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(&turn.turn_id),
+ "turn_failed",
+ "failed",
+ Some(reason_code),
+ Some(retryable),
+ json!({
+ "stage": stage,
+ "replyPlaybackMode": state.reply_playback_mode.as_str(),
+ }),
+ );
+ return Err(anyhow!("turn stream failed event"));
+ }
+ Some("turn_cancelled") => {
+ state.completed = true;
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ "runtime helper turn_stream_cancelled_event"
+ );
+ publish_reply_state_from_stream_event(
+ sink,
+ call_id,
+ trace_id,
+ &turn.turn_id,
+ &state.reply_playback_mode,
+ "reply_playback_cancelled",
+ event.seq,
+ )
+ .await?;
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(&turn.turn_id),
+ "turn_cancelled",
+ "ok",
+ None,
+ Some(false),
+ json!({
+ "replyPlaybackMode": state.reply_playback_mode.as_str(),
+ "audioChunkCount": state.audio_chunk_count,
+ }),
+ );
+ }
+ Some("activity") => {
+ if let Some(activity) = event.activity.as_ref() {
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ activity_event = %activity.event_type.as_deref().unwrap_or("unknown"),
+ stage = %activity.stage.as_deref().unwrap_or("unknown"),
+ reason_code = %activity.reason_code.as_deref().unwrap_or("none"),
+ "runtime helper turn_stream_activity"
+ );
+ }
+ }
+ Some(other) => {
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ event_type = %other,
+ "runtime helper ignored turn stream event"
+ );
+ }
+ None => {
+ warn!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ "runtime helper ignored turn stream event without type"
+ );
+ }
+ }
+ Ok(())
+}
+
+async fn publish_reply_state_from_stream_event(
+ sink: &BotAudioOutputSink,
+ call_id: &str,
+ trace_id: &str,
+ turn_id: &str,
+ reply_playback_mode: &str,
+ state: &str,
+ seq: Option<u64>,
+) -> Result<()> {
+ sink.publish_reply_state(
+ call_id,
+ trace_id,
+ turn_id,
+ reply_playback_mode,
+ state,
+ seq.unwrap_or(0),
+ )
+ .await
+}
+
+async fn write_stream_audio_chunk(
+ bridge_config: &TurnBridgeConfig,
+ sink: &BotAudioOutputSink,
+ call_id: &str,
+ trace_id: &str,
+ turn: &FinishedSpeechTurn,
+ audio_chunk: &RuntimeTurnStreamAudioChunk,
+ state: &mut RuntimeTurnStreamState,
+ turn_pipeline_started_at: Instant,
+) -> Result<usize> {
+ let payload_base64 = audio_chunk
+ .payload_base64
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .ok_or_else(|| anyhow!("reply_audio_chunk payloadBase64 missing"))?;
+ let payload = general_purpose::STANDARD
+ .decode(payload_base64)
+ .context("failed to decode reply_audio_chunk payloadBase64")?;
+ let format = audio_chunk
+ .format
+ .as_deref()
+ .unwrap_or("pcm_s16le")
+ .trim()
+ .to_ascii_lowercase();
+ let frames = if format == "pcm_s16le" {
+ pcm_s16le_payload_to_frames(
+ &payload,
+ audio_chunk.sample_rate.unwrap_or(TARGET_SAMPLE_RATE_HZ),
+ audio_chunk
+ .channels
+ .unwrap_or(u32::from(TARGET_NUM_CHANNELS)),
+ )?
+ } else if matches!(format.as_str(), "mp3" | "mpeg" | "wav") {
+ state.encoded_audio_buffer.extend_from_slice(&payload);
+ match audio::decode_audio_bytes_to_frames(
+ &state.encoded_audio_buffer,
+ "stream_chunk",
+ TARGET_SAMPLE_RATE_HZ,
+ TARGET_NUM_CHANNELS,
+ bridge_config.audio_debug_dump_dir.as_deref(),
+ call_id,
+ &format!("stream-reply-{}", turn.turn_id),
+ ) {
+ Ok(loaded) => {
+ state.encoded_audio_buffer.clear();
+ loaded.frames
+ }
+ Err(error) if !audio_chunk.last.unwrap_or(false) => {
+ warn!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ chunk_seq = audio_chunk.chunk_seq.unwrap_or_default(),
+ format = %format,
+ error = %safe_error(&error.to_string()),
+ "runtime helper stream_audio_chunk_decode_waiting_for_more_data"
+ );
+ return Ok(0);
+ }
+ Err(error) => return Err(error).context("failed to decode final stream audio chunk"),
+ }
+ } else {
+ return Err(anyhow!("unsupported reply_audio_chunk format {format}"));
+ };
+ if frames.is_empty() {
+ return Ok(0);
+ }
+
+ if !state.first_audio_frame_written {
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(&turn.turn_id),
+ "bot_reply_audio_write_started",
+ "ok",
+ None,
+ None,
+ json!({
+ "replyPlaybackMode": state.reply_playback_mode.as_str(),
+ "format": format.as_str(),
+ "chunkSeq": audio_chunk.chunk_seq,
+ "replyTotalAfterVadEndMs": turn_pipeline_started_at.elapsed().as_millis() as u64,
+ }),
+ );
+ }
+ let pacing_started_at = tokio::time::Instant::now();
+ for (index, frame) in frames.iter().enumerate() {
+ sink.write_pcm_frame(frame).await?;
+ if !state.first_audio_frame_written {
+ state.first_audio_frame_written = true;
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(&turn.turn_id),
+ "bot_reply_first_audio_frame_written",
+ "ok",
+ None,
+ None,
+ json!({
+ "replyPlaybackMode": state.reply_playback_mode.as_str(),
+ "format": format.as_str(),
+ "chunkSeq": audio_chunk.chunk_seq,
+ "replyTotalAfterVadEndMs": turn_pipeline_started_at.elapsed().as_millis() as u64,
+ }),
+ );
+ }
+ sleep_until(pacing_started_at + Duration::from_millis(((index + 1) as u64) * 20)).await;
+ }
+ if audio_chunk.last.unwrap_or(false) {
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(&turn.turn_id),
+ "bot_reply_audio_write_finished",
+ "ok",
+ None,
+ None,
+ json!({
+ "replyPlaybackMode": state.reply_playback_mode.as_str(),
+ "format": format.as_str(),
+ "chunkSeq": audio_chunk.chunk_seq,
+ "replyTotalAfterVadEndMs": turn_pipeline_started_at.elapsed().as_millis() as u64,
+ }),
+ );
+ }
+ Ok(frames.len())
+}
+
+fn pcm_s16le_payload_to_frames(
+ payload: &[u8],
+ sample_rate: u32,
+ channels: u32,
+) -> Result<Vec<PcmFrame>> {
+ if sample_rate != TARGET_SAMPLE_RATE_HZ || channels != u32::from(TARGET_NUM_CHANNELS) {
+ return Err(anyhow!(
+ "unsupported pcm_s16le stream format: sample_rate={}, channels={}",
+ sample_rate,
+ channels
+ ));
+ }
+ if payload.len() % 2 != 0 {
+ return Err(anyhow!("pcm_s16le payload has odd byte length"));
+ }
+ let samples: Vec<i16> = payload
+ .chunks_exact(2)
+ .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]))
+ .collect();
+ if samples.is_empty() {
+ return Ok(Vec::new());
+ }
+ let samples_per_channel = (sample_rate / 50).max(1);
+ let frame_sample_count = samples_per_channel as usize * channels as usize;
+ let mut frames = Vec::new();
+ for chunk in samples.chunks(frame_sample_count) {
+ let chunk_samples_per_channel = (chunk.len() / channels as usize) as u32;
+ if chunk_samples_per_channel == 0 {
+ continue;
+ }
+ frames.push(PcmFrame::new(
+ chunk.to_vec(),
+ sample_rate,
+ channels,
+ chunk_samples_per_channel,
+ ));
+ }
+ Ok(frames)
+}
+
+fn trim_ascii_whitespace(value: &[u8]) -> &[u8] {
+ let mut start = 0;
+ let mut end = value.len();
+ while start < end && value[start].is_ascii_whitespace() {
+ start += 1;
+ }
+ while end > start && value[end - 1].is_ascii_whitespace() {
+ end -= 1;
+ }
+ &value[start..end]
+}
+
+async fn request_turn_bridge(
+ http: &Client,
+ bridge_config: &TurnBridgeConfig,
+ call_id: &str,
+ trace_id: &str,
+ turn: &FinishedSpeechTurn,
+ path_ref: &str,
+ byte_size: u64,
+ turn_pipeline_started_at: Instant,
+) -> Result<RuntimeTurnBridgeOutcome> {
+ let bridge_started_at = Instant::now();
+ let request = RuntimeTurnRequest {
+ call_id: call_id.to_string(),
+ trace_id: trace_id.to_string(),
+ turn_id: turn.turn_id.clone(),
+ audio_artifact: RuntimeTurnAudioArtifact {
+ artifact_type: "local_file".to_string(),
+ path_ref: path_ref.to_string(),
+ format: "wav".to_string(),
+ sample_rate: TARGET_SAMPLE_RATE_HZ,
+ channels: u32::from(TARGET_NUM_CHANNELS),
+ duration_ms: turn.duration_ms,
+ byte_size,
+ },
+ };
+ let response = http
+ .post(bridge_config.bridge_url.as_deref().unwrap_or_default())
+ .header(
+ "X-CV-Runtime-Token",
+ bridge_config.bridge_token.as_deref().unwrap_or_default(),
+ )
+ .header("X-CV-Call-Id", call_id)
+ .header("X-CV-Trace-Id", trace_id)
+ .header(
+ "X-CV-Runtime-Session-Nonce",
+ bridge_config
+ .runtime_session_nonce
+ .as_deref()
+ .unwrap_or_default(),
+ )
+ .json(&request)
+ .send()
+ .await
+ .context("failed to post turn bridge")?;
+ let status = response.status();
+ let body = response
+ .text()
+ .await
+ .context("failed to read turn bridge response")?;
+ let body_len = body.len();
+ let parsed: Option<RuntimeTurnCommonResult<RuntimeTurnResponseData>> =
+ serde_json::from_str(&body).ok();
+ if !status.is_success() {
+ warn!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ http_status = status.as_u16(),
+ body_len,
+ code = parsed.as_ref().map(|value| value.code).unwrap_or_default(),
+ reason_code = %parsed.as_ref().and_then(|value| value.msg.as_deref()).unwrap_or("unknown"),
+ "runtime helper turn_bridge_http_failed"
+ );
+ return Err(anyhow!("turn bridge http failed"));
+ }
+ let Some(result) = parsed else {
+ warn!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ body_len,
+ "runtime helper turn_bridge_response_invalid"
+ );
+ return Err(anyhow!("turn bridge response invalid"));
+ };
+ if result.code != 0 {
+ warn!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ code = result.code,
+ reason_code = %result.msg.as_deref().unwrap_or("unknown"),
+ retryable = result.retryable.unwrap_or(false),
+ stage = %result.stage.as_deref().unwrap_or("unknown"),
+ "runtime helper turn_bridge_business_failed"
+ );
+ return Err(anyhow!("turn bridge business failed"));
+ }
+ let data = result.data;
+ let reply_audio_artifact = data
+ .as_ref()
+ .and_then(|value| value.reply_audio_artifact.as_ref());
+ let device_outputs = data
+ .as_ref()
+ .and_then(|value| value.device_outputs.clone())
+ .unwrap_or_default();
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ response_turn_id = %data.as_ref().and_then(|value| value.turn_id.as_deref()).unwrap_or("unknown"),
+ message_id_alias = %data.as_ref().and_then(|value| value.message_id.as_deref()).map(redact).unwrap_or_else(|| "none".to_string()),
+ reply_audio_present = reply_audio_artifact.is_some(),
+ reply_audio_type = %reply_audio_artifact.and_then(|value| value.artifact_type.as_deref()).unwrap_or("none"),
+ reply_audio_path_alias = %reply_audio_artifact.and_then(|value| value.path_ref.as_deref()).map(redact).unwrap_or_else(|| "none".to_string()),
+ reply_audio_format = %reply_audio_artifact.and_then(|value| value.format.as_deref()).unwrap_or("none"),
+ device_output_count = device_outputs.len(),
+ "runtime helper turn_bridge_completed"
+ );
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(&turn.turn_id),
+ "turn_bridge_completed",
+ "ok",
+ None,
+ None,
+ json!({
+ "bridgeWallDurationMs": bridge_started_at.elapsed().as_millis() as u64,
+ "replyAudioPresent": reply_audio_artifact.is_some(),
+ "deviceOutputCount": device_outputs.len(),
+ "replyTotalAfterVadEndMs": turn_pipeline_started_at.elapsed().as_millis() as u64,
+ }),
+ );
+ Ok(RuntimeTurnBridgeOutcome {
+ reply_audio_artifact: reply_audio_artifact.cloned(),
+ device_outputs,
+ })
+}
+
+async fn write_reply_audio_artifact(
+ http: &Client,
+ bridge_config: &TurnBridgeConfig,
+ sink: &BotAudioOutputSink,
+ call_id: &str,
+ trace_id: &str,
+ turn: &FinishedSpeechTurn,
+ artifact: &RuntimeTurnReplyAudioArtifact,
+ turn_pipeline_started_at: Instant,
+) -> Result<()> {
+ let artifact_type = artifact.artifact_type.as_deref().unwrap_or_default().trim();
+ if artifact_type != "local_file" {
+ return Err(anyhow!("unsupported reply audio artifact type"));
+ }
+ let path_ref = artifact
+ .path_ref
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .ok_or_else(|| anyhow!("reply audio pathRef missing"))?;
+ let artifact_root = PathBuf::from(bridge_config.artifact_dir.as_deref().unwrap_or_default());
+ let audio_path = resolve_artifact_path(&artifact_root, path_ref)?;
+ let audio_path_string = audio_path.to_string_lossy().to_string();
+ let reply_audio_format = artifact.format.as_deref().unwrap_or("unknown");
+
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ reply_audio_type = %artifact_type,
+ reply_audio_path_alias = %redact(path_ref),
+ reply_audio_format = %reply_audio_format,
+ "runtime helper bot_reply_audio_write_started"
+ );
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(&turn.turn_id),
+ "bot_reply_audio_write_started",
+ "ok",
+ None,
+ None,
+ json!({
+ "replyAudioFormat": reply_audio_format,
+ "replyTotalAfterVadEndMs": turn_pipeline_started_at.elapsed().as_millis() as u64,
+ }),
+ );
+
+ let reply_debug_label = format!("reply-{}", turn.turn_id);
+ let loaded_audio = load_pre_recorded_frames(
+ http,
+ Some(&audio_path_string),
+ None,
+ TARGET_SAMPLE_RATE_HZ,
+ TARGET_NUM_CHANNELS,
+ bridge_config.audio_debug_dump_dir.as_deref(),
+ call_id,
+ &reply_debug_label,
+ )
+ .await?
+ .ok_or_else(|| anyhow!("reply audio artifact decode returned empty"))?;
+ let AudioDiagnostics {
+ source_format,
+ source_bytes,
+ source_sample_rate_hz,
+ source_num_channels,
+ target_duration_ms,
+ frame_count,
+ rms,
+ peak,
+ clipped_sample_count,
+ silence_ratio,
+ mp3_skipped_data_count,
+ mp3_insufficient_data_count,
+ debug_source_path,
+ debug_pcm_wav_path,
+ ..
+ } = loaded_audio.diagnostics;
+ let frames = loaded_audio.frames;
+
+ let playback_started_at = Instant::now();
+ let pacing_started_at = tokio::time::Instant::now();
+ for (index, frame) in frames.iter().enumerate() {
+ sink.write_pcm_frame(frame).await?;
+ sleep_until(pacing_started_at + Duration::from_millis(((index + 1) as u64) * 20)).await;
+ }
+ let playback_wall_ms = playback_started_at.elapsed().as_millis() as i64;
+ let drift_ms = playback_wall_ms - target_duration_ms as i64;
+ sink.clear_buffer();
+
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn.turn_id,
+ source_format,
+ source_bytes,
+ source_sample_rate_hz,
+ source_num_channels,
+ frame_count,
+ theoretical_duration_ms = target_duration_ms,
+ push_wall_duration_ms = playback_wall_ms,
+ push_drift_ms = drift_ms,
+ rms = round4(rms),
+ peak = round4(peak),
+ clipped_sample_count,
+ silence_ratio = round4(silence_ratio),
+ mp3_skipped_data_count,
+ mp3_insufficient_data_count,
+ debug_source_path = debug_source_path.as_deref().unwrap_or(""),
+ debug_pcm_wav_path = debug_pcm_wav_path.as_deref().unwrap_or(""),
+ "runtime helper bot_reply_audio_write_finished"
+ );
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(&turn.turn_id),
+ "bot_reply_audio_write_finished",
+ "ok",
+ None,
+ None,
+ json!({
+ "sourceFormat": source_format,
+ "sourceBytes": source_bytes,
+ "sourceSampleRateHz": source_sample_rate_hz,
+ "sourceNumChannels": source_num_channels,
+ "frameCount": frame_count,
+ "theoreticalDurationMs": target_duration_ms,
+ "pushWallDurationMs": playback_wall_ms,
+ "pushDriftMs": drift_ms,
+ "rms": round4(rms),
+ "peak": round4(peak),
+ "clippedSampleCount": clipped_sample_count,
+ "silenceRatio": round4(silence_ratio),
+ "mp3SkippedDataCount": mp3_skipped_data_count,
+ "mp3InsufficientDataCount": mp3_insufficient_data_count,
+ "replyTotalAfterVadEndMs": turn_pipeline_started_at.elapsed().as_millis() as u64,
+ }),
);
Ok(())
}
-fn build_device_output_smoke_payload(config: &Config) -> DeviceOutputMessage {
- DeviceOutputMessage {
- message_type: DEVICE_OUTPUT_TOPIC,
- schema_version: "1.0",
- call_id: config.call_id.clone(),
- role_id: config.role_id.clone(),
- trace_id: config.trace_id.clone(),
- ack_mode: "http",
- sensor_instructions: vec![
- SensorInstruction {
- command_id: command_id(&config.call_id, 1),
- sensor_type: "Vibrator",
- operation_type: "VibratorStart",
- step: 1,
- duration_sec: None,
- extension: r#"{"levelList":[{"level":1,"percent":"0.6"}]}"#.to_string(),
- },
- SensorInstruction {
- command_id: command_id(&config.call_id, 2),
- sensor_type: "Vibrator",
- operation_type: "VibratorUp",
- step: 1,
- duration_sec: None,
- extension: r#"{"delta":1}"#.to_string(),
- },
- SensorInstruction {
- command_id: command_id(&config.call_id, 3),
- sensor_type: "Pump",
- operation_type: "JiaStart",
- step: 1,
- duration_sec: None,
- extension: r#"{"levelList":[{"level":1,"percent":"0.5"}]}"#.to_string(),
- },
- SensorInstruction {
- command_id: command_id(&config.call_id, 4),
- sensor_type: "Heating",
- operation_type: "HeatingStart",
- step: 1,
- duration_sec: Some(3),
- extension: r#"{"target":"warm"}"#.to_string(),
- },
- ],
+fn resolve_artifact_path(artifact_root: &Path, path_ref: &str) -> Result<PathBuf> {
+ let path_ref = path_ref.trim();
+ if path_ref.is_empty() {
+ return Err(anyhow!("empty artifact pathRef"));
+ }
+ let relative = Path::new(path_ref);
+ if relative.is_absolute() {
+ return Err(anyhow!("absolute artifact pathRef is not allowed"));
+ }
+ let mut safe_relative = PathBuf::new();
+ for component in relative.components() {
+ match component {
+ std::path::Component::Normal(segment) => {
+ let segment = segment
+ .to_str()
+ .ok_or_else(|| anyhow!("non-utf8 artifact pathRef segment"))?;
+ require_safe_segment(segment)?;
+ safe_relative.push(segment);
+ }
+ std::path::Component::CurDir => {}
+ _ => return Err(anyhow!("unsafe artifact pathRef component")),
+ }
+ }
+ if safe_relative.as_os_str().is_empty() {
+ return Err(anyhow!("artifact pathRef has no safe components"));
+ }
+ let root = normalize_path_lexically(artifact_root);
+ let output_path = normalize_path_lexically(&root.join(safe_relative));
+ if !output_path.starts_with(&root) {
+ return Err(anyhow!("reply artifact path escapes root"));
+ }
+ Ok(output_path)
+}
+
+#[derive(Serialize)]
+struct RuntimeTurnRequest {
+ #[serde(rename = "callId")]
+ call_id: String,
+ #[serde(rename = "traceId")]
+ trace_id: String,
+ #[serde(rename = "turnId")]
+ turn_id: String,
+ #[serde(rename = "audioArtifact")]
+ audio_artifact: RuntimeTurnAudioArtifact,
+}
+
+#[derive(Serialize)]
+struct RuntimeTurnAudioArtifact {
+ #[serde(rename = "type")]
+ artifact_type: String,
+ #[serde(rename = "pathRef")]
+ path_ref: String,
+ format: String,
+ #[serde(rename = "sampleRate")]
+ sample_rate: u32,
+ channels: u32,
+ #[serde(rename = "durationMs")]
+ duration_ms: u64,
+ #[serde(rename = "byteSize")]
+ byte_size: u64,
+}
+
+#[derive(Deserialize)]
+struct RuntimeTurnCommonResult<T> {
+ code: i64,
+ msg: Option<String>,
+ data: Option<T>,
+ stage: Option<String>,
+ retryable: Option<bool>,
+}
+
+#[derive(Default)]
+struct RuntimeTurnBridgeOutcome {
+ reply_audio_artifact: Option<RuntimeTurnReplyAudioArtifact>,
+ device_outputs: Vec<RuntimeTurnDeviceOutput>,
+}
+
+struct RuntimeTurnStreamOutcome {
+ reply_playback_mode: String,
+ audio_chunk_count: u64,
+ device_output_count: u64,
+}
+
+struct RuntimeTurnStreamState {
+ reply_playback_mode: String,
+ reply_state_seq: u64,
+ playback_started_sent: bool,
+ first_audio_frame_written: bool,
+ completed: bool,
+ audio_chunk_count: u64,
+ device_output_count: u64,
+ encoded_audio_buffer: Vec<u8>,
+}
+
+impl Default for RuntimeTurnStreamState {
+ fn default() -> Self {
+ Self {
+ reply_playback_mode: "full_tts_fallback".to_string(),
+ reply_state_seq: 0,
+ playback_started_sent: false,
+ first_audio_frame_written: false,
+ completed: false,
+ audio_chunk_count: 0,
+ device_output_count: 0,
+ encoded_audio_buffer: Vec::new(),
+ }
}
}
-fn command_id(call_id: &str, sequence: u8) -> String {
- format!("{call_id}-device-smoke-{sequence:03}")
+#[derive(Deserialize)]
+struct RuntimeTurnResponseData {
+ #[serde(rename = "turnId")]
+ turn_id: Option<String>,
+ #[serde(rename = "messageId")]
+ message_id: Option<String>,
+ #[serde(rename = "replyAudioArtifact")]
+ reply_audio_artifact: Option<RuntimeTurnReplyAudioArtifact>,
+ #[serde(rename = "deviceOutputs")]
+ device_outputs: Option<Vec<RuntimeTurnDeviceOutput>>,
+}
+
+#[derive(Deserialize)]
+struct RuntimeTurnStreamEvent {
+ #[serde(rename = "type", alias = "event")]
+ event_type: Option<String>,
+ #[serde(rename = "seq")]
+ seq: Option<u64>,
+ #[serde(rename = "replyPlaybackMode")]
+ reply_playback_mode: Option<String>,
+ state: Option<String>,
+ #[serde(rename = "audioChunk")]
+ audio_chunk: Option<RuntimeTurnStreamAudioChunk>,
+ activity: Option<RuntimeTurnStreamActivity>,
+ #[serde(rename = "deviceOutput")]
+ device_output: Option<RuntimeTurnDeviceOutput>,
+ error: Option<RuntimeTurnStreamError>,
+ completion: Option<RuntimeTurnStreamCompletion>,
+ diagnostics: Option<serde_json::Value>,
+}
+
+impl RuntimeTurnStreamEvent {
+ fn event_type(&self) -> Option<String> {
+ self.event_type.as_deref().map(|value| match value {
+ "reply_playback_mode_selected" => "reply_playback_mode_selected".to_string(),
+ "reply_state" => "reply_state".to_string(),
+ "reply_audio_chunk" => "reply_audio_chunk".to_string(),
+ "device_output" => "device_output".to_string(),
+ "turn_completed" => "turn_completed".to_string(),
+ "turn_failed" => "turn_failed".to_string(),
+ "turn_cancelled" => "turn_cancelled".to_string(),
+ "activity" => "activity".to_string(),
+ other => other.to_string(),
+ })
+ }
+}
+
+#[derive(Deserialize)]
+struct RuntimeTurnStreamAudioChunk {
+ #[serde(rename = "chunkSeq", alias = "seq")]
+ chunk_seq: Option<u64>,
+ format: Option<String>,
+ #[serde(rename = "sampleRate")]
+ sample_rate: Option<u32>,
+ channels: Option<u32>,
+ #[serde(rename = "payloadBase64", alias = "audioBase64")]
+ payload_base64: Option<String>,
+ last: Option<bool>,
+}
+
+#[derive(Deserialize)]
+struct RuntimeTurnStreamActivity {
+ #[serde(rename = "eventType", alias = "event")]
+ event_type: Option<String>,
+ stage: Option<String>,
+ #[serde(rename = "reasonCode")]
+ reason_code: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct RuntimeTurnStreamError {
+ #[serde(rename = "reasonCode")]
+ reason_code: Option<String>,
+ stage: Option<String>,
+ retryable: Option<bool>,
+}
+
+#[derive(Deserialize)]
+struct RuntimeTurnStreamCompletion {
+ #[serde(rename = "messageId")]
+ message_id: Option<String>,
+}
+
+#[derive(Clone, Deserialize)]
+struct RuntimeTurnReplyAudioArtifact {
+ #[serde(rename = "type")]
+ artifact_type: Option<String>,
+ #[serde(rename = "pathRef")]
+ path_ref: Option<String>,
+ format: Option<String>,
+}
+
+#[derive(Clone, Deserialize)]
+struct RuntimeTurnDeviceOutput {
+ #[serde(rename = "commandId")]
+ command_id: Option<String>,
+ #[serde(rename = "commandCode")]
+ command_code: Option<String>,
+ params: Option<serde_json::Value>,
+}
+
+fn require_safe_segment(value: &str) -> Result<()> {
+ if value.is_empty()
+ || value.contains('/')
+ || value.contains("..")
+ || !value
+ .chars()
+ .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
+ {
+ return Err(anyhow!("unsafe path segment"));
+ }
+ Ok(())
+}
+
+fn normalize_path_lexically(path: &Path) -> PathBuf {
+ let mut normalized = PathBuf::new();
+ for component in path.components() {
+ match component {
+ std::path::Component::CurDir => {}
+ std::path::Component::ParentDir => {
+ normalized.pop();
+ }
+ _ => normalized.push(component.as_os_str()),
+ }
+ }
+ normalized
+}
+
+fn spawn_user_audio_frame_observer(
+ track: RemoteAudioTrack,
+ call_id: String,
+ trace_id: String,
+ participant_alias: String,
+ track_sid_alias: String,
+ simple_vad_enabled: bool,
+ simple_vad_config: SimpleVadConfig,
+ vad_enabled_gate: Arc<AtomicBool>,
+ turn_bridge_config: TurnBridgeConfig,
+ http: Client,
+ sink: Arc<BotAudioOutputSink>,
+) -> JoinHandle<()> {
+ tokio::spawn(async move {
+ let mut stream = NativeAudioStream::new(
+ track.rtc_track(),
+ TARGET_SAMPLE_RATE_HZ as i32,
+ i32::from(TARGET_NUM_CHANNELS),
+ );
+ let started_at = Instant::now();
+ let mut frame_count: u64 = 0;
+ let mut sample_count: u64 = 0;
+ let mut simple_vad = if simple_vad_enabled {
+ Some(SimpleVad::new(simple_vad_config))
+ } else {
+ None
+ };
+
+ while let Some(frame) = stream.next().await {
+ frame_count += 1;
+ sample_count += u64::from(frame.samples_per_channel) * u64::from(frame.num_channels);
+ let elapsed_ms = started_at.elapsed().as_millis() as u64;
+ if frame_count == 1 {
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ participant_alias = %participant_alias,
+ track_sid_alias = %track_sid_alias,
+ sample_rate_hz = frame.sample_rate,
+ num_channels = frame.num_channels,
+ samples_per_channel = frame.samples_per_channel,
+ first_frame_elapsed_ms = elapsed_ms,
+ "runtime helper user_audio_frame_received"
+ );
+ } else if frame_count % 250 == 0 {
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ participant_alias = %participant_alias,
+ track_sid_alias = %track_sid_alias,
+ frame_count,
+ sample_count,
+ observed_wall_ms = elapsed_ms,
+ "runtime helper user_audio_frame_summary"
+ );
+ }
+
+ if let Some(vad) = simple_vad.as_mut() {
+ if vad_enabled_gate.load(Ordering::Acquire) {
+ if let Some(turn) = vad.observe_frame(
+ &call_id,
+ &trace_id,
+ &participant_alias,
+ &track_sid_alias,
+ frame_count,
+ elapsed_ms,
+ &frame,
+ ) {
+ handle_finished_turn(
+ &http,
+ &turn_bridge_config,
+ &sink,
+ &call_id,
+ &trace_id,
+ turn,
+ )
+ .await;
+ }
+ } else {
+ vad.observe_disabled_frame(
+ &call_id,
+ &trace_id,
+ &participant_alias,
+ &track_sid_alias,
+ frame_count,
+ elapsed_ms,
+ );
+ }
+ }
+ }
+
+ if let Some(vad) = simple_vad.as_mut() {
+ if let Some(turn) = vad.finish_stream(
+ &call_id,
+ &trace_id,
+ &participant_alias,
+ &track_sid_alias,
+ started_at.elapsed().as_millis() as u64,
+ ) {
+ handle_finished_turn(&http, &turn_bridge_config, &sink, &call_id, &trace_id, turn)
+ .await;
+ }
+ }
+
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ participant_alias = %participant_alias,
+ track_sid_alias = %track_sid_alias,
+ frame_count,
+ sample_count,
+ observed_wall_ms = started_at.elapsed().as_millis() as u64,
+ "runtime helper user_audio_stream_ended"
+ );
+ })
+}
+
+#[derive(Clone)]
+struct SimpleVadConfig {
+ rms_threshold: f64,
+ peak_threshold: f64,
+ start_frames: u32,
+ end_silence_ms: u64,
+ min_speech_ms: u64,
+ max_turn_ms: u64,
+ initial_ignore_ms: u64,
+}
+
+impl SimpleVadConfig {
+ fn from_env() -> Self {
+ Self {
+ rms_threshold: f64_env("CV_VAD_RMS_THRESHOLD", 0.012),
+ peak_threshold: f64_env("CV_VAD_PEAK_THRESHOLD", 0.08),
+ start_frames: u32_env("CV_VAD_START_FRAMES", 5).max(1),
+ end_silence_ms: u64_env("CV_VAD_END_SILENCE_MS", 700).max(100),
+ min_speech_ms: u64_env("CV_VAD_MIN_SPEECH_MS", 300).max(1),
+ max_turn_ms: u64_env("CV_VAD_MAX_TURN_MS", 10_000).max(1_000),
+ initial_ignore_ms: u64_env("CV_VAD_INITIAL_IGNORE_MS", 500),
+ }
+ }
+
+ fn end_silence_frames(&self, frame_duration_ms: u64) -> u32 {
+ let frame_duration_ms = frame_duration_ms.max(1);
+ self.end_silence_ms.div_ceil(frame_duration_ms) as u32
+ }
+}
+
+struct SimpleVad {
+ config: SimpleVadConfig,
+ turn_index: u64,
+ in_speech: bool,
+ voiced_run_frames: u32,
+ silence_run_frames: u32,
+ speech_start_elapsed_ms: u64,
+ speech_frame_count: u64,
+ speech_sample_count: u64,
+ speech_rms_sum: f64,
+ speech_peak: f64,
+ ignored_before_enabled_frames: u64,
+ pre_speech_frames: Vec<Vec<i16>>,
+ speech_samples: Vec<i16>,
+}
+
+struct FinishedSpeechTurn {
+ turn_index: u64,
+ turn_id: String,
+ samples: Vec<i16>,
+ duration_ms: u64,
+ frame_count: u64,
+ sample_count: u64,
+ end_reason: String,
+}
+
+impl SimpleVad {
+ fn new(config: SimpleVadConfig) -> Self {
+ Self {
+ config,
+ turn_index: 0,
+ in_speech: false,
+ voiced_run_frames: 0,
+ silence_run_frames: 0,
+ speech_start_elapsed_ms: 0,
+ speech_frame_count: 0,
+ speech_sample_count: 0,
+ speech_rms_sum: 0.0,
+ speech_peak: 0.0,
+ ignored_before_enabled_frames: 0,
+ pre_speech_frames: Vec::new(),
+ speech_samples: Vec::new(),
+ }
+ }
+
+ fn observe_disabled_frame(
+ &mut self,
+ call_id: &str,
+ trace_id: &str,
+ participant_alias: &str,
+ track_sid_alias: &str,
+ frame_count: u64,
+ elapsed_ms: u64,
+ ) {
+ self.reset_current_turn();
+ self.ignored_before_enabled_frames = self.ignored_before_enabled_frames.saturating_add(1);
+ if self.ignored_before_enabled_frames == 1 || self.ignored_before_enabled_frames % 250 == 0
+ {
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ participant_alias = %participant_alias,
+ track_sid_alias = %track_sid_alias,
+ frame_count,
+ ignored_frame_count = self.ignored_before_enabled_frames,
+ elapsed_ms,
+ "runtime helper vad_ignored_before_enabled"
+ );
+ emit_activity(
+ call_id,
+ trace_id,
+ None,
+ "ignored_user_audio_before_vad_enabled",
+ "ok",
+ None,
+ None,
+ json!({
+ "frameCount": frame_count,
+ "ignoredFrameCount": self.ignored_before_enabled_frames,
+ "elapsedMs": elapsed_ms,
+ }),
+ );
+ }
+ }
+
+ fn observe_frame(
+ &mut self,
+ call_id: &str,
+ trace_id: &str,
+ participant_alias: &str,
+ track_sid_alias: &str,
+ frame_count: u64,
+ elapsed_ms: u64,
+ frame: &AudioFrame<'_>,
+ ) -> Option<FinishedSpeechTurn> {
+ let frame_duration_ms = frame_duration_ms(frame);
+ let (rms, peak) = pcm_energy_stats(frame.data.as_ref());
+ if elapsed_ms < self.config.initial_ignore_ms {
+ return None;
+ }
+
+ let voiced = rms >= self.config.rms_threshold || peak >= self.config.peak_threshold;
+ if !self.in_speech {
+ self.remember_pre_speech_frame(frame);
+ }
+ if voiced {
+ self.voiced_run_frames = self.voiced_run_frames.saturating_add(1);
+ self.silence_run_frames = 0;
+ } else {
+ self.voiced_run_frames = 0;
+ self.silence_run_frames = self.silence_run_frames.saturating_add(1);
+ }
+
+ if !self.in_speech {
+ if self.voiced_run_frames >= self.config.start_frames {
+ self.turn_index += 1;
+ self.in_speech = true;
+ self.speech_start_elapsed_ms = elapsed_ms
+ .saturating_sub(u64::from(self.config.start_frames) * frame_duration_ms);
+ self.speech_frame_count = u64::from(self.config.start_frames);
+ self.speech_sample_count = u64::from(frame.samples_per_channel)
+ * u64::from(frame.num_channels)
+ * u64::from(self.config.start_frames);
+ self.speech_rms_sum = rms * f64::from(self.config.start_frames);
+ self.speech_peak = peak;
+ self.speech_samples = self
+ .pre_speech_frames
+ .iter()
+ .flat_map(|samples| samples.iter().copied())
+ .collect();
+ let turn_id = format!("turn-{:04}", self.turn_index);
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ participant_alias = %participant_alias,
+ track_sid_alias = %track_sid_alias,
+ turn_index = self.turn_index,
+ frame_count,
+ speech_start_elapsed_ms = self.speech_start_elapsed_ms,
+ rms = round4(rms),
+ peak = round4(peak),
+ "runtime helper vad_speech_start"
+ );
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(&turn_id),
+ "vad_speech_start",
+ "ok",
+ None,
+ None,
+ json!({
+ "turnIndex": self.turn_index,
+ "frameCount": frame_count,
+ "speechStartElapsedMs": self.speech_start_elapsed_ms,
+ "rms": round4(rms),
+ "peak": round4(peak),
+ }),
+ );
+ }
+ return None;
+ }
+
+ self.speech_samples.extend_from_slice(frame.data.as_ref());
+ self.speech_frame_count += 1;
+ self.speech_sample_count +=
+ u64::from(frame.samples_per_channel) * u64::from(frame.num_channels);
+ self.speech_rms_sum += rms;
+ self.speech_peak = self.speech_peak.max(peak);
+
+ let speech_duration_ms = elapsed_ms.saturating_sub(self.speech_start_elapsed_ms);
+ if speech_duration_ms >= self.config.max_turn_ms {
+ return self.finish_turn(
+ call_id,
+ trace_id,
+ participant_alias,
+ track_sid_alias,
+ elapsed_ms,
+ "max_turn_ms",
+ );
+ }
+
+ if !voiced && self.silence_run_frames >= self.config.end_silence_frames(frame_duration_ms) {
+ return self.finish_turn(
+ call_id,
+ trace_id,
+ participant_alias,
+ track_sid_alias,
+ elapsed_ms,
+ "silence",
+ );
+ }
+ None
+ }
+
+ fn finish_stream(
+ &mut self,
+ call_id: &str,
+ trace_id: &str,
+ participant_alias: &str,
+ track_sid_alias: &str,
+ elapsed_ms: u64,
+ ) -> Option<FinishedSpeechTurn> {
+ if self.in_speech {
+ return self.finish_turn(
+ call_id,
+ trace_id,
+ participant_alias,
+ track_sid_alias,
+ elapsed_ms,
+ "stream_end",
+ );
+ } else if self.turn_index == 0 {
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ participant_alias = %participant_alias,
+ track_sid_alias = %track_sid_alias,
+ observed_wall_ms = elapsed_ms,
+ "runtime helper vad_no_speech_summary"
+ );
+ }
+ None
+ }
+
+ fn finish_turn(
+ &mut self,
+ call_id: &str,
+ trace_id: &str,
+ participant_alias: &str,
+ track_sid_alias: &str,
+ elapsed_ms: u64,
+ end_reason: &str,
+ ) -> Option<FinishedSpeechTurn> {
+ let speech_duration_ms = elapsed_ms.saturating_sub(self.speech_start_elapsed_ms);
+ let event_name = if speech_duration_ms < self.config.min_speech_ms {
+ "runtime helper vad_speech_too_short"
+ } else {
+ "runtime helper vad_speech_end"
+ };
+ let avg_rms = if self.speech_frame_count == 0 {
+ 0.0
+ } else {
+ self.speech_rms_sum / self.speech_frame_count as f64
+ };
+
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ participant_alias = %participant_alias,
+ track_sid_alias = %track_sid_alias,
+ turn_index = self.turn_index,
+ speech_start_elapsed_ms = self.speech_start_elapsed_ms,
+ speech_end_elapsed_ms = elapsed_ms,
+ speech_duration_ms,
+ speech_frame_count = self.speech_frame_count,
+ speech_sample_count = self.speech_sample_count,
+ avg_rms = round4(avg_rms),
+ peak = round4(self.speech_peak),
+ end_reason = %end_reason,
+ "{}", event_name
+ );
+
+ let finished_turn = if speech_duration_ms >= self.config.min_speech_ms {
+ let turn_id = format!("turn-{:04}", self.turn_index);
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(&turn_id),
+ "vad_speech_end",
+ "ok",
+ None,
+ None,
+ json!({
+ "turnIndex": self.turn_index,
+ "speechStartElapsedMs": self.speech_start_elapsed_ms,
+ "speechEndElapsedMs": elapsed_ms,
+ "speechDurationMs": speech_duration_ms,
+ "speechFrameCount": self.speech_frame_count,
+ "speechSampleCount": self.speech_sample_count,
+ "avgRms": round4(avg_rms),
+ "peak": round4(self.speech_peak),
+ "endReason": end_reason,
+ }),
+ );
+ Some(FinishedSpeechTurn {
+ turn_index: self.turn_index,
+ turn_id,
+ samples: std::mem::take(&mut self.speech_samples),
+ duration_ms: speech_duration_ms,
+ frame_count: self.speech_frame_count,
+ sample_count: self.speech_sample_count,
+ end_reason: end_reason.to_string(),
+ })
+ } else {
+ None
+ };
+ self.reset_current_turn();
+ finished_turn
+ }
+
+ fn reset_current_turn(&mut self) {
+ self.in_speech = false;
+ self.voiced_run_frames = 0;
+ self.silence_run_frames = 0;
+ self.speech_start_elapsed_ms = 0;
+ self.speech_frame_count = 0;
+ self.speech_sample_count = 0;
+ self.speech_rms_sum = 0.0;
+ self.speech_peak = 0.0;
+ self.speech_samples.clear();
+ self.pre_speech_frames.clear();
+ }
+
+ fn remember_pre_speech_frame(&mut self, frame: &AudioFrame<'_>) {
+ self.pre_speech_frames.push(frame.data.as_ref().to_vec());
+ let max_frames = self.config.start_frames as usize;
+ if self.pre_speech_frames.len() > max_frames {
+ let remove_count = self.pre_speech_frames.len() - max_frames;
+ self.pre_speech_frames.drain(0..remove_count);
+ }
+ }
+}
+
+fn frame_duration_ms(frame: &AudioFrame<'_>) -> u64 {
+ if frame.sample_rate == 0 {
+ return 10;
+ }
+ ((u64::from(frame.samples_per_channel) * 1000) / u64::from(frame.sample_rate)).max(1)
+}
+
+fn pcm_energy_stats(samples: &[i16]) -> (f64, f64) {
+ if samples.is_empty() {
+ return (0.0, 0.0);
+ }
+ let mut square_sum = 0.0;
+ let mut peak = 0.0;
+ for sample in samples {
+ let normalized = f64::from(*sample) / f64::from(i16::MAX);
+ square_sum += normalized * normalized;
+ let abs = normalized.abs();
+ if abs > peak {
+ peak = abs;
+ }
+ }
+ ((square_sum / samples.len() as f64).sqrt(), peak)
+}
+
+fn round4(value: f64) -> f64 {
+ (value * 10_000.0).round() / 10_000.0
}
struct BotAudioOutputSink {
room: Arc<Room>,
rtc_source: NativeAudioSource,
track: LocalAudioTrack,
+ device_output_destination_identity: Option<String>,
}
impl BotAudioOutputSink {
@@ -339,9 +2517,12 @@
room: Arc<Room>,
room_name: &str,
participant_identity: &str,
+ call_id: &str,
+ trace_id: &str,
track_name: &str,
sample_rate: u32,
num_channels: u32,
+ device_output_destination_identity: Option<String>,
) -> Result<Self> {
let rtc_source = NativeAudioSource::new(
AudioSourceOptions::default(),
@@ -353,6 +2534,8 @@
track_name,
RtcAudioSource::Native(rtc_source.clone()),
);
+ let room_alias = redact(room_name);
+ let participant_alias = redact(participant_identity);
room.local_participant()
.publish_track(
@@ -362,26 +2545,205 @@
.await
.map_err(|error| {
anyhow!(
- "failed to publish bot audio track in room {room_name} for participant {participant_identity}: {error}"
+ "failed to publish bot audio track in room {room_alias} for participant {participant_alias}: {error}"
)
})?;
info!(
- room_id = %room_name,
- participant_alias = %redact(participant_identity),
+ room_alias = %room_alias,
+ participant_alias = %participant_alias,
track_name = %track_name,
sample_rate,
num_channels,
"runtime helper published bot audio track"
+ );
+ emit_activity(
+ call_id,
+ trace_id,
+ None,
+ "bot_track_ready",
+ "ok",
+ None,
+ None,
+ json!({
+ "trackName": track_name,
+ "sampleRate": sample_rate,
+ "numChannels": num_channels,
+ }),
);
Ok(Self {
room,
rtc_source,
track,
+ device_output_destination_identity,
})
}
+ async fn publish_device_output(
+ &self,
+ call_id: &str,
+ trace_id: &str,
+ turn_id: &str,
+ output: &RuntimeTurnDeviceOutput,
+ ) -> Result<()> {
+ let command_id = output
+ .command_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .ok_or_else(|| anyhow!("device output commandId missing"))?;
+ let command_code = output
+ .command_code
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .ok_or_else(|| anyhow!("device output commandCode missing"))?;
+ let payload = serde_json::json!({
+ "type": "device_output",
+ "schemaVersion": "1.0",
+ "callId": call_id,
+ "traceId": trace_id,
+ "turnId": turn_id,
+ "commandId": command_id,
+ "commandCode": command_code,
+ "params": output.params.clone().unwrap_or_else(|| serde_json::json!({})),
+ "source": {
+ "kind": "voice_command"
+ },
+ });
+ let destinations = self
+ .device_output_destination_identity
+ .as_ref()
+ .filter(|value| !value.trim().is_empty())
+ .map(|value| vec![ParticipantIdentity(value.trim().to_string())])
+ .unwrap_or_default();
+ let destination_count = destinations.len();
+ self.room
+ .local_participant()
+ .publish_data(DataPacket {
+ payload: serde_json::to_vec(&payload)
+ .context("failed to encode device output data message")?,
+ topic: Some("device_output".to_string()),
+ reliable: true,
+ destination_identities: destinations,
+ })
+ .await
+ .map_err(|error| anyhow!("failed to publish device output data message: {error}"))?;
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn_id,
+ command_id_alias = %redact(command_id),
+ command_code = %command_code,
+ destination_count,
+ "runtime helper device_output_sent"
+ );
+ Ok(())
+ }
+
+ async fn publish_reply_state(
+ &self,
+ call_id: &str,
+ trace_id: &str,
+ turn_id: &str,
+ reply_playback_mode: &str,
+ state: &str,
+ seq: u64,
+ ) -> Result<()> {
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(turn_id),
+ "reply_state_send_started",
+ "ok",
+ None,
+ None,
+ json!({
+ "state": state,
+ "seq": seq,
+ "replyPlaybackMode": reply_playback_mode,
+ }),
+ );
+ let payload = serde_json::json!({
+ "type": "reply_state",
+ "schemaVersion": "1.0",
+ "callId": call_id,
+ "traceId": trace_id,
+ "turnId": turn_id,
+ "replyPlaybackMode": reply_playback_mode,
+ "state": state,
+ "seq": seq,
+ "tsMs": current_time_millis(),
+ });
+ let destinations = self
+ .device_output_destination_identity
+ .as_ref()
+ .filter(|value| !value.trim().is_empty())
+ .map(|value| vec![ParticipantIdentity(value.trim().to_string())])
+ .unwrap_or_default();
+ let destination_count = destinations.len();
+ let publish_result = self
+ .room
+ .local_participant()
+ .publish_data(DataPacket {
+ payload: serde_json::to_vec(&payload)
+ .context("failed to encode reply state data message")?,
+ topic: Some("combrabo_voice.reply_state".to_string()),
+ reliable: true,
+ destination_identities: destinations,
+ })
+ .await
+ .map_err(|error| anyhow!("failed to publish reply state data message: {error}"));
+ match publish_result {
+ Ok(_) => {
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(turn_id),
+ "reply_state_send_finished",
+ "ok",
+ None,
+ None,
+ json!({
+ "state": state,
+ "seq": seq,
+ "replyPlaybackMode": reply_playback_mode,
+ "destinationCount": destination_count,
+ }),
+ );
+ info!(
+ call_id = %call_id,
+ trace_id = %trace_id,
+ turn_id = %turn_id,
+ state = %state,
+ seq,
+ reply_playback_mode = %reply_playback_mode,
+ destination_count,
+ "runtime helper reply_state_sent"
+ );
+ Ok(())
+ }
+ Err(error) => {
+ emit_activity(
+ call_id,
+ trace_id,
+ Some(turn_id),
+ "reply_state_send_failed",
+ "failed",
+ Some("REPLY_STATE_SEND_FAILED"),
+ Some(true),
+ json!({
+ "state": state,
+ "seq": seq,
+ "replyPlaybackMode": reply_playback_mode,
+ }),
+ );
+ Err(error)
+ }
+ }
+ }
+
async fn write_pcm_frame(&self, frame: &audio::PcmFrame) -> Result<()> {
let audio_frame = AudioFrame {
data: frame.data.as_slice().into(),
diff --git a/src/service.rs b/src/service.rs
new file mode 100644
index 0000000..a579636
--- /dev/null
+++ b/src/service.rs
@@ -0,0 +1,908 @@
+use std::{
+ collections::HashMap,
+ env,
+ io::{BufRead, BufReader, Read, Write},
+ net::{TcpListener, TcpStream},
+ process::{Child, Command, Stdio},
+ sync::{Arc, Mutex},
+ thread,
+ time::{Duration, SystemTime, UNIX_EPOCH},
+};
+
+use anyhow::{Context, Result, anyhow};
+use serde::Deserialize;
+use serde_json::{Value, json};
+use tracing::{info, warn};
+
+const DEFAULT_BIND_ADDR: &str = "127.0.0.1:18080";
+const HTTP_READ_LIMIT_BYTES: usize = 1024 * 1024;
+const DEFAULT_START_READY_TIMEOUT_MS: u64 = 10_000;
+
+pub fn service_mode_enabled() -> bool {
+ env::args().any(|arg| arg == "service" || arg == "--service")
+ || bool_env("CV_HELPER_SERVICE_ENABLED", false)
+}
+
+pub async fn run_service() -> Result<()> {
+ let config = ServiceConfig::from_env()?;
+ let listener = TcpListener::bind(&config.bind_addr)
+ .with_context(|| format!("failed to bind helper service {}", config.bind_addr))?;
+ let state = Arc::new(ServiceState {
+ sessions: Mutex::new(HashMap::new()),
+ config: config.clone(),
+ });
+ info!(
+ bind_addr = %config.bind_addr,
+ auth_profiles = ?config.auth_profiles.keys().collect::<Vec<_>>(),
+ "combrabo voice helper service started"
+ );
+
+ for stream in listener.incoming() {
+ match stream {
+ Ok(stream) => {
+ let state = state.clone();
+ thread::spawn(move || {
+ if let Err(error) = handle_connection(stream, state) {
+ warn!(error = %error, "helper service connection failed");
+ }
+ });
+ }
+ Err(error) => warn!(error = %error, "helper service accept failed"),
+ }
+ }
+ Ok(())
+}
+
+fn handle_connection(mut stream: TcpStream, state: Arc<ServiceState>) -> Result<()> {
+ stream.set_read_timeout(Some(Duration::from_secs(5)))?;
+ let request = HttpRequest::read(&mut stream)?;
+ let response = route(request, state);
+ response.write(&mut stream)?;
+ Ok(())
+}
+
+fn route(request: HttpRequest, state: Arc<ServiceState>) -> HttpResponse {
+ if request.method == "GET"
+ && (request.path == "/health" || request.path == "/internal/combrabo-voice/health")
+ {
+ return ok(json!({
+ "status": "UP",
+ "service": "lm-livekit-helper",
+ "version": env!("CARGO_PKG_VERSION"),
+ "mode": "service"
+ }));
+ }
+ if !authorized(&request, &state.config) {
+ return error(
+ 401,
+ 2001003404,
+ "HELPER_AUTH_FAILED",
+ "auth",
+ false,
+ trace_id(&request),
+ );
+ }
+ if request.method == "POST" && request.path == "/internal/combrabo-voice/sessions/start" {
+ return start_session(&request, state);
+ }
+ if request.method == "GET"
+ && request
+ .path
+ .starts_with("/internal/combrabo-voice/sessions/")
+ {
+ let call_id = request
+ .path
+ .trim_start_matches("/internal/combrabo-voice/sessions/")
+ .trim_matches('/');
+ return get_session(call_id, state);
+ }
+ if request.method == "POST"
+ && request
+ .path
+ .starts_with("/internal/combrabo-voice/sessions/")
+ && request.path.ends_with("/stop")
+ {
+ let call_id = request
+ .path
+ .trim_start_matches("/internal/combrabo-voice/sessions/")
+ .trim_end_matches("/stop")
+ .trim_matches('/');
+ return stop_session(call_id, &request, state);
+ }
+ error(
+ 404,
+ 2001003404,
+ "HELPER_ROUTE_NOT_FOUND",
+ "helper_http",
+ false,
+ trace_id(&request),
+ )
+}
+
+fn start_session(request: &HttpRequest, state: Arc<ServiceState>) -> HttpResponse {
+ let start_req: SessionStartRequest = match serde_json::from_slice(&request.body) {
+ Ok(value) => value,
+ Err(_) => {
+ return error(
+ 400,
+ 2001003404,
+ "HELPER_REQUEST_INVALID",
+ "validate",
+ false,
+ trace_id(request),
+ );
+ }
+ };
+ let trace_id = start_req.trace_id.clone().or_else(|| trace_id(request));
+ if start_req.call_id.trim().is_empty() || start_req.runtime_session_nonce.trim().is_empty() {
+ return error(
+ 400,
+ 2001003404,
+ "HELPER_REQUEST_INVALID",
+ "validate",
+ false,
+ trace_id,
+ );
+ }
+ let auth_profile = start_req
+ .auth_profile
+ .as_deref()
+ .filter(|value| !value.trim().is_empty())
+ .unwrap_or("default")
+ .to_string();
+ let profile = match state.config.auth_profiles.get(&auth_profile) {
+ Some(value) => value.clone(),
+ None => {
+ return error(
+ 200,
+ 2001003404,
+ "HELPER_AUTH_PROFILE_NOT_FOUND",
+ "auth",
+ true,
+ trace_id,
+ );
+ }
+ };
+ let mut sessions = match state.sessions.lock() {
+ Ok(value) => value,
+ Err(_) => {
+ return error(
+ 200,
+ 2001003404,
+ "HELPER_SESSION_LOCK_FAILED",
+ "runtime_ready",
+ true,
+ trace_id,
+ );
+ }
+ };
+ if let Some(existing) = sessions.get_mut(&start_req.call_id) {
+ let _ = refresh_child_status(existing);
+ if existing.runtime_session_nonce == start_req.runtime_session_nonce
+ && existing.status != "STOPPED"
+ {
+ return ok(session_start_data(existing));
+ }
+ return error(
+ 200,
+ 2001003404,
+ "RUNTIME_SESSION_CONFLICT",
+ "runtime_ready",
+ true,
+ trace_id,
+ );
+ }
+ let child = match spawn_worker(&start_req, &profile, &state.config) {
+ Ok(value) => value,
+ Err(spawn_error) => {
+ warn!(
+ call_id = %start_req.call_id,
+ trace_id = ?trace_id,
+ error = %spawn_error,
+ "helper service failed to spawn worker"
+ );
+ return error(
+ 200,
+ 2001003404,
+ "RUNTIME_START_FAILED",
+ "runtime_ready",
+ true,
+ trace_id,
+ );
+ }
+ };
+ let runtime_session_id = format!("rt_{}", start_req.call_id);
+ let mut child = child;
+ let child_stdout = child.stdout.take();
+ let session = HelperSession {
+ call_id: start_req.call_id.clone(),
+ trace_id: trace_id.clone(),
+ runtime_session_nonce: start_req.runtime_session_nonce.clone(),
+ runtime_session_id,
+ status: "STARTING".to_string(),
+ bot_participant_joined: false,
+ bot_track_ready: false,
+ first_audio_source: start_req
+ .audio
+ .as_ref()
+ .and_then(|value| value.first_audio_source.clone())
+ .unwrap_or_else(|| "fixed_greeting_tts".to_string()),
+ active_turn_id: None,
+ last_event_type: Some("helper_worker_started".to_string()),
+ last_event_at: now_millis(),
+ child: Some(child),
+ };
+ sessions.insert(start_req.call_id.clone(), session);
+ drop(sessions);
+
+ if let Some(stdout) = child_stdout {
+ spawn_worker_stdout_monitor(start_req.call_id.clone(), state.clone(), stdout);
+ }
+
+ match wait_for_session_ready(&start_req.call_id, state, &trace_id) {
+ WaitReadyResult::Ready(response) => ok(response),
+ WaitReadyResult::Failed(response) => response,
+ }
+}
+
+fn get_session(call_id: &str, state: Arc<ServiceState>) -> HttpResponse {
+ let mut sessions = match state.sessions.lock() {
+ Ok(value) => value,
+ Err(_) => {
+ return error(
+ 200,
+ 2001003404,
+ "HELPER_SESSION_LOCK_FAILED",
+ "runtime_ready",
+ true,
+ None,
+ );
+ }
+ };
+ let Some(session) = sessions.get_mut(call_id) else {
+ return error(
+ 200,
+ 2001003404,
+ "CALL_NOT_FOUND",
+ "runtime_ready",
+ false,
+ None,
+ );
+ };
+ let _ = refresh_child_status(session);
+ ok(json!({
+ "callId": &session.call_id,
+ "status": &session.status,
+ "botParticipantJoined": session.bot_participant_joined,
+ "botTrackReady": session.bot_track_ready,
+ "activeTurnId": session.active_turn_id.as_ref(),
+ "lastEventType": session.last_event_type.as_ref(),
+ "lastEventAt": session.last_event_at.to_string(),
+ }))
+}
+
+fn stop_session(call_id: &str, request: &HttpRequest, state: Arc<ServiceState>) -> HttpResponse {
+ let stop_req: SessionStopRequest = serde_json::from_slice(&request.body).unwrap_or_default();
+ let mut sessions = match state.sessions.lock() {
+ Ok(value) => value,
+ Err(_) => {
+ return error(
+ 200,
+ 2001003404,
+ "HELPER_SESSION_LOCK_FAILED",
+ "runtime_stop_requested",
+ true,
+ trace_id(request),
+ );
+ }
+ };
+ let Some(mut session) = sessions.remove(call_id) else {
+ return ok(json!({
+ "callId": call_id,
+ "status": "STOPPED",
+ "alreadyStopped": true,
+ }));
+ };
+ if stop_req
+ .runtime_session_nonce
+ .as_ref()
+ .is_some_and(|value| *value != session.runtime_session_nonce)
+ {
+ sessions.insert(call_id.to_string(), session);
+ return error(
+ 200,
+ 2001003404,
+ "RUNTIME_SESSION_MISMATCH",
+ "runtime_stop_requested",
+ true,
+ trace_id(request),
+ );
+ }
+ if let Some(child) = session.child.as_mut() {
+ let _ = child.kill();
+ let _ = child.wait();
+ }
+ session.status = "STOPPED".to_string();
+ ok(json!({
+ "callId": session.call_id,
+ "status": "STOPPED",
+ "alreadyStopped": false,
+ }))
+}
+
+fn spawn_worker(
+ req: &SessionStartRequest,
+ profile: &AuthProfile,
+ config: &ServiceConfig,
+) -> Result<Child> {
+ let exe = env::current_exe().context("failed to resolve helper executable")?;
+ let mut command = Command::new(exe);
+ command
+ .env_remove("CV_HELPER_SERVICE_ENABLED")
+ .env("CV_CALL_ID", &req.call_id)
+ .env(
+ "CV_TRACE_ID",
+ req.trace_id
+ .clone()
+ .unwrap_or_else(|| format!("trace_{}", req.call_id)),
+ )
+ .env("CV_RUNTIME_CALL_ID", &req.call_id)
+ .env(
+ "CV_RUNTIME_TRACE_ID",
+ req.trace_id
+ .clone()
+ .unwrap_or_else(|| format!("trace_{}", req.call_id)),
+ )
+ .env("CV_RUNTIME_SESSION_NONCE", &req.runtime_session_nonce)
+ .env("CV_LIVEKIT_URL", &req.livekit.url)
+ .env("CV_LIVEKIT_ROOM_ID", &req.livekit.room_id)
+ .env("CV_LIVEKIT_BOT_TOKEN", &req.livekit.bot_token)
+ .env(
+ "CV_LIVEKIT_BOT_PARTICIPANT_IDENTITY",
+ &req.livekit.bot_participant_identity,
+ )
+ .env("CV_RUNTIME_TURN_BRIDGE_TOKEN", &profile.turn_bridge_token)
+ .stdout(Stdio::piped())
+ .stderr(Stdio::inherit());
+ if let Some(value) = &req.livekit.user_participant_identity {
+ command.env("CV_LIVEKIT_USER_PARTICIPANT_IDENTITY", value);
+ }
+ if let Some(turn_bridge) = &req.turn_bridge {
+ command.env("CV_RUNTIME_TURN_BRIDGE_URL", &turn_bridge.url);
+ }
+ if let Some(runtime) = &req.runtime {
+ if let Some(value) = &runtime.turn_artifact_dir {
+ command.env("CV_RUNTIME_TURN_ARTIFACT_DIR", value);
+ }
+ if let Some(true) = runtime.audio_debug_dump_enabled {
+ if let Some(value) = &config.audio_debug_dump_dir {
+ command.env("CV_AUDIO_DEBUG_DUMP_DIR", value);
+ }
+ }
+ }
+ if let Some(audio) = &req.audio {
+ if let Some(first_audio_source) = &audio.first_audio_source {
+ command.env("CV_FIRST_AUDIO_SOURCE", first_audio_source);
+ command.env("CV_GREETING_SOURCE", first_audio_source);
+ }
+ if let Some(greeting_audio) = &audio.greeting_audio {
+ match greeting_audio.r#type.as_deref() {
+ Some("remote_url") => {
+ if let Some(value) = &greeting_audio.path_ref {
+ command.env("CV_GREETING_AUDIO_URL", value);
+ }
+ }
+ _ => {
+ if let Some(value) = &greeting_audio.path_ref {
+ command.env("CV_GREETING_AUDIO_FILE", value);
+ }
+ }
+ }
+ }
+ }
+ command.spawn().map_err(|error| anyhow!(error))
+}
+
+fn spawn_worker_stdout_monitor(
+ call_id: String,
+ state: Arc<ServiceState>,
+ stdout: std::process::ChildStdout,
+) {
+ thread::spawn(move || {
+ let reader = BufReader::new(stdout);
+ for line in reader.lines() {
+ let Ok(line) = line else {
+ continue;
+ };
+ println!("{line}");
+ if let Ok(value) = serde_json::from_str::<Value>(&line) {
+ apply_worker_event(&call_id, &state, &value);
+ }
+ }
+ });
+}
+
+fn apply_worker_event(call_id: &str, state: &Arc<ServiceState>, value: &Value) {
+ if value.get("type").and_then(Value::as_str) != Some("cv_activity") {
+ return;
+ }
+ if value.get("callId").and_then(Value::as_str) != Some(call_id) {
+ return;
+ }
+ let event_name = value
+ .get("eventName")
+ .and_then(Value::as_str)
+ .unwrap_or("worker_event");
+ let result = value.get("result").and_then(Value::as_str).unwrap_or("ok");
+ let Ok(mut sessions) = state.sessions.lock() else {
+ return;
+ };
+ let Some(session) = sessions.get_mut(call_id) else {
+ return;
+ };
+ session.last_event_type = Some(event_name.to_string());
+ session.last_event_at = now_millis();
+ if result != "ok" {
+ session.status = "FAILED".to_string();
+ session.bot_participant_joined = false;
+ session.bot_track_ready = false;
+ return;
+ }
+ match event_name {
+ "helper_worker_started" => {
+ session.status = "STARTING".to_string();
+ }
+ "bot_participant_joined" => {
+ session.bot_participant_joined = true;
+ }
+ "bot_track_ready" => {
+ session.bot_participant_joined = true;
+ session.bot_track_ready = true;
+ session.status = "STARTED".to_string();
+ }
+ _ => {}
+ }
+}
+
+enum WaitReadyResult {
+ Ready(Value),
+ Failed(HttpResponse),
+}
+
+fn wait_for_session_ready(
+ call_id: &str,
+ state: Arc<ServiceState>,
+ trace_id: &Option<String>,
+) -> WaitReadyResult {
+ let deadline = SystemTime::now()
+ .checked_add(Duration::from_millis(state.config.start_ready_timeout_ms))
+ .unwrap_or_else(SystemTime::now);
+ loop {
+ let snapshot = {
+ let mut sessions = match state.sessions.lock() {
+ Ok(value) => value,
+ Err(_) => {
+ return WaitReadyResult::Failed(error(
+ 200,
+ 2001003404,
+ "HELPER_SESSION_LOCK_FAILED",
+ "runtime_ready",
+ true,
+ trace_id.clone(),
+ ));
+ }
+ };
+ let Some(session) = sessions.get_mut(call_id) else {
+ return WaitReadyResult::Failed(error(
+ 200,
+ 2001003404,
+ "CALL_NOT_FOUND",
+ "runtime_ready",
+ false,
+ trace_id.clone(),
+ ));
+ };
+ let _ = refresh_child_status(session);
+ (
+ session.status.clone(),
+ session.bot_participant_joined,
+ session.bot_track_ready,
+ session_start_data(session),
+ )
+ };
+ if snapshot.0 == "STARTED" && snapshot.1 && snapshot.2 {
+ return WaitReadyResult::Ready(snapshot.3);
+ }
+ if snapshot.0 == "FAILED" || snapshot.0 == "STOPPED" {
+ return WaitReadyResult::Failed(error(
+ 200,
+ 2001003404,
+ "RUNTIME_START_FAILED",
+ "runtime_ready",
+ true,
+ trace_id.clone(),
+ ));
+ }
+ if SystemTime::now() >= deadline {
+ mark_session_start_timeout(call_id, &state);
+ return WaitReadyResult::Failed(error(
+ 200,
+ 2001003404,
+ "RUNTIME_START_TIMEOUT",
+ "runtime_ready",
+ true,
+ trace_id.clone(),
+ ));
+ }
+ thread::sleep(Duration::from_millis(50));
+ }
+}
+
+fn mark_session_start_timeout(call_id: &str, state: &Arc<ServiceState>) {
+ let Ok(mut sessions) = state.sessions.lock() else {
+ return;
+ };
+ let Some(session) = sessions.get_mut(call_id) else {
+ return;
+ };
+ if let Some(child) = session.child.as_mut() {
+ let _ = child.kill();
+ let _ = child.wait();
+ }
+ session.status = "FAILED".to_string();
+ session.bot_participant_joined = false;
+ session.bot_track_ready = false;
+ session.last_event_type = Some("worker_ready_timeout".to_string());
+ session.last_event_at = now_millis();
+}
+
+fn refresh_child_status(session: &mut HelperSession) -> Result<()> {
+ if let Some(child) = session.child.as_mut() {
+ if let Some(status) = child.try_wait()? {
+ session.status = if status.success() {
+ "STOPPED".to_string()
+ } else {
+ "FAILED".to_string()
+ };
+ session.bot_participant_joined = false;
+ session.bot_track_ready = false;
+ session.last_event_type = Some("worker_exited".to_string());
+ session.last_event_at = now_millis();
+ }
+ }
+ Ok(())
+}
+
+fn session_start_data(session: &HelperSession) -> Value {
+ json!({
+ "callId": &session.call_id,
+ "status": &session.status,
+ "runtimeSessionId": &session.runtime_session_id,
+ "botParticipantJoined": session.bot_participant_joined,
+ "botTrackReady": session.bot_track_ready,
+ "firstAudioSource": &session.first_audio_source,
+ })
+}
+
+fn authorized(request: &HttpRequest, config: &ServiceConfig) -> bool {
+ let Some(authorization) = request.headers.get("authorization") else {
+ return false;
+ };
+ let Some(token) = authorization.strip_prefix("Bearer ") else {
+ return false;
+ };
+ config
+ .auth_profiles
+ .values()
+ .any(|profile| constant_time_eq(token.as_bytes(), profile.helper_auth_token.as_bytes()))
+}
+
+fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
+ if left.len() != right.len() {
+ return false;
+ }
+ left.iter()
+ .zip(right.iter())
+ .fold(0u8, |acc, (l, r)| acc | (l ^ r))
+ == 0
+}
+
+fn trace_id(request: &HttpRequest) -> Option<String> {
+ request.headers.get("x-voice-trace-id").cloned()
+}
+
+fn ok(data: Value) -> HttpResponse {
+ HttpResponse::json(
+ 200,
+ json!({
+ "code": 0,
+ "msg": "",
+ "data": data
+ }),
+ )
+}
+
+fn error(
+ http_status: u16,
+ code: i64,
+ reason_code: &str,
+ stage: &str,
+ retryable: bool,
+ trace_id: Option<String>,
+) -> HttpResponse {
+ HttpResponse::json(
+ http_status,
+ json!({
+ "code": code,
+ "msg": reason_code,
+ "data": {
+ "reasonCode": reason_code,
+ "stage": stage,
+ "retryable": retryable,
+ "traceId": trace_id
+ }
+ }),
+ )
+}
+
+fn now_millis() -> u128 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis()
+}
+
+#[derive(Clone)]
+struct ServiceConfig {
+ bind_addr: String,
+ audio_debug_dump_dir: Option<String>,
+ start_ready_timeout_ms: u64,
+ auth_profiles: HashMap<String, AuthProfile>,
+}
+
+impl ServiceConfig {
+ fn from_env() -> Result<Self> {
+ let bind_addr = env::var("CV_HELPER_SERVICE_BIND")
+ .ok()
+ .filter(|value| !value.trim().is_empty())
+ .unwrap_or_else(|| DEFAULT_BIND_ADDR.to_string());
+ let mut auth_profiles = HashMap::new();
+ for profile in ["default", "local-dev", "dev"] {
+ if let Some(auth_profile) = AuthProfile::from_env(profile) {
+ auth_profiles.insert(profile.to_string(), auth_profile);
+ }
+ }
+ if auth_profiles.is_empty() {
+ return Err(anyhow!("missing helper auth profile config"));
+ }
+ Ok(Self {
+ bind_addr,
+ audio_debug_dump_dir: env::var("CV_AUDIO_DEBUG_DUMP_DIR").ok(),
+ start_ready_timeout_ms: env::var("CV_HELPER_SERVICE_START_READY_TIMEOUT_MS")
+ .ok()
+ .and_then(|value| value.trim().parse::<u64>().ok())
+ .filter(|value| *value > 0)
+ .unwrap_or(DEFAULT_START_READY_TIMEOUT_MS),
+ auth_profiles,
+ })
+ }
+}
+
+#[derive(Clone)]
+struct AuthProfile {
+ helper_auth_token: String,
+ turn_bridge_token: String,
+}
+
+impl AuthProfile {
+ fn from_env(profile: &str) -> Option<Self> {
+ let suffix = profile
+ .chars()
+ .map(|ch| if ch == '-' { '_' } else { ch })
+ .collect::<String>()
+ .to_ascii_uppercase();
+ let helper_auth_token = env::var(format!("CV_HELPER_AUTH_TOKEN_{suffix}"))
+ .or_else(|_| env::var("CV_HELPER_AUTH_TOKEN"))
+ .ok()?;
+ let turn_bridge_token = env::var(format!("CV_TURN_BRIDGE_TOKEN_{suffix}"))
+ .or_else(|_| env::var("CV_RUNTIME_TURN_BRIDGE_TOKEN"))
+ .ok()?;
+ Some(Self {
+ helper_auth_token,
+ turn_bridge_token,
+ })
+ }
+}
+
+struct ServiceState {
+ sessions: Mutex<HashMap<String, HelperSession>>,
+ config: ServiceConfig,
+}
+
+struct HelperSession {
+ call_id: String,
+ trace_id: Option<String>,
+ runtime_session_nonce: String,
+ runtime_session_id: String,
+ status: String,
+ bot_participant_joined: bool,
+ bot_track_ready: bool,
+ first_audio_source: String,
+ active_turn_id: Option<String>,
+ last_event_type: Option<String>,
+ last_event_at: u128,
+ child: Option<Child>,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct SessionStartRequest {
+ call_id: String,
+ trace_id: Option<String>,
+ runtime_session_nonce: String,
+ livekit: LiveKitStart,
+ turn_bridge: Option<TurnBridgeStart>,
+ auth_profile: Option<String>,
+ audio: Option<AudioStart>,
+ runtime: Option<RuntimeStart>,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct LiveKitStart {
+ url: String,
+ room_id: String,
+ bot_token: String,
+ bot_participant_identity: String,
+ user_participant_identity: Option<String>,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct TurnBridgeStart {
+ url: String,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct AudioStart {
+ first_audio_source: Option<String>,
+ greeting_audio: Option<GreetingAudioStart>,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct GreetingAudioStart {
+ r#type: Option<String>,
+ path_ref: Option<String>,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct RuntimeStart {
+ turn_artifact_dir: Option<String>,
+ audio_debug_dump_enabled: Option<bool>,
+}
+
+#[derive(Default, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct SessionStopRequest {
+ runtime_session_nonce: Option<String>,
+}
+
+struct HttpRequest {
+ method: String,
+ path: String,
+ headers: HashMap<String, String>,
+ body: Vec<u8>,
+}
+
+impl HttpRequest {
+ fn read(stream: &mut TcpStream) -> Result<Self> {
+ let mut buffer = Vec::new();
+ let mut temp = [0u8; 4096];
+ let header_end;
+ loop {
+ let read = stream.read(&mut temp)?;
+ if read == 0 {
+ return Err(anyhow!("connection closed before request header"));
+ }
+ buffer.extend_from_slice(&temp[..read]);
+ if buffer.len() > HTTP_READ_LIMIT_BYTES {
+ return Err(anyhow!("http request too large"));
+ }
+ if let Some(index) = find_header_end(&buffer) {
+ header_end = index;
+ break;
+ }
+ }
+ let header_text = String::from_utf8_lossy(&buffer[..header_end]).to_string();
+ let mut lines = header_text.split("\r\n");
+ let request_line = lines
+ .next()
+ .ok_or_else(|| anyhow!("missing request line"))?;
+ let parts = request_line.split_whitespace().collect::<Vec<_>>();
+ if parts.len() < 2 {
+ return Err(anyhow!("invalid request line"));
+ }
+ let method = parts[0].to_string();
+ let path = parts[1].split('?').next().unwrap_or(parts[1]).to_string();
+ let mut headers = HashMap::new();
+ for line in lines {
+ if let Some((key, value)) = line.split_once(':') {
+ headers.insert(key.trim().to_ascii_lowercase(), value.trim().to_string());
+ }
+ }
+ let content_length = headers
+ .get("content-length")
+ .and_then(|value| value.parse::<usize>().ok())
+ .unwrap_or(0);
+ let body_start = header_end + 4;
+ let mut body = buffer[body_start..].to_vec();
+ while body.len() < content_length {
+ let read = stream.read(&mut temp)?;
+ if read == 0 {
+ break;
+ }
+ body.extend_from_slice(&temp[..read]);
+ if body.len() > HTTP_READ_LIMIT_BYTES {
+ return Err(anyhow!("http request body too large"));
+ }
+ }
+ body.truncate(content_length);
+ Ok(Self {
+ method,
+ path,
+ headers,
+ body,
+ })
+ }
+}
+
+struct HttpResponse {
+ status: u16,
+ body: Vec<u8>,
+}
+
+impl HttpResponse {
+ fn json(status: u16, body: Value) -> Self {
+ Self {
+ status,
+ body: serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()),
+ }
+ }
+
+ fn write(self, stream: &mut TcpStream) -> Result<()> {
+ let reason = match self.status {
+ 200 => "OK",
+ 400 => "Bad Request",
+ 401 => "Unauthorized",
+ 404 => "Not Found",
+ _ => "Internal Server Error",
+ };
+ write!(
+ stream,
+ "HTTP/1.1 {} {}\r\nContent-Type: application/json;charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
+ self.status,
+ reason,
+ self.body.len()
+ )?;
+ stream.write_all(&self.body)?;
+ Ok(())
+ }
+}
+
+fn find_header_end(buffer: &[u8]) -> Option<usize> {
+ buffer.windows(4).position(|window| window == b"\r\n\r\n")
+}
+
+fn bool_env(key: &str, default_value: bool) -> bool {
+ match env::var(key) {
+ Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
+ "1" | "true" | "yes" | "on" => true,
+ "0" | "false" | "no" | "off" => false,
+ _ => default_value,
+ },
+ Err(_) => default_value,
+ }
+}
diff --git a/tools/validate-turn-stream-fixture.mjs b/tools/validate-turn-stream-fixture.mjs
new file mode 100755
index 0000000..710365f
--- /dev/null
+++ b/tools/validate-turn-stream-fixture.mjs
@@ -0,0 +1,147 @@
+#!/usr/bin/env node
+
+import fs from 'node:fs'
+import path from 'node:path'
+
+const file = process.argv[2] || 'fixtures/turn-stream-happy.ndjson'
+const absolute = path.resolve(file)
+const raw = fs.readFileSync(absolute, 'utf8')
+const lines = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
+
+const allowedEvents = new Set([
+ 'reply_playback_mode_selected',
+ 'reply_state',
+ 'reply_audio_chunk',
+ 'device_output',
+ 'turn_completed',
+ 'turn_failed',
+ 'turn_cancelled',
+ 'activity',
+])
+
+let playbackMode = null
+let sawPlaybackStarted = false
+let audioChunkCount = 0
+let sawCompleted = false
+let sawFailed = false
+let sawCancelled = false
+let lastSeq = 0
+let callId = null
+let traceId = null
+let turnId = null
+const supportedAudioFormats = new Set(['pcm_s16le', 'mp3', 'mpeg', 'wav'])
+
+function fail(message) {
+ console.error(`turn stream fixture invalid: ${message}`)
+ process.exit(1)
+}
+
+function requireSameIdentity(event) {
+ if (!event.callId || !event.traceId || !event.turnId) {
+ fail(`${event.event} missing callId/traceId/turnId`)
+ }
+ callId ??= event.callId
+ traceId ??= event.traceId
+ turnId ??= event.turnId
+ if (event.callId !== callId || event.traceId !== traceId || event.turnId !== turnId) {
+ fail(`${event.event} identity mismatch`)
+ }
+}
+
+for (const [index, line] of lines.entries()) {
+ let event
+ try {
+ event = JSON.parse(line)
+ } catch (error) {
+ fail(`line ${index + 1} is not JSON`)
+ }
+
+ if (!allowedEvents.has(event.event)) {
+ fail(`line ${index + 1} has unsupported event ${event.event}`)
+ }
+ requireSameIdentity(event)
+ if (!Number.isInteger(event.seq) || event.seq <= lastSeq) {
+ fail(`${event.event} seq must be strictly increasing`)
+ }
+ lastSeq = event.seq
+
+ if (event.event === 'reply_playback_mode_selected') {
+ if (!['streaming_tts', 'full_tts_fallback'].includes(event.replyPlaybackMode)) {
+ fail('reply_playback_mode_selected has invalid replyPlaybackMode')
+ }
+ playbackMode = event.replyPlaybackMode
+ }
+
+ if (event.event === 'reply_state') {
+ if (!event.state) {
+ fail('reply_state missing state')
+ }
+ if (event.state === 'reply_playback_started') {
+ sawPlaybackStarted = true
+ }
+ }
+
+ if (event.event === 'reply_audio_chunk') {
+ if (!playbackMode) {
+ fail('reply_audio_chunk arrived before reply_playback_mode_selected')
+ }
+ if (!sawPlaybackStarted) {
+ fail('reply_audio_chunk arrived before reply_playback_started')
+ }
+ const chunk = event.audioChunk
+ if (!chunk) {
+ fail('reply_audio_chunk missing audioChunk')
+ }
+ if (!supportedAudioFormats.has(chunk.format)) {
+ fail(`fixture fast path only accepts ${Array.from(supportedAudioFormats).join('/')}, got ${chunk.format}`)
+ }
+ const payload = Buffer.from(chunk.payloadBase64 || '', 'base64')
+ if (payload.length === 0) {
+ fail(`${chunk.format} payload must be non-empty`)
+ }
+ if (chunk.format === 'pcm_s16le') {
+ if (chunk.sampleRate !== 48000 || chunk.channels !== 1) {
+ fail('pcm_s16le chunk must be 48000Hz mono')
+ }
+ if (payload.length % 2 !== 0) {
+ fail('pcm_s16le payload must be 16-bit aligned')
+ }
+ } else {
+ if (chunk.sampleRate != null && !Number.isInteger(chunk.sampleRate)) {
+ fail(`${chunk.format} sampleRate must be an integer when present`)
+ }
+ if (chunk.channels != null && !Number.isInteger(chunk.channels)) {
+ fail(`${chunk.format} channels must be an integer when present`)
+ }
+ }
+ audioChunkCount += 1
+ }
+
+ if (event.event === 'turn_completed') {
+ sawCompleted = true
+ }
+ if (event.event === 'turn_failed') {
+ sawFailed = true
+ }
+ if (event.event === 'turn_cancelled') {
+ sawCancelled = true
+ }
+}
+
+if (lines.length === 0) {
+ fail('fixture is empty')
+}
+if (!sawCompleted && !sawFailed && !sawCancelled) {
+ fail('fixture must end with turn_completed, turn_failed or turn_cancelled')
+}
+if (sawCompleted && audioChunkCount === 0) {
+ fail('completed streaming fixture must contain at least one audio chunk')
+}
+
+console.log(JSON.stringify({
+ ok: true,
+ file: path.relative(process.cwd(), absolute),
+ eventCount: lines.length,
+ audioChunkCount,
+ terminal: sawCompleted ? 'turn_completed' : sawFailed ? 'turn_failed' : 'turn_cancelled',
+}))
--
Gitblit v1.9.3