From e64e335db38e59faf3ab255eae7351dac18f0335 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Wed, 6 May 2026 17:10:13 +0900 Subject: [PATCH] resolving envelope ambiguity --- it2taud.py | 18 ++-- mod2taud.py | 6 +- mon2taud.py | 6 +- s3m2taud.py | 7 +- terranmon.txt | 88 +++++++++++++------ .../torvald/tsvm/peripheral/AudioAdapter.kt | 29 ++++-- xm2taud.py | 14 ++- 7 files changed, 119 insertions(+), 49 deletions(-) diff --git a/it2taud.py b/it2taud.py index f8b0b30..4b0c123 100644 --- a/it2taud.py +++ b/it2taud.py @@ -571,9 +571,10 @@ def _parse_it_envelope(data: bytes, env_ptr: int, kind: str, 'filter' — IT -32..+32 → Taud 0..255 (0x80 = unity cutoff) Word layout (terranmon.txt:2049+ / 2114+): - LOOP word: 0b 0000_0sss_ssXcb_eeeee (X = 'p'/'m' for pan/pf, 0 for vol) + LOOP word: 0b 00P0_0sss_ssXcb_eeeee (X = 'p'/'m' for pan/pf, 0 for vol) SUSTAIN word: 0b 0000_0sss_ss00b_eeeee bits 12..8 = start index, bits 4..0 = end index + bit 13 = P (envelope present; gates pan/pf evaluation in the engine) bit 7 = p (pan: use default pan) / m (pf: pitch=0/filter=1) / 0 (vol) bit 6 = c (envelope carry — placed in the LOOP word) bit 5 = b (enable that region) @@ -638,7 +639,10 @@ def _parse_it_envelope(data: bytes, env_ptr: int, kind: str, # directly. Bits: 5=b enable, 6=c carry, 7=p (pan default-pan flag) / # m (pf filter mode); 12..8=start, 4..0=end. SUSTAIN word never carries # c/p/m — those live in the LOOP word. - loop_word = 0 + # P (bit 13) marks the envelope as present in source, regardless of LOOP/ + # SUSTAIN enable. We reach this point only when the IT envelope flag bit 0 + # is set (handled at function top), so P is unconditionally set here. + loop_word = 0x2000 # P: envelope present if has_env_loop and 0 <= it_lpb < 25 and 0 <= it_lpe < 25: loop_word |= 0x0020 # b: enable LOOP loop_word |= (it_lpb & 0x1F) << 8 @@ -1133,7 +1137,8 @@ def build_sample_inst_bin_it(samples_or_proxy: list, # 256-byte instrument layout (terranmon.txt:2001+). INST_STRIDE = 256 - USE_ENV_BIT = 0x0020 # b — set whenever the engine should evaluate the envelope + USE_ENV_BIT = 0x0020 # b — LOOP wrap enable (legacy; engine still honours) + ENV_PRESENT_BIT = 0x2000 # P — envelope present in source (terranmon.txt byte 16/18/20 bit 5) def _write_env(buf: bytearray, base: int, env_pts): """Write 25 (value, minifloat) pairs starting at `buf[base]`. Pads @@ -1200,7 +1205,8 @@ def build_sample_inst_bin_it(samples_or_proxy: list, # When the source has neither loop nor sustain on the volume envelope # the engine still needs the b flag so the single-point unit envelope # is evaluated — synthesise USE_ENV_BIT into the LOOP word as a fallback. - vol_env_loop = idata.get('vol_env_loop', USE_ENV_BIT) + # The P bit is informational for vol but set for consistency. + vol_env_loop = idata.get('vol_env_loop', USE_ENV_BIT | ENV_PRESENT_BIT) vol_env_sus = idata.get('vol_env_sus', 0) pan_env_loop = idata.get('pan_env_loop', 0) pan_env_sus = idata.get('pan_env_sus', 0) @@ -1236,8 +1242,10 @@ def build_sample_inst_bin_it(samples_or_proxy: list, inst_bin[base + 22] = 0 # Force engine to use this single point — set the b bit on the LOOP # word so the envelope is evaluated even though no wrap region exists. + # P is also set for consistency (vol-env presence is informational + # but converters mark it whenever they emit any node data). cur_loop = struct.unpack_from(' tuple: # IGV (byte 171) so the envelope must contribute a unit multiplier. env_vol = 63 # MOD has no envelopes; vol LOOP word b=1 just so the engine evaluates - # the unit envelope. Pan/PF stay disabled. - vol_env_loop = 0x0020 # b enable + # the unit envelope, plus P=1 (envelope present) for consistency with + # the new gate spec (terranmon.txt byte 16/18/20 bit 5). Pan/PF stay + # fully zero — the engine sees P=0 there and skips them. + vol_env_loop = 0x2020 # P (bit 13) | b (bit 5) base = taud_idx * INST_STRIDE struct.pack_into(' bytes: struct.pack_into(' tuple: # Volume envelope first point is full-scale; per-sample level is carried # by IGV (byte 171) so the envelope contributes a unit multiplier. env_vol = 63 - # Vol LOOP word: only b=1 (use envelope) — no actual loop / sustain. - vol_env_loop = 0x0020 + # Vol LOOP word: P=1 (envelope present) | b=1 (use envelope) — no actual + # loop / sustain. P added 2026-05-06 alongside the pan/pf gate spec + # change (terranmon.txt byte 16/18/20 bit 5); informational for vol but + # set for consistency. Pan/PF stay zero so the engine sees P=0 there. + vol_env_loop = 0x2020 base = taud_idx * INST_STRIDE struct.pack_into('end in the LOOP word) that the engine - recognises as evaluate-but-don't-wrap. - Until decided, IT pan/pf envelopes with flags=0x01 will not animate - between rows. Workaround: enable IT's envelope loop or sustain bit - in source so the converter sets the LOOP/SUSTAIN b bit. + [x] Same gate fix needed for pan and pitch/filter envelopes. + Resolution (2026-05-06): added P (envelope present) bit at LOOP-word + bit 13 (offsets 16/18/20 bit 5) for all three envelopes. Engine + gates pan/pf envelope evaluation on P alone; converters set P=1 + whenever they emit envelope nodes, regardless of LOOP/SUSTAIN + enable, so an enabled-no-wrap envelope (IT pan-env flag=0x01) + animates correctly. Mixer's hasPanEnv/hasPfEnv read the same gate, + so absent envelopes still bypass envelope-driven output. Pre- + 2026-05-06 .taud files predate the P bit and need re-conversion + for pan/pf envelopes to play. See byte 15/17/19 spec for the LOOP + word bit layout. [ ] implement extended tone mode (MONOTONE compat) [ ] pattern loops stops working after processed once (test with slumberjack.xm) [ ] milkytracker-style volume ramping (on sample-end only) diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index ff5a041..ac8233d 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -1241,6 +1241,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } } + // Envelope-present test (terranmon.txt byte 15/17/19, P bit at LOOP word bit 13). + // The P bit is the sole presence signal — converters set it whenever they emit + // envelope nodes. Pre-2026-05-06 .taud files without P will not have pan/pf + // envelopes evaluated; re-convert from source. + private inline fun envPresent(loopWord: Int): Boolean = ((loopWord ushr 13) and 1) != 0 + // Reusable per-envelope wrap-range scratch (avoid per-tick allocation). private val volWrap = IntArray(2) private val panWrap = IntArray(2) @@ -1305,10 +1311,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } } - // Pan envelope (only when active for this instrument) + // Pan envelope. Presence is decided once per trigger and stored on the voice + // (voice.hasPanEnv is keyed on LOOP.P — see triggerNote). Like the volume + // envelope above, evaluation is no longer gated by the wrap-enable bits: an + // envelope marked "present but no wrap" still walks forward, matching the IT + // idiom (pan-env flag=0x01) and Schism player/sndmix.c:470-502. if (!voice.hasPanEnv || !voice.panEnvOn) return - val pEnvActive = (((inst.panEnvLoop ushr 5) and 1) or ((inst.panEnvSustainWord ushr 5) and 1)) != 0 - if (!pEnvActive) return resolveEnvWrap(inst.panEnvLoop, inst.panEnvSustainWord, voice.keyOff, panWrap) val pStart = panWrap[0] val pEnd = panWrap[1] @@ -1348,10 +1356,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { * as advanceEnvelope. Result is stored in `voice.envPfValue` (0.0..1.0; 0.5 = unity). */ private fun advancePfEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) { + // Same gate semantics as the pan envelope above: presence (voice.hasPfEnv) is + // latched at trigger time from LOOP.P; evaluation is unconditional once + // present, so an enabled-no-wrap envelope animates. if (!voice.hasPfEnv || !voice.pfEnvOn) return val maxIdx = 24 - val pEnvActive = (((inst.pfEnvLoop ushr 5) and 1) or ((inst.pfEnvSustainWord ushr 5) and 1)) != 0 - if (!pEnvActive) return resolveEnvWrap(inst.pfEnvLoop, inst.pfEnvSustainWord, voice.keyOff, pfWrap) val pSusStart = pfWrap[0] val pSusEnd = pfWrap[1] @@ -1623,10 +1632,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.envPanIndex = 0 voice.envPanTimeSec = 0.0 voice.envPan = inst.panEnvelopes[0].value / 255.0 - // Pan envelope is active when EITHER the LOOP word's b bit OR the SUSTAIN word's b bit is set. - voice.hasPanEnv = (((inst.panEnvLoop ushr 5) and 1) or ((inst.panEnvSustainWord ushr 5) and 1)) != 0 + // Envelope-present gate (added 2026-05-06). Driven by the P bit at LOOP-word + // bit 13 (high byte's bit 5; offsets 16/18/20 bit 5), set by converters + // whenever they emit envelope nodes. See terranmon.txt at byte 15/17/19 for + // the bit layout and the file-header preamble for the presence-vs-wrap + // distinction. + voice.hasPanEnv = envPresent(inst.panEnvLoop) // Pitch/filter envelope state. - voice.hasPfEnv = (((inst.pfEnvLoop ushr 5) and 1) or ((inst.pfEnvSustainWord ushr 5) and 1)) != 0 + voice.hasPfEnv = envPresent(inst.pfEnvLoop) // The pf 'm' mode bit (pitch=0, filter=1) lives in the LOOP word at bit 7. voice.envPfIsFilter = (inst.pfEnvLoop ushr 7) and 1 != 0 voice.envPfIndex = 0 diff --git a/xm2taud.py b/xm2taud.py index 3dd2537..26df9f5 100644 --- a/xm2taud.py +++ b/xm2taud.py @@ -819,7 +819,11 @@ def _xm_envelope_to_taud(env_pts: list, num_pts: int, env_type: int, # LOOP word (offsets 15/17/19): b=enable, bits 12..8=start, 4..0=end. # SUSTAIN word (offsets 189/191/193): same bit layout; FT2 single-point # sustain is encoded with start == end (engine wraps that index → itself). - loop_word = 0x0020 # b: use envelope (vol always; even with no loop the engine evaluates it) + # P (bit 13) marks the envelope as present in source — this branch is only + # reached when XM_ENV_ON is set, so P is unconditionally 1 here. P gates + # whether the engine evaluates pan envelope at all (terranmon.txt byte + # 16/18/20 bit 5); for vol it is informational. + loop_word = 0x2020 # P (bit 13) | b (bit 5) if has_loop: loop_word |= (loop_start & 0x1F) << 8 loop_word |= (loop_end & 0x1F) @@ -931,7 +935,8 @@ def build_sample_inst_bin_xm(proxies: list) -> tuple: s.loop_end = min(s.loop_end, n) pos += n - USE_ENV_BIT = 0x0020 # b: engine should evaluate the envelope + USE_ENV_BIT = 0x0020 # b: engine should evaluate the envelope (LOOP wrap enable) + ENV_PRESENT_BIT = 0x2000 # P: envelope present in source (terranmon.txt byte 16/18/20 bit 5) INST_STRIDE = 256 def _write_env(buf: bytearray, base: int, env_pts, pad_value: int) -> None: @@ -962,13 +967,14 @@ def build_sample_inst_bin_xm(proxies: list) -> tuple: # Resolve envelope LOOP / SUSTAIN words from the proxy. When XM has no # envelope, fall back to a single-point unit envelope (vol LOOP word - # b=1 only) and rely on IGV for level. + # b=1 plus P=1 for consistency) and rely on IGV for level. Pan stays + # zero so the engine sees P=0 there and skips envelope-driven pan. if s.vol_env_pts is not None: vol_env_loop = s.vol_env_loop_word vol_env_sus = s.vol_env_sus_word vol_env = s.vol_env_pts else: - vol_env_loop = USE_ENV_BIT + vol_env_loop = USE_ENV_BIT | ENV_PRESENT_BIT vol_env_sus = 0 vol_env = None if s.pan_env_pts is not None: