code wiki / _hdl_build / nx_aec.nx
nx_aec.nx source
↩ module page · 210 lines · 12243 B
1// nx_aec.nx -- R4a: sovereign Acoustic Echo Canceller. The reserved-but-empty nx_aec slot in
2// nx_audio_session, now built. Adaptive FIR (NLMS) in INTEGER fixed-point (NO-FLOAT doctrine):
3// it learns the echo path (speaker->room->mic) from the far-end reference and subtracts the
4// predicted echo from the mic signal, so the far end doesn't hear itself.
5//
6// y[n] = (sum_k w[k]*x[n-k]) >> Qw (predicted echo from far-end reference x)
7// e[n] = d[n] - y[n] (mic d minus predicted echo = the cleaned output)
8// w[k] += mu * e[n] * x[n-k] / (||x||^2 + eps) (NLMS update, normalized step)
9//
10// MEASURED metric = ERLE (Echo Return Loss Enhancement) = echo power BEFORE / AFTER cancel.
11// Negative controls: (T2) adaptation OFF -> e==d -> no cancellation (the filter is load-bearing);
12// (T3) DOUBLE-TALK -- with the converged filter frozen, near-end speech must SURVIVE while the
13// echo is removed (a broken AEC would crush the near-end too); (T4) ERLE improves over time (it LEARNS).
14//
15// main() is the SELF-VALIDATING GATE. Evidence -> knowledge/status/aec.log.
16// license_tier: ORIGINAL
17import "nx_syscalls.nx"
18const AEC_MAGIC_12000: i64 = 12000
19const AEC_MAGIC_2000: i64 = 2000
20const AEC_MAGIC_13579: i64 = 13579
21const AEC_MAGIC_1103515245: i64 = 1103515245
22const AEC_MAGIC_12345: i64 = 12345
23const AEC_MAGIC_16001: i64 = 16001
24const AEC_MAGIC_8000: i64 = 8000
25const AEC_MAGIC_24680: i64 = 24680
26const AEC_MAGIC_12001: i64 = 12001
27const AEC_MAGIC_6000: i64 = 6000
28const AEC_MAGIC_26214: i64 = 26214
29const AEC_MAGIC_16384: i64 = 16384
30const AEC_MAGIC_9830: i64 = 9830
31const AEC_MAGIC_6554: i64 = 6554
32const AEC_MAGIC_3277: i64 = 3277
33const AEC_MAGIC_1966: i64 = 1966
34
35const AEC_LOG: *u8 = "knowledge/status/aec.log"
36const QW: i64 = 16 // filter coeff fixed-point
37const MU_NUM: i64 = 1
38const MU_DEN: i64 = 2 // mu = 0.5 (stable)
39const EPS: i64 = 256
40
41func aw(fd: i64, s: *u8) -> i64 { var n: i64=0; while s[n]!=(0 as u8){n=n+1} sys_write(fd,s,n); return 0 }
42func awn(fd: i64, v: i64) -> i64 { let bb: *u8=sys_mmap(28); var m: i64=v; if m<0{m=0-m;sys_write(fd,"-" as *u8,1)} let t: *u8=sys_mmap(28); var k: i64=0; if m==0{t[0]=48 as u8;k=1} while m>0{t[k]=(48+(m%10)) as u8;m=m/10;k=k+1} var i: i64=0; while i<k{bb[i]=t[k-1-i];i=i+1} sys_write(fd,bb,k); return 0 }
43
44// run the canceller over x (far-end) / d (mic) into e (cleaned). adapt=1 learns; 0 freezes.
45func aec_run(w: *i64, x: *i64, d: *i64, e: *i64, T: i64, L: i64, adapt: i64) -> i64 {
46 var n: i64 = 0
47 while n < T {
48 var y: i64 = 0
49 var k: i64 = 0
50 while k < L { if n - k >= 0 { y = y + w[k] * x[n-k] } k = k + 1 }
51 y = y >> QW
52 let en: i64 = d[n] - y
53 e[n] = en
54 if adapt == 1 {
55 var energy: i64 = 0
56 k = 0
57 while k < L { if n - k >= 0 { let xv: i64 = x[n-k]; energy = energy + xv*xv } k = k + 1 }
58 let denom: i64 = (energy + EPS) * MU_DEN
59 k = 0
60 while k < L {
61 if n - k >= 0 { w[k] = w[k] + (MU_NUM * en * x[n-k] * (1 << QW)) / denom }
62 k = k + 1
63 }
64 }
65 n = n + 1
66 }
67 return 0
68}
69
70// sum of squares of a[start .. start+n)
71func powwin(a: *i64, start: i64, n: i64) -> i64 { var s: i64=0; var i: i64=start; while i < start+n { s = s + a[i]*a[i]; i=i+1 } return s }
72// sum of squares of (a-b) over window
73func powdiff(a: *i64, b: *i64, start: i64, n: i64) -> i64 { var s: i64=0; var i: i64=start; while i < start+n { let dd: i64=a[i]-b[i]; s = s + dd*dd; i=i+1 } return s }
74// crude integer ERLE in dB from a power ratio (10*log10), lower-bound + sub-decade interp
75func erle_db(ratio: i64) -> i64 {
76 var d: i64 = 0; var t: i64 = ratio
77 while t >= 10 { d = d + 10; t = t / 10 }
78 if t >= 8 { d = d + 9 } else { if t >= 6 { d = d + 8 } else { if t >= 4 { d = d + 6 } else { if t >= 3 { d = d + 5 } else { if t >= 2 { d = d + 3 } } } } }
79 return d
80}
81
82// aec_run_dtd -- aec_run PLUS a Geigel DOUBLE-TALK DETECTOR (merged from the former runtime/ nx_aec so the
83// canonical canceller has the detector the base aec_run's HONEST SCOPE deferred). Near-end speech is present when
84// the mic is louder than the loudest recent reference could explain as echo (|d| > thresh*max|x|, thresh Q8); a
85// `hang` hangover keeps the filter frozen through the quiet inter-word gaps. Freezing during double-talk lets a
86// FAST step converge quickly without diverging when both sides talk. Same Q16/energy-normalized NLMS as aec_run.
87func aec_run_dtd(w: *i64, x: *i64, d: *i64, e: *i64, T: i64, L: i64, dtd_thresh_q8: i64, hang: i64) -> i64 {
88 var n: i64 = 0; var hold: i64 = 0
89 while n < T {
90 var y: i64 = 0; var k: i64 = 0
91 while k < L { if n - k >= 0 { y = y + w[k] * x[n-k] } k = k + 1 }
92 y = y >> QW
93 let en: i64 = d[n] - y
94 e[n] = en
95 var maxref: i64 = 0; k = 0
96 while k < L { if n - k >= 0 { var a: i64 = x[n-k]; if a < 0 { a = 0 - a } if a > maxref { maxref = a } } k = k + 1 }
97 var amic: i64 = d[n]; if amic < 0 { amic = 0 - amic }
98 if amic * 256 > dtd_thresh_q8 * maxref { hold = hang } // near speech -> (re)arm hangover
99 if hold > 0 { hold = hold - 1 } // frozen through the near-end utterance
100 else {
101 var energy: i64 = 0; k = 0
102 while k < L { if n - k >= 0 { let xv: i64 = x[n-k]; energy = energy + xv*xv } k = k + 1 }
103 let denom: i64 = (energy + EPS) * MU_DEN
104 k = 0
105 while k < L { if n - k >= 0 { w[k] = w[k] + (MU_NUM * en * x[n-k] * (1 << QW)) / denom } k = k + 1 }
106 }
107 n = n + 1
108 }
109 return 0
110}
111
112func main() -> i64 {
113 let L: i64 = 64 // filter taps
114 let M: i64 = 8 // echo-path length (inside L -> learnable)
115 let T: i64 = AEC_MAGIC_12000
116 let WIN: i64 = AEC_MAGIC_2000 // measurement window
117 let x: *i64 = sys_mmap(8*T) as *i64
118 let d: *i64 = sys_mmap(8*T) as *i64
119 let s: *i64 = sys_mmap(8*T) as *i64 // near-end speech (double-talk)
120 let d2:*i64 = sys_mmap(8*T) as *i64 // mic during double-talk (echo + near-end)
121 let e: *i64 = sys_mmap(8*T) as *i64
122 let w: *i64 = sys_mmap(8*L) as *i64
123
124 // far-end reference x: white-ish noise +-8000 (rich excitation for fast convergence)
125 var seed: i64 = AEC_MAGIC_13579
126 var i: i64 = 0
127 while i < T { seed=(seed*AEC_MAGIC_1103515245+AEC_MAGIC_12345) & 0x7fffffff; x[i]=((seed>>9) % AEC_MAGIC_16001) - AEC_MAGIC_8000; i=i+1 }
128 // near-end speech s: a DIFFERENT signal +-6000
129 seed = AEC_MAGIC_24680
130 i = 0
131 while i < T { seed=(seed*AEC_MAGIC_1103515245+AEC_MAGIC_12345) & 0x7fffffff; s[i]=((seed>>9) % AEC_MAGIC_12001) - AEC_MAGIC_6000; i=i+1 }
132
133 // echo path h (Q16): decaying room impulse response, total gain < 1
134 let h: *i64 = sys_mmap(8*M) as *i64
135 h[0]=AEC_MAGIC_26214; h[1]=AEC_MAGIC_16384; h[2]=AEC_MAGIC_9830; h[3]=AEC_MAGIC_6554; h[4]=AEC_MAGIC_3277; h[5]=AEC_MAGIC_1966; h[6]=983; h[7]=328
136 // mic echo d[n] = (sum h[k]*x[n-k])>>16 ; double-talk mic d2 = echo + near-end
137 i = 0
138 while i < T {
139 var y: i64 = 0; var k: i64 = 0
140 while k < M { if i - k >= 0 { y = y + h[k]*x[i-k] } k = k + 1 }
141 let echo: i64 = y >> QW
142 d[i] = echo
143 d2[i] = echo + s[i]
144 i = i + 1
145 }
146
147 // --- scenario 1: ADAPT on echo-only mic -> converge ---
148 var z: i64=0; while z < L { w[z]=0; z=z+1 }
149 aec_run(w, x, d, e, T, L, 1)
150 let pd_late: i64 = powwin(d, T-WIN, WIN)
151 let pe_late: i64 = powwin(e, T-WIN, WIN)
152 let ratio_late: i64 = pd_late / (pe_late + 1)
153 let erle: i64 = erle_db(ratio_late)
154 // convergence: early window (after a short warmup) vs late
155 let pd_early: i64 = powwin(d, 0, WIN)
156 let pe_early: i64 = powwin(e, 0, WIN)
157 let ratio_early: i64 = pd_early / (pe_early + 1)
158
159 // --- T2 neg-control: NO adaptation (w=0) -> e==d -> no cancellation ---
160 z=0; while z < L { w[z]=0; z=z+1 }
161 aec_run(w, x, d, e, T, L, 0)
162 let pe_noadapt: i64 = powwin(e, T-WIN, WIN)
163 let ratio_noadapt: i64 = pd_late / (pe_noadapt + 1)
164
165 // --- T3 double-talk: RE-converge on echo-only, FREEZE, then run on (echo+near-end) ---
166 z=0; while z < L { w[z]=0; z=z+1 }
167 aec_run(w, x, d, e, T, L, 1) // converge w
168 aec_run(w, x, d2, e, T, L, 0) // frozen filter on double-talk mic -> e should ~= near-end s
169 let p_recover_err: i64 = powdiff(e, s, T-WIN, WIN) // how far cleaned output is from pure near-end
170 let p_near: i64 = powwin(s, T-WIN, WIN)
171 let p_echo_in_dt: i64 = powwin(d2, T-WIN, WIN) // echo+near-end power before cancel
172
173 // --- T5 double-talk DETECTOR (merged aec_run_dtd): converge, then the Geigel DTD AUTO-freezes on double-talk ---
174 z=0; while z < L { w[z]=0; z=z+1 }
175 aec_run(w, x, d, e, T, L, 1) // converge on echo-only first
176 aec_run_dtd(w, x, d2, e, T, L, 192, 256) // DTD auto-detects near speech + freezes (no manual freeze)
177 let p_dtd_err: i64 = powdiff(e, s, T-WIN, WIN) // cleaned output vs pure near-end (DTD preserved near-end)
178
179 var ok: i64 = 1
180 // T1: real echo cancellation (>= 100x = >=20 dB ERLE)
181 if ratio_late < 100 { ok = 0 }
182 // T5: the merged Geigel DTD auto-freeze preserves near-end during double-talk (no manual freeze needed)
183 if (p_dtd_err * 4) >= p_near { ok = 0 }
184 // T2: without adaptation, no cancellation (ratio ~1)
185 if ratio_noadapt > 2 { ok = 0 }
186 // T4: the filter LEARNS (late ERLE strictly better than early)
187 if ratio_late <= ratio_early { ok = 0 }
188 // T3: double-talk -- near-end SURVIVES (cleaned output within 25% power of pure near-end),
189 // i.e. echo removed AND near-end not crushed
190 if (p_recover_err * 4) >= p_near { ok = 0 }
191
192 aw(1, "=== nx_aec -- sovereign integer NLMS acoustic echo canceller ===\n" as *u8)
193 aw(1, " taps L=" as *u8); awn(1, L); aw(1, " echo-path M=" as *u8); awn(1, M); aw(1, " samples=" as *u8); awn(1, T); aw(1, " mu=1/2 (NO-FLOAT, Q16)\n" as *u8)
194 aw(1, " T1 ERLE steady-state: echo power reduced " as *u8); awn(1, ratio_late); aw(1, "x (~" as *u8); awn(1, erle); aw(1, " dB): " as *u8); if ratio_late>=100 { aw(1,"PASS" as *u8) } else { aw(1,"FAIL" as *u8) }
195 aw(1, "\n T4 convergence early=" as *u8); awn(1, ratio_early); aw(1, "x -> late=" as *u8); awn(1, ratio_late); aw(1, "x (filter LEARNS): " as *u8); if ratio_late>ratio_early { aw(1,"PASS" as *u8) } else { aw(1,"FAIL" as *u8) }
196 aw(1, "\n T2 NEG no-adaptation: reduction " as *u8); awn(1, ratio_noadapt); aw(1, "x (~1 = nothing; filter is load-bearing): " as *u8); if ratio_noadapt<=2 { aw(1,"PASS" as *u8) } else { aw(1,"FAIL" as *u8) }
197 aw(1, "\n T3 DOUBLE-TALK: near-end recovered, residual err " as *u8); awn(1, p_recover_err); aw(1, " vs near-end power " as *u8); awn(1, p_near); aw(1, " (mic had echo+near=" as *u8); awn(1, p_echo_in_dt); aw(1, "): " as *u8); if (p_recover_err*4)<p_near { aw(1,"PASS" as *u8) } else { aw(1,"FAIL" as *u8) }
198 aw(1, "\n T5 DTD (merged from runtime nx_aec): Geigel auto-freeze, near-end residual " as *u8); awn(1, p_dtd_err); aw(1, " vs near " as *u8); awn(1, p_near); aw(1, ": " as *u8); if (p_dtd_err*4)<p_near { aw(1,"PASS" as *u8) } else { aw(1,"FAIL" as *u8) }
199 aw(1, "\n HONEST SCOPE: linear echo path, white-noise excitation; double-talk now has a Geigel DETECTOR (merged, auto-freeze) -- remaining next rung = a nonlinear residual echo suppressor.\n" as *u8)
200 if ok==1 { aw(1, "VERDICT: GREEN (measured echo cancellation; near-end preserved; adaptation load-bearing)\n" as *u8) } else { aw(1, "VERDICT: RED\n" as *u8) }
201
202 let lfd: i64 = sys_openat_append(AEC_LOG, 420)
203 if lfd >= 0 {
204 aw(lfd, "AEC erle_ratio=" as *u8); awn(lfd, ratio_late); aw(lfd, " erle_db=" as *u8); awn(lfd, erle); aw(lfd, " noadapt=" as *u8); awn(lfd, ratio_noadapt); aw(lfd, " dt_err=" as *u8); awn(lfd, p_recover_err); aw(lfd, " dt_near=" as *u8); awn(lfd, p_near)
205 if ok==1 { aw(lfd, " verdict=GREEN\n" as *u8) } else { aw(lfd, " verdict=RED\n" as *u8) }
206 sys_close(lfd)
207 }
208 if ok==1 { sys_exit(0) } else { sys_exit(1) }
209 return 0
210}