volume policy when unspecified: retrigger (note+inst cmd) -> default value, no retrigger (note cmd only) -> prev value

This commit is contained in:
minjaesong
2026-05-08 02:21:16 +09:00
parent 34b3b83d65
commit 3182ae9146
2 changed files with 69 additions and 8 deletions

View File

@@ -2339,13 +2339,15 @@ TODO:
Hz values verbatim (no SLIDE_UNITS_PER_HZ scaling) and sets the
linear-freq flag in the song-table flags byte. Spec details in
TAUD_NOTE_EFFECTS.md §1, §E, §F, §G.
[ ] milkytracker-style volume ramping (on sample-end only)
[x] milkytracker-style volume ramping (on sample-end only)
[x] make Cues tab move faster
Resolution: Cues panel now uses memory-shift (`shiftOrdersAreaHorizontal`)
for LEFT/RIGHT and `shiftPatternArea` for UP/DOWN, plus per-row
(`drawOrdersRowAt`) and per-column (`drawOrdersVoiceColumnAt`) helpers,
replacing the full-panel redraw on every keystroke.
[x] volume and panning policy to match note effect policy: when note is "retriggerred" (note command with instrument specified), the volume/pan must take default value; if not (note command with instrument 0) the volume/pan must stay at the old value. Make both audio engine and taut.js simulator changes.
[ ] xm volume column commands (+x, -x, Dx, Lx, Mx, Px, Rx, Sx, Ux, Vx) are completely ignored
[ ] theday.xm order 0x28, channel 6..8 has 'note trigger with inst 1 but no volume -> key-off -> set-volume to 0x20 -> key-off -> set-volume to 0x10 -> key-off -> ...' and it sounds like gating: key-off silences the output, set-volume turns on the output again; notably, this behaviour only works when volume envelope is turned off (any fadeouts progress normally). What I want to know before implementing this feature is that would the way it works on XM conflicts with Taud or ImpulseTracker's behaviour
Play Data: play data are series of tracker-like instructions, visualised as:

View File

