diff --git a/terranmon.txt b/terranmon.txt index 3677bd2..8335eda 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -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: diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 3131287..0d355ca 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -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