From cd04138573f1cb7092a90076fc5adfb9244570c1 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Mon, 15 Jun 2026 02:35:42 +0900 Subject: [PATCH] midi2taud: --force-synth-loop --- midi2taud.py | 58 ++++++++++---- terranmon.txt | 211 +++++++++++++++++++++++++------------------------- 2 files changed, 149 insertions(+), 120 deletions(-) diff --git a/midi2taud.py b/midi2taud.py index a27be64..78b604b 100644 --- a/midi2taud.py +++ b/midi2taud.py @@ -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, - fadeout_override, bpm0: int): + fadeout_override, bpm0: int, force_synth_loop: bool = False): """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 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: 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.ratio *= len(ms.data) / native_len - 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: resample to the 32 kHz floor (full bandwidth), keep the first - # 65535 frames and synthesize a near-seamless sustain loop near the end, plus - # 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). + def _synth_path(): + """(3) resample to the 32 kHz floor (full bandwidth), keep the first 65535 + frames and synthesize a near-seamless sustain loop near the end, plus a + peak->0 decay vol-envelope that fades the looped note to silence from + note-on. Used for unlooped long samples, and (with --force-synth-loop) for + looped samples whose real loop won't fit at the floor. synth_loop takes + precedence over any real loop_native in the record/patch writers.""" resampled = resample_linear(ms.data, r32) ms.ratio *= len(resampled) / native_len # effective rate -> 32 kHz 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.synth_loop = (ls, le) 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; " - 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") elif le32 <= SAMPLE_LEN_LIMIT - 2: # (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] vprint(f" info: '{ms.name}' {native_len} frames > 64K cap, long & looped; " 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: # (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 # 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() 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)") @@ -2432,7 +2454,8 @@ def assemble_taud(sf: SF2, song: Song, layer_insts: list, meta_records: list, # ── Sample + instrument bin ── 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 compressed = compress_blob(sampleinst_raw, "sample+inst bin") comp_size = len(compressed) @@ -2569,6 +2592,13 @@ def main(): ap.add_argument('--drum-keyoff', action='store_true', help='Emit KEY_OFF for percussion-channel notes too ' '(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', help='Omit the Project Data section — NOTE: this also ' 'omits Ixmp, collapsing every instrument to its ' diff --git a/terranmon.txt b/terranmon.txt index 23280ff..49da11b 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2775,112 +2775,111 @@ TODO: [x] Ixmp version 2, supporting per-patch ADSR 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] Midi2Taud using .mid and .sf2 as input, trim unused samples and Ixmp patches - [x] .sf2 specific resample handling - 1. If length exceeds 65535 samples, calculate resampling. - 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. - [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. - [x] SF2 filter still sounds way too muffled? - [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 - [x] midi2taud: instrument fadeout (release) is significantly longer than Fluidsynth - * 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. 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 - 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), - nudging it toward 0.30–0.35 lengthens the tail without returning to the old over-long - behaviour. - [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 - 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 - 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, - 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 - 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; - 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 / - 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 - 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 & - 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 - 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 - 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 = - 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, - = 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 = - 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 - 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. - taut.js side: loadTaudSongList now reads beat_pri/beat_sec from sMet (were skipped) into - songsMeta.songs[i]; applySongBeatDiv() + applySongPitchPreset() apply per-song beat - 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 - closed too). Only midi2taud emits sMet; the other 2taud converters omit it → 12-TET default. - [x] Taut UI commit - - 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) - - Samples playblobs: only active for actually playing samples - - Samples playcursor: true cursors for actually playing samples - [x] implement note-fade (0x0003) and wire it to it2taud - * 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 - 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 - 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) - 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: - TypeError: Cannot read property 'terminatorIdx' of undefined - at drawEnvelopeCursor (:5312:22) - at drawInstrumentsContents (:4919:41) - at Object.onClick (:4984:17) - at dispatchMouseEvent (:6275:53) - at :6633:13 - at Object.withEvent (:1168:27) - at _tvdosExec$microtone$tvdos$bin$taut$js (:6632:11) - at execApp (:1457:16) - at Object.execute (:893:38) - at :867:31 - [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. - 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 - 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 - 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. - Fix (AudioAdapter.kt, inactive-parent branch): inherit parent.keyOff/noteFading before - 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 - (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) + [x] Midi2Taud using .mid and .sf2 as input, trim unused samples and Ixmp patches + [x] .sf2 specific resample handling + 1. If length exceeds 65535 samples, calculate resampling. + 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. + [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-15. Added `--force-synth-loop` + [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. + [x] SF2 filter still sounds way too muffled? + [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). + [x] midi2taud: instrument fadeout (release) is significantly longer than Fluidsynth + * 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. 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 + 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), + nudging it toward 0.30–0.35 lengthens the tail without returning to the old over-long + behaviour. + [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 + 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 + 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, + 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 + 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; + 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 / + 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 + 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 & + 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 + 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 + 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 = + 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, + = 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 = + 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 + 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. + taut.js side: loadTaudSongList now reads beat_pri/beat_sec from sMet (were skipped) into + songsMeta.songs[i]; applySongBeatDiv() + applySongPitchPreset() apply per-song beat + 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 + closed too). Only midi2taud emits sMet; the other 2taud converters omit it → 12-TET default. + [x] Taut UI commit + - 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) + - Samples playblobs: only active for actually playing samples + - Samples playcursor: true cursors for actually playing samples + [x] implement note-fade (0x0003) and wire it to it2taud + * 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 + 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 + 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) + 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: + TypeError: Cannot read property 'terminatorIdx' of undefined + at drawEnvelopeCursor (:5312:22) + at drawInstrumentsContents (:4919:41) + at Object.onClick (:4984:17) + at dispatchMouseEvent (:6275:53) + at :6633:13 + at Object.withEvent (:1168:27) + at _tvdosExec$microtone$tvdos$bin$taut$js (:6632:11) + at execApp (:1457:16) + at Object.execute (:893:38) + at :867:31 + [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. + 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 + 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 + 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. + Fix (AudioAdapter.kt, inactive-parent branch): inherit parent.keyOff/noteFading before + 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 + (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) TODO - list of demo songs that MUST ship with Microtone: * 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes