From 1bbf0de38133200faedbadb3dc362e4db6dea375 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sat, 2 May 2026 21:13:00 +0900 Subject: [PATCH] instrument volume fadeout --- TAUD_NOTE_EFFECTS.md | 7 +++-- it2taud.py | 7 +++-- mod2taud.py | 4 ++- s3m2taud.py | 4 ++- terranmon.txt | 22 ++++++++++++---- .../torvald/tsvm/peripheral/AudioAdapter.kt | 26 +++++++++++++++---- 6 files changed, 54 insertions(+), 16 deletions(-) diff --git a/TAUD_NOTE_EFFECTS.md b/TAUD_NOTE_EFFECTS.md index 4954fc2..ddba22a 100644 --- a/TAUD_NOTE_EFFECTS.md +++ b/TAUD_NOTE_EFFECTS.md @@ -864,9 +864,9 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o ## 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 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 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.** - Panning-linear: - L_gain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0 diff --git a/it2taud.py b/it2taud.py index 96195a7..5c28757 100644 --- a/it2taud.py +++ b/it2taud.py @@ -472,7 +472,7 @@ class ITInstrument: # *_env_sustain: int (16-bit, 0b 0ut sssss pcb eeeee), 0 = no envelope # pf_is_filter: bool — pf envelope mode (False = pitch, True = filter) # 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) # 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) @@ -1222,7 +1222,9 @@ def build_sample_inst_bin_it(samples_or_proxy: list, pan_sus = idata.get('pan_sus', 0) pf_sus = idata.get('pf_sus', 0) 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(' 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). - 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_offset=song_offset, num_voices=n_channels, diff --git a/s3m2taud.py b/s3m2taud.py index 6c4da0f..ea647a5 100644 --- a/s3m2taud.py +++ b/s3m2taud.py @@ -831,7 +831,9 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes: # 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 = 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_offset=song_offset, num_voices=C, diff --git a/terranmon.txt b/terranmon.txt index d169f2e..ca687ad 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2008,13 +2008,12 @@ Instrument bin: Registry for 256 instruments, formatted as: - IT: look for sample's SusLoop flag 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 - 0b 0ut sssss pcb eeeee + 0b 0ut sssss 0cb eeeee s: sustain/loop start index e: sustain/loop end index b: use envelope c: envelope carry - p: (IT) fadeout is zero; (XM) fadeout is cut t: the loop must sustain (key-off escapes the 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 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 - Uint8 Volume Fadeout low bits (IT: 1..256; XM: 0..255) + Uint8 Volume Fadeout low bits Bit8 Fadeout and vibrato 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. Uint8 Volume swing (0..255 full range) Uint8 Vibrato speed * 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 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) - 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 0b 000 www nn 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 "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) - [ ] 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') @@ -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 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 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 * 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/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 9658d85..3851054 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -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). // 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) val flags = rawArg ushr 8 ts.panLaw = flags and 1 ts.amigaMode = (flags and 2) != 0 + ts.fadeoutCutOnZero = (flags and 4) != 0 } EffectOp.OP_A -> { 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). refreshVoiceFilter(voice) - // Volume fadeout: after key-off OR Note-Fade NNA, decrement by inst.volumeFadeout / 1024 per tick. - // The 10-bit fadeout value is split across volumeFadeoutLow + low nibble of fadeoutHigh. + // Volume fadeout: after key-off OR Note-Fade NNA, decrement by inst.volumeFadeout / 65536 per tick. + // 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) { val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8) 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) { val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8) 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. @@ -2662,6 +2672,7 @@ 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 @@ -2772,7 +2783,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } } 7 -> if (isPcmMode) { pcmUpload = true } else { 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 } 9 -> { tickRate = byte } @@ -2807,6 +2822,7 @@ 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