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}