From e1612a0151ef28c4e02c0f1843dd725a92d4c268 Mon Sep 17 00:00:00 2001
From: cai <cai@nbcai.cc>
Date: Wed, 08 Jul 2026 12:35:21 +0800
Subject: [PATCH] feat: default combrabo voice bot audio to 16k

---
 docs/runtime-contract.md               |   57 +++++++++++
 src/audio.rs                           |   32 ++++++
 tools/validate-turn-stream-fixture.mjs |    5 
 src/main.rs                            |  128 ++++++++++++++++++++-----
 fixtures/turn-stream-happy.ndjson      |    2 
 README.md                              |   30 +++++
 6 files changed, 223 insertions(+), 31 deletions(-)

diff --git a/README.md b/README.md
index 9ea88e6..88fd05a 100644
--- a/README.md
+++ b/README.md
@@ -124,6 +124,34 @@
 结构化事件后才返回 `STARTED + botTrackReady=true`;如果 worker 失败或超时,则返回结构化
 `RUNTIME_START_FAILED` / `RUNTIME_START_TIMEOUT`。
 
+## Bot 音频输出 profile
+
+helper 的 bot 下行音频输出通过部署配置选择,不通过 `sessions/start` 临时传业务参数。本轮 Lmtest / Combrabo Voice 动态回复主链路按用户最新裁决收口为全链路 `16kHz / mono / pcm_s16le`:Java stream bridge 下发 `sampleRate=16000/channels=1`,helper 写入 LiveKit bot track 前也保持 16k,不在业务层显式转成 48k。
+
+```bash
+# 默认值和本轮目标链路:bot NativeAudioSource = 16kHz / mono。
+export CV_BOT_AUDIO_PROFILE=pcm-16k
+
+# 历史 / 紧急回退:bot NativeAudioSource = 48kHz / mono。
+# 不作为本轮 Lmtest 动态回复主链路或音质验收目标。
+export CV_BOT_AUDIO_PROFILE=livekit-48k
+
+# 调试值:显式指定采样率和声道。
+export CV_BOT_AUDIO_PROFILE=custom
+export CV_BOT_SAMPLE_RATE_HZ=16000
+export CV_BOT_NUM_CHANNELS=1
+```
+
+`bot_track_ready` activity 会回写 `audioProfile`、`sampleRate`、`numChannels`,这是 smoke 判断当前运行 profile 的准确信号。动态回复 `bot_reply_audio_write_finished` 会回写 source / target 元数据:
+
+- `sourceSampleRate` / `sourceChannels`:Java stream bridge 下发的 `reply_audio_chunk` 源格式。
+- `targetAudioProfile` / `targetSampleRate` / `targetChannels`:helper 写入 LiveKit bot track 前的目标格式。
+- `networkChunkCount`、`debugSourcePath`、`debugPcmWavPath`:用于脱敏排查 chunk/frame 对齐与听感问题。
+
+本轮验收要求动态回复 `stream-reply-{turnId}-source.wav` 与 `stream-reply-{turnId}-target.wav` 都是 `16000Hz/mono/pcm_s16le`。如果 target 仍是 `48000Hz`,视为未完成全链路 16k 目标。
+
+当前只把 bot 下行输出 profile 做成可配置。用户上行采集、VAD 和 turn artifact 首版仍保持 `48kHz / mono`,避免牵动 ASR 输入、turn bridge 和历史 smoke 口径。
+
 ## 本机固定问候音频
 
 helper 当前 MVP 支持 **WAV / MP3** 音频自动识别。主动问候 `prepare` 生成的 MP3 可以直接进入 helper;本地 smoke 仍可使用 WAV fixture 作为固定兜底样本。
@@ -173,7 +201,7 @@
 <callId>-greeting-target.wav
 ```
 
-其中 `source` 是原始主动问候音频,`target` 是推送给 LiveKit 前的 `48kHz/mono/16-bit` WAV。文件只用于本地回听排查,不进入 Git、不写入协作事件正文,不上传到线上环境。
+其中 `source` 是原始主动问候音频,`target` 是推送给 LiveKit 前的目标 profile WAV。默认是 `16kHz/mono/16-bit`;只有显式配置 `CV_BOT_AUDIO_PROFILE=livekit-48k` 时才会生成 `48kHz/mono/16-bit` target。文件只用于本地回听排查,不进入 Git、不写入协作事件正文,不上传到线上环境。
 
 ## 用户上行音频观测
 
diff --git a/docs/runtime-contract.md b/docs/runtime-contract.md
index 1ac5424..d521b07 100644
--- a/docs/runtime-contract.md
+++ b/docs/runtime-contract.md
@@ -34,7 +34,7 @@
 - `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.
