mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
IT SusLoop
This commit is contained in:
39
it2taud.py
39
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('<I', inst_bin, base + 0, ptr)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user