mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
resolving envelope ambiguity
This commit is contained in:
18
it2taud.py
18
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('<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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
14
xm2taud.py
14
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:
|
||||
|
||||
Reference in New Issue
Block a user