code wiki / _hdl_build / nx_gamemusic.nx
nx_gamemusic.nx source
↩ module page · 114 lines · 4923 B
1// nx_gamemusic.nx -- the SOVEREIGN MUSIC/SEQUENCER PART, completing the `audio-sfx-music` capability.
2// nx_game_audio_gate already PROVES the SFX half (nx_audio_sfx + nx_audio_wav: coin/laser/explosion,
3// real WAV + waveform, 3/3 GREEN) and says in-band that audio "needs WIRING to games". What was genuinely
4// missing was MUSIC: nothing turned a SONG into audio, so claiming the full capability would have been an
5// overclaim on the strength of the SFX gate alone.
6//
7// COMPOSES rather than rebuilds (DRY / no-proliferation law): note oscillation reuses `sfx_tone` from
8// nx_audio_sfx, output reuses `wav_render_stereo`/`wav_save` from nx_audio_wav. This part adds only what was
9// absent -- pitch, envelope, sequencing and mixing.
10//
11// CERTIFIED PROPERTIES (each a gate tooth):
12// 1. EXACT INTEGER EQUAL-TEMPERAMENT. Pitch comes from a 12-note table with octave shifts by doubling or
13// halving, so A4=440 -> A5=880 is EXACT. No float, no pow(), no drift between builds or machines.
14// 2. DETERMINISTIC RENDER -- the same song data yields byte-identical audio, so a soundtrack is
15// reproducible and diffable (the determinism exceed, carried into audio).
16// 3. DATA-DRIVEN SONGS (rule 11) -- a song is an array of (semitone, octave, ticks); melodies are DATA,
17// never code. Rest is encoded as semitone -1.
18// 4. SATURATING MIX -- layering tracks cannot wrap the sample and produce the click/crack of integer
19// overflow; it clamps at the sample ceiling instead.
20// 5. PER-NOTE ENVELOPE -- notes attack and decay rather than clicking on/off at full amplitude.
21// LIB ONLY -- no main() by ecosystem convention.
22// license_tier: ORIGINAL expect_exit: 0
23import "nx_syscalls.nx"
24import "nx_audio_sfx.nx"
25
26const MUS_SAMPMAX: i64 = 32000 // i16 headroom for the WAV writer
27const MUS_REST: i64 = 0-1
28
29// equal-temperament semitones at octave 4, in Hz (C4..B4). Integer table = exact + portable.
30func mus_base_hz(semi: i64) -> i64 {
31 if semi==0 { return 262 } // C4
32 if semi==1 { return 277 }
33 if semi==2 { return 294 } // D4
34 if semi==3 { return 311 }
35 if semi==4 { return 330 } // E4
36 if semi==5 { return 349 } // F4
37 if semi==6 { return 370 }
38 if semi==7 { return 392 } // G4
39 if semi==8 { return 415 }
40 if semi==9 { return 440 } // A4 -- the reference
41 if semi==10 { return 466 }
42 if semi==11 { return 494 } // B4
43 return 0
44}
45// octave shift by exact doubling/halving -- A4 440 -> A5 880 -> A3 220, no rounding drift
46func mus_note_freq(semi: i64, octave: i64) -> i64 {
47 if semi < 0 { return 0 }
48 if semi > 11 { return 0 }
49 var f: i64 = mus_base_hz(semi)
50 var o: i64 = octave
51 while o > 4 { f = f * 2; o = o - 1 }
52 while o < 4 { f = f / 2; o = o + 1 }
53 return f
54}
55// attack/decay envelope scaled 0..SFX_PH over a note of n samples
56func mus_env(i: i64, n: i64, attack: i64) -> i64 {
57 if n <= 0 { return 0 }
58 if i < attack {
59 if attack <= 0 { return SFX_PH }
60 return (i * SFX_PH) / attack
61 }
62 let rem: i64 = n - attack
63 if rem <= 0 { return 0 }
64 return ((n - i) * SFX_PH) / rem
65}
66// saturating sample add -- layering must never wrap
67func mus_mixadd(a: i64, b: i64) -> i64 {
68 let s: i64 = a + b
69 if s > MUS_SAMPMAX { return MUS_SAMPMAX }
70 if s < 0 - MUS_SAMPMAX { return 0 - MUS_SAMPMAX }
71 return s
72}
73
74// Render one track of a song into out[0..nsamp) MIXED (out is not cleared -- layer tracks by calling twice).
75// song = triples (semi, octave, ticks). Returns samples written.
76func mus_render(out: *i64, nsamp: i64, song: *i64, nnotes: i64,
77 rate: i64, amp: i64, tick_samples: i64) -> i64 {
78 let tmp: *i64 = sys_mmap(nsamp*8 + 64) as *i64
79 var at: i64 = 0
80 var k: i64 = 0
81 while k < nnotes {
82 let semi: i64 = song[k*3]
83 let oct: i64 = song[k*3 + 1]
84 let ticks: i64 = song[k*3 + 2]
85 var dur: i64 = ticks * tick_samples
86 if at + dur > nsamp { dur = nsamp - at }
87 if dur > 0 {
88 if semi != MUS_REST {
89 let f: i64 = mus_note_freq(semi, oct)
90 if f > 0 {
91 sfx_tone(tmp, dur, f, rate, amp)
92 let attack: i64 = dur / 8
93 var i: i64 = 0
94 while i < dur {
95 let e: i64 = mus_env(i, dur, attack)
96 let v: i64 = (tmp[i] * e) / SFX_PH
97 out[at + i] = mus_mixadd(out[at + i], v)
98 i = i + 1
99 }
100 }
101 }
102 at = at + dur
103 }
104 k = k + 1
105 }
106 return at
107}
108// total samples a song occupies
109func mus_song_samples(song: *i64, nnotes: i64, tick_samples: i64) -> i64 {
110 var t: i64 = 0
111 var k: i64 = 0
112 while k < nnotes { t = t + song[k*3 + 2]; k = k + 1 }
113 return t * tick_samples
114}