Soundtrack expanded to 35 pieces with a leitmotif variant generator and 8 new instruments

- generator: leitmotif as scale degrees, keys/modes, augmentation, diminution, inversion,
  fragment sequences, accompaniment and drum styles; hand-written title 2, boss themes, victory 2
- organ, bell, choir, sub bass, tom, crash, harp, timpani; 8-bit samples, unused instruments stripped
- src/tracks.rs generated with categories; music director with boss themes and room moods
- all pieces validated with libopenmpt; notes checked to stay in key

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 08:09:15 +01:00
1 parent 1e92e19089
commit 5158075d7b
42 files changed
+721 -98

No files matched your search

+4 -2
View File
@@ -41,8 +41,10 @@ An original dungeon management game (genre: dungeon building & defence) that run
## Music & sound ## 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 - 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) - **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** - 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**
- Music changes with **soft crossfades** (2.5 s): title → build → battle when heroes invade → storm music while attacking the gate → jingles - 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 - Sound effects are **synthesised chip sounds** (`src/sfx.rs`), positioned by distance and stereo panning
## Map size ## Map size
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+26 -29
View File
@@ -3,32 +3,30 @@
// which starts the AudioContext on the first user interaction. // which starts the AudioContext on the first user interaction.
use crate::sfx; use crate::sfx;
use crate::tracks::TRACKS;
use crate::xm::{self, Module, Player}; use crate::xm::{self, Module, Player};
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
// Music categories; the tracks themselves are listed in tracks.rs (generated by the composer)
#[derive(Clone, Copy, PartialEq, Eq, Debug)] #[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Track { pub enum Category {
Title, Title,
Build1, Build,
Build2, Battle,
Build3, Assault,
Battle1, BossPaladin,
Battle2, BossWarden,
Torture,
Temple,
Victory, Victory,
Defeat, Defeat,
} }
const TRACKS: [(Track, &[u8]); 8] = [ // indices of all tracks of a category, in play order
(Track::Title, include_bytes!("../assets/music/title.xm")), pub fn tracks_in(c: Category) -> Vec<usize> {
(Track::Build1, include_bytes!("../assets/music/build1.xm")), TRACKS.iter().enumerate().filter(|(_, t)| t.1 == c).map(|(i, _)| i).collect()
(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")),
];
const MUSIC_GAIN: f32 = 0.45; // our replayer is ~4x louder than libopenmpt; leaves headroom for effects const MUSIC_GAIN: f32 = 0.45; // our replayer is ~4x louder than libopenmpt; leaves headroom for effects
const SFX_GAIN: f32 = 0.55; const SFX_GAIN: f32 = 0.55;
@@ -61,7 +59,7 @@ struct State {
music_vol: f32, music_vol: f32,
sfx_vol: f32, sfx_vol: f32,
muted: bool, muted: bool,
track: Option<Track>, track: Option<usize>,
engine: Option<Engine>, engine: Option<Engine>,
} }
@@ -69,24 +67,20 @@ thread_local! {
static STATE: RefCell<State> = RefCell::new(State { music_vol: 0.6, sfx_vol: 0.7, muted: false, track: None, engine: None }); static STATE: RefCell<State> = 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 { impl Engine {
fn new(rate: f32) -> 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(); 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() } 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); let speed = 1.0 / fade.max(0.01);
for v in self.music.iter_mut() { for v in self.music.iter_mut() {
v.target = 0.0; v.target = 0.0;
v.speed = speed; 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; 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 }); 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 // 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| { STATE.with(|s| {
let mut s = s.borrow_mut(); let mut s = s.borrow_mut();
if s.track == Some(t) { if s.track == Some(t) {
@@ -164,7 +158,7 @@ pub fn music_loops() -> u32 {
}) })
} }
pub fn current_track() -> Option<Track> { pub fn current_track() -> Option<usize> {
STATE.with(|s| s.borrow().track) STATE.with(|s| s.borrow().track)
} }
@@ -225,11 +219,14 @@ mod tests {
#[test] #[test]
fn engine_crossfade_and_sfx() { fn engine_crossfade_and_sfx() {
dc_audio_init(48000.0); 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; let mut peak = 0.0f32;
for k in 0..200 { for k in 0..200 {
if k == 50 { if k == 50 {
play_music(Track::Battle1, 2.5); play_music(battle[0], 2.5);
} }
if k % 10 == 0 { if k % 10 == 0 {
play_sfx(k % crate::game::SFX_ALL.len(), 1.0, 0.3); play_sfx(k % crate::game::SFX_ALL.len(), 1.0, 0.3);
@@ -243,6 +240,6 @@ mod tests {
} }
} }
assert!(peak > 0.05); assert!(peak > 0.05);
assert_eq!(current_track(), Some(Track::Battle1)); assert_eq!(current_track(), Some(battle[0]));
} }
} }
+61 -25
View File
@@ -11,6 +11,7 @@ mod render3d;
mod save; mod save;
mod settings; mod settings;
mod sfx; mod sfx;
mod tracks;
mod sound; mod sound;
mod ui; mod ui;
mod xm; mod xm;
@@ -73,35 +74,69 @@ fn apply_settings(s: &Settings, r3d: &mut R3d) {
audio::set_volumes(s.music_volume, s.volume, s.muted); 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 { struct MusicDirector {
build: usize, next: std::collections::HashMap<u8, usize>, // category -> next index within the category
current: Option<audio::Category>,
} }
impl MusicDirector { impl MusicDirector {
fn update(&mut self, g: &Game, title: bool) { fn new() -> MusicDirector {
use audio::Track; MusicDirector { next: std::collections::HashMap::new(), current: None }
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); fn pick(&mut self, c: audio::Category) -> usize {
let want = if title { let list = audio::tracks_in(c);
Track::Title let k = self.next.entry(c as u8).or_insert(0);
} else if g.state == 1 { let t = list[*k % list.len()];
Track::Victory *k += 1;
} else if g.state == 2 { t
Track::Defeat }
} else if gate_fight {
Track::Battle2 fn update(&mut self, g: &Game, title: bool, cam: (f32, f32)) {
} else if invasion { use audio::Category as C;
Track::Battle1 let alive = |f: &dyn Fn(&game::Ent) -> bool| g.ents.iter().any(|e| e.alive && f(e));
} else { let near_room = |k: map::Kind| {
if BUILD.contains(&audio::current_track().unwrap_or(Track::Title)) && audio::music_loops() >= 2 { let (cx, cz) = (cam.0 as i32, cam.1 as i32);
self.build = (self.build + 1) % BUILD.len(); 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 }; let want = if title {
audio::play_music(want, fade); 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 gfx = Gfx::new();
let mut r3d = R3d::new(); let mut r3d = R3d::new();
let mut sounds = SoundBank::new(); let mut sounds = SoundBank::new();
let mut music = MusicDirector { build: 0 }; let mut music = MusicDirector::new();
let mut ui = Ui::new(); let mut ui = Ui::new();
let mut menu = Menu::new(); let mut menu = Menu::new();
@@ -542,7 +577,8 @@ async fn main() {
for ev in app.game.events.drain(..) { for ev in app.game.events.drain(..) {
sounds.play(ev, vec2(app.cam.tx, app.cam.tz), right, hear, now); 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(..) { for m in app.game.msgs.drain(..) {
ui.log.push((m, now)); ui.log.push((m, now));
} }
+42
View File
@@ -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")),
];
+5 -18
View File
@@ -1126,23 +1126,11 @@ mod tests {
use super::*; use super::*;
use std::rc::Rc; 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] #[test]
fn render_all_songs() { 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")); let m = Rc::new(load(data).expect("load"));
assert_eq!(m.channels, 8); assert_eq!(m.channels, 8, "{}", name);
assert_eq!(m.instruments.len(), 8);
let mut p = Player::new(m, 48000.0); let mut p = Player::new(m, 48000.0);
let mut buf = vec![0.0f32; 48000 * 2]; let mut buf = vec![0.0f32; 48000 * 2];
let mut peak = 0.0f32; let mut peak = 0.0f32;
@@ -1157,8 +1145,8 @@ mod tests {
} }
} }
let rms = (sum / (20.0 * 96000.0)).sqrt(); let rms = (sum / (20.0 * 96000.0)).sqrt();
println!("song {}: peak {:.3} rms {:.4} loops {}", k, peak, rms, p.loops); println!("{:2} {:14}: peak {:.3} rms {:.4} loops {}", k, name, peak, rms, p.loops);
assert!(rms > 0.005, "song {} is silent", k); assert!(rms > 0.005, "song {} is silent", name);
} }
} }
} }
@@ -1173,8 +1161,7 @@ mod render_dump {
#[test] #[test]
fn dump_renders() { fn dump_renders() {
let Ok(dir) = std::env::var("DC_RENDER_DIR") else { return }; let Ok(dir) = std::env::var("DC_RENDER_DIR") else { return };
let names = ["title", "build1", "build2", "build3", "battle1", "battle2", "victory", "defeat"]; for (n, _, _) in crate::tracks::TRACKS.iter() {
for n in names {
let data = std::fs::read(format!("assets/music/{}.xm", n)).unwrap(); 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 p = Player::new(Rc::new(load(&data).unwrap()), 48000.0);
let mut buf = vec![0.0f32; 48000 * 2 * 30]; let mut buf = vec![0.0f32; 48000 * 2 * 30];
Binary file not shown.
+583 -24
View File
@@ -180,6 +180,84 @@ def hihat():
return out 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(): def build_instruments():
return [ return [
Instrument("Lead pulse 25%", Sample("pulse25", pulse(0.25), loop=(0, CYCLE), relnote=CYCLE_REL, finetune=CYCLE_FT), 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("Kick", Sample("kick", kick(), relnote=12)),
Instrument("Snare", Sample("snare", snare(), relnote=12)), Instrument("Snare", Sample("snare", snare(), relnote=12)),
Instrument("Hihat", Sample("hihat", hihat(), 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) LEAD, SQUARE, PAD, BASS, PLUCK, KICK, SNARE, HAT = range(1, 9)
ORGAN, BELL, CHOIR, SUB, TOM, CRASH, HARP, TIMP = range(9, 17)
# channel layout # channel layout
CH_LEAD, CH_COUNTER, CH_PAD1, CH_PAD2, CH_BASS, CH_ARP, CH_DRUM, CH_HAT = range(8) CH_LEAD, CH_COUNTER, CH_PAD1, CH_PAD2, CH_BASS, CH_ARP, CH_DRUM, CH_HAT = range(8)
CHANNELS = 8 CHANNELS = 8
@@ -601,6 +692,10 @@ def song_defeat():
def write_xm(path, song, instruments): 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 = bytearray()
out += b"Extended Module: " out += b"Extended Module: "
out += song.name.encode("ascii")[:20].ljust(20, b" ") out += song.name.encode("ascii")[:20].ljust(20, b" ")
@@ -609,7 +704,7 @@ def write_xm(path, song, instruments):
out += struct.pack("<H", 0x0104) out += struct.pack("<H", 0x0104)
order = song.order + [0] * (256 - len(song.order)) order = song.order + [0] * (256 - len(song.order))
out += struct.pack("<IHHHHHHHH", 276, len(song.order), song.restart, CHANNELS, len(song.patterns), out += struct.pack("<IHHHHHHHH", 276, len(song.order), song.restart, CHANNELS, len(song.patterns),
len(instruments), 1, song.speed, song.bpm) len(used), 1, song.speed, song.bpm)
out += bytes(order) out += bytes(order)
for pat in song.patterns: for pat in song.patterns:
data = bytearray() data = bytearray()
@@ -619,10 +714,11 @@ def write_xm(path, song, instruments):
data += b"\x80" data += b"\x80"
else: else:
note, inst, vol, fx, param = c note, inst, vol, fx, param = c
data += bytes([note, inst, vol, fx, param]) data += bytes([note, remap.get(inst, 0), vol, fx, param])
out += struct.pack("<IBHH", 9, 0, pat.rows, len(data)) out += struct.pack("<IBHH", 9, 0, pat.rows, len(data))
out += data out += data
for ins in instruments: for old in used:
ins = instruments[old - 1]
smp = ins.sample smp = ins.sample
hdr = bytearray() hdr = bytearray()
hdr += ins.name.encode("ascii")[:22].ljust(22, b"\0") hdr += ins.name.encode("ascii")[:22].ljust(22, b"\0")
@@ -634,8 +730,7 @@ def write_xm(path, song, instruments):
pts = bytearray() pts = bytearray()
for (x, y) in env[:12]: for (x, y) in env[:12]:
pts += struct.pack("<HH", x, y) pts += struct.pack("<HH", x, y)
pts = pts.ljust(48, b"\0") hdr += pts.ljust(48, b"\0")
hdr += pts
hdr += bytes(48) # panning envelope hdr += bytes(48) # panning envelope
vol_type = 0 vol_type = 0
if env: if env:
@@ -650,47 +745,511 @@ def write_xm(path, song, instruments):
hdr += bytes(22) # reserved hdr += bytes(22) # reserved
out += struct.pack("<I", 4 + len(hdr)) out += struct.pack("<I", 4 + len(hdr))
out += hdr out += hdr
# sample header (16-bit)
n = len(smp.data) n = len(smp.data)
loop_type = 0 loop_type = 0
ls, ll = 0, 0 ls, ll = 0, 0
if smp.loop: if smp.loop:
loop_type = 2 if smp.ping else 1 loop_type = 2 if smp.ping else 1
ls, ll = smp.loop ls, ll = smp.loop
out += struct.pack("<IIIBbBBbB", n * 2, ls * 2, ll * 2, smp.volume, smp.finetune, loop_type | 0x10, smp.pan, out += struct.pack("<IIIBbBBbB", n, ls, ll, smp.volume, smp.finetune, loop_type, smp.pan, smp.relnote, 0)
smp.relnote, 0)
out += smp.name.encode("ascii")[:22].ljust(22, b"\0") out += smp.name.encode("ascii")[:22].ljust(22, b"\0")
# delta encoded 16-bit sample data
prev = 0 prev = 0
for v in smp.data: for v in smp.data:
s16 = int(round(max(-1.0, min(1.0, v)) * 32767)) s8 = int(round(max(-1.0, min(1.0, v)) * 127))
d = (s16 - prev) & 0xFFFF out += struct.pack("<B", (s8 - prev) & 0xFF)
out += struct.pack("<H", d) prev = s8
prev = s16
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(out) f.write(out)
return len(out) return len(out)
SONGS = { # ------------------------------------------------------------------ variant generator
"title": song_title, #
"build1": song_build1, # The leitmotif is stored as scale degrees of a minor scale (0 = tonic, 7 = octave; a degree
"build2": song_build2, # marked "lt" is the raised leading tone). It can be realised in any key and mode and is
"build3": song_build3, # transformed (augmented, diminished, inverted, fragmented, sequenced) for every piece.
"battle1": song_battle1,
"battle2": song_battle2, MODES = {
"victory": song_victory, "minor": [0, 2, 3, 5, 7, 8, 10],
"defeat": song_defeat, "harmonic": [0, 2, 3, 5, 7, 8, 11],
"dorian": [0, 2, 3, 5, 7, 9, 10],
"phrygian": [0, 1, 3, 5, 7, 8, 10],
"major": [0, 2, 4, 5, 7, 9, 11],
"lydian": [0, 2, 4, 6, 7, 9, 11],
"mixolydian": [0, 2, 4, 5, 7, 9, 10],
} }
# (degree, rows, leading tone?)
MOTIF_DEG_A = [(0, 8), (4, 4), (2, 4), (3, 6), (2, 2), (1, 4), (-1, 4, "lt"), (0, 4), (2, 4), (4, 4), (7, 4), (6, 8), (4, 8)]
MOTIF_DEG_B = [(5, 8), (4, 4), (3, 4), (2, 6), (1, 2), (0, 4), (2, 4), (1, 4), (-1, 4, "lt"), (-3, 4), (-1, 4, "lt"), (0, 16)]
# harmony of the motif as scale degrees per bar (pairs = half bars)
MOTIF_HARM_A = [[0], [3, 4], [0], [2]]
MOTIF_HARM_B = [[5], [0], [4], [0]]
NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
def degree_semis(deg, mode, lt=False):
octv, d = divmod(deg, 7)
if lt:
return 12 * octv + 11
return 12 * octv + MODES[mode][d]
def name_of(midi):
return "%s%d" % (NOTE_NAMES[midi % 12], midi // 12)
def realize(motif, root, mode, octave):
"""motif -> '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(): def main():
os.makedirs(OUT_DIR, exist_ok=True) os.makedirs(OUT_DIR, exist_ok=True)
instruments = build_instruments() instruments = build_instruments()
for name, fn in SONGS.items(): total = 0
for name, (cat, fn) in SONGS.items():
song = fn() song = fn()
size = write_xm(os.path.join(OUT_DIR, name + ".xm"), song, instruments) 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__": if __name__ == "__main__":