From 92de27b53c231577625dd69db030e6a28d78e0df Mon Sep 17 00:00:00 2001 From: Wojciech Graj Date: Mon, 23 Sep 2024 22:08:11 +0200 Subject: [PATCH] Release 0.1.3. Use setuptools instead of requirements.txt. Reformat code. Bump python version to 3.9. --- .gitignore | 4 +- README.md | 23 +++- pyproject.toml | 3 + requirements.txt | 13 --- setup.cfg | 22 ++++ tic_midi.py | 289 ++++++++++++++++++++++++++++++----------------- 6 files changed, 228 insertions(+), 126 deletions(-) create mode 100644 pyproject.toml delete mode 100644 requirements.txt create mode 100644 setup.cfg diff --git a/.gitignore b/.gitignore index 565b35d..9a572c6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -.local \ No newline at end of file +.local +.ropeproject/ +*.egg-info/ diff --git a/README.md b/README.md index 595a63e..e275d27 100644 --- a/README.md +++ b/README.md @@ -22,27 +22,38 @@ A MIDI-to-TIC-80 converter. ### Usage -Make sure Python (3.8+) is available on your system. +Make sure Python (3.9+) is available on your system. Install the dependencies: +```sh +pip install -e . ``` -$ pip install -r requirements.txt +OR +```sh +pip install 'mido>=1.2.0,<=1.3.2' ``` Then, simply invoke the script: -``` -$ python tic_midi.py input_file -o output_file +```sh +python tic_midi.py input_file -o output_file ``` To learn about the settings you can use, invoke the script with the `-h` flag: -``` -$ python tic_midi.py -h +```sh +python tic_midi.py -h ``` When playing the music with your lua code, set `sustain=true` in your `music` function call, since the converter currently doesn't re-play notes when starting a new frame. A sample MIDI file, and the cartridge produced with it, can be found in the `example` directory. +### Development setup + +Install the development dependencies: +```sh +pip install -e '.[dev]' +``` + ### License ``` Copyright (C) 2023 Wojciech Graj diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8fe2f47 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index fc20a4f..0000000 --- a/requirements.txt +++ /dev/null @@ -1,13 +0,0 @@ -certifi==2022.12.7 -Cython==0.29.33 -flake8==6.0.0 -mccabe==0.7.0 -mido==1.2.10 -mypy-extensions==1.0.0 -pip==23.0.1 -pycodestyle==2.10.0 -pyflakes==3.0.1 -setuptools==65.6.3 -tomli==2.0.1 -typing_extensions==4.5.0 -wheel==0.38.4 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..6c6d448 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,22 @@ +[metadata] +name = TIC-MIDI +version = 0.1.3 + +[options] +python_requires = >=3.9 +packages = find: +install_requires = + mido>=1.2.0,<=1.3.2 + +[options.extras_require] +dev = + pylsp-mypy + pylsp-rope + python-lsp-isort + python-lsp-server[all] + +[pydocstyle] +ignore = D101,D102,D103,D107,D203,D212 + +[mypy] +allow_redefinition = True diff --git a/tic_midi.py b/tic_midi.py index 513cbff..179bdd0 100644 --- a/tic_midi.py +++ b/tic_midi.py @@ -1,41 +1,46 @@ """ - TIC-MIDI, a MIDI to TIC-80 cartridge converter. - Copyright (C) 2023 Wojciech Graj +TIC-MIDI, a MIDI to TIC-80 cartridge converter. - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. +Copyright (C) 2023-2024 Wojciech Graj - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . """ +from __future__ import annotations # Remove after 2026-10 + import argparse import ctypes import itertools import logging -import mido import os import random import struct import sys import time +from collections.abc import Sequence from dataclasses import dataclass -from mido.midifiles.tracks import _to_abstime -from typing import List, Tuple, Optional, Dict +from typing import Optional +import mido +from mido.midifiles.tracks import _to_abstime -VERSION_STRING = "TIC-MIDI v0.1.2 2023-03-19 by Wojciech Graj" +VERSION_STRING = "TIC-MIDI v0.1.3 2024-09-23 by Wojciech Graj" class Chunk: - """A cartridge chunk""" + """A cartridge chunk.""" + bank: int chunk_type: int size: int @@ -46,15 +51,12 @@ def __init__(self, chunk_type: int, size: int, bank: int = 0) -> None: self.bank = bank @classmethod - def from_binary(self, binary: Tuple[int]): - return Chunk( - binary[0] & 0x1F, - binary[1] | (binary[2] << 8), - binary[0] >> 5 - ) + def from_binary(self, binary: Sequence[int]) -> Chunk: + return Chunk(binary[0] & 0x1F, binary[1] | (binary[2] << 8), + binary[0] >> 5) - def serialize(self) -> Tuple[int]: - """Return the 4-byte chunk header""" + def serialize(self) -> tuple[int, ...]: + """Return the 4-byte chunk header.""" return ( ((self.bank << 5) & 0xE0) | (self.chunk_type & 0x1F), self.size & 0x00FF, @@ -69,7 +71,9 @@ class PatternRow(ctypes.LittleEndianStructure): ("param1", ctypes.c_uint32, 4), ("param2", ctypes.c_uint32, 4), ("command", ctypes.c_uint32, 3), - ("_sfx", ctypes.c_uint32, 6), # Some weird bit rearranging has to be done because it crosses a byte boundary. + # Some weird bit rearranging has to be done because it crosses a byte + # boundary. + ("_sfx", ctypes.c_uint32, 6), ("octave", ctypes.c_uint32, 3), ] @@ -81,7 +85,8 @@ def sfx(self) -> int: def sfx(self, sfx: int) -> None: self._sfx = (sfx << 1) | (sfx >> 5) - def set(self, note: int, param1: int, param2: int, command: int, sfx: int, octave: int) -> None: + def set(self, note: int, param1: int, param2: int, command: int, sfx: int, + octave: int) -> None: self.note = note self.param1 = param1 self.param2 = param2 @@ -89,17 +94,17 @@ def set(self, note: int, param1: int, param2: int, command: int, sfx: int, octav self.sfx = sfx self.octave = octave - def serialize(self) -> Tuple[int]: + def serialize(self) -> tuple[int, ...]: return struct.unpack_from("BBB", self) class Pattern: - rows: List[PatternRow] + rows: list[PatternRow] def __init__(self) -> None: self.rows = [PatternRow() for i in range(64)] - def serialize(self) -> Tuple[int]: + def serialize(self) -> tuple[int, ...]: return tuple(itertools.chain(*[row.serialize() for row in self.rows])) @@ -118,8 +123,7 @@ def get_ch(self, idx: int) -> int: return self.ch1 - 1 elif idx == 2: return self.ch2 - 1 - elif idx == 3: - return self.ch3 - 1 + return self.ch3 - 1 def set_ch(self, idx: int, ch: int) -> None: if idx == 0: @@ -131,7 +135,7 @@ def set_ch(self, idx: int, ch: int) -> None: elif idx == 3: self.ch3 = ch + 1 - def serialize(self) -> Tuple[int]: + def serialize(self) -> tuple[int, ...]: return struct.unpack_from("BBB", self) @@ -141,7 +145,7 @@ class Track(ctypes.LittleEndianStructure): ("nrows", ctypes.c_uint8), ("_speed", ctypes.c_int8), ] - frames: List[Frame] + frames: list[Frame] @property def tempo(self) -> int: @@ -162,42 +166,51 @@ def speed(self, speed: int) -> None: def __init__(self) -> None: self.frames = [Frame() for i in range(16)] - def serialize(self) -> Tuple[int]: - return tuple(itertools.chain(*[frame.serialize() for frame in self.frames], struct.unpack_from("BBB", self))) + def serialize(self) -> tuple[int, ...]: + return tuple( + itertools.chain(*[frame.serialize() for frame in self.frames], + struct.unpack_from("BBB", self))) class TrackChunk(Chunk): - tracks: List[Track] + tracks: list[Track] def __init__(self, bank: int = 0) -> None: super().__init__(14, 408, bank=bank) self.tracks = [Track() for i in range(8)] - def serialize(self) -> Tuple[int]: - return tuple(itertools.chain(super().serialize(), *[track.serialize() for track in self.tracks])) + def serialize(self) -> tuple[int, ...]: + return tuple( + itertools.chain(super().serialize(), + *[track.serialize() for track in self.tracks])) class PatternChunk(Chunk): - patterns: List[Pattern] + patterns: list[Pattern] def __init__(self, bank: int = 0) -> None: super().__init__(15, 11520, bank=bank) self.patterns = [Pattern() for i in range(60)] - def serialize(self) -> Tuple[int]: - return tuple(itertools.chain(super().serialize(), *[pattern.serialize() for pattern in self.patterns])) + def serialize(self) -> tuple[int, ...]: + return tuple( + itertools.chain( + super().serialize(), + *[pattern.serialize() for pattern in self.patterns])) @dataclass class MessageExt: - """MIDI Message with related metadata""" + """MIDI Message with related metadata.""" + msg: mido.Message sfx: int @dataclass class Channel: - """State of a single Track channel""" + """State of a single Track channel.""" + note: int = 0 sfx: int = 0 time_set: int = 0 @@ -209,17 +222,22 @@ def set(self, note: int, sfx: int, time_set: int) -> None: class ChannelState: - """State of all 4 Track channels""" - channels: List[Channel] + """State of all 4 Track channels.""" + + channels: list[Channel] def __init__(self) -> None: self.channels = [Channel() for i in range(4)] - def index(self, note: int, sfx: int) -> None: - return next((i for i, channel in enumerate(self.channels) if channel.note == note and channel.sfx == sfx), -1) + def index(self, note: int, sfx: int) -> int: + return next((i for i, channel in enumerate(self.channels) + if channel.note == note and channel.sfx == sfx), -1) - def calc_note_on_channel_placement(self, msge: MessageExt, replacement_policy: str) -> int: - if (channel_idx := self.index(msge.msg.note, msge.sfx)) != -1: # Replace channel with same note + def calc_note_on_channel_placement(self, msge: MessageExt, + replacement_policy: str) -> int: + if (channel_idx := + self.index(msge.msg.note, + msge.sfx)) != -1: # Replace channel with same note return channel_idx if (channel_idx := self.index(0, 0)) != -1: # Replace empty channel return channel_idx @@ -227,63 +245,71 @@ def calc_note_on_channel_placement(self, msge: MessageExt, replacement_policy: s if replacement_policy == 'random': return random.randrange(4) elif replacement_policy == 'fifo': - return self.channels.index(min(self.channels, key=lambda channel: channel.time_set)) + return self.channels.index( + min(self.channels, key=lambda channel: channel.time_set)) elif replacement_policy == 'lifo': - return self.channels.index(max(self.channels, key=lambda channel: channel.time_set)) - elif replacement_policy == 'none': - return -1 + return self.channels.index( + max(self.channels, key=lambda channel: channel.time_set)) + return -1 def _preprocess_messages( mid: mido.MidiFile, - sfx_names: Optional[Dict]) -> Optional[List[MessageExt]]: - """Combine all messages from all tracks into a list of MessageExt. Map tracks to sfx during conversion.""" - messages = [] + sfx_names: Optional[dict]) -> Optional[list[MessageExt]]: + """ + Combine all messages from all tracks into a list of MessageExt. + + Map tracks to sfx during conversion. + """ + messages: list[MessageExt] = [] for sfx_idx, track in enumerate(mid.tracks): if sfx_names: if track.name not in sfx_names: - logging.error(f"MIDI Track '{track.name}' not found in sfx-names.") + logging.error( + f"MIDI Track '{track.name}' not found in sfx-names.") return None sfx_idx = sfx_names[track.name] elif sfx_idx > 63: - logging.warning("MIDI file contains over 64 tracks. Discarded excess tracks.") + logging.warning( + "MIDI file contains over 64 tracks. Discarded excess tracks.") break - messages.extend((MessageExt(msg, sfx_idx) for msg in _to_abstime(track))) + messages.extend( + (MessageExt(msg, sfx_idx) for msg in _to_abstime(track))) messages.sort(key=lambda msge: msge.msg.time) return messages -def _calc_tempo(messages: List[MessageExt]) -> int: - return next((msge.msg.tempo for msge in reversed(messages) if msge.msg.type == "set_tempo"), 500000) +def _calc_tempo(messages: Sequence[MessageExt]) -> int: + return next( + (msge.msg.tempo + for msge in reversed(messages) if msge.msg.type == "set_tempo"), + 500000) -def _terminate( - pc: PatternChunk, - frame: Frame, - pattern_row_idx: int) -> None: +def _terminate(pc: PatternChunk, frame: Frame, pattern_row_idx: int) -> None: for channel_idx in range(4): if (pattern_idx := frame.get_ch(channel_idx)) != -1: pc.patterns[pattern_idx].rows[pattern_row_idx].note = 1 logging.info("Added terminator to end of track.") -def convert( - mid: mido.MidiFile, - tc: TrackChunk, - pc: PatternChunk, - resolution: int = 4, - sfx_names: Optional[Dict] = None, - track_idx: int = 0, - octave_shift: int = 0, - replacement_policy: str = 'fifo', - terminate: bool = True) -> bool: +def convert(mid: mido.MidiFile, + tc: TrackChunk, + pc: PatternChunk, + resolution: int = 4, + sfx_names: Optional[dict] = None, + track_idx: int = 0, + octave_shift: int = 0, + replacement_policy: str = 'fifo', + terminate: bool = True) -> bool: start_time = time.time() messages = _preprocess_messages(mid, sfx_names) if messages is None: return False tempo = _calc_tempo(messages) - messages = filter(lambda msge: not msge.msg.is_meta, messages) # Remove MetaMessages + messages = filter(lambda msge: not msge.msg.is_meta, + messages) # Remove MetaMessages track = tc.tracks[track_idx] track.speed = resolution + 2 @@ -309,7 +335,8 @@ def convert( frame = track.frames[frame_idx] if msg.type == "note_on": - channel_idx = channel_state.calc_note_on_channel_placement(msge, replacement_policy) + channel_idx = channel_state.calc_note_on_channel_placement( + msge, replacement_policy) if channel_idx == -1: continue @@ -321,22 +348,30 @@ def convert( frame.set_ch(channel_idx, next_unused_pattern) next_unused_pattern += 1 - row = pc.patterns[frame.get_ch(channel_idx)].rows[pattern_row_idx] + row = pc.patterns[frame.get_ch( + channel_idx)].rows[pattern_row_idx] note_scaled = max(0, msg.note - 24) volume = msg.velocity // 8 octave = note_scaled // 12 + octave_shift if not 0 <= octave <= 7: - logging.warning(f"track {track_idx}:frame {frame_idx}:channel {channel_idx + 1}:row {pattern_row_idx}:Note with octave {octave} is beyond playable range. Consider using '--octave-shift'.") + logging.warning( + f"track {track_idx}:frame {frame_idx}" + f":channel {channel_idx + 1}:row {pattern_row_idx}" + f":Note with octave {octave} is beyond playable range." + "Consider using '--octave-shift'.") octave = max(0, min(7, octave)) - row.set(note_scaled % 12 + 4, volume, volume, 1, msge.sfx, octave) - channel_state.channels[channel_idx].set(msg.note, msge.sfx, msg.time) + row.set(note_scaled % 12 + 4, volume, volume, 1, msge.sfx, + octave) + channel_state.channels[channel_idx].set( + msg.note, msge.sfx, msg.time) else: channel_idx = channel_state.index(msg.note, msge.sfx) if channel_idx == -1: continue - pc.patterns[frame.get_ch(channel_idx)].rows[pattern_row_idx].note = 1 + pc.patterns[frame.get_ch( + channel_idx)].rows[pattern_row_idx].note = 1 channel_state.channels[channel_idx].set(0, 0, msg.time) except StopIteration: logging.warning("Terminated early.") @@ -348,28 +383,32 @@ def convert( _terminate(pc, track.frames[frame_idx], pattern_row_idx) # Print summary - logging.info(("===== Summary =====" - f"\nConverted {mido.tick2second(msg.time, mid.ticks_per_beat, tempo):.2f} seconds of music in {1000 * (time.time() - start_time):.2f} millis." - f"\nUsed {frame_idx + 1}/16 frames on track {track_idx}." - f"\nUsed {min(60, next_unused_pattern)}/60 patterns." - f"\nEnded on row {pattern_row_idx} on frame {min(15, frame_idx)}.")) + logging.info( + "===== Summary =====" + "\nConverted" + f" {mido.tick2second(msg.time, mid.ticks_per_beat, tempo):.2f}" + f" seconds of music in {1000 * (time.time() - start_time):.2f} millis." + f"\nUsed {frame_idx + 1}/16 frames on track {track_idx}." + f"\nUsed {min(60, next_unused_pattern)}/60 patterns." + f"\nEnded on row {pattern_row_idx} on frame {min(15, frame_idx)}.") return True -def tic_save( - filename: str, - track_chunk: TrackChunk, - pattern_chunk: PatternChunk, - insert: bool = False) -> None: +def tic_save(filename: str, + track_chunk: TrackChunk, + pattern_chunk: PatternChunk, + insert: bool = False) -> None: if insert: filename_new = f"{filename}.new" with open(filename_new, "wb") as fd: with open(filename, "r+b") as fs: for header in iter(lambda: fs.read(4), b''): chunk = Chunk.from_binary(tuple(header)) - if ((chunk.chunk_type == 14 and chunk.bank == track_chunk.bank) - or (chunk.chunk_type == 15 and chunk.bank == pattern_chunk.bank)): + if ((chunk.chunk_type == 14 + and chunk.bank == track_chunk.bank) + or (chunk.chunk_type == 15 + and chunk.bank == pattern_chunk.bank)): fs.seek(chunk.size, os.SEEK_CUR) else: fs.seek(-4, os.SEEK_CUR) @@ -396,18 +435,52 @@ def tic_save( description='Convert a MIDI file to a TIC-80 cartridge.', formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument('input') - parser.add_argument('-v', '--version', action='version', version=VERSION_STRING, help=f"show version: {VERSION_STRING}") + parser.add_argument('-v', + '--version', + action='version', + version=VERSION_STRING, + help=f"show version: {VERSION_STRING}") parser.add_argument('-o', '--output', required=True) - parser.add_argument('--resolution', default=4, type=int, help="Accepted values: [0,7]. Determines how many notes will be used per beat. Lower values use more space but can be more detailed.") - parser.add_argument('--sfx-names', default="{}", help="Accepts a dict in of the form {'MIDI Track Name':sfx_index}. Used to map MIDI tracks to specific sfx.") - parser.add_argument('--track', default=0, type=int, help="Accepted values: [0,7].") + parser.add_argument( + '--resolution', + default=4, + type=int, + help="Accepted values: [0,7]. Determines how many notes will be used" + "per beat. Lower values use more space but can be more detailed.") + parser.add_argument( + '--sfx-names', + default="{}", + help="Accepts a dict in of the form {'MIDI Track Name':sfx_index}." + " Used to map MIDI tracks to specific sfx.") + parser.add_argument('--track', + default=0, + type=int, + help="Accepted values: [0,7].") parser.add_argument('--bank', default=0, type=int, help="Memory bank.") - parser.add_argument('--octave-shift', default=0, type=int, help="Shift all notes by some number of octaves.") - parser.add_argument('--replacement-policy', choices={'random', 'fifo', 'lifo', 'none'}, default='fifo', help="Determines note placement in channels when all channels are in use.") - parser.add_argument('--no-terminator', action='store_true', help="Do not end playback on all channels at the end of tracks.") + parser.add_argument('--octave-shift', + default=0, + type=int, + help="Shift all notes by some number of octaves.") + parser.add_argument( + '--replacement-policy', + choices={'random', 'fifo', 'lifo', 'none'}, + default='fifo', + help="Determines note placement in channels when all channels are in" + " use.") + parser.add_argument( + '--no-terminator', + action='store_true', + help="Do not end playback on all channels at the end of tracks.") ins_or_ovr = parser.add_mutually_exclusive_group() - ins_or_ovr.add_argument('--insert', action='store_true', help="Insert Track and Pattern Chunks into an existing cartridge while leaving remaining chunks intact.") - ins_or_ovr.add_argument('--overwrite', action='store_true', help="Overwrite an existing cartridge if it exists.") + ins_or_ovr.add_argument( + '--insert', + action='store_true', + help="Insert Track and Pattern Chunks into an existing cartridge while" + " leaving remaining chunks intact.") + ins_or_ovr.add_argument( + '--overwrite', + action='store_true', + help="Overwrite an existing cartridge if it exists.") args = parser.parse_args() # Validate arguments @@ -423,10 +496,14 @@ def tic_save( output_file_exists = os.path.isfile(args.output) if output_file_exists and not args.overwrite and not args.insert: - logging.error(f"Cartridge '{args.output}' already exists. Use '--overwrite' to overwrite.") + logging.error( + f"Cartridge '{args.output}' already exists. Use '--overwrite' to" + "overwrite.") sys.exit(1) elif not output_file_exists and args.insert: - logging.error(f"Cartridge '{args.output}' does not exist, but is required by '--insert'.") + logging.error( + f"Cartridge '{args.output}' does not exist, but is required by" + " '--insert'.") sys.exit(1) # Run conversion and save