fix: SF2 mode filter is too muffled

This commit is contained in:
minjaesong
2026-06-14 11:17:35 +09:00
parent 2e9b380843
commit aa9ea2eeca
2 changed files with 102 additions and 24 deletions

View File

@@ -2782,7 +2782,7 @@ TODO:
3. If not, resample at 32000. If there is no loop defined, then loop the last 8192 samples (converter SHOULD NOT take that number at face value; perform waveform analysis to derive a smoother loop; converter MAY use that number as a starting number) and modify the fade value such that it decays to zero after 10 or so seconds of firing. 3. If not, resample at 32000. If there is no loop defined, then loop the last 8192 samples (converter SHOULD NOT take that number at face value; perform waveform analysis to derive a smoother loop; converter MAY use that number as a starting number) and modify the fade value such that it decays to zero after 10 or so seconds of firing.
[x] Faithful .sf2 "release segment": Set NNA to 'Note Fade' (incl. drumkits), and make sure Volume Fadeout to have a correct number derived from the SF2 timecent unit (it seems SF2 defines envelope floor as 100 dB; needs check) [x] Faithful .sf2 "release segment": Set NNA to 'Note Fade' (incl. drumkits), and make sure Volume Fadeout to have a correct number derived from the SF2 timecent unit (it seems SF2 defines envelope floor as 100 dB; needs check)
* DONE 2026-06-14. Floor CONFIRMED 100 dB (sfspec24.txt:1934-1941: releaseVolEnv ramps a constant 100 dB per its timecent value, "until 100dB attenuation were reached"). midi2taud.py now: (a) byte 186 NNA = Note Fade (0b11) for every instrument incl. drum kits (was melodic Key-Lift 0b100000 / drum Continue 0b10); (b) the vol-env no longer carries a release leg — it ENDS at the sustain node and the engine holds there on key-off (AudioAdapter.kt:1697-1701 holds a non-zero terminator, doesn't cut); (c) Volume Fadeout (base bytes 172-173 AND per-patch Ixmp 'x' block) = the release segment, fade_sec = releaseVolEnv·(1000sus_cb)/1000 (the sustain-level → 100 dB-floor time), fadeStep = 2560/(fade_sec·bpm0) so the linear fade completes in that wall-clock time. Per-patch 'x' now also emits when only the release differs (faithful per-layer release). The engine's Key-Lift feature is unchanged (still used by KeyLiftTest); midi2taud simply stopped emitting it. See _zone_fadeout / _adsr_to_env in midi2taud.py. * DONE 2026-06-14. Floor CONFIRMED 100 dB (sfspec24.txt:1934-1941: releaseVolEnv ramps a constant 100 dB per its timecent value, "until 100dB attenuation were reached"). midi2taud.py now: (a) byte 186 NNA = Note Fade (0b11) for every instrument incl. drum kits (was melodic Key-Lift 0b100000 / drum Continue 0b10); (b) the vol-env no longer carries a release leg — it ENDS at the sustain node and the engine holds there on key-off (AudioAdapter.kt:1697-1701 holds a non-zero terminator, doesn't cut); (c) Volume Fadeout (base bytes 172-173 AND per-patch Ixmp 'x' block) = the release segment, fade_sec = releaseVolEnv·(1000sus_cb)/1000 (the sustain-level → 100 dB-floor time), fadeStep = 2560/(fade_sec·bpm0) so the linear fade completes in that wall-clock time. Per-patch 'x' now also emits when only the release differs (faithful per-layer release). The engine's Key-Lift feature is unchanged (still used by KeyLiftTest); midi2taud simply stopped emitting it. See _zone_fadeout / _adsr_to_env in midi2taud.py.
[ ] SF2 filter still sounds way too muffled? [x] SF2 filter still sounds way too muffled?
[ ] Drum notes get eaten (E2M1.mid) [ ] Drum notes get eaten (E2M1.mid)
[ ] auto-set optimal-ish Tickspeed and RPB using MIDI Time Signature events and note analysis. Break pattern when Time Signature changes. [ ] auto-set optimal-ish Tickspeed and RPB using MIDI Time Signature events and note analysis. Break pattern when Time Signature changes.
@@ -2803,6 +2803,11 @@ TODO:
NOTE: If there are no time signature events in a MIDI file, then the time signature is assumed to be 4/4. NOTE: If there are no time signature events in a MIDI file, then the time signature is assumed to be 4/4.
In a format 0 file, the time signatures changes are scattered throughout the one MTrk. In format 1, the very first MTrk should consist of only the time signature (and tempo) events so that it could be read by some device capable of generating a "tempo map". It is best not to place MIDI events in this MTrk. In format 2, each MTrk should begin with at least one initial time signature (and tempo) event. In a format 0 file, the time signatures changes are scattered throughout the one MTrk. In format 1, the very first MTrk should consist of only the time signature (and tempo) events so that it could be read by some device capable of generating a "tempo map". It is best not to place MIDI events in this MTrk. In format 2, each MTrk should begin with at least one initial time signature (and tempo) event.
[ ] Taut UI commit
- Inst > Gen.1 > sample binding: ~~~....[two doubledots] et al. (n extra samples)
- Inst > Gen.2 > filter: IT/SF mode toggle
- Samples playblobs: only active for actually playing samples
- Samples playcursor: true cursors for actually playing samples
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

@@ -18,6 +18,7 @@ import kotlin.math.roundToInt
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.sin import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.math.exp import kotlin.math.exp
private class RenderRunnable(val playhead: AudioAdapter.Playhead) : Runnable { private class RenderRunnable(val playhead: AudioAdapter.Playhead) : Runnable {
@@ -1840,6 +1841,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
* B0 = (d + 2e) / denom * B0 = (d + 2e) / denom
* B1 = e / denom * B1 = e / denom
* y[n] = A0 × x[n] + B0 × y[n1] + B1 × y[n2] * y[n] = A0 × x[n] + B0 × y[n1] + B1 × y[n2]
*
* SoundFont mode uses a different, faithful port of FluidSynth's filter
* (reference_materials/fluidsynth/src/rvoice/fluid_iir_filter_impl.cpp):
* the RBJ biquad low-pass with the SF2 `sqrt(1/Q)` resonance gain-norm.
* The IT all-pole filter is overdamped — even at the SF2 "open" default
* (13500 cents) it loses ~3 dB at 8 kHz / ~5 dB at 12 kHz, which is audible
* muffling against FluidSynth on every default-filter GM instrument. The
* biquad's passband is maximally flat (Butterworth at the default Q), so
* SF mode now switches topology rather than just remapping cutoff/Q.
*/ */
private fun refreshVoiceFilter(voice: Voice) { private fun refreshVoiceFilter(voice: Voice) {
val cut = voice.currentCutoff val cut = voice.currentCutoff
@@ -1849,27 +1859,46 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.filterResonanceCached = res voice.filterResonanceCached = res
val nyquist = SAMPLING_RATE * 0.5 - 1.0 val nyquist = SAMPLING_RATE * 0.5 - 1.0
val frequency: Double
val dmpfac: Double
// println("voice.filterSfMode = ${voice.filterSfMode}") // println("voice.filterSfMode = ${voice.filterSfMode}")
if (voice.filterSfMode) { if (voice.filterSfMode) {
// SoundFont mode: cutoff = absolute cents, resonance = centibels above DC gain. // SoundFont mode: cutoff = absolute cents, resonance = centibels above DC gain.
// freq = 8.176 Hz × 2^(cents/1200) (cents are relative to 8.176 Hz = MIDI 0) // freq = 8.176 Hz × 2^(cents/1200) (cents relative to 8.176 Hz = MIDI 0)
// SF2 Q is the resonant-peak height in dB×10. For this IT-style 2-pole IIR the // FluidSynth clamps fres to [5 Hz, 0.45·fs] and uses it as an anti-alias
// peak gain ≈ 20·log10(dmpfac) dB, so dmpfac = 10^(dB/20) = 10^(Qcb/200). // filter rather than switching off near Nyquist (fluid_iir_filter_calc).
if (cut >= 0xFFFF) { voice.filterActive = false; return } if (cut >= 0xFFFF) { voice.filterActive = false; return }
frequency = (8.176 * 2.0.pow(cut / 1200.0)).coerceIn(1.0, nyquist) val fres = (8.176 * 2.0.pow(cut / 1200.0)).coerceIn(5.0, 0.45 * SAMPLING_RATE)
// SF2 Q (centibels) → linear Q, with FluidSynth's 3.01 dB offset so that
// Q=0 cB is Butterworth (q_lin = 1/√2), i.e. no resonance hump
// (fluid_iir_filter_q_from_dB). Clamp dB to [0, 96] as FluidSynth does.
val qcb = if (res >= 0xFFFF) 0 else res val qcb = if (res >= 0xFFFF) 0 else res
// Clamp to the IT filter's max resonance (≈24 dB) to keep the IIR stable / unclipped. val qDb = (qcb / 10.0).coerceIn(0.0, 96.0) - 3.01
dmpfac = 10.0.pow(-qcb / 200.0).coerceIn(0.0625, 1.0) val qLin = 10.0.pow(qDb / 20.0).coerceAtLeast(0.001)
} else {
if (cut.coerceIn(0, 255) >= 255) { voice.filterActive = false; return } // RBJ cookbook low-pass (bilinear-transformed), normalised to a0.
val itCutoff = cut.coerceIn(0, 254) * 0.5 // 0..127 val omega = 2.0 * PI * fres / SAMPLING_RATE
val itResonance = if (res >= 255) 0.0 else res.coerceIn(0, 254) * 0.5 val sinC = sin(omega)
frequency = (110.0 * 2.0.pow(itCutoff / 24.0 + 0.25)).coerceAtMost(nyquist) val cosC = cos(omega)
dmpfac = 10.0.pow(-itResonance * (24.0 / 128.0) / 20.0) val alpha = sinC / (2.0 * qLin)
val a0inv = 1.0 / (1.0 + alpha)
// SF2 §2.01 p.59: halve the resonance-peak height by scaling the gain
// with sqrt(1/Q); folded into the b coefficients here.
val gain = a0inv / sqrt(qLin)
voice.filterBqB1 = (1.0 - cosC) * gain
voice.filterBqB02 = voice.filterBqB1 * 0.5
voice.filterBqA1 = -2.0 * cosC * a0inv
voice.filterBqA2 = (1.0 - alpha) * a0inv
voice.filterIsBiquad = true
voice.filterActive = true
return
} }
if (cut.coerceIn(0, 255) >= 255) { voice.filterActive = false; return }
val itCutoff = cut.coerceIn(0, 254) * 0.5 // 0..127
val itResonance = if (res >= 255) 0.0 else res.coerceIn(0, 254) * 0.5
val frequency = (110.0 * 2.0.pow(itCutoff / 24.0 + 0.25)).coerceAtMost(nyquist)
val dmpfac = 10.0.pow(-itResonance * (24.0 / 128.0) / 20.0)
val r = SAMPLING_RATE / (2.0 * PI * frequency) val r = SAMPLING_RATE / (2.0 * PI * frequency)
val d = dmpfac * r + dmpfac - 1.0 val d = dmpfac * r + dmpfac - 1.0
val e = r * r val e = r * r
@@ -1878,15 +1907,33 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.filterA0 = 1.0 / denom voice.filterA0 = 1.0 / denom
voice.filterB0 = (d + e + e) / denom voice.filterB0 = (d + e + e) / denom
voice.filterB1 = -e / denom voice.filterB1 = -e / denom
voice.filterIsBiquad = false
voice.filterActive = true voice.filterActive = true
} }
/** Apply the cached IT-style 2-pole LPF to one mono sample. Caller must /** Apply the cached voice low-pass to one mono sample. Caller must have
* have called refreshVoiceFilter at the start of the tick. The history * called refreshVoiceFilter at the start of the tick.
* taps are clipped to ±2.0 to tame resonance ringing on extreme settings, *
* matching OpenMPT's ClipFilter helper. */ * SoundFont voices run FluidSynth's RBJ biquad (Direct Form I):
* y[n] = b02·(x[n]+x[n-2]) + b1·x[n-1] - a1·y[n-1] - a2·y[n-2]
*
* Tracker voices run the IT all-pole recurrence, whose history taps are
* clipped to ±2.0 to tame resonance ringing on extreme settings (matching
* OpenMPT's ClipFilter helper). The biquad does not clip — FluidSynth runs
* it unclamped, and the SF2 gain-norm already bounds the resonance peak. */
private fun applyVoiceFilter(voice: Voice, x0: Double): Double { private fun applyVoiceFilter(voice: Voice, x0: Double): Double {
if (!voice.filterActive) return x0 if (!voice.filterActive) return x0
if (voice.filterIsBiquad) {
val y0 = voice.filterBqB02 * (x0 + voice.filterX2) +
voice.filterBqB1 * voice.filterX1 -
voice.filterBqA1 * voice.filterY1 -
voice.filterBqA2 * voice.filterY2
voice.filterX2 = voice.filterX1
voice.filterX1 = x0
voice.filterY2 = voice.filterY1
voice.filterY1 = y0
return y0
}
val y1Clipped = voice.filterY1.coerceIn(-2.0, 2.0) val y1Clipped = voice.filterY1.coerceIn(-2.0, 2.0)
val y2Clipped = voice.filterY2.coerceIn(-2.0, 2.0) val y2Clipped = voice.filterY2.coerceIn(-2.0, 2.0)
val y0 = voice.filterA0 * x0 + val y0 = voice.filterA0 * x0 +
@@ -2381,7 +2428,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// 255 = filter off (IT high-bit-clear); 0..254 = active range matching IT 0..127 at double resolution. // 255 = filter off (IT high-bit-clear); 0..254 = active range matching IT 0..127 at double resolution.
voice.currentCutoff = voice.activeDefaultCutoff voice.currentCutoff = voice.activeDefaultCutoff
voice.currentResonance = voice.activeDefaultResonance voice.currentResonance = voice.activeDefaultResonance
voice.filterY1 = 0.0; voice.filterY2 = 0.0 voice.filterY1 = 0.0; voice.filterY2 = 0.0; voice.filterX1 = 0.0; voice.filterX2 = 0.0
voice.filterCutoffCached = -1 // force coefficient refresh on first tick voice.filterCutoffCached = -1 // force coefficient refresh on first tick
voice.filterResonanceCached = -1 voice.filterResonanceCached = -1
voice.noteVal = noteVal voice.noteVal = noteVal
@@ -2569,6 +2616,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
v.filterB1 = src.filterB1 v.filterB1 = src.filterB1
v.filterY1 = src.filterY1 v.filterY1 = src.filterY1
v.filterY2 = src.filterY2 v.filterY2 = src.filterY2
v.filterIsBiquad = src.filterIsBiquad
v.filterBqB02 = src.filterBqB02
v.filterBqB1 = src.filterBqB1
v.filterBqA1 = src.filterBqA1
v.filterBqA2 = src.filterBqA2
v.filterX1 = src.filterX1
v.filterX2 = src.filterX2
v.filterCutoffCached = src.filterCutoffCached v.filterCutoffCached = src.filterCutoffCached
v.filterResonanceCached = src.filterResonanceCached v.filterResonanceCached = src.filterResonanceCached
v.randomVolBias = src.randomVolBias v.randomVolBias = src.randomVolBias
@@ -3509,7 +3563,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.fadeoutVolume = 1.0 voice.fadeoutVolume = 1.0
voice.autoVibPhase = 0 voice.autoVibPhase = 0
voice.autoVibTicksSinceTrigger = 0 voice.autoVibTicksSinceTrigger = 0
voice.filterY1 = 0.0; voice.filterY2 = 0.0 voice.filterY1 = 0.0; voice.filterY2 = 0.0; voice.filterX1 = 0.0; voice.filterX2 = 0.0
voice.noteVolume = applyRetrigVolMod(voice.noteVolume, voice.retrigVolMod) voice.noteVolume = applyRetrigVolMod(voice.noteVolume, voice.retrigVolMod)
voice.rowVolume = voice.noteVolume voice.rowVolume = voice.noteVolume
} }
@@ -3665,9 +3719,16 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val finalPitch = (bg.noteVal + autoVibDelta + pitchEnvDelta).coerceIn(0x20, 0xFFFF) val finalPitch = (bg.noteVal + autoVibDelta + pitchEnvDelta).coerceIn(0x20, 0xFFFF)
bg.playbackRate = computePlaybackRate(bg, finalPitch) bg.playbackRate = computePlaybackRate(bg, finalPitch)
// Filter envelope: same scaling rule as foreground, using the active cutoff. // Filter envelope: same scaling rule as foreground, using the active cutoff.
// Must branch on SF mode too — an SF-mode ghost's cutoff is in cents (0..0xFFFF),
// so the IT 0..254 clamp would otherwise collapse it to ~9 Hz (total muffling).
if (bg.hasFilterEnv && bg.pfEnvOn) { if (bg.hasFilterEnv && bg.pfEnvOn) {
val baseCut = if (bg.activeDefaultCutoff < 255) bg.activeDefaultCutoff else 254 if (bg.filterSfMode) {
bg.currentCutoff = (baseCut * bg.envFilterValue).toInt().coerceIn(0, 254) val baseCut = if (bg.activeDefaultCutoff < 0xFFFF) bg.activeDefaultCutoff else 13500
bg.currentCutoff = (baseCut * bg.envFilterValue).toInt().coerceIn(0, 0xFFFF)
} else {
val baseCut = if (bg.activeDefaultCutoff < 255) bg.activeDefaultCutoff else 254
bg.currentCutoff = (baseCut * bg.envFilterValue).toInt().coerceIn(0, 254)
}
} }
refreshVoiceFilter(bg) refreshVoiceFilter(bg)
// Reap fully-faded ghosts so the pool stays drained. // Reap fully-faded ghosts so the pool stays drained.
@@ -4249,6 +4310,18 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var filterB1 = 0.0 var filterB1 = 0.0
var filterY1 = 0.0 var filterY1 = 0.0
var filterY2 = 0.0 var filterY2 = 0.0
// SoundFont mode uses a proper RBJ biquad low-pass (matching FluidSynth's
// fluid_iir_filter, not the IT all-pole topology — see refreshVoiceFilter).
// When true, applyVoiceFilter runs the biquad recurrence:
// y[n] = b02·(x[n]+x[n-2]) + b1·x[n-1] - a1·y[n-1] - a2·y[n-2]
// sharing filterY1/Y2 as the output history and adding x[n-1]/x[n-2].
var filterIsBiquad = false
var filterBqB02 = 0.0
var filterBqB1 = 0.0
var filterBqA1 = 0.0
var filterBqA2 = 0.0
var filterX1 = 0.0
var filterX2 = 0.0
// Snapshot of cutoff/resonance the cached coefficients correspond to. // Snapshot of cutoff/resonance the cached coefficients correspond to.
var filterCutoffCached = -1 var filterCutoffCached = -1
var filterResonanceCached = -1 var filterResonanceCached = -1
@@ -4626,7 +4699,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
it.noteVal = 0x0000; it.basePitch = 0x4000 it.noteVal = 0x0000; it.basePitch = 0x4000
it.amigaPeriod = -1.0; it.linearFreq = -1.0 it.amigaPeriod = -1.0; it.linearFreq = -1.0
it.tonePortaTarget = -1; it.tonePortaSpeed = 0 it.tonePortaTarget = -1; it.tonePortaSpeed = 0
it.filterY1 = 0.0; it.filterY2 = 0.0 it.filterY1 = 0.0; it.filterY2 = 0.0; it.filterX1 = 0.0; it.filterX2 = 0.0
it.filterCutoffCached = -1; it.filterResonanceCached = -1 it.filterCutoffCached = -1; it.filterResonanceCached = -1
it.currentCutoff = 0xFF; it.currentResonance = 0xFF it.currentCutoff = 0xFF; it.currentResonance = 0xFF
it.nesDpcmCounter = 63 it.nesDpcmCounter = 63