Files
dungeoncraft/tools/compose_music.py
T
Richard ZinkandClaude Opus 5.5 2dba65be8b 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>
2026-11-03 05:57:55 +01:00

698 lines
25 KiB
Python
Executable File

#!/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()