From 57753e1b1e169369bdc83f3945ce5246c844b316 Mon Sep 17 00:00:00 2001
From: cai <cai@nbcai.cc>
Date: Thu, 09 Jul 2026 14:50:44 +0800
Subject: [PATCH] feat: callback runtime events
---
src/main.rs | 8 +
src/service.rs | 236 ++++++++++++++++++++++++++++++++++++++++++-----
README.md | 6
3 files changed, 219 insertions(+), 31 deletions(-)
diff --git a/README.md b/README.md
index 88fd05a..d81dfdc 100644
--- a/README.md
+++ b/README.md
@@ -235,7 +235,7 @@
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 / 消息落库。
+首版 VAD 不引入外部模型,只基于上行 PCM frame 的 RMS 做保守阈值判断,peak 只作为诊断字段记录。这个口径对齐 cb-sdk 的能量阈值分段思路,避免单个尖峰噪声反复打断静音窗口,使用户 turn 拖到 `max_turn_ms`。配置了 Java runtime turn bridge 后,helper 会在有效 `vad_speech_end` 后把本轮 PCM 写成 `user.wav` artifact,并调用 Java 内部 bridge;helper 仍不做 ASR / LLM / TTS / 消息落库。
可调参数:
@@ -243,8 +243,8 @@
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_END_SILENCE_MS=400
+export CV_VAD_MIN_SPEECH_MS=250
export CV_VAD_MAX_TURN_MS=10000
export CV_VAD_INITIAL_IGNORE_MS=500
export CV_VAD_GATE_UNTIL_GREETING_DONE=true
diff --git a/src/main.rs b/src/main.rs
index cf7cb4f..69ae2df 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2258,8 +2258,8 @@
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),
+ end_silence_ms: u64_env("CV_VAD_END_SILENCE_MS", 400).max(100),
+ min_speech_ms: u64_env("CV_VAD_MIN_SPEECH_MS", 250).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),
}
@@ -2372,7 +2372,9 @@
return None;
}
- let voiced = rms >= self.config.rms_threshold || peak >= self.config.peak_threshold;
+ // Align with cb-sdk's energy-based segmentation: peak is diagnostic only,
+ // otherwise isolated spikes can keep a turn open until max_turn_ms.
+ let voiced = rms >= self.config.rms_threshold;
if !self.in_speech {
self.remember_pre_speech_frame(frame);
}
diff --git a/src/service.rs b/src/service.rs
index a579636..667a774 100644
--- a/src/service.rs
+++ b/src/service.rs
@@ -219,6 +219,12 @@
trace_id: trace_id.clone(),
runtime_session_nonce: start_req.runtime_session_nonce.clone(),
runtime_session_id,
+ event_callback_url: start_req
+ .event_callback
+ .as_ref()
+ .and_then(|value| value.url.clone())
+ .filter(|value| !value.trim().is_empty()),
+ event_callback_token: Some(profile.event_callback_token.clone()),
status: "STARTING".to_string(),
bot_participant_joined: false,
bot_track_ready: false,
@@ -374,6 +380,30 @@
if let Some(value) = &runtime.turn_artifact_dir {
command.env("CV_RUNTIME_TURN_ARTIFACT_DIR", value);
}
+ if let Some(value) = runtime.vad_rms_threshold {
+ command.env("CV_VAD_RMS_THRESHOLD", value.to_string());
+ }
+ if let Some(value) = runtime.vad_peak_threshold {
+ command.env("CV_VAD_PEAK_THRESHOLD", value.to_string());
+ }
+ if let Some(value) = runtime.vad_start_frames {
+ command.env("CV_VAD_START_FRAMES", value.to_string());
+ }
+ if let Some(value) = runtime.vad_end_silence_ms {
+ command.env("CV_VAD_END_SILENCE_MS", value.to_string());
+ }
+ if let Some(value) = runtime.vad_min_speech_ms {
+ command.env("CV_VAD_MIN_SPEECH_MS", value.to_string());
+ }
+ if let Some(value) = runtime.vad_max_turn_ms {
+ command.env("CV_VAD_MAX_TURN_MS", value.to_string());
+ }
+ if let Some(value) = runtime.vad_initial_ignore_ms {
+ command.env("CV_VAD_INITIAL_IGNORE_MS", value.to_string());
+ }
+ if let Some(value) = runtime.vad_post_greeting_delay_ms {
+ command.env("CV_VAD_POST_GREETING_DELAY_MS", value.to_string());
+ }
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);
@@ -434,34 +464,159 @@
.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 callback = {
+ 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();
+ let callback = build_event_callback_dispatch(session, value);
+ if result != "ok" {
+ session.status = "FAILED".to_string();
+ session.bot_participant_joined = false;
+ session.bot_track_ready = false;
+ } else {
+ 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();
+ }
+ _ => {}
+ }
+ }
+ callback
};
- let Some(session) = sessions.get_mut(call_id) else {
- return;
+ if let Some(callback) = callback {
+ dispatch_event_callback(callback);
+ }
+}
+
+fn build_event_callback_dispatch(
+ session: &HelperSession,
+ value: &Value,
+) -> Option<EventCallbackDispatch> {
+ let url = session
+ .event_callback_url
+ .as_ref()
+ .filter(|value| !value.trim().is_empty())?
+ .clone();
+ let token = session
+ .event_callback_token
+ .as_ref()
+ .filter(|value| !value.trim().is_empty())?
+ .clone();
+ Some(EventCallbackDispatch {
+ url,
+ token,
+ call_id: session.call_id.clone(),
+ trace_id: session.trace_id.clone(),
+ runtime_session_nonce: session.runtime_session_nonce.clone(),
+ payload: value.clone(),
+ })
+}
+
+fn dispatch_event_callback(callback: EventCallbackDispatch) {
+ thread::spawn(move || {
+ let event_name = callback
+ .payload
+ .get("eventName")
+ .and_then(Value::as_str)
+ .unwrap_or("worker_event")
+ .to_string();
+ match post_event_callback(&callback) {
+ Ok(status) if (200..300).contains(&status) => {
+ info!(
+ call_id = %callback.call_id,
+ trace_id = ?callback.trace_id,
+ event_name = %event_name,
+ status = status,
+ "helper event callback delivered"
+ );
+ }
+ Ok(status) => {
+ warn!(
+ call_id = %callback.call_id,
+ trace_id = ?callback.trace_id,
+ event_name = %event_name,
+ status = status,
+ "helper event callback rejected"
+ );
+ }
+ Err(error) => {
+ warn!(
+ call_id = %callback.call_id,
+ trace_id = ?callback.trace_id,
+ event_name = %event_name,
+ error = %error,
+ "helper event callback failed"
+ );
+ }
+ }
+ });
+}
+
+fn post_event_callback(callback: &EventCallbackDispatch) -> Result<u16> {
+ let (host, path) = parse_http_url(&callback.url)?;
+ let body = serde_json::to_vec(&callback.payload)?;
+ let mut stream = TcpStream::connect(&host)
+ .with_context(|| format!("failed to connect callback host {host}"))?;
+ stream.set_read_timeout(Some(Duration::from_secs(3)))?;
+ stream.set_write_timeout(Some(Duration::from_secs(3)))?;
+ let trace_header = callback.trace_id.clone().unwrap_or_default();
+ let request = format!(
+ "POST {path} HTTP/1.1\r\n\
+ Host: {host}\r\n\
+ Authorization: Bearer {}\r\n\
+ X-CV-Runtime-Token: {}\r\n\
+ X-CV-Call-Id: {}\r\n\
+ X-CV-Trace-Id: {}\r\n\
+ X-CV-Runtime-Session-Nonce: {}\r\n\
+ Content-Type: application/json\r\n\
+ Content-Length: {}\r\n\
+ Connection: close\r\n\
+ \r\n",
+ callback.token,
+ callback.token,
+ callback.call_id,
+ trace_header,
+ callback.runtime_session_nonce,
+ body.len()
+ );
+ stream.write_all(request.as_bytes())?;
+ stream.write_all(&body)?;
+ stream.flush()?;
+ let mut response = String::new();
+ stream.read_to_string(&mut response)?;
+ response
+ .lines()
+ .next()
+ .and_then(|line| line.split_whitespace().nth(1))
+ .and_then(|value| value.parse::<u16>().ok())
+ .ok_or_else(|| anyhow!("callback response status missing"))
+}
+
+fn parse_http_url(url: &str) -> Result<(String, String)> {
+ let Some(rest) = url.strip_prefix("http://") else {
+ return Err(anyhow!("only http callback url is supported"));
};
- 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;
+ let (host, path) = match rest.split_once('/') {
+ Some((host, path)) => (host, format!("/{path}")),
+ None => (rest, "/".to_string()),
+ };
+ if host.trim().is_empty() {
+ return Err(anyhow!("callback host missing"));
}
- 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();
- }
- _ => {}
- }
+ Ok((host.to_string(), path))
}
enum WaitReadyResult {
@@ -692,6 +847,7 @@
struct AuthProfile {
helper_auth_token: String,
turn_bridge_token: String,
+ event_callback_token: String,
}
impl AuthProfile {
@@ -707,11 +863,24 @@
let turn_bridge_token = env::var(format!("CV_TURN_BRIDGE_TOKEN_{suffix}"))
.or_else(|_| env::var("CV_RUNTIME_TURN_BRIDGE_TOKEN"))
.ok()?;
+ let event_callback_token = env::var(format!("CV_EVENT_CALLBACK_TOKEN_{suffix}"))
+ .or_else(|_| env::var("CV_RUNTIME_EVENT_CALLBACK_TOKEN"))
+ .unwrap_or_else(|_| turn_bridge_token.clone());
Some(Self {
helper_auth_token,
turn_bridge_token,
+ event_callback_token,
})
}
+}
+
+struct EventCallbackDispatch {
+ url: String,
+ token: String,
+ call_id: String,
+ trace_id: Option<String>,
+ runtime_session_nonce: String,
+ payload: Value,
}
struct ServiceState {
@@ -724,6 +893,8 @@
trace_id: Option<String>,
runtime_session_nonce: String,
runtime_session_id: String,
+ event_callback_url: Option<String>,
+ event_callback_token: Option<String>,
status: String,
bot_participant_joined: bool,
bot_track_ready: bool,
@@ -742,6 +913,7 @@
runtime_session_nonce: String,
livekit: LiveKitStart,
turn_bridge: Option<TurnBridgeStart>,
+ event_callback: Option<EventCallbackStart>,
auth_profile: Option<String>,
audio: Option<AudioStart>,
runtime: Option<RuntimeStart>,
@@ -765,6 +937,12 @@
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
+struct EventCallbackStart {
+ url: Option<String>,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
struct AudioStart {
first_audio_source: Option<String>,
greeting_audio: Option<GreetingAudioStart>,
@@ -781,6 +959,14 @@
#[serde(rename_all = "camelCase")]
struct RuntimeStart {
turn_artifact_dir: Option<String>,
+ vad_rms_threshold: Option<f64>,
+ vad_peak_threshold: Option<f64>,
+ vad_start_frames: Option<u32>,
+ vad_end_silence_ms: Option<u64>,
+ vad_min_speech_ms: Option<u64>,
+ vad_max_turn_ms: Option<u64>,
+ vad_initial_ignore_ms: Option<u64>,
+ vad_post_greeting_delay_ms: Option<u64>,
audio_debug_dump_enabled: Option<bool>,
}
--
Gitblit v1.9.3