From 0ae30668b7142e60de9efcc6e2e2724f9ec77962 Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Mon, 26 May 2025 11:14:02 +1000 Subject: [PATCH 01/16] Split m17codec2 into modules --- m17codec2/src/error.rs | 22 +++ m17codec2/src/lib.rs | 350 +---------------------------------------- m17codec2/src/rx.rs | 252 +++++++++++++++++++++++++++++ m17codec2/src/tx.rs | 76 +++++++++ 4 files changed, 354 insertions(+), 346 deletions(-) create mode 100644 m17codec2/src/error.rs create mode 100644 m17codec2/src/rx.rs create mode 100644 m17codec2/src/tx.rs diff --git a/m17codec2/src/error.rs b/m17codec2/src/error.rs new file mode 100644 index 0000000..ccf94b9 --- /dev/null +++ b/m17codec2/src/error.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum M17Codec2Error { + #[error("selected card '{0}' does not exist or is in use")] + CardUnavailable(String), + + #[error("default output card is unavailable")] + DefaultCardUnavailable, + + #[error("selected card '{0}' failed to list available output configs: '{1}'")] + OutputConfigsUnavailable(String, #[source] cpal::SupportedStreamConfigsError), + + #[error("selected card '{0}' did not offer a compatible output config type, either due to hardware limitations or because it is currently in use")] + SupportedOutputUnavailable(String), + + #[error("selected card '{0}' was unable to build an output stream: '{1}'")] + OutputStreamBuildError(String, #[source] cpal::BuildStreamError), + + #[error("selected card '{0}' was unable to play an output stream: '{1}'")] + OutputStreamPlayError(String, #[source] cpal::PlayStreamError), +} diff --git a/m17codec2/src/lib.rs b/m17codec2/src/lib.rs index 0c32a5b..4023133 100644 --- a/m17codec2/src/lib.rs +++ b/m17codec2/src/lib.rs @@ -1,350 +1,8 @@ #![doc = include_str!("../README.md")] -use codec2::{Codec2, Codec2Mode}; -use cpal::traits::DeviceTrait; -use cpal::traits::HostTrait; -use cpal::traits::StreamTrait; -use cpal::{Sample, SampleFormat, SampleRate}; -use log::debug; -use m17app::adapter::StreamAdapter; -use m17app::app::TxHandle; -use m17app::error::AdapterError; -use m17app::link_setup::LinkSetup; -use m17app::link_setup::M17Address; -use m17app::StreamFrame; -use rubato::Resampler; -use rubato::SincFixedIn; -use rubato::SincInterpolationParameters; -use std::collections::VecDeque; -use std::fs::File; -use std::io::Write; -use std::path::Path; -use std::path::PathBuf; -use std::sync::{ - mpsc::{channel, Receiver, Sender}, - Arc, Mutex, -}; -use std::time::Duration; -use std::time::Instant; -use thiserror::Error; - +pub mod error; +pub mod rx; pub mod soundcards; +pub mod tx; -/// Write one or more 8-byte chunks of 3200-bit Codec2 to a raw S16LE file -/// and return the samples. -pub fn decode_codec2>(data: &[u8], out_path: P) -> Vec { - let codec2 = Codec2::new(Codec2Mode::MODE_3200); - let var_name = codec2; - let mut codec = var_name; - let mut all_samples: Vec = vec![]; - for i in 0..(data.len() / 8) { - let mut samples = vec![0; codec.samples_per_frame()]; - codec.decode(&mut samples, &data[i * 8..((i + 1) * 8)]); - all_samples.append(&mut samples); - } - let mut speech_out = File::create(out_path).unwrap(); - for b in &all_samples { - speech_out.write_all(&b.to_le_bytes()).unwrap(); - } - all_samples -} - -/// Subscribes to M17 streams and attempts to play the decoded Codec2 -pub struct Codec2Adapter { - state: Arc>, - output_card: Option, -} - -impl Codec2Adapter { - pub fn new() -> Self { - Self { - state: Arc::new(Mutex::new(AdapterState { - tx: None, - out_buf: VecDeque::new(), - codec2: Codec2::new(Codec2Mode::MODE_3200), - end_tx: None, - resampler: None, - })), - output_card: None, - } - } - - pub fn set_output_card>(&mut self, card_name: S) { - self.output_card = Some(card_name.into()); - } -} - -impl Default for Codec2Adapter { - fn default() -> Self { - Self::new() - } -} - -struct AdapterState { - tx: Option, - /// Circular buffer of output samples for playback - out_buf: VecDeque, - codec2: Codec2, - end_tx: Option>, - resampler: Option>, -} - -impl StreamAdapter for Codec2Adapter { - fn start(&self, handle: TxHandle) -> Result<(), AdapterError> { - self.state.lock().unwrap().tx = Some(handle); - - let (end_tx, end_rx) = channel(); - let (setup_tx, setup_rx) = channel(); - let state = self.state.clone(); - let output_card = self.output_card.clone(); - std::thread::spawn(move || stream_thread(end_rx, setup_tx, state, output_card)); - self.state.lock().unwrap().end_tx = Some(end_tx); - // Propagate any errors arising in the thread - let sample_rate = setup_rx.recv()??; - debug!("selected codec2 output sample rate {sample_rate}"); - if sample_rate != 8000 { - let params = SincInterpolationParameters { - sinc_len: 256, - f_cutoff: 0.95, - oversampling_factor: 128, - interpolation: rubato::SincInterpolationType::Cubic, - window: rubato::WindowFunction::BlackmanHarris2, - }; - // TODO: fix unwrap - self.state.lock().unwrap().resampler = - Some(SincFixedIn::new(sample_rate as f64 / 8000f64, 1.0, params, 160, 1).unwrap()); - } - Ok(()) - } - - fn close(&self) -> Result<(), AdapterError> { - let mut state = self.state.lock().unwrap(); - state.tx = None; - state.end_tx = None; - Ok(()) - } - - fn stream_began(&self, _link_setup: LinkSetup) { - // for now we will assume: - // - unencrypted - // - data type is Voice (Codec2 3200), not Voice+Data - // TODO: is encryption handled here or in M17App, such that we get a decrypted stream? - // TODO: handle the Voice+Data combination with Codec2 1600 - self.state.lock().unwrap().codec2 = Codec2::new(Codec2Mode::MODE_3200); - } - - fn stream_data(&self, _frame_number: u16, _is_final: bool, data: Arc<[u8; 16]>) { - let mut state = self.state.lock().unwrap(); - for encoded in data.chunks(8) { - if state.out_buf.len() < 8192 { - let mut samples = [i16::EQUILIBRIUM; 160]; // while assuming 3200 - state.codec2.decode(&mut samples, encoded); - if let Some(resampler) = state.resampler.as_mut() { - let samples_f: Vec = - samples.iter().map(|s| *s as f32 / 16384.0f32).collect(); - let res = resampler.process(&vec![samples_f], None).unwrap(); - for s in &res[0] { - state.out_buf.push_back((s * 16383.0f32) as i16); - } - } else { - // TODO: maybe get rid of VecDeque so we can decode directly into ring buffer? - for s in samples { - state.out_buf.push_back(s); - } - } - } else { - debug!("out_buf overflow"); - } - } - } -} - -fn output_cb(data: &mut [i16], state: &Mutex, channels: u16) { - let mut state = state.lock().unwrap(); - for d in data.chunks_mut(channels as usize) { - d.fill(state.out_buf.pop_front().unwrap_or(i16::EQUILIBRIUM)); - } -} - -/// Create and manage the stream from a dedicated thread since it's `!Send` -fn stream_thread( - end: Receiver<()>, - setup_tx: Sender>, - state: Arc>, - output_card: Option, -) { - let host = cpal::default_host(); - let device = if let Some(output_card) = output_card { - // TODO: more error handling for unwraps - match host - .output_devices() - .unwrap() - .find(|d| d.name().unwrap() == output_card) - { - Some(d) => d, - None => { - let _ = setup_tx.send(Err(M17Codec2Error::CardUnavailable(output_card).into())); - return; - } - } - } else { - match host.default_output_device() { - Some(d) => d, - None => { - let _ = setup_tx.send(Err(M17Codec2Error::DefaultCardUnavailable.into())); - return; - } - } - }; - let card_name = device.name().unwrap(); - let mut configs = match device.supported_output_configs() { - Ok(c) => c, - Err(e) => { - let _ = setup_tx.send(Err( - M17Codec2Error::OutputConfigsUnavailable(card_name, e).into() - )); - return; - } - }; - let config = match configs.find(|c| { - (c.channels() == 1 || c.channels() == 2) && c.sample_format() == SampleFormat::I16 - }) { - Some(c) => c, - None => { - let _ = setup_tx.send(Err( - M17Codec2Error::SupportedOutputUnavailable(card_name).into() - )); - return; - } - }; - - let target_sample_rate = - if config.min_sample_rate().0 <= 8000 && config.max_sample_rate().0 >= 8000 { - 8000 - } else { - config.min_sample_rate().0 - }; - let channels = config.channels(); - - let config = config.with_sample_rate(SampleRate(target_sample_rate)); - let stream = match device.build_output_stream( - &config.into(), - move |data: &mut [i16], _info: &cpal::OutputCallbackInfo| { - output_cb(data, &state, channels); - }, - |e| { - // trigger end_tx here? always more edge cases - debug!("error occurred in codec2 playback: {e:?}"); - }, - None, - ) { - Ok(s) => s, - Err(e) => { - let _ = setup_tx.send(Err( - M17Codec2Error::OutputStreamBuildError(card_name, e).into() - )); - return; - } - }; - match stream.play() { - Ok(()) => (), - Err(e) => { - let _ = setup_tx.send(Err( - M17Codec2Error::OutputStreamPlayError(card_name, e).into() - )); - return; - } - } - let _ = setup_tx.send(Ok(target_sample_rate)); - let _ = end.recv(); - // it seems concrete impls of Stream have a Drop implementation that will handle termination -} - -/// Transmits a wave file as an M17 stream -pub struct WavePlayer; - -impl WavePlayer { - /// Plays a wave file (blocking). - /// - /// * `path`: wave file to transmit, must be 8 kHz mono and 16-bit LE - /// * `tx`: a `TxHandle` obtained from an `M17App` - /// * `source`: address of transmission source - /// * `destination`: address of transmission destination - /// * `channel_access_number`: from 0 to 15, usually 0 - pub fn play( - path: PathBuf, - tx: TxHandle, - source: &M17Address, - destination: &M17Address, - channel_access_number: u8, - ) { - let mut reader = hound::WavReader::open(path).unwrap(); - let mut samples = reader.samples::(); - - let mut codec = Codec2::new(Codec2Mode::MODE_3200); - let mut in_buf = [0i16; 160]; - let mut out_buf = [0u8; 16]; - let mut lsf_chunk: usize = 0; - const TICK: Duration = Duration::from_millis(40); - let mut next_tick = Instant::now() + TICK; - let mut frame_number = 0; - - let mut setup = LinkSetup::new_voice(source, destination); - setup.set_channel_access_number(channel_access_number); - tx.transmit_stream_start(&setup); - - loop { - let mut last_one = false; - for out in out_buf.chunks_mut(8) { - for i in in_buf.iter_mut() { - let sample = match samples.next() { - Some(Ok(sample)) => sample, - _ => { - last_one = true; - 0 - } - }; - *i = sample; - } - codec.encode(out, &in_buf); - } - tx.transmit_stream_next(&StreamFrame { - lich_idx: lsf_chunk as u8, - lich_part: setup.lich_part(lsf_chunk as u8), - frame_number, - end_of_stream: last_one, - stream_data: out_buf, - }); - frame_number += 1; - lsf_chunk = (lsf_chunk + 1) % 6; - - if last_one { - break; - } - - std::thread::sleep(next_tick.duration_since(Instant::now())); - next_tick += TICK; - } - } -} - -#[derive(Debug, Error)] -pub enum M17Codec2Error { - #[error("selected card '{0}' does not exist or is in use")] - CardUnavailable(String), - - #[error("default output card is unavailable")] - DefaultCardUnavailable, - - #[error("selected card '{0}' failed to list available output configs: '{1}'")] - OutputConfigsUnavailable(String, #[source] cpal::SupportedStreamConfigsError), - - #[error("selected card '{0}' did not offer a compatible output config type, either due to hardware limitations or because it is currently in use")] - SupportedOutputUnavailable(String), - - #[error("selected card '{0}' was unable to build an output stream: '{1}'")] - OutputStreamBuildError(String, #[source] cpal::BuildStreamError), - - #[error("selected card '{0}' was unable to play an output stream: '{1}'")] - OutputStreamPlayError(String, #[source] cpal::PlayStreamError), -} +pub use error::M17Codec2Error; diff --git a/m17codec2/src/rx.rs b/m17codec2/src/rx.rs new file mode 100644 index 0000000..c6d3cdc --- /dev/null +++ b/m17codec2/src/rx.rs @@ -0,0 +1,252 @@ +use crate::M17Codec2Error; +use codec2::{Codec2, Codec2Mode}; +use cpal::traits::DeviceTrait; +use cpal::traits::HostTrait; +use cpal::traits::StreamTrait; +use cpal::{Sample, SampleFormat, SampleRate}; +use log::debug; +use m17app::adapter::StreamAdapter; +use m17app::app::TxHandle; +use m17app::error::AdapterError; +use m17app::link_setup::LinkSetup; +use rubato::Resampler; +use rubato::SincFixedIn; +use rubato::SincInterpolationParameters; +use std::collections::VecDeque; +use std::fs::File; +use std::io::Write; +use std::path::Path; +use std::sync::{ + mpsc::{channel, Receiver, Sender}, + Arc, Mutex, +}; + +/// Write one or more 8-byte chunks of 3200-bit Codec2 to a raw S16LE file +/// and return the samples. +pub fn decode_codec2>(data: &[u8], out_path: P) -> Vec { + let codec2 = Codec2::new(Codec2Mode::MODE_3200); + let var_name = codec2; + let mut codec = var_name; + let mut all_samples: Vec = vec![]; + for i in 0..(data.len() / 8) { + let mut samples = vec![0; codec.samples_per_frame()]; + codec.decode(&mut samples, &data[i * 8..((i + 1) * 8)]); + all_samples.append(&mut samples); + } + let mut speech_out = File::create(out_path).unwrap(); + for b in &all_samples { + speech_out.write_all(&b.to_le_bytes()).unwrap(); + } + all_samples +} + +/// Subscribes to M17 streams and attempts to play the decoded Codec2 +pub struct Codec2RxAdapter { + state: Arc>, + output_card: Option, +} + +impl Codec2RxAdapter { + pub fn new() -> Self { + Self { + state: Arc::new(Mutex::new(AdapterState { + tx: None, + out_buf: VecDeque::new(), + codec2: Codec2::new(Codec2Mode::MODE_3200), + end_tx: None, + resampler: None, + })), + output_card: None, + } + } + + pub fn set_output_card>(&mut self, card_name: S) { + self.output_card = Some(card_name.into()); + } +} + +impl Default for Codec2RxAdapter { + fn default() -> Self { + Self::new() + } +} + +struct AdapterState { + tx: Option, + /// Circular buffer of output samples for playback + out_buf: VecDeque, + codec2: Codec2, + end_tx: Option>, + resampler: Option>, +} + +impl StreamAdapter for Codec2RxAdapter { + fn start(&self, handle: TxHandle) -> Result<(), AdapterError> { + self.state.lock().unwrap().tx = Some(handle); + + let (end_tx, end_rx) = channel(); + let (setup_tx, setup_rx) = channel(); + let state = self.state.clone(); + let output_card = self.output_card.clone(); + std::thread::spawn(move || stream_thread(end_rx, setup_tx, state, output_card)); + self.state.lock().unwrap().end_tx = Some(end_tx); + // Propagate any errors arising in the thread + let sample_rate = setup_rx.recv()??; + debug!("selected codec2 output sample rate {sample_rate}"); + if sample_rate != 8000 { + let params = SincInterpolationParameters { + sinc_len: 256, + f_cutoff: 0.95, + oversampling_factor: 128, + interpolation: rubato::SincInterpolationType::Cubic, + window: rubato::WindowFunction::BlackmanHarris2, + }; + // TODO: fix unwrap + self.state.lock().unwrap().resampler = + Some(SincFixedIn::new(sample_rate as f64 / 8000f64, 1.0, params, 160, 1).unwrap()); + } + Ok(()) + } + + fn close(&self) -> Result<(), AdapterError> { + let mut state = self.state.lock().unwrap(); + state.tx = None; + state.end_tx = None; + Ok(()) + } + + fn stream_began(&self, _link_setup: LinkSetup) { + // for now we will assume: + // - unencrypted + // - data type is Voice (Codec2 3200), not Voice+Data + // TODO: is encryption handled here or in M17App, such that we get a decrypted stream? + // TODO: handle the Voice+Data combination with Codec2 1600 + self.state.lock().unwrap().codec2 = Codec2::new(Codec2Mode::MODE_3200); + } + + fn stream_data(&self, _frame_number: u16, _is_final: bool, data: Arc<[u8; 16]>) { + let mut state = self.state.lock().unwrap(); + for encoded in data.chunks(8) { + if state.out_buf.len() < 8192 { + let mut samples = [i16::EQUILIBRIUM; 160]; // while assuming 3200 + state.codec2.decode(&mut samples, encoded); + if let Some(resampler) = state.resampler.as_mut() { + let samples_f: Vec = + samples.iter().map(|s| *s as f32 / 16384.0f32).collect(); + let res = resampler.process(&[samples_f], None).unwrap(); + for s in &res[0] { + state.out_buf.push_back((s * 16383.0f32) as i16); + } + } else { + // TODO: maybe get rid of VecDeque so we can decode directly into ring buffer? + for s in samples { + state.out_buf.push_back(s); + } + } + } else { + debug!("out_buf overflow"); + } + } + } +} + +fn output_cb(data: &mut [i16], state: &Mutex, channels: u16) { + let mut state = state.lock().unwrap(); + for d in data.chunks_mut(channels as usize) { + d.fill(state.out_buf.pop_front().unwrap_or(i16::EQUILIBRIUM)); + } +} + +/// Create and manage the stream from a dedicated thread since it's `!Send` +fn stream_thread( + end: Receiver<()>, + setup_tx: Sender>, + state: Arc>, + output_card: Option, +) { + let host = cpal::default_host(); + let device = if let Some(output_card) = output_card { + // TODO: more error handling for unwraps + match host + .output_devices() + .unwrap() + .find(|d| d.name().unwrap() == output_card) + { + Some(d) => d, + None => { + let _ = setup_tx.send(Err(M17Codec2Error::CardUnavailable(output_card).into())); + return; + } + } + } else { + match host.default_output_device() { + Some(d) => d, + None => { + let _ = setup_tx.send(Err(M17Codec2Error::DefaultCardUnavailable.into())); + return; + } + } + }; + let card_name = device.name().unwrap(); + let mut configs = match device.supported_output_configs() { + Ok(c) => c, + Err(e) => { + let _ = setup_tx.send(Err( + M17Codec2Error::OutputConfigsUnavailable(card_name, e).into() + )); + return; + } + }; + let config = match configs.find(|c| { + (c.channels() == 1 || c.channels() == 2) && c.sample_format() == SampleFormat::I16 + }) { + Some(c) => c, + None => { + let _ = setup_tx.send(Err( + M17Codec2Error::SupportedOutputUnavailable(card_name).into() + )); + return; + } + }; + + let target_sample_rate = + if config.min_sample_rate().0 <= 8000 && config.max_sample_rate().0 >= 8000 { + 8000 + } else { + config.min_sample_rate().0 + }; + let channels = config.channels(); + + let config = config.with_sample_rate(SampleRate(target_sample_rate)); + let stream = match device.build_output_stream( + &config.into(), + move |data: &mut [i16], _info: &cpal::OutputCallbackInfo| { + output_cb(data, &state, channels); + }, + |e| { + // trigger end_tx here? always more edge cases + debug!("error occurred in codec2 playback: {e:?}"); + }, + None, + ) { + Ok(s) => s, + Err(e) => { + let _ = setup_tx.send(Err( + M17Codec2Error::OutputStreamBuildError(card_name, e).into() + )); + return; + } + }; + match stream.play() { + Ok(()) => (), + Err(e) => { + let _ = setup_tx.send(Err( + M17Codec2Error::OutputStreamPlayError(card_name, e).into() + )); + return; + } + } + let _ = setup_tx.send(Ok(target_sample_rate)); + let _ = end.recv(); + // it seems concrete impls of Stream have a Drop implementation that will handle termination +} diff --git a/m17codec2/src/tx.rs b/m17codec2/src/tx.rs new file mode 100644 index 0000000..b824268 --- /dev/null +++ b/m17codec2/src/tx.rs @@ -0,0 +1,76 @@ +use codec2::{Codec2, Codec2Mode}; +use m17app::app::TxHandle; +use m17app::link_setup::LinkSetup; +use m17app::link_setup::M17Address; +use m17app::StreamFrame; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; + +/// Transmits a wave file as an M17 stream +pub struct WavePlayer; + +impl WavePlayer { + /// Plays a wave file (blocking). + /// + /// * `path`: wave file to transmit, must be 8 kHz mono and 16-bit LE + /// * `tx`: a `TxHandle` obtained from an `M17App` + /// * `source`: address of transmission source + /// * `destination`: address of transmission destination + /// * `channel_access_number`: from 0 to 15, usually 0 + pub fn play( + path: PathBuf, + tx: TxHandle, + source: &M17Address, + destination: &M17Address, + channel_access_number: u8, + ) { + let mut reader = hound::WavReader::open(path).unwrap(); + let mut samples = reader.samples::(); + + let mut codec = Codec2::new(Codec2Mode::MODE_3200); + let mut in_buf = [0i16; 160]; + let mut out_buf = [0u8; 16]; + let mut lsf_chunk: usize = 0; + const TICK: Duration = Duration::from_millis(40); + let mut next_tick = Instant::now() + TICK; + let mut frame_number = 0; + + let mut setup = LinkSetup::new_voice(source, destination); + setup.set_channel_access_number(channel_access_number); + tx.transmit_stream_start(&setup); + + loop { + let mut last_one = false; + for out in out_buf.chunks_mut(8) { + for i in in_buf.iter_mut() { + let sample = match samples.next() { + Some(Ok(sample)) => sample, + _ => { + last_one = true; + 0 + } + }; + *i = sample; + } + codec.encode(out, &in_buf); + } + tx.transmit_stream_next(&StreamFrame { + lich_idx: lsf_chunk as u8, + lich_part: setup.lich_part(lsf_chunk as u8), + frame_number, + end_of_stream: last_one, + stream_data: out_buf, + }); + frame_number += 1; + lsf_chunk = (lsf_chunk + 1) % 6; + + if last_one { + break; + } + + std::thread::sleep(next_tick.duration_since(Instant::now())); + next_tick += TICK; + } + } +} -- 2.39.5 From f5cbae9d09cc58d8b549c36111c7a57a16b9a822 Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Tue, 27 May 2025 19:15:31 +1000 Subject: [PATCH 02/16] Add codec2 tx adapter --- m17codec2/src/error.rs | 15 ++ m17codec2/src/rx.rs | 9 +- m17codec2/src/tx.rs | 400 ++++++++++++++++++++++++++++++++++ tools/m17rt-demod/src/main.rs | 4 +- tools/m17rt-mod/src/main.rs | 2 +- 5 files changed, 420 insertions(+), 10 deletions(-) diff --git a/m17codec2/src/error.rs b/m17codec2/src/error.rs index ccf94b9..7fa6583 100644 --- a/m17codec2/src/error.rs +++ b/m17codec2/src/error.rs @@ -2,6 +2,9 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum M17Codec2Error { + #[error("tried to start adapter more than once")] + RepeatStart, + #[error("selected card '{0}' does not exist or is in use")] CardUnavailable(String), @@ -19,4 +22,16 @@ pub enum M17Codec2Error { #[error("selected card '{0}' was unable to play an output stream: '{1}'")] OutputStreamPlayError(String, #[source] cpal::PlayStreamError), + + #[error("selected card '{0}' failed to list available input configs: '{1}'")] + InputConfigsUnavailable(String, #[source] cpal::SupportedStreamConfigsError), + + #[error("selected card '{0}' did not offer a compatible input config type, either due to hardware limitations or because it is currently in use")] + SupportedInputUnavailable(String), + + #[error("selected card '{0}' was unable to build an input stream: '{1}'")] + InputStreamBuildError(String, #[source] cpal::BuildStreamError), + + #[error("selected card '{0}' was unable to play an input stream: '{1}'")] + InputStreamPlayError(String, #[source] cpal::PlayStreamError), } diff --git a/m17codec2/src/rx.rs b/m17codec2/src/rx.rs index c6d3cdc..7c45610 100644 --- a/m17codec2/src/rx.rs +++ b/m17codec2/src/rx.rs @@ -50,7 +50,6 @@ impl Codec2RxAdapter { pub fn new() -> Self { Self { state: Arc::new(Mutex::new(AdapterState { - tx: None, out_buf: VecDeque::new(), codec2: Codec2::new(Codec2Mode::MODE_3200), end_tx: None, @@ -72,7 +71,6 @@ impl Default for Codec2RxAdapter { } struct AdapterState { - tx: Option, /// Circular buffer of output samples for playback out_buf: VecDeque, codec2: Codec2, @@ -81,9 +79,7 @@ struct AdapterState { } impl StreamAdapter for Codec2RxAdapter { - fn start(&self, handle: TxHandle) -> Result<(), AdapterError> { - self.state.lock().unwrap().tx = Some(handle); - + fn start(&self, _handle: TxHandle) -> Result<(), AdapterError> { let (end_tx, end_rx) = channel(); let (setup_tx, setup_rx) = channel(); let state = self.state.clone(); @@ -92,7 +88,7 @@ impl StreamAdapter for Codec2RxAdapter { self.state.lock().unwrap().end_tx = Some(end_tx); // Propagate any errors arising in the thread let sample_rate = setup_rx.recv()??; - debug!("selected codec2 output sample rate {sample_rate}"); + debug!("selected codec2 speaker sample rate {sample_rate}"); if sample_rate != 8000 { let params = SincInterpolationParameters { sinc_len: 256, @@ -110,7 +106,6 @@ impl StreamAdapter for Codec2RxAdapter { fn close(&self) -> Result<(), AdapterError> { let mut state = self.state.lock().unwrap(); - state.tx = None; state.end_tx = None; Ok(()) } diff --git a/m17codec2/src/tx.rs b/m17codec2/src/tx.rs index b824268..c0eb596 100644 --- a/m17codec2/src/tx.rs +++ b/m17codec2/src/tx.rs @@ -1,12 +1,29 @@ use codec2::{Codec2, Codec2Mode}; +use cpal::traits::DeviceTrait; +use cpal::traits::HostTrait; +use cpal::traits::StreamTrait; +use cpal::SampleFormat; +use cpal::SampleRate; +use log::debug; +use m17app::adapter::StreamAdapter; use m17app::app::TxHandle; +use m17app::error::AdapterError; use m17app::link_setup::LinkSetup; use m17app::link_setup::M17Address; use m17app::StreamFrame; +use rubato::Resampler; +use rubato::SincFixedOut; +use rubato::SincInterpolationParameters; use std::path::PathBuf; +use std::sync::mpsc; +use std::sync::mpsc::channel; +use std::sync::Arc; +use std::sync::Mutex; use std::time::Duration; use std::time::Instant; +use crate::M17Codec2Error; + /// Transmits a wave file as an M17 stream pub struct WavePlayer; @@ -74,3 +91,386 @@ impl WavePlayer { } } } + +/// Control transmissions into a Codec2TxAdapter +#[derive(Clone)] +pub struct Ptt { + tx: mpsc::Sender, +} + +impl Ptt { + pub fn set_ptt(&self, ptt: bool) { + let _ = self.tx.send(if ptt { Event::PttOn } else { Event::PttOff }); + } +} + +/// Use a microphone and local PTT to transmit Codec2 voice data into an M17 channel. +pub struct Codec2TxAdapter { + input_card: Option, + event_tx: mpsc::Sender, + event_rx: Mutex>>, + source: M17Address, + destination: M17Address, +} + +impl Codec2TxAdapter { + pub fn new(source: M17Address, destination: M17Address) -> Self { + let (event_tx, event_rx) = mpsc::channel(); + Self { + input_card: None, + event_tx, + event_rx: Mutex::new(Some(event_rx)), + source, + destination, + } + } + + pub fn set_input_card>(&mut self, card_name: S) { + self.input_card = Some(card_name.into()); + } + + pub fn ptt(&self) -> Ptt { + Ptt { + tx: self.event_tx.clone(), + } + } +} + +enum Event { + PttOn, + PttOff, + MicSamples(Arc<[i16]>), + Close, +} + +impl StreamAdapter for Codec2TxAdapter { + fn start(&self, handle: TxHandle) -> Result<(), AdapterError> { + let Some(event_rx) = self.event_rx.lock().unwrap().take() else { + return Err(M17Codec2Error::RepeatStart.into()); + }; + let event_tx = self.event_tx.clone(); + let (setup_tx, setup_rx) = channel(); + let input_card = self.input_card.clone(); + let from = self.source.clone(); + let to = self.destination.clone(); + std::thread::spawn(move || { + stream_thread(event_tx, event_rx, setup_tx, input_card, handle, from, to) + }); + let sample_rate = setup_rx.recv()??; + debug!("selected codec2 microphone sample rate {sample_rate}"); + + Ok(()) + } + + fn close(&self) -> Result<(), AdapterError> { + let _ = self.event_tx.send(Event::Close); + Ok(()) + } + + fn stream_began(&self, _link_setup: LinkSetup) { + // not interested in incoming transmissions + } + + fn stream_data(&self, _frame_number: u16, _is_final: bool, _data: Arc<[u8; 16]>) { + // not interested in incoming transmissions + + // the only reason this is an adapter at all is for future "transmission aborted" feedback + // when that's implemented by m17app + } +} + +fn stream_thread( + event_tx: mpsc::Sender, + event_rx: mpsc::Receiver, + setup_tx: mpsc::Sender>, + input_card: Option, + handle: TxHandle, + source: M17Address, + destination: M17Address, +) { + let host = cpal::default_host(); + let device = if let Some(input_card) = input_card { + // TODO: more error handling for unwraps + match host + .input_devices() + .unwrap() + .find(|d| d.name().unwrap() == input_card) + { + Some(d) => d, + None => { + let _ = setup_tx.send(Err(M17Codec2Error::CardUnavailable(input_card).into())); + return; + } + } + } else { + match host.default_input_device() { + Some(d) => d, + None => { + let _ = setup_tx.send(Err(M17Codec2Error::DefaultCardUnavailable.into())); + return; + } + } + }; + let card_name = device.name().unwrap(); + let mut configs = match device.supported_input_configs() { + Ok(c) => c, + Err(e) => { + let _ = setup_tx.send(Err( + M17Codec2Error::InputConfigsUnavailable(card_name, e).into() + )); + return; + } + }; + // TODO: rank these by most favourable, same for rx + let config = match configs.find(|c| { + (c.channels() == 1 || c.channels() == 2) && c.sample_format() == SampleFormat::I16 + }) { + Some(c) => c, + None => { + let _ = setup_tx.send(Err( + M17Codec2Error::SupportedInputUnavailable(card_name).into() + )); + return; + } + }; + + let target_sample_rate = + if config.min_sample_rate().0 <= 8000 && config.max_sample_rate().0 >= 8000 { + 8000 + } else { + config.min_sample_rate().0 + }; + let channels = config.channels(); + + let mut acc: Box = if target_sample_rate != 8000 { + Box::new(ResamplingAccumulator::new(target_sample_rate as f64)) + } else { + Box::new(DirectAccumulator::new()) + }; + + let config = config.with_sample_rate(SampleRate(target_sample_rate)); + let stream = match device.build_input_stream( + &config.into(), + move |data: &[i16], _info: &cpal::InputCallbackInfo| { + let mut samples = vec![]; + for d in data.chunks(channels as usize) { + // if we were given multi-channel input we'll pick the first channel + // TODO: configurable? + samples.push(d[0]); + } + let _ = event_tx.send(Event::MicSamples(samples.into())); + }, + |e| { + // abort here? + debug!("error occurred in codec2 recording: {e:?}"); + }, + None, + ) { + Ok(s) => s, + Err(e) => { + let _ = setup_tx.send(Err( + M17Codec2Error::InputStreamBuildError(card_name, e).into() + )); + return; + } + }; + + let _ = setup_tx.send(Ok(target_sample_rate)); + let mut state = State::Idle; + let mut codec2 = Codec2::new(Codec2Mode::MODE_3200); + let link_setup = LinkSetup::new_voice(&source, &destination); + let mut lich_idx = 0; + let mut frame_number = 0; + + // Now the main loop + while let Ok(ev) = event_rx.recv() { + match ev { + Event::PttOn => { + match state { + State::Idle => { + match stream.play() { + Ok(()) => (), + Err(_e) => { + // TODO: report M17Codec2Error::InputStreamPlayError(card_name, e).into() + break; + } + } + acc.reset(); + codec2 = Codec2::new(Codec2Mode::MODE_3200); + state = State::StartTransmitting; + } + State::StartTransmitting => {} + State::Transmitting => {} + State::Ending => state = State::EndingWithPttRestart, + State::EndingWithPttRestart => {} + } + } + Event::PttOff => match state { + State::Idle => {} + State::StartTransmitting => state = State::Idle, + State::Transmitting => state = State::Ending, + State::Ending => {} + State::EndingWithPttRestart => state = State::Ending, + }, + Event::MicSamples(samples) => { + match state { + State::Idle => {} + State::StartTransmitting + | State::Transmitting + | State::Ending + | State::EndingWithPttRestart => { + acc.handle_samples(&samples); + while let Some(frame) = acc.try_next_frame() { + let mut stream_data = [0u8; 16]; + codec2.encode(&mut stream_data[0..8], &frame[0..160]); + codec2.encode(&mut stream_data[8..16], &frame[160..320]); + + if state == State::StartTransmitting { + handle.transmit_stream_start(&link_setup); + lich_idx = 0; + frame_number = 0; + state = State::Transmitting; + } + + let end_of_stream = state != State::Transmitting; + handle.transmit_stream_next(&StreamFrame { + lich_idx, + lich_part: link_setup.lich_part(lich_idx), + frame_number, + end_of_stream, + stream_data, + }); + frame_number += 1; + lich_idx = (lich_idx + 1) % 6; + + if end_of_stream { + break; + } + } + + if state == State::Ending { + // when finished sending final stream frame + let _ = stream.pause(); + state = State::Idle; + } + + if state == State::EndingWithPttRestart { + acc.reset(); + codec2 = Codec2::new(Codec2Mode::MODE_3200); + state = State::StartTransmitting; + } + } + } + } + Event::Close => { + // assume PTT etc. will clean up itself responsibly on close + break; + } + } + } +} + +#[derive(Debug, PartialEq, Eq)] +enum State { + /// Waiting for PTT + Idle, + /// PTT engaged but we are collecting the first full frame of audio data before starting the stream + StartTransmitting, + /// Streaming voice frames + Transmitting, + /// PTT disengaged; we are collecting the next frame of audio to use as a final frame + Ending, + /// PTT was re-enaged while ending; we will send final frame then go back to StartTransmitting + EndingWithPttRestart, +} + +fn resampler_params() -> SincInterpolationParameters { + SincInterpolationParameters { + sinc_len: 256, + f_cutoff: 0.95, + oversampling_factor: 128, + interpolation: rubato::SincInterpolationType::Cubic, + window: rubato::WindowFunction::BlackmanHarris2, + } +} + +trait Accumulator { + fn handle_samples(&mut self, samples: &[i16]); + /// Return 320 samples, enough for two Codec2 frames + fn try_next_frame(&mut self) -> Option>; + fn reset(&mut self); +} + +struct DirectAccumulator { + buffer: Vec, +} + +impl DirectAccumulator { + fn new() -> Self { + Self { buffer: Vec::new() } + } +} + +impl Accumulator for DirectAccumulator { + fn handle_samples(&mut self, samples: &[i16]) { + self.buffer.extend_from_slice(samples); + } + + fn try_next_frame(&mut self) -> Option> { + if self.buffer.len() >= 320 { + let part = self.buffer.split_off(320); + Some(std::mem::replace(&mut self.buffer, part)) + } else { + None + } + } + + fn reset(&mut self) { + self.buffer.clear(); + } +} + +struct ResamplingAccumulator { + input_rate: f64, + buffer: Vec, + resampler: SincFixedOut, +} + +impl ResamplingAccumulator { + fn new(input_rate: f64) -> Self { + Self { + input_rate, + buffer: Vec::new(), + resampler: make_resampler(input_rate), + } + } +} + +impl Accumulator for ResamplingAccumulator { + fn handle_samples(&mut self, samples: &[i16]) { + self.buffer.extend_from_slice(samples); + } + + fn try_next_frame(&mut self) -> Option> { + let required = self.resampler.input_frames_next(); + if self.buffer.len() >= required { + let mut part = self.buffer.split_off(required); + std::mem::swap(&mut self.buffer, &mut part); + let samples_f: Vec = part.iter().map(|s| *s as f32 / 16384.0f32).collect(); + let out = self.resampler.process(&[samples_f], None).unwrap(); + Some(out[0].iter().map(|s| (*s * 16383.0f32) as i16).collect()) + } else { + None + } + } + + fn reset(&mut self) { + self.buffer.clear(); + self.resampler = make_resampler(self.input_rate); + } +} + +fn make_resampler(input_rate: f64) -> SincFixedOut { + // want 320 samples at a time to create 2x Codec2 frames per M17 Voice frame + SincFixedOut::new(8000f64 / input_rate, 1.0, resampler_params(), 320, 1).unwrap() +} diff --git a/tools/m17rt-demod/src/main.rs b/tools/m17rt-demod/src/main.rs index 0da1501..3f7c479 100644 --- a/tools/m17rt-demod/src/main.rs +++ b/tools/m17rt-demod/src/main.rs @@ -1,7 +1,7 @@ use m17app::app::M17App; use m17app::soundcard::Soundcard; use m17app::soundmodem::{NullErrorHandler, NullOutputSink, NullPtt, Soundmodem}; -use m17codec2::Codec2Adapter; +use m17codec2::rx::Codec2RxAdapter; pub fn demod_test() { let soundcard = Soundcard::new("plughw:CARD=Device,DEV=0").unwrap(); @@ -12,7 +12,7 @@ pub fn demod_test() { NullErrorHandler::new(), ); let app = M17App::new(soundmodem); - app.add_stream_adapter(Codec2Adapter::new()).unwrap(); + app.add_stream_adapter(Codec2RxAdapter::new()).unwrap(); app.start().unwrap(); loop { diff --git a/tools/m17rt-mod/src/main.rs b/tools/m17rt-mod/src/main.rs index 104d83e..328a7da 100644 --- a/tools/m17rt-mod/src/main.rs +++ b/tools/m17rt-mod/src/main.rs @@ -3,7 +3,7 @@ use m17app::link_setup::M17Address; use m17app::serial::{PttPin, SerialPtt}; use m17app::soundcard::Soundcard; use m17app::soundmodem::{NullErrorHandler, Soundmodem}; -use m17codec2::WavePlayer; +use m17codec2::tx::WavePlayer; use std::path::PathBuf; pub fn mod_test() { -- 2.39.5 From c74046ba58c5fed4c1efc2a4e06ea12325d1d4cd Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Wed, 28 May 2025 17:55:20 +1000 Subject: [PATCH 03/16] 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 From 659d8bcef911fdcb63bf76c9f4fab7dc74cbcf08 Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Mon, 2 Jun 2025 15:34:57 +1000 Subject: [PATCH 04/16] Add ReflectorClientTnc - rx path support --- m17app/src/lib.rs | 1 + m17app/src/reflector.rs | 282 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 m17app/src/reflector.rs diff --git a/m17app/src/lib.rs b/m17app/src/lib.rs index 8a98e79..8b923ea 100644 --- a/m17app/src/lib.rs +++ b/m17app/src/lib.rs @@ -4,6 +4,7 @@ pub mod adapter; pub mod app; pub mod error; pub mod link_setup; +pub mod reflector; pub mod rtlsdr; pub mod serial; pub mod soundcard; diff --git a/m17app/src/reflector.rs b/m17app/src/reflector.rs new file mode 100644 index 0000000..9735298 --- /dev/null +++ b/m17app/src/reflector.rs @@ -0,0 +1,282 @@ +use std::{ + io::{self, Read, Write}, + net::{Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs, UdpSocket}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::{self, Receiver, Sender}, + Arc, Mutex, + }, + thread, + time::Duration, +}; + +use crate::{link_setup::M17Address, tnc::Tnc, util::out_buffer::OutBuffer}; +use m17core::{ + kiss::KissFrame, + reflector::{ + convert::VoiceToRf, + packet::{Connect, Pong, ServerMessage}, + }, +}; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ReflectorClientConfig { + hostname: String, + port: u16, + module: char, + local_callsign: M17Address, +} + +type WrappedStatusHandler = Arc>; + +/// Network-based TNC that attempts to maintain a UDP connection to a reflector. +/// +/// Streams will be sent and received over IP rather than RF. +#[derive(Clone)] +pub struct ReflectorClientTnc { + config: ReflectorClientConfig, + status_handler: WrappedStatusHandler, + kiss_out_tx: Sender>, + kiss_out: OutBuffer, + event_tx: Arc>>>, + is_closed: Arc, +} + +impl ReflectorClientTnc { + /// Create a new Reflector Client TNC. + /// + /// You must provide a configuration object and a handler for status events, such as when the TNC + /// connects and disconnects. The status events are purely information and if you're not interested + /// in them, provide a `NullStatusHandler`. + pub fn new( + config: ReflectorClientConfig, + status: S, + ) -> Self { + let (tx, rx) = mpsc::channel(); + Self { + config, + status_handler: Arc::new(Mutex::new(status)), + kiss_out_tx: tx, + kiss_out: OutBuffer::new(rx), + event_tx: Arc::new(Mutex::new(None)), + is_closed: Arc::new(AtomicBool::new(false)), + } + } +} + +impl Read for ReflectorClientTnc { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.kiss_out.read(buf) + } +} + +impl Write for ReflectorClientTnc { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl Tnc for ReflectorClientTnc { + fn try_clone(&mut self) -> Result { + Ok(self.clone()) + } + + fn start(&mut self) { + spawn_runner( + self.config.clone(), + self.status_handler.clone(), + self.event_tx.clone(), + self.is_closed.clone(), + self.kiss_out_tx.clone(), + ); + } + + fn close(&mut self) { + if let Some(tx) = self.event_tx.lock().unwrap().as_ref() { + self.is_closed.store(true, Ordering::Release); + let _ = tx.send(TncEvent::Close); + } + } +} + +#[allow(clippy::large_enum_variant)] +enum TncEvent { + Close, + Received(ServerMessage), +} + +fn spawn_runner( + config: ReflectorClientConfig, + status: WrappedStatusHandler, + event_tx: Arc>>>, + is_closed: Arc, + kiss_out_tx: Sender>, +) { + std::thread::spawn(move || { + status + .lock() + .unwrap() + .status_changed(TncStatus::Disconnected); + while !is_closed.load(Ordering::Acquire) { + status.lock().unwrap().status_changed(TncStatus::Connecting); + let sa = if let Ok(mut sa_iter) = + (config.hostname.as_str(), config.port).to_socket_addrs() + { + if let Some(sa) = sa_iter.next() { + sa + } else { + status + .lock() + .unwrap() + .status_changed(TncStatus::Disconnected); + thread::sleep(Duration::from_secs(10)); + continue; + } + } else { + status + .lock() + .unwrap() + .status_changed(TncStatus::Disconnected); + thread::sleep(Duration::from_secs(10)); + continue; + }; + let (tx, rx) = mpsc::channel(); + *event_tx.lock().unwrap() = Some(tx.clone()); + if !is_closed.load(Ordering::Acquire) { + run_single_conn( + sa, + tx, + rx, + kiss_out_tx.clone(), + config.clone(), + status.clone(), + ); + } + println!("single conn ended"); + } + status.lock().unwrap().status_changed(TncStatus::Closed); + }); +} + +fn run_single_conn( + dest: SocketAddr, + event_tx: Sender, + event_rx: Receiver, + kiss_out_tx: Sender>, + config: ReflectorClientConfig, + status: WrappedStatusHandler, +) { + let socket = if dest.is_ipv4() { + UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap() + } else { + UdpSocket::bind((Ipv6Addr::UNSPECIFIED, 0)).unwrap() + }; + + let mut connect = Connect::new(); + connect.set_address(config.local_callsign.address().to_owned()); + connect.set_module(config.module); + socket.send_to(connect.as_bytes(), dest).unwrap(); + let mut converter = VoiceToRf::new(); + let single_conn_ended = Arc::new(AtomicBool::new(false)); + // TODO: unwrap + spawn_reader( + socket.try_clone().unwrap(), + event_tx, + single_conn_ended.clone(), + ); + + while let Ok(ev) = event_rx.recv_timeout(Duration::from_secs(30)) { + match ev { + TncEvent::Close => { + println!("writer: close"); + break; + } + TncEvent::Received(server_msg) => match server_msg { + ServerMessage::ConnectAcknowledge(_) => { + status.lock().unwrap().status_changed(TncStatus::Connected); + } + ServerMessage::ConnectNack(_) => { + status + .lock() + .unwrap() + .status_changed(TncStatus::ConnectRejected); + break; + } + ServerMessage::ForceDisconnect(_) => { + status + .lock() + .unwrap() + .status_changed(TncStatus::ForceDisconnect); + break; + } + ServerMessage::Voice(voice) => { + let (lsf, stream) = converter.next(&voice); + if let Some(lsf) = lsf { + let kiss = KissFrame::new_stream_setup(&lsf.0).unwrap(); + let _ = kiss_out_tx.send(kiss.as_bytes().into()); + } + let kiss = KissFrame::new_stream_data(&stream).unwrap(); + let _ = kiss_out_tx.send(kiss.as_bytes().into()); + } + ServerMessage::Ping(_ping) => { + let mut pong = Pong::new(); + pong.set_address( + M17Address::from_callsign("VK7XT") + .unwrap() + .address() + .clone(), + ); + socket.send_to(pong.as_bytes(), dest).unwrap(); + } + _ => {} + }, + } + } + single_conn_ended.store(true, Ordering::Release); + status + .lock() + .unwrap() + .status_changed(TncStatus::Disconnected); + println!("write thread terminating"); +} + +fn spawn_reader(socket: UdpSocket, event_tx: Sender, cancel: Arc) { + std::thread::spawn(move || { + let mut buf = [0u8; 2048]; + while let Ok((n, _sa)) = socket.recv_from(&mut buf) { + if cancel.load(Ordering::Acquire) { + break; + } + if let Some(msg) = ServerMessage::parse(&buf[..n]) { + if event_tx.send(TncEvent::Received(msg)).is_err() { + break; + } + } + } + println!("read thread terminating"); + }); +} + +/// Callbacks to get runtime information about how the reflector client TNC is operating +pub trait StatusHandler { + fn status_changed(&mut self, status: TncStatus); +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum TncStatus { + Disconnected, + Connecting, + Connected, + ConnectRejected, + ForceDisconnect, + Closed, +} + +pub struct NullStatusHandler; +impl StatusHandler for NullStatusHandler { + fn status_changed(&mut self, _status: TncStatus) {} +} -- 2.39.5 From 777e27c79ca4f3b41432898238cf114a386142aa Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Mon, 2 Jun 2025 15:37:23 +1000 Subject: [PATCH 05/16] Fix clippy lints --- m17core/src/decode.rs | 10 ++-------- m17core/src/reflector/convert.rs | 1 + 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/m17core/src/decode.rs b/m17core/src/decode.rs index d3dd7ba..3bd8164 100644 --- a/m17core/src/decode.rs +++ b/m17core/src/decode.rs @@ -114,10 +114,7 @@ pub(crate) fn parse_lsf(frame: &[f32] /* length 192 */) -> Option { pub(crate) fn parse_stream(frame: &[f32] /* length 192 */) -> Option { let deinterleaved = frame_initial_decode(frame); let stream_part = &deinterleaved[12..]; - let stream = match fec::decode(stream_part, 144, p_2) { - Some(stream) => stream, - None => return None, - }; + let stream = fec::decode(stream_part, 144, p_2)?; let frame_num = u16::from_be_bytes([stream[0], stream[1]]); let eos = (frame_num & 0x8000) > 0; let frame_num = frame_num & 0x7fff; // higher layer has to handle wraparound @@ -142,10 +139,7 @@ pub(crate) fn parse_stream(frame: &[f32] /* length 192 */) -> Option Option { let deinterleaved = frame_initial_decode(frame); - let packet = match fec::decode(&deinterleaved, 206, p_3) { - Some(packet) => packet, - None => return None, - }; + let packet = fec::decode(&deinterleaved, 206, p_3)?; let final_frame = (packet[25] & 0x80) > 0; let number = (packet[25] >> 2) & 0x1f; let counter = if final_frame { diff --git a/m17core/src/reflector/convert.rs b/m17core/src/reflector/convert.rs index 7ad54c3..ecbb5a5 100644 --- a/m17core/src/reflector/convert.rs +++ b/m17core/src/reflector/convert.rs @@ -7,6 +7,7 @@ use super::packet::Voice; /// Accepts `Voice` packets from a reflector and turns them into LSF and Stream frames. /// /// This is the format required for the voice data to cross the KISS protocol boundary. +#[derive(Debug, Default)] pub struct VoiceToRf { /// Link Setup most recently acquired lsf: Option, -- 2.39.5 From 7a14bb4738cf011d9f99dc86abaee7042407b27d Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Mon, 2 Jun 2025 20:14:33 +1000 Subject: [PATCH 06/16] Implement TX for reflector TNC --- m17app/src/reflector.rs | 64 +++++++++++++++++++++++++++----- m17core/src/reflector/convert.rs | 1 + 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/m17app/src/reflector.rs b/m17app/src/reflector.rs index 9735298..419093e 100644 --- a/m17app/src/reflector.rs +++ b/m17app/src/reflector.rs @@ -12,10 +12,11 @@ use std::{ use crate::{link_setup::M17Address, tnc::Tnc, util::out_buffer::OutBuffer}; use m17core::{ - kiss::KissFrame, + kiss::{KissBuffer, KissCommand, KissFrame, PORT_STREAM}, + protocol::{LsfFrame, StreamFrame}, reflector::{ - convert::VoiceToRf, - packet::{Connect, Pong, ServerMessage}, + convert::{RfToVoice, VoiceToRf}, + packet::{Connect, Pong, ServerMessage, Voice}, }, }; @@ -40,6 +41,8 @@ pub struct ReflectorClientTnc { kiss_out: OutBuffer, event_tx: Arc>>>, is_closed: Arc, + kiss_buffer: Arc>, + rf_to_voice: Arc>>, } impl ReflectorClientTnc { @@ -60,6 +63,8 @@ impl ReflectorClientTnc { kiss_out: OutBuffer::new(rx), event_tx: Arc::new(Mutex::new(None)), is_closed: Arc::new(AtomicBool::new(false)), + kiss_buffer: Arc::new(Mutex::new(KissBuffer::new())), + rf_to_voice: Arc::new(Mutex::new(None)), } } } @@ -72,7 +77,42 @@ impl Read for ReflectorClientTnc { impl Write for ReflectorClientTnc { fn write(&mut self, buf: &[u8]) -> std::io::Result { - Ok(buf.len()) + let mut kiss = self.kiss_buffer.lock().unwrap(); + let rem = kiss.buf_remaining(); + let sz = buf.len().max(rem.len()); + rem[0..sz].copy_from_slice(&buf[0..sz]); + if let Some(frame) = kiss.next_frame() { + if Ok(KissCommand::DataFrame) == frame.command() && frame.port() == Ok(PORT_STREAM) { + let mut payload = [0u8; 30]; + if let Ok(len) = frame.decode_payload(&mut payload) { + if len == 30 { + let lsf = LsfFrame(payload); + let mut to_voice = self.rf_to_voice.lock().unwrap(); + match &mut *to_voice { + Some(to_voice) => to_voice.process_lsf(lsf), + None => *to_voice = Some(RfToVoice::new(lsf)), + } + } else if len == 26 { + let frame_num_part = u16::from_be_bytes([payload[6], payload[7]]); + let frame = StreamFrame { + lich_idx: payload[5] >> 5, + lich_part: payload[0..5].try_into().unwrap(), + frame_number: frame_num_part & 0x7fff, + end_of_stream: frame_num_part & 0x8000 > 0, + stream_data: payload[8..24].try_into().unwrap(), + }; + let to_voice = self.rf_to_voice.lock().unwrap(); + if let Some(to_voice) = &*to_voice { + let voice = to_voice.process_stream(&frame); + if let Some(tx) = self.event_tx.lock().unwrap().as_ref() { + let _ = tx.send(TncEvent::TransmitVoice(voice)); + } + } + } + }; + } + } + Ok(sz) } fn flush(&mut self) -> std::io::Result<()> { @@ -107,6 +147,7 @@ impl Tnc for ReflectorClientTnc { enum TncEvent { Close, Received(ServerMessage), + TransmitVoice(Voice), } fn spawn_runner( @@ -156,7 +197,6 @@ fn spawn_runner( status.clone(), ); } - println!("single conn ended"); } status.lock().unwrap().status_changed(TncStatus::Closed); }); @@ -179,7 +219,7 @@ fn run_single_conn( let mut connect = Connect::new(); connect.set_address(config.local_callsign.address().to_owned()); connect.set_module(config.module); - socket.send_to(connect.as_bytes(), dest).unwrap(); + let _ = socket.send_to(connect.as_bytes(), dest); let mut converter = VoiceToRf::new(); let single_conn_ended = Arc::new(AtomicBool::new(false)); // TODO: unwrap @@ -192,7 +232,6 @@ fn run_single_conn( while let Ok(ev) = event_rx.recv_timeout(Duration::from_secs(30)) { match ev { TncEvent::Close => { - println!("writer: close"); break; } TncEvent::Received(server_msg) => match server_msg { @@ -230,10 +269,17 @@ fn run_single_conn( .address() .clone(), ); - socket.send_to(pong.as_bytes(), dest).unwrap(); + if socket.send_to(pong.as_bytes(), dest).is_err() { + break; + } } _ => {} }, + TncEvent::TransmitVoice(voice) => { + if socket.send_to(voice.as_bytes(), dest).is_err() { + break; + }; + } } } single_conn_ended.store(true, Ordering::Release); @@ -241,7 +287,6 @@ fn run_single_conn( .lock() .unwrap() .status_changed(TncStatus::Disconnected); - println!("write thread terminating"); } fn spawn_reader(socket: UdpSocket, event_tx: Sender, cancel: Arc) { @@ -257,7 +302,6 @@ fn spawn_reader(socket: UdpSocket, event_tx: Sender, cancel: Arc Date: Wed, 4 Jun 2025 20:45:26 +1000 Subject: [PATCH 07/16] Update crates to 2024 edition --- m17app/Cargo.toml | 2 +- m17codec2/Cargo.toml | 2 +- m17core/Cargo.toml | 2 +- m17core/src/tnc.rs | 6 +++--- tools/m17rt-demod/Cargo.toml | 3 +-- tools/m17rt-mod/Cargo.toml | 2 +- tools/m17rt-rxpacket/Cargo.toml | 2 +- tools/m17rt-soundcards/Cargo.toml | 2 +- tools/m17rt-txpacket/Cargo.toml | 2 +- 9 files changed, 11 insertions(+), 12 deletions(-) diff --git a/m17app/Cargo.toml b/m17app/Cargo.toml index 4c2817f..5a0e621 100644 --- a/m17app/Cargo.toml +++ b/m17app/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "m17app" version = "0.1.0" -edition = "2021" +edition = "2024" keywords = ["amateur", "radio", "m17", "ham"] license = "MIT" authors = ["Thomas Karpiniec { match &mut self.state { - State::RxPacket(ref mut rx) => { + State::RxPacket(rx) => { match packet.counter { PacketFrameCounter::Frame { index } => { if index == rx.count && index < 32 { @@ -164,7 +164,7 @@ impl SoftTnc { } Frame::Stream(stream) => { match &mut self.state { - State::RxStream(ref mut rx) => { + State::RxStream(rx) => { // TODO: consider wraparound from 0x7fff if stream.frame_number < rx.index { let mut lich = LichCollection::new(); @@ -181,7 +181,7 @@ impl SoftTnc { } } } - State::RxAcquiringStream(ref mut rx) => { + State::RxAcquiringStream(rx) => { rx.lich.set_segment(stream.lich_idx, stream.lich_part); if let Some(maybe_lsf) = rx.lich.try_assemble() { let lsf = LsfFrame(maybe_lsf); diff --git a/tools/m17rt-demod/Cargo.toml b/tools/m17rt-demod/Cargo.toml index b4c091d..6e60d4a 100644 --- a/tools/m17rt-demod/Cargo.toml +++ b/tools/m17rt-demod/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "m17rt-demod" version = "0.1.0" -edition = "2021" +edition = "2024" license = "MIT" authors = ["Thomas Karpiniec Date: Wed, 4 Jun 2025 20:45:52 +1000 Subject: [PATCH 08/16] Remove unneeded cpal dep --- Cargo.lock | 2 -- tools/m17rt-rxpacket/Cargo.toml | 1 - 2 files changed, 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1ed41f0..754a0cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -494,7 +494,6 @@ dependencies = [ name = "m17rt-demod" version = "0.1.0" dependencies = [ - "cpal", "env_logger", "log", "m17app", @@ -517,7 +516,6 @@ dependencies = [ name = "m17rt-rxpacket" version = "0.1.0" dependencies = [ - "cpal", "env_logger", "log", "m17app", diff --git a/tools/m17rt-rxpacket/Cargo.toml b/tools/m17rt-rxpacket/Cargo.toml index d53b752..1cb3d31 100644 --- a/tools/m17rt-rxpacket/Cargo.toml +++ b/tools/m17rt-rxpacket/Cargo.toml @@ -10,6 +10,5 @@ publish = false m17core = { path = "../../m17core" } m17app = { path = "../../m17app" } -cpal = "0.15.3" env_logger = "0.11.6" log = "0.4.22" -- 2.39.5 From a55a59d78fea5b8b639ce594d339ef53290d141e Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Wed, 4 Jun 2025 21:37:11 +1000 Subject: [PATCH 09/16] Add basic m17rt-netclient tool --- Cargo.lock | 42 +++++++++ Cargo.toml | 2 +- m17app/src/reflector.rs | 8 +- tools/m17rt-netclient/Cargo.toml | 12 +++ tools/m17rt-netclient/src/main.rs | 138 ++++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 tools/m17rt-netclient/Cargo.toml create mode 100644 tools/m17rt-netclient/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 754a0cc..f67d132 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,6 +197,33 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.5.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + [[package]] name = "codec2" version = "0.3.0" @@ -512,6 +539,15 @@ dependencies = [ "m17core", ] +[[package]] +name = "m17rt-netclient" +version = "0.1.0" +dependencies = [ + "clap", + "m17app", + "m17codec2", +] + [[package]] name = "m17rt-rxpacket" version = "0.1.0" @@ -808,6 +844,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.94" diff --git a/Cargo.toml b/Cargo.toml index fab7146..3ea3218 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,4 +2,4 @@ resolver = "2" members = [ "m17app", "m17codec2", "m17core", "tools/m17rt-demod", "tools/m17rt-mod", "tools/m17rt-txpacket", "tools/m17rt-rxpacket", "tools/m17rt-soundcards" -] +, "tools/m17rt-netclient"] diff --git a/m17app/src/reflector.rs b/m17app/src/reflector.rs index 419093e..9b383f7 100644 --- a/m17app/src/reflector.rs +++ b/m17app/src/reflector.rs @@ -22,10 +22,10 @@ use m17core::{ #[derive(Debug, PartialEq, Eq, Clone)] pub struct ReflectorClientConfig { - hostname: String, - port: u16, - module: char, - local_callsign: M17Address, + pub hostname: String, + pub port: u16, + pub module: char, + pub local_callsign: M17Address, } type WrappedStatusHandler = Arc>; diff --git a/tools/m17rt-netclient/Cargo.toml b/tools/m17rt-netclient/Cargo.toml new file mode 100644 index 0000000..e78b89e --- /dev/null +++ b/tools/m17rt-netclient/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "m17rt-netclient" +version = "0.1.0" +edition = "2024" +license = "MIT" +authors = ["Thomas Karpiniec ("hostname").unwrap(); + let port = args.get_one::("port").unwrap(); + let callsign = args.get_one::("callsign").unwrap(); + let module = args.get_one::("module").unwrap(); + let input = args.get_one::("input"); + let output = args.get_one::("output"); + + let mut tx = Codec2TxAdapter::new(callsign.clone(), M17Address::new_broadcast()); + if let Some(input) = input { + tx.set_input_card(input); + } + let ptt = tx.ptt(); + + let mut rx = Codec2RxAdapter::new(); + if let Some(output) = output { + rx.set_output_card(output); + } + + let config = ReflectorClientConfig { + hostname: hostname.clone(), + port: *port, + module: *module, + local_callsign: callsign.clone(), + }; + let tnc = ReflectorClientTnc::new(config, ConsoleStatusHandler); + let app = M17App::new(tnc); + app.add_stream_adapter(ConsoleAdapter).unwrap(); + app.add_stream_adapter(tx).unwrap(); + app.add_stream_adapter(rx).unwrap(); + app.start().unwrap(); + + println!(">>> PRESS ENTER TO TOGGLE PTT <<<"); + let mut buf = String::new(); + + loop { + let _ = stdin().read_line(&mut buf); + ptt.set_ptt(true); + println!("PTT ON: PRESS ENTER TO END"); + + let _ = stdin().read_line(&mut buf); + ptt.set_ptt(false); + println!("PTT OFF"); + } +} + +fn valid_module(m: &str) -> Result { + let m = m.to_ascii_uppercase(); + if m.len() != 1 || !m.chars().next().unwrap().is_alphabetic() { + return Err("Module must be a single letter from A to Z".to_owned()); + } + Ok(m.chars().next().unwrap()) +} + +fn valid_callsign(c: &str) -> Result { + M17Address::from_callsign(c).map_err(|e| e.to_string()) +} + +struct ConsoleAdapter; +impl StreamAdapter for ConsoleAdapter { + fn stream_began(&self, link_setup: m17app::link_setup::LinkSetup) { + println!( + "Transmission begins. From: {} To: {}", + link_setup.source(), + link_setup.destination() + ); + } + + fn stream_data(&self, _frame_number: u16, is_final: bool, _data: Arc<[u8; 16]>) { + if is_final { + println!("Transmission ends."); + } + } +} + +struct ConsoleStatusHandler; +impl StatusHandler for ConsoleStatusHandler { + fn status_changed(&mut self, status: m17app::reflector::TncStatus) { + println!("Client status: {status:?}") + } +} -- 2.39.5 From 9a5a0d1e59e5f813f17239b674336ac9814c16b4 Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Wed, 4 Jun 2025 21:44:45 +1000 Subject: [PATCH 10/16] Clarify netclient logs --- tools/m17rt-netclient/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/m17rt-netclient/src/main.rs b/tools/m17rt-netclient/src/main.rs index 9b65e1b..fbcfb15 100644 --- a/tools/m17rt-netclient/src/main.rs +++ b/tools/m17rt-netclient/src/main.rs @@ -117,7 +117,7 @@ struct ConsoleAdapter; impl StreamAdapter for ConsoleAdapter { fn stream_began(&self, link_setup: m17app::link_setup::LinkSetup) { println!( - "Transmission begins. From: {} To: {}", + "Incoming transmission begins. From: {} To: {}", link_setup.source(), link_setup.destination() ); @@ -125,7 +125,7 @@ impl StreamAdapter for ConsoleAdapter { fn stream_data(&self, _frame_number: u16, is_final: bool, _data: Arc<[u8; 16]>) { if is_final { - println!("Transmission ends."); + println!("Incoming transmission ends."); } } } -- 2.39.5 From e307431f908eedae321aa86565fd5e213d758216 Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Thu, 5 Jun 2025 19:21:28 +1000 Subject: [PATCH 11/16] Make netclient work against mrefd --- m17app/src/error.rs | 4 +--- m17app/src/link_setup.rs | 2 +- m17app/src/reflector.rs | 16 +++++++--------- m17app/src/rtlsdr.rs | 2 +- m17app/src/soundcard.rs | 4 ++-- m17app/src/soundmodem.rs | 2 +- m17app/src/util/out_buffer.rs | 2 +- m17codec2/src/error.rs | 8 ++++++-- m17codec2/src/rx.rs | 2 +- m17codec2/src/tx.rs | 10 +++++----- m17core/src/bits.rs | 2 +- m17core/src/decode.rs | 4 ++-- m17core/src/encode.rs | 2 +- m17core/src/modem.rs | 2 +- m17core/src/protocol.rs | 2 +- m17core/src/reflector/convert.rs | 4 +++- m17core/src/reflector/packet.rs | 2 +- tools/m17rt-netclient/src/main.rs | 19 ++++++++++++++++++- tools/m17rt-rxpacket/src/main.rs | 2 +- 19 files changed, 55 insertions(+), 36 deletions(-) diff --git a/m17app/src/error.rs b/m17app/src/error.rs index e820eae..6dbd8c2 100644 --- a/m17app/src/error.rs +++ b/m17app/src/error.rs @@ -11,9 +11,7 @@ pub enum M17Error { #[error("given callsign is {0} characters long; maximum is 9")] CallsignTooLong(usize), - #[error( - "provided packet payload is too large: provided {provided} bytes, capacity {capacity}" - )] + #[error("provided packet payload is too large: provided {provided} bytes, capacity {capacity}")] PacketTooLarge { provided: usize, capacity: usize }, #[error("provided path to RRC file could not be opened: {0}")] diff --git a/m17app/src/link_setup.rs b/m17app/src/link_setup.rs index b036006..2e960a7 100644 --- a/m17app/src/link_setup.rs +++ b/m17app/src/link_setup.rs @@ -1,7 +1,7 @@ use std::fmt::Display; use m17core::{ - address::{Address, Callsign, ALPHABET}, + address::{ALPHABET, Address, Callsign}, protocol::LsfFrame, }; diff --git a/m17app/src/reflector.rs b/m17app/src/reflector.rs index 9b383f7..b5825bb 100644 --- a/m17app/src/reflector.rs +++ b/m17app/src/reflector.rs @@ -2,9 +2,9 @@ use std::{ io::{self, Read, Write}, net::{Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs, UdpSocket}, sync::{ + Arc, Mutex, atomic::{AtomicBool, Ordering}, mpsc::{self, Receiver, Sender}, - Arc, Mutex, }, thread, time::Duration, @@ -79,8 +79,9 @@ impl Write for ReflectorClientTnc { fn write(&mut self, buf: &[u8]) -> std::io::Result { let mut kiss = self.kiss_buffer.lock().unwrap(); let rem = kiss.buf_remaining(); - let sz = buf.len().max(rem.len()); + let sz = buf.len().min(rem.len()); rem[0..sz].copy_from_slice(&buf[0..sz]); + kiss.did_write(sz); if let Some(frame) = kiss.next_frame() { if Ok(KissCommand::DataFrame) == frame.command() && frame.port() == Ok(PORT_STREAM) { let mut payload = [0u8; 30]; @@ -196,6 +197,8 @@ fn spawn_runner( config.clone(), status.clone(), ); + // Cool off a bit if connect rejected, etc. + thread::sleep(Duration::from_secs(10)); } } status.lock().unwrap().status_changed(TncStatus::Closed); @@ -217,7 +220,7 @@ fn run_single_conn( }; let mut connect = Connect::new(); - connect.set_address(config.local_callsign.address().to_owned()); + connect.set_address(config.local_callsign.address()); connect.set_module(config.module); let _ = socket.send_to(connect.as_bytes(), dest); let mut converter = VoiceToRf::new(); @@ -263,12 +266,7 @@ fn run_single_conn( } ServerMessage::Ping(_ping) => { let mut pong = Pong::new(); - pong.set_address( - M17Address::from_callsign("VK7XT") - .unwrap() - .address() - .clone(), - ); + pong.set_address(config.local_callsign.address()); if socket.send_to(pong.as_bytes(), dest).is_err() { break; } diff --git a/m17app/src/rtlsdr.rs b/m17app/src/rtlsdr.rs index 269769b..72217cf 100644 --- a/m17app/src/rtlsdr.rs +++ b/m17app/src/rtlsdr.rs @@ -1,7 +1,7 @@ use std::{ io::Read, process::{Child, Command, Stdio}, - sync::{mpsc::SyncSender, Mutex}, + sync::{Mutex, mpsc::SyncSender}, }; use crate::{ diff --git a/m17app/src/soundcard.rs b/m17app/src/soundcard.rs index 9209de2..fb29282 100644 --- a/m17app/src/soundcard.rs +++ b/m17app/src/soundcard.rs @@ -1,16 +1,16 @@ use std::{ borrow::Borrow, sync::{ - mpsc::{sync_channel, Receiver, SyncSender}, Arc, RwLock, + mpsc::{Receiver, SyncSender, sync_channel}, }, time::{Duration, Instant}, }; use cpal::{ - traits::{DeviceTrait, HostTrait, StreamTrait}, BuildStreamError, DevicesError, PlayStreamError, SampleFormat, SampleRate, Stream, StreamError, SupportedStreamConfigRange, SupportedStreamConfigsError, + traits::{DeviceTrait, HostTrait, StreamTrait}, }; use thiserror::Error; diff --git a/m17app/src/soundmodem.rs b/m17app/src/soundmodem.rs index c0cbfbb..c782d86 100644 --- a/m17app/src/soundmodem.rs +++ b/m17app/src/soundmodem.rs @@ -9,8 +9,8 @@ use std::fmt::Display; use std::fs::File; use std::io::{self, Read, Write}; use std::path::PathBuf; -use std::sync::mpsc::{channel, sync_channel, Receiver, Sender, SyncSender, TryRecvError}; use std::sync::RwLock; +use std::sync::mpsc::{Receiver, Sender, SyncSender, TryRecvError, channel, sync_channel}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use thiserror::Error; diff --git a/m17app/src/util/out_buffer.rs b/m17app/src/util/out_buffer.rs index c24e0a9..06d8e91 100644 --- a/m17app/src/util/out_buffer.rs +++ b/m17app/src/util/out_buffer.rs @@ -2,7 +2,7 @@ use std::{ io::{self, ErrorKind, Read}, - sync::{mpsc::Receiver, Arc, Mutex}, + sync::{Arc, Mutex, mpsc::Receiver}, }; #[derive(Clone)] diff --git a/m17codec2/src/error.rs b/m17codec2/src/error.rs index 7fa6583..b1b3e62 100644 --- a/m17codec2/src/error.rs +++ b/m17codec2/src/error.rs @@ -14,7 +14,9 @@ pub enum M17Codec2Error { #[error("selected card '{0}' failed to list available output configs: '{1}'")] OutputConfigsUnavailable(String, #[source] cpal::SupportedStreamConfigsError), - #[error("selected card '{0}' did not offer a compatible output config type, either due to hardware limitations or because it is currently in use")] + #[error( + "selected card '{0}' did not offer a compatible output config type, either due to hardware limitations or because it is currently in use" + )] SupportedOutputUnavailable(String), #[error("selected card '{0}' was unable to build an output stream: '{1}'")] @@ -26,7 +28,9 @@ pub enum M17Codec2Error { #[error("selected card '{0}' failed to list available input configs: '{1}'")] InputConfigsUnavailable(String, #[source] cpal::SupportedStreamConfigsError), - #[error("selected card '{0}' did not offer a compatible input config type, either due to hardware limitations or because it is currently in use")] + #[error( + "selected card '{0}' did not offer a compatible input config type, either due to hardware limitations or because it is currently in use" + )] SupportedInputUnavailable(String), #[error("selected card '{0}' was unable to build an input stream: '{1}'")] diff --git a/m17codec2/src/rx.rs b/m17codec2/src/rx.rs index 5649519..455ce42 100644 --- a/m17codec2/src/rx.rs +++ b/m17codec2/src/rx.rs @@ -17,8 +17,8 @@ use std::fs::File; use std::io::Write; use std::path::Path; use std::sync::{ - mpsc::{channel, Receiver, Sender}, Arc, Mutex, + mpsc::{Receiver, Sender, channel}, }; /// Write one or more 8-byte chunks of 3200-bit Codec2 to a raw S16LE file diff --git a/m17codec2/src/tx.rs b/m17codec2/src/tx.rs index a54d864..d9028fb 100644 --- a/m17codec2/src/tx.rs +++ b/m17codec2/src/tx.rs @@ -1,24 +1,24 @@ use codec2::{Codec2, Codec2Mode}; +use cpal::SampleFormat; +use cpal::SampleRate; use cpal::traits::DeviceTrait; use cpal::traits::HostTrait; use cpal::traits::StreamTrait; -use cpal::SampleFormat; -use cpal::SampleRate; use log::debug; +use m17app::StreamFrame; use m17app::adapter::StreamAdapter; use m17app::app::TxHandle; use m17app::error::AdapterError; use m17app::link_setup::LinkSetup; use m17app::link_setup::M17Address; -use m17app::StreamFrame; use rubato::Resampler; use rubato::SincFixedOut; use rubato::SincInterpolationParameters; use std::path::PathBuf; -use std::sync::mpsc; -use std::sync::mpsc::channel; use std::sync::Arc; use std::sync::Mutex; +use std::sync::mpsc; +use std::sync::mpsc::channel; use std::time::Duration; use std::time::Instant; diff --git a/m17core/src/bits.rs b/m17core/src/bits.rs index 7b39995..4dea6d1 100644 --- a/m17core/src/bits.rs +++ b/m17core/src/bits.rs @@ -73,7 +73,7 @@ impl<'a> BitsMut<'a> { } pub(crate) fn set_bit(&mut self, idx: usize, value: u8) { - let slice = &mut self.0 .0; + let slice = &mut self.0.0; let existing = slice[idx / 8]; if value == 0 { slice[idx / 8] = existing & !(1 << (7 - (idx % 8))); diff --git a/m17core/src/decode.rs b/m17core/src/decode.rs index 3bd8164..4bc6288 100644 --- a/m17core/src/decode.rs +++ b/m17core/src/decode.rs @@ -3,8 +3,8 @@ use crate::{ fec::{self, p_1, p_2, p_3}, interleave::interleave, protocol::{ - LsfFrame, PacketFrame, PacketFrameCounter, StreamFrame, BERT_SYNC, END_OF_TRANSMISSION, - LSF_SYNC, PACKET_SYNC, PREAMBLE, STREAM_SYNC, + BERT_SYNC, END_OF_TRANSMISSION, LSF_SYNC, LsfFrame, PACKET_SYNC, PREAMBLE, PacketFrame, + PacketFrameCounter, STREAM_SYNC, StreamFrame, }, random::random_xor, }; diff --git a/m17core/src/encode.rs b/m17core/src/encode.rs index 5e0951e..94d280b 100644 --- a/m17core/src/encode.rs +++ b/m17core/src/encode.rs @@ -3,7 +3,7 @@ use crate::{ fec::{self, p_1, p_2, p_3}, interleave::interleave, protocol::{ - LsfFrame, PacketFrame, PacketFrameCounter, StreamFrame, LSF_SYNC, PACKET_SYNC, STREAM_SYNC, + LSF_SYNC, LsfFrame, PACKET_SYNC, PacketFrame, PacketFrameCounter, STREAM_SYNC, StreamFrame, }, random::random_xor, }; diff --git a/m17core/src/modem.rs b/m17core/src/modem.rs index 43ad5ac..255678f 100644 --- a/m17core/src/modem.rs +++ b/m17core/src/modem.rs @@ -1,5 +1,5 @@ use crate::decode::{ - parse_lsf, parse_packet, parse_stream, sync_burst_correlation, SyncBurst, SYNC_THRESHOLD, + SYNC_THRESHOLD, SyncBurst, parse_lsf, parse_packet, parse_stream, sync_burst_correlation, }; use crate::encode::{ encode_lsf, encode_packet, encode_stream, generate_end_of_transmission, generate_preamble, diff --git a/m17core/src/protocol.rs b/m17core/src/protocol.rs index be1eba1..251fa27 100644 --- a/m17core/src/protocol.rs +++ b/m17core/src/protocol.rs @@ -1,5 +1,5 @@ use crate::{ - address::{encode_address, Address}, + address::{Address, encode_address}, bits::BitsMut, }; diff --git a/m17core/src/reflector/convert.rs b/m17core/src/reflector/convert.rs index fd52df8..716e68a 100644 --- a/m17core/src/reflector/convert.rs +++ b/m17core/src/reflector/convert.rs @@ -70,7 +70,9 @@ pub struct RfToVoice { impl RfToVoice { pub fn new(lsf: LsfFrame) -> Self { - Self { lsf, stream_id: 0 } + // no_std "random" + let stream_id = &lsf as *const LsfFrame as u16; + Self { lsf, stream_id } } pub fn process_lsf(&mut self, lsf: LsfFrame) { diff --git a/m17core/src/reflector/packet.rs b/m17core/src/reflector/packet.rs index 3f746d6..3dc8378 100644 --- a/m17core/src/reflector/packet.rs +++ b/m17core/src/reflector/packet.rs @@ -185,7 +185,7 @@ macro_rules! impl_address { crate::address::decode_address(self.0[$from..($from + 6)].try_into().unwrap()) } - pub fn set_address(&mut self, address: Address) { + pub fn set_address(&mut self, address: &Address) { let encoded = crate::address::encode_address(&address); self.0[$from..($from + 6)].copy_from_slice(&encoded); self.recalculate_crc(); diff --git a/tools/m17rt-netclient/src/main.rs b/tools/m17rt-netclient/src/main.rs index fbcfb15..3204b5b 100644 --- a/tools/m17rt-netclient/src/main.rs +++ b/tools/m17rt-netclient/src/main.rs @@ -34,6 +34,14 @@ fn main() { .required(true) .help("Your callsign for reflector registration and transmissions"), ) + .arg( + Arg::new("reflector") + .long("reflector") + .short('r') + .value_parser(valid_callsign) + .required(true) + .help("Reflector designator/callsign, often starting with 'M17-'"), + ) .arg( Arg::new("module") .long("module") @@ -59,11 +67,20 @@ fn main() { let hostname = args.get_one::("hostname").unwrap(); let port = args.get_one::("port").unwrap(); let callsign = args.get_one::("callsign").unwrap(); + let reflector = args.get_one::("reflector").unwrap(); let module = args.get_one::("module").unwrap(); let input = args.get_one::("input"); let output = args.get_one::("output"); - let mut tx = Codec2TxAdapter::new(callsign.clone(), M17Address::new_broadcast()); + let ref_with_mod = format!("{} {}", reflector, module); + let Ok(reflector) = M17Address::from_callsign(&ref_with_mod) else { + println!( + "Unable to create valid destination address for reflector + callsign '{ref_with_mod}'" + ); + std::process::exit(1); + }; + + let mut tx = Codec2TxAdapter::new(callsign.clone(), reflector); if let Some(input) = input { tx.set_input_card(input); } diff --git a/tools/m17rt-rxpacket/src/main.rs b/tools/m17rt-rxpacket/src/main.rs index a18537e..adfc444 100644 --- a/tools/m17rt-rxpacket/src/main.rs +++ b/tools/m17rt-rxpacket/src/main.rs @@ -1,9 +1,9 @@ +use m17app::PacketType; use m17app::adapter::PacketAdapter; use m17app::app::M17App; use m17app::link_setup::LinkSetup; use m17app::soundcard::Soundcard; use m17app::soundmodem::{NullErrorHandler, NullOutputSink, NullPtt, Soundmodem}; -use m17app::PacketType; use std::sync::Arc; fn main() { -- 2.39.5 From b3e9f6de62fca1f6228741dc322258b087ac66db Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Mon, 16 Jun 2025 20:59:28 +1000 Subject: [PATCH 12/16] Document reflector destination call --- tools/m17rt-netclient/src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/m17rt-netclient/src/main.rs b/tools/m17rt-netclient/src/main.rs index 3204b5b..5fee7d6 100644 --- a/tools/m17rt-netclient/src/main.rs +++ b/tools/m17rt-netclient/src/main.rs @@ -72,6 +72,10 @@ fn main() { let input = args.get_one::("input"); let output = args.get_one::("output"); + // It is current convention that mrefd requires the destination of transmissions to match the reflector. + // If you are connected to "M17-XXX" on module B then you must set the dst to "M17-XXX B". + // This requirement is likely to change but for the purposes of this test client we'll hard-code the + // behaviour for the time being. let ref_with_mod = format!("{} {}", reflector, module); let Ok(reflector) = M17Address::from_callsign(&ref_with_mod) else { println!( -- 2.39.5 From dffe7046a9d0a6692f22564a34e8b4eb6f453503 Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Fri, 20 Jun 2025 15:08:46 +1000 Subject: [PATCH 13/16] Change clap to use derive syntax --- Cargo.lock | 19 ++++++ tools/m17rt-netclient/Cargo.toml | 2 +- tools/m17rt-netclient/src/main.rs | 108 +++++++++++------------------- 3 files changed, 58 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f67d132..4800984 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,6 +204,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -218,6 +219,18 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_derive" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "clap_lex" version = "0.7.4" @@ -373,6 +386,12 @@ version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hound" version = "3.5.1" diff --git a/tools/m17rt-netclient/Cargo.toml b/tools/m17rt-netclient/Cargo.toml index e78b89e..66c6e66 100644 --- a/tools/m17rt-netclient/Cargo.toml +++ b/tools/m17rt-netclient/Cargo.toml @@ -7,6 +7,6 @@ authors = ["Thomas Karpiniec , + #[arg( + short = 'o', + help = "Soundcard name for speaker, otherwise system default" + )] + output: Option, +} - let hostname = args.get_one::("hostname").unwrap(); - let port = args.get_one::("port").unwrap(); - let callsign = args.get_one::("callsign").unwrap(); - let reflector = args.get_one::("reflector").unwrap(); - let module = args.get_one::("module").unwrap(); - let input = args.get_one::("input"); - let output = args.get_one::("output"); +fn main() { + let args = Args::parse(); // It is current convention that mrefd requires the destination of transmissions to match the reflector. // If you are connected to "M17-XXX" on module B then you must set the dst to "M17-XXX B". // This requirement is likely to change but for the purposes of this test client we'll hard-code the // behaviour for the time being. - let ref_with_mod = format!("{} {}", reflector, module); + let ref_with_mod = format!("{} {}", args.reflector, args.module); let Ok(reflector) = M17Address::from_callsign(&ref_with_mod) else { println!( "Unable to create valid destination address for reflector + callsign '{ref_with_mod}'" @@ -84,22 +52,22 @@ fn main() { std::process::exit(1); }; - let mut tx = Codec2TxAdapter::new(callsign.clone(), reflector); - if let Some(input) = input { + let mut tx = Codec2TxAdapter::new(args.callsign.clone(), reflector); + if let Some(input) = args.input { tx.set_input_card(input); } let ptt = tx.ptt(); let mut rx = Codec2RxAdapter::new(); - if let Some(output) = output { + if let Some(output) = args.output { rx.set_output_card(output); } let config = ReflectorClientConfig { - hostname: hostname.clone(), - port: *port, - module: *module, - local_callsign: callsign.clone(), + hostname: args.hostname, + port: args.port, + module: args.module, + local_callsign: args.callsign, }; let tnc = ReflectorClientTnc::new(config, ConsoleStatusHandler); let app = M17App::new(tnc); -- 2.39.5 From 8fac255e03571df9e3ec51dea7b0ffb6d4800d0f Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Wed, 25 Jun 2025 19:40:03 +1000 Subject: [PATCH 14/16] Handle reflector packet traffic --- m17app/src/reflector.rs | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/m17app/src/reflector.rs b/m17app/src/reflector.rs index b5825bb..e773768 100644 --- a/m17app/src/reflector.rs +++ b/m17app/src/reflector.rs @@ -12,11 +12,11 @@ use std::{ use crate::{link_setup::M17Address, tnc::Tnc, util::out_buffer::OutBuffer}; use m17core::{ - kiss::{KissBuffer, KissCommand, KissFrame, PORT_STREAM}, + kiss::{KissBuffer, KissCommand, KissFrame, PORT_PACKET_BASIC, PORT_PACKET_FULL, PORT_STREAM}, protocol::{LsfFrame, StreamFrame}, reflector::{ convert::{RfToVoice, VoiceToRf}, - packet::{Connect, Pong, ServerMessage, Voice}, + packet::{Connect, Packet, Pong, ServerMessage, Voice}, }, }; @@ -82,7 +82,7 @@ impl Write for ReflectorClientTnc { let sz = buf.len().min(rem.len()); rem[0..sz].copy_from_slice(&buf[0..sz]); kiss.did_write(sz); - if let Some(frame) = kiss.next_frame() { + while let Some(frame) = kiss.next_frame() { if Ok(KissCommand::DataFrame) == frame.command() && frame.port() == Ok(PORT_STREAM) { let mut payload = [0u8; 30]; if let Ok(len) = frame.decode_payload(&mut payload) { @@ -111,6 +111,28 @@ impl Write for ReflectorClientTnc { } } }; + } else if Ok(KissCommand::DataFrame) == frame.command() + && frame.port() == Ok(PORT_PACKET_BASIC) + { + // basic packets not supported for now, they will require more config + } else if Ok(KissCommand::DataFrame) == frame.command() + && frame.port() == Ok(PORT_PACKET_FULL) + { + let mut payload = [0u8; 855]; + let Ok(len) = frame.decode_payload(&mut payload) else { + continue; + }; + if len < 33 { + continue; + } + let mut lsf = LsfFrame([0u8; 30]); + lsf.0.copy_from_slice(&payload[0..30]); + if lsf.check_crc() != 0 { + continue; + } + let mut packet = Packet::new(); + packet.set_link_setup_frame(&lsf); + packet.set_payload(&payload[30..]); } } Ok(sz) @@ -264,6 +286,13 @@ fn run_single_conn( let kiss = KissFrame::new_stream_data(&stream).unwrap(); let _ = kiss_out_tx.send(kiss.as_bytes().into()); } + ServerMessage::Packet(packet) => { + if let Ok(kiss) = + KissFrame::new_full_packet(&packet.link_setup_frame().0, packet.payload()) + { + let _ = kiss_out_tx.send(kiss.as_bytes().into()); + } + } ServerMessage::Ping(_ping) => { let mut pong = Pong::new(); pong.set_address(config.local_callsign.address()); -- 2.39.5 From 00180a4d18cac71895d4c61d53756bda90cdf447 Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Wed, 25 Jun 2025 20:26:37 +1000 Subject: [PATCH 15/16] Update READMEs with API changes --- m17app/README.md | 40 ++++++++++++++++++++++++++++++++-------- m17codec2/README.md | 3 ++- m17core/README.md | 2 +- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/m17app/README.md b/m17app/README.md index d6b548c..30a6138 100644 --- a/m17app/README.md +++ b/m17app/README.md @@ -6,17 +6,18 @@ Part of the [M17 Rust Toolkit](https://octet-stream.net/p/m17rt/). This crate pr ## Creating an `M17App` -The most important type is `M17App`. This is what your program can use to transmit packets and streams, or to subscribe to incoming packets and streams. To create an `M17App` you must provide it with a TNC, which is any type that implements the trait `Tnc`. This could be a `TcpStream` to another TNC device exposed to the network or it could be an instance of the built-in `Soundmodem`. +The most important type is `M17App`. This is what your program can use to transmit packets and streams, or to subscribe to incoming packets and streams. To create an `M17App` you must provide it with a TNC, which is any type that implements the trait `Tnc`. This could be a `TcpStream` to another TNC device exposed to the network or it could be an instance of the built-in `Soundmodem`. To connect to reflector like `mrefd` you can use the provided `ReflectorClientTnc`. ## Creating a `Soundmodem` A `Soundmodem` can use soundcards in your computer to send and receive M17 baseband signals via a radio. More generally it can accept input samples from any compatible source, and provide output samples to any compatible sink, and it will coordinate the modem and TNC in realtime in a background thread. -A `Soundmodem` requires three parameters: +A `Soundmodem` requires four parameters: * **Input source** - the signal we are receiving * **Output sink** - somewhere to send the modulated signal we want to transmit * **PTT** - a transmit switch that can be turned on or off +* **Error handler** - a callback that tells you if problems occur during operation These are all traits that you can implement yourself but you can probably use one of the types already included in `m17app`. @@ -38,14 +39,20 @@ Provided PTTs: * `SerialPtt` - Use a serial/COM port with either the RTS or DTR pin to activate PTT. * `NullPtt` - Fake device that will not control any real PTT. +Provided error handlers: + +* `StdoutErrorHandler` - Basic handler that will print events as they occur. +* `LogErrorHandler` - Uses the common `log` facility to record the event at DEBUG level. +* `NullErrorHandler` - Ignore errors. + For `Soundcard` you will need to identify the soundcard by a string name. The format of this card name is specific to the audio library used (`cpal`). Use `Soundcard::supported_input_cards()` and `Soundcard::supported_output_cards()` to list compatible devices. The bundled utility `m17rt-soundcards` may be useful. Similarly, `SerialPtt::available_ports()` lists the available serial ports. If you're using a Digirig on a Linux PC, M17 setup might look like this: ```rust,ignore let soundcard = Soundcard::new("plughw:CARD=Device,DEV=0").unwrap(); - let ptt = SerialPtt::new("/dev/ttyUSB0", PttPin::Rts); - let soundmodem = Soundmodem::new(soundcard.input(), soundcard.output(), ptt); + let ptt = SerialPtt::new("/dev/ttyUSB0", PttPin::Rts).unwrap(); + let soundmodem = Soundmodem::new(soundcard.input(), soundcard.output(), ptt, StdoutErrorHandler); let app = M17App::new(soundmodem); app.start(); ``` @@ -70,7 +77,8 @@ Transmissions are made via a `TxHandle`, which you can create by calling `app.tx ```rust,ignore let payload = b"Hello, world!"; app.tx() - .transmit_packet(&link_setup, PacketType::Sms, payload); + .transmit_packet(&link_setup, PacketType::Sms, payload) + .unwrap(); ``` Next let's see how to receive a packet. To subscribe to incoming packets you need to provide a subscriber that implements the trait `PacketAdapter`. This includes a number of lifecycle methods which are optional to implement. In this case we will handle `packet_received` and print a summary of the received packet and its contents to stdout. @@ -95,7 +103,7 @@ impl PacketAdapter for PacketPrinter { We instantiate one of these subscribers and provide it to our instance of `M17App`. ```rust,ignore - app.add_packet_adapter(PacketPrinter); + app.add_packet_adapter(PacketPrinter).unwrap(); ``` Note that if the adapter also implemented `adapter_registered`, then it would receive a copy of `TxHandle`. This allows you to create self-contained adapter implementations that can both transmit and receive. @@ -111,7 +119,23 @@ For our first example, let's see how to use the `m17codec2` helper crate to send The following line will register an adapter that monitors incoming M17 streams, attempts to decode the Codec2, and play the decoded audio on the default system sound card. ```rust,ignore - app.add_stream_adapter(Codec2Adapter::new()); + // optionally call set_output_card(...) on the adapter + app.add_stream_adapter(Codec2RxAdapter::new()).unwrap(); +``` + +This is how you set up to transmit Codec2 audio: + +```rust,ignore + // optionally call set_input_card(...) on the adapter + let mut tx = Codec2TxAdapter::new(args.callsign.clone(), reflector); + let ptt = tx.ptt(); + app.add_stream_adapter(tx).unwrap(); +``` + +Later, after you have called `start()`: + +```rust,ignore + ptt.set_ptt(true); ``` This is how to transmit a wave file of human speech (8 kHz, mono, 16 bit LE) as a Codec2 stream: @@ -140,4 +164,4 @@ To receive: * Create an adapter that implements trait `StreamAdapter` * Handle the `stream_began` and `stream_data` methods -* Add it to your `M17App` +* Add it to your `M17App` with `add_stream_adapter` diff --git a/m17codec2/README.md b/m17codec2/README.md index 0475c28..ebf92ba 100644 --- a/m17codec2/README.md +++ b/m17codec2/README.md @@ -3,6 +3,7 @@ Part of the [M17 Rust Toolkit](https://octet-stream.net/p/m17rt/). Pre-made adapters designed for the `m17app` crate that make it easier to work with Codec2 voice streams. * `WavePlayer` - transmit a wave file as a stream (8 kHz, mono, 16 bit LE) -* `Codec2Adapter` - receive all incoming streams and attempt to play the decoded audio on the default sound card +* `Codec2RxAdapter` - receive all incoming streams and attempt to play the decoded audio on a soundcard (configurable) +* `Codec2TxAdapter` - toggle a PTT to record audio from a microphone (soundcard also configurable), encode it, and transmit it with chosen source and destination addresses **Important licence note:** While `m17codec2` is under the MIT licence, it uses the `codec2` crate as a dependency, which will statically link LGPL code in the build. If you are distributing software in a way where LGPL compliance requires special care (e.g., dynamic linking), consider implementing your own codec2 adapters in a way that is compliant in your scenario. diff --git a/m17core/README.md b/m17core/README.md index dc13bf3..91b05d0 100644 --- a/m17core/README.md +++ b/m17core/README.md @@ -2,7 +2,7 @@ Part of the [M17 Rust Toolkit](https://octet-stream.net/p/m17rt/). -This crate includes a modulator, demodulator, TNC, M17 data link parsing and encoding, KISS protocol handling, and other protocol utilities. It can be used to create an M17 transmitter or receiver, however you will have to connect everything together yourself. If possible, consider using the higher-level crate `m17app`. +This crate includes a modulator, demodulator, TNC, M17 data link parsing and encoding, KISS protocol handling, UDP reflector packet formats, and other protocol utilities. It can be used to create an M17 transmitter or receiver, however you will have to connect everything together yourself. If possible, consider using the higher-level crate `m17app`. `m17core` is `no_std`, does not perform any heap allocations, and its protocol implementations are non-blocking and sans-I/O. -- 2.39.5 From 99f4fcbee0b9774a24ef2428ddce71889e602e3b Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Tue, 15 Jul 2025 21:13:55 +1000 Subject: [PATCH 16/16] fast demod bin --- Cargo.lock | 10 +++++++ Cargo.toml | 2 +- m17app/src/soundmodem.rs | 2 +- m17app/src/util/out_buffer.rs | 4 +-- m17core/src/decode.rs | 44 ++++++++++++++++++------------- m17core/src/encode.rs | 8 +++--- m17core/src/fec.rs | 8 +++--- m17core/src/modem.rs | 20 ++++++++------ tools/m17rt-fastdemod/Cargo.toml | 14 ++++++++++ tools/m17rt-fastdemod/src/main.rs | 44 +++++++++++++++++++++++++++++++ 10 files changed, 117 insertions(+), 39 deletions(-) create mode 100644 tools/m17rt-fastdemod/Cargo.toml create mode 100644 tools/m17rt-fastdemod/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 4800984..51fa142 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -547,6 +547,16 @@ dependencies = [ "m17core", ] +[[package]] +name = "m17rt-fastdemod" +version = "0.1.0" +dependencies = [ + "clap", + "env_logger", + "log", + "m17core", +] + [[package]] name = "m17rt-mod" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3ea3218..2883db3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,4 +2,4 @@ resolver = "2" members = [ "m17app", "m17codec2", "m17core", "tools/m17rt-demod", "tools/m17rt-mod", "tools/m17rt-txpacket", "tools/m17rt-rxpacket", "tools/m17rt-soundcards" -, "tools/m17rt-netclient"] +, "tools/m17rt-netclient", "tools/m17rt-fastdemod"] diff --git a/m17app/src/soundmodem.rs b/m17app/src/soundmodem.rs index c782d86..19a3dd5 100644 --- a/m17app/src/soundmodem.rs +++ b/m17app/src/soundmodem.rs @@ -234,7 +234,7 @@ fn spawn_soundmodem_worker( } SoundmodemEvent::BasebandInput(b) => { for sample in &*b { - if let Some(frame) = demodulator.demod(*sample) { + if let Some((frame, _)) = demodulator.demod(*sample) { tnc.handle_frame(frame); loop { let n = tnc.read_kiss(&mut buf); diff --git a/m17app/src/util/out_buffer.rs b/m17app/src/util/out_buffer.rs index 06d8e91..399ae71 100644 --- a/m17app/src/util/out_buffer.rs +++ b/m17app/src/util/out_buffer.rs @@ -1,7 +1,7 @@ //! Buffer between `read()` calls use std::{ - io::{self, ErrorKind, Read}, + io::{self, Read}, sync::{Arc, Mutex, mpsc::Receiver}, }; @@ -49,7 +49,7 @@ impl Read for OutBuffer { let output = { let rx = self.rx.lock().unwrap(); rx.recv() - .map_err(|s| io::Error::new(ErrorKind::Other, format!("{:?}", s)))? + .map_err(|s| io::Error::other(format!("{:?}", s)))? }; let to_write = output.len().min(buf.len()); buf[0..to_write].copy_from_slice(&output[0..to_write]); diff --git a/m17core/src/decode.rs b/m17core/src/decode.rs index 4bc6288..3195ed5 100644 --- a/m17core/src/decode.rs +++ b/m17core/src/decode.rs @@ -91,11 +91,11 @@ pub(crate) fn frame_initial_decode(frame: &[f32] /* length 192 */) -> [u8; 46] { interleave(&decoded[2..]) } -pub(crate) fn parse_lsf(frame: &[f32] /* length 192 */) -> Option { +pub(crate) fn parse_lsf(frame: &[f32] /* length 192 */) -> Option<(LsfFrame, u8)> { let deinterleaved = frame_initial_decode(frame); debug!("deinterleaved: {:?}", deinterleaved); - let lsf = match fec::decode(&deinterleaved, 240, p_1) { - Some(lsf) => LsfFrame(lsf), + let (lsf, errors) = match fec::decode(&deinterleaved, 240, p_1) { + Some((lsf, errors)) => (LsfFrame(lsf), errors), None => return None, }; debug!("full lsf: {:?}", lsf.0); @@ -108,13 +108,13 @@ pub(crate) fn parse_lsf(frame: &[f32] /* length 192 */) -> Option { debug!("encryption type: {:?}", lsf.encryption_type()); debug!("can: {}", lsf.channel_access_number()); debug!("meta: {:?}", lsf.meta()); - Some(lsf) + Some((lsf, errors)) } -pub(crate) fn parse_stream(frame: &[f32] /* length 192 */) -> Option { +pub(crate) fn parse_stream(frame: &[f32] /* length 192 */) -> Option<(StreamFrame, u8)> { let deinterleaved = frame_initial_decode(frame); let stream_part = &deinterleaved[12..]; - let stream = fec::decode(stream_part, 144, p_2)?; + let (stream, errors) = fec::decode(stream_part, 144, p_2)?; let frame_num = u16::from_be_bytes([stream[0], stream[1]]); let eos = (frame_num & 0x8000) > 0; let frame_num = frame_num & 0x7fff; // higher layer has to handle wraparound @@ -125,21 +125,24 @@ pub(crate) fn parse_stream(frame: &[f32] /* length 192 */) -> Option Option { +pub(crate) fn parse_packet(frame: &[f32] /* length 192 */) -> Option<(PacketFrame, u8)> { let deinterleaved = frame_initial_decode(frame); - let packet = fec::decode(&deinterleaved, 206, p_3)?; + let (packet, errors) = fec::decode(&deinterleaved, 206, p_3)?; let final_frame = (packet[25] & 0x80) > 0; let number = (packet[25] >> 2) & 0x1f; let counter = if final_frame { @@ -151,10 +154,13 @@ pub(crate) fn parse_packet(frame: &[f32] /* length 192 */) -> Option Option<(u8, [u8; 5])> { diff --git a/m17core/src/encode.rs b/m17core/src/encode.rs index 94d280b..0ba20d7 100644 --- a/m17core/src/encode.rs +++ b/m17core/src/encode.rs @@ -113,7 +113,7 @@ mod tests { ]); let encoded = encode_lsf(&lsf); let decoded = crate::decode::parse_lsf(&encoded); - assert_eq!(decoded, Some(lsf)); + assert!(matches!(decoded, Some((frame, _)) if frame == lsf)); } #[test] @@ -127,7 +127,7 @@ mod tests { }; let encoded = encode_stream(&stream); let decoded = crate::decode::parse_stream(&encoded); - assert_eq!(decoded, Some(stream)); + assert!(matches!(decoded, Some((frame, _)) if frame == stream)); } #[test] @@ -138,7 +138,7 @@ mod tests { }; let encoded = encode_packet(&packet); let decoded = crate::decode::parse_packet(&encoded); - assert_eq!(decoded, Some(packet)); + assert!(matches!(decoded, Some((frame, _)) if frame == packet)); let packet = PacketFrame { payload: [0u8; 25], @@ -146,7 +146,7 @@ mod tests { }; let encoded = encode_packet(&packet); let decoded = crate::decode::parse_packet(&encoded); - assert_eq!(decoded, Some(packet)); + assert!(matches!(decoded, Some((frame, _)) if frame == packet)); } #[test] diff --git a/m17core/src/fec.rs b/m17core/src/fec.rs index 5a49f87..3bf709d 100644 --- a/m17core/src/fec.rs +++ b/m17core/src/fec.rs @@ -212,7 +212,7 @@ pub(crate) fn decode( type3: &[u8], // up to len 46 input_len: usize, puncture: fn(usize) -> (bool, bool), -) -> Option<[u8; 30]> { +) -> Option<([u8; 30], u8)> { let type3_bits = Bits::new(type3); let mut type3_iter = type3_bits.iter(); let mut table = [[0u8; 32]; 244]; @@ -266,7 +266,7 @@ pub(crate) fn decode( }; } } - Some(out) + Some((out, *best)) } } @@ -332,7 +332,7 @@ mod tests { let encoded = encode(&lsf, 240, p_1); assert_eq!(encoded, expected_encoded); let decoded = decode(&encoded, 240, p_1); - assert_eq!(decoded, Some(lsf)); + assert_eq!(decoded, Some((lsf, 0))); } #[test] @@ -352,7 +352,7 @@ mod tests { if idx == 100 { assert_eq!(decoded, None); // 7 bits is too much damage } else { - assert_eq!(decoded, Some(lsf)); // recovered from errors + assert!(matches!(decoded, Some((frame, _)) if frame == lsf)); // recovered from errors } } } diff --git a/m17core/src/modem.rs b/m17core/src/modem.rs index 255678f..e7f249f 100644 --- a/m17core/src/modem.rs +++ b/m17core/src/modem.rs @@ -9,7 +9,11 @@ use crate::shaping::RRC_48K; use log::debug; pub trait Demodulator { - fn demod(&mut self, sample: i16) -> Option; + /// Handle the next sample. + /// + /// If a frame can be decoded, return it, along with an indication of many errors were fixed by FEC. + fn demod(&mut self, sample: i16) -> Option<(Frame, u8)>; + /// Does somebody else appear to be transmitting at the moment? fn data_carrier_detect(&self) -> bool; } @@ -67,7 +71,7 @@ impl SoftDemodulator { } impl Demodulator for SoftDemodulator { - fn demod(&mut self, sample: i16) -> Option { + fn demod(&mut self, sample: i16) -> Option<(Frame, u8)> { self.filter_win[self.filter_cursor] = sample; self.filter_cursor = (self.filter_cursor + 1) % 81; let mut out: f32 = 0.0; @@ -102,21 +106,21 @@ impl Demodulator for SoftDemodulator { } match c.burst { SyncBurst::Lsf => { - if let Some(frame) = parse_lsf(&pkt_samples) { - return Some(Frame::Lsf(frame)); + if let Some((frame, errors)) = parse_lsf(&pkt_samples) { + return Some((Frame::Lsf(frame), errors)); } } SyncBurst::Bert => { // TODO: BERT } SyncBurst::Stream => { - if let Some(frame) = parse_stream(&pkt_samples) { - return Some(Frame::Stream(frame)); + if let Some((frame, errors)) = parse_stream(&pkt_samples) { + return Some((Frame::Stream(frame), errors)); } } SyncBurst::Packet => { - if let Some(frame) = parse_packet(&pkt_samples) { - return Some(Frame::Packet(frame)); + if let Some((frame, errors)) = parse_packet(&pkt_samples) { + return Some((Frame::Packet(frame), errors)); } } SyncBurst::Preamble | SyncBurst::EndOfTransmission => { diff --git a/tools/m17rt-fastdemod/Cargo.toml b/tools/m17rt-fastdemod/Cargo.toml new file mode 100644 index 0000000..18f08a1 --- /dev/null +++ b/tools/m17rt-fastdemod/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "m17rt-fastdemod" +version = "0.1.0" +edition = "2024" +license = "MIT" +authors = ["Thomas Karpiniec Result<(), Box> { + env_logger::init(); + let args = Args::parse(); + + let mut file = File::open(&args.input)?; + let mut baseband = vec![]; + file.read_to_end(&mut baseband)?; + + let mut total = 0; + let mut demod = SoftDemodulator::new(); + for (idx, sample) in baseband + .chunks(2) + .map(|pair| i16::from_le_bytes([pair[0], pair[1]])) + .enumerate() + { + if let Some((frame, errors)) = demod.demod(sample) { + total += 1; + let frame_desc = match frame { + Frame::Lsf(_) => "lsf", + Frame::Stream(_) => "stream", + Frame::Packet(_) => "packet", + }; + println!("sample {}: {} with {} errors", idx, frame_desc, errors); + } + } + + println!("\ntotal successful decodes: {}", total); + + Ok(()) +} -- 2.39.5