#!/usr/bin/env python3 """xm2taud.py — Convert FastTracker 2 (.xm) to TSVM Taud (.taud) Usage: python3 xm2taud.py input.xm output.taud [-v] Limits: - Up to 20 XM channels (excess unused). - Sample bin is 8 MB (8388608 bytes); if all samples together exceed this, every sample is globally resampled down (with c2spd adjusted) so pitch is preserved, mirroring it2taud / mod2taud. Any individual sample whose 8-bit-mono form still exceeds the u16 length cap (SAMPLE_LEN_LIMIT bytes) is then resampled selectively to fit, and TOP_O sample-offset args on the affected channel are rescaled per-slot. - Multi-sample instruments use the sample selected by the *current note's* keymap entry; the converter materialises one Taud instrument slot per (XM instrument, sample-in-instrument) pair. (Note: it2taud uses the alternate Ixmp project-data extension instead — one Taud instrument per IT instrument, plus an Ixmp patch list for the keyboard mapping. XM could be retrofitted the same way to conserve Taud instrument slots; deferred until any real XM file actually hits the 255-slot cap.) Pattern length policy: - XM patterns ≤ 64 rows → 1 Taud cue with the LEN ($02xx) cuesheet instruction (rows < 64) or no instruction (rows == 64). - XM patterns > 64 rows → split into ⌊rows/64⌋ full 64-row cues plus, if rows % 64 != 0, a final cue holding the remainder rows with the LEN instruction. Full 64-row cues emit no instruction. - The cuesheet LEN instruction is decoded by AudioAdapter.kt — the engine wraps to the next cue after `rows` rows instead of always waiting for row 64. Effect support: Full XM effect dispatch per TAUD_NOTE_EFFECTS.md (FastTracker 2 → Taud conversion table). Volume column commands fold into either the Taud volume column directly or as an aux effect on the main effect slot when free, dropped otherwise (same policy as it2taud's decode_volcol). Position-jump (Bxx) and pattern-break (Dxx) are remapped to Taud cue indices. Reference: XM format spec — reference_materials/MilkyTracker/resources/reference/xm-form.txt Parser — reference_materials/MilkyTracker/src/milkyplay/LoaderXM.cpp """ import argparse import copy import math import struct import sys from taud_common import ( set_verbose, vprint, TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY, SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT, PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES, NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4, TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I, TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y, SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE, J_SEMI_TABLE, d_arg_to_col, resample_linear, rescale_offset_effects_per_slot, encode_cue, deduplicate_patterns, normalise_sample, encode_song_entry, nearest_minifloat, compress_blob, CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len, build_project_data, detect_subsongs, ) # ── XM constants ───────────────────────────────────────────────────────────── XM_MAGIC = b'Extended Module: ' # 17 bytes XM_NOTE_OFF = 97 # XM raw note value for key-off XM_RELNOTE_C4 = 49 # XM note 49 (after relnote applied) = C-4 # Sample type flags XM_SMP_LOOP_FWD = 0x01 XM_SMP_LOOP_PINGPONG = 0x02 XM_SMP_LOOP_MASK = 0x03 XM_SMP_16BIT = 0x10 # Envelope type flags XM_ENV_ON = 0x01 XM_ENV_SUSTAIN = 0x02 XM_ENV_LOOP = 0x04 SIGNATURE = b"xm2taud/TSVM " # 14 bytes # ── Data classes ───────────────────────────────────────────────────────────── class XMHeader: __slots__ = ('title', 'tracker', 'version', 'header_size', 'order_count', 'restart_pos', 'channels', 'pattern_count', 'instrument_count', 'flags', 'default_speed', 'default_bpm', 'order_list', 'linear_freq') class XMSample: __slots__ = ('name', 'length', 'loop_start', 'loop_length', 'volume', 'finetune', 'flags', 'panning', 'rel_note', 'sample_data', 'is_16bit', 'pingpong') class XMInstrument: __slots__ = ('name', 'sample_count', 'keymap', 'vol_env_pts', 'pan_env_pts', 'vol_env_count', 'pan_env_count', 'vol_sustain', 'vol_loop_start', 'vol_loop_end', 'pan_sustain', 'pan_loop_start', 'pan_loop_end', 'vol_env_type', 'pan_env_type', 'vib_type', 'vib_sweep', 'vib_depth', 'vib_rate', 'fadeout', 'samples') class XMRow: __slots__ = ('note', 'inst', 'volcol', 'effect', 'effect_arg') def __init__(self): self.note = 0 # 0=empty, 1..96=pitch, 97=key off self.inst = 0 # 1-based; 0=none self.volcol = 0 # 0=none; otherwise raw vol-col byte self.effect = 0 self.effect_arg = 0 # ── Header parser ───────────────────────────────────────────────────────────── def _read_u8(data, off): return data[off] def _read_u16(data, off): return struct.unpack_from(' XMHeader: if data[:17] != XM_MAGIC: sys.exit(f"error: not an XM file (bad magic: {data[:17]!r})") if data[37] != 0x1A: vprint(f" warning: expected 0x1A marker at offset 37, got 0x{data[37]:02X}") h = XMHeader() h.title = data[17:37].rstrip(b'\x00 ').decode('latin-1', errors='replace') h.tracker = data[38:58].rstrip(b'\x00 ').decode('latin-1', errors='replace') h.version = _read_u16(data, 58) h.header_size = _read_u32(data, 60) h.order_count = _read_u16(data, 64) h.restart_pos = _read_u16(data, 66) h.channels = _read_u16(data, 68) h.pattern_count = _read_u16(data, 70) h.instrument_count = _read_u16(data, 72) h.flags = _read_u16(data, 74) h.linear_freq = bool(h.flags & 0x01) h.default_speed = _read_u16(data, 76) h.default_bpm = _read_u16(data, 78) h.order_list = list(data[80:80 + 256]) if h.version not in (0x0102, 0x0103, 0x0104): vprint(f" warning: unusual XM version 0x{h.version:04X}") if h.channels < 2 or h.channels > 32: vprint(f" warning: unusual channel count {h.channels}") return h # ── Pattern parser ──────────────────────────────────────────────────────────── def parse_patterns(data: bytes, h: XMHeader, patterns_offset: int): """Returns (patterns_rows, next_offset). patterns_rows: list of (grid, rows) where grid is a list of `channels` arrays, each `rows` long, of XMRow. """ patterns = [] off = patterns_offset for pi in range(h.pattern_count): if off + 9 > len(data): sys.exit(f"error: truncated pattern {pi} header at offset {off}") hdr_len = _read_u32(data, off) # packing_type = data[off + 4] # always 0 rows = _read_u16(data, off + 5) packed_sz = _read_u16(data, off + 7) body_off = off + hdr_len if body_off + packed_sz > len(data): sys.exit(f"error: truncated pattern {pi} body") grid = [[XMRow() for _ in range(rows)] for _ in range(h.channels)] if packed_sz == 0: patterns.append((grid, rows)) off = body_off + packed_sz continue p = body_off end = body_off + packed_sz for r in range(rows): for c in range(h.channels): if p >= end: break first = data[p]; p += 1 cell = grid[c][r] if first & 0x80: if first & 0x01: cell.note = data[p]; p += 1 if first & 0x02: cell.inst = data[p]; p += 1 if first & 0x04: cell.volcol = data[p]; p += 1 if first & 0x08: cell.effect = data[p]; p += 1 if first & 0x10: cell.effect_arg = data[p]; p += 1 else: # Uncompressed — `first` is the note byte; 4 more follow cell.note = first cell.inst = data[p]; p += 1 cell.volcol = data[p]; p += 1 cell.effect = data[p]; p += 1 cell.effect_arg = data[p]; p += 1 patterns.append((grid, rows)) off = body_off + packed_sz return patterns, off # ── Instrument / sample parser ──────────────────────────────────────────────── def parse_instruments(data: bytes, h: XMHeader, off_start: int) -> list: insts = [] off = off_start for ii in range(h.instrument_count): if off + 29 > len(data): vprint(f" warning: truncated instrument {ii} at offset {off}") break hdr_size = _read_u32(data, off) name = data[off + 4:off + 26].rstrip(b'\x00 ').decode('latin-1', errors='replace') # type byte at +26 ignored (almost always 0) n_samples = _read_u16(data, off + 27) inst = XMInstrument() inst.name = name inst.sample_count = n_samples inst.keymap = [0] * 96 inst.vol_env_pts = [] inst.pan_env_pts = [] inst.vol_env_count = 0 inst.pan_env_count = 0 inst.vol_sustain = 0 inst.vol_loop_start = 0 inst.vol_loop_end = 0 inst.pan_sustain = 0 inst.pan_loop_start = 0 inst.pan_loop_end = 0 inst.vol_env_type = 0 inst.pan_env_type = 0 inst.vib_type = 0 inst.vib_sweep = 0 inst.vib_depth = 0 inst.vib_rate = 0 inst.fadeout = 0 inst.samples = [] if n_samples == 0: insts.append(inst) off += hdr_size continue # Extended header begins at off + 29 (per LoaderXM.cpp:162) ext = off + 29 if ext + 214 > len(data): vprint(f" warning: truncated extended header for inst {ii}") insts.append(inst) off += hdr_size continue sample_hdr_size = _read_u32(data, ext) # 4 bytes inst.keymap = list(data[ext + 4:ext + 100]) # 96 bytes # Volume envelope: 12 × (frame:u16, value:u16) = 48 bytes at ext+100 for k in range(12): fr = _read_u16(data, ext + 100 + k * 4) val = _read_u16(data, ext + 100 + k * 4 + 2) inst.vol_env_pts.append((fr, val)) # Panning envelope at ext+148 for k in range(12): fr = _read_u16(data, ext + 148 + k * 4) val = _read_u16(data, ext + 148 + k * 4 + 2) inst.pan_env_pts.append((fr, val)) inst.vol_env_count = data[ext + 196] inst.pan_env_count = data[ext + 197] inst.vol_sustain = data[ext + 198] inst.vol_loop_start = data[ext + 199] inst.vol_loop_end = data[ext + 200] inst.pan_sustain = data[ext + 201] inst.pan_loop_start = data[ext + 202] inst.pan_loop_end = data[ext + 203] inst.vol_env_type = data[ext + 204] inst.pan_env_type = data[ext + 205] inst.vib_type = data[ext + 206] inst.vib_sweep = data[ext + 207] inst.vib_depth = data[ext + 208] inst.vib_rate = data[ext + 209] inst.fadeout = _read_u16(data, ext + 210) # 2 reserved bytes at ext+212 off += hdr_size # Sample headers (40 bytes each per xm-form.txt:262-283) sample_hdrs_off = off sample_hdrs = [] for si in range(n_samples): sh = sample_hdrs_off + si * sample_hdr_size if sh + 40 > len(data): vprint(f" warning: truncated sample header inst {ii} sample {si}") break s = XMSample() s.length = _read_u32(data, sh + 0) s.loop_start = _read_u32(data, sh + 4) s.loop_length = _read_u32(data, sh + 8) s.volume = data[sh + 12] s.finetune = struct.unpack_from('b', data, sh + 13)[0] # signed s.flags = data[sh + 14] s.panning = data[sh + 15] s.rel_note = struct.unpack_from('b', data, sh + 16)[0] # signed # reserved byte at +17 s.name = data[sh + 18:sh + 40].rstrip(b'\x00 ').decode('latin-1', errors='replace') s.is_16bit = bool(s.flags & XM_SMP_16BIT) loop_type = s.flags & XM_SMP_LOOP_MASK s.pingpong = (loop_type == XM_SMP_LOOP_PINGPONG) s.sample_data = b'' sample_hdrs.append(s) off = sample_hdrs_off + n_samples * sample_hdr_size # Sample data follows immediately after all sample headers for s in sample_hdrs: if s.length == 0: continue raw = data[off:off + s.length] off += s.length # Integrate delta encoding if s.is_16bit: pcm = bytearray(s.length) last = 0 for i in range(0, s.length, 2): if i + 2 > s.length: break delta = struct.unpack_from('= 0x8000: signed = last - 0x10000 else: signed = last struct.pack_into('= 0x80: delta -= 0x100 last = (last + delta) & 0xFF pcm[i] = last # signed-stored, will be flipped by normalise_sample s.sample_data = bytes(pcm) # Normalise to unsigned 8-bit mono s.sample_data = normalise_sample( s.sample_data, signed=True, is_16bit=s.is_16bit, is_stereo=False, name=s.name or '' ) # length is now in 8-bit mono samples s.length = len(s.sample_data) s.loop_start = min(s.loop_start, s.length) s.loop_length = max(0, min(s.loop_length, s.length - s.loop_start)) inst.samples = sample_hdrs insts.append(inst) return insts, off # ── Note / volume column / effect translation ──────────────────────────────── def encode_note_xm(xm_note: int) -> int: """XM raw note (1..96) → Taud 4096-TET pitch. XM note 1 = C-0; note 49 = C-4 (matches Taud TAUD_C4 anchor). """ if xm_note == XM_NOTE_OFF: return NOTE_KEYOFF if 1 <= xm_note <= 96: semis = xm_note - XM_RELNOTE_C4 val = round(TAUD_C4 + semis * 4096 / 12) return max(0x20, min(0xFFFF, val)) return NOTE_NOP def decode_volcol_xm(vc: int): """Decode XM volume column byte. Returns (vol_sel, vol_value, pan_set, aux_effect): vol_sel/vol_value : Taud volume column override (or SEL_FINE/0) pan_set : 0..63 pan-column override, or None aux_effect : (Taud op, arg) folded into main effect slot if unoccupied, dropped otherwise XM vol-col byte ranges (xm-form.txt:958-1030): 0x10..0x50 Set volume value-0x10 (0..64) 0x60..0x6F Volume slide down (nybble = speed) 0x70..0x7F Volume slide up 0x80..0x8F Fine volume slide down 0x90..0x9F Fine volume slide up 0xA0..0xAF Set vibrato speed (nybble) 0xB0..0xBF Vibrato with depth (nybble) 0xC0..0xCF Set panning (nybble × 17) 0xD0..0xDF Panning slide left 0xE0..0xEF Panning slide right 0xF0..0xFF Tone portamento (nybble × 16) """ if vc == 0: return SEL_FINE, 0, None, None if 0x10 <= vc <= 0x50: # Set volume 0..64 → 0..63 (clamp) return SEL_SET, min(vc - 0x10, 0x3F), None, None nybble = vc & 0xF if 0x60 <= vc <= 0x6F: return SEL_DOWN, nybble, None, None if 0x70 <= vc <= 0x7F: return SEL_UP, nybble, None, None if 0x80 <= vc <= 0x8F: # Fine slide down: dir bit 0 = down; magnitude in low 5 bits. return SEL_FINE, (nybble & 0x1F), None, None if 0x90 <= vc <= 0x9F: # Fine slide up: dir bit 5 set. return SEL_FINE, (nybble & 0x1F) | 0x20, None, None if 0xA0 <= vc <= 0xAF: # Set vibrato speed → fold as TOP_H with speed in high byte. return SEL_FINE, 0, None, (TOP_H, (nybble * 0x11) << 8) if 0xB0 <= vc <= 0xBF: # Vibrato with depth → TOP_H with depth in low byte. return SEL_FINE, 0, None, (TOP_H, nybble * 0x11) if 0xC0 <= vc <= 0xCF: # Set panning: nybble × 17 = 0..255; convert to 6-bit. pan8 = nybble * 17 pan6 = min(0x3F, round(pan8 * 63 / 255)) return SEL_FINE, 0, pan6, None if 0xD0 <= vc <= 0xDF: # Pan slide left: SEL_DOWN on pan column. return SEL_FINE, 0, None, None # consumed via pan_override below if 0xE0 <= vc <= 0xEF: return SEL_FINE, 0, None, None if 0xF0 <= vc <= 0xFF: # Tone portamento: nybble × 16 → TOP_G argument in linear units. spd_period = nybble * 16 return SEL_FINE, 0, None, (TOP_G, round(spd_period * 64 / 3) & 0xFFFF) return SEL_FINE, 0, None, None def _xm_volcol_pan_override(vc: int): """Returns (pan_sel, pan_value) for vol-col D/E pan slides, or None.""" if 0xD0 <= vc <= 0xDF: return (SEL_DOWN, vc & 0xF) # left if 0xE0 <= vc <= 0xEF: return (SEL_UP, vc & 0xF) # right return None def encode_effect_xm(cmd: int, arg: int, ch: int = 0, row: int = 0, amiga_mode: bool = False) -> tuple: """Map an XM effect (cmd, arg) → (taud_op, taud_arg16, vol_override, pan_override). XM effect numbers per XModule.cpp:1303 / xm-form.txt:690-743. """ # 0 with arg=0 = true no-op; 0 with arg!=0 = arpeggio. if cmd == 0x00: if arg == 0: return (TOP_NONE, 0, None, None) hi = (arg >> 4) & 0xF lo = arg & 0xF return (TOP_J, (J_SEMI_TABLE[hi] << 8) | J_SEMI_TABLE[lo], None, None) if cmd == 0x01: # Porta up: arg in period units (Amiga) or 4096-TET-equivalent. if amiga_mode: return (TOP_F, arg & 0xFFFF, None, None) return (TOP_F, round(arg * 64 / 3) & 0xFFFF, None, None) if cmd == 0x02: if amiga_mode: return (TOP_E, arg & 0xFFFF, None, None) return (TOP_E, round(arg * 64 / 3) & 0xFFFF, None, None) if cmd == 0x03: # Tone portamento: always linear regardless of mode. return (TOP_G, round(arg * 64 / 3) & 0xFFFF, None, None) if cmd == 0x04: hi = (arg >> 4) & 0xF lo = arg & 0xF return (TOP_H, ((hi * 0x11) << 8) | (lo * 0x11), None, None) if cmd == 0x05: # Tone porta + vol slide → Taud L verbatim. The XM source byte goes # straight into L's high byte; the engine handles the combined # porta-continuation + vol-slide semantics natively (see # TAUD_NOTE_EFFECTS.md §L). XM's 500 (arg = 0) recall is honoured by # Taud's L $0000 recall against L's own private memory, so a 500 row # plays the previously emitted slide rate. This avoids the volume- # column collision that the H+vol-col split form caused on rows # already carrying a vol-column SET. return (TOP_L, (arg & 0xFF) << 8, None, None) if cmd == 0x06: # Vibrato + vol slide → Taud K verbatim (same rationale as 0x05). return (TOP_K, (arg & 0xFF) << 8, None, None) if cmd == 0x07: hi = (arg >> 4) & 0xF lo = arg & 0xF return (TOP_R, ((hi * 0x11) << 8) | (lo * 0x11), None, None) if cmd == 0x08: # Set panning 0..255 → Taud pan column 0..63. pan6 = min(0x3F, round((arg & 0xFF) * 63 / 255)) return (TOP_NONE, 0, None, (SEL_SET, pan6)) if cmd == 0x09: return (TOP_O, (arg & 0xFF) << 8, None, None) if cmd == 0x0A: # Volume slide: high nybble = up, low nybble = down. Taud TOP_D # uses the same nybble-pair layout in the high byte. return (TOP_D, (arg & 0xFF) << 8, None, None) if cmd == 0x0B: # Position jump — order index translated to Taud cue at remap time. return (TOP_B, arg & 0xFF, None, None) if cmd == 0x0C: # Set volume 0..64 → vol column SEL_SET. return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None) if cmd == 0x0D: # Pattern break: XM stores BCD row number. hi = (arg >> 4) & 0xF lo = arg & 0xF row_num = (hi * 10 + lo) & 0xFF if row_num >= PATTERN_ROWS: row_num = 0 return (TOP_C, row_num & 0xFF, None, None) if cmd == 0x0E: # Extended commands E0x..EFx — fold into Taud TOP_S sub-codes # where possible. sub = (arg >> 4) & 0xF val = arg & 0xF # Fine porta up E1x / down E2x: if sub == 0x1: # Fine porta up: TOP_F with $Fx layout (engine treats this as fine). if amiga_mode: return (TOP_F, 0xF000 | (val & 0xFFF), None, None) return (TOP_F, 0xF000 | (round(val * 16 / 3) & 0xFFF), None, None) if sub == 0x2: if amiga_mode: return (TOP_E, 0xF000 | (val & 0xFFF), None, None) return (TOP_E, 0xF000 | (round(val * 16 / 3) & 0xFFF), None, None) # E3x glissando control / E4x vibrato wave / E5x finetune / # E7x tremolo wave / E9x retrigger / EAx fine vol up / EBx fine # vol down / ECx note cut / EDx note delay / EEx pattern delay. if sub in (0x3, 0x4, 0x7, 0xC, 0xD, 0xE): return (TOP_S, (sub << 12) | (val << 8), None, None) if sub == 0x5: # Set finetune — convert to S5x sub-effect (4-bit signed nibble). return (TOP_S, 0x5000 | (val << 8), None, None) if sub == 0x6: # XM E6x = pattern loop (E60 sets loop start, E6x with x>0 loops # x times). Maps directly onto Taud SBx, which has identical # semantics — the engine handles per-voice loopStartRow / # loopCount in applySEffect (sub 0xB). return (TOP_S, 0xB000 | (val << 8), None, None) if sub == 0x8: # Pan position 0..15 → set pan column (XM nybble × 17 → 8-bit). pan8 = (val << 4) | val pan6 = min(0x3F, round(pan8 * 63 / 255)) return (TOP_NONE, 0, None, (SEL_SET, pan6)) if sub == 0x9: # Retrig with vol 0 → multi-retrig speed; map to TOP_Q. return (TOP_Q, (val & 0xF) << 8, None, None) if sub == 0xA: # Fine vol up: vol col fine slide return (TOP_NONE, 0, (SEL_FINE, (val & 0x1F) | 0x20), None) if sub == 0xB: # Fine vol down return (TOP_NONE, 0, (SEL_FINE, val & 0x1F), None) if sub == 0xF: # E$Fx in XM is unused (or "Funk repeat" in old PT) — drop. vprint(f" dropped EF{val:X} (unused / funk) at ch{ch} row{row}") return (TOP_NONE, 0, None, None) return (TOP_NONE, 0, None, None) if cmd == 0x0F: # Set speed if arg < 0x20, else set tempo (BPM). if arg == 0: return (TOP_NONE, 0, None, None) if arg < 0x20: return (TOP_A, (arg & 0xFF) << 8, None, None) # Tempo: Taud T uses bias of -25 in stored form; mirror it2taud: return (TOP_T, ((arg - 0x19) & 0xFF) << 8, None, None) if cmd == 0x10: # Set global volume 0..64 → Taud V (×4 to fit 0..255). taud_v = min(arg * 4, 0xFF) return (TOP_V, (taud_v & 0xFF) << 8, None, None) if cmd == 0x11: # Global volume slide: high nyb up, low nyb down → TOP_W. return (TOP_W, (arg & 0xFF) << 8, None, None) if cmd == 0x14: # Key off (delayed): map to a note-off via SDx-like delay sub-effect. # Taud doesn't have a direct delayed-key-off, so issue a key-off note # immediately (loses delay parameter — most XMs use Kxx with arg=0). if arg > 0: vprint(f" K{arg:02X} delay parameter lost at ch{ch} row{row}") return (TOP_NONE, 0, None, None) # caller forces note=NOTE_KEYOFF if cmd == 0x15: vprint(f" dropped L{arg:02X} (set envelope position) at ch{ch} row{row}") return (TOP_NONE, 0, None, None) if cmd == 0x19: # Pan slide → TOP_S not appropriate; use pan-column slide via # d_arg_to_col interpreted as pan. return (TOP_NONE, 0, None, d_arg_to_col(arg)) if cmd == 0x1B: # Multi retrig with volume change → TOP_Q. return (TOP_Q, (arg & 0xFF) << 8, None, None) if cmd == 0x1D: # Tremor → TOP_I. return (TOP_I, (arg & 0xFF) << 8, None, None) if cmd == 0x21: # Extra-fine porta X1x / X2x. sub = (arg >> 4) & 0xF val = arg & 0xF if sub == 1: if amiga_mode: return (TOP_F, 0xE000 | (val & 0xFFF), None, None) return (TOP_F, 0xE000 | (round(val * 4 / 3) & 0xFFF), None, None) if sub == 2: if amiga_mode: return (TOP_E, 0xE000 | (val & 0xFFF), None, None) return (TOP_E, 0xE000 | (round(val * 4 / 3) & 0xFFF), None, None) return (TOP_NONE, 0, None, None) return (TOP_NONE, 0, None, None) # ── Pattern splitting (XM-specific; mirrors it2taud's $02xx policy) ────────── def split_patterns_xm(patterns: list): """Returns (chunks, chunk_map, chunk_lens) as in it2taud.split_patterns.""" chunks = [] chunk_map = [] chunk_lens = [] for pi, (grid, rows) in enumerate(patterns): 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) chunk_len = r1 - r0 chunk_grid = [] for ch in range(len(grid)): ch_rows = [] src = grid[ch] for ri in range(PATTERN_ROWS): sr = r0 + ri if sr < r1 and sr < len(src): ch_rows.append(src[sr]) else: ch_rows.append(XMRow()) chunk_grid.append(ch_rows) idx = len(chunks) chunks.append(chunk_grid) chunk_lens.append(chunk_len) pat_chunks.append(idx) chunk_map.append(pat_chunks) return chunks, chunk_map, chunk_lens def remap_b_effects_xm(chunks: list, chunk_map: list, order_list: list, xm_ord_to_taud_cue: dict, num_channels: int, *, default_target: int = None, warn_label: str = '', chunk_indices=None) -> None: """Rewrite XM B (position jump) effects so the argument indexes Taud cues rather than XM order positions. (Pattern break Dxx already targets a row, no remap needed — the post-break behaviour is "advance to next order", which Taud emulates correctly when the cue ends.) `default_target`: when a Bxx target isn't in `xm_ord_to_taud_cue` (a cross-subsong jump), rewrite to this cue index instead of preserving the literal target. Use 0 to make cross-song jumps loop the subsong. `chunk_indices`: optional iterable; when provided, only these chunks are visited. Used by multi-song to skip unreferenced chunks (avoids spurious cross-song warnings on chunks not emitted in this song). """ crossings = 0 iter_indices = (chunk_indices if chunk_indices is not None else range(len(chunks))) for ci in iter_indices: chunk_grid = chunks[ci] for ch in range(min(num_channels, len(chunk_grid))): for row in chunk_grid[ch]: if row.effect == 0x0B: xm_ord = row.effect_arg & 0xFF if xm_ord in xm_ord_to_taud_cue: row.effect_arg = xm_ord_to_taud_cue[xm_ord] & 0xFF elif default_target is not None: crossings += 1 row.effect_arg = default_target & 0xFF else: row.effect_arg = xm_ord & 0xFF if crossings and warn_label: vprint(f" warning: {warn_label}: {crossings} Bxx target(s) cross " f"subsong boundary; clamped to cue {default_target}") def compute_keyoff_zero_marks_xm(taud_cue_list: list, chunks: list, num_xm_channels: int, instruments: list, active_channels: list) -> dict: """Identify key-off cells whose bound XM instrument has the volume envelope DISABLED. FT2's keyOff() (ft2_replayer.c:411-435) zeroes realVol/outVol on such key-offs; IT/Schism does not, and the Taud engine follows IT semantics. To preserve XM gating without diverging engine behaviour, the converter pairs each flagged key-off with `SEL_SET vol=0` in the same row's volume column — a later vol-col SET on the channel restores audibility, exactly mirroring the FT2 outVol/realVol path. Walks taud_cue_list in playback order so per-channel instrument bindings carry across cues. When the same chunk is visited under conflicting bindings, the union of all flags is kept (conservatively prefers gating). Returns: dict mapping chunk_idx → set of (active_voice_idx, row_idx) tuples. The voice_idx matches build_pattern_xm's `ch_idx` (the index into `active_channels`). """ xm_to_vi = {ch: vi for vi, ch in enumerate(active_channels)} marks = {} bound = [0] * num_xm_channels # 1-based XM instrument id; 0 = none for ci in taud_cue_list: cg = chunks[ci] chunk_marks = marks.setdefault(ci, set()) max_ch = min(num_xm_channels, len(cg)) max_rows = max((len(cg[ch]) for ch in range(max_ch)), default=0) for r in range(max_rows): for xm_ch in range(max_ch): if r >= len(cg[xm_ch]): continue cell = cg[xm_ch][r] # FT2 keyOff() reads ch->instrPtr — the latest binding wins, even # when the inst byte is on the same row as the key-off. if cell.inst > 0: bound[xm_ch] = cell.inst is_keyoff = (cell.note == XM_NOTE_OFF) or (cell.effect == 0x14) if not is_keyoff: continue ii = bound[xm_ch] if ii == 0 or ii - 1 >= len(instruments): continue inst = instruments[ii - 1] if inst.vol_env_type & XM_ENV_ON: continue vi = xm_to_vi.get(xm_ch) if vi is not None: chunk_marks.add((vi, r)) return marks # ── Sample / instrument bin ─────────────────────────────────────────────────── class _XMSampleProxy: """Adapter object passed to the inst-bin builder. One per (xm_instrument, sample-in-instrument) pair. Envelopes / fadeout / NNA / vibrato are filled from the parent XM instrument.""" __slots__ = ('name', 'length', 'loop_begin', 'loop_end', 'volume', 'finetune', 'rel_note', 'panning', 'pingpong', 'sample_data', 'c2spd', 'flags', 'fadeout', 'vib_speed', 'vib_depth', 'vib_sweep', 'vib_wave', 'vol_env_pts', 'vol_env_loop_word', 'vol_env_sus_word', 'pan_env_pts', 'pan_env_loop_word', 'pan_env_sus_word', 'has_pan_env', 'nna') def _xm_envelope_to_taud(env_pts: list, num_pts: int, env_type: int, sustain: int, loop_start: int, loop_end: int, kind: str, ticks_per_sec: float) -> tuple: """Translate one XM envelope (frame, value) list → 25 (val, mf) Taud points + LOOP word + SUSTAIN word. Returns (points, loop_word, sus_word). XM envelope value ranges: 'vol' — 0..64 → Taud 0..63 'pan' — 0..64 → Taud 0..255 (32 = centre → 0x80) XM single-point sustain becomes the SUSTAIN word with start == end. XM envelope loop becomes the LOOP word. The two are independent in XM and remain independent in Taud (matches FT2 + IT semantics described in terranmon.txt:2049+). Returns (None, 0, 0) when the envelope is disabled (XM_ENV_ON not set). """ if not (env_type & XM_ENV_ON) or num_pts < 1: return None, 0, 0 nodes = env_pts[:max(1, min(num_pts, 12))] has_sus = bool(env_type & XM_ENV_SUSTAIN) and 0 <= sustain < len(nodes) has_loop = (bool(env_type & XM_ENV_LOOP) and 0 <= loop_start < len(nodes) and loop_start <= loop_end < len(nodes)) def _to_taud_val(xm_val: int) -> int: v = max(0, min(64, xm_val)) if kind == 'vol': return min(63, round(v * 63 / 64)) return min(255, max(0, round(v * 255 / 64))) pad_value = (63 if kind == 'vol' else 0x80) points = [] for k in range(25): if k < len(nodes): frame, val = nodes[k] taud_val = _to_taud_val(val) if k < len(nodes) - 1: next_frame, _ = nodes[k + 1] delta_sec = max(0.0, (next_frame - frame) / ticks_per_sec) mf_idx = nearest_minifloat(delta_sec) else: mf_idx = 0 else: taud_val = points[-1][0] if points else pad_value mf_idx = 0 points.append((taud_val, mf_idx)) # LOOP word (offsets 15/17/19): b=enable, bits 12..8=start, 4..0=end. # SUSTAIN word (offsets 189/191/193): same bit layout; FT2 single-point # sustain is encoded with start == end (engine wraps that index → itself). # P (bit 13) marks the envelope as present in source — this branch is only # reached when XM_ENV_ON is set, so P is unconditionally 1 here. P gates # whether the engine evaluates pan envelope at all (terranmon.txt byte # 16/18/20 bit 5); for vol it is informational. loop_word = 0x2020 # P (bit 13) | b (bit 5) if has_loop: loop_word |= (loop_start & 0x1F) << 8 loop_word |= (loop_end & 0x1F) else: # Disable LOOP wrap — leave start/end zero so the engine treats it as # "no loop". The b bit still keeps the envelope active. pass sus_word = 0 if has_sus: sus_word |= 0x0020 # b: enable SUSTAIN sus_word |= (sustain & 0x1F) << 8 sus_word |= (sustain & 0x1F) # FT2 single-point: start == end return points, loop_word, sus_word def _xm_sample_to_proxy(inst: XMInstrument, samp: XMSample, ticks_per_sec: float) -> _XMSampleProxy: p = _XMSampleProxy() p.name = samp.name p.length = samp.length p.loop_begin = samp.loop_start p.loop_end = samp.loop_start + samp.loop_length p.volume = min(samp.volume, 64) # XM 0..64 p.finetune = samp.finetune # signed -128..+127 p.rel_note = samp.rel_note # signed semitones p.panning = samp.panning # 0..255 p.pingpong = samp.pingpong p.sample_data = samp.sample_data # c2spd: XM uses a per-sample finetune (1/128 semitone units) plus a # rel_note offset. We bake both into c2spd so the engine plays the # XM "C-4 row" at the correct audible pitch when the Taud note is # also C-4. semis = samp.rel_note + samp.finetune / 128.0 p.c2spd = max(1, round(8363.0 * (2.0 ** (semis / 12.0)))) loop_type = samp.flags & XM_SMP_LOOP_MASK p.flags = 1 if loop_type != 0 else 0 # 1=loop on, 0=off # Fadeout: XM file value (16-bit, spec range 0..0xFFF; MilkyTracker writes up to 32767 # to encode the "cut" UI slider position — SectionInstruments.cpp:499-500). FT2's per-tick # decrement is stored / 32768 of unit volume; Taud's engine uses stored / 1024. Divide # source by 32 (round-to-nearest) to match the per-tick rate. XM stored 1..15 round to # Taud 0 — those originals were >11 min at 50 Hz, effectively no-fade. Stored 32 → Taud 1 # (~20 s). Stored 32767 (Milky cut sentinel) → Taud 1024 (1-tick cut). See terranmon.txt # byte 172/173 and TAUD_NOTE_EFFECTS.md §1 "Volume Fadeout". p.fadeout = min(0xFFF, (int(inst.fadeout & 0xFFFF) + 16) // 32) p.vib_speed = inst.vib_rate # XM rate ↔ Taud "speed" p.vib_depth = (inst.vib_depth * 2) & 0xFF # LoaderXM.cpp:217 scaling p.vib_sweep = inst.vib_sweep & 0xFF p.vib_wave = inst.vib_type & 0x07 # Envelopes (volume + panning). p.vol_env_pts, p.vol_env_loop_word, p.vol_env_sus_word = _xm_envelope_to_taud( inst.vol_env_pts, inst.vol_env_count, inst.vol_env_type, inst.vol_sustain, inst.vol_loop_start, inst.vol_loop_end, kind='vol', ticks_per_sec=ticks_per_sec) p.pan_env_pts, p.pan_env_loop_word, p.pan_env_sus_word = _xm_envelope_to_taud( inst.pan_env_pts, inst.pan_env_count, inst.pan_env_type, inst.pan_sustain, inst.pan_loop_start, inst.pan_loop_end, kind='pan', ticks_per_sec=ticks_per_sec) p.has_pan_env = p.pan_env_pts is not None # XM has no NNA: every new note unconditionally retriggers the # channel, completely replacing whatever was playing. Use Taud # NNA=1 (cut) to suppress the engine's NNA-ghosting path entirely, # otherwise the previous voice keeps running in the background pool # while the new note plays — IT semantics, not FT2. p.nna = 1 return p def build_sample_inst_bin_xm(proxies: list) -> tuple: """proxies: list (1-indexed; slot 0 unused) of _XMSampleProxy | None. Returns (sampleinst_bin, offsets_dict, slot_ratios) where slot_ratios maps Taud slot index → effective TOP_O scale (combined global × per-sample resample ratio). """ pcm_list = [(i, s) for i, s in enumerate(proxies) if s is not None and s.sample_data] def _scale_sample(s, r): s.sample_data = resample_linear(s.sample_data, r) s.length = len(s.sample_data) s.loop_begin = max(0, int(s.loop_begin * r)) s.loop_end = max(0, min(int(s.loop_end * r), s.length)) s.c2spd = max(1, int(s.c2spd * r)) # ── Pass 1: global pool-overflow resample (8 MB cap) ──────────────────── total = sum(len(s.sample_data) for _, s in pcm_list) global_ratio = 1.0 if total > SAMPLEBIN_SIZE: global_ratio = SAMPLEBIN_SIZE / total vprint(f" info: sample bin overflow ({total} bytes); " f"resampling all by {global_ratio:.4f}") seen_g = set() for _, s in pcm_list: if id(s) in seen_g: continue seen_g.add(id(s)) _scale_sample(s, global_ratio) # ── Pass 2: per-sample u16 cap (each sample must fit in 65535 bytes) ──── # The Taud instrument record stores the sample length as u16, and TOP_O # offsets address up to 0xFF00 bytes — anything longer would silently # truncate at load time and over-shoot O-jumps. Resample only the # over-long samples and remember each one's individual ratio so the # caller can rescale TOP_O args per channel rather than globally. per_sample_ratio = {} # id(s) → per-sample ratio (after global) seen_p = set() for _, s in pcm_list: if id(s) in seen_p: continue seen_p.add(id(s)) if len(s.sample_data) > SAMPLE_LEN_LIMIT: r = SAMPLE_LEN_LIMIT / len(s.sample_data) vprint(f" info: '{s.name}' exceeds {SAMPLE_LEN_LIMIT}-byte cap " f"({len(s.sample_data)}); resampling by {r:.4f}") _scale_sample(s, r) per_sample_ratio[id(s)] = r # Effective slot → ratio for TOP_O rescaling. XM keymaps can route # several Taud slots to the same _XMSampleProxy (one slot per XM # sample-in-instrument), so they share the same per-sample ratio. slot_ratios = {} for slot_idx, s in pcm_list: slot_ratios[slot_idx] = global_ratio * per_sample_ratio.get(id(s), 1.0) ratio = slot_ratios 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 USE_ENV_BIT = 0x0020 # b: engine should evaluate the envelope (LOOP wrap enable) ENV_PRESENT_BIT = 0x2000 # P: envelope present in source (terranmon.txt byte 16/18/20 bit 5) INST_STRIDE = 256 def _write_env(buf: bytearray, base: int, env_pts, pad_value: int) -> None: """Write 25 (value, minifloat) pairs. Pads with the previous value (or pad_value) and offset=0 if shorter than 25.""" for k in range(25): if env_pts and k < len(env_pts): val, mf = env_pts[k] else: val = (env_pts[-1][0] if env_pts else pad_value) mf = 0 buf[base + k * 2] = val & 0xFF buf[base + k * 2 + 1] = mf & 0xFF inst_bin = bytearray(INSTBIN_SIZE) for i, s in enumerate(proxies): if i == 0 or i >= 256 or s is None or not s.sample_data: continue ptr = offsets.get(i, 0) & 0xFFFFFFFF s_len = min(s.length, 65535) c2spd = min(s.c2spd, 65535) ls = min(s.loop_begin, 65535) le = min(s.loop_end, 65535) loop_mode = 0 if s.flags & 1: loop_mode = 2 if s.pingpong else 1 flags_byte = loop_mode & 0x3 # Resolve envelope LOOP / SUSTAIN words from the proxy. When XM has no # envelope, fall back to a single-point unit envelope (vol LOOP word # b=1 plus P=1 for consistency) and rely on DNV (byte 196) for the # per-trigger initial level. Pan stays zero so the engine sees P=0 # there and skips envelope-driven pan. if s.vol_env_pts is not None: vol_env_loop = s.vol_env_loop_word vol_env_sus = s.vol_env_sus_word vol_env = s.vol_env_pts else: vol_env_loop = USE_ENV_BIT | ENV_PRESENT_BIT vol_env_sus = 0 vol_env = None if s.pan_env_pts is not None: pan_env_loop = s.pan_env_loop_word pan_env_sus = s.pan_env_sus_word pan_env = s.pan_env_pts else: pan_env_loop = 0 pan_env_sus = 0 pan_env = None base = i * INST_STRIDE struct.pack_into('> 8) & 0x0F # Default pan (XM sample panning 0..255 → Taud direct 0..255) inst_bin[base + 177] = s.panning & 0xFF # Filter cutoff/resonance: XM has no filters → off. inst_bin[base + 182] = 0xFF inst_bin[base + 183] = 0xFF # Auto-vibrato (XM instrument-level) inst_bin[base + 175] = s.vib_speed & 0xFF inst_bin[base + 176] = s.vib_sweep & 0xFF inst_bin[base + 187] = s.vib_depth & 0xFF inst_bin[base + 188] = s.vib_speed & 0xFF # Inst flag byte: 0bb wwwnn — wwww=vib waveform, nn=NNA inst_bin[base + 186] = ((s.vib_wave & 0x07) << 2) | (s.nna & 0x03) # SUSTAIN words at 189/191/193. struct.pack_into(' bytes: """Render one Taud channel's 512-byte pattern from a 64-row chunk grid. `keyoff_zero_rows`: optional set of row indices on this channel whose key-off cells should be paired with `SEL_SET vol=0` (FT2 vol-env-off gating — see compute_keyoff_zero_marks_xm). """ if keyoff_zero_rows is None: keyoff_zero_rows = frozenset() out = bytearray(PATTERN_BYTES) if ch_idx >= len(chunk_grid): rows = [XMRow()] * PATTERN_ROWS else: rows = chunk_grid[ch_idx] for r, cell in enumerate(rows[:PATTERN_ROWS]): # ── Volume column → vol/pan/aux-effect overrides ──────────────────── vs, vv, pan_from_vc, aux_eff = decode_volcol_xm(cell.volcol) # Pan slide via vol-col D/E (encoded as pan_override below) vc_pan_override = _xm_volcol_pan_override(cell.volcol) # ── Slot juggling for combined effects ────────────────────────────── # XM main 0x0A (vol slide → TOP_D) + vol-col Mx (porta → TOP_G aux) # combine cleanly into Taud L (porta + vol slide). Same for # vol-col Bx/Ax (vibrato → TOP_H aux) → Taud K (vibrato + vol slide). # Without this swap the vol-col aux would be dropped because the main # slot is already occupied by D. The combined K/L take their slide # nibbles directly from the source D arg (high byte of XM 0x0A), # matching the encoding used by main XM effects 5 (→ L) and 6 (→ K). if (aux_eff is not None and cell.effect == 0x0A and cell.effect_arg != 0): aux_op, aux_arg = aux_eff d_arg = cell.effect_arg & 0xFF if aux_op == TOP_G: # XM A + vol-col M → Taud L verbatim. Porta speed already # lives in Taud's private G memory (vol-col aux → G $00xx). cell.effect, cell.effect_arg = 0x05, d_arg aux_eff = None elif aux_op == TOP_H: # XM A + vol-col B (vibrato depth) → Taud K. K reuses # memory_HU; the vol-col Bx depth update is lost. cell.effect, cell.effect_arg = 0x06, d_arg aux_eff = None if (aux_arg & 0xFF) != 0: vprint(f" ch{ch_idx} row{r}: A+Bx→K, depth update " f"{aux_arg & 0xFF:02X} folded into K vibrato recall") # ── Main effect translation ───────────────────────────────────────── op, arg16, vol_override, pan_override = encode_effect_xm( cell.effect, cell.effect_arg, ch_idx, r, amiga_mode=amiga_mode) # XM K00 (0x14) = key off — force note to NOTE_KEYOFF if cell.effect == 0x14: cell.note = XM_NOTE_OFF # Fold vol-col aux into main slot if free if aux_eff is not None: if op == TOP_NONE: op, arg16 = aux_eff aux_eff = None else: vprint(f" ch{ch_idx} row{r}: dropped vol-col aux effect " f"(main effect slot occupied: cmd={cell.effect:02X} arg={cell.effect_arg:02X})") # ── Note ──────────────────────────────────────────────────────────── note_taud = NOTE_NOP if cell.note > 0: note_taud = encode_note_xm(cell.note) # XM cell.inst==0 means "no instrument change" — preserve verbatim # so the engine retriggers whatever sample slot is currently loaded. # When cell.inst > 0, look up the Taud slot via the keymap (using # the row's own note if present, else the first sample of the # instrument). if cell.inst > 0: note_for_lookup = cell.note if cell.note > 0 else None taud_slot = inst_to_taud_slot(cell.inst, note_for_lookup) or 0 else: taud_slot = 0 note_triggers = (1 <= cell.note <= 96) # ── Volume column resolution ──────────────────────────────────────── if vs != SEL_FINE or vv != 0: vol_sel, vol_value = vs, vv elif vol_override is not None: vol_sel, vol_value = vol_override else: vol_sel, vol_value = SEL_FINE, 0 # ── Pan column resolution ─────────────────────────────────────────── if pan_from_vc is not None: pan_sel, pan_value = SEL_SET, pan_from_vc elif vc_pan_override is not None: pan_sel, pan_value = vc_pan_override 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 # FT2 vol-env-off key-off gating: pair the key-off with SEL_SET vol=0 # so a later vol-col SET on the channel restores audibility (see # compute_keyoff_zero_marks_xm). Override any vol-col content the row # already has — FT2 zeros realVol/outVol after vol-col is applied # (ft2_replayer.c:411-428), so a SET on the same row would be clobbered. if r in keyoff_zero_rows and note_taud == NOTE_KEYOFF: if not (vol_sel == SEL_FINE and vol_value == 0): vprint(f" ch{ch_idx} row{r}: FT2 key-off zero overrides " f"vol-col (sel={vol_sel}, val={vol_value})") vol_sel, vol_value = SEL_SET, 0 vol_byte = (vol_value & 0x3F) | ((vol_sel & 0x3) << 6) pan_byte = (pan_value & 0x3F) | ((pan_sel & 0x3) << 6) base = r * 8 struct.pack_into(' list: in_use = set() for grid, _rows in patterns: for ch in range(len(grid)): for cell in grid[ch]: if cell.note != 0 or cell.inst != 0 or cell.effect != 0 or cell.volcol != 0: in_use.add(ch) break active = sorted(in_use) if len(active) > NUM_VOICES: vprint(f" warning: {len(active)} active channels; capping at {NUM_VOICES}") active = active[:NUM_VOICES] return active # ── Main assembly ───────────────────────────────────────────────────────────── def _per_pattern_bxx_xm(patterns: list): """Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough) for `detect_subsongs`. XM patterns vary in length; `kills_fallthrough` is True when a Bxx (effect 0x0B) appears on the absolute last row. `patterns[pi]` is `(grid, rows)`; `grid` is `[channel][row]`. """ def fn(pat_idx: int): if pat_idx < 0 or pat_idx >= len(patterns): return set(), False grid, rows = patterns[pat_idx] targets = set() last_row_has_b = False for ch_rows in grid: n = min(rows, len(ch_rows)) for r in range(n): cell = ch_rows[r] if cell.effect == 0x0B: targets.add(cell.effect_arg & 0xFF) if r == rows - 1: last_row_has_b = True return targets, last_row_has_b return fn def _build_song_payload_xm(h: XMHeader, patterns_template: list, instruments: list, positions: list, sample_ratio: dict, active_channels: list, default_pans: list, resolve_inst_slot, *, song_label: str = 'song') -> tuple: """Build pattern bin + cue sheet + (subset of) song-entry kwargs for one subsong. The caller fills in song_offset, flags_byte, and shared globals. Patterns aren't mutated by per-order walks in XM (no recall resolution), but `remap_b_effects_xm` mutates chunk grids — so we deep-copy chunks per song. (`compute_keyoff_zero_marks_xm` only reads.) """ chunks, chunk_map, chunk_lens = split_patterns_xm(patterns_template) C = len(active_channels) cue_list = [] pos_to_cue = {} for pos in positions: order = h.order_list[pos] if order >= h.pattern_count or order >= len(chunk_map): continue pos_to_cue[pos] = len(cue_list) for ci in chunk_map[order]: cue_list.append(ci) if not cue_list: # Degenerate subsong (e.g. all orders point to invalid patterns). vprint(f" warning: [{song_label}] no playable cues; emitting halt-only song") remap_b_effects_xm(chunks, chunk_map, h.order_list, pos_to_cue, C, default_target=0, warn_label=song_label, chunk_indices=set(cue_list)) keyoff_zero_marks = compute_keyoff_zero_marks_xm( cue_list, chunks, h.channels, instruments, active_channels) if any(keyoff_zero_marks.values()): flagged = sum(len(s) for s in keyoff_zero_marks.values()) vprint(f" [{song_label}] FT2 keyoff-gate: {flagged} key-off cell(s) " f"paired with vol=0 (vol-env-off instruments)") total_taud_pats = len(cue_list) * C if total_taud_pats > NUM_PATTERNS_MAX: sys.exit(f"error: [{song_label}] {len(cue_list)} cues × {C} channels = " f"{total_taud_pats} > {NUM_PATTERNS_MAX} Taud pattern limit.") pat_bin = bytearray() for ci in cue_list: cg = chunks[ci] chunk_marks = keyoff_zero_marks.get(ci, frozenset()) for vi, ch in enumerate(active_channels): row_marks = {r for (mvi, r) in chunk_marks if mvi == vi} pat_bin += build_pattern_xm(cg, ch, default_pans[vi], resolve_inst_slot, amiga_mode=not h.linear_freq, keyoff_zero_rows=row_marks) pat_bin = rescale_offset_effects_per_slot( bytes(pat_bin), len(cue_list), C, sample_ratio) orig_count = len(cue_list) * C pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count) vprint(f" [{song_label}] patterns: {orig_count} → {num_taud_pats} unique " f"({orig_count - num_taud_pats} deduplicated)") 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 len_cue_count = 0 for cue_idx, ci in enumerate(cue_list): if cue_idx >= NUM_CUES: break base_pat = cue_idx * C pats = [pat_remap[base_pat + vi] for vi in range(C)] clen = chunk_lens[ci] if ci < len(chunk_lens) else PATTERN_ROWS if clen < PATTERN_ROWS: instr = cue_instruction_len(clen) len_cue_count += 1 else: instr = CUE_INST_NOP sheet[cue_idx * CUE_SIZE:(cue_idx + 1) * CUE_SIZE] = encode_cue(pats, instr) last_active = cue_idx if last_active >= 0: if sheet[last_active * CUE_SIZE + 30] == CUE_INST_LEN: vprint(f" [{song_label}] warning: last active cue {last_active} " f"had LEN; replaced with HALT (partial tail at song terminus)") sheet[last_active * CUE_SIZE + 30] = CUE_INST_HALT sheet[last_active * CUE_SIZE + 31] = 0x00 else: sheet[30] = CUE_INST_HALT if len_cue_count: vprint(f" [{song_label}] emitted {len_cue_count} LEN cue instruction(s) " f"for partial-length patterns") pat_comp = compress_blob(bytes(pat_bin), f"[{song_label}] pattern bin") cue_comp = compress_blob(bytes(sheet), f"[{song_label}] cue sheet") # Speed/tempo are file-wide for XM; pass them through the kwargs so the # outer function fills in shared header fields uniformly. speed = h.default_speed if h.default_speed > 0 else 6 tempo = h.default_bpm if h.default_bpm > 0 else 125 tempo = max(25, min(280, tempo)) bpm_stored = (tempo - 25) & 0xFF entry_kwargs = dict( num_voices=C, num_patterns=num_taud_pats, bpm_stored=bpm_stored, tick_rate=speed, pat_bin_comp_size=len(pat_comp), cue_sheet_comp_size=len(cue_comp), ) return pat_comp, cue_comp, entry_kwargs def assemble_taud(h: XMHeader, patterns: list, instruments: list, with_project_data: bool = True) -> bytes: # XM envelope frames advance once per row tick. Tick rate is derived # from BPM the same way ProTracker derives it: ticks_per_sec = BPM × 2/5 # (matches MilkyTracker's tick clock and it2taud's ticks_per_sec). tempo_for_envs = max(25, min(280, h.default_bpm if h.default_bpm > 0 else 125)) ticks_per_sec = max(1.0, tempo_for_envs * 2.0 / 5.0) # ── Build XM-instrument → list of Taud slot proxies ───────────────────── # One Taud slot per (xm_inst, sample-in-inst). Slot 0 unused. proxies = [None] inst_to_slots = {} # xm_inst (1-based) → list of taud slots, one per sample index for ii, inst in enumerate(instruments, start=1): if not inst.samples: inst_to_slots[ii] = [] continue slots = [] for samp in inst.samples: if not samp.sample_data: slots.append(0) continue taud_slot = len(proxies) if taud_slot >= 256: vprint(f" warning: >255 sample slots; clipping at instrument {ii}") slots.append(0) continue proxies.append(_xm_sample_to_proxy(inst, samp, ticks_per_sec)) slots.append(taud_slot) inst_to_slots[ii] = slots # Closure resolving (xm_inst, note) → taud slot via per-instrument keymap. def resolve_inst_slot(xm_inst: int, note: int): slots = inst_to_slots.get(xm_inst, []) if not slots: return None if note is None or note < 1 or note > 96: # No note context; fall back to first sample of the instrument. for s in slots: if s != 0: return s return None inst = instruments[xm_inst - 1] if xm_inst - 1 < len(instruments) else None if inst is None: return slots[0] if slots[0] else None sample_idx = inst.keymap[(note - 1) % 96] if inst.keymap else 0 if 0 <= sample_idx < len(slots) and slots[sample_idx]: return slots[sample_idx] return slots[0] if slots[0] else None # ── Sample / instrument bin ───────────────────────────────────────────── vprint(f" building sample/inst bin… ({len(proxies) - 1} sample slots used)") sampleinst_raw, _, sample_ratio = build_sample_inst_bin_xm(proxies) compressed = compress_blob(sampleinst_raw, "sample+inst bin") comp_size = len(compressed) # ── Tempo / speed ─────────────────────────────────────────────────────── speed = h.default_speed if h.default_speed > 0 else 6 tempo = h.default_bpm if h.default_bpm > 0 else 125 tempo = max(25, min(280, tempo)) bpm_stored = (tempo - 25) & 0xFF vprint(f" initial speed={speed}, tempo={tempo} BPM") # ── Channels / pattern split (shared) ─────────────────────────────────── active_channels = _active_channels_xm(h, patterns) C = len(active_channels) if C == 0: sys.exit("error: no active channels found") # Default pan per active channel: alternate L/R FT2-style (0,12,12,0,...). def _xm_default_pan(idx: int) -> int: side = idx % 4 return 16 if side in (0, 3) else 47 default_pans = [_xm_default_pan(i) for i in range(C)] # ── Detect subsongs ────────────────────────────────────────────────────── # XM has no terminator marker; `order_count` bounds the live order list. # Out-of-range pattern refs (≥ pattern_count) are skipped during playback, # so we feed the detector a slice of length `order_count` and treat # everything ≥ pattern_count as a skip. orders_view = list(h.order_list[:h.order_count]) skip_set = set(range(h.pattern_count, 256)) subsongs = detect_subsongs(orders_view, _per_pattern_bxx_xm(patterns), terminators=(), skip_marker=skip_set) if not subsongs: vprint(" warning: no traversable orders in source; emitting empty song") subsongs = [{'entry': 0, 'positions': []}] n_songs = len(subsongs) if n_songs == 1: vprint(f" detected 1 song ({len(subsongs[0]['positions'])} orders)") else: vprint(f" detected {n_songs} subsongs:") for i, ss in enumerate(subsongs): vprint(f" song {i}: entry@{ss['entry']}, {len(ss['positions'])} orders") # ── Build per-song payloads ────────────────────────────────────────────── song_payloads = [] for i, ss in enumerate(subsongs): label = f"song {i}" if n_songs > 1 else "song" song_payloads.append(_build_song_payload_xm( h, patterns, instruments, ss['positions'], sample_ratio, active_channels, default_pans, resolve_inst_slot, song_label=label)) # ── Layout offsets and song table ──────────────────────────────────────── song_table_off = TAUD_HEADER_SIZE + comp_size first_song_off = song_table_off + TAUD_SONG_ENTRY * n_songs flags_byte = (0x00 if h.linear_freq else 0x01) song_table = bytearray() cur_off = first_song_off for pat_comp, cue_comp, entry_kwargs in song_payloads: # Header BPM/speed go into per-song; flags is shared (XM doesn't switch # period mode mid-file). entry = encode_song_entry(song_offset=cur_off, flags_byte=flags_byte, global_vol=0xFF, mixing_vol=0x80, base_note=0xA000, base_freq=8363.0, **entry_kwargs) assert len(entry) == TAUD_SONG_ENTRY song_table += entry cur_off += len(pat_comp) + len(cue_comp) # Project Data (optional). XM nests samples under instruments and the # converter creates one Taud slot per (xm_inst, sample) pair, so SNam is # populated from the per-Taud-slot proxies and INam carries the parent # XM-level instrument names (1-based; slot 0 empty). proj_data = b'' proj_off = 0 if with_project_data: inst_names = [''] + [(inst.name if inst is not None else '') for inst in instruments[:255]] smp_names = [''] + [(p.name if p is not None else '') for p in proxies[1:256]] proj_data = build_project_data( project_name=h.title, instrument_names=inst_names, sample_names=smp_names, ) if proj_data: proj_off = cur_off vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}") sig = (SIGNATURE + b' ' * 14)[:14] header = ( TAUD_MAGIC + bytes([TAUD_VERSION, n_songs & 0xFF]) + struct.pack('