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,
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 '

View File

@@ -2780,6 +2780,8 @@ TODO:
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·(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?
@@ -2796,9 +2798,6 @@ TODO:
(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