diff --git a/it2taud.py b/it2taud.py index 105a08f..220907a 100644 --- a/it2taud.py +++ b/it2taud.py @@ -3,7 +3,6 @@ Usage: python3 it2taud.py input.it output.taud [-v] [--no-decompress] - [--no-pf-envelope] Limits: - 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 warned; B/C effects are remapped to new cue indices. - IT2.14/IT2.15 compressed samples are decoded unless --no-decompress. - - IT instrument volume/pan envelopes (up to 12 nodes, sustain loops) are - converted to Taud format. NNA actions are ignored. Each IT instrument + - IT instrument volume/pan/pitch-or-filter envelopes (up to 25 nodes, + 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. - - 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. + - Pitch and filter envelopes are emitted natively (engine-evaluated); + auto-vibrato, fadeout, PPS/PPC, default pan, volume/pan swing, and + initial filter cutoff/resonance are forwarded to the engine via + the new instrument fields. - AdLib / OPL instruments are skipped. Effect support: @@ -38,7 +34,6 @@ Effect support: import argparse import gzip -import math import struct import sys @@ -379,7 +374,8 @@ class ITSample: 'c5_speed', 'length', 'loop_beg', 'loop_end', 'sus_beg', 'sus_end', 'smp_point', '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: samples = [] @@ -410,6 +406,11 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list: s.sus_beg = struct.unpack_from(' list: 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 ────────────────────────────────────────────────────── class ITInstrument: __slots__ = ('name', 'dfp', 'gv', 'canonical_sample', 'canonical_volume', 'vol_envelope', 'pan_envelope', 'vol_env_sustain', 'pan_env_sustain', - '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) - # 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) + 'pf_envelope', 'pf_env_sustain', 'pf_is_filter', + 'ifc', 'ifr', 'fadeout', 'pps', 'ppc', 'rv', 'rp') + # vol_envelope / pan_envelope / pf_envelope: list of 25 (value, minifloat_idx) tuples, or None + # *_env_sustain: int (16-bit, 0b 0ut sssss pcb eeeee), 0 = no envelope + # pf_is_filter: bool — pf envelope mode (False = pitch, True = filter) + # ifc / ifr : initial filter cutoff / resonance (0..127, 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: insts = [] @@ -662,9 +477,15 @@ def parse_instruments(data: bytes, h: ITHeader) -> list: inst = ITInstrument() inst.name = data[ptr+0x20:ptr+0x3A].rstrip(b'\x00').decode('latin-1', errors='replace') + inst.fadeout = struct.unpack_from(' list: # Parse IT envelopes (new-format only, ≥cmwt 0x200) # 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( - 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( - data, ptr + 0x182, True, ticks_per_sec) - inst.pf_nodes, inst.pf_flags = _parse_it_pf_envelope_raw( - data, ptr + 0x1D4) + data, ptr + 0x182, kind='pan', ticks_per_sec=ticks_per_sec) + # pf envelope: byte 0 bit 7 distinguishes filter (1) from pitch (0). + 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) 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: - """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 - 12 (value, minifloat_idx) tuples, or None if envelope not enabled. - 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. + Returns (points_list, sus_word). points_list has 25 entries (padded + with hold-zeros) or None if the envelope is disabled. - IT has two loop types: envelope loop (continues forever) and sustain loop - (breaks on key-off). Taud distinguishes them via the 't' flag. Priority - when both exist: sustain (because IT plays sustain while held, then env - loop after release; Taud can only express one). + kind: + 'vol' — IT 0..64 → Taud 0..63 (byte 1 = volume) + 'pan' — IT -32..+32 → Taud 0..255 (0x80 = centre) + '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): 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 num_nodes = max(1, min(data[env_ptr + 1], 25)) - it_lpb = data[env_ptr + 2] # envelope loop begin node - it_lpe = data[env_ptr + 3] # envelope loop end node - it_slb = data[env_ptr + 4] # sustain loop begin node - it_sle = data[env_ptr + 5] # sustain loop end node + it_lpb = data[env_ptr + 2] + it_lpe = data[env_ptr + 3] + it_slb = data[env_ptr + 4] + it_sle = data[env_ptr + 5] has_env_loop = bool(flags & 0x02) 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 - # distinguishes them: t=1 for sustain (breaks on key-off), t=0 for env loop. + # Priority: sus loop > env loop (Taud carries one loop region). if has_sus_loop: use_lb, use_le = it_slb, it_sle 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 if nptr + 2 >= len(data): 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(' 12 - if not decimated: - selected = nodes[:] - # 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 - 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)") + def _to_taud_val(it_val: int) -> int: + if kind == 'vol': + return min(63, max(0, round(it_val * 63 / 64))) + if kind == 'pan': + return min(255, max(0, round((it_val + 32) * 255 / 64))) + # pitch / filter: -32..+32 → 0..255 (0x80 = unity) + return min(255, max(0, round((it_val + 32) * 255 / 64))) - # 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 = [] - for k in range(12): - if k < len(selected): - val, tick = selected[k] - if is_pan: - taud_val = min(255, max(0, round((val + 32) * 255 / 64))) - else: - taud_val = min(63, max(0, round(val * 63 / 64))) - if k < len(selected) - 1: - _, next_tick = selected[k + 1] + for k in range(25): + if k < len(nodes): + val, tick = nodes[k] + taud_val = _to_taud_val(val) + if k < len(nodes) - 1: + _, next_tick = nodes[k + 1] delta_sec = max(0.0, (next_tick - tick) / ticks_per_sec) mf_idx = _nearest_minifloat(delta_sec) else: mf_idx = 0 # last real node: hold else: - # Pad: copy last real value, offset=0 (hold) - taud_val = points[-1][0] if points else (128 if is_pan else 63) + # Pad: hold at last real node's value. + taud_val = points[-1][0] if points else pad_value mf_idx = 0 points.append((taud_val, mf_idx)) - # Build sustain byte: bit7=enable (u), bit6=sustain (t), bits[5:3]=end, - # bits[2:0]=start. 0=disabled. t=1 → breaks on key-off (IT sustain loop); - # t=0 → loops forever (IT envelope loop). - if taud_slb >= 0 and taud_sle >= 0: - t_bit = 0x40 if is_sustain else 0x00 - sus_byte = 0x80 | t_bit | ((taud_sle & 7) << 3) | (taud_slb & 7) - else: - sus_byte = 0 + # Build 16-bit sus word. + sus_word = 0x0020 # b = 1 (use envelope) — set whenever the envelope is enabled + if carry: + sus_word |= 0x0040 + if is_filter: + sus_word |= 0x0080 + if has_loop and 0 <= use_lb < 25 and 0 <= use_le < 25: + 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 - - -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(' 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]) + return points, sus_word # ── 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) ──────────────────────────────── 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). - 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 12 (value, minifloat_idx) tuples (or None), - and inst_gv is instrument global volume (0..255, byte 15). + instr_data_by_slot: optional dict mapping taud_slot → dict with keys: + vol_env, vol_sus, pan_env, pan_sus, pf_env, pf_sus, pf_is_filter, + 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). """ @@ -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) pos += n - # New 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). - # 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), - # 5=b(use envelope), 4..0=sus_end. - USE_ENV_BIT = 0x0020 # b + # 192-byte instrument layout (terranmon.txt:1997-2070). + USE_ENV_BIT = 0x0020 # b — set whenever the engine should evaluate the envelope + + def _write_env(buf: bytearray, base: int, env_pts): + """Write 25 (value, minifloat) pairs starting at `buf[base]`. Pads + 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) for i, s in enumerate(samples_or_proxy): 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(' int: - if not has_env: - return 0 - 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 + idata = (instr_data_by_slot or {}).get(taud_idx) or {} + vol_env = idata.get('vol_env') + pan_env = idata.get('pan_env') + pf_env = idata.get('pf_env') + vol_sus = idata.get('vol_sus', USE_ENV_BIT) + pan_sus = idata.get('pan_sus', 0) + pf_sus = idata.get('pf_sus', 0) + inst_gv = idata.get('inst_gv', 0xFF) + fadeout = idata.get('fadeout', 0) & 0x3FF # 10-bit (low 8 + high 2) - vol_flags = _convert_old_sus(vol_sus, True) - pan_flags = _convert_old_sus(pan_sus, bool(pan_env)) - struct.pack_into('> 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(' list: return active def assemble_taud(h: ITHeader, samples: list, instruments: list, - patterns_rows: list, decompress: bool, - no_pf_envelope: bool = False) -> bytes: + patterns_rows: list, decompress: bool) -> bytes: # ── Resolve IT recalls ─────────────────────────────────────────────────── vprint(" resolving IT recalls…") 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: # Build a proxy sample list where Taud inst slot = IT inst index, # 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) inst_vols = {} - envelopes_by_slot = {} - bake_count = 0 + instr_data_by_slot = {} for ii, inst in enumerate(instruments): taud_slot = ii + 1 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: continue 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) inst_vols[taud_slot] = min(vol64, 0x3F) # IT global volume range is 0..128; rescale to Taud's 0..255. inst_gv_255 = min(255, round(inst.gv * 255 / 128)) - envelopes_by_slot[taud_slot] = ( - inst.vol_envelope, inst.vol_env_sustain, - inst.pan_envelope, inst.pan_env_sustain, - 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) + + # IT pitch-pan centre: note number 0..119 (C-5 = 60). The Taud + # representation is the absolute 4096-TET note value used in patterns + # (anchored to TAUD_C4 at IT note 60). + ppc_taud = TAUD_C4 + (max(0, min(119, inst.ppc)) - 60) * 4096 // 12 + + # IT default pan: 0..64 (centre = 32). Taud uses 0..255. + 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: # Samples referenced directly; proxy is samples list (0-based, slot 0 unused) proxy = [None] + list(samples) @@ -1890,8 +1666,6 @@ def main(): ap.add_argument('-v', '--verbose', action='store_true') ap.add_argument('--no-decompress', action='store_true', 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() set_verbose(args.verbose) @@ -1911,8 +1685,7 @@ def main(): patterns_rows = parse_patterns(data, h) taud = assemble_taud(h, samples, instruments, patterns_rows, - decompress=not args.no_decompress, - no_pf_envelope=args.no_pf_envelope) + decompress=not args.no_decompress) with open(args.output, 'wb') as f: f.write(taud) diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index b41b66a..379d20e 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -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 { 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 // Pan envelope is active when the `b` (use envelope) flag is set in panEnvSustain. 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.basePitch = 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.envPanIndex = 0; voice.envPanTimeSec = 0.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.channelVolume = voice.rowVolume } } - // Update playback rate from final pitchToMixer. - voice.playbackRate = computePlaybackRate(inst, pitchToMixer) + // Auto-vibrato (instrument-supplied sample LFO) — added on top of 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) + advancePfEnvelope(voice, inst, tickSec) } // 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 s = fetchTrackerSample(voice, voiceInst) 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 envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255) - (voice.channelPan + envPanRaw - 128).coerceIn(0, 255) - } else voice.channelPan + (voice.channelPan + envPanRaw - 128 + voice.randomPanBias).coerceIn(0, 255) + } else (voice.channelPan + voice.randomPanBias).coerceIn(0, 255) val lGain: Double val rGain: Double 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 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). 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