Compare commits

...

4 Commits

Author SHA1 Message Date
minjaesong
0124b062d0 tracker engine upd 2026-05-06 15:06:18 +09:00
minjaesong
18881a6d16 taut helpmsg 2026-05-06 14:36:35 +09:00
minjaesong
5a4d200fdc IT voice retire rule for fadeout=0 2026-05-06 12:15:48 +09:00
minjaesong
75ddfcde0f did it got fixed? 2026-05-06 10:38:37 +09:00
14 changed files with 370 additions and 110 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:
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:

View File

@@ -19,6 +19,10 @@ const BULLET = "\u00847u"
const VERT = "\u00B3"
const TWOVERT = "\u00BA"
if (!_G.TAUT) _G.TAUT = {};
if (!_G.TAUT.UI) _G.TAUT.UI = {};
if (!_G.TAUT.UI.NEXTPANEL) _G.TAUT.UI.NEXTPANEL = undefined;
const sym = {
/* accidentals */
accnull:"\u00A2\u00A3",
@@ -850,44 +854,44 @@ function drawPatternView(style = timelineRowStyle) {
function drawControlHint() {
let hintElemTimeline = [
[`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Cue'],
[`pg\u008418u`,'Cue'],
['sep'],
['WER','View'],
['sep'],
['Sp','Edit'],
['sp','Edit'],
['sep'],
['n','Solo'],
['m','Mute'],
['sep'],
['Tab','Panel']
['tab','Panel']
// ['sep'],
// ['q','Quit'],
]
let hintElemOrders = [
[`\u008428u\u008429u`,'Nav'],
[`Ent`,'Go to cue'],
[`ent`,'Go to cue'],
['sep'],
['Sp','Edit'],
['sp','Edit'],
['sep'],
['Tab','Panel'],
['tab','Panel'],
// ['sep'],
// ['q','Quit'],
]
let hintElemPatterns = [
[`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Ptn'],
[`pg\u008418u`,'Ptn'],
['sep'],
['Sp','Edit'],
['sp','Edit'],
['sep'],
['Tab','Panel'],
['tab','Panel'],
// ['sep'],
// ['q','Quit'],
]
let hintElemEditNoteValue = [ // only enabled in viewmode 'E' or in pattern editor
[`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Cue'],
[`pg\u008418u`,'Cue'],
['sep'],
[`A${sym.doubledot}G`,'Note'],
[`0${sym.doubledot}9`,'Oct'],
@@ -903,15 +907,15 @@ function drawControlHint() {
]
let hintElemEditInstValue = [
[`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Cue'],
[`pg\u008418u`,'Cue'],
['sep'],
[`0${sym.doubledot}9 A${sym.doubledot}F`,'Instrument'],
['sep'],
['Sp','ExitEdit'],
['sp','ExitEdit'],
]
let hintElemEditVolEff = [
[`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Cue'],
[`pg\u008418u`,'Cue'],
['sep'],
['h','Set'],
['j','SlideDn'],
@@ -924,7 +928,7 @@ function drawControlHint() {
]
let hintElemEditPanEff = [
[`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Cue'],
[`pg\u008418u`,'Cue'],
['sep'],
['h','Set'],
['j','SlideL'],
@@ -937,19 +941,19 @@ function drawControlHint() {
]
let hintElemEditFxSym = [
[`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Cue'],
[`pg\u008418u`,'Cue'],
['sep'],
[`0${sym.doubledot}9 A${sym.doubledot}F`,`FxSym`],
['sep'],
['Sp','ExitEdit'],
['sp','ExitEdit'],
]
let hintElemEditFxVal = [
[`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Cue'],
[`pg\u008418u`,'Cue'],
['sep'],
[`0${sym.doubledot}9 A${sym.doubledot}F`,`FxVal`],
['sep'],
['Sp','ExitEdit'],
['sp','ExitEdit'],
]
const hintElemExternal = [['Tab','Panel']]
@@ -1655,7 +1659,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 }
@@ -1761,7 +1765,7 @@ function simulateRowState(ptnDat, uptoRow) {
const flags = (effarg >>> 8) & 0xFF
panLaw = flags & 1
amigaMode = (flags & 2) !== 0
fadeoutCutOnZero = (flags & 4) !== 0
// bit 2 reserved (was 'm' fadeout-zero policy; removed)
}
else if (effop === OP_8) {
const x = (effarg >>> 12) & 0xF
@@ -1899,7 +1903,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,
@@ -2082,7 +2086,7 @@ const panelPatterns = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HE
// External sub-program panels: drawContents launches the sub-program synchronously.
// The sub-program draws rows 4+ and does NOT touch rows 1-3 (drawn by taut.js before launch).
// On exit, the sub-program sets _G.taut_nextPanel to request a tab switch.
// On exit, the sub-program sets _G.TAUT.UI.NEXTPANEL to request a tab switch.
function makeExternalPanelDraw(progName) {
return function(wo) {
// stop any playback first
@@ -2090,7 +2094,7 @@ function makeExternalPanelDraw(progName) {
// update the top bar
drawAlwaysOnElems()
_G.taut_nextPanel = undefined
_G.TAUT.UI.NEXTPANEL = undefined
_G.shell.execute(`${progName} ${fullPathObj.full} ${currentPanel}`)
}
}
@@ -2617,9 +2621,9 @@ while (!exitFlag) {
if (pendingExternalDraw) {
pendingExternalDraw = false
redrawPanel()
while (_G.taut_nextPanel !== undefined && _G.taut_nextPanel !== null) {
currentPanel = _G.taut_nextPanel
_G.taut_nextPanel = undefined
while (_G.TAUT.UI.NEXTPANEL !== undefined && _G.TAUT.UI.NEXTPANEL !== null) {
currentPanel = _G.TAUT.UI.NEXTPANEL
_G.TAUT.UI.NEXTPANEL = undefined
applyMuteTransition(currentPanel)
if (isExternalPanel(currentPanel)) {
con.clear(); drawAlwaysOnElems(); drawControlHint()

View File

@@ -4,7 +4,7 @@
* Rows 1-3 are owned by the parent; this program draws rows 4+.
*
* exec_args[1] = path to .taud file
* Sets _G.taut_nextPanel before returning to request a panel switch.
* Sets _G.TAUT.UI.NEXTPANEL before returning to request a panel switch.
*
* Created by minjaesong on 2026-04-27
*/
@@ -65,7 +65,7 @@ while (!done) {
if (!keyJustHit) return
if (keysym === '<TAB>') {
_G.taut_nextPanel = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT
_G.TAUT.UI.NEXTPANEL = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT
done = true
return
}

View File

@@ -0,0 +1,84 @@
if (!_G.TAUT) _G.TAUT = {};
let help = {}
////////////////////////////////////////////////////////////////////////////////////////////////////
/*
Tags:
<b> - print the text in emphasis colour (colVoiceHdr aka 230)
<c> - centre the line. If the line spans multiple lines, centre each line
<r> - align right
<l> - align left
&microtone; - replace with the brand string
&bul; - replace with bullet (\u00847u)
&ddot; - replace with double-dot (\u008419u)
&mdot; - replace with BIGDOT (\u00F9)
&updn; - up-down arrow (\u008418u)
&udlr; - four direction arrow (\u008428u\u008429u)
&keyoffsym; - pattern view key-off symbol (\u00A0\u00CD\u00CD\u00A1)
&notecutsym; - pattern view note-cut symbol (\u00A4\u00A4\u00A4\u00A4)
&nbsp; - nonbreakable space (only meaningful for typesetters)
&shy; - soft hyphen (only meaningful for typesetters)
default alignment: fully justified
*/
help.notation = `<c>CONTROL NOTATON</c>
&microtone; shortcuts differentiate normal and shifted shortcuts.
&bul;a&ddot;z : alphabet without shift-in
&bul;A&ddot;Z : alphabet with shift-in
&bul;^ : control key`
////////////////////////////////////////////////////////////////////////////////////////////////////
help.jam = `<c>NOTE JAMMING</c>
Push keys to play or insert notes.
&nbsp;w&nbsp;e&nbsp;&nbsp;&nbsp;t&nbsp;y&nbsp;u&nbsp;i
a&nbsp;s&nbsp;d&nbsp;f&nbsp;g&nbsp;h&nbsp;j&nbsp;k`
////////////////////////////////////////////////////////////////////////////////////////////////////
help.common = `<c>COMMON CONTROLS</c>
&bul;Y : play the entire song from the current cue
&bul;U : play the current cue then stop
&bul;I : play the current row
&bul;O : stop the playback
&bul;tab : switch forward a tab
&bul;TAB : switch backward a tab
&bul;q : close &microtone;`
////////////////////////////////////////////////////////////////////////////////////////////////////
help.timeline = `<c>TIMELINE VIEW</c>
Timeline has two distinct modes: view and edit mode. Two modes are toggled using the space bar.
<b>View mode</b>
&bul;Note jamming : plays the note
&bul;&udlr; : move the viewing cursor by voices and rows
&bul;pg&updn; : go to previous/next cue
&bul;W&mdot;E&mdot;R : toggle timeline view mode. W-most detailed, R-most abridged
&bul;n : toggle soloing of the selected voice
&bul;m : toggle muting of the selected voice
<b>Edit mode</b>
&bul;Note jammping : (note column) inserts the note
&bul;{&mdot;} : (note column) lower/raise a note by one octave (or period)
&bul;[&mdot;] : (note column) lower/raise a note by one unit
&bul;= : (note column) insert a key-off &keyoffsym;
&bul;^ : (note column) insert a note-cut &notecutsym;
&bul;. : remove a symbol on the selected column
&bul;bksp : delete one character on the selected column
&bul;0&ddot;9 a&ddot;f : inserts a (hexa)decimal number
&bul;^&mdot;v : (volume column) slide up/down
&bul;<&mdot;> : (panning column) slide left/right
&bul;-&mdot;= : (vol/pan col) fine slide down/up
&bul;&udlr; : move the viewing cursor by columns and rows
&bul;pg&updn; : go to previous/next cue`
////////////////////////////////////////////////////////////////////////////////////////////////////
if (!_G.TAUT.HELPMSG) _G.TAUT.HELPMSG=help;

View File

@@ -4,7 +4,7 @@
* Rows 1-3 are owned by the parent; this program draws rows 4+.
*
* exec_args[1] = path to .taud file
* Sets _G.taut_nextPanel before returning to request a panel switch.
* Sets _G.TAUT.UI.NEXTPANEL before returning to request a panel switch.
*
* Created by minjaesong on 2026-04-27
*/
@@ -65,7 +65,7 @@ while (!done) {
if (!keyJustHit) return
if (keysym === '<TAB>') {
_G.taut_nextPanel = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT
_G.TAUT.UI.NEXTPANEL = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT
done = true
return
}

View File

@@ -4,7 +4,7 @@
* Rows 1-3 are owned by the parent; this program draws rows 4+.
*
* exec_args[1] = path to .taud file
* Sets _G.taut_nextPanel before returning to request a panel switch.
* Sets _G.TAUT.UI.NEXTPANEL before returning to request a panel switch.
*
* Created by minjaesong on 2026-04-27
*/
@@ -65,7 +65,7 @@ while (!done) {
if (!keyJustHit) return
if (keysym === '<TAB>') {
_G.taut_nextPanel = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT
_G.TAUT.UI.NEXTPANEL = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT
done = true
return
}

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_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))

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
# 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,

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_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,

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)")
# 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,

View File

@@ -2049,10 +2049,23 @@ distinction (different word at a different offset), not a flag bit.
- IT: look for sample's SusLoop flag
15 Bit16 Volume envelope LOOP word
* Always-active wrap region for the volume envelope. See SUSTAIN word at offset 189 for the key-on-only wrap.
* IMPORTANT: the `b` bit gates only the LOOP wrap behaviour. The volume
envelope itself is always evaluated whenever the per-voice volume-envelope
toggle is on (default true on note-on; switched by effect S $7x / S $8x).
This matches IT/Schism (player/sndmix.c:470-502): CHN_VOLENV is independent
of ENV_VOLLOOP / ENV_VOLSUSTAIN. An envelope with no LOOP and no SUSTAIN
(both `b` bits = 0) walks once from start to its terminator and holds —
which is the IT idiom for envelope-driven decay tails.
* The cut rule: when the volume envelope walks past the last real node in
fall-through (no active sustain or loop wrap) AND that node's value is 0,
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
s (bits 12..8) : loop start index (0..24)
e (bits 4..0) : loop end index (0..24)
b (bit 5) : enable the LOOP (0 = no envelope loop)
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)
17 Bit16 Panning envelope LOOP word
@@ -2090,17 +2103,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
@@ -2147,6 +2195,36 @@ distinction (different word at a different offset), not a flag bit.
dt (bits 0..1) : Duplicate Check Type. 0=off, 1=note, 2=sample, 3=instrument.
dc (bits 2..3) : Duplicate Check Action. 0=note cut, 1=note off, 2=note fade.
* Relocated from offset 189 (which is now the volume sustain word) on 2026-05-06.
* Semantics (matches IT/Schism player/effects.c:1664-1764 csf_check_nna):
- Fires on every fresh foreground note trigger on a channel, BEFORE the
NNA-spawn step that would ghost the existing voice. Does NOT fire on
tone portamento, on note-off (0x0000), on note-cut (0xFFFE), or on
empty cells.
- The DCT/DCA values consulted belong to the EXISTING voice's instrument
(i.e. the OLD note's instrument, not the incoming note's). Different
instruments on the same channel can therefore have asymmetric duplicate
behaviour — IT-correct.
- Targets: the foreground voice on the same channel AND every background
(NNA-ghost) voice spawned earlier from that channel. Each is checked
independently against the new (instrument, note) pair.
- DCT match conditions:
off (0) : never matches; DCA never fires
note (1) : same noteVal AND same instrumentId
sample (2) : same instrumentId AND same canonical sample (matched
by samplePtr + sampleLength)
instrument (3) : same instrumentId
- DCA actions on a matching voice:
note cut (0) : fadeoutVolume := 0; voice deactivates this tick
note off (1) : keyOff := true (sustain releases; volume envelope
continues past the sustain point; if the instrument
carries a non-zero fadeout, the fadeout decay starts
per byte 172/173 semantics)
note fade (2) : noteFading := true (begin fadeout immediately, no
sustain release — sample/envelope loops continue)
- Order with NNA: applyDuplicateCheck → maybeSpawnBackgroundForNNA →
triggerNote. So when DCA flags the foreground voice, the NNA-ghost it
spawns inherits that DCA-modified state (e.g. noteFading carries over).
- The new note then triggers normally on the foreground channel.
196..255 Reserved (60 bytes free for future per-instrument fields)
@@ -2173,7 +2251,44 @@ 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.
Subsequent fixes for the 4THSYM.it hang:
(1) Implemented Schism's envelope-end + last-value-0 ⇒ cut rule
(player/sndmix.c:493-498) in AudioAdapter.kt advanceEnvelope.
(2) Volume envelope evaluation ungated from LOOP/SUSTAIN `b` bits.
IT envelopes with flags=0x01 (enabled-no-loop-no-sustain) had been
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.
[ ] 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 +2508,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

View File

@@ -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")
}
}
}

View File

@@ -1249,9 +1249,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
private fun advanceEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) {
val maxIdx = 24
// Volume envelope
val vEnvActive = (((inst.volEnvLoop ushr 5) and 1) or ((inst.volEnvSustainWord ushr 5) and 1)) != 0
if (vEnvActive && voice.volEnvOn) {
// Volume envelope. Evaluation is gated only by voice.volEnvOn (toggled by S$7/$8);
// the LOOP/SUSTAIN `b` bits gate WRAPPING behaviour, not whether the envelope runs.
// This matches Schism (player/sndmix.c:470-502): CHN_VOLENV is set independently of
// ENV_VOLLOOP / ENV_VOLSUSTAIN, so an envelope marked "enabled but no wrap" still
// walks forward — which is exactly the IT idiom of an instrument whose envelope
// shape provides the natural decay. Without this, IT envelopes with flags=0x01
// (enabled-no-loop-no-sustain) would never advance and the envelope-end-zero cut
// rule below would never fire — voices would hang forever on key-off / NNA-Continue.
// Default-only envelopes (single full-volume point at value 63 with offset 0) are
// safe to evaluate: the engine just holds at envVolume = 1.0, no audible effect.
if (voice.volEnvOn) {
resolveEnvWrap(inst.volEnvLoop, inst.volEnvSustainWord, voice.keyOff, volWrap)
val wStart = volWrap[0]
val wEnd = volWrap[1]
@@ -1265,11 +1273,21 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.envIndex = wStart
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
} else if (voice.envIndex >= maxIdx) {
voice.envVolume = (inst.volEnvelopes[maxIdx].value / 63.0).coerceIn(0.0, 1.0)
val vEnd = inst.volEnvelopes[maxIdx].value
voice.envVolume = (vEnd / 63.0).coerceIn(0.0, 1.0)
// Schism's "envelope-end + last-value-0 ⇒ cut" rule (player/sndmix.c:493-498):
// applies only in fall-through (no active sustain or loop wrap) since Schism
// suppresses fade_flag inside both wrap branches. Without this rule, instruments
// with fadeout=0 + envelope ending at 0 would silently hold their voices forever.
if (vEnd == 0 && !wrapping) voice.active = false
} else {
val vOffset = inst.volEnvelopes[voice.envIndex].offset.toDouble()
val vCurValue = inst.volEnvelopes[voice.envIndex].value
if (vOffset == 0.0) {
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
// Reached a terminator point — envelope holds here.
voice.envVolume = (vCurValue / 63.0).coerceIn(0.0, 1.0)
// Same Schism cut rule as above: only when in fall-through.
if (vCurValue == 0 && !wrapping) voice.active = false
} else {
voice.envTimeSec += tickSec
if (voice.envTimeSec >= vOffset) {
@@ -1279,7 +1297,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.envIndex = nextIdx
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
} else {
val cur = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
val cur = (vCurValue / 63.0).coerceIn(0.0, 1.0)
val nxt = (inst.volEnvelopes[(voice.envIndex + 1).coerceAtMost(maxIdx)].value / 63.0).coerceIn(0.0, 1.0)
voice.envVolume = cur + (nxt - cur) * (voice.envTimeSec / vOffset)
}
@@ -1960,11 +1978,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 +2423,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 +2487,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 +2989,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 +3102,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 +3137,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

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))))
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,