@@ -139,6 +139,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// linear-freq slides — uses A0 = 27.5 Hz with the same equal-temperament tuning,
// so emitted Hz values map directly to audible Hz at any pitch.
const val LINEAR_FREQ_C4_HZ = 261.6255653005986
// Anti-click ramp-out: when a sample naturally ends or is cut, the voice keeps
// mixing for this many output samples while gain decays linearly to 0.
// 8 ms at 32 kHz — long enough to bury the click, short enough not to read as fade.
// Applied on sample end only (preserves attack transients on note start).
const val RAMP_OUT_SAMPLES = 256
}
// Memory map (terranmon.txt:1985-1997, updated 2026-05-06):
@@ -1629,6 +1634,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val s1 = (b1 - 127.5) / 127.5
val sample = s0 + (s1 - s0) * frac
// While ramping out at sample end, hold position so the mixer keeps emitting the
// clamped last-sample value with decaying gain — no further advance, no re-trigger
// of the end check.
if (voice.rampOutSamples > 0) return sample
if (voice.forward) {
voice.samplePos += voice.playbackRate
// When the sustain bit is set, key-off escapes the loop: the sample plays past
@@ -1636,10 +1646,16 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val effectiveLoopMode =
if (inst.sampleLoopSustain && voice.keyOff) 0 else (inst.loopMode and 3)
when (effectiveLoopMode) {
0 -> if (voice.samplePos >= sampleLen) voice.active = false
0 -> if (voice.samplePos >= sampleLen) {
voice.samplePos = (sampleLen - 1).toDouble().coerceAtLeast(0.0)
startRampOut(voice)
}
1 -> if (voice.samplePos >= loopEnd) voice.samplePos -= (loopEnd - loopStart).coerceAtLeast(1.0)
2 -> if (voice.samplePos >= loopEnd) { voice.samplePos = loopEnd; voice.forward = false }
3 -> if (voice.samplePos >= sampleLen) { voice.samplePos = sampleLen.toDouble() - 1; voice.active = false }
3 -> if (voice.samplePos >= sampleLen) {
voice.samplePos = (sampleLen - 1).toDouble().coerceAtLeast(0.0)
startRampOut(voice)
}
}
} else {
voice.samplePos -= voice.playbackRate
@@ -1648,6 +1664,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
return sample
}
/**
* Engage the MilkyTracker-style sample-end ramp. The voice keeps emitting its held
* last-sample value for [RAMP_OUT_SAMPLES] more output samples while gain decays
* linearly from 1.0 to 0.0; the mixer flips voice.active = false at the end.
* No-op if already ramping (don't restart a running ramp from a re-entrant call).
*/
private fun startRampOut(voice: Voice) {
if (voice.rampOutSamples > 0) return
voice.rampOutSamples = RAMP_OUT_SAMPLES
voice.rampOutGain = 1.0
voice.rampOutStep = 1.0 / RAMP_OUT_SAMPLES
}
/**
* Trigger a fresh note on [voice]: load the instrument, reset sample position, kick off the envelope.
* Pulled out so S$Dx (note delay) can defer the same logic to a later tick.
@@ -1681,6 +1710,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.envPfValue = if (voice.hasPfEnv) inst.pfEnvelopes[0].value / 255.0 else 0.5
// Fadeout starts at unity; advances only after key-off.
voice.fadeoutVolume = 1.0
// Cancel any sample-end ramp left over from the previous note — a fresh trigger's
// attack must not be muted by a trailing fade.
voice.rampOutSamples = 0
voice.rampOutGain = 0.0
// Auto-vibrato sweep ramp restarts on every fresh trigger.
voice.autoVibPhase = 0
voice.autoVibTicksSinceTrigger = 0
@@ -2693,8 +2726,16 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
rGain = if (pan < 0x80) pan / 128.0 else 1.0
}
}
mixL += s * vol * lGain
mixR += s * vol * rGain
// Sample-end ramp-out: snapshot gain, advance the ramp, deactivate at zero.
val rampGain = if (voice.rampOutSamples > 0) {
val g = voice.rampOutGain
voice.rampOutGain -= voice.rampOutStep
voice.rampOutSamples--
if (voice.rampOutSamples == 0) voice.active = false
g
} else 1.0
mixL += s * vol * lGain * rampGain
mixR += s * vol * rGain * rampGain
}
// Background (NNA-ghost) voices — same per-sample mixing path as foreground, but
// they live in a mixer-private pool that no row event can address.
@@ -2714,14 +2755,24 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val lGain: Double
val rGain: Double
when (ts.panLaw) {
1 -> { lGain = cos(PI * pan / 512.0); rGain = sin(PI * pan / 512.0) }
1 -> {
lGain = cos(PI * pan / 512.0)
rGain = sin(PI * pan / 512.0)
}
else -> {
lGain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0
rGain = if (pan < 0x80) pan / 128.0 else 1.0
}
}
mixL += s * vol * lGain
mixR += s * vol * rGain
val rampGain = if (bg.rampOutSamples > 0) {
val g = bg.rampOutGain
bg.rampOutGain -= bg.rampOutStep
bg.rampOutSamples--
if (bg.rampOutSamples == 0) bg.active = false
g
} else 1.0
mixL += s * vol * lGain * rampGain
mixR += s * vol * rGain * rampGain
}
ts.mixLeft[n] = mixL.toFloat().coerceIn(-1.0f, 1.0f)
@@ -2946,6 +2997,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Volume fadeout — engaged after key-off, decays to 0 at rate inst.volumeFadeoutLow.
var fadeoutVolume = 1.0
// MilkyTracker-style anti-click ramp-out. Engaged when a sample naturally ends
// (loopMode 0/3 reaching sampleLen). Gain ramps from 1.0 → 0.0 over rampOutSamples
// while the held last-sample value keeps being emitted; voice deactivates at 0.
// Not engaged on note start — attack transients pass unsmoothed.
var rampOutSamples = 0
var rampOutGain = 0.0
var rampOutStep = 0.0
// Auto-vibrato (per-sample on the IT side, hoisted to the instrument here).
var autoVibPhase = 0 // 8-bit phase counter
var autoVibTicksSinceTrigger = 0 // for sweep ramp-up