mirror of
https://github.com/ScrelliCopter/TECHNO.COM.git
synced 2025-02-21 01:59:25 +11:00
midi refactor and document things a little
This commit is contained in:
91
technomid.py
91
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)
|
||||
|
||||
Reference in New Issue
Block a user