Skip to main content

rlvgl_platform/
serializer_arm.rs

1//! AIF1ADC serializer arm-phase detector (AUDIO-01-d §3 / R3.3 + R4).
2//!
3//! Host-testable, datasheet-independent classification of a captured SAI RX
4//! buffer against a known single-tone stimulus, to decide whether the WM8994
5//! AIF1ADC serializer armed bit-aligned (N=0) or shifted (the ERRATA-004
6//! arm-phase race the disco-analyzer loopback rig surfaces as a boot-variable
7//! "bit or two shift" → amplitude scaling + capture discontinuities).
8//!
9//! Intended use is the closed-loop re-arm calibration (R3.4): the consumer
10//! plays a known mono tone, captures `SAI1 RX`, calls [`detect_arm_phase`], and
11//! re-arms the serializers until [`SerializerArmOutcome::accepted`] (or a budget
12//! is exhausted). This module is the host-testable "unblocked slice" — it has
13//! NO codec/register/hardware dependency, only DSP arithmetic on an `[i16]`.
14//!
15//! All metrics run on a MONO capture (extract one channel, or drive the mono
16//! stimulus `stimulus_mode = 2`). The four discriminators (R3.3):
17//! 1. adjacent-jump count `|x[i+1]-x[i]| > 0x4000` — 2×-overflow-wrap (N≈−1) /
18//!    capture discontinuity;
19//! 2. low-byte-stuck rate (low byte ∈ {0x00, 0xff}) — byte-stuck (N≈8–15);
20//! 3. signal-bin energy fraction (Goertzel tone power / total) — coherence;
21//! 4. left-shift bits (R4) = trailing zeros of the OR of all samples — a
22//!    serializer left-shift by k leaves the low k bits zero in every sample
23//!    (gain-independent absolute-N for the left-shift family).
24//!
25//! `goertzel_coeff = 2·cos(2π·f_tone/f_s)` is precomputed by the caller (no
26//! `cos` is called here, keeping the module `core`-only / `no_std`).
27
28/// Adjacent-jump threshold (overflow-wrap / discontinuity signature), in LSB.
29const JUMP_THRESH: i32 = 0x4000;
30
31/// Result of [`detect_arm_phase`]: the four R3.3/R4 discriminators plus the
32/// bit-aligned (N=0) accept gate.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub struct SerializerArmOutcome {
35    /// Left-shift in bits (R4): trailing-zero count of the OR of all sample bit
36    /// patterns. `0` = LSB-aligned. Gain-independent.
37    pub left_shift_bits: u8,
38    /// Count of adjacent samples with `|Δ| > 0x4000` (wrap / discontinuity).
39    pub adjacent_jumps: u32,
40    /// Percent of samples whose low byte is `0x00` or `0xff` (byte-stuck).
41    pub low_byte_stuck_pct: u8,
42    /// Signal-bin energy fraction ×1000 (Goertzel tone power / total power,
43    /// real-signal normalised so a pure tone → ≈1000).
44    pub signal_bin_permille: u16,
45    /// `true` iff the capture passes the bit-aligned (N=0) accept gate:
46    /// `left_shift_bits == 0 && adjacent_jumps == 0 && low_byte_stuck_pct < 5
47    /// && signal_bin_permille > 800` (INV-AUDIO-01-4 observable form + R4 LSB).
48    pub accepted: bool,
49}
50
51/// Detect the AIF1ADC serializer arm phase from a mono capture against a known
52/// single-tone stimulus. `goertzel_coeff = 2·cos(2π·f_tone/f_s)`.
53///
54/// Captures shorter than 4 samples return the zeroed (rejected) default.
55#[must_use]
56pub fn detect_arm_phase(samples: &[i16], goertzel_coeff: f32) -> SerializerArmOutcome {
57    let n = samples.len();
58    if n < 4 {
59        return SerializerArmOutcome::default();
60    }
61
62    // (4) left-shift = trailing zeros of the OR of all sample bit patterns.
63    let or_acc: u16 = samples.iter().fold(0u16, |a, &s| a | (s as u16));
64    let left_shift_bits = if or_acc == 0 {
65        0
66    } else {
67        or_acc.trailing_zeros() as u8
68    };
69
70    // (1) adjacent-jump count.
71    let mut adjacent_jumps: u32 = 0;
72    for w in samples.windows(2) {
73        if (w[1] as i32 - w[0] as i32).abs() > JUMP_THRESH {
74            adjacent_jumps += 1;
75        }
76    }
77
78    // (2) low-byte-stuck rate.
79    let stuck = samples
80        .iter()
81        .filter(|&&s| {
82            let lb = (s as u16) & 0x00FF;
83            lb == 0x00 || lb == 0xFF
84        })
85        .count();
86    let low_byte_stuck_pct = ((stuck * 100) / n) as u8;
87
88    // (3) Goertzel single-bin power (real-signal normalised energy fraction).
89    let mut s_prev = 0f32;
90    let mut s_prev2 = 0f32;
91    let mut total_power = 0f32;
92    for &x in samples {
93        let xf = x as f32;
94        total_power += xf * xf;
95        let s = xf + goertzel_coeff * s_prev - s_prev2;
96        s_prev2 = s_prev;
97        s_prev = s;
98    }
99    let tone_power = s_prev2 * s_prev2 + s_prev * s_prev - goertzel_coeff * s_prev * s_prev2;
100    // Pure tone: tone_power ≈ total_power · n / 2  ⇒  frac ≈ 1.
101    let denom = total_power * (n as f32) * 0.5;
102    let frac = if denom > 0.0 { tone_power / denom } else { 0.0 };
103    let signal_bin_permille = if frac <= 0.0 {
104        0
105    } else if frac >= 1.0 {
106        1000
107    } else {
108        (frac * 1000.0) as u16
109    };
110
111    let accepted = left_shift_bits == 0
112        && adjacent_jumps == 0
113        && low_byte_stuck_pct < 5
114        && signal_bin_permille > 800;
115
116    SerializerArmOutcome {
117        left_shift_bits,
118        adjacent_jumps,
119        low_byte_stuck_pct,
120        signal_bin_permille,
121        accepted,
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    // 1 kHz @ 48 kHz: precomputed so the tests need no `cos`/`sin` (the module
130    // is `core`-only). COEFF = 2·cos(2π·1000/48000); SINW = sin(2π·1000/48000).
131    const COEFF_1K: f32 = 1.982_889_7;
132    const SINW_1K: f32 = 0.130_526_2;
133
134    /// Generate a clean 1 kHz tone into `buf` via the lossless sinusoid
135    /// recurrence `s[n] = COEFF·s[n-1] − s[n-2]` (= A·sin(ω·n)). No libm.
136    fn clean_1k(buf: &mut [i16], amp: f32) {
137        let mut s2 = 0f32; // A·sin(0)
138        let mut s1 = amp * SINW_1K; // A·sin(ω)
139        buf[0] = 0;
140        if buf.len() > 1 {
141            buf[1] = s1 as i16;
142        }
143        for slot in buf.iter_mut().skip(2) {
144            let s = COEFF_1K * s1 - s2;
145            *slot = s as i16;
146            s2 = s1;
147            s1 = s;
148        }
149    }
150
151    #[test]
152    fn clean_tone_accepted() {
153        let mut buf = [0i16; 256];
154        clean_1k(&mut buf, 16_000.0);
155        let o = detect_arm_phase(&buf, COEFF_1K);
156        assert_eq!(o.left_shift_bits, 0, "clean tone is LSB-aligned");
157        assert_eq!(o.adjacent_jumps, 0, "clean tone has no wraps");
158        assert!(o.low_byte_stuck_pct < 5, "stuck {}", o.low_byte_stuck_pct);
159        assert!(
160            o.signal_bin_permille > 800,
161            "permille {}",
162            o.signal_bin_permille
163        );
164        assert!(o.accepted, "{o:?}");
165    }
166
167    #[test]
168    fn left_shift_two_rejected() {
169        // Clean tone shifted left 2 bits (×4, bottom 2 bits zero) = N=+2.
170        let mut buf = [0i16; 256];
171        clean_1k(&mut buf, 4_000.0);
172        for s in buf.iter_mut() {
173            *s = ((*s as i32) << 2) as i16;
174        }
175        let o = detect_arm_phase(&buf, COEFF_1K);
176        assert_eq!(o.left_shift_bits, 2, "{o:?}");
177        assert!(!o.accepted, "left-shifted capture must be rejected: {o:?}");
178    }
179
180    #[test]
181    fn byte_stuck_rejected() {
182        // Coherent tone but low byte forced to 0x00 (byte-stuck N≈8–15 signature).
183        let mut buf = [0i16; 256];
184        clean_1k(&mut buf, 16_000.0);
185        for s in buf.iter_mut() {
186            *s = (*s as u16 & 0xFF00) as i16; // clear low byte
187        }
188        let o = detect_arm_phase(&buf, COEFF_1K);
189        assert!(o.low_byte_stuck_pct >= 50, "stuck {}", o.low_byte_stuck_pct);
190        assert!(!o.accepted, "byte-stuck capture must be rejected: {o:?}");
191    }
192
193    #[test]
194    fn wrap_rejected() {
195        // 2×-overflow-wrap: alternating near-±FS → huge adjacent jumps.
196        let mut buf = [0i16; 256];
197        for (i, s) in buf.iter_mut().enumerate() {
198            *s = if i % 2 == 0 { 30_001 } else { -30_001 };
199        }
200        let o = detect_arm_phase(&buf, COEFF_1K);
201        assert!(o.adjacent_jumps > 100, "jumps {}", o.adjacent_jumps);
202        assert!(!o.accepted, "wrapped capture must be rejected: {o:?}");
203    }
204
205    #[test]
206    fn real_2x_lock_vector_rejected() {
207        // L channel from a real disco bench capture (2026-06-13, "good lock but
208        // 2×"): a left-leg sine that clips at +FS and has a capture
209        // discontinuity (−29184 → +29311). The detector must reject it.
210        // 500 Hz coeff (L = stimulus_mode=1 two-tone left leg).
211        const COEFF_500: f32 = 1.995_717_4; // 2·cos(2π·500/48000)
212        let v: [i16; 24] = [
213            -1504, -5728, -9952, -13920, -17728, -21152, -24256, -26816, -28928, -30752, -31808,
214            -32416, -32480, -31872, -30816, -29184, 29311, 30991, 32159, 32703, 32767, 32255,
215            31167, 29567,
216        ];
217        let o = detect_arm_phase(&v, COEFF_500);
218        assert!(o.adjacent_jumps >= 1, "must catch the discontinuity: {o:?}");
219        assert!(
220            !o.accepted,
221            "real bit-shifted/clipped capture must be rejected: {o:?}"
222        );
223    }
224
225    #[test]
226    fn too_short_returns_default() {
227        let o = detect_arm_phase(&[1, 2, 3], COEFF_1K);
228        assert_eq!(o, SerializerArmOutcome::default());
229        assert!(!o.accepted);
230    }
231}