mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-19 02:44:04 +09:00
taud inspector
This commit is contained in:
937
taud_inspect.py
Normal file
937
taud_inspect.py
Normal 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()
|
||||
Reference in New Issue
Block a user