Files
RustRaptor/mods/iron_coast_music/build_music.py

234 lines
8.9 KiB
Python

#!/usr/bin/env python3
"""Author Coastal Steel as a deterministic checked MUS score, without MIDI tools.
The Rust game's MUS service clock is 70 Hz. A quarter lasts 30 ticks: 140 BPM.
56 four-beat bars give a 96-second loop. All musical phrases below are original
authored note sequences; no original-game music is read by this recipe.
"""
from collections import defaultdict
import hashlib
import json
from pathlib import Path
import struct
ROOT = Path(__file__).resolve().parent
BAR = 120
END = 56 * BAR
EVENTS = defaultdict(list)
PROGRAMS = {0: 33, 1: 30, 2: 29, 3: 81, 4: 48}
VOLUMES = {0: 86, 1: 72, 2: 85, 3: 58, 4: 42, 15: 83}
PAN = {0: 64, 1: 32, 2: 64, 3: 100, 4: 64, 15: 64}
SECTIONS = [
{"name": "Approach", "first_bar": 0, "bars": 4},
{"name": "Breakwater riff", "first_bar": 4, "bars": 12},
{"name": "Offshore pursuit", "first_bar": 16, "bars": 12},
{"name": "Stormwall bridge", "first_bar": 28, "bars": 8},
{"name": "Citadel assault", "first_bar": 36, "bars": 16},
{"name": "Return to coast", "first_bar": 52, "bars": 4},
]
def event(tick, priority, descriptor, payload):
assert 0 <= tick <= END
EVENTS[tick].append((priority, bytes([descriptor]) + bytes(payload)))
def control(channel, number, value):
event(0, 0, 0x40 | channel, [number, value])
def note(channel, key, tick, duration, velocity):
assert 0 <= key <= 127 and 0 < velocity <= 127 and duration > 0
end = min(tick + duration, END - 1)
event(tick, 2, 0x10 | channel, [key | 0x80, velocity])
event(end, 1, channel, [key])
def variable_length(value):
pieces = [value & 127]
value >>= 7
while value:
pieces.append(value & 127)
value >>= 7
return bytes(v | (0x80 if i else 0) for i, v in reversed(list(enumerate(pieces))))
for channel, program in PROGRAMS.items():
control(channel, 0, program)
for channel in VOLUMES:
control(channel, 3, VOLUMES[channel])
control(channel, 4, PAN[channel])
# E minor riff and its G-major/Am contrast. Each cell describes one eighth
# note per slot; None leaves a melodic breath. The refrain has a clear call
# (E-G-B-A) and downward answer (G-F#-E-D), with a four-bar tonic resolution.
MELODY_A = [
[76, None, 79, 83, 81, 79, 78, 76],
[74, 76, None, 79, 78, None, 76, None],
[72, None, 76, 79, 83, 81, 79, 76],
[74, 78, 81, None, 79, 78, 76, None],
]
MELODY_B = [
[79, 83, 86, None, 83, 81, 79, None],
[78, None, 81, 85, 83, 81, 78, 74],
[76, 81, None, 84, 83, 81, 79, 76],
[76, 79, 84, None, 83, 79, 76, None],
]
CADENCE = [
[81, None, 78, 74, 78, 79, 81, None],
[79, 76, 72, None, 76, None, 79, None],
[78, 75, 71, None, 75, 78, 83, None],
[83, 81, 79, 78, 76, None, None, None],
]
def harmony(bar):
if bar < 4:
return [40, 40, 36, 38][bar]
if bar < 16:
return [40, 40, 36, 38][(bar - 4) % 4]
if bar < 28:
return [43, 38, 45, 36][(bar - 16) % 4]
if bar < 36:
return [40, 35, 36, 38][(bar - 28) % 4]
if bar < 52:
return [40, 40, 36, 38][(bar - 36) % 4]
return [38, 36, 35, 40][bar - 52]
for bar in range(56):
tick = bar * BAR
root = harmony(bar)
intro = bar < 4
bridge = 28 <= bar < 36
climax = 36 <= bar < 52
turnaround = bar >= 52
# Fingered bass alternates the root and fifth, leaving space for the drums.
for slot, interval in enumerate([0, 0, 7, 0, 12, 7, 0, 7]):
if bridge and slot % 2:
continue
note(0, root + interval, tick + slot * 15,
24 if bridge else 11, 97 if slot % 2 == 0 else 78)
# Palm-muted FM power-chord pulses. Intro/bridge leave an open half-bar;
# the assault adds an upper octave accent without dense voice stealing.
slots = [0, 4, 6] if intro else [0, 2, 4, 6] if bridge else list(range(8))
for slot in slots:
start = tick + slot * 15
length = 24 if bridge else 10
velocity = 82 if slot % 2 == 0 else 63
note(1, root + 12, start, length, velocity)
note(1, root + 19, start, length, velocity - 9)
if climax and slot in [0, 4]:
note(1, root + 24, start, 9, 54)
# A quiet sustained interval opens up the bridge and supports phrase starts.
if bridge or bar % 4 == 0:
note(4, root + 24, tick, BAR - 6, 64 if bridge else 47)
note(4, root + 31, tick, BAR - 6, 49 if bridge else 37)
melody = None
if 4 <= bar < 16:
melody = MELODY_A[(bar - 4) % 4]
elif 16 <= bar < 28:
melody = MELODY_B[(bar - 16) % 4]
elif climax:
melody = MELODY_A[(bar - 36) % 4]
elif turnaround:
melody = CADENCE[bar - 52]
if melody:
for slot, key in enumerate(melody):
if key is not None:
next_rest = slot == 7 or melody[slot + 1] is None
length = min(25 if next_rest else 12, BAR - slot * 15 - 2)
note(2, key, tick + slot * 15, length,
95 if slot in [0, 4] else 83)
if climax and bar % 4 == 3:
# A short response joins the refrain at the end of the phrase.
for slot, key in [(4, 83), (5, 81), (6, 79), (7, 78)]:
note(3, key, tick + slot * 15 + 3, 10, 71)
elif bridge:
for slot, key in enumerate([root + 24, root + 31, root + 36, root + 31]):
note(3, key, tick + slot * 30, 23, 78)
# Standard percussion on MUS channel 15. Repeating closed hats are softer
# than snare/kick; phrase ends use a restrained tom fill, never a sample loop.
hats = range(0, 16, 2) if intro or bridge else range(16)
for slot in hats:
start = tick + (slot * 15) // 2
key = 46 if climax and slot == 14 else 42
note(15, key, start, 5, 64 if slot % 4 == 0 else 40 if slot % 2 else 49)
kick_slots = [0, 60] if intro or bridge else [0, 45, 60, 105]
for offset in kick_slots:
note(15, 36, tick + offset, 8, 111 if offset in [0, 60] else 83)
for offset in [30, 90]:
note(15, 38, tick + offset, 8, 104 if not bridge else 85)
if bar in [4, 16, 28, 36, 52]:
note(15, 49, tick, 18, 87)
if bar in [3, 15, 27, 35, 43, 51, 54]:
for offset, key in [(90, 47), (97, 47), (105, 45), (112, 43)]:
note(15, key, tick + offset, 6, 85)
# Release every voice at the loop boundary. The final melody resolves to E;
# the next approach begins on E without a hanging voice or an abrupt chord flip.
for channel in VOLUMES:
event(END - 1, 3, 0x30 | channel, [11])
event(END, 4, 0x60, [])
ticks = sorted(EVENTS)
stream = bytearray()
for index, tick in enumerate(ticks):
group = sorted(EVENTS[tick], key=lambda e: e[0])
for position, (_, raw) in enumerate(group):
last = position == len(group) - 1
descriptor = raw[0] | (0x80 if last and tick < END else 0)
stream.extend(bytes([descriptor]) + raw[1:])
if tick < END:
stream.extend(variable_length(ticks[index + 1] - tick))
# MUS preload tables use 100 + percussion key, distinct from the synthesizer's
# internal GENMIDI bank indexing. Verified against all eleven local original
# scores containing percussion; the recipe never reads their melody data.
instruments = sorted(set(PROGRAMS.values()) | {100 + n for n in [36, 38, 42, 43, 45, 46, 47, 49]})
start = 16 + len(instruments) * 2
assert len(stream) <= 65535
header = b"MUS\x1a" + struct.pack("<6H", len(stream), start, len(PROGRAMS), 0,
len(instruments), 0)
score = header + struct.pack("<" + "H" * len(instruments), *instruments) + stream
path = ROOT / "music/iron-coast.mus"
path.parent.mkdir(exist_ok=True)
path.write_bytes(score)
notes = sum(1 for group in EVENTS.values() for _, raw in group if raw[0] >> 4 == 1)
report = {
"title": "Coastal Steel",
"composer": "Original authored composition for RustRaptor Iron Coast",
"format": "MUS with explicit note velocities and standard GM programs",
"service_clock_hz": 70,
"tempo_bpm": 140,
"meter": "4/4",
"bars": 56,
"duration_ticks": END,
"duration_seconds": END / 70,
"tonality": "E minor with G-major contrast and B-major turnaround",
"sections": SECTIONS,
"channels": {str(k): v for k, v in PROGRAMS.items()} | {"15": "standard percussion"},
"instrument_preload_table": instruments,
"percussion_preload_encoding": "100 + MIDI percussion key (MUS convention)",
"events": sum(len(g) for g in EVENTS.values()),
"notes": notes,
"groups": len(EVENTS),
"max_events_per_tick": max(len(g) for g in EVENTS.values()),
"file": "music/iron-coast.mus",
"bytes": len(score),
"sha256": hashlib.sha256(score).hexdigest(),
"recipe": "build_music.py",
"sources": "Authored note sequences and deterministic arrangement; no original-game score or recording used",
"rendering": "Existing Rust MUS decoder and GENMIDI/OPL3 synthesizer; original instrument bank remains external",
}
(ROOT / "provenance.json").write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps(report, indent=2))