resolving envelope ambiguity

This commit is contained in:
minjaesong
2026-05-06 17:10:13 +09:00
parent 0124b062d0
commit e64e335db3
7 changed files with 119 additions and 49 deletions

View File

@@ -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('<H', inst_bin, base + 15)[0]
struct.pack_into('<H', inst_bin, base + 15, cur_loop | USE_ENV_BIT)
struct.pack_into('<H', inst_bin, base + 15, cur_loop | USE_ENV_BIT | ENV_PRESENT_BIT)
if pan_env:
_write_env(inst_bin, base + 71, pan_env)

View File

@@ -521,8 +521,10 @@ def build_sample_inst_bin(samples: list) -> 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('<I', inst_bin, base + 0, ptr)

View File

@@ -207,9 +207,9 @@ def build_sample_inst_bin() -> bytes:
struct.pack_into('<H', inst_bin, base + 10, 0) # loop start
struct.pack_into('<H', inst_bin, base + 12, len(SQUARE_SAMPLE)) # loop end
inst_bin[base + 14] = 0x01 # forward loop
struct.pack_into('<H', inst_bin, base + 15, 0x0020) # vol-env enabled
struct.pack_into('<H', inst_bin, base + 17, 0) # pan-env flags
struct.pack_into('<H', inst_bin, base + 19, 0) # pitch-env flags
struct.pack_into('<H', inst_bin, base + 15, 0x2020) # vol-env: P (bit 13) | b (bit 5)
struct.pack_into('<H', inst_bin, base + 17, 0) # pan-env flags (P=0 → mixer skips)
struct.pack_into('<H', inst_bin, base + 19, 0) # pitch-env flags (P=0 → mixer skips)
inst_bin[base + 21] = 63 # vol env pt 0 = full
inst_bin[base + 22] = 0
inst_bin[base + 171] = 0xA0 # IGV

View File

@@ -499,8 +499,11 @@ def build_sample_inst_bin(instruments: list) -> 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('<I', inst_bin, base + 0, ptr) # u32 sample pointer

View File

