code wiki / _hdl_build / nx_vox_source.nx

nx_vox_source.nx source

↩ module page · 359 lines · 18028 B

1// nx_vox_source.nx -- a SELF-OSCILLATING physical voice source. Rung R2 of the bioacoustics lane. 2// 3// WHAT MAKES THIS DIFFERENT FROM EVERY OTHER AUDIO ORGAN IN THE TREE. nx_audio_bark, nx_tts_formant, 4// nx_phoneme_synth and friends are all SOURCE-FILTER synthesisers: something generates a pulse train at 5// a frequency you tell it, and a filter bank shapes it. The frequency is an input. Nothing oscillates 6// on its own, so nothing can do what real animal larynges do -- jump registers, split into two pitches, 7// break into chaos -- unless a human scripts each event by hand. That is why nx_audio_bark's header 8// says "tune by ear": there is nothing to measure because there is no physics. 9// 10// Here the frequency is an OUTPUT. This is a mass-spring-damper model of two vocal folds, driven by 11// nothing but a constant subglottal pressure, and it oscillates because of the aerodynamics: 12// 13// * Each fold is two coupled masses (lower m1, upper m2) -- the standard two-mass idealisation of the 14// body-cover structure of a real fold. 15// * The glottal areas are a1 = g0 + xL1 + xR1 and a2 = g0 + xL2 + xR2. 16// * Bernoulli gives the driving pressure P1 = Ps * (1 - (amin/a1)^2) where amin is the narrowest 17// point. When the glottis is CONVERGENT (a2 < a1) the narrowest point is downstream, pressure 18// inside the glottis stays high, and the folds are pushed APART. When it is DIVERGENT (a1 < a2) 19// the flow separates at the entry, P1 collapses to zero, and the springs pull the folds shut. 20// * Because m2 lags m1, the glottis is convergent while opening and divergent while closing. Energy 21// goes in during opening and not during closing. That asymmetry IS the oscillation -- it is why a 22// larynx works, and it is the whole reason this file exists. 23// 24// EVERYTHING THE RULER MEASURES FALLS OUT OF THE PHYSICS, unscripted: 25// * f0 emerges from mass and stiffness; it is never written down anywhere. 26// * SUBHARMONICS appear when the two folds entrain at 2:1 instead of 1:1. 27// * BIPHONATION appears when left and right folds are detuned enough to stop entraining and each 28// runs at its own rate. This is why the model is left/right asymmetric rather than a 29// single fold mirrored -- a symmetric model can never produce it. 30// * CHAOS appears at high subglottal pressure, where the collision nonlinearity dominates. 31// * NON-REPETITION comes from turbulent pressure jitter, so no two cycles are ever identical. A dog 32// habituates to a sample loop precisely because a loop cannot do this. 33// 34// The claim "these phenomena are emergent" is not an assertion in a comment -- nx_vox_source_gate 35// SWEEPS asymmetry and pressure and shows the regimes appearing in the order the physics predicts, as 36// measured by nx_bioacoustic_bench, which was built and frozen BEFORE this file existed. 37// 38// Integer fixed-point throughout (Q16), explicit Euler at an oversampled step. Deterministic: the same 39// parameters give bit-identical audio, which is what makes a regression baseline meaningful. 40// license_tier: ORIGINAL expect_exit: 0 41 42import "nx_syscalls.nx" 43import "nx_isqrt.nx" 44 45const VF_Q: i64 = 65536 46const VF_PI2: i64 = 411774 // 2*pi in Q16 -- same constant the rest of the audio family uses 47 48// ---------------------------------------------------------------- parameters (data-driven, rule 11) 49const VF_P_F0_L: i64 = 0 // Hz natural frequency of the LEFT fold 50const VF_P_F0_R: i64 = 1 // Hz natural frequency of the RIGHT fold (detune -> biphonation) 51const VF_P_ZETA: i64 = 2 // Q16 damping ratio 52const VF_P_KCOUP: i64 = 3 // Q16 spring coupling between the lower and upper mass of one fold 53const VF_P_G0: i64 = 4 // Q16 rest half-gap; negative means the folds rest pressed together 54const VF_P_PS: i64 = 5 // Q16 subglottal pressure -- the ONLY energy input 55const VF_P_KCOLL: i64 = 6 // Q16 extra stiffness when the folds collide (the closure nonlinearity) 56const VF_P_F2RATIO: i64 = 7 // Q16 upper-mass natural frequency as a fraction of the lower 57const VF_P_DRIVE: i64 = 8 // Q16 aerodynamic force gain 58const VF_P_OVS: i64 = 9 // integration steps per audio sample 59const VF_P_SEED: i64 = 10 // jitter RNG seed 60const VF_P_JITTER: i64 = 11 // Q16 turbulent pressure fluctuation -- why no two cycles repeat 61// Rest gap of the UPPER aperture. A valve whose two apertures are identical at rest is perfectly 62// symmetric, so a1 == a2, the Bernoulli term is exactly zero, and nothing can start it: pressure alone 63// produces silence forever. The standalone model got away with this because it is handed a small 64// asymmetric nudge at t=0 and its pressure is on from the first sample, so the nudge grows before it 65// damps. Drive the same model from a chamber that takes 60 ms to pressurise and the nudge is long dead 66// by the time there is anything to amplify -- which is exactly how the squeaker came out silent. 67// Real folds and real reed tongues both rest in a CONVERGENT shape; this parameter is that shape, and 68// it makes the model self-starting from pressure alone. Zero means "same as VF_P_G0". 69const VF_P_G0_2: i64 = 12 // Q16 70const VF_P_N: i64 = 13 71 72// ---------------------------------------------------------------- state 73const VF_S_XL1: i64 = 0 74const VF_S_VL1: i64 = 1 75const VF_S_XL2: i64 = 2 76const VF_S_VL2: i64 = 3 77const VF_S_XR1: i64 = 4 78const VF_S_VR1: i64 = 5 79const VF_S_XR2: i64 = 6 80const VF_S_VR2: i64 = 7 81const VF_S_RNG: i64 = 8 82const VF_S_N: i64 = 9 83 84// ---------------------------------------------------------------- precomputed coefficients 85// UNITS. The first version of this model stored displacement and velocity both in Q16 and added the 86// aerodynamic drive directly as a pressure. Those are not the same units: stiffness enters the update 87// as (w*dt)^2, which at 420 Hz and a 384 kHz sub-rate is about 5e-5, while the drive was order 1. 88// The spring was therefore ~20000x too weak to ever pull the folds back, they flew to the safety clamp 89// and stayed there, and the model emitted a DC rail with jitter on it. It still scored 460 on the 90// ruler and still reported ax_vocal=1000, because a railed signal is locally smooth -- which is exactly 91// why the ground-truth probe (count glottal closures directly) had to exist before any of this was 92// believed. openduty_permille=999 and closures=1 is what a "working" voice source looked like. 93// 94// Now every force is expressed as an ACCELERATION in the same units as the stiffness term, and the 95// drive is stated as "the displacement this pressure would hold the fold at", which is unit-correct by 96// construction. Velocity is carried in Q32 because the per-sample acceleration of a 420 Hz oscillator 97// at Q16 rounds to the integer 3 and quantisation eats the dynamics. 98const VF_K_WL1: i64 = 0 // Q16 w*dt for the lower-left mass 99const VF_K_WL2: i64 = 1 100const VF_K_WR1: i64 = 2 101const VF_K_WR2: i64 = 3 102const VF_K_RL1: i64 = 4 // Q16 2*zeta*w*dt 103const VF_K_RL2: i64 = 5 104const VF_K_RR1: i64 = 6 105const VF_K_RR2: i64 = 7 106const VF_K_N: i64 = 8 107const VF_XCLAMP: i64 = 16 * 65536 // +-16.0 displacement; a divergent solve is bounded, not loud 108 109func vfold_defaults(p: *i64) -> i64 { 110 p[VF_P_F0_L] = 420 111 p[VF_P_F0_R] = 420 // symmetric by default -> ordinary 1:1 phonation 112 p[VF_P_ZETA] = 6553 // 0.10 113 p[VF_P_KCOUP] = 32768 // 0.50 114 p[VF_P_G0] = 6553 // 0.10 -- a small resting gap 115 p[VF_P_PS] = 65536 // 1.00 116 p[VF_P_KCOLL] = 262144 // 4.00 -- collision is much stiffer than the free spring 117 p[VF_P_F2RATIO] = 45875 // 0.70 -- the upper mass is lighter and slower 118 p[VF_P_DRIVE] = 65536 // 1.00 119 p[VF_P_OVS] = 4 120 p[VF_P_SEED] = 20260725 121 p[VF_P_JITTER] = 1310 // 0.02 -- 2% turbulent pressure fluctuation 122 p[VF_P_G0_2] = 0 // 0 = mirror VF_P_G0 (symmetric rest shape) 123 return 0 124} 125 126// Natural frequency -> per-sample stiffness and damping. A mass-spring with stiffness K (Q16) advanced 127// by x += v, v += -K*x - R*v oscillates at sqrt(K/Q) radians PER SAMPLE, so K = Q * (2*pi*f/SR)^2. 128func vfold_init(p: *i64, k: *i64, s: *i64, rate: i64) -> i64 { 129 let sr: i64 = rate * p[VF_P_OVS] 130 let wl: i64 = (VF_PI2 * p[VF_P_F0_L]) / sr 131 let wr: i64 = (VF_PI2 * p[VF_P_F0_R]) / sr 132 let wl2: i64 = (wl * p[VF_P_F2RATIO]) / VF_Q 133 let wr2: i64 = (wr * p[VF_P_F2RATIO]) / VF_Q 134 k[VF_K_WL1] = wl 135 k[VF_K_WR1] = wr 136 k[VF_K_WL2] = wl2 137 k[VF_K_WR2] = wr2 138 k[VF_K_RL1] = (2 * p[VF_P_ZETA] * wl) / VF_Q 139 k[VF_K_RR1] = (2 * p[VF_P_ZETA] * wr) / VF_Q 140 k[VF_K_RL2] = (2 * p[VF_P_ZETA] * wl2) / VF_Q 141 k[VF_K_RR2] = (2 * p[VF_P_ZETA] * wr2) / VF_Q 142 var i: i64 = 0 143 while i < VF_S_N { s[i] = 0; i = i + 1 } 144 // A tiny asymmetric nudge, not a driving term: the model must AMPLIFY this on its own or it is not 145 // a self-oscillator. vs gate assertion S2 sets Ps = 0 and requires the nudge to die away. 146 s[VF_S_XL1] = 256 147 s[VF_S_XR1] = 192 148 s[VF_S_RNG] = p[VF_P_SEED] 149 return 0 150} 151 152func vfold_rng(s: *i64) -> i64 { 153 var r: i64 = s[VF_S_RNG] 154 r = r ^ (r << 13) 155 r = r ^ (r >> 7) 156 r = r ^ (r << 17) 157 s[VF_S_RNG] = r 158 if r < 0 { return 0 - r } 159 return r 160} 161 162// One integration sub-step. Returns the glottal flow for this sub-step (Q16). 163func vfold_substep(s: *i64, p: *i64, k: *i64) -> i64 { 164 // --- glottal areas. a1 is the lower (inlet) aperture, a2 the upper (outlet). 165 var g02: i64 = p[VF_P_G0_2] 166 if g02 == 0 { g02 = p[VF_P_G0] } 167 let a1: i64 = p[VF_P_G0] + s[VF_S_XL1] + s[VF_S_XR1] 168 let a2: i64 = g02 + s[VF_S_XL2] + s[VF_S_XR2] 169 var amin: i64 = a1 170 if a2 < amin { amin = a2 } 171 172 // --- turbulent pressure jitter. Physically this is broadband noise from the jet; practically it 173 // is the reason a real animal never emits the same cycle twice. 174 var ps: i64 = p[VF_P_PS] 175 if p[VF_P_JITTER] > 0 { 176 let j: i64 = vfold_rng(s) % (2 * p[VF_P_JITTER] + 1) 177 ps = ps + (((j - p[VF_P_JITTER]) * ps) / VF_Q) 178 } 179 180 // --- Bernoulli drive. Zero while the glottis is shut: no flow, no driving pressure. 181 var p1: i64 = 0 182 var flow: i64 = 0 183 if amin > 0 { 184 if a1 > 0 { 185 let ratio: i64 = (amin * VF_Q) / a1 186 let sq: i64 = (ratio * ratio) / VF_Q 187 p1 = (ps * (VF_Q - sq)) / VF_Q 188 } 189 // U = amin * sqrt(2*Ps/rho); constants fold into DRIVE, so U ~ amin * sqrt(ps) 190 var pp: i64 = ps 191 if pp < 0 { pp = 0 } 192 flow = (amin * nx_isqrt(pp * VF_Q)) / VF_Q 193 } 194 // The drive is expressed as the DISPLACEMENT this pressure would hold a fold at, so that turning it 195 // into an acceleration below is just the stiffness term evaluated at that displacement -- units 196 // cannot drift apart again. 197 let xdrive: i64 = (p[VF_P_DRIVE] * p1) / VF_Q 198 199 // --- collision. When a fold crosses the midline the tissue is compressed, far stiffer than the 200 // free spring. Stated as a MULTIPLE of the fold's own stiffness, so it scales with pitch. 201 // This is the strongest nonlinearity here and the route to chaos at high pressure. 202 var xcoll1: i64 = 0 203 var xcoll2: i64 = 0 204 if a1 < 0 { xcoll1 = (p[VF_P_KCOLL] * (0 - a1)) / VF_Q } 205 if a2 < 0 { xcoll2 = (p[VF_P_KCOLL] * (0 - a2)) / VF_Q } 206 207 // Coupling between the lower and upper mass, also as a fraction of stiffness. 208 let cplL: i64 = (p[VF_P_KCOUP] * (s[VF_S_XL1] - s[VF_S_XL2])) / VF_Q 209 let cplR: i64 = (p[VF_P_KCOUP] * (s[VF_S_XR1] - s[VF_S_XR2])) / VF_Q 210 211 // --- accelerations, all in Q32 (velocity units). acc = w^2 * (xdrive - x) etc, computed as 212 // (W*W*X)>>16 which keeps full precision instead of rounding w^2 to the integer 3. 213 let wL1: i64 = k[VF_K_WL1] 214 let wL2: i64 = k[VF_K_WL2] 215 let wR1: i64 = k[VF_K_WR1] 216 let wR2: i64 = k[VF_K_WR2] 217 218 let eL1: i64 = xdrive + xcoll1 - s[VF_S_XL1] - cplL 219 let eL2: i64 = xcoll2 - s[VF_S_XL2] + cplL 220 let eR1: i64 = xdrive + xcoll1 - s[VF_S_XR1] - cplR 221 let eR2: i64 = xcoll2 - s[VF_S_XR2] + cplR 222 223 var aL1: i64 = ((wL1 * wL1) * eL1) >> 16 224 var aL2: i64 = ((wL2 * wL2) * eL2) >> 16 225 var aR1: i64 = ((wR1 * wR1) * eR1) >> 16 226 var aR2: i64 = ((wR2 * wR2) * eR2) >> 16 227 aL1 = aL1 - ((k[VF_K_RL1] * s[VF_S_VL1]) >> 16) 228 aL2 = aL2 - ((k[VF_K_RL2] * s[VF_S_VL2]) >> 16) 229 aR1 = aR1 - ((k[VF_K_RR1] * s[VF_S_VR1]) >> 16) 230 aR2 = aR2 - ((k[VF_K_RR2] * s[VF_S_VR2]) >> 16) 231 232 // Semi-implicit (symplectic) Euler: advance velocity first, then position with the NEW velocity. 233 // Plain explicit Euler pumps energy into an oscillator every cycle and is unconditionally unstable 234 // here; this ordering is stable for w*dt < 2, which the oversampling guarantees. 235 s[VF_S_VL1] = s[VF_S_VL1] + aL1 236 s[VF_S_VL2] = s[VF_S_VL2] + aL2 237 s[VF_S_VR1] = s[VF_S_VR1] + aR1 238 s[VF_S_VR2] = s[VF_S_VR2] + aR2 239 s[VF_S_XL1] = s[VF_S_XL1] + (s[VF_S_VL1] >> 16) 240 s[VF_S_XL2] = s[VF_S_XL2] + (s[VF_S_VL2] >> 16) 241 s[VF_S_XR1] = s[VF_S_XR1] + (s[VF_S_VR1] >> 16) 242 s[VF_S_XR2] = s[VF_S_XR2] + (s[VF_S_VR2] >> 16) 243 244 // NEVER-BRICK ANALOG (rule 26 applied to numerics): an integrator can diverge, and a divergent 245 // state would emit full-scale noise into whatever is downstream. Clamp so a bad parameter set 246 // produces a bounded, obviously-wrong sound instead of an unbounded one. The gate checks that a 247 // healthy configuration never touches this clamp -- a model that lives on the rail is not a model. 248 // Displacements are Q16 and velocities Q32, so they need DIFFERENT bounds -- clamping the Q32 249 // velocities to a Q16 bound would silently throttle every fold to a crawl. 250 let vclamp: i64 = VF_XCLAMP * VF_Q 251 var i: i64 = 0 252 while i < 8 { 253 var lim: i64 = vclamp 254 if (i % 2) == 0 { lim = VF_XCLAMP } 255 if s[i] > lim { s[i] = lim } 256 if s[i] < 0 - lim { s[i] = 0 - lim } 257 i = i + 1 258 } 259 return flow 260} 261 262// Render `n` samples of glottal flow into out[] (Q16 flow units). Returns the peak absolute value, 263// which is 0 exactly when the model did not oscillate. 264func vfold_render(out: *i64, n: i64, rate: i64, p: *i64, s: *i64, k: *i64) -> i64 { 265 var peak: i64 = 0 266 let ovs: i64 = p[VF_P_OVS] 267 var i: i64 = 0 268 while i < n { 269 var acc: i64 = 0 270 var o: i64 = 0 271 while o < ovs { acc = acc + vfold_substep(s, p, k); o = o + 1 } 272 let v: i64 = acc / ovs 273 out[i] = v 274 var a: i64 = v 275 if a < 0 { a = 0 - a } 276 if a > peak { peak = a } 277 i = i + 1 278 } 279 return peak 280} 281 282// ---------------------------------------------------------------- self-reported ground truth 283// The model counts its OWN glottal closures, which gives an f0 that owes nothing to any pitch tracker. 284// This exists because the first working-looking version of this organ was not oscillating at all -- 285// it had railed to the safety clamp and was emitting DC with jitter on it, and the ruler still scored 286// it 460 with ax_vocal=1000. A physical model must be able to state what it did independently of the 287// instrument measuring it, or there is no way to tell which of the two is wrong. 288// 289// diag[0] = closures counted, diag[1] = samples with the glottis open, diag[2] = clamp hits. 290// Returns closures per second. 291const VF_D_CLOSURES: i64 = 0 292const VF_D_OPEN: i64 = 1 293const VF_D_CLAMP: i64 = 2 294const VF_D_N: i64 = 3 295 296func vfold_render_diag(out: *i64, n: i64, rate: i64, p: *i64, s: *i64, k: *i64, diag: *i64) -> i64 { 297 diag[VF_D_CLOSURES] = 0 298 diag[VF_D_OPEN] = 0 299 diag[VF_D_CLAMP] = 0 300 let ovs: i64 = p[VF_P_OVS] 301 var wasopen: i64 = 0 302 var i: i64 = 0 303 while i < n { 304 var acc: i64 = 0 305 var o: i64 = 0 306 while o < ovs { acc = acc + vfold_substep(s, p, k); o = o + 1 } 307 out[i] = acc / ovs 308 let a1: i64 = p[VF_P_G0] + s[VF_S_XL1] + s[VF_S_XR1] 309 let a2: i64 = p[VF_P_G0] + s[VF_S_XL2] + s[VF_S_XR2] 310 var amin: i64 = a1 311 if a2 < amin { amin = a2 } 312 var isopen: i64 = 0 313 if amin > 0 { isopen = 1; diag[VF_D_OPEN] = diag[VF_D_OPEN] + 1 } 314 if wasopen == 1 { if isopen == 0 { diag[VF_D_CLOSURES] = diag[VF_D_CLOSURES] + 1 } } 315 wasopen = isopen 316 // A healthy configuration must never reach the divergence clamp. A model that lives on the 317 // rail is not a model, and the gate refuses one. 318 var j: i64 = 0 319 while j < 8 { 320 var lim: i64 = VF_XCLAMP * VF_Q 321 if (j % 2) == 0 { lim = VF_XCLAMP } 322 if s[j] >= lim { diag[VF_D_CLAMP] = diag[VF_D_CLAMP] + 1 } 323 if s[j] <= 0 - lim { diag[VF_D_CLAMP] = diag[VF_D_CLAMP] + 1 } 324 j = j + 1 325 } 326 i = i + 1 327 } 328 if n <= 0 { return 0 } 329 return (diag[VF_D_CLOSURES] * rate) / n 330} 331 332// Convenience: initialise and render in one call, then scale to a 16-bit audio range. 333// The glottal flow is a positive-only pulse train, so its DC component is removed first -- radiation 334// from a real mouth differentiates the flow, and leaving the DC in would waste headroom on an offset 335// no dog can hear. 336func vfold_render_audio(out: *i64, n: i64, rate: i64, p: *i64, amp: i64) -> i64 { 337 let s: *i64 = sys_mmap(VF_S_N * 8) as *i64 338 let k: *i64 = sys_mmap(VF_K_N * 8) as *i64 339 vfold_init(p, k, s, rate) 340 let peak: i64 = vfold_render(out, n, rate, p, s, k) 341 if peak <= 0 { return 0 } 342 var sum: i64 = 0 343 var i: i64 = 0 344 while i < n { sum = sum + out[i]; i = i + 1 } 345 let dc: i64 = sum / n 346 var mx: i64 = 0 347 i = 0 348 while i < n { 349 out[i] = out[i] - dc 350 var a: i64 = out[i] 351 if a < 0 { a = 0 - a } 352 if a > mx { mx = a } 353 i = i + 1 354 } 355 if mx <= 0 { return 0 } 356 i = 0 357 while i < n { out[i] = (out[i] * amp) / mx; i = i + 1 } 358 return mx 359}