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}