singe/util/reelMusic.py

987 lines
38 KiB
Python

# Writes the sizzle reel's music: an original FastTracker II module (.xm) in the idiom of the 90s
# PC demoscene, generated rather than sourced, so it is ours outright and carries no licence of
# anyone else's. The engine plays XM natively through libxmp (scene 99 exercises the format), so
# the reel can run as a real Singe script with this as its soundtrack instead of being rendered.
#
# python3 util/reelMusic.py [<output.xm>] (default: .builddir/reelMusic.xm)
#
# Nothing here is sampled from a record or lifted from another module: every instrument is
# synthesised a sample at a time, and the patterns are written a row at a time. It is hard rock --
# distorted guitars doubled hard left and right, a kit hit like a kit, and a riff in E rather than
# an arpeggio in A.
#
# What keeps it from sounding like a chiptune is where the work goes. The drums are 16 bit at
# 22050 Hz, each built as a transient, a body, and a tail and then soft clipped together, which is
# what punch is. The guitars are a power chord distorted as a chord, root, fifth and octave through
# one saturator, so the intermodulation between them is real rather than three clean notes played at
# once; then a cabinet: high pass, low pass, and a presence bump.
#
# The trap in baking a filter into a sample: the filter travels with the pitch. A cabinet designed
# at the rate the data was generated lands an octave and a half lower when the sample is played at
# low E, and the chord comes out muffled. So every filter here is designed against the rate the
# data will really play at -- 6922 Hz for a riff on the bottom two strings, 3461 Hz for the bass,
# 22050 Hz for a drum struck at C-4 -- which is what the RATE arguments below are for.
#
# The format is FastTracker II 1.04: a 336 byte header with its order table, then the patterns as
# packed rows, then the instruments each carrying their samples with the data delta encoded. A
# sample rendered at 22050 Hz needs relative note +17 and finetune -28 for C-4 to play it back at
# the rate it was made at, which is what tuning() works out.
import math
import os
import struct
import sys
import wave
BASE = 8363.0 # What C-4 plays a sample at with the linear frequency table.
RATE = 22050 # What the drums are rendered at: real rate, real air.
TONE_RATE = 21977 # The tuned instruments: 84 samples a cycle at concert C-4, exactly.
TONE_CYCLE = 84
GUITAR_RATE = 6922 # What the data plays at on low E, which is where the riff lives.
BASS_RATE = 3461 # An octave below that, where the bass guitar lives.
LEAD_RATE = 13844 # An octave above, where a solo sits.
ROWS = 128 # Rows to a pattern: four bars at eight rows to the beat.
CHANNELS = 14
SPEED = 3 # Ticks a row.
BPM = 150 # Arena tempo: fast enough to lift, slow enough to ring.
HEADER_SIZE = 276
INSTRUMENT_HEADER = 263
SAMPLE_HEADER = 40
PEAK = 30000 # Where a sample's loudest point sits in 16 bits, leaving headroom.
# The channels, laid out like a band: a kit, a bass, two rhythm guitars and a lead.
KICK, SNARE, HAT, CRASH, TOM, BASS, LEFT, RIGHT, CHUG, LEAD, HARM, OCT, STAB, FX = range(CHANNELS)
# Where each sits. The two rhythm guitars go hard left and hard right, which is the sound of every
# rock record ever made: the same part played twice and thrown to the walls.
PANNING = { KICK: 0x80, SNARE: 0x80, HAT: 0x60, CRASH: 0xA8, TOM: 0x70, BASS: 0x80,
LEFT: 0x18, RIGHT: 0xE8, CHUG: 0x78, LEAD: 0x90, HARM: 0x64, OCT: 0x88,
STAB: 0xC0, FX: 0x40 }
NOTES = { "C": 0, "C#": 1, "D": 2, "D#": 3, "E": 4, "F": 5, "F#": 6, "G": 7, "G#": 8, "A": 9, "A#": 10, "B": 11 }
NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
def note(name):
if name is None:
return 0
return 1 + int(name[-1]) * 12 + NOTES[name[:-1]]
def transpose(name, semitones):
value = note(name) - 1 + semitones
return "%s%d" % (NAMES[value % 12], value // 12)
# The relative note and finetune that make C-4 play a sample back at the rate it was rendered at.
def tuning(rate):
exact = 12.0 * math.log(rate / BASE, 2)
rel = int(round(exact))
return rel, int(round((exact - rel) * 128))
# ===== Real samples ===========================================================================
#
# If util/reelSamples.py has prepared the FreePats sound banks, the module is built from real
# recordings: a Fender through an amplifier rack (CC0), a Yamaha bass (CC0), and a real kit
# (CC BY 4.0, so a reel using it owes Bjorn Muldjord a credit). With no samples there, everything
# falls back to the synthesised set below and the module owes nobody anything.
SAMPLES = os.environ.get("REEL_SAMPLES", "")
REALRATE = 32000
def loadWave(path):
with wave.open(path, "rb") as handle:
frames = handle.readframes(handle.getnframes())
return list(struct.unpack("<%dh" % (len(frames) // 2), frames)), handle.getframerate()
# What relative note and finetune make the tracker play a sample at its own rate when the written
# note is the one it was recorded at. MIDI 60 is C-4, which the format calls note 49.
def sampleTuning(rate, midi):
exact = 12.0 * math.log(rate / BASE, 2) - (midi - 60)
rel = int(round(exact))
return rel, int(round((exact - rel) * 128))
# ===== Synthesis ==============================================================================
class Noise:
# Deterministic, so two runs of this program write the same file.
def __init__(self, seed=12345):
self.state = seed
def next(self):
self.state = (self.state * 1103515245 + 12345) & 0x7FFFFFFF
return ((self.state >> 11) % 4096 - 2048) / 2048.0
def saturate(value, drive=1.0):
# The soft clip that turns level into loudness: it rounds the top off a transient instead of
# squaring it, and the harmonics it adds are what make a kick audible on a small speaker.
return math.tanh(value * drive) / math.tanh(drive) if drive > 0 else value
def normalise(samples, peak=PEAK):
loudest = max(abs(v) for v in samples) or 1.0
return [int(max(-32768, min(32767, round(v * peak / loudest)))) for v in samples]
def lowpass(samples, cut, rate=RATE, passes=1):
# One pole, run as many times as the slope wants. Cheap, and the right shape for drums.
alpha = 1.0 - math.exp(-2.0 * math.pi * cut / rate)
for _ in range(passes):
out = []
last = 0.0
for value in samples:
last += alpha * (value - last)
out.append(last)
samples = out
return samples
def highpass(samples, cut, rate=RATE):
low = lowpass(samples, cut, rate)
return [value - low[i] for i, value in enumerate(samples)]
def bandpass(samples, low, high, rate=RATE):
return lowpass(highpass(samples, low, rate), high, rate, 2)
def envelope(length, attack, decay, hold=0.0):
# Attack in samples, then a hold, then an exponential decay: the shape every drum has.
out = []
for i in range(length):
if i < attack:
out.append(i / float(max(1, attack)))
elif i < attack + hold:
out.append(1.0)
else:
out.append(math.exp(-(i - attack - hold) / float(max(1, decay))))
return out
# The kick: a sine whose pitch falls off a cliff, a sub that hangs on under it, and a click on the
# front so it reads on a speaker that cannot make 50 Hz. Soft clipped together, which is the punch.
def kick(length=None):
length = length or int(0.42 * RATE)
noise = Noise(7)
body = []
phase = 0.0
for i in range(length):
t = i / float(RATE)
pitch = 47.0 + 190.0 * math.exp(-t * 75.0) # 237 Hz down to 47 in about 14 ms.
phase += 2.0 * math.pi * pitch / RATE
body.append(math.sin(phase) * math.exp(-t * 13.0))
sub = []
phase = 0.0
for i in range(length):
t = i / float(RATE)
phase += 2.0 * math.pi * 46.0 / RATE
sub.append(math.sin(phase) * math.exp(-t * 5.5))
click = lowpass([noise.next() for _ in range(length)], 4200)
shape = envelope(length, 2, int(0.0012 * RATE))
out = []
for i in range(length):
mix = body[i] * 1.0 + sub[i] * 0.9 + click[i] * shape[i] * 1.2
out.append(saturate(mix, 3.0))
return normalise(out)
# The snare: a shell that rings at two pitches, the wires as bandpassed noise, a bright transient,
# and a short tail so it sounds like a drum in a room rather than a burst of static.
def snare(length=None, bright=1.0):
length = length or int(0.34 * RATE)
noise = Noise(31)
hiss = bandpass([noise.next() for _ in range(length)], 400, 7000 * bright)
tail = bandpass([noise.next() for _ in range(length)], 900, 3000)
out = []
for i in range(length):
t = i / float(RATE)
shell = (math.sin(2.0 * math.pi * 182.0 * t) + 0.7 * math.sin(2.0 * math.pi * 276.0 * t)) * math.exp(-t * 30.0)
wires = hiss[i] * math.exp(-t * 19.0)
room = tail[i] * math.exp(-t * 7.0) * 0.35
crack = hiss[i] * math.exp(-t * 900.0) * 1.6
out.append(saturate(shell * 0.55 + wires * 1.1 + room + crack, 1.6))
return normalise(out)
# The clap: four bursts a few milliseconds apart and then the tail, which is the whole trick.
def clap(length=None):
length = length or int(0.30 * RATE)
noise = Noise(57)
raw = bandpass([noise.next() for _ in range(length)], 700, 5200)
out = [0.0] * length
for offset in (0, int(0.009 * RATE), int(0.018 * RATE), int(0.026 * RATE)):
gain = 1.0 - offset / float(length) * 1.5
for i in range(offset, min(length, offset + int(0.02 * RATE))):
out[i] += raw[i] * math.exp(-(i - offset) / (0.004 * RATE)) * gain
for i in range(length):
t = i / float(RATE)
out[i] = saturate(out[i] + raw[i] * math.exp(-t * 11.0) * 0.5, 1.4)
return normalise(out)
def hat(length=None, decay=45.0, cut=9000.0, seed=97):
length = length or int(0.07 * RATE)
noise = Noise(seed)
raw = highpass([noise.next() for _ in range(length)], cut)
out = []
for i in range(length):
t = i / float(RATE)
out.append(saturate(raw[i] * math.exp(-t * decay), 1.2))
return normalise(out, int(PEAK * 0.8))
def crash(length=None):
length = length or int(1.6 * RATE)
noise = Noise(181)
raw = highpass([noise.next() for _ in range(length)], 3500)
out = []
for i in range(length):
t = i / float(RATE)
# Two decays: the strike, then the wash.
out.append(raw[i] * (math.exp(-t * 14.0) * 0.8 + math.exp(-t * 2.0) * 0.5))
return normalise(out, int(PEAK * 0.85))
def tom(pitch=150.0, length=None):
length = length or int(0.22 * RATE)
noise = Noise(211)
raw = bandpass([noise.next() for _ in range(length)], 200, 2500)
out = []
phase = 0.0
for i in range(length):
t = i / float(RATE)
f = pitch * (1.0 + 0.5 * math.exp(-t * 40.0))
phase += 2.0 * math.pi * f / RATE
out.append(saturate(math.sin(phase) * math.exp(-t * 13.0) + raw[i] * math.exp(-t * 60.0) * 0.5, 1.5))
return normalise(out)
# ----- The guitars ----------------------------------------------------------------------------
#
# A power chord is not three notes distorted, it is three notes distorted together: the saturator
# multiplies them against each other and the sum and difference tones it makes are most of what a
# heavy guitar is. So the root, the fifth and the octave are summed first and clipped once. The
# loop is sixteen cycles of the root, which is twenty-four of the fifth and thirty-two of the
# octave, so every component closes and the seam is silent.
def cabinet(samples, rate, low=95.0, high=4300.0, presence=0.45):
# A speaker in a box: nothing under the low corner, little over the high one, and a lift where
# a guitar speaker lifts. Designed against the rate the sample will play at, not be made at.
body = lowpass(highpass(samples, low, rate), high, rate, 2)
shine = bandpass(samples, 1500.0, 3200.0, rate)
return [value + shine[i] * presence for i, value in enumerate(body)]
def strings(ratios, cycles, drive, rate, weights=None, peak=PEAK):
length = cycles * TONE_CYCLE
raw = []
for i in range(length):
phase = i / float(TONE_CYCLE)
where = i / float(length)
value = 0.0
for n, ratio in enumerate(ratios):
gain = (weights or [1.0] * len(ratios))[n]
# A string is a saw with the top rolled off and a little life in the phase.
for harmonic in range(1, 13):
drift = 0.008 * math.sin(2.0 * math.pi * (where + n * 0.3 + harmonic * 0.05))
value += gain * math.sin(2.0 * math.pi * harmonic * ratio * (phase + drift)) / (harmonic ** 1.1)
raw.append(value)
hot = [saturate(v, drive) for v in raw]
return normalise(cabinet(hot, rate), peak), length
# The riff guitar: root, fifth, octave, driven hard.
def powerChord():
return strings([1.0, 1.5, 2.0], 16, 9.0, GUITAR_RATE, [1.0, 0.85, 0.6])
# The same chord with the gain backed off, for the parts that want to be heard through rather than
# over: the intro and the verses.
def cleanerChord():
return strings([1.0, 1.5, 2.0], 16, 4.0, GUITAR_RATE, [1.0, 0.8, 0.55])
# One note for the lead, distorted enough to sing and sustain.
def leadGuitar():
return strings([1.0, 2.0], 32, 7.0, LEAD_RATE, [1.0, 0.25])
# The bass guitar: a pick attack on the front, the string under it, and just enough grit.
def bassGuitar():
return strings([1.0, 2.0, 3.0], 16, 2.6, BASS_RATE, [1.0, 0.4, 0.15])
# ----- The kit --------------------------------------------------------------------------------
#
# Hit like a kit rather than programmed like a drum machine: the kick is a thud with a beater on it
# instead of a sub sine, the snare has a shell and a room, and the cymbals are struck.
def rockKick(length=None):
length = length or int(0.30 * RATE)
noise = Noise(7)
beater = lowpass([noise.next() for _ in range(length)], 5200)
out = []
phase = 0.0
for i in range(length):
t = i / float(RATE)
pitch = 58.0 + 120.0 * math.exp(-t * 90.0) # A shorter, higher drop than a dance kick.
phase += 2.0 * math.pi * pitch / RATE
body = math.sin(phase) * math.exp(-t * 17.0)
skin = math.sin(2.0 * math.pi * 210.0 * t) * math.exp(-t * 60.0) * 0.35
click = beater[i] * math.exp(-t * 320.0) * 1.3
out.append(saturate(body + skin + click, 2.4))
return normalise(out)
def rockSnare(length=None):
length = length or int(0.40 * RATE)
noise = Noise(31)
wires = bandpass([noise.next() for _ in range(length)], 500, 8500)
room = bandpass([noise.next() for _ in range(length)], 700, 4000)
out = []
for i in range(length):
t = i / float(RATE)
shell = (math.sin(2.0 * math.pi * 198.0 * t) + 0.65 * math.sin(2.0 * math.pi * 301.0 * t)) * math.exp(-t * 26.0)
out.append(saturate(shell * 0.7 + wires[i] * math.exp(-t * 16.0) * 1.15
+ room[i] * math.exp(-t * 5.5) * 0.45
+ wires[i] * math.exp(-t * 700.0) * 1.7, 1.7))
return normalise(out)
def ride(length=None):
length = length or int(0.9 * RATE)
noise = Noise(151)
wash = highpass([noise.next() for _ in range(length)], 4000)
out = []
for i in range(length):
t = i / float(RATE)
ping = math.sin(2.0 * math.pi * 1180.0 * t) * math.exp(-t * 26.0) * 0.5
out.append(saturate(ping + wash[i] * (math.exp(-t * 18.0) * 0.7 + math.exp(-t * 3.0) * 0.35), 1.3))
return normalise(out, int(PEAK * 0.85))
class Instrument:
def __init__(self, name, samples, loop, rate, volume=64, envelope=None, sustain=None, fadeout=0, midi=None):
self.name = name
self.samples = samples
self.loop = loop
self.volume = volume
self.envelope = envelope or []
self.sustain = sustain
self.fadeout = fadeout
self.relative, self.finetune = tuning(rate) if midi is None else sampleTuning(rate, midi)
# The guitars a real set carries, as (name, file, MIDI note it was recorded at). A riff note is
# played by whichever of them was recorded nearest it, so nothing is stretched further than a few
# semitones and the amplifier's own character survives.
GUITARS = [("gtrC2", 36), ("gtrE2", 40), ("gtrA2", 45), ("gtrD3", 50), ("gtrE3", 52), ("gtrG3", 55),
("gtrB3", 59), ("gtrCs4", 61), ("gtrE4", 64), ("gtrG4", 67), ("gtrB4", 71), ("gtrC5", 72),
("gtrD5", 74)]
# The softer takes of the same guitar, by the note they were recorded at.
SOFT = [("sftC2", 36), ("sftE2", 40), ("sftA2", 45), ("sftG3", 55), ("sftB3", 59), ("sftD5", 74)]
# Filled by instruments(): which instrument name to use for a written note, and whether the set
# being used is the real one.
CHOICE = {}
REAL = False
# Every take of each drum and each guitar note, in the order to rotate through them.
TAKES = {
"kick": ("kick", "kickB", "kickC"),
"kickMid": ("kickMid", "kickMidB"),
"snare": ("snare", "snareB", "snareC", "snareD"),
"hat": ("hat", "hatB", "hatC", "hatD"),
"crash": ("crash", "crashB"),
"tom": ("tom", "tomB"),
"gtrE2": ("gtrE2", "gtrE2b", "gtrE2c", "gtrE2d"),
"gtrA2": ("gtrA2", "gtrA2b", "gtrA2c", "gtrA2d"),
"gtrD3": ("gtrD3", "gtrD3b", "gtrD3c"),
"gtrE3": ("gtrE3", "gtrE3b"),
"gtrG3": ("gtrG3", "gtrG3b", "gtrG3c", "gtrG3d"),
"gtrCs4": ("gtrCs4", "gtrCs4b"),
"gtrE4": ("gtrE4", "gtrE4b", "gtrE4c"),
"gtrB4": ("gtrB4", "gtrB4b"),
"gtrC5": ("gtrC5", "gtrC5b"),
"gtrD5": ("gtrD5", "gtrD5b"),
}
TURN = {}
# The next take of something. Every note written goes through this, so a bar of eighths on the hat
# uses four recordings of the hat rather than one of them eight times. Playing the identical
# waveform on every hit is what makes a sampled kit sound like a drum machine.
def take(n, name):
options = [item for item in TAKES.get(name, (name,)) if item in n]
if not options:
return name
TURN[name] = (TURN.get(name, -1) + 1) % len(options)
return options[TURN[name]]
def realInstruments():
out = []
# The kit, with its velocity layers: a hard hit and a softer one for the kick, the snare and the
# hat, plus a ghost snare, an open hat, a ride, a china and two toms.
volumes = { "kick": 64, "kickMid": 58, "snare": 64, "snareMid": 56, "ghost": 40, "hat": 50,
"hatMid": 42, "open": 50, "ride": 46, "china": 52, "crash": 58, "tom": 62,
"tomLow": 62 }
for label in ("kick", "kickB", "kickC", "kickMid", "kickMidB", "snare", "snareB", "snareC",
"snareD", "snareMid", "ghost", "hat", "hatB", "hatC", "hatD", "hatMid", "open",
"ride", "china", "crash", "crashB", "tom", "tomB", "tomLow"):
path = os.path.join(SAMPLES, label + ".wav")
if not os.path.isfile(path):
continue
data, rate = loadWave(path)
out.append(Instrument(label, data, False, rate, volumes.get(label.rstrip("BCD"), 56)))
data, rate = loadWave(os.path.join(SAMPLES, "bass.wav"))
out.append(Instrument("bass", data, False, rate, 44, None, None, 900, 28))
for name, midi in GUITARS:
for label in TAKES.get(name, (name,)):
path = os.path.join(SAMPLES, label + ".wav")
if os.path.isfile(path):
data, rate = loadWave(path)
out.append(Instrument(label, data, False, rate, 46, None, None, 4200, midi))
# The softer takes of the same strings, for anything that is not hit flat out.
for name, midi in SOFT:
data, rate = loadWave(os.path.join(SAMPLES, name + ".wav"))
out.append(Instrument(name, data, False, rate, 42, None, None, 4200, midi))
# Three muted guitars rather than one. A single low E sample stretched over every pitch goes
# nasal as it climbs, which is what made the guitars sound like bagpipes; a mute taken from the
# string that would really be played keeps the register honest.
for label, source, midi in (("mute", "gtrE2", 40), ("muteMid", "gtrG3", 55), ("muteHigh", "gtrB3", 59)):
data, rate = loadWave(os.path.join(SAMPLES, source + ".wav"))
out.append(Instrument(label, data, False, rate, 46, [(0, 64), (1, 40), (3, 14), (5, 0)], None, 0, midi))
return out
def synthInstruments():
crunch, crunchLoop = powerChord()
clean, cleanLoop = cleanerChord()
solo, soloLoop = leadGuitar()
bass, bassLoop = bassGuitar()
return [
Instrument("kick", rockKick(), False, RATE, 64),
Instrument("snare", rockSnare(), False, RATE, 64),
Instrument("hat", hat(int(0.06 * RATE), 55.0, 9500), False, RATE, 44),
Instrument("crash", crash(), False, RATE, 52),
Instrument("tom", tom(170.0), False, RATE, 56),
Instrument("ride", ride(), False, RATE, 46),
Instrument("bass", bass, bassLoop, TONE_RATE, 64,
[(0, 64), (2, 62), (16, 54), (48, 50)], 3, 600),
Instrument("crunch", crunch, crunchLoop, TONE_RATE, 62,
[(0, 56), (1, 64), (10, 58), (40, 54)], 3, 700),
Instrument("clean", clean, cleanLoop, TONE_RATE, 56,
[(0, 50), (1, 60), (12, 50), (40, 44)], 3, 800),
Instrument("mute", crunch, crunchLoop, TONE_RATE, 54,
[(0, 64), (2, 40), (6, 16), (12, 0)], None, 0),
Instrument("lead", solo, soloLoop, TONE_RATE, 60,
[(0, 20), (2, 64), (14, 58), (60, 56)], 3, 380),
]
def instruments():
global REAL
REAL = bool(SAMPLES) and os.path.isfile(os.path.join(SAMPLES, "gtrE2.wav"))
out = realInstruments() if REAL else synthInstruments()
# Which guitar plays a written note. With the real set, the nearest recording; with the
# synthesised one, the same two waveforms whatever the note.
CHOICE.clear()
for octave in range(0, 8):
for step, letter in enumerate(NAMES):
name = "%s%d" % (letter, octave)
midi = note(name) + 11
if REAL:
CHOICE[name] = min(GUITARS, key=lambda pair: abs(pair[1] - midi))[0]
else:
CHOICE[name] = "crunch"
return out
# The guitar to use for a note: the nearest recording of one.
def guitarFor(name, kind="crunch"):
if not REAL:
return kind
return CHOICE.get(name, "gtrE2")
def numbering(kit):
return {item.name: n + 1 for n, item in enumerate(kit)}
# ===== The patterns ===========================================================================
class Pattern:
def __init__(self, rows=ROWS):
self.rows = rows
self.cells = [[None] * CHANNELS for _ in range(rows)]
def put(self, row, channel, name=None, instrument=0, volume=0, effect=0, param=0):
if 0 <= row < self.rows:
pitch = note(name) if isinstance(name, str) else (name or 0)
self.cells[row][channel] = (pitch, instrument, volume, effect, param)
def pack(self):
out = bytearray()
for row in self.cells:
for cell in row:
if cell is None:
out.append(0x80)
continue
pitch, instrument, volume, effect, param = cell
mask = 0x80
body = bytearray()
if pitch:
mask |= 0x01
body.append(pitch)
if instrument:
mask |= 0x02
body.append(instrument)
if volume:
mask |= 0x04
body.append(volume)
if effect:
mask |= 0x08
body.append(effect)
if param:
mask |= 0x10
body.append(param)
out.append(mask)
out += body
return bytes(out)
# ===== The music ==============================================================================
#
# Arena rock in D, which is what the owner asked for after three passes of something darker: a major
# key, chords left to ring instead of chugged, and a hook that sings over the top. The progression
# is the one every anthem is built on -- D, A, B minor, G, the first, fifth, sixth and fourth of the
# key -- and the chorus starts on the fourth, because starting a chorus above where the verse sat is
# most of what "lift" means.
#
# Power chords throughout, so the thirds are left out and the melody decides the mood. The hook
# rises to a held D and stays there; the last chorus puts it an octave up, which is the oldest trick
# there is for making a final chorus feel bigger without playing anything louder.
# The key. A mixolydian: the major third with the flat seventh, which is why a G chord sits next to
# an A one in this style. Every ornament, passing tone and harmony note is snapped to it, because
# moving a note by a tone without asking what key you are in is how wrong notes get in.
KEY = (9, 11, 1, 2, 4, 6, 7)
def inKey(name):
value = note(name) - 1
best = min(range(-6, 7), key=lambda shift: (abs(shift), 0) if (value + shift) % 12 in KEY else (99, 0))
return "%s%d" % (NAMES[(value + best) % 12], (value + best) // 12)
# What the second voice plays over each chord: the tones a power chord leaves out.
COUNTER = { "A": ("C#5", "E4"), "D": ("F#4", "A4"), "E": ("G#4", "B4"), "G": ("B4", "D5"),
"C": ("E4", "G4"), "B": ("D5", "F#4"), "F": ("A4", "C5") }
# The riff. A, with the stops that make this style: a chord, a hole, a chord off the beat. Nothing
# is held for a bar anywhere in this track -- that was the slow part and it is gone.
VERSE = [
(0, "A2", 5, True), (8, "A2", 3, False), (14, "G2", 5, True), (24, "A2", 3, False),
(32, "A2", 5, True), (40, "D2", 3, False), (46, "A2", 5, True), (56, "E2", 5, True),
(64, "A2", 5, True), (72, "A2", 3, False), (78, "G2", 5, True), (88, "A2", 3, False),
(96, "D2", 5, True), (104, "D2", 3, False), (110, "E2", 5, True), (120, "A2", 7, True),
]
# The chorus: the fourth, the first, the fifth, the first, a chord to a half bar, bright and moving.
CHORUS = [
(0, "D2", 6, True), (16, "A2", 6, True), (32, "E2", 6, True), (48, "A2", 6, True),
(64, "D2", 6, True), (80, "A2", 6, True), (96, "G2", 6, True), (112, "A2", 6, True),
]
# The hook, rhythmic rather than sung: two short notes and an answer, twice, then the same idea
# higher. A major pentatonic, A B C# E F#.
HOOK = [
(0, "A4", 4), (6, "C#5", 4), (12, "E5", 8), (24, "C#5", 6),
(32, "A4", 4), (38, "B4", 4), (44, "C#5", 10), (58, "A4", 5),
(64, "E5", 4), (70, "F#5", 4), (76, "E5", 8), (88, "C#5", 6),
(96, "B4", 4), (102, "C#5", 4), (108, "A4", 14),
]
# The solo: sixteenth runs up and down the pentatonic, which is what this guitarist actually does,
# rather than long held notes.
SOLO = [
(0, "A4", 2), (2, "B4", 2), (4, "C#5", 2), (6, "E5", 2), (8, "F#5", 6), (16, "E5", 2),
(18, "C#5", 2), (20, "B4", 2), (22, "A4", 6),
(32, "C#5", 2), (34, "E5", 2), (36, "F#5", 2), (38, "A5", 8), (48, "F#5", 2), (50, "E5", 2),
(52, "C#5", 2), (54, "B4", 8),
(64, "E5", 2), (66, "F#5", 2), (68, "A5", 2), (70, "F#5", 2), (72, "E5", 6), (80, "C#5", 2),
(82, "B4", 2), (84, "A4", 10),
(96, "B4", 2), (98, "C#5", 2), (100, "E5", 2), (102, "F#5", 2), (104, "A5", 12),
(120, "E5", 7),
]
ANSWER = [
(24, "A4", 4), (30, "C#5", 4), (36, "E5", 6), (88, "F#5", 4), (94, "E5", 4), (100, "C#5", 10),
]
LIFT = SOLO
def panning(pattern):
for channel, where in PANNING.items():
for row in range(0, 6):
cell = pattern.cells[row][channel]
if cell is None:
pattern.put(row, channel, None, 0, 0, 0x08, where)
break
if cell[3] == 0:
pattern.cells[row][channel] = (cell[0], cell[1], cell[2], 0x08, where)
break
# ----- Playing it like a player ----------------------------------------------------------------
#
# The first attempt at this was sparse and repetitive, which sounded programmed. The second fixed
# that by randomising everything -- which offbeat the bass took, which hat dropped out, where a fill
# landed -- and the owner's word for the result was "haphazard". He is right: random is not human.
# A player repeats a figure on purpose and varies it where the phrase turns.
#
# So every rhythm below is written out once, as rows inside a bar, and played the same way every bar.
# The only thing left to chance is a decibel or two on a hit, because a drummer's accents are
# consistent and their absolute level is not. Variation happens where music puts it: the fourth bar
# of a four bar phrase.
BAR = 32 # Rows in a bar at eight rows to the beat.
# The groove. Kick on one, on the "a" of one, and on three; snare on two and four with a ghost
# before the last of them; eighths on the hat, accented on the beat; the hat left open on the
# "and" of two. This is one bar of rock drumming, written once.
KICK_ROWS = ((0, 0x50), (6, 0x3E), (16, 0x4C))
SNARE_ROWS = ((8, 0x50), (24, 0x50))
GHOST_ROWS = ((20, 0x24),)
HAT_ROWS = ((0, 0x2E), (4, 0x1E), (8, 0x2A), (12, 0x1E), (16, 0x2E), (20, 0x1E), (24, 0x2A), (28, 0x20))
OPEN_ROW = 12
# The fill that ends a four bar phrase, in the last bar: toms down the kit, snare on the last two.
FILL = ((16, "tom", 0x3A), (20, "tom", 0x3E), (24, "tomLow", 0x42), (26, "tomLow", 0x42),
(28, "snare", 0x46), (30, "snare", 0x4A))
# The right hand. Sixteenths, accented on the beat, with two deliberate holes: the sixteenth before
# beat four and the last one of the bar. Written once and played the same every bar, which is what
# makes it a part rather than a texture.
DRIVE_ROWS = ((0, 0x44), (2, 0x30), (4, 0x38), (6, 0x30), (8, 0x40), (10, 0x30), (12, 0x38),
(16, 0x44), (18, 0x30), (20, 0x38), (22, 0x30), (24, 0x40), (26, 0x30), (28, 0x38))
# The bass. Root on one, root on the "a" of one, root on three, octave on the "and" of two and the
# "and" of four: a figure, not a scatter of sixteenths.
BASS_ROWS = ((0, 0, 0x42), (6, 0, 0x34), (12, 12, 0x30), (16, 0, 0x3E), (22, 0, 0x32), (28, 12, 0x32))
# The stabs: the "and" of four in the second bar of a phrase, and twice in the fourth.
STAB_ROWS = { 1: ((28, 0x42),), 3: ((20, 0x3E), (28, 0x44)) }
class Rng:
# Only used for a decibel or two on a hit, so two runs still write the same file.
def __init__(self, seed):
self.state = seed * 2654435761 & 0xFFFFFFFF
def next(self, under):
self.state = (self.state * 1103515245 + 12345) & 0x7FFFFFFF
return (self.state >> 13) % under
def human(rng, level, spread=2):
return max(0x11, min(0x50, level - rng.next(spread * 2 + 1) + spread))
def muteFor(name):
if not REAL:
return "mute"
midi = note(name) + 11
return "mute" if midi < 48 else ("muteMid" if midi < 58 else "muteHigh")
def guitarSoft(name):
if not REAL:
return guitarFor(name)
midi = note(name) + 11
return min(SOFT, key=lambda pair: abs(pair[1] - midi))[0]
# The kit: the written groove every bar, the written fill in the last bar of each phrase.
def kit(pattern, n, rng, ride=False, halfTime=False, fills=True):
plate = "ride" if (ride and "ride" in n) else "hat"
bars = pattern.rows // BAR
for bar in range(bars):
at = bar * BAR
last = fills and (bar % 4 == 3)
for row, level in KICK_ROWS:
pattern.put(at + row, KICK, "C4", n[take(n, "kick")], human(rng, level))
for row, level in SNARE_ROWS:
if last and row == 24:
continue # The fill has that beat.
pattern.put(at + row, SNARE, "C4", n[take(n, "snare")], human(rng, level))
for row, level in GHOST_ROWS:
if not last and "ghost" in n:
pattern.put(at + row, SNARE, "C4", n["ghost"], human(rng, level))
for row, level in HAT_ROWS:
if last and row >= 16:
continue
what = "open" if (row == OPEN_ROW and bar % 2 == 1 and "open" in n) else plate
pattern.put(at + row, HAT, "C4", n[take(n, what)], human(rng, level))
if last:
for row, what, level in FILL:
pattern.put(at + row, TOM, transpose("C4", 4 - row // 8), n[take(n, what)], human(rng, level))
# The chords, with the written bass figure under them.
def chords(pattern, n, rng, figure, level=0x40, bass=True, octave=False, soft=False):
for row, chord, hold, accent in figure:
pick = guitarSoft if (soft or not accent) else guitarFor
pattern.put(row, LEFT, chord, n[take(n, pick(chord))], human(rng, level))
pattern.put(row, RIGHT, chord, n[guitarSoft(chord)], human(rng, level - 1), 0x0E, 0xD1)
if row + hold < pattern.rows:
pattern.put(row + hold, LEFT, 97)
pattern.put(row + hold, RIGHT, 97)
if bass:
# The figure repeats every bar, on whatever chord the bar is on.
for bar in range(pattern.rows // BAR):
at = bar * BAR
chord = chordAt(figure, at)
for row, step, level2 in BASS_ROWS:
pattern.put(at + row, BASS, inKey(transpose(chord, -12 + step)), n["bass"], human(rng, level2))
# Which chord a row falls on.
def chordAt(figure, row):
chord = figure[0][1]
for at, name, hold, accent in figure:
if at <= row:
chord = name
return chord
def drive(pattern, n, rng, figure, level=0x00):
for bar in range(pattern.rows // BAR):
at = bar * BAR
chord = chordAt(figure, at)
for row, own in DRIVE_ROWS:
pattern.put(at + row, CHUG, chord, n[take(n, muteFor(chord))], human(rng, own + level))
def stabs(pattern, n, rng, figure, level=0x00, per=0):
for bar in range(pattern.rows // BAR):
at = bar * BAR
chord = chordAt(figure, at)
above = transpose(chord, 12)
for row, own in STAB_ROWS.get(bar % 4, ()):
pattern.put(at + row, STAB, above, n[take(n, guitarFor(above))], human(rng, own + level))
pattern.put(at + row + 3, STAB, 97)
# The second voice: a written line over each chord, the third and the fifth of it, in a fixed
# rhythm. Two notes a bar, always in the same place.
def counter(pattern, n, rng, figure, level=0x30, every=16):
for bar in range(pattern.rows // BAR):
at = bar * BAR
chord = chordAt(figure, at)
tones = COUNTER.get(chord[:-1], ("A4", "D5"))
for i, row in enumerate((4, 20)):
name = tones[i % len(tones)]
pattern.put(at + row, HARM, name, n[guitarSoft(name)], human(rng, level))
pattern.put(at + row + 10, HARM, 97)
def crashes(pattern, n, rng, rows, level=0x4A):
for row in rows:
pattern.put(row, CRASH, "C4", n[take(n, "crash")], human(rng, level))
# The tune. The ornaments are written in rather than diced: a grace note into the first long note of
# each half, and nothing else.
GRACED = (32, 96)
def lead(pattern, n, rng, tune, channel=LEAD, level=0x4E, octave=0, ornament=True):
for row, name, hold in tune:
played = transpose(name, octave)
if ornament and row in GRACED:
grace = inKey(transpose(played, -2))
pattern.put(row - 1, channel, grace, n[guitarSoft(grace)], human(rng, level - 12))
pattern.put(row, channel, played, n[take(n, guitarFor(played))], human(rng, level))
if row + hold < pattern.rows:
pattern.put(row + hold, channel, 97)
def song():
kitList = instruments()
n = numbering(kitList)
out = []
# No slow bits anywhere: the riff opens it, and every entry after that is the riff, the chorus or
# the solo. Nothing fades in, nothing drops to half time, nothing holds a chord for a bar.
plan = [("riff", 1), ("riff", 2), ("chorus", 3), ("riff", 4), ("chorus", 5), ("solo", 6),
("chorus", 7), ("riff", 8), ("chorus", 9), ("solo", 10), ("chorus", 11)]
for kind, seed in plan:
rng = Rng(seed)
pattern = Pattern()
if seed == 1:
pattern.put(0, 0, None, 0, 0, 0x0F, SPEED)
if kind == "riff":
chords(pattern, n, rng, VERSE, 0x42)
drive(pattern, n, rng, VERSE)
kit(pattern, n, rng, seed % 3 == 0)
stabs(pattern, n, rng, VERSE)
crashes(pattern, n, rng, (0, 64), 0x44)
if seed > 2:
lead(pattern, n, rng, ANSWER, LEAD, 0x4A)
elif kind == "chorus":
chords(pattern, n, rng, CHORUS, 0x46)
drive(pattern, n, rng, CHORUS, -4)
kit(pattern, n, rng)
counter(pattern, n, rng, CHORUS, 0x30)
stabs(pattern, n, rng, CHORUS)
crashes(pattern, n, rng, (0, 32, 64, 96))
lead(pattern, n, rng, HOOK, LEAD, 0x4C)
lead(pattern, n, rng, [(r, inKey(transpose(name, -3)), h) for r, name, h in HOOK],
HARM, 0x36, 0, False)
else:
chords(pattern, n, rng, VERSE, 0x3E)
drive(pattern, n, rng, VERSE, -2)
kit(pattern, n, rng, True)
crashes(pattern, n, rng, (0, 64), 0x40)
lead(pattern, n, rng, SOLO, LEAD, 0x4E, 0, False)
panning(pattern)
out.append(pattern)
return kitList, out, list(range(len(out))), 0
# ===== Writing the file =======================================================================
def text(value, length):
raw = value.encode("ascii", "replace")[:length]
return raw + b"\0" * (length - len(raw))
def delta16(samples):
out = bytearray()
last = 0
for value in samples:
out += struct.pack("<h", ((value - last + 32768) & 0xFFFF) - 32768)
last = value
return bytes(out)
def envelopeBytes(points):
out = bytearray()
for tick, level in (points + [(0, 0)] * 12)[:12]:
out += struct.pack("<HH", tick, level)
return bytes(out)
def instrumentBytes(item):
body = text(item.name, 22) + bytes([0]) + struct.pack("<H", 1)
body += struct.pack("<I", SAMPLE_HEADER)
body += bytes(96)
body += envelopeBytes(item.envelope)
body += envelopeBytes([])
points = min(12, len(item.envelope))
sustain = item.sustain if item.sustain is not None else 0
kind = 0x01 | (0x02 if item.sustain is not None else 0)
body += bytes([points, 0, sustain, 0, max(0, points - 1), 0, 0, 0, kind, 0, 0, 0, 0, 0])
body += struct.pack("<H", item.fadeout)
body += bytes(22)
header = struct.pack("<I", INSTRUMENT_HEADER) + body
assert len(header) == INSTRUMENT_HEADER, "instrument header is %d bytes" % len(header)
# 16 bit: every length field is in bytes, and bit 4 of the type byte is what says so. A
# tuned instrument loops its whole sample, which is a whole number of cycles, so the loop is
# seamless; a drum is a one shot and loops not at all.
total = len(item.samples) * 2
length = item.loop * 2 if item.loop else 0
sample = struct.pack("<III", total, 0, length)
sample += bytes([item.volume, item.finetune & 0xFF, (0x01 if item.loop else 0x00) | 0x10, 128, item.relative & 0xFF, 0])
sample += text(item.name, 22)
assert len(sample) == SAMPLE_HEADER, "sample header is %d bytes" % len(sample)
return header + sample + delta16(item.samples)
def write(path, name="Singe Reel"):
kit, patterns, order, restart = song()
out = bytearray()
out += b"Extended Module: "
out += text(name, 20)
out += bytes([0x1A])
out += text("Singe reelMusic.py", 20)
out += struct.pack("<H", 0x0104)
out += struct.pack("<I", HEADER_SIZE)
out += struct.pack("<HHHHHHH", len(order), restart, CHANNELS, len(patterns), len(kit), 1, SPEED)
out += struct.pack("<H", BPM)
out += bytes(order) + bytes(256 - len(order))
assert len(out) == 60 + HEADER_SIZE, "header is %d bytes" % len(out)
for pattern in patterns:
packed = pattern.pack()
out += struct.pack("<I", 9) + bytes([0]) + struct.pack("<HH", pattern.rows, len(packed))
out += packed
for item in kit:
out += instrumentBytes(item)
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with open(path, "wb") as handle:
handle.write(bytes(out))
rowTime = 2.5 / BPM * SPEED
print("%s %.0f KB" % (path, len(out) / 1024.0))
print("%d channels, %d patterns, %d instruments; %s" % (CHANNELS, len(patterns), len(kit),
("real samples from %s" % SAMPLES) if REAL else ("synthesised, drums 16 bit at %d Hz" % RATE)))
for item in kit:
print(" %-8s %6d samples %-7s relative %+3d finetune %+4d" %
(item.name, len(item.samples), "looped" if item.loop else "one shot", item.relative, item.finetune))
print("%d beats a minute at %d ticks a row: %.1f seconds a pattern, %.0f seconds through, looping from entry %d" %
(BPM, SPEED, rowTime * ROWS, rowTime * ROWS * len(order), restart))
if __name__ == "__main__":
target = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".builddir", "reelMusic.xm")
write(target)