From 9524bf36e082ac25d04c136df0b037db970f4a02 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Mon, 4 May 2026 02:04:29 +0900 Subject: [PATCH] eff 8 (bitcrusher) and 9 (overdrive); *2taud.py rescales eff O on sample resampling --- TAUD_NOTE_EFFECTS.md | 104 ++++++++++++-- assets/disk0/tvdos/bin/taut.js | 2 +- terranmon.txt | 6 +- .../torvald/tsvm/peripheral/AudioAdapter.kt | 131 +++++++++++++++++- 4 files changed, 222 insertions(+), 21 deletions(-) diff --git a/TAUD_NOTE_EFFECTS.md b/TAUD_NOTE_EFFECTS.md index 555e5d7..67ac3ed 100644 --- a/TAUD_NOTE_EFFECTS.md +++ b/TAUD_NOTE_EFFECTS.md @@ -561,32 +561,106 @@ Peak at maximum settings: $7F × $FF >> 9 = $3F — the full panning range. Retr ## 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 -- y: bit depth (1..15). 8..15 has no effect on TSVM audio adapter (already operates on 8 bits) -- z: sample skip (0..255). 0: no skip, 1: use every 2nd samples, 2: use every 3rd samples, ..., 255: use every 256th samples -- `8 0000` will disable the bitcrusher -- `8 x000` will modify the clipping mode shared effect symbol '9' +- **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**, 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. +- **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` disables both stages and resets the shared clipping mode to clamp. +- `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 -**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 -- 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 -- `9 0000` will reset the overdrive -- `9 x000` will modify the clipping mode shared with effect symbol '9' +- **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. +- **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` resets the overdrive (gain returns to unity, the stage stops processing) **and** resets the shared clipping mode to clamp. +- `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. --- diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index 26c93d2..b493301 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -96,7 +96,7 @@ const fxNames = { '6':"UNIMPLEMENTED", '7':"UNIMPLEMENTED", '8':"Bitcrusher ", -'9':"UNIMPLEMENTED", +'9':"Overdrive ", A:"Tick speed ", B:"Jump to order", C:"Break pattern", diff --git a/terranmon.txt b/terranmon.txt index 2f224c5..1c9cd52 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2112,9 +2112,9 @@ TODO: [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] 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 - [ ] scale Oxxxx when samples get resampled - [ ] implement bitcrusher and overdrive (eff sym '8' and '9') + [ ] low-number voleffs are too quiet (needs elaboration and test cases) + [x] scale Oxxxx when samples get resampled + [x] implement bitcrusher and overdrive (eff sym '8' and '9') Play Data: play data are series of tracker-like instructions, visualised as: diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index bd15e08..cbf703b 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -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): // 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, // C=0x0C pattern break, D=0x0D vol slide, E=0x0E pitch // 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 { const val OP_NONE = 0x00 const val OP_1 = 0x01 + const val OP_8 = 0x08 + const val OP_9 = 0x09 const val OP_A = 0x0A const val OP_B = 0x0B const val OP_C = 0x0C @@ -1408,6 +1411,71 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { 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 * 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.pfEnvOn = src.pfEnvOn 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 return v } @@ -1849,6 +1924,46 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { ts.amigaMode = (flags and 2) != 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 -> { val tr = (rawArg ushr 8) and 0xFF if (tr != 0) playhead.tickRate = tr @@ -2402,7 +2517,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { for (voice in ts.voices) { if (!voice.active || voice.muted) continue 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 // 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 @@ -2434,7 +2549,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { for (bg in ts.backgroundVoices) { if (!bg.active || bg.muted) continue 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 swingScale = 1.0 + bg.randomVolBias / 255.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 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. val mem = MemorySlots() }