did it got fixed?

This commit is contained in:
minjaesong
2026-05-06 10:38:37 +09:00
parent d058f11329
commit 75ddfcde0f
10 changed files with 162 additions and 74 deletions

View File

@@ -956,7 +956,7 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
**Plain.** Sets mixer-wide behaviour flags. Available flags are: **Plain.** Sets mixer-wide behaviour flags. Available flags are:
0b 0000 Fmfp 0b 0000 0Ffp
- p unset: Linear panning mode (tracker-accurate). Centre panning gets 3 dB boost. Default setting. - p unset: Linear panning mode (tracker-accurate). Centre panning gets 3 dB boost. Default setting.
- p set: Equal-power panning mode. L/R amplitude is at 0.707 when centre-panned. - p set: Equal-power panning mode. L/R amplitude is at 0.707 when centre-panned.
@@ -965,8 +965,49 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
- Ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode. - Ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
- Ff = 2: Linear frequency mode. Pitch shift will behave against frequency number. - Ff = 2: Linear frequency mode. Pitch shift will behave against frequency number.
- m unset: IT fadeout-zero policy. An instrument with stored volume fadeout = 0 does **not** fade out on key-off; the voice plays through until the volume envelope ends it (or never, if there is no envelope). (Bit 2 is reserved. It previously held an `m` "fadeout-zero policy" flag intended to swap between IT and FT2 semantics for `storedFadeout = 0`. That flag was removed once both trackers were verified to share identical "stored 0 ⇒ no fade" semantics — see schismtracker `player/sndmix.c:330-342` and ft2-clone `src/ft2_replayer.c:1467-1481`. Fadeout scaling now lives in the converters; see "Volume Fadeout" below.)
- m set: FT2 fadeout-zero policy. An instrument with stored volume fadeout = 0 is **cut** on the first tick after key-off (or NNA Note-Fade). Nonzero fadeouts behave identically in both modes — the per-tick decrement is always `fadeout / 65536` in unity-volume units.
### Volume Fadeout
Taud's volume fadeout is a single linear decay applied per song tick after key-off (or NNA Note-Fade). It is **the only retirement mechanism** for sustained voices when the volume envelope holds non-zero or has no terminating zero node — without a non-zero stored fadeout, such voices play forever.
The 12-bit stored fadeout lives at instrument-record bytes 172 (low 8 bits) and 173 (low nibble = high 4 bits; high nibble reserved). Range 0..4095. The engine maintains a per-voice `fadeoutVolume ∈ [0, 1]` initialised to 1.0 on note-on, and once per song tick while the voice is keyed off:
```
fadeoutVolume -= storedFadeout / 1024.0
clamp fadeoutVolume to [0, 1]
if fadeoutVolume == 0: voice deactivates
```
Boundary semantics:
| `storedFadeout` | Behaviour |
| --- | --- |
| `0` | No fade. Voice plays at envelope-driven volume indefinitely. |
| `1..1023` | Graduated fade — completes in `1024 / storedFadeout` ticks. |
| `1024` | Exact 1-tick cut. The canonical "kill on key-off" value. |
| `1025..4095` | Also a 1-tick cut (clamped at 0). Headroom for converter robustness. |
There is no separate "use fadeout" flag — both extremes share the same field, exactly as in the IT and XM file formats.
**Tick-rate worked example** (default 50 Hz, BPM 125, speed 6):
- `storedFadeout = 1` → fade ≈ 20.5 s
- `storedFadeout = 32` → fade ≈ 640 ms
- `storedFadeout = 1024` → ~20 ms (one tick)
**Converter unit conversion.** Source trackers each expose fadeout in their own unit; converters scale the source value into Taud's 0..4095 field.
- **IT** (`it2taud.py`): IT files store fadeout as a 16-bit field at instrument-record offset `0x14`, range 0..1024 per ITTECH (some loaders accept up to 2048). Schism's per-tick decrement is `stored / 1024` — identical to Taud's unit. **Pass-through with clamp:**
```python
taud_fadeout = min(it_fadeout & 0xFFFF, 0x0FFF)
```
- **FT2 / XM** (`xm2taud.py`): XM files store fadeout as a 16-bit field. Spec range 0..0xFFF; MilkyTracker writes up to 32767 to encode the "cut" UI slider position (`SectionInstruments.cpp:499-500`). FT2's per-tick decrement is `stored / 32768` — to match Taud's `stored / 1024` rate, **divide source by 32 (round-to-nearest):**
```python
taud_fadeout = min((xm_fadeout + 16) // 32, 0x0FFF)
```
XM stored 1..15 round to Taud 0; the originals were >11 min at 50 Hz — effectively no-fade anyway. Stored 32 → Taud 1 (~20 s). Stored 32767 (Milky cut sentinel) → Taud 1024 (1-tick cut).
- **MOD / S3M / MON**: source has no instrument-level fadeout. Converter writes Taud `0`. Notes retire on sample-end or pattern note-cut.
**Implementation.** **Implementation.**
- Panning-linear: - Panning-linear:

