midi refactor and document things a little

This commit is contained in:
2024-03-02 01:54:08 +11:00
parent af0dd68716
commit 9061649890

View File

@@ -3,11 +3,12 @@
# Home page: https://github.com/ScrelliCopter/TECHNO.COM # Home page: https://github.com/ScrelliCopter/TECHNO.COM
# SPDX-License-Identifier: Zlib (https://opensource.org/license/Zlib) # SPDX-License-Identifier: Zlib (https://opensource.org/license/Zlib)
import os
import struct import struct
import math import math
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from typing import BinaryIO, List, Tuple from typing import BinaryIO, Iterable, Tuple
from enum import IntEnum from enum import IntEnum
@@ -54,7 +55,7 @@ class MIDIProgramChange(MIDIEvent):
if channel < 0 or channel > 0xF: if channel < 0 or channel > 0xF:
raise ValueError("MIDI Channel out of range") raise ValueError("MIDI Channel out of range")
if patch < 0 or patch >= 0x80: if patch < 0 or patch >= 0x80:
raise ValueError("Program out of range") raise ValueError("MIDI Program out of range")
self._channel = channel self._channel = channel
self._patch = patch self._patch = patch
@@ -95,74 +96,78 @@ class MIDIWriter:
self._file.write(b"MThd") self._file.write(b"MThd")
self._file.write(struct.pack(">IHHH", 6, fmt, track_count, division)) self._file.write(struct.pack(">IHHH", 6, fmt, track_count, division))
def write_track(self, events: List[Tuple[int, MIDIEvent]]): def write_track(self, events: Iterable[Tuple[int, MIDIEvent]]):
payload = b""
for event in events:
payload += self.encode_varint(event[0])
payload += event[1].serialise()
self._file.write(b"MTrk") self._file.write(b"MTrk")
self._file.write(len(payload).to_bytes(4, byteorder="big")) ofs = self._file.tell()
self._file.write(payload) self._file.write(b"\0\0\0\0") # Blank length field to write later
# Serialise and write out events
payload_len = 0
for event in events:
data = event[1].serialise()
self._file.write(self.encode_varint(event[0]) + data)
payload_len += 4 + len(data)
# Fill in track length field
self._file.seek(ofs)
self._file.write(payload_len.to_bytes(4, byteorder="big"))
self._file.seek(0, os.SEEK_END)
# Variable integer, used by event deltas.
# Up to 4 bytes can encode 7 bits each by setting the continuation bit (bit 8)
def encode_varint(self, value: int) -> bytes: def encode_varint(self, value: int) -> bytes:
if value < 0:
raise ValueError("Variable integer must be positive")
if value >= 0x10000000:
raise ValueError("Variable integer is larger than 0FFFFFFF")
if value < 0x80: if value < 0x80:
return value.to_bytes() return value.to_bytes()
if value < 0x4000:
return bytes([0x80 | (value >> 7), value & 0x7F])
if value < 0x200000:
return bytes([0x80 | (value >> 14), 0x80 | (value >> 7) & 0x7F, value & 0x7F])
if value < 0x10000000:
return bytes([0x80 | (value >> 21), 0x80 | (value >> 14) & 0x7F, 0x80 | (value >> 7) & 0x7F, value & 0x7F])
else: else:
a = (value & 0xFE00000) >> 21 raise ValueError("Variable integer out of range")
b = (value & 0x01FC000) >> 14
c = (value & 0x0003F80) >> 7
d = (value & 0x000007F)
if a != 0:
return bytes([a | 0x80, b | 0x80, c | 0x80, d])
elif b != 0:
return bytes([b | 0x80, c | 0x80, d])
elif c != 0:
return bytes([c | 0x80, d])
def generate(outpath: Path): def generate(f: BinaryIO):
timer = round((1000000 * 14.31818) / 12) def note_from_period(period: int, reference: int) -> (int, int):
frequency = reference / max(1, period) # Convert period to hz
fnote = 69 + 12 * math.log2(frequency / 440) # Convert pitch to MIDI note
note = int(round(fnote)) # Snap to nearest semitone
bend = int(round((fnote - note) * 0x1000)) # Error is encoded as pitch bend
return min(0x7F, note), min(0x1FFF, bend)
def note_from_period(period: int) -> (int, int): def techno(length: int):
frequency = timer / max(1, period) timer = int(round((1000000 * 1260 / 88) / 12)) # Intel 8253 (PIC) clock in Mhz
fnote = 69 + 12 * math.log2(frequency / 440) # Music tables from disassembly
note = int(round(fnote))
bend = min(0x1FFF, int(round((fnote - note) * 0x1000)))
return min(0x7F, note), bend
def techno():
phrase = [2, *[1, 0, 0] * 3] * 3 + [2, 3] + [0, 3] * 3 phrase = [2, *[1, 0, 0] * 3] * 3 + [2, 3] + [0, 3] * 3
freq_tbl = [5424, 2712, 2416, 2280] freq_tbl = [5424, 2712, 2416, 2280]
mangler = 0x0404 mangler = 0x0404
yield 0, MIDIMetaTempo(int(round((1000000 * 0x80000) / timer))) # 16th note every PIC tick
yield 0, MIDIProgramChange(0, 80) # Set GM patch to #81 "Lead 1 (Square)"
i = 0 i = 0
bend = 0 bend = 0
while True: while True:
for sixteenth in phrase: for sixteenth in phrase:
note, new_bend = note_from_period(freq_tbl[sixteenth]) note, new_bend = note_from_period(freq_tbl[sixteenth], timer)
if new_bend != bend: if new_bend != bend:
yield 0, MIDIPitchWheel(0, new_bend) yield 0, MIDIPitchWheel(0, new_bend)
bend = new_bend bend = new_bend
yield 0, MIDINoteOn(0, note) yield 0, MIDINoteOn(0, note)
yield 32, MIDINoteOff(0, note) yield 32, MIDINoteOff(0, note)
i += 2 i += 2
if i >= 80 * 25: if i >= length:
return return
# Scramble pitch table at the end of each measure
mangler = (mangler + len(phrase) * 2) & 0xFFFF mangler = (mangler + len(phrase) * 2) & 0xFFFF
freq_tbl = [freq ^ mangler for freq in freq_tbl] freq_tbl = [freq ^ mangler for freq in freq_tbl]
with outpath.open("wb") as f:
mid = MIDIWriter(f) mid = MIDIWriter(f)
mid.write_header(128) mid.write_header(128)
mid.write_track([ mid.write_track(techno(80 * 25))
(0, MIDIProgramChange(0, 80)),
(0, MIDIMetaTempo(int(round((1000000 * 0x80000) / timer))))
] + list(techno()))
if __name__ == "__main__": if __name__ == "__main__":
generate(Path("techno.mid")) with Path("techno.mid").open("wb") as f:
generate(f)