mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
Taud: Amiga interpolation mode and LPF toggle
This commit is contained in:
@@ -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 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
|
## 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.
|
**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:
|
**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 = 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 = 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).
|
- 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
|
### 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 |
|
| `B $xx` | `B $00xx` | Position jump |
|
||||||
| `C $xx` | Volume column `0.$xx` | Set volume |
|
| `C $xx` | Volume column `0.$xx` | Set volume |
|
||||||
| `D $xx` | `C $00xx` (after BCD decode) | Pattern break |
|
| `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 $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 $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 |
|
| `E $3x` | `S $1x00` | Glissando control |
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ P:"Chan.panslide",
|
|||||||
Q:"Retrigger ",
|
Q:"Retrigger ",
|
||||||
R:"Tremolo ",
|
R:"Tremolo ",
|
||||||
S:"Special ",
|
S:"Special ",
|
||||||
S0:"UNIMPLEMENTED", // PT: Set audio filter
|
S0:"Amiga Filter ",
|
||||||
S1:"Gliss. ctrl ",
|
S1:"Gliss. ctrl ",
|
||||||
S2:"Sample tune ",
|
S2:"Sample tune ",
|
||||||
S3:"Vibrato LFO ",
|
S3:"Vibrato LFO ",
|
||||||
@@ -140,7 +140,7 @@ T:"Tempo ",
|
|||||||
U:"Fine vibrato ",
|
U:"Fine vibrato ",
|
||||||
V:"Global volume",
|
V:"Global volume",
|
||||||
W:"G.Vol Slide ",
|
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 ",
|
Y:"Panbrello ",
|
||||||
Z:"UNIMPLEMENTED", // IT: MIDI macro
|
Z:"UNIMPLEMENTED", // IT: MIDI macro
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using
|
|||||||
|
|
||||||
<b>ACCIDENTALS</b>
|
<b>ACCIDENTALS</b>
|
||||||
&demisharp; ♯ &doublesharp; &triplesharp; &quadsharp; &demiflat; ♭ &doubleflat; &tripleflat; &accuptick; &accupup; &accdntick; &accdndn;
|
&demisharp; ♯ &doublesharp; &triplesharp; &quadsharp; &demiflat; ♭ &doubleflat; &tripleflat; &accuptick; &accupup; &accdntick; &accdndn;
|
||||||
<b>C c x cx xx B b bb bbb ^ ^^ v vv</b>
|
<b>C c cx x xx B b bb bbb ^ ^^ v vv</b>
|
||||||
`
|
`
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|||||||
@@ -2394,6 +2394,7 @@ TODO:
|
|||||||
when no V column is present. Engine + all four `*2taud` converters
|
when no V column is present. Engine + all four `*2taud` converters
|
||||||
updated; legacy `.taud` files (byte 196 == 0) fall back to the
|
updated; legacy `.taud` files (byte 196 == 0) fall back to the
|
||||||
previous "row volume default = 63" behaviour.
|
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:
|
TODO - list of demo songs that MUST ship with Microtone:
|
||||||
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
|
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import kotlin.math.roundToInt
|
|||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.exp
|
||||||
|
|
||||||
private class RenderRunnable(val playhead: AudioAdapter.Playhead) : Runnable {
|
private class RenderRunnable(val playhead: AudioAdapter.Playhead) : Runnable {
|
||||||
private fun printdbg(msg: Any) {
|
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_BANK_COUNT: Int = 16 // 16 × 512 K = 8 MB
|
||||||
const val SAMPLE_BIN_TOTAL: Long = SAMPLE_BANK_SIZE * SAMPLE_BANK_COUNT
|
const val SAMPLE_BIN_TOTAL: Long = SAMPLE_BANK_SIZE * SAMPLE_BANK_COUNT
|
||||||
const val SAMPLE_BANK_MASK: Int = SAMPLE_BANK_COUNT - 1
|
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):
|
// 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
|
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
|
if (inst.index == 0) return 0.0
|
||||||
|
|
||||||
val basePtr = inst.samplePtr
|
|
||||||
val sampleLen = inst.sampleLength.coerceAtLeast(1)
|
val sampleLen = inst.sampleLength.coerceAtLeast(1)
|
||||||
val loopStart = inst.sampleLoopStart.toDouble()
|
val loopStart = inst.sampleLoopStart.toDouble()
|
||||||
val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0)
|
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 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 i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1)
|
||||||
val i1 = (i0 + 1).coerceAtMost(sampleLen - 1)
|
|
||||||
val frac = voice.samplePos - i0.toDouble()
|
val frac = voice.samplePos - i0.toDouble()
|
||||||
var b0 = sampleBin[(basePtr + i0).coerceAtMost(binMax).toLong()].toUint()
|
|
||||||
var b1 = sampleBin[(basePtr + i1).coerceAtMost(binMax).toLong()].toUint()
|
// Interpolation:
|
||||||
// S$Fx funk repeat: bit-invert (XOR 0xFF) bytes whose loop-relative index
|
// INTERP_DEFAULT (0): 16-tap windowed sinc (Fast Sinc; MilkyTracker MIXER_SINCTABLE)
|
||||||
// is set in funkMask. Mirrors PT2's `*p = -1 - *p` (full bitwise NOT) — the
|
// INTERP_NONE (1): nearest-neighbour
|
||||||
// mask is a non-destructive overlay so the source sample stays pristine.
|
// INTERP_A500/A1200 (2/3): zero-order hold per Paula; LPF applied at mix stage
|
||||||
// Only meaningful when the sample has a loop region.
|
// Edge clamping: out-of-range taps are clipped to sample bounds (acceptable smear
|
||||||
if (inst.funkMask != null && inst.sampleLoopEnd > inst.sampleLoopStart) {
|
// at sample edges; matches MilkyTracker's outSideLoop fallback).
|
||||||
val ls = inst.sampleLoopStart
|
val sample: Double = when (interpMode) {
|
||||||
if (i0 in ls until inst.sampleLoopEnd && inst.funkBit(i0 - ls)) b0 = b0 xor 0xFF
|
INTERP_DEFAULT -> {
|
||||||
if (i1 in ls until inst.sampleLoopEnd && inst.funkBit(i1 - ls)) b1 = b1 xor 0xFF
|
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
|
// 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
|
// 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).
|
// 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),
|
// bits 0-1 (ff): 0=linear pitch, 1=Amiga period, 2=linear frequency (Hz/tick),
|
||||||
// 3=reserved
|
// 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.
|
// Panning law is fixed to the equal-energy; no runtime selection.
|
||||||
val flags = rawArg ushr 8
|
val flags = rawArg ushr 8
|
||||||
ts.toneMode = flags and 3
|
ts.toneMode = flags and 3
|
||||||
|
ts.interpolationMode = (flags ushr 2) and 3
|
||||||
}
|
}
|
||||||
EffectOp.OP_8 -> {
|
EffectOp.OP_8 -> {
|
||||||
// 8 $xyzz — Bitcrusher. See TAUD_NOTE_EFFECTS.md §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 sub = (arg ushr 12) and 0xF
|
||||||
val x = (arg ushr 8) and 0xF
|
val x = (arg ushr 8) and 0xF
|
||||||
when (sub) {
|
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)
|
0x1 -> voice.glissandoOn = (x != 0)
|
||||||
0x2 -> {
|
0x2 -> {
|
||||||
voice.noteVal = (voice.noteVal + FINETUNE_OFFSET[x]).coerceIn(1, 0xFFFD)
|
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) {
|
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 = applyTaudVoiceFx(voice, applyVoiceFilter(voice, fetchTrackerSample(voice, voiceInst)))
|
val s = applyTaudVoiceFx(voice, applyVoiceFilter(voice, fetchTrackerSample(voice, voiceInst, ts.interpolationMode)))
|
||||||
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
|
||||||
@@ -2863,7 +2961,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 = 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 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
|
||||||
@@ -2886,6 +2984,40 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
mixR += s * vol * rGain * rampGain
|
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.mixLeft[n] = mixL.toFloat().coerceIn(-1.0f, 1.0f)
|
||||||
ts.mixRight[n] = mixR.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
|
// 3 = reserved
|
||||||
var toneMode = 0
|
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).
|
// 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 pendingOrderJump = -1 // -1 = none; otherwise the order index to jump to
|
||||||
var pendingRowJump = -1 // -1 = none; otherwise the row index for the next pattern
|
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
|
initialGlobalFlags = byte
|
||||||
trackerState?.let { ts ->
|
trackerState?.let { ts ->
|
||||||
ts.toneMode = byte and 3
|
ts.toneMode = byte and 3
|
||||||
|
ts.interpolationMode = (byte ushr 2) and 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
8 -> { bpm = byte + 25 }
|
8 -> { bpm = byte + 25 }
|
||||||
@@ -3417,6 +3565,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
ts.sexWinningChannel = -1
|
ts.sexWinningChannel = -1
|
||||||
ts.finePatternDelayExtra = 0
|
ts.finePatternDelayExtra = 0
|
||||||
ts.toneMode = initialGlobalFlags and 3
|
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 {
|
ts.voices.forEach {
|
||||||
it.active = false
|
it.active = false
|
||||||
it.channelVolume = 0x3F
|
it.channelVolume = 0x3F
|
||||||
|
|||||||
Reference in New Issue
Block a user