View File

@@ -1655,7 +1655,7 @@ function simulateRowState(ptnDat, uptoRow) {
let bpm = audio.getBPM(PLAYHEAD) // best-effort starting tempo let bpm = audio.getBPM(PLAYHEAD) // best-effort starting tempo
let speed = audio.getTickRate(PLAYHEAD) let speed = audio.getTickRate(PLAYHEAD)
let globalVol = 0xFF let globalVol = 0xFF
let panLaw = 0, amigaMode = false, fadeoutCutOnZero = false let panLaw = 0, amigaMode = false
let memEF = 0, memG = 0 let memEF = 0, memG = 0
let memHU = { speed: 0, depth: 0 } let memHU = { speed: 0, depth: 0 }
@@ -1759,9 +1759,9 @@ function simulateRowState(ptnDat, uptoRow) {
if (effop !== 0 || effarg !== 0) { if (effop !== 0 || effarg !== 0) {
if (effop === OP_1) { if (effop === OP_1) {
const flags = (effarg >>> 8) & 0xFF const flags = (effarg >>> 8) & 0xFF
panLaw = flags & 1 panLaw = flags & 1
amigaMode = (flags & 2) !== 0 amigaMode = (flags & 2) !== 0
fadeoutCutOnZero = (flags & 4) !== 0 // bit 2 reserved (was 'm' fadeout-zero policy; removed)
} }
else if (effop === OP_8) { else if (effop === OP_8) {
const x = (effarg >>> 12) & 0xF const x = (effarg >>> 12) & 0xF
@@ -1899,7 +1899,7 @@ function simulateRowState(ptnDat, uptoRow) {
return { lastNote, lastInst, volAbs, panAbs, pitchOff, return { lastNote, lastInst, volAbs, panAbs, pitchOff,
bpm, speed, globalVol, bpm, speed, globalVol,
panLaw, amigaMode, fadeoutCutOnZero, panLaw, amigaMode,
bitcrushDepth, bitcrushSkip, overdriveAmp, clipMode, bitcrushDepth, bitcrushSkip, overdriveAmp, clipMode,
glissandoOn, vibratoWave, tremoloWave, panbrelloWave, glissandoOn, vibratoWave, tremoloWave, panbrelloWave,
memEF, memG, memHU, memR, memY, memEF, memG, memHU, memR, memY,

View File

@@ -1214,11 +1214,12 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
smp_vol_default = min(getattr(s, 'vol', 64), 64) smp_vol_default = min(getattr(s, 'vol', 64), 64)
smp_gv_default = min(getattr(s, 'gv', 64), 64) smp_gv_default = min(getattr(s, 'gv', 64), 64)
inst_gv = min(255, round(smp_vol_default * smp_gv_default * 255 / (64 * 64))) inst_gv = min(255, round(smp_vol_default * smp_gv_default * 255 / (64 * 64)))
# IT fadeout (file-stored 0..2048; ITTECH practical max ≈ 1024) maps verbatim to # IT fadeout (file-stored 0..1024 per ITTECH; some loaders accept up to 2048) maps
# the Taud 12-bit fadeStep. The player picks divisor 1024 in IT mode (vs 65536 # verbatim to Taud's 12-bit fadeStep. Schism's per-tick decrement is stored / 1024 of
# in FT2 mode) so that one fadeStep unit per tick matches Schism's # unit volume (sndmix.c:331-339, effects.c:1261: accumulator 65536, decrement
# `chan->fadeout_volume -= (stored<<5)<<1` semantics (sndmix.c:331-339, # = (stored<<5)<<1 = stored*64) — identical to Taud's engine divisor of 1024. Clamp
# effects.c:1261). Clamp defensively to 4095. # defensively to 4095. See terranmon.txt byte 172/173 and TAUD_NOTE_EFFECTS.md §1
# "Volume Fadeout".
fadeout = min(0xFFF, idata.get('fadeout', 0) & 0xFFFF) fadeout = min(0xFFF, idata.get('fadeout', 0) & 0xFFFF)
# LOOP words at offsets 15/17/19. # LOOP words at offsets 15/17/19.
@@ -1751,8 +1752,9 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
vprint(f" pattern bin: {len(pat_bin)}{len(pat_comp)} bytes (gzip)") vprint(f" pattern bin: {len(pat_bin)}{len(pat_comp)} bytes (gzip)")
vprint(f" cue sheet: {len(sheet)}{len(cue_comp)} bytes (gzip)") vprint(f" cue sheet: {len(sheet)}{len(cue_comp)} bytes (gzip)")
# flags byte: bit 1 (f) = Amiga pitch-slide mode (IT linear_slides flag inverted) # flags byte: bit 1 (f) = Amiga pitch-slide mode (IT linear_slides flag inverted).
# bit 2 (m) cleared: IT fadeout-zero policy — stored fadeout=0 means "no fadeout". # bit 2 was the old 'm' fadeout-zero policy flag and is now reserved (always 0); fadeout
# scaling is done per-instrument in this converter — see the fadeout pass-through below.
flags_byte = 0x00 if h.linear_slides else 0x02 flags_byte = 0x00 if h.linear_slides else 0x02
# IT global/mix volumes are 0..128; rescale to Taud's 0..255 (clamped). # IT global/mix volumes are 0..128; rescale to Taud's 0..255 (clamped).
global_vol_taud = min(0xFF, round(h.global_vol * 255 / 128)) global_vol_taud = min(0xFF, round(h.global_vol * 255 / 128))

View File

@@ -767,9 +767,10 @@ def assemble_taud(mod: dict) -> bytes:
# ProTracker is Amiga-period-based by definition, so we set the f bit so # ProTracker is Amiga-period-based by definition, so we set the f bit so
# the engine applies coarse pitch slides in period space (recovers PT's # the engine applies coarse pitch slides in period space (recovers PT's
# characteristic non-linear pitch character). # characteristic non-linear pitch character).
# bit 2 (m) set: FT2 fadeout-zero policy — PT has no fadeout, so the stored # bit 2 reserved (was 'm' fadeout-zero policy; removed). PT has no instrument-level
# zero on every instrument means "cut on key-off" (unified with S3M imports). # fadeout, so every Taud instrument carries fadeout=0 ("no fade") — notes retire
flags_byte = 0x02 | 0x04 # on sample-end or pattern note-cut instead, which matches PT semantics.
flags_byte = 0x02
song_table = encode_song_entry( song_table = encode_song_entry(
song_offset=song_offset, song_offset=song_offset,
num_voices=n_channels, num_voices=n_channels,

View File

@@ -366,7 +366,10 @@ def assemble_taud(mon: dict) -> bytes:
# BPM 150 + ticks=mon_speed → row rate = 60/mon_speed (matches Monotone). # BPM 150 + ticks=mon_speed → row rate = 60/mon_speed (matches Monotone).
bpm_stored = 150 - 24 bpm_stored = 150 - 24
flags_byte = 0x04 # m bit: fadeout-zero policy = cut on key-off. # bit 2 reserved (was 'm' fadeout-zero policy; removed). Monotone has no instrument-level
# fadeout, so every Taud instrument carries fadeout=0 ("no fade") — notes retire on
# sample-end or pattern note-cut instead.
flags_byte = 0x00
song_table = encode_song_entry( song_table = encode_song_entry(
song_offset = song_offset, song_offset = song_offset,

View File

@@ -810,10 +810,11 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
vprint(f" cue sheet: {len(cue_sheet)}{len(cue_comp)} bytes (gzip)") vprint(f" cue sheet: {len(cue_sheet)}{len(cue_comp)} bytes (gzip)")
# Song table row (32 bytes; see encode_song_entry). # Song table row (32 bytes; see encode_song_entry).
# flags byte: bit 1 (f) = Amiga pitch-slide mode (mirrors the S3M linear_slides flag inverted) # flags byte: bit 1 (f) = Amiga pitch-slide mode (mirrors the S3M linear_slides flag inverted).
# bit 2 (m) set: FT2 fadeout-zero policy S3M has no per-instrument fadeout field, so a # bit 2 reserved (was 'm' fadeout-zero policy; removed). S3M has no instrument-level
# stored zero means "cut on key-off" (matching ST3's lineage from the FT2 family). # fadeout, so every Taud instrument carries fadeout=0 ("no fade") — notes retire on
flags_byte = (0x00 if h.linear_slides else 0x02) | 0x04 # sample-end or pattern note-cut effects (SCx) instead, which matches ST3 semantics.
flags_byte = (0x00 if h.linear_slides else 0x02)
song_table = encode_song_entry( song_table = encode_song_entry(
song_offset=song_offset, song_offset=song_offset,
num_voices=C, num_voices=C,

View File

@@ -2090,17 +2090,52 @@ distinction (different word at a different offset), not a flag bit.
- ImpulseTracker also has samplewise default volume (0..64) and samplewise global volume (0..64), and they must be taken into account because Taud has no samplewise config, following the ImpulseTracker spec - ImpulseTracker also has samplewise default volume (0..64) and samplewise global volume (0..64), and they must be taken into account because Taud has no samplewise config, following the ImpulseTracker spec
* FastTracker2 has range of 0..64; multiply by (255/64) then round to int * FastTracker2 has range of 0..64; multiply by (255/64) then round to int
172 Uint8 Volume Fadeout low bits 172 Uint8 Volume Fadeout low bits
173 Bit8 Fadeout and vibrato 173 Bit8 Volume Fadeout high bits
0b 0000 ffff 0b 0000 ffff
f: Volume Fadeout high bits f: Volume Fadeout high bits (low nibble of byte 173; high nibble reserved, must be zero)
* Combined 12-bit fadeout value is the engine's per-tick decrement, in 1/65536 units * Combined 12-bit unsigned value (range 0..4095). The engine maintains
(a unity-volume voice silenced over (65536 / fadeout) ticks after key-off). a per-voice fadeoutVolume ∈ [0, 1] initialised to 1.0 on note-on, and
* Stored 0: behaviour depends on Global Behaviour bit 'm' (see Song Table) — while the voice is in key-off or NNA Note-Fade state applies once per
IT mode (m=0) leaves the voice unfaded; FT2 mode (m=1) cuts on key-off. song tick:
* Source-format mapping: fadeoutVolume -= storedFadeout / 1024.0
- IT: stored fadeout (0..1024) MUST be doubled on import (taud = it × 2); clamp fadeoutVolume to [0, 1]
Taud's per-tick scale matches FT2 natively, so IT values are scaled to match. if fadeoutVolume == 0: voice deactivates
- FT2: stored fadeout (0..0xFFF) is passed through unchanged. The voice's amplitude is multiplied by fadeoutVolume each tick.
* Stored value semantics (no separate "use fadeout" flag — like IT and
FT2 file formats, "no fade" and "instant cut" are both encoded as
extreme values of this same field):
- 0 : no fade. fadeoutVolume never moves; the voice plays
at envelope-driven volume indefinitely. Termination
must come from the volume envelope reaching a final
0-valued node, the sample ending, or a note-cut.
- 1..1023 : graduated fade. Completes in (1024 / storedFadeout)
ticks. e.g. 1 → 1024 ticks; 32 → 32 ticks.
- 1024 : exact 1-tick cut. fadeoutVolume goes 1.0 → 0.0 in
one tick (the canonical "kill on key-off" value).
- 1025..4095 : also a 1-tick cut (clamped at 0). The 4× headroom
over 1024 lets converters carry out-of-spec source
values without saturating prematurely.
* Tick-rate worked example at default 50 Hz (BPM 125, speed 6):
- storedFadeout = 1 → fade ≈ 20.5 s
- storedFadeout = 32 → fade ≈ 640 ms
- storedFadeout = 1024 → ~20 ms (one tick)
* Source-format mapping (converters scale source units → Taud field):
- IT: 16-bit field at IT instrument record offset 0x14, range
0..1024 per ITTECH (some loaders accept up to 2048). Schism's
per-tick decrement is stored / 1024 of unit volume — identical
to Taud's unit. Pass-through with clamp:
taud_fadeout = min(it_fadeout & 0xFFFF, 0x0FFF)
- FT2/XM: 16-bit field. Spec range 0..0xFFF; MilkyTracker writes
up to 32767 to encode the "cut" UI slider position
(SectionInstruments.cpp:499-500). FT2's per-tick decrement is
stored / 32768 of unit volume — to match Taud's stored / 1024
rate, divide source by 32 (round-to-nearest):
taud_fadeout = min((xm_fadeout + 16) // 32, 0x0FFF)
XM stored 1..15 round to Taud 0 (originals were >11 min at 50 Hz
— effectively "no fade" anyway). Stored 32 → Taud 1 (~20 s).
Stored 32767 (Milky cut sentinel) → Taud 1024 (1-tick cut).
- MOD/S3M/MON: no instrument-level fadeout in source; converters
write 0 (notes retire on sample-end or pattern note-cut).
174 Uint8 Volume swing (0..255 full range) 174 Uint8 Volume swing (0..255 full range)
175 Uint8 Vibrato speed 175 Uint8 Vibrato speed
* ImpulseTracker has samplewise vibrato speed (0..64), and they must be taken into account because Taud has no samplewise config * ImpulseTracker has samplewise vibrato speed (0..64), and they must be taken into account because Taud has no samplewise config
@@ -2173,7 +2208,21 @@ TODO:
[x] scale Oxxxx when samples get resampled [x] scale Oxxxx when samples get resampled
[x] implement bitcrusher and overdrive (eff sym '8' and '9') [x] implement bitcrusher and overdrive (eff sym '8' and '9')
[x] note trigger with inst and note fx set (e.g. portamento) but no volume set is not getting their default volume but getting what was before instead (SATELL.taud ptn 23) -- and simulateRowState() of taut.js always shows old volume instead of default volume, regardless of note fx's existence [x] note trigger with inst and note fx set (e.g. portamento) but no volume set is not getting their default volume but getting what was before instead (SATELL.taud ptn 23) -- and simulateRowState() of taut.js always shows old volume instead of default volume, regardless of note fx's existence
[ ] how does fadeout=0 work on IT? On XM, the note don't decay at all (that's why there's separate CUT value). Also see what Global Behaviour 'm' flag actually do on Taud (or, which slop AI had fed me *sigh*). `slumberjack.xm` plays normally but notes of `4THSYM.it` don't decay at all [x] how does fadeout=0 work on IT? On XM, the note don't decay at all (that's why there's separate CUT value). Also see what Global Behaviour 'm' flag actually do on Taud (or, which slop AI had fed me *sigh*). `slumberjack.xm` plays normally but notes of `4THSYM.it` don't decay at all
Resolution: confirmed against schismtracker (player/sndmix.c:330-342) and
ft2-clone (src/ft2_replayer.c:1467-1481). Both IT and FT2 treat stored
fadeout=0 as "no fade" — there is no separate "use fadeout" flag in
either file format; "cut" is just the slider-extreme of the same
magnitude (MilkyTracker SectionInstruments.cpp:499-500 maps the slider's
4097th position to internal 32767). The 'm' flag's claim that FT2 cuts
on key-off when fadeout=0 was AI slop. Dropped the flag entirely; the
engine now uses a single divisor (1024) and converters scale their
source units to match (IT pass-through, XM ÷32). See byte 172-173 of
the instrument record for engine semantics.
4THSYM.it notes still hang on key-off — that's a separate bug: instruments
with fadeout=0 + sustained envelope ending in a 0-valued node need the
Schism rule "envelope reached final 0 node ⇒ cut voice"
(sndmix.c:494-495). Not yet implemented in AudioAdapter.kt.
[ ] implement extended tone mode (MONOTONE compat) [ ] implement extended tone mode (MONOTONE compat)
[ ] pattern loops stops working after processed once (test with slumberjack.xm) [ ] pattern loops stops working after processed once (test with slumberjack.xm)
[ ] milkytracker-style volume ramping (on sample-end only) [ ] milkytracker-style volume ramping (on sample-end only)
@@ -2393,11 +2442,12 @@ Endianness: Little
Uint16 Current Tuning base note (1..65533). A4 (western default) is 0x5C00. C9 (tracker default) is 0xA000. If zero, assume the tracker default value Uint16 Current Tuning base note (1..65533). A4 (western default) is 0x5C00. C9 (tracker default) is 0xA000. If zero, assume the tracker default value
Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default
Uint8 Flags for Global Behaviour (effect symbol '1') Uint8 Flags for Global Behaviour (effect symbol '1')
0b 0000 Fmfp 0b 0000 0Ffp
p: panning law (0=linear, 1=equal-power) p: panning law (0=linear, 1=equal-power)
Ff: tone mode (0=linear pitch slides, 1=Amiga period slides, 2=linear-frequency slides, 3=reserved) Ff: tone mode (0=linear pitch slides, 1=Amiga period slides, 2=linear-frequency slides, 3=reserved)
m: fadeout-zero policy (0=IT — stored fadeout 0 means no fadeout; (bit 2 reserved — was 'm' fadeout-zero policy, removed; fadeout
1=FT2 — stored fadeout 0 means cut on key-off) scaling now lives entirely in the converter — see byte 172/173
of the instrument record for engine semantics)
Uint8 Song global volume Uint8 Song global volume
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int * ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
Uint8 Song mixing volume Uint8 Song mixing volume

View File

@@ -137,7 +137,8 @@ class AudioJSR223Delegate(private val vm: VM) {
ph.trackerState?.let { ts -> ph.trackerState?.let { ts ->
ts.panLaw = flags and 1 ts.panLaw = flags and 1
ts.amigaMode = (flags and 2) != 0 ts.amigaMode = (flags and 2) != 0
ts.fadeoutCutOnZero = (flags and 4) != 0 // bit 2 reserved (was 'm' fadeout-zero policy; removed — see AudioAdapter.kt
// and TAUD_NOTE_EFFECTS.md §1 "Volume Fadeout")
} }
} }
} }

View File

@@ -1960,11 +1960,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// 1 $xx00 — Global behaviour flags byte in the high byte (see TAUD_NOTE_EFFECTS.md §1). // 1 $xx00 — Global behaviour flags byte in the high byte (see TAUD_NOTE_EFFECTS.md §1).
// bit 0 (p): 0=linear pan, 1=equal-power pan // bit 0 (p): 0=linear pan, 1=equal-power pan
// bit 1 (f): 0=linear pitch slides, 1=Amiga-mode pitch slides // bit 1 (f): 0=linear pitch slides, 1=Amiga-mode pitch slides
// bit 2 (m): fadeout-zero policy. 0=IT (stored 0 ⇒ no fadeout), 1=FT2 (stored 0 ⇒ cut on key-off) // bit 2 : reserved (was 'm' fadeout-zero policy; removed — converters now scale
// source fadeout into Taud-native units, so the engine has a single divisor)
val flags = rawArg ushr 8 val flags = rawArg ushr 8
ts.panLaw = flags and 1 ts.panLaw = flags and 1
ts.amigaMode = (flags and 2) != 0 ts.amigaMode = (flags and 2) != 0
ts.fadeoutCutOnZero = (flags and 4) != 0
} }
EffectOp.OP_8 -> { EffectOp.OP_8 -> {
// 8 $xyzz — Bitcrusher. See TAUD_NOTE_EFFECTS.md §8. // 8 $xyzz — Bitcrusher. See TAUD_NOTE_EFFECTS.md §8.
@@ -2405,24 +2405,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Volume fadeout: after key-off OR Note-Fade NNA, decrement per tick. // Volume fadeout: after key-off OR Note-Fade NNA, decrement per tick.
// The 12-bit fadeStep is split across volumeFadeoutLow + low nibble of fadeoutHigh. // The 12-bit fadeStep is split across volumeFadeoutLow + low nibble of fadeoutHigh.
// Divisor selects per-tracker semantics: // Engine semantics (terranmon.txt byte 172/173, TAUD_NOTE_EFFECTS.md §1 "Volume Fadeout"):
// FT2 mode (fadeoutCutOnZero=true): fadeStep / 32768 per tick — matches ft2-clone // fadeoutVolume -= fadeStep / 1024.0 per tick, clamped at 0.
// (ft2_replayer.c:387-390, 1469-1481): the FT2 XM // stored = 0 : no fade (the if-branch is skipped — voice plays on at envelope volume)
// file format docs claim the accumulator is 16-bit // stored = 1024 : exact 1-tick cut
// (65536), but the actual replayer initialises // stored > 1024 : also a 1-tick cut (clamped)
// fadeoutVol to 32768 and decrements by stored. // Both IT and FT2 file formats encode "no fade" as stored=0 and "cut" as the slider-extreme
// IT mode (fadeoutCutOnZero=false): fadeStep / 1024 per tick — matches Schism // of the same field; converters scale source values into Taud's 0..4095 unit so the engine
// (sndmix.c:331-339 + effects.c:1261: accumulator // sees one consistent encoding.
// 65536, decrement = (stored<<5)<<1 = stored·64).
// Stored 0: FT2 mode cuts on key-off; IT mode leaves voice playing (no fade).
if (voice.keyOff || voice.noteFading) { if (voice.keyOff || voice.noteFading) {
val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8) val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8)
if (fadeStep > 0) { if (fadeStep > 0) {
val divisor = if (ts.fadeoutCutOnZero) 32768.0 else 1024.0 voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / 1024.0).coerceAtLeast(0.0)
voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / divisor).coerceAtLeast(0.0)
if (voice.fadeoutVolume <= 0.0) voice.active = false if (voice.fadeoutVolume <= 0.0) voice.active = false
} else if (ts.fadeoutCutOnZero) {
voice.active = false
} }
} }
@@ -2474,14 +2469,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (bg.keyOff || bg.noteFading) { if (bg.keyOff || bg.noteFading) {
val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8) val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8)
if (fadeStep > 0) { if (fadeStep > 0) {
// Divisor must mirror the foreground-voice fade path above // Mirrors the foreground-voice fade path above — single divisor of 1024.
// (FT2 mode: 32768 to match ft2_replayer.c:387-390+1469-1481). bg.fadeoutVolume = (bg.fadeoutVolume - fadeStep / 1024.0).coerceAtLeast(0.0)
val divisor = if (ts.fadeoutCutOnZero) 32768.0 else 1024.0
bg.fadeoutVolume = (bg.fadeoutVolume - fadeStep / divisor).coerceAtLeast(0.0)
} else if (ts.fadeoutCutOnZero) {
bg.active = false
bgIt.remove()
continue
} }
} }
// Auto-vibrato keeps running on backgrounds — it's an instrument-intrinsic LFO. // Auto-vibrato keeps running on backgrounds — it's an instrument-intrinsic LFO.
@@ -2982,7 +2971,6 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Global mixer config (effect 1). // Global mixer config (effect 1).
var panLaw = 0 // 0 = linear balance (default), 1 = equal-power var panLaw = 0 // 0 = linear balance (default), 1 = equal-power
var amigaMode = false // false = linear pitch slides, true = Amiga period-space slides var amigaMode = false // false = linear pitch slides, true = Amiga period-space slides
var fadeoutCutOnZero = false // false = IT (stored 0 ⇒ no fadeout); true = FT2 (stored 0 ⇒ cut on key-off)
// Pending row-end events (set during a row by B/C; consumed at row end). // Pending row-end events (set during a row by B/C; consumed at row end).
var pendingOrderJump = -1 // -1 = none; otherwise the order index to jump to var pendingOrderJump = -1 // -1 = none; otherwise the order index to jump to
@@ -3096,7 +3084,6 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
trackerState?.let { ts -> trackerState?.let { ts ->
ts.panLaw = byte and 1 ts.panLaw = byte and 1
ts.amigaMode = (byte and 2) != 0 ts.amigaMode = (byte and 2) != 0
ts.fadeoutCutOnZero = (byte and 4) != 0
} }
} }
8 -> { bpm = byte + 24 } 8 -> { bpm = byte + 24 }
@@ -3132,7 +3119,6 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
ts.finePatternDelayExtra = 0 ts.finePatternDelayExtra = 0
ts.panLaw = initialGlobalFlags and 1 ts.panLaw = initialGlobalFlags and 1
ts.amigaMode = (initialGlobalFlags and 2) != 0 ts.amigaMode = (initialGlobalFlags and 2) != 0
ts.fadeoutCutOnZero = (initialGlobalFlags and 4) != 0
ts.voices.forEach { ts.voices.forEach {
it.active = false it.active = false
it.channelVolume = 0x3F it.channelVolume = 0x3F

View File

@@ -858,9 +858,14 @@ def _xm_sample_to_proxy(inst: XMInstrument, samp: XMSample,
p.c2spd = max(1, round(8363.0 * (2.0 ** (semis / 12.0)))) p.c2spd = max(1, round(8363.0 * (2.0 ** (semis / 12.0))))
loop_type = samp.flags & XM_SMP_LOOP_MASK loop_type = samp.flags & XM_SMP_LOOP_MASK
p.flags = 1 if loop_type != 0 else 0 # 1=loop on, 0=off p.flags = 1 if loop_type != 0 else 0 # 1=loop on, 0=off
# Fadeout: XM stores 0..4095 (FT2 file format); 0 means "no fadeout" # Fadeout: XM file value (16-bit, spec range 0..0xFFF; MilkyTracker writes up to 32767
# in FT2 — matches Taud's fadeStep semantics where 0 = held forever. # to encode the "cut" UI slider position — SectionInstruments.cpp:499-500). FT2's per-tick
p.fadeout = min(0xFFF, inst.fadeout & 0xFFFF) # decrement is stored / 32768 of unit volume; Taud's engine uses stored / 1024. Divide
# source by 32 (round-to-nearest) to match the per-tick rate. XM stored 1..15 round to
# Taud 0 — those originals were >11 min at 50 Hz, effectively no-fade. Stored 32 → Taud 1
# (~20 s). Stored 32767 (Milky cut sentinel) → Taud 1024 (1-tick cut). See terranmon.txt
# byte 172/173 and TAUD_NOTE_EFFECTS.md §1 "Volume Fadeout".
p.fadeout = min(0xFFF, (int(inst.fadeout & 0xFFFF) + 16) // 32)
p.vib_speed = inst.vib_rate # XM rate ↔ Taud "speed" p.vib_speed = inst.vib_rate # XM rate ↔ Taud "speed"
p.vib_depth = (inst.vib_depth * 2) & 0xFF # LoaderXM.cpp:217 scaling p.vib_depth = (inst.vib_depth * 2) & 0xFF # LoaderXM.cpp:217 scaling
p.vib_sweep = inst.vib_sweep & 0xFF p.vib_sweep = inst.vib_sweep & 0xFF
@@ -1308,12 +1313,10 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes:
# Flags byte: # Flags byte:
# bit 1 (f) = Amiga pitch-slide mode (set when XM uses Amiga period table). # bit 1 (f) = Amiga pitch-slide mode (set when XM uses Amiga period table).
# bit 2 (m) = FT2 fadeout-zero policy (stored 0 ⇒ cut on key-off; fadeStep # bit 2 = reserved (was 'm' fadeout-zero policy; removed). XM fadeout values are
# divisor 65536 — XM convention). Without this, the engine # now scaled per-instrument above (÷32 with round-to-nearest), so the
# uses the IT divisor (1024), making fadeout ~64× faster # engine sees Taud-native units and uses its single divisor of 1024.
# than FT2 — voices with non-zero fadeout get silenced flags_byte = (0x00 if h.linear_freq else 0x02)
# within a few ticks of key-off instead of fading naturally.
flags_byte = (0x00 if h.linear_freq else 0x02) | 0x04
song_table = encode_song_entry( song_table = encode_song_entry(
song_offset=song_offset, song_offset=song_offset,
num_voices=C, num_voices=C,