Compare commits

...

5 Commits

Author SHA1 Message Date
minjaesong
b27ef0dbf9 gui updates 2026-05-09 21:47:02 +09:00
minjaesong
ddeab1c782 taud bugfix 2026-05-09 21:22:44 +09:00
minjaesong
f69108c40d TsvmEmulator: better snd debug view 2026-05-09 20:19:04 +09:00
minjaesong
74cba0a893 Soundscope for tracker 2026-05-09 18:15:05 +09:00
minjaesong
bc235ebb17 Taud: Amiga interpolation mode and LPF toggle 2026-05-09 16:04:57 +09:00
9 changed files with 564 additions and 173 deletions

View File

@@ -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 |

View File

@@ -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
}
@@ -676,7 +676,7 @@ function drawStatusBar() {
// play/stop sym
con.color_pair(colWHITE, 255)
con.move(1,1)
print(`${sym.playhead}${PLAYHEAD}`)
print(`P${PLAYHEAD+1}`)
con.move(2,1)
print((playbackMode == PLAYMODE_NONE) ? sym.statusstop : sym.statusplay)
@@ -2249,7 +2249,8 @@ function drawProjectContents(wo) {
let mixerflag = initialTrackerMixerflags
let toneModeStr = ['Linear pitch','Amiga pitch','Linear freq',''][mixerflag & 3]
let flagStrSelected = [toneModeStr]
let intpModeStr = ['Fast Sinc','No intp.','A500 intp.','A1200 intp.'][(mixerflag >>> 2) & 3]
let flagStrSelected = [toneModeStr, intpModeStr]
let projMeta = {

View File

@@ -107,7 +107,7 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using
<b>ACCIDENTALS</b>
&demisharp;&nbsp;&sharp;&nbsp;&doublesharp;&nbsp;&triplesharp;&nbsp;&quadsharp;&nbsp;&demiflat;&nbsp;&flat;&nbsp;&doubleflat;&nbsp;&tripleflat;&nbsp;&nbsp;&accuptick;&nbsp;&nbsp;&accupup;&nbsp;&nbsp;&accdntick;&nbsp;&nbsp;&accdndn;
<b>C&nbsp;&nbsp;c&nbsp;&nbsp;x&nbsp;&nbsp;cx&nbsp;xx&nbsp;B&nbsp;&nbsp;b&nbsp;&nbsp;bb&nbsp;bbb&nbsp;^&nbsp;&nbsp;^^&nbsp;v&nbsp;&nbsp;vv</b>
<b>C&nbsp;&nbsp;c&nbsp;&nbsp;cx&nbsp;x&nbsp;&nbsp;xx&nbsp;B&nbsp;&nbsp;b&nbsp;&nbsp;bb&nbsp;bbb&nbsp;^&nbsp;&nbsp;^^&nbsp;v&nbsp;&nbsp;vv</b>
`
////////////////////////////////////////////////////////////////////////////////////////////////////

View File

@@ -59,6 +59,9 @@ PT_MEM_TOP = frozenset({0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0xA})
# E sub-effects with memory (key is sub-nibble of the E command):
PT_MEM_E_SUB = frozenset({0x1, 0x2, 0xA, 0xB})
GLOBAL_FLAGS_AMIGA_FREQ = 0b01
GLOBAL_FLAGS_A500_INTP = 0b1000
# ── Taud constants (mod-specific) ────────────────────────────────────────────
@@ -179,6 +182,26 @@ def parse_mod(data: bytes):
inst = (b0 & 0xF0) | ((b2 >> 4) & 0x0F)
effect = b2 & 0x0F
arg = b3
# MT-style PT-strict cell rewrites (LoaderMOD.cpp:354-365):
# PT does not recall arg for portamento up/down (1xx, 2xx) or
# volume slide (Axx); the literal arg is read every tick. The
# vol-slide nibbles in 5xx/6xx likewise take literal args, with
# the recalled state living in the porta/vibrato side. So a
# zero-arg cell decays to a no-slide variant: 1/2/A drop to
# no-op, 5 collapses to bare tone-porta (3), 6 to bare vibrato
# (4). Without this, resolve_pt_recalls would back-fill these
# zero args from the cohort memory and produce a continuous
# slide where PT plays a single-row swell (canonical bug:
# GSLINGER ord 0x03 ch1 — `5 01` on r30/r38 with `5 00` on the
# rest, was fading 24→0 in 5 rows instead of stair-stepping
# 24→14 across 16 rows).
if arg == 0:
if effect in (0x1, 0x2, 0xA):
effect = 0x0
elif effect == 0x5:
effect = 0x3
elif effect == 0x6:
effect = 0x4
cell = grid[ch][r]
cell.period = period
cell.inst = inst
@@ -769,7 +792,7 @@ def assemble_taud(mod: dict, with_project_data: bool = True) -> bytes:
# equal-energy engine-wide. PT has no instrument-level fadeout, so every Taud
# instrument carries fadeout=0 ("no fade") — notes retire on sample-end or
# pattern note-cut instead, which matches PT semantics.
flags_byte = 0x01
flags_byte = GLOBAL_FLAGS_AMIGA_FREQ | GLOBAL_FLAGS_A500_INTP
song_table = encode_song_entry(
song_offset=song_offset,
num_voices=n_channels,

View File

@@ -59,6 +59,7 @@ MON_NOTE_C4 = 40
# `Frequency:=Frequency±parm1` arithmetic (see MTSRC/MT_PLAY.PAS:606-630).
# Panning law is fixed to the equal-energy — there is no `p` bit any more.
GLOBAL_FLAGS_LINEAR_FREQ = 0b10
GLOBAL_FLAGS_NO_INTERPOLATION = 0b0100
# ── Taud container ───────────────────────────────────────────────────────────
@@ -361,7 +362,7 @@ def assemble_taud(mon: dict, with_project_data: bool = True) -> bytes:
# Pan law is fixed engine-wide to the equal-energy (no flag). Monotone has no
# instrument-level fadeout, so every Taud instrument carries fadeout=0 ("no fade") —
# notes retire on sample-end or pattern note-cut instead.
flags_byte = GLOBAL_FLAGS_LINEAR_FREQ
flags_byte = GLOBAL_FLAGS_LINEAR_FREQ | GLOBAL_FLAGS_NO_INTERPOLATION
song_table = encode_song_entry(
song_offset = song_offset,

View File

@@ -2394,6 +2394,8 @@ 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.
[x] physical_presence order 0x1F chn 2: note cuts unexpectedly fast — engine fix
[x] GSLINGER order 0x03 chn 1: L 0100 fades unexpectedly fast? — converter fix
TODO - list of demo songs that MUST ship with Microtone:
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes

View File

@@ -134,12 +134,7 @@ class AudioJSR223Delegate(private val vm: VM) {
fun setTrackerMixerFlags(playhead: Int, flags: Int) {
getFirstSnd()?.playheads?.get(playhead)?.let { ph ->
ph.initialGlobalFlags = flags
ph.trackerState?.let { ts ->
ts.toneMode = flags and 3
// Bits 2-7 reserved. Bit 2 was the old 'm' fadeout-zero policy; removed.
// Pan law is fixed to the equal-energy engine-wide — no flag bit any more.
// See AudioAdapter.kt and TAUD_NOTE_EFFECTS.md §1.
}
ph.updateTrackerGlobalBehaviour(flags)
}
}

View File

@@ -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) {
@@ -124,6 +125,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
internal val DBGPRN = false
const val SAMPLING_RATE = 32000
const val TRACKER_CHUNK = 512
// Per-voice soundscope ring-buffer length. Power of two so wrap-around is a single AND.
// Sized at 2× the soundscope width so the AudioMenu waveform view always has spare
// samples on either side of the centre to search for a stable trigger point.
const val SCOPE_BUFFER_SIZE = 1024
// Mixer-private background-voice pool size per playhead. NNA "Continue/Note Off/Note Fade"
// ghosts displaced foreground voices into this pool; oldest is evicted on overflow.
const val MAX_BG_VOICES = 64
@@ -151,6 +156,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 +1698,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
@@ -2054,11 +2145,28 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// mirroring G's behaviour — the L command continues the porta started by an earlier G.
val toneG = (row.effect == EffectOp.OP_G || row.effect == EffectOp.OP_L)
when (row.note) {
// No note but an instrument byte is present: latch the instrument so
// the *next* note-only trigger picks up the right sample. Trackers
// call this an "instrument-only retrigger"; in MOD/S3M/IT the sample
// keeps playing, but the channel's instrument reference advances.
0xFFFF -> { if (row.instrment != 0) voice.instrumentId = row.instrment }
// No note but an instrument byte is present: latch the instrument and
// re-seed the channel volume from the new sample's Default Note Volume.
// PT, FT2, IT and Schism all do this — pt2_replayer.c:1086 writes
// ch->n_volume = s->volume on every sample-byte row regardless of note;
// ft2_replayer.c:1431-1434 calls resetVolumes(ch) when (note==0 && inst>0);
// schism csf_instrument_change writes chan->volume = psmp->volume whenever
// inst_column is set. Without this, a MOD pattern that runs continuous
// volume slides while re-asserting the sample byte each row (e.g.
// physical_presence ord 0x1F ch2: every row carries `... 1E A0F/A09/A02`)
// silences after the first row because the slide saturates at 0 and there's
// nothing to lift the volume back up before the next slide starts.
0xFFFF -> {
if (row.instrment != 0) {
voice.instrumentId = row.instrment
val seedVol = rowVolumeFromDefault(instruments[voice.instrumentId])
voice.channelVolume = seedVol
voice.rowVolume = seedVol
voice.keyOff = false
voice.noteFading = false
voice.fadeoutVolume = 1.0
}
}
// Key-off: release sustain; envelope walks past the sustain point and the fadeout
// begins (foreground-voice fade path at line ~2380). The voice deactivates when
// fadeoutVolume reaches 0, or immediately if FT2-mode fadeStep == 0. Setting
@@ -2131,9 +2239,10 @@ 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
playhead.updateTrackerGlobalBehaviour(flags)
}
EffectOp.OP_8 -> {
// 8 $xyzz — Bitcrusher. See TAUD_NOTE_EFFECTS.md §8.
@@ -2421,6 +2530,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)
@@ -2830,16 +2948,26 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val gvol = playhead.globalVolume / 255.0
val mvol = playhead.mixingVolume / 255.0
for (voice in ts.voices) {
if (!voice.active || voice.muted) continue
if (!voice.active || voice.muted) {
// Keep the soundscope flat between notes / while muted so the AudioMenu
// does not show stale waveform data once the voice goes silent.
voice.scopeBuffer[voice.scopeWritePos] = 0f
voice.scopeWritePos = (voice.scopeWritePos + 1) and (SCOPE_BUFFER_SIZE - 1)
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
// Volume envelope is bypassed (treated as unity) when S $77 has disabled it.
val effEnvVol = if (voice.volEnvOn) voice.envVolume else 1.0
val vol = effEnvVol * voice.fadeoutVolume * (voice.rowVolume / 63.0) *
swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0
// Split the gain stack so the soundscope can see the voice amplitude independently
// of the playhead-wide faders (master / mixing / global volume).
val perVoiceGain = effEnvVol * voice.fadeoutVolume * (voice.rowVolume / 63.0) *
swingScale * instGv
val globalGain = gvol * mvol * playhead.masterVolume / 255.0
val vol = perVoiceGain * globalGain
val pan = if (voice.hasPanEnv && voice.panEnvOn) {
val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255)
(voice.channelPan + envPanRaw - 128 + voice.randomPanBias).coerceIn(0, 255)
@@ -2855,6 +2983,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (voice.rampOutSamples == 0) voice.active = false
g
} else 1.0
// Per-voice soundscope capture — the voice's actual mono contribution before pan
// and before the playhead-global faders. Includes envelope, fadeout, tremolo,
// sample-end ramp-out and channel volume so the AudioMenu shows what the voice is
// really doing, not the raw instrument sample.
voice.scopeBuffer[voice.scopeWritePos] = (s * perVoiceGain * rampGain).toFloat()
voice.scopeWritePos = (voice.scopeWritePos + 1) and (SCOPE_BUFFER_SIZE - 1)
mixL += s * vol * lGain * rampGain
mixR += s * vol * rGain * rampGain
}
@@ -2863,7 +2997,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 +3020,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)
}
@@ -3254,6 +3422,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Effect-recall memory for this voice.
val mem = MemorySlots()
// AudioMenu soundscope ring buffer. Holds the most recent post-FX, pre-pan voice
// sample values for visualisation only — not consumed by the mixer. Size is a
// power of two so the write-position wrap is a simple AND.
val scopeBuffer = FloatArray(SCOPE_BUFFER_SIZE)
var scopeWritePos = 0
}
class TrackerState {
@@ -3272,6 +3446,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
@@ -3319,6 +3508,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var pcmQueueSizeIndex: Int = 0,
val audioDevice: OpenALBufferedAudioDevice,
) {
fun updateTrackerGlobalBehaviour(flags: Int) {
trackerState?.let { ts ->
ts.toneMode = flags and 3
ts.interpolationMode = (flags ushr 2) and 3
}
}
var trackerState: TrackerState? = TrackerState() // default mode is tracker (isPcmMode=false)
// Initial global behaviour flags (song-table byte, written via MMIO register 7 in tracker mode).
@@ -3381,9 +3577,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
7 -> if (isPcmMode) { pcmUpload = true } else {
initialGlobalFlags = byte
trackerState?.let { ts ->
ts.toneMode = byte and 3
}
updateTrackerGlobalBehaviour(initialGlobalFlags)
}
8 -> { bpm = byte + 25 }
9 -> { tickRate = byte }
@@ -3417,6 +3611,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

View File

@@ -8,7 +8,10 @@ import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.graphics.g2d.SpriteBatch
import net.torvald.reflection.extortField
import net.torvald.terrarum.modulecomputers.virtualcomputer.tvd.toUint
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_ACTIVE
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_ACTIVE2
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_ACTIVE3
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_HIGHLIGHT
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_HIGHLIGHT2
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_WELL
import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT
@@ -18,6 +21,7 @@ import java.util.BitSet
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.ln
import kotlin.math.roundToInt
/**
@@ -25,9 +29,25 @@ import kotlin.math.roundToInt
*/
class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMenu(parent, x, y, w, h) {
// Per-playhead view mode: 0=detailed pattern, 1=abridged pattern (stub), 2=super-abridged (stub), 3=cuesheet detail
private val scopeMode = IntArray(4)
// Per-playhead view mode: 0=detailed pattern, 1=abridged pattern (stub), 2=super-abridged (stub),
// 3=cuesheet detail, 4=per-voice waveform
private val scopeMode = IntArray(4) { 4 }
private val scopeScrollHorz = IntArray(4)
private val SCOPE_MODE_COUNT = 5
// Which playhead the big scope is showing. Status-panel clicks change this.
private var selectedPlayhead = 0
// Layout — one big scope on top, four status panels along the bottom.
private val bigScopeX = 7
private val bigScopeY = 5
private val bigScopeW = 622
private val bigScopeH = 336
private val statusW = 102
private val statusH = 8 * FONT.H + 4
private val statusY = bigScopeY + bigScopeH + 4
// Spread the four status panels evenly across the big-scope width.
private fun statusX(i: Int): Int = bigScopeX + i * (bigScopeW - statusW) / 3
override fun show() {
}
@@ -38,96 +58,71 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
private var guiClickLatched = arrayOf(false, false, false, false, false, false, false, false)
private var guiKeypressLatched = BitSet(256)
private fun panelAtMouse(mx: Int, my: Int): Int {
if (my !in statusY until (statusY + statusH)) return -1
for (i in 0..3) {
val sx = statusX(i)
if (mx in sx until (sx + statusW)) return i
}
return -1
}
private fun mouseInBigScope(mx: Int, my: Int): Boolean =
mx in bigScopeX until (bigScopeX + bigScopeW) &&
my in bigScopeY until (bigScopeY + bigScopeH)
override fun update() {
// mouse clicks
val mx = Gdx.input.x - x
val my = Gdx.input.y - y
// ── LEFT click ─────────────────────────────────────────────────────────────
// On a status panel: select that playhead as the big-scope target.
// On the big scope: cycle scope mode forward for the selected playhead.
if (Gdx.input.isButtonPressed(Buttons.LEFT)) {
if (!guiClickLatched[Buttons.LEFT]) {
val mx = Gdx.input.x - x
val my = Gdx.input.y - y
if (mx in 117..629) {
for (i in 0..3) {
val syTop = h - 7 - 115 * i - 8 * FONT.H
val syBot = h - 3 - 115 * i
if (my in syTop..syBot) {
scopeMode[3 - i] = (scopeMode[3 - i] + 1) and 3
break
}
}
val panel = panelAtMouse(mx, my)
if (panel >= 0) {
selectedPlayhead = panel
} else if (mouseInBigScope(mx, my)) {
scopeMode[selectedPlayhead] =
(scopeMode[selectedPlayhead] + 1) % SCOPE_MODE_COUNT
}
guiClickLatched[Buttons.LEFT] = true
}
}
else {
} else {
guiClickLatched[Buttons.LEFT] = false
}
// ── RIGHT click on the big scope: cycle scope mode backward. ────────────────
if (Gdx.input.isButtonPressed(Buttons.RIGHT)) {
if (!guiClickLatched[Buttons.RIGHT]) {
val mx = Gdx.input.x - x
val my = Gdx.input.y - y
if (mx in 117..629) {
for (i in 0..3) {
val syTop = h - 7 - 115 * i - 8 * FONT.H
val syBot = h - 3 - 115 * i
if (my in syTop..syBot) {
scopeMode[3 - i] = (scopeMode[3 - i] - 1) and 3
break
}
}
if (mouseInBigScope(mx, my)) {
scopeMode[selectedPlayhead] =
(scopeMode[selectedPlayhead] + SCOPE_MODE_COUNT - 1) % SCOPE_MODE_COUNT
}
guiClickLatched[Buttons.RIGHT] = true
}
}
else {
} else {
guiClickLatched[Buttons.RIGHT] = false
}
// keyboard left/right
// ── Keyboard left/right: scroll the selected playhead's pattern view. ───────
if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) {
if (!guiKeypressLatched[Input.Keys.LEFT]) {
val mx = Gdx.input.x - x
val my = Gdx.input.y - y
if (mx in 117..629) {
for (i in 0..3) {
val syTop = h - 7 - 115 * i - 8 * FONT.H
val syBot = h - 3 - 115 * i
if (my in syTop..syBot) {
scopeScrollHorz[3 - i] = (scopeScrollHorz[3 - i] - 1).coerceIn(0, 14)
break
}
}
}
scopeScrollHorz[selectedPlayhead] =
(scopeScrollHorz[selectedPlayhead] - 1).coerceIn(0, 14)
guiKeypressLatched[Input.Keys.LEFT] = true
}
}
else {
} else {
guiKeypressLatched[Input.Keys.LEFT] = false
}
if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) {
if (!guiKeypressLatched[Input.Keys.RIGHT]) {
val mx = Gdx.input.x - x
val my = Gdx.input.y - y
if (mx in 117..629) {
for (i in 0..3) {
val syTop = h - 7 - 115 * i - 8 * FONT.H
val syBot = h - 3 - 115 * i
if (my in syTop..syBot) {
scopeScrollHorz[3 - i] = (scopeScrollHorz[3 - i] + 1).coerceIn(0, 14)
break
}
}
}
scopeScrollHorz[selectedPlayhead] =
(scopeScrollHorz[selectedPlayhead] + 1).coerceIn(0, 14)
guiKeypressLatched[Input.Keys.RIGHT] = true
}
}
else {
} else {
guiKeypressLatched[Input.Keys.RIGHT] = false
}
}
@@ -167,27 +162,32 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
val adev = parent.currentlyPersistentVM?.vm?.peripheralTable?.getOrNull(cardIndex ?: -1)?.peripheral as? AudioAdapter
if (adev != null) {
val playheads = adev.extortField<Array<AudioAdapter.Playhead>>("playheads")!!
// draw status LCD
// ── Big scope background (row 1) and status-panel backgrounds (row 2) ─────
batch.inUse {
// draw backgrounds
batch.color = COL_WELL
for (i in 0..3) { batch.fillRect(7, 5 + 115*i, 102, 8*FONT.H + 4) }
}
for (i in 0..3) {
val ahead = adev.extortField<Array<AudioAdapter.Playhead>>("playheads")!![i]
drawStatusLCD(adev, ahead, batch, i, 9f + 7, 7f + 7 + 115 * i)
}
// draw Soundscope like this so that the overflown queue sparkline would not be overlaid on top of the envelopes
batch.inUse {
// draw backgrounds
batch.color = COL_SOUNDSCOPE_BACK
for (i in 0..3) { batch.fillRect(117, 5 + 115*i, 512, 8*FONT.H + 4) }
batch.fillRect(bigScopeX, bigScopeY, bigScopeW, bigScopeH)
// Highlight border behind the selected status panel.
batch.color = COL_ACTIVE
val selX = statusX(selectedPlayhead)
batch.fillRect(selX - 2, statusY - 2, statusW + 4, statusH + 4)
batch.color = COL_WELL
for (i in 0..3) batch.fillRect(statusX(i), statusY, statusW, statusH)
}
// ── Big scope contents — only the selected playhead ────────────────────────
drawSoundscope(adev, playheads[selectedPlayhead], batch, selectedPlayhead,
bigScopeX.toFloat(), bigScopeY.toFloat(), bigScopeW, bigScopeH)
// ── All four status LCDs along the bottom ──────────────────────────────────
// Use the same (9, 9) inset from the panel as the original layout, so the
// existing label-positioning math inside drawStatusLCD still fits cleanly.
for (i in 0..3) {
val ahead = adev.extortField<Array<AudioAdapter.Playhead>>("playheads")!![i]
drawSoundscope(adev, ahead, batch, i, 117f, 5f + 115 * i)
drawStatusLCD(adev, playheads[i], batch, i,
statusX(i).toFloat() + 9f, statusY.toFloat() + 9f)
}
}
else {
@@ -203,11 +203,16 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
// NOTE: Samples count for PCM mode is drawn by drawSoundscope() function, not this one!
batch.inUse {
// "P{n+1}" tag — bright on the selected playhead so the panel-as-button
// affordance is obvious.
batch.color = if (index == selectedPlayhead) COL_ACTIVE else Color.WHITE
FONT.draw(batch, "P${index + 1}", x, y)
batch.color = Color.WHITE
// PLAY icon
// PLAY icon (shifted right to make room for the playhead tag)
if (ahead.isPlaying)
FONT.draw(batch, STR_PLAY, x, y)
FONT.draw(batch, if (ahead.isPcmMode) "PCM" else "TRACKER", x + 21, y)
FONT.draw(batch, STR_PLAY, x + 21, y)
FONT.draw(batch, if (ahead.isPcmMode) "PCM" else "TRACKER", x + 42, y)
// PCM Mode labels
if (ahead.isPcmMode) {
@@ -238,7 +243,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
FONT.draw(batch, "Tickrate", x, y + 6*FONT.H)
batch.color = COL_ACTIVE3
FONT.drawRalign(batch, "${ahead.trackerState?.cuePos?.toString(16)?.uppercase()?.padStart(2,'0')}:${ahead.trackerState?.rowIndex?.toString()?.uppercase()?.padStart(2,'0')}", x + 84, y + 2*FONT.H)
FONT.drawRalign(batch, "${ahead.trackerState?.cuePos?.toString(16)?.uppercase()?.padStart(3,'0')}:${ahead.trackerState?.rowIndex?.toString()?.uppercase()?.padStart(2,'0')}", x + 84, y + 2*FONT.H)
FONT.drawRalign(batch, "${ahead.masterVolume}", x + 84, y + 3*FONT.H)
FONT.drawRalign(batch, "${ahead.masterPan}", x + 84, y + 4*FONT.H)
FONT.drawRalign(batch, "${ahead.bpm}", x + 84, y + 5*FONT.H)
@@ -261,58 +266,125 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
private fun bipolarCeil(d: Double) = (if (d >= 0.0) ceil(d) else floor(d)).toInt()
private fun bipolarFloor(d: Double) = (if (d >= 0.0) floor(d) else ceil(d)).toInt()
private val VOX_PER_VIEW = arrayOf(6,20,20)
/**
* Find the most-recent rising-edge zero crossing in [buf] that has at least
* [cellW]/2 samples of context on either side, and return its position as a
* sub-sample-accurate "age" (samples since the oldest sample at [writePos]).
* Returns -1.0 if no usable crossing exists — the caller should then fall back
* to a free-running display.
*/
private fun findTriggerAge(buf: FloatArray, writePos: Int, cellW: Int): Double {
val bufSize = buf.size
val mask = bufSize - 1
val halfW = cellW / 2
val maxAge = bufSize - halfW // exclusive: rightmost trigger that still has cellW/2 right-side samples
val minAge = halfW // inclusive: leftmost trigger that still has cellW/2 left-side samples
if (maxAge - 1 <= minAge) return -1.0 // cell is too wide vs the buffer
// Walk newest → oldest within the search window. The most-recent crossing gives
// the freshest snapshot on the right of the trigger, so the eye sees the least lag.
var newer = buf[(writePos + maxAge - 1) and mask]
for (age in maxAge - 2 downTo minAge) {
val older = buf[(writePos + age) and mask]
if (older < 0f && newer >= 0f) {
// Linear interpolation between the two bracketing samples.
val denom = (newer - older)
val frac = if (denom > 1e-9f) (-older) / denom else 0f
return age + frac.toDouble()
}
newer = older
}
return -1.0
}
/**
* Pick a cols × rows grid for `n` waveform cells inside an `areaW × areaH` box.
* Optimises for cell aspect close to [targetAspect] (in log-space, so 6:1 and 1.5:1
* are penalised equally relative to 3:1) and lightly penalises wasted cells. Wide
* scope areas naturally get more columns than rows; tall ones flip the other way.
*/
private fun pickWaveformGrid(n: Int, areaW: Int, areaH: Int): IntArray {
val targetAspect = 3.0
val wastePenalty = 0.3
var bestCols = 1
var bestRows = n
var bestScore = Double.POSITIVE_INFINITY
for (cols in 1..n) {
val rows = (n + cols - 1) / cols
val cellW = areaW.toDouble() / cols
val cellH = areaH.toDouble() / rows
val aspect = cellW / cellH
val score = abs(ln(aspect / targetAspect)) + wastePenalty * (cols * rows - n)
if (score < bestScore) {
bestScore = score
bestCols = cols
bestRows = rows
}
}
return intArrayOf(bestCols, bestRows)
}
private val VOX_PER_VIEW = arrayOf(10,20,20)
private val VOL_SYM = arrayOf('@','^','&',' ')
private val PAN_SYM = arrayOf('@','<','>',' ')
private fun drawSoundscope(audio: AudioAdapter, ahead: AudioAdapter.Playhead, batch: SpriteBatch, index: Int, x: Float, y: Float) {
private fun drawSoundscope(audio: AudioAdapter, ahead: AudioAdapter.Playhead, batch: SpriteBatch, index: Int, x: Float, y: Float, w: Int, h: Int) {
val gdxadev = ahead.audioDevice
val bytes = gdxadev.extortField<ByteArray>("bytes")
val bytesLen = gdxadev.extortField<Int>("bytesLength")!!
val envelopeHalfHeight = 27
val envelopeHalfHeight = h / 4
val lCenterY = h / 4
val rCenterY = 3 * h / 4
val patOffY = 0
batch.inUse {
if (ahead.isPcmMode && bytes != null) {
val smpCnt = bytesLen / 4 - 1
for (s in 0..511) {
val i = (smpCnt * (s / 511.0)).roundToInt().and(0xfffffe)
try {
for (s in 0 until w) {
val i = (smpCnt * (s / (w - 1).toDouble())).roundToInt().and(0xfffffe)
val smpL = (bytes[i*4].toUint() or bytes[i*4+1].toUint().shl(8)).u16Tos16().toDouble().div(32767)
val smpR = (bytes[i*4+2].toUint() or bytes[i*4+3].toUint().shl(8)).u16Tos16().toDouble().div(32767)
val smpL =
(bytes[i * 4].toUint() or bytes[i * 4 + 1].toUint().shl(8)).u16Tos16().toDouble().div(32767)
val smpR = (bytes[i * 4 + 2].toUint() or bytes[i * 4 + 3].toUint().shl(8)).u16Tos16().toDouble()
.div(32767)
val smpLH = smpL * envelopeHalfHeight
val smpRH = smpR * envelopeHalfHeight
val smpLH = smpL * envelopeHalfHeight
val smpRH = smpR * envelopeHalfHeight
val smpLHi = bipolarFloor(smpLH)
val smpRHi = bipolarFloor(smpRH)
val smpLHi2 = bipolarCeil(smpLH)
val smpRHi2 = bipolarCeil(smpRH)
val smpLHi = bipolarFloor(smpLH)
val smpRHi = bipolarFloor(smpRH)
val smpLHi2 = bipolarCeil(smpLH)
val smpRHi2 = bipolarCeil(smpRH)
val smpLHe = abs(smpLH - smpLHi).toFloat()
val smpRHe = abs(smpRH - smpRHi).toFloat()
val smpLHe = abs(smpLH - smpLHi).toFloat()
val smpRHe = abs(smpRH - smpRHi).toFloat()
// antialias in y-axis
if (smpLHi != smpLHi2) {
batch.color = COL_SOUNDSCOPE_FORE.cpy().mul(smpLHe)
batch.fillRect(x + s, y + 27, 1, smpLHi2)
}
if (smpRHi != smpRHi2) {
batch.color = COL_SOUNDSCOPE_FORE.cpy().mul(smpRHe)
batch.fillRect(x + s, y + 81, 1, smpRHi2)
// antialias in y-axis
if (smpLHi != smpLHi2) {
batch.color = COL_SOUNDSCOPE_FORE.cpy().mul(smpLHe)
batch.fillRect(x + s, y + lCenterY, 1, smpLHi2)
}
if (smpRHi != smpRHi2) {
batch.color = COL_SOUNDSCOPE_FORE.cpy().mul(smpRHe)
batch.fillRect(x + s, y + rCenterY, 1, smpRHi2)
}
// base texture
batch.color = COL_SOUNDSCOPE_FORE
batch.fillRect(x + s, y + lCenterY, 1, smpLHi)
batch.fillRect(x + s, y + rCenterY, 1, smpRHi)
}
// base texture
batch.color = COL_SOUNDSCOPE_FORE
batch.fillRect(x + s, y + 27, 1, smpLHi)
batch.fillRect(x + s, y + 81, 1, smpRHi)
// PCM Samples count — drawn inside the scope (top-left) since the status
// panels no longer sit beside it in the new single-scope layout.
batch.color = Color.WHITE
FONT.draw(batch, "Samples", x + 4, y + patOffY)
batch.color = COL_ACTIVE3
FONT.draw(batch, "${smpCnt + 1}", x + 4 + 8 * FONT.W, y + patOffY)
}
batch.color = Color.WHITE
FONT.draw(batch, "Samples", x - 101, y + 5*FONT.H + 9)
batch.color = COL_ACTIVE3
FONT.drawRalign(batch, "${smpCnt+1}", x - 17, y + 5*FONT.H + 9)
catch (_: ArrayIndexOutOfBoundsException) {}
}
else {
// Tracker pattern visualiser.
@@ -320,11 +392,13 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
val ts = ahead.trackerState
if (ts == null) {
batch.color = COL_SOUNDSCOPE_FORE
FONT.draw(batch, "No tracker state", x, y + 4)
FONT.draw(batch, "No tracker state", x, y + patOffY)
} else {
val cuePos = ts.cuePos
val rowIdx = ts.rowIndex
val ROWS = 17
// Rows scale with available height — the original 17-row layout was sized
// for the old 108-pixel scope; the big scope can show many more rows.
val ROWS = (h / TINY.H).coerceAtLeast(1)
val PTN_MAX_ROWS = 63
when (scopeMode[index]) {
@@ -338,11 +412,11 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
val ci = cueFirst + r
if (ci > 1023) break
val here = ci == cuePos
val ry = y + 4 + r * TINY.H
val ry = y + patOffY + r * TINY.H
if (here) {
batch.color = COL_TRACKER_ROW
batch.fillRect(x, ry, 512, TINY.H)
batch.fillRect(x, ry, w, TINY.H)
}
var cx = x
@@ -376,6 +450,91 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
}
}
// ── Mode 4: Per-voice waveform ───────────────────────────────────
// Tile one waveform cell per "currently used" voice (cue-sheet
// pattern number != 0xFFF). The soundscope area is wide and short,
// so a cols × rows grid uses the space far better than a vertical
// stack — pickWaveformGrid() picks a layout that keeps cells roughly
// 3:1 wide while minimising empty slots.
4 -> {
val cuePats = IntArray(20) { vi -> readCuePat12(audio, cuePos, vi) }
val activeVoiceIndices = (0 until 20).filter { cuePats[it] != 0xFFF }
if (activeVoiceIndices.isEmpty()) {
batch.color = COL_SOUNDSCOPE_FORE
FONT.draw(batch, "No active voices", x, y + 4)
} else {
val scopeH = h
val scopeW = w
val n = activeVoiceIndices.size
val grid = pickWaveformGrid(n, scopeW, scopeH)
val cols = grid[0]
val rows = grid[1]
val cellW = scopeW / cols
val cellH = scopeH / rows
val halfH = ((cellH - 2) / 2).coerceAtLeast(1)
val voices = ts.voices
val drawLabel = cellH >= TINY.H + 1 && cellW >= 12
// Faint grid separators between cells.
batch.color = COL_TRACKER_ROW
for (r in 1 until rows) batch.fillRect(x, y + r * cellH, scopeW, 1)
for (c in 1 until cols) batch.fillRect(x + c * cellW, y, 1, scopeH)
for ((slot, vi) in activeVoiceIndices.withIndex()) {
val voice = voices.getOrNull(vi) ?: continue
val col = slot % cols
val row = slot / cols
val cellX = x + col * cellW
val cellY = y + row * cellH
val centerY = cellY + cellH / 2
// baseline
batch.color = COL_TRACKER_ROW
batch.fillRect(cellX, centerY, cellW, 1)
// waveform — anchor the cell centre on the most recent
// sub-sample-accurate rising-edge zero crossing so that
// periodic signals appear stationary (oscilloscope trigger).
// Falls back to a free-running, oldest→newest sweep when no
// usable trigger is found (e.g. silent voice or sub-sub-Hz tone).
batch.color = COL_VOICE_PALETTE[vi % COL_VOICE_PALETTE.size]
val buf = voice.scopeBuffer
val bufSize = buf.size
val mask = bufSize - 1
val writePos = voice.scopeWritePos
val centerCol = cellW / 2
val triggerAge = findTriggerAge(buf, writePos, cellW)
val freeRunStep = (bufSize - 1).toDouble() / (cellW - 1).coerceAtLeast(1)
for (sx in 0 until cellW) {
val readAge = if (triggerAge >= 0.0)
triggerAge + (sx - centerCol).toDouble()
else
sx * freeRunStep
val baseAge = floor(readAge).toInt()
val frac = (readAge - baseAge).toFloat()
val a = buf[(writePos + baseAge) and mask]
val b = buf[(writePos + baseAge + 1) and mask]
val v = ((1f - frac) * a + frac * b).coerceIn(-1f, 1f)
val h = (v * halfH).roundToInt()
if (h == 0) {
batch.fillRect(cellX + sx, centerY, 1, 1)
} else if (h > 0) {
batch.fillRect(cellX + sx, centerY, 1, h)
} else {
batch.fillRect(cellX + sx, centerY + h, 1, -h)
}
}
// voice index label (top-left of cell), only when there is room
if (drawLabel) {
batch.color = COL_VOICE_PALETTE[vi % COL_VOICE_PALETTE.size]
TINY.draw(batch, (vi+1).toString().padStart(2, '0').uppercase(),
cellX + 1, cellY + 1)
}
}
}
}
// ── Mode 0: Detailed pattern with colour-coded fields ────────────
// ── Mode 1: Abridged pattern with colour-coded fields ────────────
// ── Mode 2: Super-abridged pattern with colour-coded fields ────────────
@@ -395,12 +554,12 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
TINY.draw(batch,
"${if (here) ">" else " "}${ci.toString(16).padStart(3, '0').uppercase()}",
x, y + 4 + r * TINY.H)
x, y + patOffY + r * TINY.H)
}
// Vertical separator
batch.color = COL_SOUNDSCOPE_FORE
for (r in 0 until ROWS) TINY.draw(batch, "|", x + cueW, y + 4 + r * TINY.H)
for (r in 0 until ROWS) TINY.draw(batch, "|", x + cueW, y + patOffY + r * TINY.H)
*/
// Pattern index for each voice in current cue
@@ -414,11 +573,11 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
val ri = rowFirst + r
if (ri > PTN_MAX_ROWS) break
val here = ri == rowIdx
val ry = y + 4 + r * TINY.H
val ry = y + patOffY + r * TINY.H
if (here) {
batch.color = COL_TRACKER_ROW
batch.fillRect(patX, ry, 512 - cueW - sepW, TINY.H)
batch.fillRect(patX, ry, w - cueW - sepW, TINY.H)
}
var cx = patX