feat: add audio LTC input path and config options (audio-input)

Co-authored-by: aider (openai/gpt-5) <aider@aider.chat>
This commit is contained in:
Chaos Rogers 2025-10-21 23:58:56 +01:00
parent 98963b0b9a
commit bc0f0ee488
4 changed files with 206 additions and 0 deletions

View file

@ -23,5 +23,10 @@ num-rational = "0.4"
num-traits = "0.2" num-traits = "0.2"
libc = "0.2" libc = "0.2"
which = "6" which = "6"
cpal = { version = "0.15", optional = true }
[features]
default = []
audio-input = ["cpal"]

155
src/audio_input.rs Normal file
View file

@ -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<LtcFrame>,
state: Arc<Mutex<LtcState>>,
device_name: Option<String>,
channel_index: Option<u16>,
sample_rate_hz: Option<u32>,
) -> Result<JoinHandle<()>, 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::<f32>::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<f32> = 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<f32> = 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<LtcFrame>,
_state: Arc<Mutex<LtcState>>,
_device_name: Option<String>,
_channel_index: Option<u16>,
_sample_rate_hz: Option<u32>,
) -> Result<JoinHandle<()>, AudioInputError> {
Err(AudioInputError::Unsupported(
"binary built without 'audio-input' feature",
))
}
#[cfg(feature = "audio-input")]
fn on_input(
data: &[f32],
buf_state: &Arc<Mutex<Vec<f32>>>,
_state: &Arc<Mutex<LtcState>>,
_sender: &Sender<LtcFrame>,
) {
// 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);
}

View file

@ -43,12 +43,27 @@ pub struct Config {
pub default_nudge_ms: i64, pub default_nudge_ms: i64,
#[serde(default)] #[serde(default)]
pub auto_sync_enabled: bool, 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<String>,
#[serde(default)]
pub audio_channel_index: Option<u16>,
#[serde(default)]
pub audio_sample_rate_hz: Option<u32>,
} }
fn default_nudge_ms() -> i64 { fn default_nudge_ms() -> i64 {
2 // Default nudge is 2ms 2 // Default nudge is 2ms
} }
fn default_input_mode() -> String {
"teensy".to_string()
}
impl Config { impl Config {
pub fn load(path: &PathBuf) -> Self { pub fn load(path: &PathBuf) -> Self {
let mut file = match File::open(path) { let mut file = match File::open(path) {
@ -74,6 +89,10 @@ impl Default for Config {
timeturner_offset: TimeturnerOffset::default(), timeturner_offset: TimeturnerOffset::default(),
default_nudge_ms: default_nudge_ms(), default_nudge_ms: default_nudge_ms(),
auto_sync_enabled: false, 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<dyn std::error
s.push_str("# Hardware offset in milliseconds for correcting capture latency.\n"); s.push_str("# Hardware offset in milliseconds for correcting capture latency.\n");
s.push_str(&format!("hardwareOffsetMs: {}\n\n", config.hardware_offset_ms)); s.push_str(&format!("hardwareOffsetMs: {}\n\n", config.hardware_offset_ms));
s.push_str("# Input mode: 'teensy' (serial via Teensy) or 'audio' (capture and decode LTC from audio).\n");
s.push_str(&format!("inputMode: {}\n", config.input_mode));
s.push_str("# When inputMode is 'audio', configure capture device (quoted string or empty),\n");
s.push_str("# optional channel index (0-based), and optional sample rate (Hz).\n");
let dev = config.audio_device_name.as_deref().unwrap_or("");
let dev_yaml = if dev.is_empty() {
"''".to_string()
} else {
format!("\"{}\"", dev.replace('\"', "\\\""))
};
s.push_str(&format!("audioDeviceName: {}\n", dev_yaml));
s.push_str(&format!(
"audioChannelIndex: {}\n",
config
.audio_channel_index
.map(|v| v.to_string())
.unwrap_or_else(|| "null".into())
));
s.push_str(&format!(
"audioSampleRateHz: {}\n\n",
config
.audio_sample_rate_hz
.map(|v| v.to_string())
.unwrap_or_else(|| "null".into())
));
s.push_str("# Enable automatic clock synchronization.\n"); s.push_str("# Enable automatic clock synchronization.\n");
s.push_str("# When enabled, the system will perform an initial full sync, then periodically\n"); s.push_str("# When enabled, the system will perform an initial full sync, then periodically\n");
s.push_str("# nudge the clock to keep it aligned with the LTC source.\n"); s.push_str("# nudge the clock to keep it aligned with the LTC source.\n");

View file

@ -1 +1,2 @@
pub mod ptp; pub mod ptp;
pub mod audio_input;