mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-12 23:54:04 +09:00
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:
371
it2taud.py
371
it2taud.py
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user