Last active
June 13, 2025 06:50
-
-
Save ChillMagic/f675e674a7247a62e8dcdb6265f254ec to your computer and use it in GitHub Desktop.
Convert 0cc-FamiTracker txt to midi
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import enum | |
| import dataclasses | |
| from typing import Union, Dict, List, Optional | |
| import sys | |
| import re | |
| from mido import MidiFile, Message, MetaMessage | |
| import argparse | |
| from pathlib import Path | |
| ChannelTypeName = { | |
| 0: "Pulse 1", | |
| 1: "Pulse 2", | |
| 2: "Triangle", | |
| 3: "Noise", | |
| 4: "DPCM", | |
| } | |
| class Pitch(str): | |
| pass | |
| @dataclasses.dataclass | |
| class Instrument: | |
| id: Optional[int] | |
| def __init__(self, id: Union[str, int]) -> None: | |
| if isinstance(id, str): | |
| if id == '..': | |
| self.id = None | |
| else: | |
| self.id = int(id, 16) | |
| else: | |
| self.id = id | |
| if (self.id is not None) and (self.id > 15): | |
| self.id = 15 | |
| @dataclasses.dataclass | |
| class Row: | |
| pitch: Pitch | |
| instrument: Instrument | |
| def __init__(self, content: List[str]): | |
| self.pitch = Pitch(content[0]) | |
| self.instrument = Instrument(content[1]) | |
| @staticmethod | |
| def none(): | |
| return Row(['...','..','.','...']) | |
| def is_none(self) -> bool: | |
| return self.pitch == '...' | |
| def is_stop(self) -> bool: | |
| return self.pitch == '---' or self.pitch == '===' | |
| def is_normal(self) -> bool: | |
| return not (self.is_none() or self.is_stop()) | |
| def __repr__(self) -> str: | |
| if self.is_none(): | |
| return ' ' | |
| elif self.is_stop(): | |
| return '--- ' | |
| try: | |
| return f'{self.pitch} {self.instrument.id:02x}' | |
| except: | |
| return f'{self.pitch} ??' | |
| @dataclasses.dataclass | |
| class Pattern: | |
| data: Dict[int, list] | |
| def add_row(self, row_id: str, content: Row): | |
| self.data[row_id] = content | |
| PatternRegex = r'PATTERN \w\w' | |
| TrackRegex = r'TRACK\s+\w+\s+\w+\s+(\w+)\s+"(.*)"' | |
| def get_split(regex: str, full_text: str): | |
| ids = list(map(lambda x: x.start(), re.finditer(regex, full_text))) + [len(full_text)] | |
| for i in range(len(ids) - 1): | |
| yield full_text[ids[i]:ids[i+1]] | |
| def get_info(full_text): | |
| tempo, song_name = re.findall(TrackRegex, full_text)[0] | |
| tempo = int(tempo) | |
| song_name = song_name.replace('""', '"') | |
| orders = list(map(lambda x: (x[0], x[1].split(' ')), re.findall(r'ORDER (\w\w) : (.*)', full_text))) | |
| assert len(set(map(lambda x: len(x[1]), orders))) == 1 | |
| column_count = len(orders[0][1]) | |
| record = {} | |
| for ci in range(column_count): | |
| record[ci] = {} | |
| for content in get_split(PatternRegex, full_text): | |
| content = content.strip().splitlines() | |
| pattern = re.fullmatch(r'PATTERN (\w\w)', content[0])[1] | |
| for k in record: | |
| record[k][pattern] = [] | |
| i = 0 | |
| for row_text in content[1:]: | |
| if not row_text.startswith('ROW'): | |
| continue | |
| row_content = row_text.split(' : ') | |
| row_id = re.fullmatch(r'ROW (\w\w)', row_content[0])[1] | |
| assert (int(row_id, 16) == i) | |
| i += 1 | |
| row_content = row_content[1:] | |
| for ci in range(column_count): | |
| record[ci][pattern].append(Row(row_content[ci].split(' '))) | |
| result = {} | |
| for ci in range(column_count): | |
| result[ci] = [] | |
| i = 0 | |
| for order in orders: | |
| idx = order[0] | |
| assert (int(idx, 16) == i) | |
| i += 1 | |
| content = order[1] | |
| for ci in range(column_count): | |
| pattern_id = content[ci] | |
| result[ci].extend(record[ci].get(pattern_id, [Row.none()] * 0x48)) | |
| for ci in range(column_count): | |
| last_instrucment = None | |
| for row in result[ci]: | |
| if row.is_normal() and (row.instrument.id is None) and (last_instrucment is not None): | |
| row.instrument = last_instrucment | |
| if row.is_normal() and (row.instrument.id is not None): | |
| last_instrucment = row.instrument | |
| assert len(set(map(len, result.values()))) == 1 | |
| return tempo, song_name, result | |
| pitch_map = { | |
| 'G-9': 127, | |
| 'F#9': 126, | |
| 'F-9': 125, | |
| 'E-9': 124, | |
| 'D#9': 123, | |
| 'D-9': 122, | |
| 'C#9': 121, | |
| 'C-9': 120, | |
| 'B-8': 119, | |
| 'A#8': 118, | |
| 'A-8': 117, | |
| 'G#8': 116, | |
| 'G-8': 115, | |
| 'F#8': 114, | |
| 'F-8': 113, | |
| 'E-8': 112, | |
| 'D#8': 111, | |
| 'D-8': 110, | |
| 'C#8': 109, | |
| 'C-8': 108, | |
| 'B-7': 107, | |
| 'A#7': 106, | |
| 'A-7': 105, | |
| 'G#7': 104, | |
| 'G-7': 103, | |
| 'F#7': 102, | |
| 'F-7': 101, | |
| 'E-7': 100, | |
| 'D#7': 99, | |
| 'D-7': 98, | |
| 'C#7': 97, | |
| 'C-7': 96, | |
| 'B-6': 95, | |
| 'A#6': 94, | |
| 'A-6': 93, | |
| 'G#6': 92, | |
| 'G-6': 91, | |
| 'F#6': 90, | |
| 'F-6': 89, | |
| 'E-6': 88, | |
| 'D#6': 87, | |
| 'D-6': 86, | |
| 'C#6': 85, | |
| 'C-6': 84, | |
| 'B-5': 83, | |
| 'A#5': 82, | |
| 'A-5': 81, | |
| 'G#5': 80, | |
| 'G-5': 79, | |
| 'F#5': 78, | |
| 'F-5': 77, | |
| 'E-5': 76, | |
| 'D#5': 75, | |
| 'D-5': 74, | |
| 'C#5': 73, | |
| 'C-5': 72, | |
| 'B-4': 71, | |
| 'A#4': 70, | |
| 'A-4': 69, | |
| 'G#4': 68, | |
| 'G-4': 67, | |
| 'F#4': 66, | |
| 'F-4': 65, | |
| 'E-4': 64, | |
| 'D#4': 63, | |
| 'D-4': 62, | |
| 'C#4': 61, | |
| 'C-4': 60, | |
| 'B-3': 59, | |
| 'A#3': 58, | |
| 'A-3': 57, | |
| 'G#3': 56, | |
| 'G-3': 55, | |
| 'F#3': 54, | |
| 'F-3': 53, | |
| 'E-3': 52, | |
| 'D#3': 51, | |
| 'D-3': 50, | |
| 'C#3': 49, | |
| 'C-3': 48, | |
| 'B-2': 47, | |
| 'A#2': 46, | |
| 'A-2': 45, | |
| 'G#2': 44, | |
| 'G-2': 43, | |
| 'F#2': 42, | |
| 'F-2': 41, | |
| 'E-2': 40, | |
| 'D#2': 39, | |
| 'D-2': 38, | |
| 'C#2': 37, | |
| 'C-2': 36, | |
| 'B-1': 35, | |
| 'A#1': 34, | |
| 'A-1': 33, | |
| 'G#1': 32, | |
| 'G-1': 31, | |
| 'F#1': 30, | |
| 'F-1': 29, | |
| 'E-1': 28, | |
| 'D#1': 27, | |
| 'D-1': 26, | |
| 'C#1': 25, | |
| 'C-1': 24, | |
| 'B-0': 23, | |
| 'A#0': 22, | |
| 'A-0': 21, | |
| 'G#0': 20, | |
| 'G-0': 19, | |
| 'F#0': 18, | |
| 'F-0': 17, | |
| 'E-0': 16, | |
| 'D#0': 15, | |
| 'D-0': 14, | |
| 'C#0': 13, | |
| 'C-0': 12, | |
| # Noise | |
| '0-#': 36, | |
| '1-#': 37, | |
| '2-#': 38, | |
| '3-#': 39, | |
| '4-#': 40, | |
| '5-#': 41, | |
| '6-#': 42, | |
| '7-#': 43, | |
| '8-#': 44, | |
| '9-#': 45, | |
| 'A-#': 46, | |
| 'B-#': 47, | |
| 'C-#': 48, | |
| 'D-#': 49, | |
| 'E-#': 50, | |
| 'F-#': 51, | |
| } | |
| def to_midi(tempo, info) -> MidiFile: | |
| column_count = len(info) | |
| mid = MidiFile() | |
| mid.tracks.append([ | |
| MetaMessage('track_name', name='Clipping', time=0), | |
| MetaMessage('set_tempo', tempo=60*1000000//tempo, time=0), | |
| MetaMessage('end_of_track', time=0)]) | |
| tracks = {} | |
| for ci in range(column_count): | |
| tracks[ci] = [MetaMessage('track_name', name=ChannelTypeName.get(ci, f'FM Channel {ci-4}'), time=0)] | |
| for ci in range(column_count): | |
| channel_type = ci | |
| last_id = 0 | |
| last = Row.none() | |
| for i, current in enumerate(info[channel_type]): | |
| if not current.is_none(): | |
| if last.is_normal(): | |
| time = i - last_id | |
| action = 'note_off' | |
| pitch = pitch_map[last.pitch] | |
| instrument = last.instrument.id | |
| last_id = i | |
| tracks[channel_type].append(Message(action, channel=instrument, note=pitch, velocity=0, time=time*120//4)) | |
| if not current.is_stop(): | |
| time = i - last_id | |
| action = 'note_on' | |
| pitch = pitch_map[current.pitch] | |
| instrument = current.instrument.id | |
| tracks[channel_type].append(Message(action, channel=instrument, note=pitch, velocity=127, time=time*120//4)) | |
| last = current | |
| last_id = i | |
| if i == len(info[channel_type]) - 1: | |
| if last.is_normal(): | |
| time = i - last_id | |
| action = 'note_off' | |
| pitch = pitch_map[last.pitch] | |
| instrument = last.instrument.id | |
| last_id = i | |
| tracks[channel_type].append(Message(action, channel=instrument, note=pitch, velocity=0, time=time*120//4)) | |
| for ci in range(column_count): | |
| mid.tracks.append(tracks[ci]) | |
| return mid | |
| @dataclasses.dataclass | |
| class Song: | |
| tempo: int | |
| info: dict | |
| def get_file_name(song_name): | |
| the_set = '\\/:*?"<>|' | |
| for c in the_set: | |
| song_name = song_name.replace(c, f'%{ord(c):02x}') | |
| return song_name | |
| def main(args): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument('input', type=Path) | |
| parser.add_argument('--dump', action='store_true') | |
| parser.add_argument('--output', type=Path) | |
| args = parser.parse_args(args) | |
| full_text = open(args.input, encoding='utf-8').read() | |
| songs = [] | |
| for content in get_split(TrackRegex.replace('(','').replace(')',''), full_text): | |
| songs.append(get_info(content)) | |
| for song_id, song in enumerate(songs): | |
| tempo, song_name, info = song | |
| if args.dump: | |
| for i in range(len(info[0])): | |
| print(f'{i:04x}', *[info[ci][i] for ci in range(len(info))]) | |
| mid_file = to_midi(tempo, info) | |
| if len(songs) == 1: | |
| if args.output is None: | |
| output_path = args.input.with_suffix('.mid') | |
| else: | |
| output_path = args.output.with_suffix('.mid') | |
| else: | |
| if args.output is None: | |
| output_dir_path = args.input.with_suffix('') | |
| else: | |
| output_dir_path = args.output | |
| output_dir_path.mkdir(exist_ok=True, parents=True) | |
| output_path = output_dir_path / f'{song_id+1}.{get_file_name(song_name)}.mid' | |
| mid_file.save(output_path) | |
| if __name__ == '__main__': | |
| sys.exit(main(sys.argv[1:])) |
Author
um, how do i do this?
Author
um, how do i do this?
At first, open your 0cc file with 0cc-FamiTracker, then save it as .txt.
Run python3 convert.py <your_txt_file>.
The .mid file can be opened with your DAW, you can import it, then edit them in DAW.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Requirement: