From bc235ebb174b9dce619bf1eaa06dc0f66c7061e3 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sat, 9 May 2026 16:04:57 +0900 Subject: [PATCH] Taud: Amiga interpolation mode and LPF toggle --- TAUD_NOTE_EFFECTS.md | 18 +- assets/disk0/tvdos/bin/taut.js | 4 +- assets/disk0/tvdos/bin/taut_helpmsg.js | 2 +- terranmon.txt | 1 + .../torvald/tsvm/peripheral/AudioAdapter.kt | 188 ++++++++++++++++-- 5 files changed, 189 insertions(+), 24 deletions(-) diff --git a/TAUD_NOTE_EFFECTS.md b/TAUD_NOTE_EFFECTS.md index 48ccc2f..d4e41ec 100644 --- a/TAUD_NOTE_EFFECTS.md +++ b/TAUD_NOTE_EFFECTS.md @@ -828,6 +828,15 @@ When both effects 8 and 9 are active on the same voice the chain is **filter → S is a multiplexing opcode; the **high nibble of the high byte** selects the sub-effect, and the remainder is the sub-argument. +# S $0x00 — Amiga LPF/LED Switch + +**Plain.** `$0100` turns filter off; `$0000` turns it on. The parameter of the filter is somewhat dependent on the current interpolation mode: follows Amiga 1200 LPF on 1200 mode, Amiga 500 LPF on 500 mode. For other interpolation modes, this command is no-op. (see § Effects That Modifies Global Behaviour) + +**Compatibility.** ST3/IT `S00`/`S01` and PT `E00`/`E01` maps directly. To actually hear the effect, the interpolation mode must be set to one of the two Amiga modes. + +**Implementation.** Per-playhead boolean `ledFilterOn` (default off). Writes from row are gated on `interpolationMode ∈ {Amiga 500, Amiga 1200}`; in linear / no-interp / default modes the filter chain is bypassed entirely so the toggle is a silent no-op. The post-mix LPF chain runs on the stereo bus (left/right state per playhead) before dithering: in Amiga 500 mode a 1-pole RC LPF (R = 360 Ω, C = 0.1 µF, fc ≈ 4421 Hz) is always applied; in Amiga 1200 mode that LPF is bypassed (cutoff ~34 kHz, well above 32 kHz Nyquist — matches `pt2_paula.c`). When the LED toggle is on, an additional 2-pole Sallen-Key LPF (R1=R2=10 kΩ, C1=6800 pF, C2=3900 pF, fc ≈ 3091 Hz, Q ≈ 0.660) is run after the mode LPF. Coefficients precomputed once at SAMPLING_RATE; recurrence follows musicdsp.org #38 with `pt2_rcfilters.c` parameter mapping. + + ## S $1x00 — PT/ST3/IT Glissando control **Plain.** `$1000` turns glissando off; `$1100` turns it on. When on, tone portamento (G) output is quantised to the nearest semitone ($0155 approximation) before being sent to the mixer. The internal G pitch counter still advances smoothly; only the audible pitch steps. **This command is implemented sorely for ST3/IT compatibility** and therefore only works in 12-TET context. @@ -1113,13 +1122,16 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o **Plain.** Sets mixer-wide behaviour flags. Available flags are: - 0b 0000 00ff + 0b 0000 rr ff - ff = 0: 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. - 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 tone mode (MONOTONE compat). **E, F, and G arguments are stored as Hz/tick** (a signed change in audible frequency per song tick), and the engine converts the channel's stored 4096-TET pitch back to a frequency, adds/subtracts the argument, then converts back to 4096-TET. Reference is fixed at 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, which matches MONOTONE's MT_PLAY.PAS `notesHz` table (A0 = 27.5 Hz, equal-temperament). Unlike Amiga mode, *all three* slide effects use the new arithmetic — Monotone's `1xx`, `2xx`, and `3xx` are all in Hz/tick (see MTSRC/MT_PLAY.PAS:606-630). -(Bits 2-7 are reserved. Bit 2 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.) +- rr = 0: Yes interpolation. Actual interpolation algorithm is implementation-dependent, but recommended to use either Fast Sinc or Linear. +- rr = 1: No interpolation. +- rr = 2: Amiga 500 interpolation. +- rr = 3: Amiga 1200 interpolation. ### Volume Fadeout @@ -1204,7 +1216,7 @@ This table maps each PT effect to its Taud equivalent. Arguments follow PT's two | `B $xx` | `B $00xx` | Position jump | | `C $xx` | Volume column `0.$xx` | Set volume | | `D $xx` | `C $00xx` (after BCD decode) | Pattern break | -| `E $0x` | `S $000x` | (UNIMPLEMENTED) Set filter | +| `E $0x` | `S $0x00` | Set low-pass filter | | `E $1x` | `F $F00x` (Amiga mode, `f` set) | Fine pitch slide up; raw PT period units, applied in period space at tick 0 | | `E $2x` | `E $F00x` (Amiga mode, `f` set) | Fine pitch slide down; raw PT period units, applied in period space at tick 0 | | `E $3x` | `S $1x00` | Glissando control | diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index dcbfa5e..3138825 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -120,7 +120,7 @@ P:"Chan.panslide", Q:"Retrigger ", R:"Tremolo ", S:"Special ", -S0:"UNIMPLEMENTED", // PT: Set audio filter +S0:"Amiga Filter ", S1:"Gliss. ctrl ", S2:"Sample tune ", S3:"Vibrato LFO ", @@ -140,7 +140,7 @@ T:"Tempo ", U:"Fine vibrato ", V:"Global volume", W:"G.Vol Slide ", -X:"UNIMPLEMENTED", // IT: 8-bit channel panning. Use S80xx instead +X:"UNIMPLEMENTED", // IT: 8-bit channel panning. Use S 80xx instead Y:"Panbrello ", Z:"UNIMPLEMENTED", // IT: MIDI macro } diff --git a/assets/disk0/tvdos/bin/taut_helpmsg.js b/assets/disk0/tvdos/bin/taut_helpmsg.js index 211712f..a6c8e53 100644 --- a/assets/disk0/tvdos/bin/taut_helpmsg.js +++ b/assets/disk0/tvdos/bin/taut_helpmsg.js @@ -107,7 +107,7 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using ACCIDENTALS &demisharp; ♯ &doublesharp; &triplesharp; &quadsharp; &demiflat; ♭ &doubleflat; &tripleflat;  &accuptick;  &accupup;  &accdntick;  &accdndn; -C  c  x  cx xx B  b  bb bbb ^  ^^ v  vv +C  c  cx x  xx B  b  bb bbb ^  ^^ v  vv ` //////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/terranmon.txt b/terranmon.txt index bfd9dc5..d9d23ed 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2394,6 +2394,7 @@ TODO: when no V column is present. Engine + all four `*2taud` converters updated; legacy `.taud` files (byte 196 == 0) fall back to the previous "row volume default = 63" behaviour. + [ ] Physical Presence order 1F chn 2: note cuts unexpectedly fast? TODO - list of demo songs that MUST ship with Microtone: * 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 24d9c28..2d31801 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -18,6 +18,7 @@ import kotlin.math.roundToInt import kotlin.math.PI import kotlin.math.cos import kotlin.math.sin +import kotlin.math.exp private class RenderRunnable(val playhead: AudioAdapter.Playhead) : Runnable { private fun printdbg(msg: Any) { @@ -151,6 +152,70 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { const val SAMPLE_BANK_COUNT: Int = 16 // 16 × 512 K = 8 MB const val SAMPLE_BIN_TOTAL: Long = SAMPLE_BANK_SIZE * SAMPLE_BANK_COUNT const val SAMPLE_BANK_MASK: Int = SAMPLE_BANK_COUNT - 1 + + // Interpolation modes (TAUD_NOTE_EFFECTS.md §1, bits 2-3 of global behaviour flags). + // 0 = default (Fast Sinc, 16-tap windowed sinc), 1 = none (zero-order hold), + // 2 = Amiga 500 (ZOH + A500 1-pole LPF), 3 = Amiga 1200 (ZOH + A1200 LPF — bypassed). + // Amiga modes additionally apply a 2-pole Sallen-Key "LED" LPF when ts.ledFilterOn, + // which is toggled by S $0000 / S $0100 (TAUD_NOTE_EFFECTS.md §"S $0x00"). + const val INTERP_DEFAULT = 0 + const val INTERP_NONE = 1 + const val INTERP_A500 = 2 + const val INTERP_A1200 = 3 + + // Fast Sinc — 16-tap windowed sinc with 1024 sub-sample positions. + // Mirrors MilkyTracker's MIXER_SINCTABLE (ResamplerSinc.h: WIDTH=8, 1024-step table, + // window = 0.5 + 0.5·cos(πi / WIDTH·step)). Coefficients are symmetric so we only + // store half the kernel; the second half is index-mirrored at lookup time. + const val SINC_WIDTH = 8 + const val SINC_PRECISION_SHIFT = 10 + const val SINC_PRECISION = 1 shl SINC_PRECISION_SHIFT // 1024 + private val SINC_TABLE: DoubleArray = run { + val n = SINC_PRECISION * SINC_WIDTH + val out = DoubleArray(n) + val winFreq = PI / SINC_WIDTH / SINC_PRECISION + out[0] = 1.0 + for (i in 1 until n) { + val t = i * PI / SINC_PRECISION + val win = 0.5 + 0.5 * cos(winFreq * i) + out[i] = sin(t) / t * win + } + out + } + + /** Windowed-sinc kernel value for fractional offset `frac ∈ [0,1)` and signed tap [−WIDTH+1, WIDTH]. */ + private fun sincTap(frac: Double, tap: Int): Double { + val x = (tap - frac) * SINC_PRECISION // distance in sub-sample units + val ax = kotlin.math.abs(x) + val idx = ax.toInt() + if (idx >= SINC_PRECISION * SINC_WIDTH - 1) return 0.0 + // Linear interpolation between adjacent table entries for sub-sub-sample precision. + val f = ax - idx + return SINC_TABLE[idx] * (1.0 - f) + SINC_TABLE[idx + 1] * f + } + + // Amiga filter coefficients (precomputed at SAMPLING_RATE = 32 kHz, see pt2_paula.c + // and pt2_rcfilters.c). All filters operate on the post-mix stereo bus per playhead. + // + // A500_LP : 1-pole RC LPF, R = 360 Ω, C = 0.1 µF → fc ≈ 4420.97 Hz + // LED_LP : 2-pole Sallen-Key, R1=R2=10 kΩ, C1=6800 pF, C2=3900 pF + // → fc ≈ 3090.53 Hz, Q ≈ 0.660225 + // A1200_LP: cutoff ~34.4 kHz, well above Nyquist at 32 kHz → bypassed (matches pt2-clone). + private val AMIGA_A500_LP_FC = 4420.971 + private val AMIGA_LED_FC = 3090.533 + private val AMIGA_LED_Q = 0.660225 + + // 1-pole coefficients (Direct Form II) for A500 LPF. + val AMIGA_A500_B1: Double = exp(-2.0 * PI * AMIGA_A500_LP_FC / SAMPLING_RATE) + val AMIGA_A500_A0: Double = 1.0 - AMIGA_A500_B1 + + // 2-pole biquad coefficients (musicdsp.org #38) for LED Sallen-Key LPF. + private val AMIGA_LED_A_BASE = 1.0 / kotlin.math.tan(PI * AMIGA_LED_FC / SAMPLING_RATE) + private val AMIGA_LED_B_BASE = 1.0 / AMIGA_LED_Q + val AMIGA_LED_A1: Double = 1.0 / (1.0 + AMIGA_LED_B_BASE * AMIGA_LED_A_BASE + AMIGA_LED_A_BASE * AMIGA_LED_A_BASE) + val AMIGA_LED_A2: Double = 2.0 * AMIGA_LED_A1 + val AMIGA_LED_B1: Double = 2.0 * (1.0 - AMIGA_LED_A_BASE * AMIGA_LED_A_BASE) * AMIGA_LED_A1 + val AMIGA_LED_B2: Double = (1.0 - AMIGA_LED_B_BASE * AMIGA_LED_A_BASE + AMIGA_LED_A_BASE * AMIGA_LED_A_BASE) * AMIGA_LED_A1 } // Memory map (terranmon.txt:1985-1997, updated 2026-05-08): @@ -1629,32 +1694,54 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { return pitchDelta } - private fun fetchTrackerSample(voice: Voice, inst: TaudInst): Double { + /** + * Read one PCM sample (in [-1, 1]) at integer index [idx], honouring the instrument's + * funk-repeat mask. Out-of-range indices are clamped to the sample bounds; the + * caller is responsible for wrapping into a loop region first if loop semantics apply. + */ + private fun readSamplePoint(inst: TaudInst, idx: Int, sampleLen: Int, binMax: Int): Double { + val i = idx.coerceIn(0, sampleLen - 1) + var b = sampleBin[(inst.samplePtr + i).coerceAtMost(binMax).toLong()].toUint() + if (inst.funkMask != null && inst.sampleLoopEnd > inst.sampleLoopStart) { + val ls = inst.sampleLoopStart + if (i in ls until inst.sampleLoopEnd && inst.funkBit(i - ls)) b = b xor 0xFF + } + return (b - 127.5) / 127.5 + } + + private fun fetchTrackerSample(voice: Voice, inst: TaudInst, interpMode: Int): Double { if (inst.index == 0) return 0.0 - val basePtr = inst.samplePtr val sampleLen = inst.sampleLength.coerceAtLeast(1) val loopStart = inst.sampleLoopStart.toDouble() val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0) val binMax = (SAMPLE_BIN_TOTAL - 1).toInt() // 8 MB pool, addressed via samplePtr directly (not banked) val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1) - val i1 = (i0 + 1).coerceAtMost(sampleLen - 1) val frac = voice.samplePos - i0.toDouble() - var b0 = sampleBin[(basePtr + i0).coerceAtMost(binMax).toLong()].toUint() - var b1 = sampleBin[(basePtr + i1).coerceAtMost(binMax).toLong()].toUint() - // S$Fx funk repeat: bit-invert (XOR 0xFF) bytes whose loop-relative index - // is set in funkMask. Mirrors PT2's `*p = -1 - *p` (full bitwise NOT) — the - // mask is a non-destructive overlay so the source sample stays pristine. - // Only meaningful when the sample has a loop region. - if (inst.funkMask != null && inst.sampleLoopEnd > inst.sampleLoopStart) { - val ls = inst.sampleLoopStart - if (i0 in ls until inst.sampleLoopEnd && inst.funkBit(i0 - ls)) b0 = b0 xor 0xFF - if (i1 in ls until inst.sampleLoopEnd && inst.funkBit(i1 - ls)) b1 = b1 xor 0xFF + + // Interpolation: + // INTERP_DEFAULT (0): 16-tap windowed sinc (Fast Sinc; MilkyTracker MIXER_SINCTABLE) + // INTERP_NONE (1): nearest-neighbour + // INTERP_A500/A1200 (2/3): zero-order hold per Paula; LPF applied at mix stage + // Edge clamping: out-of-range taps are clipped to sample bounds (acceptable smear + // at sample edges; matches MilkyTracker's outSideLoop fallback). + val sample: Double = when (interpMode) { + INTERP_DEFAULT -> { + var acc = 0.0 + // Taps span [i0 - WIDTH + 1, i0 + WIDTH], with the kernel centred on i0+frac. + for (j in -SINC_WIDTH + 1 .. SINC_WIDTH) { + val coeff = sincTap(frac, j) + if (coeff != 0.0) acc += readSamplePoint(inst, i0 + j, sampleLen, binMax) * coeff + } + acc + } + INTERP_NONE, INTERP_A500, INTERP_A1200 -> + // Paula-style ZOH — emit the integer-indexed sample byte without + // sub-sample fade. Aliasing is removed by the post-mix Amiga LPFs. + readSamplePoint(inst, i0, sampleLen, binMax) + else -> readSamplePoint(inst, i0, sampleLen, binMax) } - val s0 = (b0 - 127.5) / 127.5 - val s1 = (b1 - 127.5) / 127.5 - val sample = s0 + (s1 - s0) * frac // While ramping out at sample end, hold position so the mixer keeps emitting the // clamped last-sample value with decaying gain — no further advance, no re-trigger @@ -2131,9 +2218,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). // bits 0-1 (ff): 0=linear pitch, 1=Amiga period, 2=linear frequency (Hz/tick), // 3=reserved + // bits 2-3 (rr): 0=Fast Sinc, 1=none, 2=Amiga 500, 3=Amiga 1200 // Panning law is fixed to the equal-energy; no runtime selection. val flags = rawArg ushr 8 ts.toneMode = flags and 3 + ts.interpolationMode = (flags ushr 2) and 3 } EffectOp.OP_8 -> { // 8 $xyzz — Bitcrusher. See TAUD_NOTE_EFFECTS.md §8. @@ -2421,6 +2510,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { val sub = (arg ushr 12) and 0xF val x = (arg ushr 8) and 0xF when (sub) { + 0x0 -> { + // S $0000 = LED on, S $0100 = LED off (PT E00 / E01 — pt2_replayer.c:608 + // computes filterOn = (cmd & 1) ^ 1, so x=0 → on, x=1 → off). + // Only meaningful in Amiga interpolation modes; on linear / no-interp the + // filter chain is bypassed so writes are silent no-ops. + if (ts.interpolationMode == INTERP_A500 || ts.interpolationMode == INTERP_A1200) { + ts.ledFilterOn = (x == 0) + } + } 0x1 -> voice.glissandoOn = (x != 0) 0x2 -> { voice.noteVal = (voice.noteVal + FINETUNE_OFFSET[x]).coerceIn(1, 0xFFFD) @@ -2832,7 +2930,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 = applyTaudVoiceFx(voice, applyVoiceFilter(voice, fetchTrackerSample(voice, voiceInst))) + val s = applyTaudVoiceFx(voice, applyVoiceFilter(voice, fetchTrackerSample(voice, voiceInst, ts.interpolationMode))) 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 @@ -2863,7 +2961,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 = applyTaudVoiceFx(bg, applyVoiceFilter(bg, fetchTrackerSample(bg, bgInst))) + val s = applyTaudVoiceFx(bg, applyVoiceFilter(bg, fetchTrackerSample(bg, bgInst, ts.interpolationMode))) val instGv = bgInst.instGlobalVolume / 255.0 val swingScale = 1.0 + bg.randomVolBias / 255.0 val effEnvVol = if (bg.volEnvOn) bg.envVolume else 1.0 @@ -2886,6 +2984,40 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { mixR += s * vol * rGain * rampGain } + // Amiga interpolation modes: post-mix LPF chain (matches pt2-clone Paula stage). + // INTERP_A500 applies the 1-pole RC LPF (~4421 Hz). INTERP_A1200 has a cutoff + // above Nyquist so its LPF is bypassed. The 2-pole "LED" filter (~3091 Hz, Q≈0.66) + // is added on either Amiga mode when ts.ledFilterOn (S $0000 = on, S $0100 = off). + // No-op for INTERP_DEFAULT and INTERP_NONE so non-Amiga modes pay no cost. + when (ts.interpolationMode) { + INTERP_A500 -> { + ts.amigaLPStateL = mixL * AMIGA_A500_A0 + ts.amigaLPStateL * AMIGA_A500_B1 + ts.amigaLPStateR = mixR * AMIGA_A500_A0 + ts.amigaLPStateR * AMIGA_A500_B1 + mixL = ts.amigaLPStateL + mixR = ts.amigaLPStateR + if (ts.ledFilterOn) { + val sl = ts.amigaLEDStateL; val sr = ts.amigaLEDStateR + val outL = mixL * AMIGA_LED_A1 + sl[0] * AMIGA_LED_A2 + sl[1] * AMIGA_LED_A1 - sl[2] * AMIGA_LED_B1 - sl[3] * AMIGA_LED_B2 + val outR = mixR * AMIGA_LED_A1 + sr[0] * AMIGA_LED_A2 + sr[1] * AMIGA_LED_A1 - sr[2] * AMIGA_LED_B1 - sr[3] * AMIGA_LED_B2 + sl[1] = sl[0]; sl[0] = mixL; sl[3] = sl[2]; sl[2] = outL + sr[1] = sr[0]; sr[0] = mixR; sr[3] = sr[2]; sr[2] = outR + mixL = outL; mixR = outR + } + } + INTERP_A1200 -> { + // A1200 1-pole LPF cutoff (~34 kHz) is above Nyquist at SAMPLING_RATE = 32 kHz, + // so it is bypassed (matches pt2_paula.c: useLowpassFilter = false). + if (ts.ledFilterOn) { + val sl = ts.amigaLEDStateL; val sr = ts.amigaLEDStateR + val outL = mixL * AMIGA_LED_A1 + sl[0] * AMIGA_LED_A2 + sl[1] * AMIGA_LED_A1 - sl[2] * AMIGA_LED_B1 - sl[3] * AMIGA_LED_B2 + val outR = mixR * AMIGA_LED_A1 + sr[0] * AMIGA_LED_A2 + sr[1] * AMIGA_LED_A1 - sr[2] * AMIGA_LED_B1 - sr[3] * AMIGA_LED_B2 + sl[1] = sl[0]; sl[0] = mixL; sl[3] = sl[2]; sl[2] = outL + sr[1] = sr[0]; sr[0] = mixR; sr[3] = sr[2]; sr[2] = outR + mixL = outL; mixR = outR + } + } + } + ts.mixLeft[n] = mixL.toFloat().coerceIn(-1.0f, 1.0f) ts.mixRight[n] = mixR.toFloat().coerceIn(-1.0f, 1.0f) } @@ -3272,6 +3404,21 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { // 3 = reserved var toneMode = 0 + // Interpolation mode (TAUD_NOTE_EFFECTS.md §1, bits 2-3 of global behaviour flags). + // 0=Fast Sinc default, 1=none, 2=Amiga 500, 3=Amiga 1200. See AudioAdapter.INTERP_*. + var interpolationMode = INTERP_DEFAULT + // Amiga "LED" 2-pole LPF on/off (S $0000 = on, S $0100 = off; PT E00/E01). + // Only applies when interpolationMode is INTERP_A500 or INTERP_A1200. + var ledFilterOn = false + + // Per-playhead Amiga filter state. Live on the post-mix stereo bus so voice + // come/go does not reset filter history. All zeroed on resetParams(). + var amigaLPStateL = 0.0 + var amigaLPStateR = 0.0 + // 2-pole biquad delay line: [in_z1, in_z2, out_z1, out_z2] for L and R. + val amigaLEDStateL = DoubleArray(4) + val amigaLEDStateR = DoubleArray(4) + // 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 pendingRowJump = -1 // -1 = none; otherwise the row index for the next pattern @@ -3383,6 +3530,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { initialGlobalFlags = byte trackerState?.let { ts -> ts.toneMode = byte and 3 + ts.interpolationMode = (byte ushr 2) and 3 } } 8 -> { bpm = byte + 25 } @@ -3417,6 +3565,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { ts.sexWinningChannel = -1 ts.finePatternDelayExtra = 0 ts.toneMode = initialGlobalFlags and 3 + ts.interpolationMode = (initialGlobalFlags ushr 2) and 3 + ts.ledFilterOn = false + ts.amigaLPStateL = 0.0; ts.amigaLPStateR = 0.0 + ts.amigaLEDStateL.fill(0.0); ts.amigaLEDStateR.fill(0.0) ts.voices.forEach { it.active = false it.channelVolume = 0x3F