+- `pcm_s16le` chunks are `16000Hz` or `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.
 
@@ -80,6 +80,61 @@
 
 Service mode does not change the media worker boundary. It only replaces `lmrobot-app -> ProcessBuilder` with `lmrobot-app -> helper HTTP control plane`.
 
+## Bot audio output profile
+
+The bot output profile is a helper deployment setting, not a per-call business field. Worker mode reads it directly from `CV_*` variables. Service mode workers inherit it from the helper service process.
+
+For the current Lmtest / Combrabo Voice dynamic reply target, the business chain is fixed to `16000Hz / mono / pcm_s16le` from Java stream bridge to helper bot-track output. The helper must not explicitly convert dynamic reply output to `48000Hz` in this path. `livekit-48k` is retained only for historical paths or emergency rollback, not as this round's target profile.
+
+| Profile | NativeAudioSource | Purpose |
+| --- | --- | --- |
+| `pcm-16k` | `16000Hz / mono` | Default and current dynamic reply target profile. |
+| `livekit-48k` | `48000Hz / mono` | Historical / emergency rollback profile only. |
+| `custom` | `CV_BOT_SAMPLE_RATE_HZ / CV_BOT_NUM_CHANNELS` | Local debug only unless promoted by a separate decision. |
+
+```bash
+CV_BOT_AUDIO_PROFILE=pcm-16k
+CV_BOT_AUDIO_PROFILE=livekit-48k
+CV_BOT_AUDIO_PROFILE=custom CV_BOT_SAMPLE_RATE_HZ=16000 CV_BOT_NUM_CHANNELS=1
+```
+
+`bot_track_ready` is the authoritative runtime signal for the selected profile:
+
+```json
+{
+  "eventType": "bot_track_ready",
+  "eventPayload": {
+    "trackName": "bot-main-audio",
+    "audioProfile": "pcm-16k",
+    "sampleRate": 16000,
+    "numChannels": 1
+  }
+}
+```
+
+Dynamic streaming reply completion must expose both source and target audio metadata:
+
+```json
+{
+  "eventType": "bot_reply_audio_write_finished",
+  "eventPayload": {
+    "format": "pcm_s16le",
+    "sourceSampleRate": 16000,
+    "sourceChannels": 1,
+    "targetAudioProfile": "pcm-16k",
+    "targetSampleRate": 16000,
+    "targetChannels": 1,
+    "networkChunkCount": 68,
+    "debugSourcePath": "/tmp/.../stream-reply-turn-0001-source.wav",
+    "debugPcmWavPath": "/tmp/.../stream-reply-turn-0001-target.wav"
+  }
+}
+```
+
+This profile affects bot output only. User audio observation, VAD and turn artifacts remain `48000Hz / mono` in this phase.
+
+The current acceptance requires both `stream-reply-{turnId}-source.wav` and `stream-reply-{turnId}-target.wav` to be `16000Hz / mono / pcm_s16le`. If the target dump is `48000Hz`, the dynamic reply 16k chain is not complete.
+
 ## Service mode endpoints
 
 All session endpoints require:
diff --git a/fixtures/turn-stream-happy.ndjson b/fixtures/turn-stream-happy.ndjson
index b5f48b3..d7e7c4a 100644
--- a/fixtures/turn-stream-happy.ndjson
+++ b/fixtures/turn-stream-happy.ndjson
@@ -1,5 +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":"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":16000,"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/src/audio.rs b/src/audio.rs
index 34415a7..46613eb 100644
--- a/src/audio.rs
+++ b/src/audio.rs
@@ -1040,4 +1040,36 @@
         assert_eq!(960, second.frames[0].samples_per_channel);
         assert_eq!(0, second.buffered_source_samples);
     }
+
+    #[test]
+    fn pcm_s16le_stream_decoder_can_keep_16k_native_audio_profile() {
+        let mut decoder = PcmS16leStreamDecoder::new(16_000, 1, 16_000, 1).expect("stream decoder");
+        let mut pcm = Vec::new();
+        for index in 0..1_600 {
+            let sample = if index % 2 == 0 { 768_i16 } else { -768_i16 };
+            pcm.extend_from_slice(&sample.to_le_bytes());
+        }
+
+        let mut frames = Vec::new();
+        let chunk_size = 250 * 2;
+        for (index, chunk) in pcm.chunks(chunk_size).enumerate() {
+            let last = (index + 1) * chunk_size >= pcm.len();
+            let result = decoder
+                .push_bytes(chunk, 16_000, 1, last)
+                .expect("push pcm chunk");
+            frames.extend(result.frames);
+        }
+
+        assert_eq!(5, frames.len());
+        assert!(frames.iter().all(|frame| frame.sample_rate == 16_000));
+        assert!(frames.iter().all(|frame| frame.num_channels == 1));
+        assert!(frames.iter().all(|frame| frame.samples_per_channel == 320));
+        assert_eq!(
+            1_600,
+            frames
+                .iter()
+                .map(|frame| frame.samples.len())
+                .sum::<usize>()
+        );
+    }
 }
diff --git a/src/main.rs b/src/main.rs
index 64eedd4..cf7cb4f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -34,8 +34,12 @@
 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 USER_AUDIO_SAMPLE_RATE_HZ: u32 = 48_000;
+const USER_AUDIO_NUM_CHANNELS: u16 = 1;
+const DEFAULT_BOT_AUDIO_PROFILE: &str = "pcm-16k";
+const LIVEKIT_48K_SAMPLE_RATE_HZ: u32 = 48_000;
+const PCM_16K_SAMPLE_RATE_HZ: u32 = 16_000;
+const BOT_NUM_CHANNELS: u16 = 1;
 const TRACK_NAME: &str = "bot-main-audio";
 
 #[tokio::main(flavor = "multi_thread")]
@@ -58,8 +62,8 @@
         &http,
         config.greeting_audio_file.as_deref(),
         config.greeting_audio_url.as_deref(),
-        TARGET_SAMPLE_RATE_HZ,
-        TARGET_NUM_CHANNELS,
+        config.bot_audio_profile.sample_rate_hz,
+        config.bot_audio_profile.num_channels,
         config.audio_debug_dump_dir.as_deref(),
         &config.call_id,
         "greeting",
@@ -81,6 +85,9 @@
         room_alias = %redact(&config.room_id),
         participant_alias = %redact(&config.bot_participant_identity),
         greeting_source = %config.greeting_source,
+        bot_audio_profile = %config.bot_audio_profile.profile,
+        bot_sample_rate_hz = config.bot_audio_profile.sample_rate_hz,
+        bot_num_channels = config.bot_audio_profile.num_channels,
         "combrabo voice runtime helper connected"
     );
     emit_activity(
@@ -129,8 +136,9 @@
         &config.call_id,
         &config.trace_id,
         TRACK_NAME,
-        TARGET_SAMPLE_RATE_HZ,
-        u32::from(TARGET_NUM_CHANNELS),
+        config.bot_audio_profile.profile.clone(),
+        config.bot_audio_profile.sample_rate_hz,
+        u32::from(config.bot_audio_profile.num_channels),
         config.user_participant_identity.clone(),
     )
     .await?;
@@ -269,6 +277,54 @@
     simple_vad_gate_until_greeting_done: bool,
     simple_vad_post_greeting_delay_ms: u64,
     simple_vad_config: SimpleVadConfig,
+    bot_audio_profile: BotAudioProfile,
+}
+
+#[derive(Clone)]
+struct BotAudioProfile {
+    profile: String,
+    sample_rate_hz: u32,
+    num_channels: u16,
+}
+
+impl BotAudioProfile {
+    fn from_env() -> Result<Self> {
+        let profile = env::var("CV_BOT_AUDIO_PROFILE")
+            .unwrap_or_else(|_| DEFAULT_BOT_AUDIO_PROFILE.to_string())
+            .trim()
+            .to_ascii_lowercase();
+        match profile.as_str() {
+            "livekit-48k" | "48k" => Ok(Self {
+                profile: "livekit-48k".to_string(),
+                sample_rate_hz: LIVEKIT_48K_SAMPLE_RATE_HZ,
+                num_channels: BOT_NUM_CHANNELS,
+            }),
+            "pcm-16k" | "16k" => Ok(Self {
+                profile: "pcm-16k".to_string(),
+                sample_rate_hz: PCM_16K_SAMPLE_RATE_HZ,
+                num_channels: BOT_NUM_CHANNELS,
+            }),
+            "custom" => {
+                let sample_rate_hz = u32_env("CV_BOT_SAMPLE_RATE_HZ", LIVEKIT_48K_SAMPLE_RATE_HZ);
+                let num_channels = u16_env("CV_BOT_NUM_CHANNELS", BOT_NUM_CHANNELS);
+                if sample_rate_hz == 0 {
+                    return Err(anyhow!("CV_BOT_SAMPLE_RATE_HZ must be positive"));
+                }
+                if num_channels == 0 {
+                    return Err(anyhow!("CV_BOT_NUM_CHANNELS must be positive"));
+                }
+                Ok(Self {
+                    profile,
+                    sample_rate_hz,
+                    num_channels,
+                })
+            }
+            _ => Err(anyhow!(
+                "unsupported CV_BOT_AUDIO_PROFILE {}; expected livekit-48k, pcm-16k or custom",
+                profile
+            )),
+        }
+    }
 }
 
 #[derive(Clone)]
@@ -346,6 +402,7 @@
             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(),
+            bot_audio_profile: BotAudioProfile::from_env()?,
         })
     }
 }
@@ -489,6 +546,13 @@
     env::var(key)
         .ok()
         .and_then(|value| value.trim().parse::<u32>().ok())
+        .unwrap_or(default_value)
+}
+
+fn u16_env(key: &str, default_value: u16) -> u16 {
+    env::var(key)
+        .ok()
+        .and_then(|value| value.trim().parse::<u16>().ok())
         .unwrap_or(default_value)
 }
 
@@ -930,8 +994,8 @@
     audio::write_pcm_wav(
         &output_path,
         &turn.samples,
-        TARGET_SAMPLE_RATE_HZ,
-        TARGET_NUM_CHANNELS,
+        USER_AUDIO_SAMPLE_RATE_HZ,
+        USER_AUDIO_NUM_CHANNELS,
     )?;
     let byte_size = fs::metadata(&output_path)
         .context("failed to stat turn artifact")?
@@ -959,8 +1023,8 @@
             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),
+            sample_rate: USER_AUDIO_SAMPLE_RATE_HZ,
+            channels: u32::from(USER_AUDIO_NUM_CHANNELS),
             duration_ms: turn.duration_ms,
             byte_size,
         },
@@ -1343,16 +1407,14 @@
         .trim()
         .to_ascii_lowercase();
     let frames = if format == "pcm_s16le" {
-        let sample_rate = audio_chunk.sample_rate.unwrap_or(TARGET_SAMPLE_RATE_HZ);
-        let channels = audio_chunk
-            .channels
-            .unwrap_or(u32::from(TARGET_NUM_CHANNELS));
+        let sample_rate = audio_chunk.sample_rate.unwrap_or(sink.sample_rate_hz);
+        let channels = audio_chunk.channels.unwrap_or(u32::from(sink.num_channels));
         if state.pcm_stream_decoder.is_none() {
             state.pcm_stream_decoder = Some(audio::PcmS16leStreamDecoder::new(
                 sample_rate,
                 channels,
-                TARGET_SAMPLE_RATE_HZ,
-                TARGET_NUM_CHANNELS,
+                sink.sample_rate_hz,
+                sink.num_channels,
             )?);
         }
         state.pcm_stream_network_chunk_count =
@@ -1396,8 +1458,8 @@
         match audio::decode_audio_bytes_to_frames(
             &state.encoded_audio_buffer,
             "stream_chunk",
-            TARGET_SAMPLE_RATE_HZ,
-            TARGET_NUM_CHANNELS,
+            sink.sample_rate_hz,
+            sink.num_channels,
             bridge_config.audio_debug_dump_dir.as_deref(),
             call_id,
             &format!("stream-reply-{}", turn.turn_id),
@@ -1526,8 +1588,11 @@
                 "debugSourcePath": debug_source_path,
                 "debugPcmWavPath": debug_pcm_wav_path,
                 "debugPcmWavSizeBytes": debug_pcm_wav_size_bytes,
-                "sampleRate": audio_chunk.sample_rate,
-                "channels": audio_chunk.channels,
+                "sourceSampleRate": audio_chunk.sample_rate,
+                "sourceChannels": audio_chunk.channels,
+                "targetAudioProfile": sink.profile.as_str(),
+                "targetSampleRate": sink.sample_rate_hz,
+                "targetChannels": sink.num_channels,
                 "replyTotalAfterVadEndMs": turn_pipeline_started_at.elapsed().as_millis() as u64,
             }),
         );
@@ -1566,8 +1631,8 @@
             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),
+            sample_rate: USER_AUDIO_SAMPLE_RATE_HZ,
+            channels: u32::from(USER_AUDIO_NUM_CHANNELS),
             duration_ms: turn.duration_ms,
             byte_size,
         },
@@ -1730,8 +1795,8 @@
         http,
         Some(&audio_path_string),
         None,
-        TARGET_SAMPLE_RATE_HZ,
-        TARGET_NUM_CHANNELS,
+        sink.sample_rate_hz,
+        sink.num_channels,
         bridge_config.audio_debug_dump_dir.as_deref(),
         call_id,
         &reply_debug_label,
@@ -2075,8 +2140,8 @@
     tokio::spawn(async move {
         let mut stream = NativeAudioStream::new(
             track.rtc_track(),
-            TARGET_SAMPLE_RATE_HZ as i32,
-            i32::from(TARGET_NUM_CHANNELS),
+            USER_AUDIO_SAMPLE_RATE_HZ as i32,
+            i32::from(USER_AUDIO_NUM_CHANNELS),
         );
         let started_at = Instant::now();
         let mut frame_count: u64 = 0;
@@ -2563,6 +2628,9 @@
     rtc_source: NativeAudioSource,
     track: LocalAudioTrack,
     device_output_destination_identity: Option<String>,
+    profile: String,
+    sample_rate_hz: u32,
+    num_channels: u16,
 }
 
 impl BotAudioOutputSink {
@@ -2573,6 +2641,7 @@
         call_id: &str,
         trace_id: &str,
         track_name: &str,
+        profile: String,
         sample_rate: u32,
         num_channels: u32,
         device_output_destination_identity: Option<String>,
@@ -2601,11 +2670,14 @@
                     "failed to publish bot audio track in room {room_alias} for participant {participant_alias}: {error}"
                 )
             })?;
+        let num_channels_u16 = u16::try_from(num_channels)
+            .map_err(|_| anyhow!("unsupported bot audio channel count {num_channels}"))?;
 
         info!(
             room_alias = %room_alias,
             participant_alias = %participant_alias,
             track_name = %track_name,
+            bot_audio_profile = %profile,
             sample_rate,
             num_channels,
             "runtime helper published bot audio track"
@@ -2620,6 +2692,7 @@
             None,
             json!({
                 "trackName": track_name,
+                "audioProfile": profile,
                 "sampleRate": sample_rate,
                 "numChannels": num_channels,
             }),
@@ -2630,6 +2703,9 @@
             rtc_source,
             track,
             device_output_destination_identity,
+            profile,
+            sample_rate_hz: sample_rate,
+            num_channels: num_channels_u16,
         })
     }
 
diff --git a/tools/validate-turn-stream-fixture.mjs b/tools/validate-turn-stream-fixture.mjs
index 710365f..bcdd77f 100755
--- a/tools/validate-turn-stream-fixture.mjs
+++ b/tools/validate-turn-stream-fixture.mjs
@@ -30,6 +30,7 @@
 let traceId = null
 let turnId = null
 const supportedAudioFormats = new Set(['pcm_s16le', 'mp3', 'mpeg', 'wav'])
+const supportedPcmSampleRates = new Set([16000, 48000])
 
 function fail(message) {
   console.error(`turn stream fixture invalid: ${message}`)
@@ -100,8 +101,8 @@
       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 (!supportedPcmSampleRates.has(chunk.sampleRate) || chunk.channels !== 1) {
+        fail('pcm_s16le chunk must be 16000Hz or 48000Hz mono')
       }
       if (payload.length % 2 !== 0) {
         fail('pcm_s16le payload must be 16-bit aligned')

--
Gitblit v1.9.3