instrument volume fadeout

This commit is contained in:
minjaesong
2026-05-02 21:13:00 +09:00
parent 5e6ac17146
commit 1bbf0de381
6 changed files with 54 additions and 16 deletions

View File

@@ -864,9 +864,9 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
## 1 $xx00 — Global behaviour flags ## 1 $xx00 — Global behaviour flags
**Plain.** Sets how the mixer should treat the panning. Available flags are: **Plain.** Sets mixer-wide behaviour flags. Available flags are:
0b 0000 00fp 0b 0000 0mfp
- 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.
@@ -874,6 +874,9 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
- f unset: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch. - f unset: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch.
- f set: 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. - f set: 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.
- 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.
**Implementation.** **Implementation.**
- Panning-linear: - Panning-linear:
- L_gain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0 - L_gain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0

View File

@@ -472,7 +472,7 @@ class ITInstrument:
# *_env_sustain: int (16-bit, 0b 0ut sssss pcb eeeee), 0 = no envelope # *_env_sustain: int (16-bit, 0b 0ut sssss pcb eeeee), 0 = no envelope
# pf_is_filter: bool — pf envelope mode (False = pitch, True = filter) # pf_is_filter: bool — pf envelope mode (False = pitch, True = filter)
# ifc / ifr : initial filter cutoff / resonance (0..127, 0 if not set) # ifc / ifr : initial filter cutoff / resonance (0..127, 0 if not set)
# fadeout : 0..1024 (IT FadeOut field, applied per tick after key-off) # fadeout : 0..1024 (IT FadeOut field; doubled to 0..2048 when written to Taud's 12-bit field)
# pps / ppc : pitch-pan separation (signed -32..+32) and centre note (0..119) # pps / ppc : pitch-pan separation (signed -32..+32) and centre note (0..119)
# rv / rp : random volume swing (0..100) / random pan swing (0..64) # rv / rp : random volume swing (0..100) / random pan swing (0..64)
# nna : new note action (IT 0=cut, 1=continue, 2=note off, 3=note fade) # nna : new note action (IT 0=cut, 1=continue, 2=note off, 3=note fade)
@@ -1222,7 +1222,9 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
pan_sus = idata.get('pan_sus', 0) pan_sus = idata.get('pan_sus', 0)
pf_sus = idata.get('pf_sus', 0) pf_sus = idata.get('pf_sus', 0)
inst_gv = idata.get('inst_gv', 0xFF) inst_gv = idata.get('inst_gv', 0xFF)
fadeout = idata.get('fadeout', 0) & 0x3FF # 10-bit (low 8 + high 2) # IT fadeout (0..1024) is in half-units of Taud's per-tick scale; double to align with
# FT2 / native Taud (12-bit, engine subtracts fadeout/65536 per tick). Clamp defensively.
fadeout = min(0xFFF, (idata.get('fadeout', 0) & 0xFFFF) * 2)
struct.pack_into('<H', inst_bin, base + 15, vol_sus & 0xFFFF) struct.pack_into('<H', inst_bin, base + 15, vol_sus & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 17, pan_sus & 0xFFFF) struct.pack_into('<H', inst_bin, base + 17, pan_sus & 0xFFFF)
@@ -1741,6 +1743,7 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
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".
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

@@ -758,7 +758,9 @@ 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).
flags_byte = 0x02 # 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
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

@@ -831,7 +831,9 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
# 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)
flags_byte = 0x00 if h.linear_slides else 0x02 # 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
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

