eff 8 (bitcrusher) and 9 (overdrive); *2taud.py rescales eff O on sample resampling

This commit is contained in:
minjaesong
2026-05-04 02:04:29 +09:00
parent 8e17256224
commit 9524bf36e0
4 changed files with 222 additions and 21 deletions

View File

@@ -561,32 +561,106 @@ Peak at maximum settings: $7F × $FF >> 9 = $3F — the full panning range. Retr
## 8 $xyzz — Bitcrusher ## 8 $xyzz — Bitcrusher
**Plain.** Applies Bitcrusher to the current voice. **Plain.** Applies a bitcrusher to the current voice. The crusher has two independent stages — a sample-rate reducer (`zz`, sample-and-hold) and a bit-depth quantiser (`y`) — and shares its clipping mode (`x`) with effect 9 (Overdrive). The two stages are orthogonal: enabling either is sufficient to engage the effect, and either can be active alone.
- x: clipping mode. 0: clamp, 1: fold, 2: wrap - **x — clipping mode** (shared with effect 9): `0` clamp (hard limit at ±1.0), `1` fold (ping-pong around ±1.0; values outside the range mirror back symmetrically), `2` wrap (saw-tooth wrap mod 2; ±1 are fixed points so no DC step at the boundary). Values 3..F are reserved and treated as clamp.
- y: bit depth (1..15). 8..15 has no effect on TSVM audio adapter (already operates on 8 bits) - **y — bit depth**, range $1..$F. `0` disables the quantiser stage. `1` reduces the voice to a 1-bit (sign-only) signal. `8..F` are accepted but produce no audible quantisation, since TSVM's mix bus is already 8-bit; they are reserved for future hardware revisions.
- z: sample skip (0..255). 0: no skip, 1: use every 2nd samples, 2: use every 3rd samples, ..., 255: use every 256th samples - **zz — sample skip**, range $00..$FF. `0` disables skip; non-zero N holds the post-quantiser output for N additional output samples (i.e. emit one fresh sample every N+1). The held value is the bitcrusher's *output*, so the sample-and-hold is downstream of the quantiser and the shared clipper.
- `8 0000` will disable the bitcrusher - `8 $0000` disables both stages and resets the shared clipping mode to clamp.
- `8 x000` will modify the clipping mode shared effect symbol '9' - `8 $x000` updates only the shared clipping mode and leaves the active depth/skip undisturbed — useful for switching between clamp/fold/wrap mid-pattern without retyping the whole argument. The same form on effect 9 has identical semantics.
**Compatibility.** Unique to Taud. No compatible equivalent exists. **Compatibility.** Unique to Taud — no ST3/IT/PT equivalent. The effect has no memory: every cell that names effect 8 must spell out its full argument (apart from the `$x000` shorthand described above). `8 $1100` ⇒ 1-bit, no skip, fold-clipped — a useful sanity check pattern.
**Implementation.** TODO **Implementation.** Per-voice state: `bitcrusherDepth` (0..15; 0 = quantiser off), `bitcrusherSkip` (0..255), `bitcrusherCounter` (mod skip+1), `bitcrusherHeld` (last emitted sample), and `clipMode` (0..2, shared with effect 9). On row parse:
```
on row parse (8 $xyzz):
voice.clipMode = x & 3
if arg == $0000:
voice.bitcrusherDepth = 0
voice.bitcrusherSkip = 0
voice.bitcrusherCounter = 0
else if y == 0 and zz == 0:
# x000 — clip-mode-only update; preserve depth/skip/counter
pass
else:
voice.bitcrusherDepth = y
voice.bitcrusherSkip = zz
voice.bitcrusherCounter = 0
```
On every output sample, after `applyVoiceFilter` and *after* the overdrive stage of effect 9:
```
on output sample (per voice):
if voice.bitcrusherCounter == 0:
s' = sample # post-overdrive input
if 1 ≤ voice.bitcrusherDepth ≤ 7:
s' = clip(s', voice.clipMode) # ensure in-range before quantising
levels = (1 << voice.bitcrusherDepth) - 1
q = round((s' + 1) × 0.5 × levels) # nearest integer; clamp to [0, levels]
s' = (q / levels) × 2 - 1
voice.bitcrusherHeld = s'
out = s'
else:
out = voice.bitcrusherHeld
if voice.bitcrusherSkip > 0:
voice.bitcrusherCounter = (voice.bitcrusherCounter + 1) mod (voice.bitcrusherSkip + 1)
```
The clipper is shared between effects 8 and 9 and is implemented as a single helper:
```
clip(x, mode):
if mode == 1: # fold (triangle)
while x > +1: x = 2 - x
while x < -1: x = -2 - x
return x
if mode == 2: # wrap (saw, period 2)
v = ((x + 1) mod 2 + 2) mod 2
return v - 1
return clamp(x, -1, +1) # mode 0 (and reserved values)
```
The voice-FX state is preserved verbatim by the NNA-ghost copier, so the post-NNA tail of a note keeps the same timbre as the foreground voice that spawned it.
--- ---
## 9 $x0zz — Overdrive ## 9 $x0zz — Overdrive
**Plain.** Amplify the volume. **Plain.** Amplifies the voice's post-filter signal and routes it through the shared clipper. With `x = 0` (clamp) the effect is a hard-knee soft-clipping distortion; with `x = 1` (fold) it becomes a wave-folder; with `x = 2` (wrap) it produces aggressive aliased fuzz with sawtooth-style discontinuities at the rails. Volume is *not* re-normalised after clipping — `9 $00FF` clamp-clipped plays at roughly the same loudness as the dry voice once everything saturates. The middle nibble is reserved and must be zero.
- x: clipping mode. 0: clamp, 1: fold, 2: wrap - **x — clipping mode** (shared with effect 8): `0` clamp, `1` fold, `2` wrap (see effect 8 for the precise transfer functions). Values 3..F are reserved and treated as clamp.
- z: amplification. $00: 1x amplification (no extra volume), $01: 17/16 amplification, $02: 18/16 amplification, $10: 2x amplification (+ 6 dBFS), $F0: 16x amplification, $FF: 16.9375x amplification - **zz — amplification index**, range $00..$FF. The applied gain is `(16 + zz) / 16`, so `$00` is 1.0× (effect inactive), `$10` is 2.0× (+6 dBFS), `$F0` is 16.0× (+24 dBFS), and `$FF` is 16.9375× (≈ +24.55 dBFS).
- `9 0000` will reset the overdrive - `9 $0000` resets the overdrive (gain returns to unity, the stage stops processing) **and** resets the shared clipping mode to clamp.
- `9 x000` will modify the clipping mode shared with effect symbol '9' - `9 $x000` updates only the shared clipping mode and leaves the active amplification undisturbed — symmetric with `8 $x000`.
**Compatibility.** Unique to Taud. No compatible equivalent exists. **Compatibility.** Unique to Taud — no ST3/IT/PT equivalent. The effect has no memory.
**Implementation.** TODO **Implementation.** Per-voice state: `overdriveAmp` (0..255; 0 = effect off) and `clipMode` (shared with effect 8). On row parse:
```
on row parse (9 $x0zz):
voice.clipMode = x & 3
if arg == $0000:
voice.overdriveAmp = 0
else if zz == 0:
# x000 — clip-mode-only update; preserve amp
pass
else:
voice.overdriveAmp = zz
```
On every output sample, after `applyVoiceFilter` and *before* the bitcrusher stage of effect 8:
```
on output sample (per voice):
if voice.overdriveAmp > 0:
sample = sample × (16 + voice.overdriveAmp) / 16
sample = clip(sample, voice.clipMode)
```
When both effects 8 and 9 are active on the same voice the chain is **filter → overdrive (×gain → clip) → bitcrusher (bit-depth quantise → sample-skip hold)**. Because the clipper is shared, changing `clipMode` from either effect propagates to the other on the next sample — there is one mode per voice, not one per stage.
--- ---

