code wiki / (root) / nx_vad.nx

nx_vad.nx source

↩ module page · 157 lines · 5231 B

1// nx_vad.nx -- voice activity detection. Phase 4a piece 4/5. 2// 3// Returns a sealed per-frame verdict so the encoder can SKIP 4// transmitting silent frames (save ~30% of typical-call bandwidth) 5// or transmit only a "comfort noise" descriptor (a few bytes vs a 6// full-rate payload). 7// 8// Features used (all integer): 9// 1. Short-time energy: sum of squared samples in the frame. 10// 2. Zero-crossing rate: count of sign flips; voice has lower ZCR 11// than unvoiced (whisper, breath, hiss). 12// 3. Stability: smoothed energy of previous 3 frames vs current. 13// 14// We DON'T use spectral features (FFT) at this layer -- they're 15// available via nx_fft but would couple this primitive to that one; 16// substrate cardinal "compose; don't couple" keeps VAD self-contained 17// and Tier-0 friendly. 18// 19// genealogy_id: rabiner_sambur_1975_voiced_unvoiced + opus_silk_vad + 20// webrtc_vad_2011 21// lineage_id: nishi_vad_q10 22 23// nx_safety_envelope: 24// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 25// sil_target: SIL1 26// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 27// verdict: NOT_YET_EVALUATED 28 29import "nx_syscalls.nx" 30 31// Sealed verdict per frame. 32const NX_VAD_VERDICT_UNKNOWN: i64 = 0 33const NX_VAD_VERDICT_SILENCE: i64 = 1 34const NX_VAD_VERDICT_TRANSITION: i64 = 2 // start/end of an utterance 35const NX_VAD_VERDICT_VOICE: i64 = 3 36const NX_VAD_VERDICT_NOISE: i64 = 4 // non-voice sound (e.g. typing) 37const NX_VAD_VERDICT_N: i64 = 5 38 39// Detector state. Caller persists across frames. 40struct VadState { 41 prev_energy_1: i64, 42 prev_energy_2: i64, 43 prev_energy_3: i64, 44 hangover: i64, // frames-since-last-voice; smooth transitions 45 noise_floor: i64, // adaptive; rises slowly during silence 46 init_done: i64 47} 48 49// Compute short-time energy. Samples are signed i64. 50func nx_vad_energy(samples: *i64, n: i64) -> i64 { 51 var sum: i64 = 0 52 var i: i64 = 0 53 while i < n { 54 let s: i64 = samples[i] 55 sum = sum + (s * s) 56 i = i + 1 57 } 58 // Normalise by N to make threshold compare-able across frame 59 // sizes. Right-shift instead of divide for tier-0 friendliness. 60 var shift: i64 = 0 61 var nn: i64 = n 62 while nn > 1 { nn = nn >> 1; shift = shift + 1 } 63 return sum >> shift 64} 65 66// Count sign changes in the frame. 67func nx_vad_zero_crossings(samples: *i64, n: i64) -> i64 { 68 if n < 2 { return 0 } 69 var zc: i64 = 0 70 var i: i64 = 1 71 while i < n { 72 let prev_pos: i64 = (samples[i-1] >= 0) 73 let curr_pos: i64 = (samples[i] >= 0) 74 if prev_pos != curr_pos { zc = zc + 1 } 75 i = i + 1 76 } 77 return zc 78} 79 80func nx_vad_init(s: *VadState) -> i64 { 81 s.prev_energy_1 = 0 82 s.prev_energy_2 = 0 83 s.prev_energy_3 = 0 84 s.hangover = 0 85 s.noise_floor = 64 // seed; adapts upward in silence 86 s.init_done = 1 87 return 0 88} 89 90// Classify the frame. Updates state. Returns the sealed verdict. 91// 92// Thresholds are conservative defaults; the encoder can tune by 93// passing the noise_floor knob. Caller-controlled tuning preferred 94// over fixed magic numbers per cardinal feedback-no-magic-numbers. 95func nx_vad_classify(s: *VadState, samples: *i64, n: i64) -> i64 { 96 if s.init_done != 1 { nx_vad_init(s) } 97 98 let e: i64 = nx_vad_energy(samples, n) 99 let zc: i64 = nx_vad_zero_crossings(samples, n) 100 let zcr_per_1k: i64 = (zc * 1000) / n 101 102 let prev_avg: i64 = (s.prev_energy_1 + s.prev_energy_2 + s.prev_energy_3) / 3 103 104 // Update state (shift in current). 105 s.prev_energy_3 = s.prev_energy_2 106 s.prev_energy_2 = s.prev_energy_1 107 s.prev_energy_1 = e 108 109 // Verdict: 110 // - energy < noise_floor * 1.25 => silence 111 // - energy >= noise_floor * 4 and zcr_per_1k < 250 => voice 112 // - energy >= noise_floor * 4 and zcr_per_1k >= 250 => noise 113 // (hiss, typing, etc; high zero-crossing rate) 114 // - otherwise => transition 115 116 var verdict: i64 = NX_VAD_VERDICT_TRANSITION 117 118 if e < s.noise_floor + (s.noise_floor >> 2) { 119 verdict = NX_VAD_VERDICT_SILENCE 120 } else { 121 if e >= (s.noise_floor << 2) { 122 if zcr_per_1k < 250 { 123 verdict = NX_VAD_VERDICT_VOICE 124 } else { 125 verdict = NX_VAD_VERDICT_NOISE 126 } 127 } 128 } 129 130 // Hangover: extend a few frames after VOICE to catch trailing 131 // syllables. Avoids choppy speech. 132 if verdict == NX_VAD_VERDICT_VOICE { 133 s.hangover = 5 134 } else { 135 if s.hangover > 0 { 136 s.hangover = s.hangover - 1 137 if verdict == NX_VAD_VERDICT_SILENCE { verdict = NX_VAD_VERDICT_TRANSITION } 138 } 139 } 140 141 // Adapt noise floor SLOWLY during silence; never let it run away. 142 if verdict == NX_VAD_VERDICT_SILENCE { 143 let target: i64 = (e * 7 + s.noise_floor * 1) >> 3 144 s.noise_floor = target 145 if s.noise_floor < 16 { s.noise_floor = 16 } 146 if s.noise_floor > 1000000 { s.noise_floor = 1000000 } 147 } 148 149 return verdict 150} 151 152// Sealed-enum validity gate. 153func nx_vad_verdict_is_valid(v: i64) -> i64 { 154 if v < 0 { return 0 } 155 if v >= NX_VAD_VERDICT_N { return 0 } 156 return 1 157}