XM gating behaviour with no volenv and key-off (converter manages it)

This commit is contained in:
minjaesong
2026-05-08 02:52:32 +09:00
parent 3182ae9146
commit e49140902b
2 changed files with 91 additions and 4 deletions

View File

@@ -2347,7 +2347,9 @@ TODO:
replacing the full-panel redraw on every keystroke.
[x] volume and panning policy to match note effect policy: when note is "retriggerred" (note command with instrument specified), the volume/pan must take default value; if not (note command with instrument 0) the volume/pan must stay at the old value. Make both audio engine and taut.js simulator changes.
[ ] xm volume column commands (+x, -x, Dx, Lx, Mx, Px, Rx, Sx, Ux, Vx) are completely ignored
[ ] theday.xm order 0x28, channel 6..8 has 'note trigger with inst 1 but no volume -> key-off -> set-volume to 0x20 -> key-off -> set-volume to 0x10 -> key-off -> ...' and it sounds like gating: key-off silences the output, set-volume turns on the output again; notably, this behaviour only works when volume envelope is turned off (any fadeouts progress normally). What I want to know before implementing this feature is that would the way it works on XM conflicts with Taud or ImpulseTracker's behaviour
[x] theday.xm order 0x28, channel 6..8 has 'note trigger with inst 1 but no volume -> key-off -> set-volume to 0x20 -> key-off -> set-volume to 0x10 -> key-off -> ...' and it sounds like gating: key-off silences the output, set-volume turns on the output again; notably, this behaviour only works when volume envelope is turned off (any fadeouts progress normally). FT2's keyOff (ft2_replayer.c:411-435) zeroes realVol/outVol when the volume envelope is disabled — IT/Schism does not, and Taud's engine follows IT semantics (no fade when fadeStep == 0). Resolved in xm2taud.py: a pre-pass tracks per-channel bound XM instrument across the order-list walk, and any key-off cell whose bound instrument has vol_env_type & XM_ENV_ON == 0 is paired with `SEL_SET vol=0` in the same row. A subsequent vol-col SET on the channel restores audibility — exactly mirroring FT2's outVol/realVol gate without diverging the engine. Engine semantics stay IT-pure.
[ ] remove panning mode selection and replace global panning rule to 3 dB rule (not the equal energy)
[ ] FT2/MOD double effects (5xx, 6xx) missing volume column -> easiest solution: fully implement `L xy00` and `K xy00` and map 5xx to L, 6xx to K (xm2taud, mod2taud), Kxy and Lxy verbatim (s3m2taud.py, it2taud.py)
Play Data: play data are series of tracker-like instructions, visualised as:

View File

