mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-19 02:44:04 +09:00
midi2taud: another attack-filterenv fix
This commit is contained in:
@@ -196,7 +196,9 @@ The Taud playback engine lives in `tsvm_core/src/net/torvald/tsvm/peripheral/Aud
|
|||||||
|
|
||||||
**Per-patch envelopes go through the Voice's ACTIVE-envelope view, never `inst.*` directly.** Since 2026-06-13 an Ixmp patch can carry its own volume / pan / filter / pitch envelopes (+ fadeout / cutoff / resonance) — see terranmon.txt §Ixmp, variable-length patches. `applyActiveSample` → `resolveActiveEnvelopes(voice, inst, patch)` snapshots the effective envelope source onto `voice.active{Vol,Pan,Pitch,Filter}Env{,Loop,Sustain}`, `voice.has{Pitch,Filter}Env`, and `voice.active{FadeoutStep,DefaultCutoff,DefaultResonance}`. The base instrument exposes **two** pf-envelope slots — bytes 19.. (`pfEnv*`) and bytes 197..250 (`pf2Env*`, the mandatory complement) — routed into the pitch/filter roles by each slot's m-bit (LOOP-word bit 7). `advanceEnvelope` (vol+pan), `advancePitchEnvelope`, `advanceFilterEnvelope`, `applyKeyLift`, the per-tick pitch/filter/fadeout application (foreground AND background), and `triggerNote`'s envelope seeds must ALL read the `voice.active*` view, not `inst.*`. `copyVoice` (NNA ghost) must copy the whole active view so ghosts keep their patch's envelopes. There is no single `envPf*`/`envPfIsFilter` field any more — it was split into explicit `envPitch*`/`envFilter*` pairs. Headless coverage: `devtests/ixmp/PatchEnvTest` (per-patch env applied) + `IxmpFileTest /tmp/m_e1m1.taud`.
|
**Per-patch envelopes go through the Voice's ACTIVE-envelope view, never `inst.*` directly.** Since 2026-06-13 an Ixmp patch can carry its own volume / pan / filter / pitch envelopes (+ fadeout / cutoff / resonance) — see terranmon.txt §Ixmp, variable-length patches. `applyActiveSample` → `resolveActiveEnvelopes(voice, inst, patch)` snapshots the effective envelope source onto `voice.active{Vol,Pan,Pitch,Filter}Env{,Loop,Sustain}`, `voice.has{Pitch,Filter}Env`, and `voice.active{FadeoutStep,DefaultCutoff,DefaultResonance}`. The base instrument exposes **two** pf-envelope slots — bytes 19.. (`pfEnv*`) and bytes 197..250 (`pf2Env*`, the mandatory complement) — routed into the pitch/filter roles by each slot's m-bit (LOOP-word bit 7). `advanceEnvelope` (vol+pan), `advancePitchEnvelope`, `advanceFilterEnvelope`, `applyKeyLift`, the per-tick pitch/filter/fadeout application (foreground AND background), and `triggerNote`'s envelope seeds must ALL read the `voice.active*` view, not `inst.*`. `copyVoice` (NNA ghost) must copy the whole active view so ghosts keep their patch's envelopes. There is no single `envPf*`/`envPfIsFilter` field any more — it was split into explicit `envPitch*`/`envFilter*` pairs. Headless coverage: `devtests/ixmp/PatchEnvTest` (per-patch env applied) + `IxmpFileTest /tmp/m_e1m1.taud`.
|
||||||
|
|
||||||
**The shared pitch/filter envelope walker (`advancePfRole`) must SKIP zero-duration nodes, not freeze on them.** A node whose `offset` rounds to 0 — sub-4 ms, since `ThreeFiveMinifloat`'s smallest non-zero step is ≈3.9 ms — represents an instant transition; the walk must advance to the next node. The old code `return`ed on `offset == 0.0` without advancing the index, stranding fast-attack envelopes at their first node. The audible damage: SF2 filter mod-envelopes (`midi2taud.py` `_filter_env_block_sf`) routinely have a ~1 ms attack that stores offset 0, so the filter never opened from its base cutoff to its sustain cutoff — Strings/Flute/Guitar (SGM base ~600 Hz, sustain ~6 kHz) and low-base sweep drums played permanently muffled at their floor. The skip loop stops at a sustain/loop boundary (`susEnd`, handled by the dispatch above) or `maxIdx`. This also affects pitch mod-envs and any IT/XM envelope with a zero-tick (vertical-jump) node, all now correct. There is still a one-tick (≈seed) delay before the env opens — inaudible on sustained notes; the seed value is the base node.
|
**The shared pitch/filter envelope walker (`advancePfRole`) must SKIP zero-duration nodes, not freeze on them.** A node whose `offset` rounds to 0 — sub-4 ms, since `ThreeFiveMinifloat`'s smallest non-zero step is ≈3.9 ms — represents an instant transition; the walk must advance to the next node. The old code `return`ed on `offset == 0.0` without advancing the index, stranding fast-attack envelopes at their first node. The audible damage: SF2 filter mod-envelopes (`midi2taud.py` `_filter_env_block_sf`) routinely have a ~1 ms attack that stores offset 0, so the filter never opened from its base cutoff to its sustain cutoff — Strings/Flute/Guitar (SGM base ~600 Hz, sustain ~6 kHz) and low-base sweep drums played permanently muffled at their floor. The skip loop stops at a sustain/loop boundary (`susEnd`, handled by the dispatch above) or `maxIdx`. This also affects pitch mod-envs and any IT/XM envelope with a zero-tick (vertical-jump) node, all now correct.
|
||||||
|
|
||||||
|
**The note-on / Q-retrigger SEED must also settle past leading zero-duration nodes (`seedPfRole`), not capture node 0.** The trigger code used to seed `envFilterValue`/`envPitchValue` at `activeFilterEnv[0]` and only let `advancePfRole` skip the zero node on the NEXT tick — a one-tick hold at the base node. Inaudible on a sustained note (the old "≈seed delay" caveat), but on a PERCUSSIVE instrument that one tick is the whole attack transient: GeneralUser-GS Slap Bass (PASSPORT.MID) has a 1 ms (offset-0) filter-mod attack opening base→peak then a 0.7 s decay to a mellow sustain — the slap should be BRIGHT then mellow, but the seed played the muddy base cutoff (~507 Hz) for tick 0 then "suddenly opened" to full brightness. `seedPfRole` runs the walker with `tickSec = 0` / `keyOff = false` at note-on (and on Q-retrigger) so the seed lands on the post-attack value (index settled past the dur-0 nodes); an env with a real (non-zero) attack is unchanged. The sample start-offset is a red herring (it was 0) — a quiet sample lead-in would only MASK the muddy tick, which is why it surfaced on slap bass. The vol/pan walker (`advanceEnvelope`) intentionally FREEZES on zero-offset nodes (IT terminator semantics) and is NOT seeded this way.
|
||||||
|
|
||||||
**SoundFont filter mode uses an RBJ biquad, NOT the IT all-pole filter.** `refreshVoiceFilter` has two topologies. The IT/tracker path (`else` branch) is the all-pole 2-pole resonant LPF from `reference_materials/tracker_filter/` (no feedforward zeros) — must stay byte-faithful for tracker playback, do not touch it. The **`filterSfMode` branch ports FluidSynth's voice filter** (`reference_materials/fluidsynth/`, see its `README.md`): cutoff = absolute cents → Hz via `8.176·2^(cents/1200)` clamped to `[5 Hz, 0.45·fs]`; Q from centibels with FluidSynth's **−3.01 dB offset** (so Q=0 cB ⇒ q_lin = 1/√2 Butterworth, no resonance hump); RBJ cookbook low-pass coefficients with the SF2 `1/√Q` passband gain-norm. `applyVoiceFilter` runs the biquad (Direct Form I: `y = b02·(x+x₂) + b1·x₁ − a1·y₁ − a2·y₂`) when `voice.filterIsBiquad`. The old code reused the all-pole filter for SF mode too; it is overdamped and rolled the passband off ~3 dB @ 8 kHz / ~5 dB @ 12 kHz vs FluidSynth → audible muffling on every filtered GM instrument. Per-voice biquad state (`filterBqB02/B1/A1/A2`, input history `filterX1/X2`) must be reset on trigger/retrigger and copied in `copyVoice` (NNA ghost) alongside the output history. The background-voice filter-env path must branch on `filterSfMode` too, else an SF-mode ghost's cents-domain cutoff gets clamped into the IT 0..254 byte range (≈9 Hz → silence).
|
**SoundFont filter mode uses an RBJ biquad, NOT the IT all-pole filter.** `refreshVoiceFilter` has two topologies. The IT/tracker path (`else` branch) is the all-pole 2-pole resonant LPF from `reference_materials/tracker_filter/` (no feedforward zeros) — must stay byte-faithful for tracker playback, do not touch it. The **`filterSfMode` branch ports FluidSynth's voice filter** (`reference_materials/fluidsynth/`, see its `README.md`): cutoff = absolute cents → Hz via `8.176·2^(cents/1200)` clamped to `[5 Hz, 0.45·fs]`; Q from centibels with FluidSynth's **−3.01 dB offset** (so Q=0 cB ⇒ q_lin = 1/√2 Butterworth, no resonance hump); RBJ cookbook low-pass coefficients with the SF2 `1/√Q` passband gain-norm. `applyVoiceFilter` runs the biquad (Direct Form I: `y = b02·(x+x₂) + b1·x₁ − a1·y₁ − a2·y₂`) when `voice.filterIsBiquad`. The old code reused the all-pole filter for SF mode too; it is overdamped and rolled the passband off ~3 dB @ 8 kHz / ~5 dB @ 12 kHz vs FluidSynth → audible muffling on every filtered GM instrument. Per-voice biquad state (`filterBqB02/B1/A1/A2`, input history `filterX1/X2`) must be reset on trigger/retrigger and copied in `copyVoice` (NNA ghost) alongside the output history. The background-voice filter-env path must branch on `filterSfMode` too, else an SF-mode ghost's cents-domain cutoff gets clamped into the IT 0..254 byte range (≈9 Hz → silence).
|
||||||
|
|
||||||
|
|||||||
@@ -2913,7 +2913,7 @@ TODO - list of demo songs that MUST ship with Microtone:
|
|||||||
(C) Jakim 2010
|
(C) Jakim 2010
|
||||||
* SWINGIN1 (rename to Swinging Waste) — for demonstrating Monotone compatibility.
|
* SWINGIN1 (rename to Swinging Waste) — for demonstrating Monotone compatibility.
|
||||||
(C) Phoenix/Hornet 2015
|
(C) Phoenix/Hornet 2015
|
||||||
* Keep On Rolling — for MIDI and SoundFont capability.
|
* Keep On Rolling — for MIDI and SoundFont capability. (using GeneralUser-GS created by mrbumpy409)
|
||||||
(C) Trolley Trev
|
(C) Trolley Trev
|
||||||
|
|
||||||
Play Data: play data are series of tracker-like instructions, visualised as:
|
Play Data: play data are series of tracker-like instructions, visualised as:
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
const val SCOPE_BUFFER_SIZE = 2048
|
const val SCOPE_BUFFER_SIZE = 2048
|
||||||
// Mixer-private background-voice pool size per playhead. NNA "Continue/Note Off/Note Fade"
|
// 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.
|
// ghosts displaced foreground voices into this pool; oldest is evicted on overflow.
|
||||||
const val MAX_BG_VOICES = 256
|
const val MAX_BG_VOICES = 64
|
||||||
const val MIDDLE_C = 0x5000 // reference C for instrument samplingRate (terranmon.txt:2000)
|
const val MIDDLE_C = 0x5000 // reference C for instrument samplingRate (terranmon.txt:2000)
|
||||||
// Amiga period at MIDDLE_C for a standard 8363 Hz instrument (NTSC clock 3579545 Hz).
|
// Amiga period at MIDDLE_C for a standard 8363 Hz instrument (NTSC clock 3579545 Hz).
|
||||||
// PT "C-2" period 428 ↔ TSVM MIDDLE_C ↔ 8363 Hz; mod2taud uses the same convention.
|
// PT "C-2" period 428 ↔ TSVM MIDDLE_C ↔ 8363 Hz; mod2taud uses the same convention.
|
||||||
@@ -1827,6 +1827,20 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Seed a pitch/filter (advancePfRole) envelope playhead at note-on, settling past
|
||||||
|
* any leading zero-duration nodes so an instant SF2 attack lands on its post-attack
|
||||||
|
* value immediately. The old seed captured node 0 and only skipped the zero node on
|
||||||
|
* the NEXT tick — a one-tick hold at the base node. Inaudible mid-sustain, but on a
|
||||||
|
* percussive instrument that one tick IS the attack transient: a slap-bass filter
|
||||||
|
* mod-env (1 ms attack stored as offset 0, opening base→peak) played its muddy base
|
||||||
|
* cutoff for the slap, then "suddenly opened" to full brightness. Runs the walker with
|
||||||
|
* tickSec = 0 / keyOff = false; the settled index + carry are left in pfIdxBox[0] /
|
||||||
|
* pfTimeBox[0] for the caller to copy into the voice. Returns the seed value. */
|
||||||
|
private fun seedPfRole(env: Array<TaudInstEnvPoint>, loopWord: Int, susWord: Int): Double {
|
||||||
|
pfIdxBox[0] = 0; pfTimeBox[0] = 0.0
|
||||||
|
return advancePfRole(env, loopWord, susWord, false, 0.0, pfWrap, pfIdxBox, pfTimeBox)
|
||||||
|
}
|
||||||
|
|
||||||
/** Advance the pitch envelope (drives playback rate; 0.5 = unity). */
|
/** Advance the pitch envelope (drives playback rate; 0.5 = unity). */
|
||||||
private fun advancePitchEnvelope(voice: Voice, tickSec: Double) {
|
private fun advancePitchEnvelope(voice: Voice, tickSec: Double) {
|
||||||
if (!voice.hasPitchEnv || !voice.pitchEnvOn) return
|
if (!voice.hasPitchEnv || !voice.pitchEnvOn) return
|
||||||
@@ -2424,12 +2438,22 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
voice.hasPanEnv = envPresent(voice.activePanEnvLoop)
|
voice.hasPanEnv = envPresent(voice.activePanEnvLoop)
|
||||||
// Pitch / filter envelope playhead seeds (the role split + presence were resolved
|
// Pitch / filter envelope playhead seeds (the role split + presence were resolved
|
||||||
// by resolveActiveEnvelopes from the base inst's two pf-slots and any patch override).
|
// by resolveActiveEnvelopes from the base inst's two pf-slots and any patch override).
|
||||||
voice.envPitchIndex = 0
|
// seedPfRole settles past leading zero-duration nodes so an instant SF2 attack opens
|
||||||
voice.envPitchTimeSec = 0.0
|
// the env at note-on rather than one tick later (the slap-bass "muddy attack").
|
||||||
voice.envPitchValue = if (voice.hasPitchEnv) voice.activePitchEnv[0].value / 255.0 else 0.5
|
if (voice.hasPitchEnv) {
|
||||||
voice.envFilterIndex = 0
|
voice.envPitchValue = seedPfRole(voice.activePitchEnv, voice.activePitchEnvLoop,
|
||||||
voice.envFilterTimeSec = 0.0
|
voice.activePitchEnvSustain)
|
||||||
voice.envFilterValue = if (voice.hasFilterEnv) voice.activeFilterEnv[0].value / 255.0 else 0.5
|
voice.envPitchIndex = pfIdxBox[0]; voice.envPitchTimeSec = pfTimeBox[0]
|
||||||
|
} else {
|
||||||
|
voice.envPitchValue = 0.5; voice.envPitchIndex = 0; voice.envPitchTimeSec = 0.0
|
||||||
|
}
|
||||||
|
if (voice.hasFilterEnv) {
|
||||||
|
voice.envFilterValue = seedPfRole(voice.activeFilterEnv, voice.activeFilterEnvLoop,
|
||||||
|
voice.activeFilterEnvSustain)
|
||||||
|
voice.envFilterIndex = pfIdxBox[0]; voice.envFilterTimeSec = pfTimeBox[0]
|
||||||
|
} else {
|
||||||
|
voice.envFilterValue = 0.5; voice.envFilterIndex = 0; voice.envFilterTimeSec = 0.0
|
||||||
|
}
|
||||||
// Fadeout starts at unity; advances only after key-off.
|
// Fadeout starts at unity; advances only after key-off.
|
||||||
voice.fadeoutVolume = 1.0
|
voice.fadeoutVolume = 1.0
|
||||||
// Cancel any sample-end ramp left over from the previous note — a fresh trigger's
|
// Cancel any sample-end ramp left over from the previous note — a fresh trigger's
|
||||||
@@ -3720,10 +3744,22 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
voice.envIndex = 0; voice.envTimeSec = 0.0
|
voice.envIndex = 0; voice.envTimeSec = 0.0
|
||||||
voice.envPanIndex = 0; voice.envPanTimeSec = 0.0
|
voice.envPanIndex = 0; voice.envPanTimeSec = 0.0
|
||||||
voice.envPan = voice.activePanEnv[0].value / 255.0
|
voice.envPan = voice.activePanEnv[0].value / 255.0
|
||||||
voice.envPitchIndex = 0; voice.envPitchTimeSec = 0.0
|
// Re-seed pf-envs past leading zero-duration nodes (as at fresh trigger),
|
||||||
voice.envPitchValue = if (voice.hasPitchEnv) voice.activePitchEnv[0].value / 255.0 else 0.5
|
// so a Q-retriggered percussive note opens immediately too (see seedPfRole).
|
||||||
voice.envFilterIndex = 0; voice.envFilterTimeSec = 0.0
|
if (voice.hasPitchEnv) {
|
||||||
voice.envFilterValue = if (voice.hasFilterEnv) voice.activeFilterEnv[0].value / 255.0 else 0.5
|
voice.envPitchValue = seedPfRole(voice.activePitchEnv, voice.activePitchEnvLoop,
|
||||||
|
voice.activePitchEnvSustain)
|
||||||
|
voice.envPitchIndex = pfIdxBox[0]; voice.envPitchTimeSec = pfTimeBox[0]
|
||||||
|
} else {
|
||||||
|
voice.envPitchValue = 0.5; voice.envPitchIndex = 0; voice.envPitchTimeSec = 0.0
|
||||||
|
}
|
||||||
|
if (voice.hasFilterEnv) {
|
||||||
|
voice.envFilterValue = seedPfRole(voice.activeFilterEnv, voice.activeFilterEnvLoop,
|
||||||
|
voice.activeFilterEnvSustain)
|
||||||
|
voice.envFilterIndex = pfIdxBox[0]; voice.envFilterTimeSec = pfTimeBox[0]
|
||||||
|
} else {
|
||||||
|
voice.envFilterValue = 0.5; voice.envFilterIndex = 0; voice.envFilterTimeSec = 0.0
|
||||||
|
}
|
||||||
voice.fadeoutVolume = 1.0
|
voice.fadeoutVolume = 1.0
|
||||||
voice.autoVibPhase = 0
|
voice.autoVibPhase = 0
|
||||||
voice.autoVibTicksSinceTrigger = 0
|
voice.autoVibTicksSinceTrigger = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user