it2taud: 12 vol/pan envelope nodes; experimental 'filter bake in'

Implemented in it2taud.py:
- _parse_it_pf_envelope_raw() (it2taud.py:677) — parses IT's third envelope at IMPI+0x1D4, keeping all 25 nodes (no decimation), distinguishing pitch vs filter mode via flag bit 7.
- _env_value_at() — tick-time linear interpolation honouring env-loop wrap.
- _clone_sample(), _plan_baked_length() — sample copy and entry + N×loop_len length planner (N up to 16).
- _bake_pitch_envelope() — time-varying linear-interpolated resampling, rate = 2^(env_v/12).
- _bake_filter_envelope() — RBJ 2-pole resonant LP biquad with time-varying coefficients; cutoff mapped 110 Hz (env_v=−32) → ~28 kHz (env_v=+32), Q from inst.ifr ∈ [0.5, 6.0].
- ITInstrument extended with pf_nodes, pf_flags, ifc, ifr. parse_instruments() reads IFC/IFR at IMPI+0x39/0x3A and pf envelope at IMPI+0x1D4.
- assemble_taud() use_instruments branch now substitutes baked copies in the proxy[] list (originals in samples[] stay intact).
- --no-pf-envelope CLI flag for A/B testing; module docstring updated.
This commit is contained in:
minjaesong
2026-05-01 01:48:33 +09:00
parent 80c26c6b35
commit 909f970d60

View File

