IT SusLoop

This commit is contained in:
minjaesong
2026-05-02 14:26:57 +09:00
parent 902ab00132
commit 219ca1e475
3 changed files with 47 additions and 12 deletions

View File

@@ -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('<I', inst_bin, base + 0, ptr)

View File

@@ -2095,7 +2095,8 @@ TODO:
[x] `S B000` and `S B100` not working as intended -- on first playback it jumps to the next cue same row, on subsequent playbacks the commands are completely ignored
[x] implement S6x command
[x] implement Wxx command (global volume slide)
[ ] implement sample loop sustain
[x] implement sample loop sustain
"Caveat: on a foreground voice, key-off (row.note == 0x0000) currently sets voice.active = false at AudioAdapter.kt:1713, which silences the channel immediately. Sustain-loop escape therefore only takes effect on background voices spawned by NNA "Note Off" — which matches the IT idiom of layering a new note over a sustained one. Let me know if you also want the foreground key-off to keep the voice playing through fadeout."
[ ] cue and pattern compression of the Taud format (taud_common.py, taud.mjs)
[ ] figure out how IT (8 bits) and FT2 (12 bits) handles volume fadeout numbers, and come up with a compatible Taud spec, then implement
[ ] implement bitcrusher (eff sym '8')

View File

@@ -1454,7 +1454,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (voice.forward) {
voice.samplePos += voice.playbackRate
when (inst.loopMode) {
// When the sustain bit is set, key-off escapes the loop: the sample plays past
// loopEnd until it ends naturally (loopMode 0 semantics).
val effectiveLoopMode =
if (inst.sampleLoopSustain && voice.keyOff) 0 else (inst.loopMode and 3)
when (effectiveLoopMode) {
0 -> 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 }