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