code wiki / _hdl_build / nx_vox_tract.nx

nx_vox_tract.nx source

↩ module page · 254 lines · 12782 B

1// nx_vox_tract.nx -- the TIMBRE half of animal-sound synthesis: a vocal tract as a physical tube. 2// 3// nx_vox_source produces a glottal flow. That flow sounds like a buzz until it passes through a 4// throat, and the shape of the throat is most of what makes a rabbit sound like a rabbit and a dog 5// sound like a dog. This organ is that throat, modelled as a chain of cylindrical sections. 6// 7// WHAT IS GENUINELY NEW HERE, AND WHAT IS DELIBERATELY NOT. A tree-wide audit ran before a line was 8// written (operator: "make sure we arent duplicating stuff"), and it found that most of this problem is 9// already solved: 10// * nx_lpc_levinson ALREADY emits PARCOR reflection coefficients in Q30. Reflection coefficients are 11// exactly what a tube chain is made of, so this organ adopts that representation verbatim rather 12// than inventing a second one -- a tract built here is interchangeable with the voice stack's. 13// * nx_lpc_synth ALREADY implements the all-pole filter in direct form. It is imported and USED. 14// What did not exist anywhere in 16000+ files, and is therefore all this organ adds: 15// * vt_area_to_k -- tube GEOMETRY (cross-sectional areas) -> reflection coefficients. The whole 16// point: the existing coefficients are FITTED to a recording, these are DERIVED 17// from a shape you can state, so a species can be described instead of sampled. 18// * vt_k_to_a -- the step-up recursion, so a geometry can drive the existing direct-form filter. 19// * vt_ladder -- the Kelly-Lochbaum scattering ladder. 20// 21// WHY A LADDER AT ALL, IF THE DIRECT FORM ALREADY EXISTS. They compute the same transfer function, so 22// the ladder has to earn its place: it is the PHYSICAL structure. Each coefficient is one tube 23// junction, forward and backward travelling waves are separately available, |k|<1 guarantees stability 24// by construction, and a section can be moved while the filter runs (which is what articulation is). 25// The direct form has none of that. To prove this is a second REALISATION and not a second 26// IMPLEMENTATION, nx_vox_tract_gate asserts the ladder and nx_lpc_synth agree sample-for-sample on the 27// same geometry. If they ever diverge, one of them is wrong and the gate says so. 28// 29// TRACT LENGTH SETS THE FORMANTS, and that is the species signature. For a uniform tube the 30// resonances sit at (2n-1)*c/(4L), so a 150 mm dog throat puts its first formant near 570 Hz and a 31// 15 mm mouse throat puts it near 5700 Hz. Section count is NOT free: a waveguide advances one section 32// per sample, so N = 2*L*rate/c. The organ derives N rather than letting a caller pick a number that 33// silently means a different tube than the one they asked for. 34// 35// license_tier: ORIGINAL expect_exit: 0 36 37import "nx_syscalls.nx" 38import "nx_lpc_synth.nx" // REUSE: the existing direct-form all-pole filter 39 40const VT_Q30: i64 = 1073741824 // 2^30 -- the PARCOR scale nx_lpc_levinson already uses 41const VT_C: i64 = 343000 // speed of sound, mm/s 42const VT_MAXSEC: i64 = 96 // sections; 96 covers a 170 mm tract at 96 kHz 43const VT_SHAPE_N: i64 = 8 // control points in a species shape profile 44 45// ---------------------------------------------------------------- geometry -> reflection coefficients 46// Section count a waveguide must use for a tube of length L at this sample rate: one section per 47// sample of travel time. Returned clamped to VT_MAXSEC. 48func vt_nsec(len_mm: i64, rate: i64) -> i64 { 49 if len_mm <= 0 { return 0 } 50 var n: i64 = (2 * len_mm * rate) / VT_C 51 if n < 2 { n = 2 } 52 if n > VT_MAXSEC { n = VT_MAXSEC } 53 return n 54} 55 56// Reflection coefficient at the junction between section i and i+1: 57// k = (A_i - A_{i+1}) / (A_i + A_{i+1}) 58// Areas may be in any consistent unit -- only their ratio matters, which is why a species can be 59// described by a SHAPE and scaled freely. Writes nsec-1 coefficients in Q30. Returns the count. 60func vt_area_to_k(areas: *i64, nsec: i64, k_out: *i64) -> i64 { 61 if nsec < 2 { return 0 } 62 var i: i64 = 0 63 while i < nsec - 1 { 64 let a0: i64 = areas[i] 65 let a1: i64 = areas[i + 1] 66 let sum: i64 = a0 + a1 67 var k: i64 = 0 68 if sum > 0 { k = ((a0 - a1) * VT_Q30) / sum } 69 // |k| < 1 is what makes a tube chain stable; a degenerate area could push it to the boundary. 70 if k > VT_Q30 - 1 { k = VT_Q30 - 1 } 71 if k < 1 - VT_Q30 { k = 1 - VT_Q30 } 72 k_out[i] = k 73 i = i + 1 74 } 75 return nsec - 1 76} 77 78// Step-up recursion: reflection coefficients -> direct-form predictor coefficients, both Q30. 79// 80// SIGN CONVENTION IS NOT A DETAIL. nx_lpc_synth computes s[n] = e[n] - sum a[j] s[n-j], i.e. 81// A(z) = 1 + sum a_j z^-j, and that convention requires PLUS in the step-up recursion. The other 82// common textbook convention (A(z) = 1 - sum ...) uses minus, and picking it here produced a filter 83// that was internally self-consistent and completely disagreed with the organ it was supposed to 84// reuse -- max sample difference 40768 out of a 32767 range. Both halves were "right"; they were 85// right about different conventions. This one follows nx_lpc_synth because reuse is the point. 86func vt_k_to_a(k: *i64, order: i64, a_out: *i64) -> i64 { 87 if order < 1 { return 0 } 88 let tmp: *i64 = sys_mmap((order + 1) * 8) as *i64 89 var i: i64 = 0 90 while i <= order { a_out[i] = 0; tmp[i] = 0; i = i + 1 } 91 var m: i64 = 1 92 while m <= order { 93 // a^(m)_m = k_m 94 tmp[m - 1] = k[m - 1] 95 var j: i64 = 1 96 while j < m { 97 tmp[j - 1] = a_out[j - 1] + ((k[m - 1] * a_out[m - j - 1]) / VT_Q30) 98 j = j + 1 99 } 100 j = 1 101 while j <= m { a_out[j - 1] = tmp[j - 1]; j = j + 1 } 102 m = m + 1 103 } 104 return order 105} 106 107// ---------------------------------------------------------------- the Kelly-Lochbaum ladder 108// All-pole lattice synthesis. exc[] is the excitation (glottal flow), out[] the radiated signal. 109// b[] carries the backward-travelling wave between samples -- that state IS the air in the tube. 110func vt_ladder(exc: *i64, n: i64, k: *i64, order: i64, out: *i64) -> i64 { 111 if order < 1 { return 0 - 1 } 112 let b: *i64 = sys_mmap((order + 2) * 8) as *i64 113 var i: i64 = 0 114 while i <= order + 1 { b[i] = 0; i = i + 1 } 115 var t: i64 = 0 116 while t < n { 117 var f: i64 = exc[t] 118 // walk the junctions from the lips back to the glottis 119 // Matched to the step-up above and hence to nx_lpc_synth: subtract on the forward path, 120 // add on the backward path. T5 in the gate is what holds these three in agreement. 121 var m: i64 = order 122 while m >= 1 { 123 f = f - ((k[m - 1] * b[m - 1]) / VT_Q30) 124 b[m] = b[m - 1] + ((k[m - 1] * f) / VT_Q30) 125 m = m - 1 126 } 127 b[0] = f 128 out[t] = f 129 t = t + 1 130 } 131 return 0 132} 133 134// ---------------------------------------------------------------- species 135// A species is a tract LENGTH plus a SHAPE (relative areas, lips last). Length sets where the 136// formants sit; shape sets their pattern. Both are data, not code (rule 11) -- the table below is the 137// fallback and data/vox_species.cfg overrides it. 138const VT_SP_LEN: i64 = 0 // mm, glottis to lips 139const VT_SP_F0: i64 = 1 // Hz, typical fundamental for the source driving it 140const VT_SP_SHAPE: i64 = 2 // VT_SHAPE_N relative areas follow 141const VT_SP_N: i64 = 10 // 2 + VT_SHAPE_N 142 143const VT_NSPECIES: i64 = 5 144const VT_SP_RABBIT: i64 = 0 145const VT_SP_RODENT: i64 = 1 146const VT_SP_BIRD: i64 = 2 147const VT_SP_DOG: i64 = 3 148const VT_SP_UNIFORM: i64 = 4 // a plain tube: the control case, no shaping at all 149 150// Shapes are relative areas from glottis (index 0) to lips. A narrow glottal end opening into a wider 151// pharynx and closing again at the lips is the generic mammalian pattern; the differences between these 152// rows are what a listener hears as species. 153// 154// PROVENANCE: these are PLAUSIBLE SHAPES consistent with published tract lengths for each class, NOT 155// measured area functions from imaging or casts. The lane has no licensed morphometric source, so the 156// lengths are defensible and the shapes are illustrative. Stated here so the distinction survives. 157func vt_species_defaults(tbl: *i64) -> i64 { 158 var i: i64 = 0 159 while i < VT_NSPECIES * VT_SP_N { tbl[i] = 0; i = i + 1 } 160 161 // rabbit: ~55 mm tract, distress calls around 1-3 kHz 162 tbl[VT_SP_RABBIT * VT_SP_N + VT_SP_LEN] = 55 163 tbl[VT_SP_RABBIT * VT_SP_N + VT_SP_F0] = 1400 164 tbl[VT_SP_RABBIT * VT_SP_N + VT_SP_SHAPE + 0] = 10 165 tbl[VT_SP_RABBIT * VT_SP_N + VT_SP_SHAPE + 1] = 22 166 tbl[VT_SP_RABBIT * VT_SP_N + VT_SP_SHAPE + 2] = 34 167 tbl[VT_SP_RABBIT * VT_SP_N + VT_SP_SHAPE + 3] = 30 168 tbl[VT_SP_RABBIT * VT_SP_N + VT_SP_SHAPE + 4] = 20 169 tbl[VT_SP_RABBIT * VT_SP_N + VT_SP_SHAPE + 5] = 14 170 tbl[VT_SP_RABBIT * VT_SP_N + VT_SP_SHAPE + 6] = 12 171 tbl[VT_SP_RABBIT * VT_SP_N + VT_SP_SHAPE + 7] = 9 172 173 // rodent: ~16 mm tract, squeaks reaching well past 8 kHz -- the terrier target 174 tbl[VT_SP_RODENT * VT_SP_N + VT_SP_LEN] = 16 175 tbl[VT_SP_RODENT * VT_SP_N + VT_SP_F0] = 4200 176 tbl[VT_SP_RODENT * VT_SP_N + VT_SP_SHAPE + 0] = 6 177 tbl[VT_SP_RODENT * VT_SP_N + VT_SP_SHAPE + 1] = 9 178 tbl[VT_SP_RODENT * VT_SP_N + VT_SP_SHAPE + 2] = 14 179 tbl[VT_SP_RODENT * VT_SP_N + VT_SP_SHAPE + 3] = 16 180 tbl[VT_SP_RODENT * VT_SP_N + VT_SP_SHAPE + 4] = 13 181 tbl[VT_SP_RODENT * VT_SP_N + VT_SP_SHAPE + 5] = 10 182 tbl[VT_SP_RODENT * VT_SP_N + VT_SP_SHAPE + 6] = 8 183 tbl[VT_SP_RODENT * VT_SP_N + VT_SP_SHAPE + 7] = 7 184 185 // upland bird: ~75 mm trachea, near-uniform bore -- birds shape far less than mammals 186 tbl[VT_SP_BIRD * VT_SP_N + VT_SP_LEN] = 75 187 tbl[VT_SP_BIRD * VT_SP_N + VT_SP_F0] = 2300 188 tbl[VT_SP_BIRD * VT_SP_N + VT_SP_SHAPE + 0] = 12 189 tbl[VT_SP_BIRD * VT_SP_N + VT_SP_SHAPE + 1] = 14 190 tbl[VT_SP_BIRD * VT_SP_N + VT_SP_SHAPE + 2] = 15 191 tbl[VT_SP_BIRD * VT_SP_N + VT_SP_SHAPE + 3] = 15 192 tbl[VT_SP_BIRD * VT_SP_N + VT_SP_SHAPE + 4] = 15 193 tbl[VT_SP_BIRD * VT_SP_N + VT_SP_SHAPE + 5] = 14 194 tbl[VT_SP_BIRD * VT_SP_N + VT_SP_SHAPE + 6] = 13 195 tbl[VT_SP_BIRD * VT_SP_N + VT_SP_SHAPE + 7] = 12 196 197 // dog: ~150 mm tract -- present so the toy can be told what NOT to sound like 198 tbl[VT_SP_DOG * VT_SP_N + VT_SP_LEN] = 150 199 tbl[VT_SP_DOG * VT_SP_N + VT_SP_F0] = 450 200 tbl[VT_SP_DOG * VT_SP_N + VT_SP_SHAPE + 0] = 14 201 tbl[VT_SP_DOG * VT_SP_N + VT_SP_SHAPE + 1] = 30 202 tbl[VT_SP_DOG * VT_SP_N + VT_SP_SHAPE + 2] = 46 203 tbl[VT_SP_DOG * VT_SP_N + VT_SP_SHAPE + 3] = 42 204 tbl[VT_SP_DOG * VT_SP_N + VT_SP_SHAPE + 4] = 28 205 tbl[VT_SP_DOG * VT_SP_N + VT_SP_SHAPE + 5] = 22 206 tbl[VT_SP_DOG * VT_SP_N + VT_SP_SHAPE + 6] = 20 207 tbl[VT_SP_DOG * VT_SP_N + VT_SP_SHAPE + 7] = 18 208 209 // uniform tube: every area equal, so every reflection coefficient is zero. The control. 210 tbl[VT_SP_UNIFORM * VT_SP_N + VT_SP_LEN] = 55 211 tbl[VT_SP_UNIFORM * VT_SP_N + VT_SP_F0] = 1400 212 i = 0 213 while i < VT_SHAPE_N { tbl[VT_SP_UNIFORM * VT_SP_N + VT_SP_SHAPE + i] = 20; i = i + 1 } 214 return 0 215} 216 217// Expand a species' 8-point shape into `nsec` section areas by linear interpolation. 218func vt_species_areas(tbl: *i64, sp: i64, nsec: i64, areas: *i64) -> i64 { 219 if nsec < 2 { return 0 } 220 let base: i64 = sp * VT_SP_N + VT_SP_SHAPE 221 var i: i64 = 0 222 while i < nsec { 223 // position along the shape, in units of (VT_SHAPE_N-1) scaled by 1024 for the interpolation 224 let pos: i64 = (i * (VT_SHAPE_N - 1) * 1024) / (nsec - 1) 225 let idx: i64 = pos / 1024 226 let frac: i64 = pos % 1024 227 var a0: i64 = tbl[base + idx] 228 var a1: i64 = a0 229 if idx + 1 < VT_SHAPE_N { a1 = tbl[base + idx + 1] } 230 areas[i] = a0 + ((a1 - a0) * frac) / 1024 231 if areas[i] < 1 { areas[i] = 1 } 232 i = i + 1 233 } 234 return nsec 235} 236 237// First formant of a uniform tube of this length: F1 = c / (4L). Published beside any species so the 238// tract's headline number is checkable by hand rather than taken on trust. 239func vt_f1_uniform(len_mm: i64) -> i64 { 240 if len_mm <= 0 { return 0 } 241 return VT_C / (4 * len_mm) 242} 243 244// Convenience: drive a species tract with an excitation, in one call. 245func vt_speak(tbl: *i64, sp: i64, exc: *i64, n: i64, rate: i64, out: *i64) -> i64 { 246 let len_mm: i64 = tbl[sp * VT_SP_N + VT_SP_LEN] 247 let nsec: i64 = vt_nsec(len_mm, rate) 248 if nsec < 2 { return 0 - 1 } 249 let areas: *i64 = sys_mmap(nsec * 8) as *i64 250 let k: *i64 = sys_mmap(nsec * 8) as *i64 251 vt_species_areas(tbl, sp, nsec, areas) 252 let order: i64 = vt_area_to_k(areas, nsec, k) 253 return vt_ladder(exc, n, k, order, out) 254}