@@ -3,6 +3,7 @@
Usage: Usage:
python3 it2taud.py input.it output.taud [-v] [--no-decompress] python3 it2taud.py input.it output.taud [-v] [--no-decompress]
[--no-pf-envelope]
Limits: Limits:
- Up to 20 IT channels (excess dropped; hard error if chunk count - Up to 20 IT channels (excess dropped; hard error if chunk count
@@ -11,9 +12,17 @@ Limits:
Taud patterns. Pattern-loop (SBx) crossing a chunk boundary is Taud patterns. Pattern-loop (SBx) crossing a chunk boundary is
warned; B/C effects are remapped to new cue indices. warned; B/C effects are remapped to new cue indices.
- IT2.14/IT2.15 compressed samples are decoded unless --no-decompress. - IT2.14/IT2.15 compressed samples are decoded unless --no-decompress.
- IT instrument volume/pan envelopes (up to 8 nodes, sustain loops) are - IT instrument volume/pan envelopes (up to 12 nodes, sustain loops) are
converted to Taud format. NNA actions are ignored. Each IT instrument converted to Taud format. NNA actions are ignored. Each IT instrument
resolves to its C-5 canonical sample. resolves to its C-5 canonical sample.
- IT pitch/filter envelope (no Taud equivalent) is BAKED onto a per-
instrument copy of the canonical sample (--no-pf-envelope to disable).
Pitch mode uses time-varying linear-interpolated resampling; filter
mode uses a 2-pole resonant low-pass biquad (RBJ cookbook),
approximate to IT's actual filter. Looped samples are rendered as
`entry + N×loop_len` with the loop reapplied to the tail. Caveat:
the envelope is locked to the sample's playback rate, so playing
the instrument an octave up advances the envelope twice as fast.
- AdLib / OPL instruments are skipped. - AdLib / OPL instruments are skipped.
Effect support: Effect support:
@@ -503,13 +512,206 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list:
return samples return samples
# ── Pitch / filter envelope baker ─────────────────────────────────────────────
# IT instruments carry a third envelope (pitch or filter, distinguished by
# flag bit 7) that has no Taud equivalent. We render its effect onto a
# per-instrument copy of the canonical sample so the instrument can play
# through Taud's normal sample path with the modulation already baked in.
#
# Caveat: the baked envelope's time axis is locked to the sample's playback
# rate, so playing the instrument an octave up advances the envelope twice
# as fast (IT's envelope is wall-clock-time, regardless of note pitch). For
# typical drum/lead use cases this is musically acceptable.
def _clone_sample(src: 'ITSample') -> 'ITSample':
dst = ITSample()
for slot in ITSample.__slots__:
setattr(dst, slot, getattr(src, slot))
dst.sample_data = bytes(src.sample_data)
return dst
def _plan_baked_length(src: 'ITSample', nodes: list, flags: dict,
ticks_per_sec: float) -> tuple:
"""Determine baked output length and loop boundaries.
Non-looped src → (src.length, 0, 0).
Looped src → (entry + N×loop_len, entry, entry + N×loop_len),
with N the smallest integer that covers the envelope's
active duration in seconds. N is clamped to [1, 16].
"""
if not nodes:
return src.length, 0, 0
last_tick = nodes[-1][1]
env_dur_sec = last_tick / ticks_per_sec if ticks_per_sec > 0 else 0.0
env_dur_samples = int(env_dur_sec * src.c5_speed)
if not src.has_loop or src.loop_end <= src.loop_beg:
return src.length, 0, 0
entry = max(0, src.loop_beg)
loop_len = src.loop_end - src.loop_beg
if loop_len <= 0:
return src.length, 0, 0
samples_needed = max(0, env_dur_samples - entry)
n = max(1, (samples_needed + loop_len - 1) // loop_len)
n = min(n, 16)
out_len = entry + n * loop_len
return out_len, entry, out_len
def _read_src_sample(sd: bytes, pos: float, src: 'ITSample') -> float:
"""Linear-interpolated read of `sd` (unsigned u8 PCM) at fractional `pos`,
returning a signed float in roughly [-128, +127]. Honours `src.has_loop`
by wrapping into [loop_beg, loop_end) once `pos >= loop_end`. Past the
end of a non-looped sample, returns 0.0 (silence)."""
if not sd:
return 0.0
if src.has_loop and src.loop_end > src.loop_beg:
if pos >= src.loop_end:
span = src.loop_end - src.loop_beg
pos = src.loop_beg + ((pos - src.loop_beg) % span)
if pos < 0.0:
return 0.0
if pos >= len(sd) - 1:
if pos >= len(sd):
return 0.0
return float(sd[len(sd) - 1] - 128)
i0 = int(pos)
frac = pos - i0
a = sd[i0] - 128
b = sd[i0 + 1] - 128
return a + (b - a) * frac
def _bake_pitch_envelope(src: 'ITSample', nodes: list, flags: dict,
ticks_per_sec: float) -> 'ITSample':
if not src.sample_data:
return src
out_len, lb, le = _plan_baked_length(src, nodes, flags, ticks_per_sec)
tick_per_sample = ticks_per_sec / max(1, src.c5_speed)
sd = src.sample_data
out = bytearray(out_len)
read_pos = 0.0
t_tick = 0.0
for i in range(out_len):
env_v = _env_value_at(t_tick, nodes, flags)
rate = 2.0 ** (env_v / 12.0)
v = _read_src_sample(sd, read_pos, src)
b = int(round(v)) + 128
if b < 0: b = 0
elif b > 255: b = 255
out[i] = b
read_pos += rate
t_tick += tick_per_sample
dst = _clone_sample(src)
dst.sample_data = bytes(out)
dst.length = out_len
dst.loop_beg = lb
dst.loop_end = le
if lb < le:
dst.has_loop = True
dst.flags = (dst.flags | IT_SMP_LOOP) & ~IT_SMP_PINGPONG
else:
dst.has_loop = False
dst.flags = dst.flags & ~(IT_SMP_LOOP | IT_SMP_PINGPONG)
return dst
def _bake_filter_envelope(src: 'ITSample', nodes: list, flags: dict,
ticks_per_sec: float, ifr: int) -> 'ITSample':
"""Time-varying 2-pole resonant low-pass biquad (RBJ cookbook).
Approximates IT's filter; not bit-exact to IT's Pentium routine."""
if not src.sample_data:
return src
out_len, lb, le = _plan_baked_length(src, nodes, flags, ticks_per_sec)
tick_per_sample = ticks_per_sec / max(1, src.c5_speed)
sr = max(1, src.c5_speed)
nyq = sr * 0.5 - 1.0
# Resonance (0..127) → Q ∈ [0.5, 6.0]
Q = 0.5 + (max(0, min(ifr, 127)) / 127.0) * 5.5
sd = src.sample_data
out = bytearray(out_len)
x1 = x2 = 0.0
y1 = y2 = 0.0
t_tick = 0.0
src_len = len(sd)
for i in range(out_len):
env_v = _env_value_at(t_tick, nodes, flags)
# Map env value -32..+32 to cutoff: 110 Hz (closed) to ~28 kHz (open)
cutoff = 110.0 * (2.0 ** ((env_v + 32.0) * 0.125))
if cutoff > nyq: cutoff = nyq
if cutoff < 1.0: cutoff = 1.0
w0 = 2.0 * math.pi * cutoff / sr
cosw = math.cos(w0)
sinw = math.sin(w0)
alpha = sinw / (2.0 * Q)
b0 = (1.0 - cosw) * 0.5
b1 = 1.0 - cosw
b2 = b0
a0 = 1.0 + alpha
a1 = -2.0 * cosw
a2 = 1.0 - alpha
# Read source with looping
in_pos = i
if src.has_loop and src.loop_end > src.loop_beg and in_pos >= src.loop_end:
span = src.loop_end - src.loop_beg
in_pos = src.loop_beg + ((in_pos - src.loop_beg) % span)
if 0 <= in_pos < src_len:
x0 = float(sd[in_pos] - 128)
else:
x0 = 0.0
y0 = (b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2) / a0
x2 = x1; x1 = x0
y2 = y1; y1 = y0
b = int(round(y0)) + 128
if b < 0: b = 0
elif b > 255: b = 255
out[i] = b
t_tick += tick_per_sample
dst = _clone_sample(src)
dst.sample_data = bytes(out)
dst.length = out_len
dst.loop_beg = lb
dst.loop_end = le
if lb < le:
dst.has_loop = True
dst.flags = (dst.flags | IT_SMP_LOOP) & ~IT_SMP_PINGPONG
else:
dst.has_loop = False
dst.flags = dst.flags & ~(IT_SMP_LOOP | IT_SMP_PINGPONG)
return dst
def _bake_pf_envelope(src: 'ITSample', inst, ticks_per_sec: float) -> 'ITSample':
if not inst.pf_nodes or not inst.pf_flags or not inst.pf_flags.get('enabled'):
return src
if inst.pf_flags['is_filter']:
return _bake_filter_envelope(src, inst.pf_nodes, inst.pf_flags,
ticks_per_sec, inst.ifr)
return _bake_pitch_envelope(src, inst.pf_nodes, inst.pf_flags, ticks_per_sec)
# ── IT instrument parser ────────────────────────────────────────────────────── # ── IT instrument parser ──────────────────────────────────────────────────────
class ITInstrument: class ITInstrument:
__slots__ = ('name', 'dfp', 'gv', 'canonical_sample', 'canonical_volume', __slots__ = ('name', 'dfp', 'gv', 'canonical_sample', 'canonical_volume',
'vol_envelope', 'pan_envelope', 'vol_env_sustain', 'pan_env_sustain') 'vol_envelope', 'pan_envelope', 'vol_env_sustain', 'pan_env_sustain',
# vol_envelope / pan_envelope: list of 8 (value, minifloat_idx) tuples, or None 'pf_nodes', 'pf_flags', 'ifc', 'ifr')
# vol_envelope / pan_envelope: list of 12 (value, minifloat_idx) tuples, or None
# vol_env_sustain / pan_env_sustain: int (0 = disabled, else (end<<3)|start) # vol_env_sustain / pan_env_sustain: int (0 = disabled, else (end<<3)|start)
# pf_nodes: raw list of (int8 value, uint16 tick) tuples (up to 25), or None
# pf_flags: dict {enabled, has_env_loop, has_sus_loop, lpb, lpe, slb, sle,
# is_filter, carry}, or None
# ifc / ifr: initial filter cutoff / resonance (0..127, or 0 if not set)
def parse_instruments(data: bytes, h: ITHeader) -> list: def parse_instruments(data: bytes, h: ITHeader) -> list:
insts = [] insts = []
@@ -542,25 +744,35 @@ def parse_instruments(data: bytes, h: ITHeader) -> list:
inst.canonical_sample = c5_smp # 1-based sample index, 0 = none inst.canonical_sample = c5_smp # 1-based sample index, 0 = none
inst.canonical_volume = min(inst.gv, 64) inst.canonical_volume = min(inst.gv, 64)
# Initial filter cutoff/resonance (high bit = enabled, low 7 bits = value)
ifc_raw = data[ptr + 0x39]
ifr_raw = data[ptr + 0x3A]
inst.ifc = ifc_raw & 0x7F if (ifc_raw & 0x80) else 0
inst.ifr = ifr_raw & 0x7F if (ifr_raw & 0x80) else 0
# Parse IT envelopes (new-format only, ≥cmwt 0x200) # Parse IT envelopes (new-format only, ≥cmwt 0x200)
# Vol envelope at ptr+0x130; pan envelope at ptr+0x182 # Vol envelope at ptr+0x130; pan envelope at ptr+0x182; pf envelope at ptr+0x1D4
ticks_per_sec = max(h.initial_tempo * 2.0 / 5.0, 1.0) # tick rate = bpm×2/5 (50 Hz at 125 BPM); speed is ticks-per-row, irrelevant here ticks_per_sec = max(h.initial_tempo * 2.0 / 5.0, 1.0) # tick rate = bpm×2/5 (50 Hz at 125 BPM); speed is ticks-per-row, irrelevant here
inst.vol_envelope, inst.vol_env_sustain = _parse_it_envelope( inst.vol_envelope, inst.vol_env_sustain = _parse_it_envelope(
data, ptr + 0x130, False, ticks_per_sec) data, ptr + 0x130, False, ticks_per_sec)
inst.pan_envelope, inst.pan_env_sustain = _parse_it_envelope( inst.pan_envelope, inst.pan_env_sustain = _parse_it_envelope(
data, ptr + 0x182, True, ticks_per_sec) data, ptr + 0x182, True, ticks_per_sec)
inst.pf_nodes, inst.pf_flags = _parse_it_pf_envelope_raw(
data, ptr + 0x1D4)
insts.append(inst) insts.append(inst)
return insts return insts
def _parse_it_envelope(data: bytes, env_ptr: int, is_pan: bool, def _parse_it_envelope(data: bytes, env_ptr: int, is_pan: bool,
ticks_per_sec: float) -> tuple: ticks_per_sec: float) -> tuple:
"""Parse one IT envelope block into 8 Taud (value, minifloat_idx) points. """Parse one IT envelope block into 12 Taud (value, minifloat_idx) points.
Returns (points_list, sustain_byte) where points_list is a list of Returns (points_list, sustain_byte) where points_list is a list of
8 (value, minifloat_idx) tuples, or None if envelope not enabled. 12 (value, minifloat_idx) tuples, or None if envelope not enabled.
sustain_byte: bit7=enabled (u), bit6=sustain (t: 1=breaks on key-off, sustain_byte: bit7=enabled (u), bit6=sustain (t: 1=breaks on key-off,
0=loops forever), bits[5:3]=end_idx, bits[2:0]=start_idx; 0=disabled. 0=loops forever), bits[5:3]=end_idx, bits[2:0]=start_idx; 0=disabled.
Note: sustain byte still uses 3-bit indices (0..7), so loop nodes
referencing indices 8..11 cannot be encoded and fall back to no loop.
IT has two loop types: envelope loop (continues forever) and sustain loop IT has two loop types: envelope loop (continues forever) and sustain loop
(breaks on key-off). Taud distinguishes them via the 't' flag. Priority (breaks on key-off). Taud distinguishes them via the 't' flag. Priority
@@ -609,23 +821,26 @@ def _parse_it_envelope(data: bytes, env_ptr: int, is_pan: bool,
if not nodes: if not nodes:
return None, 0 return None, 0
# Decimate to 8 nodes if needed, preserving first/last # Decimate to 12 nodes if needed, preserving first/last
decimated = len(nodes) > 8 decimated = len(nodes) > 12
if not decimated: if not decimated:
selected = nodes[:] selected = nodes[:]
if has_loop and use_lb < len(selected) and use_le < len(selected): # Sustain byte encodes 3-bit indices; loop nodes ≥8 cannot be referenced.
if has_loop and use_lb < min(len(selected), 8) and use_le < min(len(selected), 8):
taud_slb, taud_sle = use_lb, use_le taud_slb, taud_sle = use_lb, use_le
else: else:
taud_slb = taud_sle = -1 taud_slb = taud_sle = -1
if has_loop and (use_lb >= 8 or use_le >= 8):
vprint(f" loop indices ≥8 cannot be encoded in 3-bit sustain field")
else: else:
selected = [nodes[round(k * (len(nodes) - 1) / 7)] for k in range(8)] selected = [nodes[round(k * (len(nodes) - 1) / 11)] for k in range(12)]
taud_slb = taud_sle = -1 # loop indices lost in decimation taud_slb = taud_sle = -1 # loop indices lost in decimation
if has_loop: if has_loop:
vprint(f" loop indices lost due to decimation ({len(nodes)} nodes → 8)") vprint(f" loop indices lost due to decimation ({len(nodes)} nodes → 12)")
# Build 8 Taud envelope points with delta-time minifloats # Build 12 Taud envelope points with delta-time minifloats
points = [] points = []
for k in range(8): for k in range(12):
if k < len(selected): if k < len(selected):
val, tick = selected[k] val, tick = selected[k]
if is_pan: if is_pan:
@@ -656,6 +871,87 @@ def _parse_it_envelope(data: bytes, env_ptr: int, is_pan: bool,
return points, sus_byte return points, sus_byte
def _parse_it_pf_envelope_raw(data: bytes, env_ptr: int) -> tuple:
"""Parse the IT pitch/filter envelope keeping all nodes (no decimation).
Returns (nodes, flags) where:
nodes: list of (int8 value, uint16 tick) tuples (up to 25 entries),
values in -32..+32 semitones (pitch) or filter modulation units.
flags: dict {enabled, has_env_loop, has_sus_loop, lpb, lpe, slb, sle,
is_filter, carry}
Returns (None, None) if the envelope is not enabled or out of range.
"""
if env_ptr + 82 > len(data):
return None, None
flags_byte = data[env_ptr]
if not (flags_byte & 0x01):
return None, None
num_nodes = max(1, min(data[env_ptr + 1], 25))
flags = {
'enabled': True,
'has_env_loop': bool(flags_byte & 0x02),
'has_sus_loop': bool(flags_byte & 0x04),
'carry': bool(flags_byte & 0x08),
'is_filter': bool(flags_byte & 0x80),
'lpb': data[env_ptr + 2],
'lpe': data[env_ptr + 3],
'slb': data[env_ptr + 4],
'sle': data[env_ptr + 5],
}
nodes = []
for n in range(num_nodes):
nptr = env_ptr + 6 + n * 3
if nptr + 2 >= len(data):
break
val = struct.unpack_from('b', data, nptr)[0]
tick = struct.unpack_from('<H', data, nptr + 1)[0]
nodes.append((val, tick))
if not nodes:
return None, None
# Clamp loop indices into valid range
for k in ('lpb', 'lpe', 'slb', 'sle'):
flags[k] = min(flags[k], len(nodes) - 1)
return nodes, flags
def _env_value_at(t_tick: float, nodes: list, flags: dict) -> float:
"""Return interpolated envelope value at time `t_tick` (IT envelope ticks).
Honours env-loop wrap. Sus-loop is treated as 'play through once then hold
last value' (no key-off model). Returns nodes[0].value if t_tick is before
the first node, nodes[-1].value if past the last node and no env loop.
"""
if not nodes:
return 0.0
first_tick = nodes[0][1]
if t_tick <= first_tick:
return float(nodes[0][0])
if flags['has_env_loop']:
lpb_tick = nodes[flags['lpb']][1]
lpe_tick = nodes[flags['lpe']][1]
loop_span = lpe_tick - lpb_tick
if loop_span > 0 and t_tick >= lpe_tick:
t_tick = lpb_tick + ((t_tick - lpb_tick) % loop_span)
elif loop_span <= 0 and t_tick >= lpe_tick:
return float(nodes[flags['lpe']][0])
last_tick = nodes[-1][1]
if t_tick >= last_tick:
return float(nodes[-1][0])
# Linear interpolate between bracketing nodes
for k in range(len(nodes) - 1):
a_val, a_t = nodes[k]
b_val, b_t = nodes[k + 1]
if a_t <= t_tick <= b_t:
if b_t == a_t:
return float(a_val)
frac = (t_tick - a_t) / (b_t - a_t)
return a_val + (b_val - a_val) * frac
return float(nodes[-1][0])
# ── IT pattern parser ───────────────────────────────────────────────────────── # ── IT pattern parser ─────────────────────────────────────────────────────────
class ITRow: class ITRow:
@@ -1133,7 +1429,7 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
"""samples_or_proxy: list of ITSample | None, indexed 1-based (index 0 unused). """samples_or_proxy: list of ITSample | None, indexed 1-based (index 0 unused).
envelopes_by_slot: optional dict mapping taud_slot → (vol_env, vol_sus, pan_env, pan_sus, inst_gv) envelopes_by_slot: optional dict mapping taud_slot → (vol_env, vol_sus, pan_env, pan_sus, inst_gv)
where vol_env/pan_env are lists of 8 (value, minifloat_idx) tuples (or None), where vol_env/pan_env are lists of 12 (value, minifloat_idx) tuples (or None),
and inst_gv is instrument global volume (0..255, byte 15). and inst_gv is instrument global volume (0..255, byte 15).
Returns (bin_bytes[SAMPLEINST_SIZE], offsets_dict). Returns (bin_bytes[SAMPLEINST_SIZE], offsets_dict).
@@ -1198,33 +1494,33 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
struct.pack_into('<H', inst_bin, base + 10, le) struct.pack_into('<H', inst_bin, base + 10, le)
inst_bin[base + 12] = flags_byte inst_bin[base + 12] = flags_byte
# Write envelope data (new 8-point format) # Write envelope data (12-point format: vol at +16..+39, pan at +40..+63)
env_data = envelopes_by_slot.get(taud_idx) if envelopes_by_slot else None env_data = envelopes_by_slot.get(taud_idx) if envelopes_by_slot else None
if env_data and env_data[0]: if env_data and env_data[0]:
vol_env, vol_sus, pan_env, pan_sus, inst_gv = env_data vol_env, vol_sus, pan_env, pan_sus, inst_gv = env_data
inst_bin[base + 13] = vol_sus & 0xFF inst_bin[base + 13] = vol_sus & 0xFF
inst_bin[base + 14] = pan_sus & 0xFF inst_bin[base + 14] = pan_sus & 0xFF
inst_bin[base + 15] = inst_gv & 0xFF inst_bin[base + 15] = inst_gv & 0xFF
for k, (val, mf) in enumerate(vol_env[:8]): for k, (val, mf) in enumerate(vol_env[:12]):
inst_bin[base + 16 + k*2] = val & 0xFF inst_bin[base + 16 + k*2] = val & 0xFF
inst_bin[base + 16 + k*2 + 1] = mf & 0xFF inst_bin[base + 16 + k*2 + 1] = mf & 0xFF
if pan_env: if pan_env:
for k, (val, mf) in enumerate(pan_env[:8]): for k, (val, mf) in enumerate(pan_env[:12]):
inst_bin[base + 32 + k*2] = val & 0xFF inst_bin[base + 40 + k*2] = val & 0xFF
inst_bin[base + 32 + k*2 + 1] = mf & 0xFF inst_bin[base + 40 + k*2 + 1] = mf & 0xFF
else: else:
for k in range(8): for k in range(12):
inst_bin[base + 32 + k*2] = 0x80 # pan centre inst_bin[base + 40 + k*2] = 0x80 # pan centre
inst_bin[base + 32 + k*2 + 1] = 0x00 # hold inst_bin[base + 40 + k*2 + 1] = 0x00 # hold
else: else:
# No instrument envelope: single-point vol, neutral pan, full gv # No instrument envelope: single-point vol, neutral pan, full gv
inst_gv = env_data[4] if env_data else 255 inst_gv = env_data[4] if env_data else 255
inst_bin[base + 15] = inst_gv & 0xFF inst_bin[base + 15] = inst_gv & 0xFF
inst_bin[base + 16] = min(s.vol, 63) # value 0-63 inst_bin[base + 16] = min(s.vol, 63) # value 0-63
inst_bin[base + 17] = 0 # offset 0 = hold inst_bin[base + 17] = 0 # offset 0 = hold
for k in range(8): for k in range(12):
inst_bin[base + 32 + k*2] = 0x80 # pan centre inst_bin[base + 40 + k*2] = 0x80 # pan centre
inst_bin[base + 32 + k*2 + 1] = 0x00 # hold inst_bin[base + 40 + k*2 + 1] = 0x00 # hold
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}") vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
return bytes(sample_bin) + bytes(inst_bin), offsets return bytes(sample_bin) + bytes(inst_bin), offsets
@@ -1416,7 +1712,8 @@ def _active_channels(h: ITHeader, patterns_rows: list) -> list:
return active return active
def assemble_taud(h: ITHeader, samples: list, instruments: list, def assemble_taud(h: ITHeader, samples: list, instruments: list,
patterns_rows: list, decompress: bool) -> bytes: patterns_rows: list, decompress: bool,
no_pf_envelope: bool = False) -> bytes:
# ── Resolve IT recalls ─────────────────────────────────────────────────── # ── Resolve IT recalls ───────────────────────────────────────────────────
vprint(" resolving IT recalls…") vprint(" resolving IT recalls…")
resolve_it_recalls(patterns_rows, h.order_list, 64, h.link_gef, resolve_it_recalls(patterns_rows, h.order_list, 64, h.link_gef,
@@ -1481,9 +1778,11 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
if h.use_instruments: if h.use_instruments:
# Build a proxy sample list where Taud inst slot = IT inst index, # Build a proxy sample list where Taud inst slot = IT inst index,
# resolved to the canonical sample. Slot 0 unused. # resolved to the canonical sample. Slot 0 unused.
ticks_per_sec = max(h.initial_tempo * 2.0 / 5.0, 1.0)
proxy = [None] * (max(len(instruments), 256) + 1) proxy = [None] * (max(len(instruments), 256) + 1)
inst_vols = {} inst_vols = {}
envelopes_by_slot = {} envelopes_by_slot = {}
bake_count = 0
for ii, inst in enumerate(instruments): for ii, inst in enumerate(instruments):
taud_slot = ii + 1 taud_slot = ii + 1
if taud_slot >= 256: break if taud_slot >= 256: break
@@ -1491,7 +1790,18 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
si = inst.canonical_sample - 1 # 0-based sample index si = inst.canonical_sample - 1 # 0-based sample index
if si < 0 or si >= len(samples) or samples[si] is None: if si < 0 or si >= len(samples) or samples[si] is None:
continue continue
proxy[taud_slot] = samples[si] src_smp = samples[si]
if (not no_pf_envelope and inst.pf_nodes
and inst.pf_flags and inst.pf_flags.get('enabled')):
baked = _bake_pf_envelope(src_smp, inst, ticks_per_sec)
if baked is not src_smp:
bake_count += 1
mode = 'filter' if inst.pf_flags['is_filter'] else 'pitch'
vprint(f" baked pf envelope on inst[{ii+1}] '{inst.name}' "
f"(mode={mode}, src_len={src_smp.length}, "
f"out_len={baked.length})")
src_smp = baked
proxy[taud_slot] = src_smp
vol64 = min(inst.canonical_volume, 64) vol64 = min(inst.canonical_volume, 64)
inst_vols[taud_slot] = min(vol64, 0x3F) inst_vols[taud_slot] = min(vol64, 0x3F)
# IT global volume range is 0..128; rescale to Taud's 0..255. # IT global volume range is 0..128; rescale to Taud's 0..255.
@@ -1501,6 +1811,8 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
inst.pan_envelope, inst.pan_env_sustain, inst.pan_envelope, inst.pan_env_sustain,
inst_gv_255, inst_gv_255,
) )
if bake_count:
vprint(f" pf envelope baking: {bake_count} instrument(s)")
sampleinst_raw, _ = build_sample_inst_bin_it(proxy, envelopes_by_slot) sampleinst_raw, _ = build_sample_inst_bin_it(proxy, envelopes_by_slot)
else: else:
# Samples referenced directly; proxy is samples list (0-based, slot 0 unused) # Samples referenced directly; proxy is samples list (0-based, slot 0 unused)
@@ -1602,6 +1914,8 @@ def main():
ap.add_argument('-v', '--verbose', action='store_true') ap.add_argument('-v', '--verbose', action='store_true')
ap.add_argument('--no-decompress', action='store_true', ap.add_argument('--no-decompress', action='store_true',
help='Treat compressed IT samples as silent (debug)') help='Treat compressed IT samples as silent (debug)')
ap.add_argument('--no-pf-envelope', action='store_true',
help='Skip baking IT pitch/filter envelope onto sample copies')
args = ap.parse_args() args = ap.parse_args()
VERBOSE = args.verbose VERBOSE = args.verbose
@@ -1621,7 +1935,8 @@ def main():
patterns_rows = parse_patterns(data, h) patterns_rows = parse_patterns(data, h)
taud = assemble_taud(h, samples, instruments, patterns_rows, taud = assemble_taud(h, samples, instruments, patterns_rows,
decompress=not args.no_decompress) decompress=not args.no_decompress,
no_pf_envelope=args.no_pf_envelope)
with open(args.output, 'wb') as f: with open(args.output, 'wb') as f:
f.write(taud) f.write(taud)