Skip to main content

radio_utils_cw_decoder/
lib.rs

1#![no_std]
2#![forbid(unsafe_code)]
3
4//! Streaming CW (Morse code) decoder.
5//!
6//! `no_std`, no allocator. All state lives inline in [`Decoder`]: a
7//! packed `u8` accumulator for the in-flight character and a
8//! `heapless::Deque<u8, 32>` ring of recently decoded ASCII bytes.
9//!
10//! ## Usage
11//!
12//! Feed the decoder with [`Decoder::on_transition`] whenever the keyed
13//! line flips (key-down → key-up or vice versa), passing the current
14//! timestamp in microseconds and the sender's WPM. Call
15//! [`Decoder::poll`] periodically (e.g. every UI tick) so the decoder
16//! can flush a completed character once the inter-character silence
17//! threshold elapses — without [`Decoder::poll`] the trailing character
18//! of a transmission would only appear when the *next* key-down arrives.
19//!
20//! ## Timing model
21//!
22//! At `wpm` words per minute the canonical dit duration is
23//! `1_200_000 / wpm` microseconds. Standard Morse spacing is:
24//!
25//! | element                | dits   |
26//! |------------------------|--------|
27//! | dit pulse              | 1      |
28//! | dah pulse              | 3      |
29//! | intra-character gap    | 1      |
30//! | inter-character gap    | 3      |
31//! | inter-word gap         | 7      |
32//!
33//! The decoder uses these thresholds:
34//!
35//! * pulse ≥ `2 × dit` → dah (otherwise dit)
36//! * gap ≥ `2 × dit`   → emit the in-flight character (inter-character)
37//! * gap ≥ `6 × dit`   → also emit a space (inter-word; 6 leaves a one-dit
38//!   tolerance below the canonical 7-dit boundary so slightly-clipped
39//!   sending still produces word breaks)
40
41/// Maximum decoded characters retained in the recent-history ring.
42/// Older characters are evicted from the front on overflow.
43pub const HISTORY_CAPACITY: usize = 32;
44
45/// Streaming CW decoder. See the module docs for usage.
46pub struct Decoder {
47    state: State,
48    last_transition_us: u64,
49    /// In-flight Morse code as a packed bitstring: a leading `1`
50    /// sentinel marks "start of code", followed by `0` (dit) or `1`
51    /// (dah) per element. Reset to `1` after each emitted character.
52    /// `0` is the sentinel for "overflowed / invalid"; that pending
53    /// character is silently dropped on the next emission.
54    accum: u8,
55    /// Set after emitting an inter-word space so a sustained silence
56    /// doesn't keep emitting more spaces on every [`Decoder::poll`].
57    /// Cleared on the next key-down.
58    space_emitted: bool,
59    history: heapless::Deque<u8, HISTORY_CAPACITY>,
60}
61
62#[derive(Clone, Copy, PartialEq, Eq)]
63enum State {
64    /// No transitions yet — initial state at construction.
65    Idle,
66    /// Key is currently asserted; a pulse is in progress.
67    Down,
68    /// Key is currently released; a gap is in progress.
69    Up,
70}
71
72impl Decoder {
73    /// Construct an empty decoder. `const` so it can be placed in a
74    /// `static` without lazy initialisation.
75    pub const fn new() -> Self {
76        Self {
77            state: State::Idle,
78            last_transition_us: 0,
79            accum: 1,
80            space_emitted: false,
81            history: heapless::Deque::new(),
82        }
83    }
84
85    /// Feed a key-line transition.
86    ///
87    /// `now_us` is the absolute time of the edge — any monotonic clock
88    /// works since only deltas are used. `key_down` is the new state of
89    /// the line. `wpm` is the sender's speed; thresholds derive from it.
90    ///
91    /// O(1) work — safe to call from a high-priority context.
92    pub fn on_transition(&mut self, now_us: u64, key_down: bool, wpm: u8) {
93        let dit_us = dit_duration_us(wpm) as u64;
94        let elapsed = now_us.saturating_sub(self.last_transition_us);
95        if key_down {
96            if self.state == State::Up {
97                self.process_gap(elapsed, dit_us);
98            }
99            self.state = State::Down;
100        } else {
101            if self.state == State::Down {
102                self.process_pulse(elapsed, dit_us);
103            }
104            self.state = State::Up;
105            self.space_emitted = false;
106        }
107        self.last_transition_us = now_us;
108    }
109
110    /// Poll the decoder so pending characters and inter-word spaces
111    /// get flushed during extended silence.
112    ///
113    /// Call at a regular cadence (e.g. once per UI tick). Idempotent
114    /// during a single sustained silence — repeated calls emit at most
115    /// one character and one space.
116    ///
117    /// O(1) work.
118    pub fn poll(&mut self, now_us: u64, wpm: u8) {
119        if self.state != State::Up {
120            return;
121        }
122        let dit_us = dit_duration_us(wpm) as u64;
123        let gap = now_us.saturating_sub(self.last_transition_us);
124        self.process_gap(gap, dit_us);
125    }
126
127    /// Copy the recent decoded history (oldest-first ASCII bytes) into
128    /// `out`. Returns the number of bytes written. Does not mutate.
129    pub fn snapshot(&self, out: &mut [u8]) -> usize {
130        let mut n = 0;
131        for &ch in self.history.iter() {
132            if n >= out.len() {
133                break;
134            }
135            out[n] = ch;
136            n += 1;
137        }
138        n
139    }
140
141    /// Number of characters currently in the recent-history ring.
142    pub fn len(&self) -> usize {
143        self.history.len()
144    }
145
146    /// True iff no characters have been decoded yet (or [`Decoder::clear`]
147    /// was called).
148    pub fn is_empty(&self) -> bool {
149        self.history.is_empty()
150    }
151
152    /// Drop the recent history and reset in-flight Morse state. The
153    /// next pulse / gap is treated as the start of a fresh stream.
154    pub fn clear(&mut self) {
155        self.history.clear();
156        self.accum = 1;
157        self.space_emitted = false;
158        self.state = State::Idle;
159    }
160
161    fn process_pulse(&mut self, pulse_us: u64, dit_us: u64) {
162        // Invariants on `accum` going into this function:
163        //   * `accum == 0`  → in-flight char was already invalidated;
164        //     skip until the next gap clears it.
165        //   * `accum == 1`  → start of a new character (just the sentinel).
166        //   * `accum` in `[2, 127]` → 1..6 elements accumulated; sentinel
167        //     still in the top half of the u8. Valid Morse is ≤ 6
168        //     elements so this is the full legal range emitted to
169        //     LOOKUP at gap time.
170        //   * `accum` in `[128, 255]` → 7th element would shift the
171        //     sentinel out of the u8. Caught below and zeroed.
172        if self.accum == 0 {
173            return;
174        }
175        if self.accum & 0b1000_0000 != 0 {
176            self.accum = 0;
177            return;
178        }
179        let bit = if pulse_us >= 2 * dit_us { 1 } else { 0 };
180        self.accum = (self.accum << 1) | bit;
181    }
182
183    fn process_gap(&mut self, gap_us: u64, dit_us: u64) {
184        if gap_us >= 6 * dit_us {
185            self.emit_pending_char();
186            if !self.space_emitted {
187                self.push_char(b' ');
188                self.space_emitted = true;
189            }
190        } else if gap_us >= 2 * dit_us {
191            self.emit_pending_char();
192        }
193        // < 2 × dit: intra-character gap; in-flight character is still
194        // accumulating.
195    }
196
197    fn emit_pending_char(&mut self) {
198        if self.accum > 1 && self.accum < 128 {
199            let ch = LOOKUP[self.accum as usize];
200            if ch != 0 {
201                self.push_char(ch);
202            }
203        }
204        self.accum = 1;
205    }
206
207    fn push_char(&mut self, ch: u8) {
208        if self.history.is_full() {
209            let _ = self.history.pop_front();
210        }
211        let _ = self.history.push_back(ch);
212    }
213}
214
215impl Default for Decoder {
216    fn default() -> Self {
217        Self::new()
218    }
219}
220
221fn dit_duration_us(wpm: u8) -> u32 {
222    1_200_000 / wpm.max(1) as u32
223}
224
225// ── Morse → ASCII lookup ──────────────────────────────────────────
226// Indexed by the packed Morse representation: leading 1 sentinel, then
227// 0 (dit) or 1 (dah) per element. So E (.) = 0b10 = 2, T (-) = 0b11 = 3,
228// A (.-) = 0b101 = 5, and so on. Entries left at 0 are "no character"
229// (unallocated codes or codes we choose not to decode).
230const LOOKUP: [u8; 128] = {
231    let mut t = [0u8; 128];
232    // Letters
233    t[2] = b'E';
234    t[3] = b'T';
235    t[4] = b'I';
236    t[5] = b'A';
237    t[6] = b'N';
238    t[7] = b'M';
239    t[8] = b'S';
240    t[9] = b'U';
241    t[10] = b'R';
242    t[11] = b'W';
243    t[12] = b'D';
244    t[13] = b'K';
245    t[14] = b'G';
246    t[15] = b'O';
247    t[16] = b'H';
248    t[17] = b'V';
249    t[18] = b'F';
250    t[20] = b'L';
251    t[22] = b'P';
252    t[23] = b'J';
253    t[24] = b'B';
254    t[25] = b'X';
255    t[26] = b'C';
256    t[27] = b'Y';
257    t[28] = b'Z';
258    t[29] = b'Q';
259    // Digits
260    t[32] = b'5';
261    t[33] = b'4';
262    t[35] = b'3';
263    t[39] = b'2';
264    t[47] = b'1';
265    t[48] = b'6';
266    t[56] = b'7';
267    t[60] = b'8';
268    t[62] = b'9';
269    t[63] = b'0';
270    // Punctuation
271    t[49] = b'='; // -...-   BT (paragraph break / "=")
272    t[50] = b'/'; // -..-.
273    t[76] = b'?'; // ..--..
274    t[85] = b'.'; // .-.-.-
275    t[115] = b','; // --..--
276    t
277};
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    /// Drive a sequence of (key_down, duration_us) transitions through
284    /// a fresh decoder and return its decoded text (oldest-first).
285    /// After the final transition, polls once at the same timestamp so
286    /// any in-flight character that completed via the last `(false, ...)`
287    /// gap gets flushed without adding extra trailing silence.
288    fn decode(wpm: u8, durations: &[(bool, u64)]) -> heapless::String<64> {
289        let mut d = Decoder::new();
290        let mut now = 0u64;
291        for &(k, dur) in durations {
292            d.on_transition(now, k, wpm);
293            now += dur;
294        }
295        d.poll(now, wpm);
296        let mut buf = [0u8; 64];
297        let n = d.snapshot(&mut buf);
298        let mut out: heapless::String<64> = heapless::String::new();
299        for &b in &buf[..n] {
300            let _ = out.push(b as char);
301        }
302        out
303    }
304
305    const fn dit_us(wpm: u8) -> u64 {
306        1_200_000 / wpm as u64
307    }
308
309    #[test]
310    fn decodes_single_dit_as_e() {
311        let d = dit_us(20);
312        // Pulse 1 dit, then 4-dit gap to flush.
313        let r = decode(20, &[(true, d), (false, 4 * d)]);
314        assert_eq!(r.as_str(), "E");
315    }
316
317    #[test]
318    fn decodes_single_dah_as_t() {
319        let d = dit_us(20);
320        let r = decode(20, &[(true, 3 * d), (false, 4 * d)]);
321        assert_eq!(r.as_str(), "T");
322    }
323
324    #[test]
325    fn decodes_paris() {
326        let d = dit_us(20);
327        let s = 3 * d;
328        let g_intra = d;
329        let g_inter = 3 * d;
330        // Trailing gap < 6 × dit so we don't get a trailing space.
331        let g_final = 4 * d;
332        let seq = &[
333            // P = .--.
334            (true, d),
335            (false, g_intra),
336            (true, s),
337            (false, g_intra),
338            (true, s),
339            (false, g_intra),
340            (true, d),
341            (false, g_inter),
342            // A = .-
343            (true, d),
344            (false, g_intra),
345            (true, s),
346            (false, g_inter),
347            // R = .-.
348            (true, d),
349            (false, g_intra),
350            (true, s),
351            (false, g_intra),
352            (true, d),
353            (false, g_inter),
354            // I = ..
355            (true, d),
356            (false, g_intra),
357            (true, d),
358            (false, g_inter),
359            // S = ...
360            (true, d),
361            (false, g_intra),
362            (true, d),
363            (false, g_intra),
364            (true, d),
365            (false, g_final),
366        ];
367        assert_eq!(decode(20, seq).as_str(), "PARIS");
368    }
369
370    #[test]
371    fn emits_word_space_after_7_dit_gap() {
372        let d = dit_us(20);
373        let s = 3 * d;
374        let seq = &[
375            (true, s),
376            (false, 7 * d), // T then word break
377            (true, d),
378            (false, 4 * d), // E + flush
379        ];
380        assert_eq!(decode(20, seq).as_str(), "T E");
381    }
382
383    #[test]
384    fn space_at_exact_inter_word_threshold() {
385        // Boundary: gap of exactly 6 × dit must emit the space (the
386        // `>=` branch in `process_gap`). 7-dit and 5-dit cases are
387        // covered by the surrounding tests; this nails the threshold
388        // itself.
389        let d = dit_us(20);
390        let s = 3 * d;
391        let seq = &[(true, s), (false, 6 * d), (true, d), (false, 4 * d)];
392        assert_eq!(decode(20, seq).as_str(), "T E");
393    }
394
395    #[test]
396    fn no_space_below_inter_word_threshold() {
397        let d = dit_us(20);
398        let s = 3 * d;
399        // 5 × dit gap is above inter-char (2 ×) but below inter-word
400        // (6 ×) — flush the char but no space.
401        let seq = &[(true, s), (false, 5 * d), (true, d), (false, 4 * d)];
402        assert_eq!(decode(20, seq).as_str(), "TE");
403    }
404
405    #[test]
406    fn decodes_digits() {
407        let d = dit_us(20);
408        let s = 3 * d;
409        let g_intra = d;
410        let g_inter = 3 * d;
411        let g_final = 4 * d;
412        // "73"
413        let seq = &[
414            // 7 = --...
415            (true, s),
416            (false, g_intra),
417            (true, s),
418            (false, g_intra),
419            (true, d),
420            (false, g_intra),
421            (true, d),
422            (false, g_intra),
423            (true, d),
424            (false, g_inter),
425            // 3 = ...--
426            (true, d),
427            (false, g_intra),
428            (true, d),
429            (false, g_intra),
430            (true, d),
431            (false, g_intra),
432            (true, s),
433            (false, g_intra),
434            (true, s),
435            (false, g_final),
436        ];
437        assert_eq!(decode(20, seq).as_str(), "73");
438    }
439
440    #[test]
441    fn poll_alone_flushes_pending_character() {
442        // Don't feed a final inter-char gap — rely on poll to detect
443        // the silence and emit. Mirrors the firmware UI pattern where
444        // the periodic poll is what produces the last char of a
445        // transmission.
446        let d = dit_us(20);
447        let mut dec = Decoder::new();
448        dec.on_transition(0, true, 20); // key down
449        dec.on_transition(d, false, 20); // key up after 1 dit
450                                         // No further transitions. Poll 4 dits after the last edge.
451        dec.poll(d + 4 * d, 20);
452        let mut buf = [0u8; 4];
453        let n = dec.snapshot(&mut buf);
454        assert_eq!(&buf[..n], b"E");
455    }
456
457    #[test]
458    fn repeated_poll_during_silence_emits_one_space() {
459        let d = dit_us(20);
460        let mut dec = Decoder::new();
461        dec.on_transition(0, true, 20);
462        dec.on_transition(d, false, 20);
463        // Poll three times deep into the inter-word silence — should
464        // produce "E " once, not "E   ".
465        for _ in 0..3 {
466            dec.poll(d + 10 * d, 20);
467        }
468        let mut buf = [0u8; 8];
469        let n = dec.snapshot(&mut buf);
470        assert_eq!(&buf[..n], b"E ");
471    }
472
473    #[test]
474    fn clear_resets_history_and_in_flight() {
475        let d = dit_us(20);
476        let mut dec = Decoder::new();
477        dec.on_transition(0, true, 20);
478        dec.on_transition(d, false, 20);
479        dec.poll(d + 4 * d, 20);
480        assert_eq!(dec.len(), 1);
481        dec.clear();
482        assert!(dec.is_empty());
483        // After clear, a fresh transmission decodes from scratch.
484        dec.on_transition(0, true, 20);
485        dec.on_transition(3 * d, false, 20);
486        dec.poll(3 * d + 4 * d, 20);
487        let mut buf = [0u8; 4];
488        let n = dec.snapshot(&mut buf);
489        assert_eq!(&buf[..n], b"T");
490    }
491
492    #[test]
493    fn overflowed_character_silently_dropped() {
494        // 10 dits in one "character" — way more than the 6 allowed by
495        // valid Morse codes. Decoder should mark in-flight invalid and
496        // emit nothing on the next gap.
497        let d = dit_us(20);
498        let mut dec = Decoder::new();
499        let mut now = 0u64;
500        let mut last_edge = 0u64;
501        for _ in 0..10 {
502            dec.on_transition(now, true, 20);
503            now += d;
504            dec.on_transition(now, false, 20);
505            last_edge = now;
506            now += d; // intra-char gap
507        }
508        // Poll at 3 × dit silence after the last edge — enough to
509        // trigger inter-char emit (≥ 2 × dit), well below the 6 × dit
510        // inter-word threshold so no trailing space is added.
511        dec.poll(last_edge + 3 * d, 20);
512        assert!(
513            dec.is_empty(),
514            "expected no decoded chars from overflow, got len={}",
515            dec.len()
516        );
517    }
518
519    #[test]
520    fn history_evicts_oldest_on_overflow() {
521        // Send 40 dits with inter-char gaps; the ring holds only 32.
522        let d = dit_us(20);
523        let mut dec = Decoder::new();
524        let mut now = 0u64;
525        for _ in 0..40 {
526            dec.on_transition(now, true, 20);
527            now += d;
528            dec.on_transition(now, false, 20);
529            now += 3 * d;
530        }
531        dec.poll(now + 2 * d, 20);
532        // Capacity is 32 — last 32 'E's retained, first 8 evicted.
533        let mut buf = [0u8; 64];
534        let n = dec.snapshot(&mut buf);
535        assert_eq!(n, HISTORY_CAPACITY);
536        assert!(buf[..n].iter().all(|&b| b == b'E'));
537    }
538}