From 2e939a72d7aa36af6f567d8ef59d2454cf1493fa Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 22 Feb 2026 08:03:43 +0000 Subject: [PATCH 1/4] fix(audio): handle A113 PDM S32 offset in recorder --- .../client-rust/src/services/audio/record.rs | 152 +++++++++++++++--- 1 file changed, 126 insertions(+), 26 deletions(-) diff --git a/packages/client-rust/src/services/audio/record.rs b/packages/client-rust/src/services/audio/record.rs index 02ef8d2..32db314 100644 --- a/packages/client-rust/src/services/audio/record.rs +++ b/packages/client-rust/src/services/audio/record.rs @@ -16,6 +16,12 @@ enum State { Recording, } +const A113_CAPTURE_BITS_PER_SAMPLE: u16 = 32; + +fn is_default_noop_recording(config: &AudioConfig) -> bool { + config.pcm == "noop" && config.channels == 1 && config.bits_per_sample == 16 +} + pub struct AudioRecorder { state: Arc>, arecord_thread: Arc>>, @@ -69,45 +75,47 @@ impl AudioRecorder { return Ok(()); } - let config = config.unwrap_or_else(|| (*AUDIO_CONFIG).clone()); + let requested_config = config.unwrap_or_else(|| (*AUDIO_CONFIG).clone()); + let mut fix_enabled = should_enable_a113_fix(&requested_config).await; + let mut capture_config = capture_config_for_transform(&requested_config, fix_enabled); - let mut arecord_thread = Command::new("arecord") - .args([ - "--quiet", - "-t", - "raw", - "-D", - &config.pcm, - "-f", - &format!("S{}_LE", config.bits_per_sample), - "-r", - &config.sample_rate.to_string(), - "-c", - &config.channels.to_string(), - "--buffer-size", - &config.buffer_size.to_string(), - "--period-size", - &config.period_size.to_string(), - ]) - .stdout(Stdio::piped()) - .spawn()?; + let mut arecord_thread = match spawn_arecord(&capture_config) { + Ok(child) => child, + Err(err) => { + // Keep backward compatibility if S32 capture isn't supported. + if fix_enabled { + fix_enabled = false; + capture_config = requested_config.clone(); + spawn_arecord(&capture_config)? + } else { + return Err(err); + } + } + }; let mut stdout = arecord_thread.stdout.take().unwrap(); let read_thread = tokio::spawn(async move { - let size = (config.bits_per_sample / 8) as usize; - let target_size = config.buffer_size as usize * size; + let bytes_per_sample = (capture_config.bits_per_sample.max(8) / 8) as usize; + let bytes_per_frame = bytes_per_sample * capture_config.channels.max(1) as usize; + let target_frames = capture_config.buffer_size.max(1) as usize; + let read_frames = capture_config.period_size.max(1) as usize; + let target_size = target_frames * bytes_per_frame; + let read_size = read_frames * bytes_per_frame; let mut accumulated_data = Vec::new(); - let mut buffer = vec![0u8; config.period_size as usize * size]; + let mut buffer = vec![0u8; read_size]; loop { match stdout.read(&mut buffer).await { Ok(size) if size > 0 => { accumulated_data.extend_from_slice(&buffer[..size]); - if accumulated_data.len() >= target_size { + while accumulated_data.len() >= target_size { let data_to_send = accumulated_data.drain(..target_size).collect::>(); - let _ = on_stream(data_to_send).await; + let data_to_send = transform_stream_chunk(data_to_send, fix_enabled); + if !data_to_send.is_empty() { + let _ = on_stream(data_to_send).await; + } } } _ => break, @@ -124,3 +132,95 @@ impl AudioRecorder { Ok(()) } } + +fn capture_config_for_transform( + requested: &AudioConfig, + fix_enabled: bool, +) -> AudioConfig { + let mut capture = requested.clone(); + if fix_enabled { + capture.bits_per_sample = A113_CAPTURE_BITS_PER_SAMPLE; + } + capture +} + +fn transform_stream_chunk( + chunk: Vec, + fix_enabled: bool, +) -> Vec { + if !fix_enabled { + return chunk; + } + convert_a113_s32_to_s16(&chunk) +} + +fn convert_a113_s32_to_s16(chunk: &[u8]) -> Vec { + if chunk.len() % 4 != 0 { + return Vec::new(); + } + + let frame_count = chunk.len() / 4; + let mut out = vec![0u8; frame_count * 2]; + + for frame in 0..frame_count { + let base = frame * 4; + let sample = i32::from_le_bytes([ + chunk[base], + chunk[base + 1], + chunk[base + 2], + chunk[base + 3], + ]); + // A113 PDM data lives in lower 24 bits of S32_LE: shift by 8 (not 16). + let mapped = (sample >> 8).clamp(i16::MIN as i32, i16::MAX as i32) as i16; + let out_base = frame * 2; + out[out_base..out_base + 2].copy_from_slice(&mapped.to_le_bytes()); + } + + out +} + +async fn should_enable_a113_fix(config: &AudioConfig) -> bool { + if !is_default_noop_recording(config) { + return false; + } + detect_a113_platform().await +} + +async fn detect_a113_platform() -> bool { + let out = match Command::new("sh") + .arg("-c") + .arg("cat /proc/device-tree/compatible 2>/dev/null; cat /proc/cpuinfo 2>/dev/null") + .output() + .await + { + Ok(out) => out, + Err(_) => return false, + }; + + let probe = String::from_utf8_lossy(&out.stdout).to_lowercase(); + probe.contains("a113") || (probe.contains("amlogic") && probe.contains("meson")) +} + +fn spawn_arecord(config: &AudioConfig) -> Result { + let child = Command::new("arecord") + .args([ + "--quiet", + "-t", + "raw", + "-D", + &config.pcm, + "-f", + &format!("S{}_LE", config.bits_per_sample), + "-r", + &config.sample_rate.to_string(), + "-c", + &config.channels.to_string(), + "--buffer-size", + &config.buffer_size.to_string(), + "--period-size", + &config.period_size.to_string(), + ]) + .stdout(Stdio::piped()) + .spawn()?; + Ok(child) +} From e986e45d7e0d00d7f536c53bcc3a54320ff874a1 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 22 Feb 2026 08:08:28 +0000 Subject: [PATCH 2/4] refactor(audio): remove A113 probe and fallback path --- .../client-rust/src/services/audio/record.rs | 41 ++----------------- 1 file changed, 3 insertions(+), 38 deletions(-) diff --git a/packages/client-rust/src/services/audio/record.rs b/packages/client-rust/src/services/audio/record.rs index 32db314..3313869 100644 --- a/packages/client-rust/src/services/audio/record.rs +++ b/packages/client-rust/src/services/audio/record.rs @@ -76,22 +76,9 @@ impl AudioRecorder { } let requested_config = config.unwrap_or_else(|| (*AUDIO_CONFIG).clone()); - let mut fix_enabled = should_enable_a113_fix(&requested_config).await; - let mut capture_config = capture_config_for_transform(&requested_config, fix_enabled); - - let mut arecord_thread = match spawn_arecord(&capture_config) { - Ok(child) => child, - Err(err) => { - // Keep backward compatibility if S32 capture isn't supported. - if fix_enabled { - fix_enabled = false; - capture_config = requested_config.clone(); - spawn_arecord(&capture_config)? - } else { - return Err(err); - } - } - }; + let fix_enabled = is_default_noop_recording(&requested_config); + let capture_config = capture_config_for_transform(&requested_config, fix_enabled); + let mut arecord_thread = spawn_arecord(&capture_config)?; let mut stdout = arecord_thread.stdout.take().unwrap(); let read_thread = tokio::spawn(async move { @@ -179,28 +166,6 @@ fn convert_a113_s32_to_s16(chunk: &[u8]) -> Vec { out } -async fn should_enable_a113_fix(config: &AudioConfig) -> bool { - if !is_default_noop_recording(config) { - return false; - } - detect_a113_platform().await -} - -async fn detect_a113_platform() -> bool { - let out = match Command::new("sh") - .arg("-c") - .arg("cat /proc/device-tree/compatible 2>/dev/null; cat /proc/cpuinfo 2>/dev/null") - .output() - .await - { - Ok(out) => out, - Err(_) => return false, - }; - - let probe = String::from_utf8_lossy(&out.stdout).to_lowercase(); - probe.contains("a113") || (probe.contains("amlogic") && probe.contains("meson")) -} - fn spawn_arecord(config: &AudioConfig) -> Result { let child = Command::new("arecord") .args([ From fbef7c495c7a9627ff4dd52853e0e8a90470af26 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 22 Feb 2026 08:11:49 +0000 Subject: [PATCH 3/4] refactor(audio): inline A113 conversion flow without flag state --- .../client-rust/src/services/audio/record.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/client-rust/src/services/audio/record.rs b/packages/client-rust/src/services/audio/record.rs index 3313869..0498377 100644 --- a/packages/client-rust/src/services/audio/record.rs +++ b/packages/client-rust/src/services/audio/record.rs @@ -76,8 +76,7 @@ impl AudioRecorder { } let requested_config = config.unwrap_or_else(|| (*AUDIO_CONFIG).clone()); - let fix_enabled = is_default_noop_recording(&requested_config); - let capture_config = capture_config_for_transform(&requested_config, fix_enabled); + let capture_config = capture_config_for_recording(&requested_config); let mut arecord_thread = spawn_arecord(&capture_config)?; let mut stdout = arecord_thread.stdout.take().unwrap(); @@ -99,7 +98,8 @@ impl AudioRecorder { while accumulated_data.len() >= target_size { let data_to_send = accumulated_data.drain(..target_size).collect::>(); - let data_to_send = transform_stream_chunk(data_to_send, fix_enabled); + let data_to_send = + transform_stream_chunk(data_to_send, &requested_config); if !data_to_send.is_empty() { let _ = on_stream(data_to_send).await; } @@ -120,12 +120,9 @@ impl AudioRecorder { } } -fn capture_config_for_transform( - requested: &AudioConfig, - fix_enabled: bool, -) -> AudioConfig { +fn capture_config_for_recording(requested: &AudioConfig) -> AudioConfig { let mut capture = requested.clone(); - if fix_enabled { + if is_default_noop_recording(requested) { capture.bits_per_sample = A113_CAPTURE_BITS_PER_SAMPLE; } capture @@ -133,9 +130,9 @@ fn capture_config_for_transform( fn transform_stream_chunk( chunk: Vec, - fix_enabled: bool, + requested: &AudioConfig, ) -> Vec { - if !fix_enabled { + if !is_default_noop_recording(requested) { return chunk; } convert_a113_s32_to_s16(&chunk) From 0748bedcebf606a050307be60b670167e46569c6 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 22 Feb 2026 08:15:39 +0000 Subject: [PATCH 4/4] refactor(audio): remove flag-style gating from A113 conversion --- packages/client-rust/src/services/audio/record.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/client-rust/src/services/audio/record.rs b/packages/client-rust/src/services/audio/record.rs index 0498377..3c4c42d 100644 --- a/packages/client-rust/src/services/audio/record.rs +++ b/packages/client-rust/src/services/audio/record.rs @@ -18,10 +18,6 @@ enum State { const A113_CAPTURE_BITS_PER_SAMPLE: u16 = 32; -fn is_default_noop_recording(config: &AudioConfig) -> bool { - config.pcm == "noop" && config.channels == 1 && config.bits_per_sample == 16 -} - pub struct AudioRecorder { state: Arc>, arecord_thread: Arc>>, @@ -99,7 +95,7 @@ impl AudioRecorder { let data_to_send = accumulated_data.drain(..target_size).collect::>(); let data_to_send = - transform_stream_chunk(data_to_send, &requested_config); + transform_stream_chunk(data_to_send, &requested_config, &capture_config); if !data_to_send.is_empty() { let _ = on_stream(data_to_send).await; } @@ -122,7 +118,7 @@ impl AudioRecorder { fn capture_config_for_recording(requested: &AudioConfig) -> AudioConfig { let mut capture = requested.clone(); - if is_default_noop_recording(requested) { + if requested.bits_per_sample == 16 { capture.bits_per_sample = A113_CAPTURE_BITS_PER_SAMPLE; } capture @@ -131,8 +127,9 @@ fn capture_config_for_recording(requested: &AudioConfig) -> AudioConfig { fn transform_stream_chunk( chunk: Vec, requested: &AudioConfig, + capture: &AudioConfig, ) -> Vec { - if !is_default_noop_recording(requested) { + if requested.bits_per_sample != 16 || capture.bits_per_sample != A113_CAPTURE_BITS_PER_SAMPLE { return chunk; } convert_a113_s32_to_s16(&chunk)