ProTracker-faithful Funk Repeat emulation

This commit is contained in:
minjaesong
2026-05-09 03:16:50 +09:00
parent 3c57e33f8f
commit 9f01bdfee9
2 changed files with 27 additions and 13 deletions

View File

@@ -1058,11 +1058,10 @@ funk_table[16] = { 0, 5, 6, 7, 8, $A, $B, $D, $10, $13, $16, $1A, $20, $2B, $40,
on every tick (when S $Fxxxx is active with x != 0): on every tick (when S $Fxxxx is active with x != 0):
funk_accumulator += funk_length funk_accumulator += funk_length
while funk_accumulator >= $80: if funk_accumulator >= $80: # hard reset, drops residual
funk_accumulator -= $80 funk_accumulator = 0
bit = funk_mask[funk_write_pos] funk_write_pos = (funk_write_pos + 1) mod loop_length # pre-increment
funk_mask[funk_write_pos] = bit XOR 1 funk_mask[funk_write_pos] = funk_mask[funk_write_pos] XOR 1
funk_write_pos = (funk_write_pos + 1) mod loop_length
on sample byte read during loop playback: on sample byte read during loop playback:
raw_byte = sample_data[offset_in_loop] raw_byte = sample_data[offset_in_loop]
@@ -1072,7 +1071,7 @@ on sample byte read during loop playback:
output_byte = raw_byte output_byte = raw_byte
``` ```
`S $F000` clears `funk_accumulator` but leaves `funk_mask` intact (so the accumulated inversion pattern persists until the instrument is reset). On a fresh note or instrument-change event, Taud optionally resets `funk_mask` to all zero; this is a per-implementation choice, but the recommended default is **reset on instrument-change, preserve on pure note retrigger**. `S $F000` clears `funk_accumulator` but leaves `funk_mask` intact (the accumulated inversion pattern persists). **On every fresh note trigger**, `funk_write_pos` resets to 0 (matching PT2's `n_wavestart = n_loopstart`); `funk_accumulator` and `funk_speed` persist across notes. The `funk_mask` itself is **only cleared on cue-start reset** (i.e. song-start / stop-and-replay) — within a single playback session it accumulates as PT2's destructive in-place edits would, but a clean replay always reproduces the same audio without needing to reload the song from disk.
--- ---

View File

@@ -1643,12 +1643,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val frac = voice.samplePos - i0.toDouble() val frac = voice.samplePos - i0.toDouble()
var b0 = sampleBin[(basePtr + i0).coerceAtMost(binMax).toLong()].toUint() var b0 = sampleBin[(basePtr + i0).coerceAtMost(binMax).toLong()].toUint()
var b1 = sampleBin[(basePtr + i1).coerceAtMost(binMax).toLong()].toUint() var b1 = sampleBin[(basePtr + i1).coerceAtMost(binMax).toLong()].toUint()
// S$Fx funk repeat: XOR the high bit of bytes whose loop-relative index // S$Fx funk repeat: bit-invert (XOR 0xFF) bytes whose loop-relative index
// is set in funkMask. Only meaningful when the sample has a loop region. // is set in funkMask. Mirrors PT2's `*p = -1 - *p` (full bitwise NOT) — the
// mask is a non-destructive overlay so the source sample stays pristine.
// Only meaningful when the sample has a loop region.
if (inst.funkMask != null && inst.sampleLoopEnd > inst.sampleLoopStart) { if (inst.funkMask != null && inst.sampleLoopEnd > inst.sampleLoopStart) {
val ls = inst.sampleLoopStart val ls = inst.sampleLoopStart
if (i0 in ls until inst.sampleLoopEnd && inst.funkBit(i0 - ls)) b0 = b0 xor 0x80 if (i0 in ls until inst.sampleLoopEnd && inst.funkBit(i0 - ls)) b0 = b0 xor 0xFF
if (i1 in ls until inst.sampleLoopEnd && inst.funkBit(i1 - ls)) b1 = b1 xor 0x80 if (i1 in ls until inst.sampleLoopEnd && inst.funkBit(i1 - ls)) b1 = b1 xor 0xFF
} }
val s0 = (b0 - 127.5) / 127.5 val s0 = (b0 - 127.5) / 127.5
val s1 = (b1 - 127.5) / 127.5 val s1 = (b1 - 127.5) / 127.5
@@ -1751,6 +1753,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Auto-vibrato sweep ramp restarts on every fresh trigger. // Auto-vibrato sweep ramp restarts on every fresh trigger.
voice.autoVibPhase = 0 voice.autoVibPhase = 0
voice.autoVibTicksSinceTrigger = 0 voice.autoVibTicksSinceTrigger = 0
// Funk repeat (S$Fx): PT2 resets n_wavestart to n_loopstart on every fresh
// note trigger (pt2_replayer.c:1094, 1100). funkSpeed and funkAccumulator
// persist across notes, matching PT2.
voice.funkWritePos = 0
// Random vol/pan swing biases — seeded once per trigger (range determined by inst.volumeSwing/panSwing). // Random vol/pan swing biases — seeded once per trigger (range determined by inst.volumeSwing/panSwing).
voice.randomVolBias = if (inst.volumeSwing != 0) voice.randomVolBias = if (inst.volumeSwing != 0)
(Math.random() * (2 * inst.volumeSwing + 1)).toInt() - inst.volumeSwing else 0 (Math.random() * (2 * inst.volumeSwing + 1)).toInt() - inst.volumeSwing else 0
@@ -2711,16 +2717,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
// Funk repeat (S$Fxxxx) — advance bit-mask per tick on instruments with active funkSpeed. // Funk repeat (S$Fxxxx) — advance bit-mask per tick on instruments with active funkSpeed.
// Matches PT2 updateFunk (pt2_replayer.c:278-297): hard-reset accumulator on overflow
// (NOT subtract — drops residual), and pre-increment the write pointer before flipping
// so the first invert after a fresh trigger lands on loop-relative byte 1.
for (voice in ts.voices) { for (voice in ts.voices) {
if (voice.funkSpeed == 0 || !voice.active) continue if (voice.funkSpeed == 0 || !voice.active) continue
val inst = instruments[voice.instrumentId] val inst = instruments[voice.instrumentId]
if (inst.sampleLoopEnd <= inst.sampleLoopStart) continue if (inst.sampleLoopEnd <= inst.sampleLoopStart) continue
voice.funkAccumulator += voice.funkSpeed voice.funkAccumulator += voice.funkSpeed
while (voice.funkAccumulator >= 0x80) { if (voice.funkAccumulator >= 0x80) {
voice.funkAccumulator -= 0x80 voice.funkAccumulator = 0
val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1) val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1)
inst.toggleFunkBit(voice.funkWritePos % loopLen)
voice.funkWritePos = (voice.funkWritePos + 1) % loopLen voice.funkWritePos = (voice.funkWritePos + 1) % loopLen
inst.toggleFunkBit(voice.funkWritePos)
} }
} }
@@ -3426,6 +3435,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
it.noteFading = false it.noteFading = false
} }
ts.backgroundVoices.clear() ts.backgroundVoices.clear()
// Funk repeat (S$Fx): drop every per-instrument inversion mask so that
// stop-and-replay starts from a clean cue-initial state. The masks accumulate
// within a single playback (matching PT2's destructive-but-stable behaviour);
// here we snapshot back to "no inversions yet" so a fresh play is reproducible
// without needing to reload the song from disk.
parent.instruments.forEach { it.funkMask = null }
} }
} }