From eddd65fa13ea7598cfa08b5cbd8ed9d06b226b87 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sat, 23 May 2026 19:03:53 +0900 Subject: [PATCH] taud: more interpolation --- terranmon.txt | 2 +- .../torvald/tsvm/peripheral/AudioAdapter.kt | 134 ++++++++++++++++-- 2 files changed, 123 insertions(+), 13 deletions(-) diff --git a/terranmon.txt b/terranmon.txt index 8807ccf..c02b5f9 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2649,7 +2649,7 @@ Endianness: Little Uint8 Flags for Global Behaviour (effect symbol '1') 0b 000 rrr ff ff: tone mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: reserved) - rrr: interpolation mode (0: default, 1: none, 2: Amiga 500, 3: Amiga 1200, 4: SNES 4-tap Gaussian, 5: preserve delta modulation) + rrr: interpolation mode (0: default, 1: none, 2: Amiga 500, 3: Amiga 1200, 4: SNES 4-tap Gaussian, 5: NES DPCM simulation) Uint8 Song global volume * ImpulseTracker has range of 0..128; multiply by (255/128) then round to int Uint8 Song mixing volume diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index e1b5861..f17a7aa 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -166,15 +166,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { 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). + // Interpolation modes (TAUD_NOTE_EFFECTS.md §1, bits 2-4 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). + // 2 = Amiga 500 (ZOH + A500 1-pole LPF), 3 = Amiga 1200 (ZOH + A1200 LPF — bypassed), + // 4 = SNES 4-tap gaussian (BRR-style, preserves the int16 mid-sum overflow quirk), + // 5 = NES 2A03 DPCM (1-bit sigma-delta on a 7-bit ±2-stepping counter). // 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 + const val INTERP_DEFAULT = 0 + const val INTERP_NONE = 1 + const val INTERP_A500 = 2 + const val INTERP_A1200 = 3 + const val INTERP_SNES = 4 + const val INTERP_NES_DPCM = 5 // Fast Sinc — 6-tap windowed sinc with 1024 sub-sample positions. // Mirrors MilkyTracker's MIXER_SINCTABLE (ResamplerSinc.h: WIDTH=8, 1024-step table, @@ -207,6 +211,49 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { return SINC_TABLE[idx] * (1.0 - f) + SINC_TABLE[idx + 1] * f } + // SNES BRR 4-tap gaussian table (512 entries, monotonically rising 0x000..0x519). + // Mirrors xander-haj/z3c snes/dsp.c gaussValues[]. The DSP indexes this table with + // four phases derived from an 8-bit fractional offset: gauss[offset] (newest tap), + // gauss[0x100+offset] (olds — peaks near the playhead), gauss[0x1ff-offset] (olders — + // contains the peak value 0x519), and gauss[0xff-offset] (oldest). Coefficients sum + // to ~2049 at every phase, so the SNES DSP right-shifts the sum by 1 after a + // deliberate int16 wrap-around on the partial sum (audible as the famous + // "SNES gauss overflow chirp" on loud samples — preserved here for authenticity). + private val SNES_GAUSS = intArrayOf( + 0x000, 0x000, 0x000, 0x000, 0x000, 0x000, 0x000, 0x000, 0x000, 0x000, 0x000, 0x000, 0x000, 0x000, 0x000, 0x000, + 0x001, 0x001, 0x001, 0x001, 0x001, 0x001, 0x001, 0x001, 0x001, 0x001, 0x001, 0x002, 0x002, 0x002, 0x002, 0x002, + 0x002, 0x002, 0x003, 0x003, 0x003, 0x003, 0x003, 0x004, 0x004, 0x004, 0x004, 0x004, 0x005, 0x005, 0x005, 0x005, + 0x006, 0x006, 0x006, 0x006, 0x007, 0x007, 0x007, 0x008, 0x008, 0x008, 0x009, 0x009, 0x009, 0x00A, 0x00A, 0x00A, + 0x00B, 0x00B, 0x00B, 0x00C, 0x00C, 0x00D, 0x00D, 0x00E, 0x00E, 0x00F, 0x00F, 0x00F, 0x010, 0x010, 0x011, 0x011, + 0x012, 0x013, 0x013, 0x014, 0x014, 0x015, 0x015, 0x016, 0x017, 0x017, 0x018, 0x018, 0x019, 0x01A, 0x01B, 0x01B, + 0x01C, 0x01D, 0x01D, 0x01E, 0x01F, 0x020, 0x020, 0x021, 0x022, 0x023, 0x024, 0x024, 0x025, 0x026, 0x027, 0x028, + 0x029, 0x02A, 0x02B, 0x02C, 0x02D, 0x02E, 0x02F, 0x030, 0x031, 0x032, 0x033, 0x034, 0x035, 0x036, 0x037, 0x038, + 0x03A, 0x03B, 0x03C, 0x03D, 0x03E, 0x040, 0x041, 0x042, 0x043, 0x045, 0x046, 0x047, 0x049, 0x04A, 0x04C, 0x04D, + 0x04E, 0x050, 0x051, 0x053, 0x054, 0x056, 0x057, 0x059, 0x05A, 0x05C, 0x05E, 0x05F, 0x061, 0x063, 0x064, 0x066, + 0x068, 0x06A, 0x06B, 0x06D, 0x06F, 0x071, 0x073, 0x075, 0x076, 0x078, 0x07A, 0x07C, 0x07E, 0x080, 0x082, 0x084, + 0x086, 0x089, 0x08B, 0x08D, 0x08F, 0x091, 0x093, 0x096, 0x098, 0x09A, 0x09C, 0x09F, 0x0A1, 0x0A3, 0x0A6, 0x0A8, + 0x0AB, 0x0AD, 0x0AF, 0x0B2, 0x0B4, 0x0B7, 0x0BA, 0x0BC, 0x0BF, 0x0C1, 0x0C4, 0x0C7, 0x0C9, 0x0CC, 0x0CF, 0x0D2, + 0x0D4, 0x0D7, 0x0DA, 0x0DD, 0x0E0, 0x0E3, 0x0E6, 0x0E9, 0x0EC, 0x0EF, 0x0F2, 0x0F5, 0x0F8, 0x0FB, 0x0FE, 0x101, + 0x104, 0x107, 0x10B, 0x10E, 0x111, 0x114, 0x118, 0x11B, 0x11E, 0x122, 0x125, 0x129, 0x12C, 0x130, 0x133, 0x137, + 0x13A, 0x13E, 0x141, 0x145, 0x148, 0x14C, 0x150, 0x153, 0x157, 0x15B, 0x15F, 0x162, 0x166, 0x16A, 0x16E, 0x172, + 0x176, 0x17A, 0x17D, 0x181, 0x185, 0x189, 0x18D, 0x191, 0x195, 0x19A, 0x19E, 0x1A2, 0x1A6, 0x1AA, 0x1AE, 0x1B2, + 0x1B7, 0x1BB, 0x1BF, 0x1C3, 0x1C8, 0x1CC, 0x1D0, 0x1D5, 0x1D9, 0x1DD, 0x1E2, 0x1E6, 0x1EB, 0x1EF, 0x1F3, 0x1F8, + 0x1FC, 0x201, 0x205, 0x20A, 0x20F, 0x213, 0x218, 0x21C, 0x221, 0x226, 0x22A, 0x22F, 0x233, 0x238, 0x23D, 0x241, + 0x246, 0x24B, 0x250, 0x254, 0x259, 0x25E, 0x263, 0x267, 0x26C, 0x271, 0x276, 0x27B, 0x280, 0x284, 0x289, 0x28E, + 0x293, 0x298, 0x29D, 0x2A2, 0x2A6, 0x2AB, 0x2B0, 0x2B5, 0x2BA, 0x2BF, 0x2C4, 0x2C9, 0x2CE, 0x2D3, 0x2D8, 0x2DC, + 0x2E1, 0x2E6, 0x2EB, 0x2F0, 0x2F5, 0x2FA, 0x2FF, 0x304, 0x309, 0x30E, 0x313, 0x318, 0x31D, 0x322, 0x326, 0x32B, + 0x330, 0x335, 0x33A, 0x33F, 0x344, 0x349, 0x34E, 0x353, 0x357, 0x35C, 0x361, 0x366, 0x36B, 0x370, 0x374, 0x379, + 0x37E, 0x383, 0x388, 0x38C, 0x391, 0x396, 0x39B, 0x39F, 0x3A4, 0x3A9, 0x3AD, 0x3B2, 0x3B7, 0x3BB, 0x3C0, 0x3C5, + 0x3C9, 0x3CE, 0x3D2, 0x3D7, 0x3DC, 0x3E0, 0x3E5, 0x3E9, 0x3ED, 0x3F2, 0x3F6, 0x3FB, 0x3FF, 0x403, 0x408, 0x40C, + 0x410, 0x415, 0x419, 0x41D, 0x421, 0x425, 0x42A, 0x42E, 0x432, 0x436, 0x43A, 0x43E, 0x442, 0x446, 0x44A, 0x44E, + 0x452, 0x455, 0x459, 0x45D, 0x461, 0x465, 0x468, 0x46C, 0x470, 0x473, 0x477, 0x47A, 0x47E, 0x481, 0x485, 0x488, + 0x48C, 0x48F, 0x492, 0x496, 0x499, 0x49C, 0x49F, 0x4A2, 0x4A6, 0x4A9, 0x4AC, 0x4AF, 0x4B2, 0x4B5, 0x4B7, 0x4BA, + 0x4BD, 0x4C0, 0x4C3, 0x4C5, 0x4C8, 0x4CB, 0x4CD, 0x4D0, 0x4D2, 0x4D5, 0x4D7, 0x4D9, 0x4DC, 0x4DE, 0x4E0, 0x4E3, + 0x4E5, 0x4E7, 0x4E9, 0x4EB, 0x4ED, 0x4EF, 0x4F1, 0x4F3, 0x4F5, 0x4F6, 0x4F8, 0x4FA, 0x4FB, 0x4FD, 0x4FF, 0x500, + 0x502, 0x503, 0x504, 0x506, 0x507, 0x508, 0x50A, 0x50B, 0x50C, 0x50D, 0x50E, 0x50F, 0x510, 0x511, 0x511, 0x512, + 0x513, 0x514, 0x514, 0x515, 0x516, 0x516, 0x517, 0x517, 0x517, 0x518, 0x518, 0x518, 0x518, 0x518, 0x519, 0x519 + ) + // 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. // @@ -1766,9 +1813,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { val frac = voice.samplePos - i0.toDouble() // Interpolation: - // INTERP_DEFAULT (0): 16-tap windowed sinc (Fast Sinc; MilkyTracker MIXER_SINCTABLE) - // INTERP_NONE (1): nearest-neighbour + // INTERP_DEFAULT (0): 6-tap windowed sinc (Fast Sinc; MilkyTracker MIXER_SINCTABLE) + // INTERP_NONE (1): zero-order hold // INTERP_A500/A1200 (2/3): zero-order hold per Paula; LPF applied at mix stage + // INTERP_SNES (4): SNES BRR 4-tap gaussian + // INTERP_NES_DPCM (5): NES 2A03 DMC 1-bit sigma-delta playback simulation // 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) { @@ -1781,6 +1830,54 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } acc } + INTERP_SNES -> { + // Four taps centred between samples i0 and i0+1, indexed in SNES naming: + // oldests = sample at i0 - 1, olders = i0, olds = i0 + 1, news = i0 + 2. + // Promote each [-1, 1] sample to signed 16-bit, run the canonical BRR + // formula in integer arithmetic, then map (out >> 1) back to [-1, 1]. + // The (out & 0xffff) → int16 cast after the third tap reproduces the + // SNES hardware mid-sum overflow (the famous gauss "chirp"). + val oldest = (readSamplePoint(inst, i0 - 1, sampleLen, binMax) * 32767.0).toInt() + val olders = (readSamplePoint(inst, i0, sampleLen, binMax) * 32767.0).toInt() + val olds = (readSamplePoint(inst, i0 + 1, sampleLen, binMax) * 32767.0).toInt() + val news = (readSamplePoint(inst, i0 + 2, sampleLen, binMax) * 32767.0).toInt() + val offset = (frac * 256.0).toInt().coerceIn(0, 255) + var out = (SNES_GAUSS[0xff - offset] * oldest) shr 10 + out += (SNES_GAUSS[0x1ff - offset] * olders) shr 10 + out += (SNES_GAUSS[0x100 + offset] * olds) shr 10 + out = out.toShort().toInt() + out += (SNES_GAUSS[offset] * news) shr 10 + out = out.coerceIn(-32768, 32767) + (out shr 1) / 16384.0 + } + INTERP_NES_DPCM -> { + // NES 2A03 DMC (Delta Modulation Channel) playback simulation. The DMC + // is a 1-bit sigma-delta DAC: each clock reads one bit and slews a 7-bit + // output counter (0..127) by ±2, clamped at the rails. Here the bitstream + // is synthesised on the fly by comparing each ZOH-fetched sample against + // the counter, then applying canonical DMC update rules (NESdev wiki + // "APU DMC"): + // target > counter ∧ counter ≤ 125 : counter += 2 + // target < counter ∧ counter ≥ 2 : counter -= 2 + // else : silent clip at the rail + // The DMC clock is locked to the host sample rate (32 kHz, just below + // NTSC DMC rate $F = 33144 Hz); the ±2-per-tick slew-rate limit gives + // DPCM its signature — slow / quiet signals reconstruct cleanly, fast + // transients break into triangle-flank crunch. The 7-bit counter further + // imposes 64 effective output levels (only even values are reachable from + // a mid-rail seed), reproducing DMC's coarse quantisation. Per-voice + // counter persists across samples and is reseeded to mid-rail on note + // trigger (see triggerNote). + val target = readSamplePoint(inst, i0, sampleLen, binMax) + val targetLevel = ((target + 1.0) * 63.5).toInt().coerceIn(0, 127) + when { + targetLevel > voice.nesDpcmCounter && voice.nesDpcmCounter <= 125 -> + voice.nesDpcmCounter += 2 + targetLevel < voice.nesDpcmCounter && voice.nesDpcmCounter >= 2 -> + voice.nesDpcmCounter -= 2 + } + (voice.nesDpcmCounter - 63.5) / 63.5 + } 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. @@ -1926,6 +2023,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { // Auto-vibrato sweep ramp restarts on every fresh trigger. voice.autoVibPhase = 0 voice.autoVibTicksSinceTrigger = 0 + // Reseed the NES DPCM sigma-delta counter to mid-rail so the first + // output sample after key-on doesn't carry the previous note's residual + // DC slew (relevant only when interpolationMode == INTERP_NES_DPCM). + voice.nesDpcmCounter = 63 // Funk repeat (S$Fx): PT2 resets n_wavestart to n_loopstart on every fresh // note trigger (pt2_replayer.c:1094, 1100). funkSpeed and funkAccumulator // persist across notes, matching PT2. @@ -3582,6 +3683,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { var autoVibPhase = 0 // 8-bit phase counter var autoVibTicksSinceTrigger = 0 // for sweep ramp-up + // NES 2A03 DMC counter for INTERP_NES_DPCM (interpolation mode 5). + // 7-bit unsigned (0..127), slews ±2 per output sample as the sigma-delta + // bitstream is generated on the fly. Seeded to mid-rail (63) on every + // fresh trigger so the first sample doesn't have to slew ~30 ticks up + // from 0 to reach a typical instrument's DC level. + var nesDpcmCounter = 63 + // Filter / cutoff state — drives the per-voice IT-compatible 2-pole resonant LPF. // Convention: 255 = filter off (matches IT's high-bit-clear sentinel); // 0..254 = active range mirroring IT 0..127 at double resolution. @@ -3747,8 +3855,9 @@ 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_*. + // Interpolation mode (TAUD_NOTE_EFFECTS.md §1, bits 2-4 of global behaviour flags). + // 0=Fast Sinc default, 1=none, 2=Amiga 500, 3=Amiga 1200, 4=SNES 4-tap gaussian, + // 5=NES 2A03 DPCM simulation. 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. @@ -3812,7 +3921,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { fun updateTrackerGlobalBehaviour(flags: Int) { trackerState?.let { ts -> ts.toneMode = flags and 3 - ts.interpolationMode = (flags ushr 2) and 3 + ts.interpolationMode = (flags ushr 2) and 7 } } @@ -3912,7 +4021,7 @@ 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.interpolationMode = (initialGlobalFlags ushr 2) and 7 ts.ledFilterOn = false ts.amigaLPStateL = 0.0; ts.amigaLPStateR = 0.0 ts.amigaLEDStateL.fill(0.0); ts.amigaLEDStateR.fill(0.0) @@ -3973,6 +4082,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { it.filterY1 = 0.0; it.filterY2 = 0.0 it.filterCutoffCached = -1; it.filterResonanceCached = -1 it.currentCutoff = 0xFF; it.currentResonance = 0xFF + it.nesDpcmCounter = 63 } ts.backgroundVoices.clear() // Funk repeat (S$Fx): drop every per-instrument inversion mask so that