more volume ramping

This commit is contained in:
minjaesong
2026-05-10 14:59:30 +09:00
parent 2cdd731c3b
commit 72761c0552
4 changed files with 151 additions and 21 deletions

View File

@@ -241,7 +241,7 @@ Glissando control (S $1x) snaps the output pitch to the nearest semitone after e
**Plain.** Slides the channel's current pitch toward the note specified in the same row, at $xxxx units per tick (after tick 0), stopping when the target is reached. A row with G and a note does **not** re-trigger the sample — the note's pitch becomes the portamento target and the already-sounding sample continues at its current pitch.
The unit of `$xxxx` depends on the song-table tone mode (effect `1`, bits 1-2):
The unit of `$xxxx` depends on the song-table tone mode (effect `1`, bits 0-1):
- `ff = 0` (linear) and `ff = 1` (Amiga): 4096-TET pitch units per tick. Amiga sources should be converted to linear units on G, since the original PT G slide already operated semi-linearly within a small range and the shared-memory pitfall of E/F does not apply here.
- `ff = 2` (linear-frequency): Hz/tick. The engine walks the channel's *frequency* toward the target note's frequency by `±$xxxx` Hz each non-first tick. This is MONOTONE's `3xx` behaviour verbatim (MTSRC/MT_PLAY.PAS:620-630).
@@ -1122,16 +1122,18 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
**Plain.** Sets mixer-wide behaviour flags. Available flags are:
0b 0000 rr ff
0b 000 rrr ff
- ff = 0: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch.
- ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
- ff = 2: Linear-frequency tone mode (MONOTONE compat). **E, F, and G arguments are stored as Hz/tick** (a signed change in audible frequency per song tick), and the engine converts the channel's stored 4096-TET pitch back to a frequency, adds/subtracts the argument, then converts back to 4096-TET. Reference is fixed at 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, which matches MONOTONE's MT_PLAY.PAS `notesHz` table (A0 = 27.5 Hz, equal-temperament). Unlike Amiga mode, *all three* slide effects use the new arithmetic — Monotone's `1xx`, `2xx`, and `3xx` are all in Hz/tick (see MTSRC/MT_PLAY.PAS:606-630).
- rr = 0: Yes interpolation. Actual interpolation algorithm is implementation-dependent, but recommended to use either Fast Sinc or Linear.
- rr = 1: No interpolation.
- rr = 2: Amiga 500 interpolation.
- rr = 3: Amiga 1200 interpolation.
- rrr = 0: Yes interpolation. Actual interpolation algorithm is implementation-dependent, but recommended to use either Fast Sinc or Linear.
- rrr = 1: No interpolation.
- rrr = 2: Amiga 500 interpolation.
- rrr = 3: Amiga 1200 interpolation.
- rrr = 4: SNES 4-tap Gaussian
- rrr = 5: Preserve delta modulation (linear intp.)
### Volume Fadeout

View File

@@ -1,5 +1,5 @@
/**
* TSVM Audio Device Tracker
* Microtone. formerly known as TSVM Audio Device Tracker. (taut)
*
* Created by minjaesong on 2026-04-20
*/
@@ -19,6 +19,7 @@ const BULLET = "\u00847u"
const VERT = "\u00B3"
const TWOVERT = "\u00BA"
// global var for the app
if (!_G.TAUT) _G.TAUT = {};
if (!_G.TAUT.UI) _G.TAUT.UI = {};
if (!_G.TAUT.UI.NEXTPANEL) _G.TAUT.UI.NEXTPANEL = undefined;

View File

@@ -2634,8 +2634,9 @@ Endianness: Little
Uint16 Current Tuning base note (1..65533). A4 (western default) is 0x5C00. C9 (tracker default) is 0xA000. If zero, assume the tracker default value
Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default
Uint8 Flags for Global Behaviour (effect symbol '1')
0b 0000 00ff
0b 000 rrr ff
ff: tone mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: reserved)
rrr: interpolation mode (0: default, 1: none, 2: Amiga 500, 3: Amiga 1200, 4: SNES 4-tap Gaussian, 5: preserve delta modulation)
Uint8 Song global volume
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
Uint8 Song mixing volume

