mirror of
https://github.com/cjfranko/NTP-Timeturner.git
synced 2025-11-08 18:32:02 +00:00
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:
parent
98963b0b9a
commit
bc0f0ee488
4 changed files with 206 additions and 0 deletions
|
|
@ -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"]
|
||||
|
||||
|
||||
|
|
|
|||
155
src/audio_input.rs
Normal file
155
src/audio_input.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub audio_channel_index: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub audio_sample_rate_hz: Option<u32>,
|
||||
}
|
||||
|
||||
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<dyn std::error
|
|||
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("# 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("# 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");
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
pub mod ptp;
|
||||
pub mod audio_input;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue