mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-10 15:04:03 +09:00
Compare commits
5 Commits
9f01bdfee9
...
b27ef0dbf9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b27ef0dbf9 | ||
|
|
ddeab1c782 | ||
|
|
f69108c40d | ||
|
|
74cba0a893 | ||
|
|
bc235ebb17 |
@@ -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 |
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -107,7 +107,7 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using
|
||||
|
||||
<b>ACCIDENTALS</b>
|
||||
&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>
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
25
mod2taud.py
25
mod2taud.py
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user