Compare commits

...
2 Commits
Author SHA1 Message Date
Richard ZinkandClaude Opus 5.5 d7b6d6de7e Heart sounds: the hurt soul core beats softly, the rival hearts creak, chime and bubble nearby
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:12:08 +01:00
Richard ZinkandClaude Opus 5.5 9800ce7156 Holiday music: a dark carol for Christmas, a spring tune for Easter, a harpsichord dance for Halloween
During a holiday the title music plays its piece, the build music every fourth time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:12:08 +01:00
11 changed files with 503 additions and 10 deletions

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
+66 -1
View File
@@ -24,6 +24,10 @@ pub enum Category {
Defeat, Defeat,
Surface, // above ground: flute, harp, birdsong Surface, // above ground: flute, harp, birdsong
SurfaceBattle, // fighting above ground SurfaceBattle, // fighting above ground
// the holidays' pieces (see holiday_category)
Christmas,
Easter,
Halloween,
} }
// indices of all tracks of a category, in play order // indices of all tracks of a category, in play order
@@ -34,7 +38,27 @@ pub fn tracks_in(c: Category) -> Vec<usize> {
// the calm tracks the music player offers (no battle, victory or defeat music) // the calm tracks the music player offers (no battle, victory or defeat music)
pub fn player_tracks() -> Vec<usize> { pub fn player_tracks() -> Vec<usize> {
use Category as C; use Category as C;
TRACKS.iter().enumerate().filter(|(_, t)| matches!(t.1, C::Title | C::Build | C::Surface | C::Torture | C::Temple)).map(|(i, _)| i).collect() TRACKS.iter().enumerate().filter(|(_, t)| matches!(t.1, C::Title | C::Build | C::Surface | C::Torture | C::Temple | C::Christmas | C::Easter | C::Halloween)).map(|(i, _)| i).collect()
}
// the music of a holiday (holiday::current()), if it has one
pub fn holiday_music(holiday: u8) -> Option<Category> {
match holiday {
crate::holiday::CHRISTMAS => Some(Category::Christmas),
crate::holiday::EASTER => Some(Category::Easter),
crate::holiday::HALLOWEEN => Some(Category::Halloween),
_ => None,
}
}
// the category actually played for `want` during a holiday: the title music always becomes the
// holiday's piece, the build music every fourth time (`build_picks`: build tracks picked so far)
pub fn holiday_category(want: Category, holiday: u8, build_picks: usize) -> Category {
match (want, holiday_music(holiday)) {
(Category::Title, Some(h)) => h,
(Category::Build, Some(h)) if build_picks % 4 == 3 => h,
_ => want,
}
} }
pub fn track_id(t: usize) -> &'static str { pub fn track_id(t: usize) -> &'static str {
@@ -366,6 +390,47 @@ mod tests {
assert_eq!(track_title(track_by_id("build13").unwrap()), "Underground River"); assert_eq!(track_title(track_by_id("build13").unwrap()), "Underground River");
} }
#[test]
fn every_holiday_has_its_piece_for_the_title_and_now_and_then_the_build_music() {
use crate::holiday::{CHRISTMAS, EASTER, HALLOWEEN, NONE};
for (h, c, id, title) in [
(CHRISTMAS, Category::Christmas, "holiday_christmas", "Frostbound Carol"),
(EASTER, Category::Easter, "holiday_easter", "Eggs in the Moss"),
(HALLOWEEN, Category::Halloween, "holiday_halloween", "Crypt of Lanterns"),
] {
let t = track_by_id(id).unwrap();
assert_eq!(tracks_in(c), vec![t]);
assert_eq!(track_title(t), title);
assert!(player_tracks().contains(&t));
assert_eq!(holiday_category(Category::Title, h, 0), c);
assert_eq!(holiday_category(Category::Title, h, 5), c);
let builds: Vec<Category> = (0..8).map(|k| holiday_category(Category::Build, h, k)).collect();
assert_eq!(builds.iter().filter(|&&b| b == c).count(), 2, "now and then, not always");
assert!(builds.iter().all(|&b| b == c || b == Category::Build));
for other in [Category::Battle, Category::Surface, Category::Victory, Category::Torture] {
assert_eq!(holiday_category(other, h, 3), other);
}
// the piece plays: audible, finite and not clipping
let m = std::rc::Rc::new(xm::load(TRACKS[t].2).unwrap());
let mut p = Player::new(m, 22050.0);
let mut buf = vec![0.0f32; 2 * 22050];
let mut peak = 0.0f32;
for _ in 0..20 {
buf.iter_mut().for_each(|v| *v = 0.0);
p.render(&mut buf, MUSIC_GAIN);
for &v in &buf {
assert!(v.is_finite());
peak = peak.max(v.abs());
}
}
assert!(peak > 0.05 && peak < 1.0, "{} peak {}", id, peak);
}
for k in 0..8 {
assert_eq!(holiday_category(Category::Title, NONE, k), Category::Title);
assert_eq!(holiday_category(Category::Build, NONE, k), Category::Build);
}
}
#[test] #[test]
fn engine_crossfade_and_sfx() { fn engine_crossfade_and_sfx() {
dc_audio_init(48000.0); dc_audio_init(48000.0);
+8 -1
View File
@@ -70,11 +70,17 @@ pub enum Sfx {
Rain, Rain,
Wind, Wind,
Spores, Spores,
// hearts (played by the sound module where the camera looks): the soul core when hurt, the
// other masters' hearts (Mossgrim's tree, Lady Nightwell's lily, Saffrax's sun)
Heartbeat,
HeartMoss,
HeartLily,
HeartSun,
} }
pub const VOICE_SYLLABLES: [Sfx; 6] = [Sfx::Voice1, Sfx::Voice2, Sfx::Voice3, Sfx::Voice4, Sfx::Voice5, Sfx::Voice6]; pub const VOICE_SYLLABLES: [Sfx; 6] = [Sfx::Voice1, Sfx::Voice2, Sfx::Voice3, Sfx::Voice4, Sfx::Voice5, Sfx::Voice6];
pub const SFX_ALL: [Sfx; 49] = [ pub const SFX_ALL: [Sfx; 53] = [
Sfx::Dig, Sfx::GoldDig, Sfx::DigDone, Sfx::Claim, Sfx::Deposit, Sfx::Build, Sfx::Sell, Sfx::MeleeHit, Sfx::Dig, Sfx::GoldDig, Sfx::DigDone, Sfx::Claim, Sfx::Deposit, Sfx::Build, Sfx::Sell, Sfx::MeleeHit,
Sfx::Shoot, Sfx::Death, Sfx::HeroDeath, Sfx::Lightning, Sfx::Heal, Sfx::Summon, Sfx::PortalArrive, Sfx::Shoot, Sfx::Death, Sfx::HeroDeath, Sfx::Lightning, Sfx::Heal, Sfx::Summon, Sfx::PortalArrive,
Sfx::WaveAlarm, Sfx::WaveStart, Sfx::CoreHit, Sfx::GateHit, Sfx::GateDefenders, Sfx::LevelUp, Sfx::WaveAlarm, Sfx::WaveStart, Sfx::CoreHit, Sfx::GateHit, Sfx::GateDefenders, Sfx::LevelUp,
@@ -82,6 +88,7 @@ pub const SFX_ALL: [Sfx; 49] = [
Sfx::Reinforce, Sfx::Cannon, Sfx::Magic, Sfx::Capture, Sfx::Convert, Sfx::Slap, Sfx::Punch, Sfx::Reinforce, Sfx::Cannon, Sfx::Magic, Sfx::Capture, Sfx::Convert, Sfx::Slap, Sfx::Punch,
Sfx::Voice1, Sfx::Voice2, Sfx::Voice3, Sfx::Voice4, Sfx::Voice5, Sfx::Voice6, Sfx::Voice1, Sfx::Voice2, Sfx::Voice3, Sfx::Voice4, Sfx::Voice5, Sfx::Voice6,
Sfx::Thunder, Sfx::Steal, Sfx::Snare, Sfx::Sermon, Sfx::Rain, Sfx::Wind, Sfx::Spores, Sfx::Thunder, Sfx::Steal, Sfx::Snare, Sfx::Sermon, Sfx::Rain, Sfx::Wind, Sfx::Spores,
Sfx::Heartbeat, Sfx::HeartMoss, Sfx::HeartLily, Sfx::HeartSun,
]; ];
impl Sfx { impl Sfx {
+11 -3
View File
@@ -492,11 +492,12 @@ fn apply_settings(s: &Settings, r3d: &mut R3d) {
struct MusicDirector { struct MusicDirector {
next: std::collections::HashMap<u8, usize>, // category -> next index within the category next: std::collections::HashMap<u8, usize>, // category -> next index within the category
current: Option<audio::Category>, current: Option<audio::Category>,
build_picks: usize, // build tracks picked so far (during a holiday every fourth is its piece)
} }
impl MusicDirector { impl MusicDirector {
fn new() -> MusicDirector { fn new() -> MusicDirector {
MusicDirector { next: std::collections::HashMap::new(), current: None } MusicDirector { next: std::collections::HashMap::new(), current: None, build_picks: 0 }
} }
fn pick(&mut self, c: audio::Category) -> usize { fn pick(&mut self, c: audio::Category) -> usize {
@@ -573,11 +574,17 @@ impl MusicDirector {
} }
let rotate = matches!(want, C::Build | C::Title | C::Surface) && self.current == Some(want) && audio::music_loops() >= 2; let rotate = matches!(want, C::Build | C::Title | C::Surface) && self.current == Some(want) && audio::music_loops() >= 2;
// after the chosen track was let go, the director picks again // after the chosen track was let go, the director picks again
let foreign = chosen.is_none() && audio::current_track().map_or(false, |t| crate::tracks::TRACKS.get(t).map_or(false, |x| x.1 != want)) && calm && self.current == Some(want); // (a holiday's piece belongs to the title and build music)
let holiday = holiday::current();
let fits = |c: C| c == want || (matches!(want, C::Title | C::Build) && audio::holiday_music(holiday) == Some(c));
let foreign = chosen.is_none() && audio::current_track().map_or(false, |t| crate::tracks::TRACKS.get(t).map_or(false, |x| !fits(x.1))) && calm && self.current == Some(want);
if self.current != Some(want) || rotate || foreign || audio::current_track().is_none() { if self.current != Some(want) || rotate || foreign || audio::current_track().is_none() {
let before = self.current; let before = self.current;
self.current = Some(want); self.current = Some(want);
let t = self.pick(want); let t = self.pick(audio::holiday_category(want, holiday, self.build_picks));
if want == C::Build {
self.build_picks += 1;
}
// between the dungeon and the surface the music changes slowly // between the dungeon and the surface the music changes slowly
let fade = if matches!(want, C::Victory | C::Defeat) { let fade = if matches!(want, C::Victory | C::Defeat) {
1.0 1.0
@@ -2037,6 +2044,7 @@ app.pending_briefing = briefing_of(&campaign, i);
sounds.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now); sounds.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now);
} }
sounds.ambience(&app.game, (app.cam.rx, app.cam.rz), now); sounds.ambience(&app.game, (app.cam.rx, app.cam.rz), now);
sounds.hearts(&app.game, vec2(app.cam.rx, app.cam.rz), right, now);
// a won mission goes into the campaign progress (with the carry-over); a co-op mission of an // a won mission goes into the campaign progress (with the carry-over); a co-op mission of an
// editor campaign keeps no progress (its index is not a built-in mission) // editor campaign keeps no progress (its index is not a built-in mission)
if app.coop_editor { if app.coop_editor {
+38
View File
@@ -189,6 +189,44 @@ fn def(s: Sfx, r: f32) -> Vec<f32> {
Sfx::Wind => l(&[seg(Noise, 300.0, 700.0, 0.6, 0.03, -3.0), seg(Noise, 700.0, 500.0, 1.4, 0.18, 0.2), seg(Noise, 500.0, 250.0, 0.6, 0.15, 3.0)], 82), Sfx::Wind => l(&[seg(Noise, 300.0, 700.0, 0.6, 0.03, -3.0), seg(Noise, 700.0, 500.0, 1.4, 0.18, 0.2), seg(Noise, 500.0, 250.0, 0.6, 0.15, 3.0)], 82),
// the spores of a grove: a faint chime // the spores of a grove: a faint chime
Sfx::Spores => l(&arp(Sine, &[2093.0, 2637.0, 3136.0, 2637.0], 0.09, 0.07), 83), Sfx::Spores => l(&arp(Sine, &[2093.0, 2637.0, 3136.0, 2637.0], 0.09, 0.07), 83),
// the soul core: a soft, low lub-dub (a quiet click on top so small speakers carry it)
Sfx::Heartbeat => mix(vec![
l(&[seg(Tri, 78.0, 50.0, 0.11, 0.55, 16.0), seg(Tri, 50.0, 50.0, 0.06, 0.0, 0.0), seg(Tri, 70.0, 46.0, 0.12, 0.38, 18.0)], 84),
l(&[seg(Noise, 600.0, 250.0, 0.04, 0.07, 45.0), seg(Noise, 250.0, 250.0, 0.13, 0.0, 0.0), seg(Noise, 500.0, 220.0, 0.04, 0.05, 45.0)], 85),
]),
// Mossgrim's tree: old wood creaking under the beat, the moss and twigs rustling
Sfx::HeartMoss => mix(vec![
l(&[seg(Tri, 90.0, 60.0, 0.12, 0.4, 14.0)], 86),
l(&[seg(Square(0.5), 40.0, 40.0, 0.08, 0.0, 0.0), vib(seg(Square(0.3), 36.0, 54.0, 0.45, 0.07, 3.0), 0.12), seg(Square(0.3), 54.0, 44.0, 0.2, 0.05, 6.0)], 87),
l(&[seg(Noise, 2600.0, 2600.0, 0.25, 0.0, 0.0), seg(Noise, 2600.0, 3400.0, 0.2, 0.05, -4.0), seg(Noise, 3400.0, 1800.0, 0.5, 0.06, 5.0)], 88),
]),
// Lady Nightwell's lily: a soft beat and a glassy chime (inharmonic partials ringing out)
Sfx::HeartLily => mix(vec![
l(&[seg(Sine, 110.0, 70.0, 0.12, 0.35, 14.0)], 89),
l(&[seg(Sine, 1568.0, 1568.0, 1.3, 0.09, 3.0)], 90),
l(&[seg(Sine, 2470.0, 2470.0, 1.0, 0.06, 4.5)], 91),
l(&[seg(Sine, 3729.0, 3729.0, 0.7, 0.04, 6.0)], 92),
l(&[seg(Sine, 2093.0, 2093.0, 0.18, 0.0, 0.0), seg(Sine, 2093.0, 2093.0, 0.9, 0.05, 4.0)], 93),
]),
// Saffrax's sun: a warm beat, then bubbling and a faint fizz like a boiling crucible
Sfx::HeartSun => mix(vec![
l(&[seg(Tri, 100.0, 64.0, 0.12, 0.42, 14.0)], 94),
l(&[
seg(Sine, 400.0, 400.0, 0.1, 0.0, 0.0),
seg(Sine, 380.0, 720.0, 0.035, 0.1, 25.0),
seg(Sine, 380.0, 380.0, 0.05, 0.0, 0.0),
seg(Sine, 520.0, 950.0, 0.03, 0.08, 25.0),
seg(Sine, 380.0, 380.0, 0.03, 0.0, 0.0),
seg(Sine, 300.0, 600.0, 0.04, 0.1, 25.0),
seg(Sine, 380.0, 380.0, 0.07, 0.0, 0.0),
seg(Sine, 610.0, 1100.0, 0.03, 0.07, 25.0),
seg(Sine, 380.0, 380.0, 0.04, 0.0, 0.0),
seg(Sine, 450.0, 820.0, 0.035, 0.08, 25.0),
seg(Sine, 380.0, 380.0, 0.06, 0.0, 0.0),
seg(Sine, 700.0, 1250.0, 0.03, 0.06, 25.0),
], 95),
l(&[seg(Noise, 9000.0, 9000.0, 0.1, 0.0, 0.0), seg(Noise, 9000.0, 11000.0, 0.25, 0.025, -3.0), seg(Noise, 11000.0, 8000.0, 0.35, 0.03, 6.0)], 96),
]),
} }
} }
+137 -1
View File
@@ -11,11 +11,90 @@ pub struct SoundBank {
next_rain: f64, next_rain: f64,
next_wind: f64, next_wind: f64,
next_spores: f64, next_spores: f64,
core_beat: i64, // the soul core's last heard beat
rival_beats: Vec<(u8, i64, f64)>, // per rival side: last beat seen, next time its sound may play
}
// how far (tiles) a heart is heard from the camera's target: full up to NEAR, silent beyond FAR
const HEART_NEAR: f32 = 3.0;
const HEART_FAR: f32 = 10.0;
// the soul core beats audibly once it has lost this much of its life
const CORE_HURT: f32 = 0.85;
// at least this many seconds between two sounds of one rival heart
const RIVAL_HEART_GAP: f64 = 5.0;
// a heart's beats per second by its life (0..1), as render3d's draw_heart shows them
fn heart_rate(hp: f32) -> f64 {
0.9 + (1.0 - hp.clamp(0.0, 1.0) as f64) * 1.6
} }
impl SoundBank { impl SoundBank {
pub fn new() -> SoundBank { pub fn new() -> SoundBank {
SoundBank { last: vec![-1.0; SFX_ALL.len()], next_rain: 0.0, next_wind: 0.0, next_spores: 0.0 } SoundBank { last: vec![-1.0; SFX_ALL.len()], next_rain: 0.0, next_wind: 0.0, next_spores: 0.0, core_beat: i64::MIN, rival_beats: Vec::new() }
}
// the hearts near the camera, in time with their drawn beat (`now`: the clock the renderer
// animates with): the soul core thumps softly once it is hurt (louder the worse), the other
// masters' hearts give their own sound every few beats. Returns what was played.
pub fn hearts(&mut self, g: &crate::game::Game, listener: Vec2, right: Vec2, now: f64) -> Vec<(Sfx, f32)> {
let mut out = Vec::new();
if g.state != 0 {
return out;
}
let place = |x: f32, z: f32| {
let d = vec2(x, z) - listener;
let f = 1.0 - ((d.length() - HEART_NEAR) / (HEART_FAR - HEART_NEAR)).clamp(0.0, 1.0);
(f, (d.dot(right) / HEART_FAR).clamp(-1.0, 1.0) * 0.6)
};
// the soul core
let hp = (g.core_hp / crate::game::CORE_HP).clamp(0.0, 1.0);
let beat = (now * heart_rate(hp)).floor() as i64;
if beat != self.core_beat {
let first = self.core_beat == i64::MIN;
self.core_beat = beat;
let (f, pan) = place(g.map.core.0 as f32 + 1.5, g.map.core.1 as f32 + 1.5);
if !first && hp < CORE_HURT && g.core_hp > 0.0 && f > 0.05 {
let vol = f * (0.25 + 0.45 * (CORE_HURT - hp) / CORE_HURT);
audio::play_sfx(Sfx::Heartbeat.index(), vol, pan);
out.push((Sfx::Heartbeat, vol));
}
}
// the other masters' hearts (drawn with their clock shifted by side * 0.37)
for rv in g.rivals.iter().filter(|r| r.alive) {
let sfx = match rv.side {
2 => Sfx::HeartMoss,
3 => Sfx::HeartLily,
4 => Sfx::HeartSun,
_ => continue,
};
let hp = (rv.hp / crate::rival::RIVAL_CORE_HP).clamp(0.0, 1.0);
let beat = ((now + rv.side as f64 * 0.37) * heart_rate(hp)).floor() as i64;
let k = match self.rival_beats.iter().position(|b| b.0 == rv.side) {
Some(k) => k,
None => {
// the first sound only after a moment (no burst when the camera arrives)
self.rival_beats.push((rv.side, beat, now + 1.5));
continue;
}
};
let (_, last, next) = self.rival_beats[k];
if beat == last {
continue;
}
self.rival_beats[k].1 = beat;
let (cx, cz) = rv.centre();
let shown = g.map.shown(crate::map::idx(rv.core.0 + 1, rv.core.1 + 1));
let (f, pan) = place(cx, cz);
if now < next || !shown || f <= 0.05 {
continue;
}
// every few seconds, a little irregular so it never sounds like a metronome
self.rival_beats[k].2 = now + RIVAL_HEART_GAP + ((beat as f64) * 1.7).sin().abs() * 2.5;
let vol = 0.5 * f;
audio::play_sfx(sfx.index(), vol, pan);
out.push((sfx, vol));
}
out
} }
// background sounds where the camera looks: rain and wind over the land above ground (the // background sounds where the camera looks: rain and wind over the land above ground (the
@@ -87,3 +166,60 @@ impl SoundBank {
audio::play_sfx(i, vol, pan); audio::play_sfx(i, vol, pan);
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::game::{Game, CORE_HP};
// plays `secs` seconds at 60 frames per second with the camera at `cam`
fn run(b: &mut SoundBank, g: &Game, cam: Vec2, from: f64, secs: f64) -> Vec<(Sfx, f32)> {
let mut out = Vec::new();
let mut t = from;
while t < from + secs {
out.extend(b.hearts(g, cam, vec2(1.0, 0.0), t));
t += 1.0 / 60.0;
}
out
}
#[test]
fn the_hurt_soul_core_beats_and_the_rival_hearts_sound_now_and_then() {
let mut g = Game::new(7, 64, 64);
let core = vec2(g.map.core.0 as f32 + 1.5, g.map.core.1 as f32 + 1.5);
let mut b = SoundBank::new();
// a whole heart is silent
assert!(run(&mut b, &g, core, 0.0, 6.0).is_empty());
// a hurt one thumps with its drawn beat, louder the worse, and only near the camera
g.core_hp = CORE_HP * 0.5;
let half = run(&mut b, &g, core, 10.0, 10.0);
let rate = heart_rate(0.5);
assert!(half.iter().all(|s| s.0 == Sfx::Heartbeat));
assert!((half.len() as f64 - rate * 10.0).abs() <= 1.5, "{} beats", half.len());
g.core_hp = CORE_HP * 0.1;
let worse = run(&mut b, &g, core, 30.0, 4.0);
assert!(worse[0].1 > half[0].1 && worse.iter().all(|s| s.1 <= 0.75));
assert!(run(&mut b, &g, core + vec2(30.0, 0.0), 40.0, 5.0).is_empty());
g.core_hp = CORE_HP;
// the rivals' hearts: each its own sound, not more often than every RIVAL_HEART_GAP seconds
g.add_rival(2, 50, 50, 1);
g.add_rival(3, 50, 14, 1);
g.add_rival(4, 14, 50, 1);
g.map.explored.clear(); // no fog: every heart in view
for (side, sfx) in [(2, Sfx::HeartMoss), (3, Sfx::HeartLily), (4, Sfx::HeartSun)] {
let rv = g.rivals.iter().find(|r| r.side == side).unwrap();
let (x, z) = rv.centre();
let mut b = SoundBank::new();
let played = run(&mut b, &g, vec2(x, z), 100.0, 30.0);
assert!(played.len() >= 3 && played.len() <= 30 / RIVAL_HEART_GAP as usize + 1, "{:?}: {}", sfx, played.len());
assert!(played.iter().all(|s| s.0 == sfx && s.1 <= 0.5));
// far away or hidden in the fog: silence
assert!(run(&mut b, &g, vec2(x + 40.0, z), 200.0, 20.0).iter().all(|s| s.0 != sfx));
}
g.init_fog();
let rv = &g.rivals[0];
let mut b = SoundBank::new();
assert!(!g.map.shown(crate::map::idx(rv.core.0 + 1, rv.core.1 + 1)));
assert!(run(&mut b, &g, vec2(rv.centre().0, rv.centre().1), 300.0, 20.0).is_empty());
}
}
+4 -1
View File
@@ -3,7 +3,7 @@
use crate::audio::Category; use crate::audio::Category;
pub const TRACKS: [(&str, Category, &[u8]); 47] = [ pub const TRACKS: [(&str, Category, &[u8]); 50] = [
("title", Category::Title, include_bytes!("../assets/music/title.xm")), ("title", Category::Title, include_bytes!("../assets/music/title.xm")),
("title2", Category::Title, include_bytes!("../assets/music/title2.xm")), ("title2", Category::Title, include_bytes!("../assets/music/title2.xm")),
("build1", Category::Build, include_bytes!("../assets/music/build1.xm")), ("build1", Category::Build, include_bytes!("../assets/music/build1.xm")),
@@ -51,4 +51,7 @@ pub const TRACKS: [(&str, Category, &[u8]); 47] = [
("victory", Category::Victory, include_bytes!("../assets/music/victory.xm")), ("victory", Category::Victory, include_bytes!("../assets/music/victory.xm")),
("victory2", Category::Victory, include_bytes!("../assets/music/victory2.xm")), ("victory2", Category::Victory, include_bytes!("../assets/music/victory2.xm")),
("defeat", Category::Defeat, include_bytes!("../assets/music/defeat.xm")), ("defeat", Category::Defeat, include_bytes!("../assets/music/defeat.xm")),
("holiday_christmas", Category::Christmas, include_bytes!("../assets/music/holiday_christmas.xm")),
("holiday_easter", Category::Easter, include_bytes!("../assets/music/holiday_easter.xm")),
("holiday_halloween", Category::Halloween, include_bytes!("../assets/music/holiday_halloween.xm")),
]; ];
+2 -1
View File
@@ -1130,7 +1130,8 @@ mod tests {
fn render_all_songs() { fn render_all_songs() {
for (k, (name, _, data)) in crate::tracks::TRACKS.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!(m.channels == 8 || (m.channels == 10 && name.starts_with("surface")), "{} {}", name, m.channels); // birds and wind above ground and in the spring
assert!(m.channels == 8 || (m.channels == 10 && (name.starts_with("surface") || *name == "holiday_easter")), "{} {}", name, m.channels);
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;
+237 -2
View File
@@ -75,6 +75,10 @@ CHORDS = {
"C": ["C", "E", "G"], "C": ["C", "E", "G"],
"Am": ["A", "C", "E"], "Am": ["A", "C", "E"],
"C7": ["C", "E", "Bb"], "C7": ["C", "E", "Bb"],
"E": ["E", "G#", "B"],
"Em": ["E", "G", "B"],
"Bm": ["B", "D", "F#"],
"Eb": ["Eb", "G", "Bb"],
} }
@@ -304,6 +308,15 @@ def wind():
return out[: n - k] return out[: n - k]
def harpsichord_cycle(amp=0.4):
# bright and nasal: a narrow pulse with a saw on top (many strong harmonics, like a plucked quill)
raw = [(1.0 if i < CYCLE * 0.18 else -0.22) + 0.6 * (2.0 * i / CYCLE - 1.0) for i in range(CYCLE)]
mean = sum(raw) / CYCLE
raw = [v - mean for v in raw]
m = max(abs(v) for v in raw)
return [amp * v / m for v in raw]
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),
@@ -336,12 +349,14 @@ def build_instruments():
Instrument("Bird", Sample("bird", bird(), relnote=12)), Instrument("Bird", Sample("bird", bird(), relnote=12)),
Instrument("Wind", Sample("wind", wind(), loop=(0, len(wind())), relnote=12), Instrument("Wind", Sample("wind", wind(), loop=(0, len(wind())), relnote=12),
env=[(0, 0), (60, 48), (120, 40)], sustain=2, fadeout=200), env=[(0, 0), (60, 48), (120, 40)], sustain=2, fadeout=200),
Instrument("Harpsichord", Sample("harpsichord", harpsichord_cycle(), loop=(0, CYCLE), relnote=CYCLE_REL, finetune=CYCLE_FT),
env=[(0, 64), (2, 46), (14, 22), (40, 8), (70, 0)], fadeout=2400),
] ]
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) ORGAN, BELL, CHOIR, SUB, TOM, CRASH, HARP, TIMP = range(9, 17)
FLUTE, BIRD, WIND = range(17, 20) FLUTE, BIRD, WIND, HARPSI = range(17, 21)
# channel layout (the last two only for songs above ground: birds and wind) # channel layout (the last two only for songs above ground: birds and wind)
CH_LEAD, CH_COUNTER, CH_PAD1, CH_PAD2, CH_BASS, CH_ARP, CH_DRUM, CH_HAT, CH_BIRD, CH_WIND = range(10) CH_LEAD, CH_COUNTER, CH_PAD1, CH_PAD2, CH_BASS, CH_ARP, CH_DRUM, CH_HAT, CH_BIRD, CH_WIND = range(10)
CHANNELS = 10 CHANNELS = 10
@@ -1283,6 +1298,222 @@ def song_victory2():
return s return s
# ------------------------------------------------------------------ holiday pieces
# During a holiday the title music (and now and then the build music) plays the holiday's piece
# (see audio::holiday_category). Each one quotes the leitmotif in its own colours.
def sleigh(pat, vol=8):
"""Sleigh bells: the hihat pitched up, every eighth, louder on the beats."""
for r in range(0, pat.rows, 2):
pat.set(r, CH_HAT, note_num("C6"), HAT, vol + (6 if r % 4 == 0 else 0))
def organ_pads(pat, chords, vol=18, octave=4):
r = 0
for name, rows in chords:
notes = chord_notes(name, octave)
pat.set(r, CH_PAD1, notes[1], ORGAN, vol)
pat.set(r, CH_PAD2, notes[2], ORGAN, vol)
r += rows
def choir_pads(pat, chords, vol=18, octave=4):
r = 0
for name, rows in chords:
notes = chord_notes(name, octave)
pat.set(r, CH_PAD1, notes[1], CHOIR, vol)
pat.set(r, CH_PAD2, notes[2], CHOIR, vol)
r += rows
def song_christmas():
"""Frostbound Carol: a dark carol in D minor on bells and choir, sleigh bells, a turn to
D major in the middle that falls back into the minor; the leitmotif rung on the bells."""
s = Song("Frostbound Carol", 100)
mel_a = "A4:4 A4:4 Bb4:4 A4:4 | G4:4 F4:4 E4:8 | F4:4 G4:4 A4:4 D5:4 | C#5:12 -:4"
mel_b = "D5:4 C5:4 Bb4:4 A4:4 | Bb4:4 A4:4 G4:8 | F4:4 E4:4 D4:4 F4:4 | E4:6 C#4:2 D4:8"
mel_c = "F#4:4 A4:4 D5:8 | B4:4 A4:4 G4:8 | F#4:4 G4:4 A4:4 B4:4 | A4:12 -:4"
ch_a = bars(("Dm", 1), ("Gm", .5), ("A", .5), ("Dm", .5), ("F", .5), ("A", 1))
ch_b = bars(("Bb", 1), ("Gm", 1), ("Dm", 1), ("A", .5), ("Dm", .5))
ch_c = bars(("D", 1), ("G", 1), ("D", .5), ("G", .5), ("A", 1))
ch_i = bars(("Dm", 1), ("Bb", 1), ("Gm", 1), ("A", 1))
# intro: the bells toll over the choir, timpani like distant steps in the snow
p0 = Pattern()
p0.line(CH_LEAD, "D5:16 | D5:16 | D5:16 | C#5:16", BELL, 36)
choir_pads(p0, ch_i, vol=20)
p0.bass(ch_i, "R...............", inst=SUB, vol=42)
for k in (0, 32):
p0.set(k, CH_DRUM, note_num("C4"), TIMP, 44)
p1 = Pattern()
p1.line(CH_LEAD, transpose(mel_a, 12), BELL, 44)
choir_pads(p1, ch_a, vol=18)
p1.bass(ch_a, "R.......F.......", vol=40)
sleigh(p1, 6)
p2 = Pattern()
p2.line(CH_LEAD, transpose(mel_b, 12), BELL, 44)
choir_pads(p2, ch_b, vol=18)
p2.bass(ch_b, "R.......F.......", vol=40)
sleigh(p2, 6)
# second verse: the organ sings, the harp ripples, a soft kick
p3 = Pattern()
p3.line(CH_LEAD, mel_a, ORGAN, 38)
p3.line(CH_COUNTER, "D4:16 | Bb3:8 C#4:8 | D4:8 C4:8 | C#4:16", CHOIR, 22)
p3.pads(ch_a, vol=14)
p3.bass(ch_a, "R...R.F.R...F.O.", vol=40)
p3.arp(ch_a, step=2, vol=11, inst=HARP)
p3.drums(kick="x.......x.......", kv=44)
sleigh(p3, 8)
p4 = Pattern()
p4.line(CH_LEAD, mel_b, ORGAN, 38)
p4.line(CH_COUNTER, "F4:16 | D4:16 | A3:16 | G3:8 F3:8", CHOIR, 22)
p4.pads(ch_b, vol=14)
p4.bass(ch_b, "R...R.F.R...F.O.", vol=40)
p4.arp(ch_b, step=2, vol=11, inst=HARP)
p4.drums(kick="x.......x.......", kv=44)
sleigh(p4, 8)
# the turn to major: candle light for a moment
p5 = Pattern()
p5.line(CH_LEAD, transpose(mel_c, 12), BELL, 46)
p5.line(CH_COUNTER, "D4:16 | D4:16 | D4:8 B3:8 | C#4:16", ORGAN, 26)
choir_pads(p5, ch_c, vol=18)
p5.bass(ch_c, "R.......F.......", vol=42)
p5.arp(ch_c, step=4, vol=10, inst=HARP)
sleigh(p5, 8)
p5.set(48, CH_DRUM, note_num("C4"), TIMP, 40)
# the leitmotif on the bells, echoed by the harp
p6 = Pattern()
p6.line(CH_LEAD, transpose(MOTIF_A, 12), BELL, 42)
p6.echo(CH_LEAD, CH_COUNTER, 4, 18, HARP)
choir_pads(p6, CHORDS_A, vol=18)
p6.bass(CHORDS_A, "R.......F.......", inst=SUB, vol=42)
sleigh(p6, 6)
p7 = Pattern()
p7.line(CH_LEAD, transpose(MOTIF_B, 12), BELL, 42)
p7.echo(CH_LEAD, CH_COUNTER, 4, 18, HARP)
choir_pads(p7, CHORDS_B, vol=18)
p7.bass(CHORDS_B, "R.......F.......", inst=SUB, vol=42)
sleigh(p7, 6)
p7.set(48, CH_DRUM, note_num("C4"), TIMP, 42)
p = [s.add(x) for x in (p0, p1, p2, p3, p4, p5, p6, p7)]
s.order = [p[0], p[1], p[2], p[3], p[4], p[5], p[2], p[6], p[7], p[1], p[2]]
s.restart = 1
return s
def song_easter():
"""Eggs in the Moss: light and playful in G major, flute and harp, birdsong, a bouncing
beat; the leitmotif turned into a spring tune."""
s = Song("Eggs in the Moss", 120)
rng = random.Random(61)
mel_a = "G4:2 B4:2 D5:4 B4:2 D5:2 G5:4 | E5:2 D5:2 C5:2 B4:2 A4:8 | F#4:2 A4:2 D5:4 C5:2 B4:2 A4:4 | B4:4 G4:4 G4:8"
mel_b = "E5:4 D5:2 C5:2 B4:4 A4:4 | D5:4 C5:2 B4:2 A4:8 | G4:2 A4:2 B4:2 C5:2 D5:4 E5:4 | D5:8 -:8"
ch_a = bars(("G", 1), ("C", .5), ("D", .5), ("D", 1), ("G", 1))
ch_b = bars(("Em", 1), ("D", 1), ("C", .5), ("G", .5), ("D", 1))
mot_a = realize(MOTIF_DEG_A, NOTE_BASE["G"], "major", 4)
mot_b = realize(MOTIF_DEG_B, NOTE_BASE["G"], "major", 4)
ch_ma = bars(("G", 1), ("C", .5), ("D", .5), ("G", 1), ("Bm", 1))
ch_mb = bars(("Em", 1), ("G", 1), ("D", 1), ("G", 1))
bouncy = dict(kick="x.......x.......", snare="............x...", hat="..x...x...x...x.", hv=12, sv=28)
def birds(pat):
for bar in range(pat.rows // BAR):
if rng.random() < 0.6:
row = bar * BAR + rng.choice([2, 5, 7, 10, 13])
pat.set(row, CH_BIRD, note_num(rng.choice(["C4", "D4", "E4", "G4", "A4", "C5"])), BIRD, 12 + rng.randrange(10))
def verse(mel, ch, lead, lvol, echo=None):
p = Pattern()
p.line(CH_LEAD, mel, lead, lvol)
if echo:
p.echo(CH_LEAD, CH_COUNTER, 2, 16, echo)
p.pads(ch, vol=13)
p.bass(ch, "R.-.R.F.-.R.O.F.", vol=40)
p.arp(ch, step=2, vol=11, inst=HARP, order=(0, 1, 2, 1))
p.drums(**bouncy)
birds(p)
return p
p0 = Pattern()
choir_pads(p0, ch_a, vol=12)
p0.bass(ch_a, "R.......F.......", inst=SUB, vol=38)
p0.arp(ch_a, step=2, vol=11, inst=HARP, order=(0, 1, 2, 1))
p0.drums(hat="..x...x...x...x.", hv=10)
birds(p0)
p0.set(0, CH_WIND, note_num("C4"), WIND, 10)
p1 = verse(mel_a, ch_a, FLUTE, 40)
p2 = verse(mel_b, ch_b, FLUTE, 40)
p3 = verse(transpose(mel_a, 12), ch_a, PLUCK, 34, HARP)
p4 = verse(transpose(mel_b, 12), ch_b, PLUCK, 34, HARP)
p5 = verse(mot_a, ch_ma, FLUTE, 40, HARP)
p6 = verse(mot_b, ch_mb, FLUTE, 40, HARP)
p = [s.add(x) for x in (p0, p1, p2, p3, p4, p5, p6)]
s.order = [p[0], p[1], p[2], p[3], p[4], p[5], p[6], p[3], p[4], p[1], p[2]]
s.restart = 1
return s
def song_halloween():
"""Crypt of Lanterns: a spooky dance in D harmonic minor, harpsichord and organ, midnight
bells, toms like knocking on coffin lids; the leitmotif on the harpsichord."""
s = Song("Crypt of Lanterns", 120)
mel_a = "D4:4 F4:4 G#4:4 A4:4 | Bb4:6 A4:2 G#4:8 | A4:4 C5:4 Bb4:4 G4:4 | A4:16"
mel_b = "D5:2 Eb5:2 D5:4 A4:4 F4:4 | G4:2 F4:2 E4:4 C#4:8 | D4:2 E4:2 F4:2 G4:2 A4:2 Bb4:2 C#5:4 | D5:16"
ch_a = bars(("Dm", 1), ("E", 1), ("Gm", 1), ("A", 1))
ch_b = bars(("Dm", 1), ("A", 1), ("Gm", .5), ("A", .5), ("Dm", 1))
def knock(pat):
for bar in range(pat.rows // BAR):
pat.set(bar * BAR + 14, CH_DRUM, note_num("A3"), TOM, 36)
# midnight: twelve... well, four bells over an organ drone
p0 = Pattern()
p0.line(CH_LEAD, "D4:16 | D4:16 | D4:16 | C#4:16", BELL, 36)
p0.line(CH_PAD1, "D3:64", ORGAN, 20)
p0.line(CH_PAD2, "A3:32 | G#3:32", ORGAN, 18)
p0.bass(ch_a, "R...............", inst=SUB, vol=44)
p0.arp(ch_a, step=4, vol=9, octave=4, inst=HARPSI)
def verse(mel, ch, lead, lvol, heavy=False):
p = Pattern()
p.line(CH_LEAD, mel, lead, lvol)
organ_pads(p, ch, vol=16)
p.bass(ch, "R.......R...F..." if not heavy else "R.R.....R.R.F...", inst=SUB, vol=44)
p.arp(ch, step=1, vol=10, octave=4, order=(0, 1, 2, 1), inst=HARPSI)
if heavy:
p.drums(kick="x.....x.x.......", snare="....x.......x...", hat="..x...x...x...x.", kv=52, sv=34, hv=10)
else:
p.drums(kick="x.......x.......", hat="..x...x...x...x.", kv=48, hv=9)
knock(p)
return p
p1 = verse(transpose(mel_a, 12), ch_a, HARPSI, 40)
p2 = verse(transpose(mel_b, 12), ch_b, HARPSI, 40)
p3 = verse(mel_a, ch_a, ORGAN, 36, heavy=True)
p3.line(CH_COUNTER, "A3:16 | G#3:16 | Bb3:16 | C#4:16", HARPSI, 24)
p4 = verse(mel_b, ch_b, ORGAN, 36, heavy=True)
p4.line(CH_COUNTER, "F3:16 | E3:16 | D3:8 E3:8 | F3:16", HARPSI, 24)
# the leitmotif on the harpsichord with its own ghostly echo
p5 = verse(transpose(MOTIF_A, 12), CHORDS_A, HARPSI, 40)
p5.echo(CH_LEAD, CH_COUNTER, 3, 16, HARPSI)
p6 = verse(transpose(MOTIF_B, 12), CHORDS_B, HARPSI, 40)
p6.echo(CH_LEAD, CH_COUNTER, 3, 16, HARPSI)
# creeping down: a chromatic organ line over a pedal, the bells again
p7 = Pattern()
p7.line(CH_LEAD, "A4:8 G#4:8 | G4:8 F#4:8 | F4:8 E4:8 | Eb4:8 D4:8", ORGAN, 34)
p7.line(CH_PAD1, "D3:64", ORGAN, 16)
p7.line(CH_PAD2, "A3:64", ORGAN, 14)
p7.bass(ch_a, "R...............", inst=SUB, vol=44)
for k in (0, 32):
p7.set(k, CH_COUNTER, note_num("D5"), BELL, 30)
p7.set(k, CH_DRUM, note_num("C4"), TIMP, 46)
p7.arp(ch_a, step=2, vol=9, octave=4, inst=HARPSI, order=(0, 2))
p = [s.add(x) for x in (p0, p1, p2, p3, p4, p5, p6, p7)]
s.order = [p[0], p[1], p[2], p[3], p[4], p[7], p[5], p[6], p[1], p[2], p[3], p[4]]
s.restart = 1
return s
def generated(spec_): def generated(spec_):
return lambda: Gen(spec_).build() return lambda: Gen(spec_).build()
@@ -1317,6 +1548,9 @@ SONGS["mood_temple"] = ("temple", generated(MOOD_SPECS["temple"]))
SONGS["victory"] = ("victory", song_victory) SONGS["victory"] = ("victory", song_victory)
SONGS["victory2"] = ("victory", song_victory2) SONGS["victory2"] = ("victory", song_victory2)
SONGS["defeat"] = ("defeat", song_defeat) SONGS["defeat"] = ("defeat", song_defeat)
SONGS["holiday_christmas"] = ("christmas", song_christmas)
SONGS["holiday_easter"] = ("easter", song_easter)
SONGS["holiday_halloween"] = ("halloween", song_halloween)
def write_rust_table(names): def write_rust_table(names):
@@ -1331,7 +1565,8 @@ def write_rust_table(names):
] ]
cat = {"title": "Title", "build": "Build", "battle": "Battle", "assault": "Assault", "boss_paladin": "BossPaladin", cat = {"title": "Title", "build": "Build", "battle": "Battle", "assault": "Assault", "boss_paladin": "BossPaladin",
"boss_warden": "BossWarden", "torture": "Torture", "temple": "Temple", "victory": "Victory", "defeat": "Defeat", "boss_warden": "BossWarden", "torture": "Torture", "temple": "Temple", "victory": "Victory", "defeat": "Defeat",
"surface": "Surface", "surface_battle": "SurfaceBattle"} "surface": "Surface", "surface_battle": "SurfaceBattle", "christmas": "Christmas", "easter": "Easter",
"halloween": "Halloween"}
for n in names: for n in names:
lines.append(' ("%s", Category::%s, include_bytes!("../assets/music/%s.xm")),' % (n, cat[SONGS[n][0]], n)) lines.append(' ("%s", Category::%s, include_bytes!("../assets/music/%s.xm")),' % (n, cat[SONGS[n][0]], n))
lines.append("];") lines.append("];")