diff --git a/it2taud.py b/it2taud.py index 5c9c1c4..35f2b9e 100644 --- a/it2taud.py +++ b/it2taud.py @@ -84,6 +84,7 @@ IT_SMP_COMPRESSED = 0x08 IT_SMP_LOOP = 0x10 IT_SMP_SUS_LOOP = 0x20 IT_SMP_PINGPONG = 0x40 +IT_SMP_PINGPONG_SUS = 0x80 # Vol-column byte ranges (inclusive lower, inclusive upper) VC_VOL_LO, VC_VOL_HI = 0, 64 @@ -438,6 +439,8 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list: s.length = len(s.sample_data) s.loop_beg = min(s.loop_beg, s.length) s.loop_end = min(s.loop_end, s.length) + s.sus_beg = min(s.sus_beg, s.length) + s.sus_end = min(s.sus_end, s.length) except Exception as e: vprint(f" warning: '{s.name}' decompression failed ({e}), silent") else: @@ -452,6 +455,8 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list: s.length = len(s.sample_data) s.loop_beg = min(s.loop_beg, s.length) s.loop_end = min(s.loop_end, s.length) + s.sus_beg = min(s.sus_beg, s.length) + s.sus_end = min(s.sus_end, s.length) samples.append(s) return samples @@ -1128,6 +1133,8 @@ def build_sample_inst_bin_it(samples_or_proxy: list, s.length = len(new_data) s.loop_beg = max(0, int(s.loop_beg * ratio)) s.loop_end = max(0, min(int(s.loop_end * ratio), s.length)) + s.sus_beg = max(0, int(s.sus_beg * ratio)) + s.sus_end = max(0, min(int(s.sus_end * ratio), s.length)) s.c5_speed = max(1, int(s.c5_speed * ratio)) sample_bin = bytearray(SAMPLEBIN_SIZE) @@ -1142,7 +1149,9 @@ def build_sample_inst_bin_it(samples_or_proxy: list, offsets[idx] = pos if n < len(s.sample_data): vprint(f" warning: '{s.name}' truncated {len(s.sample_data)} → {n}") - s.length = n; s.loop_end = min(s.loop_end, n) + s.length = n + s.loop_end = min(s.loop_end, n) + s.sus_end = min(s.sus_end, n) pos += n # 192-byte instrument layout (terranmon.txt:1997-2070). @@ -1168,15 +1177,33 @@ def build_sample_inst_bin_it(samples_or_proxy: list, ptr = offsets.get(i, 0) & 0xFFFFFFFF s_len = min(s.length, 65535) c2spd = min(s.c5_speed, 65535) - ls = min(s.loop_beg, 65535) - le = min(s.loop_end, 65535) - if s.has_loop and (s.flags & IT_SMP_PINGPONG): - loop_mode = 2 # backandforth + # Sustain loop wins over the regular loop because Taud carries one loop + # region. After key-off the engine drops the loop entirely (terranmon.txt:2007). + if s.flags & IT_SMP_SUS_LOOP: + ls = min(s.sus_beg, 65535) + le = min(s.sus_end, 65535) + sustain_bit = 0x4 + pingpong_active = bool(s.flags & IT_SMP_PINGPONG_SUS) + has_active_loop = True elif s.has_loop: + ls = min(s.loop_beg, 65535) + le = min(s.loop_end, 65535) + sustain_bit = 0x0 + pingpong_active = bool(s.flags & IT_SMP_PINGPONG) + has_active_loop = True + else: + ls = min(s.loop_beg, 65535) + le = min(s.loop_end, 65535) + sustain_bit = 0x0 + pingpong_active = False + has_active_loop = False + if has_active_loop and pingpong_active: + loop_mode = 2 # backandforth + elif has_active_loop: loop_mode = 1 # forward loop else: loop_mode = 0 # no loop - flags_byte = loop_mode & 0x3 + flags_byte = (loop_mode & 0x3) | sustain_bit base = taud_idx * 192 struct.pack_into(' if (voice.samplePos >= sampleLen) voice.active = false 1 -> if (voice.samplePos >= loopEnd) voice.samplePos -= (loopEnd - loopStart).coerceAtLeast(1.0) 2 -> if (voice.samplePos >= loopEnd) { voice.samplePos = loopEnd; voice.forward = false } @@ -1836,7 +1840,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { val arg = resolveArg(rawArg, voice.mem.o).also { if (rawArg != 0) voice.mem.o = it } val inst = instruments[voice.instrumentId] var off = arg - if (inst.loopMode != 0 && inst.sampleLoopEnd > inst.sampleLoopStart && off > inst.sampleLoopEnd) { + if ((inst.loopMode and 3) != 0 && inst.sampleLoopEnd > inst.sampleLoopStart && off > inst.sampleLoopEnd) { val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1) off = inst.sampleLoopStart + ((off - inst.sampleLoopStart) % loopLen) } @@ -2924,7 +2928,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { var samplePlayStart: Int, var sampleLoopStart: Int, var sampleLoopEnd: Int, - var loopMode: Int, // byte 14, low 2 bits + var loopMode: Int, // byte 14, low 3 bits (bits 0-1: loop kind, bit 2: sustain) var volEnvSustain: Int, // bytes 15-16 (16-bit, see flag layout) var panEnvSustain: Int, // bytes 17-18 var pfEnvSustain: Int, // bytes 19-20 (pitch/filter) @@ -2957,6 +2961,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { 0, 0, 0, 0 ) + /** Sample-flag byte 14 bit 2 — when set, the sample loop is a sustain loop: + * it loops while the note is held and is escaped on key-off. */ + val sampleLoopSustain: Boolean get() = (loopMode and 0x04) != 0 /** New note action — instrumentFlag bits 0-1. * 0=note off, 1=note cut, 2=continue, 3=note fade. */ val newNoteAction: Int get() = instrumentFlag and 0x03 @@ -3019,7 +3026,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { 12 -> sampleLoopEnd.toByte() 13 -> sampleLoopEnd.ushr(8).toByte() - 14 -> (loopMode and 3).toByte() + 14 -> (loopMode and 7).toByte() 15 -> volEnvSustain.toByte() 16 -> volEnvSustain.ushr(8).toByte() 17 -> panEnvSustain.toByte() @@ -3074,7 +3081,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { 12 -> { sampleLoopEnd = (sampleLoopEnd and 0xff00) or byte } 13 -> { sampleLoopEnd = (sampleLoopEnd and 0x00ff) or (byte shl 8) } - 14 -> { loopMode = byte and 3 } + 14 -> { loopMode = byte and 7 } 15 -> { volEnvSustain = (volEnvSustain and 0xff00) or byte } 16 -> { volEnvSustain = (volEnvSustain and 0x00ff) or (byte shl 8) } 17 -> { panEnvSustain = (panEnvSustain and 0xff00) or byte }