View File

@@ -96,7 +96,7 @@ const fxNames = {
'6':"UNIMPLEMENTED", '6':"UNIMPLEMENTED",
'7':"UNIMPLEMENTED", '7':"UNIMPLEMENTED",
'8':"Bitcrusher ", '8':"Bitcrusher ",
'9':"UNIMPLEMENTED", '9':"Overdrive ",
A:"Tick speed ", A:"Tick speed ",
B:"Jump to order", B:"Jump to order",
C:"Break pattern", C:"Break pattern",

View File

@@ -2112,9 +2112,9 @@ TODO:
[x] 4THSYM.it: pitchbend is wrong, some notes keep playing (loudly!) even if new notes are emitted [x] 4THSYM.it: pitchbend is wrong, some notes keep playing (loudly!) even if new notes are emitted
[x] `*2taud.py`: some notes are emitted with wrong volume-set command. Tested with GSLINGER.mod: on order 0x15 channel 1, mod2taud.py emits volume 8 -- also many of the effects are dropped. Suggested solution: currently all converters write default volume to the voleff when original modules (.mod/.s3m/.it) specify nothing; we should also write nothing and let the engine resolve the value just like other trackers do (also we now have "Instrument Global Volume" on instrument definition unlike the other time). This bug may affecting other formats, not just mod2taud.py, as well [x] `*2taud.py`: some notes are emitted with wrong volume-set command. Tested with GSLINGER.mod: on order 0x15 channel 1, mod2taud.py emits volume 8 -- also many of the effects are dropped. Suggested solution: currently all converters write default volume to the voleff when original modules (.mod/.s3m/.it) specify nothing; we should also write nothing and let the engine resolve the value just like other trackers do (also we now have "Instrument Global Volume" on instrument definition unlike the other time). This bug may affecting other formats, not just mod2taud.py, as well
[x] nearly_there_.mod: `C#5 SD300 / ... / C-5 SD200 / A#4 / G#4 (at tickspeed 4)`: every `C-5 SD200` (there are four occurances) gets skipped [x] nearly_there_.mod: `C#5 SD300 / ... / C-5 SD200 / A#4 / G#4 (at tickspeed 4)`: every `C-5 SD200` (there are four occurances) gets skipped
[ ] low-number voleffs are too quiet [ ] low-number voleffs are too quiet (needs elaboration and test cases)
[ ] scale Oxxxx when samples get resampled [x] scale Oxxxx when samples get resampled
[ ] implement bitcrusher and overdrive (eff sym '8' and '9') [x] implement bitcrusher and overdrive (eff sym '8' and '9')
Play Data: play data are series of tracker-like instructions, visualised as: Play Data: play data are series of tracker-like instructions, visualised as:

View File

@@ -1093,6 +1093,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// //
// Effect opcodes follow base-36 digit values (see TAUD_NOTE_EFFECTS.md): // Effect opcodes follow base-36 digit values (see TAUD_NOTE_EFFECTS.md):
// 0x00 : no effect // 0x00 : no effect
// 0x08, 0x09 : Taud-only voice FX (8 = bitcrusher, 9 = overdrive; see §8/§9).
// 0x0A..0x23 : letters A..Z (A=0x0A speed, B=0x0B order jump, // 0x0A..0x23 : letters A..Z (A=0x0A speed, B=0x0B order jump,
// C=0x0C pattern break, D=0x0D vol slide, E=0x0E pitch // C=0x0C pattern break, D=0x0D vol slide, E=0x0E pitch
// down, F=0x0F pitch up, G=0x10 tone porta, // down, F=0x0F pitch up, G=0x10 tone porta,
@@ -1141,6 +1142,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
private object EffectOp { private object EffectOp {
const val OP_NONE = 0x00 const val OP_NONE = 0x00
const val OP_1 = 0x01 const val OP_1 = 0x01
const val OP_8 = 0x08
const val OP_9 = 0x09
const val OP_A = 0x0A const val OP_A = 0x0A
const val OP_B = 0x0B const val OP_B = 0x0B
const val OP_C = 0x0C const val OP_C = 0x0C
@@ -1408,6 +1411,71 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
return y0 return y0
} }
/**
* Apply Taud's voice-level overdrive (effect 9) and bitcrusher (effect 8) to a
* post-filter sample in [-1, 1]. Call once per output sample, per active voice.
*
* Order is overdrive → shared clipper → bitcrusher (sample-rate reduce → bit depth quantise).
* If neither effect is engaged the input is returned unchanged. See TAUD_NOTE_EFFECTS.md §8/§9.
*/
private fun applyTaudVoiceFx(voice: Voice, sample: Double): Double {
var s = sample
val overdriveOn = voice.overdriveAmp > 0
// 8..15 collapses to a no-op on TSVM's 8-bit mixdown, but we still allow the bit field to
// ride alongside an active sample-skip — only depth in 1..7 actually quantises.
val depthQuantises = voice.bitcrusherDepth in 1..7
val skipActive = voice.bitcrusherSkip > 0
val crushActive = depthQuantises || skipActive
if (overdriveOn) {
s *= (16 + voice.overdriveAmp) / 16.0
s = clipSample(s, voice.clipMode)
}
if (crushActive) {
if (voice.bitcrusherCounter == 0) {
if (depthQuantises) {
val levels = (1 shl voice.bitcrusherDepth) - 1
val clipped = clipSample(s, voice.clipMode).coerceIn(-1.0, 1.0)
val q = kotlin.math.floor((clipped + 1.0) * 0.5 * levels + 0.5)
.coerceIn(0.0, levels.toDouble())
s = (q / levels) * 2.0 - 1.0
}
voice.bitcrusherHeld = s
} else {
s = voice.bitcrusherHeld
}
if (skipActive) {
voice.bitcrusherCounter = (voice.bitcrusherCounter + 1) % (voice.bitcrusherSkip + 1)
} else {
voice.bitcrusherCounter = 0
}
}
return s
}
/**
* Shared clipper for effects 8 and 9. Modes: 0 clamp, 1 fold (triangle), 2 wrap (sawtooth).
* Inputs outside [-1, 1] are folded/wrapped back into range; well-behaved samples pass through.
*/
private fun clipSample(x: Double, mode: Int): Double = when (mode and 3) {
1 -> {
// Ping-pong fold around ±1. Loops handle arbitrary overdrive ratios up to 16.94×
// without runaway: each iteration shrinks |v| by 2, so worst-case ~5 passes.
var v = x
while (v > 1.0) v = 2.0 - v
while (v < -1.0) v = -2.0 - v
v
}
2 -> {
// Period-2 wrap, mapped so that x = ±1 land on themselves (no DC step at boundary).
var v = ((x + 1.0) % 2.0)
if (v < 0.0) v += 2.0
v - 1.0
}
else -> x.coerceIn(-1.0, 1.0) // mode 0 (and any reserved value) — clamp
}
/** /**
* IT-style auto-vibrato: returns a 4096-TET pitch delta to add to the * IT-style auto-vibrato: returns a 4096-TET pitch delta to add to the
* playback note for the current tick, and advances the LFO phase. * playback note for the current tick, and advances the LFO phase.
@@ -1701,6 +1769,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
v.panEnvOn = src.panEnvOn v.panEnvOn = src.panEnvOn
v.pfEnvOn = src.pfEnvOn v.pfEnvOn = src.pfEnvOn
v.noteFading = src.noteFading v.noteFading = src.noteFading
// Voice-FX state (effects 8/9): preserve so the NNA-ghosted tail keeps the same timbre.
v.clipMode = src.clipMode
v.bitcrusherDepth = src.bitcrusherDepth
v.bitcrusherSkip = src.bitcrusherSkip
v.bitcrusherCounter = src.bitcrusherCounter
v.bitcrusherHeld = src.bitcrusherHeld
v.overdriveAmp = src.overdriveAmp
v.sourceChannel = channel v.sourceChannel = channel
return v return v
} }
@@ -1849,6 +1924,46 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
ts.amigaMode = (flags and 2) != 0 ts.amigaMode = (flags and 2) != 0
ts.fadeoutCutOnZero = (flags and 4) != 0 ts.fadeoutCutOnZero = (flags and 4) != 0
} }
EffectOp.OP_8 -> {
// 8 $xyzz — Bitcrusher. See TAUD_NOTE_EFFECTS.md §8.
// x = clipping mode (shared with effect 9): 0 clamp, 1 fold, 2 wrap.
// y = bit depth 1..15 (0 disables quantiser; 8..15 no-op on TSVM 8-bit output).
// zz = sample-skip count 0..255.
// 8 $0000 disables the bitcrusher entirely.
// 8 $x000 only updates the shared clipping mode (does not disturb depth/skip).
val x = (rawArg ushr 12) and 0xF
val y = (rawArg ushr 8) and 0xF
val z = rawArg and 0xFF
voice.clipMode = x and 3
if (rawArg == 0) {
voice.bitcrusherDepth = 0
voice.bitcrusherSkip = 0
voice.bitcrusherCounter = 0
} else if (y == 0 && z == 0) {
// x000 — clip mode only, leave bitcrusher state alone.
} else {
voice.bitcrusherDepth = y
voice.bitcrusherSkip = z
voice.bitcrusherCounter = 0
}
}
EffectOp.OP_9 -> {
// 9 $x0zz — Overdrive. See TAUD_NOTE_EFFECTS.md §9.
// x = clipping mode (shared with effect 8): 0 clamp, 1 fold, 2 wrap.
// zz = amplification index 0..255; gain = (16 + zz) / 16 ⇒ $00=1×, $10=2×, $FF≈16.94×.
// 9 $0000 disables the overdrive entirely.
// 9 $x000 only updates the shared clipping mode.
val x = (rawArg ushr 12) and 0xF
val z = rawArg and 0xFF
voice.clipMode = x and 3
if (rawArg == 0) {
voice.overdriveAmp = 0
} else if (z == 0) {
// x000 — clip mode only.
} else {
voice.overdriveAmp = z
}
}
EffectOp.OP_A -> { EffectOp.OP_A -> {
val tr = (rawArg ushr 8) and 0xFF val tr = (rawArg ushr 8) and 0xFF
if (tr != 0) playhead.tickRate = tr if (tr != 0) playhead.tickRate = tr
@@ -2402,7 +2517,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
for (voice in ts.voices) { for (voice in ts.voices) {
if (!voice.active || voice.muted) continue if (!voice.active || voice.muted) continue
val voiceInst = instruments[voice.instrumentId] val voiceInst = instruments[voice.instrumentId]
val s = applyVoiceFilter(voice, fetchTrackerSample(voice, voiceInst)) val s = applyTaudVoiceFx(voice, applyVoiceFilter(voice, fetchTrackerSample(voice, voiceInst)))
val instGv = voiceInst.instGlobalVolume / 255.0 val instGv = voiceInst.instGlobalVolume / 255.0
// Volume swing bias (random per-trigger, ±randomVolBias of 0..255 units folded into the 0..63 row volume). // Volume swing bias (random per-trigger, ±randomVolBias of 0..255 units folded into the 0..63 row volume).
val swingScale = 1.0 + voice.randomVolBias / 255.0 val swingScale = 1.0 + voice.randomVolBias / 255.0
@@ -2434,7 +2549,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
for (bg in ts.backgroundVoices) { for (bg in ts.backgroundVoices) {
if (!bg.active || bg.muted) continue if (!bg.active || bg.muted) continue
val bgInst = instruments[bg.instrumentId] val bgInst = instruments[bg.instrumentId]
val s = applyVoiceFilter(bg, fetchTrackerSample(bg, bgInst)) val s = applyTaudVoiceFx(bg, applyVoiceFilter(bg, fetchTrackerSample(bg, bgInst)))
val instGv = bgInst.instGlobalVolume / 255.0 val instGv = bgInst.instGlobalVolume / 255.0
val swingScale = 1.0 + bg.randomVolBias / 255.0 val swingScale = 1.0 + bg.randomVolBias / 255.0
val effEnvVol = if (bg.volEnvOn) bg.envVolume else 1.0 val effEnvVol = if (bg.volEnvOn) bg.envVolume else 1.0
@@ -2770,6 +2885,18 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var panColSlideRight = 0 var panColSlideRight = 0
var panColSlideLeft = 0 var panColSlideLeft = 0
// Bitcrusher (effect 8) and Overdrive (effect 9) — Taud-only voice FX.
// clipMode is shared between both effects: 0=clamp, 1=fold, 2=wrap. See TAUD_NOTE_EFFECTS.md §8/§9.
var clipMode = 0
// Bitcrusher: depth in 1..15 (0 = quantiser disabled; 8..15 are no-op for TSVM 8-bit output).
var bitcrusherDepth = 0
// Bitcrusher: sample-skip count. 0 = no skip, N = hold post-FX output for N additional samples.
var bitcrusherSkip = 0
var bitcrusherCounter = 0 // sample-rate-reduction counter, mod (skip + 1)
var bitcrusherHeld = 0.0 // last emitted post-quantisation value, held when skipping
// Overdrive: 0 = disabled. Otherwise gain = (16 + amp) / 16, range 17/16..271/16 (≈16.94×).
var overdriveAmp = 0
// Effect-recall memory for this voice. // Effect-recall memory for this voice.
val mem = MemorySlots() val mem = MemorySlots()
} }