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}