From fe59df18f713f0630785a1c520573481480025a3 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Fri, 1 May 2026 23:19:49 +0900 Subject: [PATCH] IT filters --- CLAUDE.md | 20 +++ TAUD_NOTE_EFFECTS.md | 35 +++++ assets/disk0/tvdos/bin/taut.js | 2 + it2taud.py | 10 +- terranmon.txt | 42 ++++-- .../torvald/tsvm/peripheral/AudioAdapter.kt | 133 ++++++++++-------- tsvm_executable/src/net/torvald/tsvm/VMGUI.kt | 17 +++ 7 files changed, 186 insertions(+), 73 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7bc4624..bb370b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,26 @@ Documentation for TSVM and TVDOS are available on `./doc/*.tex` as machine-reada Documentatino for TSVM architecture is available on `terranmon.txt` +## Reference Materials + +Third-party source-code references that inform TSVM implementations live in +`reference_materials//`. Each topic folder has a `README.md` that +summarises the takeaway and points back into the verbatim source files. +**Consult these before reimplementing tracker / codec / DSP behaviour from +memory** — TSVM aims to match the audible behaviour of the originals. + +Current topics: + +- `reference_materials/tracker_filter/` — Impulse Tracker / OpenMPT / Schism + Tracker resonant low-pass filter source. Defines the cutoff formula, the + resonance damping curve, and the **IIR-only 2-pole topology** (NOT a + biquad — no feedforward x[n−1] / x[n−2] terms) that `AudioAdapter.kt` uses + for Taud playback. + +When fetching new references, copy the relevant upstream files verbatim into +a topic folder, write a `README.md` summarising the relevant maths / +algorithms with file:line citations, and add an entry here. + ## Architecture ### Core Components diff --git a/TAUD_NOTE_EFFECTS.md b/TAUD_NOTE_EFFECTS.md index 2756fd0..43a18cd 100644 --- a/TAUD_NOTE_EFFECTS.md +++ b/TAUD_NOTE_EFFECTS.md @@ -648,6 +648,41 @@ ProTracker `E5x` maps to Taud `S $2x00` with the same index meaning. **Implementation.** As for S $3x, but applied to Y's separate state (`panbrello_waveform`, `panbrello_retrigger`, and panbrello `lfo_pos`). +--- + +## S $6x00 — Fine pattern delay + +**Plain.** Extends the current row by $x ticks. If multiple S6x commands are on the same row, the sum of their parameters is used. + +**Compatibility.** IT `S6x` maps directly. + +**Implementation.** TODO + +--- + +## S $7x00 — Note/Instrument actions + +**Plain.** Performs following action to the note. + +| $x | Operation | Description | +|---|---|---| +| $0 | Past Note Cut | Cuts all notes playing as a result of New Note Actions on the current channel | +| $1 | Past Note Off | Sends a Note Off to all notes playing as a result of New Note Actions on the current channel | +| $2 | Past Note Fade | Fades out all notes playing as a result of New Note Actions on the current channel | +| $3 | NNA Note Cut | Sets the currently active note's New Note Action to Note Cut | +| $4 | NNA Note Continue | Sets the currently active note's New Note Action to Continue | +| $5 | NNA Note Off | Sets the currently active note's New Note Action to Note Off | +| $6 | NNA Note Fade | Sets the currently active note's New Note Action to Note Fade | +| $7 | Volume Envelope Off | Disables the currently active note's volume envelope | +| $8 | Volume Envelope On | Enables the currently active note's volume envelope | +| $9 | Panning Envelope Off | Disables the currently active note's panning envelope | +| $A | Panning Envelope On | Enables the currently active note's panning envelope | +| $B | Pitch Envelope Off | Disables the currently active note's pitch or filter envelope | +| $C | Pitch Envelope On | Enables the currently active note's pitch envelope | + +**Compatibility.** IT `S7x` maps directly. + +**Implementation.** TODO --- diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index 3807b00..4577a63 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -2370,6 +2370,8 @@ taud.uploadTaudFile(fullPathObj.full, 0, PLAYHEAD) audio.setMasterVolume(PLAYHEAD, 255) audio.setMasterPan(PLAYHEAD, 128) const initialTrackerMixerflags = audio.getTrackerMixerFlags(PLAYHEAD) +//const initialGlobalVolume = +//const initialMixingVolume = function isExternalPanel(p) { return p === VIEW_SAMPLES || p === VIEW_INSTRMNT || p === VIEW_FILE diff --git a/it2taud.py b/it2taud.py index fdd8bae..b3472c7 100644 --- a/it2taud.py +++ b/it2taud.py @@ -507,8 +507,10 @@ def parse_instruments(data: bytes, h: ITHeader) -> list: # Initial filter cutoff/resonance (high bit = enabled, low 7 bits = value) ifc_raw = data[ptr + 0x39] ifr_raw = data[ptr + 0x3A] - inst.ifc = ifc_raw & 0x7F if (ifc_raw & 0x80) else 0 - inst.ifr = ifr_raw & 0x7F if (ifr_raw & 0x80) else 0 + # Taud uses full 0..255 range (double IT's resolution): IT 0..127 → Taud 0..254, + # IT "off" (high bit clear) → Taud 255. + inst.ifc = (ifc_raw & 0x7F) * 2 if (ifc_raw & 0x80) else 255 + inst.ifr = (ifr_raw & 0x7F) * 2 if (ifr_raw & 0x80) else 255 # Parse IT envelopes (new-format only, ≥cmwt 0x200) # Vol envelope at ptr+0x130; pan envelope at ptr+0x182; pf envelope at ptr+0x1D4 @@ -1234,8 +1236,8 @@ def build_sample_inst_bin_it(samples_or_proxy: list, struct.pack_into('b', inst_bin, base + 180, max(-128, min(127, idata.get('pps', 0)))) inst_bin[base + 181] = idata.get('pan_swing', 0) & 0xFF - inst_bin[base + 182] = idata.get('ifc', 0) & 0xFF - inst_bin[base + 183] = idata.get('ifr', 0) & 0xFF + inst_bin[base + 182] = idata.get('ifc', 255) & 0xFF + inst_bin[base + 183] = idata.get('ifr', 255) & 0xFF vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}") diff --git a/terranmon.txt b/terranmon.txt index 7d1fe7f..174ede5 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2002,8 +2002,10 @@ Instrument bin: Registry for 256 instruments, formatted as: Uint16 Loop Start (can be smaller than Play Start) Uint16 Loop End Bit8 Sample Flags - 0b 0000 00pp + 0b 0000 0spp pp: loop mode. 0-no loop, 1-loop, 2-backandforth, 3-oneshot (ignores note length unless overridden by other notes) + s: loop is sustain (key-off escapes the loop) + - IT: look for sample's SusLoop flag Bit16 Volume envelope sustain/loops and flags * Sustain is implemented by enabling 't' flag. FastTracker has no 'Sus Loop' but only 'Sus Point'; use same value for start and end index 0b 0ut sssss pcb eeeee @@ -2051,26 +2053,50 @@ Instrument bin: Registry for 256 instruments, formatted as: Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely. Uint8 Instrument Global Volume (0..255) * ImpulseTracker has range of 0..128; multiply by (255/128) then round to int + - ImpulseTracker has samplewise default volume (0..64) and samplewise global volume (0..64), and they must be taken into account because Taud has no samplewise config, following the ImpulseTracker spec * FastTracker2 has range of 0..64; multiply by (255/64) then round to int Uint8 Volume Fadeout low bits (IT: 1..256; XM: 0..255) Bit8 Fadeout and vibrato - 0b dddd ffff + 0b 0000 ffff f: Volume Fadeout high bits - d: Vibrato depth Uint8 Volume swing (0..255 full range) Uint8 Vibrato speed + * ImpulseTracker has samplewise vibrato speed (0..64), and they must be taken into account because Taud has no samplewise config + * FastTracker2 has instrumentwise config (0..255) + * The spec follows FastTracker2, and conversion must be performed when importing from FastTracker2 Uint8 Vibrato sweep + * FastTracker2 instrument config Uint8 Default pan value (0..255 full range) + * ImpulseTracker has samplewise default volume and samplewise global volume, and they must be taken into account because Taud has no samplewise config Uint16 Pitch-pan centre (4096-TET note value) Sint8 Pitch-pan separation (-128..127 full range) Uint8 Pan swing (0..255 full range) Uint8 Default cutoff (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud) Uint8 Default resonance (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud) Uint16 Sample detune (in 4096-TET unit) (XM finetune scale need to be rescaled accordingly) - Byte[6] Reserved + Bit8 Instrument Flag + 0b 000 www nn + n: New note action. 00: note off, 01: note cut, 10: continue, 11: note fade (arranged differently to IT) + ww: Vibrato waveform (IT: sample config, FT2: instrument config). 00: sine, 01: ramp-down saw, 10: square, 11: random, 100: ramp-up saw (FT2 only) + Uint8 Vibrato Depth (0..255 full range) + * ImpulseTracker has range of 0..32 ON THE SAMPLE SETTINGS; multiply by (255/32) then round to int + * FastTracker2 has range of 0..16; multiply by (255/16) then round to int + Uint8 Vibrato Rate (0..255 full range) + * ImpulseTracker sample config. The spec follows ImpulseTracker precisely + + Byte[3] Reserved + + +TODO: + * implement Instrument Flag, Vibrato Depth, Vibrato Rate, other samplewise/instrumentwise changes to it2taud + * implement sample loop sustain + * implement new note action on the audio engine (IT uses "background channels", maybe we can do the same but make "background channels" mixer-private) + * implement S6x and S7x command + * implement Vxx (set global volume) and Wxx command (global volume slide) + * Amiga mode freq shift now "underdelivers" (pitch bend not "strong" enough) + * cue and pattern compression of the Taud format (taud_common.py, taud.mjs) + * figure out how IT (8 bits) and FT2 (12 bits) handles volume fadeout numbers, and come up with a compatible Taud spec, then implement - TODO: after *2taud.py is done, extend with 25 envelopes and add Pitch/Filter features. 192 bytes per instrument granted (48k space). This is a breaking change. - TODO: use it2taud.py to implement pitch/filter -- don't delete rerender code yet Play Data: play data are series of tracker-like instructions, visualised as: @@ -2371,8 +2397,8 @@ prefixes: Note: custom notations will use internal index 65535 down to 65520 (index 0 = 65535, index 15 = 65520) Note Tuning: - 1. "Base Note at C3" will be derived using "Current Tuning Base Note" and "Frequency at the Base Note" from the song table. If the values are A3,440Hz, it will be converted to C3,261.6255653Hz - 2. Frequency at C4 will be (Base Note at C3) × (Interval Size) + 1. "Base Note at C4" will be derived using "Current Tuning Base Note" and "Frequency at the Base Note" from the song table. If the values are A4,440Hz, it will be converted to C4,261.6255653Hz + 2. Frequency at C5 will be (Base Note at C4) × (Interval Size) 3. 4096 notes will be equidistance-distributed between (Frequency at C3) and (Frequency at C4), with logarithmic pitch progression; this builds the frequency-offset table 4. Frequency-Offset Table from the previous step will be applied against the "Base Note at C3" to construct the notes within the notation. Value at index zero of the Frequency Table must be 0 5. The progress will continue outside the "root interval" (C3..C4) to build a complete note-to-frequency table diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 646b1d3..d7155d9 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -124,10 +124,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { internal val DBGPRN = false const val SAMPLING_RATE = 32000 const val TRACKER_CHUNK = 512 - const val TRACKER_C3 = 0x4000 // legacy alias (one octave below the new reference) - const val TRACKER_C4 = 0x5000 // reference C for instrument samplingRate (terranmon.txt:2000) - // Amiga period at TRACKER_C4 for a standard 8363 Hz instrument (NTSC clock 3579545 Hz). - // PT "C-2" period 428 ↔ TSVM TRACKER_C4 ↔ 8363 Hz; mod2taud uses the same convention. + const val MIDDLE_C = 0x5000 // reference C for instrument samplingRate (terranmon.txt:2000) + // Amiga period at MIDDLE_C for a standard 8363 Hz instrument (NTSC clock 3579545 Hz). + // PT "C-2" period 428 ↔ TSVM MIDDLE_C ↔ 8363 Hz; mod2taud uses the same convention. + // Trackers may use different labelling conventions (e.g. C5) for Middle C. + // For non-tracker context, Middle C shall be labelled as C4. const val AMIGA_BASE_PERIOD = 428.0 } @@ -1168,7 +1169,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } private fun computePlaybackRate(inst: TaudInst, noteVal: Int): Double = - inst.samplingRate.toDouble() / SAMPLING_RATE * 2.0.pow((noteVal - TRACKER_C4) / 4096.0) + inst.samplingRate.toDouble() / SAMPLING_RATE * 2.0.pow((noteVal - MIDDLE_C) / 4096.0) // Applies one tick of Amiga-mode pitch slide. When the song is in Amiga tone mode, E/F coarse // slide arguments are stored as raw tracker period units (the original ProTracker/ST3 byte), @@ -1176,9 +1177,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { // linear mode: negative = pitch down (E effect), positive = pitch up (F effect), so a positive // slideArg subtracts from the period (pitch rises). private fun amigaSlide(noteVal: Int, slideArg: Int): Int { - val period = AMIGA_BASE_PERIOD * 2.0.pow(-(noteVal - TRACKER_C4).toDouble() / 4096.0) + val period = AMIGA_BASE_PERIOD * 2.0.pow(-(noteVal - MIDDLE_C).toDouble() / 4096.0) val newPeriod = (period - slideArg).coerceAtLeast(1.0) - return (TRACKER_C4 + 4096.0 * log2(AMIGA_BASE_PERIOD / newPeriod)).roundToInt() + return (MIDDLE_C + 4096.0 * log2(AMIGA_BASE_PERIOD / newPeriod)).roundToInt() } private fun advanceEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) { @@ -1318,11 +1319,28 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } /** - * Recompute the biquad LPF coefficients for `voice` when its cutoff or - * resonance has changed since the last refresh. Cutoff 0..255 maps - * exponentially from ~110 Hz to ~14 kHz; resonance 0..255 maps linearly - * to Q ∈ [0.5, 6.0]. The filter is disabled at full-open (cutoff ≥ 0xFE - * with no resonance), avoiding the per-sample cost when transparent. + * Recompute the IT-compatible 2-pole resonant low-pass coefficients for + * `voice` when its cutoff or resonance has changed since the last refresh. + * + * Taud's filter range mirrors Impulse Tracker's at double resolution: + * Taud 0..254 maps to IT 0..127, while Taud 255 means "filter off" (the + * IT high-bit-clear sentinel). The filter is bypassed when cutoff = 255. + * + * The coefficient math and topology mirror OpenMPT/Schism Tracker (see + * reference_materials/tracker_filter/openmpt_Snd_flt.cpp and + * schism_filters.c). Notably this is NOT a biquad: the recurrence has no + * feedforward x[n-1] / x[n-2] terms. + * + * frequency = 110 Hz × 2^(itCutoff / 24 + 0.25) (IT 0..127) + * dmpfac = 10 ^ (-itResonance × 0.009375) (= 24/128/20 dB) + * r = mixingFreq / (2π × frequency) + * d = dmpfac × r + dmpfac − 1 + * e = r² + * denom = 1 + d + e + * A0 = 1 / denom + * B0 = (d + 2e) / denom + * B1 = −e / denom + * y[n] = A0 × x[n] + B0 × y[n−1] + B1 × y[n−2] */ private fun refreshVoiceFilter(voice: Voice) { val cut = voice.currentCutoff.coerceIn(0, 255) @@ -1331,49 +1349,42 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.filterCutoffCached = cut voice.filterResonanceCached = res - if (cut >= 0xFE && res == 0) { + if (cut >= 255) { voice.filterActive = false return } - // Exponential cutoff: 110 Hz × 2^(cut × log2(14000/110) / 255). - // log2(14000/110) ≈ 6.992, so exponent ≈ cut × 0.0274. - val cutoffHz = 110.0 * 2.0.pow(cut * 6.992 / 255.0) - val nyquist = SAMPLING_RATE * 0.5 - 1.0 - val f0 = cutoffHz.coerceIn(20.0, nyquist) - val Q = 0.5 + (res / 255.0) * 5.5 + val itCutoff = cut * 0.5 // 0..127 + val itResonance = if (res >= 255) 0.0 else res * 0.5 // 0..127 - val w0 = 2.0 * PI * f0 / SAMPLING_RATE - val cosW = cos(w0) - val sinW = sin(w0) - val alpha = sinW / (2.0 * Q) + val nyquist = SAMPLING_RATE * 0.5 - 1.0 + val frequency = (110.0 * 2.0.pow(itCutoff / 24.0 + 0.25)).coerceAtMost(nyquist) + val dmpfac = 10.0.pow(-itResonance * (24.0 / 128.0) / 20.0) - val b0 = (1.0 - cosW) * 0.5 - val b1 = 1.0 - cosW - val b2 = b0 - val a0 = 1.0 + alpha - val a1 = -2.0 * cosW - val a2 = 1.0 - alpha + val r = SAMPLING_RATE / (2.0 * PI * frequency) + val d = dmpfac * r + dmpfac - 1.0 + val e = r * r + val denom = 1.0 + d + e - voice.filterB0 = b0 / a0 - voice.filterB1 = b1 / a0 - voice.filterB2 = b2 / a0 - voice.filterA1 = a1 / a0 - voice.filterA2 = a2 / a0 + voice.filterA0 = 1.0 / denom + voice.filterB0 = (d + e + e) / denom + voice.filterB1 = -e / denom voice.filterActive = true } - /** Apply the cached biquad LPF to one mono sample. Caller must have called - * refreshVoiceFilter at the start of the tick. */ + /** Apply the cached IT-style 2-pole LPF to one mono sample. Caller must + * have called refreshVoiceFilter at the start of the tick. The history + * taps are clipped to ±2.0 to tame resonance ringing on extreme settings, + * matching OpenMPT's ClipFilter helper. */ private fun applyVoiceFilter(voice: Voice, x0: Double): Double { if (!voice.filterActive) return x0 - val y0 = voice.filterB0 * x0 + - voice.filterB1 * voice.filterX1 + - voice.filterB2 * voice.filterX2 - - voice.filterA1 * voice.filterY1 - - voice.filterA2 * voice.filterY2 - voice.filterX2 = voice.filterX1; voice.filterX1 = x0 - voice.filterY2 = voice.filterY1; voice.filterY1 = y0 + val y1Clipped = voice.filterY1.coerceIn(-2.0, 2.0) + val y2Clipped = voice.filterY2.coerceIn(-2.0, 2.0) + val y0 = voice.filterA0 * x0 + + voice.filterB0 * y1Clipped + + voice.filterB1 * y2Clipped + voice.filterY2 = voice.filterY1 + voice.filterY1 = y0 return y0 } @@ -1499,9 +1510,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63) } // Filter cutoff/resonance defaults — adjusted per-tick by the pf envelope when in filter mode. - voice.currentCutoff = if (inst.defaultCutoff > 0) inst.defaultCutoff else 0xFF + // 255 = filter off (IT high-bit-clear); 0..254 = active range matching IT 0..127 at double resolution. + voice.currentCutoff = inst.defaultCutoff voice.currentResonance = inst.defaultResonance - voice.filterX1 = 0.0; voice.filterX2 = 0.0 voice.filterY1 = 0.0; voice.filterY2 = 0.0 voice.filterCutoffCached = -1 // force coefficient refresh on first tick voice.filterResonanceCached = -1 @@ -1942,7 +1953,6 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.fadeoutVolume = 1.0 voice.autoVibPhase = 0 voice.autoVibTicksSinceTrigger = 0 - voice.filterX1 = 0.0; voice.filterX2 = 0.0 voice.filterY1 = 0.0; voice.filterY2 = 0.0 voice.rowVolume = applyRetrigVolMod(voice.rowVolume, voice.retrigVolMod) voice.channelVolume = voice.rowVolume @@ -1962,9 +1972,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.playbackRate = computePlaybackRate(inst, finalPitch) // Filter envelope (filter mode): scale current cutoff by env value (0..1, 0.5 = unity). + // If the instrument has no initial cutoff (255 = off), the envelope drives the filter + // from the maximum active value (254) so the filter can become audible during the note. if (voice.hasPfEnv && voice.envPfIsFilter) { - val baseCut = if (inst.defaultCutoff > 0) inst.defaultCutoff else 0xFF - voice.currentCutoff = (baseCut * (voice.envPfValue * 2.0)).toInt().coerceIn(0, 0xFF) + val baseCut = if (inst.defaultCutoff < 255) inst.defaultCutoff else 254 + voice.currentCutoff = (baseCut * (voice.envPfValue * 2.0)).toInt().coerceIn(0, 254) } // Refresh biquad filter coefficients once per tick (only recomputes when changed). @@ -2264,19 +2276,18 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { var autoVibPhase = 0 // 8-bit phase counter var autoVibTicksSinceTrigger = 0 // for sweep ramp-up - // Filter / cutoff state — drives the per-voice 2-pole resonant LPF. - var currentCutoff = 0xFF // 0..255 (0xFF = open / unfiltered) - var currentResonance = 0 // 0..255 - // Biquad state (updated per output sample) and cached coefficients - // (recomputed per tick when cutoff/resonance change). + // 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. + var currentCutoff = 0xFF + var currentResonance = 0xFF + // IT 2-pole IIR-only state (updated per output sample) and cached coefficients + // (recomputed per tick when cutoff/resonance change). Recurrence: + // y[n] = A0 × x[n] + B0 × y[n-1] + B1 × y[n-2] var filterActive = false - var filterB0 = 1.0 + var filterA0 = 1.0 + var filterB0 = 0.0 var filterB1 = 0.0 - var filterB2 = 0.0 - var filterA1 = 0.0 - var filterA2 = 0.0 - var filterX1 = 0.0 - var filterX2 = 0.0 var filterY1 = 0.0 var filterY2 = 0.0 // Snapshot of cutoff/resonance the cached coefficients correspond to. @@ -2591,7 +2602,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { * Layout: * 0..3 u32 sample pointer * 4..5 u16 sample length - * 6..7 u16 sampling rate at TRACKER_C4 (0x5000) + * 6..7 u16 sampling rate at Middle C (0x5000) // NOTE: Taud treats middle C as C4, but some trackers show you C4 even if they are internally C5. Best practice: copy the value as-is. * 8..9 u16 play start * 10..11 u16 loop start * 12..13 u16 loop end @@ -2621,7 +2632,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { var samplePtr: Int, // 32-bit sample bin offset var sampleLength: Int, - var samplingRate: Int, // rate at TRACKER_C4 + var samplingRate: Int, // rate at MIDDLE_C var samplePlayStart: Int, var sampleLoopStart: Int, var sampleLoopEnd: Int, diff --git a/tsvm_executable/src/net/torvald/tsvm/VMGUI.kt b/tsvm_executable/src/net/torvald/tsvm/VMGUI.kt index 32c46c1..2bc8bc5 100644 --- a/tsvm_executable/src/net/torvald/tsvm/VMGUI.kt +++ b/tsvm_executable/src/net/torvald/tsvm/VMGUI.kt @@ -2,6 +2,7 @@ package net.torvald.tsvm import com.badlogic.gdx.ApplicationAdapter import com.badlogic.gdx.Gdx +import com.badlogic.gdx.Input import com.badlogic.gdx.graphics.* import com.badlogic.gdx.graphics.g2d.SpriteBatch import com.badlogic.gdx.graphics.g2d.TextureRegion @@ -205,6 +206,8 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe private var updateAkku = 0.0 private var updateRate = 1f / 60f + private var crtShaderSignalMode = 0 + override fun render() { gdxClearAndSetBlend(.094f, .094f, .094f, 0f) setCameraPosition(0f, 0f) @@ -250,6 +253,19 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe vm.update(delta) if (vm.resetDown) rebootRequested = true + + if ((Gdx.input.isKeyPressed(Input.Keys.SHIFT_LEFT) || Gdx.input.isKeyPressed(Input.Keys.SHIFT_RIGHT)) && + (Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) || Gdx.input.isKeyPressed(Input.Keys.CONTROL_RIGHT))) { + if (Gdx.input.isKeyPressed(Input.Keys.S)) { + crtShaderSignalMode = -1 // RGB + } + else if (Gdx.input.isKeyPressed(Input.Keys.D)) { + crtShaderSignalMode = 0 // S-video + } + else if (Gdx.input.isKeyPressed(Input.Keys.F)) { + crtShaderSignalMode = 1 // Composite + } + } } fun poke(addr: Long, value: Byte) = vm.poke(addr, value) @@ -292,6 +308,7 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe batch.shader.setUniformf("resolution", viewportWidth.toFloat(), viewportHeight.toFloat()) batch.shader.setUniformf("interlacer", (framecount % 2).toFloat()) batch.shader.setUniformf("time", (framecount % 640).toFloat()) + batch.shader.setUniformi("signalMode", crtShaderSignalMode) batch.setBlendFunctionSeparate(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA, GL20.GL_SRC_ALPHA, GL20.GL_ONE) batch.draw(gpuFBO.colorBufferTexture, 0f, 0f) }