mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
2taud: export to multiple song if possible
This commit is contained in:
397
it2taud.py
397
it2taud.py
@@ -35,6 +35,7 @@ Effect support:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import copy
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ from taud_common import (
|
|||||||
encode_cue, deduplicate_patterns,
|
encode_cue, deduplicate_patterns,
|
||||||
normalise_sample, encode_song_entry, nearest_minifloat, compress_blob,
|
normalise_sample, encode_song_entry, nearest_minifloat, compress_blob,
|
||||||
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
|
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
|
||||||
build_project_data,
|
build_project_data, detect_subsongs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1057,7 +1058,10 @@ def split_patterns(patterns_rows: list):
|
|||||||
|
|
||||||
def _remap_bc_effects(chunks: list, chunk_map: list,
|
def _remap_bc_effects(chunks: list, chunk_map: list,
|
||||||
order_list: list, it_ord_to_taud_cue: dict,
|
order_list: list, it_ord_to_taud_cue: dict,
|
||||||
num_channels: int) -> None:
|
num_channels: int,
|
||||||
|
*, default_target: int = None,
|
||||||
|
warn_label: str = '',
|
||||||
|
chunk_indices=None) -> None:
|
||||||
"""Rewrite B (position-jump) effects using remapped order indices.
|
"""Rewrite B (position-jump) effects using remapped order indices.
|
||||||
|
|
||||||
B effects are rewritten to point to the first chunk of the target IT
|
B effects are rewritten to point to the first chunk of the target IT
|
||||||
@@ -1068,15 +1072,36 @@ def _remap_bc_effects(chunks: list, chunk_map: list,
|
|||||||
being emitted by the engine when the source pattern's row pointer
|
being emitted by the engine when the source pattern's row pointer
|
||||||
naturally hits a chunk boundary. Since splits at exact multiples of
|
naturally hits a chunk boundary. Since splits at exact multiples of
|
||||||
64 have no LEN gap, no C-skip injection is required.
|
64 have no LEN gap, no C-skip injection is required.
|
||||||
|
|
||||||
|
`default_target` (multi-song): when a Bxx points to an order outside
|
||||||
|
`it_ord_to_taud_cue` (a cross-subsong jump), rewrite to this cue
|
||||||
|
index instead of preserving the literal target. Set to 0 to make
|
||||||
|
cross-song jumps loop the subsong; leave None for legacy behaviour.
|
||||||
|
|
||||||
|
`chunk_indices`: optional iterable; when provided, only these chunks
|
||||||
|
are visited. Used by multi-song to skip unreferenced chunks (avoids
|
||||||
|
spurious cross-song warnings on chunks that won't be emitted).
|
||||||
"""
|
"""
|
||||||
for ci, chunk_grid in enumerate(chunks):
|
crossings = 0
|
||||||
|
iter_indices = (chunk_indices if chunk_indices is not None
|
||||||
|
else range(len(chunks)))
|
||||||
|
for ci in iter_indices:
|
||||||
|
chunk_grid = chunks[ci]
|
||||||
for ch in range(num_channels):
|
for ch in range(num_channels):
|
||||||
if ch >= len(chunk_grid): continue
|
if ch >= len(chunk_grid): continue
|
||||||
for row in chunk_grid[ch]:
|
for row in chunk_grid[ch]:
|
||||||
if row.effect == EFF_B:
|
if row.effect == EFF_B:
|
||||||
it_tgt = row.effect_arg
|
it_tgt = row.effect_arg
|
||||||
taud_cue = it_ord_to_taud_cue.get(it_tgt, it_tgt)
|
if it_tgt in it_ord_to_taud_cue:
|
||||||
row.effect_arg = taud_cue & 0xFF
|
row.effect_arg = it_ord_to_taud_cue[it_tgt] & 0xFF
|
||||||
|
elif default_target is not None:
|
||||||
|
crossings += 1
|
||||||
|
row.effect_arg = default_target & 0xFF
|
||||||
|
else:
|
||||||
|
row.effect_arg = it_tgt & 0xFF
|
||||||
|
if crossings and warn_label:
|
||||||
|
vprint(f" warning: {warn_label}: {crossings} Bxx target(s) cross "
|
||||||
|
f"subsong boundary; clamped to cue {default_target}")
|
||||||
|
|
||||||
|
|
||||||
# ── Sample / instrument bin (same as s3m2taud) ────────────────────────────────
|
# ── Sample / instrument bin (same as s3m2taud) ────────────────────────────────
|
||||||
@@ -1573,22 +1598,176 @@ def _active_channels(h: ITHeader, patterns_rows: list) -> list:
|
|||||||
active = active[:NUM_VOICES]
|
active = active[:NUM_VOICES]
|
||||||
return active
|
return active
|
||||||
|
|
||||||
|
def _per_pattern_bxx_it(patterns_rows: list):
|
||||||
|
"""Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough)
|
||||||
|
for use by `detect_subsongs`. `kills_fallthrough` is True iff the pattern
|
||||||
|
carries a Bxx on its absolute last row — the unconditional terminating
|
||||||
|
jump idiom every tracker uses for "song ends here, loop back".
|
||||||
|
"""
|
||||||
|
def fn(pat_idx: int):
|
||||||
|
if pat_idx < 0 or pat_idx >= len(patterns_rows):
|
||||||
|
return set(), False
|
||||||
|
grid, rows = patterns_rows[pat_idx]
|
||||||
|
targets = set()
|
||||||
|
last_row_has_b = False
|
||||||
|
for ch in range(64):
|
||||||
|
if ch >= len(grid): continue
|
||||||
|
ch_rows = grid[ch]
|
||||||
|
for r in range(min(rows, len(ch_rows))):
|
||||||
|
cell = ch_rows[r]
|
||||||
|
if cell.effect == EFF_B:
|
||||||
|
targets.add(cell.effect_arg)
|
||||||
|
if r == rows - 1:
|
||||||
|
last_row_has_b = True
|
||||||
|
return targets, last_row_has_b
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
def _build_song_payload(h: ITHeader, patterns_rows_template: list,
|
||||||
|
positions: list, sample_ratio: dict,
|
||||||
|
inst_vols: dict, active_channels: list,
|
||||||
|
*, song_label: str = 'song') -> tuple:
|
||||||
|
"""Build pattern bin + cue sheet + song-entry kwargs for one subsong.
|
||||||
|
|
||||||
|
Returns (pat_comp, cue_comp, entry_kwargs). The caller fills in
|
||||||
|
`song_offset` from the global layout before calling encode_song_entry.
|
||||||
|
|
||||||
|
`patterns_rows_template` is deep-copied so per-song stateful walks
|
||||||
|
(recall resolution, late-note-delay relocation, Bxx remap on chunks)
|
||||||
|
don't leak into the next subsong.
|
||||||
|
"""
|
||||||
|
pats = copy.deepcopy(patterns_rows_template)
|
||||||
|
virtual_orders = [h.order_list[pos] for pos in positions]
|
||||||
|
|
||||||
|
vprint(f" [{song_label}] resolving IT recalls…")
|
||||||
|
resolve_it_recalls(pats, virtual_orders, 64, h.link_gef,
|
||||||
|
old_effects=h.old_effects)
|
||||||
|
|
||||||
|
init_speed, _ = find_initial_bpm_speed(pats, virtual_orders,
|
||||||
|
h.initial_speed, h.initial_tempo)
|
||||||
|
relocate_late_note_delays(pats, virtual_orders, 64, init_speed)
|
||||||
|
|
||||||
|
chunks, chunk_map, chunk_lens = split_patterns(pats)
|
||||||
|
|
||||||
|
C = len(active_channels)
|
||||||
|
|
||||||
|
# Cue list = expand each subsong position into chunk indices for its pattern.
|
||||||
|
# pos_to_cue maps the original order-list position → first cue in this song.
|
||||||
|
cue_list = []
|
||||||
|
pos_to_cue = {}
|
||||||
|
for pos in positions:
|
||||||
|
order = h.order_list[pos]
|
||||||
|
if order >= IT_ORD_END or order >= len(chunk_map):
|
||||||
|
continue
|
||||||
|
pos_to_cue[pos] = len(cue_list)
|
||||||
|
for ci in chunk_map[order]:
|
||||||
|
cue_list.append(ci)
|
||||||
|
|
||||||
|
# Bxx remap: source-position → cue-index. Cross-subsong Bxx targets clamp
|
||||||
|
# to cue 0 (loop the subsong rather than jump out of bounds). Only walk
|
||||||
|
# chunks that this song actually emits — avoids spurious warnings on
|
||||||
|
# patterns owned by other subsongs.
|
||||||
|
_remap_bc_effects(chunks, chunk_map, virtual_orders, pos_to_cue, C,
|
||||||
|
default_target=0, warn_label=song_label,
|
||||||
|
chunk_indices=set(cue_list))
|
||||||
|
|
||||||
|
speed, tempo = find_initial_bpm_speed(pats, virtual_orders,
|
||||||
|
h.initial_speed, h.initial_tempo)
|
||||||
|
tempo = max(25, min(280, tempo))
|
||||||
|
bpm_stored = (tempo - 25) & 0xFF
|
||||||
|
vprint(f" [{song_label}] initial speed={speed}, tempo={tempo} BPM")
|
||||||
|
|
||||||
|
default_pans = [_it_default_pan(h.chnl_pan[ch]) for ch in active_channels]
|
||||||
|
total_taud_pats = len(cue_list) * C
|
||||||
|
if total_taud_pats > NUM_PATTERNS_MAX:
|
||||||
|
sys.exit(
|
||||||
|
f"error: [{song_label}] {len(cue_list)} cues × {C} channels = "
|
||||||
|
f"{total_taud_pats} > {NUM_PATTERNS_MAX} Taud pattern limit."
|
||||||
|
)
|
||||||
|
|
||||||
|
pat_bin = bytearray()
|
||||||
|
for ci in cue_list:
|
||||||
|
cg = chunks[ci]
|
||||||
|
for vi, ch in enumerate(active_channels):
|
||||||
|
pat_bin += build_pattern_it(cg, ch, default_pans[vi], inst_vols,
|
||||||
|
amiga_mode=not h.linear_slides)
|
||||||
|
|
||||||
|
pat_bin = rescale_offset_effects_per_slot(
|
||||||
|
bytes(pat_bin), len(cue_list), C, sample_ratio)
|
||||||
|
|
||||||
|
orig_count = len(cue_list) * C
|
||||||
|
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||||
|
vprint(f" [{song_label}] patterns: {orig_count} → {num_taud_pats} unique "
|
||||||
|
f"({orig_count - num_taud_pats} deduplicated)")
|
||||||
|
|
||||||
|
sheet = bytearray(NUM_CUES * CUE_SIZE)
|
||||||
|
for c in range(NUM_CUES):
|
||||||
|
sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0)
|
||||||
|
|
||||||
|
last_active = -1
|
||||||
|
len_cue_count = 0
|
||||||
|
for cue_idx, ci in enumerate(cue_list):
|
||||||
|
if cue_idx >= NUM_CUES: break
|
||||||
|
base_pat = cue_idx * C
|
||||||
|
pat_idx_list = [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(pat_idx_list, instr)
|
||||||
|
last_active = cue_idx
|
||||||
|
|
||||||
|
if last_active >= 0:
|
||||||
|
b30_existing = sheet[last_active * CUE_SIZE + 30]
|
||||||
|
if b30_existing == CUE_INST_LEN:
|
||||||
|
vprint(f" [{song_label}] 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" [{song_label}] emitted {len_cue_count} LEN cue instruction(s) "
|
||||||
|
f"for partial-length patterns")
|
||||||
|
|
||||||
|
pat_comp = compress_blob(bytes(pat_bin), f"[{song_label}] pattern bin")
|
||||||
|
cue_comp = compress_blob(bytes(sheet), f"[{song_label}] cue sheet")
|
||||||
|
|
||||||
|
flags_byte = 0x00 if h.linear_slides else 0x01
|
||||||
|
global_vol_taud = min(0xFF, round(h.global_vol * 255 / 128))
|
||||||
|
mixing_vol_taud = min(0xFF, round(h.mix_vol * 255 / 128))
|
||||||
|
|
||||||
|
entry_kwargs = dict(
|
||||||
|
num_voices=C,
|
||||||
|
num_patterns=num_taud_pats,
|
||||||
|
bpm_stored=bpm_stored,
|
||||||
|
tick_rate=speed,
|
||||||
|
base_note=0xA000, # C9
|
||||||
|
base_freq=8363.0,
|
||||||
|
flags_byte=flags_byte,
|
||||||
|
pat_bin_comp_size=len(pat_comp),
|
||||||
|
cue_sheet_comp_size=len(cue_comp),
|
||||||
|
global_vol=global_vol_taud,
|
||||||
|
mixing_vol=mixing_vol_taud,
|
||||||
|
)
|
||||||
|
return pat_comp, cue_comp, entry_kwargs
|
||||||
|
|
||||||
|
|
||||||
def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||||
patterns_rows: list, decompress: bool,
|
patterns_rows: list, decompress: bool,
|
||||||
with_project_data: bool = True) -> bytes:
|
with_project_data: bool = True) -> bytes:
|
||||||
# ── Resolve IT recalls ───────────────────────────────────────────────────
|
# ── Active channels (shared across subsongs) ─────────────────────────────
|
||||||
vprint(" resolving IT recalls…")
|
active_channels = _active_channels(h, patterns_rows)
|
||||||
resolve_it_recalls(patterns_rows, h.order_list, 64, h.link_gef,
|
C = len(active_channels)
|
||||||
old_effects=h.old_effects)
|
if C == 0:
|
||||||
|
sys.exit("error: no active channels found")
|
||||||
|
|
||||||
init_speed, _ = find_initial_bpm_speed(patterns_rows, h.order_list,
|
# ── SBx chunk-crossing warning (informational only; pattern data is read,
|
||||||
h.initial_speed, h.initial_tempo)
|
# not modified, so this is safe to do once over the shared template) ──
|
||||||
relocate_late_note_delays(patterns_rows, h.order_list, 64, init_speed)
|
|
||||||
|
|
||||||
# ── Check SBx chunk crossing (warn only) ─────────────────────────────────
|
|
||||||
for pi, (grid, rows) in enumerate(patterns_rows):
|
for pi, (grid, rows) in enumerate(patterns_rows):
|
||||||
if rows <= PATTERN_ROWS: continue
|
if rows <= PATTERN_ROWS: continue
|
||||||
n_chunks = (rows + PATTERN_ROWS - 1) // PATTERN_ROWS
|
|
||||||
for ch in range(64):
|
for ch in range(64):
|
||||||
if ch >= len(grid): continue
|
if ch >= len(grid): continue
|
||||||
loop_start_chunk = None
|
loop_start_chunk = None
|
||||||
@@ -1605,36 +1784,6 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
|||||||
f"chunk boundary (loops may misbehave)")
|
f"chunk boundary (loops may misbehave)")
|
||||||
break
|
break
|
||||||
|
|
||||||
# ── Split patterns into 64-row chunks ────────────────────────────────────
|
|
||||||
vprint(" splitting patterns…")
|
|
||||||
chunks, chunk_map, chunk_lens = split_patterns(patterns_rows)
|
|
||||||
|
|
||||||
# ── Choose active channels ───────────────────────────────────────────────
|
|
||||||
active_channels = _active_channels(h, patterns_rows)
|
|
||||||
C = len(active_channels)
|
|
||||||
if C == 0:
|
|
||||||
sys.exit("error: no active channels found")
|
|
||||||
|
|
||||||
# ── Build the ordered list of (taud_chunk_idx, voice_idx) triples ────────
|
|
||||||
# Expand order list: each IT order → sequence of chunk indices for that pattern
|
|
||||||
taud_cue_list = [] # list of chunk_idx (source patterns, already chunked)
|
|
||||||
it_ord_to_taud_cue = {} # first taud cue for IT order i
|
|
||||||
|
|
||||||
for oi, order in enumerate(h.order_list):
|
|
||||||
if order == IT_ORD_END:
|
|
||||||
break
|
|
||||||
if order == IT_ORD_SKIP:
|
|
||||||
continue
|
|
||||||
if order >= len(chunk_map):
|
|
||||||
continue
|
|
||||||
it_ord_to_taud_cue.setdefault(oi, len(taud_cue_list))
|
|
||||||
for ci in chunk_map[order]:
|
|
||||||
taud_cue_list.append(ci)
|
|
||||||
|
|
||||||
# ── Remap B effects ──────────────────────────────────────────────────────
|
|
||||||
_remap_bc_effects(chunks, chunk_map, h.order_list, it_ord_to_taud_cue,
|
|
||||||
len(active_channels))
|
|
||||||
|
|
||||||
# ── Build sample proxy list (0-indexed, slot 0 unused) ──────────────────
|
# ── Build sample proxy list (0-indexed, slot 0 unused) ──────────────────
|
||||||
# When use_instruments: map Taud instrument slots to samples via canonical_sample.
|
# When use_instruments: map Taud instrument slots to samples via canonical_sample.
|
||||||
# Pattern cells carry IT instrument numbers; for use_instruments mode, those
|
# Pattern cells carry IT instrument numbers; for use_instruments mode, those
|
||||||
@@ -1750,116 +1899,47 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
|||||||
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
||||||
comp_size = len(compressed)
|
comp_size = len(compressed)
|
||||||
|
|
||||||
# ── BPM / speed ──────────────────────────────────────────────────────────
|
# ── Detect subsongs ──────────────────────────────────────────────────────
|
||||||
speed, tempo = find_initial_bpm_speed(patterns_rows, h.order_list,
|
subsongs = detect_subsongs(h.order_list, _per_pattern_bxx_it(patterns_rows),
|
||||||
h.initial_speed, h.initial_tempo)
|
terminators=(IT_ORD_END,),
|
||||||
tempo = max(25, min(280, tempo))
|
skip_marker=IT_ORD_SKIP)
|
||||||
bpm_stored = (tempo - 25) & 0xFF
|
if not subsongs:
|
||||||
vprint(f" initial speed={speed}, tempo={tempo} BPM")
|
# Degenerate file: every order is a terminator. Emit one empty subsong.
|
||||||
|
vprint(" warning: no traversable orders in source; emitting empty song")
|
||||||
# ── Pattern bin ──────────────────────────────────────────────────────────
|
subsongs = [{'entry': 0, 'positions': []}]
|
||||||
vprint(" building pattern bin…")
|
n_songs = len(subsongs)
|
||||||
default_pans = [_it_default_pan(h.chnl_pan[ch]) for ch in active_channels]
|
if n_songs == 1:
|
||||||
total_taud_pats = len(taud_cue_list) * C
|
vprint(f" detected 1 song ({len(subsongs[0]['positions'])} orders)")
|
||||||
if total_taud_pats > NUM_PATTERNS_MAX:
|
|
||||||
sys.exit(
|
|
||||||
f"error: {len(taud_cue_list)} cues × {C} channels = "
|
|
||||||
f"{total_taud_pats} > {NUM_PATTERNS_MAX} Taud pattern limit."
|
|
||||||
)
|
|
||||||
|
|
||||||
pat_bin = bytearray()
|
|
||||||
for ci in taud_cue_list:
|
|
||||||
cg = chunks[ci]
|
|
||||||
for vi, ch in enumerate(active_channels):
|
|
||||||
pat_bin += build_pattern_it(cg, ch, default_pans[vi], inst_vols,
|
|
||||||
amiga_mode=not h.linear_slides)
|
|
||||||
|
|
||||||
# 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 ────────────────────────────────────────────────────────────
|
|
||||||
vprint(" building cue sheet…")
|
|
||||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
|
||||||
sheet = bytearray(NUM_CUES * CUE_SIZE)
|
|
||||||
for c in range(NUM_CUES):
|
|
||||||
sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0)
|
|
||||||
|
|
||||||
last_active = -1
|
|
||||||
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:
|
|
||||||
# Halt overlays whatever LEN was on this cue. If both apply
|
|
||||||
# (the song terminates on a partial-tail chunk), the LEN is
|
|
||||||
# mooted by halt — warn so the user is aware.
|
|
||||||
b30_existing = sheet[last_active * CUE_SIZE + 30]
|
|
||||||
if b30_existing == 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:
|
else:
|
||||||
sheet[30] = CUE_INST_HALT
|
vprint(f" detected {n_songs} subsongs:")
|
||||||
if len_cue_count:
|
for i, ss in enumerate(subsongs):
|
||||||
vprint(f" emitted {len_cue_count} LEN cue instruction(s) "
|
vprint(f" song {i}: entry@{ss['entry']}, {len(ss['positions'])} orders")
|
||||||
f"for partial-length patterns")
|
|
||||||
|
|
||||||
# ── Header ───────────────────────────────────────────────────────────────
|
# ── Build per-song payloads ──────────────────────────────────────────────
|
||||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
song_payloads = [] # list of (pat_comp, cue_comp, entry_kwargs)
|
||||||
|
for i, ss in enumerate(subsongs):
|
||||||
|
label = f"song {i}" if n_songs > 1 else "song"
|
||||||
|
song_payloads.append(_build_song_payload(
|
||||||
|
h, patterns_rows, ss['positions'],
|
||||||
|
sample_ratio, inst_vols, active_channels,
|
||||||
|
song_label=label))
|
||||||
|
|
||||||
# Compress pattern bin and cue sheet (per Taud spec)
|
# ── Compute layout offsets and assemble song table ───────────────────────
|
||||||
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
song_table_off = TAUD_HEADER_SIZE + comp_size
|
||||||
cue_comp = compress_blob(bytes(sheet), "cue sheet")
|
first_song_off = song_table_off + TAUD_SONG_ENTRY * n_songs
|
||||||
|
|
||||||
# flags byte: bits 0-1 (ff) = tone mode. ff=1 (Amiga period slides) when IT's
|
song_table = bytearray()
|
||||||
# linear_slides flag is clear; ff=0 otherwise. Pan law is fixed engine-wide to
|
cur_off = first_song_off
|
||||||
# the equal-energy — no `p` bit any more. Bit 2 was the old 'm' fadeout-zero
|
for pat_comp, cue_comp, entry_kwargs in song_payloads:
|
||||||
# policy flag and is now reserved (always 0); fadeout scaling is done per-instrument
|
entry = encode_song_entry(song_offset=cur_off, **entry_kwargs)
|
||||||
# in this converter — see the fadeout pass-through below.
|
assert len(entry) == TAUD_SONG_ENTRY
|
||||||
flags_byte = 0x00 if h.linear_slides else 0x01
|
song_table += entry
|
||||||
# IT global/mix volumes are 0..128; rescale to Taud's 0..255 (clamped).
|
cur_off += len(pat_comp) + len(cue_comp)
|
||||||
global_vol_taud = min(0xFF, round(h.global_vol * 255 / 128))
|
|
||||||
mixing_vol_taud = min(0xFF, round(h.mix_vol * 255 / 128))
|
|
||||||
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, # C9
|
|
||||||
base_freq=8363.0,
|
|
||||||
flags_byte=flags_byte,
|
|
||||||
pat_bin_comp_size=len(pat_comp),
|
|
||||||
cue_sheet_comp_size=len(cue_comp),
|
|
||||||
global_vol=global_vol_taud,
|
|
||||||
mixing_vol=mixing_vol_taud,
|
|
||||||
)
|
|
||||||
assert len(song_table) == TAUD_SONG_ENTRY
|
|
||||||
|
|
||||||
# Project Data (optional). IT distinguishes instruments from samples, so
|
# ── Project Data (optional) ──────────────────────────────────────────────
|
||||||
# both INam and SNam can carry distinct content. Slot 0 is unused, so the
|
# IT distinguishes instruments from samples, so both INam and SNam can carry
|
||||||
# tables are 1-indexed with an empty slot-0 entry.
|
# distinct content. Slot 0 is unused, so the tables are 1-indexed with an
|
||||||
|
# empty slot-0 entry.
|
||||||
proj_data = b''
|
proj_data = b''
|
||||||
proj_off = 0
|
proj_off = 0
|
||||||
if with_project_data:
|
if with_project_data:
|
||||||
@@ -1873,20 +1953,29 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
|||||||
sample_names=smp_names,
|
sample_names=smp_names,
|
||||||
)
|
)
|
||||||
if proj_data:
|
if proj_data:
|
||||||
proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \
|
proj_off = cur_off
|
||||||
+ len(pat_comp) + len(cue_comp)
|
|
||||||
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
||||||
|
|
||||||
|
# ── Header ───────────────────────────────────────────────────────────────
|
||||||
|
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||||
header = (
|
header = (
|
||||||
TAUD_MAGIC +
|
TAUD_MAGIC +
|
||||||
bytes([TAUD_VERSION, 1]) +
|
bytes([TAUD_VERSION, n_songs & 0xFF]) +
|
||||||
struct.pack('<I', comp_size) +
|
struct.pack('<I', comp_size) +
|
||||||
struct.pack('<I', proj_off) +
|
struct.pack('<I', proj_off) +
|
||||||
sig
|
sig
|
||||||
)
|
)
|
||||||
assert len(header) == TAUD_HEADER_SIZE
|
assert len(header) == TAUD_HEADER_SIZE
|
||||||
|
|
||||||
return header + compressed + song_table + pat_comp + cue_comp + proj_data
|
out = bytearray()
|
||||||
|
out += header
|
||||||
|
out += compressed
|
||||||
|
out += song_table
|
||||||
|
for pat_comp, cue_comp, _ in song_payloads:
|
||||||
|
out += pat_comp
|
||||||
|
out += cue_comp
|
||||||
|
out += proj_data
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
270
mod2taud.py
270
mod2taud.py
@@ -24,6 +24,7 @@ Effect support:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import copy
|
||||||
import math
|
import math
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
@@ -40,7 +41,7 @@ from taud_common import (
|
|||||||
J_SEMI_TABLE,
|
J_SEMI_TABLE,
|
||||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||||
encode_song_entry, compress_blob,
|
encode_song_entry, compress_blob,
|
||||||
build_project_data,
|
build_project_data, detect_subsongs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -702,99 +703,133 @@ def find_initial_bpm_speed(patterns: list, order_list: list) -> tuple:
|
|||||||
return speed, tempo
|
return speed, tempo
|
||||||
|
|
||||||
|
|
||||||
def assemble_taud(mod: dict, with_project_data: bool = True) -> bytes:
|
def _per_pattern_bxx_mod(patterns: list, n_channels: int):
|
||||||
samples = mod['samples']
|
"""Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough)
|
||||||
patterns = mod['patterns']
|
for `detect_subsongs`. MOD patterns are 64 rows × n_channels; Bxx is
|
||||||
order_list = mod['order_list']
|
raw effect digit 0xB.
|
||||||
n_channels = mod['n_channels']
|
"""
|
||||||
n_patterns = mod['n_patterns']
|
def fn(pat_idx: int):
|
||||||
|
if pat_idx < 0 or pat_idx >= len(patterns):
|
||||||
if n_channels > NUM_VOICES:
|
return set(), False
|
||||||
vprint(f" warning: MOD has {n_channels} channels; truncating to {NUM_VOICES}")
|
grid = patterns[pat_idx]
|
||||||
n_channels = NUM_VOICES
|
targets = set()
|
||||||
|
last_row_has_b = False
|
||||||
if n_patterns * n_channels > NUM_PATTERNS_MAX:
|
|
||||||
sys.exit(
|
|
||||||
f"error: {n_patterns} MOD patterns × {n_channels} channels = "
|
|
||||||
f"{n_patterns*n_channels} > {NUM_PATTERNS_MAX} Taud pattern limit.\n"
|
|
||||||
f" Reduce the MOD to ≤ {NUM_PATTERNS_MAX // max(n_channels,1)} patterns."
|
|
||||||
)
|
|
||||||
|
|
||||||
vprint(f" channels: {n_channels}, mod patterns: {n_patterns}, "
|
|
||||||
f"taud patterns: {n_patterns * n_channels}")
|
|
||||||
|
|
||||||
# Fold Cxx into row.vol_set so the volume column carries explicit set-volume.
|
|
||||||
# This is done in-place before recall resolution so Cxx with arg 0 still
|
|
||||||
# resolves to vol 0 (silence) rather than recalling another effect's memory.
|
|
||||||
for grid in patterns:
|
|
||||||
for ch in range(min(n_channels, len(grid))):
|
for ch in range(min(n_channels, len(grid))):
|
||||||
for row in grid[ch]:
|
ch_rows = grid[ch]
|
||||||
if row.effect == 0xC:
|
for r in range(min(PATTERN_ROWS, len(ch_rows))):
|
||||||
row.vol_set = min(row.effect_arg, 0x3F)
|
cell = ch_rows[r]
|
||||||
row.effect = 0
|
if cell.effect == 0xB:
|
||||||
row.effect_arg = 0
|
targets.add(cell.effect_arg & 0xFF)
|
||||||
|
if r == PATTERN_ROWS - 1:
|
||||||
|
last_row_has_b = True
|
||||||
|
return targets, last_row_has_b
|
||||||
|
return fn
|
||||||
|
|
||||||
vprint(" resolving PT per-effect recalls…")
|
|
||||||
resolve_pt_recalls(patterns, order_list, n_channels)
|
|
||||||
|
|
||||||
init_speed, _ = find_initial_bpm_speed(patterns, order_list)
|
def _build_song_payload_mod(mod: dict, patterns_template: list,
|
||||||
relocate_late_note_delays(patterns, order_list, n_channels, init_speed)
|
positions: list, sample_ratio: dict,
|
||||||
|
inst_vols: dict, n_channels: int,
|
||||||
|
*, song_label: str = 'song') -> tuple:
|
||||||
|
"""Build pattern bin + cue sheet + song-entry kwargs for one MOD subsong.
|
||||||
|
|
||||||
vprint(" building sample/instrument bin…")
|
`patterns_template` is deep-copied so per-song stateful transforms
|
||||||
sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(samples)
|
(recall resolution, late-note-delay relocation, Bxx remap) don't leak
|
||||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
into the next subsong.
|
||||||
|
"""
|
||||||
|
patterns = copy.deepcopy(patterns_template)
|
||||||
|
order_list = mod['order_list']
|
||||||
|
virtual_orders = [order_list[pos] for pos in positions]
|
||||||
|
|
||||||
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
vprint(f" [{song_label}] resolving PT per-effect recalls…")
|
||||||
comp_size = len(compressed)
|
resolve_pt_recalls(patterns, virtual_orders, n_channels)
|
||||||
|
|
||||||
speed, tempo = find_initial_bpm_speed(patterns, order_list)
|
init_speed, _ = find_initial_bpm_speed(patterns, virtual_orders)
|
||||||
|
relocate_late_note_delays(patterns, virtual_orders, n_channels, init_speed)
|
||||||
|
|
||||||
|
speed, tempo = find_initial_bpm_speed(patterns, virtual_orders)
|
||||||
tempo = max(25, min(280, tempo))
|
tempo = max(25, min(280, tempo))
|
||||||
bpm_stored = (tempo - 25) & 0xFF
|
bpm_stored = (tempo - 25) & 0xFF
|
||||||
vprint(f" initial speed={speed}, tempo(BPM)={tempo}")
|
vprint(f" [{song_label}] initial speed={speed}, tempo(BPM)={tempo}")
|
||||||
|
|
||||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
n_patterns = mod['n_patterns']
|
||||||
|
|
||||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
# Cue list and pos→cue mapping, skipping orders that aren't valid pattern refs.
|
||||||
|
cue_list = []
|
||||||
|
pos_to_cue = {}
|
||||||
|
for pos in positions:
|
||||||
|
order = order_list[pos]
|
||||||
|
if order >= n_patterns:
|
||||||
|
continue
|
||||||
|
pos_to_cue[pos] = len(cue_list)
|
||||||
|
cue_list.append(order)
|
||||||
|
|
||||||
|
# Densely renumber the patterns this song uses.
|
||||||
|
used_ordered = []
|
||||||
|
seen = set()
|
||||||
|
for src_pat in cue_list:
|
||||||
|
if src_pat not in seen:
|
||||||
|
used_ordered.append(src_pat)
|
||||||
|
seen.add(src_pat)
|
||||||
|
pat_idx_remap = {src: i for i, src in enumerate(used_ordered)}
|
||||||
|
P_used = len(used_ordered)
|
||||||
|
|
||||||
|
if P_used * n_channels > NUM_PATTERNS_MAX:
|
||||||
|
sys.exit(f"error: [{song_label}] {P_used} patterns × {n_channels} channels = "
|
||||||
|
f"{P_used*n_channels} > {NUM_PATTERNS_MAX} Taud pattern limit.")
|
||||||
|
|
||||||
|
# Bxx remap on the patterns this song actually emits.
|
||||||
|
crossings = 0
|
||||||
|
for src_pat in used_ordered:
|
||||||
|
if src_pat >= len(patterns): continue
|
||||||
|
grid = patterns[src_pat]
|
||||||
|
for ch in range(min(n_channels, len(grid))):
|
||||||
|
for row in grid[ch]:
|
||||||
|
if row.effect == 0xB:
|
||||||
|
if row.effect_arg in pos_to_cue:
|
||||||
|
row.effect_arg = pos_to_cue[row.effect_arg] & 0xFF
|
||||||
|
else:
|
||||||
|
crossings += 1
|
||||||
|
row.effect_arg = 0
|
||||||
|
if crossings:
|
||||||
|
vprint(f" warning: [{song_label}]: {crossings} Bxx target(s) cross "
|
||||||
|
f"subsong boundary; clamped to cue 0")
|
||||||
|
|
||||||
vprint(" building pattern bin…")
|
|
||||||
inst_vols = {
|
|
||||||
i + 1: min(s.volume, 0x3F)
|
|
||||||
for i, s in enumerate(samples)
|
|
||||||
if s.sample_data
|
|
||||||
}
|
|
||||||
pat_bin = bytearray()
|
pat_bin = bytearray()
|
||||||
for pi in range(n_patterns):
|
for src_pat in used_ordered:
|
||||||
grid = patterns[pi]
|
grid = patterns[src_pat]
|
||||||
for ch in range(n_channels):
|
for ch in range(n_channels):
|
||||||
default_pan = _default_channel_pan(ch)
|
default_pan = _default_channel_pan(ch)
|
||||||
pat_bin += build_pattern(grid, ch, default_pan, inst_vols)
|
pat_bin += build_pattern(grid, ch, default_pan, inst_vols)
|
||||||
assert len(pat_bin) == n_patterns * n_channels * PATTERN_BYTES
|
|
||||||
|
|
||||||
# Rescale TOP_O sample-offset args if samples were globally downsampled.
|
|
||||||
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||||
|
|
||||||
vprint(" deduplicating patterns…")
|
orig_count = P_used * n_channels
|
||||||
orig_count = n_patterns * n_channels
|
|
||||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique "
|
vprint(f" [{song_label}] patterns: {orig_count} → {num_taud_pats} unique "
|
||||||
f"({orig_count - num_taud_pats} deduplicated)")
|
f"({orig_count - num_taud_pats} deduplicated)")
|
||||||
|
|
||||||
vprint(" building cue sheet…")
|
sheet = bytearray(NUM_CUES * CUE_SIZE)
|
||||||
cue_sheet = build_cue_sheet(order_list, n_patterns, n_channels, pat_remap)
|
for c in range(NUM_CUES):
|
||||||
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
|
sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0)
|
||||||
|
|
||||||
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
last_active = -1
|
||||||
cue_comp = compress_blob(bytes(cue_sheet), "cue sheet")
|
for cue_idx, src_pat in enumerate(cue_list):
|
||||||
|
if cue_idx >= NUM_CUES: break
|
||||||
|
new_pat_idx = pat_idx_remap[src_pat]
|
||||||
|
orig_pats = [new_pat_idx * n_channels + v for v in range(n_channels)]
|
||||||
|
sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(
|
||||||
|
[pat_remap[p] for p in orig_pats], 0)
|
||||||
|
last_active = cue_idx
|
||||||
|
|
||||||
|
if last_active >= 0:
|
||||||
|
sheet[last_active * CUE_SIZE + 30] = 0x01
|
||||||
|
else:
|
||||||
|
sheet[30] = 0x01
|
||||||
|
|
||||||
|
pat_comp = compress_blob(bytes(pat_bin), f"[{song_label}] pattern bin")
|
||||||
|
cue_comp = compress_blob(bytes(sheet), f"[{song_label}] cue sheet")
|
||||||
|
|
||||||
# ProTracker is Amiga-period-based by definition, so we set ff=1 (bits 0-1) so
|
|
||||||
# the engine applies coarse pitch slides in period space (recovers PT's
|
|
||||||
# characteristic non-linear pitch character). Pan law is fixed to the
|
|
||||||
# equal-energy engine-wide. PT has no instrument-level fadeout, so every Taud
|
|
||||||
# instrument carries fadeout=0 ("no fade") — notes retire on sample-end or
|
|
||||||
# pattern note-cut instead, which matches PT semantics.
|
|
||||||
flags_byte = GLOBAL_FLAGS_AMIGA_FREQ | GLOBAL_FLAGS_A500_INTP
|
flags_byte = GLOBAL_FLAGS_AMIGA_FREQ | GLOBAL_FLAGS_A500_INTP
|
||||||
song_table = encode_song_entry(
|
entry_kwargs = dict(
|
||||||
song_offset=song_offset,
|
|
||||||
num_voices=n_channels,
|
num_voices=n_channels,
|
||||||
num_patterns=num_taud_pats,
|
num_patterns=num_taud_pats,
|
||||||
bpm_stored=bpm_stored,
|
bpm_stored=bpm_stored,
|
||||||
@@ -807,7 +842,82 @@ def assemble_taud(mod: dict, with_project_data: bool = True) -> bytes:
|
|||||||
global_vol=0xFF,
|
global_vol=0xFF,
|
||||||
mixing_vol=180,
|
mixing_vol=180,
|
||||||
)
|
)
|
||||||
assert len(song_table) == TAUD_SONG_ENTRY
|
return pat_comp, cue_comp, entry_kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def assemble_taud(mod: dict, with_project_data: bool = True) -> bytes:
|
||||||
|
samples = mod['samples']
|
||||||
|
patterns = mod['patterns']
|
||||||
|
order_list = mod['order_list']
|
||||||
|
n_channels = mod['n_channels']
|
||||||
|
n_patterns = mod['n_patterns']
|
||||||
|
|
||||||
|
if n_channels > NUM_VOICES:
|
||||||
|
vprint(f" warning: MOD has {n_channels} channels; truncating to {NUM_VOICES}")
|
||||||
|
n_channels = NUM_VOICES
|
||||||
|
vprint(f" channels: {n_channels}, mod patterns: {n_patterns}")
|
||||||
|
|
||||||
|
# Fold Cxx into row.vol_set so the volume column carries explicit set-volume.
|
||||||
|
# This is non-stateful (doesn't depend on order list) so it runs once on the
|
||||||
|
# shared template; per-song deepcopies inherit the folded form.
|
||||||
|
for grid in patterns:
|
||||||
|
for ch in range(min(n_channels, len(grid))):
|
||||||
|
for row in grid[ch]:
|
||||||
|
if row.effect == 0xC:
|
||||||
|
row.vol_set = min(row.effect_arg, 0x3F)
|
||||||
|
row.effect = 0
|
||||||
|
row.effect_arg = 0
|
||||||
|
|
||||||
|
vprint(" building sample/instrument bin…")
|
||||||
|
sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(samples)
|
||||||
|
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||||
|
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
||||||
|
comp_size = len(compressed)
|
||||||
|
|
||||||
|
inst_vols = {
|
||||||
|
i + 1: min(s.volume, 0x3F)
|
||||||
|
for i, s in enumerate(samples)
|
||||||
|
if s.sample_data
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Detect subsongs ──────────────────────────────────────────────────────
|
||||||
|
# MOD shares IT/S3M's 0xFF-end / 0xFE-skip convention; orders ≥ n_patterns
|
||||||
|
# are also unplayable and treated as skips by the player (build_cue_sheet).
|
||||||
|
skip_set = set([0xFE]) | set(range(n_patterns, 256))
|
||||||
|
subsongs = detect_subsongs(order_list,
|
||||||
|
_per_pattern_bxx_mod(patterns, n_channels),
|
||||||
|
terminators=(0xFF,),
|
||||||
|
skip_marker=skip_set)
|
||||||
|
if not subsongs:
|
||||||
|
vprint(" warning: no traversable orders in source; emitting empty song")
|
||||||
|
subsongs = [{'entry': 0, 'positions': []}]
|
||||||
|
n_songs = len(subsongs)
|
||||||
|
if n_songs == 1:
|
||||||
|
vprint(f" detected 1 song ({len(subsongs[0]['positions'])} orders)")
|
||||||
|
else:
|
||||||
|
vprint(f" detected {n_songs} subsongs:")
|
||||||
|
for i, ss in enumerate(subsongs):
|
||||||
|
vprint(f" song {i}: entry@{ss['entry']}, {len(ss['positions'])} orders")
|
||||||
|
|
||||||
|
# ── Build per-song payloads ──────────────────────────────────────────────
|
||||||
|
song_payloads = []
|
||||||
|
for i, ss in enumerate(subsongs):
|
||||||
|
label = f"song {i}" if n_songs > 1 else "song"
|
||||||
|
song_payloads.append(_build_song_payload_mod(
|
||||||
|
mod, patterns, ss['positions'], sample_ratio, inst_vols,
|
||||||
|
n_channels, song_label=label))
|
||||||
|
|
||||||
|
# ── Layout offsets and song table ────────────────────────────────────────
|
||||||
|
song_table_off = TAUD_HEADER_SIZE + comp_size
|
||||||
|
first_song_off = song_table_off + TAUD_SONG_ENTRY * n_songs
|
||||||
|
|
||||||
|
song_table = bytearray()
|
||||||
|
cur_off = first_song_off
|
||||||
|
for pat_comp, cue_comp, entry_kwargs in song_payloads:
|
||||||
|
entry = encode_song_entry(song_offset=cur_off, **entry_kwargs)
|
||||||
|
assert len(entry) == TAUD_SONG_ENTRY
|
||||||
|
song_table += entry
|
||||||
|
cur_off += len(pat_comp) + len(cue_comp)
|
||||||
|
|
||||||
# Project Data (optional). MOD samples *are* its instruments — the names
|
# Project Data (optional). MOD samples *are* its instruments — the names
|
||||||
# populate both INam and SNam (1-based; slot 0 empty).
|
# populate both INam and SNam (1-based; slot 0 empty).
|
||||||
@@ -821,20 +931,28 @@ def assemble_taud(mod: dict, with_project_data: bool = True) -> bytes:
|
|||||||
sample_names=names,
|
sample_names=names,
|
||||||
)
|
)
|
||||||
if proj_data:
|
if proj_data:
|
||||||
proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \
|
proj_off = cur_off
|
||||||
+ len(pat_comp) + len(cue_comp)
|
|
||||||
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
||||||
|
|
||||||
|
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||||
header = (
|
header = (
|
||||||
TAUD_MAGIC +
|
TAUD_MAGIC +
|
||||||
bytes([TAUD_VERSION, 1]) +
|
bytes([TAUD_VERSION, n_songs & 0xFF]) +
|
||||||
struct.pack('<I', comp_size) +
|
struct.pack('<I', comp_size) +
|
||||||
struct.pack('<I', proj_off) +
|
struct.pack('<I', proj_off) +
|
||||||
sig
|
sig
|
||||||
)
|
)
|
||||||
assert len(header) == TAUD_HEADER_SIZE
|
assert len(header) == TAUD_HEADER_SIZE
|
||||||
|
|
||||||
return header + compressed + song_table + pat_comp + cue_comp + proj_data
|
out = bytearray()
|
||||||
|
out += header
|
||||||
|
out += compressed
|
||||||
|
out += song_table
|
||||||
|
for pat_comp, cue_comp, _ in song_payloads:
|
||||||
|
out += pat_comp
|
||||||
|
out += cue_comp
|
||||||
|
out += proj_data
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
236
mon2taud.py
236
mon2taud.py
@@ -22,6 +22,7 @@ Limits: numVoices ≤ 20, numPatterns × numVoices ≤ 4095.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import copy
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ from taud_common import (
|
|||||||
SEL_SET, SEL_FINE,
|
SEL_SET, SEL_FINE,
|
||||||
J_SEMI_TABLE,
|
J_SEMI_TABLE,
|
||||||
encode_cue, deduplicate_patterns, encode_song_entry, compress_blob,
|
encode_cue, deduplicate_patterns, encode_song_entry, compress_blob,
|
||||||
build_project_data,
|
build_project_data, detect_subsongs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -304,6 +305,130 @@ def find_initial_speed(patterns: list, order_list: list, num_voices: int) -> int
|
|||||||
|
|
||||||
# ── Top-level assembly ───────────────────────────────────────────────────────
|
# ── Top-level assembly ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _per_pattern_bxx_mon(patterns: list, num_voices: int):
|
||||||
|
"""Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough)
|
||||||
|
for `detect_subsongs`. Monotone effect index 5 is 'B' (position jump);
|
||||||
|
arg is 6 bits (0..63). Patterns are 64 rows × num_voices. `grid[v][r]`.
|
||||||
|
"""
|
||||||
|
def fn(pat_idx: int):
|
||||||
|
if pat_idx < 0 or pat_idx >= len(patterns):
|
||||||
|
return set(), False
|
||||||
|
grid = patterns[pat_idx]
|
||||||
|
targets = set()
|
||||||
|
last_row_has_b = False
|
||||||
|
for v in range(min(num_voices, len(grid))):
|
||||||
|
v_rows = grid[v]
|
||||||
|
for r in range(min(MON_PATTERN_ROWS, len(v_rows))):
|
||||||
|
cell = v_rows[r]
|
||||||
|
if cell.effect == 5:
|
||||||
|
targets.add(cell.effect_arg & 0x3F)
|
||||||
|
if r == MON_PATTERN_ROWS - 1:
|
||||||
|
last_row_has_b = True
|
||||||
|
return targets, last_row_has_b
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
def _build_song_payload_mon(mon: dict, patterns_template: list,
|
||||||
|
positions: list, num_voices: int,
|
||||||
|
*, song_label: str = 'song') -> tuple:
|
||||||
|
"""Build pattern bin + cue sheet + song-entry kwargs for one Monotone
|
||||||
|
subsong. Mutates a deepcopy of the patterns to remap Bxx targets to
|
||||||
|
per-song cue indices.
|
||||||
|
"""
|
||||||
|
patterns = copy.deepcopy(patterns_template)
|
||||||
|
order_list = mon['order_list']
|
||||||
|
n_patterns = mon['n_patterns']
|
||||||
|
virtual_orders = [order_list[pos] for pos in positions]
|
||||||
|
|
||||||
|
speed = find_initial_speed(patterns, virtual_orders, num_voices)
|
||||||
|
vprint(f" [{song_label}] initial speed (ticks/row): {speed}")
|
||||||
|
|
||||||
|
cue_list = []
|
||||||
|
pos_to_cue = {}
|
||||||
|
for pos in positions:
|
||||||
|
order = order_list[pos]
|
||||||
|
if order >= n_patterns:
|
||||||
|
continue
|
||||||
|
pos_to_cue[pos] = len(cue_list)
|
||||||
|
cue_list.append(order)
|
||||||
|
|
||||||
|
used_ordered = []
|
||||||
|
seen = set()
|
||||||
|
for src_pat in cue_list:
|
||||||
|
if src_pat not in seen:
|
||||||
|
used_ordered.append(src_pat)
|
||||||
|
seen.add(src_pat)
|
||||||
|
pat_idx_remap = {src: i for i, src in enumerate(used_ordered)}
|
||||||
|
P_used = len(used_ordered)
|
||||||
|
|
||||||
|
if P_used * num_voices > NUM_PATTERNS_MAX:
|
||||||
|
sys.exit(f"error: [{song_label}] {P_used} patterns × {num_voices} voices = "
|
||||||
|
f"{P_used*num_voices} > {NUM_PATTERNS_MAX} Taud pattern limit.")
|
||||||
|
|
||||||
|
# Bxx remap: source position → cue index. Cross-song clamps to cue 0.
|
||||||
|
crossings = 0
|
||||||
|
for src_pat in used_ordered:
|
||||||
|
if src_pat >= len(patterns): continue
|
||||||
|
grid = patterns[src_pat]
|
||||||
|
for v in range(min(num_voices, len(grid))):
|
||||||
|
for row in grid[v]:
|
||||||
|
if row.effect == 5:
|
||||||
|
if row.effect_arg in pos_to_cue:
|
||||||
|
row.effect_arg = pos_to_cue[row.effect_arg] & 0x3F
|
||||||
|
else:
|
||||||
|
crossings += 1
|
||||||
|
row.effect_arg = 0
|
||||||
|
if crossings:
|
||||||
|
vprint(f" warning: [{song_label}]: {crossings} Bxx target(s) cross "
|
||||||
|
f"subsong boundary; clamped to cue 0")
|
||||||
|
|
||||||
|
pat_bin = bytearray()
|
||||||
|
for src_pat in used_ordered:
|
||||||
|
grid = patterns[src_pat]
|
||||||
|
for v in range(num_voices):
|
||||||
|
pat_bin += build_taud_pattern(grid, v)
|
||||||
|
|
||||||
|
orig_count = P_used * num_voices
|
||||||
|
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
|
||||||
|
vprint(f" [{song_label}] patterns: {orig_count} → {num_taud_pats} unique "
|
||||||
|
f"({orig_count - num_taud_pats} deduplicated)")
|
||||||
|
|
||||||
|
sheet = bytearray(NUM_CUES * CUE_SIZE)
|
||||||
|
for c in range(NUM_CUES):
|
||||||
|
sheet[c*CUE_SIZE:(c+1)*CUE_SIZE] = encode_cue([], 0)
|
||||||
|
|
||||||
|
last_active = -1
|
||||||
|
for cue_idx, src_pat in enumerate(cue_list):
|
||||||
|
if cue_idx >= NUM_CUES: break
|
||||||
|
new_pat_idx = pat_idx_remap[src_pat]
|
||||||
|
orig_pats = [new_pat_idx * num_voices + v for v in range(num_voices)]
|
||||||
|
sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(
|
||||||
|
[pat_remap[p] for p in orig_pats], 0)
|
||||||
|
last_active = cue_idx
|
||||||
|
if last_active >= 0:
|
||||||
|
sheet[last_active * CUE_SIZE + 30] = 0x01
|
||||||
|
|
||||||
|
pat_comp = compress_blob(bytes(pat_bin), f"[{song_label}] pattern bin")
|
||||||
|
cue_comp = compress_blob(bytes(sheet), f"[{song_label}] cue sheet")
|
||||||
|
|
||||||
|
flags_byte = GLOBAL_FLAGS_LINEAR_FREQ | GLOBAL_FLAGS_NO_INTERPOLATION
|
||||||
|
bpm_stored = 150 - 25
|
||||||
|
entry_kwargs = dict(
|
||||||
|
num_voices=num_voices,
|
||||||
|
num_patterns=num_taud_pats,
|
||||||
|
bpm_stored=bpm_stored,
|
||||||
|
tick_rate=speed,
|
||||||
|
base_note=0xA000,
|
||||||
|
base_freq=SQUARE_C2SPD,
|
||||||
|
flags_byte=flags_byte,
|
||||||
|
pat_bin_comp_size=len(pat_comp),
|
||||||
|
cue_sheet_comp_size=len(cue_comp),
|
||||||
|
global_vol=0xFF,
|
||||||
|
mixing_vol=round(180 / num_voices),
|
||||||
|
)
|
||||||
|
return pat_comp, cue_comp, entry_kwargs
|
||||||
|
|
||||||
|
|
||||||
def assemble_taud(mon: dict, with_project_data: bool = True) -> bytes:
|
def assemble_taud(mon: dict, with_project_data: bool = True) -> bytes:
|
||||||
num_voices = mon['num_voices']
|
num_voices = mon['num_voices']
|
||||||
patterns = mon['patterns']
|
patterns = mon['patterns']
|
||||||
@@ -313,18 +438,7 @@ def assemble_taud(mon: dict, with_project_data: bool = True) -> bytes:
|
|||||||
if num_voices > NUM_VOICES:
|
if num_voices > NUM_VOICES:
|
||||||
vprint(f" warning: {num_voices} voices > {NUM_VOICES}; truncating")
|
vprint(f" warning: {num_voices} voices > {NUM_VOICES}; truncating")
|
||||||
num_voices = NUM_VOICES
|
num_voices = NUM_VOICES
|
||||||
|
vprint(f" voices: {num_voices}, mon patterns: {n_patterns}")
|
||||||
if n_patterns * num_voices > NUM_PATTERNS_MAX:
|
|
||||||
sys.exit(
|
|
||||||
f"error: {n_patterns} patterns × {num_voices} voices = "
|
|
||||||
f"{n_patterns*num_voices} > {NUM_PATTERNS_MAX} Taud limit"
|
|
||||||
)
|
|
||||||
|
|
||||||
vprint(f" voices: {num_voices}, mon patterns: {n_patterns}, "
|
|
||||||
f"taud patterns: {n_patterns * num_voices}")
|
|
||||||
|
|
||||||
speed = find_initial_speed(patterns, order_list, num_voices)
|
|
||||||
vprint(f" initial speed (ticks/row): {speed}")
|
|
||||||
|
|
||||||
vprint(" building sample/instrument bin…")
|
vprint(" building sample/instrument bin…")
|
||||||
sampleinst_raw = build_sample_inst_bin()
|
sampleinst_raw = build_sample_inst_bin()
|
||||||
@@ -332,53 +446,44 @@ def assemble_taud(mon: dict, with_project_data: bool = True) -> bytes:
|
|||||||
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
||||||
comp_size = len(compressed)
|
comp_size = len(compressed)
|
||||||
|
|
||||||
vprint(" building pattern bin…")
|
# ── Detect subsongs ──────────────────────────────────────────────────────
|
||||||
pat_bin = bytearray()
|
# Monotone strips 0xFF (skip) markers during parse, so the order list is
|
||||||
for pi in range(n_patterns):
|
# already a clean sequence of pattern indices. No terminator/skip values
|
||||||
grid = patterns[pi]
|
# to feed the detector — subsongs only emerge from the Bxx graph.
|
||||||
for v in range(num_voices):
|
skip_set = set(range(n_patterns, 256)) # invalid pattern refs → skip
|
||||||
pat_bin += build_taud_pattern(grid, v)
|
subsongs = detect_subsongs(order_list,
|
||||||
assert len(pat_bin) == n_patterns * num_voices * PATTERN_BYTES
|
_per_pattern_bxx_mon(patterns, num_voices),
|
||||||
|
terminators=(),
|
||||||
|
skip_marker=skip_set)
|
||||||
|
if not subsongs:
|
||||||
|
vprint(" warning: no traversable orders in source; emitting empty song")
|
||||||
|
subsongs = [{'entry': 0, 'positions': []}]
|
||||||
|
n_songs = len(subsongs)
|
||||||
|
if n_songs == 1:
|
||||||
|
vprint(f" detected 1 song ({len(subsongs[0]['positions'])} orders)")
|
||||||
|
else:
|
||||||
|
vprint(f" detected {n_songs} subsongs:")
|
||||||
|
for i, ss in enumerate(subsongs):
|
||||||
|
vprint(f" song {i}: entry@{ss['entry']}, {len(ss['positions'])} orders")
|
||||||
|
|
||||||
vprint(" deduplicating patterns…")
|
# ── Build per-song payloads ──────────────────────────────────────────────
|
||||||
orig_count = n_patterns * num_voices
|
song_payloads = []
|
||||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
|
for i, ss in enumerate(subsongs):
|
||||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique "
|
label = f"song {i}" if n_songs > 1 else "song"
|
||||||
f"({orig_count - num_taud_pats} deduplicated)")
|
song_payloads.append(_build_song_payload_mon(
|
||||||
|
mon, patterns, ss['positions'], num_voices, song_label=label))
|
||||||
|
|
||||||
vprint(" building cue sheet…")
|
# ── Layout offsets and song table ────────────────────────────────────────
|
||||||
cue_sheet = build_cue_sheet(order_list, num_voices, pat_remap)
|
song_table_off = TAUD_HEADER_SIZE + comp_size
|
||||||
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
|
first_song_off = song_table_off + TAUD_SONG_ENTRY * n_songs
|
||||||
|
|
||||||
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
song_table = bytearray()
|
||||||
cue_comp = compress_blob(bytes(cue_sheet), "cue sheet")
|
cur_off = first_song_off
|
||||||
|
for pat_comp, cue_comp, entry_kwargs in song_payloads:
|
||||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
entry = encode_song_entry(song_offset=cur_off, **entry_kwargs)
|
||||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
assert len(entry) == TAUD_SONG_ENTRY
|
||||||
|
song_table += entry
|
||||||
# BPM 150 + ticks=mon_speed → row rate = 60/mon_speed (matches Monotone).
|
cur_off += len(pat_comp) + len(cue_comp)
|
||||||
bpm_stored = 150 - 25
|
|
||||||
# Linear-frequency tone mode (ff=2) so 1xx/2xx/3xx Hz/tick semantics survive verbatim.
|
|
||||||
# Pan law is fixed engine-wide to the equal-energy (no flag). Monotone has no
|
|
||||||
# instrument-level fadeout, so every Taud instrument carries fadeout=0 ("no fade") —
|
|
||||||
# notes retire on sample-end or pattern note-cut instead.
|
|
||||||
flags_byte = GLOBAL_FLAGS_LINEAR_FREQ | GLOBAL_FLAGS_NO_INTERPOLATION
|
|
||||||
|
|
||||||
song_table = encode_song_entry(
|
|
||||||
song_offset = song_offset,
|
|
||||||
num_voices = num_voices,
|
|
||||||
num_patterns = num_taud_pats,
|
|
||||||
bpm_stored = bpm_stored,
|
|
||||||
tick_rate = speed,
|
|
||||||
base_note = 0xA000,
|
|
||||||
base_freq = SQUARE_C2SPD,
|
|
||||||
flags_byte = flags_byte,
|
|
||||||
pat_bin_comp_size = len(pat_comp),
|
|
||||||
cue_sheet_comp_size = len(cue_comp),
|
|
||||||
global_vol = 0xFF,
|
|
||||||
mixing_vol = round(180 / num_voices),
|
|
||||||
)
|
|
||||||
assert len(song_table) == TAUD_SONG_ENTRY
|
|
||||||
|
|
||||||
# Project Data (optional). Monotone has no title, no user instruments and
|
# Project Data (optional). Monotone has no title, no user instruments and
|
||||||
# no per-sample names, but we still emit one identifying entry so the
|
# no per-sample names, but we still emit one identifying entry so the
|
||||||
@@ -391,21 +496,28 @@ def assemble_taud(mon: dict, with_project_data: bool = True) -> bytes:
|
|||||||
sample_names=['', 'PC speaker square'],
|
sample_names=['', 'PC speaker square'],
|
||||||
)
|
)
|
||||||
if proj_data:
|
if proj_data:
|
||||||
proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \
|
proj_off = cur_off
|
||||||
+ len(pat_comp) + len(cue_comp)
|
|
||||||
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
||||||
|
|
||||||
# Header: magic, version, num_songs=1, comp_size of sample+inst, projOff, sig.
|
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||||
header = (
|
header = (
|
||||||
TAUD_MAGIC
|
TAUD_MAGIC
|
||||||
+ bytes([TAUD_VERSION, 1])
|
+ bytes([TAUD_VERSION, n_songs & 0xFF])
|
||||||
+ struct.pack('<I', comp_size)
|
+ struct.pack('<I', comp_size)
|
||||||
+ struct.pack('<I', proj_off)
|
+ struct.pack('<I', proj_off)
|
||||||
+ sig
|
+ sig
|
||||||
)
|
)
|
||||||
assert len(header) == TAUD_HEADER_SIZE
|
assert len(header) == TAUD_HEADER_SIZE
|
||||||
|
|
||||||
return header + compressed + song_table + pat_comp + cue_comp + proj_data
|
out = bytearray()
|
||||||
|
out += header
|
||||||
|
out += compressed
|
||||||
|
out += song_table
|
||||||
|
for pat_comp, cue_comp, _ in song_payloads:
|
||||||
|
out += pat_comp
|
||||||
|
out += cue_comp
|
||||||
|
out += proj_data
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
280
s3m2taud.py
280
s3m2taud.py
@@ -25,6 +25,7 @@ Effect support:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import copy
|
||||||
import math
|
import math
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
@@ -44,7 +45,7 @@ from taud_common import (
|
|||||||
J_SEMI_TABLE,
|
J_SEMI_TABLE,
|
||||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||||
normalise_sample, encode_song_entry, compress_blob,
|
normalise_sample, encode_song_entry, compress_blob,
|
||||||
build_project_data,
|
build_project_data, detect_subsongs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -724,101 +725,146 @@ def find_initial_bpm_speed(patterns: list, order_list: list,
|
|||||||
return speed, tempo
|
return speed, tempo
|
||||||
|
|
||||||
|
|
||||||
def assemble_taud(h: S3MHeader, instruments: list, patterns: list,
|
def _per_pattern_bxx_s3m(patterns: list):
|
||||||
with_project_data: bool = True) -> bytes:
|
"""Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough)
|
||||||
# Determine active channels (bit7 clear = enabled)
|
for `detect_subsongs`. `kills_fallthrough` is True iff the pattern carries
|
||||||
active_channels = [i for i, cs in enumerate(h.channel_settings)
|
a Bxx on its absolute last row (the unconditional terminating-jump idiom).
|
||||||
if i < 32 and not (cs & 0x80)][:NUM_VOICES]
|
S3M patterns are always 64 rows.
|
||||||
C = len(active_channels)
|
"""
|
||||||
P = len(patterns)
|
def fn(pat_idx: int):
|
||||||
|
if pat_idx < 0 or pat_idx >= len(patterns):
|
||||||
|
return set(), False
|
||||||
|
grid = patterns[pat_idx]
|
||||||
|
targets = set()
|
||||||
|
last_row_has_b = False
|
||||||
|
for ch in range(min(32, len(grid))):
|
||||||
|
ch_rows = grid[ch]
|
||||||
|
for r in range(min(PATTERN_ROWS, len(ch_rows))):
|
||||||
|
cell = ch_rows[r]
|
||||||
|
if getattr(cell, 'effect', 0) == EFF_B:
|
||||||
|
targets.add(cell.effect_arg)
|
||||||
|
if r == PATTERN_ROWS - 1:
|
||||||
|
last_row_has_b = True
|
||||||
|
return targets, last_row_has_b
|
||||||
|
return fn
|
||||||
|
|
||||||
if P * C > NUM_PATTERNS_MAX:
|
|
||||||
sys.exit(
|
|
||||||
f"error: {P} S3M patterns × {C} channels = {P*C} > {NUM_PATTERNS_MAX} Taud pattern limit.\n"
|
|
||||||
f" Reduce the S3M to ≤ {NUM_PATTERNS_MAX // max(C,1)} patterns, or mute "
|
|
||||||
f"channels to bring active count below {NUM_PATTERNS_MAX // max(P,1) + 1}."
|
|
||||||
)
|
|
||||||
|
|
||||||
vprint(f" channels: {C}, s3m patterns: {P}, taud patterns: {P*C}")
|
def _build_song_payload_s3m(h: S3MHeader, patterns_template: list,
|
||||||
|
positions: list, sample_ratio: dict,
|
||||||
|
inst_vols: dict, active_channels: list,
|
||||||
|
*, song_label: str = 'song') -> tuple:
|
||||||
|
"""Build pattern bin + cue sheet + song-entry kwargs for one subsong.
|
||||||
|
|
||||||
# Resolve ST3 shared-memory recalls (D/E/F/I/J/K/L/Q/R/S with $00 arg)
|
Returns (pat_comp, cue_comp, entry_kwargs). The caller fills in
|
||||||
# before any per-row encoding, so cohort-aware Taud effects see explicit
|
`song_offset` from the global layout. `patterns_template` is deep-copied
|
||||||
# arguments. Mutates patterns in place.
|
so per-song stateful walks (recall resolution, late-note-delay
|
||||||
vprint(" resolving ST3 shared-memory recalls…")
|
relocation, Bxx remap) don't leak into the next subsong.
|
||||||
resolve_st3_recalls(patterns, h.order_list, 32)
|
"""
|
||||||
warn_st3_quirks(patterns, h.order_list, 32)
|
pats = copy.deepcopy(patterns_template)
|
||||||
|
virtual_orders = [h.order_list[pos] for pos in positions]
|
||||||
|
|
||||||
init_speed, _ = find_initial_bpm_speed(patterns, h.order_list,
|
vprint(f" [{song_label}] resolving ST3 shared-memory recalls…")
|
||||||
|
resolve_st3_recalls(pats, virtual_orders, 32)
|
||||||
|
warn_st3_quirks(pats, virtual_orders, 32)
|
||||||
|
|
||||||
|
init_speed, _ = find_initial_bpm_speed(pats, virtual_orders,
|
||||||
h.initial_speed, h.initial_tempo)
|
h.initial_speed, h.initial_tempo)
|
||||||
relocate_late_note_delays(patterns, h.order_list, 32, init_speed)
|
relocate_late_note_delays(pats, virtual_orders, 32, init_speed)
|
||||||
|
|
||||||
# Build sample+instrument bin
|
speed, tempo = find_initial_bpm_speed(pats, virtual_orders,
|
||||||
vprint(" building sample/instrument bin…")
|
|
||||||
sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(instruments)
|
|
||||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
|
||||||
|
|
||||||
# Compress
|
|
||||||
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
|
||||||
comp_size = len(compressed)
|
|
||||||
|
|
||||||
# Initial BPM / speed
|
|
||||||
speed, tempo = find_initial_bpm_speed(patterns, h.order_list,
|
|
||||||
h.initial_speed, h.initial_tempo)
|
h.initial_speed, h.initial_tempo)
|
||||||
tempo = max(25, min(280, tempo))
|
tempo = max(25, min(280, tempo))
|
||||||
bpm_stored = (tempo - 25) & 0xFF
|
bpm_stored = (tempo - 25) & 0xFF
|
||||||
vprint(f" initial speed={speed}, tempo(BPM)={tempo}")
|
vprint(f" [{song_label}] initial speed={speed}, tempo(BPM)={tempo}")
|
||||||
|
|
||||||
# Song offset = header(32) + compressed + song_table(8)
|
# Cue list (source pattern indices) and pos→cue mapping. Skip orders that
|
||||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
# already terminate (S3M_ORDER_END) or point past the pattern table.
|
||||||
num_taud_pats = P * C
|
cue_list = []
|
||||||
|
pos_to_cue = {}
|
||||||
|
for pos in positions:
|
||||||
|
order = h.order_list[pos]
|
||||||
|
if order >= S3M_ORDER_END or order >= len(pats):
|
||||||
|
continue
|
||||||
|
pos_to_cue[pos] = len(cue_list)
|
||||||
|
cue_list.append(order)
|
||||||
|
|
||||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
# Densely renumber the patterns this song actually emits.
|
||||||
|
used_ordered = []
|
||||||
|
seen = set()
|
||||||
|
for src_pat in cue_list:
|
||||||
|
if src_pat not in seen:
|
||||||
|
used_ordered.append(src_pat)
|
||||||
|
seen.add(src_pat)
|
||||||
|
pat_idx_remap = {src: i for i, src in enumerate(used_ordered)}
|
||||||
|
P_used = len(used_ordered)
|
||||||
|
|
||||||
# Pattern bin: for each s3m pattern, for each active channel, 512 bytes
|
C = len(active_channels)
|
||||||
vprint(" building pattern bin…")
|
if P_used * C > NUM_PATTERNS_MAX:
|
||||||
default_pans = [_default_channel_pan(h.channel_settings[ch]) for ch in active_channels]
|
sys.exit(
|
||||||
# 1-based inst index → default volume (0..63) for note-trigger vol injection.
|
f"error: [{song_label}] {P_used} patterns × {C} channels = "
|
||||||
inst_vols = {
|
f"{P_used*C} > {NUM_PATTERNS_MAX} Taud pattern limit."
|
||||||
i + 1: min(inst.volume, 0x3F)
|
)
|
||||||
for i, inst in enumerate(instruments)
|
|
||||||
if inst is not None and inst.itype == S3M_TYPE_PCM
|
# Bxx remap: target source-position → cue-index. Cross-subsong jumps
|
||||||
}
|
# clamp to cue 0 (loop the subsong rather than jump out of bounds). Walk
|
||||||
|
# only the patterns this song actually emits.
|
||||||
|
crossings = 0
|
||||||
|
for src_pat in used_ordered:
|
||||||
|
if src_pat >= len(pats): continue
|
||||||
|
grid = pats[src_pat]
|
||||||
|
for ch in range(min(32, len(grid))):
|
||||||
|
for row in grid[ch]:
|
||||||
|
if row.effect == EFF_B:
|
||||||
|
if row.effect_arg in pos_to_cue:
|
||||||
|
row.effect_arg = pos_to_cue[row.effect_arg] & 0xFF
|
||||||
|
else:
|
||||||
|
crossings += 1
|
||||||
|
row.effect_arg = 0
|
||||||
|
if crossings:
|
||||||
|
vprint(f" warning: [{song_label}]: {crossings} Bxx target(s) cross "
|
||||||
|
f"subsong boundary; clamped to cue 0")
|
||||||
|
|
||||||
|
# Pattern bin: emit only patterns this song uses (densely indexed).
|
||||||
|
default_pans = [_default_channel_pan(h.channel_settings[ch])
|
||||||
|
for ch in active_channels]
|
||||||
pat_bin = bytearray()
|
pat_bin = bytearray()
|
||||||
for pi in range(P):
|
for src_pat in used_ordered:
|
||||||
grid = patterns[pi]
|
grid = pats[src_pat]
|
||||||
for vi, ch in enumerate(active_channels):
|
for vi, ch in enumerate(active_channels):
|
||||||
pat_bin += build_pattern(grid, ch, default_pans[vi], h.linear_slides,
|
pat_bin += build_pattern(grid, ch, default_pans[vi],
|
||||||
inst_vols, amiga_mode=not h.linear_slides)
|
h.linear_slides, inst_vols,
|
||||||
assert len(pat_bin) == num_taud_pats * PATTERN_BYTES
|
amiga_mode=not h.linear_slides)
|
||||||
|
|
||||||
# Rescale TOP_O sample-offset args if samples were globally downsampled.
|
|
||||||
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||||
|
orig_count = P_used * C
|
||||||
# Deduplicate identical patterns
|
|
||||||
vprint(" deduplicating patterns…")
|
|
||||||
orig_count = num_taud_pats
|
|
||||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique ({orig_count - num_taud_pats} deduplicated)")
|
vprint(f" [{song_label}] patterns: {orig_count} → {num_taud_pats} unique "
|
||||||
|
f"({orig_count - num_taud_pats} deduplicated)")
|
||||||
|
|
||||||
# Cue sheet (using remapped pattern indices)
|
# Cue sheet
|
||||||
vprint(" building cue sheet…")
|
sheet = bytearray(NUM_CUES * CUE_SIZE)
|
||||||
cue_sheet = build_cue_sheet(h.order_list, P, C, pat_remap)
|
for c in range(NUM_CUES):
|
||||||
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
|
sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0)
|
||||||
|
|
||||||
# Compress pattern bin and cue sheet (per Taud spec)
|
last_active = -1
|
||||||
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
for cue_idx, src_pat in enumerate(cue_list):
|
||||||
cue_comp = compress_blob(bytes(cue_sheet), "cue sheet")
|
if cue_idx >= NUM_CUES: break
|
||||||
|
new_pat_idx = pat_idx_remap[src_pat]
|
||||||
|
orig_pats = [new_pat_idx * C + v for v in range(C)]
|
||||||
|
sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(
|
||||||
|
[pat_remap[p] for p in orig_pats], 0)
|
||||||
|
last_active = cue_idx
|
||||||
|
|
||||||
|
if last_active >= 0:
|
||||||
|
sheet[last_active * CUE_SIZE + 30] = 0x01
|
||||||
|
else:
|
||||||
|
sheet[30] = 0x01
|
||||||
|
|
||||||
|
pat_comp = compress_blob(bytes(pat_bin), f"[{song_label}] pattern bin")
|
||||||
|
cue_comp = compress_blob(bytes(sheet), f"[{song_label}] cue sheet")
|
||||||
|
|
||||||
# Song table row (32 bytes; see encode_song_entry).
|
|
||||||
# flags byte: bits 0-1 (ff) = tone mode. ff=1 (Amiga period slides) when S3M's
|
|
||||||
# linear_slides flag is clear; 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). S3M has no instrument-level fadeout, so every Taud instrument
|
|
||||||
# carries fadeout=0 ("no fade") — notes retire on sample-end or pattern note-cut
|
|
||||||
# effects (SCx) instead, which matches ST3 semantics.
|
|
||||||
flags_byte = (0x00 if h.linear_slides else 0x01)
|
flags_byte = (0x00 if h.linear_slides else 0x01)
|
||||||
song_table = encode_song_entry(
|
entry_kwargs = dict(
|
||||||
song_offset=song_offset,
|
|
||||||
num_voices=C,
|
num_voices=C,
|
||||||
num_patterns=num_taud_pats,
|
num_patterns=num_taud_pats,
|
||||||
bpm_stored=bpm_stored,
|
bpm_stored=bpm_stored,
|
||||||
@@ -831,10 +877,70 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list,
|
|||||||
global_vol=0xFF,
|
global_vol=0xFF,
|
||||||
mixing_vol=180,
|
mixing_vol=180,
|
||||||
)
|
)
|
||||||
assert len(song_table) == TAUD_SONG_ENTRY
|
return pat_comp, cue_comp, entry_kwargs
|
||||||
|
|
||||||
# Project Data (optional). S3M instruments and samples share the same slot
|
|
||||||
# space, so the names go into both INam and SNam (1-based; slot 0 empty).
|
def assemble_taud(h: S3MHeader, instruments: list, patterns: list,
|
||||||
|
with_project_data: bool = True) -> bytes:
|
||||||
|
# Determine active channels (bit7 clear = enabled)
|
||||||
|
active_channels = [i for i, cs in enumerate(h.channel_settings)
|
||||||
|
if i < 32 and not (cs & 0x80)][:NUM_VOICES]
|
||||||
|
C = len(active_channels)
|
||||||
|
P = len(patterns)
|
||||||
|
vprint(f" channels: {C}, s3m patterns: {P}")
|
||||||
|
|
||||||
|
# Build sample+instrument bin (shared across subsongs)
|
||||||
|
vprint(" building sample/instrument bin…")
|
||||||
|
sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(instruments)
|
||||||
|
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||||
|
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
||||||
|
comp_size = len(compressed)
|
||||||
|
|
||||||
|
# 1-based inst index → default volume (0..63) for note-trigger vol injection.
|
||||||
|
inst_vols = {
|
||||||
|
i + 1: min(inst.volume, 0x3F)
|
||||||
|
for i, inst in enumerate(instruments)
|
||||||
|
if inst is not None and inst.itype == S3M_TYPE_PCM
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Detect subsongs ──────────────────────────────────────────────────────
|
||||||
|
subsongs = detect_subsongs(h.order_list, _per_pattern_bxx_s3m(patterns),
|
||||||
|
terminators=(S3M_ORDER_END,),
|
||||||
|
skip_marker=S3M_ORDER_SKIP)
|
||||||
|
if not subsongs:
|
||||||
|
vprint(" warning: no traversable orders in source; emitting empty song")
|
||||||
|
subsongs = [{'entry': 0, 'positions': []}]
|
||||||
|
n_songs = len(subsongs)
|
||||||
|
if n_songs == 1:
|
||||||
|
vprint(f" detected 1 song ({len(subsongs[0]['positions'])} orders)")
|
||||||
|
else:
|
||||||
|
vprint(f" detected {n_songs} subsongs:")
|
||||||
|
for i, ss in enumerate(subsongs):
|
||||||
|
vprint(f" song {i}: entry@{ss['entry']}, {len(ss['positions'])} orders")
|
||||||
|
|
||||||
|
# ── Build per-song payloads ──────────────────────────────────────────────
|
||||||
|
song_payloads = []
|
||||||
|
for i, ss in enumerate(subsongs):
|
||||||
|
label = f"song {i}" if n_songs > 1 else "song"
|
||||||
|
song_payloads.append(_build_song_payload_s3m(
|
||||||
|
h, patterns, ss['positions'], sample_ratio, inst_vols,
|
||||||
|
active_channels, song_label=label))
|
||||||
|
|
||||||
|
# ── Layout offsets and song table ────────────────────────────────────────
|
||||||
|
song_table_off = TAUD_HEADER_SIZE + comp_size
|
||||||
|
first_song_off = song_table_off + TAUD_SONG_ENTRY * n_songs
|
||||||
|
|
||||||
|
song_table = bytearray()
|
||||||
|
cur_off = first_song_off
|
||||||
|
for pat_comp, cue_comp, entry_kwargs in song_payloads:
|
||||||
|
entry = encode_song_entry(song_offset=cur_off, **entry_kwargs)
|
||||||
|
assert len(entry) == TAUD_SONG_ENTRY
|
||||||
|
song_table += entry
|
||||||
|
cur_off += len(pat_comp) + len(cue_comp)
|
||||||
|
|
||||||
|
# ── Project Data (optional) ──────────────────────────────────────────────
|
||||||
|
# S3M instruments and samples share the same slot space, so the names go
|
||||||
|
# into both INam and SNam (1-based; slot 0 empty).
|
||||||
proj_data = b''
|
proj_data = b''
|
||||||
proj_off = 0
|
proj_off = 0
|
||||||
if with_project_data:
|
if with_project_data:
|
||||||
@@ -846,21 +952,29 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list,
|
|||||||
sample_names=names,
|
sample_names=names,
|
||||||
)
|
)
|
||||||
if proj_data:
|
if proj_data:
|
||||||
proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \
|
proj_off = cur_off
|
||||||
+ len(pat_comp) + len(cue_comp)
|
|
||||||
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
||||||
|
|
||||||
# Header (32 bytes): magic(8)+ver(1)+numSongs(1)+compSize(4)+projOff(4)+sig(14)
|
# ── Header ───────────────────────────────────────────────────────────────
|
||||||
|
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||||
header = (
|
header = (
|
||||||
TAUD_MAGIC +
|
TAUD_MAGIC +
|
||||||
bytes([TAUD_VERSION, 1]) +
|
bytes([TAUD_VERSION, n_songs & 0xFF]) +
|
||||||
struct.pack('<I', comp_size) +
|
struct.pack('<I', comp_size) +
|
||||||
struct.pack('<I', proj_off) +
|
struct.pack('<I', proj_off) +
|
||||||
sig
|
sig
|
||||||
)
|
)
|
||||||
assert len(header) == TAUD_HEADER_SIZE
|
assert len(header) == TAUD_HEADER_SIZE
|
||||||
|
|
||||||
return header + compressed + song_table + pat_comp + cue_comp + proj_data
|
out = bytearray()
|
||||||
|
out += header
|
||||||
|
out += compressed
|
||||||
|
out += song_table
|
||||||
|
for pat_comp, cue_comp, _ in song_payloads:
|
||||||
|
out += pat_comp
|
||||||
|
out += cue_comp
|
||||||
|
out += proj_data
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
111
taud_common.py
111
taud_common.py
@@ -411,6 +411,117 @@ def encode_song_entry(song_offset: int, num_voices: int, num_patterns: int,
|
|||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
# ── Subsong detection (multi-song .taud emission) ────────────────────────────
|
||||||
|
#
|
||||||
|
# Modules and trackers don't natively carry a subsong table; subsongs emerge
|
||||||
|
# from the order-list flow graph. OpenMPT-style: take the lowest unvisited
|
||||||
|
# non-terminator order as the next subsong entry, do forward reachability via
|
||||||
|
# fall-through (oi→oi+1) plus pattern-Bxx targets, mark all reached orders
|
||||||
|
# visited, repeat until no entries remain.
|
||||||
|
#
|
||||||
|
# Fall-through is treated as dead when the pattern at oi has a Bxx on its
|
||||||
|
# absolute last row — the convention every tracker uses for "song ends here,
|
||||||
|
# loop back" — which lets non-looping subsongs separated by Bxx-terminated
|
||||||
|
# predecessors be detected even without an explicit 0xFF marker.
|
||||||
|
#
|
||||||
|
# WHEN.s3m → 4 subsongs (0xFF separators); Insaniq2.it → 8 subsongs (Bxx-row-63
|
||||||
|
# terminators, no 0xFF separators). Single-song files collapse to 1 subsong.
|
||||||
|
|
||||||
|
def detect_subsongs(orders, pattern_bxx_fn, *,
|
||||||
|
terminators=(0xFF,), skip_marker=0xFE):
|
||||||
|
"""Detect subsongs by repeated forward reachability.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
orders: list of raw order bytes from the source file. Each element is
|
||||||
|
either a pattern index (0..n-1), a skip value (transparently
|
||||||
|
skipped), or a terminator value (ends a path).
|
||||||
|
pattern_bxx_fn: callable(pattern_idx) → (set_of_bxx_target_order_indices,
|
||||||
|
kills_fallthrough). `kills_fallthrough` is True when the pattern's
|
||||||
|
last row carries a Bxx (unconditional terminator); when False,
|
||||||
|
fall-through to oi+1 is kept as a graph edge.
|
||||||
|
terminators: int, or iterable of ints. Order values that end a path
|
||||||
|
(default 0xFF). Pass an empty iterable for formats without a
|
||||||
|
terminator marker (XM).
|
||||||
|
skip_marker: int, or iterable of ints. Order values that are
|
||||||
|
transparently passed during traversal (default 0xFE). XM passes
|
||||||
|
`range(pattern_count, 256)` to skip out-of-range pattern refs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of subsongs in entry-order. Each subsong is a dict:
|
||||||
|
'entry': original order-list position of the entry (int)
|
||||||
|
'positions': list of original order-list positions belonging to this
|
||||||
|
subsong, in cue-sheet order (entry first, then ascending index
|
||||||
|
wrap-around). Each position's pattern index = orders[pos].
|
||||||
|
For a single-song file the result has one element whose 'positions'
|
||||||
|
covers the whole order list (minus terminators/skips). For files where
|
||||||
|
every order is a terminator/skip, the result is empty.
|
||||||
|
"""
|
||||||
|
n = len(orders)
|
||||||
|
term = {terminators} if isinstance(terminators, int) else set(terminators)
|
||||||
|
skips = ({skip_marker} if isinstance(skip_marker, int)
|
||||||
|
else set(skip_marker))
|
||||||
|
|
||||||
|
def _is_traversable(pos: int) -> bool:
|
||||||
|
if pos < 0 or pos >= n:
|
||||||
|
return False
|
||||||
|
v = orders[pos]
|
||||||
|
return v not in term and v not in skips
|
||||||
|
|
||||||
|
visited = set()
|
||||||
|
songs = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Lowest unvisited traversable position = next subsong entry.
|
||||||
|
entry = next((i for i in range(n)
|
||||||
|
if i not in visited and _is_traversable(i)), None)
|
||||||
|
if entry is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Reachability claims orders for this subsong, stopping at orders
|
||||||
|
# already owned by a previous subsong.
|
||||||
|
owned = set()
|
||||||
|
stack = [entry]
|
||||||
|
while stack:
|
||||||
|
oi = stack.pop()
|
||||||
|
if oi in owned or oi in visited:
|
||||||
|
continue
|
||||||
|
if oi < 0 or oi >= n:
|
||||||
|
continue
|
||||||
|
v = orders[oi]
|
||||||
|
if v in term:
|
||||||
|
continue
|
||||||
|
if v in skips:
|
||||||
|
if oi + 1 < n:
|
||||||
|
stack.append(oi + 1)
|
||||||
|
continue
|
||||||
|
owned.add(oi)
|
||||||
|
tgts, kills = pattern_bxx_fn(v)
|
||||||
|
for t in tgts:
|
||||||
|
if 0 <= t < n:
|
||||||
|
stack.append(t)
|
||||||
|
if not kills and oi + 1 < n:
|
||||||
|
stack.append(oi + 1)
|
||||||
|
|
||||||
|
if not owned:
|
||||||
|
# Avoid infinite loop on a degenerate entry (shouldn't happen
|
||||||
|
# since _is_traversable already filtered terminators / skips).
|
||||||
|
visited.add(entry)
|
||||||
|
continue
|
||||||
|
visited |= owned
|
||||||
|
|
||||||
|
# Cue-sheet order: ascending index, rotated so entry comes first.
|
||||||
|
# The natural order-list traversal is sequential, so increasing index
|
||||||
|
# matches the play sequence when fall-through is alive; rotation
|
||||||
|
# ensures cue 0 is the entry order.
|
||||||
|
sorted_owned = sorted(owned)
|
||||||
|
rot = sorted_owned.index(entry)
|
||||||
|
positions = sorted_owned[rot:] + sorted_owned[:rot]
|
||||||
|
|
||||||
|
songs.append({'entry': entry, 'positions': positions})
|
||||||
|
|
||||||
|
return songs
|
||||||
|
|
||||||
|
|
||||||
# ── Project Data section (terranmon.txt:2601+) ───────────────────────────────
|
# ── Project Data section (terranmon.txt:2601+) ───────────────────────────────
|
||||||
|
|
||||||
PROJECT_DATA_MAGIC = bytes([0x1E, 0x54, 0x61, 0x75, 0x64, 0x50, 0x72, 0x4A]) # \x1ETaudPrJ
|
PROJECT_DATA_MAGIC = bytes([0x1E, 0x54, 0x61, 0x75, 0x64, 0x50, 0x72, 0x4A]) # \x1ETaudPrJ
|
||||||
|
|||||||
358
xm2taud.py
358
xm2taud.py
@@ -41,6 +41,7 @@ Reference:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import copy
|
||||||
import math
|
import math
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
@@ -59,7 +60,7 @@ from taud_common import (
|
|||||||
encode_cue, deduplicate_patterns,
|
encode_cue, deduplicate_patterns,
|
||||||
normalise_sample, encode_song_entry, nearest_minifloat, compress_blob,
|
normalise_sample, encode_song_entry, nearest_minifloat, compress_blob,
|
||||||
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
|
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
|
||||||
build_project_data,
|
build_project_data, detect_subsongs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -694,18 +695,42 @@ def split_patterns_xm(patterns: list):
|
|||||||
|
|
||||||
def remap_b_effects_xm(chunks: list, chunk_map: list,
|
def remap_b_effects_xm(chunks: list, chunk_map: list,
|
||||||
order_list: list, xm_ord_to_taud_cue: dict,
|
order_list: list, xm_ord_to_taud_cue: dict,
|
||||||
num_channels: int) -> None:
|
num_channels: int,
|
||||||
|
*, default_target: int = None,
|
||||||
|
warn_label: str = '',
|
||||||
|
chunk_indices=None) -> None:
|
||||||
"""Rewrite XM B (position jump) effects so the argument indexes Taud cues
|
"""Rewrite XM B (position jump) effects so the argument indexes Taud cues
|
||||||
rather than XM order positions. (Pattern break Dxx already targets a row,
|
rather than XM order positions. (Pattern break Dxx already targets a row,
|
||||||
no remap needed — the post-break behaviour is "advance to next order",
|
no remap needed — the post-break behaviour is "advance to next order",
|
||||||
which Taud emulates correctly when the cue ends.)"""
|
which Taud emulates correctly when the cue ends.)
|
||||||
for chunk_grid in chunks:
|
|
||||||
|
`default_target`: when a Bxx target isn't in `xm_ord_to_taud_cue` (a
|
||||||
|
cross-subsong jump), rewrite to this cue index instead of preserving
|
||||||
|
the literal target. Use 0 to make cross-song jumps loop the subsong.
|
||||||
|
|
||||||
|
`chunk_indices`: optional iterable; when provided, only these chunks are
|
||||||
|
visited. Used by multi-song to skip unreferenced chunks (avoids spurious
|
||||||
|
cross-song warnings on chunks not emitted in this song).
|
||||||
|
"""
|
||||||
|
crossings = 0
|
||||||
|
iter_indices = (chunk_indices if chunk_indices is not None
|
||||||
|
else range(len(chunks)))
|
||||||
|
for ci in iter_indices:
|
||||||
|
chunk_grid = chunks[ci]
|
||||||
for ch in range(min(num_channels, len(chunk_grid))):
|
for ch in range(min(num_channels, len(chunk_grid))):
|
||||||
for row in chunk_grid[ch]:
|
for row in chunk_grid[ch]:
|
||||||
if row.effect == 0x0B:
|
if row.effect == 0x0B:
|
||||||
xm_ord = row.effect_arg & 0xFF
|
xm_ord = row.effect_arg & 0xFF
|
||||||
taud_cue = xm_ord_to_taud_cue.get(xm_ord, xm_ord)
|
if xm_ord in xm_ord_to_taud_cue:
|
||||||
row.effect_arg = taud_cue & 0xFF
|
row.effect_arg = xm_ord_to_taud_cue[xm_ord] & 0xFF
|
||||||
|
elif default_target is not None:
|
||||||
|
crossings += 1
|
||||||
|
row.effect_arg = default_target & 0xFF
|
||||||
|
else:
|
||||||
|
row.effect_arg = xm_ord & 0xFF
|
||||||
|
if crossings and warn_label:
|
||||||
|
vprint(f" warning: {warn_label}: {crossings} Bxx target(s) cross "
|
||||||
|
f"subsong boundary; clamped to cue {default_target}")
|
||||||
|
|
||||||
|
|
||||||
def compute_keyoff_zero_marks_xm(taud_cue_list: list, chunks: list,
|
def compute_keyoff_zero_marks_xm(taud_cue_list: list, chunks: list,
|
||||||
@@ -1253,6 +1278,147 @@ def _active_channels_xm(h: XMHeader, patterns: list) -> list:
|
|||||||
|
|
||||||
# ── Main assembly ─────────────────────────────────────────────────────────────
|
# ── Main assembly ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _per_pattern_bxx_xm(patterns: list):
|
||||||
|
"""Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough)
|
||||||
|
for `detect_subsongs`. XM patterns vary in length; `kills_fallthrough` is
|
||||||
|
True when a Bxx (effect 0x0B) appears on the absolute last row.
|
||||||
|
`patterns[pi]` is `(grid, rows)`; `grid` is `[channel][row]`.
|
||||||
|
"""
|
||||||
|
def fn(pat_idx: int):
|
||||||
|
if pat_idx < 0 or pat_idx >= len(patterns):
|
||||||
|
return set(), False
|
||||||
|
grid, rows = patterns[pat_idx]
|
||||||
|
targets = set()
|
||||||
|
last_row_has_b = False
|
||||||
|
for ch_rows in grid:
|
||||||
|
n = min(rows, len(ch_rows))
|
||||||
|
for r in range(n):
|
||||||
|
cell = ch_rows[r]
|
||||||
|
if cell.effect == 0x0B:
|
||||||
|
targets.add(cell.effect_arg & 0xFF)
|
||||||
|
if r == rows - 1:
|
||||||
|
last_row_has_b = True
|
||||||
|
return targets, last_row_has_b
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
def _build_song_payload_xm(h: XMHeader, patterns_template: list,
|
||||||
|
instruments: list, positions: list,
|
||||||
|
sample_ratio: dict, active_channels: list,
|
||||||
|
default_pans: list, resolve_inst_slot,
|
||||||
|
*, song_label: str = 'song') -> tuple:
|
||||||
|
"""Build pattern bin + cue sheet + (subset of) song-entry kwargs for
|
||||||
|
one subsong. The caller fills in song_offset, flags_byte, and shared
|
||||||
|
globals.
|
||||||
|
|
||||||
|
Patterns aren't mutated by per-order walks in XM (no recall resolution),
|
||||||
|
but `remap_b_effects_xm` mutates chunk grids — so we deep-copy chunks
|
||||||
|
per song. (`compute_keyoff_zero_marks_xm` only reads.)
|
||||||
|
"""
|
||||||
|
chunks, chunk_map, chunk_lens = split_patterns_xm(patterns_template)
|
||||||
|
|
||||||
|
C = len(active_channels)
|
||||||
|
|
||||||
|
cue_list = []
|
||||||
|
pos_to_cue = {}
|
||||||
|
for pos in positions:
|
||||||
|
order = h.order_list[pos]
|
||||||
|
if order >= h.pattern_count or order >= len(chunk_map):
|
||||||
|
continue
|
||||||
|
pos_to_cue[pos] = len(cue_list)
|
||||||
|
for ci in chunk_map[order]:
|
||||||
|
cue_list.append(ci)
|
||||||
|
|
||||||
|
if not cue_list:
|
||||||
|
# Degenerate subsong (e.g. all orders point to invalid patterns).
|
||||||
|
vprint(f" warning: [{song_label}] no playable cues; emitting halt-only song")
|
||||||
|
|
||||||
|
remap_b_effects_xm(chunks, chunk_map, h.order_list, pos_to_cue, C,
|
||||||
|
default_target=0, warn_label=song_label,
|
||||||
|
chunk_indices=set(cue_list))
|
||||||
|
|
||||||
|
keyoff_zero_marks = compute_keyoff_zero_marks_xm(
|
||||||
|
cue_list, chunks, h.channels, instruments, active_channels)
|
||||||
|
if any(keyoff_zero_marks.values()):
|
||||||
|
flagged = sum(len(s) for s in keyoff_zero_marks.values())
|
||||||
|
vprint(f" [{song_label}] FT2 keyoff-gate: {flagged} key-off cell(s) "
|
||||||
|
f"paired with vol=0 (vol-env-off instruments)")
|
||||||
|
|
||||||
|
total_taud_pats = len(cue_list) * C
|
||||||
|
if total_taud_pats > NUM_PATTERNS_MAX:
|
||||||
|
sys.exit(f"error: [{song_label}] {len(cue_list)} cues × {C} channels = "
|
||||||
|
f"{total_taud_pats} > {NUM_PATTERNS_MAX} Taud pattern limit.")
|
||||||
|
|
||||||
|
pat_bin = bytearray()
|
||||||
|
for ci in cue_list:
|
||||||
|
cg = chunks[ci]
|
||||||
|
chunk_marks = keyoff_zero_marks.get(ci, frozenset())
|
||||||
|
for vi, ch in enumerate(active_channels):
|
||||||
|
row_marks = {r for (mvi, r) in chunk_marks if mvi == vi}
|
||||||
|
pat_bin += build_pattern_xm(cg, ch, default_pans[vi],
|
||||||
|
resolve_inst_slot,
|
||||||
|
amiga_mode=not h.linear_freq,
|
||||||
|
keyoff_zero_rows=row_marks)
|
||||||
|
pat_bin = rescale_offset_effects_per_slot(
|
||||||
|
bytes(pat_bin), len(cue_list), C, sample_ratio)
|
||||||
|
|
||||||
|
orig_count = len(cue_list) * C
|
||||||
|
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||||
|
vprint(f" [{song_label}] patterns: {orig_count} → {num_taud_pats} unique "
|
||||||
|
f"({orig_count - num_taud_pats} deduplicated)")
|
||||||
|
|
||||||
|
sheet = bytearray(NUM_CUES * CUE_SIZE)
|
||||||
|
for c in range(NUM_CUES):
|
||||||
|
sheet[c * CUE_SIZE:c * CUE_SIZE + CUE_SIZE] = encode_cue([], 0)
|
||||||
|
|
||||||
|
last_active = -1
|
||||||
|
len_cue_count = 0
|
||||||
|
for cue_idx, ci in enumerate(cue_list):
|
||||||
|
if cue_idx >= NUM_CUES: break
|
||||||
|
base_pat = cue_idx * C
|
||||||
|
pats = [pat_remap[base_pat + vi] for vi in range(C)]
|
||||||
|
clen = chunk_lens[ci] if ci < len(chunk_lens) else PATTERN_ROWS
|
||||||
|
if clen < PATTERN_ROWS:
|
||||||
|
instr = cue_instruction_len(clen)
|
||||||
|
len_cue_count += 1
|
||||||
|
else:
|
||||||
|
instr = CUE_INST_NOP
|
||||||
|
sheet[cue_idx * CUE_SIZE:(cue_idx + 1) * CUE_SIZE] = encode_cue(pats, instr)
|
||||||
|
last_active = cue_idx
|
||||||
|
|
||||||
|
if last_active >= 0:
|
||||||
|
if sheet[last_active * CUE_SIZE + 30] == CUE_INST_LEN:
|
||||||
|
vprint(f" [{song_label}] warning: last active cue {last_active} "
|
||||||
|
f"had LEN; replaced with HALT (partial tail at song terminus)")
|
||||||
|
sheet[last_active * CUE_SIZE + 30] = CUE_INST_HALT
|
||||||
|
sheet[last_active * CUE_SIZE + 31] = 0x00
|
||||||
|
else:
|
||||||
|
sheet[30] = CUE_INST_HALT
|
||||||
|
if len_cue_count:
|
||||||
|
vprint(f" [{song_label}] emitted {len_cue_count} LEN cue instruction(s) "
|
||||||
|
f"for partial-length patterns")
|
||||||
|
|
||||||
|
pat_comp = compress_blob(bytes(pat_bin), f"[{song_label}] pattern bin")
|
||||||
|
cue_comp = compress_blob(bytes(sheet), f"[{song_label}] cue sheet")
|
||||||
|
|
||||||
|
# Speed/tempo are file-wide for XM; pass them through the kwargs so the
|
||||||
|
# outer function fills in shared header fields uniformly.
|
||||||
|
speed = h.default_speed if h.default_speed > 0 else 6
|
||||||
|
tempo = h.default_bpm if h.default_bpm > 0 else 125
|
||||||
|
tempo = max(25, min(280, tempo))
|
||||||
|
bpm_stored = (tempo - 25) & 0xFF
|
||||||
|
|
||||||
|
entry_kwargs = dict(
|
||||||
|
num_voices=C,
|
||||||
|
num_patterns=num_taud_pats,
|
||||||
|
bpm_stored=bpm_stored,
|
||||||
|
tick_rate=speed,
|
||||||
|
pat_bin_comp_size=len(pat_comp),
|
||||||
|
cue_sheet_comp_size=len(cue_comp),
|
||||||
|
)
|
||||||
|
return pat_comp, cue_comp, entry_kwargs
|
||||||
|
|
||||||
|
|
||||||
def assemble_taud(h: XMHeader, patterns: list, instruments: list,
|
def assemble_taud(h: XMHeader, patterns: list, instruments: list,
|
||||||
with_project_data: bool = True) -> bytes:
|
with_project_data: bool = True) -> bytes:
|
||||||
# XM envelope frames advance once per row tick. Tick rate is derived
|
# XM envelope frames advance once per row tick. Tick rate is derived
|
||||||
@@ -1315,139 +1481,69 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list,
|
|||||||
bpm_stored = (tempo - 25) & 0xFF
|
bpm_stored = (tempo - 25) & 0xFF
|
||||||
vprint(f" initial speed={speed}, tempo={tempo} BPM")
|
vprint(f" initial speed={speed}, tempo={tempo} BPM")
|
||||||
|
|
||||||
# ── Channels / cue list ─────────────────────────────────────────────────
|
# ── Channels / pattern split (shared) ───────────────────────────────────
|
||||||
active_channels = _active_channels_xm(h, patterns)
|
active_channels = _active_channels_xm(h, patterns)
|
||||||
C = len(active_channels)
|
C = len(active_channels)
|
||||||
if C == 0:
|
if C == 0:
|
||||||
sys.exit("error: no active channels found")
|
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,...).
|
# Default pan per active channel: alternate L/R FT2-style (0,12,12,0,...).
|
||||||
def _xm_default_pan(idx: int) -> int:
|
def _xm_default_pan(idx: int) -> int:
|
||||||
side = idx % 4
|
side = idx % 4
|
||||||
return 16 if side in (0, 3) else 47
|
return 16 if side in (0, 3) else 47
|
||||||
default_pans = [_xm_default_pan(i) for i in range(C)]
|
default_pans = [_xm_default_pan(i) for i in range(C)]
|
||||||
|
|
||||||
pat_bin = bytearray()
|
# ── Detect subsongs ──────────────────────────────────────────────────────
|
||||||
for ci in taud_cue_list:
|
# XM has no terminator marker; `order_count` bounds the live order list.
|
||||||
cg = chunks[ci]
|
# Out-of-range pattern refs (≥ pattern_count) are skipped during playback,
|
||||||
chunk_marks = keyoff_zero_marks.get(ci, frozenset())
|
# so we feed the detector a slice of length `order_count` and treat
|
||||||
for vi, ch in enumerate(active_channels):
|
# everything ≥ pattern_count as a skip.
|
||||||
row_marks = {r for (mvi, r) in chunk_marks if mvi == vi}
|
orders_view = list(h.order_list[:h.order_count])
|
||||||
pat_bin += build_pattern_xm(cg, ch, default_pans[vi],
|
skip_set = set(range(h.pattern_count, 256))
|
||||||
resolve_inst_slot,
|
subsongs = detect_subsongs(orders_view, _per_pattern_bxx_xm(patterns),
|
||||||
amiga_mode=not h.linear_freq,
|
terminators=(),
|
||||||
keyoff_zero_rows=row_marks)
|
skip_marker=skip_set)
|
||||||
# Rescale TOP_O sample-offset args per channel using the active slot's
|
if not subsongs:
|
||||||
# ratio (combined global + per-sample). Walks pat_bin in cue-major /
|
vprint(" warning: no traversable orders in source; emitting empty song")
|
||||||
# channel-minor order, tracking the most recent inst byte seen on each
|
subsongs = [{'entry': 0, 'positions': []}]
|
||||||
# channel — must run before deduplication so the channel state stays
|
n_songs = len(subsongs)
|
||||||
# linear.
|
if n_songs == 1:
|
||||||
pat_bin = rescale_offset_effects_per_slot(
|
vprint(f" detected 1 song ({len(subsongs[0]['positions'])} orders)")
|
||||||
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:
|
else:
|
||||||
sheet[30] = CUE_INST_HALT
|
vprint(f" detected {n_songs} subsongs:")
|
||||||
if len_cue_count:
|
for i, ss in enumerate(subsongs):
|
||||||
vprint(f" emitted {len_cue_count} LEN cue instruction(s) "
|
vprint(f" song {i}: entry@{ss['entry']}, {len(ss['positions'])} orders")
|
||||||
f"for partial-length patterns")
|
|
||||||
|
|
||||||
# ── Header / song table ─────────────────────────────────────────────────
|
# ── Build per-song payloads ──────────────────────────────────────────────
|
||||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
song_payloads = []
|
||||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
for i, ss in enumerate(subsongs):
|
||||||
|
label = f"song {i}" if n_songs > 1 else "song"
|
||||||
|
song_payloads.append(_build_song_payload_xm(
|
||||||
|
h, patterns, instruments, ss['positions'],
|
||||||
|
sample_ratio, active_channels, default_pans,
|
||||||
|
resolve_inst_slot,
|
||||||
|
song_label=label))
|
||||||
|
|
||||||
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
# ── Layout offsets and song table ────────────────────────────────────────
|
||||||
cue_comp = compress_blob(bytes(sheet), "cue sheet")
|
song_table_off = TAUD_HEADER_SIZE + comp_size
|
||||||
|
first_song_off = song_table_off + TAUD_SONG_ENTRY * n_songs
|
||||||
|
|
||||||
# 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)
|
flags_byte = (0x00 if h.linear_freq else 0x01)
|
||||||
song_table = encode_song_entry(
|
song_table = bytearray()
|
||||||
song_offset=song_offset,
|
cur_off = first_song_off
|
||||||
num_voices=C,
|
for pat_comp, cue_comp, entry_kwargs in song_payloads:
|
||||||
num_patterns=num_taud_pats,
|
# Header BPM/speed go into per-song; flags is shared (XM doesn't switch
|
||||||
bpm_stored=bpm_stored,
|
# period mode mid-file).
|
||||||
tick_rate=speed,
|
entry = encode_song_entry(song_offset=cur_off,
|
||||||
base_note=0xA000,
|
flags_byte=flags_byte,
|
||||||
base_freq=8363.0,
|
global_vol=0xFF,
|
||||||
flags_byte=flags_byte,
|
mixing_vol=0x80,
|
||||||
pat_bin_comp_size=len(pat_comp),
|
base_note=0xA000,
|
||||||
cue_sheet_comp_size=len(cue_comp),
|
base_freq=8363.0,
|
||||||
global_vol=0xFF,
|
**entry_kwargs)
|
||||||
mixing_vol=0x80,
|
assert len(entry) == TAUD_SONG_ENTRY
|
||||||
)
|
song_table += entry
|
||||||
assert len(song_table) == TAUD_SONG_ENTRY
|
cur_off += len(pat_comp) + len(cue_comp)
|
||||||
|
|
||||||
# Project Data (optional). XM nests samples under instruments and the
|
# Project Data (optional). XM nests samples under instruments and the
|
||||||
# converter creates one Taud slot per (xm_inst, sample) pair, so SNam is
|
# converter creates one Taud slot per (xm_inst, sample) pair, so SNam is
|
||||||
@@ -1466,20 +1562,28 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list,
|
|||||||
sample_names=smp_names,
|
sample_names=smp_names,
|
||||||
)
|
)
|
||||||
if proj_data:
|
if proj_data:
|
||||||
proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \
|
proj_off = cur_off
|
||||||
+ len(pat_comp) + len(cue_comp)
|
|
||||||
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
||||||
|
|
||||||
|
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||||
header = (
|
header = (
|
||||||
TAUD_MAGIC +
|
TAUD_MAGIC +
|
||||||
bytes([TAUD_VERSION, 1]) +
|
bytes([TAUD_VERSION, n_songs & 0xFF]) +
|
||||||
struct.pack('<I', comp_size) +
|
struct.pack('<I', comp_size) +
|
||||||
struct.pack('<I', proj_off) +
|
struct.pack('<I', proj_off) +
|
||||||
sig
|
sig
|
||||||
)
|
)
|
||||||
assert len(header) == TAUD_HEADER_SIZE
|
assert len(header) == TAUD_HEADER_SIZE
|
||||||
|
|
||||||
return header + compressed + song_table + pat_comp + cue_comp + proj_data
|
out = bytearray()
|
||||||
|
out += header
|
||||||
|
out += compressed
|
||||||
|
out += song_table
|
||||||
|
for pat_comp, cue_comp, _ in song_payloads:
|
||||||
|
out += pat_comp
|
||||||
|
out += cue_comp
|
||||||
|
out += proj_data
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user