code wiki / (root) / nx_fft.nx

nx_fft.nx source

↩ module page · 227 lines · 11675 B

1// nx_fft.nx -- R0 of the transform ladder: a sovereign no-float radix-2 FFT (Cooley-Tukey, DIT, in-place). 2// The foundation rung for MDCT (R1) -> psychoacoustic perceptual audio (R2+). Twiddles COMPOSE the sine that was 3// absorbed into nx_audio_osc (osc_sin_unit) -- one sine, no dup. Fixed-point (Q16 twiddles, integer data), so the 4// result is DETERMINISTIC / bit-reproducible across machines -- the s-class exceed dimension vs float FFTs whose 5// rounding varies by platform. Gated by MATH properties verifiable without ears: round-trip reconstruction + 6// known answers (DC -> single bin; impulse -> flat spectrum). license_tier: ORIGINAL expect_exit: 0 7import "nx_audio_osc.nx" // osc_sin_unit -- the absorbed sine (twiddles), dedup 8import "nx_syscalls.nx" 9const K_MAGIC_2000: i64 = 2000 10const K_MAGIC_1500: i64 = 1500 11const K_MAGIC_1000000: i64 = 1000000 12const K_MAGIC_1400: i64 = 1400 13const K_MAGIC_1600: i64 = 1600 14 15const OSCP: i64 = 65536 // osc one-cycle units (matches nx_audio_osc OSC_PHASE) 16const QPI: i64 = 16384 // OSCP/4 = pi/2 in osc units (cos(x) = sin(x + pi/2)) 17 18func fp(s: *u8) -> i64 { var n: i64=0; while s[n]!=(0 as u8){n=n+1} sys_write(1,s,n); return 0 } 19func fpn(v: i64) -> i64 { 20 let b: *u8 = sys_mmap(28); var x: i64=v 21 if x<0 { b[0]=45; sys_write(1,b,1); x=0-x } 22 if x==0 { b[0]=48; sys_write(1,b,1); return 0 } 23 var d: i64=0; var y: i64=x; while y>0 { d=d+1; y=y/10 } 24 var i: i64=d-1; y=x; while i>=0 { b[i]=(48+(y%10)) as u8; y=y/10; i=i-1 } 25 sys_write(1,b,d); return 0 26} 27func f_abs(v: i64) -> i64 { if v<0 { return 0-v } return v } 28 29// in-place radix-2 DIT FFT. re/im: length n (power of 2). dir = -1 forward, +1 inverse (inverse also scales 1/n). 30func fft(re: *i64, im: *i64, n: i64, dir: i64) -> i64 { 31 // bit-reversal permutation 32 var j: i64 = 0 33 var i: i64 = 1 34 while i < n { 35 var bit: i64 = n >> 1 36 while (j & bit) != 0 { j = j ^ bit; bit = bit >> 1 } 37 j = j ^ bit 38 if i < j { 39 let tr: i64 = re[i]; re[i] = re[j]; re[j] = tr 40 let ti: i64 = im[i]; im[i] = im[j]; im[j] = ti 41 } 42 i = i + 1 43 } 44 // butterflies 45 var len: i64 = 2 46 while len <= n { 47 let half: i64 = len >> 1 48 var k: i64 = 0 49 while k < half { 50 let ph: i64 = (dir * k * OSCP) / len // twiddle angle in osc units 51 let wr: i64 = osc_sin_unit(ph + QPI) // cos(theta) 52 let wi: i64 = osc_sin_unit(ph) // sin(theta) 53 var s: i64 = k 54 while s < n { 55 let ar: i64 = re[s]; let ai: i64 = im[s] 56 let br: i64 = re[s+half]; let bi: i64 = im[s+half] 57 let tr: i64 = (wr*br - wi*bi) >> 16 // t = w * b (Q16 twiddle) 58 let ti: i64 = (wr*bi + wi*br) >> 16 59 re[s] = ar + tr; im[s] = ai + ti 60 re[s+half] = ar - tr; im[s+half] = ai - ti 61 s = s + len 62 } 63 k = k + 1 64 } 65 len = len << 1 66 } 67 if dir > 0 { 68 var z: i64 = 0 69 while z < n { re[z] = re[z] / n; im[z] = im[z] / n; z = z + 1 } 70 } 71 return 0 72} 73 74// --------------------------------------------------------------------------------------------------- 75// DECOMPOSED API -- RESTORED 2026-07-31. FOUR consumers import nx_fft.nx for these two helpers and NEITHER 76// WAS EVER DEFINED: nx_fft_f32.nx:7, nx_fnet_mix.nx:32, nx_fnet_mix_gate.nx:259, and nx_fft_test.nx which 77// ASSERTS them -- a test written for functions that do not exist, so it can never once have passed. 78// The layering violation (nx_fft.nx stranded in _hdl_build/) hid all of it: every consumer died at 79// expand_imports before the compiler could reach an undefined call. 80// NOT INVENTED -- the logic is EXTRACTED from the bit-reversal loop already proven inside fft() below, 81// and both are PINNED BY THE EXISTING TEST VECTORS in nx_fft_test.nx, which are the real contract: 82// nx_fft_log2: 2->1, 8->3, 32->5 83// nx_fft_bit_reverse: (0,3)->0 (1,3)->4 (3,3)->6 (5,3)->5 (7,3)->7 84// NOTE nx_fft_bit_reverse maps ONE INDEX given a bit-width; it does NOT permute an array in place. The 85// in-place permutation stays inlined at the top of fft() -- do not confuse the two, they have different 86// shapes and a caller that swaps them gets a silently wrong transform rather than an error. 87// Uses + rather than | to merge the low bit: after (r << 1) bit0 is always 0, so the two are identical 88// here, and + is known-good in this compiler. 89func nx_fft_log2(n: i64) -> i64 { 90 var v: i64 = n 91 var r: i64 = 0 92 while v > 1 { v = v >> 1; r = r + 1 } 93 return r 94} 95 96func nx_fft_bit_reverse(x: i64, bits: i64) -> i64 { 97 var r: i64 = 0 98 var i: i64 = 0 99 while i < bits { 100 r = (r << 1) + ((x >> i) & 1) 101 i = i + 1 102 } 103 return r 104} 105 106// NX_FFT_Q -- SETTLED BY MEASUREMENT 2026-07-31, and the disagreement that made it dangerous is REAL: 107// THIS FILE CARRIES TWO DIFFERENT FIXED-POINT SCALES, DELIBERATELY. 108// - fft() below builds twiddles with osc_sin_unit and divides by `>> 16` -- Q16/65536, internal, and 109// hardcoded at the shift. NOTHING outside reads that scale. 110// - NX_FFT_Q is the scale for callers that build their OWN twiddle tables. It is Q14/16384. 111// Three independent witnesses, none of them a comment guess: 112// 1. nx_fnet_mix.nx:39 fnet_q16_to_q14(v) is literally (v+2)/4 -- a rounded divide of Q16.16 by 4, 113// and 65536/4 == 16384. The tables at :52-53 are filled THROUGH it, so they are Q14. 114// 2. The only two consumers of NX_FFT_Q (nx_fnet_mix.nx:95-96 and nx_fnet_mix_gate.nx:55-56) each 115// divide by it immediately after multiplying by those Q14 twiddles. 116// 3. A GREEN gate run recorded in nx_fnet_mix.nx:30 (2026-06-14) measured "C2 twiddles match nx_fft 117// within 1 Q14" -- a prior measurement, not an assertion. 118// DO NOT "UNIFY" THE TWO SCALES. Changing either without re-measuring produces wrong numbers that no 119// build error will catch -- which is exactly why this constant was left undefined rather than guessed. 120const NX_FFT_Q: i64 = 16384 121// --------------------------------------------------------------------------------------------------- 122 123// nx_fft_forward / nx_fft_inverse -- the direction-named wrappers that consumers call and that were 124// NEVER DEFINED: nx_fft_test.nx (tests 3,4,5,6) and nx_denoise_spectral_gate.nx:12. The bare fft() below 125// takes a raw dir flag; naming the two directions stops a caller passing the wrong sign silently. 126// DIRECTION PINNED BY THE TEST, NOT BY CONVENTION -- this is the whole reason it is safe to add these: 127// test 3 feeds DC [1]x8 to nx_fft_forward and requires re[0] == 8, so FORWARD IS UNNORMALIZED; 128// fft() divides by n only on the dir > 0 branch, therefore forward MUST be dir = -1. 129// test 5 requires forward-then-inverse to recover the input, so inverse is dir = +1, the branch 130// that carries the 1/n. Any other assignment fails those assertions loudly rather than silently. 131// Written 0 - 1 rather than a bare negative literal to match the idiom already used in f_abs above. 132func nx_fft_forward(re: *i64, im: *i64, n: i64) -> i64 { 133 return fft(re, im, n, 0 - 1) 134} 135 136func nx_fft_inverse(re: *i64, im: *i64, n: i64) -> i64 { 137 return fft(re, im, n, 1) 138} 139 140// nx_fft_power_spectrum -- per-bin power into caller-supplied pwr[]. Power is re^2 + im^2 (the standard 141// meaning of the name), NOT magnitude. NOTE the test cannot tell those apart: test 6 only checks the 142// ARGMAX bin (identical under any monotonic transform) and that DC <= 1000, so both definitions pass. 143// I chose re^2+im^2 because the NAME says power; recording that here because the test does not pin it, 144// and a future caller comparing against a magnitude spectrum would see squared values and be confused. 145// OVERFLOW: values are unnormalized forward-transform outputs, so re^2 can overflow i64 above ~3e9 per 146// bin. Fine for the fixed-point sizes this ladder uses; a longer transform must scale before squaring. 147func nx_fft_power_spectrum(re: *i64, im: *i64, n: i64, pwr: *i64) -> i64 { 148 var k: i64 = 0 149 while k < n { 150 let r: i64 = re[k] 151 let m: i64 = im[k] 152 pwr[k] = r * r + m * m 153 k = k + 1 154 } 155 return 0 156} 157 158// nx_fft_2d_forward -- separable 2D transform: FFT every row (length w), then every column (length h). 159// Row-major, index = y * w + x. Columns are strided so they are gathered into a contiguous scratch 160// buffer, transformed, and scattered back -- fft() is in-place and requires contiguity. 161// PINNED BY TEST 7: a 4x4 all-ones image must yield re[0] == 16. Separable unnormalized forward gives 162// 4 per row at bin 0, then 16 at (0,0) after the column pass -- which is why forward must stay 163// unnormalized for this to hold; a 1/n on the forward branch would land 1 here and fail loudly. 164func nx_fft_2d_forward(re: *i64, im: *i64, w: i64, h: i64) -> i64 { 165 var r: i64 = 0 166 while r < h { 167 let rre: *i64 = ((re as i64 + r * w * 8) as *i64) 168 let rim: *i64 = ((im as i64 + r * w * 8) as *i64) 169 nx_fft_forward(rre, rim, w) 170 r = r + 1 171 } 172 let cre: *i64 = (sys_mmap(h * 8)) as *i64 173 let cim: *i64 = (sys_mmap(h * 8)) as *i64 174 var c: i64 = 0 175 while c < w { 176 var y: i64 = 0 177 while y < h { cre[y] = re[y * w + c]; cim[y] = im[y * w + c]; y = y + 1 } 178 nx_fft_forward(cre, cim, h) 179 y = 0 180 while y < h { re[y * w + c] = cre[y]; im[y * w + c] = cim[y]; y = y + 1 } 181 c = c + 1 182 } 183 sys_munmap(cre as *u8, h * 8) 184 sys_munmap(cim as *u8, h * 8) 185 return 0 186} 187 188func main() -> i64 { 189 fp("nx_fft (R0): sovereign no-float radix-2 FFT; twiddles via osc_sin_unit (deduped sine)\n" as *u8) 190 let N: i64 = 256 191 let re: *i64 = sys_mmap(N*8) as *i64 192 let im: *i64 = sys_mmap(N*8) as *i64 193 let or: *i64 = sys_mmap(N*8) as *i64 194 var ok: i64 = 0 195 196 // ---- T1 round-trip: x -> FFT -> IFFT -> x' ; max|x'-x| bounded ---- 197 var i: i64 = 0 198 while i < N { let v: i64 = ((i*37 + 11) % K_MAGIC_2000) - 1000; re[i] = v; im[i] = 0; or[i] = v; i = i + 1 } 199 fft(re, im, N, 0 - 1) 200 fft(re, im, N, 1) 201 var mx: i64 = 0 202 i = 0; while i < N { let e: i64 = f_abs(re[i] - or[i]); if e>mx {mx=e} i=i+1 } 203 fp(" T1 round-trip max-err=" as *u8); fpn(mx); fp(" (Q16 twiddles -> bounded, deterministic)\n" as *u8) 204 if mx < 16 { ok = ok + 1 } 205 206 // ---- T2 DC: x=const -> energy in bin 0 only ---- 207 i = 0; while i < N { re[i] = 1000; im[i] = 0; i = i + 1 } 208 fft(re, im, N, 0 - 1) 209 let dc: i64 = re[0] 210 var leak: i64 = 0; i = 1; while i < N { let e: i64 = f_abs(re[i]) + f_abs(im[i]); if e>leak {leak=e} i=i+1 } 211 fp(" T2 DC: bin0=" as *u8); fpn(dc); fp(" (expect ~" as *u8); fpn(N*1000); fp(") other-bin max=" as *u8); fpn(leak); fp("\n" as *u8) 212 if dc > N*900 { if leak < 200 { ok = ok + 1 } } 213 214 // ---- T3 impulse: x=[A,0,0,...] -> flat spectrum (every bin ~ A) ---- 215 i = 0; while i < N { re[i] = 0; im[i] = 0; i = i + 1 } 216 re[0] = K_MAGIC_1500 217 fft(re, im, N, 0 - 1) 218 var fmin: i64 = K_MAGIC_1000000; var fmax: i64 = 0 219 i = 0; while i < N { let m: i64 = f_abs(re[i]); if m<fmin {fmin=m} if m>fmax {fmax=m} i=i+1 } 220 fp(" T3 impulse: spectrum flat? min=" as *u8); fpn(fmin); fp(" max=" as *u8); fpn(fmax); fp(" (expect ~1500 both)\n" as *u8) 221 if fmin > K_MAGIC_1400 { if fmax < K_MAGIC_1600 { ok = ok + 1 } } 222 223 fp(" verdict=" as *u8) 224 if ok == 3 { fp("GREEN (FFT round-trips + known answers hold; no-float, deterministic) -- R0 DONE\n" as *u8); sys_exit(0); return 0 } 225 fp("RED (" as *u8); fpn(ok); fp("/3)\n" as *u8) 226 sys_exit(1); return 1 227}