taud inspector

This commit is contained in:
minjaesong
2026-06-18 21:54:50 +09:00
parent 37ffce6d2d
commit c6af35aded

937
taud_inspect.py Normal file
View File

@@ -0,0 +1,937 @@
#!/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()