mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-15 00:44:05 +09:00
fix: midi2taud eats notes
This commit is contained in:
@@ -52,6 +52,8 @@ doubledntick:"\u009D",
|
|||||||
/* special notes */
|
/* special notes */
|
||||||
keyoff:"\u00A0\u00B1\u00B1\u00A1",
|
keyoff:"\u00A0\u00B1\u00B1\u00A1",
|
||||||
notecut:"\u00A4\u00A4\u00A4\u00A4",
|
notecut:"\u00A4\u00A4\u00A4\u00A4",
|
||||||
|
notefade:"~~~~",
|
||||||
|
notefastfade:"\u0084127u".repeat(4),
|
||||||
|
|
||||||
/* special effects */
|
/* special effects */
|
||||||
volset:'',//MIDDOT,
|
volset:'',//MIDDOT,
|
||||||
@@ -494,7 +496,7 @@ function retuneAllPatterns(newIdx, method) {
|
|||||||
for (let row = 0; row < ROWS_PER_PAT; row++) {
|
for (let row = 0; row < ROWS_PER_PAT; row++) {
|
||||||
const off = 8 * row
|
const off = 8 * row
|
||||||
const note = ptn[off] | (ptn[off+1] << 8)
|
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
|
// Use the full absolute pitch as tonic; the modular ops
|
||||||
// in _cadTension / _harmonicCost normalise it.
|
// in _cadTension / _harmonicCost normalise it.
|
||||||
tonic = note
|
tonic = note
|
||||||
@@ -504,7 +506,7 @@ function retuneAllPatterns(newIdx, method) {
|
|||||||
for (let row = 0; row < ROWS_PER_PAT; row++) {
|
for (let row = 0; row < ROWS_PER_PAT; row++) {
|
||||||
const off = 8 * row
|
const off = 8 * row
|
||||||
const note = ptn[off] | (ptn[off+1] << 8)
|
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
|
const origAbs = note
|
||||||
let newAbs
|
let newAbs
|
||||||
if ((method === 'delta' || method === 'cadence' || method === 'harmonic') && prevOrigAbs >= 0) {
|
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 === 0x0000) return sym.middot.repeat(4)
|
||||||
if (note === 0x0001) return sym.keyoff
|
if (note === 0x0001) return sym.keyoff
|
||||||
if (note === 0x0002) return sym.notecut
|
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)
|
if (note >= 0x0010 && note <= 0x001F) return ('Int' + (note & 0xF).toString(16).toUpperCase()).padEnd(4)
|
||||||
const preset = pitchTablePresets[PITCH_PRESET_IDX]
|
const preset = pitchTablePresets[PITCH_PRESET_IDX]
|
||||||
if (preset.table.length === 0) return note.hex04()
|
if (preset.table.length === 0) return note.hex04()
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -70,7 +70,7 @@ function expandEntities(s) {
|
|||||||
.replaceAll('&udlr;', '\u008428u\u008429u')
|
.replaceAll('&udlr;', '\u008428u\u008429u')
|
||||||
.replaceAll('&keyoffsym;', '\u00A0\u00B1\u00B1\u00A1')
|
.replaceAll('&keyoffsym;', '\u00A0\u00B1\u00B1\u00A1')
|
||||||
.replaceAll('¬ecutsym;', '\u00A4\u00A4\u00A4\u00A4')
|
.replaceAll('¬ecutsym;', '\u00A4\u00A4\u00A4\u00A4')
|
||||||
.replaceAll(' ', '\u007F')
|
.replaceAll(' ', ' ')
|
||||||
.replaceAll('­', '')
|
.replaceAll('­', '')
|
||||||
.replaceAll('<', '<')
|
.replaceAll('<', '<')
|
||||||
.replaceAll('>', '>')
|
.replaceAll('>', '>')
|
||||||
@@ -105,7 +105,7 @@ function expandEntities(s) {
|
|||||||
// Width accounting:
|
// Width accounting:
|
||||||
// - ANSI escapes (`\x1B[...m`) : 0 visible chars
|
// - ANSI escapes (`\x1B[...m`) : 0 visible chars
|
||||||
// - TSVM unicode escapes (`\u0084..u`) : 1 visible char
|
// - 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)
|
// - soft hyphen (\u00AD) : dropped (not implemented as a break point)
|
||||||
// - everything else : 1 visible char
|
// - everything else : 1 visible char
|
||||||
function tokenise(line) {
|
function tokenise(line) {
|
||||||
|
|||||||
107
midi2taud.py
107
midi2taud.py
@@ -46,6 +46,11 @@ Behaviour (per midi2taud.md):
|
|||||||
moves to a background ghost on the next trigger and dies over its own
|
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
|
release time. Voice budget defaults to 16 columns (--max-voices); overflow
|
||||||
releases the oldest pedal-held or soonest-ending note early, not cut.
|
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`
|
* 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).
|
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 /
|
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,
|
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
|
||||||
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT,
|
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT,
|
||||||
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
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,
|
TOP_G, TOP_M, TOP_S, TOP_T,
|
||||||
SEL_SET, SEL_FINE,
|
SEL_SET, SEL_FINE,
|
||||||
CUE_INST_NOP, CUE_INST_HALT,
|
CUE_INST_NOP, CUE_INST_HALT,
|
||||||
@@ -220,7 +225,7 @@ def parse_midi(path: str):
|
|||||||
|
|
||||||
class Note:
|
class Note:
|
||||||
__slots__ = ('ch', 'key', 'vel', 'start_ft', 'end_ft', 'inst_key',
|
__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):
|
def __init__(self, ch, key, vel, start_ft, inst_key, bend0):
|
||||||
self.ch = ch
|
self.ch = ch
|
||||||
self.key = key
|
self.key = key
|
||||||
@@ -233,6 +238,7 @@ class Note:
|
|||||||
self.voice = -1
|
self.voice = -1
|
||||||
self.drum = (inst_key[0] == 'd')
|
self.drum = (inst_key[0] == 'd')
|
||||||
self.pedal_ft = None # physical key-up time when only the pedal holds it
|
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:
|
class _ChState:
|
||||||
@@ -434,6 +440,7 @@ GEN_FILTERFC = 8 # initialFilterFc (absolute cents; default 13500 =
|
|||||||
GEN_FILTERQ = 9 # initialFilterQ (cB of resonance; default 0)
|
GEN_FILTERQ = 9 # initialFilterQ (cB of resonance; default 0)
|
||||||
GEN_MODENV2FILT = 11 # modEnvToFilterFc (signed cents at full mod-env)
|
GEN_MODENV2FILT = 11 # modEnvToFilterFc (signed cents at full mod-env)
|
||||||
GEN_END_COARSE = 12
|
GEN_END_COARSE = 12
|
||||||
|
GEN_EXCLUSIVECLASS = 57 # drum mutual-exclusion group (instrument-level; 0 = none)
|
||||||
GEN_PAN = 17
|
GEN_PAN = 17
|
||||||
GEN_DELAY_MODENV = 25
|
GEN_DELAY_MODENV = 25
|
||||||
GEN_ATTACK_MODENV = 26
|
GEN_ATTACK_MODENV = 26
|
||||||
@@ -502,7 +509,9 @@ class SFZone:
|
|||||||
'atten_cb', 'filter_fc', 'filter_q',
|
'atten_cb', 'filter_fc', 'filter_q',
|
||||||
# modulation envelope (drives pitch and/or filter) + its targets.
|
# modulation envelope (drives pitch and/or filter) + its targets.
|
||||||
'm_delay', 'm_attack', 'm_hold', 'm_decay', 'm_sustain_pc',
|
'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:
|
class SF2:
|
||||||
@@ -714,6 +723,11 @@ def parse_sf2(path: str) -> SF2:
|
|||||||
+ pz.get(GEN_RELEASE_MODENV, 0))
|
+ pz.get(GEN_RELEASE_MODENV, 0))
|
||||||
z.me2pitch = iz.get(GEN_MODENV2PITCH, 0) + pz.get(GEN_MODENV2PITCH, 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)
|
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)
|
z.a_start = (s.start + iz.get(GEN_START_OFF, 0)
|
||||||
+ 32768 * iz.get(GEN_START_COARSE, 0))
|
+ 32768 * iz.get(GEN_START_COARSE, 0))
|
||||||
z.a_end = (s.end + iz.get(GEN_END_OFF, 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
|
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):
|
def _rect_of_zone(z: SFZone):
|
||||||
"""Zone key/vel ranges → Taud (pitch_lo, pitch_hi, vol_lo, vol_hi).
|
"""Zone key/vel ranges → Taud (pitch_lo, pitch_hi, vol_lo, vol_hi).
|
||||||
Pitch bounds sit on half-semitone boundaries so triggers carrying an
|
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
|
end_row = srow + 1 # ghost carries the ring
|
||||||
else:
|
else:
|
||||||
end_row = max(srow + 1, n.end_ft // speed) # free at key-off row
|
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
|
n.voice = v
|
||||||
v_end[v], v_slot[v], v_note[v] = end_row, n.slot, n
|
v_end[v], v_slot[v], v_note[v] = end_row, n.slot, n
|
||||||
if stolen:
|
if stolen:
|
||||||
@@ -1848,6 +1918,32 @@ def emit_cells(song: Song, insts: dict, speed: int, rpb: int,
|
|||||||
if skipped_offs:
|
if skipped_offs:
|
||||||
vprint(f" info: {skipped_offs} key-off(s) absorbed by same-row retriggers")
|
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 ──
|
# ── Pass 3: pitch-bend portamento segments ──
|
||||||
# One linear segment per row: the cell carries the exact 4096-TET target
|
# 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
|
# 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:
|
for n in song.notes:
|
||||||
n.start_ft -= shift_ft
|
n.start_ft -= shift_ft
|
||||||
n.end_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
|
eps_units = args.bend_epsilon * 4096.0 / 1200.0
|
||||||
cells, n_voices, total_rows, bpm0 = emit_cells(
|
cells, n_voices, total_rows, bpm0 = emit_cells(
|
||||||
@@ -2175,6 +2273,9 @@ def main():
|
|||||||
sf = parse_sf2(args.soundfont)
|
sf = parse_sf2(args.soundfont)
|
||||||
vprint(f" {len(sf.presets)} preset(s), {len(sf.shdrs)} sample header(s)")
|
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-
|
# 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
|
# bend, vol6) pair the patterns will carry, so layer trimming sees precisely what
|
||||||
# the engine matches at runtime.
|
# the engine matches at runtime.
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ SAMPLE_LEN_LIMIT = 65535
|
|||||||
NOTE_NOP = 0x0000
|
NOTE_NOP = 0x0000
|
||||||
NOTE_KEYOFF = 0x0001
|
NOTE_KEYOFF = 0x0001
|
||||||
NOTE_CUT = 0x0002
|
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
|
TAUD_C4 = 0x5000 # The audio engine's Middle C
|
||||||
|
|
||||||
# Cue sheet instruction byte (cue offset 30; offset 31 = arg byte for 2-byte forms).
|
# Cue sheet instruction byte (cue offset 30; offset 31 = arg byte for 2-byte forms).
|
||||||
|
|||||||
@@ -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)
|
[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·(1000−sus_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·(1000−sus_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?
|
[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.
|
[ ] auto-set optimal-ish Tickspeed and RPB using MIDI Time Signature events and note analysis. Break pattern when Time Signature changes.
|
||||||
|
|
||||||
Time Signature
|
Time Signature
|
||||||
@@ -2808,6 +2823,19 @@ TODO:
|
|||||||
- Inst > Gen.2 > filter: IT/SF mode toggle
|
- Inst > Gen.2 > filter: IT/SF mode toggle
|
||||||
- Samples playblobs: only active for actually playing samples
|
- Samples playblobs: only active for actually playing samples
|
||||||
- Samples playcursor: true cursors 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:
|
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
|
||||||
@@ -2833,8 +2861,10 @@ notes are tuned as 4096 Tone-Equal Temperament. Tuning is set per-sample using t
|
|||||||
Special values:
|
Special values:
|
||||||
|
|
||||||
note 0x0000: no-op
|
note 0x0000: no-op
|
||||||
note 0x0001: key-off
|
note 0x0001: key-off (====)
|
||||||
note 0x0002: note cut
|
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.
|
note 0x0010..0x001F: Interrupt 0..F (notation: Int0..IntF) — reserved interrupt slots; engine has no default handler.
|
||||||
|
|
||||||
inst 0: no instrument change
|
inst 0: no instrument change
|
||||||
|
|||||||
@@ -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.
|
// 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).
|
// Applied on sample end only (preserves attack transients on note start).
|
||||||
const val RAMP_OUT_SAMPLES = 256
|
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,
|
// Volume-change anti-click ramp: voleff/notefx (volume column, D vol-slides,
|
||||||
// tremor, tremolo, retrig vol-mod, fine slides etc.) mutate Voice.rowVolume
|
// tremor, tremolo, retrig vol-mod, fine slides etc.) mutate Voice.rowVolume
|
||||||
// and M / N mutate Voice.channelVolume mid-note. The mixer ramps the actual
|
// 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
|
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
|
* Per-sample volume-ramp tick. Smooths [Voice.currentMixVolume] toward
|
||||||
* `(rowVolume / 63.0) × (channelVolume / 63.0)` over [VOL_RAMP_SAMPLES]
|
* `(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
|
voice.delayedInst = 0; voice.delayedVol = -1
|
||||||
} else { voice.active = false; cutLayerChildren(ts, vi) } // note cut (immediate)
|
} 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 0x0003..0x000F -> { /* reserved sentinel range, no engine handler */ }
|
||||||
in 0x0010..0x001F -> { /* Int0..IntF: reserved interrupt slots, no engine handler yet */ }
|
in 0x0010..0x001F -> { /* Int0..IntF: reserved interrupt slots, no engine handler yet */ }
|
||||||
else -> {
|
else -> {
|
||||||
@@ -3397,6 +3431,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
applyKeyLift(voice, instruments[voice.instrumentId])
|
applyKeyLift(voice, instruments[voice.instrumentId])
|
||||||
}
|
}
|
||||||
0x0002 -> { voice.active = false; cutLayerChildren(ts, vi) } // delayed note cut
|
0x0002 -> { voice.active = false; cutLayerChildren(ts, vi) } // delayed note cut
|
||||||
|
0x0004 -> startFastFade(voice, playhead) // delayed fast fade
|
||||||
else -> {
|
else -> {
|
||||||
applyDuplicateCheck(ts, vi, voice.delayedInst, voice.delayedNote)
|
applyDuplicateCheck(ts, vi, voice.delayedInst, voice.delayedNote)
|
||||||
maybeSpawnBackgroundForNNA(ts, voice, vi)
|
maybeSpawnBackgroundForNNA(ts, voice, vi)
|
||||||
|
|||||||
Reference in New Issue
Block a user