From 2282e0c10b849f2ce1ac3b7ff613f4f49ed36e17 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Wed, 29 Apr 2026 09:21:28 +0900 Subject: [PATCH] it2taud, instrument format changes --- it2taud.py | 1580 +++++++++++++++++ terranmon.txt | 21 +- .../torvald/tsvm/peripheral/AudioAdapter.kt | 136 +- 3 files changed, 1704 insertions(+), 33 deletions(-) create mode 100644 it2taud.py diff --git a/it2taud.py b/it2taud.py new file mode 100644 index 0000000..21602e7 --- /dev/null +++ b/it2taud.py @@ -0,0 +1,1580 @@ +#!/usr/bin/env python3 +"""it2taud.py — Convert ImpulseTracker (.it) to TSVM Taud (.taud) + +Usage: + python3 it2taud.py input.it output.taud [-v] [--no-decompress] + +Limits: + - Up to 20 IT channels (excess dropped; hard error if chunk count + × channel count > 4095). + - IT patterns with >64 rows are split into ⌈rows/64⌉ consecutive + Taud patterns. Pattern-loop (SBx) crossing a chunk boundary is + warned; B/C effects are remapped to new cue indices. + - IT2.14/IT2.15 compressed samples are decoded unless --no-decompress. + - IT instrument volume/pan envelopes (up to 8 nodes, sustain loops) are + converted to Taud format. NNA actions are ignored. Each IT instrument + resolves to its C-5 canonical sample. + - AdLib / OPL instruments are skipped. + +Effect support: + A-Z dispatch per TAUD_NOTE_EFFECTS.md. IT-specific: Cxx is binary + (not BCD like ST3). V scales by ×2 (IT 0-128 → Taud 0-255). X is + the full 8-bit IT pan. Y panbrello nibble-repeats. Z (MIDI macro) + dropped. S6x tick-delay dropped. SAx high-offset dropped. S7x NNA + toggles dropped. Vol-column pitch-slide / tone-porta / vibrato sub- + commands forwarded to main effect slot when empty; dropped otherwise. + Per-effect private memory cohorts resolved eagerly (D/K/L share; + E/F optionally linked with G per flag bit 5). +""" + +import argparse +import gzip +import math +import struct +import sys + +VERBOSE = False + +def vprint(*a, **kw): + if VERBOSE: + print(*a, **kw, file=sys.stderr) + + +# ── IT constants ───────────────────────────────────────────────────────────── + +IT_MAGIC = b'IMPM' +IT_SMP_MAGIC = b'IMPS' +IT_INST_MAGIC = b'IMPI' + +IT_NOTE_OFF = 255 +IT_NOTE_CUT = 254 +IT_NOTE_FADE = 246 # treated as key-off + +IT_ORD_END = 255 +IT_ORD_SKIP = 254 + +IT_FLAG_STEREO = 0x01 +IT_FLAG_USE_INST = 0x04 +IT_FLAG_LINEAR = 0x08 +IT_FLAG_OLD_EFFECTS = 0x10 +IT_FLAG_LINK_GEF = 0x20 # link G memory with E/F + +# Sample flags (Flg byte at IMPS+0x12) +IT_SMP_ASSOC = 0x01 +IT_SMP_16BIT = 0x02 +IT_SMP_STEREO = 0x04 +IT_SMP_COMPRESSED = 0x08 +IT_SMP_LOOP = 0x10 +IT_SMP_SUS_LOOP = 0x20 +IT_SMP_PINGPONG = 0x40 + +# Vol-column byte ranges (inclusive lower, inclusive upper) +VC_VOL_LO, VC_VOL_HI = 0, 64 +VC_FVUP_LO, VC_FVUP_HI = 65, 74 # fine vol up A (value = vc-64, 1..10) +VC_FVDN_LO, VC_FVDN_HI = 75, 84 # fine vol down B (value = vc-74, 1..10) +VC_VUP_LO, VC_VUP_HI = 85, 94 # vol slide up C (value = vc-84, 1..10) +VC_VDN_LO, VC_VDN_HI = 95, 104 # vol slide dn D (value = vc-94, 1..10) +VC_PDN_LO, VC_PDN_HI = 105, 114 # pitch dn E (value = vc-104, 1..10) +VC_PUP_LO, VC_PUP_HI = 115, 124 # pitch up F (value = vc-114, 1..10) +VC_PAN_LO, VC_PAN_HI = 128, 192 # set pan 0..64 (value = vc-128) +VC_TPORTA_LO, VC_TPORTA_HI = 193, 202 # tone porta G +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) +IT_MEM_EFFECTS = frozenset({ + EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J, + EFF_K, EFF_L, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, + EFF_S, EFF_T, EFF_U, EFF_W, EFF_X, EFF_Y, +}) + + +# ── Taud constants ──────────────────────────────────────────────────────────── + +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, + 0.25, 0.28125, 0.3125, 0.34375, 0.375, 0.40625, 0.4375, 0.46875, + 0.5, 0.53125, 0.5625, 0.59375, 0.625, 0.65625, 0.6875, 0.71875, + 0.75, 0.78125, 0.8125, 0.84375, 0.875, 0.90625, 0.9375, 0.96875, + 1.0, 1.03125, 1.0625, 1.09375, 1.125, 1.15625, 1.1875, 1.21875, + 1.25, 1.28125, 1.3125, 1.34375, 1.375, 1.40625, 1.4375, 1.46875, + 1.5, 1.53125, 1.5625, 1.59375, 1.625, 1.65625, 1.6875, 1.71875, + 1.75, 1.78125, 1.8125, 1.84375, 1.875, 1.90625, 1.9375, 1.96875, + 2.0, 2.0625, 2.125, 2.1875, 2.25, 2.3125, 2.375, 2.4375, + 2.5, 2.5625, 2.625, 2.6875, 2.75, 2.8125, 2.875, 2.9375, + 3.0, 3.0625, 3.125, 3.1875, 3.25, 3.3125, 3.375, 3.4375, + 3.5, 3.5625, 3.625, 3.6875, 3.75, 3.8125, 3.875, 3.9375, + 4.0, 4.125, 4.25, 4.375, 4.5, 4.625, 4.75, 4.875, + 5.0, 5.125, 5.25, 5.375, 5.5, 5.625, 5.75, 5.875, + 6.0, 6.125, 6.25, 6.375, 6.5, 6.625, 6.75, 6.875, + 7.0, 7.125, 7.25, 7.375, 7.5, 7.625, 7.75, 7.875, + 8.0, 8.25, 8.5, 8.75, 9.0, 9.25, 9.5, 9.75, + 10.0, 10.25, 10.5, 10.75, 11.0, 11.25, 11.5, 11.75, + 12.0, 12.25, 12.5, 12.75, 13.0, 13.25, 13.5, 13.75, + 14.0, 14.25, 14.5, 14.75, 15.0, 15.25, 15.5, 15.75, + 16.0, 16.5, 17.0, 17.5, 18.0, 18.5, 19.0, 19.5, + 20.0, 20.5, 21.0, 21.5, 22.0, 22.5, 23.0, 23.5, + 24.0, 24.5, 25.0, 25.5, 26.0, 26.5, 27.0, 27.5, + 28.0, 28.5, 29.0, 29.5, 30.0, 30.5, 31.0, 31.5, + 32.0, 33.0, 34.0, 35.0, 36.0, 37.0, 38.0, 39.0, + 40.0, 41.0, 42.0, 43.0, 44.0, 45.0, 46.0, 47.0, + 48.0, 49.0, 50.0, 51.0, 52.0, 53.0, 54.0, 55.0, + 56.0, 57.0, 58.0, 59.0, 60.0, 61.0, 62.0, 63.0, + 64.0, 66.0, 68.0, 70.0, 72.0, 74.0, 76.0, 78.0, + 80.0, 82.0, 84.0, 86.0, 88.0, 90.0, 92.0, 94.0, + 96.0, 98.0, 100.0, 102.0, 104.0, 106.0, 108.0, 110.0, + 112.0, 114.0, 116.0, 118.0, 120.0, 122.0, 124.0, 126.0, +] + +def _nearest_minifloat(sec: float) -> int: + """Return ThreeFiveMiniUfloat index (0-255) for the nearest representable seconds value.""" + if sec <= 0.0: + return 0 + if sec >= 126.0: + return 255 + lo, hi = 0, len(_MINUFLOAT_LUT) - 1 + while lo < hi: + mid = (lo + hi) // 2 + if _MINUFLOAT_LUT[mid] < sec: + lo = mid + 1 + else: + hi = mid + # lo is first index where LUT[lo] >= sec; check lo-1 for nearest + if lo > 0 and abs(_MINUFLOAT_LUT[lo - 1] - sec) <= abs(_MINUFLOAT_LUT[lo] - sec): + return lo - 1 + return lo + + +# ── IT header parser ────────────────────────────────────────────────────────── + +class ITHeader: + __slots__ = ('title', 'ord_count', 'ins_count', 'smp_count', 'pat_count', + 'cwt', 'cmwt', 'flags', 'special', + 'global_vol', 'mix_vol', 'initial_speed', 'initial_tempo', + 'pan_sep', 'linear_slides', 'use_instruments', + 'link_gef', 'old_effects', + 'chnl_pan', 'chnl_vol', + 'order_list', 'ins_ptrs', 'smp_ptrs', 'pat_ptrs') + +def parse_it_header(data: bytes) -> ITHeader: + if len(data) < 0xC0: + sys.exit("error: file too short to be IT") + if data[0:4] != IT_MAGIC: + sys.exit("error: not an IT file (bad magic)") + + h = ITHeader() + h.title = data[0x04:0x1E].rstrip(b'\x00').decode('latin-1', errors='replace') + h.ord_count = struct.unpack_from(' int: + """Wrap to signed int8 range (C int8 overflow behaviour).""" + v &= 0xFF + return v if v < 128 else v - 256 + +def _wrap16(v: int) -> int: + """Wrap to signed int16 range.""" + v &= 0xFFFF + return v if v < 32768 else v - 65536 + +def _sign_extend(val: int, bits: int) -> int: + sign_bit = 1 << (bits - 1) + return (val & (sign_bit - 1)) - (val & sign_bit) + +def _it214_decompress_block(payload: bytes, num_samples: int, + is_16bit: bool, is_it215: bool) -> list: + """Decode one compressed block payload. Returns list of signed int output samples. + + Algorithm from libxmp / schism source: + 8-bit: init_width=9, short escape reads 3 bits, mid border=(1<>= n + bit_cnt -= n + return val + + if is_16bit: + init_width = 17 + range_count = 16 # escape range size for mid/full forms + escape_bits = 4 # bits to read in short-form escape + else: + init_width = 9 + range_count = 8 + escape_bits = 3 + + width = init_width + d1 = d2 = 0 + out = [] + n = 0 + + while n < num_samples: + v = read_bits(width) + + if width <= 6: + # Short form: top bit == escape trigger + if v == (1 << (width - 1)): + new_w = read_bits(escape_bits) + 1 + if new_w >= width: + new_w += 1 + width = new_w + continue + + elif width < init_width: + # Mid form: top `range_count` representable values are escape codes + border = (1 << width) - range_count + if v >= border: + new_w = (v - border) + 1 # 1..range_count + if new_w >= width: + new_w += 1 + width = new_w + continue + + else: + # Full form: top bit (bit init_width-1) is escape flag + top_bit = 1 << (init_width - 1) + if v & top_bit: + new_w = (v & (top_bit - 1)) + 1 + if new_w >= width: + new_w += 1 + width = new_w + continue + + # Real sample: sign-extend delta and accumulate + delta = _sign_extend(v, width) + if is_16bit: + d1 = _wrap16(d1 + delta) + if is_it215: + d2 = _wrap16(d2 + d1) + out.append(d2) + else: + out.append(d1) + else: + d1 = _wrap8(d1 + delta) + if is_it215: + d2 = _wrap8(d2 + d1) + out.append(d2) + else: + out.append(d1) + n += 1 + + return out + +def it214_decompress(blob: bytes, smp_offset: int, num_samples: int, + is_16bit: bool, is_it215: bool) -> bytes: + """Decode IT2.14/IT2.15 compressed sample data. Returns raw PCM bytes (signed).""" + block_size = 0x4000 if is_16bit else 0x8000 + pos = smp_offset + out_samples = [] + + while len(out_samples) < num_samples: + if pos + 2 > len(blob): + break + block_len = struct.unpack_from(' 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: + __slots__ = ('name', 'filename', 'gv', 'vol', 'flags', 'cvt', 'dfp', + 'c5_speed', 'length', 'loop_beg', 'loop_end', + 'sus_beg', 'sus_end', 'smp_point', + 'has_loop', 'is_16bit', 'is_stereo', 'is_compressed', + 'is_signed', 'sample_data') + +def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list: + samples = [] + is_it215 = (h.cmwt >= 0x0215) + for i, ptr in enumerate(h.smp_ptrs): + if ptr == 0 or ptr + 0x50 > len(data): + vprint(f" warning: sample {i+1} pointer {ptr:#x} out of range, skipping") + samples.append(None) + continue + if data[ptr:ptr+4] != IT_SMP_MAGIC: + vprint(f" warning: sample {i+1} at {ptr:#x} has bad magic, skipping") + samples.append(None) + continue + + s = ITSample() + s.filename = data[ptr+0x04:ptr+0x10].rstrip(b'\x00').decode('latin-1', errors='replace') + s.gv = data[ptr+0x11] + s.flags = data[ptr+0x12] + s.vol = data[ptr+0x13] + s.name = data[ptr+0x14:ptr+0x2E].rstrip(b'\x00').decode('latin-1', errors='replace') + s.cvt = data[ptr+0x2E] + s.dfp = data[ptr+0x2F] + s.length = struct.unpack_from(' 0 + if has_data: + if s.is_compressed: + if not decompress: + vprint(f" warning: '{s.name}' is IT2.14 compressed, --no-decompress → silent") + else: + try: + raw = it214_decompress(data, s.smp_point, s.length, + s.is_16bit, is_it215) + 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) + s.loop_end = min(s.loop_end, s.length) + except Exception as e: + vprint(f" warning: '{s.name}' decompression failed ({e}), silent") + else: + byte_len = s.length * (2 if s.is_16bit else 1) * (2 if s.is_stereo else 1) + if s.smp_point + byte_len > len(data): + vprint(f" warning: '{s.name}' sample data out of range, zeroing") + 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.is_16bit, s.is_stereo, s.name) + s.length = len(s.sample_data) + s.loop_beg = min(s.loop_beg, s.length) + s.loop_end = min(s.loop_end, s.length) + samples.append(s) + return samples + + +# ── IT instrument parser ────────────────────────────────────────────────────── + +class ITInstrument: + __slots__ = ('name', 'dfp', 'gv', 'canonical_sample', 'canonical_volume', + 'vol_envelope', 'pan_envelope', 'vol_env_sustain', 'pan_env_sustain') + # vol_envelope / pan_envelope: list of 8 (value, minifloat_idx) tuples, or None + # vol_env_sustain / pan_env_sustain: int (0 = disabled, else (end<<3)|start) + +def parse_instruments(data: bytes, h: ITHeader) -> list: + insts = [] + for i, ptr in enumerate(h.ins_ptrs): + if ptr == 0 or ptr + 0x48 > len(data): + insts.append(None); continue + if data[ptr:ptr+4] != IT_INST_MAGIC: + insts.append(None); continue + + inst = ITInstrument() + inst.name = data[ptr+0x20:ptr+0x3A].rstrip(b'\x00').decode('latin-1', errors='replace') + inst.gv = data[ptr+0x18] + dfp_raw = data[ptr+0x19] + inst.dfp = dfp_raw & 0x7F if (dfp_raw & 0x80) else None # None = don't use + + # Keyboard table: 240 bytes at ptr+0x44, 120 pairs of (note, sample_1based) + keyboard = [] + for n in range(120): + kb_note = data[ptr + 0x44 + n*2] + kb_smp = data[ptr + 0x44 + n*2 + 1] + keyboard.append(kb_smp) # 0 = no sample + + # Pick C-5 (note 60) sample; fall back to most-frequent non-zero + c5_smp = keyboard[60] if 60 < len(keyboard) else 0 + if c5_smp == 0: + from collections import Counter + freq = Counter(s for s in keyboard if s != 0) + c5_smp = freq.most_common(1)[0][0] if freq else 0 + + inst.canonical_sample = c5_smp # 1-based sample index, 0 = none + inst.canonical_volume = min(inst.gv, 64) + + # Parse IT envelopes (new-format only, ≥cmwt 0x200) + # Vol envelope at ptr+0x130; pan envelope at ptr+0x182 + ticks_per_sec = max(h.initial_tempo / 60.0 * h.initial_speed, 1.0) + inst.vol_envelope, inst.vol_env_sustain = _parse_it_envelope( + data, ptr + 0x130, False, ticks_per_sec) + inst.pan_envelope, inst.pan_env_sustain = _parse_it_envelope( + data, ptr + 0x182, True, ticks_per_sec) + insts.append(inst) + return insts + + +def _parse_it_envelope(data: bytes, env_ptr: int, is_pan: bool, + ticks_per_sec: float) -> tuple: + """Parse one IT envelope block into 8 Taud (value, minifloat_idx) points. + + Returns (points_list, sustain_byte) where points_list is a list of + 8 (value, minifloat_idx) tuples, or None if envelope not enabled. + sustain_byte: bit7=enabled, bits[5:3]=end_idx, bits[2:0]=start_idx; 0=disabled. + + IT has two loop types: envelope loop (always on) and sustain loop (breaks on + key-off). Taud only has sustain loop semantics. Priority: sustain > env loop. + When slb==sle the AudioAdapter holds at that node (no cycling); for slb!=sle + it cycles between them. + """ + if env_ptr + 82 > len(data): + return None, 0 + flags = data[env_ptr] + if not (flags & 0x01): + return None, 0 # envelope not enabled + + num_nodes = max(1, min(data[env_ptr + 1], 25)) + it_lpb = data[env_ptr + 2] # envelope loop begin node + it_lpe = data[env_ptr + 3] # envelope loop end node + it_slb = data[env_ptr + 4] # sustain loop begin node + it_sle = data[env_ptr + 5] # sustain loop end node + has_env_loop = bool(flags & 0x02) + has_sus_loop = bool(flags & 0x04) + + # Choose which IT loop to map to Taud sustain (priority: sus > env) + if has_sus_loop: + use_lb, use_le = it_slb, it_sle + has_loop = True + elif has_env_loop: + use_lb, use_le = it_lpb, it_lpe + has_loop = True + vprint(f" envelope loop mapped as sustain loop (approximation: breaks on key-off)") + else: + use_lb = use_le = -1 + has_loop = False + + # Read IT nodes: (int8 value, uint16 tick_pos LE) + nodes = [] + for n in range(num_nodes): + nptr = env_ptr + 6 + n * 3 + if nptr + 2 >= len(data): + break + val = struct.unpack_from('b', data, nptr)[0] # signed: vol 0..64, pan -32..32 + tick = struct.unpack_from(' 8 + if not decimated: + selected = nodes[:] + if has_loop and use_lb < len(selected) and use_le < len(selected): + taud_slb, taud_sle = use_lb, use_le + else: + taud_slb = taud_sle = -1 + else: + selected = [nodes[round(k * (len(nodes) - 1) / 7)] for k in range(8)] + taud_slb = taud_sle = -1 # loop indices lost in decimation + if has_loop: + vprint(f" loop indices lost due to decimation ({len(nodes)} nodes → 8)") + + # Build 8 Taud envelope points with delta-time minifloats + points = [] + for k in range(8): + if k < len(selected): + val, tick = selected[k] + if is_pan: + taud_val = min(255, max(0, round((val + 32) * 255 / 64))) + else: + taud_val = min(63, max(0, round(val * 63 / 64))) + if k < len(selected) - 1: + _, next_tick = selected[k + 1] + delta_sec = max(0.0, (next_tick - tick) / ticks_per_sec) + mf_idx = _nearest_minifloat(delta_sec) + else: + mf_idx = 0 # last real node: hold + else: + # Pad: copy last real value, offset=0 (hold) + taud_val = points[-1][0] if points else (128 if is_pan else 63) + mf_idx = 0 + points.append((taud_val, mf_idx)) + + # Build sustain byte: bit7=enabled, bits[5:3]=end_idx, bits[2:0]=start_idx. + # 0 = disabled (no bit7). All (slb, sle) pairs including (0,0) are valid when bit7=1. + if taud_slb >= 0 and taud_sle >= 0: + sus_byte = 0x80 | ((taud_sle & 7) << 3) | (taud_slb & 7) + else: + sus_byte = 0 + + return points, sus_byte + + +# ── IT pattern parser ───────────────────────────────────────────────────────── + +class ITRow: + __slots__ = ('note', 'inst', 'vol', 'effect', 'effect_arg', 'volcol', + 'pan_set', 'aux_effect') + def __init__(self): + self.note = -1 # -1=empty, 0-119=pitch, IT_NOTE_* + self.inst = 0 # 1-based + self.vol = -1 # -1=not set + self.effect = 0 + self.effect_arg = 0 + self.volcol = -1 # raw IT vol-col byte, -1 = not set + self.pan_set = None # 0..63 from vol-col, or None + self.aux_effect = None # (cmd,arg) from vol-col, or None + +def _parse_one_pattern(data: bytes, ptr: int) -> tuple: + """Returns (grid: list[64_channels][rows], row_count: int).""" + if ptr == 0 or ptr + 8 > len(data): + return [[ITRow() for _ in range(PATTERN_ROWS)] for _ in range(64)], PATTERN_ROWS + + data_len = struct.unpack_from('= end: break + last_mask[ch] = data[pos]; pos += 1 + mask = last_mask[ch] + + cell = grid[ch][row] + + if mask & 0x01: + last_note[ch] = data[pos]; pos += 1 + if mask & 0x02: + last_inst[ch] = data[pos]; pos += 1 + if mask & 0x04: + last_volcol[ch] = data[pos]; pos += 1 + if mask & 0x08: + last_cmd[ch] = data[pos]; pos += 1 + last_arg[ch] = data[pos]; pos += 1 + + if mask & 0x11: cell.note = last_note[ch] + if mask & 0x22: cell.inst = last_inst[ch] + if mask & 0x44: cell.volcol = last_volcol[ch] + if mask & 0x88: + cell.effect = last_cmd[ch] + cell.effect_arg = last_arg[ch] + + return grid, row_count + +def parse_patterns(data: bytes, h: ITHeader) -> list: + """Returns list of (grid, row_count) tuples.""" + patterns = [] + for ptr in h.pat_ptrs: + grid, rows = _parse_one_pattern(data, ptr) + patterns.append((grid, rows)) + return patterns + + +# ── Note encoding (IT linear 0-119 → Taud pitch units) ─────────────────────── + +def encode_note_it(it_note: int) -> int: + if it_note == IT_NOTE_OFF or it_note == IT_NOTE_FADE: + return NOTE_KEYOFF + if it_note == IT_NOTE_CUT: + return NOTE_CUT + if 0 <= it_note <= 119: + # IT middle C is C-5 (note 60); Taud reference is C-3 (TAUD_C3 = 0x4000). + # IT C-5 anchors to Taud C-3, so offset = it_note - 60. + semis = it_note - 60 + val = round(TAUD_C3 + semis * 4096 / 12) + return max(1, min(0xFFFD, val)) + return NOTE_NOP + + +# ── Vol-column decoder ──────────────────────────────────────────────────────── + +def decode_volcol(vc: int): + """Return (vol_sel, vol_value, pan_set, aux_effect) or None for each field.""" + if vc < 0: # not set + return SEL_FINE, 0, None, None + if vc <= VC_VOL_HI: + return SEL_SET, min(vc, 0x3F), None, None + if VC_FVUP_LO <= vc <= VC_FVUP_HI: + mag = vc - VC_FVUP_LO + 1 # 1..10 + return SEL_FINE, (mag & 0x1F) | 0x20, None, None # fine up + if VC_FVDN_LO <= vc <= VC_FVDN_HI: + mag = vc - VC_FVDN_LO + 1 + return SEL_FINE, mag & 0x1F, None, None # fine down + if VC_VUP_LO <= vc <= VC_VUP_HI: + return SEL_UP, vc - VC_VUP_LO + 1, None, None + if VC_VDN_LO <= vc <= VC_VDN_HI: + return SEL_DOWN, vc - VC_VDN_LO + 1, None, None + if VC_PDN_LO <= vc <= VC_PDN_HI: + # Pitch slide down: each unit = 4 ST3 coarse units (1/16 semitone each) + units = (vc - VC_PDN_LO + 1) * 4 + return SEL_FINE, 0, None, (EFF_E, units & 0xFF) + if VC_PUP_LO <= vc <= VC_PUP_HI: + units = (vc - VC_PUP_LO + 1) * 4 + return SEL_FINE, 0, None, (EFF_F, units & 0xFF) + if VC_PAN_LO <= vc <= VC_PAN_HI: + pan64 = vc - VC_PAN_LO # 0..64 + pan6 = min(0x3F, round(pan64 * 63 / 64)) + return SEL_FINE, 0, pan6, None + if VC_TPORTA_LO <= vc <= VC_TPORTA_HI: + spd = VC_TPORTA_TABLE[vc - VC_TPORTA_LO] + return SEL_FINE, 0, None, (EFF_G, spd & 0xFF) + if VC_VIB_LO <= vc <= VC_VIB_HI: + depth = vc - VC_VIB_LO + 1 # 1..10 + return SEL_FINE, 0, None, (EFF_H, depth & 0x0F) + return SEL_FINE, 0, None, None + + +# ── 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). + + Differs from s3m2taud.encode_effect in: + - Cxx: binary row number, not BCD + - V: IT global vol 0-128 scaled ×2 + - X: IT full 8-bit pan → 6-bit + - S6x, S7x, SAx, SFx handled (mostly dropped) + """ + if cmd == 0: + return (TOP_NONE, 0, None, None) + + if cmd == EFF_A: + if arg == 0: + return (TOP_NONE, 0, None, None) + return (TOP_A, (arg & 0xFF) << 8, None, None) + + if cmd == EFF_B: + return (TOP_B, arg & 0xFF, None, None) + + if cmd == EFF_C: + # IT stores binary (not BCD like ST3) + row_num = arg & 0xFF + if row_num >= PATTERN_ROWS: + row_num = 0 + return (TOP_C, row_num & 0xFF, None, None) + + if cmd == EFF_D: + return (TOP_D, (arg & 0xFF) << 8, None, None) + + if cmd in (EFF_E, EFF_F): + op = TOP_E if cmd == EFF_E else TOP_F + hi = (arg >> 4) & 0xF + lo = arg & 0xF + if hi in (0xE, 0xF) and lo > 0: + return (op, 0xF000 | (round(lo * 16 / 3) & 0xFFF), None, None) + return (op, round(arg * 64 / 3) & 0xFFFF, None, None) + + if cmd == EFF_G: + return (TOP_G, round(arg * 64 / 3) & 0xFFFF, None, None) + + if cmd in (EFF_H, EFF_I, EFF_R, EFF_U): + op = {EFF_H: TOP_H, EFF_I: TOP_I, EFF_R: TOP_R, EFF_U: TOP_U}[cmd] + hi = (arg >> 4) & 0xF + lo = arg & 0xF + return (op, ((hi * 0x11) << 8) | (lo * 0x11), None, None) + + if cmd == EFF_J: + hi_semi = (arg >> 4) & 0xF + lo_semi = arg & 0xF + 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) + + if cmd == EFF_L: + 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) + + 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)) + + if cmd == EFF_Q: + return (TOP_Q, (arg & 0xFF) << 8, None, None) + + if cmd == EFF_S: + sub = (arg >> 4) & 0xF + val = arg & 0xF + if sub in (0x1, 0x2, 0x3, 0x4, 0xB, 0xC, 0xD, 0xE): + return (TOP_S, (sub << 12) | (val << 8), None, None) + if sub == 0x5: + return (TOP_S, 0x5000 | (val << 8), None, None) + if sub == 0x8: + # IT S8x: 4-bit → nibble-repeat into 8-bit SEL_SET pan + pan8 = (val << 4) | val + pan6 = min(0x3F, round(pan8 * 63 / 255)) + return (TOP_NONE, 0, None, (SEL_SET, pan6)) + if sub == 0x6: + vprint(f" dropped S6{val:X} (tick delay) at ch{ch} row{row}") + return (TOP_NONE, 0, None, None) + if sub == 0x7: + return (TOP_NONE, 0, None, None) # NNA/envelope — drop silently + if sub == 0x9: + return (TOP_NONE, 0, None, None) # sound control — drop silently + if sub == 0xA: + vprint(f" dropped SA{val:X} (high offset) at ch{ch} row{row}") + return (TOP_NONE, 0, None, None) + if sub == 0xF: + vprint(f" dropped SF{val:X} (MIDI macro) at ch{ch} row{row}") + return (TOP_NONE, 0, None, None) + return (TOP_NONE, 0, None, None) + + if cmd == EFF_T: + if arg >= 0x20: + return (TOP_T, ((arg - 0x18) & 0xFF) << 8, None, None) + return (TOP_T, arg & 0xFF, None, None) + + if cmd == EFF_V: + # IT global vol is 0-128; Taud uses 0-255 → ×2 + taud_v = min(arg * 2, 0xFF) + return (TOP_V, (taud_v & 0xFF) << 8, None, None) + + if cmd == EFF_W: + vprint(f" dropped W{arg:02X} (global vol slide) at ch{ch} row{row}") + return (TOP_NONE, 0, None, None) + + if cmd == EFF_X: + # IT X is full 8-bit pan (0=left, 255=right; 128=centre in OpenMPT but + # IT spec says 0-255 maps to full left-right) + pan6 = min(0x3F, round(arg * 63 / 255)) + return (TOP_NONE, 0, None, (SEL_SET, pan6)) + + if cmd == EFF_Y: + hi = (arg >> 4) & 0xF + lo = arg & 0xF + return (TOP_Y, ((hi * 0x11) << 8) | (lo * 0x11), None, None) + + if cmd == EFF_Z: + vprint(f" dropped Z{arg:02X} (MIDI macro) at ch{ch} row{row}") + return (TOP_NONE, 0, None, None) + + return (TOP_NONE, 0, None, None) + + +# ── IT recall resolution ────────────────────────────────────────────────────── + +def resolve_it_recalls(patterns_rows: list, order_list: list, + num_channels: int, link_gef: bool) -> None: + """Walk in order, resolve zero-arg recalls per-effect-per-channel. + + IT effect memory groups: + - D / K / L: shared vol-slide cohort + - E / F (/ G when link_gef): shared pitch-slide cohort + - G: own slot (or part of EF cohort when link_gef) + - All others: private slots + """ + # last_mem[ch][eff_key] = last_non_zero_arg + # eff_key: integer 1-26 for most effects; we merge cohorts by normalising. + last_mem = [{} for _ in range(num_channels)] + + def cohort_key(cmd): + if cmd in (EFF_D, EFF_K, EFF_L): + return EFF_D # vol-slide cohort + if link_gef and cmd in (EFF_E, EFF_F, EFF_G): + return EFF_E # EFG cohort + if not link_gef and cmd in (EFF_E, EFF_F): + return EFF_E # EF cohort + return cmd + + for order in order_list: + if order >= IT_ORD_END: + break + if order >= len(patterns_rows): + continue + grid, rows = patterns_rows[order] + for r in range(rows): + for ch in range(num_channels): + if ch >= len(grid): + continue + cell = grid[ch][r] + if cell.effect not in IT_MEM_EFFECTS: + continue + key = cohort_key(cell.effect) + if cell.effect_arg == 0: + cell.effect_arg = last_mem[ch].get(key, 0) + else: + last_mem[ch][key] = cell.effect_arg + + +# ── Pattern row-chunk splitter ──────────────────────────────────────────────── + +def split_patterns(patterns_rows: list): + """ + Returns (chunks, chunk_map). + chunks: flat list of 64-row grids (list of 64 × 64-channel ITRow arrays) + chunk_map: list per source pattern of [chunk_idx_0, chunk_idx_1, ...] + """ + chunks = [] + chunk_map = [] + + for pi, (grid, rows) in enumerate(patterns_rows): + if rows == 0: + chunk_map.append([]) + continue + + n_chunks = (rows + PATTERN_ROWS - 1) // PATTERN_ROWS + if n_chunks > 1: + vprint(f" pattern {pi}: {rows} rows → {n_chunks} chunks") + + pat_chunks = [] + for k in range(n_chunks): + r0 = k * PATTERN_ROWS + r1 = min(r0 + PATTERN_ROWS, rows) + # Build a 64-row grid for this chunk + chunk_grid = [] + for ch in range(64): + ch_rows = [] + src = grid[ch] if ch < len(grid) else [] + for ri in range(PATTERN_ROWS): + sr = r0 + ri + if sr < r1 and ri < len(src): + ch_rows.append(src[sr]) + else: + ch_rows.append(ITRow()) + chunk_grid.append(ch_rows) + + # If this is not the last chunk, add a C $0000 on ch0 row (r1-r0-1) + # to immediately break to next order (skip padding rows). + # Only needed when the last real row of this chunk is < 63. + if k < n_chunks - 1: + last_real = r1 - r0 - 1 + pad_row = chunk_grid[0][last_real] + if pad_row.effect == 0: + pad_row.effect = EFF_C + pad_row.effect_arg = 0 + elif rows < PATTERN_ROWS and n_chunks == 1: + # Single chunk, short pattern → break at last real row + last_real = rows - 1 + if last_real < PATTERN_ROWS - 1: + pad_row = chunk_grid[0][last_real] + if pad_row.effect == 0: + pad_row.effect = EFF_C + pad_row.effect_arg = 0 + + idx = len(chunks) + chunks.append(chunk_grid) + pat_chunks.append(idx) + + chunk_map.append(pat_chunks) + return chunks, chunk_map + + +def _remap_bc_effects(chunks: list, chunk_map: list, + order_list: list, it_ord_to_taud_cue: dict, + num_channels: int) -> None: + """Rewrite B/C effects using remapped order indices. + + B effects in all chunks are rewritten to point to the first chunk + of the target IT order. C effects in non-final chunks of a split + pattern get a co-row B to skip remaining chunks. + """ + # For each chunk, record which (it_pat, chunk_k, n_chunks) it came from. + # We build this from chunk_map. + chunk_info = {} # chunk_idx → (it_pat_idx, k, n_chunks) + for pi, pat_chunks in enumerate(chunk_map): + n = len(pat_chunks) + for k, ci in enumerate(pat_chunks): + chunk_info[ci] = (pi, k, n) + + for ci, chunk_grid in enumerate(chunks): + pi, k, n = chunk_info.get(ci, (0, 0, 1)) + + for ch in range(num_channels): + if ch >= len(chunk_grid): continue + for row in chunk_grid[ch]: + if row.effect == EFF_B: + it_tgt = row.effect_arg + taud_cue = it_ord_to_taud_cue.get(it_tgt, it_tgt) + row.effect_arg = taud_cue & 0xFF + elif row.effect == EFF_C and k < n - 1: + # C in non-final chunk: need B to skip remaining chunks + # Find the cue index immediately after all chunks of this pat + # (the cue right after the last chunk of pi in the order list) + # We store the B in aux_effect; the Taud builder handles it. + skip_cue = _find_post_pat_cue(pi, order_list, chunk_map, + it_ord_to_taud_cue) + if skip_cue is not None: + row.aux_effect = (EFF_B, skip_cue & 0xFF) + +def _find_post_pat_cue(pi: int, order_list: list, chunk_map: list, + it_ord_to_taud_cue: dict): + """Return the Taud cue index that follows ALL chunks of pattern pi in the order list.""" + for taud_cue, it_ord in it_ord_to_taud_cue.items(): + # Find first Taud cue after the last chunk of pi + pass + # Simpler: walk the Taud cue list (we'll compute it in assemble_taud) + # Return None for now — assemble_taud will do a second pass. + return None + + +# ── 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). + + envelopes_by_slot: optional dict mapping taud_slot → (vol_env, vol_sus, pan_env, pan_sus) + where vol_env/pan_env are lists of 8 (value, minifloat_idx) tuples (or None). + + Returns (bin_bytes[SAMPLEINST_SIZE], offsets_dict). + """ + pcm_list = [(i, s) for i, s in enumerate(samples_or_proxy) + if s is not None and s.sample_data] + + total = sum(len(s.sample_data) for _, s in pcm_list) + 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_list: + 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)) + s.loop_end = max(0, min(int(s.loop_end * ratio), s.length)) + s.c5_speed = max(1, int(s.c5_speed * ratio)) + + sample_bin = bytearray(SAMPLEBIN_SIZE) + offsets = {} + pos = 0 + for idx, s in pcm_list: + 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 {len(s.sample_data)} → {n}") + s.length = n; s.loop_end = min(s.loop_end, n) + pos += n + + inst_bin = bytearray(INSTBIN_SIZE) + for i, s in enumerate(samples_or_proxy): + taud_idx = i # samples_or_proxy is 0-based here; slot 0 unused + if i == 0 or i >= 256 or s is None: + continue + ptr = offsets.get(i, 0) + ptr_lo = ptr & 0xFFFF + ptr_hi = ptr >> 16 + s_len = min(s.length, 65535) + c2spd = min(s.c5_speed, 65535) + ls = min(s.loop_beg, 65535) + le = min(s.loop_end, 65535) + loop_mode = 1 if s.has_loop else 0 + flags_byte = (ptr_hi << 4) | (loop_mode & 0x3) + + base = taud_idx * 64 + struct.pack_into(' int: + """Convert raw IT channel-pan byte to Taud 0..63.""" + if raw_pan == 100: # surround → centre + return 31 + p = raw_pan & 0x7F + return min(0x3F, round(p * 63 / 64)) + +def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int, + inst_vols: dict) -> bytes: + """Build a 512-byte Taud pattern for one IT channel from a 64-row chunk grid.""" + out = bytearray(PATTERN_BYTES) + rows = chunk_grid[ch_idx] if ch_idx < len(chunk_grid) else [ITRow()] * PATTERN_ROWS + last_inst = 0 + last_note_it = -1 + last_vol = None + + for r, cell in enumerate(rows[:PATTERN_ROWS]): + # ── Resolve vol-col into overrides ────────────────────────────────── + vs, vv, pan_from_vc, aux_eff = decode_volcol(cell.volcol) + + # If vol-col provides an aux effect and cell has no main effect, use it + if aux_eff is not None and cell.effect == 0: + cell.effect, cell.effect_arg = aux_eff + aux_eff = None + elif aux_eff is not None: + vprint(f" ch{ch_idx} row{r}: dropped vol-col aux effect " + f"(main effect slot occupied)") + aux_eff = None + + # If vol-col has a pan override + if pan_from_vc is not None: + cell.pan_set = pan_from_vc + + # Encode main effect + op, arg16, vol_override, pan_override = encode_effect_it( + cell.effect, cell.effect_arg, ch_idx, r) + + # ── Note ──────────────────────────────────────────────────────────── + note_taud = NOTE_NOP + if cell.note >= 0: + note_taud = encode_note_it(cell.note) + + if cell.inst > 0: + last_inst = cell.inst + + note_triggers = (0 <= (cell.note if cell.note >= 0 else -1) <= 119) + + # ── Volume column ──────────────────────────────────────────────────── + # Priority: explicit cell vol (from vol-col 0-64) > note-trigger default + # > retrigger recall > vol-col slide > main-effect vol override > nop + if cell.volcol >= 0 and cell.volcol <= VC_VOL_HI: + vol_sel, vol_value = SEL_SET, min(cell.volcol, 0x3F) + elif note_triggers and last_inst > 0: + vol_sel = SEL_SET + vol_value = inst_vols.get(last_inst, 0x3F) + elif (cell.inst > 0 and cell.note < 0 + and last_note_it >= 0 and last_vol is not None): + # Instrument-only retrigger: restate last volume + vol_sel, vol_value = SEL_SET, last_vol + elif vol_override is not None: + vol_sel, vol_value = vol_override + elif vs != SEL_FINE or vv != 0: + vol_sel, vol_value = vs, vv + else: + vol_sel, vol_value = SEL_FINE, 0 + + if cell.note is not None and 0 <= (cell.note if cell.note >= 0 else -1) <= 119: + last_note_it = cell.note + if vol_sel == SEL_SET: + last_vol = vol_value + + # ── Pan column ─────────────────────────────────────────────────────── + if cell.pan_set is not None: + pan_sel, pan_value = SEL_SET, cell.pan_set + elif 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 + + # Handle aux_effect (B) stored on a C cell for chunk-skip + if cell.aux_effect is not None: + aux_cmd, aux_arg = cell.aux_effect + if aux_cmd == EFF_B and op == TOP_C: + # Encode as B effect; C row break handled by engine's simultaneous B+C + op = TOP_C + # We need to emit both; store the B's target in arg16 high byte + # Taud simultaneous B+C: B sets order, C sets row. Engine handles. + # Encoding: keep op=TOP_C (pattern break), store B target in + # a separate "B command on another channel". We can't encode two + # effects in one cell, so instead just emit the B effect here + # and let the order index point past the remaining chunks. + # This is a best-effort; the engine should honour the lowest-channel B. + op = TOP_B + arg16 = aux_arg & 0xFF + + vol_byte = (vol_value & 0x3F) | ((vol_sel & 0x3) << 6) + pan_byte = (pan_value & 0x3F) | ((pan_sel & 0x3) << 6) + + taud_inst = last_inst & 0xFF if (note_triggers or cell.inst > 0) else 0 + + base = r * 8 + struct.pack_into(' 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, + default_speed: int, default_tempo: int) -> tuple: + speed = default_speed or 6 + tempo = default_tempo or 125 + for order in order_list: + if order >= IT_ORD_END: break + if order >= len(patterns_rows): continue + grid, _rows = patterns_rows[order] + for ch_rows in grid: + if not ch_rows: continue + cell = ch_rows[0] + if cell.effect == EFF_A and cell.effect_arg > 0: + speed = cell.effect_arg + if cell.effect == EFF_T and cell.effect_arg > 0: + tempo = cell.effect_arg + break + return speed, tempo + +def _active_channels(h: ITHeader, patterns_rows: list) -> list: + """Return up to 20 non-muted, in-use channel indices.""" + # Muted = bit 7 of chnl_pan set, or == 0xC0 + muted = set() + for i, p in enumerate(h.chnl_pan): + if p & 0x80 or p == 0xC0: + muted.add(i) + + # In-use = any non-empty cell appears on this channel + in_use = set() + for grid, rows in patterns_rows: + for ch in range(64): + if ch >= len(grid): continue + for cell in grid[ch]: + if cell.note >= 0 or cell.inst > 0 or cell.effect != 0: + in_use.add(ch) + break + + active = [i for i in range(64) if i in in_use and i not in muted] + if len(active) > NUM_VOICES: + vprint(f" warning: {len(active)} active channels; capping at {NUM_VOICES}") + active = active[:NUM_VOICES] + return active + +def assemble_taud(h: ITHeader, samples: list, instruments: list, + patterns_rows: list, decompress: bool) -> bytes: + # ── Resolve IT recalls ─────────────────────────────────────────────────── + vprint(" resolving IT recalls…") + resolve_it_recalls(patterns_rows, h.order_list, 64, h.link_gef) + + # ── Check SBx chunk crossing (warn only) ───────────────────────────────── + for pi, (grid, rows) in enumerate(patterns_rows): + if rows <= PATTERN_ROWS: continue + n_chunks = (rows + PATTERN_ROWS - 1) // PATTERN_ROWS + for ch in range(64): + if ch >= len(grid): continue + loop_start_chunk = None + for r, cell in enumerate(grid[ch]): + if cell.effect == EFF_S: + sub = (cell.effect_arg >> 4) & 0xF + val = cell.effect_arg & 0xF + k = r // PATTERN_ROWS + if sub == 0xB and val == 0: + loop_start_chunk = k + elif sub == 0xB and val > 0: + if loop_start_chunk is not None and k != loop_start_chunk: + vprint(f" warning: pattern {pi} ch{ch}: SBx crosses " + f"chunk boundary (loops may misbehave)") + break + + # ── Split patterns into 64-row chunks ──────────────────────────────────── + vprint(" splitting patterns…") + chunks, chunk_map = split_patterns(patterns_rows) + + # ── Choose active channels ─────────────────────────────────────────────── + active_channels = _active_channels(h, patterns_rows) + C = len(active_channels) + if C == 0: + sys.exit("error: no active channels found") + + # ── Build the ordered list of (taud_chunk_idx, voice_idx) triples ──────── + # Expand order list: each IT order → sequence of chunk indices for that pattern + taud_cue_list = [] # list of chunk_idx (source patterns, already chunked) + it_ord_to_taud_cue = {} # first taud cue for IT order i + + for oi, order in enumerate(h.order_list): + if order == IT_ORD_END: + break + if order == IT_ORD_SKIP: + continue + if order >= len(chunk_map): + continue + it_ord_to_taud_cue.setdefault(oi, len(taud_cue_list)) + for ci in chunk_map[order]: + taud_cue_list.append(ci) + + # ── Remap B effects ────────────────────────────────────────────────────── + _remap_bc_effects(chunks, chunk_map, h.order_list, it_ord_to_taud_cue, + len(active_channels)) + + # ── Build sample proxy list (0-indexed, slot 0 unused) ────────────────── + # When use_instruments: map Taud instrument slots to samples via canonical_sample. + # Pattern cells carry IT instrument numbers; for use_instruments mode, those + # are instrument indices; we remap to samples below. + # Taud only knows "instrument" slots (1-based, 8-bit). We lay samples in order. + if h.use_instruments: + # Build a proxy sample list where Taud inst slot = IT inst index, + # resolved to the canonical sample. Slot 0 unused. + proxy = [None] * (max(len(instruments), 256) + 1) + inst_vols = {} + envelopes_by_slot = {} + for ii, inst in enumerate(instruments): + taud_slot = ii + 1 + if taud_slot >= 256: break + if inst is None: continue + si = inst.canonical_sample - 1 # 0-based sample index + if si < 0 or si >= len(samples) or samples[si] is None: + continue + proxy[taud_slot] = samples[si] + vol64 = min(inst.canonical_volume, 64) + inst_vols[taud_slot] = min(vol64, 0x3F) + envelopes_by_slot[taud_slot] = ( + inst.vol_envelope, inst.vol_env_sustain, + inst.pan_envelope, inst.pan_env_sustain, + ) + sampleinst_raw, _ = build_sample_inst_bin_it(proxy, envelopes_by_slot) + else: + # Samples referenced directly; proxy is samples list (0-based, slot 0 unused) + proxy = [None] + list(samples) + inst_vols = { + i+1: min(s.vol, 0x3F) + for i, s in enumerate(samples) + if s is not None + } + sampleinst_raw, _ = build_sample_inst_bin_it(proxy) + + 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)") + + # ── BPM / speed ────────────────────────────────────────────────────────── + speed, tempo = find_initial_bpm_speed(patterns_rows, 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={tempo} BPM") + + # ── Pattern bin ────────────────────────────────────────────────────────── + vprint(" building pattern bin…") + default_pans = [_it_default_pan(h.chnl_pan[ch]) for ch in active_channels] + total_taud_pats = len(taud_cue_list) * C + if total_taud_pats > NUM_PATTERNS_MAX: + sys.exit( + f"error: {len(taud_cue_list)} cues × {C} channels = " + f"{total_taud_pats} > {NUM_PATTERNS_MAX} Taud pattern limit." + ) + + pat_bin = bytearray() + for ci in taud_cue_list: + cg = chunks[ci] + for vi, ch in enumerate(active_channels): + pat_bin += build_pattern_it(cg, ch, default_pans[vi], inst_vols) + + orig_count = len(taud_cue_list) * C + 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)") + + # ── Cue sheet ──────────────────────────────────────────────────────────── + vprint(" building cue sheet…") + 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) + + 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) + last_active = cue_idx + + if last_active >= 0: + sheet[last_active * CUE_SIZE + 30] = 0x01 + else: + sheet[30] = 0x01 + + # ── Header ─────────────────────────────────────────────────────────────── + sig = (SIGNATURE + b' ' * 14)[:14] + header = ( + TAUD_MAGIC + + bytes([TAUD_VERSION, 1]) + + struct.pack('= 23) { - voice.envVolume = inst.envelopes[23].volume / 255.0 - return - } - val offset = inst.envelopes[voice.envIndex].offset.toFloat().toDouble() - if (offset == 0.0) { - voice.envVolume = inst.envelopes[voice.envIndex].volume / 255.0 - return - } - voice.envTimeSec += tickSec - if (voice.envTimeSec >= offset) { - voice.envTimeSec -= offset - voice.envIndex = (voice.envIndex + 1).coerceAtMost(23) - voice.envVolume = inst.envelopes[voice.envIndex].volume / 255.0 + // Volume envelope + // sustain byte: bit7=enabled, bits[5:3]=end_idx, bits[2:0]=start_idx + val vSus = inst.volEnvSustain + val vSusOn = (vSus and 0x80) != 0 && !voice.keyOff + val vSusStart = vSus and 7 + val vSusEnd = (vSus ushr 3) and 7 + + if (voice.envIndex >= 7) { + voice.envVolume = (inst.volEnvelopes[7].value / 63.0).coerceIn(0.0, 1.0) + } else if (vSusOn && voice.envIndex == vSusEnd && vSusStart == vSusEnd) { + // slb == sle: hold at this node until key-off (no cycling) + voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0) } else { - val cur = inst.envelopes[voice.envIndex].volume / 255.0 - val nxt = inst.envelopes[(voice.envIndex + 1).coerceAtMost(23)].volume / 255.0 - voice.envVolume = cur + (nxt - cur) * (voice.envTimeSec / offset) + val vOffset = inst.volEnvelopes[voice.envIndex].offset.toDouble() + if (vOffset == 0.0) { + voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0) + } else { + voice.envTimeSec += tickSec + if (voice.envTimeSec >= vOffset) { + voice.envTimeSec -= vOffset + val nextIdx = if (vSusOn && voice.envIndex == vSusEnd) vSusStart + else (voice.envIndex + 1).coerceAtMost(7) + voice.envIndex = nextIdx + voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0) + } else { + val cur = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0) + val nxt = (inst.volEnvelopes[(voice.envIndex + 1).coerceAtMost(7)].value / 63.0).coerceIn(0.0, 1.0) + voice.envVolume = cur + (nxt - cur) * (voice.envTimeSec / vOffset) + } + } + } + + // Pan envelope (only when active for this instrument) + if (!voice.hasPanEnv) return + val pSus = inst.panEnvSustain + val pSusOn = (pSus and 0x80) != 0 && !voice.keyOff + val pSusStart = pSus and 7 + val pSusEnd = (pSus ushr 3) and 7 + + if (voice.envPanIndex >= 7) { + voice.envPan = inst.panEnvelopes[7].value / 255.0 + } else if (pSusOn && voice.envPanIndex == pSusEnd && pSusStart == pSusEnd) { + // slb == sle: hold at this pan node until key-off + voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0 + } else { + val pOffset = inst.panEnvelopes[voice.envPanIndex].offset.toDouble() + if (pOffset == 0.0) { + voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0 + } else { + voice.envPanTimeSec += tickSec + if (voice.envPanTimeSec >= pOffset) { + voice.envPanTimeSec -= pOffset + val nextIdx = if (pSusOn && voice.envPanIndex == pSusEnd) pSusStart + else (voice.envPanIndex + 1).coerceAtMost(7) + voice.envPanIndex = nextIdx + voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0 + } else { + val cur = inst.panEnvelopes[voice.envPanIndex].value / 255.0 + val nxt = inst.panEnvelopes[(voice.envPanIndex + 1).coerceAtMost(7)].value / 255.0 + voice.envPan = cur + (nxt - cur) * (voice.envPanTimeSec / pOffset) + } + } } } @@ -1238,9 +1282,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.samplePos = inst.samplePlayStart.toDouble() voice.forward = true voice.active = true + voice.keyOff = false voice.envIndex = 0 voice.envTimeSec = 0.0 - voice.envVolume = inst.envelopes[0].volume / 255.0 + voice.envVolume = (inst.volEnvelopes[0].value / 63.0).coerceIn(0.0, 1.0) + voice.envPanIndex = 0 + voice.envPanTimeSec = 0.0 + voice.envPan = inst.panEnvelopes[0].value / 255.0 + voice.hasPanEnv = inst.panEnvelopes.any { it.offset.toFloat() > 0.0f } voice.noteVal = noteVal voice.basePitch = noteVal voice.playbackRate = computePlaybackRate(inst, noteVal) @@ -1322,7 +1371,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { val toneG = (row.effect == EffectOp.OP_G) when (row.note) { 0xFFFF -> {} // no-op - 0x0000 -> voice.active = false // key-off (TODO release envelope) + 0x0000 -> { voice.keyOff = true; voice.active = false } // key-off; breaks sustain loop 0xFFFE -> voice.active = false // note cut else -> { if (toneG && voice.active) { @@ -1652,8 +1701,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.retrigCounter++ if (voice.retrigCounter >= voice.retrigInterval) { voice.retrigCounter = 0 - voice.samplePos = instruments[voice.instrumentId].samplePlayStart.toDouble() + val retrigInst = instruments[voice.instrumentId] + voice.samplePos = retrigInst.samplePlayStart.toDouble() + voice.keyOff = false voice.envIndex = 0; voice.envTimeSec = 0.0 + voice.envPanIndex = 0; voice.envPanTimeSec = 0.0 + voice.envPan = retrigInst.panEnvelopes[0].value / 255.0 voice.rowVolume = applyRetrigVolMod(voice.rowVolume, voice.retrigVolMod) voice.channelVolume = voice.rowVolume } @@ -1741,7 +1794,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { if (!voice.active || voice.muted) continue val s = fetchTrackerSample(voice, instruments[voice.instrumentId]) val vol = voice.envVolume * voice.rowVolume / 63.0 * gvol * playhead.masterVolume / 255.0 - val pan = voice.channelPan + val pan = if (voice.hasPanEnv) { + val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255) + (voice.channelPan + envPanRaw - 128).coerceIn(0, 255) + } else voice.channelPan val lGain: Double val rGain: Double when (ts.panLaw) { @@ -1915,9 +1971,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { var channelPan = 0x80 // 8-bit; $80 centre. Cell column packs into 6-bit, S$80xx writes the full 8-bit. var rowPan = 32 // 6-bit pan used by mixer, derived from channelPan + var keyOff = false var envIndex = 0 var envTimeSec = 0.0 var envVolume = 1.0 + var envPanIndex = 0 + var envPanTimeSec = 0.0 + var envPan = 0.5 // 0.0=full-left, 1.0=full-right, 0.5=centre + var hasPanEnv = false // Pitch state (4096-TET units, signed when slid). var noteVal = 0xFFFF // The currently sounding base note (no per-row vibrato/arp added) @@ -2208,7 +2269,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } - data class TaudInstVolEnv(var volume: Int, var offset: ThreeFiveMiniUfloat) + data class TaudInstEnvPoint(var value: Int, var offset: ThreeFiveMiniUfloat) data class TaudInst( var index: Int, @@ -2220,9 +2281,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { var sampleLoopEnd: Int, // flags var loopMode: Int, - var envelopes: Array // first int: volume (0..255), second int: offsets (minifloat indices) + var volEnvSustain: Int, // byte 13: 00 eee sss (0 = no sustain loop) + var panEnvSustain: Int, // byte 14: 00 eee sss (0 = no sustain loop) + var volEnvelopes: Array, // 8 points, value 0x00-0x3F + var panEnvelopes: Array // 8 points, value 0x00-0xFF (0x80 = centre) ) { - constructor(index: Int) : this(index, 0, 0, 0, 0, 0, 0, 0, Array(24) { TaudInstVolEnv(0, ThreeFiveMiniUfloat(0)) }) + constructor(index: Int) : this(index, 0, 0, 0, 0, 0, 0, 0, 0, 0, + Array(8) { TaudInstEnvPoint(0x3F, ThreeFiveMiniUfloat(0)) }, + Array(8) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) }) // Funk repeat (S$Fx00) bit-mask — non-destructive XOR overlay across the loop region. // Lazily allocated; a 1-bit flips the byte, a 0-bit leaves it intact. @@ -2260,9 +2326,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { 11 -> sampleLoopEnd.ushr(8).toByte() 12 -> (samplePtr.ushr(16).and(15).shl(4) or loopMode.and(3)).toByte() - 13,14,15 -> 0 - in 16..63 step 2 -> envelopes[(offset - 16) / 2].volume.toByte() - in 17..63 step 2 -> envelopes[(offset - 17) / 2].offset.index.toByte() + 13 -> volEnvSustain.toByte() + 14 -> panEnvSustain.toByte() + 15 -> 0 + in 16..30 step 2 -> volEnvelopes[(offset - 16) / 2].value.toByte() + in 17..31 step 2 -> volEnvelopes[(offset - 17) / 2].offset.index.toByte() + in 32..46 step 2 -> panEnvelopes[(offset - 32) / 2].value.toByte() + in 33..47 step 2 -> panEnvelopes[(offset - 33) / 2].offset.index.toByte() + in 48..63 -> 0 else -> throw InternalError("Bad offset $offset") } @@ -2290,10 +2361,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { else samplePtr and 0x0ffff loopMode = byte and 3 } - 13, 14, 15 -> {} + 13 -> { volEnvSustain = byte } + 14 -> { panEnvSustain = byte } + 15 -> {} - in 16..63 step 2 -> envelopes[(offset - 16) / 2].volume = byte - in 17..63 step 2 -> envelopes[(offset - 17) / 2].offset = ThreeFiveMiniUfloat(byte) + in 16..30 step 2 -> volEnvelopes[(offset - 16) / 2].value = byte + in 17..31 step 2 -> volEnvelopes[(offset - 17) / 2].offset = ThreeFiveMiniUfloat(byte) + in 32..46 step 2 -> panEnvelopes[(offset - 32) / 2].value = byte + in 33..47 step 2 -> panEnvelopes[(offset - 33) / 2].offset = ThreeFiveMiniUfloat(byte) + in 48..63 -> {} else -> throw InternalError("Bad offset $offset") } }