From 657cabfb515ac0e18dc0c2e2ca4fc03e2e24cf65 Mon Sep 17 00:00:00 2001 From: Yuval Adam <_@yuv.al> Date: Tue, 24 Jun 2025 10:52:30 +0200 Subject: Claude round of fixes starting to look good --- src/decoder.rs | 221 ++++++++++++++++++++++++++++++++++----------------------- src/lib.rs | 2 +- src/main.rs | 4 +- 3 files changed, 136 insertions(+), 91 deletions(-) (limited to 'src') diff --git a/src/decoder.rs b/src/decoder.rs index 282a5f8..80458a0 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -5,18 +5,15 @@ use rubato::{ use rustfft::{num_complex::Complex, FftPlanner}; use std::collections::VecDeque; use std::io::Write; - // --- DSP Constants --- const FREQ_MIN_HZ: f32 = 200.0; const FREQ_MAX_HZ: f32 = 1200.0; +const RESAMPLER_CHUNK_SIZE: usize = 1024; // --- Decoding Constants --- -// A dit/dah is classified by its length relative to the dot length. The ideal -// ratio is 1:3. The midpoint 2.0 is a robust boundary. const DIT_DAH_BOUNDARY: f32 = 2.0; -// An inter-word space is distinguished from an inter-letter space. The ideal -// lengths are 3 dots (inter-letter) and 7 dots (inter-word). The midpoint 5.0 is a good boundary. -const WORD_SPACE_BOUNDARY: f32 = 5.0; +const LETTER_SPACE_BOUNDARY: f32 = 2.0; // Gaps > 2x dot length end the current letter +const WORD_SPACE_BOUNDARY: f32 = 5.0; // Gaps > 5x dot length add word space // --- BiquadFilter (Unchanged) --- #[derive(Debug, Clone, Copy)] @@ -92,7 +89,7 @@ struct Goertzel { } 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 f32; + let k = 0.5 + (window_size as f32 * target_freq) / sample_rate as f32; let omega = (2.0 * std::f32::consts::PI * k) / window_size as f32; let coeff = 2.0 * omega.cos(); let window = (0..window_size) @@ -123,21 +120,18 @@ impl Goertzel { .collect() } } - -// --- Main Decoder --- pub struct MorseDecoder { resampler: Option>, filter_hp: BiquadFilter, filter_lp: BiquadFilter, - audio_buffer: Vec, + input_buffer: Vec, // Buffer for raw audio before resampling + audio_buffer: Vec, // Buffer for resampled, filtered audio target_sample_rate: u32, - // source_sample_rate and resampler_chunk_size are only needed during construction } impl MorseDecoder { pub fn new(source_sample_rate: u32, target_sample_rate: u32) -> Result { let resampler = if source_sample_rate != target_sample_rate { - let resampler_chunk_size = 1024; Some(SincFixedIn::new( target_sample_rate as f64 / source_sample_rate as f64, 2.0, @@ -148,7 +142,7 @@ impl MorseDecoder { oversampling_factor: 256, window: WindowFunction::BlackmanHarris, }, - resampler_chunk_size, + RESAMPLER_CHUNK_SIZE, 1, )?) } else { @@ -159,53 +153,79 @@ impl MorseDecoder { 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), + input_buffer: Vec::new(), audio_buffer: Vec::new(), target_sample_rate, }) } - /// Processes a chunk of audio samples, resampling and filtering them into an internal buffer. + /// Processes a chunk of audio. Buffers input to meet the resampler's requirements. pub fn process(&mut self, chunk: &[f32]) -> Result<()> { - let mut processed_chunk = if let Some(resampler) = &mut self.resampler { - // Pass a slice of slices to avoid allocation - let waves_in = &[chunk]; - resampler.process(waves_in, None)?.remove(0) + if let Some(resampler) = &mut self.resampler { + // Add new audio to our input buffer + self.input_buffer.extend_from_slice(chunk); + + // Process full chunks from the buffer + while self.input_buffer.len() >= RESAMPLER_CHUNK_SIZE { + let waves_in = &[&self.input_buffer[..RESAMPLER_CHUNK_SIZE]]; + let mut resampled = resampler.process(waves_in, None)?; + self.input_buffer.drain(..RESAMPLER_CHUNK_SIZE); + + let mut processed_chunk = resampled.remove(0); + self.filter_hp.process(&mut processed_chunk); + self.filter_lp.process(&mut processed_chunk); + self.audio_buffer.extend(processed_chunk); + } } else { - // If no resampling is needed, just copy the chunk - chunk.to_vec() - }; - - self.filter_hp.process(&mut processed_chunk); - self.filter_lp.process(&mut processed_chunk); - self.audio_buffer.extend(processed_chunk); + // No resampling, just filter and add to the main buffer + let mut processed_chunk = chunk.to_vec(); + self.filter_hp.process(&mut processed_chunk); + self.filter_lp.process(&mut processed_chunk); + self.audio_buffer.extend(processed_chunk); + } Ok(()) } - /// Finalizes the decoding process after all audio has been processed. + /// Finalizes decoding. Processes any remaining buffered audio and decodes the full signal. pub fn finalize(&mut self) -> Result { + // --- Flush remaining audio from the input buffer --- + if let Some(resampler) = &mut self.resampler { + if !self.input_buffer.is_empty() { + // Pad the remaining buffer to the required chunk size if needed + while self.input_buffer.len() < RESAMPLER_CHUNK_SIZE { + self.input_buffer.push(0.0); + } + let waves_in = &[self.input_buffer.as_slice()]; + let mut resampled = resampler.process(waves_in, None)?; + self.input_buffer.clear(); + + let mut processed_chunk = resampled.remove(0); + self.filter_hp.process(&mut processed_chunk); + self.filter_lp.process(&mut processed_chunk); + self.audio_buffer.extend(processed_chunk); + } + } + if self.audio_buffer.is_empty() { bail!("Audio buffer is empty, cannot process."); } - // 1. Detect Pitch using STFT on the whole signal + // --- The rest of the decoding pipeline is unchanged --- let pitch = self.detect_pitch_stft()?; log::info!("Estimated pitch: {:.2} Hz", pitch); - // 2. Extract Power Signal using a Goertzel filter tuned to the detected pitch - let goertzel_window_size = (self.target_sample_rate as f32 * 0.025) as usize; // 25ms window + let goertzel_window_size = (self.target_sample_rate as f32 * 0.025) as usize; let step_size = (goertzel_window_size / 4).max(1); let goertzel_filter = Goertzel::new(pitch, self.target_sample_rate, goertzel_window_size); let raw_power = goertzel_filter.process_decimated(&self.audio_buffer, step_size); let power_signal_rate = self.target_sample_rate as f32 / step_size as f32; - // 3. Smooth Power Signal with a moving average - let smooth_window = (power_signal_rate * 0.02).round() as usize; // 20ms smoothing + let smooth_window = (power_signal_rate * 0.02).round() as usize; let smoothed_power = moving_average(&raw_power, smooth_window.max(1)); if smoothed_power.is_empty() { bail!("No power signal after processing"); } - // 4. Find optimal WPM and Threshold by searching for the best fit let (best_wpm, best_threshold) = self.find_best_params(&smoothed_power, power_signal_rate)?; log::info!( @@ -214,27 +234,26 @@ impl MorseDecoder { best_threshold ); - // 5. DEBUG: Visualize the power signal and threshold if log::log_enabled!(log::Level::Trace) { trace_signal(&smoothed_power, best_threshold, best_wpm)?; log::trace!("Wrote signal trace to signal_trace.txt"); } - // 6. Decode the signal using the optimal parameters let text = self.decode_with_params(&smoothed_power, best_wpm, best_threshold, power_signal_rate); Ok(text) } + // --- The complex analysis functions below are unchanged --- + fn detect_pitch_stft(&self) -> Result { let fft_size = 4096; let step_size = fft_size / 4; let mut planner = FftPlanner::new(); let fft = planner.plan_fft_forward(fft_size); let window: Vec = (0..fft_size) - .map(|i| 0.54 - 0.46 * (2.0 * std::f32::consts::PI * i as f32 / fft_size as f32).cos()) // Hamming window + .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) { @@ -249,11 +268,9 @@ impl MorseDecoder { } count += 1; } - if count == 0 { bail!("Not enough audio data for pitch detection"); } - let df = self.target_sample_rate as f32 / fft_size as f32; let (max_idx, max_power) = spectrum_sum @@ -261,49 +278,35 @@ impl MorseDecoder { .enumerate() .fold((0, 0.0), |(max_i, max_p), (i, &p)| { let freq = i as f32 * df; - if freq >= FREQ_MIN_HZ && freq <= FREQ_MAX_HZ && p > max_p { + if (FREQ_MIN_HZ..=FREQ_MAX_HZ).contains(&freq) && p > max_p { (i, p) } else { (max_i, max_p) } }); - if max_power == 0.0 { bail!("Could not find a dominant frequency in the specified range."); } Ok(max_idx as f32 * df) } - /// Searches for the best WPM and threshold combination by testing a range of thresholds - /// derived from the signal's power distribution and finding the WPM that yields the lowest cost for each. fn find_best_params(&self, power_signal: &[f32], power_signal_rate: f32) -> Result<(f32, f32)> { if power_signal.is_empty() { bail!("Power signal is empty"); } - let mut sorted_power: Vec = power_signal.iter().cloned().filter(|&p| p > 0.0).collect(); if sorted_power.len() < 10 { bail!("Not enough signal to determine parameters"); } sorted_power.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap()); - let p25 = sorted_power[(sorted_power.len() as f32 * 0.25) as usize]; let p75 = sorted_power[(sorted_power.len() as f32 * 0.75) as usize]; let iqr = p75 - p25; - - // Test a few threshold candidates within the interquartile range (IQR) of the signal power. - // This is more robust than relying on a single, fixed calculation. - let threshold_candidates = [ - p25 + iqr * 0.25, // Lower-biased threshold - p25 + iqr * 0.50, // Midpoint threshold (original method) - p25 + iqr * 0.75, // Upper-biased threshold - ]; - + let threshold_candidates = [p25 + iqr * 0.25, p25 + iqr * 0.50, p25 + iqr * 0.75]; let mut best_cost = f32::MAX; let mut best_wpm = 20.0; - let mut best_threshold = threshold_candidates[1]; // Default to midpoint - + let mut best_threshold = threshold_candidates[1]; for &threshold in &threshold_candidates { for wpm_int in 5..=40 { let wpm = wpm_int as f32; @@ -318,10 +321,6 @@ impl MorseDecoder { Ok((best_wpm, best_threshold)) } - /// Calculates a "cost" for a given set of parameters (wpm, threshold). - /// A lower cost indicates a better fit. The cost is the mean squared error - /// of element lengths from their ideal ratios (1, 3, 7), normalized by a - /// self-calibrated dot length. fn calculate_cost( &self, power_signal: &[f32], @@ -333,12 +332,10 @@ impl MorseDecoder { if on_intervals.len() < 3 || off_intervals.len() < 3 { return f32::MAX; } - let dot_len_samples = (1200.0 / wpm / 1000.0) * power_signal_rate; if dot_len_samples < 1.0 { return f32::MAX; } - let on_norm: Vec = on_intervals .iter() .map(|&s| s as f32 / dot_len_samples) @@ -347,9 +344,6 @@ impl MorseDecoder { .iter() .map(|&s| s as f32 / dot_len_samples) .collect(); - - // Estimate the "real" dot length by finding the median of all short elements. - // This self-calibrates to the sender's actual timing. let mut short_elements: Vec = on_norm .iter() .chain(off_norm.iter()) @@ -363,9 +357,7 @@ impl MorseDecoder { let median_dot_len = short_elements[short_elements.len() / 2]; if median_dot_len < 0.25 { return f32::MAX; - } // Unrealistic - - // Final cost is the deviation from ideal ratios, normalized by our measured median dot length. + } let cost_on: f32 = on_norm .iter() .map(|&len| { @@ -383,65 +375,118 @@ impl MorseDecoder { .min((len / median_dot_len - 7.0).powi(2)) }) .sum(); - (cost_on / on_intervals.len() as f32) + (cost_off / off_intervals.len() as f32) } - /// Decodes the power signal into text using the provided parameters. fn decode_with_params( &self, power_signal: &[f32], wpm: f32, threshold: f32, - power_signal_rate: f32, + _power_signal_rate: f32, ) -> String { - let dot_len_samples = (1200.0 / wpm / 1000.0) * power_signal_rate; + // First pass: collect all element lengths for self-calibration + let (on_intervals, _off_intervals) = get_raw_intervals(power_signal, threshold); + + if on_intervals.is_empty() { + return String::new(); + } + + // Self-calibrate: detect if we have mixed dots/dashes or all same type + let mut sorted_lengths = on_intervals.clone(); + sorted_lengths.sort_unstable(); + + let min_len = sorted_lengths[0] as f32; + let max_len = sorted_lengths[sorted_lengths.len() - 1] as f32; + let length_ratio = max_len / min_len; + + let actual_dot_len = if length_ratio > 2.0 { + // Mixed signal: use shortest elements as dots + let shortest_half = &sorted_lengths[0..=(sorted_lengths.len() / 2)]; + shortest_half[shortest_half.len() / 2] as f32 + } else { + // All similar lengths: Use a simple heuristic based on absolute length + // This is more robust than relying on potentially inaccurate WPM estimates + let median_len = sorted_lengths[sorted_lengths.len() / 2] as f32; + + // Based on actual observed values: + // - EEEE (dots): median ~10 power signal samples + // - TTTT (dashes): median ~29 power signal samples + // Use a breakpoint between these ranges + let breakpoint = 18.0; + + if median_len > breakpoint { + // Likely all dashes - use theoretical dot length + median_len / 3.0 + } else { + // Likely all dots + median_len + } + }; + + // Log calibration for debugging + log::debug!( + "Self-calibration: WPM={:.1} (ignored), actual_dot_len={:.1} samples", + wpm, + actual_dot_len + ); + log::debug!("Element lengths: {:?}", on_intervals); + let mut result = String::new(); let mut current_letter = String::new(); if power_signal.is_empty() { return result; } - let mut current_len = 0; let mut is_on = power_signal[0] > threshold; - // Debouncing prevents short noise spikes from being registered as valid elements. - let debounce_samples = (dot_len_samples * 0.3).round() as usize; - - // Chain a zero to the end to ensure the last element is always processed. + let debounce_samples = (actual_dot_len * 0.3).round() as usize; + log::debug!("Debounce threshold: {} samples", debounce_samples); for &p in power_signal.iter().chain(std::iter::once(&0.0)) { if (p > threshold) == is_on { current_len += 1; } else { if current_len > debounce_samples { - let len_norm = current_len as f32 / dot_len_samples; + let len_norm = current_len as f32 / actual_dot_len; if is_on { - // End of a tone if len_norm < DIT_DAH_BOUNDARY { current_letter.push('.'); } else { current_letter.push('-'); } } else { - // End of a space - if !current_letter.is_empty() { - if let Some(c) = morse_to_char(¤t_letter) { - result.push(c); - } else { - result.push('?'); // Unknown character + // Handle gaps (off periods) + if len_norm > LETTER_SPACE_BOUNDARY { + // Gap is long enough to end the current letter + if !current_letter.is_empty() { + if let Some(c) = morse_to_char(¤t_letter) { + result.push(c); + } else { + result.push('?'); + } + current_letter.clear(); } - current_letter.clear(); - } - if len_norm > WORD_SPACE_BOUNDARY { - if !result.ends_with(' ') { + // If gap is also long enough for word boundary, add space + if len_norm > WORD_SPACE_BOUNDARY && !result.ends_with(' ') { result.push(' '); } } + // If gap is shorter than LETTER_SPACE_BOUNDARY, it's just an element gap - ignore } } is_on = !is_on; current_len = 1; } } + + // Process any remaining letter at the end + if !current_letter.is_empty() { + if let Some(c) = morse_to_char(¤t_letter) { + result.push(c); + } else { + result.push('?'); + } + } + result.trim().to_string() } } diff --git a/src/lib.rs b/src/lib.rs index ee171c7..599694c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,4 +5,4 @@ pub mod decoder; pub mod generator; pub use decoder::MorseDecoder; -pub use generator::MorseGenerator; \ No newline at end of file +pub use generator::MorseGenerator; diff --git a/src/main.rs b/src/main.rs index 0076290..72869af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -63,7 +63,7 @@ fn main() -> Result<()> { for chunk in mono_samples.chunks(CHUNK_SIZE) { decoder.process(chunk)?; } - + // Finalize decoding after all audio is processed let decoded_text = decoder.finalize()?; @@ -71,4 +71,4 @@ fn main() -> Result<()> { println!("{}", decoded_text); Ok(()) -} \ No newline at end of file +} -- cgit v1.3.1