fix: taud note with SDx not firing due to unbound inst

This commit is contained in:
minjaesong
2026-05-30 09:05:28 +09:00
parent 1d3b5ce8aa
commit 038db60b59
5 changed files with 52 additions and 2 deletions

View File

@@ -165,6 +165,14 @@ Peripheral memories can be accessed using `vm.peek()` and `vm.poke()` functions,
- The 'gzip' namespace in TSVM's JS programs is a misnomer: the actual 'gzip' functions (defined in CompressorDelegate.kt) call Zstd functions. - The 'gzip' namespace in TSVM's JS programs is a misnomer: the actual 'gzip' functions (defined in CompressorDelegate.kt) call Zstd functions.
## Taud Tracker Engine
The Taud playback engine lives in `tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt`.
### Critical Implementation Notes
**Re-bind the local `inst` after any mid-tick `triggerNote`.** `applyTrackerTick` binds `var inst = instruments[voice.instrumentId]` once at the top of the per-voice loop. When the note-delay (`S$Dx`) deferred trigger fires mid-tick, `triggerNote` swaps the voice's `instrumentId` — but the rest of that tick (playback-rate recompute at the `computePlaybackRate(inst, finalPitch)` line, `advanceEnvelope`, `advancePfEnvelope`, `advanceAutoVibrato`, and the fadeout / filter-env reads of `inst.*`) keeps using the captured binding. The damage on a **never-triggered voice** (`instrumentId == 0` → stale `inst = instruments[0]`, whose `samplingRate == 0`) is that `playbackRate` is overwritten with `0.0`, freezing the sample at its start for the trigger tick — perceived as "the first delayed note on a fresh channel doesn't fire" (canonical: WHEN.taud cue 0 voice 13 pattern 0x0A row 16, inst `0x11` SD2 on a fresh play). On a warm voice the stale `inst` is a real instrument with non-zero rate, so the note sounds (at the wrong rate for one tick — a sub-perceptual glitch). Re-bind `inst = instruments[voice.instrumentId]` immediately after the note-delay fire block. Any future in-tick trigger paths (currently only S$Dx) must do the same.
## TVDOS ## TVDOS
### TVDOS Movie Formats ### TVDOS Movie Formats

View File

