From 3903e719137aba15d30dd58b8d917965ec602400 Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Wed, 29 Jan 2025 19:24:20 +1100 Subject: [PATCH 1/1] Fix timing bugs and add documentation --- LICENCE.TXT | 2 +- README.TXT | 26 +++--- m17app/Cargo.toml | 4 +- m17app/README.md | 143 +++++++++++++++++++++++++++++++ m17app/src/app.rs | 3 +- m17app/src/lib.rs | 2 + m17app/src/rtlsdr.rs | 14 +-- m17app/src/serial.rs | 4 +- m17app/src/soundmodem.rs | 8 +- m17codec2/Cargo.toml | 4 +- m17codec2/README.md | 8 ++ m17codec2/src/lib.rs | 11 +++ m17core/README.md | 19 ++++ m17core/src/decode.rs | 2 - m17core/src/lib.rs | 1 + m17core/src/modem.rs | 11 +-- m17core/src/tnc.rs | 2 + tools/m17rt-mod/src/main.rs | 4 +- tools/m17rt-txpacket/src/main.rs | 1 + 19 files changed, 225 insertions(+), 44 deletions(-) diff --git a/LICENCE.TXT b/LICENCE.TXT index 4b4e102..df053ed 100644 --- a/LICENCE.TXT +++ b/LICENCE.TXT @@ -1,4 +1,4 @@ -Copyright (c) 2024 Thomas Karpiniec +Copyright (c) 2025 Thomas Karpiniec Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.TXT b/README.TXT index 27d4231..302b3e6 100644 --- a/README.TXT +++ b/README.TXT @@ -2,26 +2,24 @@ M17 RUST TOOLKIT ================== -As of Dec 2024 this software is still under active development. The description that follows is not fully implemented yet. - M17RT is a collection of Rust crates and utilities to make it as easy to possible to implement programs that use the M17 -Protocol for amateur radio: . +Protocol for amateur radio: . - ┌──────────────────────────────────────┐ <. - .> │ m17app │ | Fan in data from adapters to TNC + ┌──────────────────────────────────────┐ <- + -> │ m17app │ | Fan in data from adapters to TNC High level API | │ - High-level API for packets/streams │ | Fan out data from radio to adapters - For PC-based apps | │ - Sound card integration │ <. + For PC-based apps | │ - Sound card integration │ <- | │ - TCP client/server KISS │ vv KISS ^^ - .> │ - Multithreading │ <. + -> │ - Multithreading │ <- └──────────────────────────────────────┘ | Soundmodem worker thread: | Takes a sound card, PTT, ┌──────────────────────────────────────┐ | assembles the components - .> │ m17core │ | from m17core and puts it + -> │ m17core │ | from m17core and puts it Low level API | │ - M17 KISS protocol │ | behind a KISS interface. no_std, no heap | │ - TNC / CSMA │ | could be no-float | │ - M17 Data Link │ | - .> │ - Baseband Modem │ <. + -> │ - Baseband Modem │ <- └──────────────────────────────────────┘ @@ -36,16 +34,16 @@ a KISS TNC, including M17 applications that do not use this toolkit or are not w The basic structure of a program is that you will configure your TNC, use it to initialise an M17App, then add adapters to the M17App which will handle all or a subset of the traffic. They will all share the same TNC. -Codec2 support follows the same pattern - the m17codec2 crate will provide standard M17App stream adapters to handle both: - mic -> encode -> transmit stream - incoming stream -> decode -> output on sound card +Codec2 support follows the same pattern - the m17codec2 crate provides standard M17App stream adapters to handle both: + human speech audio -> encode -> transmit stream + incoming M17 stream -> decode -> output on sound card Splitting this into a separate crate serves two purposes. This reduces the dependency count if your app does not actually use codec2. It also means you can avoid statically linking LGPL code into your Rust binary if you are relying on M17RT's permissive licence. In this situation you can probably still find a way to use codec2 but it's not going to be as simple as putting this in your Cargo.toml since Rust makes dynamic linking difficult. -Finally, there will be a series of utility binaries for modulation, demodulation, creating a KISS TCP server, etc. These +Finally, there is a series of utility binaries for modulation, demodulation, creating a KISS TCP server, etc. These may be useful in their own right but their primary purpose is to test and demonstrate the toolkit. User-facing programs should be their own projects that will provide proper attention to detail for their use cases. @@ -53,6 +51,6 @@ should be their own projects that will provide proper attention to detail for th LICENCE ========= -Copyright 2024 Thomas Karpiniec +Copyright 2025 Thomas Karpiniec M17 Rust Toolkit is made available under the MIT Licence. See LICENCE.TXT for details. diff --git a/m17app/Cargo.toml b/m17app/Cargo.toml index 44a7b5f..4c2817f 100755 --- a/m17app/Cargo.toml +++ b/m17app/Cargo.toml @@ -14,7 +14,7 @@ readme = "README.md" [dependencies] cpal = "0.15.3" -m17core = { path = "../m17core" } +m17core = { version = "0.1", path = "../m17core" } log = "0.4.22" -serialport = {version = "4.7.0", default-features = false } +serialport = { version = "4.7.0", default-features = false } thiserror = "2.0.11" diff --git a/m17app/README.md b/m17app/README.md index e69de29..d6b548c 100644 --- a/m17app/README.md +++ b/m17app/README.md @@ -0,0 +1,143 @@ +# m17app + +Part of the [M17 Rust Toolkit](https://octet-stream.net/p/m17rt/). This crate provides a high-level API for working with the [M17 digital radio protocol](https://m17project.org/). It is designed for building radio software that runs on regular PCs or equivalently powerful devices like a smartphone or Raspberry Pi. You can either point it at an external TNC, or activate the built-in soundmodem to use a standard soundcard and serial PTT. + +`m17app` can be considered an easy-to-use wrapper around `m17core`, a separate crate which provides all the modem and TNC functions. + +## 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`. + +## 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: + +* **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 + +These are all traits that you can implement yourself but you can probably use one of the types already included in `m17app`. + +Provided inputs: + +* `Soundcard` - Once you have initialised a card, call `input()` to get an input source handle to provide to the `Soundmodem`. +* `RtlSdr` - Receive using an RTL-SDR dongle. This requires that the `rtl_fm` utility is installed and present in your path. +* `InputRrcFile` - Read from an M17 `.rrc` file on disk, which contains shaped baseband data as 16-bit LE 48 kHz samples. +* `NullInputSource` - Fake device that provides a continuous stream of silence. + +Provided outputs: + +* `Soundcard` - Once you have initialised a card, call `output()` to get an output sink handle. +* `OutputRrcFile` - Write transmissions to a `.rrc` on disk. +* `NullOutputSink` - Fake device that will swallow any samples it is given. + +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. + +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 app = M17App::new(soundmodem); + app.start(); +``` + +## Working with packets + +First let's transmit a packet. We will need to configure some metadata for the transmission, beginning with the source and destination callsigns. Create suitable addresses of type `M17Address`, which will validate that the address is a valid format. + +```rust,ignore + let source = M17Address::from_callsign("VK7XT-1").unwrap(); + let destination = M17Address::new_broadcast(); +``` + +All M17 transmissions require a link setup frame which includes the source and destination addresses plus other data. If you wish, you can use the raw `LsfFrame` type to create exactly the frame you want. Here we will use a convenience method to create an LSF for unencrypted packet data. + +```rust,ignore + let link_setup = LinkSetup::new_packet(&source, &destination); +``` + +Transmissions are made via a `TxHandle`, which you can create by calling `app.tx()`. We must provide the packet application type and the payload as a byte slice, up to approx 822 bytes. This sends the transmission command to the TNC, which will transmit it when the channel is clear. + +```rust,ignore + let payload = b"Hello, world!"; + app.tx() + .transmit_packet(&link_setup, PacketType::Sms, payload); +``` + +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. + +```rust,ignore +struct PacketPrinter; + +impl PacketAdapter for PacketPrinter { + fn packet_received(&self, link_setup: LinkSetup, packet_type: PacketType, content: Arc<[u8]>) { + println!( + "from {} to {} type {:?} len {}", + link_setup.source(), + link_setup.destination(), + packet_type, + content.len() + ); + println!("{}", String::from_utf8_lossy(&content)); + } +} +``` + +We instantiate one of these subscribers and provide it to our instance of `M17App`. + +```rust,ignore + app.add_packet_adapter(PacketPrinter); +``` + +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. + +Adding an adapter returns an identifier that you can use it to remove it again later if you wish. You can add an arbitrary number of adapters. Each will receive its own copy of the packet (or stream, as in the next section). + +## Working with streams + +M17 also provides streams, which are continuous transmissions of arbitrary length. Unlike packets, you are not guaranteed to receive every frame, and it is possible for a receiver to lock on to a transmission that has previously started and begin decoding it in the middle. These streams may contain voice (generally 3200 bit/s Codec2), arbitrary data, or a combination of voice and data. + +For our first example, let's see how to use the `m17codec2` helper crate to send and receive Codec2 audio. + +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()); +``` + +This is how to transmit a wave file of human speech (8 kHz, mono, 16 bit LE) as a Codec2 stream: + +```rust,ignore + WavePlayer::play( + PathBuf::from("audio.wav"), + app.tx(), // TxHandle + &M17Address::from_callsign("VK7XT-1").unwrap(), // source + &M17Address::new_broadcast(), // destination + 0, // channel access number + ); +``` + +Transmitting and receiving your own stream types works in a similar way to packets however the requirements are somewhat stricter. + +To transmit: + +* Construct a `LinkSetup` frame, possibly using the `LinkSetup::new_voice()` helper, and call `tx.transmit_stream_start(lsf)` +* Immediately construct a `StreamFrame` with data and call `tx.transmit_stream_next(stream_frame)` +* Continue sending a `StreamFrame` every 40 ms until you finish with one where `end_of_stream` is set to `true`. + +You are required to fill in two LICH-related fields in `StreamFrame` yourself. The counter should rotate from 0 to 5 (inclusive), and you can get the corresponding bytes using the `lich_part()` helper method on your original `LinkSetup`. The frame number starts at 0 and counts upward. + +To receive: + +* Create an adapter that implements trait `StreamAdapter` +* Handle the `stream_began` and `stream_data` methods +* Add it to your `M17App` diff --git a/m17app/src/app.rs b/m17app/src/app.rs index ae01976..7f17df6 100644 --- a/m17app/src/app.rs +++ b/m17app/src/app.rs @@ -1,8 +1,9 @@ use crate::adapter::{PacketAdapter, StreamAdapter}; use crate::link_setup::LinkSetup; use crate::tnc::Tnc; +use crate::{LsfFrame, PacketType, StreamFrame}; use m17core::kiss::{KissBuffer, KissCommand, KissFrame}; -use m17core::protocol::{EncryptionType, LsfFrame, PacketType, StreamFrame}; +use m17core::protocol::EncryptionType; use log::debug; use std::collections::HashMap; diff --git a/m17app/src/lib.rs b/m17app/src/lib.rs index 06a6cfa..9113515 100755 --- a/m17app/src/lib.rs +++ b/m17app/src/lib.rs @@ -1,3 +1,5 @@ +#![doc = include_str!("../README.md")] + pub mod adapter; pub mod app; pub mod error; diff --git a/m17app/src/rtlsdr.rs b/m17app/src/rtlsdr.rs index 33f8070..658aca4 100644 --- a/m17app/src/rtlsdr.rs +++ b/m17app/src/rtlsdr.rs @@ -1,21 +1,12 @@ use std::{ io::Read, process::{Child, Command, Stdio}, - sync::{ - mpsc::{sync_channel, Receiver, SyncSender}, - Arc, Mutex, RwLock, - }, - time::{Duration, Instant}, -}; - -use cpal::{ - traits::{DeviceTrait, HostTrait, StreamTrait}, - SampleFormat, SampleRate, Stream, + sync::{mpsc::SyncSender, Mutex}, }; use crate::{ error::M17Error, - soundmodem::{InputSource, OutputBuffer, OutputSink, SoundmodemEvent}, + soundmodem::{InputSource, SoundmodemEvent}, }; pub struct RtlSdr { @@ -80,6 +71,7 @@ impl InputSource for RtlSdr { } } }); + *self.rtlfm.lock().unwrap() = Some(cmd); } fn close(&self) { diff --git a/m17app/src/serial.rs b/m17app/src/serial.rs index d3eec19..8abb4b9 100644 --- a/m17app/src/serial.rs +++ b/m17app/src/serial.rs @@ -26,7 +26,9 @@ impl SerialPtt { pub fn new(port_name: &str, pin: PttPin) -> Self { // TODO: error handling let port = serialport::new(port_name, 9600).open().unwrap(); - Self { port, pin } + let mut s = Self { port, pin }; + s.ptt_off(); + s } } diff --git a/m17app/src/soundmodem.rs b/m17app/src/soundmodem.rs index 974fd45..391bfad 100644 --- a/m17app/src/soundmodem.rs +++ b/m17app/src/soundmodem.rs @@ -138,8 +138,12 @@ fn spawn_soundmodem_worker( let mut ptt = false; while let Ok(ev) = event_rx.recv() { // Update clock on TNC before we do anything - let sample_time = (start.elapsed().as_nanos() / 48000) as u64; - tnc.set_now(sample_time); + let sample_time = start.elapsed(); + let secs = sample_time.as_secs(); + let nanos = sample_time.subsec_nanos(); + // Accurate to within approx 1 sample + let now_samples = 48000 * secs + (nanos as u64 / 20833); + tnc.set_now(now_samples); // Handle event match ev { diff --git a/m17codec2/Cargo.toml b/m17codec2/Cargo.toml index e090160..01b032f 100755 --- a/m17codec2/Cargo.toml +++ b/m17codec2/Cargo.toml @@ -16,7 +16,7 @@ readme = "README.md" codec2 = "0.3.0" cpal = "0.15.3" hound = "3.5.1" -m17core = { path = "../m17core" } -m17app = { path = "../m17app" } +m17core = { version = "0.1", path = "../m17core" } +m17app = { version = "0.1", path = "../m17app" } log = "0.4.22" diff --git a/m17codec2/README.md b/m17codec2/README.md index e69de29..0475c28 100644 --- a/m17codec2/README.md +++ b/m17codec2/README.md @@ -0,0 +1,8 @@ +# m17codec2 + +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 + +**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/m17codec2/src/lib.rs b/m17codec2/src/lib.rs index 1d05f27..e4bfbac 100755 --- a/m17codec2/src/lib.rs +++ b/m17codec2/src/lib.rs @@ -1,3 +1,5 @@ +#![doc = include_str!("../README.md")] + use codec2::{Codec2, Codec2Mode}; use cpal::traits::DeviceTrait; use cpal::traits::HostTrait; @@ -40,6 +42,7 @@ pub fn decode_codec2>(data: &[u8], out_path: P) -> Vec { all_samples } +/// Subscribes to M17 streams and attempts to play the decoded Codec2 pub struct Codec2Adapter { state: Arc>, // TODO: make this configurable @@ -162,9 +165,17 @@ fn stream_thread(end: Receiver<()>, state: Arc>, output_card // 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, diff --git a/m17core/README.md b/m17core/README.md index e69de29..cee5df0 100644 --- a/m17core/README.md +++ b/m17core/README.md @@ -0,0 +1,19 @@ +# m17codec2 + +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`. + +`m17core` is `no_std`, does not perform any heap allocations, and its protocol implementations are non-blocking and sans-I/O. + +You might be interested in using this crate directly for: + +* Developing on bare metal targets where `std` is not available or appropriate +* Specialised M17 utilities or simulations + +There is an implied protocol between `SoftModulator`, `SoftDemodulator` and `SoftTnc`. For a full example see the implementation of `Soundmodem` in `m17app`. + +In brief: the rx path is that new samples will be given to `SoftDemodulator`. It may emit a frame, which should be delivered to `SoftTnc`. In turn, it may emit a KISS frame for the host. + +The tx path is a little more complicated. You must supply a ring buffer which is shared between the DAC consuming samples and the `SoftModulator` creating samples. The `SoftTnc` indicates when a transmission begins, then the flow of data is controlled by `SoftModulator` which will opportunistically draw new frames out of the TNC to keep the output buffer topped up. When the TNC indicates the end of the transmission, it will wait for the `SoftModulator` to indicate when tx will finish and PTT should be disengaged. While this is occurring, new stream frames should be delivered via `SoftTnc`'s KISS interface at an equal ratio to the output samples being read so that buffers do not overflow or underrun. + diff --git a/m17core/src/decode.rs b/m17core/src/decode.rs index 911d673..d3dd7ba 100755 --- a/m17core/src/decode.rs +++ b/m17core/src/decode.rs @@ -146,8 +146,6 @@ pub(crate) fn parse_packet(frame: &[f32] /* length 192 */) -> Option packet, None => return None, }; - // TODO: the spec is inconsistent about which bit in packet[25] is EOF - // https://github.com/M17-Project/M17_spec/issues/147 let final_frame = (packet[25] & 0x80) > 0; let number = (packet[25] >> 2) & 0x1f; let counter = if final_frame { diff --git a/m17core/src/lib.rs b/m17core/src/lib.rs index eb4a457..b971718 100755 --- a/m17core/src/lib.rs +++ b/m17core/src/lib.rs @@ -1,3 +1,4 @@ +#![doc = include_str!("../README.md")] #![allow(clippy::needless_range_loop)] #![cfg_attr(not(test), no_std)] diff --git a/m17core/src/modem.rs b/m17core/src/modem.rs index 3d4890a..43ad5ac 100644 --- a/m17core/src/modem.rs +++ b/m17core/src/modem.rs @@ -333,6 +333,7 @@ impl SoftModulator { next_len: 0, next_read: 0, tx_delay_padding: 0, + // TODO: actually set this to false when we are worried about underrun update_idle: true, idle: true, calculate_tx_end: false, @@ -384,7 +385,6 @@ impl Modulator for SoftModulator { capacity: usize, output_latency: usize, ) { - //log::debug!("modulator update_output_buffer {samples_to_play} {capacity} {output_latency}"); self.output_latency = output_latency; self.buf_capacity = capacity; self.samples_in_buf = samples_to_play; @@ -412,12 +412,9 @@ impl Modulator for SoftModulator { ModulatorFrame::Preamble { tx_delay } => { // TODO: Stop assuming 48 kHz everywhere. 24 kHz should be fine too. let tx_delay_samples = tx_delay as usize * 480; - // TxDelay and output latency have the same effect - account for whichever is bigger. - // We want our sound card DAC hitting preamble right when PTT fully engages. - // The modulator calls the shots here - TNC hands over Preamble and asserts PTT, then - // waits to be told when transmission will be complete. This estimate will not be - // made and delivered until we generate the EOT frame. - self.tx_delay_padding = tx_delay_samples.max(self.output_latency); + // Our output latency gives us a certain amount of unavoidable TxDelay + // So only introduce artificial delay if the requested TxDelay exceeds that + self.tx_delay_padding = tx_delay_samples.saturating_sub(self.output_latency); // We should be starting from a filter_win of zeroes // Transmission is effectively smeared by 80 taps and we'll capture that in EOT diff --git a/m17core/src/tnc.rs b/m17core/src/tnc.rs index a64f367..b46958c 100644 --- a/m17core/src/tnc.rs +++ b/m17core/src/tnc.rs @@ -217,6 +217,8 @@ impl SoftTnc { pub fn set_now(&mut self, now_samples: u64) { self.now = now_samples; + // TODO: expose this to higher layer so we can schedule a precise delay + // rather than waiting for some soundcard I/O event if let State::TxEndingAtTime(time) = self.state { if now_samples >= time { self.ptt = false; diff --git a/tools/m17rt-mod/src/main.rs b/tools/m17rt-mod/src/main.rs index 3bd6727..b5708e9 100644 --- a/tools/m17rt-mod/src/main.rs +++ b/tools/m17rt-mod/src/main.rs @@ -28,6 +28,8 @@ pub fn mod_test() { } fn main() { - env_logger::init(); + env_logger::builder() + .format_timestamp(Some(env_logger::TimestampPrecision::Millis)) + .init(); mod_test(); } diff --git a/tools/m17rt-txpacket/src/main.rs b/tools/m17rt-txpacket/src/main.rs index fb6eda1..916edf2 100644 --- a/tools/m17rt-txpacket/src/main.rs +++ b/tools/m17rt-txpacket/src/main.rs @@ -7,6 +7,7 @@ use m17core::protocol::PacketType; fn main() { let soundcard = Soundcard::new("plughw:CARD=Device,DEV=0").unwrap(); + soundcard.set_tx_inverted(true); let ptt = SerialPtt::new("/dev/ttyUSB0", PttPin::Rts); let soundmodem = Soundmodem::new(soundcard.input(), soundcard.output(), ptt); let app = M17App::new(soundmodem); -- 2.39.5