@@ -2036,6 +2036,36 @@ The b flag is the SOLE enable bit for each region; the historical 't'
present in this encoding — sustain vs loop is now a structural
distinction (different word at a different offset), not a flag bit.
Envelope PRESENCE — distinct from LOOP/SUSTAIN enable — is signalled by
the `P` bit at LOOP-word bit 13 (the high byte's bit 5; offsets 16/18/20
bit 5). Added 2026-05-06 to disambiguate two cases that the wrap-enable
bits cannot tell apart on their own:
P=0: the source had no envelope of this kind. Engine ignores the
node array entirely and the mixer skips envelope-driven output
for this voice (pan reads from channelPan only, cutoff/pitch
reads from sample defaults only). The 25 node slots may still
be left as default-fill garbage; nothing reads them.
P=1: envelope is defined. Engine evaluates the nodes every tick.
Wrap behaviour is independently controlled by LOOP.b and
SUSTAIN.b — when both are 0 the envelope walks once forward
and holds at its terminator (the IT idiom for envelope-driven
decay tails / shaped attacks).
The P bit was introduced to fix a gating ambiguity for pan and pitch/
filter envelopes: the engine could not distinguish "no envelope at all"
(treat as absent) from "envelope present but neither LOOP nor SUSTAIN
wrap is enabled" (evaluate and apply, just don't wrap). Volume envelope
evaluation has always been unconditional in the engine (a default
single-point envelope at value 63 is harmlessly held at unity), so
P_vol is currently informational only — converters should still set it
when the source defines a volume envelope, for consistency and to
support future per-voice gating.
P is the SOLE presence signal: converters MUST set P=1 whenever they
emit envelope nodes, regardless of whether the source enables LOOP or
SUSTAIN. Pre-2026-05-06 .taud files predate the P bit and will not have
their pan / pf envelopes evaluated by the current engine — re-convert
from source.
0 Uint32 Sample Pointer
4 Uint16 Sample length
6 Uint16 Sampling rate at C4 (note number 0x5000)
@@ -2061,16 +2091,20 @@ distinction (different word at a different offset), not a flag bit.
the engine deactivates the voice (player/sndmix.c:493-498). Without this,
instruments with stored fadeout=0 + envelope ending at 0 would silently
hold their voices forever.
0b 000_sssss_0cb_eeeee
0b 00P_sssss_0cb_eeeee
s (bits 12..8) : loop start index (0..24)
e (bits 4..0) : loop end index (0..24)
b (bit 5) : enable the LOOP wrap (0 = envelope walks once to its
terminator and holds; non-zero loops between s and e)
c (bit 6) : envelope carry (cross-trigger envelope position carry)
(bits 7, 13..15 reserved — set to 0)
P (bit 13) : envelope present in source (informational for vol —
engine evaluates vol env unconditionally; converters
should set P=1 when emitting nodes for consistency
with pan/pf envelopes, see file-header preamble)
(bits 7, 14..15 reserved — set to 0)
17 Bit16 Panning envelope LOOP word
* Always-active wrap region for the pan envelope.
0b 000_sssss_pcb_eeeee
0b 00P_sssss_pcb_eeeee
s (bits 12..8) : loop start index
e (bits 4..0) : loop end index
b (bit 5) : enable the LOOP
@@ -2079,16 +2113,28 @@ distinction (different word at a different offset), not a flag bit.
Independent of LOOP enable; the engine reads this bit
from the LOOP word as the canonical home for envelope-
level meta flags.
(bits 13..15 reserved)
P (bit 13) : envelope present in source. Gates whether the mixer
applies envelope-driven pan at all. P=0 ⇒ mixer uses
channelPan only and the node array is ignored. P=1 ⇒
evaluate every tick, even when both LOOP.b and SUSTAIN.b
are 0 (envelope walks once and holds — IT pan-env
flag=0x01 idiom).
(bits 14..15 reserved)
19 Bit16 Pitch/Filter envelope LOOP word
* Always-active wrap region for the pitch/filter envelope.
0b 000_sssss_mcb_eeeee
0b 00P_sssss_mcb_eeeee
s (bits 12..8) : loop start index
e (bits 4..0) : loop end index
b (bit 5) : enable the LOOP
c (bit 6) : envelope carry
m (bit 7) : mode — 0 = pitch envelope, 1 = filter envelope
(bits 13..15 reserved)
P (bit 13) : envelope present in source. Same semantics as the
pan envelope's P bit: gates whether the mixer applies
envelope-driven pitch / cutoff at all. P=0 ⇒ no
envelope contribution (sample plays at its own pitch /
default cutoff). P=1 ⇒ evaluate every tick regardless
of LOOP.b / SUSTAIN.b.
(bits 14..15 reserved)
21 Bit16x25 Volume envelopes
Byte 1: Volume (00..3F)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
@@ -2270,25 +2316,17 @@ TODO:
skipped because vEnvActive required either b bit. Now evaluation
is gated only by voice.volEnvOn (matches CHN_VOLENV in Schism).
See byte 15 spec for the LOOP word.
[ ] Same gate fix needed for pan and pitch/filter envelopes? Currently
advanceEnvelope/advancePfEnvelope still require LOOP-b OR SUSTAIN-b
before evaluating, AND the same condition feeds voice.hasPanEnv /
voice.hasPfEnv which the mixer uses to decide whether to apply
envelope-driven pan / cutoff at all. The simple "drop the gate"
treatment that worked for vol env doesn't transfer cleanly: an
absent pan/pf envelope (FT2 default, no env at all) needs to look
different from an enabled-no-wrap envelope so the mixer can ignore
the absent case. Options:
(a) Distinguish via a new format bit (e.g. byte 15/17/19 bit 7
for vol/pan, but bit 7 of pf already carries 'm' filter mode).
(b) Content-based detection at note trigger: envelope is "present"
if any node has non-default value or non-zero offset.
(c) Make the converters write a dedicated "envelope present"
sentinel (e.g. start>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)

View File

@@ -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

View File

@@ -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: