Files
tsvm/mod2taud.py
2026-05-03 15:10:36 +09:00

808 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""mod2taud.py — Convert ProTracker (.mod) to TSVM Taud (.taud)
Usage:
python3 mod2taud.py input.mod output.taud [-v]
Limits:
- Up to 20 MOD channels (excess disabled; hard error if pattern count
× channel count > 4095).
- Sample bin is 737280 bytes; if all samples together exceed this, every
sample is globally resampled down (with c2spd adjusted) so pitch is
preserved.
Effect support:
Full PT effect dispatch per TAUD_NOTE_EFFECTS.md "ProTracker to Taud
conversion table". PT recalls (effect $00 args) are eagerly resolved
per channel using PT's per-effect private memory model. Cxx folds
into the volume column (0.$xx). Axy / EAx / EBx fold into the volume
column. 8xx and E8x fold into the pan column. Periods convert to Taud
units via log2 against PT period 428 (≡ Taud C3). Sample finetune is
pre-baked into the per-instrument c2spd. Amiga-mode flag is set in
the song-table flags byte so the engine applies coarse pitch slides
in period space.
"""
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_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y,
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
J_SEMI_TABLE,
d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns,
encode_song_entry,
)
# ── MOD constants ────────────────────────────────────────────────────────────
MOD_NUM_SAMPLES = 31
MOD_PATTERN_ROWS = 64
# PT effect numbers (single hex digit 0..F). Effect $E uses sub-nibbles.
PT_E_BASE = 0xE
PT_F = 0xF
# PT effects that have private memory and therefore recall their last
# non-zero argument when re-issued with $00. Top-level effects:
PT_MEM_TOP = frozenset({0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0xA})
# E sub-effects with memory (key is sub-nibble of the E command):
PT_MEM_E_SUB = frozenset({0x1, 0x2, 0xA, 0xB})
# ── Taud constants (mod-specific) ────────────────────────────────────────────
SIGNATURE = b"mod2taud/TSVM " # 14 bytes
# PT period 428 (PT "C-2") corresponds to OpenMPT/IT C-4 which s3m2taud
# anchors to Taud C4 (0x5000). We use the same anchor so MOD/S3M imports
# share a pitch reference.
PT_REFERENCE_PERIOD = 428.0
# ── MOD parser ───────────────────────────────────────────────────────────────
class ModSample:
__slots__ = ('name','length','finetune','volume','loop_begin','loop_end',
'sample_data','c2spd','flags')
class ModRow:
__slots__ = ('period','inst','effect','effect_arg','vol_set')
def __init__(self):
self.period = 0 # 0 = empty / no trigger
self.inst = 0 # 0 = no instrument set
self.effect = 0 # PT effect digit ($0..$F)
self.effect_arg = 0
# PT has no volume column; Cxx folds into vol_set during parsing.
# -1 = no explicit volume.
self.vol_set = -1
def _parse_magic(magic: bytes) -> int:
"""Return number of channels declared by the 4-byte MOD magic."""
if magic in (b'M.K.', b'M!K!', b'FLT4', b'M&K!', b'N.T.'):
return 4
if magic == b'FLT8':
return 8
if magic == b'OCTA' or magic == b'CD81':
return 8
# xCHN (1..9 channels)
if len(magic) == 4 and magic[1:] == b'CHN' and 0x31 <= magic[0] <= 0x39:
return magic[0] - 0x30
# xxCH (10..32 channels)
if len(magic) == 4 and magic[2:] == b'CH' and magic[:2].isdigit():
return int(magic[:2].decode('ascii'))
# xxCN (e.g., 16CN — rare)
if len(magic) == 4 and magic[2:] == b'CN' and magic[:2].isdigit():
return int(magic[:2].decode('ascii'))
return 0
def parse_mod(data: bytes):
if len(data) < 0x43C:
sys.exit("error: file too short to be a ProTracker module")
title = data[0x00:0x14].rstrip(b'\x00').decode('latin-1', errors='replace')
# 31 sample headers
samples = []
for i in range(MOD_NUM_SAMPLES):
base = 0x14 + i * 30
s = ModSample()
s.name = data[base:base+22].rstrip(b'\x00').decode('latin-1', errors='replace')
s.length = struct.unpack_from('>H', data, base + 22)[0] * 2
s.finetune = data[base + 24] & 0x0F # signed nibble 0..15
s.volume = data[base + 25] # 0..64
s.loop_begin = struct.unpack_from('>H', data, base + 26)[0] * 2
loop_len_w = struct.unpack_from('>H', data, base + 28)[0]
loop_len = loop_len_w * 2
s.loop_end = s.loop_begin + loop_len
# Flag bit 0 = looped (loop_len > 2 by convention; loop_len_w == 1 means no loop)
s.flags = 1 if loop_len_w > 1 else 0
if not s.flags:
s.loop_begin = 0
s.loop_end = 0
s.sample_data = b''
s.c2spd = round(8363.0 * (2.0 ** (_signed4(s.finetune) / 96.0)))
samples.append(s)
song_length = data[0x3B6]
# 0x3B7 = restart byte (unused by us)
order_table = list(data[0x3B8:0x438])
magic = data[0x438:0x43C]
n_channels = _parse_magic(magic)
if n_channels == 0:
# Some very old MODs have only 15 samples and no magic. Detect 15-sample MOD.
# Header is 0x14 (title) + 15*30 (samples) = 0x14 + 0x1C2 = 0x1D6.
# Order table at 0x1D6, then 0x1D6+0x80 = 0x256, then patterns directly.
# We don't auto-detect that; require a magic.
sys.exit(f"error: unrecognised MOD magic {magic!r} at 0x438; "
f"expected M.K., M!K!, FLT4, FLT8, xCHN or xxCH")
# Order list: only the first song_length entries are part of the song.
# Pattern count = 1 + max(order_table[0..127]) (scan all 128).
n_patterns = 1 + max(order_table)
pat_data_off = 0x43C
cell_size = 4
pattern_size = MOD_PATTERN_ROWS * n_channels * cell_size
# Parse patterns
patterns = [] # patterns[pat_idx][channel][row] -> ModRow
for pi in range(n_patterns):
grid = [[ModRow() for _ in range(MOD_PATTERN_ROWS)] for _ in range(n_channels)]
base = pat_data_off + pi * pattern_size
if base + pattern_size > len(data):
vprint(f" warning: pattern {pi} truncated; padding with empty rows")
patterns.append(grid)
continue
for r in range(MOD_PATTERN_ROWS):
row_off = base + r * n_channels * cell_size
for ch in range(n_channels):
cell_off = row_off + ch * cell_size
b0 = data[cell_off]
b1 = data[cell_off + 1]
b2 = data[cell_off + 2]
b3 = data[cell_off + 3]
period = ((b0 & 0x0F) << 8) | b1
inst = (b0 & 0xF0) | ((b2 >> 4) & 0x0F)
effect = b2 & 0x0F
arg = b3
cell = grid[ch][r]
cell.period = period
cell.inst = inst
cell.effect = effect
cell.effect_arg = arg
patterns.append(grid)
# Sample data follows pattern data
sample_off = pat_data_off + n_patterns * pattern_size
for s in samples:
if s.length == 0:
continue
n = min(s.length, max(0, len(data) - sample_off))
if n <= 0:
break
raw = data[sample_off:sample_off + n]
# PT samples are signed 8-bit; convert to unsigned by XOR 0x80.
s.sample_data = bytes((b ^ 0x80) for b in raw)
s.length = len(s.sample_data)
if s.flags:
s.loop_begin = min(s.loop_begin, s.length)
s.loop_end = min(s.loop_end, s.length)
sample_off += n
return {
'title': title,
'samples': samples,
'order_list': order_table[:song_length],
'order_full': order_table,
'n_channels': n_channels,
'n_patterns': n_patterns,
'patterns': patterns,
'magic': magic,
}
def _signed4(nibble: int) -> int:
"""Convert a 4-bit unsigned nibble to signed -8..+7."""
return nibble - 16 if nibble >= 8 else nibble
# ── Note encoding (period → Taud) ────────────────────────────────────────────
def period_to_taud_note(period: int) -> int:
if period <= 0:
return NOTE_NOP
val = round(TAUD_C4 + 4096.0 * math.log2(PT_REFERENCE_PERIOD / period))
return max(1, min(0xFFFD, val))
# ── PT effect → Taud effect ──────────────────────────────────────────────────
def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
"""Return (taud_op, taud_arg16, vol_override, pan_override).
The caller is responsible for resolving PT zero-arg recalls before this
point — see resolve_pt_recalls(). cmd is the raw PT digit ($0..$F).
"""
# $0 with arg 0 is a true no-op; $0 with arg != 0 is arpeggio.
if cmd == 0x0:
if arg == 0:
return (TOP_NONE, 0, None, None)
hi = (arg >> 4) & 0xF
lo = arg & 0xF
return (TOP_J, (J_SEMI_TABLE[hi] << 8) | J_SEMI_TABLE[lo], None, None)
# PT is Amiga-cycle-based by definition (the Taud Amiga-mode flag is set in
# the song table, see end of build_taud()). E/F coarse pitch-slide arguments
# are therefore stored as raw PT period units; the engine consumes them
# directly in period space. G (tone portamento) is treated as linear even
# in Amiga mode per the Taud spec, so its argument is still quantised to
# 4096-TET units. Fine slides (E1x/E2x below) likewise remain linear.
if cmd == 0x1:
return (TOP_F, arg & 0xFFFF, None, None)
if cmd == 0x2:
return (TOP_E, arg & 0xFFFF, None, None)
if cmd == 0x3:
return (TOP_G, round(arg * 64 / 3) & 0xFFFF, None, None)
if cmd == 0x4:
hi = (arg >> 4) & 0xF
lo = arg & 0xF
return (TOP_H, ((hi * 0x11) << 8) | (lo * 0x11), None, None)
if cmd == 0x5:
# Tone porta + vol slide → Taud L (engine splits internally).
return (TOP_G, 0x0000, d_arg_to_col(arg), None)
if cmd == 0x6:
# Vibrato + vol slide → Taud K.
return (TOP_H, 0x0000, d_arg_to_col(arg), None)
if cmd == 0x7:
hi = (arg >> 4) & 0xF
lo = arg & 0xF
return (TOP_R, ((hi * 0x11) << 8) | (lo * 0x11), None, None)
if cmd == 0x8:
# PT 8xx is fine pan (or unused/sync in some trackers). Map to pan
# column 0.$yy where yy is the upper 6 bits of the 8-bit pan.
return (TOP_NONE, 0, None, (SEL_SET, (arg >> 2) & 0x3F))
if cmd == 0x9:
return (TOP_O, (arg & 0xFF) << 8, None, None)
if cmd == 0xA:
return (TOP_NONE, 0, d_arg_to_col(arg), None)
if cmd == 0xB:
return (TOP_B, arg & 0xFF, None, None)
if cmd == 0xC:
# Caller folds Cxx into vol_set during parsing; this branch is a
# safety net in case a Cxx slips through.
return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None)
if cmd == 0xD:
# PT pattern break is BCD on disk.
bcd_row = ((arg >> 4) & 0xF) * 10 + (arg & 0xF)
if bcd_row >= PATTERN_ROWS:
bcd_row = 0
return (TOP_C, bcd_row & 0xFF, None, None)
if cmd == 0xE:
sub = (arg >> 4) & 0xF
x = arg & 0xF
if sub == 0x0:
# E0x = filter on/off (Amiga LED filter); no Taud equivalent.
return (TOP_NONE, 0, None, None)
if sub == 0x1:
# Fine pitch up — raw PT period units in Amiga mode (file is always Amiga).
return (TOP_F, 0xF000 | (x & 0xFFF), None, None)
if sub == 0x2:
# Fine pitch down — raw PT period units in Amiga mode.
return (TOP_E, 0xF000 | (x & 0xFFF), None, None)
if sub == 0x3:
return (TOP_S, 0x1000 | (x << 8), None, None)
if sub == 0x4:
return (TOP_S, 0x3000 | (x << 8), None, None)
if sub == 0x5:
return (TOP_S, 0x2000 | (x << 8), None, None)
if sub == 0x6:
return (TOP_S, 0xB000 | (x << 8), None, None)
if sub == 0x7:
return (TOP_S, 0x4000 | (x << 8), None, None)
if sub == 0x8:
# Coarse pan (4-bit). Map nibble 0..15 to pan 0..63 via × 4.2.
return (TOP_NONE, 0, None, (SEL_SET, round(x * 4.2)))
if sub == 0x9:
return (TOP_Q, (x & 0xF) << 8, None, None)
if sub == 0xA:
# Fine vol slide up.
return (TOP_NONE, 0, (SEL_FINE, (x & 0xF) | 0x20), None)
if sub == 0xB:
# Fine vol slide down.
return (TOP_NONE, 0, (SEL_FINE, x & 0xF), None)
if sub == 0xC:
return (TOP_S, 0xC000 | (x << 8), None, None)
if sub == 0xD:
return (TOP_S, 0xD000 | (x << 8), None, None)
if sub == 0xE:
return (TOP_S, 0xE000 | (x << 8), None, None)
if sub == 0xF:
funk_table = [0, 5, 6, 7, 8, 0xA, 0xB, 0xD, 0x10, 0x13, 0x16, 0x1A, 0x20, 0x2B, 0x40, 0x80]
return (TOP_S, 0xF000 | funk_table[x], None, None)
return (TOP_NONE, 0, None, None)
if cmd == 0xF:
if arg < 0x20:
if arg == 0:
return (TOP_NONE, 0, None, None)
return (TOP_A, (arg & 0xFF) << 8, None, None)
return (TOP_T, ((arg - 0x18) & 0xFF) << 8, None, None)
return (TOP_NONE, 0, None, None)
def relocate_late_note_delays(patterns: list, order_list: list,
n_channels: int, initial_speed: int) -> None:
"""Move EDx-delayed notes to the next row when x ≥ tick speed.
PT triggers a Note Delay during the current row; if x reaches the tick
speed, the trigger never lands. When the next row in the same channel is
empty, relocate the note (with delay = x speed) so it actually plays.
"""
visited = set()
for order in order_list:
if order >= 0xFF:
break
if order >= len(patterns) or order in visited:
continue
visited.add(order)
grid = patterns[order]
speed = initial_speed
for r in range(MOD_PATTERN_ROWS):
for ch in range(min(n_channels, len(grid))):
row = grid[ch][r]
if row.effect == 0xF and 0 < row.effect_arg < 0x20:
speed = row.effect_arg
break
if r + 1 >= MOD_PATTERN_ROWS or speed <= 0:
continue
for ch in range(min(n_channels, len(grid))):
row = grid[ch][r]
if row.effect != 0xE or row.period == 0:
continue
if ((row.effect_arg >> 4) & 0xF) != 0xD:
continue
x = row.effect_arg & 0xF
if x < speed:
continue
nxt = grid[ch][r + 1]
if (nxt.period or nxt.inst or nxt.effect or nxt.effect_arg
or nxt.vol_set != -1):
continue
new_delay = x - speed
nxt.period = row.period
nxt.inst = row.inst
nxt.vol_set = row.vol_set
if new_delay > 0:
nxt.effect = 0xE
nxt.effect_arg = 0xD0 | (new_delay & 0xF)
row.period = 0
row.inst = 0
row.effect = 0
row.effect_arg = 0
row.vol_set = -1
vprint(f" fix: pat{order} ch{ch} row{r}: ED{x:X} ≥ speed{speed}, "
f"moved note to row{r+1}"
+ (f" with ED{new_delay:X}" if new_delay > 0 else ""))
def resolve_pt_recalls(patterns: list, order_list: list, n_channels: int) -> None:
"""In-place: replace PT zero-arg recalls with each effect's last non-zero arg.
PT memory is per-effect-private. Walking patterns in order-list order,
we track each channel's last non-zero arg per memorising effect and
rewrite recall args to make them explicit.
"""
# mem[ch][key] = last_non_zero_arg
# key is either an int (top-level 0..F) or a tuple ('E', sub) for E-subs.
mem = [dict() for _ in range(n_channels)]
for order in order_list:
if order >= 0xFF:
break
if order >= len(patterns):
continue
grid = patterns[order]
for r in range(MOD_PATTERN_ROWS):
for ch in range(n_channels):
if ch >= len(grid):
continue
row = grid[ch][r]
cmd = row.effect
arg = row.effect_arg
if cmd in PT_MEM_TOP:
if arg == 0:
row.effect_arg = mem[ch].get(cmd, 0)
else:
mem[ch][cmd] = arg
elif cmd == 0xE:
sub = (arg >> 4) & 0xF
x = arg & 0xF
if sub in PT_MEM_E_SUB:
key = ('E', sub)
if x == 0:
recalled = mem[ch].get(key, 0)
row.effect_arg = (sub << 4) | (recalled & 0xF)
else:
mem[ch][key] = x
# ── Sample resampling and Taud sample/instrument bin (port of s3m2taud) ──────
def build_sample_inst_bin(samples: list) -> tuple:
"""Returns (bin_bytes[786432], offsets_dict). 1-based indexing."""
pcm = [(i, s) for i, s in enumerate(samples) if s.sample_data]
total = sum(len(s.sample_data) for _, s in pcm)
ratio = 1.0
if total > SAMPLEBIN_SIZE:
ratio = SAMPLEBIN_SIZE / total
vprint(f" info: sample bin overflow ({total} bytes); resampling all by {ratio:.4f}")
for _, s in pcm:
new_data = resample_linear(s.sample_data, ratio)
s.sample_data = new_data
s.length = len(new_data)
s.loop_begin = max(0, int(s.loop_begin * ratio))
s.loop_end = max(0, min(int(s.loop_end * ratio), s.length))
s.c2spd = max(1, int(s.c2spd * ratio))
sample_bin = bytearray(SAMPLEBIN_SIZE)
offsets = {}
pos = 0
for idx, s in pcm:
n = min(len(s.sample_data), SAMPLEBIN_SIZE - pos)
if n <= 0:
vprint(f" warning: sample bin full, dropping '{s.name}'")
offsets[idx] = 0
s.length = 0
continue
sample_bin[pos:pos+n] = s.sample_data[:n]
offsets[idx] = pos
if n < len(s.sample_data):
vprint(f" warning: '{s.name}' truncated from {len(s.sample_data)} to {n}")
s.length = n
s.loop_end = min(s.loop_end, n)
pos += n
# New 192-byte instrument layout (terranmon.txt:1997-2070).
inst_bin = bytearray(INSTBIN_SIZE)
for i, s in enumerate(samples):
taud_idx = i + 1 # 1-based instrument number
if i >= 256:
break
if not s.sample_data:
continue
ptr = offsets.get(i, 0) & 0xFFFFFFFF
s_len = min(s.length, 65535)
c2spd = min(s.c2spd, 65535)
ps = 0
ls = min(s.loop_begin, 65535)
le = min(s.loop_end, 65535)
loop_mode = 1 if (s.flags & 1) else 0
flags_byte = loop_mode & 0x3
env_vol = min(s.volume, 63)
vol_env_flags = 0x0020 # use-envelope bit
base = taud_idx * 192
struct.pack_into('<I', inst_bin, base + 0, ptr)
struct.pack_into('<H', inst_bin, base + 4, s_len)
struct.pack_into('<H', inst_bin, base + 6, c2spd)
struct.pack_into('<H', inst_bin, base + 8, ps)
struct.pack_into('<H', inst_bin, base + 10, ls)
struct.pack_into('<H', inst_bin, base + 12, le)
inst_bin[base + 14] = flags_byte
struct.pack_into('<H', inst_bin, base + 15, vol_env_flags)
struct.pack_into('<H', inst_bin, base + 17, 0)
struct.pack_into('<H', inst_bin, base + 19, 0)
inst_bin[base + 21] = env_vol
inst_bin[base + 22] = 0
# Instrument Global Volume carries the MOD sample's default volume (0..64 → 0..255).
# The pattern builder no longer emits SEL_SET=Sv on note triggers; the engine
# multiplies by IGV instead, so the per-instrument level lives here.
inst_bin[base + 171] = min(0xFF, round(min(s.volume, 64) * 255 / 64))
inst_bin[base + 177] = 0x80 # default pan = centre (unused; pan env "p" flag not set)
inst_bin[base + 182] = 0xFF # filter cutoff = off
inst_bin[base + 183] = 0xFF # filter resonance = off
inst_bin[base + 186] = 1 # NNA: note cut
vprint(f" instrument[{taud_idx}] '{s.name}' ptr={ptr} c2spd={s.c2spd} "
f"vol={s.volume} loop=({ls},{le},{'on' if loop_mode else 'off'})")
return bytes(sample_bin) + bytes(inst_bin), offsets
# ── Pattern build ────────────────────────────────────────────────────────────
# PT hard-pans channels in LRRL order: 0=L 1=R 2=R 3=L (and tile for >4).
def _default_channel_pan(ch_idx: int) -> int:
side = (ch_idx % 4)
return 16 if side in (0, 3) else 47
def build_pattern(grid: list, ch_idx: int, default_pan: int,
inst_vols: dict) -> bytes:
"""Build a 512-byte Taud pattern for one MOD channel.
Volume column: explicit Cxx → SEL_SET; effect-folded vol slide → vol_override;
otherwise SEL_FINE/0 (no-op). Per-instrument default volume lives in IGV
(byte 171) and is applied by the engine on every fresh trigger — the
converter no longer has to emit SEL_SET=Sv to scale notes.
"""
out = bytearray(PATTERN_BYTES)
rows = grid[ch_idx] if ch_idx < len(grid) else [ModRow()] * MOD_PATTERN_ROWS
last_inst = 0
last_period = 0
for r, row in enumerate(rows[:MOD_PATTERN_ROWS]):
note_taud = period_to_taud_note(row.period)
note_triggers = (row.period > 0)
if row.inst > 0:
last_inst = row.inst
op, arg, vol_override, pan_override = encode_effect(
row.effect, row.effect_arg, ch_idx, r)
# ── Volume column ──
if row.vol_set >= 0:
vol_sel, vol_value = SEL_SET, min(row.vol_set, 0x3F)
if vol_override is not None and vol_override[0] != SEL_SET:
vprint(f" ch{ch_idx} row{r}: dropped vol slide "
f"(cell already carries explicit Cxx volume)")
elif vol_override is not None:
vol_sel, vol_value = vol_override
else:
vol_sel, vol_value = SEL_FINE, 0
if note_triggers:
last_period = row.period
# ── Pan column ──
if pan_override is not None:
pan_sel, pan_value = pan_override
elif r == 0:
pan_sel, pan_value = SEL_SET, default_pan & 0x3F
else:
pan_sel, pan_value = SEL_FINE, 0
vol_byte = (vol_value & 0x3F) | ((vol_sel & 0x3) << 6)
pan_byte = (pan_value & 0x3F) | ((pan_sel & 0x3) << 6)
base = r * 8
struct.pack_into('<H', out, base + 0, note_taud)
out[base + 2] = row.inst & 0xFF
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, n_pats_mod: int, n_channels: int,
pat_remap: dict = None) -> bytes:
sheet = bytearray(NUM_CUES * CUE_SIZE)
for c in range(NUM_CUES):
sheet[c*CUE_SIZE : c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0)
cue_idx = 0
last_active = -1
for order in order_list:
if order == 0xFF or cue_idx >= NUM_CUES:
break
if order == 0xFE:
continue
if order >= n_pats_mod:
continue
orig = [order * n_channels + v for v in range(n_channels)]
pats = [pat_remap[p] if pat_remap else p for p in orig]
sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = encode_cue(pats, 0)
last_active = cue_idx
cue_idx += 1
if last_active >= 0:
sheet[last_active * CUE_SIZE + 30] = 0x01
elif cue_idx < NUM_CUES:
sheet[30] = 0x01
return bytes(sheet)
def find_initial_bpm_speed(patterns: list, order_list: list) -> tuple:
"""Scan first pattern in order for Fxx in row 0 of any channel."""
speed = 6
tempo = 125
for order in order_list:
if order >= 0xFF:
break
if order >= len(patterns):
continue
grid = patterns[order]
for ch_rows in grid:
row = ch_rows[0]
if row.effect == 0xF and row.effect_arg > 0:
if row.effect_arg < 0x20:
speed = row.effect_arg
else:
tempo = row.effect_arg
break
return speed, tempo
def assemble_taud(mod: dict) -> bytes:
samples = mod['samples']
patterns = mod['patterns']
order_list = mod['order_list']
n_channels = mod['n_channels']
n_patterns = mod['n_patterns']
if n_channels > NUM_VOICES:
vprint(f" warning: MOD has {n_channels} channels; truncating to {NUM_VOICES}")
n_channels = NUM_VOICES
if n_patterns * n_channels > NUM_PATTERNS_MAX:
sys.exit(
f"error: {n_patterns} MOD patterns × {n_channels} channels = "
f"{n_patterns*n_channels} > {NUM_PATTERNS_MAX} Taud pattern limit.\n"
f" Reduce the MOD to ≤ {NUM_PATTERNS_MAX // max(n_channels,1)} patterns."
)
vprint(f" channels: {n_channels}, mod patterns: {n_patterns}, "
f"taud patterns: {n_patterns * n_channels}")
# Fold Cxx into row.vol_set so the volume column carries explicit set-volume.
# This is done in-place before recall resolution so Cxx with arg 0 still
# resolves to vol 0 (silence) rather than recalling another effect's memory.
for grid in patterns:
for ch in range(min(n_channels, len(grid))):
for row in grid[ch]:
if row.effect == 0xC:
row.vol_set = min(row.effect_arg, 0x3F)
row.effect = 0
row.effect_arg = 0
vprint(" resolving PT per-effect recalls…")
resolve_pt_recalls(patterns, order_list, n_channels)
init_speed, _ = find_initial_bpm_speed(patterns, order_list)
relocate_late_note_delays(patterns, order_list, n_channels, init_speed)
vprint(" building sample/instrument bin…")
sampleinst_raw, _offsets = build_sample_inst_bin(samples)
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)")
speed, tempo = find_initial_bpm_speed(patterns, order_list)
tempo = max(24, min(280, tempo))
bpm_stored = (tempo - 24) & 0xFF
vprint(f" initial speed={speed}, tempo(BPM)={tempo}")
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
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
vprint(" building pattern bin…")
inst_vols = {
i + 1: min(s.volume, 0x3F)
for i, s in enumerate(samples)
if s.sample_data
}
pat_bin = bytearray()
for pi in range(n_patterns):
grid = patterns[pi]
for ch in range(n_channels):
default_pan = _default_channel_pan(ch)
pat_bin += build_pattern(grid, ch, default_pan, inst_vols)
assert len(pat_bin) == n_patterns * n_channels * PATTERN_BYTES
vprint(" deduplicating patterns…")
orig_count = n_patterns * n_channels
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, n_patterns, n_channels, 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)")
# ProTracker is Amiga-period-based by definition, so we set the f bit so
# the engine applies coarse pitch slides in period space (recovers PT's
# characteristic non-linear pitch character).
# bit 2 (m) set: FT2 fadeout-zero policy — PT has no fadeout, so the stored
# zero on every instrument means "cut on key-off" (unified with S3M imports).
flags_byte = 0x02 | 0x04
song_table = encode_song_entry(
song_offset=song_offset,
num_voices=n_channels,
num_patterns=num_taud_pats,
bpm_stored=bpm_stored,
tick_rate=speed,
base_note=0xA000,
base_freq=8363.0,
flags_byte=flags_byte,
pat_bin_comp_size=len(pat_comp),
cue_sheet_comp_size=len(cue_comp),
global_vol=0xFF,
mixing_vol=0x7F,
)
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 .mod 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)…")
mod = parse_mod(data)
vprint(f" title: '{mod['title']}'")
vprint(f" magic: {mod['magic']!r} ({mod['n_channels']} channels)")
vprint(f" orders={len(mod['order_list'])}, patterns={mod['n_patterns']}, "
f"samples={sum(1 for s in mod['samples'] if s.sample_data)}")
taud = assemble_taud(mod)
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()