mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
2taud.py: project data composing
This commit is contained in:
49
it2taud.py
49
it2taud.py
@@ -55,6 +55,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1561,7 +1562,8 @@ def _active_channels(h: ITHeader, patterns_rows: list) -> list:
|
|||||||
return active
|
return active
|
||||||
|
|
||||||
def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
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 ───────────────────────────────────────────────────
|
# ── Resolve IT recalls ───────────────────────────────────────────────────
|
||||||
vprint(" resolving IT recalls…")
|
vprint(" resolving IT recalls…")
|
||||||
resolve_it_recalls(patterns_rows, h.order_list, 64, h.link_gef,
|
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 ───────────────────────────────────────────────────────────────
|
# ── Header ───────────────────────────────────────────────────────────────
|
||||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||||
header = (
|
|
||||||
TAUD_MAGIC +
|
|
||||||
bytes([TAUD_VERSION, 1]) +
|
|
||||||
struct.pack('<I', comp_size) +
|
|
||||||
b'\x00\x00\x00\x00' +
|
|
||||||
sig
|
|
||||||
)
|
|
||||||
assert len(header) == TAUD_HEADER_SIZE
|
|
||||||
|
|
||||||
# Compress pattern bin and cue sheet (per Taud spec)
|
# Compress pattern bin and cue sheet (per Taud spec)
|
||||||
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
||||||
@@ -1846,7 +1840,36 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
|||||||
)
|
)
|
||||||
assert len(song_table) == TAUD_SONG_ENTRY
|
assert len(song_table) == TAUD_SONG_ENTRY
|
||||||
|
|
||||||
return header + compressed + song_table + pat_comp + cue_comp
|
# Project Data (optional). IT distinguishes instruments from samples, so
|
||||||
|
# both INam and SNam can carry distinct content. Slot 0 is unused, so the
|
||||||
|
# tables are 1-indexed with an empty slot-0 entry.
|
||||||
|
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 = [''] + [(s.name if s is not None else '')
|
||||||
|
for s in samples[:255]]
|
||||||
|
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('<I', comp_size) +
|
||||||
|
struct.pack('<I', proj_off) +
|
||||||
|
sig
|
||||||
|
)
|
||||||
|
assert len(header) == TAUD_HEADER_SIZE
|
||||||
|
|
||||||
|
return header + compressed + song_table + pat_comp + cue_comp + proj_data
|
||||||
|
|
||||||
|
|
||||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -1859,6 +1882,9 @@ def main():
|
|||||||
ap.add_argument('-v', '--verbose', action='store_true')
|
ap.add_argument('-v', '--verbose', action='store_true')
|
||||||
ap.add_argument('--no-decompress', action='store_true',
|
ap.add_argument('--no-decompress', action='store_true',
|
||||||
help='Treat compressed IT samples as silent (debug)')
|
help='Treat compressed IT samples as silent (debug)')
|
||||||
|
ap.add_argument('--no-project-data', action='store_true',
|
||||||
|
help='Omit the optional Project Data section '
|
||||||
|
'(song / instrument / sample names)')
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
set_verbose(args.verbose)
|
set_verbose(args.verbose)
|
||||||
|
|
||||||
@@ -1878,7 +1904,8 @@ def main():
|
|||||||
patterns_rows = parse_patterns(data, h)
|
patterns_rows = parse_patterns(data, h)
|
||||||
|
|
||||||
taud = assemble_taud(h, samples, instruments, patterns_rows,
|
taud = assemble_taud(h, samples, instruments, patterns_rows,
|
||||||
decompress=not args.no_decompress)
|
decompress=not args.no_decompress,
|
||||||
|
with_project_data=not args.no_project_data)
|
||||||
|
|
||||||
with open(args.output, 'wb') as f:
|
with open(args.output, 'wb') as f:
|
||||||
f.write(taud)
|
f.write(taud)
|
||||||
|
|||||||
43
mod2taud.py
43
mod2taud.py
@@ -40,6 +40,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -675,7 +676,7 @@ def find_initial_bpm_speed(patterns: list, order_list: list) -> tuple:
|
|||||||
return speed, tempo
|
return speed, tempo
|
||||||
|
|
||||||
|
|
||||||
def assemble_taud(mod: dict) -> bytes:
|
def assemble_taud(mod: dict, with_project_data: bool = True) -> bytes:
|
||||||
samples = mod['samples']
|
samples = mod['samples']
|
||||||
patterns = mod['patterns']
|
patterns = mod['patterns']
|
||||||
order_list = mod['order_list']
|
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
|
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
||||||
|
|
||||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||||
header = (
|
|
||||||
TAUD_MAGIC +
|
|
||||||
bytes([TAUD_VERSION, 1]) +
|
|
||||||
struct.pack('<I', comp_size) +
|
|
||||||
b'\x00\x00\x00\x00' +
|
|
||||||
sig
|
|
||||||
)
|
|
||||||
assert len(header) == TAUD_HEADER_SIZE
|
|
||||||
|
|
||||||
vprint(" building pattern bin…")
|
vprint(" building pattern bin…")
|
||||||
inst_vols = {
|
inst_vols = {
|
||||||
@@ -790,7 +783,32 @@ def assemble_taud(mod: dict) -> bytes:
|
|||||||
)
|
)
|
||||||
assert len(song_table) == TAUD_SONG_ENTRY
|
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('<I', comp_size) +
|
||||||
|
struct.pack('<I', proj_off) +
|
||||||
|
sig
|
||||||
|
)
|
||||||
|
assert len(header) == TAUD_HEADER_SIZE
|
||||||
|
|
||||||
|
return header + compressed + song_table + pat_comp + cue_comp + proj_data
|
||||||
|
|
||||||
|
|
||||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -802,6 +820,9 @@ def main():
|
|||||||
ap.add_argument('output', help='Output .taud file')
|
ap.add_argument('output', help='Output .taud file')
|
||||||
ap.add_argument('-v', '--verbose', action='store_true',
|
ap.add_argument('-v', '--verbose', action='store_true',
|
||||||
help='Print conversion details to stderr')
|
help='Print conversion details to stderr')
|
||||||
|
ap.add_argument('--no-project-data', action='store_true',
|
||||||
|
help='Omit the optional Project Data section '
|
||||||
|
'(song / instrument / sample names)')
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
set_verbose(args.verbose)
|
set_verbose(args.verbose)
|
||||||
@@ -816,7 +837,7 @@ def main():
|
|||||||
vprint(f" orders={len(mod['order_list'])}, patterns={mod['n_patterns']}, "
|
vprint(f" orders={len(mod['order_list'])}, patterns={mod['n_patterns']}, "
|
||||||
f"samples={sum(1 for s in mod['samples'] if s.sample_data)}")
|
f"samples={sum(1 for s in mod['samples'] if s.sample_data)}")
|
||||||
|
|
||||||
taud = assemble_taud(mod)
|
taud = assemble_taud(mod, with_project_data=not args.no_project_data)
|
||||||
|
|
||||||
with open(args.output, 'wb') as f:
|
with open(args.output, 'wb') as f:
|
||||||
f.write(taud)
|
f.write(taud)
|
||||||
|
|||||||
45
mon2taud.py
45
mon2taud.py
@@ -35,6 +35,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -298,7 +299,7 @@ def find_initial_speed(patterns: list, order_list: list, num_voices: int) -> int
|
|||||||
|
|
||||||
# ── Top-level assembly ───────────────────────────────────────────────────────
|
# ── 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']
|
num_voices = mon['num_voices']
|
||||||
patterns = mon['patterns']
|
patterns = mon['patterns']
|
||||||
order_list = mon['order_list']
|
order_list = mon['order_list']
|
||||||
@@ -347,17 +348,7 @@ def assemble_taud(mon: dict) -> bytes:
|
|||||||
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
||||||
cue_comp = compress_blob(bytes(cue_sheet), "cue sheet")
|
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]
|
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||||
header = (
|
|
||||||
TAUD_MAGIC
|
|
||||||
+ bytes([TAUD_VERSION, 1])
|
|
||||||
+ struct.pack('<I', comp_size)
|
|
||||||
+ b'\x00\x00\x00\x00'
|
|
||||||
+ sig
|
|
||||||
)
|
|
||||||
assert len(header) == TAUD_HEADER_SIZE
|
|
||||||
|
|
||||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
||||||
|
|
||||||
# BPM 150 + ticks=mon_speed → row rate = 60/mon_speed (matches Monotone).
|
# BPM 150 + ticks=mon_speed → row rate = 60/mon_speed (matches Monotone).
|
||||||
@@ -384,7 +375,32 @@ def assemble_taud(mon: dict) -> bytes:
|
|||||||
)
|
)
|
||||||
assert len(song_table) == TAUD_SONG_ENTRY
|
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('<I', comp_size)
|
||||||
|
+ struct.pack('<I', proj_off)
|
||||||
|
+ sig
|
||||||
|
)
|
||||||
|
assert len(header) == TAUD_HEADER_SIZE
|
||||||
|
|
||||||
|
return header + compressed + song_table + pat_comp + cue_comp + proj_data
|
||||||
|
|
||||||
|
|
||||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -396,6 +412,9 @@ def main():
|
|||||||
ap.add_argument('output', help='Output .taud file')
|
ap.add_argument('output', help='Output .taud file')
|
||||||
ap.add_argument('-v', '--verbose', action='store_true',
|
ap.add_argument('-v', '--verbose', action='store_true',
|
||||||
help='Print conversion details to stderr')
|
help='Print conversion details to stderr')
|
||||||
|
ap.add_argument('--no-project-data', action='store_true',
|
||||||
|
help='Omit the optional Project Data section '
|
||||||
|
'(song / instrument / sample names)')
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
set_verbose(args.verbose)
|
set_verbose(args.verbose)
|
||||||
@@ -408,7 +427,7 @@ def main():
|
|||||||
vprint(f" songLen={mon['song_len']}, voices={mon['num_voices']}, "
|
vprint(f" songLen={mon['song_len']}, voices={mon['num_voices']}, "
|
||||||
f"patterns={mon['n_patterns']}, orders={len(mon['order_list'])}")
|
f"patterns={mon['n_patterns']}, orders={len(mon['order_list'])}")
|
||||||
|
|
||||||
taud = assemble_taud(mon)
|
taud = assemble_taud(mon, with_project_data=not args.no_project_data)
|
||||||
|
|
||||||
with open(args.output, 'wb') as f:
|
with open(args.output, 'wb') as f:
|
||||||
f.write(taud)
|
f.write(taud)
|
||||||
|
|||||||
48
s3m2taud.py
48
s3m2taud.py
@@ -44,6 +44,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -718,7 +719,8 @@ 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) -> bytes:
|
def assemble_taud(h: S3MHeader, instruments: list, patterns: list,
|
||||||
|
with_project_data: bool = True) -> bytes:
|
||||||
# Determine active channels (bit7 clear = enabled)
|
# Determine active channels (bit7 clear = enabled)
|
||||||
active_channels = [i for i, cs in enumerate(h.channel_settings)
|
active_channels = [i for i, cs in enumerate(h.channel_settings)
|
||||||
if i < 32 and not (cs & 0x80)][:NUM_VOICES]
|
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
|
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
||||||
num_taud_pats = P * C
|
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]
|
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||||
header = (
|
|
||||||
TAUD_MAGIC +
|
|
||||||
bytes([TAUD_VERSION, 1]) +
|
|
||||||
struct.pack('<I', comp_size) +
|
|
||||||
b'\x00\x00\x00\x00' +
|
|
||||||
sig
|
|
||||||
)
|
|
||||||
assert len(header) == TAUD_HEADER_SIZE
|
|
||||||
|
|
||||||
# Pattern bin: for each s3m pattern, for each active channel, 512 bytes
|
# Pattern bin: for each s3m pattern, for each active channel, 512 bytes
|
||||||
vprint(" building pattern bin…")
|
vprint(" building pattern bin…")
|
||||||
@@ -835,7 +828,34 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
|||||||
)
|
)
|
||||||
assert len(song_table) == TAUD_SONG_ENTRY
|
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('<I', comp_size) +
|
||||||
|
struct.pack('<I', proj_off) +
|
||||||
|
sig
|
||||||
|
)
|
||||||
|
assert len(header) == TAUD_HEADER_SIZE
|
||||||
|
|
||||||
|
return header + compressed + song_table + pat_comp + cue_comp + proj_data
|
||||||
|
|
||||||
|
|
||||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -847,6 +867,9 @@ def main():
|
|||||||
ap.add_argument('output', help='Output .taud file')
|
ap.add_argument('output', help='Output .taud file')
|
||||||
ap.add_argument('-v', '--verbose', action='store_true',
|
ap.add_argument('-v', '--verbose', action='store_true',
|
||||||
help='Print conversion details to stderr')
|
help='Print conversion details to stderr')
|
||||||
|
ap.add_argument('--no-project-data', action='store_true',
|
||||||
|
help='Omit the optional Project Data section '
|
||||||
|
'(song / instrument / sample names)')
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
set_verbose(args.verbose)
|
set_verbose(args.verbose)
|
||||||
@@ -862,7 +885,8 @@ def main():
|
|||||||
instruments = parse_instruments(data, h)
|
instruments = parse_instruments(data, h)
|
||||||
patterns = parse_patterns(data, h)
|
patterns = parse_patterns(data, h)
|
||||||
|
|
||||||
taud = assemble_taud(h, instruments, patterns)
|
taud = assemble_taud(h, instruments, patterns,
|
||||||
|
with_project_data=not args.no_project_data)
|
||||||
|
|
||||||
with open(args.output, 'wb') as f:
|
with open(args.output, 'wb') as f:
|
||||||
f.write(taud)
|
f.write(taud)
|
||||||
|
|||||||
@@ -411,6 +411,96 @@ def encode_song_entry(song_offset: int, num_voices: int, num_patterns: int,
|
|||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
# ── Project Data section (terranmon.txt:2601+) ───────────────────────────────
|
||||||
|
|
||||||
|
PROJECT_DATA_MAGIC = bytes([0x1E, 0x54, 0x61, 0x75, 0x64, 0x50, 0x72, 0x4A]) # \x1ETaudPrJ
|
||||||
|
PROJECT_DATA_HEADER_SIZE = 16 # 8-byte magic + 8 reserved
|
||||||
|
|
||||||
|
|
||||||
|
def _name_table_blob(names) -> 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('<I', len(payload)) + payload)
|
||||||
|
|
||||||
|
if project_name:
|
||||||
|
add(b'PNam', project_name.encode('utf-8', 'replace'))
|
||||||
|
if author:
|
||||||
|
add(b'PCom', author.encode('utf-8', 'replace'))
|
||||||
|
if copyright_str:
|
||||||
|
add(b'PCpr', copyright_str.encode('utf-8', 'replace'))
|
||||||
|
|
||||||
|
add(b'INam', _name_table_blob(instrument_names))
|
||||||
|
add(b'SNam', _name_table_blob(sample_names))
|
||||||
|
add(b'pNam', _name_table_blob(pattern_names))
|
||||||
|
|
||||||
|
if song_metadata:
|
||||||
|
smet = bytearray()
|
||||||
|
for entry in song_metadata:
|
||||||
|
idx = entry.get('index', 0) & 0xFF
|
||||||
|
notation = entry.get('notation', 0) & 0xFFFF
|
||||||
|
beat_pri = entry.get('beat_pri', 4) & 0xFF
|
||||||
|
beat_sec = entry.get('beat_sec', 16) & 0xFF
|
||||||
|
name_b = entry.get('name', '').encode('utf-8', 'replace') + b'\x00'
|
||||||
|
comp_b = entry.get('composer', '').encode('utf-8', 'replace') + b'\x00'
|
||||||
|
copr_b = entry.get('copyright', '').encode('utf-8', 'replace') + b'\x00'
|
||||||
|
payload = (struct.pack('<HBB', notation, beat_pri, beat_sec)
|
||||||
|
+ name_b + comp_b + copr_b)
|
||||||
|
smet.append(idx)
|
||||||
|
smet += struct.pack('<I', len(payload))
|
||||||
|
smet += payload
|
||||||
|
add(b'sMet', bytes(smet))
|
||||||
|
|
||||||
|
if not sections:
|
||||||
|
return b''
|
||||||
|
|
||||||
|
return PROJECT_DATA_MAGIC + b'\x00' * 8 + b''.join(sections)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sample normalisation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def normalise_sample(raw: bytes, signed: bool, is_16bit: bool,
|
def normalise_sample(raw: bytes, signed: bool, is_16bit: bool,
|
||||||
is_stereo: bool, name: str) -> bytes:
|
is_stereo: bool, name: str) -> bytes:
|
||||||
"""Return unsigned 8-bit mono sample bytes, downmixing/depthing as needed."""
|
"""Return unsigned 8-bit mono sample bytes, downmixing/depthing as needed."""
|
||||||
|
|||||||
50
xm2taud.py
50
xm2taud.py
@@ -59,6 +59,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1245,7 +1246,8 @@ def _active_channels_xm(h: XMHeader, patterns: list) -> list:
|
|||||||
|
|
||||||
# ── Main assembly ─────────────────────────────────────────────────────────────
|
# ── 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
|
# 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
|
# 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).
|
# (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 ─────────────────────────────────────────────────
|
# ── Header / song table ─────────────────────────────────────────────────
|
||||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
||||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||||
header = (
|
|
||||||
TAUD_MAGIC +
|
|
||||||
bytes([TAUD_VERSION, 1]) +
|
|
||||||
struct.pack('<I', comp_size) +
|
|
||||||
b'\x00\x00\x00\x00' +
|
|
||||||
sig
|
|
||||||
)
|
|
||||||
assert len(header) == TAUD_HEADER_SIZE
|
|
||||||
|
|
||||||
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
|
||||||
cue_comp = compress_blob(bytes(sheet), "cue sheet")
|
cue_comp = compress_blob(bytes(sheet), "cue sheet")
|
||||||
@@ -1448,7 +1442,37 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes:
|
|||||||
)
|
)
|
||||||
assert len(song_table) == TAUD_SONG_ENTRY
|
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('<I', comp_size) +
|
||||||
|
struct.pack('<I', proj_off) +
|
||||||
|
sig
|
||||||
|
)
|
||||||
|
assert len(header) == TAUD_HEADER_SIZE
|
||||||
|
|
||||||
|
return header + compressed + song_table + pat_comp + cue_comp + proj_data
|
||||||
|
|
||||||
|
|
||||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -1460,6 +1484,9 @@ def main():
|
|||||||
ap.add_argument('input', help='Input .xm file')
|
ap.add_argument('input', help='Input .xm file')
|
||||||
ap.add_argument('output', help='Output .taud file')
|
ap.add_argument('output', help='Output .taud file')
|
||||||
ap.add_argument('-v', '--verbose', action='store_true')
|
ap.add_argument('-v', '--verbose', action='store_true')
|
||||||
|
ap.add_argument('--no-project-data', action='store_true',
|
||||||
|
help='Omit the optional Project Data section '
|
||||||
|
'(song / instrument / sample names)')
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
set_verbose(args.verbose)
|
set_verbose(args.verbose)
|
||||||
|
|
||||||
@@ -1478,7 +1505,8 @@ def main():
|
|||||||
patterns, after_patterns = parse_patterns(data, h, patterns_off)
|
patterns, after_patterns = parse_patterns(data, h, patterns_off)
|
||||||
instruments, _after = parse_instruments(data, h, after_patterns)
|
instruments, _after = parse_instruments(data, h, after_patterns)
|
||||||
|
|
||||||
taud = assemble_taud(h, patterns, instruments)
|
taud = assemble_taud(h, patterns, instruments,
|
||||||
|
with_project_data=not args.no_project_data)
|
||||||
|
|
||||||
with open(args.output, 'wb') as f:
|
with open(args.output, 'wb') as f:
|
||||||
f.write(taud)
|
f.write(taud)
|
||||||
|
|||||||
Reference in New Issue
Block a user