code wiki / _hdl_build / nx_squeaker.nx
nx_squeaker.nx source
↩ module page · 290 lines · 14542 B
1// nx_squeaker.nx -- THE TOY. A dog-toy ball that makes its noise because a dog's jaw compresses it.
2//
3// Operator 2026-07-25: "a dog toy ball that is designed to make the noise when compressed by a dog's
4// mouth". This is the physical model of that object, from the bite down:
5//
6// jaw force -> shell compression -> air displaced -> chamber pressure -> reed valve flutters
7// -> flow through the neck -> radiated sound
8//
9// Every stage is a real physical quantity in manufacturable units (newtons, millimetres, cubic
10// millimetres), because the whole point of rung R5 is to INVERT this model and hand back a geometry
11// somebody can actually mould. A model in arbitrary units cannot produce a spec.
12//
13// WHY THE PITCH GLIDES, AND WHY THAT IS THE PRODUCT. The chamber is a Helmholtz resonator:
14// w = c * sqrt(A_neck / (V_chamber * L_neck))
15// A dog's bite REMOVES volume from the chamber, and w rises as V falls. So the sound a dog produces
16// by biting is not a fixed beep -- it is a rising squeal whose contour is drawn by the dog's own jaw.
17// Prey distress calls rise in pitch too. That correspondence is the reason this object can sound like
18// something worth catching instead of like a rubber toy, and it is measurable: the gate checks that a
19// harder bite raises the pitch and that a bigger chamber lowers it, both in the direction the physics
20// demands rather than the direction I would like.
21//
22// THE REED IS THE SAME PHYSICS AS A VOCAL FOLD. A squeaker tongue and a larynx are both elastic
23// valves driven unstably by the flow past them, so this organ does not implement a second oscillator:
24// it drives nx_vox_source with the chamber pressure and takes its flow back. That reuse is not just
25// DRY, it is why the toy inherits nonlinear regimes -- the roughness, the pitch breaks and the
26// cycle-to-cycle variation that stop a dog habituating -- from a model already gated at 13/13.
27//
28// Integer fixed-point, deterministic. license_tier: ORIGINAL expect_exit: 0
29
30import "nx_syscalls.nx"
31import "nx_isqrt.nx"
32import "nx_vox_source.nx"
33
34const SQ_Q: i64 = 65536
35const SQ_C: i64 = 343000 // speed of sound, mm/s
36
37// ---------------------------------------------------------------- parameters, in buildable units
38const SQ_P_V0: i64 = 0 // mm^3 chamber volume at rest
39const SQ_P_AN_X100: i64 = 1 // mm^2 neck cross-section, x100
40const SQ_P_LN_X100: i64 = 2 // mm neck length, x100
41const SQ_P_DVDF_X100: i64 = 3 // mm^3/N volume displaced per newton of bite, x100 (shell durometer)
42const SQ_P_BITE_N: i64 = 4 // N peak bite force
43const SQ_P_RISE_MS: i64 = 5 // ms jaw closing time
44const SQ_P_HOLD_MS: i64 = 6 // ms
45const SQ_P_REL_MS: i64 = 7 // ms
46const SQ_P_ZETA: i64 = 8 // Q16 acoustic damping of the resonator
47const SQ_P_REED_F0: i64 = 9 // Hz reed natural frequency
48const SQ_P_REED_DET: i64 = 10 // Hz second-reed detune -> nonlinear regimes
49const SQ_P_REED_ZETA: i64 = 11 // Q16
50const SQ_P_FLOWGAIN: i64 = 12 // Q16 reed opening -> neck conductance
51const SQ_P_PGAIN: i64 = 13 // Q16 chamber pressure -> reed drive
52const SQ_P_OVS: i64 = 14
53const SQ_P_SEED: i64 = 15
54const SQ_P_JITTER: i64 = 16 // Q16
55const SQ_P_N: i64 = 17
56
57func sq_defaults(p: *i64) -> i64 {
58 // A 65 mm chew ball with a small squeaker chamber, medium-soft shell.
59 p[SQ_P_V0] = 12000 // mm^3, the squeaker chamber (not the whole ball)
60 p[SQ_P_AN_X100] = 1200 // 12.00 mm^2 neck
61 p[SQ_P_LN_X100] = 600 // 6.00 mm neck
62 p[SQ_P_DVDF_X100] = 1500 // 15.00 mm^3 per newton
63 p[SQ_P_BITE_N] = 200 // N -- a medium gundog's carnassial bite
64 p[SQ_P_RISE_MS] = 60
65 p[SQ_P_HOLD_MS] = 120
66 p[SQ_P_REL_MS] = 90
67 p[SQ_P_ZETA] = 3276 // 0.05
68 p[SQ_P_REED_F0] = 1800 // Hz
69 p[SQ_P_REED_DET] = 0
70 p[SQ_P_REED_ZETA] = 4587 // 0.07
71 p[SQ_P_FLOWGAIN] = 65536
72 // 128x, MEASURED not chosen: the chamber runs at ~0.018 in normalised units and the reed needs
73 // ~1.0 to leave its rest position, so this is a unit conversion between two subsystems. Swept
74 // 1x..128x with the gate: reed closures per bite rise 1 -> 3 and then fall again, and the
75 // nonlinear regime is NOT recovered at any value -- see the note on Q7 in nx_squeaker_gate.
76 p[SQ_P_PGAIN] = 3604480
77 p[SQ_P_OVS] = 4
78 p[SQ_P_SEED] = 20260725
79 p[SQ_P_JITTER] = 1966 // 0.03
80 return 0
81}
82
83// ---------------------------------------------------------------- state
84const SQ_S_P: i64 = 0 // chamber overpressure (Q16, normalised)
85const SQ_S_U: i64 = 1 // neck volume flow (Q16)
86const SQ_S_VOL: i64 = 2 // current chamber volume, mm^3
87const SQ_S_N: i64 = 3
88
89// Helmholtz angular frequency for the CURRENT volume, returned in Q16 radians per sub-sample.
90// w = c * sqrt(A / (V * L))
91// Computed as sqrt(A*1e12/(V*L)) so the square root runs on an integer large enough to keep precision;
92// the result is then in units of 1e-6 per mm and scales straight into rad/s by the speed of sound.
93func sq_omega(p: *i64, vol: i64, sr: i64) -> i64 {
94 if vol <= 0 { return 0 }
95 let denom: i64 = vol * p[SQ_P_LN_X100]
96 if denom <= 0 { return 0 }
97 let z: i64 = (p[SQ_P_AN_X100] * 1000000000000) / denom
98 let rt: i64 = nx_isqrt(z) // sqrt(A/(V*L)) in 1e-6 per mm
99 // w_per_sample_q16 = c * rt * 1e-6 * 65536 / sr
100 return (SQ_C * rt * SQ_Q) / (1000000 * sr)
101}
102
103// Bite force at sub-sample i, in Q16 NEWTONS. Trapezoid: the jaw closes, holds, releases.
104//
105// The Q16 matters. Returning whole newtons makes the force a staircase that steps by 1 N, so the air
106// being pumped -- which is the DERIVATIVE of that force -- is zero on almost every sample and a spike
107// on the rest. The toy then emits a train of clicks instead of a squeal, and the reed, seeing no
108// sustained pressure, flutters twice in 300 ms. A 200 N bite over 60 ms advances by 0.035 N per
109// sample; anything coarser than that is not a bite, it is a hammer.
110func sq_bite(p: *i64, i: i64, sr: i64) -> i64 {
111 let peak: i64 = p[SQ_P_BITE_N] * SQ_Q
112 let rise: i64 = (p[SQ_P_RISE_MS] * sr) / 1000
113 let hold: i64 = (p[SQ_P_HOLD_MS] * sr) / 1000
114 let rel: i64 = (p[SQ_P_REL_MS] * sr) / 1000
115 if i < rise {
116 if rise <= 0 { return peak }
117 return (peak / rise) * i + ((peak % rise) * i) / rise
118 }
119 if i < rise + hold { return peak }
120 if i < rise + hold + rel {
121 if rel <= 0 { return 0 }
122 let left: i64 = rise + hold + rel - i
123 return (peak / rel) * left + ((peak % rel) * left) / rel
124 }
125 return 0
126}
127
128// Render `n` samples of RADIATED sound. Radiation from a small aperture goes as the time derivative
129// of the volume flow, so the output is dU/dt rather than U itself -- using U directly would publish a
130// sound with far too much low end and would flatter every spectral axis in the ruler.
131//
132// diag[0] = peak chamber pressure, diag[1] = final volume, diag[2] = reed closures.
133const SQ_D_PPEAK: i64 = 0
134const SQ_D_VEND: i64 = 1
135const SQ_D_CYC: i64 = 2
136const SQ_D_N: i64 = 3
137
138func sq_render(out: *i64, n: i64, rate: i64, p: *i64, diag: *i64) -> i64 {
139 let s: *i64 = sys_mmap(SQ_S_N * 8) as *i64
140 let vp: *i64 = sys_mmap(VF_P_N * 8) as *i64
141 let vs: *i64 = sys_mmap(VF_S_N * 8) as *i64
142 let vk: *i64 = sys_mmap(VF_K_N * 8) as *i64
143
144 // the reed, expressed as a vocal-fold pair: same equations, different tissue
145 vfold_defaults(vp)
146 vp[VF_P_F0_L] = p[SQ_P_REED_F0]
147 vp[VF_P_F0_R] = p[SQ_P_REED_F0] + p[SQ_P_REED_DET]
148 vp[VF_P_ZETA] = p[SQ_P_REED_ZETA]
149 vp[VF_P_OVS] = 1 // this organ owns the oversampling loop
150 vp[VF_P_SEED] = p[SQ_P_SEED]
151 vp[VF_P_JITTER] = p[SQ_P_JITTER]
152 vp[VF_P_PS] = 0
153 vp[VF_P_G0_2] = (vp[VF_P_G0] * 3) / 5 // convergent rest shape: the reed self-starts
154 vfold_init(vp, vk, vs, rate * p[SQ_P_OVS])
155
156 s[SQ_S_P] = 0
157 s[SQ_S_U] = 0
158 s[SQ_S_VOL] = p[SQ_P_V0]
159 diag[SQ_D_PPEAK] = 0
160 diag[SQ_D_CYC] = 0
161
162 let ovs: i64 = p[SQ_P_OVS]
163 let sr: i64 = rate * ovs
164 var prevU: i64 = 0
165 var prevDv: i64 = 0
166 var wasopen: i64 = 0
167 var vmin: i64 = p[SQ_P_V0]
168 var sub: i64 = 0
169 var i: i64 = 0
170 while i < n {
171 var acc: i64 = 0
172 var o: i64 = 0
173 while o < ovs {
174 // ---- the jaw. Volume displaced tracks force; its RATE of change is the air being pumped.
175 let f: i64 = sq_bite(p, sub, sr)
176 let dv: i64 = (p[SQ_P_DVDF_X100] * f) / 100 // Q16 mm^3 displaced
177 var vol: i64 = p[SQ_P_V0] - (dv / SQ_Q)
178 if vol < p[SQ_P_V0] / 20 { vol = p[SQ_P_V0] / 20 } // the shell cannot be crushed to nothing
179 s[SQ_S_VOL] = vol
180 if vol < vmin { vmin = vol }
181 let qin: i64 = dv - prevDv // Q16 mm^3 per sub-sample
182
183 // ---- the reed, driven by the chamber it sits in. Clamped to a sane operating range:
184 // the fold model expects a pressure near 1.0 and rails above ~16, and an unclamped
185 // chamber drove it to 19 -- the reed stuck wide open and fluttered twice in 300 ms.
186 // s[SQ_S_P] is Q32; VF_P_PS is Q16. Shifting down is NOT optional: when the resonator was
187 // moved to Q32 this line was left as-is, so the reed was handed a pressure 65536x too large
188 // and sat pinned at the clamp for every sample of every run. That single missing shift is
189 // what made SQ_P_PGAIN a no-op across a 64x sweep, and it put the whole toy into a
190 // chamber-refill relaxation oscillation near 110 Hz whose frequency was independent of the
191 // reed's tuning -- the reed could not flutter because it was never allowed to move freely.
192 var pd: i64 = ((s[SQ_S_P] >> 16) * p[SQ_P_PGAIN]) / SQ_Q
193 if pd < 0 { pd = 0 }
194 // Ceiling sits below the fold model's own divergence clamp (16.0) so the reed cannot rail,
195 // but NOT so low that it swallows the parameter: at 4.0 the chamber saturated it on every
196 // sample and a 64x sweep of SQ_P_PGAIN produced byte-identical audio -- a live-looking knob
197 // wired to nothing. A clamp that eats its input is a bug, not a safety feature.
198 if pd > 12 * SQ_Q { pd = 12 * SQ_Q }
199 vp[VF_P_PS] = pd
200 let rflow: i64 = vfold_substep(vs, vp, vk)
201 // reed opening gates the neck: no opening, no path out
202 let cond: i64 = (rflow * p[SQ_P_FLOWGAIN]) / SQ_Q
203
204 // ---- Helmholtz resonator at the CURRENT volume. This is where the glide comes from.
205 // P and U are carried at Q32, not Q16. At Q16 the pressure increment per sub-step
206 // truncates to the integer 1 for a chamber this size, so the resonator has barely one
207 // significant figure and the toy renders as a handful of quantisation steps -- the
208 // same precision failure the vocal-fold velocity had, in a different organ.
209 let w: i64 = sq_omega(p, vol, sr)
210 let r: i64 = (2 * p[SQ_P_ZETA] * w) / SQ_Q
211 // P' = w^2 * (qin - U) ; U' = P - r*U (=> U'' + rU' + w^2 U = w^2 qin)
212 let net: i64 = (qin * SQ_Q) - s[SQ_S_U]
213 let dp: i64 = ((w * w) * (net >> 16)) >> 16
214 s[SQ_S_P] = s[SQ_S_P] + dp
215 var du: i64 = s[SQ_S_P] - ((r * (s[SQ_S_U] >> 16)))
216 // the reed valve scales the flow that can actually leave
217 du = (du / (SQ_Q + cond)) * cond
218 s[SQ_S_U] = s[SQ_S_U] + du
219
220 // NEVER-BRICK ANALOG: bound the acoustic state so a bad geometry produces a quiet wrong
221 // answer rather than an unbounded one.
222 let acl: i64 = 4096 * SQ_Q * SQ_Q
223 if s[SQ_S_P] > acl { s[SQ_S_P] = acl }
224 if s[SQ_S_P] < 0 - acl { s[SQ_S_P] = 0 - acl }
225 if s[SQ_S_U] > acl { s[SQ_S_U] = acl }
226 if s[SQ_S_U] < 0 - acl { s[SQ_S_U] = 0 - acl }
227
228 var pa: i64 = s[SQ_S_P] >> 16
229 if pa < 0 { pa = 0 - pa }
230 if pa > diag[SQ_D_PPEAK] { diag[SQ_D_PPEAK] = pa }
231 var isopen: i64 = 0
232 if rflow > 0 { isopen = 1 }
233 if wasopen == 1 { if isopen == 0 { diag[SQ_D_CYC] = diag[SQ_D_CYC] + 1 } }
234 wasopen = isopen
235
236 acc = acc + ((s[SQ_S_U] - prevU) >> 16) // dU/dt = radiated pressure
237 prevU = s[SQ_S_U]
238 prevDv = dv
239 sub = sub + 1
240 o = o + 1
241 }
242 out[i] = acc / ovs
243 i = i + 1
244 }
245 diag[SQ_D_VEND] = vmin
246 return 0
247}
248
249// Render and normalise to a 16-bit audio range. Returns the raw peak (0 = the toy made no sound).
250func sq_render_audio(out: *i64, n: i64, rate: i64, p: *i64, amp: i64, diag: *i64) -> i64 {
251 sq_render(out, n, rate, p, diag)
252 var sum: i64 = 0
253 var i: i64 = 0
254 while i < n { sum = sum + out[i]; i = i + 1 }
255 let dc: i64 = sum / n
256 var mx: i64 = 0
257 i = 0
258 while i < n {
259 out[i] = out[i] - dc
260 var a: i64 = out[i]
261 if a < 0 { a = 0 - a }
262 if a > mx { mx = a }
263 i = i + 1
264 }
265 if mx <= 0 { return 0 }
266 i = 0
267 while i < n { out[i] = (out[i] * amp) / mx; i = i + 1 }
268 return mx
269}
270
271// The Helmholtz frequency this geometry gives at rest and at full bite, in Hz. Published alongside
272// any design so the glide is a stated number rather than a claim.
273// Computed straight in Hz from the geometry: f = c*sqrt(A/(V*L))/(2*pi). Deriving it from the
274// per-sample Q16 omega instead loses the whole value to integer division and silently reports 0 Hz.
275func sq_f_at(p: *i64, vol: i64) -> i64 {
276 if vol <= 0 { return 0 }
277 let denom: i64 = vol * p[SQ_P_LN_X100]
278 if denom <= 0 { return 0 }
279 let z: i64 = (p[SQ_P_AN_X100] * 1000000000000) / denom
280 let rt: i64 = nx_isqrt(z) // sqrt(A/(V*L)) in 1e-6 per mm
281 return (SQ_C * rt) / 6283185 // c*rt/1e6/(2*pi), folded into one divisor
282}
283func sq_vol_bitten(p: *i64) -> i64 {
284 let dv: i64 = (p[SQ_P_DVDF_X100] * p[SQ_P_BITE_N]) / 100
285 var vol: i64 = p[SQ_P_V0] - dv
286 if vol < p[SQ_P_V0] / 20 { vol = p[SQ_P_V0] / 20 }
287 return vol
288}
289func sq_f_rest(p: *i64) -> i64 { return sq_f_at(p, p[SQ_P_V0]) }
290func sq_f_bitten(p: *i64) -> i64 { return sq_f_at(p, sq_vol_bitten(p)) }