mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
adding missing mon2taud
This commit is contained in:
422
mon2taud.py
Normal file
422
mon2taud.py
Normal file
@@ -0,0 +1,422 @@
|
||||
#!/usr/bin/env python3
|
||||
"""mon2taud.py — Convert Monotone (.MON) tracker modules to TSVM Taud (.taud)
|
||||
|
||||
Usage:
|
||||
python3 mon2taud.py input.MON output.taud [-v]
|
||||
|
||||
Monotone is Calvin "Trixter" French's tracker for the PC speaker / Tandy /
|
||||
TI-99 SN76489. It has no user-defined instruments (the only instrument is
|
||||
the beeper), 1..12 voices, 64 rows per pattern, ProTracker-flavoured 2-byte
|
||||
cells and a reduced 8-effect set: 0,1,2,3,4,B,D,F.
|
||||
|
||||
This converter:
|
||||
- synthesises a single 32-byte squarewave instrument (instrument #1)
|
||||
- splits each Monotone pattern (64 × N voices) into N Taud patterns
|
||||
- converts notes (A0=27.5 Hz chromatic) to Taud 4096-TET centred on C4
|
||||
- maps the 8 Monotone effects to their closest Taud equivalents
|
||||
- approximates Hz/tick slides (1xx/2xx/3xx) at an A4=440 Hz reference
|
||||
|
||||
Limits: numVoices ≤ 20, numPatterns × numVoices ≤ 4095.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
|
||||
from taud_common import (
|
||||
set_verbose, vprint,
|
||||
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
|
||||
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE,
|
||||
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
||||
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
|
||||
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_E, TOP_F, TOP_G, TOP_H, TOP_J,
|
||||
SEL_SET, SEL_FINE,
|
||||
J_SEMI_TABLE,
|
||||
encode_cue, deduplicate_patterns, encode_song_entry,
|
||||
)
|
||||
|
||||
|
||||
# ── Monotone constants ───────────────────────────────────────────────────────
|
||||
|
||||
MON_MAGIC_PREFIX = b'\x08MONOTONE' # only the first 9 bytes are stable
|
||||
MON_HEADER_SIZE = 0x15F # 92 magic + 3 meta + 256 order list
|
||||
MON_PATTERN_ROWS = 64
|
||||
MON_CELL_BYTES = 2
|
||||
|
||||
# Effect-code (3-bit) → ProTracker-style letter, following the format-doc table.
|
||||
MON_EFFECT_LETTERS = ['0', '1', '2', '3', '4', 'B', 'D', 'F']
|
||||
|
||||
# Note value 1 = A0; C4 sits at value 40 (A0 + 39 semitones).
|
||||
MON_NOTE_C4 = 40
|
||||
|
||||
# Slides are linear-in-Hz on Monotone but linear-in-4096-TET on Taud. Take A4
|
||||
# (440 Hz) as the reference: 1 Hz at A4 ≈ 12/(440·ln 2) semitones, scaled by
|
||||
# 4096/12 to Taud units. ≈ 161.0. Off by ±1 octave at the extremes; documented
|
||||
# in the script header.
|
||||
SLIDE_UNITS_PER_HZ = 12.0 / (440.0 * math.log(2.0)) * 4096.0 / 12.0
|
||||
|
||||
|
||||
# ── Taud container ───────────────────────────────────────────────────────────
|
||||
|
||||
SIGNATURE = b"mon2taud/TSVM " # 14 bytes
|
||||
|
||||
|
||||
# ── Monotone parser ──────────────────────────────────────────────────────────
|
||||
|
||||
class MonRow:
|
||||
__slots__ = ('note', 'effect', 'effect_arg')
|
||||
def __init__(self):
|
||||
self.note = 0 # 0 = empty, 0x7F = note off, else 1..126
|
||||
self.effect = 0 # 0..7 (raw 3-bit code)
|
||||
self.effect_arg = 0 # 0..63 (6-bit data)
|
||||
|
||||
|
||||
def parse_mon(data: bytes):
|
||||
if len(data) < MON_HEADER_SIZE:
|
||||
sys.exit(f"error: file too short ({len(data)} bytes); "
|
||||
f"need at least {MON_HEADER_SIZE} for the header")
|
||||
|
||||
if data[:9] != MON_MAGIC_PREFIX:
|
||||
sys.exit(f"error: bad magic; expected '\\x08MONOTONE', got {data[:9]!r}")
|
||||
|
||||
song_len = data[0x5C]
|
||||
num_voices = data[0x5D]
|
||||
if num_voices < 1 or num_voices > 12:
|
||||
sys.exit(f"error: invalid voice count {num_voices} (expected 1..12)")
|
||||
|
||||
order_raw = data[0x5F:0x15F]
|
||||
# Effective order list: take first song_len entries and drop 0xFF skip-slots
|
||||
# (matches mtreader.lua and MT_PLAY.PAS' "ignore 0xFF" semantics).
|
||||
order_list = [b for b in order_raw[:song_len] if b != 0xFF]
|
||||
if not order_list:
|
||||
sys.exit("error: order list is empty after filtering 0xFF skip slots")
|
||||
|
||||
n_patterns = max(order_list) + 1
|
||||
pattern_size = MON_PATTERN_ROWS * num_voices * MON_CELL_BYTES
|
||||
expected = MON_HEADER_SIZE + n_patterns * pattern_size
|
||||
if len(data) < expected:
|
||||
sys.exit(f"error: file truncated; expected {expected} bytes for "
|
||||
f"{n_patterns} patterns × {num_voices} voices, got {len(data)}")
|
||||
|
||||
# patterns[pi][voice][row] -> MonRow
|
||||
patterns = []
|
||||
for pi in range(n_patterns):
|
||||
base = MON_HEADER_SIZE + pi * pattern_size
|
||||
grid = [[MonRow() for _ in range(MON_PATTERN_ROWS)] for _ in range(num_voices)]
|
||||
for r in range(MON_PATTERN_ROWS):
|
||||
row_off = base + r * num_voices * MON_CELL_BYTES
|
||||
for v in range(num_voices):
|
||||
cell_off = row_off + v * MON_CELL_BYTES
|
||||
# Little-endian 16-bit cell.
|
||||
word = data[cell_off] | (data[cell_off + 1] << 8)
|
||||
cell = grid[v][r]
|
||||
cell.note = (word >> 9) & 0x7F
|
||||
cell.effect = (word >> 6) & 0x07
|
||||
cell.effect_arg = word & 0x3F
|
||||
patterns.append(grid)
|
||||
|
||||
return {
|
||||
'song_len': song_len,
|
||||
'num_voices': num_voices,
|
||||
'order_list': order_list,
|
||||
'n_patterns': n_patterns,
|
||||
'patterns': patterns,
|
||||
}
|
||||
|
||||
|
||||
# ── Note conversion (Monotone → Taud 4096-TET) ───────────────────────────────
|
||||
|
||||
def mon_note_to_taud(mon_note: int) -> int:
|
||||
if mon_note == 0:
|
||||
return NOTE_NOP
|
||||
if mon_note == 0x7F:
|
||||
return NOTE_KEYOFF
|
||||
val = TAUD_C4 + round((mon_note - MON_NOTE_C4) * 4096.0 / 12.0)
|
||||
return max(1, min(0xFFFD, val))
|
||||
|
||||
|
||||
# ── Effect mapping (Monotone 3-bit code + 6-bit data → Taud) ─────────────────
|
||||
|
||||
def encode_effect(eff_code: int, data: int) -> tuple:
|
||||
"""Return (taud_op, taud_arg16)."""
|
||||
letter = MON_EFFECT_LETTERS[eff_code & 7]
|
||||
|
||||
if letter == '0':
|
||||
if data == 0:
|
||||
return (TOP_NONE, 0)
|
||||
x = (data >> 3) & 0x7
|
||||
y = data & 0x7
|
||||
return (TOP_J, (J_SEMI_TABLE[x] << 8) | J_SEMI_TABLE[y])
|
||||
|
||||
if letter == '1': # slide up Hz/tick → Taud F
|
||||
arg = round(data * SLIDE_UNITS_PER_HZ) & 0xFFFF
|
||||
return (TOP_F, arg)
|
||||
|
||||
if letter == '2': # slide down Hz/tick → Taud E
|
||||
arg = round(data * SLIDE_UNITS_PER_HZ) & 0xFFFF
|
||||
return (TOP_E, arg)
|
||||
|
||||
if letter == '3': # tone porta Hz/tick → Taud G
|
||||
arg = round(data * SLIDE_UNITS_PER_HZ) & 0xFFFF
|
||||
return (TOP_G, arg)
|
||||
|
||||
if letter == '4': # vibrato xy → Taud H
|
||||
x = (data >> 3) & 0x7 # speed (3 bits)
|
||||
y = data & 0x7 # depth (3 bits)
|
||||
# Scale 3-bit nibble (0..7) to 8-bit byte (0..252) via × 0x24 (= 36).
|
||||
return (TOP_H, ((x * 0x24) << 8) | (y * 0x24))
|
||||
|
||||
if letter == 'B': # position jump → Taud B
|
||||
return (TOP_B, data & 0xFF)
|
||||
|
||||
if letter == 'D': # pattern break → Taud C
|
||||
return (TOP_C, data & 0xFF)
|
||||
|
||||
if letter == 'F': # set speed → Taud A
|
||||
if data == 0: # invalid in Monotone
|
||||
return (TOP_NONE, 0)
|
||||
return (TOP_A, (data & 0xFF) << 8)
|
||||
|
||||
return (TOP_NONE, 0)
|
||||
|
||||
|
||||
# ── Squarewave instrument synthesis ──────────────────────────────────────────
|
||||
|
||||
# 32-byte single-cycle 50%-duty square; played at 8372 Hz at C4 → 261.6 Hz tone.
|
||||
SQUARE_SAMPLE = bytes([0xFF] * 16 + [0x00] * 16)
|
||||
SQUARE_C2SPD = 8372
|
||||
|
||||
def build_sample_inst_bin() -> bytes:
|
||||
"""Emit the full 786432-byte sample+instrument bin.
|
||||
|
||||
Instrument 1 carries the synthesised square wave; all other slots stay
|
||||
zero. Sample bin starts with the 32-byte square at offset 0; rest is
|
||||
silence padding.
|
||||
"""
|
||||
sample_bin = bytearray(SAMPLEBIN_SIZE)
|
||||
sample_bin[0:len(SQUARE_SAMPLE)] = SQUARE_SAMPLE
|
||||
|
||||
inst_bin = bytearray(INSTBIN_SIZE)
|
||||
base = 1 * 256 # instrument #1 (slot 0 always blank)
|
||||
struct.pack_into('<I', inst_bin, base + 0, 0) # sample ptr
|
||||
struct.pack_into('<H', inst_bin, base + 4, len(SQUARE_SAMPLE)) # length
|
||||
struct.pack_into('<H', inst_bin, base + 6, SQUARE_C2SPD) # rate at C4
|
||||
struct.pack_into('<H', inst_bin, base + 8, 0) # play start
|
||||
struct.pack_into('<H', inst_bin, base + 10, 0) # loop start
|
||||
struct.pack_into('<H', inst_bin, base + 12, len(SQUARE_SAMPLE)) # loop end
|
||||
inst_bin[base + 14] = 0x01 # forward loop
|
||||
struct.pack_into('<H', inst_bin, base + 15, 0x0020) # vol-env enabled
|
||||
struct.pack_into('<H', inst_bin, base + 17, 0) # pan-env flags
|
||||
struct.pack_into('<H', inst_bin, base + 19, 0) # pitch-env flags
|
||||
inst_bin[base + 21] = 63 # vol env pt 0 = full
|
||||
inst_bin[base + 22] = 0
|
||||
inst_bin[base + 171] = 0xA0 # IGV
|
||||
inst_bin[base + 177] = 0x80 # default pan = centre
|
||||
inst_bin[base + 182] = 0xFF # filter cutoff off
|
||||
inst_bin[base + 183] = 0xFF # filter resonance off
|
||||
inst_bin[base + 186] = 0x01 # NNA: cut
|
||||
|
||||
return bytes(sample_bin) + bytes(inst_bin)
|
||||
|
||||
|
||||
# ── Pattern build ────────────────────────────────────────────────────────────
|
||||
|
||||
def build_taud_pattern(grid: list, voice: int) -> bytes:
|
||||
"""Build one 512-byte Taud pattern from one Monotone voice's 64 rows."""
|
||||
out = bytearray(PATTERN_BYTES)
|
||||
rows = grid[voice]
|
||||
for r, row in enumerate(rows):
|
||||
note_taud = mon_note_to_taud(row.note)
|
||||
# Trigger instrument #1 only when an actual note (1..0x7E) starts.
|
||||
triggers = (1 <= row.note <= 0x7E)
|
||||
|
||||
op, arg = encode_effect(row.effect, row.effect_arg)
|
||||
|
||||
# Volume column: Monotone has none → permanent no-op (FINE 0).
|
||||
vol_byte = (SEL_FINE << 6) | 0
|
||||
# Pan column: SET centre on row 0, no-op afterwards.
|
||||
if r == 0:
|
||||
pan_byte = (SEL_SET << 6) | 32
|
||||
else:
|
||||
pan_byte = (SEL_FINE << 6) | 0
|
||||
|
||||
base = r * 8
|
||||
struct.pack_into('<H', out, base + 0, note_taud)
|
||||
out[base + 2] = 1 if triggers else 0
|
||||
out[base + 3] = vol_byte
|
||||
out[base + 4] = pan_byte
|
||||
out[base + 5] = op & 0xFF
|
||||
struct.pack_into('<H', out, base + 6, arg & 0xFFFF)
|
||||
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_cue_sheet(order_list: list, num_voices: int, pat_remap: dict) -> bytes:
|
||||
"""One cue per order-list entry; last cue carries the halt instruction."""
|
||||
sheet = bytearray(NUM_CUES * CUE_SIZE)
|
||||
for c in range(NUM_CUES):
|
||||
sheet[c*CUE_SIZE : (c+1)*CUE_SIZE] = encode_cue([], 0)
|
||||
|
||||
cue_idx = 0
|
||||
last_active = -1
|
||||
for order in order_list:
|
||||
if cue_idx >= NUM_CUES:
|
||||
break
|
||||
orig_pats = [order * num_voices + v for v in range(num_voices)]
|
||||
mapped = [pat_remap[p] for p in orig_pats]
|
||||
sheet[cue_idx*CUE_SIZE : (cue_idx+1)*CUE_SIZE] = encode_cue(mapped, 0)
|
||||
last_active = cue_idx
|
||||
cue_idx += 1
|
||||
|
||||
if last_active >= 0:
|
||||
sheet[last_active * CUE_SIZE + 30] = 0x01
|
||||
|
||||
return bytes(sheet)
|
||||
|
||||
|
||||
# ── Initial speed scan ───────────────────────────────────────────────────────
|
||||
|
||||
def find_initial_speed(patterns: list, order_list: list, num_voices: int) -> int:
|
||||
"""Pick up an Fxx in the first ordered pattern's row 0 if present.
|
||||
|
||||
Default tempo per MT_PLAY.PAS:238-239 is `max(numTracks, 4)`.
|
||||
"""
|
||||
default_speed = max(num_voices, 4)
|
||||
if not order_list:
|
||||
return default_speed
|
||||
first = order_list[0]
|
||||
if first >= len(patterns):
|
||||
return default_speed
|
||||
grid = patterns[first]
|
||||
for v in range(num_voices):
|
||||
row = grid[v][0]
|
||||
if row.effect == 7 and 0 < row.effect_arg < 0x40: # Fxx (idx 7)
|
||||
return row.effect_arg
|
||||
return default_speed
|
||||
|
||||
|
||||
# ── Top-level assembly ───────────────────────────────────────────────────────
|
||||
|
||||
def assemble_taud(mon: dict) -> bytes:
|
||||
num_voices = mon['num_voices']
|
||||
patterns = mon['patterns']
|
||||
order_list = mon['order_list']
|
||||
n_patterns = mon['n_patterns']
|
||||
|
||||
if num_voices > NUM_VOICES:
|
||||
vprint(f" warning: {num_voices} voices > {NUM_VOICES}; truncating")
|
||||
num_voices = NUM_VOICES
|
||||
|
||||
if n_patterns * num_voices > NUM_PATTERNS_MAX:
|
||||
sys.exit(
|
||||
f"error: {n_patterns} patterns × {num_voices} voices = "
|
||||
f"{n_patterns*num_voices} > {NUM_PATTERNS_MAX} Taud limit"
|
||||
)
|
||||
|
||||
vprint(f" voices: {num_voices}, mon patterns: {n_patterns}, "
|
||||
f"taud patterns: {n_patterns * num_voices}")
|
||||
|
||||
speed = find_initial_speed(patterns, order_list, num_voices)
|
||||
vprint(f" initial speed (ticks/row): {speed}")
|
||||
|
||||
vprint(" building sample/instrument bin…")
|
||||
sampleinst_raw = build_sample_inst_bin()
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
|
||||
comp_size = len(compressed)
|
||||
vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)")
|
||||
|
||||
vprint(" building pattern bin…")
|
||||
pat_bin = bytearray()
|
||||
for pi in range(n_patterns):
|
||||
grid = patterns[pi]
|
||||
for v in range(num_voices):
|
||||
pat_bin += build_taud_pattern(grid, v)
|
||||
assert len(pat_bin) == n_patterns * num_voices * PATTERN_BYTES
|
||||
|
||||
vprint(" deduplicating patterns…")
|
||||
orig_count = n_patterns * num_voices
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
|
||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique "
|
||||
f"({orig_count - num_taud_pats} deduplicated)")
|
||||
|
||||
vprint(" building cue sheet…")
|
||||
cue_sheet = build_cue_sheet(order_list, num_voices, pat_remap)
|
||||
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
|
||||
|
||||
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
|
||||
cue_comp = gzip.compress(bytes(cue_sheet), compresslevel=9, mtime=0)
|
||||
vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)")
|
||||
vprint(f" cue sheet: {len(cue_sheet)} → {len(cue_comp)} bytes (gzip)")
|
||||
|
||||
# Header: magic, version, num_songs=1, comp_size of sample+inst, projOff=0, sig.
|
||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||
header = (
|
||||
TAUD_MAGIC
|
||||
+ bytes([TAUD_VERSION, 1])
|
||||
+ struct.pack('<I', comp_size)
|
||||
+ b'\x00\x00\x00\x00'
|
||||
+ sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
||||
|
||||
# BPM 150 + ticks=mon_speed → row rate = 60/mon_speed (matches Monotone).
|
||||
bpm_stored = 150 - 24
|
||||
flags_byte = 0x04 # m bit: fadeout-zero policy = cut on key-off.
|
||||
|
||||
song_table = encode_song_entry(
|
||||
song_offset = song_offset,
|
||||
num_voices = num_voices,
|
||||
num_patterns = num_taud_pats,
|
||||
bpm_stored = bpm_stored,
|
||||
tick_rate = speed,
|
||||
base_note = 0xA000,
|
||||
base_freq = SQUARE_C2SPD,
|
||||
flags_byte = flags_byte,
|
||||
pat_bin_comp_size = len(pat_comp),
|
||||
cue_sheet_comp_size = len(cue_comp),
|
||||
global_vol = 0xFF,
|
||||
mixing_vol = round(180 / num_voices),
|
||||
)
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument('input', help='Input .MON file')
|
||||
ap.add_argument('output', help='Output .taud file')
|
||||
ap.add_argument('-v', '--verbose', action='store_true',
|
||||
help='Print conversion details to stderr')
|
||||
args = ap.parse_args()
|
||||
|
||||
set_verbose(args.verbose)
|
||||
|
||||
with open(args.input, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
vprint(f"parsing '{args.input}' ({len(data)} bytes)…")
|
||||
mon = parse_mon(data)
|
||||
vprint(f" songLen={mon['song_len']}, voices={mon['num_voices']}, "
|
||||
f"patterns={mon['n_patterns']}, orders={len(mon['order_list'])}")
|
||||
|
||||
taud = assemble_taud(mon)
|
||||
|
||||
with open(args.output, 'wb') as f:
|
||||
f.write(taud)
|
||||
|
||||
print(f"wrote {len(taud)} bytes to '{args.output}'")
|
||||
if args.verbose:
|
||||
print(f" magic ok: {taud[:8].hex()}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -2173,9 +2173,10 @@ TODO:
|
||||
[x] scale Oxxxx when samples get resampled
|
||||
[x] implement bitcrusher and overdrive (eff sym '8' and '9')
|
||||
[x] note trigger with inst and note fx set (e.g. portamento) but no volume set is not getting their default volume but getting what was before instead (SATELL.taud ptn 23) -- and simulateRowState() of taut.js always shows old volume instead of default volume, regardless of note fx's existence
|
||||
[ ] how does fadeout=0 work on IT? On XM, the note don't decay at all (that's why there's separate CUT value). Also see what Global Behaviour 'm' flag actually do on Taud (or, which slop AI had fed me *sigh*). `slumberjack.xm` plays normally but notes of `4THSYM.it` don't decay at all
|
||||
[ ] implement extended tone mode (MONOTONE compat)
|
||||
[ ] pattern loops stops working after processed once (test with slumberjack.xm)
|
||||
[ ] how does fadeout=0 work on IT? On XM, the note don't decay at all (that's why there's separate CUT value). Also see what Global Behaviour 'm' flag actually do on Taud (or, which slop AI had fed me *sigh*)
|
||||
[ ] milkytracker-style volume ramping (on sample-end only)
|
||||
|
||||
|
||||
Play Data: play data are series of tracker-like instructions, visualised as:
|
||||
|
||||
Reference in New Issue
Block a user