it2taud to use new Taud instrument fields

This commit is contained in:
minjaesong
2026-05-01 12:42:27 +09:00
parent 01ff4b1d47
commit ac94a52329
2 changed files with 373 additions and 436 deletions

View File

@@ -3,7 +3,6 @@
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
@@ -12,17 +11,14 @@ 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 12 nodes, sustain loops) are - IT instrument volume/pan/pitch-or-filter envelopes (up to 25 nodes,
converted to Taud format. NNA actions are ignored. Each IT instrument sustain & env loops) are converted directly to the new Taud 192-byte
instrument 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- - Pitch and filter envelopes are emitted natively (engine-evaluated);
instrument copy of the canonical sample (--no-pf-envelope to disable). auto-vibrato, fadeout, PPS/PPC, default pan, volume/pan swing, and
Pitch mode uses time-varying linear-interpolated resampling; filter initial filter cutoff/resonance are forwarded to the engine via
mode uses a 2-pole resonant low-pass biquad (RBJ cookbook), the new instrument fields.
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:
@@ -38,7 +34,6 @@ Effect support:
import argparse import argparse
import gzip import gzip
import math
import struct import struct
import sys import sys
@@ -379,7 +374,8 @@ class ITSample:
'c5_speed', 'length', 'loop_beg', 'loop_end', 'c5_speed', 'length', 'loop_beg', 'loop_end',
'sus_beg', 'sus_end', 'smp_point', 'sus_beg', 'sus_end', 'smp_point',
'has_loop', 'is_16bit', 'is_stereo', 'is_compressed', 'has_loop', 'is_16bit', 'is_stereo', 'is_compressed',
'is_signed', 'sample_data') 'is_signed', 'sample_data',
'av_speed', 'av_depth', 'av_sweep', 'av_wave')
def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list: def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list:
samples = [] samples = []
@@ -410,6 +406,11 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list:
s.sus_beg = struct.unpack_from('<I', data, ptr+0x40)[0] s.sus_beg = struct.unpack_from('<I', data, ptr+0x40)[0]
s.sus_end = struct.unpack_from('<I', data, ptr+0x44)[0] s.sus_end = struct.unpack_from('<I', data, ptr+0x44)[0]
s.smp_point = struct.unpack_from('<I', data, ptr+0x48)[0] s.smp_point = struct.unpack_from('<I', data, ptr+0x48)[0]
# Auto-vibrato (per-sample): IMPS+0x4C..0x4F.
s.av_speed = data[ptr + 0x4C]
s.av_depth = data[ptr + 0x4D]
s.av_sweep = data[ptr + 0x4E]
s.av_wave = data[ptr + 0x4F]
s.has_loop = bool(s.flags & IT_SMP_LOOP) s.has_loop = bool(s.flags & IT_SMP_LOOP)
s.is_16bit = bool(s.flags & IT_SMP_16BIT) s.is_16bit = bool(s.flags & IT_SMP_16BIT)
@@ -451,206 +452,20 @@ 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',
'pf_nodes', 'pf_flags', 'ifc', 'ifr') 'pf_envelope', 'pf_env_sustain', 'pf_is_filter',
# vol_envelope / pan_envelope: list of 12 (value, minifloat_idx) tuples, or None 'ifc', 'ifr', 'fadeout', 'pps', 'ppc', 'rv', 'rp')
# vol_env_sustain / pan_env_sustain: int (0 = disabled, else (end<<3)|start) # vol_envelope / pan_envelope / pf_envelope: list of 25 (value, minifloat_idx) tuples, or None
# pf_nodes: raw list of (int8 value, uint16 tick) tuples (up to 25), or None # *_env_sustain: int (16-bit, 0b 0ut sssss pcb eeeee), 0 = no envelope
# pf_flags: dict {enabled, has_env_loop, has_sus_loop, lpb, lpe, slb, sle, # pf_is_filter: bool — pf envelope mode (False = pitch, True = filter)
# is_filter, carry}, or None # ifc / ifr : initial filter cutoff / resonance (0..127, 0 if not set)
# ifc / ifr: initial filter cutoff / resonance (0..127, or 0 if not set) # fadeout : 0..1024 (IT FadeOut field, applied per tick after key-off)
# pps / ppc : pitch-pan separation (signed -32..+32) and centre note (0..119)
# rv / rp : random volume swing (0..100) / random pan swing (0..64)
def parse_instruments(data: bytes, h: ITHeader) -> list: def parse_instruments(data: bytes, h: ITHeader) -> list:
insts = [] insts = []
@@ -662,9 +477,15 @@ def parse_instruments(data: bytes, h: ITHeader) -> list:
inst = ITInstrument() inst = ITInstrument()
inst.name = data[ptr+0x20:ptr+0x3A].rstrip(b'\x00').decode('latin-1', errors='replace') inst.name = data[ptr+0x20:ptr+0x3A].rstrip(b'\x00').decode('latin-1', errors='replace')
inst.fadeout = struct.unpack_from('<H', data, ptr + 0x14)[0] # 0..1024
# PPS is signed -32..+32; PPC is the centre note (IT note number 0..119, C-5=60).
inst.pps = struct.unpack_from('b', data, ptr + 0x16)[0]
inst.ppc = data[ptr + 0x17]
inst.gv = data[ptr+0x18] inst.gv = data[ptr+0x18]
dfp_raw = data[ptr+0x19] dfp_raw = data[ptr+0x19]
inst.dfp = dfp_raw & 0x7F if (dfp_raw & 0x80) else None # None = don't use inst.dfp = dfp_raw & 0x7F if (dfp_raw & 0x80) else None # None = don't use
inst.rv = data[ptr + 0x1A] # 0..100
inst.rp = data[ptr + 0x1B] # 0..64
# Keyboard table: 240 bytes at ptr+0x44, 120 pairs of (note, sample_1based) # Keyboard table: 240 bytes at ptr+0x44, 120 pairs of (note, sample_1based)
keyboard = [] keyboard = []
@@ -691,32 +512,43 @@ def parse_instruments(data: bytes, h: ITHeader) -> list:
# 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; pf envelope at ptr+0x1D4 # 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)
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, kind='vol', ticks_per_sec=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, kind='pan', ticks_per_sec=ticks_per_sec)
inst.pf_nodes, inst.pf_flags = _parse_it_pf_envelope_raw( # pf envelope: byte 0 bit 7 distinguishes filter (1) from pitch (0).
data, ptr + 0x1D4) pf_flag_byte = data[ptr + 0x1D4] if ptr + 0x1D4 < len(data) else 0
inst.pf_is_filter = bool(pf_flag_byte & 0x80)
inst.pf_envelope, inst.pf_env_sustain = _parse_it_envelope(
data, ptr + 0x1D4, kind=('filter' if inst.pf_is_filter else 'pitch'),
ticks_per_sec=ticks_per_sec)
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, kind: str,
ticks_per_sec: float) -> tuple: ticks_per_sec: float) -> tuple:
"""Parse one IT envelope block into 12 Taud (value, minifloat_idx) points. """Parse one IT envelope block (vol / pan / pitch / filter) into up to 25
Taud (value, minifloat_idx) points + a 16-bit sustain/flags word.
Returns (points_list, sustain_byte) where points_list is a list of Returns (points_list, sus_word). points_list has 25 entries (padded
12 (value, minifloat_idx) tuples, or None if envelope not enabled. with hold-zeros) or None if the envelope is disabled.
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.
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 kind:
(breaks on key-off). Taud distinguishes them via the 't' flag. Priority 'vol' — IT 0..64 → Taud 0..63 (byte 1 = volume)
when both exist: sustain (because IT plays sustain while held, then env 'pan' — IT -32..+32 → Taud 0..255 (0x80 = centre)
loop after release; Taud can only express one). 'pitch' — IT -32..+32 → Taud 0..255 (0x80 = unity, 1 unit ≈ 1 semitone)
'filter' — IT -32..+32 → Taud 0..255 (0x80 = unity cutoff)
sus_word layout (16 bits, 0b 0ut sssss pcb eeeee):
bit 14 = u (enable sustain/loop)
bit 13 = t (sustain — breaks on key-off when set)
bits 12..8 = sustain/loop start (5-bit index 0..24)
bit 7 = p (vol: fadeout-zero; pan: use default pan; pf: filter mode)
bit 6 = c (envelope carry)
bit 5 = b (use envelope at all)
bits 4..0 = sustain/loop end (5-bit index 0..24)
""" """
if env_ptr + 82 > len(data): if env_ptr + 82 > len(data):
return None, 0 return None, 0
@@ -725,15 +557,16 @@ def _parse_it_envelope(data: bytes, env_ptr: int, is_pan: bool,
return None, 0 # envelope not enabled return None, 0 # envelope not enabled
num_nodes = max(1, min(data[env_ptr + 1], 25)) num_nodes = max(1, min(data[env_ptr + 1], 25))
it_lpb = data[env_ptr + 2] # envelope loop begin node it_lpb = data[env_ptr + 2]
it_lpe = data[env_ptr + 3] # envelope loop end node it_lpe = data[env_ptr + 3]
it_slb = data[env_ptr + 4] # sustain loop begin node it_slb = data[env_ptr + 4]
it_sle = data[env_ptr + 5] # sustain loop end node it_sle = data[env_ptr + 5]
has_env_loop = bool(flags & 0x02) has_env_loop = bool(flags & 0x02)
has_sus_loop = bool(flags & 0x04) has_sus_loop = bool(flags & 0x04)
carry = bool(flags & 0x08)
is_filter = bool(flags & 0x80) and kind in ('pitch', 'filter')
# Choose which IT loop to map to Taud (priority: sus > env). The 't' flag # Priority: sus loop > env loop (Taud carries one loop region).
# distinguishes them: t=1 for sustain (breaks on key-off), t=0 for env loop.
if has_sus_loop: if has_sus_loop:
use_lb, use_le = it_slb, it_sle use_lb, use_le = it_slb, it_sle
has_loop = True has_loop = True
@@ -753,142 +586,55 @@ def _parse_it_envelope(data: bytes, env_ptr: int, is_pan: bool,
nptr = env_ptr + 6 + n * 3 nptr = env_ptr + 6 + n * 3
if nptr + 2 >= len(data): if nptr + 2 >= len(data):
break break
val = struct.unpack_from('b', data, nptr)[0] # signed: vol 0..64, pan -32..32 val = struct.unpack_from('b', data, nptr)[0]
tick = struct.unpack_from('<H', data, nptr + 1)[0] tick = struct.unpack_from('<H', data, nptr + 1)[0]
nodes.append((val, tick)) nodes.append((val, tick))
if not nodes: if not nodes:
return None, 0 return None, 0
# Decimate to 12 nodes if needed, preserving first/last def _to_taud_val(it_val: int) -> int:
decimated = len(nodes) > 12 if kind == 'vol':
if not decimated: return min(63, max(0, round(it_val * 63 / 64)))
selected = nodes[:] if kind == 'pan':
# Sustain byte encodes 3-bit indices; loop nodes ≥8 cannot be referenced. return min(255, max(0, round((it_val + 32) * 255 / 64)))
if has_loop and use_lb < min(len(selected), 8) and use_le < min(len(selected), 8): # pitch / filter: -32..+32 → 0..255 (0x80 = unity)
taud_slb, taud_sle = use_lb, use_le return min(255, max(0, round((it_val + 32) * 255 / 64)))
else:
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:
selected = [nodes[round(k * (len(nodes) - 1) / 11)] for k in range(12)]
taud_slb = taud_sle = -1 # loop indices lost in decimation
if has_loop:
vprint(f" loop indices lost due to decimation ({len(nodes)} nodes → 12)")
# Build 12 Taud envelope points with delta-time minifloats pad_value = (63 if kind == 'vol' else 0x80)
# Build Taud envelope points with delta-time minifloats. We keep all
# IT nodes verbatim (up to 25), so loop indices stay valid.
points = [] points = []
for k in range(12): for k in range(25):
if k < len(selected): if k < len(nodes):
val, tick = selected[k] val, tick = nodes[k]
if is_pan: taud_val = _to_taud_val(val)
taud_val = min(255, max(0, round((val + 32) * 255 / 64))) if k < len(nodes) - 1:
else: _, next_tick = nodes[k + 1]
taud_val = min(63, max(0, round(val * 63 / 64)))
if k < len(selected) - 1:
_, next_tick = selected[k + 1]
delta_sec = max(0.0, (next_tick - tick) / ticks_per_sec) delta_sec = max(0.0, (next_tick - tick) / ticks_per_sec)
mf_idx = _nearest_minifloat(delta_sec) mf_idx = _nearest_minifloat(delta_sec)
else: else:
mf_idx = 0 # last real node: hold mf_idx = 0 # last real node: hold
else: else:
# Pad: copy last real value, offset=0 (hold) # Pad: hold at last real node's value.
taud_val = points[-1][0] if points else (128 if is_pan else 63) taud_val = points[-1][0] if points else pad_value
mf_idx = 0 mf_idx = 0
points.append((taud_val, mf_idx)) points.append((taud_val, mf_idx))
# Build sustain byte: bit7=enable (u), bit6=sustain (t), bits[5:3]=end, # Build 16-bit sus word.
# bits[2:0]=start. 0=disabled. t=1 → breaks on key-off (IT sustain loop); sus_word = 0x0020 # b = 1 (use envelope) — set whenever the envelope is enabled
# t=0 → loops forever (IT envelope loop). if carry:
if taud_slb >= 0 and taud_sle >= 0: sus_word |= 0x0040
t_bit = 0x40 if is_sustain else 0x00 if is_filter:
sus_byte = 0x80 | t_bit | ((taud_sle & 7) << 3) | (taud_slb & 7) sus_word |= 0x0080
else: if has_loop and 0 <= use_lb < 25 and 0 <= use_le < 25:
sus_byte = 0 sus_word |= 0x4000 # u
if is_sustain:
sus_word |= 0x2000 # t
sus_word |= (use_lb & 0x1F) << 8 # sssss
sus_word |= (use_le & 0x1F) # eeeee
return points, sus_byte return points, sus_word
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 ─────────────────────────────────────────────────────────
@@ -1335,12 +1081,14 @@ def _find_post_pat_cue(pi: int, order_list: list, chunk_map: list,
# ── Sample / instrument bin (same as s3m2taud) ──────────────────────────────── # ── Sample / instrument bin (same as s3m2taud) ────────────────────────────────
def build_sample_inst_bin_it(samples_or_proxy: list, def build_sample_inst_bin_it(samples_or_proxy: list,
envelopes_by_slot: dict = None) -> tuple: instr_data_by_slot: dict = None) -> tuple:
"""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) instr_data_by_slot: optional dict mapping taud_slot → dict with keys:
where vol_env/pan_env are lists of 12 (value, minifloat_idx) tuples (or None), vol_env, vol_sus, pan_env, pan_sus, pf_env, pf_sus, pf_is_filter,
and inst_gv is instrument global volume (0..255, byte 15). inst_gv, fadeout, vib_speed, vib_depth, vib_sweep, default_pan,
pps, ppc_taud, pan_swing, vol_swing, ifc, ifr.
All optional; missing keys default to neutral values.
Returns (bin_bytes[SAMPLEINST_SIZE], offsets_dict). Returns (bin_bytes[SAMPLEINST_SIZE], offsets_dict).
""" """
@@ -1375,12 +1123,21 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
s.length = n; s.loop_end = min(s.loop_end, n) s.length = n; s.loop_end = min(s.loop_end, n)
pos += n pos += n
# New 192-byte instrument layout (terranmon.txt:1997-2070). # 192-byte instrument layout (terranmon.txt:1997-2070).
# Vol env @ 21..70 (25 pts), Pan env @ 71..120 (25 pts), P/F env @ 121..170 (25 pts). USE_ENV_BIT = 0x0020 # b — set whenever the engine should evaluate the envelope
# Envelope flag bits (16-bit, 0b 0ut sssss pcb eeeee):
# bit 14=u(enable), 13=t(sustain), 12..8=sus_start, 7=p, 6=c(carry), def _write_env(buf: bytearray, base: int, env_pts):
# 5=b(use envelope), 4..0=sus_end. """Write 25 (value, minifloat) pairs starting at `buf[base]`. Pads
USE_ENV_BIT = 0x0020 # b with the previous value (or 0/0x80) and offset=0 if shorter than 25."""
for k in range(25):
if env_pts and k < len(env_pts):
val, mf = env_pts[k]
else:
val = (env_pts[-1][0] if env_pts else 0)
mf = 0
buf[base + k*2] = val & 0xFF
buf[base + k*2 + 1] = mf & 0xFF
inst_bin = bytearray(INSTBIN_SIZE) inst_bin = bytearray(INSTBIN_SIZE)
for i, s in enumerate(samples_or_proxy): for i, s in enumerate(samples_or_proxy):
taud_idx = i # samples_or_proxy is 0-based here; slot 0 unused taud_idx = i # samples_or_proxy is 0-based here; slot 0 unused
@@ -1408,56 +1165,62 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
struct.pack_into('<H', inst_bin, base + 12, le) struct.pack_into('<H', inst_bin, base + 12, le)
inst_bin[base + 14] = flags_byte inst_bin[base + 14] = flags_byte
# Write envelope data idata = (instr_data_by_slot or {}).get(taud_idx) or {}
env_data = envelopes_by_slot.get(taud_idx) if envelopes_by_slot else None vol_env = idata.get('vol_env')
if env_data and env_data[0]: pan_env = idata.get('pan_env')
vol_env, vol_sus, pan_env, pan_sus, inst_gv = env_data pf_env = idata.get('pf_env')
# Old caller passed an 8-bit sustain byte (0b ut eee sss for 12-point indices). vol_sus = idata.get('vol_sus', USE_ENV_BIT)
# Convert to new 16-bit layout (5-bit sus indices in bits 12..8 / 4..0). pan_sus = idata.get('pan_sus', 0)
def _convert_old_sus(b: int, has_env: bool) -> int: pf_sus = idata.get('pf_sus', 0)
if not has_env: inst_gv = idata.get('inst_gv', 0xFF)
return 0 fadeout = idata.get('fadeout', 0) & 0x3FF # 10-bit (low 8 + high 2)
sus_start = b & 0x07
sus_end = (b >> 3) & 0x07
t_bit = (b >> 6) & 0x01
u_bit = (b >> 7) & 0x01
out = USE_ENV_BIT
out |= (sus_start & 0x1F) << 8
out |= (sus_end & 0x1F)
out |= (t_bit << 13)
out |= (u_bit << 14)
return out
vol_flags = _convert_old_sus(vol_sus, True) struct.pack_into('<H', inst_bin, base + 15, vol_sus & 0xFFFF)
pan_flags = _convert_old_sus(pan_sus, bool(pan_env)) struct.pack_into('<H', inst_bin, base + 17, pan_sus & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 15, vol_flags) struct.pack_into('<H', inst_bin, base + 19, pf_sus & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 17, pan_flags)
struct.pack_into('<H', inst_bin, base + 19, 0) # pitch/filter env unused
inst_bin[base + 171] = inst_gv & 0xFF if vol_env:
for k, (val, mf) in enumerate(vol_env[:25]): _write_env(inst_bin, base + 21, vol_env)
inst_bin[base + 21 + k*2] = val & 0xFF
inst_bin[base + 21 + k*2 + 1] = mf & 0xFF
if pan_env:
for k, (val, mf) in enumerate(pan_env[:25]):
inst_bin[base + 71 + k*2] = val & 0xFF
inst_bin[base + 71 + k*2 + 1] = mf & 0xFF
else:
for k in range(25):
inst_bin[base + 71 + k*2] = 0x80 # pan centre
inst_bin[base + 71 + k*2 + 1] = 0x00 # hold
else: else:
# No instrument envelope: single-point vol, neutral pan, full gv. # Single-point: hold at sample default volume.
inst_gv = env_data[4] if env_data else 255 inst_bin[base + 21] = min(getattr(s, 'vol', 63), 63)
inst_bin[base + 22] = 0
# Force engine to use this single point.
struct.pack_into('<H', inst_bin, base + 15, USE_ENV_BIT) struct.pack_into('<H', inst_bin, base + 15, USE_ENV_BIT)
struct.pack_into('<H', inst_bin, base + 17, 0)
struct.pack_into('<H', inst_bin, base + 19, 0) if pan_env:
inst_bin[base + 171] = inst_gv & 0xFF _write_env(inst_bin, base + 71, pan_env)
inst_bin[base + 21] = min(s.vol, 63) # value 0-63 else:
inst_bin[base + 22] = 0 # offset 0 = hold
for k in range(25): for k in range(25):
inst_bin[base + 71 + k*2] = 0x80 inst_bin[base + 71 + k*2] = 0x80
inst_bin[base + 71 + k*2 + 1] = 0x00 inst_bin[base + 71 + k*2 + 1] = 0x00
if pf_env:
_write_env(inst_bin, base + 121, pf_env)
else:
for k in range(25):
inst_bin[base + 121 + k*2] = 0x80
inst_bin[base + 121 + k*2 + 1] = 0x00
inst_bin[base + 171] = inst_gv & 0xFF
inst_bin[base + 172] = fadeout & 0xFF # low 8 bits
# Byte 173: high nybble = vibrato depth, low nybble = fadeout high bits.
fade_hi = (fadeout >> 8) & 0x03
vib_depth = idata.get('vib_depth', 0) & 0x0F
inst_bin[base + 173] = ((vib_depth & 0xF) << 4) | fade_hi
inst_bin[base + 174] = idata.get('vol_swing', 0) & 0xFF
inst_bin[base + 175] = idata.get('vib_speed', 0) & 0xFF
inst_bin[base + 176] = idata.get('vib_sweep', 0) & 0xFF
inst_bin[base + 177] = idata.get('default_pan', 0x80) & 0xFF
struct.pack_into('<H', inst_bin, base + 178,
idata.get('ppc_taud', 0x5000) & 0xFFFF)
# PPS is signed (-128..+127); struct 'b' handles the conversion.
struct.pack_into('b', inst_bin, base + 180,
max(-128, min(127, idata.get('pps', 0))))
inst_bin[base + 181] = idata.get('pan_swing', 0) & 0xFF
inst_bin[base + 182] = idata.get('ifc', 0) & 0xFF
inst_bin[base + 183] = idata.get('ifr', 0) & 0xFF
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
@@ -1685,8 +1448,7 @@ 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, patterns_rows: list, decompress: bool) -> bytes:
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,
@@ -1755,11 +1517,9 @@ 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 = {} instr_data_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
@@ -1768,29 +1528,45 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
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
src_smp = 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 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.
inst_gv_255 = min(255, round(inst.gv * 255 / 128)) inst_gv_255 = min(255, round(inst.gv * 255 / 128))
envelopes_by_slot[taud_slot] = (
inst.vol_envelope, inst.vol_env_sustain, # IT pitch-pan centre: note number 0..119 (C-5 = 60). The Taud
inst.pan_envelope, inst.pan_env_sustain, # representation is the absolute 4096-TET note value used in patterns
inst_gv_255, # (anchored to TAUD_C4 at IT note 60).
) ppc_taud = TAUD_C4 + (max(0, min(119, inst.ppc)) - 60) * 4096 // 12
if bake_count:
vprint(f" pf envelope baking: {bake_count} instrument(s)") # IT default pan: 0..64 (centre = 32). Taud uses 0..255.
sampleinst_raw, _ = build_sample_inst_bin_it(proxy, envelopes_by_slot) if inst.dfp is not None:
default_pan = min(255, max(0, round(inst.dfp * 255 / 64)))
else:
default_pan = 0x80
# Auto-vibrato lives on the canonical sample (not the IT instrument).
instr_data_by_slot[taud_slot] = {
'vol_env': inst.vol_envelope,
'vol_sus': inst.vol_env_sustain,
'pan_env': inst.pan_envelope,
'pan_sus': inst.pan_env_sustain,
'pf_env': inst.pf_envelope,
'pf_sus': inst.pf_env_sustain,
'inst_gv': inst_gv_255,
'fadeout': inst.fadeout,
'vib_speed': src_smp.av_speed,
'vib_depth': src_smp.av_depth,
'vib_sweep': src_smp.av_sweep,
'default_pan': default_pan,
'pps': inst.pps,
'ppc_taud': ppc_taud & 0xFFFF,
'pan_swing': min(255, round(inst.rp * 255 / 64)) if inst.rp else 0,
'vol_swing': min(255, round(inst.rv * 255 / 100)) if inst.rv else 0,
'ifc': inst.ifc,
'ifr': inst.ifr,
}
sampleinst_raw, _ = build_sample_inst_bin_it(proxy, instr_data_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)
proxy = [None] + list(samples) proxy = [None] + list(samples)
@@ -1890,8 +1666,6 @@ 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()
set_verbose(args.verbose) set_verbose(args.verbose)
@@ -1911,8 +1685,7 @@ 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)

View File

@@ -1274,6 +1274,82 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
} }
/**
* Advance the pitch/filter envelope by `tickSec`. Same loop / sustain semantics
* as advanceEnvelope. Result is stored in `voice.envPfValue` (0.0..1.0; 0.5 = unity).
*/
private fun advancePfEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) {
if (!voice.hasPfEnv) return
val maxIdx = 24
val pSus = inst.pfEnvSustain
val pUseEnv = (pSus ushr 5) and 1 != 0
if (!pUseEnv) return
val pEnabled = (pSus ushr 14) and 1 != 0
val pIsSustain = (pSus ushr 13) and 1 != 0
val pSusOn = pEnabled && (!pIsSustain || !voice.keyOff)
val pSusStart = (pSus ushr 8) and 0x1F
val pSusEnd = pSus and 0x1F
if (pSusOn && voice.envPfIndex == pSusEnd && pSusStart == pSusEnd) {
voice.envPfValue = inst.pfEnvelopes[voice.envPfIndex].value / 255.0
} else if (pSusOn && voice.envPfIndex == pSusEnd) {
voice.envPfTimeSec = 0.0
voice.envPfIndex = pSusStart
voice.envPfValue = inst.pfEnvelopes[voice.envPfIndex].value / 255.0
} else if (voice.envPfIndex >= maxIdx) {
voice.envPfValue = inst.pfEnvelopes[maxIdx].value / 255.0
} else {
val pOffset = inst.pfEnvelopes[voice.envPfIndex].offset.toDouble()
if (pOffset == 0.0) {
voice.envPfValue = inst.pfEnvelopes[voice.envPfIndex].value / 255.0
} else {
voice.envPfTimeSec += tickSec
if (voice.envPfTimeSec >= pOffset) {
voice.envPfTimeSec -= pOffset
val nextIdx = if (pSusOn && voice.envPfIndex == pSusEnd) pSusStart
else (voice.envPfIndex + 1).coerceAtMost(maxIdx)
voice.envPfIndex = nextIdx
voice.envPfValue = inst.pfEnvelopes[voice.envPfIndex].value / 255.0
} else {
val cur = inst.pfEnvelopes[voice.envPfIndex].value / 255.0
val nxt = inst.pfEnvelopes[(voice.envPfIndex + 1).coerceAtMost(maxIdx)].value / 255.0
voice.envPfValue = cur + (nxt - cur) * (voice.envPfTimeSec / pOffset)
}
}
}
}
/**
* IT-style auto-vibrato: returns a 4096-TET pitch delta to add to the
* playback note for the current tick, and advances the LFO phase.
* Vibrato depth ramps in linearly over `vibratoSweep` ticks (Sweep semantics
* inverted from IT — IT's "Sweep" is actually the ramp-up time in ticks;
* 0 means full depth immediately).
*/
private fun advanceAutoVibrato(voice: Voice, inst: TaudInst): Int {
val depth0 = (inst.fadeoutHighVibDepth ushr 4) and 0xF
if (depth0 == 0 || inst.vibratoSpeed == 0) return 0
val sweep = inst.vibratoSweep
val rampDepth = if (sweep == 0) depth0
else ((depth0 * voice.autoVibTicksSinceTrigger / sweep)
.coerceAtMost(depth0))
voice.autoVibTicksSinceTrigger++
// Wave selector lives in the high nibble of vibratoSweep is not standard;
// IT keeps a separate wave byte that we don't currently surface, so treat
// as sine. The same `lfoSample` table used for H/U effects works here
// (8-bit phase, signed -127..+127).
val sine = lfoSample(voice.autoVibPhase, 0)
// 4096-TET delta: vib depth is in IT units (≈ 1/256 semitone). One
// semitone = 4096/12 ≈ 341.33 4096-TET units; IT auto-vibrato depth 1
// is ~6.25 cents = 21 4096-TET units. (sine * rampDepth) is roughly
// -127*15 .. +127*15 = ±1905, divided by 64 → ±30 ≈ ±10 cents at depth 15.
val pitchDelta = (sine * rampDepth) shr 6
voice.autoVibPhase = (voice.autoVibPhase + inst.vibratoSpeed * 2) and 0xFF
return pitchDelta
}
private fun fetchTrackerSample(voice: Voice, inst: TaudInst): Double { private fun fetchTrackerSample(voice: Voice, inst: TaudInst): Double {
if (inst.index == 0) return 0.0 if (inst.index == 0) return 0.0
@@ -1334,6 +1410,39 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.envPan = inst.panEnvelopes[0].value / 255.0 voice.envPan = inst.panEnvelopes[0].value / 255.0
// Pan envelope is active when the `b` (use envelope) flag is set in panEnvSustain. // Pan envelope is active when the `b` (use envelope) flag is set in panEnvSustain.
voice.hasPanEnv = (inst.panEnvSustain ushr 5) and 1 != 0 voice.hasPanEnv = (inst.panEnvSustain ushr 5) and 1 != 0
// Pitch/filter envelope state.
voice.hasPfEnv = (inst.pfEnvSustain ushr 5) and 1 != 0
voice.envPfIsFilter = (inst.pfEnvSustain ushr 7) and 1 != 0
voice.envPfIndex = 0
voice.envPfTimeSec = 0.0
voice.envPfValue = if (voice.hasPfEnv) inst.pfEnvelopes[0].value / 255.0 else 0.5
// Fadeout starts at unity; advances only after key-off.
voice.fadeoutVolume = 1.0
// Auto-vibrato sweep ramp restarts on every fresh trigger.
voice.autoVibPhase = 0
voice.autoVibTicksSinceTrigger = 0
// Random vol/pan swing biases — seeded once per trigger (range determined by inst.volumeSwing/panSwing).
voice.randomVolBias = if (inst.volumeSwing != 0)
(Math.random() * (2 * inst.volumeSwing + 1)).toInt() - inst.volumeSwing else 0
voice.randomPanBias = if (inst.panSwing != 0)
(Math.random() * (2 * inst.panSwing + 1)).toInt() - inst.panSwing else 0
// Default pan: applied unless the pattern row has already overridden channelPan.
// We treat the pan envelope "p" flag (panEnvSustain bit 7) as "use default pan".
if ((inst.panEnvSustain ushr 7) and 1 != 0) {
voice.channelPan = inst.defaultPan
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
}
// Pitch-pan separation: when PPS != 0, played notes far from PPC drift in pan.
// PPS is signed (-32..+32), full-scale at one octave (4096 4096-TET units) above PPC.
if (inst.pitchPanSeparation != 0) {
val noteDelta = (noteVal - inst.pitchPanCentre).toDouble() / 4096.0
val panShift = (noteDelta * inst.pitchPanSeparation * 4.0).toInt() // ~×4 = 32→128 swing
voice.channelPan = (voice.channelPan + panShift).coerceIn(0, 255)
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
}
// Filter cutoff/resonance defaults — adjusted per-tick by the pf envelope when in filter mode.
voice.currentCutoff = if (inst.defaultCutoff > 0) inst.defaultCutoff else 0xFF
voice.currentResonance = inst.defaultResonance
voice.noteVal = noteVal voice.noteVal = noteVal
voice.basePitch = noteVal voice.basePitch = noteVal
voice.playbackRate = computePlaybackRate(inst, noteVal) voice.playbackRate = computePlaybackRate(inst, noteVal)
@@ -1758,15 +1867,45 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.envIndex = 0; voice.envTimeSec = 0.0 voice.envIndex = 0; voice.envTimeSec = 0.0
voice.envPanIndex = 0; voice.envPanTimeSec = 0.0 voice.envPanIndex = 0; voice.envPanTimeSec = 0.0
voice.envPan = retrigInst.panEnvelopes[0].value / 255.0 voice.envPan = retrigInst.panEnvelopes[0].value / 255.0
voice.envPfIndex = 0; voice.envPfTimeSec = 0.0
voice.envPfValue = if (voice.hasPfEnv) retrigInst.pfEnvelopes[0].value / 255.0 else 0.5
voice.fadeoutVolume = 1.0
voice.autoVibPhase = 0
voice.autoVibTicksSinceTrigger = 0
voice.rowVolume = applyRetrigVolMod(voice.rowVolume, voice.retrigVolMod) voice.rowVolume = applyRetrigVolMod(voice.rowVolume, voice.retrigVolMod)
voice.channelVolume = voice.rowVolume voice.channelVolume = voice.rowVolume
} }
} }
// Update playback rate from final pitchToMixer. // Auto-vibrato (instrument-supplied sample LFO) — added on top of pitchToMixer.
voice.playbackRate = computePlaybackRate(inst, pitchToMixer) val autoVibDelta = advanceAutoVibrato(voice, inst)
// Pitch envelope contribution: env value 0..1, 0.5 = unity. -32..+32
// semitone range maps to ±32 × 4096/12 ≈ ±10923 4096-TET units.
val pitchEnvDelta = if (voice.hasPfEnv && !voice.envPfIsFilter)
((voice.envPfValue - 0.5) * 2.0 * 32.0 * 4096.0 / 12.0).toInt()
else 0
val finalPitch = (pitchToMixer + autoVibDelta + pitchEnvDelta).coerceIn(0, 0xFFFE)
voice.playbackRate = computePlaybackRate(inst, finalPitch)
// Filter envelope (filter mode): scale current cutoff by env value (0..1, 0.5 = unity).
if (voice.hasPfEnv && voice.envPfIsFilter) {
val baseCut = if (inst.defaultCutoff > 0) inst.defaultCutoff else 0xFF
voice.currentCutoff = (baseCut * (voice.envPfValue * 2.0)).toInt().coerceIn(0, 0xFF)
}
// Volume fadeout: after key-off, decrement by inst.volumeFadeout / 1024 per tick.
// The 10-bit fadeout value is split across volumeFadeoutLow + low nibble of fadeoutHighVibDepth.
if (voice.keyOff) {
val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHighVibDepth and 0x0F) shl 8)
if (fadeStep > 0) {
voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / 1024.0).coerceAtLeast(0.0)
}
}
advanceEnvelope(voice, inst, tickSec) advanceEnvelope(voice, inst, tickSec)
advancePfEnvelope(voice, inst, tickSec)
} }
// Tempo slide — applied once per tick at the playhead level (any channel that armed it). // Tempo slide — applied once per tick at the playhead level (any channel that armed it).
@@ -1846,11 +1985,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val voiceInst = instruments[voice.instrumentId] val voiceInst = instruments[voice.instrumentId]
val s = fetchTrackerSample(voice, voiceInst) val s = fetchTrackerSample(voice, voiceInst)
val instGv = voiceInst.instGlobalVolume / 255.0 val instGv = voiceInst.instGlobalVolume / 255.0
val vol = voice.envVolume * voice.rowVolume / 63.0 * gvol * instGv * playhead.masterVolume / 255.0 // Volume swing bias (random per-trigger, ±randomVolBias of 0..255 units folded into the 0..63 row volume).
val swingScale = 1.0 + voice.randomVolBias / 255.0
val vol = voice.envVolume * voice.fadeoutVolume * voice.rowVolume / 63.0 *
swingScale * gvol * instGv * playhead.masterVolume / 255.0
val pan = if (voice.hasPanEnv) { val pan = if (voice.hasPanEnv) {
val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255) val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255)
(voice.channelPan + envPanRaw - 128).coerceIn(0, 255) (voice.channelPan + envPanRaw - 128 + voice.randomPanBias).coerceIn(0, 255)
} else voice.channelPan } else (voice.channelPan + voice.randomPanBias).coerceIn(0, 255)
val lGain: Double val lGain: Double
val rGain: Double val rGain: Double
when (ts.panLaw) { when (ts.panLaw) {
@@ -2033,6 +2175,28 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var envPan = 0.5 // 0.0=full-left, 1.0=full-right, 0.5=centre var envPan = 0.5 // 0.0=full-left, 1.0=full-right, 0.5=centre
var hasPanEnv = false var hasPanEnv = false
// Pitch / filter envelope (instrument-supplied, byte 19-20 + bytes 121-170).
var hasPfEnv = false
var envPfIndex = 0
var envPfTimeSec = 0.0
var envPfValue = 0.5 // 0.0..1.0; 0.5 = unity (no pitch shift / unmodulated cutoff)
var envPfIsFilter = false // mirror of inst.pfEnvSustain bit 7 latched at trigger
// Volume fadeout — engaged after key-off, decays to 0 at rate inst.volumeFadeoutLow.
var fadeoutVolume = 1.0
// Auto-vibrato (per-sample on the IT side, hoisted to the instrument here).
var autoVibPhase = 0 // 8-bit phase counter
var autoVibTicksSinceTrigger = 0 // for sweep ramp-up
// Filter / cutoff state (engine-side; biquad filter not yet applied to the output).
var currentCutoff = 0xFF // 0..255 (0xFF = open / unfiltered)
var currentResonance = 0 // 0..255
// Per-trigger random offsets from RV / RP swing (added to base vol/pan).
var randomVolBias = 0 // signed
var randomPanBias = 0 // signed
// Pitch state (4096-TET units, signed when slid). // Pitch state (4096-TET units, signed when slid).
var noteVal = 0xFFFF // The currently sounding base note (no per-row vibrato/arp added) var noteVal = 0xFFFF // The currently sounding base note (no per-row vibrato/arp added)
var basePitch = 0x4000 // Saved pre-effect pitch for vibrato/arp/glissando overlay var basePitch = 0x4000 // Saved pre-effect pitch for vibrato/arp/glissando overlay