better working signals

This commit is contained in:
leviathan
2026-02-08 01:26:21 -05:00
parent 284bf45198
commit 4029177fd6
10 changed files with 671 additions and 204 deletions
+273 -54
View File
@@ -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]
);
}
}
}
}
}