@@ -2008,13 +2008,12 @@ Instrument bin: Registry for 256 instruments, formatted as:
- IT: look for sample's SusLoop flag - IT: look for sample's SusLoop flag
Bit16 Volume envelope sustain/loops and flags Bit16 Volume envelope sustain/loops and flags
* Sustain is implemented by enabling 't' flag. FastTracker has no 'Sus Loop' but only 'Sus Point'; use same value for start and end index * Sustain is implemented by enabling 't' flag. FastTracker has no 'Sus Loop' but only 'Sus Point'; use same value for start and end index
0b 0ut sssss pcb eeeee 0b 0ut sssss 0cb eeeee
s: sustain/loop start index s: sustain/loop start index
e: sustain/loop end index e: sustain/loop end index
b: use envelope b: use envelope
c: envelope carry c: envelope carry
p: (IT) fadeout is zero; (XM) fadeout is cut
t: the loop must sustain (key-off escapes the loop) t: the loop must sustain (key-off escapes the loop)
u: set to enable the sustain/loop u: set to enable the sustain/loop
@@ -2055,10 +2054,18 @@ Instrument bin: Registry for 256 instruments, formatted as:
* 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
- 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
Uint8 Volume Fadeout low bits (IT: 1..256; XM: 0..255) Uint8 Volume Fadeout low bits
Bit8 Fadeout and vibrato Bit8 Fadeout and vibrato
0b 0000 ffff 0b 0000 ffff
f: Volume Fadeout high bits 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.
Uint8 Volume swing (0..255 full range) Uint8 Volume swing (0..255 full range)
Uint8 Vibrato speed 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
@@ -2073,7 +2080,7 @@ Instrument bin: Registry for 256 instruments, formatted as:
Uint8 Pan swing (0..255 full range) Uint8 Pan swing (0..255 full range)
Uint8 Default cutoff (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud) Uint8 Default cutoff (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud)
Uint8 Default resonance (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud) Uint8 Default resonance (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud)
Uint16 Sample detune (in 4096-TET unit) (XM finetune scale need to be rescaled accordingly) Uint16 Sample detune (in 4096-TET unit) (FT2 finetune scale need to be rescaled accordingly)
Bit8 Instrument Flag Bit8 Instrument Flag
0b 000 www nn 0b 000 www nn
n: New note action. 00: note off, 01: note cut, 10: continue, 11: note fade (arranged differently to IT) n: New note action. 00: note off, 01: note cut, 10: continue, 11: note fade (arranged differently to IT)
@@ -2098,7 +2105,7 @@ TODO:
[x] implement sample loop sustain [x] implement sample loop sustain
"Caveat: on a foreground voice, key-off (row.note == 0x0000) currently sets voice.active = false at AudioAdapter.kt:1713, which silences the channel immediately. Sustain-loop escape therefore only takes effect on background voices spawned by NNA "Note Off" — which matches the IT idiom of layering a new note over a sustained one. Let me know if you also want the foreground key-off to keep the voice playing through fadeout." "Caveat: on a foreground voice, key-off (row.note == 0x0000) currently sets voice.active = false at AudioAdapter.kt:1713, which silences the channel immediately. Sustain-loop escape therefore only takes effect on background voices spawned by NNA "Note Off" — which matches the IT idiom of layering a new note over a sustained one. Let me know if you also want the foreground key-off to keep the voice playing through fadeout."
[x] cue and pattern compression of the Taud format (taud_common.py, taud.mjs) [x] cue and pattern compression of the Taud format (taud_common.py, taud.mjs)
[ ] figure out how IT (8 bits) and FT2 (12 bits) handles volume fadeout numbers, and come up with a compatible Taud spec, then implement [x] figure out how IT (0..256) and FT2 (0..FFF + cut) handles volume fadeout numbers, and come up with a compatible Taud spec, then implement
[ ] implement bitcrusher (eff sym '8') [ ] implement bitcrusher (eff sym '8')
@@ -2314,6 +2321,11 @@ 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 0mfp
p: panning law (0=linear, 1=equal-power)
f: tone mode (0=linear pitch slides, 1=Amiga period slides)
m: fadeout-zero policy (0=IT — stored fadeout 0 means no fadeout;
1=FT2 — stored fadeout 0 means cut on key-off)
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

@@ -1754,9 +1754,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)
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_A -> { EffectOp.OP_A -> {
val tr = (rawArg ushr 8) and 0xFF val tr = (rawArg ushr 8) and 0xFF
@@ -2141,12 +2143,16 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Refresh biquad filter coefficients once per tick (only recomputes when changed). // Refresh biquad filter coefficients once per tick (only recomputes when changed).
refreshVoiceFilter(voice) refreshVoiceFilter(voice)
// Volume fadeout: after key-off OR Note-Fade NNA, decrement by inst.volumeFadeout / 1024 per tick. // Volume fadeout: after key-off OR Note-Fade NNA, decrement by inst.volumeFadeout / 65536 per tick.
// The 10-bit fadeout value is split across volumeFadeoutLow + low nibble of fadeoutHigh. // The 12-bit fadeout value is split across volumeFadeoutLow + low nibble of fadeoutHigh.
// Stored 0: with fadeoutCutOnZero (FT2 mode) the voice is cut on key-off; otherwise no fadeout (IT mode).
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) {
voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / 1024.0).coerceAtLeast(0.0) voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / 65536.0).coerceAtLeast(0.0)
if (voice.fadeoutVolume <= 0.0) voice.active = false
} else if (ts.fadeoutCutOnZero) {
voice.active = false
} }
} }
@@ -2198,7 +2204,11 @@ 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) {
bg.fadeoutVolume = (bg.fadeoutVolume - fadeStep / 1024.0).coerceAtLeast(0.0) bg.fadeoutVolume = (bg.fadeoutVolume - fadeStep / 65536.0).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.
@@ -2662,6 +2672,7 @@ 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
@@ -2772,7 +2783,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} } } }
7 -> if (isPcmMode) { pcmUpload = true } else { 7 -> if (isPcmMode) { pcmUpload = true } else {
initialGlobalFlags = byte initialGlobalFlags = byte
trackerState?.let { ts -> ts.panLaw = byte and 1; ts.amigaMode = (byte and 2) != 0 } trackerState?.let { ts ->
ts.panLaw = byte and 1
ts.amigaMode = (byte and 2) != 0
ts.fadeoutCutOnZero = (byte and 4) != 0
}
} }
8 -> { bpm = byte + 24 } 8 -> { bpm = byte + 24 }
9 -> { tickRate = byte } 9 -> { tickRate = byte }
@@ -2807,6 +2822,7 @@ 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