#!/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(' 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('> 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('> 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()