mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-19 02:44:04 +09:00
938 lines
40 KiB
Python
938 lines
40 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
taud_inspect.py — pretty-printer / debugger for Taud (.taud / .tsii / .tpif) files.
|
||
|
||
Parses the Taud serialisation format (terranmon.txt §"Taud serialisation format",
|
||
§"Audio Adapter", TAUD_NOTE_EFFECTS.md) and dumps:
|
||
|
||
* DETAILED — every sample + instrument: base sample fields, all four envelopes
|
||
(vol / pan / pitch / filter, both pf-env slots routed by their
|
||
m-bit), the full instrument record, Ixmp extra-sample patches with
|
||
their pitch×volume assignment rectangles and per-patch
|
||
envelopes/extra-base blocks, and Metainstrument layering (per-layer
|
||
rectangle, mix-dB, detune).
|
||
* BRIEF — song table, pattern/cue overview, project-data metadata
|
||
(names, author, copyright, message, per-song metadata, notations).
|
||
|
||
Usage:
|
||
python3 taud_inspect.py FILE.taud [options]
|
||
|
||
Options:
|
||
--song N Which song to summarise patterns/cues for (default: all).
|
||
--inst N Restrict instrument detail to slot N (decimal or 0x..).
|
||
--no-instruments Skip the (verbose) instrument/sample section.
|
||
--samples Include raw PCM stats (peak/RMS) for each sample region.
|
||
--pattern P Dump every non-empty row of pattern P (decimal or 0x..).
|
||
--cues Dump the full cue/order list for the selected song(s).
|
||
--cue C Dump one cue's voice→pattern map (decimal or 0x..).
|
||
--max-cues N Limit the cue overview to the first N cues (default 64).
|
||
|
||
Container kinds (top two bits of the version byte):
|
||
00 full .taud — sample+inst image + song table + patterns
|
||
10 .tsii — sample+inst image only (numSongs == 0)
|
||
11 .tpif — patterns only (sample+inst section absent)
|
||
"""
|
||
|
||
import argparse
|
||
import gzip
|
||
import struct
|
||
import sys
|
||
|
||
try:
|
||
import zstandard as zstd
|
||
except ImportError:
|
||
zstd = None
|
||
|
||
|
||
# ── compression ──────────────────────────────────────────────────────────────
|
||
|
||
def decomp(b):
|
||
"""Auto-detect zstd / gzip by 4-byte magic and decompress (terranmon §File Structure)."""
|
||
if b[:4] == bytes([0x28, 0xB5, 0x2F, 0xFD]):
|
||
if zstd is None:
|
||
raise RuntimeError("zstd blob but the 'zstandard' module is not installed")
|
||
return zstd.ZstdDecompressor().decompress(b, max_output_size=64 * 1024 * 1024)
|
||
if b[:2] == bytes([0x1F, 0x8B]):
|
||
return gzip.decompress(b)
|
||
raise ValueError("unknown compression magic %r" % (b[:4],))
|
||
|
||
|
||
# ── little-endian readers ────────────────────────────────────────────────────
|
||
|
||
def u8(d, o): return d[o]
|
||
def s8(d, o): return d[o] - 256 if d[o] >= 128 else d[o]
|
||
def u16(d, o): return struct.unpack_from('<H', d, o)[0]
|
||
def s16(d, o): return struct.unpack_from('<h', d, o)[0]
|
||
def u24(d, o): return d[o] | (d[o + 1] << 8) | (d[o + 2] << 16)
|
||
def u32(d, o): return struct.unpack_from('<I', d, o)[0]
|
||
|
||
|
||
# ── unit conversions ─────────────────────────────────────────────────────────
|
||
|
||
NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
||
|
||
# 4096-TET: octave = 4096 units; C at octave 0 = 0x1000 (terranmon.txt §Play Data).
|
||
# noteVal * 12 / 4096 gives a MIDI-style semitone index (C4 = 0x5000 -> 60).
|
||
def note_name(nv):
|
||
midi = nv * 12.0 / 4096.0
|
||
n = round(midi)
|
||
cents = (midi - n) * 100.0
|
||
idx = n % 12
|
||
octv = n // 12 - 1
|
||
s = "%s%d" % (NOTE_NAMES[idx], octv)
|
||
if abs(cents) >= 1.0:
|
||
s += "%+dc" % round(cents)
|
||
return s
|
||
|
||
# Pattern-cell note sentinels (terranmon.txt §Play Data "Special values").
|
||
def cell_note(nv):
|
||
if nv == 0x0000: return "---" # no-op
|
||
if nv == 0x0001: return "===OFF" # key-off
|
||
if nv == 0x0002: return "^^^CUT" # note cut
|
||
if nv == 0x0003: return "~~~FADE" # note fade
|
||
if nv == 0x0004: return "vvvFAST" # fast fade
|
||
if 0x0010 <= nv <= 0x001F: return "Int%X" % (nv - 0x10)
|
||
if nv < 0x0020: return "?%04X" % nv # reserved
|
||
return "%s($%04X)" % (note_name(nv), nv)
|
||
|
||
|
||
# 3.5 unsigned minifloat, rebiased 2026-05-07: smallest step 1/256 s, max 15.75 s
|
||
# (terranmon.txt §"Table of 3.5 Minifloat values"). High 3 bits = exponent,
|
||
# low 5 bits = mantissa. exp 0 is the denormal range.
|
||
def minifloat35(b):
|
||
exp = (b >> 5) & 0x07
|
||
man = b & 0x1F
|
||
if exp == 0:
|
||
return man / 256.0
|
||
return (2 ** (exp - 1)) * (32 + man) / 256.0
|
||
|
||
|
||
# "Perceptually Significant Octet to Decibel Table" (terranmon.txt). 159 = 0 dB.
|
||
def octet_to_db(o):
|
||
if o <= 0:
|
||
return float('-inf')
|
||
db = 0.0
|
||
if o >= 159:
|
||
for x in range(160, o + 1):
|
||
db += 0.125 if x <= 207 else (0.25 if x <= 231 else 0.5)
|
||
else:
|
||
for x in range(158, o - 1, -1):
|
||
db -= 0.125 if x >= 111 else (0.25 if x >= 87 else (0.5 if x >= 63 else 1.0))
|
||
return db
|
||
|
||
def db_str(o):
|
||
d = octet_to_db(o)
|
||
if d == float('-inf'):
|
||
return "-inf dB (silent)"
|
||
return "%+.3g dB" % d
|
||
|
||
|
||
# Fadeout field semantics (terranmon.txt instrument byte 172-173).
|
||
def fadeout_str(v):
|
||
if v == 0:
|
||
return "0 (no fade)"
|
||
if v >= 1024:
|
||
return "%d (1-tick cut)" % v
|
||
# graduated fade: completes in 1024/v ticks
|
||
return "%d (~%d ticks; %.1f s @ 50 Hz)" % (v, round(1024 / v), (1024 / v) / 50.0)
|
||
|
||
|
||
# ── envelope decode ──────────────────────────────────────────────────────────
|
||
|
||
def parse_env_word_loop(w):
|
||
"""LOOP word 0b 00P_sssss_Xcb_eeeee (terranmon §instrument 15/17/19/197)."""
|
||
return {
|
||
'end': w & 0x1F,
|
||
'b': (w >> 5) & 1, # enable loop wrap
|
||
'c': (w >> 6) & 1, # carry
|
||
'bit7': (w >> 7) & 1, # pan: use-default-pan; pf: m (0=pitch,1=filter)
|
||
'start': (w >> 8) & 0x1F,
|
||
'P': (w >> 13) & 1, # envelope present
|
||
'raw': w,
|
||
}
|
||
|
||
def parse_env_word_sustain(w):
|
||
"""SUSTAIN word 0b 000_sssss_00b_eeeee (terranmon §instrument 189/191/193/199)."""
|
||
return {'end': w & 0x1F, 'b': (w >> 5) & 1, 'start': (w >> 8) & 0x1F, 'raw': w}
|
||
|
||
def parse_env_nodes(d, off):
|
||
"""25 nodes of (value, time-minifloat). Returns (nodes, authored_last_index).
|
||
|
||
The encoder pads unused slots by repeating the last authored node's VALUE with
|
||
time 0 (taud_common._encode_env_block). The authored portion therefore ends at
|
||
the last index before that trailing constant-value, zero-time pad run. NOTE: a
|
||
zero-time node is NOT necessarily a terminator — for pitch/filter envelopes it is
|
||
an instant transition the engine skips (advancePfRole); only the final authored
|
||
node holds. So we trim the pad run rather than stopping at the first zero-time."""
|
||
nodes = []
|
||
for i in range(25):
|
||
val = d[off + i * 2]
|
||
mf = d[off + i * 2 + 1]
|
||
nodes.append((val, mf, minifloat35(mf)))
|
||
last = 24
|
||
while last > 0 and nodes[last][1] == 0 and nodes[last][0] == nodes[last - 1][0]:
|
||
last -= 1
|
||
return nodes, last
|
||
|
||
|
||
def fmt_env(loop, sus, nodes, term, role, valfmt=lambda v: "%3d" % v):
|
||
"""Pretty multi-line envelope description."""
|
||
out = []
|
||
flags = []
|
||
if loop['P']:
|
||
flags.append("PRESENT")
|
||
else:
|
||
flags.append("absent(P=0)")
|
||
if loop['b']:
|
||
flags.append("loop[%d..%d]" % (loop['start'], loop['end']))
|
||
if loop['c']:
|
||
flags.append("carry")
|
||
if sus['b']:
|
||
if sus['start'] == sus['end']:
|
||
flags.append("sustain-point@%d" % sus['start'])
|
||
else:
|
||
flags.append("sustain-loop[%d..%d]" % (sus['start'], sus['end']))
|
||
out.append("%-14s %s" % (role + ":", " ".join(flags)))
|
||
if not loop['P']:
|
||
return "\n".join(out) # absent envelope: node array is ignored by the engine
|
||
# show authored nodes up to the last authored index (or the furthest wrap index)
|
||
last = max(term, loop['end'] if loop['b'] else 0, sus['end'] if sus['b'] else 0)
|
||
last = min(last, 24)
|
||
parts = []
|
||
cum = 0.0
|
||
for i in range(last + 1):
|
||
val, mf, sec = nodes[i]
|
||
cum += sec
|
||
tag = ""
|
||
if i == term:
|
||
tag = "H" # final authored node: holds here if no active wrap
|
||
elif mf == 0:
|
||
tag = "!" # zero-duration: instant jump (skipped for pitch/filter)
|
||
parts.append("[%d]%s@%.3fs%s" % (i, valfmt(val), cum, ("(" + tag + ")") if tag else ""))
|
||
pad = 25 - (last + 1)
|
||
line = " " + " ".join(parts)
|
||
if pad > 0:
|
||
line += " (+%d pad)" % pad
|
||
out.append(line)
|
||
return "\n".join(out)
|
||
|
||
|
||
# ── instrument record decode ─────────────────────────────────────────────────
|
||
|
||
NNA_NAMES = {0: "Note Off", 1: "Note Cut", 2: "Continue", 3: "Note Fade"}
|
||
VIBWAVE = {0: "sine", 1: "ramp-down", 2: "square", 3: "random", 4: "ramp-up"}
|
||
LOOPMODE = {0: "no loop", 1: "loop", 2: "ping-pong", 3: "oneshot"}
|
||
DCT_NAMES = {0: "off", 1: "note", 2: "sample", 3: "instrument"}
|
||
DCA_NAMES = {0: "note cut", 1: "note off", 2: "note fade"}
|
||
|
||
|
||
def is_meta(rec):
|
||
return (u32(rec, 0) >> 16) == 0xFFFF
|
||
|
||
|
||
def parse_meta(rec):
|
||
"""Metainstrument: bytes 0..3 alias the sample pointer (0xFFFF_ll_tt)."""
|
||
typ = rec[0]
|
||
count = rec[1]
|
||
layers = []
|
||
for i in range(count):
|
||
o = 4 + i * 10
|
||
if o + 10 > 256:
|
||
break
|
||
layers.append({
|
||
'inst': rec[o],
|
||
'mixvol': rec[o + 1],
|
||
'detune': s16(rec, o + 2),
|
||
'pitch_start': u16(rec, o + 4),
|
||
'pitch_end': u16(rec, o + 6),
|
||
'vol_start': rec[o + 8],
|
||
'vol_end': rec[o + 9],
|
||
})
|
||
return {'type': typ >> 1, 'strict': typ & 1, 'count': count, 'layers': layers}
|
||
|
||
|
||
def parse_instrument(rec):
|
||
"""Decode a 256-byte normal instrument record into a dict."""
|
||
sf_mode = (rec[173] >> 4) & 1 # filter interpretation mode (m bit)
|
||
fadeout = rec[172] | ((rec[173] & 0x0F) << 8)
|
||
flag = rec[186]
|
||
inst = {
|
||
'sample_ptr': u32(rec, 0),
|
||
'sample_len': u16(rec, 4),
|
||
'rate': u16(rec, 6),
|
||
'play_start': u16(rec, 8),
|
||
'loop_start': u16(rec, 10),
|
||
'loop_end': u16(rec, 12),
|
||
'loop_mode': rec[14] & 0x03,
|
||
'loop_sustain': (rec[14] >> 2) & 1,
|
||
'igv': rec[171],
|
||
'fadeout': fadeout,
|
||
'sf_filter': sf_mode,
|
||
'vol_swing': rec[174],
|
||
'vib_speed': rec[175],
|
||
'vib_sweep': rec[176],
|
||
'default_pan': rec[177],
|
||
'ppc': u16(rec, 178),
|
||
'pps': s8(rec, 180),
|
||
'pan_swing': rec[181],
|
||
'detune': s16(rec, 184),
|
||
'nna': flag & 0x03,
|
||
'vib_wave': (flag >> 2) & 0x07,
|
||
'key_lift': (flag >> 5) & 1,
|
||
'vib_depth': rec[187],
|
||
'vib_rate': rec[188],
|
||
'dct': rec[195] & 0x03,
|
||
'dca': (rec[195] >> 2) & 0x03,
|
||
'default_note_vol': rec[196],
|
||
'init_atten': rec[251],
|
||
}
|
||
if sf_mode:
|
||
inst['cutoff'] = (rec[182] << 8) | rec[252] # absolute cents
|
||
inst['resonance'] = (rec[183] << 8) | rec[253] # centibels
|
||
else:
|
||
inst['cutoff'] = rec[182] # IT 0..254 / 255=off
|
||
inst['resonance'] = rec[183]
|
||
|
||
# envelopes
|
||
inst['vol_env'] = (parse_env_word_loop(u16(rec, 15)),
|
||
parse_env_word_sustain(u16(rec, 189)),
|
||
*parse_env_nodes(rec, 21))
|
||
inst['pan_env'] = (parse_env_word_loop(u16(rec, 17)),
|
||
parse_env_word_sustain(u16(rec, 191)),
|
||
*parse_env_nodes(rec, 71))
|
||
inst['pf1'] = (parse_env_word_loop(u16(rec, 19)),
|
||
parse_env_word_sustain(u16(rec, 193)),
|
||
*parse_env_nodes(rec, 121))
|
||
inst['pf2'] = (parse_env_word_loop(u16(rec, 197)),
|
||
parse_env_word_sustain(u16(rec, 199)),
|
||
*parse_env_nodes(rec, 201))
|
||
return inst
|
||
|
||
|
||
# ── Ixmp patch decode ────────────────────────────────────────────────────────
|
||
|
||
IXMP_VER_I, IXMP_VER_V, IXMP_VER_P, IXMP_VER_F, IXMP_VER_PITCH, IXMP_VER_X = \
|
||
0x01, 0x02, 0x04, 0x08, 0x10, 0x80
|
||
|
||
|
||
def parse_ixmp_patch(d, o, end):
|
||
"""Decode one variable-length Ixmp patch. Returns (patch_dict, next_offset)."""
|
||
ver = d[o]
|
||
common = struct.unpack_from('<BHHBBIHHHHHhBBBBBBBB', d, o)
|
||
p = {
|
||
'ver': ver,
|
||
'pitch_start': common[1], 'pitch_end': common[2],
|
||
'vol_start': common[3], 'vol_end': common[4],
|
||
'sample_ptr': common[5], 'sample_len': common[6],
|
||
'play_start': common[7], 'loop_start': common[8], 'loop_end': common[9],
|
||
'rate': common[10], 'detune': common[11],
|
||
'loop_mode': common[12] & 0x03, 'loop_sustain': (common[12] >> 2) & 1,
|
||
'default_pan': common[13], 'default_note_vol': common[14],
|
||
'vib_speed': common[15], 'vib_sweep': common[16],
|
||
'vib_depth': common[17], 'vib_rate': common[18], 'vib_wave': common[19],
|
||
}
|
||
q = o + 31
|
||
if ver & IXMP_VER_X:
|
||
flags1 = u32(d, q)
|
||
flags2 = u32(d, q + 4)
|
||
p['x'] = {
|
||
'sf_filter': flags1 & 1,
|
||
'flags1': flags1, 'flags2': flags2,
|
||
'fadeout': u16(d, q + 8),
|
||
'cutoff': u16(d, q + 10),
|
||
'resonance': u16(d, q + 12),
|
||
'init_atten': d[q + 14],
|
||
}
|
||
q += 15
|
||
for key, bit in (('vol_env', IXMP_VER_V), ('pan_env', IXMP_VER_P),
|
||
('filter_env', IXMP_VER_F), ('pitch_env', IXMP_VER_PITCH)):
|
||
if ver & bit:
|
||
loop = parse_env_word_loop(u16(d, q))
|
||
sus = parse_env_word_sustain(u16(d, q + 2))
|
||
nodes, term = parse_env_nodes(d, q + 4)
|
||
p[key] = (loop, sus, nodes, term)
|
||
q += 54
|
||
return p, q
|
||
|
||
|
||
def parse_ixmp_section(payload):
|
||
"""One Ixmp project-data section payload -> {instId: [patch,...]}."""
|
||
res = {}
|
||
q = 0
|
||
end = len(payload)
|
||
while q + 4 <= end:
|
||
inst_id = payload[q]
|
||
cnt = u24(payload, q + 1)
|
||
q += 4
|
||
patches = []
|
||
ok = True
|
||
for _ in range(cnt):
|
||
if q + 31 > end:
|
||
ok = False
|
||
break
|
||
patch, nq = parse_ixmp_patch(payload, q, end)
|
||
if nq > end:
|
||
ok = False
|
||
break
|
||
patches.append(patch)
|
||
q = nq
|
||
res.setdefault(inst_id, []).extend(patches)
|
||
if not ok:
|
||
break
|
||
return res
|
||
|
||
|
||
# ── project data ─────────────────────────────────────────────────────────────
|
||
|
||
PROJ_MAGIC = bytes([0x1E, 0x54, 0x61, 0x75, 0x64, 0x50, 0x72, 0x4A]) # \x1ETaudPrJ
|
||
|
||
|
||
def split_names(blob):
|
||
"""0x1E-separated string table (INam / SNam / pNam)."""
|
||
if not blob:
|
||
return []
|
||
return [s.decode('utf-8', 'replace') for s in blob.split(b'\x1E')]
|
||
|
||
|
||
def parse_project_data(data, proj_off):
|
||
"""Walk FourCC sections. Returns dict of {fourcc: [payload,...]} plus parsed extras."""
|
||
sections = {}
|
||
if proj_off == 0 or proj_off + 16 > len(data):
|
||
return sections
|
||
if data[proj_off:proj_off + 8] != PROJ_MAGIC:
|
||
return sections
|
||
p = proj_off + 16
|
||
while p + 8 <= len(data):
|
||
fourcc = data[p:p + 4].decode('latin-1')
|
||
seclen = u32(data, p + 4)
|
||
payload = data[p + 8:p + 8 + seclen]
|
||
if p + 8 + seclen > len(data):
|
||
break
|
||
sections.setdefault(fourcc, []).append(payload)
|
||
p += 8 + seclen
|
||
return sections
|
||
|
||
|
||
def parse_smet(payload):
|
||
"""sMet — per-song metadata (terranmon §Project Data sMet)."""
|
||
songs = []
|
||
p = 0
|
||
n = len(payload)
|
||
while p + 5 <= n:
|
||
idx = payload[p]
|
||
size = u32(payload, p + 1)
|
||
body = payload[p + 5:p + 5 + size]
|
||
p += 5 + size
|
||
notation = u16(body, 0) if len(body) >= 2 else 0
|
||
beat_pri = body[2] if len(body) >= 3 else 0
|
||
beat_sec = body[3] if len(body) >= 4 else 0
|
||
# three null-terminated UTF-8 strings: name, composer, copyright
|
||
rest = body[4:]
|
||
strs = rest.split(b'\x00')
|
||
name = strs[0].decode('utf-8', 'replace') if len(strs) > 0 else ''
|
||
comp = strs[1].decode('utf-8', 'replace') if len(strs) > 1 else ''
|
||
copyr = strs[2].decode('utf-8', 'replace') if len(strs) > 2 else ''
|
||
songs.append({'idx': idx, 'notation': notation, 'beat_pri': beat_pri,
|
||
'beat_sec': beat_sec, 'name': name, 'composer': comp,
|
||
'copyright': copyr})
|
||
return songs
|
||
|
||
|
||
# ── pattern / cue decode ─────────────────────────────────────────────────────
|
||
|
||
PATTERN_SIZE = 512
|
||
CUE_SIZE = 32
|
||
|
||
EFFECT_NAMES = {
|
||
'0': "arpeggio", '1': "global-behaviour", '5': "filter cutoff",
|
||
'6': "resonance", '7': "pattern ditto", '8': "bitcrusher", '9': "overdrive",
|
||
'A': "set speed", 'B': "jump to cue", 'C': "break to row", 'D': "vol slide",
|
||
'E': "pitch down", 'F': "pitch up", 'G': "tone porta", 'H': "vibrato",
|
||
'I': "tremor", 'J': "arpeggio(micro)", 'K': "vib+volslide", 'L': "porta+volslide",
|
||
'M': "set chan vol", 'N': "chan vol slide", 'O': "sample offset",
|
||
'P': "chan pan slide", 'Q': "retrigger", 'R': "tremolo", 'S': "special",
|
||
'T': "tempo", 'U': "fine vibrato", 'V': "set global vol", 'W': "global vol slide",
|
||
'X': "fine set pan", 'Y': "panbrello",
|
||
}
|
||
|
||
SEL_NAMES = {0: "set", 1: "up", 2: "dn", 3: "fine"}
|
||
|
||
# S $Xy.. subcommands (TAUD_NOTE_EFFECTS.md §S).
|
||
S_SUB = {
|
||
0x1: "glissando", 0x2: "finetune", 0x3: "vib-wave", 0x4: "trem-wave",
|
||
0x5: "panb-wave", 0x6: "fine-pat-delay", 0x7: "note/inst action",
|
||
0x8: "set-pan", 0xB: "pattern-loop", 0xC: "note-cut", 0xD: "note-delay",
|
||
0xE: "pattern-delay", 0xF: "funk-repeat",
|
||
}
|
||
|
||
|
||
def describe_effect(sym, arg):
|
||
"""Human-readable note for one effect cell."""
|
||
if sym == 'S':
|
||
sub = (arg >> 12) & 0xF
|
||
if sub == 0x8:
|
||
return "set pan = $%02X" % (arg & 0xFF)
|
||
if sub == 0xF:
|
||
return "funk-repeat $%03X" % (arg & 0xFFF)
|
||
return "%s %d" % (S_SUB.get(sub, "S?"), (arg >> 8) & 0xF)
|
||
return EFFECT_NAMES.get(sym, "")
|
||
|
||
|
||
def op_symbol(op):
|
||
if op < 10:
|
||
return str(op)
|
||
if op <= 35:
|
||
return chr(55 + op) # 10 -> 'A'
|
||
return "?%02X" % op
|
||
|
||
|
||
def vol_col(b):
|
||
sel = b >> 6
|
||
val = b & 0x3F
|
||
if sel == 3 and val == 0:
|
||
return "--" # no-op (3.00)
|
||
return "%s%02X" % (SEL_NAMES[sel][0], val)
|
||
|
||
|
||
def cue_patterns(cue_bin, ci):
|
||
"""Decode one cue's 20 voice→pattern numbers + instruction bytes."""
|
||
e = cue_bin[ci * 32:ci * 32 + 32]
|
||
pats = []
|
||
for v in range(20):
|
||
bi = v // 2
|
||
if v % 2 == 0:
|
||
lo = (e[bi] >> 4) & 0xF
|
||
mi = (e[10 + bi] >> 4) & 0xF
|
||
hi = (e[20 + bi] >> 4) & 0xF
|
||
else:
|
||
lo = e[bi] & 0xF
|
||
mi = e[10 + bi] & 0xF
|
||
hi = e[20 + bi] & 0xF
|
||
pats.append((hi << 8) | (mi << 4) | lo)
|
||
return pats, e[30], e[31]
|
||
|
||
|
||
def cue_instruction(b30, b31):
|
||
"""Decode cue instruction bytes (terranmon §Cue Sheet 32768..)."""
|
||
if b30 == 0 and b31 == 0:
|
||
return None
|
||
hi = b30 & 0xF0
|
||
if hi == 0x80:
|
||
return "BACK %d" % (((b30 & 0xF) << 8) | b31)
|
||
if hi == 0x90:
|
||
return "FWD %d" % (((b30 & 0xF) << 8) | b31)
|
||
if hi == 0xF0:
|
||
return "JMP -> pat %d" % (((b30 & 0xF) << 8) | b31)
|
||
if b30 == 0x02:
|
||
return "LEN %d rows" % ((b31 & 0x3F) + 1)
|
||
if b30 == 0x01:
|
||
if (b31 & 0xC0) == 0x40:
|
||
return "HALT @ row %d" % (b31 & 0x3F)
|
||
if (b31 & 0x3F) == 0:
|
||
return "HALT"
|
||
return "FADE -> row %d" % (b31 & 0x3F)
|
||
return "?%02X%02X" % (b30, b31)
|
||
|
||
|
||
# ── output helpers ───────────────────────────────────────────────────────────
|
||
|
||
def hr(title=""):
|
||
if title:
|
||
return "\n" + "=" * 78 + "\n " + title + "\n" + "=" * 78
|
||
return "=" * 78
|
||
|
||
def sub(title):
|
||
return "\n" + "-" * 78 + "\n " + title + "\n" + "-" * 78
|
||
|
||
|
||
# ── sample stats ─────────────────────────────────────────────────────────────
|
||
|
||
def sample_stats(samplebin, ptr, length):
|
||
"""Raw PCM stats. Samples are 8-bit unsigned, centre 128 (terranmon §Sound Hardware)."""
|
||
if length <= 0 or ptr + length > len(samplebin):
|
||
return None
|
||
peak = 0
|
||
acc = 0.0
|
||
nonsilent = 0
|
||
for i in range(ptr, ptr + length):
|
||
dev = samplebin[i] - 128
|
||
a = abs(dev)
|
||
if a > peak:
|
||
peak = a
|
||
if a > 1:
|
||
nonsilent += 1
|
||
acc += dev * dev
|
||
rms = (acc / length) ** 0.5
|
||
return {'peak': peak, 'rms': rms, 'nonsilent': nonsilent}
|
||
|
||
|
||
# ── main inspection ──────────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="Inspect / debug Taud (.taud/.tsii/.tpif) files.")
|
||
ap.add_argument("file")
|
||
ap.add_argument("--song", type=int, default=None, help="only summarise this song's patterns/cues")
|
||
ap.add_argument("--inst", type=lambda x: int(x, 0), default=None, help="restrict instrument detail to slot N")
|
||
ap.add_argument("--no-instruments", action="store_true")
|
||
ap.add_argument("--samples", action="store_true", help="include raw PCM stats per sample region")
|
||
ap.add_argument("--pattern", type=lambda x: int(x, 0), default=None, help="dump rows of pattern P")
|
||
ap.add_argument("--cues", action="store_true", help="dump cue order list")
|
||
ap.add_argument("--cue", type=lambda x: int(x, 0), default=None, help="dump one cue's voice->pattern map")
|
||
ap.add_argument("--max-cues", type=int, default=64)
|
||
args = ap.parse_args()
|
||
|
||
data = open(args.file, 'rb').read()
|
||
if data[:8] != bytes([0x1F, 0x54, 0x53, 0x56, 0x4D, 0x61, 0x75, 0x64]):
|
||
sys.exit("not a Taud file (bad magic)")
|
||
|
||
version = data[8]
|
||
num_songs = data[9]
|
||
comp_size = u32(data, 10)
|
||
proj_off = u32(data, 14)
|
||
signature = data[18:32].decode('latin-1').rstrip(' \x00')
|
||
|
||
kind = version & 0xC0
|
||
kind_name = {0x00: "full .taud", 0x80: ".tsii (sample+inst only)",
|
||
0xC0: ".tpif (patterns only)"}.get(kind, "unknown")
|
||
base_ver = version & 0x3F
|
||
|
||
print(hr("CONTAINER"))
|
||
print("file : %s (%d bytes)" % (args.file, len(data)))
|
||
print("kind : %s" % kind_name)
|
||
print("format version : %d (version byte 0x%02X)" % (base_ver, version))
|
||
print("songs : %d" % num_songs)
|
||
print("sample+inst comp : %d bytes" % comp_size)
|
||
print("project data offset: %s" % ("%d" % proj_off if proj_off else "0 (none)"))
|
||
print("tracker signature : %r" % signature)
|
||
|
||
# -- project data (parse first; names feed the instrument/song sections) ----
|
||
sections = parse_project_data(data, proj_off)
|
||
inam = split_names(sections['INam'][0]) if 'INam' in sections else []
|
||
snam = split_names(sections['SNam'][0]) if 'SNam' in sections else []
|
||
pnam = split_names(sections['pNam'][0]) if 'pNam' in sections else []
|
||
ixmp_by_inst = {}
|
||
for payload in sections.get('Ixmp', []):
|
||
for k, v in parse_ixmp_section(payload).items():
|
||
ixmp_by_inst.setdefault(k, []).extend(v)
|
||
smet = parse_smet(sections['sMet'][0]) if 'sMet' in sections else []
|
||
|
||
def inst_name(i):
|
||
return inam[i] if i < len(inam) and inam[i] else ""
|
||
|
||
# -- sample + instrument bin ------------------------------------------------
|
||
samplebin = None
|
||
instbin = None
|
||
if kind != 0xC0 and comp_size > 0:
|
||
blob = decomp(data[32:32 + comp_size])
|
||
SAMPLE_SIZE = 8 * 1024 * 1024
|
||
samplebin = blob[:SAMPLE_SIZE]
|
||
instbin = blob[SAMPLE_SIZE:SAMPLE_SIZE + 65536]
|
||
|
||
if instbin is not None and not args.no_instruments:
|
||
print(hr("SAMPLES & INSTRUMENTS"))
|
||
present = []
|
||
for i in range(256):
|
||
rec = instbin[i * 256:i * 256 + 256]
|
||
has_ixmp = i in ixmp_by_inst
|
||
if is_meta(rec):
|
||
present.append(i)
|
||
elif u32(rec, 0) != 0 or u16(rec, 4) != 0 or has_ixmp or inst_name(i):
|
||
present.append(i)
|
||
if args.inst is not None:
|
||
present = [i for i in present if i == args.inst]
|
||
print("present instruments: %d %s" % (len(present),
|
||
"(filtered to slot %d)" % args.inst if args.inst is not None else ""))
|
||
|
||
# sample-bin high-water mark
|
||
hi = 0
|
||
for i in range(256):
|
||
rec = instbin[i * 256:i * 256 + 256]
|
||
if not is_meta(rec):
|
||
hi = max(hi, u32(rec, 0) + u16(rec, 4))
|
||
for plist in ixmp_by_inst.values():
|
||
for p in plist:
|
||
hi = max(hi, p['sample_ptr'] + p['sample_len'])
|
||
print("sample-bin used : up to %d bytes (0x%X) of 8 MB pool" % (hi, hi))
|
||
|
||
for i in present:
|
||
rec = instbin[i * 256:i * 256 + 256]
|
||
nm = inst_name(i)
|
||
print(sub("INSTRUMENT %d (0x%02X)%s" % (i, i, (" \"%s\"" % nm) if nm else "")))
|
||
|
||
if is_meta(rec):
|
||
m = parse_meta(rec)
|
||
print(" METAINSTRUMENT type=%d strict-layering=%s layers=%d"
|
||
% (m['type'], "yes" if m['strict'] else "no (legacy)", m['count']))
|
||
print(" %-3s %-26s %-9s %-7s %-26s %s"
|
||
% ("#", "layer instrument", "mix", "detune", "pitch range", "vol range"))
|
||
for li, L in enumerate(m['layers']):
|
||
lnm = inst_name(L['inst'])
|
||
label = "inst %d%s" % (L['inst'], (' "%s"' % lnm) if lnm else "")
|
||
pr = "%s..%s" % (note_name(L['pitch_start']), note_name(L['pitch_end']))
|
||
mix = octet_to_db(L['mixvol'])
|
||
mixs = "-inf" if mix == float('-inf') else "%+.3gdB" % mix
|
||
print(" %-3d %-26s %-9s %+6d %-26s %d..%d"
|
||
% (li, label[:26], mixs, L['detune'], pr,
|
||
L['vol_start'], L['vol_end']))
|
||
continue
|
||
|
||
inst = parse_instrument(rec)
|
||
print(" BASE SAMPLE")
|
||
print(" pointer=0x%06X length=%d rate@C4=%d Hz"
|
||
% (inst['sample_ptr'], inst['sample_len'], inst['rate']))
|
||
print(" play_start=%d loop=%d..%d loop_mode=%s%s"
|
||
% (inst['play_start'], inst['loop_start'], inst['loop_end'],
|
||
LOOPMODE[inst['loop_mode']],
|
||
" (sustain-loop)" if inst['loop_sustain'] else ""))
|
||
if inst['detune']:
|
||
print(" sample detune=%+d (4096-TET units)" % inst['detune'])
|
||
if args.samples and samplebin is not None:
|
||
st = sample_stats(samplebin, inst['sample_ptr'], inst['sample_len'])
|
||
if st:
|
||
print(" pcm: peak=%d/128 rms=%.1f non-silent=%d/%d"
|
||
% (st['peak'], st['rms'], st['nonsilent'], inst['sample_len']))
|
||
|
||
print(" INSTRUMENT")
|
||
print(" global volume=%d default note vol=%d(->%d/63) init atten=%s"
|
||
% (inst['igv'], inst['default_note_vol'],
|
||
round(inst['default_note_vol'] * 63 / 255),
|
||
db_str(inst['init_atten']) if inst['init_atten'] else "unity(0)"))
|
||
print(" fadeout=%s" % fadeout_str(inst['fadeout']))
|
||
print(" NNA=%s key-lift=%s DCT=%s DCA=%s"
|
||
% (NNA_NAMES[inst['nna']], "yes" if inst['key_lift'] else "no",
|
||
DCT_NAMES[inst['dct']], DCA_NAMES[inst['dca']]))
|
||
print(" default pan=%d pitch-pan centre=%s sep=%+d swing(vol/pan)=%d/%d"
|
||
% (inst['default_pan'], note_name(inst['ppc']), inst['pps'],
|
||
inst['vol_swing'], inst['pan_swing']))
|
||
if inst['sf_filter']:
|
||
print(" filter(SF mode): cutoff=%d cents resonance=%d cB"
|
||
% (inst['cutoff'], inst['resonance']))
|
||
else:
|
||
cu = "off" if inst['cutoff'] == 255 else str(inst['cutoff'])
|
||
rz = "off" if inst['resonance'] == 255 else str(inst['resonance'])
|
||
print(" filter(IT mode): cutoff=%s resonance=%s" % (cu, rz))
|
||
print(" vibrato: wave=%s speed=%d sweep=%d depth=%d rate=%d"
|
||
% (VIBWAVE.get(inst['vib_wave'], '?'), inst['vib_speed'],
|
||
inst['vib_sweep'], inst['vib_depth'], inst['vib_rate']))
|
||
|
||
print(" ENVELOPES")
|
||
print(" " + fmt_env(*inst['vol_env'], role="volume").replace("\n", "\n "))
|
||
print(" " + fmt_env(*inst['pan_env'], role="panning").replace("\n", "\n "))
|
||
for slot, key in (("pf-slot1", 'pf1'), ("pf-slot2", 'pf2')):
|
||
loop = inst[key][0]
|
||
role = ("filter" if loop['bit7'] else "pitch") + "(" + slot + ")"
|
||
print(" " + fmt_env(*inst[key], role=role).replace("\n", "\n "))
|
||
|
||
# Ixmp patches
|
||
patches = ixmp_by_inst.get(i, [])
|
||
if patches:
|
||
print(" IXMP PATCHES (%d) — pitch×volume assignment rectangles" % len(patches))
|
||
for pi, p in enumerate(patches):
|
||
flags = []
|
||
if p['ver'] & IXMP_VER_X: flags.append("x")
|
||
if p['ver'] & IXMP_VER_V: flags.append("v")
|
||
if p['ver'] & IXMP_VER_P: flags.append("p")
|
||
if p['ver'] & IXMP_VER_F: flags.append("f")
|
||
if p['ver'] & IXMP_VER_PITCH: flags.append("P")
|
||
print(" patch %d ver=0x%02X[%s]" % (pi, p['ver'], "".join(flags) or "i"))
|
||
print(" rect: pitch %s..%s ($%04X..$%04X) vol %d..%d"
|
||
% (note_name(p['pitch_start']), note_name(p['pitch_end']),
|
||
p['pitch_start'], p['pitch_end'], p['vol_start'], p['vol_end']))
|
||
print(" sample: ptr=0x%06X len=%d rate@C4=%d play=%d loop=%d..%d mode=%s%s detune=%+d"
|
||
% (p['sample_ptr'], p['sample_len'], p['rate'], p['play_start'],
|
||
p['loop_start'], p['loop_end'], LOOPMODE[p['loop_mode']],
|
||
" sus" if p['loop_sustain'] else "", p['detune']))
|
||
extras = []
|
||
if p['default_pan'] != 0xFF: extras.append("pan=%d" % p['default_pan'])
|
||
if p['default_note_vol'] != 0: extras.append("note-vol=%d" % p['default_note_vol'])
|
||
if p['vib_wave'] != 0xFF:
|
||
extras.append("vib(w=%s,sp=%d,sw=%d,d=%d,r=%d)"
|
||
% (VIBWAVE.get(p['vib_wave'], '?'), p['vib_speed'],
|
||
p['vib_sweep'], p['vib_depth'], p['vib_rate']))
|
||
if extras:
|
||
print(" overrides: " + " ".join(extras))
|
||
if 'x' in p:
|
||
x = p['x']
|
||
if x['sf_filter']:
|
||
filt = "cutoff=%d cents resonance=%d cB" % (x['cutoff'], x['resonance'])
|
||
else:
|
||
filt = "cutoff=%s resonance=%s" % (
|
||
"off" if x['cutoff'] == 0xFFFF else x['cutoff'],
|
||
"off" if x['resonance'] == 0xFFFF else x['resonance'])
|
||
print(" extra: filter-mode=%s %s fadeout=%s init-atten=%s"
|
||
% ("SF" if x['sf_filter'] else "IT", filt,
|
||
fadeout_str(x['fadeout']),
|
||
db_str(x['init_atten']) if x['init_atten'] else "unity(0)"))
|
||
for key, role in (('vol_env', 'volume'), ('pan_env', 'panning'),
|
||
('filter_env', 'filter'), ('pitch_env', 'pitch')):
|
||
if key in p:
|
||
print(" " + fmt_env(*p[key], role=role).replace("\n", "\n "))
|
||
|
||
# -- songs ------------------------------------------------------------------
|
||
if kind != 0x80 and num_songs > 0:
|
||
print(hr("SONGS"))
|
||
song_table_off = 32 + comp_size
|
||
song_range = range(num_songs) if args.song is None else [args.song]
|
||
for s in song_range:
|
||
if s < 0 or s >= num_songs:
|
||
continue
|
||
eoff = song_table_off + 32 * s
|
||
soff = u32(data, eoff)
|
||
nvoices = data[eoff + 4]
|
||
npats = u16(data, eoff + 5)
|
||
bpm = data[eoff + 7] + 25
|
||
tickrate = data[eoff + 8]
|
||
tuning_base = u16(data, eoff + 9)
|
||
base_freq = struct.unpack_from('<f', data, eoff + 11)[0]
|
||
gbflags = data[eoff + 15]
|
||
gvol = data[eoff + 16]
|
||
mvol = data[eoff + 17]
|
||
patc = u32(data, eoff + 18)
|
||
cuec = u32(data, eoff + 22)
|
||
tone = gbflags & 0x03
|
||
interp = (gbflags >> 2) & 0x07
|
||
tone_names = {0: "linear-pitch", 1: "Amiga-period", 2: "linear-freq", 3: "reserved"}
|
||
interp_names = {0: "default", 1: "none", 2: "Amiga500", 3: "Amiga1200",
|
||
4: "SNES Gaussian", 5: "NES DPCM"}
|
||
|
||
meta = next((m for m in smet if m['idx'] == s), None)
|
||
title = (' "%s"' % meta['name']) if meta and meta['name'] else ""
|
||
print(sub("SONG %d%s" % (s, title)))
|
||
if meta:
|
||
if meta['composer']:
|
||
print(" composer : %s" % meta['composer'])
|
||
if meta['copyright']:
|
||
print(" copyright : %s" % meta['copyright'])
|
||
print(" notation : %d beat division: %d/%d rows"
|
||
% (meta['notation'], meta['beat_pri'], meta['beat_sec']))
|
||
# classic tracker timing: tick = 2500/BPM ms -> ticks/s = BPM/2.5;
|
||
# tickrate is the speed (ticks per row), so rows/s = ticks/s / tickrate.
|
||
ticks_s = bpm / 2.5
|
||
print(" voices=%d patterns=%d BPM=%d speed(tickrate)=%d -> %.1f ticks/s, %.1f rows/s"
|
||
% (nvoices, npats, bpm, tickrate, ticks_s, ticks_s / tickrate if tickrate else 0))
|
||
print(" tuning base note=$%04X (%s) base freq=%.3f Hz"
|
||
% (tuning_base if tuning_base else 0xA000,
|
||
note_name(tuning_base if tuning_base else 0xA000),
|
||
base_freq if base_freq else 8363.0))
|
||
print(" global vol=%d mixing vol=%d tone-mode=%s interpolation=%s"
|
||
% (gvol, mvol, tone_names[tone], interp_names.get(interp, '?')))
|
||
print(" pat-bin comp=%d cue-sheet comp=%d (song offset=%d)" % (patc, cuec, soff))
|
||
|
||
# decompress patterns + cues for overview
|
||
try:
|
||
pat_bin = decomp(data[soff:soff + patc])
|
||
cue_bin = decomp(data[soff + patc:soff + patc + cuec])
|
||
except Exception as e:
|
||
print(" [could not decompress patterns/cues: %s]" % e)
|
||
continue
|
||
npat_real = len(pat_bin) // PATTERN_SIZE
|
||
ncue_real = len(cue_bin) // CUE_SIZE
|
||
|
||
# non-empty pattern count
|
||
nonempty = 0
|
||
for p in range(npat_real):
|
||
blk = pat_bin[p * PATTERN_SIZE:(p + 1) * PATTERN_SIZE]
|
||
if any(blk):
|
||
nonempty += 1
|
||
# used cue count (last cue with any non-FFF voice or instruction)
|
||
used_cues = 0
|
||
for c in range(ncue_real):
|
||
pats, b30, b31 = cue_patterns(cue_bin, c)
|
||
if any(x != 0xFFF for x in pats) or (b30 or b31):
|
||
used_cues = c + 1
|
||
print(" patterns in bin=%d (%d non-empty) cues used=%d" % (npat_real, nonempty, used_cues))
|
||
|
||
do_cues = args.cues and (args.song is None or args.song == s)
|
||
if do_cues or (args.cue is not None and (args.song is None or args.song == s)):
|
||
print(" ORDER LIST (cue -> per-voice pattern):")
|
||
clist = range(min(used_cues, args.max_cues)) if do_cues else [args.cue]
|
||
for c in clist:
|
||
if c >= ncue_real:
|
||
continue
|
||
pats, b30, b31 = cue_patterns(cue_bin, c)
|
||
ins = cue_instruction(b30, b31)
|
||
body = " ".join("v%d=%03X" % (vi, x) for vi, x in enumerate(pats) if x != 0xFFF)
|
||
print(" cue %3d: %s%s" % (c, body, (" [%s]" % ins) if ins else ""))
|
||
|
||
if args.pattern is not None and (args.song is None or args.song == s):
|
||
dump_pattern(pat_bin, args.pattern, nvoices, cue_bin, ncue_real)
|
||
|
||
# -- project-data metadata (brief) -----------------------------------------
|
||
print(hr("PROJECT DATA"))
|
||
if not sections:
|
||
print("(none)")
|
||
else:
|
||
order = ['PNam', 'PCom', 'PCpr', 'Pmsg', 'INam', 'SNam', 'pNam', 'sMet', 'nota', 'Ixmp']
|
||
text = {'PNam': 'name', 'PCom': 'author', 'PCpr': 'copyright', 'Pmsg': 'message'}
|
||
for fc in order:
|
||
if fc not in sections:
|
||
continue
|
||
if fc in text:
|
||
print("%-6s: %s" % (text[fc], sections[fc][0].decode('utf-8', 'replace').rstrip('\x00')))
|
||
if inam:
|
||
named = [(i, n) for i, n in enumerate(inam) if n]
|
||
print("INam : %d instrument names" % len(named))
|
||
for i, n in named[:64]:
|
||
print(" [%3d] %s" % (i, n))
|
||
if len(named) > 64:
|
||
print(" ... (%d more)" % (len(named) - 64))
|
||
if snam:
|
||
named = [(i, n) for i, n in enumerate(snam) if n]
|
||
print("SNam : %d sample names" % len(named))
|
||
for i, n in named[:64]:
|
||
print(" [%3d] %s" % (i, n))
|
||
if len(named) > 64:
|
||
print(" ... (%d more)" % (len(named) - 64))
|
||
if pnam:
|
||
named = [(i, n) for i, n in enumerate(pnam) if n]
|
||
print("pNam : %d pattern names" % len(named))
|
||
if ixmp_by_inst:
|
||
total = sum(len(v) for v in ixmp_by_inst.values())
|
||
print("Ixmp : %d patches across %d instruments %s"
|
||
% (total, len(ixmp_by_inst), sorted(ixmp_by_inst.keys())))
|
||
if smet:
|
||
print("sMet : %d song metadata entries" % len(smet))
|
||
for fc in sorted(sections):
|
||
if fc not in order:
|
||
print("%-6s: %d section(s) (not decoded)" % (fc, len(sections[fc])))
|
||
|
||
|
||
def dump_pattern(pat_bin, pidx, nvoices, cue_bin, ncue_real):
|
||
"""Dump every non-empty row of one pattern."""
|
||
npat = len(pat_bin) // PATTERN_SIZE
|
||
if pidx < 0 or pidx >= npat:
|
||
print(" [pattern %d out of range 0..%d]" % (pidx, npat - 1))
|
||
return
|
||
print(sub("PATTERN 0x%03X (%d) rows" % (pidx, pidx)))
|
||
base = pidx * PATTERN_SIZE
|
||
EMPTY = True
|
||
for r in range(64):
|
||
o = base + r * 8
|
||
note = u16(pat_bin, o)
|
||
inst = pat_bin[o + 2]
|
||
volb = pat_bin[o + 3]
|
||
panb = pat_bin[o + 4]
|
||
op = pat_bin[o + 5]
|
||
arg = u16(pat_bin, o + 6)
|
||
# empty cell: note 0, inst 0, no op, vol/pan no-op (3.00)
|
||
if note == 0 and inst == 0 and op == 0 and arg == 0 \
|
||
and (volb >> 6) == 3 and (volb & 0x3F) == 0 \
|
||
and (panb >> 6) == 3 and (panb & 0x3F) == 0:
|
||
continue
|
||
EMPTY = False
|
||
sym = op_symbol(op)
|
||
if op == 0 and arg == 0:
|
||
fx = "...."
|
||
else:
|
||
fx = "%s%04X" % (sym, arg)
|
||
en = "" if (op == 0 and arg == 0) else describe_effect(sym, arg)
|
||
print(" r%2d %-12s i%02X v=%s p=%s %-6s %s"
|
||
% (r, cell_note(note), inst, vol_col(volb), vol_col(panb), fx,
|
||
("; " + en) if en else ""))
|
||
if EMPTY:
|
||
print(" (pattern is empty)")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|