View File

@@ -126,9 +126,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
const val SAMPLING_RATE = 32000
const val TRACKER_CHUNK = 512
// Per-voice soundscope ring-buffer length. Power of two so wrap-around is a single AND.
// Sized at 2× the soundscope width so the AudioMenu waveform view always has spare
// Sized at 4× the soundscope width so the AudioMenu waveform view always has spare
// samples on either side of the centre to search for a stable trigger point.
const val SCOPE_BUFFER_SIZE = 1024
const val SCOPE_BUFFER_SIZE = 2048
// Mixer-private background-voice pool size per playhead. NNA "Continue/Note Off/Note Fade"
// ghosts displaced foreground voices into this pool; oldest is evicted on overflow.
const val MAX_BG_VOICES = 64
@@ -149,6 +149,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// 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
// Volume-change anti-click ramp: voleff/notefx (volume column, D vol-slides,
// tremor, tremolo, retrig vol-mod, fine slides etc.) mutate Voice.rowVolume
// mid-note. The mixer ramps the actual applied gain across [VOL_RAMP_SAMPLES]
// output samples to mask the discontinuity. ~2 ms at 32 kHz — short enough
// not to smear tremolo at fast speeds, long enough to bury per-tick slide
// steps. Bypassed on fresh note triggers (triggerNote snaps currentMixVolume
// to target) so attack transients pass through untouched.
const val VOL_RAMP_SAMPLES = 64
// Sample bin: 8 MB total, banked through a 512 K window at peripheral
// memory 0..524287. MMIO 46 holds the currently-exposed bank index.
@@ -167,11 +175,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
const val INTERP_A500 = 2
const val INTERP_A1200 = 3
// Fast Sinc — 16-tap windowed sinc with 1024 sub-sample positions.
// Fast Sinc — 6-tap windowed sinc with 1024 sub-sample positions.
// Mirrors MilkyTracker's MIXER_SINCTABLE (ResamplerSinc.h: WIDTH=8, 1024-step table,
// window = 0.5 + 0.5·cos(πi / WIDTH·step)). Coefficients are symmetric so we only
// store half the kernel; the second half is index-mirrored at lookup time.
const val SINC_WIDTH = 8
const val SINC_WIDTH = 3
const val SINC_PRECISION_SHIFT = 10
const val SINC_PRECISION = 1 shl SINC_PRECISION_SHIFT // 1024
private val SINC_TABLE: DoubleArray = run {
@@ -1421,7 +1429,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// applies only in fall-through (no active sustain or loop wrap) since Schism
// suppresses fade_flag inside both wrap branches. Without this rule, instruments
// with fadeout=0 + envelope ending at 0 would silently hold their voices forever.
if (vEnd == 0 && !wrapping) voice.active = false
// Use startRampOut instead of bare active=false so the trailing sample value
// fades to zero over RAMP_OUT_SAMPLES (~8 ms); a hard deactivation here would
// click because envVolMix still has not fully reached 0 by the time this tick
// fires.
if (vEnd == 0 && !wrapping) startRampOut(voice)
} else {
val vOffset = inst.volEnvelopes[voice.envIndex].offset.toDouble()
val vCurValue = inst.volEnvelopes[voice.envIndex].value
@@ -1429,7 +1441,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Reached a terminator point — envelope holds here.
voice.envVolume = (vCurValue / 63.0).coerceIn(0.0, 1.0)
// Same Schism cut rule as above: only when in fall-through.
if (vCurValue == 0 && !wrapping) voice.active = false
if (vCurValue == 0 && !wrapping) startRampOut(voice)
} else {
voice.envTimeSec += tickSec
if (voice.envTimeSec >= vOffset) {
@@ -1741,8 +1753,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val sample: Double = when (interpMode) {
INTERP_DEFAULT -> {
var acc = 0.0
// Taps span [i0 - WIDTH + 1, i0 + WIDTH], with the kernel centred on i0+frac.
for (j in -SINC_WIDTH + 1 .. SINC_WIDTH) {
// Taps span [i0 - WIDTH, i0 + WIDTH], with the kernel centred on i0+frac.
for (j in -SINC_WIDTH .. SINC_WIDTH) {
val coeff = sincTap(frac, j)
if (coeff != 0.0) acc += readSamplePoint(inst, i0 + j, sampleLen, binMax) * coeff
}
@@ -1798,6 +1810,38 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.rampOutStep = 1.0 / RAMP_OUT_SAMPLES
}
/**
* Per-sample volume-ramp tick. Smooths [Voice.currentMixVolume] toward
* `rowVolume / 63.0` over [VOL_RAMP_SAMPLES] samples whenever the mixer
* detects a discrepancy. Discrepancies arise from voleff/notefx that
* mutate rowVolume mid-note (volume column SET / fine slides, D
* vol-slide tick, vol-column slide tick, tremor gating, tremolo,
* retrig vol-mod, S$80 cuts, etc.). Fresh triggers bypass this by
* snapping currentMixVolume in [triggerNote], so attacks are unsmoothed.
*/
private fun advanceVolumeRamp(voice: Voice) {
val target = voice.rowVolume / 63.0
// Deferred key-on snap: triggerNote arms this so the first mixer sample after a
// fresh trigger re-syncs to the post-row rowVolume (already adjusted by any
// V-column SET / fine slide on the same row). Bypasses the ramp entirely.
if (voice.snapMixVolume) {
voice.currentMixVolume = target
voice.volRampSamples = 0
voice.volRampStep = 0.0
voice.snapMixVolume = false
return
}
if (voice.volRampSamples > 0) {
voice.currentMixVolume += voice.volRampStep
voice.volRampSamples--
if (voice.volRampSamples == 0) voice.currentMixVolume = target
} else if (voice.currentMixVolume != target) {
voice.volRampStep = (target - voice.currentMixVolume) / VOL_RAMP_SAMPLES
voice.volRampSamples = VOL_RAMP_SAMPLES - 1
voice.currentMixVolume += voice.volRampStep
}
}
/**
* 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.
@@ -1827,6 +1871,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.envIndex = 0
voice.envTimeSec = 0.0
voice.envVolume = (inst.volEnvelopes[0].value / 63.0).coerceIn(0.0, 1.0)
// Snap the per-sample-smoothed envelope to the fresh starting value so attack
// transients land at the envelope's node-0 value immediately. Per-tick step is
// recomputed by applyTrackerTick on the next tick boundary.
voice.envVolMix = voice.envVolume
voice.envVolStep = 0.0
voice.envPanIndex = 0
voice.envPanTimeSec = 0.0
voice.envPan = inst.panEnvelopes[0].value / 255.0
@@ -1907,6 +1956,16 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
else -> voice.channelVolume
}
voice.rowVolume = voice.channelVolume
// Defer the anti-click ramp snap to the next mixer sample. applyVolColumn and
// applyEffectRow run *after* triggerNote in applyTrackerRow and frequently
// override rowVolume on the same row (e.g., a key-on row carrying a V column
// value of 30). Snapping currentMixVolume here would set it to 1.0, then the
// mixer would detect the lowered post-volColumn target and ramp DOWN from
// 1.0 — an audible transient spike at every soft-attack note. The deferred
// snap reads rowVolume after the row has fully resolved.
voice.snapMixVolume = true
voice.volRampSamples = 0
voice.volRampStep = 0.0
voice.noteWasCut = false
voice.noteFading = false
// S $73..$7C state resets on each fresh trigger so per-note overrides don't leak.
@@ -2015,10 +2074,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
v.rowVolume = src.rowVolume
v.channelPan = src.channelPan
v.rowPan = src.rowPan
// Inherit the smoothed gain so the ghost picks up where the foreground left off
// without a click. Ramp state (counter/step) intentionally not copied — the ghost
// doesn't take new voleff/notefx events, so any in-flight ramp can complete via
// the snap-to-target on first mix iteration.
v.currentMixVolume = src.currentMixVolume
v.keyOff = src.keyOff
v.envIndex = src.envIndex
v.envTimeSec = src.envTimeSec
v.envVolume = src.envVolume
v.envVolMix = src.envVolMix
v.envVolStep = src.envVolStep
v.envPanIndex = src.envPanIndex
v.envPanTimeSec = src.envPanTimeSec
v.envPan = src.envPan
@@ -2614,6 +2680,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
private fun applyTrackerTick(ts: TrackerState, playhead: Playhead) {
val tickSec = 2.5 / playhead.bpm
// Samples-per-tick at the current BPM — used to spread the per-tick envVolume
// jump across the upcoming tick interval so the mixer hears a continuous slope
// instead of a stair-step. Recomputed every tick because BPM can change mid-row.
val spt = SAMPLING_RATE * tickSec
for (vi in 0 until ts.voices.size) {
val voice = ts.voices[vi]
if (!voice.active && voice.noteDelayTick < 0) continue
@@ -2634,7 +2704,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.noteDelayTick = -1
}
if (!voice.active) { advanceEnvelope(voice, inst, tickSec); continue }
if (!voice.active) {
advanceEnvelope(voice, inst, tickSec)
voice.envVolStep = if (spt > 0.0) (voice.envVolume - voice.envVolMix) / spt else 0.0
continue
}
// Pitch slides (E/F coarse on tick > 0).
if (ts.tickInRow > 0 && (voice.slideMode == 1 || voice.slideMode == 2)) {
@@ -2822,6 +2896,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
}
advanceEnvelope(voice, inst, tickSec)
// Compute per-sample slope so envVolMix walks smoothly to the new envVolume
// across the next tick interval; this turns the mixer's view of the envelope
// from a stair-step into a continuous ramp and removes the per-tick clicks
// that are otherwise audible on steep envelope slopes (e.g., XM volume
// envelopes with fast attack/decay nodes — the slumberjack.xm symptom).
voice.envVolStep = if (spt > 0.0) (voice.envVolume - voice.envVolMix) / spt else 0.0
advancePfEnvelope(voice, inst, tickSec)
}
@@ -2868,6 +2948,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (!bg.active) { bgIt.remove(); continue }
val inst = instruments[bg.instrumentId]
advanceEnvelope(bg, inst, tickSec)
bg.envVolStep = if (spt > 0.0) (bg.envVolume - bg.envVolMix) / spt else 0.0
advancePfEnvelope(bg, inst, tickSec)
if (bg.keyOff || bg.noteFading) {
val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8)
@@ -2968,11 +3049,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val instGv = voiceInst.instGlobalVolume / 255.0
// Volume swing bias (random per-trigger, ±randomVolBias of 0..255 units folded into the 0..63 row volume).
val swingScale = 1.0 + voice.randomVolBias / 255.0
// Per-sample envelope smoothing: walk envVolMix toward the tick-set
// envVolume so the mixer sees a continuous slope instead of the per-tick
// stair-step that produces clicks on steep envelope segments.
voice.envVolMix += voice.envVolStep
// Volume envelope is bypassed (treated as unity) when S $77 has disabled it.
val effEnvVol = if (voice.volEnvOn) voice.envVolume else 1.0
val effEnvVol = if (voice.volEnvOn) voice.envVolMix else 1.0
// Anti-click ramp: smooths voleff/notefx-driven rowVolume steps. Key-on
// triggers snap currentMixVolume to target (in triggerNote) so attacks
// are passed through unramped.
advanceVolumeRamp(voice)
// Split the gain stack so the soundscope can see the voice amplitude independently
// of the playhead-wide faders (master / mixing / global volume).
val perVoiceGain = effEnvVol * voice.fadeoutVolume * (voice.rowVolume / 63.0) *
val perVoiceGain = effEnvVol * voice.fadeoutVolume * voice.currentMixVolume *
swingScale * instGv
val globalGain = gvol * mvol * playhead.masterVolume / 255.0
val vol = perVoiceGain * globalGain
@@ -3008,8 +3097,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val s = applyTaudVoiceFx(bg, applyVoiceFilter(bg, fetchTrackerSample(bg, bgInst, ts.interpolationMode)))
val instGv = bgInst.instGlobalVolume / 255.0
val swingScale = 1.0 + bg.randomVolBias / 255.0
val effEnvVol = if (bg.volEnvOn) bg.envVolume else 1.0
val vol = effEnvVol * bg.fadeoutVolume * (bg.rowVolume / 63.0) *
bg.envVolMix += bg.envVolStep
val effEnvVol = if (bg.volEnvOn) bg.envVolMix else 1.0
// Background voices don't receive new voleff/notefx events, but ghosting
// can leave currentMixVolume mid-ramp from the foreground's last change —
// keep advancing so the inherited ramp completes cleanly.
advanceVolumeRamp(bg)
val vol = effEnvVol * bg.fadeoutVolume * bg.currentMixVolume *
swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0
val pan = if (bg.hasPanEnv && bg.panEnvOn) {
val envPanRaw = (bg.envPan * 255.0).roundToInt().coerceIn(0, 255)
@@ -3274,10 +3368,36 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var channelPan = 0x80 // 8-bit; $80 centre. Cell column packs into 6-bit, S$80xx writes the full 8-bit.
var rowPan = 32 // 6-bit pan used by mixer, derived from channelPan
// Anti-click volume ramp. The mixer feeds [currentMixVolume] (smoothed copy of
// rowVolume/63) into the per-voice gain stack instead of rowVolume directly so
// that voleff/notefx-driven steps (vol column, D slides, tremor, tremolo, retrig
// vol-mod, fine slides) ramp across [VOL_RAMP_SAMPLES] samples rather than
// jumping. triggerNote() arms [snapMixVolume] so the next mixer sample re-syncs
// currentMixVolume to rowVolume/63 — bypassing the ramp on key-on attacks.
// The snap is deferred (not applied inside triggerNote) because applyVolColumn
// and applyEffectRow run *after* triggerNote in applyTrackerRow and may lower
// rowVolume on the same row (e.g., a key-on with a low V column value); snapping
// immediately in triggerNote would leave currentMixVolume at 1.0 and force a
// ramp-down to the new low target, producing an audible transient spike.
var currentMixVolume = 1.0
var volRampSamples = 0
var volRampStep = 0.0
var snapMixVolume = false
var keyOff = false
var envIndex = 0
var envTimeSec = 0.0
var envVolume = 1.0
// Per-sample smoothed copy of envVolume. advanceEnvelope() runs once per tick
// (~640 samples at 125 BPM, 32 kHz) and overwrites envVolume with a stair-step
// approximation of the linear interpolation between envelope nodes — between
// ticks envVolume is held constant, so steep envelope slopes click audibly at
// every tick boundary. The mixer feeds envVolMix to the gain stack instead;
// applyTrackerTick computes envVolStep so envVolMix ramps linearly across the
// upcoming tick interval and lands on the new envVolume by the next tick.
// triggerNote snaps envVolMix to the fresh envVolume so attacks aren't smeared.
var envVolMix = 1.0
var envVolStep = 0.0
var envPanIndex = 0
var envPanTimeSec = 0.0
var envPan = 0.5 // 0.0=full-left, 1.0=full-right, 0.5=centre
@@ -3627,6 +3747,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
it.active = false
it.channelVolume = 0x3F
it.rowVolume = 0x3F
it.currentMixVolume = 1.0
it.volRampSamples = 0
it.volRampStep = 0.0
it.snapMixVolume = false
it.envVolMix = 1.0
it.envVolStep = 0.0
it.channelPan = 0x80
it.rowPan = 32
it.glissandoOn = false