@@ -4829,10 +4829,19 @@ function nudgeTickRate(delta) {
drawAlwaysOnElems() drawAlwaysOnElems()
} }
// Drop accumulated funk-repeat (S$Fx) run-state and loop-inversion masks so a fresh play
// starts deterministic instead of inheriting a prior session's funkSpeed / inverted bytes.
// Engine still keeps PT2-style persistence across a natural loop; older runtimes lacking the
// API simply retain the inversions.
function clearFunkState() {
if (typeof audio.resetFunkState === 'function') audio.resetFunkState(PLAYHEAD)
}
function startPlaySong() { function startPlaySong() {
restoreFullSongParams() restoreFullSongParams()
reuploadPatternsIfNeeded() reuploadPatternsIfNeeded()
audio.stop(PLAYHEAD) audio.stop(PLAYHEAD)
clearFunkState()
audio.setCuePosition(PLAYHEAD, cueIdx) audio.setCuePosition(PLAYHEAD, cueIdx)
audio.setTrackerRow(PLAYHEAD, 0) audio.setTrackerRow(PLAYHEAD, 0)
cursorRow = 0 cursorRow = 0
@@ -4847,6 +4856,7 @@ function startPlayCue() {
restoreFullSongParams() restoreFullSongParams()
reuploadPatternsIfNeeded() reuploadPatternsIfNeeded()
audio.stop(PLAYHEAD) audio.stop(PLAYHEAD)
clearFunkState()
audio.setCuePosition(PLAYHEAD, cueIdx) audio.setCuePosition(PLAYHEAD, cueIdx)
audio.setTrackerRow(PLAYHEAD, 0) audio.setTrackerRow(PLAYHEAD, 0)
playStartCue = cueIdx playStartCue = cueIdx
@@ -4864,6 +4874,7 @@ function startPlayRow(fromRow, fromCue) {
if (fromRow === undefined) fromRow = cursorRow if (fromRow === undefined) fromRow = cursorRow
if (fromCue === undefined) fromCue = cueIdx if (fromCue === undefined) fromCue = cueIdx
audio.stop(PLAYHEAD) audio.stop(PLAYHEAD)
clearFunkState()
audio.setCuePosition(PLAYHEAD, fromCue) audio.setCuePosition(PLAYHEAD, fromCue)
audio.setTrackerRow(PLAYHEAD, fromRow) audio.setTrackerRow(PLAYHEAD, fromRow)
playStartCue = fromCue playStartCue = fromCue
@@ -4880,6 +4891,7 @@ function startPlayPattern() {
audio.stop(PLAYHEAD) audio.stop(PLAYHEAD)
audio.setBPM(PLAYHEAD, song.bpm) audio.setBPM(PLAYHEAD, song.bpm)
audio.uploadCue(PREVIEW_CUE_IDX, buildPreviewCue(patternIdx)) audio.uploadCue(PREVIEW_CUE_IDX, buildPreviewCue(patternIdx))
clearFunkState()
audio.setCuePosition(PLAYHEAD, PREVIEW_CUE_IDX) audio.setCuePosition(PLAYHEAD, PREVIEW_CUE_IDX)
audio.setTrackerRow(PLAYHEAD, 0) audio.setTrackerRow(PLAYHEAD, 0)
playStartCue = PREVIEW_CUE_IDX playStartCue = PREVIEW_CUE_IDX
@@ -4896,6 +4908,7 @@ function startPlayPatternRow() {
audio.stop(PLAYHEAD) audio.stop(PLAYHEAD)
audio.setBPM(PLAYHEAD, song.bpm) audio.setBPM(PLAYHEAD, song.bpm)
audio.uploadCue(PREVIEW_CUE_IDX, buildPreviewCue(patternIdx)) audio.uploadCue(PREVIEW_CUE_IDX, buildPreviewCue(patternIdx))
clearFunkState()
audio.setCuePosition(PLAYHEAD, PREVIEW_CUE_IDX) audio.setCuePosition(PLAYHEAD, PREVIEW_CUE_IDX)
audio.setTrackerRow(PLAYHEAD, patternGridRow) audio.setTrackerRow(PLAYHEAD, patternGridRow)
playStartCue = PREVIEW_CUE_IDX playStartCue = PREVIEW_CUE_IDX

View File

@@ -2409,8 +2409,9 @@ TODO:
[x] expose song table on UI (test with `insaniq2.taud`) [x] expose song table on UI (test with `insaniq2.taud`)
[x] 0x0000 - no-op; 0x0001 - key-off; 0x0002 - note-cut 0x0010..0x001F - INT0..INTF [x] 0x0000 - no-op; 0x0001 - key-off; 0x0002 - note-cut 0x0010..0x001F - INT0..INTF
[ ] establish hooks for the interrupts [ ] establish hooks for the interrupts
[ ] Samples and Instruments view (viewer on taut.js; editor on separate .js) [x] Samples and Instruments view (viewer on taut.js; editor on separate .js)
follow the ImpulseTracker design first, then improve from there follow the ImpulseTracker design first, then improve from there
[ ] Sample desig for instrument in Pitch-Volume space (one rectangle = one patch). If undefined, the old sample pointer falls thru
TODO - list of demo songs that MUST ship with Microtone: TODO - list of demo songs that MUST ship with Microtone:
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes * 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes

View File

@@ -315,6 +315,13 @@ class AudioJSR223Delegate(private val vm: VM) {
getPlayhead(playhead)?.resetParams() getPlayhead(playhead)?.resetParams()
} }
/** Clear funk-repeat (S$Fx) state (per-voice run-state + per-instrument loop-inversion masks)
* without disturbing tempo / volume / position. Call on a fresh play-from-start so stale funk
* state from a prior playback doesn't bleed into the replay. */
fun resetFunkState(playhead: Int) {
getPlayhead(playhead)?.resetFunkState()
}
fun purgeQueue(playhead: Int) { fun purgeQueue(playhead: Int) {
getPlayhead(playhead)?.purgeQueue() getPlayhead(playhead)?.purgeQueue()
} }

View File

@@ -2905,7 +2905,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
for (vi in 0 until ts.voices.size) { for (vi in 0 until ts.voices.size) {
val voice = ts.voices[vi] val voice = ts.voices[vi]
if (!voice.active && voice.noteDelayTick < 0) continue if (!voice.active && voice.noteDelayTick < 0) continue
val inst = instruments[voice.instrumentId] var inst = instruments[voice.instrumentId]
// Note cut. Zero noteVolume / rowVolume (silence this note) but leave channelVolume // Note cut. Zero noteVolume / rowVolume (silence this note) but leave channelVolume
// alone — IT's note cut stops the sample, it doesn't reset chan->global_volume. // alone — IT's note cut stops the sample, it doesn't reset chan->global_volume.
@@ -2921,6 +2921,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
maybeSpawnBackgroundForNNA(ts, voice, vi) maybeSpawnBackgroundForNNA(ts, voice, vi)
triggerNote(voice, voice.delayedNote, voice.delayedInst, voice.delayedVol) triggerNote(voice, voice.delayedNote, voice.delayedInst, voice.delayedVol)
voice.noteDelayTick = -1 voice.noteDelayTick = -1
// triggerNote may have swapped in a new instrument; re-bind so the rest of this
// tick's per-voice work (playbackRate at L3090, envelope/fadeout/auto-vibrato)
// uses the instrument that just fired, not the one the voice held on entry. On a
// never-triggered voice the stale binding is instruments[0] (samplingRate 0),
// which would zero playbackRate and freeze the sample — the "first note on a
// fresh channel via S$Dx is silent" bug.
inst = instruments[voice.instrumentId]
} }
if (!voice.active) { if (!voice.active) {
@@ -4094,6 +4101,20 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
} }
/** Clear funk-repeat (S$Fx) state only — per-voice run-state plus the per-instrument
* loop-inversion masks — without touching tempo / volume / position. taut calls this on
* every fresh play-from-start so accumulated inversions and a stale funkSpeed don't bleed
* from a prior session into the replay; full resetParams would also clobber bpm / tickRate /
* volume, which a replay must preserve. Masks still persist across a natural song loop. */
fun resetFunkState() {
trackerState?.voices?.forEach {
it.funkSpeed = 0
it.funkAccumulator = 0
it.funkWritePos = 0
}
parent.instruments.forEach { it.funkMask = null }
}
fun purgeQueue() { fun purgeQueue() {
pcmQueue.clear() pcmQueue.clear()
if (isPcmMode) { if (isPcmMode) {