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

@@ -2780,6 +2780,8 @@ TODO:
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] 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) [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. * 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? [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 (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 ^^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). 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 [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