mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 13:38:30 +09:00
1380 lines
57 KiB
Python
1380 lines
57 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 737280 bytes; if all samples together exceed this,
|
||
every sample is globally resampled down (with c2spd adjusted) so
|
||
pitch is preserved, mirroring it2taud / mod2taud.
|
||
- 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 gzip
|
||
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,
|
||
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, encode_cue, deduplicate_patterns,
|
||
normalise_sample, encode_song_entry,
|
||
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
|
||
|
||
|
||
# ── Minifloat LUT (must match it2taud / engine) ──────────────────────────────
|
||
|
||
_MINUFLOAT_LUT = [
|
||
0.0, 0.03125, 0.0625, 0.09375, 0.125, 0.15625, 0.1875, 0.21875,
|
||
0.25, 0.28125, 0.3125, 0.34375, 0.375, 0.40625, 0.4375, 0.46875,
|
||
0.5, 0.53125, 0.5625, 0.59375, 0.625, 0.65625, 0.6875, 0.71875,
|
||
0.75, 0.78125, 0.8125, 0.84375, 0.875, 0.90625, 0.9375, 0.96875,
|
||
1.0, 1.03125, 1.0625, 1.09375, 1.125, 1.15625, 1.1875, 1.21875,
|
||
1.25, 1.28125, 1.3125, 1.34375, 1.375, 1.40625, 1.4375, 1.46875,
|
||
1.5, 1.53125, 1.5625, 1.59375, 1.625, 1.65625, 1.6875, 1.71875,
|
||
1.75, 1.78125, 1.8125, 1.84375, 1.875, 1.90625, 1.9375, 1.96875,
|
||
2.0, 2.0625, 2.125, 2.1875, 2.25, 2.3125, 2.375, 2.4375,
|
||
2.5, 2.5625, 2.625, 2.6875, 2.75, 2.8125, 2.875, 2.9375,
|
||
3.0, 3.0625, 3.125, 3.1875, 3.25, 3.3125, 3.375, 3.4375,
|
||
3.5, 3.5625, 3.625, 3.6875, 3.75, 3.8125, 3.875, 3.9375,
|
||
4.0, 4.125, 4.25, 4.375, 4.5, 4.625, 4.75, 4.875,
|
||
5.0, 5.125, 5.25, 5.375, 5.5, 5.625, 5.75, 5.875,
|
||
6.0, 6.125, 6.25, 6.375, 6.5, 6.625, 6.75, 6.875,
|
||
7.0, 7.125, 7.25, 7.375, 7.5, 7.625, 7.75, 7.875,
|
||
8.0, 8.25, 8.5, 8.75, 9.0, 9.25, 9.5, 9.75,
|
||
10.0, 10.25, 10.5, 10.75, 11.0, 11.25, 11.5, 11.75,
|
||
12.0, 12.25, 12.5, 12.75, 13.0, 13.25, 13.5, 13.75,
|
||
14.0, 14.25, 14.5, 14.75, 15.0, 15.25, 15.5, 15.75,
|
||
16.0, 16.5, 17.0, 17.5, 18.0, 18.5, 19.0, 19.5,
|
||
20.0, 20.5, 21.0, 21.5, 22.0, 22.5, 23.0, 23.5,
|
||
24.0, 24.5, 25.0, 25.5, 26.0, 26.5, 27.0, 27.5,
|
||
28.0, 28.5, 29.0, 29.5, 30.0, 30.5, 31.0, 31.5,
|
||
32.0, 33.0, 34.0, 35.0, 36.0, 37.0, 38.0, 39.0,
|
||
40.0, 41.0, 42.0, 43.0, 44.0, 45.0, 46.0, 47.0,
|
||
48.0, 49.0, 50.0, 51.0, 52.0, 53.0, 54.0, 55.0,
|
||
56.0, 57.0, 58.0, 59.0, 60.0, 61.0, 62.0, 63.0,
|
||
64.0, 66.0, 68.0, 70.0, 72.0, 74.0, 76.0, 78.0,
|
||
80.0, 82.0, 84.0, 86.0, 88.0, 90.0, 92.0, 94.0,
|
||
96.0, 98.0, 100.0, 102.0, 104.0, 106.0, 108.0, 110.0,
|
||
112.0, 114.0, 116.0, 118.0, 120.0, 122.0, 124.0, 126.0,
|
||
]
|
||
|
||
|
||
def _nearest_minifloat(sec: float) -> int:
|
||
if sec <= 0.0:
|
||
return 0
|
||
if sec >= 126.0:
|
||
return 255
|
||
lo, hi = 0, len(_MINUFLOAT_LUT) - 1
|
||
while lo < hi:
|
||
mid = (lo + hi) // 2
|
||
if _MINUFLOAT_LUT[mid] < sec:
|
||
lo = mid + 1
|
||
else:
|
||
hi = mid
|
||
if lo > 0 and abs(_MINUFLOAT_LUT[lo - 1] - sec) < abs(_MINUFLOAT_LUT[lo] - sec):
|
||
return lo - 1
|
||
return lo
|
||
|
||
|
||
# ── 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 (G + d_arg vol slide override).
|
||
return (TOP_G, 0x0000, d_arg_to_col(arg), None)
|
||
|
||
if cmd == 0x06:
|
||
# Vibrato + vol slide → Taud K (H + d_arg vol slide override).
|
||
return (TOP_H, 0x0000, d_arg_to_col(arg), 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:
|
||
# Set loop point / loop. Taud S6x = fine pattern delay; the
|
||
# closest analogue here is dropping with a warn if val>0.
|
||
vprint(f" dropped E6{val:X} (set loop) at ch{ch} row{row}")
|
||
return (TOP_NONE, 0, 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
|
||
|
||
|
||
# ── 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).
|
||
loop_word = 0x0020 # b: use envelope (vol always; even with no loop the engine evaluates it)
|
||
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, ratio).
|
||
"""
|
||
pcm_list = [(i, s) for i, s in enumerate(proxies)
|
||
if s is not None and s.sample_data]
|
||
|
||
total = sum(len(s.sample_data) for _, s in pcm_list)
|
||
ratio = 1.0
|
||
if total > SAMPLEBIN_SIZE:
|
||
ratio = SAMPLEBIN_SIZE / total
|
||
vprint(f" info: sample bin overflow ({total} bytes); "
|
||
f"resampling all by {ratio:.4f}")
|
||
for _, s in pcm_list:
|
||
new_data = resample_linear(s.sample_data, ratio)
|
||
s.sample_data = new_data
|
||
s.length = len(new_data)
|
||
s.loop_begin = max(0, int(s.loop_begin * ratio))
|
||
s.loop_end = max(0, min(int(s.loop_end * ratio), s.length))
|
||
s.c2spd = max(1, int(s.c2spd * ratio))
|
||
|
||
sample_bin = bytearray(SAMPLEBIN_SIZE)
|
||
offsets = {}
|
||
pos = 0
|
||
for idx, s in pcm_list:
|
||
n = min(len(s.sample_data), SAMPLEBIN_SIZE - pos)
|
||
if n <= 0:
|
||
vprint(f" warning: sample bin full, dropping '{s.name}'")
|
||
offsets[idx] = 0
|
||
s.length = 0
|
||
continue
|
||
sample_bin[pos:pos + n] = s.sample_data[:n]
|
||
offsets[idx] = pos
|
||
if n < len(s.sample_data):
|
||
vprint(f" warning: '{s.name}' truncated {len(s.sample_data)} → {n}")
|
||
s.length = n
|
||
s.loop_end = min(s.loop_end, n)
|
||
pos += n
|
||
|
||
USE_ENV_BIT = 0x0020 # b: engine should evaluate the envelope
|
||
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 only) and rely on IGV for level.
|
||
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
|
||
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) -> bytes:
|
||
"""Render one Taud channel's 512-byte pattern from a 64-row chunk grid."""
|
||
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)
|
||
|
||
# ── 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)")
|
||
|
||
# ── 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
|
||
|
||
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 = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
|
||
comp_size = len(compressed)
|
||
vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)")
|
||
|
||
# ── 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)
|
||
|
||
# ── 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]
|
||
for vi, ch in enumerate(active_channels):
|
||
pat_bin += build_pattern_xm(cg, ch, default_pans[vi],
|
||
resolve_inst_slot,
|
||
amiga_mode=not h.linear_freq)
|
||
pat_bin = rescale_offset_effects(bytes(pat_bin), 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 = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
|
||
cue_comp = gzip.compress(bytes(sheet), compresslevel=9, mtime=0)
|
||
vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)")
|
||
vprint(f" cue sheet: {len(sheet)} → {len(cue_comp)} bytes (gzip)")
|
||
|
||
# Flags byte:
|
||
# bit 1 (f) = Amiga pitch-slide mode (set when XM uses Amiga period table).
|
||
# 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 0x02)
|
||
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()
|