midi2taud: --force-synth-loop

This commit is contained in:
minjaesong
2026-06-15 02:35:42 +09:00
parent d6f9e64147
commit cd04138573
2 changed files with 149 additions and 120 deletions

View File

@@ -1785,11 +1785,17 @@ def _effective_vol_env(z: SFZone, ms: 'MonoSample') -> dict:
def build_sample_inst_bin(sf: SF2, pool: list, layer_insts: list, meta_records: list, def build_sample_inst_bin(sf: SF2, pool: list, layer_insts: list, meta_records: list,
fadeout_override, bpm0: int): fadeout_override, bpm0: int, force_synth_loop: bool = False):
"""Render & pool every used MonoSample (with the 65535-byte per-sample """Render & pool every used MonoSample (with the 65535-byte per-sample
and 8 MB global caps), write the 256-byte normal-instrument records for every and 8 MB global caps), write the 256-byte normal-instrument records for every
layer instrument, then the Metainstrument records. Returns the raw layer instrument, then the Metainstrument records. Returns the raw
SAMPLEINST_SIZE image.""" SAMPLEINST_SIZE image.
`force_synth_loop`: when a looped sample's loop sits past the 65535-frame cap
even at 32 kHz (case 3c), replace its real loop with a synthesized one at the
32 kHz floor instead of muffling the whole sample down to fit (the default).
Trades the genuine sustain loop for full bandwidth + a 10 s decay — useful for
banks of multi-second far-loop instruments (e.g. Timbres of Heaven)."""
for ms in pool: for ms in pool:
ms.render(sf) ms.render(sf)
@@ -1817,15 +1823,13 @@ def build_sample_inst_bin(sf: SF2, pool: list, layer_insts: list, meta_records:
ms.data = resample_linear(ms.data, r_fit) ms.data = resample_linear(ms.data, r_fit)
ms.ratio *= len(ms.data) / native_len ms.ratio *= len(ms.data) / native_len
if rate_fit >= SF2_RESAMPLE_FLOOR_HZ: def _synth_path():
_fit_whole() """(3) resample to the 32 kHz floor (full bandwidth), keep the first 65535
vprint(f" info: '{ms.name}' {native_len} frames > 64K cap; " frames and synthesize a near-seamless sustain loop near the end, plus a
f"resampling by {r_fit:.4f} (rate {rate_fit:.0f} Hz)") peak->0 decay vol-envelope that fades the looped note to silence from
elif ms.loop_native is None: note-on. Used for unlooped long samples, and (with --force-synth-loop) for
# (3) No loop: resample to the 32 kHz floor (full bandwidth), keep the first looped samples whose real loop won't fit at the floor. synth_loop takes
# 65535 frames and synthesize a near-seamless sustain loop near the end, plus precedence over any real loop_native in the record/patch writers."""
# a peak->0 decay vol-envelope that fades the looped note to silence from
# note-on (the SF2 sample stops on its own otherwise; a loop would ring).
resampled = resample_linear(ms.data, r32) resampled = resample_linear(ms.data, r32)
ms.ratio *= len(resampled) / native_len # effective rate -> 32 kHz ms.ratio *= len(resampled) / native_len # effective rate -> 32 kHz
ms.data = resampled ms.data = resampled
@@ -1833,8 +1837,17 @@ def build_sample_inst_bin(sf: SF2, pool: list, layer_insts: list, meta_records:
ms.data = body ms.data = body
ms.synth_loop = (ls, le) ms.synth_loop = (ls, le)
ms.synth_decay = SF2_SYNTH_DECAY_SEC ms.synth_decay = SF2_SYNTH_DECAY_SEC
return ls, le, len(body)
if rate_fit >= SF2_RESAMPLE_FLOOR_HZ:
_fit_whole()
vprint(f" info: '{ms.name}' {native_len} frames > 64K cap; "
f"resampling by {r_fit:.4f} (rate {rate_fit:.0f} Hz)")
elif ms.loop_native is None:
# (3) No loop: synthesize one at the 32 kHz floor.
ls, le, n = _synth_path()
vprint(f" info: '{ms.name}' {native_len} frames > 64K cap, long & unlooped; " vprint(f" info: '{ms.name}' {native_len} frames > 64K cap, long & unlooped; "
f"32 kHz, kept {len(body)} frames, synth loop [{ls}..{le}] " f"32 kHz, kept {n} frames, synth loop [{ls}..{le}] "
f"+ {SF2_SYNTH_DECAY_SEC:.0f}s decay") f"+ {SF2_SYNTH_DECAY_SEC:.0f}s decay")
elif le32 <= SAMPLE_LEN_LIMIT - 2: elif le32 <= SAMPLE_LEN_LIMIT - 2:
# (3) Looped, and the loop fits at the 32 kHz floor: resample to 32 kHz and # (3) Looped, and the loop fits at the 32 kHz floor: resample to 32 kHz and
@@ -1846,11 +1859,20 @@ def build_sample_inst_bin(sf: SF2, pool: list, layer_insts: list, meta_records:
ms.data = resampled[:SAMPLE_LEN_LIMIT] ms.data = resampled[:SAMPLE_LEN_LIMIT]
vprint(f" info: '{ms.name}' {native_len} frames > 64K cap, long & looped; " vprint(f" info: '{ms.name}' {native_len} frames > 64K cap, long & looped; "
f"32 kHz, kept first {len(ms.data)} frames (loop_end {le32})") f"32 kHz, kept first {len(ms.data)} frames (loop_end {le32})")
elif force_synth_loop:
# (3c, forced) Looped, far loop, but --force-synth-loop: drop the genuine
# loop and synthesize one at the 32 kHz floor rather than muffling the whole
# sample. Full bandwidth + a 10 s decay, at the cost of the real sustain.
ls, le, n = _synth_path()
vprint(f" info: '{ms.name}' {native_len} frames > 64K cap, long, looped, far "
f"loop; FORCED synth: 32 kHz, kept {n} frames, synth loop [{ls}..{le}] "
f"+ {SF2_SYNTH_DECAY_SEC:.0f}s decay")
else: else:
# (3) Looped but the loop sits past the 65535-frame cap at 32 kHz (a far-end # (3) Looped but the loop sits past the 65535-frame cap at 32 kHz (a far-end
# sustain loop on a multi-second sample): the floor rate can't hold it, so # sustain loop on a multi-second sample): the floor rate can't hold it, so
# downsample the whole sample to fit — the ratio-scaled loop stays valid, # downsample the whole sample to fit — the ratio-scaled loop stays valid,
# at a sub-32 kHz rate. (This is the pre-existing fit-to-cap behaviour.) # at a sub-32 kHz rate. (This is the pre-existing fit-to-cap behaviour;
# --force-synth-loop swaps it for the synth path above.)
_fit_whole() _fit_whole()
vprint(f" info: '{ms.name}' {native_len} frames > 64K cap, long, looped, " vprint(f" info: '{ms.name}' {native_len} frames > 64K cap, long, looped, "
f"far loop; fit-to-cap by {r_fit:.4f} (rate {ms.rate * r_fit:.0f} Hz)") f"far loop; fit-to-cap by {r_fit:.4f} (rate {ms.rate * r_fit:.0f} Hz)")
@@ -2432,7 +2454,8 @@ def assemble_taud(sf: SF2, song: Song, layer_insts: list, meta_records: list,
# ── Sample + instrument bin ── # ── Sample + instrument bin ──
sampleinst_raw = build_sample_inst_bin(sf, pool, layer_insts, meta_records, sampleinst_raw = build_sample_inst_bin(sf, pool, layer_insts, meta_records,
args.fadeout, bpm0) args.fadeout, bpm0,
force_synth_loop=args.force_synth_loop)
assert len(sampleinst_raw) == SAMPLEINST_SIZE assert len(sampleinst_raw) == SAMPLEINST_SIZE
compressed = compress_blob(sampleinst_raw, "sample+inst bin") compressed = compress_blob(sampleinst_raw, "sample+inst bin")
comp_size = len(compressed) comp_size = len(compressed)
@@ -2569,6 +2592,13 @@ def main():
ap.add_argument('--drum-keyoff', action='store_true', ap.add_argument('--drum-keyoff', action='store_true',
help='Emit KEY_OFF for percussion-channel notes too ' help='Emit KEY_OFF for percussion-channel notes too '
'(GM drums normally ignore note-off)') '(GM drums normally ignore note-off)')
ap.add_argument('--force-synth-loop', action='store_true',
help='For looped samples whose loop sits past the 65535-frame '
'cap even at 32 kHz (multi-second far-loop instruments, '
'e.g. Timbres of Heaven): replace the real loop with a '
'synthesized one at 32 kHz + a 10 s decay, instead of '
'muffling the whole sample down to fit. Trades the genuine '
'sustain loop for full bandwidth')
ap.add_argument('--no-project-data', action='store_true', ap.add_argument('--no-project-data', action='store_true',
help='Omit the Project Data section — NOTE: this also ' help='Omit the Project Data section — NOTE: this also '
'omits Ixmp, collapsing every instrument to its ' 'omits Ixmp, collapsing every instrument to its '

View File

@@ -2775,112 +2775,111 @@ TODO:
[x] Ixmp version 2, supporting per-patch ADSR [x] Ixmp version 2, supporting per-patch ADSR
For UI concerns, taut_instredit.js will take care of it (aka problem for later) For UI concerns, taut_instredit.js will take care of it (aka problem for later)
[x] .sf2 import module (for generic use, including "Import instrument from soundfont" and midi2taud conversion) [x] .sf2 import module (for generic use, including "Import instrument from soundfont" and midi2taud conversion)
[x] Midi2Taud using .mid and .sf2 as input, trim unused samples and Ixmp patches [x] Midi2Taud using .mid and .sf2 as input, trim unused samples and Ixmp patches
[x] .sf2 specific resample handling [x] .sf2 specific resample handling
1. If length exceeds 65535 samples, calculate resampling. 1. If length exceeds 65535 samples, calculate resampling.
2. If calculated resampling >= 32000, use that. 2. If calculated resampling >= 32000, use that.
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] Add option to force this behaviour for samples with loops that are far beyond the 64k limit (e.g. Timbres of Heaven)
* 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-15. Added `--force-synth-loop`
[x] SF2 filter still sounds way too muffled? [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] Drum notes get eaten (E2M1.mid) * 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. Root cause: midi2taud ignored SF2 exclusiveClass (gen 57). SGM's [x] SF2 filter still sounds way too muffled?
STANDARD 1 puts the closed/pedal/open hi-hats (keys 42/44/46) in class 1; without the [x] Drum notes get eaten (E2M1.mid)
choke the open hi-hat's multi-second tail (≈4.9 s) washes over the whole beat and * DONE 2026-06-14. Root cause: midi2taud ignored SF2 exclusiveClass (gen 57). SGM's
buries the other hits. FluidSynth kills same-class voices on note-on STANDARD 1 puts the closed/pedal/open hi-hats (keys 42/44/46) in class 1; without the
(fluid_synth_kill_by_exclusive_class → fluid_voice_kill_excl, a fast 2000-timecent choke the open hi-hat's multi-second tail (≈4.9 s) washes over the whole beat and
release ≈ 0.3 s). Fix in two parts: (a) midi2taud.py parses gen 57, and per drum buries the other hits. FluidSynth kills same-class voices on note-on
channel chokes each note at the next same-class onset — emitting the new fast-fade (fluid_synth_kill_by_exclusive_class → fluid_voice_kill_excl, a fast 2000-timecent
note 0x0004 (apply_exclusive_class + emit_cells pass 2b; allocate_voices holds the release ≈ 0.3 s). Fix in two parts: (a) midi2taud.py parses gen 57, and per drum
choked voice foreground until the choke); (b) the engine implements note 0x0004 channel chokes each note at the next same-class onset — emitting the new fast-fade
(startFastFade): a ~0.3 s note-fade while the sample keeps playing, distinct from note 0x0004 (apply_exclusive_class + emit_cells pass 2b; allocate_voices holds the
^^CUT's hard stop. Headless coverage: devtests/ixmp/FastFadeTest. Verified on choked voice foreground until the choke); (b) the engine implements note 0x0004
E2M1 (open hat no longer rings >0.3 s; crashes, not in a class, still ring out). (startFastFade): a ~0.3 s note-fade while the sample keeps playing, distinct from
[ ] midi2taud: toggleable option for disabling filter for percussions [default: on] ^^CUT's hard stop. Headless coverage: devtests/ixmp/FastFadeTest. Verified on
- Anything on bank 127 and 128 (usually asso siated with ch 10) E2M1 (open hat no longer rings >0.3 s; crashes, not in a class, still ring out).
- GeneralMIDI instruments 113..128 [x] midi2taud: instrument fadeout (release) is significantly longer than Fluidsynth
[x] midi2taud: instrument fadeout (release) is significantly longer than Fluidsynth * DONE 2026-06-14. _zone_fadeout (midi2taud.py) now scales fade_sec by
* DONE 2026-06-14. _zone_fadeout (midi2taud.py) now scales fade_sec by _RELEASE_PERCEPTUAL_SCALE = 0.25, bringing the fadeout in line with FluidSynth's perceived
_RELEASE_PERCEPTUAL_SCALE = 0.25, bringing the fadeout in line with FluidSynth's perceived release. fadeStep comes out ~4× larger (faster fade). I kept the IT/FT2 engine path untouched
release. fadeStep comes out ~4× larger (faster fade). I kept the IT/FT2 engine path untouched — it's shared, byte-faithful tracker behaviour and must stay linear-amplitude; the
— it's shared, byte-faithful tracker behaviour and must stay linear-amplitude; the compensation belongs on the encoder side. 0.25 targets the ~22 dB "release ended" point.
compensation belongs on the encoder side. 0.25 targets the ~22 dB "release ended" point. If you find sustained pads now cut a touch short (their long tails are more noticeable),
If you find sustained pads now cut a touch short (their long tails are more noticeable), nudging it toward 0.300.35 lengthens the tail without returning to the old over-long
nudging it toward 0.300.35 lengthens the tail without returning to the old over-long behaviour.
behaviour. [x] auto-set optimal-ish Tickspeed and RPB using MIDI Time Signature events and note analysis. Break pattern when Time Signature changes.
[x] auto-set optimal-ish Tickspeed and RPB using MIDI Time Signature events and note analysis. Break pattern when Time Signature changes. * DONE 2026-06-14. midi2taud.py now parses the time-signature meta event (FF 58) and
* DONE 2026-06-14. midi2taud.py now parses the time-signature meta event (FF 58) and auto-sets the grid by DEFAULT (--rpb/--speed default to auto; passing one pins that
auto-sets the grid by DEFAULT (--rpb/--speed default to auto; passing one pins that axis and auto-fits the other, pass both to override). auto_timing() picks F = rpb·speed
axis and auto-fits the other, pass both to override). auto_timing() picks F = rpb·speed fine-ticks/beat to (a) represent the finest onset subdivision actually used
fine-ticks/beat to (a) represent the finest onset subdivision actually used (_detect_subdivision: smallest 1/D-of-quarter grid covering >=95% of note onsets,
(_detect_subdivision: smallest 1/D-of-quarter grid covering >=95% of note onsets, D in {1,2,3,4,6,8,12,16}), (b) keep every tempo inside the Taud BPM register [25,280],
D in {1,2,3,4,6,8,12,16}), (b) keep every tempo inside the Taud BPM register [25,280], and (c) anchor at the proven 24-fts/beat grid (smallest multiple of D that is >=24), so
and (c) anchor at the proven 24-fts/beat grid (smallest multiple of D that is >=24), so plain material reproduces the old speed 6 / rpb 4. Lexicographic key: init-tempo-fits >
plain material reproduces the old speed 6 / rpb 4. Lexicographic key: init-tempo-fits > fewest-clamped-tempos > prefer-rpb-4 (rows = beats×rpb, so rpb caps pattern count;
fewest-clamped-tempos > prefer-rpb-4 (rows = beats×rpb, so rpb caps pattern count; speed is "free" sub-row + tempo precision) > closeness-to-F_want > exact-grid > near
speed is "free" sub-row + tempo precision) > closeness-to-F_want > exact-grid > near speed 6. Verified rpb4/speed6 on Onestop / E1M1 / E2M1 / flourish / keep_on_rolling /
speed 6. Verified rpb4/speed6 on Onestop / E1M1 / E2M1 / flourish / keep_on_rolling / pokemon-theme. RPB bump (final step, both axes auto only): a bend-heavy (>=24 non-centre
pokemon-theme. RPB bump (final step, both axes auto only): a bend-heavy (>=24 non-centre bend events AND >=0.25/note) OR many-polyphony (peak simultaneous notes >= 10) song with
bend events AND >=0.25/note) OR many-polyphony (peak simultaneous notes >= 10) song with rpb < 8 gets rpb doubled / speed halved (F=rpb·speed, hence tempo, UNCHANGED) up to rpb 8,
rpb < 8 gets rpb doubled / speed halved (F=rpb·speed, hence tempo, UNCHANGED) up to rpb 8, so more rows host key-offs / chokes / bend-G / channel-M and fewer are eaten by same-row &
so more rows host key-offs / chokes / bend-G / channel-M and fewer are eaten by same-row & per-cell-slot collisions. Guarded by a cue/pattern-budget estimate so it can't flip a long
per-cell-slot collisions. Guarded by a cue/pattern-budget estimate so it can't flip a long dense song into a hard error (pin --rpb 4 to opt out). Measured on Onestop: key-offs
dense song into a hard error (pin --rpb 4 to opt out). Measured on Onestop: key-offs absorbed 1856→1574, bend segments 266→552. All six demo MIDIs are dense → rpb8/speed3.
absorbed 1856→1574, bend segments 266→552. All six demo MIDIs are dense → rpb8/speed3. Pattern breaking: plan_cues() breaks a cue at every time-sig change and
Pattern breaking: plan_cues() breaks a cue at every time-sig change and packs each section into whole-bar cues (largest multiple of the bar length that fits in
packs each section into whole-bar cues (largest multiple of the bar length that fits in 64 rows) via the LEN cue instruction (constant 4/4 still = 64-row cues; 7/8 flourish =
64 rows) via the LEN cue instruction (constant 4/4 still = 64-row cues; 7/8 flourish = 56-row cues; mid-song change starts the new section on a fresh cue). The project-data
56-row cues; mid-song change starts the new section on a fresh cue). The project-data sMet block gets Primary beat division = rows per NOTATED beat (the time-sig denominator,
sMet block gets Primary beat division = rows per NOTATED beat (the time-sig denominator, = round(rpb·4/2^dpow): 4 for x/4, 2 for x/8 — always a divisor of the bar so the highlight
= round(rpb·4/2^dpow): 4 for x/4, 2 for x/8 — always a divisor of the bar so the highlight stays bar-aligned; rpb=rows-per-quarter would drift on 7/8 since 14%4!=0) and Secondary =
stays bar-aligned; rpb=rows-per-quarter would drift on 7/8 since 14%4!=0) and Secondary = rows per bar, so the tracker's bar/beat highlight lines up. sMet notation = 120 (12-TET;
rows per bar, so the tracker's bar/beat highlight lines up. sMet notation = 120 (12-TET; MIDI is 12-TET) — REQUIRED now that taut.js honours notation on load (the taud_common
MIDI is 12-TET) — REQUIRED now that taut.js honours notation on load (the taud_common default 0 = "Raw format" would show hex note numbers). build_pattern_bin now takes
default 0 = "Raw format" would show hex note numbers). build_pattern_bin now takes (cue_starts, cue_lens) instead of a flat 64-row chunking.
(cue_starts, cue_lens) instead of a flat 64-row chunking. taut.js side: loadTaudSongList now reads beat_pri/beat_sec from sMet (were skipped) into
taut.js side: loadTaudSongList now reads beat_pri/beat_sec from sMet (were skipped) into songsMeta.songs[i]; applySongBeatDiv() + applySongPitchPreset() apply per-song beat
songsMeta.songs[i]; applySongBeatDiv() + applySongPitchPreset() apply per-song beat divisions AND notation/pitch-preset on BOTH initial open and switchSong (initial open
divisions AND notation/pitch-preset on BOTH initial open and switchSong (initial open previously left both at the 4/16 + 12-TET defaults — the latent pitch-preset gap is now
previously left both at the 4/16 + 12-TET defaults — the latent pitch-preset gap is now closed too). Only midi2taud emits sMet; the other 2taud converters omit it → 12-TET default.
closed too). Only midi2taud emits sMet; the other 2taud converters omit it → 12-TET default. [x] Taut UI commit
[x] Taut UI commit - Inst > Gen.1 > sample binding: ~~~....[two doubledots] et al. (n extra samples)
- Inst > Gen.1 > sample binding: ~~~....[two doubledots] et al. (n extra samples) - Inst > Gen.2 > filter: IT/SF mode toggle (which also need to redefine slider range and their writebacks as IT takes 8-bit and SF takes 16-bit values)
- Inst > Gen.2 > filter: IT/SF mode toggle (which also need to redefine slider range and their writebacks as IT takes 8-bit and SF takes 16-bit values) - 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 [x] implement note-fade (0x0003) and wire it to it2taud
[x] implement note-fade (0x0003) and wire it to it2taud * DONE 2026-06-14. Engine: note word 0x0003 sets voice.noteFading (IT CHN_NOTEFADE,
* DONE 2026-06-14. Engine: note word 0x0003 sets voice.noteFading (IT CHN_NOTEFADE, Schism effects.c:1505-1509) — the instrument's own activeFadeoutStep drives
Schism effects.c:1505-1509) — the instrument's own activeFadeoutStep drives fadeoutVolume to 0 in the line ~3676 fade path while sustain loop + vol envelope
fadeoutVolume to 0 in the line ~3676 fade path while sustain loop + vol envelope keep running; no applyKeyLift (sustain kept), no rate override (unlike fast fade
keep running; no applyKeyLift (sustain kept), no rate override (unlike fast fade 0x0004); a zero fadeout rings on, matching IT. Honours sub-row S$Dx delay (delayed
0x0004); a zero fadeout rings on, matching IT. Honours sub-row S$Dx delay (delayed dispatch L3453). it2taud: IT_NOTE_FADE (246) now emits NOTE_NOTEFADE (0x0003)
dispatch L3453). it2taud: IT_NOTE_FADE (246) now emits NOTE_NOTEFADE (0x0003) instead of collapsing to key-off; NOTE_NOTEFADE added to taud_common.py.
instead of collapsing to key-off; NOTE_NOTEFADE added to taud_common.py. [x] taut.js quit with TypeError: Cannot read property 'terminatorIdx' of undefined:
[x] taut.js quit with TypeError: Cannot read property 'terminatorIdx' of undefined: TypeError: Cannot read property 'terminatorIdx' of undefined
TypeError: Cannot read property 'terminatorIdx' of undefined at drawEnvelopeCursor (<eval>:5312:22)
at drawEnvelopeCursor (<eval>:5312:22) at drawInstrumentsContents (<eval>:4919:41)
at drawInstrumentsContents (<eval>:4919:41) at Object.onClick (<eval>:4984:17)
at Object.onClick (<eval>:4984:17) at dispatchMouseEvent (<eval>:6275:53)
at dispatchMouseEvent (<eval>:6275:53) at <eval>:6633:13
at <eval>:6633:13 at Object.withEvent (<eval>:1168:27)
at Object.withEvent (<eval>:1168:27) at _tvdosExec$microtone$tvdos$bin$taut$js (<eval>:6632:11)
at _tvdosExec$microtone$tvdos$bin$taut$js (<eval>:6632:11) at execApp (<eval>:1457:16)
at execApp (<eval>:1457:16) at Object.execute (<eval>:893:38)
at Object.execute (<eval>:893:38) at <eval>:867:31
at <eval>:867:31 [x] Timbres of Heaven's Overdriven Gt decays EXTREMELY SLOWLY even after the fix
[x] Timbres of Heaven's Overdriven Gt decays EXTREMELY SLOWLY even after the fix * DONE 2026-06-15. Root cause (engine). Overdriven Gt becomes a 4-layer Metainstrument.
* DONE 2026-06-15. Root cause (engine). Overdriven Gt becomes a 4-layer Metainstrument. Layer children inherit the parent's key-off only via the per-tick background-voice sync
Layer children inherit the parent's key-off only via the per-tick background-voice sync (AudioAdapter.kt:3782), which requires parent.active. With a fast fadeout (fo=1067 = 1-tick
(AudioAdapter.kt:3782), which requires parent.active. With a fast fadeout (fo=1067 = 1-tick cut), the foreground voice deactivates in the same tick KEY_OFF fires — before the sync runs —
cut), the foreground voice deactivates in the same tick KEY_OFF fires — before the sync runs — so the children were detached as orphans that never picked up the release and kept looping at
so the children were detached as orphans that never picked up the release and kept looping at the ~0.70 sustain until the next note. That's the "extremely slow decay"/ringing tail, and it's
the ~0.70 sustain until the next note. That's the "extremely slow decay"/ringing tail, and it's ToH-specific because ToH leans on multi-layer presets with short releases.
ToH-specific because ToH leans on multi-layer presets with short releases. Fix (AudioAdapter.kt, inactive-parent branch): inherit parent.keyOff/noteFading before
Fix (AudioAdapter.kt, inactive-parent branch): inherit parent.keyOff/noteFading before detaching, mirroring the existing active-branch. parent.keyOff survives deactivation and is
detaching, mirroring the existing active-branch. parent.keyOff survives deactivation and is reset on retrigger, so a true value means this note was released. A parent that ended naturally
reset on retrigger, so a true value means this note was released. A parent that ended naturally (no release) still leaves the child to finish on its own.
(no release) still leaves the child to finish on its own. [ ] Some ways to decouple Sample+Inst and patterns into separate files (tsvm-doom needs separate file access; samplepack can be uploaded once on init)
[ ] Some ways to decouple Sample+Inst and patterns into separate files (tsvm-doom needs separate file access; samplepack can be uploaded once on init)
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