@@ -696,6 +696,58 @@ def remap_b_effects_xm(chunks: list, chunk_map: list,
row.effect_arg = taud_cue & 0xFF
def compute_keyoff_zero_marks_xm(taud_cue_list: list, chunks: list,
num_xm_channels: int, instruments: list,
active_channels: list) -> dict:
"""Identify key-off cells whose bound XM instrument has the volume envelope
DISABLED. FT2's keyOff() (ft2_replayer.c:411-435) zeroes realVol/outVol on
such key-offs; IT/Schism does not, and the Taud engine follows IT semantics.
To preserve XM gating without diverging engine behaviour, the converter pairs
each flagged key-off with `SEL_SET vol=0` in the same row's volume column —
a later vol-col SET on the channel restores audibility, exactly mirroring
the FT2 outVol/realVol path.
Walks taud_cue_list in playback order so per-channel instrument bindings
carry across cues. When the same chunk is visited under conflicting
bindings, the union of all flags is kept (conservatively prefers gating).
Returns: dict mapping chunk_idx → set of (active_voice_idx, row_idx) tuples.
The voice_idx matches build_pattern_xm's `ch_idx` (the index into
`active_channels`).
"""
xm_to_vi = {ch: vi for vi, ch in enumerate(active_channels)}
marks = {}
bound = [0] * num_xm_channels # 1-based XM instrument id; 0 = none
for ci in taud_cue_list:
cg = chunks[ci]
chunk_marks = marks.setdefault(ci, set())
max_ch = min(num_xm_channels, len(cg))
max_rows = max((len(cg[ch]) for ch in range(max_ch)), default=0)
for r in range(max_rows):
for xm_ch in range(max_ch):
if r >= len(cg[xm_ch]):
continue
cell = cg[xm_ch][r]
# FT2 keyOff() reads ch->instrPtr — the latest binding wins, even
# when the inst byte is on the same row as the key-off.
if cell.inst > 0:
bound[xm_ch] = cell.inst
is_keyoff = (cell.note == XM_NOTE_OFF) or (cell.effect == 0x14)
if not is_keyoff:
continue
ii = bound[xm_ch]
if ii == 0 or ii - 1 >= len(instruments):
continue
inst = instruments[ii - 1]
if inst.vol_env_type & XM_ENV_ON:
continue
vi = xm_to_vi.get(xm_ch)
if vi is not None:
chunk_marks.add((vi, r))
return marks
# ── Sample / instrument bin ───────────────────────────────────────────────────
class _XMSampleProxy:
@@ -999,8 +1051,16 @@ def build_sample_inst_bin_xm(proxies: list) -> tuple:
# ── Pattern bin builder ───────────────────────────────────────────────────────
def build_pattern_xm(chunk_grid: list, ch_idx: int, default_pan: int,
inst_to_taud_slot: dict, amiga_mode: bool = False) -> bytes:
"""Render one Taud channel's 512-byte pattern from a 64-row chunk grid."""
inst_to_taud_slot: dict, amiga_mode: bool = False,
keyoff_zero_rows: set = None) -> bytes:
"""Render one Taud channel's 512-byte pattern from a 64-row chunk grid.
`keyoff_zero_rows`: optional set of row indices on this channel whose key-off
cells should be paired with `SEL_SET vol=0` (FT2 vol-env-off gating — see
compute_keyoff_zero_marks_xm).
"""
if keyoff_zero_rows is None:
keyoff_zero_rows = frozenset()
out = bytearray(PATTERN_BYTES)
if ch_idx >= len(chunk_grid):
rows = [XMRow()] * PATTERN_ROWS
@@ -1068,6 +1128,17 @@ def build_pattern_xm(chunk_grid: list, ch_idx: int, default_pan: int,
else:
pan_sel, pan_value = SEL_FINE, 0
# FT2 vol-env-off key-off gating: pair the key-off with SEL_SET vol=0
# so a later vol-col SET on the channel restores audibility (see
# compute_keyoff_zero_marks_xm). Override any vol-col content the row
# already has — FT2 zeros realVol/outVol after vol-col is applied
# (ft2_replayer.c:411-428), so a SET on the same row would be clobbered.
if r in keyoff_zero_rows and note_taud == NOTE_KEYOFF:
if not (vol_sel == SEL_FINE and vol_value == 0):
vprint(f" ch{ch_idx} row{r}: FT2 key-off zero overrides "
f"vol-col (sel={vol_sel}, val={vol_value})")
vol_sel, vol_value = SEL_SET, 0
vol_byte = (vol_value & 0x3F) | ((vol_sel & 0x3) << 6)
pan_byte = (pan_value & 0x3F) | ((pan_sel & 0x3) << 6)
@@ -1187,6 +1258,17 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes:
remap_b_effects_xm(chunks, chunk_map, h.order_list, xm_ord_to_taud_cue, C)
# FT2 vol-env-off key-off gating: pre-compute per-(chunk, voice, row) flags
# for key-off cells whose bound XM instrument has volume envelope disabled.
# build_pattern_xm pairs each flagged key-off with `SEL_SET vol=0` so the
# IT-style Taud engine reproduces FT2's channel-volume zeroing gate.
keyoff_zero_marks = compute_keyoff_zero_marks_xm(
taud_cue_list, chunks, h.channels, instruments, active_channels)
if any(keyoff_zero_marks.values()):
flagged = sum(len(s) for s in keyoff_zero_marks.values())
vprint(f" FT2 keyoff-gate: {flagged} key-off cell(s) paired with vol=0 "
f"(vol-env-off instruments)")
# ── Pattern bin ─────────────────────────────────────────────────────────
total_taud_pats = len(taud_cue_list) * C
if total_taud_pats > NUM_PATTERNS_MAX:
@@ -1202,10 +1284,13 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes:
pat_bin = bytearray()
for ci in taud_cue_list:
cg = chunks[ci]
chunk_marks = keyoff_zero_marks.get(ci, frozenset())
for vi, ch in enumerate(active_channels):
row_marks = {r for (mvi, r) in chunk_marks if mvi == vi}
pat_bin += build_pattern_xm(cg, ch, default_pans[vi],
resolve_inst_slot,
amiga_mode=not h.linear_freq)
amiga_mode=not h.linear_freq,
keyoff_zero_rows=row_marks)
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
orig_count = len(taud_cue_list) * C