#!/usr/bin/env python3 """DungeonCraft soundtrack composer. Writes FastTracker 2 / MilkyTracker compatible XM modules to assets/music/. All instruments are synthesised chip waveforms (pulse, square, triangle, detuned saw pad) and synthesised drums. The title leitmotif appears in every piece: Dm | Gm A | Dm | F D A F | G F E C#| D F A D | C A Bb | Dm | A | Dm Bb A G | F E D F | E C# A C# | D Usage: python3 tools/compose_music.py (writes assets/music/*.xm) The generated files can be opened and edited in MilkyTracker. """ import math import os import random import struct OUT_DIR = os.path.join(os.path.dirname(__file__), "..", "assets", "music") ROWS_PER_BEAT = 4 BAR = 16 # rows per 4/4 bar # ------------------------------------------------------------------ notes NOTE_BASE = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11} KEY_OFF = 97 def note_num(s): """'C#4', 'Bb3', 'D-4' -> XM note number (C-0 = 1).""" n = NOTE_BASE[s[0].upper()] i = 1 if s[i] == "#": n += 1 i += 1 elif s[i] == "b": n -= 1 i += 1 elif s[i] == "-": i += 1 octave = int(s[i:]) return octave * 12 + n + 1 def transpose(seq, semis): """Transpose a melody string by semitones.""" out = [] names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] for tok in seq.split(): if tok == "|": out.append(tok) continue n, l = tok.split(":") if n in ("-", "="): out.append(tok) continue v = note_num(n) - 1 + semis out.append("%s%d:%s" % (names[v % 12], v // 12, l)) return " ".join(out) # chord tones (pitch classes as note names, root first) CHORDS = { "Dm": ["D", "F", "A"], "D": ["D", "F#", "A"], "Gm": ["G", "Bb", "D"], "G": ["G", "B", "D"], "A": ["A", "C#", "E"], "F": ["F", "A", "C"], "Bb": ["Bb", "D", "F"], "C": ["C", "E", "G"], "Am": ["A", "C", "E"], "C7": ["C", "E", "Bb"], } def chord_notes(name, octave): """Chord tones from the given octave upwards.""" out = [] last = -1 for pc in CHORDS[name]: v = note_num(pc + str(octave)) while v <= last: v += 12 out.append(v) last = v return out # ------------------------------------------------------------------ samples & instruments C4_RATE = 8363.0 class Sample: def __init__(self, name, data, loop=None, ping=False, volume=64, finetune=0, relnote=0, pan=128): self.name = name self.data = data # floats -1..1 self.loop = loop # (start, length) or None self.ping = ping self.volume = volume self.finetune = finetune self.relnote = relnote self.pan = pan class Instrument: def __init__(self, name, sample, env=None, sustain=None, loop=None, fadeout=0, vib=(0, 0, 0, 0)): self.name = name self.sample = sample self.env = env # list of (tick, 0..64) self.sustain = sustain self.loop = loop # (start point, end point) self.fadeout = fadeout self.vib = vib # (type, sweep, depth, rate) CYCLE = 128 # samples per single cycle -> C2 at 8363 Hz; relnote 24 maps note C-4 to C4 CYCLE_REL = 24 CYCLE_FT = 2 # 8363/128 = 65.34 Hz vs. C2 = 65.41 Hz -> +2/128 semitone def pulse(duty, amp=0.42): hi = int(CYCLE * duty) # remove DC so the waveform is centred low = -amp * duty / (1.0 - duty) return [amp if i < hi else low for i in range(CYCLE)] def triangle(amp=0.55): return [amp * (4.0 * abs(i / CYCLE - 0.5) - 1.0) for i in range(CYCLE)] def saw_pad(amp=0.32): # two saws with 128- and 127-sample cycles: loops seamlessly after 128*127 samples, natural chorus n = 128 * 127 return [amp * (((i % 128) / 64.0 - 1.0) + ((i % 127) / 63.5 - 1.0)) * 0.5 for i in range(n)] DRUM_RATE = C4_RATE * 2 # drums are rendered at 16726 Hz and played at C-4 with relnote +12 def kick(): n = int(DRUM_RATE * 0.32) out, ph = [], 0.0 for i in range(n): t = i / DRUM_RATE f = 45 + 110 * math.exp(-t * 28) ph += 2 * math.pi * f / DRUM_RATE out.append(0.95 * math.sin(ph) * math.exp(-t * 9)) return out def snare(): rnd = random.Random(7) n = int(DRUM_RATE * 0.22) out, ph = [], 0.0 for i in range(n): t = i / DRUM_RATE ph += 2 * math.pi * 185 / DRUM_RATE tone = 0.45 * math.sin(ph) * math.exp(-t * 30) noise = 0.6 * (rnd.random() * 2 - 1) * math.exp(-t * 16) out.append(max(-1, min(1, tone + noise))) return out def hihat(): rnd = random.Random(3) n = int(DRUM_RATE * 0.07) out, prev = [], 0.0 for i in range(n): t = i / DRUM_RATE x = rnd.random() * 2 - 1 out.append(0.6 * (x - prev) * 0.5 * math.exp(-t * 60)) prev = x return out # ---- additional timbres (used by the generated pieces) def organ(amp=0.42): harm = [(1, 1.0), (2, 0.6), (3, 0.4), (4, 0.3), (6, 0.2), (8, 0.15)] raw = [sum(a * math.sin(2 * math.pi * h * i / CYCLE) for h, a in harm) for i in range(CYCLE)] m = max(abs(v) for v in raw) return [amp * v / m for v in raw] def sine_cycle(amp=0.6): return [amp * math.sin(2 * math.pi * i / CYCLE) for i in range(CYCLE)] def harp_cycle(amp=0.5): raw = [math.sin(2 * math.pi * i / CYCLE) + 0.35 * math.sin(4 * math.pi * i / CYCLE) + 0.15 * math.sin(6 * math.pi * i / CYCLE) for i in range(CYCLE)] m = max(abs(v) for v in raw) return [amp * v / m for v in raw] def choir_pad(amp=0.36): # vowel-like spectrum ("ah": strong 2nd/3rd and 7th/8th harmonics), two detuned copies -> chorus harm = [(1, 0.5), (2, 1.0), (3, 0.8), (4, 0.3), (5, 0.2), (7, 0.45), (8, 0.4), (9, 0.15)] def wave(ph): return sum(a * math.sin(2 * math.pi * h * ph) for h, a in harm) n = 128 * 127 raw = [wave((i % 128) / 128.0) + wave((i % 127) / 127.0) for i in range(n)] m = max(abs(v) for v in raw) return [amp * v / m for v in raw] def bell(): # inharmonic partials, rendered at C4 for 16726 Hz playback (relnote +12) f0 = 261.63 n = int(DRUM_RATE * 1.3) parts = [(1.0, 1.0, 2.5), (2.0, 0.5, 3.5), (2.76, 0.4, 5.0), (5.4, 0.25, 8.0), (8.9, 0.12, 12.0)] out = [] for i in range(n): t = i / DRUM_RATE out.append(0.5 * sum(a * math.exp(-t * d) * math.sin(2 * math.pi * f0 * r * t) for r, a, d in parts)) return out def tom(): n = int(DRUM_RATE * 0.35) out, ph = [], 0.0 for i in range(n): t = i / DRUM_RATE f = 95 + 90 * math.exp(-t * 18) ph += 2 * math.pi * f / DRUM_RATE out.append(0.85 * math.sin(ph) * math.exp(-t * 9)) return out def crash(): rnd = random.Random(11) n = int(DRUM_RATE * 1.0) out, prev = [], 0.0 for i in range(n): t = i / DRUM_RATE x = rnd.random() * 2 - 1 out.append(0.55 * (x - 0.7 * prev) * math.exp(-t * 3.5)) prev = x return out def timpani(): rnd = random.Random(5) n = int(DRUM_RATE * 0.9) out, ph = [], 0.0 for i in range(n): t = i / DRUM_RATE f = 98 - 8 * math.exp(-t * 6) ph += 2 * math.pi * f / DRUM_RATE hit = 0.3 * (rnd.random() * 2 - 1) * math.exp(-t * 40) out.append(0.8 * math.sin(ph) * math.exp(-t * 3.2) + hit) return out def build_instruments(): return [ Instrument("Lead pulse 25%", Sample("pulse25", pulse(0.25), loop=(0, CYCLE), relnote=CYCLE_REL, finetune=CYCLE_FT), env=[(0, 0), (2, 64), (12, 46), (40, 40)], sustain=3, fadeout=1400, vib=(0, 24, 3, 10)), Instrument("Square", Sample("square", pulse(0.5, 0.36), loop=(0, CYCLE), relnote=CYCLE_REL, finetune=CYCLE_FT), env=[(0, 0), (2, 56), (16, 40), (50, 34)], sustain=3, fadeout=1600, vib=(0, 30, 2, 9)), Instrument("Saw pad", Sample("saw pad", saw_pad(), loop=(0, 128 * 127), relnote=CYCLE_REL, finetune=CYCLE_FT), env=[(0, 0), (30, 52), (60, 48)], sustain=2, fadeout=500), Instrument("Triangle bass", Sample("triangle", triangle(), loop=(0, CYCLE), relnote=CYCLE_REL, finetune=CYCLE_FT), env=[(0, 64), (6, 56), (40, 44)], sustain=2, fadeout=2800), Instrument("Pluck 12%", Sample("pulse12", pulse(0.125, 0.38), loop=(0, CYCLE), relnote=CYCLE_REL, finetune=CYCLE_FT), env=[(0, 64), (5, 32), (24, 8), (48, 0)], fadeout=3000), Instrument("Kick", Sample("kick", kick(), relnote=12)), Instrument("Snare", Sample("snare", snare(), relnote=12)), Instrument("Hihat", Sample("hihat", hihat(), relnote=12)), Instrument("Organ", Sample("organ", organ(), loop=(0, CYCLE), relnote=CYCLE_REL, finetune=CYCLE_FT), env=[(0, 0), (2, 60), (40, 56)], sustain=2, fadeout=900), Instrument("Bell", Sample("bell", bell(), relnote=12)), Instrument("Choir", Sample("choir", choir_pad(), loop=(0, 128 * 127), relnote=CYCLE_REL, finetune=CYCLE_FT), env=[(0, 0), (40, 50), (80, 46)], sustain=2, fadeout=450), Instrument("Sub bass", Sample("sine", sine_cycle(), loop=(0, CYCLE), relnote=CYCLE_REL, finetune=CYCLE_FT), env=[(0, 64), (10, 54), (60, 48)], sustain=2, fadeout=2400), Instrument("Tom", Sample("tom", tom(), relnote=12)), Instrument("Crash", Sample("crash", crash(), relnote=12)), Instrument("Harp", Sample("harp", harp_cycle(), loop=(0, CYCLE), relnote=CYCLE_REL, finetune=CYCLE_FT), env=[(0, 64), (4, 44), (30, 12), (70, 0)], fadeout=2000), Instrument("Timpani", Sample("timpani", timpani(), relnote=12)), ] LEAD, SQUARE, PAD, BASS, PLUCK, KICK, SNARE, HAT = range(1, 9) ORGAN, BELL, CHOIR, SUB, TOM, CRASH, HARP, TIMP = range(9, 17) # channel layout CH_LEAD, CH_COUNTER, CH_PAD1, CH_PAD2, CH_BASS, CH_ARP, CH_DRUM, CH_HAT = range(8) CHANNELS = 8 # ------------------------------------------------------------------ patterns class Pattern: def __init__(self, bars=4): self.rows = bars * BAR self.cells = [[None] * CHANNELS for _ in range(self.rows)] def set(self, row, ch, note=0, inst=0, vol=None, fx=0, param=0): if 0 <= row < self.rows: v = 0 if vol is None else 0x10 + max(0, min(64, vol)) self.cells[row][ch] = (note, inst, v, fx, param) def line(self, ch, seq, inst, vol=None, start=0, release=True): """Melody from 'D4:8 A4:4 F4:4 | -:4 ...' (lengths in rows, '-' rest, '=' key off).""" r = start for tok in seq.split(): if tok == "|": continue n, l = tok.split(":") l = int(l) if n == "-": if release: self.set(r, ch, KEY_OFF) elif n == "=": self.set(r, ch, KEY_OFF) else: self.set(r, ch, note_num(n), inst, vol) r += l return r def bass(self, chords, pattern, inst=BASS, vol=48, octave=2, start=0): """chords: list of (chord, rows); pattern: per-bar string of 16 chars: 'R' root, 'F' fifth, 'O' octave, 'T' third, '.' hold, '-' release.""" r = start for name, rows in chords: notes = chord_notes(name, octave) root, third, fifth = notes[0], notes[1], notes[2] for k in range(rows): c = pattern[(r + k) % len(pattern)] n = {"R": root, "F": fifth, "O": root + 12, "T": third}.get(c) if n: self.set(r + k, CH_BASS, n, inst, vol) elif c == "-": self.set(r + k, CH_BASS, KEY_OFF) r += rows def pads(self, chords, vol=22, octave=4, start=0): r = start for name, rows in chords: notes = chord_notes(name, octave) self.set(r, CH_PAD1, notes[1], PAD, vol) self.set(r, CH_PAD2, notes[2], PAD, vol) r += rows def arp(self, chords, step=2, vol=18, octave=5, start=0, order=(0, 1, 2, 1), ch=CH_ARP, inst=PLUCK): r = start for name, rows in chords: notes = chord_notes(name, octave) k = 0 for rr in range(0, rows, step): self.set(r + rr, ch, notes[order[k % len(order)]], inst, vol) k += 1 r += rows def drums(self, kick="", snare="", hat="", kv=60, sv=44, hv=18): """16-char strings per bar, 'x' = hit (hat: 'x' normal, 'o' accent).""" for bar in range(self.rows // BAR): for i in range(BAR): r = bar * BAR + i if kick and kick[i] == "x": self.set(r, CH_DRUM, note_num("C4"), KICK, kv) elif snare and snare[i] == "x": self.set(r, CH_DRUM, note_num("C4"), SNARE, sv) if hat and hat[i] in "xo": self.set(r, CH_HAT, note_num("C4"), HAT, hv + (10 if hat[i] == "o" else 0)) def echo(self, src, dst, delay=3, vol=22, inst=SQUARE): """Classic tracker echo: copy notes of one channel delayed and quieter.""" for r in range(self.rows): c = self.cells[r][src] if c and 0 < c[0] < KEY_OFF: self.set(r + delay, dst, c[0], inst, vol) def bars(*items): """('Dm', 1), ('Gm', .5) ... -> list of (chord, rows)""" return [(c, int(b * BAR)) for c, b in items] # ------------------------------------------------------------------ the leitmotif MOTIF_A = "D4:8 A4:4 F4:4 | G4:6 F4:2 E4:4 C#4:4 | D4:4 F4:4 A4:4 D5:4 | C5:8 A4:8" MOTIF_B = "Bb4:8 A4:4 G4:4 | F4:6 E4:2 D4:4 F4:4 | E4:4 C#4:4 A3:4 C#4:4 | D4:16" CHORDS_A = bars(("Dm", 1), ("Gm", .5), ("A", .5), ("Dm", 1), ("F", 1)) CHORDS_B = bars(("Bb", 1), ("Dm", 1), ("A", 1), ("Dm", 1)) def double(seq): """Augmentation: every length doubled.""" return " ".join(t if t == "|" else "%s:%d" % (t.split(":")[0], int(t.split(":")[1]) * 2) for t in seq.split()) def half(seq): """Diminution: every length halved.""" return " ".join(t if t == "|" else "%s:%d" % (t.split(":")[0], max(1, int(t.split(":")[1]) // 2)) for t in seq.split()) # ------------------------------------------------------------------ songs class Song: def __init__(self, name, bpm, speed=6): self.name = name self.bpm = bpm self.speed = speed self.patterns = [] self.order = [] self.restart = 0 def add(self, pat): self.patterns.append(pat) return len(self.patterns) - 1 def song_title(): s = Song("DungeonCraft - Title", 108) intro = Pattern() ch = bars(("Dm", 1), ("Bb", 1), ("Gm", 1), ("A", 1)) intro.pads(ch) intro.bass(ch, "R.......R...F...") intro.arp(ch, vol=14) intro.drums(hat="x...x...x...x...", hv=12) a = Pattern() a.line(CH_LEAD, MOTIF_A, LEAD, 46) a.pads(CHORDS_A) a.bass(CHORDS_A, "R...R.F.R...F.O.") a.arp(CHORDS_A, vol=14) a.drums(kick="x.......x.......", snare="....x.......x...", hat="x.x.x.x.x.x.x.x.", sv=36, hv=12) b = Pattern() b.line(CH_LEAD, MOTIF_B, LEAD, 46) b.pads(CHORDS_B) b.bass(CHORDS_B, "R...R.F.R...F.O.") b.arp(CHORDS_B, vol=14) b.drums(kick="x.......x.......", snare="....x.......x...", hat="x.x.x.x.x.x.x.x.", sv=36, hv=12) # second statement: lead an octave higher, counter melody a third below + echo a2 = Pattern() a2.line(CH_LEAD, transpose(MOTIF_A, 12), LEAD, 44) a2.line(CH_COUNTER, "F4:8 F4:4 D4:4 | Bb3:6 A3:2 C#4:4 A3:4 | F3:4 A3:4 D4:4 F4:4 | A4:8 F4:8", SQUARE, 30) a2.pads(CHORDS_A) a2.bass(CHORDS_A, "R.R.R.F.R.R.F.O.") a2.arp(CHORDS_A, vol=16, octave=5, order=(0, 1, 2, 3 % 3)) a2.drums(kick="x.....x.x.......", snare="....x.......x..x", hat="x.x.x.x.x.x.x.x.", sv=40, hv=14) b2 = Pattern() b2.line(CH_LEAD, transpose(MOTIF_B, 12), LEAD, 44) b2.line(CH_COUNTER, "D4:8 F4:4 E4:4 | D4:6 C#4:2 A3:4 D4:4 | C#4:4 A3:4 E3:4 A3:4 | F3:16", SQUARE, 30) b2.pads(CHORDS_B) b2.bass(CHORDS_B, "R.R.R.F.R.R.F.O.") b2.arp(CHORDS_B, vol=16) b2.drums(kick="x.....x.x.......", snare="....x.......x..x", hat="x.x.x.x.x.x.x.x.", sv=40, hv=14) bridge = Pattern() bch = bars(("Bb", 1), ("C", 1), ("Dm", 1), ("A", 1)) bridge.line(CH_LEAD, "Bb4:8 F5:4 D5:4 | C5:8 G4:4 E4:4 | D5:4 C5:4 A4:4 F4:4 | E4:8 C#4:8", LEAD, 44) bridge.echo(CH_LEAD, CH_COUNTER, 3, 22) bridge.pads(bch) bridge.bass(bch, "R...R...R...F...") bridge.arp(bch, vol=14) bridge.drums(kick="x...............", snare="............x...", hat="x...x...x...x...", hv=12) p = [s.add(x) for x in (intro, a, b, a2, b2, bridge)] s.order = [p[0], p[1], p[2], p[3], p[4], p[5], p[1], p[2]] s.restart = 1 return s def song_build1(): """Deep Halls: slow, the motif augmented over its own harmony.""" s = Song("DungeonCraft - Deep Halls", 84) ch1 = bars(("Dm", 1), ("Dm", 1), ("Gm", 1), ("A", 1)) ch2 = bars(("Dm", 1), ("Dm", 1), ("F", 1), ("F", 1)) ch3 = bars(("Bb", 1), ("Gm", 1), ("A", 1), ("A", 1)) p0 = Pattern() p0.pads(ch1, vol=20) p0.bass(ch1, "R...............", vol=40) p1 = Pattern() p1.pads(ch2, vol=20) p1.bass(ch2, "R.......F.......", vol=40) p1.arp(ch2, step=4, vol=12, octave=5) aug = double(MOTIF_A).split("|") p2 = Pattern() p2.line(CH_LEAD, "|".join(aug[0:2]), SQUARE, 30) p2.echo(CH_LEAD, CH_COUNTER, 4, 16) p2.pads(ch1, vol=20) p2.bass(ch1, "R.......F.......", vol=40) p2.arp(ch1, step=4, vol=12) p3 = Pattern() p3.line(CH_LEAD, "|".join(aug[2:4]), SQUARE, 30) p3.echo(CH_LEAD, CH_COUNTER, 4, 16) p3.pads(ch2, vol=20) p3.bass(ch2, "R.......F.......", vol=40) p3.arp(ch2, step=4, vol=12) p4 = Pattern() p4.pads(ch3, vol=20) p4.bass(ch3, "R.......R...F...", vol=40) p4.arp(ch3, step=2, vol=11, order=(0, 2, 1, 2)) p4.line(CH_LEAD, "-:48 D5:4 C#5:4 A4:8", PLUCK, 26) p = [s.add(x) for x in (p0, p1, p2, p3, p4)] s.order = [p[0], p[1], p[2], p[3], p[4], p[1], p[2], p[3], p[4]] s.restart = 1 return s def song_build2(): """Imp Work: bouncy D dorian, the motif's opening as a call.""" s = Song("DungeonCraft - Imp Work", 100) ch = bars(("Dm", 1), ("G", 1), ("Dm", 1), ("C", 1)) groove = "R.-.R.F.-.R.O.F." p0 = Pattern() p0.bass(ch, groove, vol=44) p0.drums(kick="x.......x.......", hat="..x...x...x...x.", hv=14) p0.arp(ch, step=4, vol=12, order=(0, 2)) p1 = Pattern() p1.line(CH_LEAD, "D5:4 A4:4 F4:4 A4:4 | G4:6 F4:2 E4:4 D4:4 | F4:4 A4:4 C5:4 A4:4 | G4:8 E4:8", SQUARE, 36) p1.pads(ch, vol=16) p1.bass(ch, groove, vol=44) p1.drums(kick="x.......x.......", snare="............x...", hat="..x...x...x...x.", sv=30, hv=14) p2 = Pattern() p2.line(CH_LEAD, "D5:4 A4:4 F4:4 A4:4 | B4:6 A4:2 G4:4 B4:4 | A4:4 F4:4 D4:4 F4:4 | E4:8 C4:4 E4:4", SQUARE, 36) p2.echo(CH_LEAD, CH_COUNTER, 3, 18) p2.pads(ch, vol=16) p2.bass(ch, groove, vol=44) p2.drums(kick="x.......x.......", snare="............x...", hat="..x...x...x...x.", sv=30, hv=14) p3 = Pattern() # call: the motif's first bar, quick, answered by the pluck p3.line(CH_LEAD, "D5:2 A5:2 F5:4 -:8 | -:16 | D5:2 A5:2 F5:4 -:4 G5:2 E5:2 | D5:16", PLUCK, 34) p3.line(CH_COUNTER, "-:8 A4:2 F4:2 D4:4 | -:16 | -:16 | -:16", PLUCK, 22) p3.pads(ch, vol=16) p3.bass(ch, groove, vol=44) p3.arp(ch, step=2, vol=12) p3.drums(kick="x.......x.......", snare="............x...", hat="..x...x...x...x.", sv=30, hv=14) 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_build3(): """Torchlight: the motif in F major, warm, with echo.""" s = Song("DungeonCraft - Torchlight", 92) motif_f_a = "F4:8 C5:4 A4:4 | Bb4:6 A4:2 G4:4 E4:4 | F4:4 A4:4 C5:4 F5:4 | E5:8 C5:8" motif_f_b = "D5:8 C5:4 Bb4:4 | A4:6 G4:2 F4:4 A4:4 | G4:4 E4:4 C4:4 E4:4 | F4:16" cha = bars(("F", 1), ("C7", 1), ("F", 1), ("C", 1)) chb = bars(("Bb", 1), ("F", 1), ("C", 1), ("F", 1)) p0 = Pattern() p0.pads(cha, vol=18) p0.bass(cha, "R.......F.......", vol=40) p0.arp(cha, step=2, vol=12, order=(0, 1, 2, 1)) p1 = Pattern() p1.line(CH_LEAD, motif_f_a, LEAD, 36) p1.echo(CH_LEAD, CH_COUNTER, 3, 18) p1.pads(cha, vol=18) p1.bass(cha, "R.......F.......", vol=40) p1.arp(cha, step=2, vol=11) p1.drums(hat="x...x...x...x...", hv=10) p2 = Pattern() p2.line(CH_LEAD, motif_f_b, LEAD, 36) p2.echo(CH_LEAD, CH_COUNTER, 3, 18) p2.pads(chb, vol=18) p2.bass(chb, "R.......F.......", vol=40) p2.arp(chb, step=2, vol=11) p2.drums(hat="x...x...x...x...", hv=10) p3 = Pattern() chc = bars(("Dm", 1), ("Bb", 1), ("C", 1), ("C", 1)) p3.pads(chc, vol=18) p3.bass(chc, "R...R...F...R...", vol=40) p3.arp(chc, step=1, vol=10, order=(0, 1, 2, 3 % 3, 2, 1)) p3.line(CH_LEAD, "A4:16 | Bb4:16 | C5:8 E5:8 | G5:16", SQUARE, 24) 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_battle1(): """Heroes at the Gate: driving minor, full motif plus riff.""" s = Song("DungeonCraft - Heroes at the Gate", 140) drive = "RRRRRRRRRRRRRRRR" eighths = "R.R.R.R.R.R.R.R." rock = dict(kick="x.....x.x.......", snare="....x.......x...", hat="x.x.x.x.x.x.x.x.") intro = Pattern() ich = bars(("Dm", 1), ("Dm", 1), ("Bb", 1), ("A", 1)) intro.bass(ich, eighths, vol=48) intro.drums(kick="x...x...x...x...", hat="x.x.x.x.x.x.x.x.", hv=16) intro.line(CH_LEAD, "-:48 A4:4 C#5:4 E5:4 A5:4", SQUARE, 34) a = Pattern() a.line(CH_LEAD, transpose(MOTIF_A, 12), LEAD, 46) a.pads(CHORDS_A, vol=18) a.bass(CHORDS_A, eighths, vol=48) a.arp(CHORDS_A, step=1, vol=10, order=(0, 1, 2)) a.drums(**rock, sv=46, hv=16) b = Pattern() b.line(CH_LEAD, transpose(MOTIF_B, 12), LEAD, 46) b.pads(CHORDS_B, vol=18) b.bass(CHORDS_B, eighths, vol=48) b.arp(CHORDS_B, step=1, vol=10, order=(0, 1, 2)) b.drums(**rock, sv=46, hv=16) riff_ch = bars(("Dm", 1), ("C", 1), ("Bb", 1), ("A", 1)) riff = "D5:2 -:2 D5:2 A5:2 F5:4 D5:4 | C5:2 -:2 C5:2 G5:2 E5:4 C5:4 | Bb4:2 -:2 Bb4:2 F5:2 D5:4 Bb4:4 | A4:4 C#5:4 E5:4 A5:4" r1 = Pattern() r1.line(CH_LEAD, riff, SQUARE, 40) r1.pads(riff_ch, vol=16) r1.bass(riff_ch, drive, vol=44) r1.drums(kick="x.x...x.x.x...x.", snare="....x.......x...", hat="x.x.x.x.x.x.x.x.", sv=46, hv=16) r2 = Pattern() r2.line(CH_LEAD, riff, SQUARE, 40) r2.line(CH_COUNTER, transpose(riff, -12), PLUCK, 28) r2.pads(riff_ch, vol=16) r2.bass(riff_ch, drive, vol=44) r2.drums(kick="x.x...x.x.x...x.", snare="....x.......x.xx", hat="xxxxxxxxxxxxxxxx", sv=46, hv=12) p = [s.add(x) for x in (intro, a, b, r1, r2)] s.order = [p[0], p[1], p[2], p[3], p[4], p[1], p[2], p[3], p[4]] s.restart = 1 return s def song_battle2(): """Storm the Gate: fast, the motif in diminution over pounding drums.""" s = Song("DungeonCraft - Storm the Gate", 156) sixteen = "RRRRRRRRRRRRRRRR" ch1 = bars(("Bb", 1), ("C", 1), ("Dm", 1), ("Dm", 1)) ch2 = bars(("Dm", .5), ("Gm", .25), ("A", .25), ("Dm", .5), ("F", .5), ("Bb", .5), ("Dm", .5), ("A", 1)) p0 = Pattern() p0.bass(ch1, sixteen, vol=44) p0.pads(ch1, vol=18) p0.drums(kick="x...x...x...x...", snare="....x.......x...", hat="xxxxxxxxxxxxxxxx", sv=44, hv=10) p0.line(CH_LEAD, "-:32 D5:4 E5:4 F5:4 G5:4 | A5:16", SQUARE, 34) dim = half(MOTIF_A) + " | " + half(MOTIF_B) p1 = Pattern() p1.line(CH_LEAD, transpose(dim, 12), LEAD, 46) p1.line(CH_COUNTER, dim, SQUARE, 26) p1.pads(ch2, vol=18) p1.bass(ch2, sixteen, vol=44) p1.arp(ch2, step=1, vol=10, order=(0, 1, 2, 1)) p1.drums(kick="x.x.x...x.x.x...", snare="....x.......x...", hat="xxxxxxxxxxxxxxxx", sv=48, hv=10) p2 = Pattern() p2.line(CH_LEAD, "Bb5:4 A5:4 F5:4 D5:4 | C6:4 Bb5:4 G5:4 E5:4 | D6:4 C6:4 A5:4 F5:4 | D6:16", LEAD, 44) p2.echo(CH_LEAD, CH_COUNTER, 2, 20) p2.pads(ch1, vol=18) p2.bass(ch1, sixteen, vol=44) p2.arp(ch1, step=1, vol=10, order=(0, 1, 2)) p2.drums(kick="x.x.x.x.x.x.x.x.", snare="....x.......x.xx", hat="xxxxxxxxxxxxxxxx", sv=48, 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_victory(): """Victory jingle: the motif in D major; ends in a silent loop.""" s = Song("DungeonCraft - Victory", 120) p0 = Pattern() ch = bars(("D", 1), ("G", .5), ("A", .5), ("D", 2)) p0.line(CH_LEAD, "D5:4 A5:4 F#5:4 A5:4 | G5:6 F#5:2 E5:4 C#5:4 | D5:32", LEAD, 50) p0.line(CH_COUNTER, "F#4:8 D4:8 | B4:8 A4:8 | F#4:32", SQUARE, 30) p0.pads(ch, vol=24) p0.bass(ch, "R...R...R...R...", vol=48) p0.drums(kick="x.......x.......", snare="xxxx............", sv=30) p0.set(63, CH_LEAD, KEY_OFF) 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 song_defeat(): """Defeat jingle: the motif falls apart, slow and low; ends in a silent loop.""" s = Song("DungeonCraft - Defeat", 70) p0 = Pattern() ch = bars(("Dm", 1), ("Bb", 1), ("A", 1), ("Dm", 1)) p0.line(CH_LEAD, "A4:8 F4:8 | D4:8 Bb3:8 | A3:8 C#4:4 E4:4 | D4:16", LEAD, 44) p0.pads(ch, vol=22, octave=3) p0.bass(ch, "R...............", vol=44, octave=1) p0.drums(kick="x...............", kv=44) 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 # ------------------------------------------------------------------ XM writer def write_xm(path, song, instruments): """Writes an XM file. Only instruments used by the song are stored (renumbered), samples are 8-bit like on the Amiga to keep the files small.""" used = sorted({c[1] for pat in song.patterns for row in pat.cells for c in row if c and c[1]}) remap = {old: new + 1 for new, old in enumerate(used)} out = bytearray() out += b"Extended Module: " out += song.name.encode("ascii")[:20].ljust(20, b" ") out += b"\x1a" out += b"DungeonCraft compose".ljust(20, b" ") out += struct.pack(" sample 0 env = ins.env or [] pts = bytearray() for (x, y) in env[:12]: pts += struct.pack(" 'X4:8 ...' melody string in the given key""" base = 12 * octave + root out = [] for item in motif: deg, l = item[0], item[1] lt = len(item) > 2 and mode in ("minor", "harmonic", "dorian", "phrygian") out.append("%s:%d" % (name_of(base + degree_semis(deg, mode, lt)), l)) return " ".join(out) def m_aug(m): return [(i[0], i[1] * 2) + tuple(i[2:]) for i in m] def m_dim(m): return [(i[0], max(1, i[1] // 2)) + tuple(i[2:]) for i in m] def m_inv(m): # mirror the melody around the tonic (keeps the rhythm) return [(-i[0], i[1]) for i in m] def m_shift(m, d): return [(i[0] + d, i[1]) + tuple(i[2:]) for i in m] def m_take(m, rows): """first `rows` rows of a motif""" out, r = [], 0 for i in m: if r >= rows: break l = min(i[1], rows - r) out.append((i[0], l) + tuple(i[2:])) r += l return out def rows_of(m): return sum(i[1] for i in m) def chord_tones(deg, mode, root, octave, dominant=False): """triad on a scale degree -> list of midi notes""" base = 12 * octave + root notes = [base + degree_semis(deg + k, mode) for k in (0, 2, 4)] if dominant and mode in ("minor", "dorian", "phrygian", "harmonic") and deg % 7 == 4: notes[1] = base + degree_semis(deg, mode) + 4 # major third on V return notes class Gen: """Builds a song from a spec dict (see SPECS).""" def __init__(self, spec): self.sp = spec self.rng = random.Random(spec["seed"]) self.root = NOTE_BASE[spec["key"][0]] + (1 if spec["key"].endswith("#") else -1 if spec["key"].endswith("b") and len(spec["key"]) > 1 else 0) self.mode = spec["mode"] # ---- accompaniment for a list of chords (degree, rows) def accompany(self, pat, chords): sp = self.sp r = 0 for deg, rows in chords: notes = chord_tones(deg, self.mode, self.root, 4, dominant=True) bass = chord_tones(deg, self.mode, self.root, 2, dominant=True) # chord_tones() counts from C-0 = 0, XM notes from 1 if sp.get("pad"): pat.set(r, CH_PAD1, notes[1] + 1, sp["pad"], sp.get("pad_vol", 20)) pat.set(r, CH_PAD2, notes[2] + 1, sp["pad"], sp.get("pad_vol", 20)) bp = sp.get("bass_pat", "R.......F.......") for k in range(rows): c = bp[(r + k) % len(bp)] n = {"R": bass[0], "F": bass[2], "O": bass[0] + 12, "T": bass[1]}.get(c) if n: pat.set(r + k, CH_BASS, n + 1, sp.get("bass", BASS), sp.get("bass_vol", 44)) elif c == "-": pat.set(r + k, CH_BASS, KEY_OFF) if sp.get("arp"): step = sp.get("arp_step", 2) order = sp.get("arp_order", (0, 1, 2, 1)) an = chord_tones(deg, self.mode, self.root, sp.get("arp_oct", 5), dominant=True) for i, rr in enumerate(range(0, rows, step)): pat.set(r + rr, CH_ARP, an[order[i % len(order)]] + 1, sp["arp"], sp.get("arp_vol", 13)) r += rows def drums(self, pat, style): styles = { "none": {}, "soft": dict(hat="x.......x.......", hv=10), "calm": dict(kick="x...............", hat="x...x...x...x...", hv=10, kv=44), "bouncy": dict(kick="x.......x.......", snare="............x...", hat="..x...x...x...x.", hv=13, sv=30), "rock": dict(kick="x.....x.x.......", snare="....x.......x...", hat="x.x.x.x.x.x.x.x.", hv=15, sv=44), "driving": dict(kick="x...x...x...x...", snare="....x.......x...", hat="xxxxxxxxxxxxxxxx", hv=10, sv=46), "march": dict(kick="x.......x.......", snare="....x.x.....x.xx", hat="x...x...x...x...", hv=12, sv=38), "tribal": dict(kick="x..x..x...x..x..", hat="x.x.x.x.x.x.x.x.", hv=11, kv=52), "boss": dict(kick="x.x.x.x.x.x.x.x.", snare="....x.......x...", hat="xxxxxxxxxxxxxxxx", hv=10, sv=50), } d = styles[style] pat.drums(kick=d.get("kick", ""), snare=d.get("snare", ""), hat=d.get("hat", ""), kv=d.get("kv", 60), sv=d.get("sv", 44), hv=d.get("hv", 18)) if style == "tribal": for bar in range(pat.rows // BAR): for i in (4, 12, 14): pat.set(bar * BAR + i, CH_DRUM, note_num("A3" if i == 14 else "D4"), TOM, 46) if style in ("boss", "driving", "march"): pat.set(0, CH_HAT, note_num("C4"), CRASH, 34) # ---- generated melody over chords (strong beats on chord tones) def melody(self, chords, inst, vol, octave, style): rhythms = { "calm": [[8, 8], [12, 4], [4, 4, 8], [16], [6, 2, 8]], "flow": [[4, 4, 4, 4], [6, 2, 4, 4], [8, 4, 4], [4, 4, 8]], "bouncy": [[2, 2, 4, 2, 2, 4], [4, 2, 2, 4, 4], [2, 2, 2, 2, 8], [4, 4, 2, 2, 4]], "driving": [[2, 2, 2, 2, 2, 2, 4], [4, 2, 2, 4, 4], [2, 2, 4, 2, 2, 4], [2] * 8], }[style] scale = MODES[self.mode] base = 12 * octave + self.root pool = [base + 12 * o + iv for o in (-1, 0, 1) for iv in scale] cur = base + scale[0] out = [] for deg, rows in chords: ct = [n % 12 for n in chord_tones(deg, self.mode, self.root, 4, dominant=True)] r = 0 while r < rows: pat_r = self.rng.choice(rhythms) for k, l in enumerate(pat_r): if r >= rows: break l = min(l, rows - r) strong = k == 0 or (r % 8 == 0) if strong: cands = [n for n in pool if n % 12 in ct and abs(n - cur) <= 7] else: cands = [n for n in pool if 0 < abs(n - cur) <= 3] if not cands: cands = [n for n in pool if n % 12 in ct] # keep the line within about an octave around the centre cands.sort(key=lambda n: abs(n - cur) + 0.4 * abs(n - (base + 7)) + self.rng.random() * 2) cur = cands[0] out.append("%s:%d" % (name_of(cur), l)) r += l # occasional breath at the end of a phrase return " ".join(out) def harm_rows(self, harm, rows_per_bar=BAR): """[[0],[3,4],...] -> [(degree, rows)]""" out = [] for bar in harm: for d in bar: out.append((d, rows_per_bar // len(bar))) return out def build(self): sp = self.sp song = Song(sp["title"], sp["bpm"]) lead, lvol, loct = sp.get("lead", LEAD), sp.get("lead_vol", 40), sp.get("lead_oct", 4) treat = sp.get("motif", "normal") prog = sp["prog"] # list of 4 degrees (one bar each) for free sections # motif treatment mA, mB = MOTIF_DEG_A, MOTIF_DEG_B hA, hB = MOTIF_HARM_A, MOTIF_HARM_B if treat == "inv": mA, mB = m_inv(mA), m_inv(mB) elif treat == "shift": mA, mB = m_shift(mA, 2), m_shift(mB, 2) sections = [] # (melody string, chords) per 4 bars if treat in ("normal", "inv", "shift"): sections.append((realize(mA, self.root, self.mode, loct), self.harm_rows(hA))) sections.append((realize(mB, self.root, self.mode, loct), self.harm_rows(hB))) elif treat == "aug": a = m_aug(m_take(mA, 32)) b = m_aug(m_take(mA[6:], 32)) if False else m_aug(m_take(mA, 64)[len(m_take(mA, 32)):]) sections.append((realize(a, self.root, self.mode, loct), self.harm_rows([[0], [0], [3], [4]]))) sections.append((realize(b, self.root, self.mode, loct), self.harm_rows([[0], [0], [2], [2]]))) elif treat == "dim": d = m_dim(mA) + m_dim(mB) sections.append((realize(d, self.root, self.mode, loct + 1), self.harm_rows([[0, 3], [0, 2], [5, 0], [4, 0]]))) elif treat == "frag": # the motif's first bar as a rising sequence frag = m_take(mA, 16) seq = frag + m_shift(frag, 1) + m_shift(frag, 2) + m_take(m_shift(mA, 0)[3:], 16) sections.append((realize(seq, self.root, self.mode, loct), self.harm_rows([[0], [1], [2], [4]]))) free = self.harm_rows([[d] for d in prog]) mel_style = sp.get("mel_style", "flow") sections.append((self.melody(free, lead, lvol, loct, mel_style), free)) if sp.get("long"): free2 = self.harm_rows([[d] for d in sp.get("prog2", prog[::-1])]) sections.append((self.melody(free2, lead, lvol, loct, mel_style), free2)) # intro: accompaniment only pats = [] intro = Pattern() self.accompany(intro, free) self.drums(intro, sp.get("intro_drums", "soft" if sp.get("drums", "none") != "none" else "none")) pats.append(intro) for k, (mel, chords) in enumerate(sections): p = Pattern() p.line(CH_LEAD, mel, lead, lvol) if sp.get("echo"): p.echo(CH_LEAD, CH_COUNTER, sp.get("echo_delay", 3), max(12, lvol // 2), sp.get("echo_inst", SQUARE)) elif sp.get("counter") and k % 2 == 1: # counter melody: the bass line of the chords an octave up, long notes cm = " ".join("%s:%d" % (name_of(chord_tones(d, self.mode, self.root, loct - 1, True)[1]), rows) for d, rows in chords) p.line(CH_COUNTER, cm, sp["counter"], sp.get("counter_vol", 24)) self.accompany(p, chords) self.drums(p, sp.get("drums", "none")) pats.append(p) idx = [song.add(x) for x in pats] song.order = idx + idx[1:] song.restart = 1 return song def spec(title, seed, key, mode, bpm, prog, **kw): d = dict(title=title, seed=seed, key=key, mode=mode, bpm=bpm, prog=prog) d.update(kw) return d # fifteen build tracks in total (three hand-written + twelve generated), calm and varied BUILD_SPECS = [ spec("Dripping Stone", 4, "A", "minor", 78, [0, 5, 3, 4], motif="aug", lead=HARP, lead_vol=34, pad=CHOIR, bass=SUB, arp=PLUCK, arp_step=4, drums="soft", echo=True, echo_inst=HARP), spec("Fungus Gardens", 5, "E", "dorian", 96, [0, 3, 0, 6], motif="normal", lead=PLUCK, lead_vol=34, pad=PAD, bass_pat="R.-.R.F.-.R.O.F.", arp=HARP, drums="bouncy", mel_style="bouncy"), spec("Lantern Path", 6, "G", "minor", 88, [0, 2, 6, 4], motif="frag", lead=SQUARE, lead_vol=32, pad=ORGAN, pad_vol=16, arp=PLUCK, drums="calm", echo=True), spec("Deep Forge", 7, "C", "phrygian", 92, [0, 1, 0, 6], motif="normal", lead=LEAD, lead_vol=36, pad=PAD, bass=SUB, bass_pat="R.R.....R.R.....", drums="tribal", counter=ORGAN), spec("Silent Vault", 8, "F", "minor", 72, [0, 5, 2, 4], motif="aug", lead=BELL, lead_vol=40, pad=CHOIR, bass=SUB, bass_pat="R...............", drums="none"), spec("Crystal Hollow", 9, "B", "minor", 84, [0, 5, 6, 4], motif="inv", lead=BELL, lead_vol=36, pad=PAD, arp=HARP, arp_step=2, arp_order=(0, 1, 2, 3 % 3, 2, 1), drums="soft", echo=True, echo_inst=HARP), spec("Whispering Halls", 10, "D", "dorian", 80, [0, 3, 6, 0], motif="shift", lead=HARP, lead_vol=34, pad=CHOIR, bass=BASS, drums="calm", long=True, prog2=[3, 0, 4, 0]), spec("Old Bones", 11, "A", "phrygian", 86, [0, 1, 6, 0], motif="frag", lead=ORGAN, lead_vol=30, pad=PAD, bass=SUB, drums="tribal", arp=PLUCK, arp_step=4), spec("Ember Chamber", 12, "F#", "minor", 94, [0, 3, 4, 0], motif="normal", lead=LEAD, lead_vol=34, pad=ORGAN, pad_vol=15, arp=PLUCK, drums="calm", counter=SQUARE), spec("Underground River", 13, "E", "minor", 100, [0, 6, 5, 6], motif="dim", lead=HARP, lead_vol=34, pad=CHOIR, bass_pat="R.R.F.R.R.R.F.O.", arp=PLUCK, arp_step=1, arp_vol=10, drums="soft"), spec("Moss and Shadow", 14, "G", "dorian", 90, [0, 3, 0, 4], motif="aug", lead=SQUARE, lead_vol=30, pad=PAD, arp=HARP, drums="bouncy", mel_style="bouncy", echo=True), spec("The Long Dig", 15, "D", "minor", 98, [0, 2, 3, 4], motif="normal", lead=PLUCK, lead_vol=36, pad=PAD, bass_pat="R...R.F.R...F.O.", arp=HARP, drums="bouncy", long=True, prog2=[5, 3, 0, 4]), ] # seven wave battles (one hand-written + six generated) BATTLE_SPECS = [ spec("Steel in the Dark", 20, "E", "minor", 144, [0, 5, 6, 4], motif="normal", lead=LEAD, lead_vol=44, pad=PAD, pad_vol=16, bass_pat="R.R.R.R.R.R.R.R.", arp=PLUCK, arp_step=1, arp_vol=10, drums="rock", lead_oct=5, mel_style="driving", counter=SQUARE), spec("Breach!", 21, "C", "harmonic", 152, [0, 5, 3, 4], motif="dim", lead=SQUARE, lead_vol=42, pad=ORGAN, pad_vol=14, bass_pat="RRRRRRRRRRRRRRRR", drums="driving", mel_style="driving"), spec("Shield Wall", 22, "G", "minor", 138, [0, 3, 6, 4], motif="normal", lead=LEAD, lead_vol=44, pad=CHOIR, bass_pat="R.R.R.F.R.R.F.O.", drums="march", lead_oct=5, mel_style="driving", echo=True), spec("Torches and Pitchforks", 23, "A", "dorian", 148, [0, 3, 0, 6], motif="frag", lead=SQUARE, lead_vol=40, pad=PAD, pad_vol=15, bass_pat="R.R.R.R.R.R.R.R.", arp=PLUCK, arp_step=1, drums="rock", lead_oct=5, mel_style="driving"), spec("Blood on the Stones", 24, "D", "phrygian", 142, [0, 1, 0, 6], motif="inv", lead=LEAD, lead_vol=44, pad=ORGAN, pad_vol=14, bass_pat="RRRRRRRRRRRRRRRR", drums="tribal", lead_oct=5, mel_style="driving", counter=SQUARE), spec("No Retreat", 25, "B", "minor", 156, [0, 5, 3, 4], motif="normal", lead=LEAD, lead_vol=44, pad=PAD, pad_vol=15, bass_pat="R.R.R.R.R.R.R.R.", arp=PLUCK, arp_step=1, arp_vol=10, drums="driving", lead_oct=5, mel_style="driving", long=True, prog2=[5, 6, 0, 4]), ] # four gate assaults (one hand-written + three generated) ASSAULT_SPECS = [ spec("Siege Engine", 30, "D", "harmonic", 160, [0, 5, 1, 4], motif="dim", lead=LEAD, lead_vol=46, pad=ORGAN, pad_vol=15, bass_pat="RRRRRRRRRRRRRRRR", arp=PLUCK, arp_step=1, arp_vol=10, drums="boss", mel_style="driving"), spec("The Gate Trembles", 31, "F", "minor", 164, [0, 6, 5, 4], motif="frag", lead=SQUARE, lead_vol=44, pad=CHOIR, bass_pat="RRRRRRRRRRRRRRRR", drums="driving", lead_oct=5, mel_style="driving", echo=True, echo_delay=2), spec("Last Charge", 32, "C", "minor", 168, [0, 3, 4, 0], motif="normal", lead=LEAD, lead_vol=46, pad=PAD, pad_vol=16, bass_pat="R.R.R.R.R.R.R.R.", arp=PLUCK, arp_step=1, arp_vol=10, drums="boss", lead_oct=5, mel_style="driving", counter=ORGAN), ] MOOD_SPECS = { "torture": spec("The Rack", 40, "C#", "phrygian", 66, [0, 1, 0, 1], motif="aug", lead=BELL, lead_vol=34, pad=CHOIR, pad_vol=16, bass=SUB, bass_pat="R...............", drums="none", intro_drums="none", arp=PLUCK, arp_step=8, arp_order=(0, 1), arp_vol=12), "temple": spec("Ember Shrine", 41, "D", "lydian", 74, [0, 1, 4, 0], motif="aug", lead=ORGAN, lead_vol=32, pad=CHOIR, pad_vol=20, bass=SUB, bass_pat="R...............", drums="none", intro_drums="none", arp=BELL, arp_step=8, arp_order=(0, 2), arp_vol=18, echo=True, echo_inst=HARP), } # ------------------------------------------------------------------ hand-written additions def song_title2(): """Throne of Ash: second title theme, the motif slow and majestic on organ and choir.""" s = Song("DungeonCraft - Throne of Ash", 92) ch_a = bars(("Dm", 1), ("Bb", 1), ("Gm", 1), ("A", 1)) p0 = Pattern() p0.set(0, CH_DRUM, note_num("C4"), TIMP, 50) for c, (n, r) in enumerate([("D3", 0), ("D3", 32)]): p0.set(r, CH_DRUM, note_num("C4"), TIMP, 46) p0.pads(ch_a, vol=20) for k in range(4): p0.set(k * BAR, CH_PAD1, chord_notes(ch_a[k][0], 4)[1], CHOIR, 22) p0.set(k * BAR, CH_PAD2, chord_notes(ch_a[k][0], 4)[2], CHOIR, 22) p0.bass(ch_a, "R...............", inst=SUB, vol=46) p1 = Pattern() p1.line(CH_LEAD, MOTIF_A, ORGAN, 40) p1.line(CH_COUNTER, "D3:16 | G2:8 A2:8 | D3:16 | F2:16", BASS, 40) for k, (c, rr) in enumerate(CHORDS_A): pass p1.pads(CHORDS_A, vol=18) p1.drums(kick="x...............", kv=50) p1.set(0, CH_HAT, note_num("C4"), CRASH, 30) p2 = Pattern() p2.line(CH_LEAD, MOTIF_B, ORGAN, 40) p2.line(CH_COUNTER, "Bb2:16 | D3:16 | A2:16 | D3:16", BASS, 40) p2.pads(CHORDS_B, vol=18) p2.drums(kick="x.......x.......", kv=48) p3 = Pattern() p3.line(CH_LEAD, transpose(MOTIF_A, 12), BELL, 40) p3.echo(CH_LEAD, CH_COUNTER, 4, 20, HARP) p3.pads(CHORDS_A, vol=20) p3.bass(CHORDS_A, "R.......F.......", inst=SUB, vol=44) p3.arp(CHORDS_A, step=2, vol=12, inst=HARP) p3.drums(kick="x.......x.......", hat="x...x...x...x...", hv=10) p = [s.add(x) for x in (p0, p1, p2, p3)] s.order = [p[0], p[1], p[2], p[3], p[2]] s.restart = 1 return s def song_boss_paladin(): """The Paladin of Light: the heroes' fanfare (major) clashes with the leitmotif (minor).""" s = Song("DungeonCraft - Paladin of Light", 150) fan_ch = bars(("D", 1), ("G", 1), ("A", 1), ("D", 1)) p0 = Pattern() p0.line(CH_LEAD, "D5:2 D5:2 D5:2 A5:10 | G5:2 F#5:2 E5:2 D5:10 | E5:4 F#5:4 G5:4 A5:4 | D6:16", SQUARE, 44) p0.line(CH_COUNTER, "F#4:16 | B4:16 | C#5:16 | F#5:16", SQUARE, 28) p0.pads(fan_ch, vol=18) p0.bass(fan_ch, "R...R...R...R...", vol=46) p0.drums(kick="x.......x.......", snare="....x.xx....xxxx", hat="x.x.x.x.x.x.x.x.", sv=44, hv=12) p0.set(0, CH_HAT, note_num("C4"), CRASH, 36) p1 = Pattern() p1.line(CH_LEAD, transpose(MOTIF_A, 12), LEAD, 46) p1.line(CH_COUNTER, "D4:2 D4:2 D4:2 A4:10 | G4:8 A4:8 | D4:16 | F4:16", SQUARE, 26) p1.pads(CHORDS_A, vol=16) p1.bass(CHORDS_A, "RRRRRRRRRRRRRRRR", vol=44) p1.drums(kick="x.x.x.x.x.x.x.x.", snare="....x.......x...", hat="xxxxxxxxxxxxxxxx", sv=48, hv=10) p1.set(0, CH_HAT, note_num("C4"), CRASH, 34) p2 = Pattern() p2.line(CH_LEAD, transpose(MOTIF_B, 12), LEAD, 46) p2.pads(CHORDS_B, vol=16) p2.bass(CHORDS_B, "RRRRRRRRRRRRRRRR", vol=44) p2.arp(CHORDS_B, step=1, vol=10) p2.drums(kick="x.x.x.x.x.x.x.x.", snare="....x.......x.xx", hat="xxxxxxxxxxxxxxxx", sv=48, hv=10) p3 = Pattern() ch3 = bars(("Bb", 1), ("C", 1), ("Dm", 1), ("A", 1)) p3.line(CH_LEAD, "D6:4 C6:4 Bb5:4 A5:4 | G5:4 A5:4 Bb5:4 C6:4 | D6:4 A5:4 F5:4 D5:4 | C#5:8 E5:8", LEAD, 46) p3.line(CH_COUNTER, "D5:2 D5:2 D5:2 F5:10 | E5:2 E5:2 E5:2 G5:10 | F5:16 | E5:16", SQUARE, 26) p3.pads(ch3, vol=16) p3.bass(ch3, "RRRRRRRRRRRRRRRR", vol=44) p3.drums(kick="x.x.x.x.x.x.x.x.", snare="....x.......x...", hat="xxxxxxxxxxxxxxxx", sv=50, hv=10) for k in range(4): p3.set(k * BAR + 14, CH_DRUM, note_num("D4"), TIMP, 50) p = [s.add(x) for x in (p0, p1, p2, p3)] s.order = [p[0], p[1], p[2], p[3], p[1], p[2], p[3]] s.restart = 1 return s def song_boss_warden(): """The Gate Warden: heavy, slow-fast, organ and timpani; the motif inverted.""" s = Song("DungeonCraft - Gate Warden", 132) ch = bars(("Dm", 1), ("Bb", 1), ("Gm", .5), ("A", .5), ("Dm", 1)) inv = realize(m_inv(MOTIF_DEG_A), NOTE_BASE["D"], "harmonic", 5) p0 = Pattern() for k in range(4): p0.set(k * BAR, CH_DRUM, note_num("C4"), TIMP, 56) p0.set(k * BAR + 8, CH_DRUM, note_num("C4"), TIMP, 46) p0.line(CH_PAD1, "D4:32 | C#4:32", ORGAN, 24) p0.line(CH_PAD2, "A4:32 | E4:32", ORGAN, 24) p0.bass(ch, "R...............", inst=SUB, vol=50) p1 = Pattern() p1.line(CH_LEAD, inv, LEAD, 46) p1.pads(ch, vol=18) p1.bass(ch, "R.R.R.R.R.R.R.R.", vol=46) p1.drums(kick="x..x..x.x..x..x.", snare="....x.......x...", hat="x.x.x.x.x.x.x.x.", sv=48, hv=12) p1.set(0, CH_HAT, note_num("C4"), CRASH, 34) p2 = Pattern() p2.line(CH_LEAD, transpose(MOTIF_A, 12), ORGAN, 42) p2.echo(CH_LEAD, CH_COUNTER, 2, 22, SQUARE) p2.pads(CHORDS_A, vol=18) p2.bass(CHORDS_A, "RRRRRRRRRRRRRRRR", vol=46) p2.drums(kick="x.x.x.x.x.x.x.x.", snare="....x.......x.xx", hat="xxxxxxxxxxxxxxxx", sv=50, hv=10) p = [s.add(x) for x in (p0, p1, p2)] s.order = [p[0], p[1], p[2], p[1], p[2]] s.restart = 1 return s def song_victory2(): """Crown of the Deep: a slower, triumphant victory jingle with bells.""" s = Song("DungeonCraft - Crown of the Deep", 100) p0 = Pattern() ch = bars(("D", 1), ("Bb", .5), ("C", .5), ("D", 2)) p0.line(CH_LEAD, "A4:4 D5:4 F#5:4 A5:4 | Bb5:6 A5:2 G5:4 E5:4 | D5:32", BELL, 48) p0.line(CH_COUNTER, "D4:16 | D4:8 E4:8 | F#4:32", ORGAN, 30) p0.pads(ch, vol=22) p0.bass(ch, "R.......R.......", inst=SUB, vol=46) for k in (0, 32, 40, 44): p0.set(k, CH_DRUM, note_num("C4"), TIMP, 52) p0.set(32, CH_HAT, note_num("C4"), CRASH, 36) for c in range(CHANNELS): p0.set(63, c, KEY_OFF) silence = Pattern(1) p = [s.add(x) for x in (p0, silence)] s.order = [p[0], p[1]] s.restart = 1 return s def generated(spec_): return lambda: Gen(spec_).build() # name -> (category, song function); the order within a category is the play order SONGS = {} SONGS["title"] = ("title", song_title) SONGS["title2"] = ("title", song_title2) SONGS["build1"] = ("build", song_build1) SONGS["build2"] = ("build", song_build2) SONGS["build3"] = ("build", song_build3) for k, sp_ in enumerate(BUILD_SPECS): SONGS["build%d" % (k + 4)] = ("build", generated(sp_)) SONGS["battle1"] = ("battle", song_battle1) for k, sp_ in enumerate(BATTLE_SPECS): SONGS["battle%d" % (k + 3)] = ("battle", generated(sp_)) SONGS["battle2"] = ("assault", song_battle2) for k, sp_ in enumerate(ASSAULT_SPECS): SONGS["assault%d" % (k + 2)] = ("assault", generated(sp_)) SONGS["boss_paladin"] = ("boss_paladin", song_boss_paladin) SONGS["boss_warden"] = ("boss_warden", song_boss_warden) SONGS["mood_torture"] = ("torture", generated(MOOD_SPECS["torture"])) SONGS["mood_temple"] = ("temple", generated(MOOD_SPECS["temple"])) SONGS["victory"] = ("victory", song_victory) SONGS["victory2"] = ("victory", song_victory2) SONGS["defeat"] = ("defeat", song_defeat) def write_rust_table(names): """src/tracks.rs: embedded music with categories (generated, do not edit)""" lines = [ "// Generated by tools/compose_music.py - do not edit.", "// All music modules with their category (see audio::Category).", "", "use crate::audio::Category;", "", "pub const TRACKS: [(&str, Category, &[u8]); %d] = [" % len(names), ] cat = {"title": "Title", "build": "Build", "battle": "Battle", "assault": "Assault", "boss_paladin": "BossPaladin", "boss_warden": "BossWarden", "torture": "Torture", "temple": "Temple", "victory": "Victory", "defeat": "Defeat"} for n in names: lines.append(' ("%s", Category::%s, include_bytes!("../assets/music/%s.xm")),' % (n, cat[SONGS[n][0]], n)) lines.append("];") path = os.path.join(os.path.dirname(__file__), "..", "src", "tracks.rs") with open(path, "w") as f: f.write("\n".join(lines) + "\n") def main(): os.makedirs(OUT_DIR, exist_ok=True) instruments = build_instruments() total = 0 for name, (cat, fn) in SONGS.items(): song = fn() size = write_xm(os.path.join(OUT_DIR, name + ".xm"), song, instruments) 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__": main()