summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Cargo.lock468
-rw-r--r--Cargo.toml13
-rw-r--r--src/decoder.rs442
-rw-r--r--src/main.rs72
5 files changed, 996 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..1d103ee
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,468 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.98"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "clap"
+version = "4.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f"
+dependencies = [
+ "clap_builder",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+[[package]]
+name = "ditdah"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "env_logger",
+ "hound",
+ "log",
+ "rubato",
+ "rustfft",
+]
+
+[[package]]
+name = "env_filter"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "env_filter",
+ "jiff",
+ "log",
+]
+
+[[package]]
+name = "hound"
+version = "3.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
+
+[[package]]
+name = "jiff"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49"
+dependencies = [
+ "jiff-static",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "log"
+version = "0.4.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
+
+[[package]]
+name = "memchr"
+version = "2.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
+
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "num-traits",
+]
+
+[[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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
+
+[[package]]
+name = "portable-atomic"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "primal-check"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
+dependencies = [
+ "num-integer",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.95"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "realfft"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677"
+dependencies = [
+ "rustfft",
+]
+
+[[package]]
+name = "regex"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+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-complex",
+ "num-integer",
+ "num-traits",
+ "realfft",
+]
+
+[[package]]
+name = "rustfft"
+version = "6.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f140db74548f7c9d7cce60912c9ac414e74df5e718dc947d514b051b42f3f4"
+dependencies = [
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "primal-check",
+ "strength_reduce",
+ "transpose",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "strength_reduce"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
+
+[[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.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "transpose"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
+dependencies = [
+ "num-integer",
+ "strength_reduce",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..eaaba43
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "ditdah"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+anyhow = "1.0.98"
+clap = "4.5.40"
+env_logger = "0.11.8"
+hound = "3.5.1"
+log = "0.4.27"
+rubato = "0.16.2"
+rustfft = "6.4.0"
diff --git a/src/decoder.rs b/src/decoder.rs
new file mode 100644
index 0000000..657bff7
--- /dev/null
+++ b/src/decoder.rs
@@ -0,0 +1,442 @@
+// A Rust implementation of the ggmorse signal processing pipeline.
+// This includes a resampler, band-pass filter, STFT for pitch detection,
+// a Goertzel filter for tone extraction, and the core decoding logic.
+
+use anyhow::{Result, bail};
+use collections::VecDeque;
+use rubato::{InterpolationParameters, InterpolationType, Resampler, SincFixedIn, WindowFunction};
+use rustfft::{Fft, FftPlanner, num_complex::Complex};
+use std::sync::Arc;
+
+// --- DSP Constants (ported from ggmorse) ---
+const FREQ_MIN_HZ: f32 = 200.0;
+const FREQ_MAX_HZ: f32 = 1200.0;
+const HISTORY_S: f32 = 3.0; // How many seconds of audio to keep for analysis
+
+// --- Biquad Filter (Corrected Implementation) ---
+#[derive(Debug, Clone, Copy)]
+pub enum FilterType {
+ HighPass,
+ LowPass,
+}
+
+pub struct BiquadFilter {
+ a0: f32,
+ a1: f32,
+ a2: f32,
+ b1: f32,
+ b2: f32,
+ x1: f32,
+ x2: f32, // Delayed inputs
+ y1: f32,
+ y2: f32, // Delayed outputs
+}
+
+impl BiquadFilter {
+ pub fn new(filter_type: FilterType, cutoff_hz: f32, sample_rate: u32) -> Self {
+ let mut filter = Self {
+ a0: 1.0,
+ a1: 0.0,
+ a2: 0.0,
+ b1: 0.0,
+ b2: 0.0,
+ x1: 0.0,
+ x2: 0.0,
+ y1: 0.0,
+ y2: 0.0,
+ };
+ let c = (std::f32::consts::PI * cutoff_hz / sample_rate as f32).tan();
+ let sqrt2 = 2.0f32.sqrt();
+
+ match filter_type {
+ FilterType::LowPass => {
+ let d = 1.0 / (1.0 + sqrt2 * c + c * c);
+ filter.a0 = c * c * d;
+ filter.a1 = 2.0 * filter.a0;
+ filter.a2 = filter.a0;
+ filter.b1 = 2.0 * (c * c - 1.0) * d;
+ filter.b2 = (1.0 - sqrt2 * c + c * c) * d;
+ }
+ FilterType::HighPass => {
+ let d = 1.0 / (1.0 + sqrt2 * c + c * c);
+ filter.a0 = d;
+ filter.a1 = -2.0 * d;
+ filter.a2 = d;
+ filter.b1 = 2.0 * (c * c - 1.0) * d;
+ filter.b2 = (1.0 - sqrt2 * c + c * c) * d;
+ }
+ }
+ filter
+ }
+
+ pub fn process(&mut self, input: &mut [f32]) {
+ for sample in input.iter_mut() {
+ let x0 = *sample;
+ let y0 = self.a0 * x0 + self.a1 * self.x1 + self.a2 * self.x2
+ - self.b1 * self.y1
+ - self.b2 * self.y2;
+
+ self.x2 = self.x1;
+ self.x1 = x0;
+ self.y2 = self.y1;
+ self.y1 = y0;
+
+ *sample = y0;
+ }
+ }
+}
+
+// --- Goertzel Filter ---
+struct Goertzel {
+ coeff: f32,
+ history: VecDeque<f32>,
+ window: Vec<f32>,
+}
+
+impl Goertzel {
+ fn new(target_freq: f32, sample_rate: u32, window_size: usize) -> Self {
+ let k = (0.5 + (window_size as f32 * target_freq) / sample_rate as f32) as usize;
+ let omega = (2.0 * std::f32::consts::PI * k as f32) / window_size as f32;
+ let coeff = 2.0 * omega.cos();
+
+ let window = (0..window_size)
+ .map(|i| {
+ 0.54 - 0.46 * (2.0 * std::f32::consts::PI * i as f32 / window_size as f32).cos()
+ })
+ .collect();
+
+ Self {
+ coeff,
+ history: VecDeque::with_capacity(window_size),
+ window,
+ }
+ }
+
+ fn run(&self, samples: &[f32]) -> f32 {
+ let mut q0;
+ let mut q1 = 0.0;
+ let mut q2 = 0.0;
+
+ for (i, &sample) in samples.iter().enumerate() {
+ q0 = self.coeff * q1 - q2 + sample * self.window[i];
+ q2 = q1;
+ q1 = q0;
+ }
+
+ q1 * q1 + q2 * q2 - self.coeff * q1 * q2
+ }
+
+ fn process_stream(&mut self, samples: &[f32]) -> Vec<f32> {
+ self.history.extend(samples.iter());
+ let mut power = Vec::new();
+ while self.history.len() >= self.window.len() {
+ let chunk: Vec<f32> = self
+ .history
+ .iter()
+ .take(self.window.len())
+ .copied()
+ .collect();
+ power.push(self.run(&chunk));
+ self.history.pop_front();
+ }
+ power
+ }
+}
+
+// --- Main Decoder ---
+pub struct MorseDecoder {
+ resampler: SincFixedIn<f32>,
+ filter_hp: BiquadFilter,
+ filter_lp: BiquadFilter,
+ audio_buffer: Vec<f32>,
+ target_sample_rate: u32,
+ estimated_pitch: Option<f32>,
+}
+
+impl MorseDecoder {
+ pub fn new(source_sample_rate: u32, target_sample_rate: u32) -> Result<Self> {
+ let resampler = if source_sample_rate != target_sample_rate {
+ let params = InterpolationParameters {
+ sinc_len: 256,
+ f_cutoff: 0.95,
+ interpolation: InterpolationType::Linear,
+ oversampling_factor: 256,
+ window: WindowFunction::BlackmanHarris,
+ };
+ SincFixedIn::new(
+ target_sample_rate as f64 / source_sample_rate as f64,
+ 2.0,
+ params,
+ 1024, // chunk size
+ 1, // channels
+ )?
+ } else {
+ // Create a dummy resampler if not needed
+ SincFixedIn::new(
+ 1.0,
+ 1.0,
+ InterpolationParameters {
+ sinc_len: 2,
+ ..Default::default()
+ },
+ 1024,
+ 1,
+ )?
+ };
+
+ Ok(Self {
+ resampler,
+ filter_hp: BiquadFilter::new(FilterType::HighPass, FREQ_MIN_HZ, target_sample_rate),
+ filter_lp: BiquadFilter::new(FilterType::LowPass, FREQ_MAX_HZ, target_sample_rate),
+ audio_buffer: Vec::new(),
+ target_sample_rate,
+ estimated_pitch: None,
+ })
+ }
+
+ pub fn process(&mut self, chunk: &[f32]) -> Result<()> {
+ let waves_in = vec![chunk.to_vec()];
+ let mut resampled = self.resampler.process(&waves_in, None)?;
+
+ let mut audio_chunk = resampled.remove(0);
+ self.filter_hp.process(&mut audio_chunk);
+ self.filter_lp.process(&mut audio_chunk);
+
+ self.audio_buffer.extend(audio_chunk);
+ Ok(())
+ }
+
+ pub fn finalize(&mut self) -> Result<String> {
+ if self.audio_buffer.is_empty() {
+ return Ok(String::new());
+ }
+
+ // 1. Detect Pitch using STFT
+ let pitch = self.detect_pitch_stft()?;
+ log::info!("Estimated pitch: {:.2} Hz", pitch);
+ self.estimated_pitch = Some(pitch);
+
+ // 2. Extract signal power using Goertzel filter
+ let goertzel_window_size = (self.target_sample_rate / 50) as usize; // ~20ms window
+ let mut goertzel_filter =
+ Goertzel::new(pitch, self.target_sample_rate, goertzel_window_size);
+ let power_signal = goertzel_filter.process_stream(&self.audio_buffer);
+
+ // 3. Find optimal WPM and Threshold
+ let (best_wpm, best_threshold) = self.find_best_params(&power_signal)?;
+ log::info!(
+ "Best fit: WPM = {}, Threshold = {:.4}",
+ best_wpm,
+ best_threshold
+ );
+
+ // 4. Decode with optimal parameters
+ let text = self.decode_with_params(&power_signal, best_wpm, best_threshold);
+
+ Ok(text)
+ }
+
+ fn detect_pitch_stft(&self) -> Result<f32> {
+ let fft_size = 2048;
+ let step_size = fft_size / 4;
+
+ let mut planner = FftPlanner::new();
+ let fft = planner.plan_fft_forward(fft_size);
+ let window: Vec<f32> = (0..fft_size)
+ .map(|i| 0.54 - 0.46 * (2.0 * std::f32::consts::PI * i as f32 / fft_size as f32).cos())
+ .collect();
+
+ let mut spectrum_sum = vec![0.0; fft_size / 2];
+ let mut count = 0;
+
+ for chunk in self.audio_buffer.windows(fft_size).step_by(step_size) {
+ let mut buffer: Vec<Complex<f32>> = chunk
+ .iter()
+ .zip(window.iter())
+ .map(|(s, w)| Complex::new(s * w, 0.0))
+ .collect();
+
+ fft.process(&mut buffer);
+
+ for (i, v) in buffer.iter().take(fft_size / 2).enumerate() {
+ spectrum_sum[i] += v.norm_sqr();
+ }
+ count += 1;
+ }
+
+ if count == 0 {
+ bail!("Not enough audio data to detect pitch.");
+ }
+
+ let df = self.target_sample_rate as f32 / fft_size as f32;
+ let mut max_power = 0.0;
+ let mut best_freq = 0.0;
+
+ for i in 0..fft_size / 2 {
+ let freq = i as f32 * df;
+ if freq >= FREQ_MIN_HZ && freq <= FREQ_MAX_HZ {
+ let power = spectrum_sum[i];
+ if power > max_power {
+ max_power = power;
+ best_freq = freq;
+ }
+ }
+ }
+
+ Ok(best_freq)
+ }
+
+ fn find_best_params(&self, power_signal: &[f32]) -> Result<(f32, f32)> {
+ let mut best_cost = f32::MAX;
+ let mut best_wpm = 20.0;
+ let mut best_threshold = 0.0;
+
+ let mean_power = power_signal.iter().sum::<f32>() / power_signal.len() as f32;
+
+ for wpm_int in 5..=40 {
+ let wpm = wpm_int as f32;
+ for l in (10..=90).step_by(5) {
+ let threshold = mean_power * (l as f32 / 100.0);
+ let cost = self.calculate_cost(power_signal, wpm, threshold);
+ if cost < best_cost {
+ best_cost = cost;
+ best_wpm = wpm;
+ best_threshold = threshold;
+ }
+ }
+ }
+ Ok((best_wpm, best_threshold))
+ }
+
+ fn calculate_cost(&self, power_signal: &[f32], wpm: f32, threshold: f32) -> f32 {
+ let dot_len_ms = 1200.0 / wpm;
+ let dot_len_samples = (dot_len_ms / 1000.0)
+ * (self.target_sample_rate as f32 / ((self.target_sample_rate / 50) as f32));
+
+ let mut on_intervals = Vec::new();
+ let mut off_intervals = Vec::new();
+ let mut current_len = 0;
+ let mut is_on = power_signal[0] > threshold;
+
+ for &p in power_signal {
+ if (p > threshold) == is_on {
+ current_len += 1;
+ } else {
+ if is_on {
+ on_intervals.push(current_len);
+ } else {
+ off_intervals.push(current_len);
+ }
+ is_on = !is_on;
+ current_len = 1;
+ }
+ }
+
+ if on_intervals.is_empty() {
+ return f32::MAX;
+ }
+
+ let cost_on: f32 = on_intervals
+ .iter()
+ .map(|&len| {
+ let cost_dot = (len as f32 / dot_len_samples - 1.0).powi(2);
+ let cost_dash = (len as f32 / dot_len_samples - 3.0).powi(2);
+ cost_dot.min(cost_dash)
+ })
+ .sum();
+
+ cost_on / on_intervals.len() as f32
+ }
+
+ fn decode_with_params(&self, power_signal: &[f32], wpm: f32, threshold: f32) -> String {
+ let dot_len_ms = 1200.0 / wpm;
+ // The power signal has a lower sample rate because of the Goertzel windowing
+ let power_signal_rate =
+ self.target_sample_rate as f32 / (self.target_sample_rate as f32 / 50.0);
+ let dot_len_samples = (dot_len_ms / 1000.0) * power_signal_rate;
+
+ let mut result = String::new();
+ let mut current_letter = String::new();
+
+ let mut current_len = 0;
+ let mut is_on = power_signal[0] > threshold;
+
+ for &p in power_signal.iter().chain(std::iter::once(&0.0)) {
+ // Add sentinel
+ if (p > threshold) == is_on {
+ current_len += 1;
+ } else {
+ let len_norm = current_len as f32 / dot_len_samples;
+ if is_on {
+ // end of a tone
+ if (len_norm - 1.0).abs() < (len_norm - 3.0).abs() {
+ current_letter.push('0'); // dot
+ } else {
+ current_letter.push('1'); // dash
+ }
+ } else {
+ // end of a space
+ if len_norm > 2.0 {
+ // inter-letter space
+ if let Some(c) = morse_to_char(&current_letter) {
+ result.push(c);
+ } else if !current_letter.is_empty() {
+ result.push('?'); // Unknown character
+ }
+ current_letter.clear();
+ if len_norm > 5.0 {
+ // word space
+ result.push(' ');
+ }
+ }
+ // else, it's an inter-element space, do nothing
+ }
+ is_on = !is_on;
+ current_len = 1;
+ }
+ }
+ result
+ }
+}
+
+fn morse_to_char(s: &str) -> Option<char> {
+ match s {
+ "01" => Some('A'),
+ "1000" => Some('B'),
+ "1010" => Some('C'),
+ "100" => Some('D'),
+ "0" => Some('E'),
+ "0010" => Some('F'),
+ "110" => Some('G'),
+ "0000" => Some('H'),
+ "00" => Some('I'),
+ "0111" => Some('J'),
+ "101" => Some('K'),
+ "0100" => Some('L'),
+ "11" => Some('M'),
+ "10" => Some('N'),
+ "111" => Some('O'),
+ "0110" => Some('P'),
+ "1101" => Some('Q'),
+ "010" => Some('R'),
+ "000" => Some('S'),
+ "1" => Some('T'),
+ "001" => Some('U'),
+ "0001" => Some('V'),
+ "011" => Some('W'),
+ "1001" => Some('X'),
+ "1011" => Some('Y'),
+ "1100" => Some('Z'),
+ "01111" => Some('1'),
+ "00111" => Some('2'),
+ "00011" => Some('3'),
+ "00001" => Some('4'),
+ "00000" => Some('5'),
+ "10000" => Some('6'),
+ "11000" => Some('7'),
+ "11100" => Some('8'),
+ "11110" => Some('9'),
+ "11111" => Some('0'),
+ _ => None,
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..d288331
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,72 @@
+use anyhow::{bail, Result};
+use clap::Parser;
+use hound::{SampleFormat, WavReader};
+use std::path::PathBuf;
+
+mod decoder;
+use decoder::MorseDecoder;
+
+const TARGET_SAMPLE_RATE: u32 = 12000; // Same as ggmorse's kBaseSampleRate
+const CHUNK_SIZE: usize = 4096;
+
+#[derive(Parser)]
+#[command(author, version, about, long_about = None)]
+struct Cli {
+ /// Path to the input WAV file
+ #[arg(value_name = "WAV_FILE")]
+ wav_file: PathBuf,
+}
+
+fn main() -> Result<()> {
+ // Set up logging. Use `RUST_LOG=info` or `RUST_LOG=debug` to see output.
+ env_logger::init();
+ let cli = Cli::parse();
+
+ log::info!("Opening WAV file: {:?}", cli.wav_file);
+ let mut reader = WavReader::open(cli.wav_file)?;
+ let spec = reader.spec();
+ log::info!("WAV spec: {:?}", spec);
+
+ if spec.sample_format != SampleFormat::Int && spec.sample_format != SampleFormat::Float {
+ bail!(
+ "Unsupported sample format: {:?}. Only 16-bit Int and 32-bit Float are supported.",
+ spec.sample_format
+ );
+ }
+
+ // --- Create the Morse Decoder ---
+ let mut decoder = MorseDecoder::new(spec.sample_rate, TARGET_SAMPLE_RATE)?;
+
+ // --- Read and process the audio in chunks ---
+ let samples_f32: Vec<f32> = if spec.sample_format == SampleFormat::Int {
+ reader
+ .samples::<i16>()
+ .map(|s| s.unwrap() as f32 / 32768.0)
+ .collect()
+ } else {
+ reader.samples::<f32>().map(|s| s.unwrap()).collect()
+ };
+
+ // Convert to mono by averaging channels if necessary
+ let mono_samples: Vec<f32> = if spec.channels > 1 {
+ samples_f32
+ .chunks_exact(spec.channels as usize)
+ .map(|chunk| chunk.iter().sum::<f32>() / spec.channels as f32)
+ .collect()
+ } else {
+ samples_f32
+ };
+
+ // Process the entire audio buffer
+ for chunk in mono_samples.chunks(CHUNK_SIZE) {
+ decoder.process(chunk)?;
+ }
+
+ // Finalize decoding after all audio is processed
+ let decoded_text = decoder.finalize()?;
+
+ println!("\n--- Decoded Text ---");
+ println!("{}", decoded_text);
+
+ Ok(())
+} \ No newline at end of file