code wiki / (root) / nx_aec_q15.nx

nx_aec_q15.nx source

↩ module page · 69 lines · 4196 B

1// nx_aec.nx -- sovereign ACOUSTIC ECHO CANCELLATION (the echoCancellation feature Zoom/Discord/Meet have): the 2// mic picks up the near-end speaker PLUS an echo of the far-end audio coming out of the speakers; AEC learns the 3// echo path with an adaptive NLMS filter over the far-end REFERENCE signal and subtracts the predicted echo, so the 4// other side hears your voice without their own voice bouncing back. Pure Nishi DSP (the browser only ferries raw 5// PCM). w[] = filter taps in Q15 (persist across calls, start zeroed). license_tier: ORIGINAL 6 7// process n samples: mic[] = near-speech + echo; ref[] = far-end reference. Writes the echo-cancelled signal to 8// out[] and adapts w[L] in place (NLMS, step in Q15 ~ mu*32768). 9func aec_run(mic: *i64, ref: *i64, n: i64, L: i64, step_q15: i64, w: *i64, out: *i64) -> i64 { 10 var i: i64 = 0 11 while i < n { 12 // predicted echo = sum_k w[k]*ref[i-k] (Q15 -> descale) 13 var echo: i64 = 0 14 var k: i64 = 0 15 while k < L { if i - k >= 0 { echo = echo + w[k] * ref[i - k] } k = k + 1 } 16 echo = echo / 32768 17 let err: i64 = mic[i] - echo // echo removed -> the near-end voice 18 out[i] = err 19 // reference power (NLMS normalization) 20 var power: i64 = 1 21 k = 0 22 while k < L { if i - k >= 0 { let r: i64 = ref[i - k]; power = power + r * r } k = k + 1 } 23 // NLMS tap update: w[k] += step * err * ref[i-k] / power 24 k = 0 25 while k < L { if i - k >= 0 { w[k] = w[k] + (step_q15 * err * ref[i - k]) / power } k = k + 1 } 26 i = i + 1 27 } 28 return 0 29} 30 31// aec_run_dtd -- AEC with a Geigel DOUBLE-TALK DETECTOR. The trouble with plain NLMS: when BOTH sides speak at once 32// (double-talk), the near-end voice corrupts the error signal and the filter mis-adapts (diverges). The fix is to 33// FREEZE adaptation whenever near-end speech is present. Geigel's test: near speech is present when the mic sample is 34// louder than the loudest recent reference could explain as echo -- |mic| > thresh * max_k|ref[i-k]| (thresh in Q8, 35// e.g. 192 = 0.75 for an echo path attenuated to <= ~0.75x). This lets us use a FAST step (quick convergence when the 36// far-end talks alone) WITHOUT diverging during double-talk -- strictly better than a slow step. A HANGOVER (hang 37// samples) keeps the filter frozen through the quiet inter-word gaps of a near-end utterance -- without it, a single 38// instant where near speech and echo cancel looks "quiet" and the filter wrongly resumes adapting mid-utterance. 39// Research: Geigel DTD (Duttweiler), normalized-cross-correlation DTD, hangover timing. license_tier: ORIGINAL 40func aec_run_dtd(mic: *i64, ref: *i64, n: i64, L: i64, step_q15: i64, w: *i64, out: *i64, dtd_thresh_q8: i64, hang: i64) -> i64 { 41 var i: i64 = 0 42 var hold: i64 = 0 // hangover counter: >0 -> recently heard near speech, stay frozen 43 while i < n { 44 var echo: i64 = 0 45 var k: i64 = 0 46 while k < L { if i - k >= 0 { echo = echo + w[k] * ref[i - k] } k = k + 1 } 47 echo = echo / 32768 48 let err: i64 = mic[i] - echo 49 out[i] = err 50 // Geigel double-talk detector: loudest |ref| over the filter window 51 var maxref: i64 = 0 52 k = 0 53 while k < L { if i - k >= 0 { var a: i64 = ref[i - k]; if a < 0 { a = 0 - a } if a > maxref { maxref = a } } k = k + 1 } 54 var amic: i64 = mic[i] 55 if amic < 0 { amic = 0 - amic } 56 if amic * 256 > dtd_thresh_q8 * maxref { hold = hang } // mic louder than echo could explain -> near speech, (re)arm hangover 57 if hold > 0 { 58 hold = hold - 1 // inside a near-end utterance -> FREEZE adaptation 59 } else { // far-end alone -> adapt 60 var power: i64 = 1 61 k = 0 62 while k < L { if i - k >= 0 { let r: i64 = ref[i - k]; power = power + r * r } k = k + 1 } 63 k = 0 64 while k < L { if i - k >= 0 { w[k] = w[k] + (step_q15 * err * ref[i - k]) / power } k = k + 1 } 65 } 66 i = i + 1 67 } 68 return 0 69}