From 7184392521b5c04acd6432b1e6d742ea5273da0a Mon Sep 17 00:00:00 2001 From: minjaesong Date: Fri, 1 May 2026 06:47:35 +0900 Subject: [PATCH] 2taud converters refactoring --- it2taud.py | 173 ++++++------------------------------- mod2taud.py | 150 +++++--------------------------- s3m2taud.py | 230 ++++++------------------------------------------- taud_common.py | 197 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 272 insertions(+), 478 deletions(-) create mode 100644 taud_common.py diff --git a/it2taud.py b/it2taud.py index d63437a..dd81852 100644 --- a/it2taud.py +++ b/it2taud.py @@ -42,11 +42,22 @@ import math import struct import sys -VERBOSE = False - -def vprint(*a, **kw): - if VERBOSE: - print(*a, **kw, file=sys.stderr) +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_C3, + 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, + EFF_A, EFF_B, EFF_C, EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J, + EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T, + EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z, + J_SEMI_TABLE, + d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns, + normalise_sample, +) # ── IT constants ───────────────────────────────────────────────────────────── @@ -91,14 +102,6 @@ VC_VIB_LO, VC_VIB_HI = 203, 212 # vibrato H (depth 1..10) VC_TPORTA_TABLE = (0, 1, 4, 8, 16, 32, 64, 96, 128, 255) -# IT effect letters (1-based, same numbering as S3M so we can reuse encode_effect) -EFF_A = 1; EFF_B = 2; EFF_C = 3; EFF_D = 4; EFF_E = 5 -EFF_F = 6; EFF_G = 7; EFF_H = 8; EFF_I = 9; EFF_J = 10 -EFF_K = 11; EFF_L = 12; EFF_M = 13; EFF_N = 14; EFF_O = 15 -EFF_P = 16; EFF_Q = 17; EFF_R = 18; EFF_S = 19; EFF_T = 20 -EFF_U = 21; EFF_V = 22; EFF_W = 23; EFF_X = 24; EFF_Y = 25 -EFF_Z = 26 - # IT effects that recall last non-zero arg (per-effect-private, with cohort exceptions). # V (Set Global Volume) recalls in IT compat mode — the first V $00 resolves to the # header's global_vol, not literal 0. Without this, songs starting with V $00 silence. @@ -109,40 +112,10 @@ IT_MEM_EFFECTS = frozenset({ }) -# ── Taud constants ──────────────────────────────────────────────────────────── +# ── Taud constants (it-specific) ────────────────────────────────────────────── -TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64]) -TAUD_VERSION = 1 -TAUD_HEADER_SIZE = 32 -TAUD_SONG_ENTRY = 16 -SAMPLEBIN_SIZE = 770048 -INSTBIN_SIZE = 16384 -SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE -PATTERN_ROWS = 64 -PATTERN_BYTES = PATTERN_ROWS * 8 -NUM_PATTERNS_MAX = 4095 -NUM_CUES = 1024 -CUE_SIZE = 32 -NUM_VOICES = 20 SIGNATURE = b'it2taud/TSVM ' # 14 bytes -NOTE_NOP = 0xFFFF -NOTE_KEYOFF = 0x0000 -NOTE_CUT = 0xFFFE -TAUD_C3 = 0x4000 - -TOP_NONE = 0x00 -TOP_A = 0x0A; TOP_B = 0x0B; TOP_C = 0x0C; TOP_D = 0x0D -TOP_E = 0x0E; TOP_F = 0x0F; TOP_G = 0x10; TOP_H = 0x11 -TOP_I = 0x12; TOP_J = 0x13; TOP_K = 0x14; TOP_L = 0x15 -TOP_O = 0x18; TOP_Q = 0x1A; TOP_R = 0x1B; TOP_S = 0x1C -TOP_T = 0x1D; TOP_U = 0x1E; TOP_V = 0x1F; TOP_Y = 0x22 - -SEL_SET = 0; SEL_UP = 1; SEL_DOWN = 2; SEL_FINE = 3 - -J_SEMI_TABLE = [0x00, 0x01, 0x03, 0x04, 0x05, 0x07, 0x08, 0x09, - 0x0B, 0x0C, 0x0D, 0x0F, 0x10, 0x11, 0x13, 0x14] - # ThreeFiveMiniUfloat LUT — 256 entries, seconds 0.0..126.0 (must match Kotlin) _MINUFLOAT_LUT = [ 0.0, 0.03125, 0.0625, 0.09375, 0.125, 0.15625, 0.1875, 0.21875, @@ -399,40 +372,6 @@ def it214_decompress(blob: bytes, smp_offset: int, num_samples: int, return bytes(s & 0xFF for s in out_samples) -# ── Sample normaliser (same as s3m2taud but signed derived from cvt byte) ──── - -def _normalise_sample(raw: bytes, signed: bool, is_16bit: bool, - is_stereo: bool, name: str) -> bytes: - """Return unsigned 8-bit mono sample bytes.""" - out = [] - stride = (2 if is_16bit else 1) * (2 if is_stereo else 1) - i = 0 - while i + stride <= len(raw): - if is_16bit: - if is_stereo: - l16 = struct.unpack_from('> 1 - else: - s = struct.unpack_from('> 8) + 128 - else: - if is_stereo: - l8 = raw[i]; r8 = raw[i+1] - raw_s = (l8 + r8) // 2 - else: - raw_s = raw[i] - if signed: - v = (raw_s ^ 0x80) & 0xFF - else: - v = raw_s - out.append(v & 0xFF) - i += stride - if is_16bit or is_stereo: - vprint(f" info: '{name}' converted to unsigned 8-bit mono ({len(out)} samples)") - return bytes(out) - - # ── IT sample parser ────────────────────────────────────────────────────────── class ITSample: @@ -489,7 +428,7 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list: is_it215 = bool(s.cvt & 0x04) raw = it214_decompress(data, s.smp_point, s.length, s.is_16bit, is_it215) - s.sample_data = _normalise_sample(raw, True, + s.sample_data = normalise_sample(raw, True, s.is_16bit, s.is_stereo, s.name) s.length = len(s.sample_data) s.loop_beg = min(s.loop_beg, s.length) @@ -503,7 +442,7 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list: s.sample_data = bytes(min(s.length, 256)) else: raw = data[s.smp_point : s.smp_point + byte_len] - s.sample_data = _normalise_sample(raw, s.is_signed, + s.sample_data = normalise_sample(raw, s.is_signed, s.is_16bit, s.is_stereo, s.name) s.length = len(s.sample_data) s.loop_beg = min(s.loop_beg, s.length) @@ -1085,21 +1024,6 @@ def decode_volcol(vc: int): # ── Effect translator ───────────────────────────────────────────────────────── -def _d_arg_to_col(arg: int): - if arg == 0: - return None - hi = (arg >> 4) & 0xF - lo = arg & 0xF - if hi == 0xF and lo > 0: - return (SEL_FINE, lo & 0x1F) - if lo == 0xF and hi > 0: - return (SEL_FINE, (hi & 0x1F) | 0x20) - if hi > 0 and lo == 0: - return (SEL_UP, hi) - if lo > 0 and hi == 0: - return (SEL_DOWN, lo) - return (SEL_UP, hi) - def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple: """Return (taud_op, taud_arg16, vol_override, pan_override). @@ -1153,22 +1077,22 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple: return (TOP_J, (J_SEMI_TABLE[hi_semi] << 8) | J_SEMI_TABLE[lo_semi], None, None) if cmd == EFF_K: - return (TOP_H, 0x0000, _d_arg_to_col(arg), None) + return (TOP_H, 0x0000, d_arg_to_col(arg), None) if cmd == EFF_L: - return (TOP_G, 0x0000, _d_arg_to_col(arg), None) + return (TOP_G, 0x0000, d_arg_to_col(arg), None) if cmd == EFF_M: return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None) if cmd == EFF_N: - return (TOP_NONE, 0, _d_arg_to_col(arg), None) + return (TOP_NONE, 0, d_arg_to_col(arg), None) if cmd == EFF_O: return (TOP_O, (arg & 0xFF) << 8, None, None) if cmd == EFF_P: - return (TOP_NONE, 0, None, _d_arg_to_col(arg)) + return (TOP_NONE, 0, None, d_arg_to_col(arg)) if cmd == EFF_Q: return (TOP_Q, (arg & 0xFF) << 8, None, None) @@ -1410,20 +1334,6 @@ def _find_post_pat_cue(pi: int, order_list: list, chunk_map: list, # ── Sample / instrument bin (same as s3m2taud) ──────────────────────────────── -def _resample_linear(data: bytes, ratio: float) -> bytes: - if not data: - return data - n_out = max(1, int(len(data) * ratio)) - out = bytearray(n_out) - for i in range(n_out): - src = i / ratio - i0 = int(src) - frac = src - i0 - i1 = min(i0 + 1, len(data) - 1) - v = data[i0] * (1.0 - frac) + data[i1] * frac - out[i] = int(v + 0.5) & 0xFF - return bytes(out) - def build_sample_inst_bin_it(samples_or_proxy: list, envelopes_by_slot: dict = None) -> tuple: """samples_or_proxy: list of ITSample | None, indexed 1-based (index 0 unused). @@ -1443,7 +1353,7 @@ def build_sample_inst_bin_it(samples_or_proxy: list, ratio = SAMPLEBIN_SIZE / total vprint(f" info: sample bin overflow ({total} bytes); resampling all by {ratio:.4f}") for _, s in pcm_list: - new_data = _resample_linear(s.sample_data, ratio) + new_data = resample_linear(s.sample_data, ratio) s.sample_data = new_data s.length = len(new_data) s.loop_beg = max(0, int(s.loop_beg * ratio)) @@ -1641,32 +1551,6 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int, return bytes(out) -# ── Cue sheet helpers (adapted from s3m2taud) ───────────────────────────────── - -def _encode_cue(patterns12: list, instruction: int) -> bytearray: - pats = list(patterns12) + [0xFFF] * NUM_VOICES - pats = pats[:NUM_VOICES] - entry = bytearray(CUE_SIZE) - for i in range(10): - v0, v1 = pats[i*2], pats[i*2+1] - entry[i] = ((v0 & 0xF) << 4) | (v1 & 0xF) - entry[10 + i] = (((v0 >> 4) & 0xF) << 4) | ((v1 >> 4) & 0xF) - entry[20 + i] = (((v0 >> 8) & 0xF) << 4) | ((v1 >> 8) & 0xF) - entry[30] = instruction & 0xFF - return entry - -def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple: - seen = {}; remap = {}; canonical = [] - for i in range(num_pats): - pat = pat_bin[i*PATTERN_BYTES:(i+1)*PATTERN_BYTES] - if pat in seen: - remap[i] = seen[pat] - else: - ci = len(canonical) - seen[pat] = ci; remap[i] = ci; canonical.append(pat) - return b''.join(canonical), remap, len(canonical) - - # ── Main assembly ───────────────────────────────────────────────────────────── def find_initial_bpm_speed(patterns_rows: list, order_list: list, @@ -1863,14 +1747,14 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list, song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY sheet = bytearray(NUM_CUES * CUE_SIZE) for c in range(NUM_CUES): - sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = _encode_cue([], 0) + sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0) last_active = -1 for cue_idx, ci in enumerate(taud_cue_list): if cue_idx >= NUM_CUES: break base_pat = cue_idx * C pats = [pat_remap[base_pat + vi] for vi in range(C)] - sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = _encode_cue(pats, 0) + sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(pats, 0) last_active = cue_idx if last_active >= 0: @@ -1906,7 +1790,6 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list, # ── Main ────────────────────────────────────────────────────────────────────── def main(): - global VERBOSE ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('input', help='Input .it file') @@ -1917,7 +1800,7 @@ def main(): ap.add_argument('--no-pf-envelope', action='store_true', help='Skip baking IT pitch/filter envelope onto sample copies') args = ap.parse_args() - VERBOSE = args.verbose + set_verbose(args.verbose) with open(args.input, 'rb') as f: data = f.read() @@ -1942,7 +1825,7 @@ def main(): f.write(taud) print(f"wrote {len(taud)} bytes to '{args.output}'") - if VERBOSE: + if args.verbose: print(f" magic ok: {taud[:8].hex()}", file=sys.stderr) sig_off = TAUD_HEADER_SIZE - 14 print(f" signature: {taud[sig_off:sig_off+14]}", file=sys.stderr) diff --git a/mod2taud.py b/mod2taud.py index fbe70e5..4d1ff07 100644 --- a/mod2taud.py +++ b/mod2taud.py @@ -29,11 +29,18 @@ import math import struct import sys -VERBOSE = False - -def vprint(*a, **kw): - if VERBOSE: - print(*a, **kw, file=sys.stderr) +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_C3, + 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, +) # ── MOD constants ──────────────────────────────────────────────────────────── @@ -52,66 +59,15 @@ PT_MEM_TOP = frozenset({0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0xA}) PT_MEM_E_SUB = frozenset({0x1, 0x2, 0xA, 0xB}) -# ── Taud constants (identical to s3m2taud) ─────────────────────────────────── +# ── Taud constants (mod-specific) ──────────────────────────────────────────── -TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64]) -TAUD_VERSION = 1 -TAUD_HEADER_SIZE = 32 -TAUD_SONG_ENTRY = 16 -SAMPLEBIN_SIZE = 770048 -INSTBIN_SIZE = 16384 -SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE -PATTERN_ROWS = 64 -PATTERN_BYTES = PATTERN_ROWS * 8 -NUM_PATTERNS_MAX = 4095 -NUM_CUES = 1024 -CUE_SIZE = 32 -NUM_VOICES = 20 SIGNATURE = b"mod2taud/TSVM " # 14 bytes -# Taud note constants -NOTE_NOP = 0xFFFF -NOTE_KEYOFF = 0x0000 -NOTE_CUT = 0xFFFE -TAUD_C3 = 0x4000 - # PT period 428 (PT "C-2") corresponds to OpenMPT/IT C-4 which s3m2taud # anchors to Taud C3 (0x4000). We use the same anchor so MOD/S3M imports # share a pitch reference. PT_REFERENCE_PERIOD = 428.0 -# Taud effect opcode bytes -TOP_NONE = 0x00 -TOP_A = 0x0A -TOP_B = 0x0B -TOP_C = 0x0C -TOP_D = 0x0D -TOP_E = 0x0E -TOP_F = 0x0F -TOP_G = 0x10 -TOP_H = 0x11 -TOP_I = 0x12 -TOP_J = 0x13 -TOP_K = 0x14 -TOP_L = 0x15 -TOP_O = 0x18 -TOP_Q = 0x1A -TOP_R = 0x1B -TOP_S = 0x1C -TOP_T = 0x1D -TOP_U = 0x1E -TOP_V = 0x1F -TOP_Y = 0x22 - -SEL_SET = 0 -SEL_UP = 1 -SEL_DOWN = 2 -SEL_FINE = 3 - -# 12-TET semitone → Taud J-arpeggio byte (high byte of pitch delta). -J_SEMI_TABLE = [0x00, 0x01, 0x03, 0x04, 0x05, 0x07, 0x08, 0x09, - 0x0B, 0x0C, 0x0D, 0x0F, 0x10, 0x11, 0x13, 0x14] - # ── MOD parser ─────────────────────────────────────────────────────────────── @@ -272,25 +228,6 @@ def period_to_taud_note(period: int) -> int: return max(1, min(0xFFFD, val)) -# ── Volume / pan column helper (shared semantics with s3m2taud) ────────────── - -def _d_arg_to_col(arg: int): - if arg == 0: - return None - hi = (arg >> 4) & 0xF - lo = arg & 0xF - if hi == 0xF and lo > 0: - return (SEL_FINE, lo & 0x1F) # fine slide down - if lo == 0xF and hi > 0: - return (SEL_FINE, (hi & 0x1F) | 0x20) # fine slide up - if hi > 0 and lo == 0: - return (SEL_UP, hi) - if lo > 0 and hi == 0: - return (SEL_DOWN, lo) - # Both nibbles non-zero, neither $F → ambiguous; PT prefers up. - return (SEL_UP, hi) - - # ── PT effect → Taud effect ────────────────────────────────────────────────── def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple: @@ -323,11 +260,11 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple: if cmd == 0x5: # Tone porta + vol slide → Taud L (engine splits internally). - return (TOP_G, 0x0000, _d_arg_to_col(arg), None) + 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) + return (TOP_H, 0x0000, d_arg_to_col(arg), None) if cmd == 0x7: hi = (arg >> 4) & 0xF @@ -343,7 +280,7 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple: return (TOP_O, (arg & 0xFF) << 8, None, None) if cmd == 0xA: - return (TOP_NONE, 0, _d_arg_to_col(arg), None) + return (TOP_NONE, 0, d_arg_to_col(arg), None) if cmd == 0xB: return (TOP_B, arg & 0xFF, None, None) @@ -455,21 +392,6 @@ def resolve_pt_recalls(patterns: list, order_list: list, n_channels: int) -> Non # ── Sample resampling and Taud sample/instrument bin (port of s3m2taud) ────── -def _resample_linear(data: bytes, ratio: float) -> bytes: - if not data: - return data - n_out = max(1, int(len(data) * ratio)) - out = bytearray(n_out) - for i in range(n_out): - src = i / ratio - i0 = int(src) - frac = src - i0 - i1 = min(i0 + 1, len(data) - 1) - v = data[i0] * (1.0 - frac) + data[i1] * frac - out[i] = int(v + 0.5) & 0xFF - return bytes(out) - - 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] @@ -480,7 +402,7 @@ def build_sample_inst_bin(samples: list) -> tuple: 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) + 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)) @@ -619,40 +541,11 @@ def build_pattern(grid: list, ch_idx: int, default_pan: int, return bytes(out) -def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple: - seen = {} - remap = {} - canon = [] - for i in range(num_pats): - pat = pat_bin[i * PATTERN_BYTES : (i + 1) * PATTERN_BYTES] - if pat in seen: - remap[i] = seen[pat] - else: - ci = len(canon) - seen[pat] = ci - remap[i] = ci - canon.append(pat) - return b''.join(canon), remap, len(canon) - - -def _encode_cue(patterns12: list, instruction: int) -> bytearray: - pats = list(patterns12) + [0xFFF] * NUM_VOICES - pats = pats[:NUM_VOICES] - entry = bytearray(CUE_SIZE) - for i in range(10): - v0, v1 = pats[i*2], pats[i*2+1] - entry[i] = ((v0 & 0xF) << 4) | (v1 & 0xF) - entry[10 + i] = (((v0 >> 4) & 0xF) << 4) | ((v1 >> 4) & 0xF) - entry[20 + i] = (((v0 >> 8) & 0xF) << 4) | ((v1 >> 8) & 0xF) - entry[30] = instruction & 0xFF - return entry - - 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) + sheet[c*CUE_SIZE : c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0) cue_idx = 0 last_active = -1 @@ -665,7 +558,7 @@ def build_cue_sheet(order_list: list, n_pats_mod: int, n_channels: int, 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) + sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = encode_cue(pats, 0) last_active = cue_idx cue_idx += 1 @@ -804,7 +697,6 @@ def assemble_taud(mod: dict) -> bytes: # ── Main ───────────────────────────────────────────────────────────────────── def main(): - global VERBOSE ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('input', help='Input .mod file') @@ -813,7 +705,7 @@ def main(): help='Print conversion details to stderr') args = ap.parse_args() - VERBOSE = args.verbose + set_verbose(args.verbose) with open(args.input, 'rb') as f: data = f.read() @@ -831,7 +723,7 @@ def main(): f.write(taud) print(f"wrote {len(taud)} bytes to '{args.output}'") - if VERBOSE: + if args.verbose: print(f" magic ok: {taud[:8].hex()}", file=sys.stderr) diff --git a/s3m2taud.py b/s3m2taud.py index f46ea5b..b9e7833 100644 --- a/s3m2taud.py +++ b/s3m2taud.py @@ -29,11 +29,22 @@ import math import struct import sys -VERBOSE = False - -def vprint(*a, **kw): - if VERBOSE: - print(*a, **kw, file=sys.stderr) +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_C3, + 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, + EFF_A, EFF_B, EFF_C, EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J, + EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T, + EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z, + J_SEMI_TABLE, + d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns, + normalise_sample, +) # ── S3M constants ──────────────────────────────────────────────────────────── @@ -45,92 +56,8 @@ S3M_NOTE_OFF = 0xFE S3M_ORDER_SKIP = 0xFE S3M_ORDER_END = 0xFF -# S3M effect letters (1-based: 1='A', 2='B', …) -EFF_A = 1 # set speed -EFF_B = 2 # jump to order -EFF_C = 3 # pattern break -EFF_D = 4 # volume slide -EFF_E = 5 # porta down -EFF_F = 6 # porta up -EFF_G = 7 # tone porta -EFF_H = 8 # vibrato -EFF_I = 9 # tremor -EFF_J = 10 # arpeggio -EFF_K = 11 # vibrato+volslide -EFF_L = 12 # porta+volslide -EFF_M = 13 # channel vol -EFF_N = 14 # chan vol slide -EFF_O = 15 # sample offset -EFF_P = 16 # pan slide -EFF_Q = 17 # retrigger -EFF_R = 18 # tremolo -EFF_S = 19 # special (sub-cmds) -EFF_T = 20 # set BPM -EFF_U = 21 # fine vibrato -EFF_V = 22 # global vol -EFF_W = 23 # global vol slide -EFF_X = 24 # set pan -EFF_Y = 25 # panbrello -EFF_Z = 26 # sync - - -# ── Taud constants ─────────────────────────────────────────────────────────── - -TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64]) -TAUD_VERSION = 1 -TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(2)+sig(16) -TAUD_SONG_ENTRY = 16 # offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+basenote(2)+basefreq(4)+flags(1) -SAMPLEBIN_SIZE = 770048 -INSTBIN_SIZE = 16384 # 256 instruments × 64 bytes -SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE # 786432 -PATTERN_ROWS = 64 -PATTERN_BYTES = PATTERN_ROWS * 8 # 512 -NUM_PATTERNS_MAX = 4095 -NUM_CUES = 1024 -CUE_SIZE = 32 # packed 12-bit×20 voices + instruction + pad -NUM_VOICES = 20 SIGNATURE = b"s3m2taud/TSVM " # 14 bytes -# Taud note constants -NOTE_NOP = 0xFFFF -NOTE_KEYOFF = 0x0000 -NOTE_CUT = 0xFFFE -TAUD_C3 = 0x4000 - -# Taud effect opcode bytes (base-36: 0..9 → 0x00..0x09, A..Z → 0x0A..0x23) -TOP_NONE = 0x00 -TOP_A = 0x0A # set tick speed -TOP_B = 0x0B # jump to order -TOP_C = 0x0C # break to row -TOP_D = 0x0D # volume slide -TOP_E = 0x0E # pitch slide down -TOP_F = 0x0F # pitch slide up -TOP_G = 0x10 # tone porta -TOP_H = 0x11 # vibrato -TOP_I = 0x12 # tremor -TOP_J = 0x13 # microtonal arpeggio -TOP_K = 0x14 # vibrato + vol slide (engine no-op; converter splits) -TOP_L = 0x15 # tone porta + vol slide (engine no-op; converter splits) -TOP_O = 0x18 # sample offset -TOP_Q = 0x1A # retrigger -TOP_R = 0x1B # tremolo -TOP_S = 0x1C # sub-effects -TOP_T = 0x1D # tempo set/slide -TOP_U = 0x1E # fine vibrato -TOP_V = 0x1F # global volume -TOP_Y = 0x22 # panbrello - -# Volume / pan column selectors (2-bit field, packed into top of vol/pan byte). -SEL_SET = 0 # 6-bit value: set vol / pan -SEL_UP = 1 # 6-bit per-tick slide up / right -SEL_DOWN = 2 # 6-bit per-tick slide down / left -SEL_FINE = 3 # 1-bit dir + 5-bit magnitude, fired on tick 0 - -# 12-TET semitone → Taud J-arpeggio byte (high byte of pitch delta). -# byte = round(semitone * 4096 / 12 / 256) = round(semitone * 4 / 3). -J_SEMI_TABLE = [0x00, 0x01, 0x03, 0x04, 0x05, 0x07, 0x08, 0x09, - 0x0B, 0x0C, 0x0D, 0x0F, 0x10, 0x11, 0x13, 0x14] - # ST3's single shared memory slot backs these effects. ST3_SHARED_EFFECTS = frozenset({ EFF_D, EFF_E, EFF_F, EFF_I, EFF_J, EFF_K, EFF_L, EFF_Q, EFF_R, EFF_S @@ -234,7 +161,7 @@ def parse_instruments(data: bytes, h: S3MHeader) -> list: inst.sample_data = bytes(min(sample_len, 256)) else: raw = data[sample_off:sample_off + sample_len] - inst.sample_data = _normalise_sample(raw, inst.signed, is_16bit, is_stereo, inst.name) + inst.sample_data = normalise_sample(raw, inst.signed, is_16bit, is_stereo, inst.name) inst.length = len(inst.sample_data) inst.loop_begin = min(inst.loop_begin, inst.length) inst.loop_end = min(inst.loop_end, inst.length) @@ -242,37 +169,6 @@ def parse_instruments(data: bytes, h: S3MHeader) -> list: return insts -def _normalise_sample(raw: bytes, signed: bool, is_16bit: bool, is_stereo: bool, name: str) -> bytes: - """Return unsigned 8-bit mono sample bytes.""" - out = [] - stride = (2 if is_16bit else 1) * (2 if is_stereo else 1) - i = 0 - while i + stride <= len(raw): - if is_16bit: - if is_stereo: - l16 = struct.unpack_from('> 1 - else: - s = struct.unpack_from('> 8) + 128 - else: - if is_stereo: - l8 = raw[i]; r8 = raw[i+1] - raw_s = (l8 + r8) // 2 - else: - raw_s = raw[i] - if signed: - v = ((raw_s ^ 0x80) & 0xFF) # signed→unsigned - else: - v = raw_s - out.append(v & 0xFF) - i += stride - if is_16bit or is_stereo: - vprint(f" info: '{name}' converted to unsigned 8-bit mono ({len(out)} samples)") - return bytes(out) - - # ── S3M pattern parser ─────────────────────────────────────────────────────── class S3MRow: @@ -339,28 +235,6 @@ def encode_note(s3m_note: int) -> int: return max(1, min(0xFFFD, val)) -def _d_arg_to_col(arg: int): - """Convert an ST3 D-style two-nibble vol/pan slide arg into a column override. - - Returns (selector, value) or None for no-op. Volume column treats - selector 1 as up / 2 as down; pan column reuses 1 = right, 2 = left. - """ - if arg == 0: - return None - hi = (arg >> 4) & 0xF - lo = arg & 0xF - if hi == 0xF and lo > 0: - return (SEL_FINE, lo & 0x1F) # fine slide down (dir bit 0) - if lo == 0xF and hi > 0: - return (SEL_FINE, (hi & 0x1F) | 0x20) # fine slide up (dir bit 1) - if hi > 0 and lo == 0: - return (SEL_UP, hi) - if lo > 0 and hi == 0: - return (SEL_DOWN, lo) - # Both nibbles non-zero, neither $F → ambiguous; ST3 prefers up. - return (SEL_UP, hi) - - def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple: """Return (taud_op, taud_arg16, vol_override, pan_override). @@ -417,23 +291,23 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple: if cmd == EFF_K: # K = vibrato continuation + vol slide; engine treats K as no-op. # Split into: H $0000 (recall vibrato from HU memory) + vol-col slide. - return (TOP_H, 0x0000, _d_arg_to_col(arg), None) + return (TOP_H, 0x0000, d_arg_to_col(arg), None) if cmd == EFF_L: # L = tone-porta continuation + vol slide; split similarly. - return (TOP_G, 0x0000, _d_arg_to_col(arg), None) + return (TOP_G, 0x0000, d_arg_to_col(arg), None) if cmd == EFF_M: return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None) if cmd == EFF_N: - return (TOP_NONE, 0, _d_arg_to_col(arg), None) + return (TOP_NONE, 0, d_arg_to_col(arg), None) if cmd == EFF_O: return (TOP_O, (arg & 0xFF) << 8, None, None) if cmd == EFF_P: - return (TOP_NONE, 0, None, _d_arg_to_col(arg)) + return (TOP_NONE, 0, None, d_arg_to_col(arg)) if cmd == EFF_Q: return (TOP_Q, (arg & 0xFF) << 8, None, None) @@ -541,22 +415,6 @@ def warn_st3_quirks(patterns: list, order_list: list, num_channels: int) -> None # ── Taud builders ──────────────────────────────────────────────────────────── -def _resample_linear(data: bytes, ratio: float) -> bytes: - """Resample bytes by ratio (< 1 = downsample) using linear interpolation.""" - if not data: - return data - n_out = max(1, int(len(data) * ratio)) - out = bytearray(n_out) - for i in range(n_out): - src = i / ratio - i0 = int(src) - frac = src - i0 - i1 = min(i0 + 1, len(data) - 1) - v = data[i0] * (1.0 - frac) + data[i1] * frac - out[i] = int(v + 0.5) & 0xFF - return bytes(out) - - def build_sample_inst_bin(instruments: list) -> tuple: """ Returns (bin_bytes[786432], offsets_list, updated_insts). @@ -571,7 +429,7 @@ def build_sample_inst_bin(instruments: list) -> tuple: ratio = SAMPLEBIN_SIZE / total vprint(f" info: sample bin overflow ({total} bytes); resampling all by {ratio:.4f}") for _, inst in pcm_insts: - new_data = _resample_linear(inst.sample_data, ratio) + new_data = resample_linear(inst.sample_data, ratio) old_len = len(inst.sample_data) inst.sample_data = new_data inst.length = len(new_data) @@ -734,48 +592,13 @@ def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int, return bytes(out) -def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple: - """ - Consolidate identical 512-byte Taud patterns into a single copy. - Returns (deduped_bin, remap, num_unique) where remap[original_idx] = canonical_idx. - """ - seen = {} # pattern_bytes -> canonical_index - remap = {} # original_index -> canonical_index - canonical = [] - for i in range(num_pats): - pat = pat_bin[i * PATTERN_BYTES : (i + 1) * PATTERN_BYTES] - if pat in seen: - remap[i] = seen[pat] - else: - ci = len(canonical) - seen[pat] = ci - remap[i] = ci - canonical.append(pat) - return b''.join(canonical), remap, len(canonical) - - -def _encode_cue(patterns12: list, instruction: int) -> bytearray: - """Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers.""" - # patterns12: list of up to NUM_VOICES 12-bit values (0xFFF = disabled) - pats = list(patterns12) + [0xFFF] * NUM_VOICES - pats = pats[:NUM_VOICES] - entry = bytearray(CUE_SIZE) - for i in range(10): # 10 bytes: 2 voices per byte - v0, v1 = pats[i*2], pats[i*2+1] - entry[i] = ((v0 & 0xF) << 4) | (v1 & 0xF) # low nybbles - entry[10 + i] = (((v0 >> 4) & 0xF) << 4) | ((v1 >> 4) & 0xF) # mid nybbles - entry[20 + i] = (((v0 >> 8) & 0xF) << 4) | ((v1 >> 8) & 0xF) # high nybbles - entry[30] = instruction & 0xFF - return entry - - def build_cue_sheet(order_list: list, num_pats_s3m: int, num_channels: int, pat_remap: dict = None) -> bytes: """Build the 1024×32-byte cue sheet with 12-bit packed pattern numbers.""" sheet = bytearray(NUM_CUES * CUE_SIZE) # Fill entire sheet with the "all disabled" cue (patterns=0xFFF, instr=0) for c in range(NUM_CUES): - sheet[c*CUE_SIZE : c*CUE_SIZE+CUE_SIZE] = _encode_cue([], 0) + sheet[c*CUE_SIZE : c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0) cue_idx = 0 last_active = -1 @@ -786,7 +609,7 @@ def build_cue_sheet(order_list: list, num_pats_s3m: int, num_channels: int, continue orig = [order * num_channels + v for v in range(num_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) + sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = encode_cue(pats, 0) last_active = cue_idx cue_idx += 1 @@ -926,7 +749,6 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes: # ── Main ───────────────────────────────────────────────────────────────────── def main(): - global VERBOSE ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('input', help='Input .s3m file') @@ -935,7 +757,7 @@ def main(): help='Print conversion details to stderr') args = ap.parse_args() - VERBOSE = args.verbose + set_verbose(args.verbose) with open(args.input, 'rb') as f: data = f.read() @@ -954,7 +776,7 @@ def main(): f.write(taud) print(f"wrote {len(taud)} bytes to '{args.output}'") - if VERBOSE: + if args.verbose: print(f" magic ok: {taud[:8].hex()}", file=sys.stderr) diff --git a/taud_common.py b/taud_common.py new file mode 100644 index 0000000..2bcec86 --- /dev/null +++ b/taud_common.py @@ -0,0 +1,197 @@ +"""taud_common.py — Shared constants and helpers for *2taud converters. + +Imported by s3m2taud.py, it2taud.py, and mod2taud.py. Holds the Taud +container constants, the effect-letter index table, and the small set +of helpers (sample resampler, vol/pan column packer, cue encoder, +pattern deduper, sample normaliser) that all three converters used to +duplicate verbatim. +""" + +import struct +import sys + + +# ── Verbose logging (shared across converters via set_verbose) ─────────────── + +VERBOSE = False + +def set_verbose(b: bool) -> None: + global VERBOSE + VERBOSE = bool(b) + +def vprint(*a, **kw) -> None: + if VERBOSE: + print(*a, **kw, file=sys.stderr) + + +# ── Taud container constants ───────────────────────────────────────────────── + +TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64]) +TAUD_VERSION = 1 +TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(4)+sig(14) +TAUD_SONG_ENTRY = 16 # offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+basenote(2)+basefreq(4)+flags(1) +SAMPLEBIN_SIZE = 770048 +INSTBIN_SIZE = 16384 # 256 instruments × 64 bytes +SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE +PATTERN_ROWS = 64 +PATTERN_BYTES = PATTERN_ROWS * 8 # 512 +NUM_PATTERNS_MAX = 4095 +NUM_CUES = 1024 +CUE_SIZE = 32 +NUM_VOICES = 20 + +# Note word sentinels +NOTE_NOP = 0xFFFF +NOTE_KEYOFF = 0x0000 +NOTE_CUT = 0xFFFE +TAUD_C3 = 0x4000 + +# Taud effect opcodes (base-36: 0..9 → 0x00..0x09, A..Z → 0x0A..0x23) +TOP_NONE = 0x00 +TOP_A = 0x0A +TOP_B = 0x0B +TOP_C = 0x0C +TOP_D = 0x0D +TOP_E = 0x0E +TOP_F = 0x0F +TOP_G = 0x10 +TOP_H = 0x11 +TOP_I = 0x12 +TOP_J = 0x13 +TOP_K = 0x14 +TOP_L = 0x15 +TOP_O = 0x18 +TOP_Q = 0x1A +TOP_R = 0x1B +TOP_S = 0x1C +TOP_T = 0x1D +TOP_U = 0x1E +TOP_V = 0x1F +TOP_Y = 0x22 + +# Volume / pan column selectors (2-bit field at top of vol/pan byte) +SEL_SET = 0 # 6-bit value: set vol / pan +SEL_UP = 1 # 6-bit per-tick slide up / right +SEL_DOWN = 2 # 6-bit per-tick slide down / left +SEL_FINE = 3 # 1-bit dir + 5-bit magnitude, fired on tick 0 + +# 12-TET semitone → Taud J-arpeggio byte (high byte of pitch delta). +# byte = round(semitone * 4096 / 12 / 256) = round(semitone * 4 / 3). +J_SEMI_TABLE = [0x00, 0x01, 0x03, 0x04, 0x05, 0x07, 0x08, 0x09, + 0x0B, 0x0C, 0x0D, 0x0F, 0x10, 0x11, 0x13, 0x14] + +# Effect-letter indices (1-based; A=1..Z=26). Shared by s3m2taud and it2taud. +EFF_A = 1; EFF_B = 2; EFF_C = 3; EFF_D = 4; EFF_E = 5 +EFF_F = 6; EFF_G = 7; EFF_H = 8; EFF_I = 9; EFF_J = 10 +EFF_K = 11; EFF_L = 12; EFF_M = 13; EFF_N = 14; EFF_O = 15 +EFF_P = 16; EFF_Q = 17; EFF_R = 18; EFF_S = 19; EFF_T = 20 +EFF_U = 21; EFF_V = 22; EFF_W = 23; EFF_X = 24; EFF_Y = 25 +EFF_Z = 26 + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +def d_arg_to_col(arg: int): + """Convert a two-nibble D-style vol/pan slide arg into a column override. + + Returns (selector, value) or None for no-op. Volume column treats + selector 1 as up / 2 as down; pan column reuses 1 = right, 2 = left. + Both-nibbles-non-zero (and neither $F) is ambiguous; ST3/PT/IT all + prefer up. + """ + if arg == 0: + return None + hi = (arg >> 4) & 0xF + lo = arg & 0xF + if hi == 0xF and lo > 0: + return (SEL_FINE, lo & 0x1F) # fine slide down (dir bit 0) + if lo == 0xF and hi > 0: + return (SEL_FINE, (hi & 0x1F) | 0x20) # fine slide up (dir bit 1) + if hi > 0 and lo == 0: + return (SEL_UP, hi) + if lo > 0 and hi == 0: + return (SEL_DOWN, lo) + return (SEL_UP, hi) + + +def resample_linear(data: bytes, ratio: float) -> bytes: + """Resample bytes by ratio (< 1 = downsample) using linear interpolation.""" + if not data: + return data + n_out = max(1, int(len(data) * ratio)) + out = bytearray(n_out) + for i in range(n_out): + src = i / ratio + i0 = int(src) + frac = src - i0 + i1 = min(i0 + 1, len(data) - 1) + v = data[i0] * (1.0 - frac) + data[i1] * frac + out[i] = int(v + 0.5) & 0xFF + return bytes(out) + + +def encode_cue(patterns12: list, instruction: int) -> bytearray: + """Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers.""" + pats = list(patterns12) + [0xFFF] * NUM_VOICES + pats = pats[:NUM_VOICES] + entry = bytearray(CUE_SIZE) + for i in range(10): # 10 bytes: 2 voices per byte + v0, v1 = pats[i*2], pats[i*2+1] + entry[i] = ((v0 & 0xF) << 4) | (v1 & 0xF) # low nybbles + entry[10 + i] = (((v0 >> 4) & 0xF) << 4) | ((v1 >> 4) & 0xF) # mid nybbles + entry[20 + i] = (((v0 >> 8) & 0xF) << 4) | ((v1 >> 8) & 0xF) # high nybbles + entry[30] = instruction & 0xFF + return entry + + +def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple: + """Consolidate identical 512-byte Taud patterns into a single copy. + + Returns (deduped_bin, remap, num_unique) where remap[original_idx] = + canonical_idx. + """ + seen = {} + remap = {} + canonical = [] + for i in range(num_pats): + pat = pat_bin[i * PATTERN_BYTES : (i + 1) * PATTERN_BYTES] + if pat in seen: + remap[i] = seen[pat] + else: + ci = len(canonical) + seen[pat] = ci + remap[i] = ci + canonical.append(pat) + return b''.join(canonical), remap, len(canonical) + + +def normalise_sample(raw: bytes, signed: bool, is_16bit: bool, + is_stereo: bool, name: str) -> bytes: + """Return unsigned 8-bit mono sample bytes, downmixing/depthing as needed.""" + out = [] + stride = (2 if is_16bit else 1) * (2 if is_stereo else 1) + i = 0 + while i + stride <= len(raw): + if is_16bit: + if is_stereo: + l16 = struct.unpack_from('> 1 + else: + s = struct.unpack_from('> 8) + 128 + else: + if is_stereo: + l8 = raw[i]; r8 = raw[i+1] + raw_s = (l8 + r8) // 2 + else: + raw_s = raw[i] + if signed: + v = (raw_s ^ 0x80) & 0xFF + else: + v = raw_s + out.append(v & 0xFF) + i += stride + if is_16bit or is_stereo: + vprint(f" info: '{name}' converted to unsigned 8-bit mono ({len(out)} samples)") + return bytes(out)