diff options
| author | Yuval Adam <_@yuv.al> | 2025-06-24 13:07:16 +0200 |
|---|---|---|
| committer | Yuval Adam <_@yuv.al> | 2025-06-24 13:07:16 +0200 |
| commit | f26568cc73e2104e9c78e29ef385636538010e10 (patch) | |
| tree | b08c2649d8a13748533aa771637065e3d2b51ef7 | |
| parent | 06a4169d7bd188039f22bdb20172b6c0829aac6c (diff) | |
Update README and run fmt
| -rw-r--r-- | README.md | 67 | ||||
| -rw-r--r-- | src/decoder.rs | 1 | ||||
| -rw-r--r-- | src/lib.rs | 28 | ||||
| -rw-r--r-- | src/main.rs | 167 | ||||
| -rw-r--r-- | tests/integration_tests.rs | 3 |
5 files changed, 173 insertions, 93 deletions
@@ -8,6 +8,8 @@ A high-performance Rust implementation of a Morse code decoder that can process ## Features - **High Accuracy**: Achieves 100% pass rate on comprehensive test suite +- **Clean Library API**: High-level functions for easy integration (`decode_wav_file`, `decode_samples`) +- **Full-Featured CLI**: Decode files, generate test audio, verbose output, timing information - **Audio Processing**: Supports WAV files with various sample rates (12kHz, 44.1kHz) and formats - **Signal Processing**: Uses FFT-based pitch detection, Goertzel filtering, and adaptive threshold detection - **Self-Calibrating**: Automatically determines timing, WPM, and optimal thresholds @@ -37,33 +39,45 @@ cargo build --release ### Command Line Interface -Decode a WAV file containing Morse code: - +**Decode a WAV file:** ```bash cargo run -- input.wav ``` -With debug output: +**With verbose output and timing:** +```bash +cargo run -- input.wav --verbose --time +``` + +**Generate test Morse code WAV files:** +```bash +cargo run -- --generate "SOS" --verbose +cargo run -- --generate "HELLO WORLD" --output test.wav --frequency 800 --wpm 25 +``` + +**With debug logging:** ```bash RUST_LOG=info cargo run -- input.wav ``` ### Library Usage +**High-level API (recommended):** ```rust -use ditdah::{MorseDecoder, MorseGenerator}; - -// Create a decoder -let mut decoder = MorseDecoder::new(44100, 12000)?; // source_rate, target_rate +use ditdah::{decode_wav_file, decode_samples, MorseGenerator}; -// Process audio chunks -for chunk in audio_chunks { - decoder.process(&chunk)?; -} +// Decode a WAV file directly +let decoded_text = decode_wav_file("morse.wav")?; +println!("Decoded: {}", decoded_text); -// Get decoded text -let decoded_text = decoder.finalize()?; +// Decode audio samples directly +let samples: Vec<f32> = /* your audio data */; +let decoded_text = decode_samples(&samples, 12000)?; println!("Decoded: {}", decoded_text); + +// Generate Morse code WAV files +let generator = MorseGenerator::new(12000, 600.0, 20.0); +generator.generate_wav_file("SOS", "output.wav")?; ``` ## Testing @@ -113,30 +127,35 @@ The decoder uses a sophisticated multi-stage approach: - **Adaptive gap detection**: Distinguishes element gaps, letter gaps, and word gaps - **Robust parameter estimation**: Works across different speeds and frequencies -## Configuration +## Library API -Key constants in `src/decoder.rs`: +The library provides a clean, high-level API: ```rust -const FREQ_MIN_HZ: f32 = 200.0; // Minimum frequency to detect -const FREQ_MAX_HZ: f32 = 1200.0; // Maximum frequency to detect -const DIT_DAH_BOUNDARY: f32 = 2.0; // Threshold between dots and dashes -const LETTER_SPACE_BOUNDARY: f32 = 2.0; // Threshold to end current letter -const WORD_SPACE_BOUNDARY: f32 = 5.0; // Threshold to add word space +pub fn decode_wav_file<P: AsRef<std::path::Path>>(path: P) -> Result<String> +pub fn decode_samples(samples: &[f32], sample_rate: u32) -> Result<String> +pub use generator::MorseGenerator; ``` +**All signal processing complexity is handled internally** - the library automatically: +- Detects audio format and converts to the required sample rate +- Performs frequency analysis and filtering +- Calibrates timing parameters +- Decodes Morse patterns to text + ## Project Structure ``` ditdah/ ├── src/ │ ├── main.rs # CLI application -│ ├── lib.rs # Library interface -│ ├── decoder.rs # Core Morse decoder logic -│ └── generator.rs # Morse code generator (for testing) +│ ├── lib.rs # Public library API +│ ├── decoder.rs # Internal Morse decoder implementation +│ └── generator.rs # Public Morse code generator ├── tests/ │ └── integration_tests.rs # Comprehensive test suite -├── Cargo.toml # Project configuration +├── .github/workflows/ # CI pipeline +├── Cargo.toml # Rust 2024 edition project configuration ├── LICENSE # MIT License └── README.md # This file ``` diff --git a/src/decoder.rs b/src/decoder.rs index 1a217d3..4bf075b 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -245,7 +245,6 @@ impl MorseDecoder { } // --- The complex analysis functions below are unchanged --- - fn detect_pitch_stft(&self) -> Result<f32> { let fft_size = 4096; let step_size = fft_size / 4; @@ -4,26 +4,26 @@ mod decoder; pub mod generator; -pub use generator::MorseGenerator; use decoder::MorseDecoder; +pub use generator::MorseGenerator; use anyhow::Result; /// High-level convenience function to decode a WAV file directly -/// +/// /// # Example /// ```no_run /// use ditdah::decode_wav_file; -/// +/// /// let decoded_text = decode_wav_file("morse.wav").unwrap(); /// println!("Decoded: {}", decoded_text); /// ``` pub fn decode_wav_file<P: AsRef<std::path::Path>>(path: P) -> Result<String> { use hound::{SampleFormat, WavReader}; - + let mut reader = WavReader::open(path)?; let spec = reader.spec(); - + // Check supported formats if spec.sample_format != SampleFormat::Int && spec.sample_format != SampleFormat::Float { anyhow::bail!( @@ -31,10 +31,10 @@ pub fn decode_wav_file<P: AsRef<std::path::Path>>(path: P) -> Result<String> { spec.sample_format ); } - + // Create decoder with automatic sample rate conversion let mut decoder = MorseDecoder::new(spec.sample_rate, 12000)?; - + // Read all samples let samples_f32: Vec<f32> = if spec.sample_format == SampleFormat::Int { reader @@ -44,7 +44,7 @@ pub fn decode_wav_file<P: AsRef<std::path::Path>>(path: P) -> Result<String> { } else { reader.samples::<f32>().map(|s| s.unwrap()).collect() }; - + // Convert to mono if necessary let mono_samples: Vec<f32> = if spec.channels > 1 { samples_f32 @@ -54,22 +54,22 @@ pub fn decode_wav_file<P: AsRef<std::path::Path>>(path: P) -> Result<String> { } else { samples_f32 }; - + // Process in chunks for better memory efficiency const CHUNK_SIZE: usize = 4096; for chunk in mono_samples.chunks(CHUNK_SIZE) { decoder.process(chunk)?; } - + decoder.finalize() } /// High-level convenience function to decode audio samples directly -/// +/// /// # Example /// ```no_run /// use ditdah::decode_samples; -/// +/// /// // For real audio data with sufficient length for processing /// let samples = vec![0.0; 48000]; // 4 seconds of audio at 12kHz /// let decoded_text = decode_samples(&samples, 12000).unwrap(); @@ -77,11 +77,11 @@ pub fn decode_wav_file<P: AsRef<std::path::Path>>(path: P) -> Result<String> { /// ``` pub fn decode_samples(samples: &[f32], sample_rate: u32) -> Result<String> { let mut decoder = MorseDecoder::new(sample_rate, 12000)?; - + const CHUNK_SIZE: usize = 4096; for chunk in samples.chunks(CHUNK_SIZE) { decoder.process(chunk)?; } - + decoder.finalize() } diff --git a/src/main.rs b/src/main.rs index c711d3d..98429ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,72 +16,97 @@ struct Cli { /// Path to the input WAV file #[arg(value_name = "FILE", help = "WAV file containing Morse code audio")] wav_file: Option<PathBuf>, - + /// Show detailed processing information #[arg(short, long, help = "Enable verbose output")] verbose: bool, - + /// Show timing information #[arg(short, long, help = "Show processing time")] time: bool, - + /// Generate a test WAV file instead of decoding - #[arg(long, value_name = "TEXT", help = "Generate test WAV file with given text")] + #[arg( + long, + value_name = "TEXT", + help = "Generate test WAV file with given text" + )] generate: Option<String>, - + /// Output file for generation (default: output.wav) - #[arg(short, long, value_name = "FILE", help = "Output file for generated WAV")] + #[arg( + short, + long, + value_name = "FILE", + help = "Output file for generated WAV" + )] output: Option<PathBuf>, - + /// Frequency for generated audio (default: 600 Hz) - #[arg(long, value_name = "HZ", default_value = "600", help = "Frequency in Hz for generated audio")] + #[arg( + long, + value_name = "HZ", + default_value = "600", + help = "Frequency in Hz for generated audio" + )] frequency: f32, - + /// Words per minute for generated audio (default: 20 WPM) - #[arg(long, value_name = "WPM", default_value = "20", help = "Words per minute for generated audio")] + #[arg( + long, + value_name = "WPM", + default_value = "20", + help = "Words per minute for generated audio" + )] wpm: f32, } fn main() -> Result<()> { let cli = Cli::parse(); - + // Set up logging - user can control with RUST_LOG environment variable env_logger::try_init().ok(); - + // Handle generation mode if let Some(text) = &cli.generate { return generate_wav_file(&cli, text); } - + // Validate we have an input file for decoding - let wav_file = cli.wav_file.as_ref() - .ok_or_else(|| anyhow::anyhow!("No input file specified. Use --help for usage information."))?; - + let wav_file = cli.wav_file.as_ref().ok_or_else(|| { + anyhow::anyhow!("No input file specified. Use --help for usage information.") + })?; + // Validate input file exists if !wav_file.exists() { bail!("File not found: {}", wav_file.display()); } - + let start_time = Instant::now(); - + if cli.verbose { println!("Processing: {}", wav_file.display()); } - + // Use the new high-level API - let decoded_text = ditdah::decode_wav_file(wav_file) - .map_err(|e| { - // Provide user-friendly error messages - match e.to_string().as_str() { - s if s.contains("No such file") => anyhow::anyhow!("File not found: {}", wav_file.display()), - s if s.contains("Unsupported sample format") => anyhow::anyhow!("Unsupported audio format. Please use 16-bit or 32-bit WAV files."), - s if s.contains("Could not find a dominant frequency") => anyhow::anyhow!("No Morse code signal detected. Check that the file contains clear Morse audio."), - _ => e + let decoded_text = ditdah::decode_wav_file(wav_file).map_err(|e| { + // Provide user-friendly error messages + match e.to_string().as_str() { + s if s.contains("No such file") => { + anyhow::anyhow!("File not found: {}", wav_file.display()) + } + s if s.contains("Unsupported sample format") => { + anyhow::anyhow!("Unsupported audio format. Please use 16-bit or 32-bit WAV files.") } - })?; - + s if s.contains("Could not find a dominant frequency") => anyhow::anyhow!( + "No Morse code signal detected. Check that the file contains clear Morse audio." + ), + _ => e, + } + })?; + let duration = start_time.elapsed(); - + // Output results if decoded_text.is_empty() { println!("No Morse code detected"); @@ -90,11 +115,11 @@ fn main() -> Result<()> { } } else { println!("Decoded: {}", decoded_text); - + if cli.time { println!("Processing time: {:.2?}", duration); } - + if cli.verbose { println!("Length: {} characters", decoded_text.len()); if decoded_text.len() > 50 { @@ -102,52 +127,90 @@ fn main() -> Result<()> { } } } - + Ok(()) } fn generate_wav_file(cli: &Cli, text: &str) -> Result<()> { use generator::MorseGenerator; - - let output_path = cli.output.as_ref() + + let output_path = cli + .output + .as_ref() .map(|p| p.clone()) .unwrap_or_else(|| PathBuf::from("output.wav")); - + if cli.verbose { - println!("Generating: '{}' at {} Hz, {} WPM", text, cli.frequency, cli.wpm); + println!( + "Generating: '{}' at {} Hz, {} WPM", + text, cli.frequency, cli.wpm + ); println!("Output: {}", output_path.display()); } - + let generator = MorseGenerator::new(12000, cli.frequency, cli.wpm); generator.generate_wav_file(text, &output_path)?; - + println!("Generated: {}", output_path.display()); - + if cli.verbose { - println!("Settings: {} Hz, {} WPM, 12kHz sample rate", cli.frequency, cli.wpm); - + println!( + "Settings: {} Hz, {} WPM, 12kHz sample rate", + cli.frequency, cli.wpm + ); + // Show what the Morse pattern looks like let morse_pattern = text_to_morse_pattern(text); if !morse_pattern.is_empty() { println!("Morse: {}", morse_pattern); } } - + Ok(()) } fn text_to_morse_pattern(text: &str) -> String { let morse_map = [ - ('A', ".-"), ('B', "-..."), ('C', "-.-."), ('D', "-.."), ('E', "."), - ('F', "..-."), ('G', "--."), ('H', "...."), ('I', ".."), ('J', ".---"), - ('K', "-.-"), ('L', ".-.."), ('M', "--"), ('N', "-."), ('O', "---"), - ('P', ".--."), ('Q', "--.-"), ('R', ".-."), ('S', "..."), ('T', "-"), - ('U', "..-"), ('V', "...-"), ('W', ".--"), ('X', "-..-"), ('Y', "-.--"), - ('Z', "--.."), ('1', ".----"), ('2', "..---"), ('3', "...--"), ('4', "....-"), - ('5', "....."), ('6', "-...."), ('7', "--..."), ('8', "---.."), ('9', "----."), + ('A', ".-"), + ('B', "-..."), + ('C', "-.-."), + ('D', "-.."), + ('E', "."), + ('F', "..-."), + ('G', "--."), + ('H', "...."), + ('I', ".."), + ('J', ".---"), + ('K', "-.-"), + ('L', ".-.."), + ('M', "--"), + ('N', "-."), + ('O', "---"), + ('P', ".--."), + ('Q', "--.-"), + ('R', ".-."), + ('S', "..."), + ('T', "-"), + ('U', "..-"), + ('V', "...-"), + ('W', ".--"), + ('X', "-..-"), + ('Y', "-.--"), + ('Z', "--.."), + ('1', ".----"), + ('2', "..---"), + ('3', "...--"), + ('4', "....-"), + ('5', "....."), + ('6', "-...."), + ('7', "--..."), + ('8', "---.."), + ('9', "----."), ('0', "-----"), - ].into_iter().collect::<std::collections::HashMap<_, _>>(); - + ] + .into_iter() + .collect::<std::collections::HashMap<_, _>>(); + text.to_uppercase() .chars() .filter_map(|c| { diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 4bc0887..a68fd70 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -2,10 +2,9 @@ // Comprehensive integration tests for the Morse decoder use anyhow::Result; -use ditdah::{decode_wav_file, MorseGenerator}; +use ditdah::{MorseGenerator, decode_wav_file}; use std::{fs, io::Write}; - #[derive(Debug)] struct TestCase { name: &'static str, |
