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
# SPDX-License-Identifier: Zlib (https://opensource.org/license/Zlib)
import os
import struct
import math
from abc import ABC, abstractmethod
from pathlib import Path
from typing import BinaryIO, List, Tuple
from typing import BinaryIO, Iterable, Tuple
from enum import IntEnum
@@ -54,7 +55,7 @@ class MIDIProgramChange(MIDIEvent):
if channel < 0 or channel > 0xF:
raise ValueError("MIDI Channel out of range")
if patch < 0 or patch >= 0x80:
raise ValueError("Program out of range")
raise ValueError("MIDI Program out of range")
self._channel = channel
self._patch = patch
@@ -95,74 +96,78 @@ class MIDIWriter:
self._file.write(b"MThd")
self._file.write(struct.pack(">IHHH", 6, fmt, track_count, division))
def write_track(self, events: List[Tuple[int, MIDIEvent]]):
payload = b""
for event in events:
payload += self.encode_varint(event[0])
payload += event[1].serialise()
def write_track(self, events: Iterable[Tuple[int, MIDIEvent]]):
self._file.write(b"MTrk")
self._file.write(len(payload).to_bytes(4, byteorder="big"))
self._file.write(payload)
ofs = self._file.tell()
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:
if value < 0:
raise ValueError("Variable integer must be positive")
if value >= 0x10000000:
raise ValueError("Variable integer is larger than 0FFFFFFF")
if value < 0x80:
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:
a = (value & 0xFE00000) >> 21
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])
raise ValueError("Variable integer out of range")
def generate(outpath: Path):
timer = round((1000000 * 14.31818) / 12)
def generate(f: BinaryIO):
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):
frequency = timer / max(1, period)
fnote = 69 + 12 * math.log2(frequency / 440)
note = int(round(fnote))
bend = min(0x1FFF, int(round((fnote - note) * 0x1000)))
return min(0x7F, note), bend
def techno():
def techno(length: int):
timer = int(round((1000000 * 1260 / 88) / 12)) # Intel 8253 (PIC) clock in Mhz
# Music tables from disassembly
phrase = [2, *[1, 0, 0] * 3] * 3 + [2, 3] + [0, 3] * 3
freq_tbl = [5424, 2712, 2416, 2280]
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
bend = 0
while True:
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:
yield 0, MIDIPitchWheel(0, new_bend)
bend = new_bend
yield 0, MIDINoteOn(0, note)
yield 32, MIDINoteOff(0, note)
i += 2
if i >= 80 * 25:
if i >= length:
return
# Scramble pitch table at the end of each measure
mangler = (mangler + len(phrase) * 2) & 0xFFFF
freq_tbl = [freq ^ mangler for freq in freq_tbl]
with outpath.open("wb") as f:
mid = MIDIWriter(f)
mid.write_header(128)
mid.write_track([
(0, MIDIProgramChange(0, 80)),
(0, MIDIMetaTempo(int(round((1000000 * 0x80000) / timer))))
] + list(techno()))
mid = MIDIWriter(f)
mid.write_header(128)
mid.write_track(techno(80 * 25))
if __name__ == "__main__":
generate(Path("techno.mid"))
with Path("techno.mid").open("wb") as f:
generate(f)