From 6b02d73600a517fa98d9f44eba558487ae96f7ea Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sat, 9 May 2026 00:57:55 +0900 Subject: [PATCH] 2taud.py: project data composing --- it2taud.py | 49 +++++++++++++++++++++------ mod2taud.py | 43 ++++++++++++++++++------ mon2taud.py | 45 +++++++++++++++++-------- s3m2taud.py | 48 ++++++++++++++++++++------- taud_common.py | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++ xm2taud.py | 50 ++++++++++++++++++++++------ 6 files changed, 267 insertions(+), 58 deletions(-) diff --git a/it2taud.py b/it2taud.py index c355d29..d40c421 100644 --- a/it2taud.py +++ b/it2taud.py @@ -55,6 +55,7 @@ from taud_common import ( encode_cue, deduplicate_patterns, normalise_sample, encode_song_entry, nearest_minifloat, compress_blob, CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len, + build_project_data, ) @@ -1561,7 +1562,8 @@ def _active_channels(h: ITHeader, patterns_rows: list) -> list: return active def assemble_taud(h: ITHeader, samples: list, instruments: list, - patterns_rows: list, decompress: bool) -> bytes: + patterns_rows: list, decompress: bool, + with_project_data: bool = True) -> bytes: # ── Resolve IT recalls ─────────────────────────────────────────────────── vprint(" resolving IT recalls…") resolve_it_recalls(patterns_rows, h.order_list, 64, h.link_gef, @@ -1808,14 +1810,6 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list, # ── Header ─────────────────────────────────────────────────────────────── sig = (SIGNATURE + b' ' * 14)[:14] - header = ( - TAUD_MAGIC + - bytes([TAUD_VERSION, 1]) + - struct.pack(' tuple: return speed, tempo -def assemble_taud(mod: dict) -> bytes: +def assemble_taud(mod: dict, with_project_data: bool = True) -> bytes: samples = mod['samples'] patterns = mod['patterns'] order_list = mod['order_list'] @@ -728,14 +729,6 @@ def assemble_taud(mod: dict) -> bytes: song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY sig = (SIGNATURE + b' ' * 14)[:14] - header = ( - TAUD_MAGIC + - bytes([TAUD_VERSION, 1]) + - struct.pack(' bytes: ) assert len(song_table) == TAUD_SONG_ENTRY - return header + compressed + song_table + pat_comp + cue_comp + # Project Data (optional). MOD samples *are* its instruments — the names + # populate both INam and SNam (1-based; slot 0 empty). + proj_data = b'' + proj_off = 0 + if with_project_data: + names = [''] + [s.name for s in samples[:255]] + proj_data = build_project_data( + project_name=mod['title'], + instrument_names=names, + sample_names=names, + ) + if proj_data: + proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \ + + len(pat_comp) + len(cue_comp) + vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}") + + header = ( + TAUD_MAGIC + + bytes([TAUD_VERSION, 1]) + + struct.pack(' int # ── Top-level assembly ─────────────────────────────────────────────────────── -def assemble_taud(mon: dict) -> bytes: +def assemble_taud(mon: dict, with_project_data: bool = True) -> bytes: num_voices = mon['num_voices'] patterns = mon['patterns'] order_list = mon['order_list'] @@ -347,17 +348,7 @@ def assemble_taud(mon: dict) -> bytes: pat_comp = compress_blob(bytes(pat_bin), "pattern bin") cue_comp = compress_blob(bytes(cue_sheet), "cue sheet") - # Header: magic, version, num_songs=1, comp_size of sample+inst, projOff=0, sig. sig = (SIGNATURE + b' ' * 14)[:14] - header = ( - TAUD_MAGIC - + bytes([TAUD_VERSION, 1]) - + struct.pack(' bytes: ) assert len(song_table) == TAUD_SONG_ENTRY - return header + compressed + song_table + pat_comp + cue_comp + # Project Data (optional). Monotone has no title, no user instruments and + # no per-sample names, but we still emit one identifying entry so the + # synthesised square slot is documented. + proj_data = b'' + proj_off = 0 + if with_project_data: + proj_data = build_project_data( + instrument_names=['', 'PC speaker square'], + sample_names=['', 'PC speaker square'], + ) + if proj_data: + proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \ + + len(pat_comp) + len(cue_comp) + vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}") + + # Header: magic, version, num_songs=1, comp_size of sample+inst, projOff, sig. + header = ( + TAUD_MAGIC + + bytes([TAUD_VERSION, 1]) + + struct.pack(' bytes: +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] @@ -765,16 +767,7 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes: song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY num_taud_pats = P * C - # Header (32 bytes): magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(4)+sig(14) sig = (SIGNATURE + b' ' * 14)[:14] - header = ( - TAUD_MAGIC + - bytes([TAUD_VERSION, 1]) + - struct.pack(' bytes: ) assert len(song_table) == TAUD_SONG_ENTRY - return header + compressed + song_table + pat_comp + 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_off = 0 + if with_project_data: + names = [''] + [(inst.name if inst is not None else '') + for inst in instruments[:255]] + proj_data = build_project_data( + project_name=h.title, + instrument_names=names, + sample_names=names, + ) + if proj_data: + proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \ + + len(pat_comp) + len(cue_comp) + 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 = ( + TAUD_MAGIC + + bytes([TAUD_VERSION, 1]) + + struct.pack(' bytes: + """Encode a list of names (slot-indexed; slot 0 is left empty in source) as + 0x1E-separated UTF-8 bytes. Trailing empty slots are trimmed to save space. + Returns b'' when every name is empty. + """ + if not names: + return b'' + end = len(names) + while end > 0 and not names[end - 1]: + end -= 1 + if end == 0: + return b'' + return b'\x1E'.join((n or '').encode('utf-8', 'replace') for n in names[:end]) + + +def build_project_data(*, project_name: str = '', + author: str = '', + copyright_str: str = '', + sample_names=None, + instrument_names=None, + pattern_names=None, + song_metadata=None) -> bytes: + """Build the optional PROJECT DATA section payload. + + Returns the full block (8-byte magic + 8 reserved bytes + concatenated + FourCC sections), or b'' when there's nothing to write so the caller can + leave the header's projOff field at zero. + + `sample_names` / `instrument_names` / `pattern_names` are slot-indexed + lists (entry 0 is typically empty since slot 0 is reserved); they are + encoded as 0x1E-separated UTF-8 strings inside SNam / INam / pNam blocks. + + `song_metadata` is an optional list of dicts, one per song: + { 'index': int (0..255), + 'notation': int = 0, + 'beat_pri': int = 4, + 'beat_sec': int = 16, + 'name': str = '', + 'composer': str = '', + 'copyright': str = '' } + """ + sections = [] + + def add(fourcc: bytes, payload: bytes) -> None: + if not payload: + return + sections.append(fourcc + struct.pack(' bytes: """Return unsigned 8-bit mono sample bytes, downmixing/depthing as needed.""" diff --git a/xm2taud.py b/xm2taud.py index 56e34b7..1e06503 100644 --- a/xm2taud.py +++ b/xm2taud.py @@ -59,6 +59,7 @@ from taud_common import ( encode_cue, deduplicate_patterns, normalise_sample, encode_song_entry, nearest_minifloat, compress_blob, CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len, + build_project_data, ) @@ -1245,7 +1246,8 @@ def _active_channels_xm(h: XMHeader, patterns: list) -> list: # ── Main assembly ───────────────────────────────────────────────────────────── -def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes: +def assemble_taud(h: XMHeader, patterns: list, instruments: list, + with_project_data: bool = True) -> bytes: # XM envelope frames advance once per row tick. Tick rate is derived # from BPM the same way ProTracker derives it: ticks_per_sec = BPM × 2/5 # (matches MilkyTracker's tick clock and it2taud's ticks_per_sec). @@ -1412,14 +1414,6 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes: # ── Header / song table ───────────────────────────────────────────────── song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY sig = (SIGNATURE + b' ' * 14)[:14] - header = ( - TAUD_MAGIC + - bytes([TAUD_VERSION, 1]) + - struct.pack(' bytes: ) assert len(song_table) == TAUD_SONG_ENTRY - return header + compressed + song_table + pat_comp + cue_comp + # Project Data (optional). XM nests samples under instruments and the + # converter creates one Taud slot per (xm_inst, sample) pair, so SNam is + # populated from the per-Taud-slot proxies and INam carries the parent + # XM-level instrument names (1-based; slot 0 empty). + proj_data = b'' + proj_off = 0 + if with_project_data: + inst_names = [''] + [(inst.name if inst is not None else '') + for inst in instruments[:255]] + smp_names = [''] + [(p.name if p is not None else '') + for p in proxies[1:256]] + proj_data = build_project_data( + project_name=h.title, + instrument_names=inst_names, + sample_names=smp_names, + ) + if proj_data: + proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \ + + len(pat_comp) + len(cue_comp) + vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}") + + header = ( + TAUD_MAGIC + + bytes([TAUD_VERSION, 1]) + + struct.pack('