diff --git a/technomid.py b/technomid.py index fdc81e0..bdaa156 100644 --- a/technomid.py +++ b/technomid.py @@ -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)