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"
|
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
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,
|
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");
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
pub mod ptp;
|
pub mod ptp;
|
||||||
|
pub mod audio_input;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue