Skip to content

Instantly share code, notes, and snippets.

@ChillMagic
Last active June 13, 2025 06:50
Show Gist options
  • Select an option

  • Save ChillMagic/f675e674a7247a62e8dcdb6265f254ec to your computer and use it in GitHub Desktop.

Select an option

Save ChillMagic/f675e674a7247a62e8dcdb6265f254ec to your computer and use it in GitHub Desktop.
Convert 0cc-FamiTracker txt to midi
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:]))
@ChillMagic
Copy link
Copy Markdown
Author

Requirement:

python3 -m pip install mido

@vinnyg0621
Copy link
Copy Markdown

um, how do i do this?

@ChillMagic
Copy link
Copy Markdown
Author

ChillMagic commented Jun 13, 2025

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