From c74046ba58c5fed4c1efc2a4e06ea12325d1d4cd Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Wed, 28 May 2025 17:55:20 +1000 Subject: [PATCH 1/1] Stereo support in soundmodem soundcards --- Cargo.lock | 8 ++++ m17app/src/soundcard.rs | 61 ++++++++++++++++++--------- m17codec2/src/lib.rs | 1 - m17codec2/src/rx.rs | 27 ++++++++++++ m17codec2/src/soundcards.rs | 61 --------------------------- m17codec2/src/tx.rs | 27 ++++++++++++ tools/m17rt-soundcards/Cargo.toml | 3 ++ tools/m17rt-soundcards/src/main.rs | 67 +++++++++++++++++++++++++----- 8 files changed, 163 insertions(+), 92 deletions(-) delete mode 100644 m17codec2/src/soundcards.rs diff --git a/Cargo.lock b/Cargo.lock index 294bbc3..1ed41f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "ascii_table" +version = "4.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb3cd3541590f14025990035bb02b205870f8ca3f5297956dd2c6b32e6470fa1" + [[package]] name = "autocfg" version = "1.3.0" @@ -522,7 +528,9 @@ dependencies = [ name = "m17rt-soundcards" version = "0.1.0" dependencies = [ + "ascii_table", "m17app", + "m17codec2", ] [[package]] diff --git a/m17app/src/soundcard.rs b/m17app/src/soundcard.rs index dc08036..9209de2 100644 --- a/m17app/src/soundcard.rs +++ b/m17app/src/soundcard.rs @@ -1,4 +1,5 @@ use std::{ + borrow::Borrow, sync::{ mpsc::{sync_channel, Receiver, SyncSender}, Arc, RwLock, @@ -9,7 +10,7 @@ use std::{ use cpal::{ traits::{DeviceTrait, HostTrait, StreamTrait}, BuildStreamError, DevicesError, PlayStreamError, SampleFormat, SampleRate, Stream, StreamError, - SupportedStreamConfigsError, + SupportedStreamConfigRange, SupportedStreamConfigsError, }; use thiserror::Error; @@ -17,6 +18,13 @@ use crate::soundmodem::{ InputSource, OutputBuffer, OutputSink, SoundmodemErrorSender, SoundmodemEvent, }; +/// A soundcard for used for transmitting/receiving baseband with a `Soundmodem`. +/// +/// Use `input()` and `output()` to retrieve source/sink handles for the soundmodem. +/// It is fine to use an input from one soundcard and and output from another. +/// +/// If you try to create more than one `Soundcard` instance at a time for the same card +/// then it may not work. pub struct Soundcard { event_tx: SyncSender, } @@ -53,6 +61,9 @@ impl Soundcard { let _ = self.event_tx.send(SoundcardEvent::SetTxInverted(inverted)); } + /// List soundcards supported for soundmodem output. + /// + /// Today, this requires support for a 48kHz sample rate. pub fn supported_output_cards() -> Vec { let mut out = vec![]; let host = cpal::default_host(); @@ -63,9 +74,7 @@ impl Soundcard { let Ok(mut configs) = d.supported_output_configs() else { continue; }; - if configs - .any(|config| config.channels() == 1 && config.sample_format() == SampleFormat::I16) - { + if configs.any(config_is_compatible) { let Ok(name) = d.name() else { continue; }; @@ -76,6 +85,9 @@ impl Soundcard { out } + /// List soundcards supported for soundmodem input. + /// + /// Today, this requires support for a 48kHz sample rate. pub fn supported_input_cards() -> Vec { let mut out = vec![]; let host = cpal::default_host(); @@ -86,9 +98,7 @@ impl Soundcard { let Ok(mut configs) = d.supported_input_configs() else { continue; }; - if configs - .any(|config| config.channels() == 1 && config.sample_format() == SampleFormat::I16) - { + if configs.any(config_is_compatible) { let Ok(name) = d.name() else { continue; }; @@ -100,6 +110,14 @@ impl Soundcard { } } +fn config_is_compatible>(config: C) -> bool { + let config = config.borrow(); + (config.channels() == 1 || config.channels() == 2) + && config.sample_format() == SampleFormat::I16 + && config.min_sample_rate().0 <= 48000 + && config.max_sample_rate().0 >= 48000 +} + enum SoundcardEvent { SetRxInverted(bool), SetTxInverted(bool), @@ -189,9 +207,7 @@ fn spawn_soundcard_worker( continue; } }; - let input_config = match input_configs - .find(|c| c.channels() == 1 && c.sample_format() == SampleFormat::I16) - { + let input_config = match input_configs.find(|c| config_is_compatible(c)) { Some(c) => c, None => { errors.send_error(SoundcardError::NoValidConfigAvailable); @@ -199,14 +215,20 @@ fn spawn_soundcard_worker( } }; let input_config = input_config.with_sample_rate(SampleRate(48000)); + let channels = input_config.channels(); let errors_1 = errors.clone(); let stream = match device.build_input_stream( &input_config.into(), move |data: &[i16], _info: &cpal::InputCallbackInfo| { - let out: Vec = data - .iter() - .map(|s| if rx_inverted { s.saturating_neg() } else { *s }) - .collect(); + let mut out = vec![]; + for d in data.chunks(channels as usize) { + // if we were given multi-channel input we'll pick the first channel + let mut sample = d[0]; + if rx_inverted { + sample = sample.saturating_neg(); + } + out.push(sample); + } let _ = samples.try_send(SoundmodemEvent::BasebandInput(out.into())); }, move |e| { @@ -241,9 +263,7 @@ fn spawn_soundcard_worker( continue; } }; - let output_config = match output_configs - .find(|c| c.channels() == 1 && c.sample_format() == SampleFormat::I16) - { + let output_config = match output_configs.find(|c| config_is_compatible(c)) { Some(c) => c, None => { errors.send_error(SoundcardError::NoValidConfigAvailable); @@ -251,6 +271,7 @@ fn spawn_soundcard_worker( } }; let output_config = output_config.with_sample_rate(SampleRate(48000)); + let channels = output_config.channels(); let errors_1 = errors.clone(); let stream = match device.build_output_stream( &output_config.into(), @@ -263,12 +284,12 @@ fn spawn_soundcard_worker( .unwrap_or(Duration::ZERO); let mut buffer = buffer.write().unwrap(); buffer.latency = latency; - for out in data.iter_mut() { + for out in data.chunks_mut(channels as usize) { if let Some(s) = buffer.samples.pop_front() { - *out = if tx_inverted { s.saturating_neg() } else { s }; + out.fill(if tx_inverted { s.saturating_neg() } else { s }); taken += 1; } else if buffer.idling { - *out = 0; + out.fill(0); } else { let _ = event_tx.send(SoundmodemEvent::OutputUnderrun); break; diff --git a/m17codec2/src/lib.rs b/m17codec2/src/lib.rs index 4023133..b09dd65 100644 --- a/m17codec2/src/lib.rs +++ b/m17codec2/src/lib.rs @@ -2,7 +2,6 @@ pub mod error; pub mod rx; -pub mod soundcards; pub mod tx; pub use error::M17Codec2Error; diff --git a/m17codec2/src/rx.rs b/m17codec2/src/rx.rs index 7c45610..5649519 100644 --- a/m17codec2/src/rx.rs +++ b/m17codec2/src/rx.rs @@ -62,6 +62,33 @@ impl Codec2RxAdapter { pub fn set_output_card>(&mut self, card_name: S) { self.output_card = Some(card_name.into()); } + + /// List sound cards supported for audio output. + /// + /// M17RT will handle any card with 1 or 2 channels and 16-bit output. + pub fn supported_output_cards() -> Vec { + let mut out = vec![]; + let host = cpal::default_host(); + let Ok(output_devices) = host.output_devices() else { + return out; + }; + for d in output_devices { + let Ok(mut configs) = d.supported_output_configs() else { + continue; + }; + if configs.any(|config| { + (config.channels() == 1 || config.channels() == 2) + && config.sample_format() == SampleFormat::I16 + }) { + let Ok(name) = d.name() else { + continue; + }; + out.push(name); + } + } + out.sort(); + out + } } impl Default for Codec2RxAdapter { diff --git a/m17codec2/src/soundcards.rs b/m17codec2/src/soundcards.rs deleted file mode 100644 index 24cff0a..0000000 --- a/m17codec2/src/soundcards.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! Utilities for selecting suitable sound cards. - -use cpal::{ - traits::{DeviceTrait, HostTrait}, - SampleFormat, -}; - -/// List sound cards supported for audio output. -/// -/// M17RT will handle any card with 1 or 2 channels and 16-bit output. -pub fn supported_output_cards() -> Vec { - let mut out = vec![]; - let host = cpal::default_host(); - let Ok(output_devices) = host.output_devices() else { - return out; - }; - for d in output_devices { - let Ok(mut configs) = d.supported_output_configs() else { - continue; - }; - if configs.any(|config| { - (config.channels() == 1 || config.channels() == 2) - && config.sample_format() == SampleFormat::I16 - }) { - let Ok(name) = d.name() else { - continue; - }; - out.push(name); - } - } - out.sort(); - out -} - -/// List sound cards supported for audio input. -/// -/// -/// M17RT will handle any card with 1 or 2 channels and 16-bit output. -pub fn supported_input_cards() -> Vec { - let mut out = vec![]; - let host = cpal::default_host(); - let Ok(input_devices) = host.input_devices() else { - return out; - }; - for d in input_devices { - let Ok(mut configs) = d.supported_input_configs() else { - continue; - }; - if configs.any(|config| { - (config.channels() == 1 || config.channels() == 2) - && config.sample_format() == SampleFormat::I16 - }) { - let Ok(name) = d.name() else { - continue; - }; - out.push(name); - } - } - out.sort(); - out -} diff --git a/m17codec2/src/tx.rs b/m17codec2/src/tx.rs index c0eb596..a54d864 100644 --- a/m17codec2/src/tx.rs +++ b/m17codec2/src/tx.rs @@ -134,6 +134,33 @@ impl Codec2TxAdapter { tx: self.event_tx.clone(), } } + + /// List sound cards supported for audio input. + /// + /// M17RT will handle any card with 1 or 2 channels and 16-bit output. + pub fn supported_input_cards() -> Vec { + let mut out = vec![]; + let host = cpal::default_host(); + let Ok(input_devices) = host.input_devices() else { + return out; + }; + for d in input_devices { + let Ok(mut configs) = d.supported_input_configs() else { + continue; + }; + if configs.any(|config| { + (config.channels() == 1 || config.channels() == 2) + && config.sample_format() == SampleFormat::I16 + }) { + let Ok(name) = d.name() else { + continue; + }; + out.push(name); + } + } + out.sort(); + out + } } enum Event { diff --git a/tools/m17rt-soundcards/Cargo.toml b/tools/m17rt-soundcards/Cargo.toml index b8f7db5..02831ef 100644 --- a/tools/m17rt-soundcards/Cargo.toml +++ b/tools/m17rt-soundcards/Cargo.toml @@ -8,3 +8,6 @@ publish = false [dependencies] m17app = { path = "../../m17app" } +m17codec2 = { path = "../../m17codec2" } + +ascii_table = "4.0.7" diff --git a/tools/m17rt-soundcards/src/main.rs b/tools/m17rt-soundcards/src/main.rs index bc5b13d..1a82869 100644 --- a/tools/m17rt-soundcards/src/main.rs +++ b/tools/m17rt-soundcards/src/main.rs @@ -1,16 +1,63 @@ +use ascii_table::{Align, AsciiTable}; use m17app::soundcard::Soundcard; +use m17codec2::{rx::Codec2RxAdapter, tx::Codec2TxAdapter}; fn main() { - let inputs = Soundcard::supported_input_cards(); - let outputs = Soundcard::supported_output_cards(); + // On some platforms enumerating devices will emit junk to the terminal: + // https://github.com/RustAudio/cpal/issues/384 + // To minimise the impact, enumerate first and put our output at the end. + let soundmodem_in = Soundcard::supported_input_cards(); + let soundmodem_out = Soundcard::supported_output_cards(); + let codec2_in = Codec2TxAdapter::supported_input_cards(); + let codec2_out = Codec2RxAdapter::supported_output_cards(); - println!("\nSupported Input Soundcards ({}):\n", inputs.len()); - for i in inputs { - println!("{}", i); - } + println!("\nDetected sound cards compatible with M17 Rust Toolkit:"); - println!("\nSupported Output Soundcards ({}):\n", outputs.len()); - for o in outputs { - println!("{}", o); - } + generate_table( + "SOUNDMODEM", + "INPUT", + "OUTPUT", + &soundmodem_in, + &soundmodem_out, + ); + generate_table("CODEC2 AUDIO", "TX", "RX", &codec2_in, &codec2_out); +} + +fn generate_table( + heading: &str, + input: &str, + output: &str, + input_cards: &[String], + output_cards: &[String], +) { + let mut merged: Vec<&str> = input_cards + .iter() + .chain(output_cards.iter()) + .map(|s| s.as_str()) + .collect(); + merged.sort(); + merged.dedup(); + let yes = "OK"; + let no = ""; + let data = merged.into_iter().map(|c| { + [ + c, + if input_cards.iter().any(|s| s == c) { + yes + } else { + no + }, + if output_cards.iter().any(|s| s == c) { + yes + } else { + no + }, + ] + }); + + let mut table = AsciiTable::default(); + table.column(0).set_header(heading).set_align(Align::Left); + table.column(1).set_header(input).set_align(Align::Center); + table.column(2).set_header(output).set_align(Align::Center); + table.print(data); } -- 2.39.5