summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorYuval Adam <_@yuv.al>2025-06-24 10:52:30 +0200
committerYuval Adam <_@yuv.al>2025-06-24 10:52:30 +0200
commit657cabfb515ac0e18dc0c2e2ca4fc03e2e24cf65 (patch)
tree8cd9997fd4bd35ec2e556216e29013c236af0180 /src
parentd151456417ff4a531101f94c90207263e3319406 (diff)
Claude round of fixes starting to look good
Diffstat (limited to 'src')
-rw-r--r--src/decoder.rs221
-rw-r--r--src/lib.rs2
-rw-r--r--src/main.rs4
3 files changed, 136 insertions, 91 deletions
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<SincFixedIn<f32>>,
filter_hp: BiquadFilter,
filter_lp: BiquadFilter,
- audio_buffer: Vec<f32>,
+ input_buffer: Vec<f32>, // Buffer for raw audio before resampling
+ audio_buffer: Vec<f32>, // 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<Self> {
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)
- } else {
- // If no resampling is needed, just copy the chunk
- chunk.to_vec()
- };
+ if let Some(resampler) = &mut self.resampler {
+ // Add new audio to our input buffer
+ self.input_buffer.extend_from_slice(chunk);
- self.filter_hp.process(&mut processed_chunk);
- self.filter_lp.process(&mut processed_chunk);
- self.audio_buffer.extend(processed_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 {
+ // 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<String> {
+ // --- 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<f32> {
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<f32> = (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<f32> =
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<f32> = 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<f32> = 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(&current_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(&current_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(&current_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
+}