fix: midi2taud eats notes

This commit is contained in:
minjaesong
2026-06-14 13:28:52 +09:00
parent aa9ea2eeca
commit 240ce01209
8 changed files with 184 additions and 12 deletions

View File

@@ -52,6 +52,8 @@ doubledntick:"\u009D",
/* special notes */
keyoff:"\u00A0\u00B1\u00B1\u00A1",
notecut:"\u00A4\u00A4\u00A4\u00A4",
notefade:"~~~~",
notefastfade:"\u0084127u".repeat(4),
/* special effects */
volset:'',//MIDDOT,
@@ -494,7 +496,7 @@ function retuneAllPatterns(newIdx, method) {
for (let row = 0; row < ROWS_PER_PAT; row++) {
const off = 8 * row
const note = ptn[off] | (ptn[off+1] << 8)
if (note === 0x0000 || note === 0x0001 || note === 0x0002 || (note >= 0x0010 && note <= 0x001F)) continue
if (note >= 0x0000 && note <= 0x001F) continue
// Use the full absolute pitch as tonic; the modular ops
// in _cadTension / _harmonicCost normalise it.
tonic = note
@@ -504,7 +506,7 @@ function retuneAllPatterns(newIdx, method) {
for (let row = 0; row < ROWS_PER_PAT; row++) {
const off = 8 * row
const note = ptn[off] | (ptn[off+1] << 8)
if (note === 0x0000 || note === 0x0001 || note === 0x0002 || (note >= 0x0010 && note <= 0x001F)) continue
if (note >= 0x0000 && note <= 0x001F) continue
const origAbs = note
let newAbs
if ((method === 'delta' || method === 'cadence' || method === 'harmonic') && prevOrigAbs >= 0) {
@@ -589,6 +591,8 @@ function noteToStr(note) {
if (note === 0x0000) return sym.middot.repeat(4)
if (note === 0x0001) return sym.keyoff
if (note === 0x0002) return sym.notecut
if (note === 0x0003) return sym.notefade
if (note === 0x0004) return sym.notefastfade
if (note >= 0x0010 && note <= 0x001F) return ('Int' + (note & 0xF).toString(16).toUpperCase()).padEnd(4)
const preset = pitchTablePresets[PITCH_PRESET_IDX]
if (preset.table.length === 0) return note.hex04()

Binary file not shown.

View File

@@ -70,7 +70,7 @@ function expandEntities(s) {
.replaceAll('&udlr;', '\u008428u\u008429u')
.replaceAll('&keyoffsym;', '\u00A0\u00B1\u00B1\u00A1')
.replaceAll('&notecutsym;', '\u00A4\u00A4\u00A4\u00A4')
.replaceAll('&nbsp;', '\u007F')
.replaceAll('&nbsp;', ' ')
.replaceAll('&shy;', '')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
@@ -105,7 +105,7 @@ function expandEntities(s) {
// Width accounting:
// - ANSI escapes (`\x1B[...m`) : 0 visible chars
// - TSVM unicode escapes (`\u0084..u`) : 1 visible char
// - non-breaking space (\u007F) : 1 visible char (consumed as part of a word)
// - non-breaking space (' ') : 1 visible char (consumed as part of a word)
// - soft hyphen (\u00AD) : dropped (not implemented as a break point)
// - everything else : 1 visible char
function tokenise(line) {

View File

@@ -46,6 +46,11 @@ Behaviour (per midi2taud.md):
moves to a background ghost on the next trigger and dies over its own
release time. Voice budget defaults to 16 columns (--max-voices); overflow
releases the oldest pedal-held or soonest-ending note early, not cut.
* SF2 exclusiveClass (gen 57) is honoured on the percussion channel: a new note
in a class chokes any ringing note of the same class (e.g. a closed hi-hat
silences a ringing open hi-hat), matching FluidSynth's kill-by-exclusive-class.
The choke is the new fast note-fade (note 0x0004, ~0.3 s) emitted at the next
same-class onset; without it long percussion tails wash over the whole beat.
* Sub-row timing is carried by S $Dx note delays (one row = `--speed`
ticks, default 6; one beat = `--rpb` rows, default 4 → 1/24-beat grid).
MIDI tempo changes map to T $xx00 set-tempo effects; channel volume /
@@ -66,7 +71,7 @@ from taud_common import (
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT,
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
NOTE_NOP, NOTE_KEYOFF, TAUD_C4,
NOTE_NOP, NOTE_KEYOFF, NOTE_FASTFADE, TAUD_C4,
TOP_G, TOP_M, TOP_S, TOP_T,
SEL_SET, SEL_FINE,
CUE_INST_NOP, CUE_INST_HALT,
@@ -220,7 +225,7 @@ def parse_midi(path: str):
class Note:
__slots__ = ('ch', 'key', 'vel', 'start_ft', 'end_ft', 'inst_key',
'bend0', 'slot', 'voice', 'drum', 'pedal_ft')
'bend0', 'slot', 'voice', 'drum', 'pedal_ft', 'excl_cut_ft')
def __init__(self, ch, key, vel, start_ft, inst_key, bend0):
self.ch = ch
self.key = key
@@ -233,6 +238,7 @@ class Note:
self.voice = -1
self.drum = (inst_key[0] == 'd')
self.pedal_ft = None # physical key-up time when only the pedal holds it
self.excl_cut_ft = None # ft at which a same-exclusiveClass note chokes this one
class _ChState:
@@ -434,6 +440,7 @@ GEN_FILTERFC = 8 # initialFilterFc (absolute cents; default 13500 =
GEN_FILTERQ = 9 # initialFilterQ (cB of resonance; default 0)
GEN_MODENV2FILT = 11 # modEnvToFilterFc (signed cents at full mod-env)
GEN_END_COARSE = 12
GEN_EXCLUSIVECLASS = 57 # drum mutual-exclusion group (instrument-level; 0 = none)
GEN_PAN = 17
GEN_DELAY_MODENV = 25
GEN_ATTACK_MODENV = 26
@@ -502,7 +509,9 @@ class SFZone:
'atten_cb', 'filter_fc', 'filter_q',
# modulation envelope (drives pitch and/or filter) + its targets.
'm_delay', 'm_attack', 'm_hold', 'm_decay', 'm_sustain_pc',
'm_release', 'me2pitch', 'me2filt')
'm_release', 'me2pitch', 'me2filt',
# exclusiveClass (gen 57): drum mutual-exclusion group (0 = none).
'excl_class')
class SF2:
@@ -714,6 +723,11 @@ def parse_sf2(path: str) -> SF2:
+ pz.get(GEN_RELEASE_MODENV, 0))
z.me2pitch = iz.get(GEN_MODENV2PITCH, 0) + pz.get(GEN_MODENV2PITCH, 0)
z.me2filt = iz.get(GEN_MODENV2FILT, 0) + pz.get(GEN_MODENV2FILT, 0)
# exclusiveClass is instrument-level and NON-additive (SF2.04 §8.1.2 #57):
# a new note in class C kills sounding notes of the same class on the same
# channel (FluidSynth fluid_synth_kill_by_exclusive_class). Drum kits use it
# so a closed hi-hat (42) chokes a ringing open hi-hat (46).
z.excl_class = iz.get(GEN_EXCLUSIVECLASS, 0)
z.a_start = (s.start + iz.get(GEN_START_OFF, 0)
+ 32768 * iz.get(GEN_START_COARSE, 0))
z.a_end = (s.end + iz.get(GEN_END_OFF, 0)
@@ -811,6 +825,54 @@ def merge_stereo_zones(zones: list, shdrs: list) -> list:
return out
def apply_exclusive_class(song, sf, perc_force):
"""SF2 exclusiveClass (gen 57): starting a note in class C kills any ringing note
of the same class on the same channel — FluidSynth's
fluid_synth_kill_by_exclusive_class (fluid_synth.c:5453). GM drum kits use it so a
closed hi-hat (key 42) chokes a ringing open hi-hat (key 46); without it the open
hi-hat's multi-second tail washes over the whole beat and buries the other hits.
Resolve each percussion note's exclusiveClass from the SF2 zone it plays, then within
each (channel, class) serialise the chokes: every note is cut at the next note of the
same class that starts strictly later. `emit_cells` emits a fast note-fade
(NOTE_FASTFADE) at that point and `allocate_voices` keeps the choked voice foreground
until then. Drum channel only — GM melodic presets do not set gen 57, and a hard choke
would fight the melodic key-off/release machinery."""
zone_cache = {}
def excl_of(n):
if not n.drum:
return 0
zones = zone_cache.get(n.inst_key)
if zones is None:
res = resolve_preset(sf, n.inst_key, perc_force)
zones = merge_stereo_zones(res[1], sf.shdrs) if res else []
zone_cache[n.inst_key] = zones
# SF2 zone selection: first zone whose key/velocity rect contains the note.
for z in zones:
if z.keylo <= n.key <= z.keyhi and z.vello <= n.vel <= z.velhi:
return z.excl_class
return 0
groups = {}
for n in song.notes:
c = excl_of(n)
if c:
groups.setdefault((n.ch, c), []).append(n)
n_cut = 0
for notes in groups.values():
notes.sort(key=lambda n: n.start_ft)
for i, n in enumerate(notes):
for j in range(i + 1, len(notes)):
if notes[j].start_ft > n.start_ft: # next strictly-later onset chokes n
n.excl_cut_ft = notes[j].start_ft
n_cut += 1
break
if n_cut:
vprint(f" exclusiveClass: {n_cut} percussion choke(s) across "
f"{len(groups)} group(s)")
def _rect_of_zone(z: SFZone):
"""Zone key/vel ranges → Taud (pitch_lo, pitch_hi, vol_lo, vol_hi).
Pitch bounds sit on half-semitone boundaries so triggers carrying an
@@ -1763,6 +1825,14 @@ def allocate_voices(notes: list, speed: int, max_voices: int) -> int:
end_row = srow + 1 # ghost carries the ring
else:
end_row = max(srow + 1, n.end_ft // speed) # free at key-off row
if n.excl_cut_ft is not None:
# exclusiveClass choke: hold the voice through the choke row so this note stays
# FOREGROUND until then (the fast-fade cell must land on it, not a ghost), and so
# the choking same-class note cannot reuse this column at the choke row.
crow = n.excl_cut_ft // speed
if crow <= srow:
crow = srow + 1
end_row = max(end_row, crow + 1)
n.voice = v
v_end[v], v_slot[v], v_note[v] = end_row, n.slot, n
if stolen:
@@ -1848,6 +1918,32 @@ def emit_cells(song: Song, insts: dict, speed: int, rpb: int,
if skipped_offs:
vprint(f" info: {skipped_offs} key-off(s) absorbed by same-row retriggers")
# ── Pass 2b: exclusiveClass chokes (fast note-fade) ──
# The choked note holds its voice through the choke row (allocate_voices), so the
# NOTE_FASTFADE lands on it while it is still foreground. The next same-class note
# plays on a different column, so this never collides with a fresh trigger.
for n in notes:
if n.excl_cut_ft is None:
continue
srow = n.start_ft // speed
row, tick = n.excl_cut_ft // speed, n.excl_cut_ft % speed
if row <= srow: # choke within the trigger row → round up one row
row = srow + 1
tick = 0
c = cells.get((n.voice, row))
if c is None:
c = _cell(cells, n.voice, row)
c['note'] = NOTE_FASTFADE
if tick > 0:
c['eff'] = (TOP_S, 0xD000 | (tick << 8))
c['prio'] = PRIO_DELAY
elif c['note'] in (NOTE_NOP, NOTE_KEYOFF):
c['note'] = NOTE_FASTFADE # choke supersedes a natural key-off
if tick > 0 and c['eff'] is None:
c['eff'] = (TOP_S, 0xD000 | (tick << 8))
c['prio'] = PRIO_DELAY
# else: row already holds a fresh trigger — that note cuts/NNAs this one anyway.
# ── Pass 3: pitch-bend portamento segments ──
# One linear segment per row: the cell carries the exact 4096-TET target
# plus G at units/tick sized to land on it by row end (G slides on the
@@ -1995,6 +2091,8 @@ def assemble_taud(sf: SF2, song: Song, layer_insts: list, meta_records: list,
for n in song.notes:
n.start_ft -= shift_ft
n.end_ft -= shift_ft
if n.excl_cut_ft is not None:
n.excl_cut_ft -= shift_ft
eps_units = args.bend_epsilon * 4096.0 / 1200.0
cells, n_voices, total_rows, bpm0 = emit_cells(
@@ -2175,6 +2273,9 @@ def main():
sf = parse_sf2(args.soundfont)
vprint(f" {len(sf.presets)} preset(s), {len(sf.shdrs)} sample header(s)")
# SF2 exclusiveClass percussion choking (closed hi-hat silences open hi-hat, etc.).
apply_exclusive_class(song, sf, args.perc_force_mapping)
# Presets in first-use order; triggers keyed by the exact (noteVal-with-initial-
# bend, vol6) pair the patterns will carry, so layer trimming sees precisely what
# the engine matches at runtime.

View File

@@ -99,6 +99,8 @@ SAMPLE_LEN_LIMIT = 65535
NOTE_NOP = 0x0000
NOTE_KEYOFF = 0x0001
NOTE_CUT = 0x0002
# 0x0003 reserved for Impulse Tracker Note Fade.
NOTE_FASTFADE = 0x0004 # ~0.3 s note-fade (SF2 exclusiveClass choke; fluid_voice_kill_excl)
TAUD_C4 = 0x5000 # The audio engine's Middle C
# Cue sheet instruction byte (cue offset 30; offset 31 = arg byte for 2-byte forms).

View File

@@ -2783,7 +2783,22 @@ TODO:
[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.
[x] SF2 filter still sounds way too muffled?
[ ] Drum notes get eaten (E2M1.mid)
[x] Drum notes get eaten (E2M1.mid)
* DONE 2026-06-14. Root cause: midi2taud ignored SF2 exclusiveClass (gen 57). SGM's
STANDARD 1 puts the closed/pedal/open hi-hats (keys 42/44/46) in class 1; without the
choke the open hi-hat's multi-second tail (≈4.9 s) washes over the whole beat and
buries the other hits. FluidSynth kills same-class voices on note-on
(fluid_synth_kill_by_exclusive_class → fluid_voice_kill_excl, a fast 2000-timecent
release ≈ 0.3 s). Fix in two parts: (a) midi2taud.py parses gen 57, and per drum
channel chokes each note at the next same-class onset — emitting the new fast-fade
note 0x0004 (apply_exclusive_class + emit_cells pass 2b; allocate_voices holds the
choked voice foreground until the choke); (b) the engine implements note 0x0004
(startFastFade): a ~0.3 s note-fade while the sample keeps playing, distinct from
^^CUT's hard stop. Headless coverage: devtests/ixmp/FastFadeTest. Verified on
E2M1 (open hat no longer rings >0.3 s; crashes, not in a class, still ring out).
[ ] midi2taud: toggleable option for disabling filter for percussions [default: on]
- Anything on bank 127 and 128 (usually asso siated with ch 10)
- GeneralMIDI instruments 113..128
[ ] auto-set optimal-ish Tickspeed and RPB using MIDI Time Signature events and note analysis. Break pattern when Time Signature changes.
Time Signature
@@ -2808,6 +2823,19 @@ TODO:
- Inst > Gen.2 > filter: IT/SF mode toggle
- Samples playblobs: only active for actually playing samples
- Samples playcursor: true cursors for actually playing samples
[ ] implement note-fade (0x0003) and wire it to it2taud
[ ] taut.js quit with TypeError: Cannot read property 'terminatorIdx' of undefined:
TypeError: Cannot read property 'terminatorIdx' of undefined
at drawEnvelopeCursor (<eval>:5312:22)
at drawInstrumentsContents (<eval>:4919:41)
at Object.onClick (<eval>:4984:17)
at dispatchMouseEvent (<eval>:6275:53)
at <eval>:6633:13
at Object.withEvent (<eval>:1168:27)
at _tvdosExec$microtone$tvdos$bin$taut$js (<eval>:6632:11)
at execApp (<eval>:1457:16)
at Object.execute (<eval>:893:38)
at <eval>:867:31
TODO - list of demo songs that MUST ship with Microtone:
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
@@ -2833,8 +2861,10 @@ notes are tuned as 4096 Tone-Equal Temperament. Tuning is set per-sample using t
Special values:
note 0x0000: no-op
note 0x0001: key-off
note 0x0002: note cut
note 0x0001: key-off (====)
note 0x0002: note cut (^^^^)
note 0x0003: note fade (~~~~)
note 0x0004: fast fade (vvvv)
note 0x0010..0x001F: Interrupt 0..F (notation: Int0..IntF) — reserved interrupt slots; engine has no default handler.
inst 0: no instrument change

View File

@@ -150,6 +150,11 @@ 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
// Fast note-fade (note word 0x0004): a quick choke for SF2 exclusiveClass (e.g. a
// closed hi-hat silencing a ringing open hi-hat). FluidSynth's kill uses
// GEN_VOLENVRELEASE = -2000 timecents ≈ 0.315 s (fluid_voice.c:1404); the voice keeps
// playing while fadeoutVolume ramps to zero over this time, then deactivates.
const val FAST_FADE_SEC = 0.3
// Volume-change anti-click ramp: voleff/notefx (volume column, D vol-slides,
// tremor, tremolo, retrig vol-mod, fine slides etc.) mutate Voice.rowVolume
// and M / N mutate Voice.channelVolume mid-note. The mixer ramps the actual
@@ -2198,6 +2203,22 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.rampOutStep = 1.0 / RAMP_OUT_SAMPLES
}
/**
* Fast note-fade (note word 0x0004 — SF2 exclusiveClass choke). Starts an immediate
* note-fade that drives fadeoutVolume from 1.0 to 0.0 over [FAST_FADE_SEC] while the
* sample keeps advancing (unlike ^^CUT's hard stop, and far quicker than the
* instrument's own release fadeout). The per-tick fadeout step (subtracted as
* fadeStep/1024 each song tick at bpm·0.4 Hz) is sized to the current tempo so the
* fade lands on [FAST_FADE_SEC] regardless of BPM. Mirrors FluidSynth's
* fluid_voice_kill_excl (a 2000-timecent release). No-op on an inactive voice.
*/
private fun startFastFade(voice: Voice, playhead: Playhead) {
if (!voice.active) return
voice.noteFading = true
val ticks = (FAST_FADE_SEC * playhead.bpm * 0.4).coerceAtLeast(1.0)
voice.activeFadeoutStep = (1024.0 / ticks).roundToInt().coerceIn(1, 0xFFF)
}
/**
* Per-sample volume-ramp tick. Smooths [Voice.currentMixVolume] toward
* `(rowVolume / 63.0) × (channelVolume / 63.0)` over [VOL_RAMP_SAMPLES]
@@ -2906,6 +2927,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.delayedInst = 0; voice.delayedVol = -1
} else { voice.active = false; cutLayerChildren(ts, vi) } // note cut (immediate)
}
// Fast note-fade (SF2 exclusiveClass choke): begin a ~0.3 s fade. Honours a
// sub-row S$Dx delay the same way KEY_OFF / note-cut do.
0x0004 -> {
val dTick = if ((row.effect == EffectOp.OP_S) && ((row.effectArg ushr 12) and 0xF) == 0xD)
(row.effectArg ushr 8) and 0xF else 0
if (dTick > 0) {
voice.noteDelayTick = dTick; voice.delayedNote = 0x0004
voice.delayedInst = 0; voice.delayedVol = -1
} else {
startFastFade(voice, playhead)
}
}
// 0x0003 (IT-style slow note fade, "~~~~") not yet implemented; 0x0005..0x000F reserved.
in 0x0003..0x000F -> { /* reserved sentinel range, no engine handler */ }
in 0x0010..0x001F -> { /* Int0..IntF: reserved interrupt slots, no engine handler yet */ }
else -> {
@@ -3397,6 +3431,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
applyKeyLift(voice, instruments[voice.instrumentId])
}
0x0002 -> { voice.active = false; cutLayerChildren(ts, vi) } // delayed note cut
0x0004 -> startFastFade(voice, playhead) // delayed fast fade
else -> {
applyDuplicateCheck(ts, vi, voice.delayedInst, voice.delayedNote)
maybeSpawnBackgroundForNNA(ts, voice, vi)