mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 21:44:04 +09:00
1495 lines
64 KiB
Python
1495 lines
64 KiB
Python
#!/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.
|
||
|
||
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 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,
|
||
)
|
||
|
||
|
||
# ── 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('<H', data, off)[0]
|
||
def _read_u32(data, off): return struct.unpack_from('<I', data, off)[0]
|
||
|
||
|
||
def parse_xm_header(data: bytes) -> 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('<h', raw, i)[0]
|
||
last = (last + delta) & 0xFFFF
|
||
if last >= 0x8000:
|
||
signed = last - 0x10000
|
||
else:
|
||
signed = last
|
||
struct.pack_into('<h', pcm, i, signed)
|
||
# Update length / loop fields to be in sample units (not byte units)
|
||
s.length //= 2
|
||
s.loop_start //= 2
|
||
s.loop_length //= 2
|
||
s.sample_data = bytes(pcm)
|
||
else:
|
||
pcm = bytearray(s.length)
|
||
last = 0
|
||
for i in range(s.length):
|
||
delta = raw[i]
|
||
if delta >= 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 '<unnamed>'
|
||
)
|
||
# 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(1, min(0xFFFD, 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 -24 in stored form; mirror it2taud:
|
||
return (TOP_T, ((arg - 0x18) & 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) -> 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.)"""
|
||
for chunk_grid in chunks:
|
||
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
|
||
taud_cue = xm_ord_to_taud_cue.get(xm_ord, xm_ord)
|
||
row.effect_arg = taud_cue & 0xFF
|
||
|
||
|
||
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 IGV for 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('<I', inst_bin, base + 0, ptr)
|
||
struct.pack_into('<H', inst_bin, base + 4, s_len)
|
||
struct.pack_into('<H', inst_bin, base + 6, c2spd)
|
||
struct.pack_into('<H', inst_bin, base + 8, 0) # play start
|
||
struct.pack_into('<H', inst_bin, base + 10, ls)
|
||
struct.pack_into('<H', inst_bin, base + 12, le)
|
||
inst_bin[base + 14] = flags_byte
|
||
# LOOP words at 15/17/19.
|
||
struct.pack_into('<H', inst_bin, base + 15, vol_env_loop & 0xFFFF)
|
||
struct.pack_into('<H', inst_bin, base + 17, pan_env_loop & 0xFFFF)
|
||
struct.pack_into('<H', inst_bin, base + 19, 0) # pf envelope: off
|
||
|
||
if vol_env:
|
||
_write_env(inst_bin, base + 21, vol_env, pad_value=63)
|
||
else:
|
||
inst_bin[base + 21] = 63
|
||
inst_bin[base + 22] = 0
|
||
|
||
if pan_env:
|
||
_write_env(inst_bin, base + 71, pan_env, pad_value=0x80)
|
||
else:
|
||
for k in range(25):
|
||
inst_bin[base + 71 + k * 2] = 0x80
|
||
inst_bin[base + 71 + k * 2 + 1] = 0x00
|
||
|
||
# pf envelope (pitch/filter): unused — keep at unity centre.
|
||
for k in range(25):
|
||
inst_bin[base + 121 + k * 2] = 0x80
|
||
inst_bin[base + 121 + k * 2 + 1] = 0x00
|
||
|
||
# IGV: XM volume 0..64 → 0..255
|
||
inst_bin[base + 171] = min(0xFF, round(s.volume * 255 / 64))
|
||
# Fadeout: 12-bit. Low 8 bits at +172, high 4 bits at +173.
|
||
inst_bin[base + 172] = s.fadeout & 0xFF
|
||
inst_bin[base + 173] = (s.fadeout >> 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('<H', inst_bin, base + 189, vol_env_sus & 0xFFFF)
|
||
struct.pack_into('<H', inst_bin, base + 191, pan_env_sus & 0xFFFF)
|
||
struct.pack_into('<H', inst_bin, base + 193, 0) # pf sustain: off
|
||
# Byte 195 (DCT/DCA) — XM has no NNA / duplicate-check, leave 0.
|
||
|
||
env_tag = ''
|
||
if vol_env: env_tag += 'V'
|
||
if pan_env: env_tag += 'P'
|
||
vprint(f" instrument[{i}] '{s.name}' ptr={ptr} c2spd={s.c2spd} "
|
||
f"vol={s.volume} loop=({ls},{le},{'on' if loop_mode else 'off'}) "
|
||
f"fade={s.fadeout} nna={s.nna} env=[{env_tag or '-'}]")
|
||
|
||
return bytes(sample_bin) + bytes(inst_bin), offsets, ratio
|
||
|
||
|
||
# ── Pattern bin builder ───────────────────────────────────────────────────────
|
||
|
||
def build_pattern_xm(chunk_grid: list, ch_idx: int, default_pan: int,
|
||
inst_to_taud_slot: dict, amiga_mode: bool = False,
|
||
keyoff_zero_rows: set = None) -> 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('<H', out, base + 0, note_taud)
|
||
out[base + 2] = taud_slot & 0xFF
|
||
out[base + 3] = vol_byte
|
||
out[base + 4] = pan_byte
|
||
out[base + 5] = op & 0xFF
|
||
struct.pack_into('<H', out, base + 6, arg16 & 0xFFFF)
|
||
|
||
return bytes(out)
|
||
|
||
|
||
# ── Channel selection ─────────────────────────────────────────────────────────
|
||
|
||
def _active_channels_xm(h: XMHeader, patterns: list) -> 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 assemble_taud(h: XMHeader, patterns: list, instruments: list) -> 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(24, 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(24, min(280, tempo))
|
||
bpm_stored = (tempo - 24) & 0xFF
|
||
vprint(f" initial speed={speed}, tempo={tempo} BPM")
|
||
|
||
# ── Channels / cue list ─────────────────────────────────────────────────
|
||
active_channels = _active_channels_xm(h, patterns)
|
||
C = len(active_channels)
|
||
if C == 0:
|
||
sys.exit("error: no active channels found")
|
||
|
||
chunks, chunk_map, chunk_lens = split_patterns_xm(patterns)
|
||
|
||
taud_cue_list = []
|
||
xm_ord_to_taud_cue = {}
|
||
for oi, order in enumerate(h.order_list[:h.order_count]):
|
||
if order >= h.pattern_count:
|
||
continue
|
||
if order >= len(chunk_map):
|
||
continue
|
||
xm_ord_to_taud_cue.setdefault(oi, len(taud_cue_list))
|
||
for ci in chunk_map[order]:
|
||
taud_cue_list.append(ci)
|
||
|
||
if not taud_cue_list:
|
||
sys.exit("error: order list resolved to no playable cues")
|
||
|
||
remap_b_effects_xm(chunks, chunk_map, h.order_list, xm_ord_to_taud_cue, C)
|
||
|
||
# FT2 vol-env-off key-off gating: pre-compute per-(chunk, voice, row) flags
|
||
# for key-off cells whose bound XM instrument has volume envelope disabled.
|
||
# build_pattern_xm pairs each flagged key-off with `SEL_SET vol=0` so the
|
||
# IT-style Taud engine reproduces FT2's channel-volume zeroing gate.
|
||
keyoff_zero_marks = compute_keyoff_zero_marks_xm(
|
||
taud_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" FT2 keyoff-gate: {flagged} key-off cell(s) paired with vol=0 "
|
||
f"(vol-env-off instruments)")
|
||
|
||
# ── Pattern bin ─────────────────────────────────────────────────────────
|
||
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.")
|
||
|
||
# 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)]
|
||
|
||
pat_bin = bytearray()
|
||
for ci in taud_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)
|
||
# Rescale TOP_O sample-offset args per channel using the active slot's
|
||
# ratio (combined global + per-sample). Walks pat_bin in cue-major /
|
||
# channel-minor order, tracking the most recent inst byte seen on each
|
||
# channel — must run before deduplication so the channel state stays
|
||
# linear.
|
||
pat_bin = rescale_offset_effects_per_slot(
|
||
bytes(pat_bin), len(taud_cue_list), C, sample_ratio)
|
||
|
||
orig_count = len(taud_cue_list) * C
|
||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique "
|
||
f"({orig_count - num_taud_pats} deduplicated)")
|
||
|
||
# ── Cue sheet ───────────────────────────────────────────────────────────
|
||
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(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)]
|
||
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" warning: last active cue {last_active} had LEN; "
|
||
f"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" emitted {len_cue_count} LEN cue instruction(s) "
|
||
f"for partial-length patterns")
|
||
|
||
# ── Header / song table ─────────────────────────────────────────────────
|
||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||
header = (
|
||
TAUD_MAGIC +
|
||
bytes([TAUD_VERSION, 1]) +
|
||
struct.pack('<I', comp_size) +
|
||
b'\x00\x00\x00\x00' +
|
||
sig
|
||
)
|
||
assert len(header) == TAUD_HEADER_SIZE
|
||
|
||
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
||
cue_comp = compress_blob(bytes(sheet), "cue sheet")
|
||
|
||
# Flags byte:
|
||
# bits 0-1 (ff) = tone mode. ff=1 (Amiga period slides) when XM uses the Amiga
|
||
# period table; ff=0 otherwise. Pan law is fixed engine-wide to
|
||
# the equal-energy — no `p` bit any more.
|
||
# bit 2 = reserved (was 'm' fadeout-zero policy; removed). XM fadeout values
|
||
# are now scaled per-instrument above (÷32 with round-to-nearest), so
|
||
# the engine sees Taud-native units and uses its single divisor of 1024.
|
||
flags_byte = (0x00 if h.linear_freq else 0x01)
|
||
song_table = encode_song_entry(
|
||
song_offset=song_offset,
|
||
num_voices=C,
|
||
num_patterns=num_taud_pats,
|
||
bpm_stored=bpm_stored,
|
||
tick_rate=speed,
|
||
base_note=0xA000,
|
||
base_freq=8363.0,
|
||
flags_byte=flags_byte,
|
||
pat_bin_comp_size=len(pat_comp),
|
||
cue_sheet_comp_size=len(cue_comp),
|
||
global_vol=0xFF,
|
||
mixing_vol=0x80,
|
||
)
|
||
assert len(song_table) == TAUD_SONG_ENTRY
|
||
|
||
return header + compressed + song_table + pat_comp + cue_comp
|
||
|
||
|
||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(
|
||
description=__doc__,
|
||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
ap.add_argument('input', help='Input .xm file')
|
||
ap.add_argument('output', help='Output .taud file')
|
||
ap.add_argument('-v', '--verbose', action='store_true')
|
||
args = ap.parse_args()
|
||
set_verbose(args.verbose)
|
||
|
||
with open(args.input, 'rb') as f:
|
||
data = f.read()
|
||
|
||
vprint(f"parsing '{args.input}' ({len(data)} bytes)…")
|
||
h = parse_xm_header(data)
|
||
vprint(f" title: '{h.title}'")
|
||
vprint(f" tracker: '{h.tracker}' version=0x{h.version:04X}")
|
||
vprint(f" channels={h.channels} patterns={h.pattern_count} "
|
||
f"insts={h.instrument_count} orders={h.order_count}")
|
||
vprint(f" freq table: {'linear' if h.linear_freq else 'Amiga'}")
|
||
|
||
patterns_off = 60 + h.header_size
|
||
patterns, after_patterns = parse_patterns(data, h, patterns_off)
|
||
instruments, _after = parse_instruments(data, h, after_patterns)
|
||
|
||
taud = assemble_taud(h, patterns, instruments)
|
||
|
||
with open(args.output, 'wb') as f:
|
||
f.write(taud)
|
||
|
||
print(f"wrote {len(taud)} bytes to '{args.output}'")
|
||
if args.verbose:
|
||
print(f" magic ok: {taud[:8].hex()}", file=sys.stderr)
|
||
sig_off = TAUD_HEADER_SIZE - 14
|
||
print(f" signature: {taud[sig_off:sig_off + 14]}", file=sys.stderr)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|