From d058f11329891fa283b984e986b85aedff0592e9 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Wed, 6 May 2026 05:54:36 +0900 Subject: [PATCH] adding missing mon2taud --- mon2taud.py | 422 ++++++++++++++++++++++++++++++++++++++++++++++++++ terranmon.txt | 3 +- 2 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 mon2taud.py diff --git a/mon2taud.py b/mon2taud.py new file mode 100644 index 0000000..6e963ff --- /dev/null +++ b/mon2taud.py @@ -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(' 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(' 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('