Soundtrack: XM composer script and 8 generated chiptune modules

tools/compose_music.py synthesises chip instruments and writes FastTracker 2 XM files
(editable in MilkyTracker). The title leitmotif runs through all pieces: title,
three build tracks, two battle tracks, victory and defeat jingles.
Validated with libopenmpt; instrument tuning checked (A4 = 440 Hz).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 05:57:55 +01:00
1 parent fb7dab3683
commit 2dba65be8b
9 files changed
+697

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+697
View File
@@ -0,0 +1,697 @@
#!/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
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)),
]
LEAD, SQUARE, PAD, BASS, PLUCK, KICK, SNARE, HAT = range(1, 9)
# 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):
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("<H", 0x0104)
order = song.order + [0] * (256 - len(song.order))
out += struct.pack("<IHHHHHHHH", 276, len(song.order), song.restart, CHANNELS, len(song.patterns),
len(instruments), 1, song.speed, song.bpm)
out += bytes(order)
for pat in song.patterns:
data = bytearray()
for row in pat.cells:
for c in row:
if c is None:
data += b"\x80"
else:
note, inst, vol, fx, param = c
data += bytes([note, inst, vol, fx, param])
out += struct.pack("<IBHH", 9, 0, pat.rows, len(data))
out += data
for ins in instruments:
smp = ins.sample
hdr = bytearray()
hdr += ins.name.encode("ascii")[:22].ljust(22, b"\0")
hdr += b"\0" # type
hdr += struct.pack("<H", 1)
hdr += struct.pack("<I", 40)
hdr += bytes(96) # all notes -> sample 0
env = ins.env or []
pts = bytearray()
for (x, y) in env[:12]:
pts += struct.pack("<HH", x, y)
pts = pts.ljust(48, b"\0")
hdr += pts
hdr += bytes(48) # panning envelope
vol_type = 0
if env:
vol_type |= 1
if ins.sustain is not None:
vol_type |= 2
if ins.loop is not None:
vol_type |= 4
hdr += bytes([len(env), 0, ins.sustain or 0, (ins.loop or (0, 0))[0], (ins.loop or (0, 0))[1], 0, 0, 0, vol_type, 0])
hdr += bytes(ins.vib)
hdr += struct.pack("<H", ins.fadeout)
hdr += bytes(22) # reserved
out += struct.pack("<I", 4 + len(hdr))
out += hdr
# sample header (16-bit)
n = len(smp.data)
loop_type = 0
ls, ll = 0, 0
if smp.loop:
loop_type = 2 if smp.ping else 1
ls, ll = smp.loop
out += struct.pack("<IIIBbBBbB", n * 2, ls * 2, ll * 2, smp.volume, smp.finetune, loop_type | 0x10, smp.pan,
smp.relnote, 0)
out += smp.name.encode("ascii")[:22].ljust(22, b"\0")
# delta encoded 16-bit sample data
prev = 0
for v in smp.data:
s16 = int(round(max(-1.0, min(1.0, v)) * 32767))
d = (s16 - prev) & 0xFFFF
out += struct.pack("<H", d)
prev = s16
with open(path, "wb") as f:
f.write(out)
return len(out)
SONGS = {
"title": song_title,
"build1": song_build1,
"build2": song_build2,
"build3": song_build3,
"battle1": song_battle1,
"battle2": song_battle2,
"victory": song_victory,
"defeat": song_defeat,
}
def main():
os.makedirs(OUT_DIR, exist_ok=True)
instruments = build_instruments()
for name, fn in SONGS.items():
song = fn()
size = write_xm(os.path.join(OUT_DIR, name + ".xm"), song, instruments)
print("%-8s %3d BPM %2d patterns %6d bytes" % (name, song.bpm, len(song.patterns), size))
if __name__ == "__main__":
main()