diff --git a/DESIGN.md b/DESIGN.md index c6149a8..9c305cf 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -41,8 +41,10 @@ An original dungeon management game (genre: dungeon building & defence) that run ## Music & sound - Own **XM/MOD replayer in Rust** (`src/xm.rs`): instruments, envelopes, fadeout, auto-vibrato, linear/Amiga frequencies, common FT2 effects; verified against libopenmpt - **Shared mixer** for music + sound effects (`src/audio.rs`), streamed to the browser by the JS plugin (replaces macroquad audio) -- Soundtrack in classic **Amiga chiptune** style, composed as `.xm` by `tools/compose_music.py` (editable in MilkyTracker): title/menu, **3 build tracks** (rotating), **2 battle tracks** (wave defence, gate assault), victory/defeat jingles; **the title melody is a recognisable leitmotif in every piece** -- Music changes with **soft crossfades** (2.5 s): title → build → battle when heroes invade → storm music while attacking the gate → jingles +- Soundtrack in classic **Amiga chiptune** style, **35 pieces**, composed as `.xm` by `tools/compose_music.py` (editable in MilkyTracker): 2 title, 15 build, 7 wave battle, 4 gate assault, 2 boss (Paladin of Light, Gate Warden), 1 torture chamber mood, 1 temple mood, 2 victory, 1 defeat; **the title melody is a recognisable leitmotif in every piece** +- Most pieces come from a **variant generator**: the leitmotif is stored as scale degrees and realised in different keys and modes (minor, harmonic minor, dorian, phrygian, lydian), augmented, diminished, inverted or as fragment sequences, with varying accompaniment, drum styles, instruments and forms; title 2, both boss themes and the second victory jingle are hand-written +- 16 chip instruments (pulse, square, saw pad, triangle bass, pluck, kick, snare, hihat, organ, bell, choir, sub bass, tom, crash, harp, timpani); 8-bit samples, each file only contains the instruments it uses +- Music changes with **soft crossfades** (2.5 s): title → build (rotating through all build pieces) → battle when heroes invade → assault while attacking the gate → boss themes while a Paladin of Light or the Gate Warden is alive → jingles; near a torture chamber or temple the matching **room mood** plays - Sound effects are **synthesised chip sounds** (`src/sfx.rs`), positioned by distance and stereo panning ## Map size diff --git a/assets/music/assault2.xm b/assets/music/assault2.xm new file mode 100644 index 0000000..9894e0a Binary files /dev/null and b/assets/music/assault2.xm differ diff --git a/assets/music/assault3.xm b/assets/music/assault3.xm new file mode 100644 index 0000000..ea8644a Binary files /dev/null and b/assets/music/assault3.xm differ diff --git a/assets/music/assault4.xm b/assets/music/assault4.xm new file mode 100644 index 0000000..7693317 Binary files /dev/null and b/assets/music/assault4.xm differ diff --git a/assets/music/battle1.xm b/assets/music/battle1.xm index 439f6a3..97fb824 100644 Binary files a/assets/music/battle1.xm and b/assets/music/battle1.xm differ diff --git a/assets/music/battle2.xm b/assets/music/battle2.xm index 9854e2e..161c0bc 100644 Binary files a/assets/music/battle2.xm and b/assets/music/battle2.xm differ diff --git a/assets/music/battle3.xm b/assets/music/battle3.xm new file mode 100644 index 0000000..41865db Binary files /dev/null and b/assets/music/battle3.xm differ diff --git a/assets/music/battle4.xm b/assets/music/battle4.xm new file mode 100644 index 0000000..52f0939 Binary files /dev/null and b/assets/music/battle4.xm differ diff --git a/assets/music/battle5.xm b/assets/music/battle5.xm new file mode 100644 index 0000000..89607c9 Binary files /dev/null and b/assets/music/battle5.xm differ diff --git a/assets/music/battle6.xm b/assets/music/battle6.xm new file mode 100644 index 0000000..1960580 Binary files /dev/null and b/assets/music/battle6.xm differ diff --git a/assets/music/battle7.xm b/assets/music/battle7.xm new file mode 100644 index 0000000..cfc748f Binary files /dev/null and b/assets/music/battle7.xm differ diff --git a/assets/music/battle8.xm b/assets/music/battle8.xm new file mode 100644 index 0000000..c9fc18e Binary files /dev/null and b/assets/music/battle8.xm differ diff --git a/assets/music/boss_paladin.xm b/assets/music/boss_paladin.xm new file mode 100644 index 0000000..309f1de Binary files /dev/null and b/assets/music/boss_paladin.xm differ diff --git a/assets/music/boss_warden.xm b/assets/music/boss_warden.xm new file mode 100644 index 0000000..4d579c7 Binary files /dev/null and b/assets/music/boss_warden.xm differ diff --git a/assets/music/build1.xm b/assets/music/build1.xm index 2538dcf..4ec0ca8 100644 Binary files a/assets/music/build1.xm and b/assets/music/build1.xm differ diff --git a/assets/music/build10.xm b/assets/music/build10.xm new file mode 100644 index 0000000..473282b Binary files /dev/null and b/assets/music/build10.xm differ diff --git a/assets/music/build11.xm b/assets/music/build11.xm new file mode 100644 index 0000000..9d22472 Binary files /dev/null and b/assets/music/build11.xm differ diff --git a/assets/music/build12.xm b/assets/music/build12.xm new file mode 100644 index 0000000..f1dc1d6 Binary files /dev/null and b/assets/music/build12.xm differ diff --git a/assets/music/build13.xm b/assets/music/build13.xm new file mode 100644 index 0000000..3591f97 Binary files /dev/null and b/assets/music/build13.xm differ diff --git a/assets/music/build14.xm b/assets/music/build14.xm new file mode 100644 index 0000000..277faca Binary files /dev/null and b/assets/music/build14.xm differ diff --git a/assets/music/build15.xm b/assets/music/build15.xm new file mode 100644 index 0000000..316b449 Binary files /dev/null and b/assets/music/build15.xm differ diff --git a/assets/music/build2.xm b/assets/music/build2.xm index 9894c02..d30a12b 100644 Binary files a/assets/music/build2.xm and b/assets/music/build2.xm differ diff --git a/assets/music/build3.xm b/assets/music/build3.xm index 13f81a7..03ebf34 100644 Binary files a/assets/music/build3.xm and b/assets/music/build3.xm differ diff --git a/assets/music/build4.xm b/assets/music/build4.xm new file mode 100644 index 0000000..d43fd77 Binary files /dev/null and b/assets/music/build4.xm differ diff --git a/assets/music/build5.xm b/assets/music/build5.xm new file mode 100644 index 0000000..5c6baf4 Binary files /dev/null and b/assets/music/build5.xm differ diff --git a/assets/music/build6.xm b/assets/music/build6.xm new file mode 100644 index 0000000..575166e Binary files /dev/null and b/assets/music/build6.xm differ diff --git a/assets/music/build7.xm b/assets/music/build7.xm new file mode 100644 index 0000000..59e46bd Binary files /dev/null and b/assets/music/build7.xm differ diff --git a/assets/music/build8.xm b/assets/music/build8.xm new file mode 100644 index 0000000..439cec1 Binary files /dev/null and b/assets/music/build8.xm differ diff --git a/assets/music/build9.xm b/assets/music/build9.xm new file mode 100644 index 0000000..3595bd4 Binary files /dev/null and b/assets/music/build9.xm differ diff --git a/assets/music/defeat.xm b/assets/music/defeat.xm index 36ef422..7b3d1f1 100644 Binary files a/assets/music/defeat.xm and b/assets/music/defeat.xm differ diff --git a/assets/music/mood_temple.xm b/assets/music/mood_temple.xm new file mode 100644 index 0000000..6c8314e Binary files /dev/null and b/assets/music/mood_temple.xm differ diff --git a/assets/music/mood_torture.xm b/assets/music/mood_torture.xm new file mode 100644 index 0000000..614243b Binary files /dev/null and b/assets/music/mood_torture.xm differ diff --git a/assets/music/title.xm b/assets/music/title.xm index adb092a..243f367 100644 Binary files a/assets/music/title.xm and b/assets/music/title.xm differ diff --git a/assets/music/title2.xm b/assets/music/title2.xm new file mode 100644 index 0000000..6f0d4f9 Binary files /dev/null and b/assets/music/title2.xm differ diff --git a/assets/music/victory.xm b/assets/music/victory.xm index 78f261f..d2de466 100644 Binary files a/assets/music/victory.xm and b/assets/music/victory.xm differ diff --git a/assets/music/victory2.xm b/assets/music/victory2.xm new file mode 100644 index 0000000..7e8e0e2 Binary files /dev/null and b/assets/music/victory2.xm differ diff --git a/src/audio.rs b/src/audio.rs index 5a0b729..19fb01a 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -3,32 +3,30 @@ // which starts the AudioContext on the first user interaction. use crate::sfx; +use crate::tracks::TRACKS; use crate::xm::{self, Module, Player}; use std::cell::RefCell; use std::rc::Rc; +// Music categories; the tracks themselves are listed in tracks.rs (generated by the composer) #[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Track { +pub enum Category { Title, - Build1, - Build2, - Build3, - Battle1, - Battle2, + Build, + Battle, + Assault, + BossPaladin, + BossWarden, + Torture, + Temple, Victory, Defeat, } -const TRACKS: [(Track, &[u8]); 8] = [ - (Track::Title, include_bytes!("../assets/music/title.xm")), - (Track::Build1, include_bytes!("../assets/music/build1.xm")), - (Track::Build2, include_bytes!("../assets/music/build2.xm")), - (Track::Build3, include_bytes!("../assets/music/build3.xm")), - (Track::Battle1, include_bytes!("../assets/music/battle1.xm")), - (Track::Battle2, include_bytes!("../assets/music/battle2.xm")), - (Track::Victory, include_bytes!("../assets/music/victory.xm")), - (Track::Defeat, include_bytes!("../assets/music/defeat.xm")), -]; +// indices of all tracks of a category, in play order +pub fn tracks_in(c: Category) -> Vec { + TRACKS.iter().enumerate().filter(|(_, t)| t.1 == c).map(|(i, _)| i).collect() +} const MUSIC_GAIN: f32 = 0.45; // our replayer is ~4x louder than libopenmpt; leaves headroom for effects const SFX_GAIN: f32 = 0.55; @@ -61,7 +59,7 @@ struct State { music_vol: f32, sfx_vol: f32, muted: bool, - track: Option, + track: Option, engine: Option, } @@ -69,24 +67,20 @@ thread_local! { static STATE: RefCell = RefCell::new(State { music_vol: 0.6, sfx_vol: 0.7, muted: false, track: None, engine: None }); } -fn track_index(t: Track) -> usize { - TRACKS.iter().position(|(k, _)| *k == t).unwrap_or(0) -} - impl Engine { fn new(rate: f32) -> Engine { - let modules = TRACKS.iter().map(|(_, d)| Rc::new(xm::load(d).expect("built-in music"))).collect(); + let modules = TRACKS.iter().map(|(_, _, d)| Rc::new(xm::load(d).expect("built-in music"))).collect(); let sfx = sfx::generate_all(rate).into_iter().map(Rc::new).collect(); Engine { rate, modules, music: Vec::new(), voices: Vec::new(), sfx, buf: Vec::new() } } - fn start_track(&mut self, t: Track, fade: f32) { + fn start_track(&mut self, t: usize, fade: f32) { let speed = 1.0 / fade.max(0.01); for v in self.music.iter_mut() { v.target = 0.0; v.speed = speed; } - let player = Player::new(self.modules[track_index(t)].clone(), self.rate); + let player = Player::new(self.modules[t.min(self.modules.len() - 1)].clone(), self.rate); let instant = self.music.is_empty() || fade <= 0.0; self.music.push(MusicVoice { player, gain: if instant { 1.0 } else { 0.0 }, target: 1.0, speed }); } @@ -143,7 +137,7 @@ pub fn set_volumes(music: f32, sfx: f32, muted: bool) { } // Switch music with a crossfade (seconds); does nothing if the track already plays -pub fn play_music(t: Track, fade: f32) { +pub fn play_music(t: usize, fade: f32) { STATE.with(|s| { let mut s = s.borrow_mut(); if s.track == Some(t) { @@ -164,7 +158,7 @@ pub fn music_loops() -> u32 { }) } -pub fn current_track() -> Option { +pub fn current_track() -> Option { STATE.with(|s| s.borrow().track) } @@ -225,11 +219,14 @@ mod tests { #[test] fn engine_crossfade_and_sfx() { dc_audio_init(48000.0); - play_music(Track::Build1, 0.0); + let build = tracks_in(Category::Build); + let battle = tracks_in(Category::Battle); + assert_eq!(build.len(), 15); + play_music(build[0], 0.0); let mut peak = 0.0f32; for k in 0..200 { if k == 50 { - play_music(Track::Battle1, 2.5); + play_music(battle[0], 2.5); } if k % 10 == 0 { play_sfx(k % crate::game::SFX_ALL.len(), 1.0, 0.3); @@ -243,6 +240,6 @@ mod tests { } } assert!(peak > 0.05); - assert_eq!(current_track(), Some(Track::Battle1)); + assert_eq!(current_track(), Some(battle[0])); } } diff --git a/src/main.rs b/src/main.rs index 6571e05..c8f4084 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod render3d; mod save; mod settings; mod sfx; +mod tracks; mod sound; mod ui; mod xm; @@ -73,35 +74,69 @@ fn apply_settings(s: &Settings, r3d: &mut R3d) { audio::set_volumes(s.music_volume, s.volume, s.muted); } -// Chooses the music for the current situation; build tracks rotate after two loops +// Chooses the music for the current situation. Each category rotates through its tracks; +// build and title music switch to the next track after two loops. struct MusicDirector { - build: usize, + next: std::collections::HashMap, // category -> next index within the category + current: Option, } impl MusicDirector { - fn update(&mut self, g: &Game, title: bool) { - use audio::Track; - const BUILD: [Track; 3] = [Track::Build1, Track::Build2, Track::Build3]; - let gate_fight = g.time - g.gate_hit_t < 12.0 || g.gate_defenders_alive(); - let invasion = g.ents.iter().any(|e| e.alive && e.active_hero() && !e.guard); - let want = if title { - Track::Title - } else if g.state == 1 { - Track::Victory - } else if g.state == 2 { - Track::Defeat - } else if gate_fight { - Track::Battle2 - } else if invasion { - Track::Battle1 - } else { - if BUILD.contains(&audio::current_track().unwrap_or(Track::Title)) && audio::music_loops() >= 2 { - self.build = (self.build + 1) % BUILD.len(); + fn new() -> MusicDirector { + MusicDirector { next: std::collections::HashMap::new(), current: None } + } + + fn pick(&mut self, c: audio::Category) -> usize { + let list = audio::tracks_in(c); + let k = self.next.entry(c as u8).or_insert(0); + let t = list[*k % list.len()]; + *k += 1; + t + } + + fn update(&mut self, g: &Game, title: bool, cam: (f32, f32)) { + use audio::Category as C; + let alive = |f: &dyn Fn(&game::Ent) -> bool| g.ents.iter().any(|e| e.alive && f(e)); + let near_room = |k: map::Kind| { + let (cx, cz) = (cam.0 as i32, cam.1 as i32); + let mut n = 0; + for y in cz - 5..=cz + 5 { + for x in cx - 5..=cx + 5 { + if g.map.get(x, y).map_or(false, |t| t.kind == k) { + n += 1; + } + } } - BUILD[self.build] + n >= 4 }; - let fade = if matches!(want, Track::Victory | Track::Defeat) { 1.0 } else { 2.5 }; - audio::play_music(want, fade); + let want = if title { + C::Title + } else if g.state == 1 { + C::Victory + } else if g.state == 2 { + C::Defeat + } else if alive(&|e| e.active_hero() && e.kind == game::EK::Paladin && e.guard) { + C::BossWarden + } else if alive(&|e| e.active_hero() && e.kind == game::EK::Paladin) { + C::BossPaladin + } else if g.time - g.gate_hit_t < 12.0 || g.gate_defenders_alive() { + C::Assault + } else if alive(&|e| e.active_hero() && !e.guard) { + C::Battle + } else if near_room(map::Kind::Torture) { + C::Torture + } else if near_room(map::Kind::Temple) { + C::Temple + } else { + C::Build + }; + let rotate = matches!(want, C::Build | C::Title) && self.current == Some(want) && audio::music_loops() >= 2; + if self.current != Some(want) || rotate || audio::current_track().is_none() { + self.current = Some(want); + let t = self.pick(want); + let fade = if matches!(want, C::Victory | C::Defeat) { 1.0 } else { 2.5 }; + audio::play_music(t, fade); + } } } @@ -175,7 +210,7 @@ async fn main() { let gfx = Gfx::new(); let mut r3d = R3d::new(); let mut sounds = SoundBank::new(); - let mut music = MusicDirector { build: 0 }; + let mut music = MusicDirector::new(); let mut ui = Ui::new(); let mut menu = Menu::new(); @@ -542,7 +577,8 @@ async fn main() { for ev in app.game.events.drain(..) { sounds.play(ev, vec2(app.cam.tx, app.cam.tz), right, hear, now); } - music.update(&app.game, menu.page == Some(Page::Title) || matches!(menu.page, Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }))); + let on_title = menu.page == Some(Page::Title) || matches!(menu.page, Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }) | Some(Page::NewGame)); + music.update(&app.game, on_title, (app.cam.tx, app.cam.tz)); for m in app.game.msgs.drain(..) { ui.log.push((m, now)); } diff --git a/src/tracks.rs b/src/tracks.rs new file mode 100644 index 0000000..f7acd17 --- /dev/null +++ b/src/tracks.rs @@ -0,0 +1,42 @@ +// Generated by tools/compose_music.py - do not edit. +// All music modules with their category (see audio::Category). + +use crate::audio::Category; + +pub const TRACKS: [(&str, Category, &[u8]); 35] = [ + ("title", Category::Title, include_bytes!("../assets/music/title.xm")), + ("title2", Category::Title, include_bytes!("../assets/music/title2.xm")), + ("build1", Category::Build, include_bytes!("../assets/music/build1.xm")), + ("build2", Category::Build, include_bytes!("../assets/music/build2.xm")), + ("build3", Category::Build, include_bytes!("../assets/music/build3.xm")), + ("build4", Category::Build, include_bytes!("../assets/music/build4.xm")), + ("build5", Category::Build, include_bytes!("../assets/music/build5.xm")), + ("build6", Category::Build, include_bytes!("../assets/music/build6.xm")), + ("build7", Category::Build, include_bytes!("../assets/music/build7.xm")), + ("build8", Category::Build, include_bytes!("../assets/music/build8.xm")), + ("build9", Category::Build, include_bytes!("../assets/music/build9.xm")), + ("build10", Category::Build, include_bytes!("../assets/music/build10.xm")), + ("build11", Category::Build, include_bytes!("../assets/music/build11.xm")), + ("build12", Category::Build, include_bytes!("../assets/music/build12.xm")), + ("build13", Category::Build, include_bytes!("../assets/music/build13.xm")), + ("build14", Category::Build, include_bytes!("../assets/music/build14.xm")), + ("build15", Category::Build, include_bytes!("../assets/music/build15.xm")), + ("battle1", Category::Battle, include_bytes!("../assets/music/battle1.xm")), + ("battle3", Category::Battle, include_bytes!("../assets/music/battle3.xm")), + ("battle4", Category::Battle, include_bytes!("../assets/music/battle4.xm")), + ("battle5", Category::Battle, include_bytes!("../assets/music/battle5.xm")), + ("battle6", Category::Battle, include_bytes!("../assets/music/battle6.xm")), + ("battle7", Category::Battle, include_bytes!("../assets/music/battle7.xm")), + ("battle8", Category::Battle, include_bytes!("../assets/music/battle8.xm")), + ("battle2", Category::Assault, include_bytes!("../assets/music/battle2.xm")), + ("assault2", Category::Assault, include_bytes!("../assets/music/assault2.xm")), + ("assault3", Category::Assault, include_bytes!("../assets/music/assault3.xm")), + ("assault4", Category::Assault, include_bytes!("../assets/music/assault4.xm")), + ("boss_paladin", Category::BossPaladin, include_bytes!("../assets/music/boss_paladin.xm")), + ("boss_warden", Category::BossWarden, include_bytes!("../assets/music/boss_warden.xm")), + ("mood_torture", Category::Torture, include_bytes!("../assets/music/mood_torture.xm")), + ("mood_temple", Category::Temple, include_bytes!("../assets/music/mood_temple.xm")), + ("victory", Category::Victory, include_bytes!("../assets/music/victory.xm")), + ("victory2", Category::Victory, include_bytes!("../assets/music/victory2.xm")), + ("defeat", Category::Defeat, include_bytes!("../assets/music/defeat.xm")), +]; diff --git a/src/xm.rs b/src/xm.rs index 7334b4f..fc68ad5 100644 --- a/src/xm.rs +++ b/src/xm.rs @@ -1126,23 +1126,11 @@ mod tests { use super::*; use std::rc::Rc; - const SONGS: [&[u8]; 8] = [ - include_bytes!("../assets/music/title.xm"), - include_bytes!("../assets/music/build1.xm"), - include_bytes!("../assets/music/build2.xm"), - include_bytes!("../assets/music/build3.xm"), - include_bytes!("../assets/music/battle1.xm"), - include_bytes!("../assets/music/battle2.xm"), - include_bytes!("../assets/music/victory.xm"), - include_bytes!("../assets/music/defeat.xm"), - ]; - #[test] fn render_all_songs() { - for (k, data) in SONGS.iter().enumerate() { + for (k, (name, _, data)) in crate::tracks::TRACKS.iter().enumerate() { let m = Rc::new(load(data).expect("load")); - assert_eq!(m.channels, 8); - assert_eq!(m.instruments.len(), 8); + assert_eq!(m.channels, 8, "{}", name); let mut p = Player::new(m, 48000.0); let mut buf = vec![0.0f32; 48000 * 2]; let mut peak = 0.0f32; @@ -1157,8 +1145,8 @@ mod tests { } } let rms = (sum / (20.0 * 96000.0)).sqrt(); - println!("song {}: peak {:.3} rms {:.4} loops {}", k, peak, rms, p.loops); - assert!(rms > 0.005, "song {} is silent", k); + println!("{:2} {:14}: peak {:.3} rms {:.4} loops {}", k, name, peak, rms, p.loops); + assert!(rms > 0.005, "song {} is silent", name); } } } @@ -1173,8 +1161,7 @@ mod render_dump { #[test] fn dump_renders() { let Ok(dir) = std::env::var("DC_RENDER_DIR") else { return }; - let names = ["title", "build1", "build2", "build3", "battle1", "battle2", "victory", "defeat"]; - for n in names { + for (n, _, _) in crate::tracks::TRACKS.iter() { let data = std::fs::read(format!("assets/music/{}.xm", n)).unwrap(); let mut p = Player::new(Rc::new(load(&data).unwrap()), 48000.0); let mut buf = vec![0.0f32; 48000 * 2 * 30]; diff --git a/tools/__pycache__/compose_music.cpython-311.pyc b/tools/__pycache__/compose_music.cpython-311.pyc index 882b702..b219a30 100644 Binary files a/tools/__pycache__/compose_music.cpython-311.pyc and b/tools/__pycache__/compose_music.cpython-311.pyc differ diff --git a/tools/compose_music.py b/tools/compose_music.py index 6df481b..e15b362 100755 --- a/tools/compose_music.py +++ b/tools/compose_music.py @@ -180,6 +180,84 @@ def hihat(): return out +# ---- additional timbres (used by the generated pieces) + +def organ(amp=0.42): + harm = [(1, 1.0), (2, 0.6), (3, 0.4), (4, 0.3), (6, 0.2), (8, 0.15)] + raw = [sum(a * math.sin(2 * math.pi * h * i / CYCLE) for h, a in harm) for i in range(CYCLE)] + m = max(abs(v) for v in raw) + return [amp * v / m for v in raw] + + +def sine_cycle(amp=0.6): + return [amp * math.sin(2 * math.pi * i / CYCLE) for i in range(CYCLE)] + + +def harp_cycle(amp=0.5): + raw = [math.sin(2 * math.pi * i / CYCLE) + 0.35 * math.sin(4 * math.pi * i / CYCLE) + 0.15 * math.sin(6 * math.pi * i / CYCLE) for i in range(CYCLE)] + m = max(abs(v) for v in raw) + return [amp * v / m for v in raw] + + +def choir_pad(amp=0.36): + # vowel-like spectrum ("ah": strong 2nd/3rd and 7th/8th harmonics), two detuned copies -> chorus + harm = [(1, 0.5), (2, 1.0), (3, 0.8), (4, 0.3), (5, 0.2), (7, 0.45), (8, 0.4), (9, 0.15)] + def wave(ph): + return sum(a * math.sin(2 * math.pi * h * ph) for h, a in harm) + n = 128 * 127 + raw = [wave((i % 128) / 128.0) + wave((i % 127) / 127.0) for i in range(n)] + m = max(abs(v) for v in raw) + return [amp * v / m for v in raw] + + +def bell(): + # inharmonic partials, rendered at C4 for 16726 Hz playback (relnote +12) + f0 = 261.63 + n = int(DRUM_RATE * 1.3) + parts = [(1.0, 1.0, 2.5), (2.0, 0.5, 3.5), (2.76, 0.4, 5.0), (5.4, 0.25, 8.0), (8.9, 0.12, 12.0)] + out = [] + for i in range(n): + t = i / DRUM_RATE + out.append(0.5 * sum(a * math.exp(-t * d) * math.sin(2 * math.pi * f0 * r * t) for r, a, d in parts)) + return out + + +def tom(): + n = int(DRUM_RATE * 0.35) + out, ph = [], 0.0 + for i in range(n): + t = i / DRUM_RATE + f = 95 + 90 * math.exp(-t * 18) + ph += 2 * math.pi * f / DRUM_RATE + out.append(0.85 * math.sin(ph) * math.exp(-t * 9)) + return out + + +def crash(): + rnd = random.Random(11) + n = int(DRUM_RATE * 1.0) + out, prev = [], 0.0 + for i in range(n): + t = i / DRUM_RATE + x = rnd.random() * 2 - 1 + out.append(0.55 * (x - 0.7 * prev) * math.exp(-t * 3.5)) + prev = x + return out + + +def timpani(): + rnd = random.Random(5) + n = int(DRUM_RATE * 0.9) + out, ph = [], 0.0 + for i in range(n): + t = i / DRUM_RATE + f = 98 - 8 * math.exp(-t * 6) + ph += 2 * math.pi * f / DRUM_RATE + hit = 0.3 * (rnd.random() * 2 - 1) * math.exp(-t * 40) + out.append(0.8 * math.sin(ph) * math.exp(-t * 3.2) + hit) + return out + + def build_instruments(): return [ Instrument("Lead pulse 25%", Sample("pulse25", pulse(0.25), loop=(0, CYCLE), relnote=CYCLE_REL, finetune=CYCLE_FT), @@ -195,10 +273,23 @@ def build_instruments(): Instrument("Kick", Sample("kick", kick(), relnote=12)), Instrument("Snare", Sample("snare", snare(), relnote=12)), Instrument("Hihat", Sample("hihat", hihat(), relnote=12)), + Instrument("Organ", Sample("organ", organ(), loop=(0, CYCLE), relnote=CYCLE_REL, finetune=CYCLE_FT), + env=[(0, 0), (2, 60), (40, 56)], sustain=2, fadeout=900), + Instrument("Bell", Sample("bell", bell(), relnote=12)), + Instrument("Choir", Sample("choir", choir_pad(), loop=(0, 128 * 127), relnote=CYCLE_REL, finetune=CYCLE_FT), + env=[(0, 0), (40, 50), (80, 46)], sustain=2, fadeout=450), + Instrument("Sub bass", Sample("sine", sine_cycle(), loop=(0, CYCLE), relnote=CYCLE_REL, finetune=CYCLE_FT), + env=[(0, 64), (10, 54), (60, 48)], sustain=2, fadeout=2400), + Instrument("Tom", Sample("tom", tom(), relnote=12)), + Instrument("Crash", Sample("crash", crash(), relnote=12)), + Instrument("Harp", Sample("harp", harp_cycle(), loop=(0, CYCLE), relnote=CYCLE_REL, finetune=CYCLE_FT), + env=[(0, 64), (4, 44), (30, 12), (70, 0)], fadeout=2000), + Instrument("Timpani", Sample("timpani", timpani(), relnote=12)), ] LEAD, SQUARE, PAD, BASS, PLUCK, KICK, SNARE, HAT = range(1, 9) +ORGAN, BELL, CHOIR, SUB, TOM, CRASH, HARP, TIMP = range(9, 17) # channel layout CH_LEAD, CH_COUNTER, CH_PAD1, CH_PAD2, CH_BASS, CH_ARP, CH_DRUM, CH_HAT = range(8) CHANNELS = 8 @@ -601,6 +692,10 @@ def song_defeat(): def write_xm(path, song, instruments): + """Writes an XM file. Only instruments used by the song are stored (renumbered), + samples are 8-bit like on the Amiga to keep the files small.""" + used = sorted({c[1] for pat in song.patterns for row in pat.cells for c in row if c and c[1]}) + remap = {old: new + 1 for new, old in enumerate(used)} out = bytearray() out += b"Extended Module: " out += song.name.encode("ascii")[:20].ljust(20, b" ") @@ -609,7 +704,7 @@ def write_xm(path, song, instruments): out += struct.pack(" 'X4:8 ...' melody string in the given key""" + base = 12 * octave + root + out = [] + for item in motif: + deg, l = item[0], item[1] + lt = len(item) > 2 and mode in ("minor", "harmonic", "dorian", "phrygian") + out.append("%s:%d" % (name_of(base + degree_semis(deg, mode, lt)), l)) + return " ".join(out) + + +def m_aug(m): + return [(i[0], i[1] * 2) + tuple(i[2:]) for i in m] + + +def m_dim(m): + return [(i[0], max(1, i[1] // 2)) + tuple(i[2:]) for i in m] + + +def m_inv(m): + # mirror the melody around the tonic (keeps the rhythm) + return [(-i[0], i[1]) for i in m] + + +def m_shift(m, d): + return [(i[0] + d, i[1]) + tuple(i[2:]) for i in m] + + +def m_take(m, rows): + """first `rows` rows of a motif""" + out, r = [], 0 + for i in m: + if r >= rows: + break + l = min(i[1], rows - r) + out.append((i[0], l) + tuple(i[2:])) + r += l + return out + + +def rows_of(m): + return sum(i[1] for i in m) + + +def chord_tones(deg, mode, root, octave, dominant=False): + """triad on a scale degree -> list of midi notes""" + base = 12 * octave + root + notes = [base + degree_semis(deg + k, mode) for k in (0, 2, 4)] + if dominant and mode in ("minor", "dorian", "phrygian", "harmonic") and deg % 7 == 4: + notes[1] = base + degree_semis(deg, mode) + 4 # major third on V + return notes + + +class Gen: + """Builds a song from a spec dict (see SPECS).""" + + def __init__(self, spec): + self.sp = spec + self.rng = random.Random(spec["seed"]) + self.root = NOTE_BASE[spec["key"][0]] + (1 if spec["key"].endswith("#") else -1 if spec["key"].endswith("b") and len(spec["key"]) > 1 else 0) + self.mode = spec["mode"] + + # ---- accompaniment for a list of chords (degree, rows) + def accompany(self, pat, chords): + sp = self.sp + r = 0 + for deg, rows in chords: + notes = chord_tones(deg, self.mode, self.root, 4, dominant=True) + bass = chord_tones(deg, self.mode, self.root, 2, dominant=True) + # chord_tones() counts from C-0 = 0, XM notes from 1 + if sp.get("pad"): + pat.set(r, CH_PAD1, notes[1] + 1, sp["pad"], sp.get("pad_vol", 20)) + pat.set(r, CH_PAD2, notes[2] + 1, sp["pad"], sp.get("pad_vol", 20)) + bp = sp.get("bass_pat", "R.......F.......") + for k in range(rows): + c = bp[(r + k) % len(bp)] + n = {"R": bass[0], "F": bass[2], "O": bass[0] + 12, "T": bass[1]}.get(c) + if n: + pat.set(r + k, CH_BASS, n + 1, sp.get("bass", BASS), sp.get("bass_vol", 44)) + elif c == "-": + pat.set(r + k, CH_BASS, KEY_OFF) + if sp.get("arp"): + step = sp.get("arp_step", 2) + order = sp.get("arp_order", (0, 1, 2, 1)) + an = chord_tones(deg, self.mode, self.root, sp.get("arp_oct", 5), dominant=True) + for i, rr in enumerate(range(0, rows, step)): + pat.set(r + rr, CH_ARP, an[order[i % len(order)]] + 1, sp["arp"], sp.get("arp_vol", 13)) + r += rows + + def drums(self, pat, style): + styles = { + "none": {}, + "soft": dict(hat="x.......x.......", hv=10), + "calm": dict(kick="x...............", hat="x...x...x...x...", hv=10, kv=44), + "bouncy": dict(kick="x.......x.......", snare="............x...", hat="..x...x...x...x.", hv=13, sv=30), + "rock": dict(kick="x.....x.x.......", snare="....x.......x...", hat="x.x.x.x.x.x.x.x.", hv=15, sv=44), + "driving": dict(kick="x...x...x...x...", snare="....x.......x...", hat="xxxxxxxxxxxxxxxx", hv=10, sv=46), + "march": dict(kick="x.......x.......", snare="....x.x.....x.xx", hat="x...x...x...x...", hv=12, sv=38), + "tribal": dict(kick="x..x..x...x..x..", hat="x.x.x.x.x.x.x.x.", hv=11, kv=52), + "boss": dict(kick="x.x.x.x.x.x.x.x.", snare="....x.......x...", hat="xxxxxxxxxxxxxxxx", hv=10, sv=50), + } + d = styles[style] + pat.drums(kick=d.get("kick", ""), snare=d.get("snare", ""), hat=d.get("hat", ""), kv=d.get("kv", 60), sv=d.get("sv", 44), hv=d.get("hv", 18)) + if style == "tribal": + for bar in range(pat.rows // BAR): + for i in (4, 12, 14): + pat.set(bar * BAR + i, CH_DRUM, note_num("A3" if i == 14 else "D4"), TOM, 46) + if style in ("boss", "driving", "march"): + pat.set(0, CH_HAT, note_num("C4"), CRASH, 34) + + # ---- generated melody over chords (strong beats on chord tones) + def melody(self, chords, inst, vol, octave, style): + rhythms = { + "calm": [[8, 8], [12, 4], [4, 4, 8], [16], [6, 2, 8]], + "flow": [[4, 4, 4, 4], [6, 2, 4, 4], [8, 4, 4], [4, 4, 8]], + "bouncy": [[2, 2, 4, 2, 2, 4], [4, 2, 2, 4, 4], [2, 2, 2, 2, 8], [4, 4, 2, 2, 4]], + "driving": [[2, 2, 2, 2, 2, 2, 4], [4, 2, 2, 4, 4], [2, 2, 4, 2, 2, 4], [2] * 8], + }[style] + scale = MODES[self.mode] + base = 12 * octave + self.root + pool = [base + 12 * o + iv for o in (-1, 0, 1) for iv in scale] + cur = base + scale[0] + out = [] + for deg, rows in chords: + ct = [n % 12 for n in chord_tones(deg, self.mode, self.root, 4, dominant=True)] + r = 0 + while r < rows: + pat_r = self.rng.choice(rhythms) + for k, l in enumerate(pat_r): + if r >= rows: + break + l = min(l, rows - r) + strong = k == 0 or (r % 8 == 0) + if strong: + cands = [n for n in pool if n % 12 in ct and abs(n - cur) <= 7] + else: + cands = [n for n in pool if 0 < abs(n - cur) <= 3] + if not cands: + cands = [n for n in pool if n % 12 in ct] + # keep the line within about an octave around the centre + cands.sort(key=lambda n: abs(n - cur) + 0.4 * abs(n - (base + 7)) + self.rng.random() * 2) + cur = cands[0] + out.append("%s:%d" % (name_of(cur), l)) + r += l + # occasional breath at the end of a phrase + return " ".join(out) + + def harm_rows(self, harm, rows_per_bar=BAR): + """[[0],[3,4],...] -> [(degree, rows)]""" + out = [] + for bar in harm: + for d in bar: + out.append((d, rows_per_bar // len(bar))) + return out + + def build(self): + sp = self.sp + song = Song(sp["title"], sp["bpm"]) + lead, lvol, loct = sp.get("lead", LEAD), sp.get("lead_vol", 40), sp.get("lead_oct", 4) + treat = sp.get("motif", "normal") + prog = sp["prog"] # list of 4 degrees (one bar each) for free sections + # motif treatment + mA, mB = MOTIF_DEG_A, MOTIF_DEG_B + hA, hB = MOTIF_HARM_A, MOTIF_HARM_B + if treat == "inv": + mA, mB = m_inv(mA), m_inv(mB) + elif treat == "shift": + mA, mB = m_shift(mA, 2), m_shift(mB, 2) + sections = [] # (melody string, chords) per 4 bars + if treat in ("normal", "inv", "shift"): + sections.append((realize(mA, self.root, self.mode, loct), self.harm_rows(hA))) + sections.append((realize(mB, self.root, self.mode, loct), self.harm_rows(hB))) + elif treat == "aug": + a = m_aug(m_take(mA, 32)) + b = m_aug(m_take(mA[6:], 32)) if False else m_aug(m_take(mA, 64)[len(m_take(mA, 32)):]) + sections.append((realize(a, self.root, self.mode, loct), self.harm_rows([[0], [0], [3], [4]]))) + sections.append((realize(b, self.root, self.mode, loct), self.harm_rows([[0], [0], [2], [2]]))) + elif treat == "dim": + d = m_dim(mA) + m_dim(mB) + sections.append((realize(d, self.root, self.mode, loct + 1), self.harm_rows([[0, 3], [0, 2], [5, 0], [4, 0]]))) + elif treat == "frag": + # the motif's first bar as a rising sequence + frag = m_take(mA, 16) + seq = frag + m_shift(frag, 1) + m_shift(frag, 2) + m_take(m_shift(mA, 0)[3:], 16) + sections.append((realize(seq, self.root, self.mode, loct), self.harm_rows([[0], [1], [2], [4]]))) + free = self.harm_rows([[d] for d in prog]) + mel_style = sp.get("mel_style", "flow") + sections.append((self.melody(free, lead, lvol, loct, mel_style), free)) + if sp.get("long"): + free2 = self.harm_rows([[d] for d in sp.get("prog2", prog[::-1])]) + sections.append((self.melody(free2, lead, lvol, loct, mel_style), free2)) + # intro: accompaniment only + pats = [] + intro = Pattern() + self.accompany(intro, free) + self.drums(intro, sp.get("intro_drums", "soft" if sp.get("drums", "none") != "none" else "none")) + pats.append(intro) + for k, (mel, chords) in enumerate(sections): + p = Pattern() + p.line(CH_LEAD, mel, lead, lvol) + if sp.get("echo"): + p.echo(CH_LEAD, CH_COUNTER, sp.get("echo_delay", 3), max(12, lvol // 2), sp.get("echo_inst", SQUARE)) + elif sp.get("counter") and k % 2 == 1: + # counter melody: the bass line of the chords an octave up, long notes + cm = " ".join("%s:%d" % (name_of(chord_tones(d, self.mode, self.root, loct - 1, True)[1]), rows) for d, rows in chords) + p.line(CH_COUNTER, cm, sp["counter"], sp.get("counter_vol", 24)) + self.accompany(p, chords) + self.drums(p, sp.get("drums", "none")) + pats.append(p) + idx = [song.add(x) for x in pats] + song.order = idx + idx[1:] + song.restart = 1 + return song + + +def spec(title, seed, key, mode, bpm, prog, **kw): + d = dict(title=title, seed=seed, key=key, mode=mode, bpm=bpm, prog=prog) + d.update(kw) + return d + + +# fifteen build tracks in total (three hand-written + twelve generated), calm and varied +BUILD_SPECS = [ + spec("Dripping Stone", 4, "A", "minor", 78, [0, 5, 3, 4], motif="aug", lead=HARP, lead_vol=34, pad=CHOIR, bass=SUB, arp=PLUCK, arp_step=4, drums="soft", echo=True, echo_inst=HARP), + spec("Fungus Gardens", 5, "E", "dorian", 96, [0, 3, 0, 6], motif="normal", lead=PLUCK, lead_vol=34, pad=PAD, bass_pat="R.-.R.F.-.R.O.F.", arp=HARP, drums="bouncy", mel_style="bouncy"), + spec("Lantern Path", 6, "G", "minor", 88, [0, 2, 6, 4], motif="frag", lead=SQUARE, lead_vol=32, pad=ORGAN, pad_vol=16, arp=PLUCK, drums="calm", echo=True), + spec("Deep Forge", 7, "C", "phrygian", 92, [0, 1, 0, 6], motif="normal", lead=LEAD, lead_vol=36, pad=PAD, bass=SUB, bass_pat="R.R.....R.R.....", drums="tribal", counter=ORGAN), + spec("Silent Vault", 8, "F", "minor", 72, [0, 5, 2, 4], motif="aug", lead=BELL, lead_vol=40, pad=CHOIR, bass=SUB, bass_pat="R...............", drums="none"), + spec("Crystal Hollow", 9, "B", "minor", 84, [0, 5, 6, 4], motif="inv", lead=BELL, lead_vol=36, pad=PAD, arp=HARP, arp_step=2, arp_order=(0, 1, 2, 3 % 3, 2, 1), drums="soft", echo=True, echo_inst=HARP), + spec("Whispering Halls", 10, "D", "dorian", 80, [0, 3, 6, 0], motif="shift", lead=HARP, lead_vol=34, pad=CHOIR, bass=BASS, drums="calm", long=True, prog2=[3, 0, 4, 0]), + spec("Old Bones", 11, "A", "phrygian", 86, [0, 1, 6, 0], motif="frag", lead=ORGAN, lead_vol=30, pad=PAD, bass=SUB, drums="tribal", arp=PLUCK, arp_step=4), + spec("Ember Chamber", 12, "F#", "minor", 94, [0, 3, 4, 0], motif="normal", lead=LEAD, lead_vol=34, pad=ORGAN, pad_vol=15, arp=PLUCK, drums="calm", counter=SQUARE), + spec("Underground River", 13, "E", "minor", 100, [0, 6, 5, 6], motif="dim", lead=HARP, lead_vol=34, pad=CHOIR, bass_pat="R.R.F.R.R.R.F.O.", arp=PLUCK, arp_step=1, arp_vol=10, drums="soft"), + spec("Moss and Shadow", 14, "G", "dorian", 90, [0, 3, 0, 4], motif="aug", lead=SQUARE, lead_vol=30, pad=PAD, arp=HARP, drums="bouncy", mel_style="bouncy", echo=True), + spec("The Long Dig", 15, "D", "minor", 98, [0, 2, 3, 4], motif="normal", lead=PLUCK, lead_vol=36, pad=PAD, bass_pat="R...R.F.R...F.O.", arp=HARP, drums="bouncy", long=True, prog2=[5, 3, 0, 4]), +] + +# seven wave battles (one hand-written + six generated) +BATTLE_SPECS = [ + spec("Steel in the Dark", 20, "E", "minor", 144, [0, 5, 6, 4], motif="normal", lead=LEAD, lead_vol=44, pad=PAD, pad_vol=16, bass_pat="R.R.R.R.R.R.R.R.", arp=PLUCK, arp_step=1, arp_vol=10, drums="rock", lead_oct=5, mel_style="driving", counter=SQUARE), + spec("Breach!", 21, "C", "harmonic", 152, [0, 5, 3, 4], motif="dim", lead=SQUARE, lead_vol=42, pad=ORGAN, pad_vol=14, bass_pat="RRRRRRRRRRRRRRRR", drums="driving", mel_style="driving"), + spec("Shield Wall", 22, "G", "minor", 138, [0, 3, 6, 4], motif="normal", lead=LEAD, lead_vol=44, pad=CHOIR, bass_pat="R.R.R.F.R.R.F.O.", drums="march", lead_oct=5, mel_style="driving", echo=True), + spec("Torches and Pitchforks", 23, "A", "dorian", 148, [0, 3, 0, 6], motif="frag", lead=SQUARE, lead_vol=40, pad=PAD, pad_vol=15, bass_pat="R.R.R.R.R.R.R.R.", arp=PLUCK, arp_step=1, drums="rock", lead_oct=5, mel_style="driving"), + spec("Blood on the Stones", 24, "D", "phrygian", 142, [0, 1, 0, 6], motif="inv", lead=LEAD, lead_vol=44, pad=ORGAN, pad_vol=14, bass_pat="RRRRRRRRRRRRRRRR", drums="tribal", lead_oct=5, mel_style="driving", counter=SQUARE), + spec("No Retreat", 25, "B", "minor", 156, [0, 5, 3, 4], motif="normal", lead=LEAD, lead_vol=44, pad=PAD, pad_vol=15, bass_pat="R.R.R.R.R.R.R.R.", arp=PLUCK, arp_step=1, arp_vol=10, drums="driving", lead_oct=5, mel_style="driving", long=True, prog2=[5, 6, 0, 4]), +] + +# four gate assaults (one hand-written + three generated) +ASSAULT_SPECS = [ + spec("Siege Engine", 30, "D", "harmonic", 160, [0, 5, 1, 4], motif="dim", lead=LEAD, lead_vol=46, pad=ORGAN, pad_vol=15, bass_pat="RRRRRRRRRRRRRRRR", arp=PLUCK, arp_step=1, arp_vol=10, drums="boss", mel_style="driving"), + spec("The Gate Trembles", 31, "F", "minor", 164, [0, 6, 5, 4], motif="frag", lead=SQUARE, lead_vol=44, pad=CHOIR, bass_pat="RRRRRRRRRRRRRRRR", drums="driving", lead_oct=5, mel_style="driving", echo=True, echo_delay=2), + spec("Last Charge", 32, "C", "minor", 168, [0, 3, 4, 0], motif="normal", lead=LEAD, lead_vol=46, pad=PAD, pad_vol=16, bass_pat="R.R.R.R.R.R.R.R.", arp=PLUCK, arp_step=1, arp_vol=10, drums="boss", lead_oct=5, mel_style="driving", counter=ORGAN), +] + +MOOD_SPECS = { + "torture": spec("The Rack", 40, "C#", "phrygian", 66, [0, 1, 0, 1], motif="aug", lead=BELL, lead_vol=34, pad=CHOIR, pad_vol=16, bass=SUB, bass_pat="R...............", drums="none", intro_drums="none", arp=PLUCK, arp_step=8, arp_order=(0, 1), arp_vol=12), + "temple": spec("Ember Shrine", 41, "D", "lydian", 74, [0, 1, 4, 0], motif="aug", lead=ORGAN, lead_vol=32, pad=CHOIR, pad_vol=20, bass=SUB, bass_pat="R...............", drums="none", intro_drums="none", arp=BELL, arp_step=8, arp_order=(0, 2), arp_vol=18, echo=True, echo_inst=HARP), +} + + +# ------------------------------------------------------------------ hand-written additions + +def song_title2(): + """Throne of Ash: second title theme, the motif slow and majestic on organ and choir.""" + s = Song("DungeonCraft - Throne of Ash", 92) + ch_a = bars(("Dm", 1), ("Bb", 1), ("Gm", 1), ("A", 1)) + p0 = Pattern() + p0.set(0, CH_DRUM, note_num("C4"), TIMP, 50) + for c, (n, r) in enumerate([("D3", 0), ("D3", 32)]): + p0.set(r, CH_DRUM, note_num("C4"), TIMP, 46) + p0.pads(ch_a, vol=20) + for k in range(4): + p0.set(k * BAR, CH_PAD1, chord_notes(ch_a[k][0], 4)[1], CHOIR, 22) + p0.set(k * BAR, CH_PAD2, chord_notes(ch_a[k][0], 4)[2], CHOIR, 22) + p0.bass(ch_a, "R...............", inst=SUB, vol=46) + p1 = Pattern() + p1.line(CH_LEAD, MOTIF_A, ORGAN, 40) + p1.line(CH_COUNTER, "D3:16 | G2:8 A2:8 | D3:16 | F2:16", BASS, 40) + for k, (c, rr) in enumerate(CHORDS_A): + pass + p1.pads(CHORDS_A, vol=18) + p1.drums(kick="x...............", kv=50) + p1.set(0, CH_HAT, note_num("C4"), CRASH, 30) + p2 = Pattern() + p2.line(CH_LEAD, MOTIF_B, ORGAN, 40) + p2.line(CH_COUNTER, "Bb2:16 | D3:16 | A2:16 | D3:16", BASS, 40) + p2.pads(CHORDS_B, vol=18) + p2.drums(kick="x.......x.......", kv=48) + p3 = Pattern() + p3.line(CH_LEAD, transpose(MOTIF_A, 12), BELL, 40) + p3.echo(CH_LEAD, CH_COUNTER, 4, 20, HARP) + p3.pads(CHORDS_A, vol=20) + p3.bass(CHORDS_A, "R.......F.......", inst=SUB, vol=44) + p3.arp(CHORDS_A, step=2, vol=12, inst=HARP) + p3.drums(kick="x.......x.......", hat="x...x...x...x...", hv=10) + p = [s.add(x) for x in (p0, p1, p2, p3)] + s.order = [p[0], p[1], p[2], p[3], p[2]] + s.restart = 1 + return s + + +def song_boss_paladin(): + """The Paladin of Light: the heroes' fanfare (major) clashes with the leitmotif (minor).""" + s = Song("DungeonCraft - Paladin of Light", 150) + fan_ch = bars(("D", 1), ("G", 1), ("A", 1), ("D", 1)) + p0 = Pattern() + p0.line(CH_LEAD, "D5:2 D5:2 D5:2 A5:10 | G5:2 F#5:2 E5:2 D5:10 | E5:4 F#5:4 G5:4 A5:4 | D6:16", SQUARE, 44) + p0.line(CH_COUNTER, "F#4:16 | B4:16 | C#5:16 | F#5:16", SQUARE, 28) + p0.pads(fan_ch, vol=18) + p0.bass(fan_ch, "R...R...R...R...", vol=46) + p0.drums(kick="x.......x.......", snare="....x.xx....xxxx", hat="x.x.x.x.x.x.x.x.", sv=44, hv=12) + p0.set(0, CH_HAT, note_num("C4"), CRASH, 36) + p1 = Pattern() + p1.line(CH_LEAD, transpose(MOTIF_A, 12), LEAD, 46) + p1.line(CH_COUNTER, "D4:2 D4:2 D4:2 A4:10 | G4:8 A4:8 | D4:16 | F4:16", SQUARE, 26) + p1.pads(CHORDS_A, vol=16) + p1.bass(CHORDS_A, "RRRRRRRRRRRRRRRR", vol=44) + p1.drums(kick="x.x.x.x.x.x.x.x.", snare="....x.......x...", hat="xxxxxxxxxxxxxxxx", sv=48, hv=10) + p1.set(0, CH_HAT, note_num("C4"), CRASH, 34) + p2 = Pattern() + p2.line(CH_LEAD, transpose(MOTIF_B, 12), LEAD, 46) + p2.pads(CHORDS_B, vol=16) + p2.bass(CHORDS_B, "RRRRRRRRRRRRRRRR", vol=44) + p2.arp(CHORDS_B, step=1, vol=10) + p2.drums(kick="x.x.x.x.x.x.x.x.", snare="....x.......x.xx", hat="xxxxxxxxxxxxxxxx", sv=48, hv=10) + p3 = Pattern() + ch3 = bars(("Bb", 1), ("C", 1), ("Dm", 1), ("A", 1)) + p3.line(CH_LEAD, "D6:4 C6:4 Bb5:4 A5:4 | G5:4 A5:4 Bb5:4 C6:4 | D6:4 A5:4 F5:4 D5:4 | C#5:8 E5:8", LEAD, 46) + p3.line(CH_COUNTER, "D5:2 D5:2 D5:2 F5:10 | E5:2 E5:2 E5:2 G5:10 | F5:16 | E5:16", SQUARE, 26) + p3.pads(ch3, vol=16) + p3.bass(ch3, "RRRRRRRRRRRRRRRR", vol=44) + p3.drums(kick="x.x.x.x.x.x.x.x.", snare="....x.......x...", hat="xxxxxxxxxxxxxxxx", sv=50, hv=10) + for k in range(4): + p3.set(k * BAR + 14, CH_DRUM, note_num("D4"), TIMP, 50) + p = [s.add(x) for x in (p0, p1, p2, p3)] + s.order = [p[0], p[1], p[2], p[3], p[1], p[2], p[3]] + s.restart = 1 + return s + + +def song_boss_warden(): + """The Gate Warden: heavy, slow-fast, organ and timpani; the motif inverted.""" + s = Song("DungeonCraft - Gate Warden", 132) + ch = bars(("Dm", 1), ("Bb", 1), ("Gm", .5), ("A", .5), ("Dm", 1)) + inv = realize(m_inv(MOTIF_DEG_A), NOTE_BASE["D"], "harmonic", 5) + p0 = Pattern() + for k in range(4): + p0.set(k * BAR, CH_DRUM, note_num("C4"), TIMP, 56) + p0.set(k * BAR + 8, CH_DRUM, note_num("C4"), TIMP, 46) + p0.line(CH_PAD1, "D4:32 | C#4:32", ORGAN, 24) + p0.line(CH_PAD2, "A4:32 | E4:32", ORGAN, 24) + p0.bass(ch, "R...............", inst=SUB, vol=50) + p1 = Pattern() + p1.line(CH_LEAD, inv, LEAD, 46) + p1.pads(ch, vol=18) + p1.bass(ch, "R.R.R.R.R.R.R.R.", vol=46) + p1.drums(kick="x..x..x.x..x..x.", snare="....x.......x...", hat="x.x.x.x.x.x.x.x.", sv=48, hv=12) + p1.set(0, CH_HAT, note_num("C4"), CRASH, 34) + p2 = Pattern() + p2.line(CH_LEAD, transpose(MOTIF_A, 12), ORGAN, 42) + p2.echo(CH_LEAD, CH_COUNTER, 2, 22, SQUARE) + p2.pads(CHORDS_A, vol=18) + p2.bass(CHORDS_A, "RRRRRRRRRRRRRRRR", vol=46) + p2.drums(kick="x.x.x.x.x.x.x.x.", snare="....x.......x.xx", hat="xxxxxxxxxxxxxxxx", sv=50, hv=10) + p = [s.add(x) for x in (p0, p1, p2)] + s.order = [p[0], p[1], p[2], p[1], p[2]] + s.restart = 1 + return s + + +def song_victory2(): + """Crown of the Deep: a slower, triumphant victory jingle with bells.""" + s = Song("DungeonCraft - Crown of the Deep", 100) + p0 = Pattern() + ch = bars(("D", 1), ("Bb", .5), ("C", .5), ("D", 2)) + p0.line(CH_LEAD, "A4:4 D5:4 F#5:4 A5:4 | Bb5:6 A5:2 G5:4 E5:4 | D5:32", BELL, 48) + p0.line(CH_COUNTER, "D4:16 | D4:8 E4:8 | F#4:32", ORGAN, 30) + p0.pads(ch, vol=22) + p0.bass(ch, "R.......R.......", inst=SUB, vol=46) + for k in (0, 32, 40, 44): + p0.set(k, CH_DRUM, note_num("C4"), TIMP, 52) + p0.set(32, CH_HAT, note_num("C4"), CRASH, 36) + for c in range(CHANNELS): + p0.set(63, c, KEY_OFF) + silence = Pattern(1) + p = [s.add(x) for x in (p0, silence)] + s.order = [p[0], p[1]] + s.restart = 1 + return s + + +def generated(spec_): + return lambda: Gen(spec_).build() + + +# name -> (category, song function); the order within a category is the play order +SONGS = {} +SONGS["title"] = ("title", song_title) +SONGS["title2"] = ("title", song_title2) +SONGS["build1"] = ("build", song_build1) +SONGS["build2"] = ("build", song_build2) +SONGS["build3"] = ("build", song_build3) +for k, sp_ in enumerate(BUILD_SPECS): + SONGS["build%d" % (k + 4)] = ("build", generated(sp_)) +SONGS["battle1"] = ("battle", song_battle1) +for k, sp_ in enumerate(BATTLE_SPECS): + SONGS["battle%d" % (k + 3)] = ("battle", generated(sp_)) +SONGS["battle2"] = ("assault", song_battle2) +for k, sp_ in enumerate(ASSAULT_SPECS): + SONGS["assault%d" % (k + 2)] = ("assault", generated(sp_)) +SONGS["boss_paladin"] = ("boss_paladin", song_boss_paladin) +SONGS["boss_warden"] = ("boss_warden", song_boss_warden) +SONGS["mood_torture"] = ("torture", generated(MOOD_SPECS["torture"])) +SONGS["mood_temple"] = ("temple", generated(MOOD_SPECS["temple"])) +SONGS["victory"] = ("victory", song_victory) +SONGS["victory2"] = ("victory", song_victory2) +SONGS["defeat"] = ("defeat", song_defeat) + + +def write_rust_table(names): + """src/tracks.rs: embedded music with categories (generated, do not edit)""" + lines = [ + "// Generated by tools/compose_music.py - do not edit.", + "// All music modules with their category (see audio::Category).", + "", + "use crate::audio::Category;", + "", + "pub const TRACKS: [(&str, Category, &[u8]); %d] = [" % len(names), + ] + cat = {"title": "Title", "build": "Build", "battle": "Battle", "assault": "Assault", "boss_paladin": "BossPaladin", + "boss_warden": "BossWarden", "torture": "Torture", "temple": "Temple", "victory": "Victory", "defeat": "Defeat"} + for n in names: + lines.append(' ("%s", Category::%s, include_bytes!("../assets/music/%s.xm")),' % (n, cat[SONGS[n][0]], n)) + lines.append("];") + path = os.path.join(os.path.dirname(__file__), "..", "src", "tracks.rs") + with open(path, "w") as f: + f.write("\n".join(lines) + "\n") + def main(): os.makedirs(OUT_DIR, exist_ok=True) instruments = build_instruments() - for name, fn in SONGS.items(): + total = 0 + for name, (cat, fn) in SONGS.items(): song = fn() size = write_xm(os.path.join(OUT_DIR, name + ".xm"), song, instruments) - print("%-8s %3d BPM %2d patterns %6d bytes" % (name, song.bpm, len(song.patterns), size)) + total += size + print("%-13s %-12s %3d BPM %2d patterns %6d bytes %s" % (name, cat, song.bpm, len(song.patterns), size, song.name)) + write_rust_table(list(SONGS.keys())) + print("%d songs, %d KB" % (len(SONGS), total // 1024)) if __name__ == "__main__":