]> code.octet-stream.net Git - m17rt/commitdiff
Stereo support in soundmodem soundcards
authorThomas Karpiniec <tom.karpiniec@outlook.com>
Wed, 28 May 2025 07:55:20 +0000 (17:55 +1000)
committerThomas Karpiniec <tom.karpiniec@outlook.com>
Wed, 28 May 2025 07:55:20 +0000 (17:55 +1000)
Cargo.lock
m17app/src/soundcard.rs
m17codec2/src/lib.rs
m17codec2/src/rx.rs
m17codec2/src/soundcards.rs [deleted file]
m17codec2/src/tx.rs
tools/m17rt-soundcards/Cargo.toml
tools/m17rt-soundcards/src/main.rs

index 294bbc3513d3722374f35382488a3b00754d9aae..1ed41f0dab5d34a4ce5e414ba86729771676c75f 100644 (file)
@@ -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]]
index dc08036effa7dbe99fc462b07a4d9e13283f7f2b..9209de2a09c705642d46158b03476f4ff9a4524e 100644 (file)
@@ -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<SoundcardEvent>,
 }
@@ -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<String> {
         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<String> {
         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<C: Borrow<SupportedStreamConfigRange>>(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<i16> = 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;
index 4023133a7905bf9ea6c8ea96c827fd58cf7d8ab0..b09dd651d6ec19e116ab8accca055094aec1c2e1 100644 (file)
@@ -2,7 +2,6 @@
 
 pub mod error;
 pub mod rx;
-pub mod soundcards;
 pub mod tx;
 
 pub use error::M17Codec2Error;
index 7c456109fc85ce703ecd9a9e30c867f184d30246..5649519c2b50b2acbf5316acee0f90f5e33731df 100644 (file)
@@ -62,6 +62,33 @@ impl Codec2RxAdapter {
     pub fn set_output_card<S: Into<String>>(&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<String> {
+        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 (file)
index 24cff0a..0000000
+++ /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<String> {
-    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<String> {
-    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
-}
index c0eb5968fcdd31217ba3dbac6fec5ee0019ed1fe..a54d864a651ec000750dbab27dbfaa082c5e1c11 100644 (file)
@@ -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<String> {
+        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 {
index b8f7db5d5f1ed4aed027f9f7853719603e1268fe..02831ef5ffcccf16cb064c80d32d7588632e626d 100644 (file)
@@ -8,3 +8,6 @@ publish = false
 
 [dependencies]
 m17app = { path = "../../m17app" }
+m17codec2 = { path = "../../m17codec2" }
+
+ascii_table = "4.0.7"
index bc5b13d13bd7881dc2ed0a9c3eb44509b179ec32..1a82869704d006756eb83b254c7db7cb41dfbfd0 100644 (file)
@@ -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);
 }