//! AM/OOK demodulator for extracting level+duration pairs from raw IQ samples. //! //! This demodulator converts raw IQ samples into a stream of (level, duration_us) pairs //! that can be processed by protocol decoders, similar to how the Flipper Zero SubGHz //! system works. //! //! Key design decisions for HackRF (vs Flipper's CC1101 hardware slicer): //! - Adaptive threshold with hysteresis to prevent chattering at decision boundary //! - Debounce mechanism to reject noise spikes shorter than min_duration_us //! - Magnitude smoothing (EMA) to reduce per-sample noise //! - Fast initial threshold adaptation, slower during steady-state reception /// A single level+duration pair representing one segment of the signal #[derive(Debug, Clone, Copy)] pub struct LevelDuration { /// Signal level (true = high, false = low) pub level: bool, /// Duration in microseconds pub duration_us: u32, } impl LevelDuration { pub fn new(level: bool, duration_us: u32) -> Self { Self { level, duration_us } } } /// Demodulator for processing raw IQ samples into level+duration pairs pub struct Demodulator { /// Sample rate in Hz #[allow(dead_code)] sample_rate: u32, /// Samples per microsecond samples_per_us: f64, // ── Adaptive threshold with hysteresis ── /// Current threshold for high/low detection threshold: f32, /// Adaptive threshold - high level estimate high_level: f32, /// Adaptive threshold - low level estimate low_level: f32, /// Hysteresis (half-width of dead zone around threshold) hysteresis: f32, // ── Magnitude smoothing ── /// Smoothed magnitude (exponential moving average) mag_smooth: f32, // ── Current confirmed level state ── /// Current confirmed signal level (high or low) current_level: bool, /// Sample count at current confirmed level level_sample_count: u64, // ── Level magnitude tracking (for transition-based threshold updates) ── /// Sum of smoothed magnitudes during the current level period level_mag_sum: f64, /// Count of samples during the current level period (for averaging) level_mag_count: u64, // ── Debounce / pending transition ── /// Whether we're in a pending transition (unconfirmed level change) in_transition: bool, /// The level we're tentatively transitioning to pending_level: bool, /// Sample count accumulated at the pending level pending_count: u64, /// Sum of smoothed magnitudes during the pending transition pending_mag_sum: f64, // ── Output and limits ── /// Accumulated level+duration pairs pairs: Vec, /// Total samples processed (for adaptive threshold speed) total_samples: u64, /// Minimum duration to consider valid (in µs) — also debounce threshold min_duration_us: u32, /// Maximum gap before considering signal complete (in µs) max_gap_us: u32, /// Samples since last confirmed edge (for gap detection) samples_since_edge: u64, } impl Demodulator { /// Create a new demodulator pub fn new(sample_rate: u32) -> Self { Self { sample_rate, samples_per_us: sample_rate as f64 / 1_000_000.0, // Start with lower initial threshold — the HackRF's signal levels // vary widely depending on gain and distance; starting low ensures // we don't miss weak signals during initial adaptation. threshold: 0.08, high_level: 0.15, low_level: 0.02, hysteresis: 0.02, mag_smooth: 0.0, current_level: false, level_sample_count: 0, level_mag_sum: 0.0, level_mag_count: 0, in_transition: false, pending_level: false, pending_count: 0, pending_mag_sum: 0.0, pairs: Vec::with_capacity(2048), total_samples: 0, min_duration_us: 40, // 40µs debounce (was 50 — slightly more permissive) max_gap_us: 20_000, // 20ms gap = end of signal (was 10ms — wider to avoid splitting signals with internal gaps) samples_since_edge: 0, } } /// Process raw IQ samples and return level+duration pairs if signal complete /// /// Returns None if still accumulating, Some(pairs) when a complete signal is detected pub fn process_samples(&mut self, samples: &[i8]) -> Option> { // Process each IQ sample pair for chunk in samples.chunks(2) { if chunk.len() < 2 { continue; } // Calculate magnitude (AM envelope detection) let i = chunk[0] as f32 / 128.0; let q = chunk[1] as f32 / 128.0; let magnitude = (i * i + q * q).sqrt(); // Smooth the magnitude with EMA to reduce per-sample noise. // Alpha=0.1 gives a time constant of ~10 samples (5µs at 2MHz), // which smooths noise without distorting pulse edges. self.mag_smooth = self.mag_smooth * 0.9 + magnitude * 0.1; // During initial calibration, use fast per-sample threshold updates. // After calibration, threshold is updated at transitions (see below) // to avoid the duty-cycle bias that causes pulse asymmetry. if self.total_samples < 10_000 { self.update_threshold_fast(self.mag_smooth); } // Determine level using hysteresis (Schmitt trigger behavior): // LOW → HIGH requires magnitude > threshold + hysteresis // HIGH → LOW requires magnitude < threshold - hysteresis let is_high = if self.current_level { // Currently HIGH: stay HIGH unless magnitude drops well below threshold self.mag_smooth > (self.threshold - self.hysteresis) } else { // Currently LOW: go HIGH only if magnitude rises well above threshold self.mag_smooth > (self.threshold + self.hysteresis) }; self.total_samples += 1; // Track magnitude for the current level period (used for // transition-based threshold updates after initial calibration) let mag_f64 = self.mag_smooth as f64; // ── Debounce state machine ── // When we see a level change, we don't immediately commit to it. // Instead, we enter a "pending transition" state and wait for the // new level to persist for at least min_duration_us. If it flips // back sooner, we treat it as noise and absorb it. if self.in_transition { if is_high == self.pending_level { // Still at the new (pending) level — accumulate self.pending_count += 1; self.pending_mag_sum += mag_f64; let pending_us = (self.pending_count as f64 / self.samples_per_us) as u32; if pending_us >= self.min_duration_us { // Transition confirmed! Update threshold from the // COMPLETED level's average magnitude. This ensures // equal contribution from HIGH and LOW periods // regardless of their duration (no duty-cycle bias). if self.total_samples >= 10_000 && self.level_mag_count > 0 { let avg_mag = (self.level_mag_sum / self.level_mag_count as f64) as f32; self.update_threshold_at_transition(avg_mag, self.current_level); } // Record the previous level's duration. let duration_us = (self.level_sample_count as f64 / self.samples_per_us) as u32; if duration_us >= self.min_duration_us { self.pairs.push(LevelDuration::new( self.current_level, duration_us, )); } self.samples_since_edge = 0; self.current_level = self.pending_level; self.level_sample_count = self.pending_count; // Transfer pending magnitude tracking to current level self.level_mag_sum = self.pending_mag_sum; self.level_mag_count = self.pending_count; self.in_transition = false; } } else { // Flipped back before confirmation — this was noise. // Absorb the pending samples back into the current level. self.level_sample_count += self.pending_count + 1; self.level_mag_sum += self.pending_mag_sum + mag_f64; self.level_mag_count += self.pending_count + 1; self.in_transition = false; } } else if is_high != self.current_level && self.level_sample_count > 0 { // Potential new transition — start pending self.in_transition = true; self.pending_level = is_high; self.pending_count = 1; self.pending_mag_sum = mag_f64; } else { // Same level as before, just accumulate self.level_sample_count += 1; self.level_mag_sum += mag_f64; self.level_mag_count += 1; self.samples_since_edge += 1; } } // Check if we have a complete signal (long gap detected) let gap_samples = (self.max_gap_us as f64 * self.samples_per_us) as u64; if !self.pairs.is_empty() && self.samples_since_edge > gap_samples { // Flush any pending transition if self.in_transition { let duration_us = (self.level_sample_count as f64 / self.samples_per_us) as u32; if duration_us >= self.min_duration_us { self.pairs .push(LevelDuration::new(self.current_level, duration_us)); } self.level_sample_count = self.pending_count; self.current_level = self.pending_level; self.in_transition = false; } // Add the final level duration let duration_us = (self.level_sample_count as f64 / self.samples_per_us) as u32; if duration_us >= self.min_duration_us { self.pairs .push(LevelDuration::new(self.current_level, duration_us)); } // Return the pairs and reset let result = std::mem::take(&mut self.pairs); self.reset_state(); if result.len() >= 10 { return Some(result); } } // Limit buffer size to prevent unbounded growth if self.pairs.len() > 4096 { self.reset_state(); } None } /// Fast per-sample threshold update — used only during initial calibration /// (first ~5ms / 10K samples). Updates high/low estimates every sample for /// quick convergence to the signal's dynamic range. fn update_threshold_fast(&mut self, magnitude: f32) { let alpha: f32 = 0.01; if magnitude > self.threshold { self.high_level = self.high_level * (1.0 - alpha) + magnitude * alpha; } else { self.low_level = self.low_level * (1.0 - alpha) + magnitude * alpha; } self.recalc_threshold(); } /// Transition-based threshold update — used after initial calibration. /// /// Called once per confirmed level transition with the AVERAGE magnitude /// of the completed level period. This eliminates the duty-cycle bias /// that per-sample updates cause: a 500µs HIGH and a 100µs LOW now /// contribute equally to their respective level estimates, producing /// symmetric pulse widths in the demodulated output. /// /// Alpha=0.3 provides fast convergence (~5 pulses / 10 transitions to /// reach 97% of the correct threshold). This is critical because after /// a long silence, high_level starts at a stale initial guess and must /// converge before the data section begins. With alpha=0.05 it took ~50 /// transitions (entire preamble + data), causing massive pulse asymmetry. fn update_threshold_at_transition(&mut self, avg_magnitude: f32, was_high: bool) { let alpha: f32 = 0.3; // Fast convergence — one update per transition if was_high { self.high_level = self.high_level * (1.0 - alpha) + avg_magnitude * alpha; } else { self.low_level = self.low_level * (1.0 - alpha) + avg_magnitude * alpha; } self.recalc_threshold(); } /// Recalculate threshold and hysteresis from current high/low estimates. fn recalc_threshold(&mut self) { // Threshold is midpoint between low and high estimates self.threshold = (self.low_level + self.high_level) / 2.0; // Ensure reasonable bounds — very low threshold for weak signals, // but not so low that ADC noise alone triggers it self.threshold = self.threshold.max(0.02).min(0.5); // Dynamic hysteresis: 10% of the estimated signal-noise gap, clamped to [0.01, 0.08]. // This prevents chattering near the threshold while allowing clean transitions // for both strong and weak signals. self.hysteresis = ((self.high_level - self.low_level) * 0.10) .max(0.01) .min(0.08); } /// Reset the demodulator state (keeps threshold adaptation) fn reset_state(&mut self) { self.pairs.clear(); self.level_sample_count = 0; self.level_mag_sum = 0.0; self.level_mag_count = 0; self.samples_since_edge = 0; self.current_level = false; self.in_transition = false; self.pending_level = false; self.pending_count = 0; self.pending_mag_sum = 0.0; } /// Reset completely (including threshold adaptation) #[allow(dead_code)] pub fn reset(&mut self) { self.reset_state(); self.threshold = 0.08; self.high_level = 0.15; self.low_level = 0.02; self.hysteresis = 0.02; self.mag_smooth = 0.0; self.total_samples = 0; } } // Note: duration_diff macro is defined in protocols/mod.rs #[cfg(test)] mod tests { use super::*; #[test] fn test_demodulator_creation() { let demod = Demodulator::new(2_000_000); assert_eq!(demod.sample_rate, 2_000_000); } #[test] fn test_level_duration() { let ld = LevelDuration::new(true, 500); assert!(ld.level); assert_eq!(ld.duration_us, 500); } #[test] fn test_no_consecutive_same_levels() { // Simulate a signal with a noise spike in the middle of a LOW period. // The debounce should absorb the spike and never produce consecutive same-level pairs. let mut demod = Demodulator::new(2_000_000); // At 2MHz, 1 sample = 0.5µs. // Create a buffer: 200µs LOW, 20µs HIGH spike (noise), 200µs LOW, 200µs HIGH, 200µs LOW // 200µs = 400 samples, 20µs = 40 samples let mut buf = Vec::new(); // LOW: magnitude ≈ 0.01 for _ in 0..400 { buf.push(1i8); buf.push(0i8); } // Brief HIGH spike: magnitude ≈ 0.9 for _ in 0..40 { buf.push(115i8); buf.push(0i8); } // LOW again for _ in 0..400 { buf.push(1i8); buf.push(0i8); } // Real HIGH for _ in 0..400 { buf.push(115i8); buf.push(0i8); } // LOW for _ in 0..400 { buf.push(1i8); buf.push(0i8); } // Process (won't return signal yet since no long gap) let _ = demod.process_samples(&buf); // Add a long gap to flush let gap_buf: Vec = vec![1, 0].repeat(50_000); // 25ms LOW if let Some(pairs) = demod.process_samples(&gap_buf) { // Verify no consecutive same-level pairs for window in pairs.windows(2) { if window[0].level == window[1].level { panic!( "Found consecutive same-level pairs: {:?} and {:?}", window[0], window[1] ); } } } } }