better working signals
This commit is contained in:
+273
-54
@@ -3,6 +3,12 @@
|
||||
//! 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)]
|
||||
@@ -26,25 +32,53 @@ pub struct Demodulator {
|
||||
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
|
||||
/// Adaptive threshold - low level estimate
|
||||
low_level: f32,
|
||||
/// Current signal state (high or low)
|
||||
/// 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 level
|
||||
/// 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<LevelDuration>,
|
||||
/// Total samples processed
|
||||
/// Total samples processed (for adaptive threshold speed)
|
||||
total_samples: u64,
|
||||
/// Minimum duration to consider valid (in µs)
|
||||
/// 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 edge
|
||||
/// Samples since last confirmed edge (for gap detection)
|
||||
samples_since_edge: u64,
|
||||
}
|
||||
|
||||
@@ -54,21 +88,38 @@ impl Demodulator {
|
||||
Self {
|
||||
sample_rate,
|
||||
samples_per_us: sample_rate as f64 / 1_000_000.0,
|
||||
threshold: 0.15,
|
||||
high_level: 0.3,
|
||||
low_level: 0.05,
|
||||
|
||||
// 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: 50, // Minimum 50µs pulse
|
||||
max_gap_us: 10_000, // 10ms gap = end of signal
|
||||
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<Vec<LevelDuration>> {
|
||||
// Process each IQ sample pair
|
||||
@@ -82,53 +133,136 @@ impl Demodulator {
|
||||
let q = chunk[1] as f32 / 128.0;
|
||||
let magnitude = (i * i + q * q).sqrt();
|
||||
|
||||
// Update adaptive threshold
|
||||
self.update_threshold(magnitude);
|
||||
// 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;
|
||||
|
||||
// Detect level
|
||||
let is_high = magnitude > self.threshold;
|
||||
|
||||
// Check for level change
|
||||
if is_high != self.current_level && self.level_sample_count > 0 {
|
||||
// Calculate duration of the previous level
|
||||
let duration_us = (self.level_sample_count as f64 / self.samples_per_us) as u32;
|
||||
|
||||
// Only record if above minimum duration (noise filtering)
|
||||
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 = is_high;
|
||||
self.level_sample_count = 1;
|
||||
} else {
|
||||
self.level_sample_count += 1;
|
||||
self.samples_since_edge += 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;
|
||||
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.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
|
||||
// Limit buffer size to prevent unbounded growth
|
||||
if self.pairs.len() > 4096 {
|
||||
self.reset_state();
|
||||
}
|
||||
@@ -136,40 +270,87 @@ impl Demodulator {
|
||||
None
|
||||
}
|
||||
|
||||
/// Update adaptive threshold based on signal levels
|
||||
fn update_threshold(&mut self, magnitude: f32) {
|
||||
const ALPHA: f32 = 0.001; // Slow adaptation
|
||||
/// 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 {
|
||||
// Update high level estimate
|
||||
self.high_level = self.high_level * (1.0 - ALPHA) + magnitude * ALPHA;
|
||||
self.high_level = self.high_level * (1.0 - alpha) + magnitude * alpha;
|
||||
} else {
|
||||
// Update low level estimate
|
||||
self.low_level = self.low_level * (1.0 - ALPHA) + magnitude * ALPHA;
|
||||
self.low_level = self.low_level * (1.0 - alpha) + magnitude * alpha;
|
||||
}
|
||||
|
||||
// Threshold is midpoint between low and high
|
||||
self.threshold = (self.low_level + self.high_level) / 2.0;
|
||||
|
||||
// Ensure reasonable bounds
|
||||
self.threshold = self.threshold.max(0.05).min(0.5);
|
||||
self.recalc_threshold();
|
||||
}
|
||||
|
||||
/// Reset the demodulator state
|
||||
/// 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.15;
|
||||
self.high_level = 0.3;
|
||||
self.low_level = 0.05;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,4 +372,42 @@ mod tests {
|
||||
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<i8> = 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user