diff --git a/TAUD_NOTE_EFFECTS.md b/TAUD_NOTE_EFFECTS.md index 13c338b..f62f634 100644 --- a/TAUD_NOTE_EFFECTS.md +++ b/TAUD_NOTE_EFFECTS.md @@ -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: - 0b 0000 Fmfp + 0b 0000 0Ffp - 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. @@ -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 = 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). -- 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. +(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.) + +### 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.** - Panning-linear: diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index 00af359..31163f1 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -1655,7 +1655,7 @@ function simulateRowState(ptnDat, uptoRow) { let bpm = audio.getBPM(PLAYHEAD) // best-effort starting tempo let speed = audio.getTickRate(PLAYHEAD) let globalVol = 0xFF - let panLaw = 0, amigaMode = false, fadeoutCutOnZero = false + let panLaw = 0, amigaMode = false let memEF = 0, memG = 0 let memHU = { speed: 0, depth: 0 } @@ -1759,9 +1759,9 @@ function simulateRowState(ptnDat, uptoRow) { if (effop !== 0 || effarg !== 0) { if (effop === OP_1) { const flags = (effarg >>> 8) & 0xFF - panLaw = flags & 1 - amigaMode = (flags & 2) !== 0 - fadeoutCutOnZero = (flags & 4) !== 0 + panLaw = flags & 1 + amigaMode = (flags & 2) !== 0 + // bit 2 reserved (was 'm' fadeout-zero policy; removed) } else if (effop === OP_8) { const x = (effarg >>> 12) & 0xF @@ -1899,7 +1899,7 @@ function simulateRowState(ptnDat, uptoRow) { return { lastNote, lastInst, volAbs, panAbs, pitchOff, bpm, speed, globalVol, - panLaw, amigaMode, fadeoutCutOnZero, + panLaw, amigaMode, bitcrushDepth, bitcrushSkip, overdriveAmp, clipMode, glissandoOn, vibratoWave, tremoloWave, panbrelloWave, memEF, memG, memHU, memR, memY, diff --git a/it2taud.py b/it2taud.py index b9dce56..f8b0b30 100644 --- a/it2taud.py +++ b/it2taud.py @@ -1214,11 +1214,12 @@ def build_sample_inst_bin_it(samples_or_proxy: list, smp_vol_default = min(getattr(s, 'vol', 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))) - # IT fadeout (file-stored 0..2048; ITTECH practical max ≈ 1024) maps verbatim to - # the Taud 12-bit fadeStep. The player picks divisor 1024 in IT mode (vs 65536 - # in FT2 mode) so that one fadeStep unit per tick matches Schism's - # `chan->fadeout_volume -= (stored<<5)<<1` semantics (sndmix.c:331-339, - # effects.c:1261). Clamp defensively to 4095. + # IT fadeout (file-stored 0..1024 per ITTECH; some loaders accept up to 2048) maps + # verbatim to Taud's 12-bit fadeStep. Schism's per-tick decrement is stored / 1024 of + # unit volume (sndmix.c:331-339, effects.c:1261: accumulator 65536, decrement + # = (stored<<5)<<1 = stored*64) — identical to Taud's engine divisor of 1024. Clamp + # 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) # 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" cue sheet: {len(sheet)} → {len(cue_comp)} bytes (gzip)") - # 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". + # flags byte: bit 1 (f) = Amiga pitch-slide mode (IT linear_slides flag inverted). + # 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 # 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)) diff --git a/mod2taud.py b/mod2taud.py index 48978e8..fc4fb6e 100644 --- a/mod2taud.py +++ b/mod2taud.py @@ -767,9 +767,10 @@ def assemble_taud(mod: dict) -> bytes: # 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 # characteristic non-linear pitch character). - # bit 2 (m) set: FT2 fadeout-zero policy — PT has no fadeout, so the stored - # zero on every instrument means "cut on key-off" (unified with S3M imports). - flags_byte = 0x02 | 0x04 + # bit 2 reserved (was 'm' fadeout-zero policy; removed). PT has no instrument-level + # fadeout, so every Taud instrument carries fadeout=0 ("no fade") — notes retire + # on sample-end or pattern note-cut instead, which matches PT semantics. + flags_byte = 0x02 song_table = encode_song_entry( song_offset=song_offset, num_voices=n_channels, diff --git a/mon2taud.py b/mon2taud.py index 6e963ff..79e43a6 100644 --- a/mon2taud.py +++ b/mon2taud.py @@ -366,7 +366,10 @@ def assemble_taud(mon: dict) -> bytes: # BPM 150 + ticks=mon_speed → row rate = 60/mon_speed (matches Monotone). 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_offset = song_offset, diff --git a/s3m2taud.py b/s3m2taud.py index aa787d9..845f00b 100644 --- a/s3m2taud.py +++ b/s3m2taud.py @@ -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)") # 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) - # bit 2 (m) set: FT2 fadeout-zero policy — S3M has no per-instrument fadeout field, so a - # stored zero means "cut on key-off" (matching ST3's lineage from the FT2 family). - flags_byte = (0x00 if h.linear_slides else 0x02) | 0x04 + # flags byte: bit 1 (f) = Amiga pitch-slide mode (mirrors the S3M linear_slides flag inverted). + # bit 2 reserved (was 'm' fadeout-zero policy; removed). S3M has no instrument-level + # fadeout, so every Taud instrument carries fadeout=0 ("no fade") — notes retire on + # 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_offset=song_offset, num_voices=C, diff --git a/terranmon.txt b/terranmon.txt index 302d081..8652b2f 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -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 * FastTracker2 has range of 0..64; multiply by (255/64) then round to int 172 Uint8 Volume Fadeout low bits -173 Bit8 Fadeout and vibrato +173 Bit8 Volume Fadeout high bits 0b 0000 ffff - f: Volume Fadeout high bits - * Combined 12-bit fadeout value is the engine's per-tick decrement, in 1/65536 units - (a unity-volume voice silenced over (65536 / fadeout) ticks after key-off). - * Stored 0: behaviour depends on Global Behaviour bit 'm' (see Song Table) — - IT mode (m=0) leaves the voice unfaded; FT2 mode (m=1) cuts on key-off. - * Source-format mapping: - - IT: stored fadeout (0..1024) MUST be doubled on import (taud = it × 2); - Taud's per-tick scale matches FT2 natively, so IT values are scaled to match. - - FT2: stored fadeout (0..0xFFF) is passed through unchanged. + f: Volume Fadeout high bits (low nibble of byte 173; high nibble reserved, must be zero) + * Combined 12-bit unsigned value (range 0..4095). The engine maintains + a per-voice fadeoutVolume ∈ [0, 1] initialised to 1.0 on note-on, and + while the voice is in key-off or NNA Note-Fade state applies once per + song tick: + fadeoutVolume -= storedFadeout / 1024.0 + clamp fadeoutVolume to [0, 1] + if fadeoutVolume == 0: voice deactivates + 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) 175 Uint8 Vibrato speed * 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] 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 - [ ] 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) [ ] pattern loops stops working after processed once (test with slumberjack.xm) [ ] 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 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') - 0b 0000 Fmfp + 0b 0000 0Ffp 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) - m: fadeout-zero policy (0=IT — stored fadeout 0 means no fadeout; - 1=FT2 — stored fadeout 0 means cut on key-off) + (bit 2 reserved — was 'm' fadeout-zero policy, removed; fadeout + scaling now lives entirely in the converter — see byte 172/173 + of the instrument record for engine semantics) Uint8 Song global volume * ImpulseTracker has range of 0..128; multiply by (255/128) then round to int Uint8 Song mixing volume diff --git a/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt b/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt index ff58666..7b740c3 100644 --- a/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt +++ b/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt @@ -137,7 +137,8 @@ class AudioJSR223Delegate(private val vm: VM) { ph.trackerState?.let { ts -> ts.panLaw = flags and 1 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") } } } diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index a0491de..cec3c99 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -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). // bit 0 (p): 0=linear pan, 1=equal-power pan // 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 ts.panLaw = flags and 1 ts.amigaMode = (flags and 2) != 0 - ts.fadeoutCutOnZero = (flags and 4) != 0 } EffectOp.OP_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. // The 12-bit fadeStep is split across volumeFadeoutLow + low nibble of fadeoutHigh. - // Divisor selects per-tracker semantics: - // FT2 mode (fadeoutCutOnZero=true): fadeStep / 32768 per tick — matches ft2-clone - // (ft2_replayer.c:387-390, 1469-1481): the FT2 XM - // file format docs claim the accumulator is 16-bit - // (65536), but the actual replayer initialises - // fadeoutVol to 32768 and decrements by stored. - // IT mode (fadeoutCutOnZero=false): fadeStep / 1024 per tick — matches Schism - // (sndmix.c:331-339 + effects.c:1261: accumulator - // 65536, decrement = (stored<<5)<<1 = stored·64). - // Stored 0: FT2 mode cuts on key-off; IT mode leaves voice playing (no fade). + // Engine semantics (terranmon.txt byte 172/173, TAUD_NOTE_EFFECTS.md §1 "Volume Fadeout"): + // fadeoutVolume -= fadeStep / 1024.0 per tick, clamped at 0. + // stored = 0 : no fade (the if-branch is skipped — voice plays on at envelope volume) + // stored = 1024 : exact 1-tick cut + // stored > 1024 : also a 1-tick cut (clamped) + // Both IT and FT2 file formats encode "no fade" as stored=0 and "cut" as the slider-extreme + // of the same field; converters scale source values into Taud's 0..4095 unit so the engine + // sees one consistent encoding. if (voice.keyOff || voice.noteFading) { val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8) if (fadeStep > 0) { - val divisor = if (ts.fadeoutCutOnZero) 32768.0 else 1024.0 - voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / divisor).coerceAtLeast(0.0) + voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / 1024.0).coerceAtLeast(0.0) 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) { val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8) if (fadeStep > 0) { - // Divisor must mirror the foreground-voice fade path above - // (FT2 mode: 32768 to match ft2_replayer.c:387-390+1469-1481). - 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 + // Mirrors the foreground-voice fade path above — single divisor of 1024. + bg.fadeoutVolume = (bg.fadeoutVolume - fadeStep / 1024.0).coerceAtLeast(0.0) } } // 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). var panLaw = 0 // 0 = linear balance (default), 1 = equal-power 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). 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 -> ts.panLaw = byte and 1 ts.amigaMode = (byte and 2) != 0 - ts.fadeoutCutOnZero = (byte and 4) != 0 } } 8 -> { bpm = byte + 24 } @@ -3132,7 +3119,6 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { ts.finePatternDelayExtra = 0 ts.panLaw = initialGlobalFlags and 1 ts.amigaMode = (initialGlobalFlags and 2) != 0 - ts.fadeoutCutOnZero = (initialGlobalFlags and 4) != 0 ts.voices.forEach { it.active = false it.channelVolume = 0x3F diff --git a/xm2taud.py b/xm2taud.py index 4a1c803..3dd2537 100644 --- a/xm2taud.py +++ b/xm2taud.py @@ -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)))) loop_type = samp.flags & XM_SMP_LOOP_MASK 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" - # in FT2 — matches Taud's fadeStep semantics where 0 = held forever. - p.fadeout = min(0xFFF, inst.fadeout & 0xFFFF) + # Fadeout: XM file value (16-bit, 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; 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_depth = (inst.vib_depth * 2) & 0xFF # LoaderXM.cpp:217 scaling p.vib_sweep = inst.vib_sweep & 0xFF @@ -1308,12 +1313,10 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes: # Flags byte: # 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 - # divisor 65536 — XM convention). Without this, the engine - # uses the IT divisor (1024), making fadeout ~64× faster - # than FT2 — voices with non-zero fadeout get silenced - # within a few ticks of key-off instead of fading naturally. - flags_byte = (0x00 if h.linear_freq else 0x02) | 0x04 + # bit 2 = reserved (was 'm' fadeout-zero policy; removed). XM fadeout values are + # now scaled per-instrument above (÷32 with round-to-nearest), so the + # engine sees Taud-native units and uses its single divisor of 1024. + flags_byte = (0x00 if h.linear_freq else 0x02) song_table = encode_song_entry( song_offset=song_offset, num_voices=C,