nx_polypitch.nx source
↩ module page · 114 lines · 4105 B
1// nx_polypitch.nx -- INSTRUMENT-LEARN-GAME rung LG-3: POLYPHONIC pitch detection
2// (chords). Guitars and pianos play several notes at once; a single-F0 detector
3// (nx_pitch) can't see that. Instead of a big FFT (nx_fft caps at N=32 = 250 Hz
4// bins, useless for notes), we run a GOERTZEL filter bank: evaluate the signal's
5// energy DIRECTLY at each 12-TET note frequency. One cosine per note, O(N) per
6// note -- exact note resolution, no FFT-bin quantization.
7//
8// A note is "present" when its Goertzel power is a local peak across the note
9// grid AND above a fraction of the strongest note's power. The set of present
10// notes = the chord.
11//
12// Pure integer. Imports only the x86_64 syscall layer (mmap scratch). Also
13// exposes a sovereign full-range sin/cos (nx_pp_sinfull / nx_pp_cosfull) reusable
14// across the audio arc.
15//
16// license_tier: ORIGINAL
17// module: nishi-core.audio.polypitch
18// depends: nishi-core.audio.note (note table, caller-built)
19// capability: AUDIO_POLYPHONY
20import "nx_syscalls_x86_64.nx"
21
22const PP_PI: i64 = 205887 // pi in Q16.16
23const PP_HALFPI: i64 = 102944 // pi/2
24const PP_TWOPI: i64 = 411775 // 2*pi
25
26// cos(th), th in Q16.16 over [0, pi]; returns Q16.16 in [-65536, 65536].
27func nx_pp_cosq16(th: i64) -> i64 {
28 var x: i64 = th
29 var sign: i64 = 1
30 if x > PP_HALFPI { x = PP_PI - x; sign = 0 - 1 }
31 let x2: i64 = (x * x) >> 16
32 let x4: i64 = (x2 * x2) >> 16
33 let x6: i64 = (x4 * x2) >> 16
34 var c: i64 = 65536
35 c = c - (x2 / 2)
36 c = c + (x4 / 24)
37 c = c - (x6 / 720)
38 return sign * c
39}
40// cos over a full turn [0, 2*pi)
41func nx_pp_cosfull(ph: i64) -> i64 {
42 var x: i64 = ph
43 if x >= PP_PI { x = PP_TWOPI - x } // cos(2pi - x) = cos(x)
44 return nx_pp_cosq16(x)
45}
46// sin over a full turn [0, 2*pi): sin(x) = cos(x - pi/2)
47func nx_pp_sinfull(ph: i64) -> i64 {
48 var c: i64 = ph - PP_HALFPI
49 if c < 0 { c = c + PP_TWOPI }
50 return nx_pp_cosfull(c)
51}
52
53func _pp_i16(pcm: *u8, i: i64) -> i64 {
54 let lo: i64 = pcm[i * 2]
55 let hi: i64 = pcm[i * 2 + 1]
56 var v: i64 = lo | (hi << 8)
57 if v >= 0x8000 { v = v - 0x10000 }
58 return v
59}
60
61// Goertzel power at a frequency whose coeff = 2*cos(2*pi*f/fs) in Q14.
62func _pp_goertzel(pcm: *u8, start: i64, N: i64, coeff_q14: i64) -> i64 {
63 var s1: i64 = 0
64 var s2: i64 = 0
65 var n: i64 = 0
66 while n < N {
67 let x: i64 = _pp_i16(pcm, start + n)
68 let s0: i64 = x + ((coeff_q14 * s1) >> 14) - s2
69 s2 = s1
70 s1 = s0
71 n = n + 1
72 }
73 var p: i64 = s1 * s1 + s2 * s2 - ((coeff_q14 * s1 * s2) >> 14)
74 if p < 0 { p = 0 }
75 return p
76}
77
78// nx_polypitch -- detect present notes in [midi_lo, midi_hi] over frame
79// pcm[start..start+N). tbl = 128-entry note table (centi-Hz) from nx_note_table.
80// Writes detected MIDI notes (ascending) into out_notes (up to max_notes);
81// returns the count.
82func nx_polypitch(pcm: *u8, start: i64, N: i64, fs: i64, tbl: *i64,
83 midi_lo: i64, midi_hi: i64, out_notes: *i64, max_notes: i64) -> i64 {
84 let pw: *i64 = sys_mmap(128 * 8) as *i64
85 var maxp: i64 = 0
86 var m: i64 = midi_lo
87 while m <= midi_hi {
88 // theta = 2*pi*f/fs at FULL centi-Hz precision (truncating tbl[m]/100 to
89 // integer Hz detunes the Goertzel ~1 Hz -> sharp resonance loss at large N).
90 let th: i64 = (PP_TWOPI * tbl[m]) / (fs * 100) // 2*pi*f/fs in Q16 (<= pi)
91 let coeff: i64 = nx_pp_cosq16(th) >> 1 // 2*cos in Q14
92 let p: i64 = _pp_goertzel(pcm, start, N, coeff)
93 pw[m] = p
94 if p > maxp { maxp = p }
95 m = m + 1
96 }
97 let thresh: i64 = maxp / 8 // peaks must exceed 12.5% of the strongest
98 var cnt: i64 = 0
99 m = midi_lo + 1
100 while m < midi_hi {
101 if cnt < max_notes {
102 if pw[m] > thresh {
103 if pw[m] > pw[m - 1] {
104 if pw[m] >= pw[m + 1] {
105 out_notes[cnt] = m
106 cnt = cnt + 1
107 }
108 }
109 }
110 }
111 m = m + 1
112 }
113 return cnt
114}