Skip to main content

unlit3dx_examples/scene/
music.rs

1//! The demo's two pieces of music, written out as MIDI events.
2//!
3//! The demo carries no MIDI asset: both tracks are short enough to state,
4//! and stating them keeps the notes in the source that shapes them.
5
6use std::sync::Arc;
7
8use unlit3dx::prelude::{MidiEvent, MidiEventKind, midi_file};
9use unlit3dx::synth::MidiFile;
10
11/// The General MIDI music box, as a zero-based program number: a bell
12/// like plucked timbre that stays smooth where a piano turns brash.
13pub const MUSIC_BOX_PROGRAM: u8 = 10;
14
15/// The General MIDI electric piano, as a zero-based program number: soft
16/// enough to sit under the sequence without competing with it.
17pub const ELECTRIC_PIANO_PROGRAM: u8 = 4;
18
19/// The pentatonic scale the sequence walks, as MIDI keys.
20///
21/// The octave sits low on purpose: the soundfont's higher keys turn
22/// brittle, and a lower register stays mellow.
23pub const SCALE: [i32; 10] = [48, 50, 52, 55, 57, 60, 62, 64, 67, 69];
24
25/// Push one note onto an event list: a press at `start`, a release
26/// `hold` seconds later, both on `channel`.
27fn note(events: &mut Vec<MidiEvent>, channel: u8, key: i32, start: f64, hold: f64, velocity: u8) {
28    events.push((
29        start,
30        MidiEventKind::NoteOn {
31            channel,
32            key: key as u8,
33            velocity,
34        },
35    ));
36    events.push((
37        start + hold,
38        MidiEventKind::NoteOff {
39            channel,
40            key: key as u8,
41        },
42    ));
43}
44
45/// The demo's MIDI sequence: the scale walked up and back down, one note
46/// every 0.28 seconds, each held for 0.25 of them, on the music box.
47pub fn sequence_midi() -> Arc<MidiFile> {
48    let mut events = vec![(
49        0.0,
50        MidiEventKind::ProgramChange {
51            channel: 0,
52            program: MUSIC_BOX_PROGRAM,
53        },
54    )];
55    for (index, &key) in SCALE.iter().chain(SCALE.iter().rev().skip(1)).enumerate() {
56        let start = index as f64 * 0.28;
57        note(&mut events, 0, key, start, 0.25, 96);
58    }
59    midi_file(events).expect("the demo sequence is well formed")
60}
61
62/// The bars of the background loop, as chords: three keys each, root
63/// first. Four bars of a I-vi-IV-V turnround, twice, so the loop breathes
64/// without changing key.
65const BGM_CHORDS: [[i32; 3]; 8] = [
66    [48, 55, 60],
67    [45, 52, 57],
68    [41, 53, 57],
69    [43, 50, 59],
70    [48, 55, 60],
71    [45, 52, 57],
72    [41, 53, 60],
73    [43, 55, 62],
74];
75
76/// The melody over the loop, as scale degrees: an index into [`SCALE`],
77/// or `None` for a rest. One entry per half bar.
78const BGM_MELODY: [Option<usize>; 16] = [
79    Some(5),
80    None,
81    Some(4),
82    Some(3),
83    Some(2),
84    None,
85    Some(3),
86    Some(4),
87    Some(5),
88    None,
89    Some(7),
90    Some(6),
91    Some(4),
92    None,
93    Some(2),
94    Some(3),
95];
96
97/// How long one bar of the background loop lasts, in seconds.
98const BGM_BAR_SECONDS: f64 = 2.0;
99
100/// Which chord key sounds on each of a bar's eight eighths: up the chord
101/// and back down again, so the accompaniment moves with the balls.
102const BGM_ARPEGGIO: [usize; 8] = [0, 1, 2, 1, 0, 1, 2, 1];
103
104/// The looping background music: a bass note per bar, the chord's keys
105/// arpeggiated across its eight eighths, and a slow melody on top, all on
106/// the electric piano. Written out as events rather than read from a file:
107/// the demo carries no MIDI asset, and the loop is short enough to state.
108pub fn bgm_midi() -> Arc<MidiFile> {
109    let eighth = BGM_BAR_SECONDS / 8.0;
110    // Three channels, one instrument each: the bass, the arpeggio and the
111    // melody, so the loop reads as three parts instead of one chord wall.
112    let mut events = vec![
113        (
114            0.0,
115            MidiEventKind::ProgramChange {
116                channel: 0,
117                program: ELECTRIC_PIANO_PROGRAM,
118            },
119        ),
120        (
121            0.0,
122            MidiEventKind::ProgramChange {
123                channel: 1,
124                program: ELECTRIC_PIANO_PROGRAM,
125            },
126        ),
127        (
128            0.0,
129            MidiEventKind::ProgramChange {
130                channel: 2,
131                program: MUSIC_BOX_PROGRAM,
132            },
133        ),
134    ];
135    for (bar, chord) in BGM_CHORDS.iter().enumerate() {
136        let start = bar as f64 * BGM_BAR_SECONDS;
137        // The bass holds the bar: an octave below its root.
138        note(&mut events, 0, chord[0] - 12, start, BGM_BAR_SECONDS, 56);
139        // The chord walks up and back over the bar's eighths, so the
140        // accompaniment moves without ever leaving the harmony.
141        for (step, &finger) in BGM_ARPEGGIO.iter().enumerate() {
142            let key = chord[finger] + 12;
143            note(
144                &mut events,
145                1,
146                key,
147                start + step as f64 * eighth,
148                eighth,
149                44,
150            );
151        }
152        // The melody: one note per half bar, resting where the chart says.
153        for half in 0..2 {
154            let Some(degree) = BGM_MELODY[bar * 2 + half] else {
155                continue;
156            };
157            note(
158                &mut events,
159                2,
160                SCALE[degree] + 12,
161                start + half as f64 * (BGM_BAR_SECONDS / 2.0),
162                BGM_BAR_SECONDS / 2.0 * 0.9,
163                72,
164            );
165        }
166    }
167    midi_file(events).expect("the background loop is well formed")
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use unlit3dx::synth::MidiMessage;
174
175    /// Count a file's key presses and releases: every sounding key must be
176    /// let go again, or a looping track leaves a chord hanging forever.
177    fn presses_and_releases(file: &MidiFile) -> (usize, usize) {
178        let mut presses = 0;
179        let mut releases = 0;
180        for message in file.get_messages() {
181            let MidiMessage::Normal { status, .. } = message else {
182                continue;
183            };
184            match status & 0xF0 {
185                0x90 => presses += 1,
186                0x80 => releases += 1,
187                _ => {}
188            }
189        }
190        (presses, releases)
191    }
192
193    /// The demo's two tracks are well formed: sorted times, and every key
194    /// press released again, so the loop's seam stays clean.
195    #[test]
196    fn the_midi_tracks_release_every_note() {
197        for file in [bgm_midi(), sequence_midi()] {
198            let (presses, releases) = presses_and_releases(&file);
199            assert!(presses > 0, "a track with no notes is not music");
200            assert_eq!(
201                presses,
202                releases,
203                "the track leaves {} keys hanging",
204                presses - releases
205            );
206            let times = file.get_times();
207            for pair in times.windows(2) {
208                assert!(
209                    pair[0] <= pair[1],
210                    "the event times run backwards at {}",
211                    pair[1]
212                );
213            }
214        }
215    }
216
217    /// The background loop is whole bars long: its length is a multiple of
218    /// the bar, so a loop point lands on the downbeat rather than mid-bar.
219    #[test]
220    fn the_background_loop_spans_whole_bars() {
221        let file = bgm_midi();
222        let bars = file.get_length() / BGM_BAR_SECONDS;
223        assert!(
224            (bars - bars.round()).abs() < f64::EPSILON,
225            "the loop is {bars} bars long"
226        );
227        assert!(bars >= 4.0, "a four-bar loop is the shortest that breathes");
228    }
229}