From bc0f0ee48807d636d7f6cd83a6241cb4c4d696fe Mon Sep 17 00:00:00 2001 From: Chaos Rogers Date: Tue, 21 Oct 2025 23:58:56 +0100 Subject: [PATCH] feat: add audio LTC input path and config options (audio-input) Co-authored-by: aider (openai/gpt-5) --- Cargo.toml | 5 ++ src/audio_input.rs | 155 +++++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 45 +++++++++++++ src/lib.rs | 1 + 4 files changed, 206 insertions(+) create mode 100644 src/audio_input.rs diff --git a/Cargo.toml b/Cargo.toml index 8d8ecba..ce806a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,5 +23,10 @@ num-rational = "0.4" num-traits = "0.2" libc = "0.2" which = "6" +cpal = { version = "0.15", optional = true } + +[features] +default = [] +audio-input = ["cpal"] diff --git a/src/audio_input.rs b/src/audio_input.rs new file mode 100644 index 0000000..a03935a --- /dev/null +++ b/src/audio_input.rs @@ -0,0 +1,155 @@ +use std::sync::mpsc::Sender; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use crate::sync_logic::{LtcFrame, LtcState}; + +#[derive(Debug)] +pub enum AudioInputError { + Unsupported(&'static str), + StartError(String), +} + +#[cfg(feature = "audio-input")] +pub fn start_audio_ltc_thread( + sender: Sender, + state: Arc>, + device_name: Option, + channel_index: Option, + sample_rate_hz: Option, +) -> Result, AudioInputError> { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + + let host = cpal::default_host(); + + let device = if let Some(name) = device_name { + let mut found = None; + for d in host.input_devices().map_err(|e| AudioInputError::StartError(e.to_string()))? { + if let Ok(dname) = d.name() { + if dname.contains(&name) { + found = Some(d); + break; + } + } + } + found.unwrap_or(host.default_input_device().ok_or_else(|| { + AudioInputError::StartError("No input device found".into()) + })?) + } else { + host.default_input_device() + .ok_or_else(|| AudioInputError::StartError("No input device found".into()))? + }; + + let mut config: cpal::StreamConfig = device + .default_input_config() + .map_err(|e| AudioInputError::StartError(e.to_string()))? + .config(); + + if let Some(sr) = sample_rate_hz { + config.sample_rate = cpal::SampleRate(sr); + } + + // Choose channel if provided + if let Some(ch) = channel_index { + config.channels = ch.saturating_add(1); // ensure at least ch+1 channels exist + } + + // Shared buffer for simple windowing (placeholder for real LTC decoding) + let buf_state = Arc::new(Mutex::new(Vec::::with_capacity(48000))); + + let buf_state_clone = Arc::clone(&buf_state); + let state_clone = Arc::clone(&state); + let sender_clone = sender.clone(); + + let sample_format = device + .default_input_config() + .map_err(|e| AudioInputError::StartError(e.to_string()))? + .sample_format(); + + let stream = match sample_format { + cpal::SampleFormat::F32 => device.build_input_stream( + &config, + move |data: &[f32], _| on_input(data, &buf_state_clone, &state_clone, &sender_clone), + on_err, + None, + ), + cpal::SampleFormat::I16 => device.build_input_stream( + &config, + move |data: &[i16], _| { + let data_f32: Vec = data.iter().map(|s| *s as f32 / i16::MAX as f32).collect(); + on_input(&data_f32, &buf_state_clone, &state_clone, &sender_clone) + }, + on_err, + None, + ), + cpal::SampleFormat::U16 => device.build_input_stream( + &config, + move |data: &[u16], _| { + let data_f32: Vec = data + .iter() + .map(|s| (*s as f32 - 32768.0) / 32768.0) + .collect(); + on_input(&data_f32, &buf_state_clone, &state_clone, &sender_clone) + }, + on_err, + None, + ), + _ => return Err(AudioInputError::StartError("Unsupported sample format".into())), + } + .map_err(|e| AudioInputError::StartError(e.to_string()))?; + + stream + .play() + .map_err(|e| AudioInputError::StartError(e.to_string()))?; + + // Keep the stream alive on a background thread. + let handle = std::thread::spawn(move || { + log::info!("🎧 Audio LTC capture started (device: {:?}, config: {:?})", device.name().ok(), config); + // Keep thread alive; real decoder would run here or be driven by callbacks. + loop { + std::thread::sleep(std::time::Duration::from_millis(500)); + } + // stream dropped here when thread ends + }); + + // Prevent unused warnings for now (until real decoder feeds frames) + let _ = sender; + let _ = state; + + Ok(handle) +} + +#[cfg(not(feature = "audio-input"))] +pub fn start_audio_ltc_thread( + _sender: Sender, + _state: Arc>, + _device_name: Option, + _channel_index: Option, + _sample_rate_hz: Option, +) -> Result, AudioInputError> { + Err(AudioInputError::Unsupported( + "binary built without 'audio-input' feature", + )) +} + +#[cfg(feature = "audio-input")] +fn on_input( + data: &[f32], + buf_state: &Arc>>, + _state: &Arc>, + _sender: &Sender, +) { + // Placeholder: buffer samples and (in future) run LTC decode on windows. + // For now, just cap the buffer to a few seconds to avoid unbounded growth. + let mut buf = buf_state.lock().unwrap(); + buf.extend_from_slice(data); + if buf.len() > 5 * 48000 { + let keep_from = buf.len() - 5 * 48000; + buf.drain(0..keep_from); + } +} + +#[cfg(feature = "audio-input")] +fn on_err(err: cpal::StreamError) { + log::error!("Audio input stream error: {}", err); +} diff --git a/src/config.rs b/src/config.rs index 8669e62..08f2923 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,12 +43,27 @@ pub struct Config { pub default_nudge_ms: i64, #[serde(default)] pub auto_sync_enabled: bool, + + // Input selection: "teensy" (serial) or "audio" + #[serde(default = "default_input_mode")] + pub input_mode: String, + // Optional audio input configuration (used when input_mode == "audio") + #[serde(default)] + pub audio_device_name: Option, + #[serde(default)] + pub audio_channel_index: Option, + #[serde(default)] + pub audio_sample_rate_hz: Option, } fn default_nudge_ms() -> i64 { 2 // Default nudge is 2ms } +fn default_input_mode() -> String { + "teensy".to_string() +} + impl Config { pub fn load(path: &PathBuf) -> Self { let mut file = match File::open(path) { @@ -74,6 +89,10 @@ impl Default for Config { timeturner_offset: TimeturnerOffset::default(), default_nudge_ms: default_nudge_ms(), auto_sync_enabled: false, + input_mode: default_input_mode(), + audio_device_name: None, + audio_channel_index: None, + audio_sample_rate_hz: None, } } } @@ -83,6 +102,32 @@ pub fn save_config(path: &str, config: &Config) -> Result<(), Box