#!/usr/bin/env python3 """s3m2taud.py — Convert Scream Tracker 3 (.s3m) to TSVM Taud (.taud) Usage: python3 s3m2taud.py input.s3m output.taud [-v] Limits: - Up to 15 S3M channels (excess disabled; hard error if pattern count × channel count > 256). - Sample bin is 770048 bytes; if all samples together exceed this, every sample is globally resampled down (with c2spd adjusted) so pitch is preserved. - AdLib instruments are skipped. - Effects mapped: D (vol-slide), E/F (pitch slide, rough approx), SC (note-cut), A (initial speed), T (initial BPM). Others dropped. Pitch-slide approximation: Amiga-period mode: taud_arg ≈ s3m_arg * 2 (mid-register heuristic) Linear-slide mode: taud_arg = s3m_arg * 4 (exact) """ import argparse import gzip import math import struct import sys VERBOSE = False def vprint(*a, **kw): if VERBOSE: print(*a, **kw, file=sys.stderr) # ── S3M constants ──────────────────────────────────────────────────────────── S3M_MAGIC = b"SCRM" S3M_TYPE_PCM = 1 S3M_NOTE_EMPTY = 0xFF 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_lo(1)+pats_hi(1)+bpm(1)+tick(1)+pad(7) 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 " # 16 bytes # Taud note constants NOTE_NOP = 0xFFFF NOTE_KEYOFF = 0x0000 NOTE_CUT = 0xFFFE TAUD_C3 = 0x4000 # ── S3M parser ─────────────────────────────────────────────────────────────── class S3MHeader: __slots__ = ('title','order_count','inst_count','pat_count', 'flags','cwt_v','sample_type','global_vol', 'initial_speed','initial_tempo','master_vol', 'linear_slides','default_pan_flag', 'channel_settings','pan_values','order_list', 'inst_ptrs','pat_ptrs') def parse_s3m(data: bytes) -> S3MHeader: if len(data) < 0x60: sys.exit("error: file too short to be S3M") if data[0x2C:0x30] != S3M_MAGIC: sys.exit("error: not an S3M file (bad magic at 0x2C)") h = S3MHeader() h.title = data[0x00:0x1C].rstrip(b'\x00').decode('latin-1', errors='replace') h.order_count = struct.unpack_from(' list: insts = [] for ptr in h.inst_ptrs: if ptr + 0x50 > len(data): vprint(f" warning: instrument pointer {ptr:#x} out of range, skipping") insts.append(None) continue inst = S3MInstrument() inst.itype = data[ptr] inst.filename = data[ptr+1:ptr+13].rstrip(b'\x00').decode('latin-1', errors='replace') # memseg: 3 bytes at offsets 0x0D,0x0E,0x0F — high byte first (quirk) memseg_hi = data[ptr + 0x0D] memseg_lo = struct.unpack_from(' len(data): vprint(f" warning: sample '{inst.name}' data out of range, zeroing") 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.length = len(inst.sample_data) inst.loop_begin = min(inst.loop_begin, inst.length) inst.loop_end = min(inst.loop_end, inst.length) insts.append(inst) 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: """One cell in a pattern grid.""" __slots__ = ('note','inst','vol','effect','effect_arg') def __init__(self): self.note = S3M_NOTE_EMPTY # 0xFF=empty, 0xFE=off, else (oct<<4|pitch) self.inst = 0 self.vol = -1 # -1 = not set (use instrument default) self.effect = 0 # 1-based letter index (0=none) self.effect_arg = 0 def parse_patterns(data: bytes, h: S3MHeader) -> list: """Returns list[pat_idx] of list[channel][row] → S3MRow.""" patterns = [] for pi, ptr in enumerate(h.pat_ptrs): # 32 channels × 64 rows grid = [[S3MRow() for _ in range(PATTERN_ROWS)] for _ in range(32)] if ptr == 0 or ptr + 2 > len(data): patterns.append(grid) continue pat_len = struct.unpack_from('= end: break cell.note = data[pos]; pos += 1 cell.inst = data[pos]; pos += 1 if has_v: if pos >= end: break cell.vol = data[pos]; pos += 1 if has_e: if pos + 1 >= end: break cell.effect = data[pos]; pos += 1 cell.effect_arg = data[pos]; pos += 1 patterns.append(grid) return patterns # ── Note / effect encoding ─────────────────────────────────────────────────── def encode_note(s3m_note: int) -> int: if s3m_note == S3M_NOTE_EMPTY: return NOTE_NOP if s3m_note == S3M_NOTE_OFF: return NOTE_KEYOFF octave = (s3m_note >> 4) & 0xF pitch = s3m_note & 0xF if pitch > 11: return NOTE_NOP semitones = (octave - 4) * 12 + pitch val = round(TAUD_C3 + semitones * 4096 / 12) return max(1, min(0xFFFD, val)) def encode_effect(cmd: int, arg: int, linear: bool) -> tuple: """Return (taud_op, taud_arg16) or (0, 0) for no-op.""" if cmd == EFF_D: # Volume slide: same nibble layout return (0x0A, arg & 0xFF) if cmd == EFF_E: # Porta down if linear: targ = min(arg * 4, 0xFFFF) else: targ = min(arg * 2, 0xFFFF) return (0x02, targ) if cmd == EFF_F: # Porta up if linear: targ = min(arg * 4, 0xFFFF) else: targ = min(arg * 2, 0xFFFF) return (0x01, targ) if cmd == EFF_S: sub = (arg >> 4) & 0xF val = arg & 0xF if sub == 0xC: # SC - note cut return (0xEC, val) return (0x00, 0x0000) # ── 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). Resamples globally if total exceeds SAMPLEBIN_SIZE. """ pcm_insts = [(i, inst) for i, inst in enumerate(instruments) if inst is not None and inst.itype == S3M_TYPE_PCM and inst.sample_data] total = sum(len(inst.sample_data) for _, inst in pcm_insts) 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 _, inst in pcm_insts: new_data = _resample_linear(inst.sample_data, ratio) old_len = len(inst.sample_data) inst.sample_data = new_data inst.length = len(new_data) inst.loop_begin = max(0, int(inst.loop_begin * ratio)) inst.loop_end = max(0, min(int(inst.loop_end * ratio), inst.length)) inst.c2spd = max(1, int(inst.c2spd * ratio)) sample_bin = bytearray(SAMPLEBIN_SIZE) offsets = {} pos = 0 for idx, inst in pcm_insts: n = min(len(inst.sample_data), SAMPLEBIN_SIZE - pos) if n <= 0: vprint(f" warning: sample bin full, dropping '{inst.name}'") offsets[idx] = 0 inst.length = 0 continue sample_bin[pos:pos+n] = inst.sample_data[:n] offsets[idx] = pos if n < len(inst.sample_data): vprint(f" warning: '{inst.name}' truncated from {len(inst.sample_data)} to {n}") inst.length = n inst.loop_end = min(inst.loop_end, n) pos += n # Build instrument bin (256 × 64 bytes) inst_bin = bytearray(INSTBIN_SIZE) for i, inst in enumerate(instruments): if i >= 256: break if inst is None or inst.itype != S3M_TYPE_PCM: continue ptr = offsets.get(i, 0) ptr_lo = ptr & 0xFFFF ptr_hi = (ptr >> 16) s_len = min(inst.length, 65535) c2spd = min(inst.c2spd, 65535) ps = 0 ls = min(inst.loop_begin, 65535) le = min(inst.loop_end, 65535) loop_mode = 1 if (inst.flags & 1) else 0 flags_byte = (ptr_hi << 4) | (loop_mode & 0x3) # hhhh 00pp base = i * 64 struct.pack_into(' 65535: vprint(f" warning: sampling rate of '{inst.name}' exceeds 65535 (got '{inst.c2spd}')") return bytes(sample_bin) + bytes(inst_bin), offsets def _default_channel_pan(ch_setting: int) -> int: """Return Taud pan 0..63 from S3M channel-setting byte.""" # Bits 4-7 of channel setting are ignored; left/right from bit 3 # Actually the channel type (0-7 left, 8-15 right) encodes stereo side ch_type = ch_setting & 0x7F if 0 <= ch_type <= 7: return 16 # left elif 8 <= ch_type <= 15: return 47 # right return 31 # centre def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int, linear_slides: bool) -> bytes: """Build a 512-byte Taud pattern for one S3M channel.""" out = bytearray(PATTERN_BYTES) rows = s3m_grid[ch_idx] if ch_idx < len(s3m_grid) else [S3MRow()] * PATTERN_ROWS for r, row in enumerate(rows[:PATTERN_ROWS]): note = encode_note(row.note) inst = max(0, row.inst - 1) # S3M 1-based → Taud 0-based vol = min(row.vol, 63) if row.vol >= 0 else 63 pan = default_pan op, arg = encode_effect(row.effect, row.effect_arg, linear_slides) if row.effect != 0 and op == 0: eff_name = chr(ord('A') + row.effect - 1) if 1 <= row.effect <= 26 else '?' vprint(f" dropped effect {eff_name}{row.effect_arg:02X} at ch{ch_idx} row{r}") base = r * 8 struct.pack_into(' 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) cue_idx = 0 for order in order_list: if order == S3M_ORDER_END or cue_idx >= NUM_CUES: break if order == S3M_ORDER_SKIP: cue_idx += 1 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) cue_idx += 1 # Halt at end if cue_idx < NUM_CUES: sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = _encode_cue([], 0x01) return bytes(sheet) def find_initial_bpm_speed(patterns: list, order_list: list, default_speed: int, default_tempo: int) -> tuple: """Scan first pattern in order for Axx/Txx in row 0 of any channel.""" speed = default_speed or 6 tempo = default_tempo or 125 for order in order_list: if order >= S3M_ORDER_END: break if order >= len(patterns): continue grid = patterns[order] for ch_rows in grid: row = ch_rows[0] if row.effect == EFF_A and row.effect_arg > 0: speed = row.effect_arg if row.effect == EFF_T and row.effect_arg > 0: tempo = row.effect_arg break return speed, tempo def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes: # Determine active channels (bit7 clear = enabled) active_channels = [i for i, cs in enumerate(h.channel_settings) if i < 32 and not (cs & 0x80)][:NUM_VOICES] C = len(active_channels) P = len(patterns) if P * C > NUM_PATTERNS_MAX: sys.exit( f"error: {P} S3M patterns × {C} channels = {P*C} > {NUM_PATTERNS_MAX} Taud pattern limit.\n" f" Reduce the S3M to ≤ {NUM_PATTERNS_MAX // max(C,1)} patterns, or mute " f"channels to bring active count below {NUM_PATTERNS_MAX // max(P,1) + 1}." ) vprint(f" channels: {C}, s3m patterns: {P}, taud patterns: {P*C}") # Build sample+instrument bin vprint(" building sample/instrument bin…") sampleinst_raw, _offsets = build_sample_inst_bin(instruments) assert len(sampleinst_raw) == SAMPLEINST_SIZE # Compress compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0) comp_size = len(compressed) vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)") # Initial BPM / speed speed, tempo = find_initial_bpm_speed(patterns, h.order_list, h.initial_speed, h.initial_tempo) tempo = max(24, min(280, tempo)) bpm_stored = (tempo - 24) & 0xFF vprint(f" initial speed={speed}, tempo(BPM)={tempo}") # Song offset = header(32) + compressed + song_table(8) song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY num_taud_pats = P * C # Header (32 bytes): magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(2)+sig(16) sig = (SIGNATURE + b' ' * 16)[:16] header = ( TAUD_MAGIC + bytes([TAUD_VERSION, 1]) + struct.pack('> 8) & 0xFF song_table = struct.pack('