From 2c7d53c113f4e19f24c928b2c5eca1c3f6f799af Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Wed, 14 May 2025 19:30:04 +1000 Subject: [PATCH] Voice UDP to RF conversion, and better sample rate management in codec2 --- Cargo.lock | 20 +++++ Cargo.toml | 0 buildscripts/build.sh | 0 buildscripts/dist-generic.sh | 0 buildscripts/dist.sh | 0 buildscripts/lint.sh | 0 buildscripts/test.sh | 0 m17app/Cargo.toml | 0 m17app/src/lib.rs | 0 m17app/src/link_setup.rs | 2 +- m17codec2/Cargo.toml | 1 + m17codec2/src/lib.rs | 74 ++++++++++++++----- m17codec2/src/soundcards.rs | 61 +++++++++++++++ m17core/Cargo.toml | 0 m17core/src/address.rs | 0 m17core/src/bits.rs | 0 m17core/src/crc.rs | 0 m17core/src/decode.rs | 0 m17core/src/fec.rs | 0 m17core/src/interleave.rs | 0 m17core/src/lib.rs | 0 m17core/src/protocol.rs | 0 m17core/src/random.rs | 0 m17core/src/reflector/convert.rs | 58 +++++++++++++++ m17core/src/reflector/mod.rs | 5 ++ .../src/{reflector.rs => reflector/packet.rs} | 3 +- m17core/src/shaping.rs | 0 tools/m17rt-demod/Cargo.toml | 0 tools/m17rt-demod/src/main.rs | 0 tools/m17rt-rxpacket/Cargo.toml | 0 tools/m17rt-rxpacket/src/main.rs | 0 31 files changed, 202 insertions(+), 22 deletions(-) mode change 100755 => 100644 Cargo.lock mode change 100755 => 100644 Cargo.toml mode change 100755 => 100644 buildscripts/build.sh mode change 100755 => 100644 buildscripts/dist-generic.sh mode change 100755 => 100644 buildscripts/dist.sh mode change 100755 => 100644 buildscripts/lint.sh mode change 100755 => 100644 buildscripts/test.sh mode change 100755 => 100644 m17app/Cargo.toml mode change 100755 => 100644 m17app/src/lib.rs mode change 100755 => 100644 m17codec2/Cargo.toml mode change 100755 => 100644 m17codec2/src/lib.rs create mode 100644 m17codec2/src/soundcards.rs mode change 100755 => 100644 m17core/Cargo.toml mode change 100755 => 100644 m17core/src/address.rs mode change 100755 => 100644 m17core/src/bits.rs mode change 100755 => 100644 m17core/src/crc.rs mode change 100755 => 100644 m17core/src/decode.rs mode change 100755 => 100644 m17core/src/fec.rs mode change 100755 => 100644 m17core/src/interleave.rs mode change 100755 => 100644 m17core/src/lib.rs mode change 100755 => 100644 m17core/src/protocol.rs mode change 100755 => 100644 m17core/src/random.rs create mode 100644 m17core/src/reflector/convert.rs create mode 100644 m17core/src/reflector/mod.rs rename m17core/src/{reflector.rs => reflector/packet.rs} (99%) mode change 100755 => 100644 m17core/src/shaping.rs mode change 100755 => 100644 tools/m17rt-demod/Cargo.toml mode change 100755 => 100644 tools/m17rt-demod/src/main.rs mode change 100755 => 100644 tools/m17rt-rxpacket/Cargo.toml mode change 100755 => 100644 tools/m17rt-rxpacket/src/main.rs diff --git a/Cargo.lock b/Cargo.lock old mode 100755 new mode 100644 index e9719ee..294bbc3 --- a/Cargo.lock +++ b/Cargo.lock @@ -471,6 +471,7 @@ dependencies = [ "log", "m17app", "m17core", + "rubato", "thiserror 2.0.11", ] @@ -616,6 +617,15 @@ dependencies = [ "syn", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -737,6 +747,16 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rubato" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5258099699851cfd0082aeb645feb9c084d9a5e1f1b8d5372086b989fc5e56a1" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "rustc-hash" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml old mode 100755 new mode 100644 diff --git a/buildscripts/build.sh b/buildscripts/build.sh old mode 100755 new mode 100644 diff --git a/buildscripts/dist-generic.sh b/buildscripts/dist-generic.sh old mode 100755 new mode 100644 diff --git a/buildscripts/dist.sh b/buildscripts/dist.sh old mode 100755 new mode 100644 diff --git a/buildscripts/lint.sh b/buildscripts/lint.sh old mode 100755 new mode 100644 diff --git a/buildscripts/test.sh b/buildscripts/test.sh old mode 100755 new mode 100644 diff --git a/m17app/Cargo.toml b/m17app/Cargo.toml old mode 100755 new mode 100644 diff --git a/m17app/src/lib.rs b/m17app/src/lib.rs old mode 100755 new mode 100644 diff --git a/m17app/src/link_setup.rs b/m17app/src/link_setup.rs index 2a701de..2394725 100644 --- a/m17app/src/link_setup.rs +++ b/m17app/src/link_setup.rs @@ -79,7 +79,7 @@ impl M17Address { Ok(Self(Address::Callsign(Callsign(address)))) } - pub(crate) fn address(&self) -> &Address { + pub fn address(&self) -> &Address { &self.0 } } diff --git a/m17codec2/Cargo.toml b/m17codec2/Cargo.toml old mode 100755 new mode 100644 index 4b8014c..29f8323 --- a/m17codec2/Cargo.toml +++ b/m17codec2/Cargo.toml @@ -19,4 +19,5 @@ hound = "3.5.1" m17core = { version = "0.1", path = "../m17core" } m17app = { version = "0.1", path = "../m17app" } log = "0.4.22" +rubato = { version = "0.16.2", "default-features" = false } thiserror = "2.0.11" diff --git a/m17codec2/src/lib.rs b/m17codec2/src/lib.rs old mode 100755 new mode 100644 index 879000d..693cb09 --- a/m17codec2/src/lib.rs +++ b/m17codec2/src/lib.rs @@ -12,6 +12,9 @@ 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; @@ -25,6 +28,10 @@ use std::time::Duration; use std::time::Instant; use thiserror::Error; +pub mod soundcards; + +/// 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; @@ -35,8 +42,6 @@ pub fn decode_codec2>(data: &[u8], out_path: P) -> Vec { codec.decode(&mut samples, &data[i * 8..((i + 1) * 8)]); all_samples.append(&mut samples); } - - // dude this works let mut speech_out = File::create(out_path).unwrap(); for b in &all_samples { speech_out.write_all(&b.to_le_bytes()).unwrap(); @@ -58,8 +63,8 @@ impl Codec2Adapter { out_buf: VecDeque::new(), codec2: Codec2::new(Codec2Mode::MODE_3200), end_tx: None, + resampler: None, })), - // TODO: this doesn't work on rpi. Use default_output_device() by default output_card: None, } } @@ -81,6 +86,7 @@ struct AdapterState { out_buf: VecDeque, codec2: Codec2, end_tx: Option>, + resampler: Option>, } impl StreamAdapter for Codec2Adapter { @@ -94,7 +100,21 @@ impl StreamAdapter for Codec2Adapter { 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 - setup_rx.recv()? + 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: 256, + 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> { @@ -116,12 +136,21 @@ impl StreamAdapter for Codec2Adapter { 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() < 1024 { + if state.out_buf.len() < 8192 { let mut samples = [i16::EQUILIBRIUM; 160]; // while assuming 3200 state.codec2.decode(&mut samples, encoded); - // TODO: maybe get rid of VecDeque so we can decode directly into ring buffer? - for s in samples { - state.out_buf.push_back(s); + 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"); @@ -130,17 +159,17 @@ impl StreamAdapter for Codec2Adapter { } } -fn output_cb(data: &mut [i16], state: &Mutex) { +fn output_cb(data: &mut [i16], state: &Mutex, channels: u16) { let mut state = state.lock().unwrap(); - for d in data { - *d = state.out_buf.pop_front().unwrap_or(i16::EQUILIBRIUM); + 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>, + setup_tx: Sender>, state: Arc>, output_card: Option, ) { @@ -177,10 +206,9 @@ fn stream_thread( return; } }; - // TODO: channels == 1 doesn't work on a Raspberry Pi - // make this configurable and support interleaving LRLR stereo samples if using 2 channels - let config = match configs.find(|c| c.channels() == 1 && c.sample_format() == SampleFormat::I16) - { + 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( @@ -190,11 +218,19 @@ fn stream_thread( } }; - let config = config.with_sample_rate(SampleRate(8000)); + let target_sample_rate = + if config.min_sample_rate().0 <= 8000 && config.max_sample_rate().0 >= 8000 { + 8000 + } else { + config.max_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); + output_cb(data, &state, channels); }, |e| { // trigger end_tx here? always more edge cases @@ -219,7 +255,7 @@ fn stream_thread( return; } } - let _ = setup_tx.send(Ok(())); + 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/soundcards.rs b/m17codec2/src/soundcards.rs new file mode 100644 index 0000000..24cff0a --- /dev/null +++ b/m17codec2/src/soundcards.rs @@ -0,0 +1,61 @@ +//! 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/m17core/Cargo.toml b/m17core/Cargo.toml old mode 100755 new mode 100644 diff --git a/m17core/src/address.rs b/m17core/src/address.rs old mode 100755 new mode 100644 diff --git a/m17core/src/bits.rs b/m17core/src/bits.rs old mode 100755 new mode 100644 diff --git a/m17core/src/crc.rs b/m17core/src/crc.rs old mode 100755 new mode 100644 diff --git a/m17core/src/decode.rs b/m17core/src/decode.rs old mode 100755 new mode 100644 diff --git a/m17core/src/fec.rs b/m17core/src/fec.rs old mode 100755 new mode 100644 diff --git a/m17core/src/interleave.rs b/m17core/src/interleave.rs old mode 100755 new mode 100644 diff --git a/m17core/src/lib.rs b/m17core/src/lib.rs old mode 100755 new mode 100644 diff --git a/m17core/src/protocol.rs b/m17core/src/protocol.rs old mode 100755 new mode 100644 diff --git a/m17core/src/random.rs b/m17core/src/random.rs old mode 100755 new mode 100644 diff --git a/m17core/src/reflector/convert.rs b/m17core/src/reflector/convert.rs new file mode 100644 index 0000000..8e5a96d --- /dev/null +++ b/m17core/src/reflector/convert.rs @@ -0,0 +1,58 @@ +//! Utilities for converting streams between UDP and RF representations + +use crate::protocol::{LsfFrame, StreamFrame}; + +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. +pub struct VoiceToRf { + /// Link Setup most recently acquired + lsf: Option, + /// Which LICH part we are going to emit next, 0-5 + lich_cnt: usize, +} + +impl VoiceToRf { + pub fn new() -> Self { + Self { + lsf: None, + lich_cnt: 0, + } + } + + /// For a Voice packet received from a reflector, return the frames that would be transmitted + /// on RF, including by reconstructing the LICH parts of the stream frame. + /// + /// If this is the start of a new or different stream transmission, this returns the Link Setup + /// Frame which comes first, then the first associated Stream frame. + /// + /// If this is a continuation of a transmission matching the previous LSF, then it returns only + /// the Stream frame. + pub fn next(&mut self, voice: &Voice) -> (Option, StreamFrame) { + let this_lsf = voice.link_setup_frame(); + let emit_lsf = if Some(&this_lsf) != self.lsf.as_ref() { + self.lsf = Some(this_lsf.clone()); + self.lich_cnt = 0; + true + } else { + false + }; + let lsf = self.lsf.as_ref().unwrap(); + let stream = StreamFrame { + lich_idx: self.lich_cnt as u8, + lich_part: (&lsf.0[self.lich_cnt * 5..(self.lich_cnt + 1) * 5]) + .try_into() + .unwrap(), + frame_number: voice.frame_number(), + end_of_stream: voice.is_end_of_stream(), + stream_data: voice.payload().try_into().unwrap(), + }; + let lsf = if emit_lsf { self.lsf.clone() } else { None }; + if voice.is_end_of_stream() { + self.lsf = None; + } + (lsf, stream) + } +} diff --git a/m17core/src/reflector/mod.rs b/m17core/src/reflector/mod.rs new file mode 100644 index 0000000..057bae0 --- /dev/null +++ b/m17core/src/reflector/mod.rs @@ -0,0 +1,5 @@ +// Based on https://github.com/n7tae/mrefd/blob/master/Packet-Description.md +// and the main M17 specification + +pub mod convert; +pub mod packet; diff --git a/m17core/src/reflector.rs b/m17core/src/reflector/packet.rs similarity index 99% rename from m17core/src/reflector.rs rename to m17core/src/reflector/packet.rs index 657d864..3f746d6 100644 --- a/m17core/src/reflector.rs +++ b/m17core/src/reflector/packet.rs @@ -1,5 +1,4 @@ -// Based on https://github.com/n7tae/mrefd/blob/master/Packet-Description.md -// and the main M17 specification +//! UDP datagrams and binary encoding/decoding for client-reflector and reflector-reflector communication. use crate::address::Address; use crate::protocol::LsfFrame; diff --git a/m17core/src/shaping.rs b/m17core/src/shaping.rs old mode 100755 new mode 100644 diff --git a/tools/m17rt-demod/Cargo.toml b/tools/m17rt-demod/Cargo.toml old mode 100755 new mode 100644 diff --git a/tools/m17rt-demod/src/main.rs b/tools/m17rt-demod/src/main.rs old mode 100755 new mode 100644 diff --git a/tools/m17rt-rxpacket/Cargo.toml b/tools/m17rt-rxpacket/Cargo.toml old mode 100755 new mode 100644 diff --git a/tools/m17rt-rxpacket/src/main.rs b/tools/m17rt-rxpacket/src/main.rs old mode 100755 new mode 100644 -- 2.39.5