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:
1 parent
1e92e19089
commit
5158075d7b
42 files changed
+721
-98
No files matched your search
+26
-29
@@ -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<usize> {
|
||||
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>,
|
||||
track: Option<usize>,
|
||||
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 });
|
||||
}
|
||||
|
||||
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<Track> {
|
||||
pub fn current_track() -> Option<usize> {
|
||||
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]));
|
||||
}
|
||||
}
|
||||
+61
-25
@@ -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<u8, usize>, // category -> next index within the category
|
||||
current: Option<audio::Category>,
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
];
|
||||
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user