mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-18 02:14:04 +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 is a multiplexing opcode; the **high nibble of the high byte** selects the sub-effect, and the remainder is the sub-argument.
|
||||||
|
|
||||||
|
# S $0x00 — Amiga LPF/LED Switch
|
||||||
|
|
||||||
|
**Plain.** `$0100` turns filter off; `$0000` turns it on. The parameter of the filter is somewhat dependent on the current interpolation mode: follows Amiga 1200 LPF on 1200 mode, Amiga 500 LPF on 500 mode. For other interpolation modes, this command is no-op. (see § Effects That Modifies Global Behaviour)
|
||||||
|
|
||||||
|
**Compatibility.** ST3/IT `S00`/`S01` and PT `E00`/`E01` maps directly. To actually hear the effect, the interpolation mode must be set to one of the two Amiga modes.
|
||||||
|
|
||||||
|
**Implementation.** Per-playhead boolean `ledFilterOn` (default off). Writes from row are gated on `interpolationMode ∈ {Amiga 500, Amiga 1200}`; in linear / no-interp / default modes the filter chain is bypassed entirely so the toggle is a silent no-op. The post-mix LPF chain runs on the stereo bus (left/right state per playhead) before dithering: in Amiga 500 mode a 1-pole RC LPF (R = 360 Ω, C = 0.1 µF, fc ≈ 4421 Hz) is always applied; in Amiga 1200 mode that LPF is bypassed (cutoff ~34 kHz, well above 32 kHz Nyquist — matches `pt2_paula.c`). When the LED toggle is on, an additional 2-pole Sallen-Key LPF (R1=R2=10 kΩ, C1=6800 pF, C2=3900 pF, fc ≈ 3091 Hz, Q ≈ 0.660) is run after the mode LPF. Coefficients precomputed once at SAMPLING_RATE; recurrence follows musicdsp.org #38 with `pt2_rcfilters.c` parameter mapping.
|
||||||
|
|
||||||
|
|
||||||
## S $1x00 — PT/ST3/IT Glissando control
|
## S $1x00 — PT/ST3/IT Glissando control
|
||||||
|
|
||||||
**Plain.** `$1000` turns glissando off; `$1100` turns it on. When on, tone portamento (G) output is quantised to the nearest semitone ($0155 approximation) before being sent to the mixer. The internal G pitch counter still advances smoothly; only the audible pitch steps. **This command is implemented sorely for ST3/IT compatibility** and therefore only works in 12-TET context.
|
**Plain.** `$1000` turns glissando off; `$1100` turns it on. When on, tone portamento (G) output is quantised to the nearest semitone ($0155 approximation) before being sent to the mixer. The internal G pitch counter still advances smoothly; only the audible pitch steps. **This command is implemented sorely for ST3/IT compatibility** and therefore only works in 12-TET context.
|
||||||
@@ -1113,13 +1122,16 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
|
|||||||
|
|
||||||
**Plain.** Sets mixer-wide behaviour flags. Available flags are:
|
**Plain.** Sets mixer-wide behaviour flags. Available flags are:
|
||||||
|
|
||||||
0b 0000 00ff
|
0b 0000 rr ff
|
||||||
|
|
||||||
- ff = 0: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch.
|
- ff = 0: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch.
|
||||||
- ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
|
- ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
|
||||||
- ff = 2: Linear-frequency tone mode (MONOTONE compat). **E, F, and G arguments are stored as Hz/tick** (a signed change in audible frequency per song tick), and the engine converts the channel's stored 4096-TET pitch back to a frequency, adds/subtracts the argument, then converts back to 4096-TET. Reference is fixed at 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, which matches MONOTONE's MT_PLAY.PAS `notesHz` table (A0 = 27.5 Hz, equal-temperament). Unlike Amiga mode, *all three* slide effects use the new arithmetic — Monotone's `1xx`, `2xx`, and `3xx` are all in Hz/tick (see MTSRC/MT_PLAY.PAS:606-630).
|
- ff = 2: Linear-frequency tone mode (MONOTONE compat). **E, F, and G arguments are stored as Hz/tick** (a signed change in audible frequency per song tick), and the engine converts the channel's stored 4096-TET pitch back to a frequency, adds/subtracts the argument, then converts back to 4096-TET. Reference is fixed at 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, which matches MONOTONE's MT_PLAY.PAS `notesHz` table (A0 = 27.5 Hz, equal-temperament). Unlike Amiga mode, *all three* slide effects use the new arithmetic — Monotone's `1xx`, `2xx`, and `3xx` are all in Hz/tick (see MTSRC/MT_PLAY.PAS:606-630).
|
||||||
|
|
||||||
(Bits 2-7 are reserved. Bit 2 previously held an `m` "fadeout-zero policy" flag intended to swap between IT and FT2 semantics for `storedFadeout = 0`. That flag was removed once both trackers were verified to share identical "stored 0 ⇒ no fade" semantics — see schismtracker `player/sndmix.c:330-342` and ft2-clone `src/ft2_replayer.c:1467-1481`. Fadeout scaling now lives in the converters; see "Volume Fadeout" below.)
|
- rr = 0: Yes interpolation. Actual interpolation algorithm is implementation-dependent, but recommended to use either Fast Sinc or Linear.
|
||||||
|
- rr = 1: No interpolation.
|
||||||
|
- rr = 2: Amiga 500 interpolation.
|
||||||
|
- rr = 3: Amiga 1200 interpolation.
|
||||||
|
|
||||||
### Volume Fadeout
|
### Volume Fadeout
|
||||||
|
|
||||||
@@ -1204,7 +1216,7 @@ This table maps each PT effect to its Taud equivalent. Arguments follow PT's two
|
|||||||
| `B $xx` | `B $00xx` | Position jump |
|
| `B $xx` | `B $00xx` | Position jump |
|
||||||
| `C $xx` | Volume column `0.$xx` | Set volume |
|
| `C $xx` | Volume column `0.$xx` | Set volume |
|
||||||
| `D $xx` | `C $00xx` (after BCD decode) | Pattern break |
|
| `D $xx` | `C $00xx` (after BCD decode) | Pattern break |
|
||||||
| `E $0x` | `S $000x` | (UNIMPLEMENTED) Set filter |
|
| `E $0x` | `S $0x00` | Set low-pass filter |
|
||||||
| `E $1x` | `F $F00x` (Amiga mode, `f` set) | Fine pitch slide up; raw PT period units, applied in period space at tick 0 |
|
| `E $1x` | `F $F00x` (Amiga mode, `f` set) | Fine pitch slide up; raw PT period units, applied in period space at tick 0 |
|
||||||
| `E $2x` | `E $F00x` (Amiga mode, `f` set) | Fine pitch slide down; raw PT period units, applied in period space at tick 0 |
|
| `E $2x` | `E $F00x` (Amiga mode, `f` set) | Fine pitch slide down; raw PT period units, applied in period space at tick 0 |
|
||||||
| `E $3x` | `S $1x00` | Glissando control |
|
| `E $3x` | `S $1x00` | Glissando control |
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ P:"Chan.panslide",
|
|||||||
Q:"Retrigger ",
|
Q:"Retrigger ",
|
||||||
R:"Tremolo ",
|
R:"Tremolo ",
|
||||||
S:"Special ",
|
S:"Special ",
|
||||||
S0:"UNIMPLEMENTED", // PT: Set audio filter
|
S0:"Amiga Filter ",
|
||||||
S1:"Gliss. ctrl ",
|
S1:"Gliss. ctrl ",
|
||||||
S2:"Sample tune ",
|
S2:"Sample tune ",
|
||||||
S3:"Vibrato LFO ",
|
S3:"Vibrato LFO ",
|
||||||
@@ -140,7 +140,7 @@ T:"Tempo ",
|
|||||||
U:"Fine vibrato ",
|
U:"Fine vibrato ",
|
||||||
V:"Global volume",
|
V:"Global volume",
|
||||||
W:"G.Vol Slide ",
|
W:"G.Vol Slide ",
|
||||||
X:"UNIMPLEMENTED", // IT: 8-bit channel panning. Use S80xx instead
|
X:"UNIMPLEMENTED", // IT: 8-bit channel panning. Use S 80xx instead
|
||||||
Y:"Panbrello ",
|
Y:"Panbrello ",
|
||||||
Z:"UNIMPLEMENTED", // IT: MIDI macro
|
Z:"UNIMPLEMENTED", // IT: MIDI macro
|
||||||
}
|
}
|
||||||
@@ -676,7 +676,7 @@ function drawStatusBar() {
|
|||||||
// play/stop sym
|
// play/stop sym
|
||||||
con.color_pair(colWHITE, 255)
|
con.color_pair(colWHITE, 255)
|
||||||
con.move(1,1)
|
con.move(1,1)
|
||||||
print(`${sym.playhead}${PLAYHEAD}`)
|
print(`P${PLAYHEAD+1}`)
|
||||||
con.move(2,1)
|
con.move(2,1)
|
||||||
print((playbackMode == PLAYMODE_NONE) ? sym.statusstop : sym.statusplay)
|
print((playbackMode == PLAYMODE_NONE) ? sym.statusstop : sym.statusplay)
|
||||||
|
|
||||||
@@ -2249,7 +2249,8 @@ function drawProjectContents(wo) {
|
|||||||
|
|
||||||
let mixerflag = initialTrackerMixerflags
|
let mixerflag = initialTrackerMixerflags
|
||||||
let toneModeStr = ['Linear pitch','Amiga pitch','Linear freq',''][mixerflag & 3]
|
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 = {
|
let projMeta = {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using
|
|||||||
|
|
||||||
<b>ACCIDENTALS</b>
|
<b>ACCIDENTALS</b>
|
||||||
&demisharp; ♯ &doublesharp; &triplesharp; &quadsharp; &demiflat; ♭ &doubleflat; &tripleflat; &accuptick; &accupup; &accdntick; &accdndn;
|
&demisharp; ♯ &doublesharp; &triplesharp; &quadsharp; &demiflat; ♭ &doubleflat; &tripleflat; &accuptick; &accupup; &accdntick; &accdndn;
|
||||||
<b>C c x cx xx B b bb bbb ^ ^^ v vv</b>
|
<b>C c cx x xx B b bb bbb ^ ^^ v vv</b>
|
||||||
`
|
`
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|||||||
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):
|
# E sub-effects with memory (key is sub-nibble of the E command):
|
||||||
PT_MEM_E_SUB = frozenset({0x1, 0x2, 0xA, 0xB})
|
PT_MEM_E_SUB = frozenset({0x1, 0x2, 0xA, 0xB})
|
||||||
|
|
||||||
|
GLOBAL_FLAGS_AMIGA_FREQ = 0b01
|
||||||
|
GLOBAL_FLAGS_A500_INTP = 0b1000
|
||||||
|
|
||||||
|
|
||||||
# ── Taud constants (mod-specific) ────────────────────────────────────────────
|
# ── Taud constants (mod-specific) ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -179,6 +182,26 @@ def parse_mod(data: bytes):
|
|||||||
inst = (b0 & 0xF0) | ((b2 >> 4) & 0x0F)
|
inst = (b0 & 0xF0) | ((b2 >> 4) & 0x0F)
|
||||||
effect = b2 & 0x0F
|
effect = b2 & 0x0F
|
||||||
arg = b3
|
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 = grid[ch][r]
|
||||||
cell.period = period
|
cell.period = period
|
||||||
cell.inst = inst
|
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
|
# 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
|
# instrument carries fadeout=0 ("no fade") — notes retire on sample-end or
|
||||||
# pattern note-cut instead, which matches PT semantics.
|
# 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_table = encode_song_entry(
|
||||||
song_offset=song_offset,
|
song_offset=song_offset,
|
||||||
num_voices=n_channels,
|
num_voices=n_channels,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ MON_NOTE_C4 = 40
|
|||||||
# `Frequency:=Frequency±parm1` arithmetic (see MTSRC/MT_PLAY.PAS:606-630).
|
# `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.
|
# Panning law is fixed to the equal-energy — there is no `p` bit any more.
|
||||||
GLOBAL_FLAGS_LINEAR_FREQ = 0b10
|
GLOBAL_FLAGS_LINEAR_FREQ = 0b10
|
||||||
|
GLOBAL_FLAGS_NO_INTERPOLATION = 0b0100
|
||||||
|
|
||||||
|
|
||||||
# ── Taud container ───────────────────────────────────────────────────────────
|
# ── 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
|
# 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") —
|
# instrument-level fadeout, so every Taud instrument carries fadeout=0 ("no fade") —
|
||||||
# notes retire on sample-end or pattern note-cut instead.
|
# 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_table = encode_song_entry(
|
||||||
song_offset = song_offset,
|
song_offset = song_offset,
|
||||||
|
|||||||
@@ -2394,6 +2394,8 @@ TODO:
|
|||||||
when no V column is present. Engine + all four `*2taud` converters
|
when no V column is present. Engine + all four `*2taud` converters
|
||||||
updated; legacy `.taud` files (byte 196 == 0) fall back to the
|
updated; legacy `.taud` files (byte 196 == 0) fall back to the
|
||||||
previous "row volume default = 63" behaviour.
|
previous "row volume default = 63" behaviour.
|
||||||
|
[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:
|
TODO - list of demo songs that MUST ship with Microtone:
|
||||||
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
|
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
|
||||||
|
|||||||
@@ -134,12 +134,7 @@ class AudioJSR223Delegate(private val vm: VM) {
|
|||||||
fun setTrackerMixerFlags(playhead: Int, flags: Int) {
|
fun setTrackerMixerFlags(playhead: Int, flags: Int) {
|
||||||
getFirstSnd()?.playheads?.get(playhead)?.let { ph ->
|
getFirstSnd()?.playheads?.get(playhead)?.let { ph ->
|
||||||
ph.initialGlobalFlags = flags
|
ph.initialGlobalFlags = flags
|
||||||
ph.trackerState?.let { ts ->
|
ph.updateTrackerGlobalBehaviour(flags)
|
||||||
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.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import kotlin.math.roundToInt
|
|||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.exp
|
||||||
|
|
||||||
private class RenderRunnable(val playhead: AudioAdapter.Playhead) : Runnable {
|
private class RenderRunnable(val playhead: AudioAdapter.Playhead) : Runnable {
|
||||||
private fun printdbg(msg: Any) {
|
private fun printdbg(msg: Any) {
|
||||||
@@ -124,6 +125,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
internal val DBGPRN = false
|
internal val DBGPRN = false
|
||||||
const val SAMPLING_RATE = 32000
|
const val SAMPLING_RATE = 32000
|
||||||
const val TRACKER_CHUNK = 512
|
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"
|
// 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.
|
// ghosts displaced foreground voices into this pool; oldest is evicted on overflow.
|
||||||
const val MAX_BG_VOICES = 64
|
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_BANK_COUNT: Int = 16 // 16 × 512 K = 8 MB
|
||||||
const val SAMPLE_BIN_TOTAL: Long = SAMPLE_BANK_SIZE * SAMPLE_BANK_COUNT
|
const val SAMPLE_BIN_TOTAL: Long = SAMPLE_BANK_SIZE * SAMPLE_BANK_COUNT
|
||||||
const val SAMPLE_BANK_MASK: Int = SAMPLE_BANK_COUNT - 1
|
const val SAMPLE_BANK_MASK: Int = SAMPLE_BANK_COUNT - 1
|
||||||
|
|
||||||
|
// Interpolation modes (TAUD_NOTE_EFFECTS.md §1, bits 2-3 of global behaviour flags).
|
||||||
|
// 0 = default (Fast Sinc, 16-tap windowed sinc), 1 = none (zero-order hold),
|
||||||
|
// 2 = Amiga 500 (ZOH + A500 1-pole LPF), 3 = Amiga 1200 (ZOH + A1200 LPF — bypassed).
|
||||||
|
// Amiga modes additionally apply a 2-pole Sallen-Key "LED" LPF when ts.ledFilterOn,
|
||||||
|
// which is toggled by S $0000 / S $0100 (TAUD_NOTE_EFFECTS.md §"S $0x00").
|
||||||
|
const val INTERP_DEFAULT = 0
|
||||||
|
const val INTERP_NONE = 1
|
||||||
|
const val INTERP_A500 = 2
|
||||||
|
const val INTERP_A1200 = 3
|
||||||
|
|
||||||
|
// Fast Sinc — 16-tap windowed sinc with 1024 sub-sample positions.
|
||||||
|
// Mirrors MilkyTracker's MIXER_SINCTABLE (ResamplerSinc.h: WIDTH=8, 1024-step table,
|
||||||
|
// window = 0.5 + 0.5·cos(πi / WIDTH·step)). Coefficients are symmetric so we only
|
||||||
|
// store half the kernel; the second half is index-mirrored at lookup time.
|
||||||
|
const val SINC_WIDTH = 8
|
||||||
|
const val SINC_PRECISION_SHIFT = 10
|
||||||
|
const val SINC_PRECISION = 1 shl SINC_PRECISION_SHIFT // 1024
|
||||||
|
private val SINC_TABLE: DoubleArray = run {
|
||||||
|
val n = SINC_PRECISION * SINC_WIDTH
|
||||||
|
val out = DoubleArray(n)
|
||||||
|
val winFreq = PI / SINC_WIDTH / SINC_PRECISION
|
||||||
|
out[0] = 1.0
|
||||||
|
for (i in 1 until n) {
|
||||||
|
val t = i * PI / SINC_PRECISION
|
||||||
|
val win = 0.5 + 0.5 * cos(winFreq * i)
|
||||||
|
out[i] = sin(t) / t * win
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Windowed-sinc kernel value for fractional offset `frac ∈ [0,1)` and signed tap [−WIDTH+1, WIDTH]. */
|
||||||
|
private fun sincTap(frac: Double, tap: Int): Double {
|
||||||
|
val x = (tap - frac) * SINC_PRECISION // distance in sub-sample units
|
||||||
|
val ax = kotlin.math.abs(x)
|
||||||
|
val idx = ax.toInt()
|
||||||
|
if (idx >= SINC_PRECISION * SINC_WIDTH - 1) return 0.0
|
||||||
|
// Linear interpolation between adjacent table entries for sub-sub-sample precision.
|
||||||
|
val f = ax - idx
|
||||||
|
return SINC_TABLE[idx] * (1.0 - f) + SINC_TABLE[idx + 1] * f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amiga filter coefficients (precomputed at SAMPLING_RATE = 32 kHz, see pt2_paula.c
|
||||||
|
// and pt2_rcfilters.c). All filters operate on the post-mix stereo bus per playhead.
|
||||||
|
//
|
||||||
|
// A500_LP : 1-pole RC LPF, R = 360 Ω, C = 0.1 µF → fc ≈ 4420.97 Hz
|
||||||
|
// LED_LP : 2-pole Sallen-Key, R1=R2=10 kΩ, C1=6800 pF, C2=3900 pF
|
||||||
|
// → fc ≈ 3090.53 Hz, Q ≈ 0.660225
|
||||||
|
// A1200_LP: cutoff ~34.4 kHz, well above Nyquist at 32 kHz → bypassed (matches pt2-clone).
|
||||||
|
private val AMIGA_A500_LP_FC = 4420.971
|
||||||
|
private val AMIGA_LED_FC = 3090.533
|
||||||
|
private val AMIGA_LED_Q = 0.660225
|
||||||
|
|
||||||
|
// 1-pole coefficients (Direct Form II) for A500 LPF.
|
||||||
|
val AMIGA_A500_B1: Double = exp(-2.0 * PI * AMIGA_A500_LP_FC / SAMPLING_RATE)
|
||||||
|
val AMIGA_A500_A0: Double = 1.0 - AMIGA_A500_B1
|
||||||
|
|
||||||
|
// 2-pole biquad coefficients (musicdsp.org #38) for LED Sallen-Key LPF.
|
||||||
|
private val AMIGA_LED_A_BASE = 1.0 / kotlin.math.tan(PI * AMIGA_LED_FC / SAMPLING_RATE)
|
||||||
|
private val AMIGA_LED_B_BASE = 1.0 / AMIGA_LED_Q
|
||||||
|
val AMIGA_LED_A1: Double = 1.0 / (1.0 + AMIGA_LED_B_BASE * AMIGA_LED_A_BASE + AMIGA_LED_A_BASE * AMIGA_LED_A_BASE)
|
||||||
|
val AMIGA_LED_A2: Double = 2.0 * AMIGA_LED_A1
|
||||||
|
val AMIGA_LED_B1: Double = 2.0 * (1.0 - AMIGA_LED_A_BASE * AMIGA_LED_A_BASE) * AMIGA_LED_A1
|
||||||
|
val AMIGA_LED_B2: Double = (1.0 - AMIGA_LED_B_BASE * AMIGA_LED_A_BASE + AMIGA_LED_A_BASE * AMIGA_LED_A_BASE) * AMIGA_LED_A1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memory map (terranmon.txt:1985-1997, updated 2026-05-08):
|
// Memory map (terranmon.txt:1985-1997, updated 2026-05-08):
|
||||||
@@ -1629,32 +1698,54 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
return pitchDelta
|
return pitchDelta
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchTrackerSample(voice: Voice, inst: TaudInst): Double {
|
/**
|
||||||
|
* Read one PCM sample (in [-1, 1]) at integer index [idx], honouring the instrument's
|
||||||
|
* funk-repeat mask. Out-of-range indices are clamped to the sample bounds; the
|
||||||
|
* caller is responsible for wrapping into a loop region first if loop semantics apply.
|
||||||
|
*/
|
||||||
|
private fun readSamplePoint(inst: TaudInst, idx: Int, sampleLen: Int, binMax: Int): Double {
|
||||||
|
val i = idx.coerceIn(0, sampleLen - 1)
|
||||||
|
var b = sampleBin[(inst.samplePtr + i).coerceAtMost(binMax).toLong()].toUint()
|
||||||
|
if (inst.funkMask != null && inst.sampleLoopEnd > inst.sampleLoopStart) {
|
||||||
|
val ls = inst.sampleLoopStart
|
||||||
|
if (i in ls until inst.sampleLoopEnd && inst.funkBit(i - ls)) b = b xor 0xFF
|
||||||
|
}
|
||||||
|
return (b - 127.5) / 127.5
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchTrackerSample(voice: Voice, inst: TaudInst, interpMode: Int): Double {
|
||||||
if (inst.index == 0) return 0.0
|
if (inst.index == 0) return 0.0
|
||||||
|
|
||||||
val basePtr = inst.samplePtr
|
|
||||||
val sampleLen = inst.sampleLength.coerceAtLeast(1)
|
val sampleLen = inst.sampleLength.coerceAtLeast(1)
|
||||||
val loopStart = inst.sampleLoopStart.toDouble()
|
val loopStart = inst.sampleLoopStart.toDouble()
|
||||||
val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0)
|
val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0)
|
||||||
val binMax = (SAMPLE_BIN_TOTAL - 1).toInt() // 8 MB pool, addressed via samplePtr directly (not banked)
|
val binMax = (SAMPLE_BIN_TOTAL - 1).toInt() // 8 MB pool, addressed via samplePtr directly (not banked)
|
||||||
|
|
||||||
val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1)
|
val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1)
|
||||||
val i1 = (i0 + 1).coerceAtMost(sampleLen - 1)
|
|
||||||
val frac = voice.samplePos - i0.toDouble()
|
val frac = voice.samplePos - i0.toDouble()
|
||||||
var b0 = sampleBin[(basePtr + i0).coerceAtMost(binMax).toLong()].toUint()
|
|
||||||
var b1 = sampleBin[(basePtr + i1).coerceAtMost(binMax).toLong()].toUint()
|
// Interpolation:
|
||||||
// S$Fx funk repeat: bit-invert (XOR 0xFF) bytes whose loop-relative index
|
// INTERP_DEFAULT (0): 16-tap windowed sinc (Fast Sinc; MilkyTracker MIXER_SINCTABLE)
|
||||||
// is set in funkMask. Mirrors PT2's `*p = -1 - *p` (full bitwise NOT) — the
|
// INTERP_NONE (1): nearest-neighbour
|
||||||
// mask is a non-destructive overlay so the source sample stays pristine.
|
// INTERP_A500/A1200 (2/3): zero-order hold per Paula; LPF applied at mix stage
|
||||||
// Only meaningful when the sample has a loop region.
|
// Edge clamping: out-of-range taps are clipped to sample bounds (acceptable smear
|
||||||
if (inst.funkMask != null && inst.sampleLoopEnd > inst.sampleLoopStart) {
|
// at sample edges; matches MilkyTracker's outSideLoop fallback).
|
||||||
val ls = inst.sampleLoopStart
|
val sample: Double = when (interpMode) {
|
||||||
if (i0 in ls until inst.sampleLoopEnd && inst.funkBit(i0 - ls)) b0 = b0 xor 0xFF
|
INTERP_DEFAULT -> {
|
||||||
if (i1 in ls until inst.sampleLoopEnd && inst.funkBit(i1 - ls)) b1 = b1 xor 0xFF
|
var acc = 0.0
|
||||||
|
// Taps span [i0 - WIDTH + 1, i0 + WIDTH], with the kernel centred on i0+frac.
|
||||||
|
for (j in -SINC_WIDTH + 1 .. SINC_WIDTH) {
|
||||||
|
val coeff = sincTap(frac, j)
|
||||||
|
if (coeff != 0.0) acc += readSamplePoint(inst, i0 + j, sampleLen, binMax) * coeff
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
INTERP_NONE, INTERP_A500, INTERP_A1200 ->
|
||||||
|
// Paula-style ZOH — emit the integer-indexed sample byte without
|
||||||
|
// sub-sample fade. Aliasing is removed by the post-mix Amiga LPFs.
|
||||||
|
readSamplePoint(inst, i0, sampleLen, binMax)
|
||||||
|
else -> readSamplePoint(inst, i0, sampleLen, binMax)
|
||||||
}
|
}
|
||||||
val s0 = (b0 - 127.5) / 127.5
|
|
||||||
val s1 = (b1 - 127.5) / 127.5
|
|
||||||
val sample = s0 + (s1 - s0) * frac
|
|
||||||
|
|
||||||
// While ramping out at sample end, hold position so the mixer keeps emitting the
|
// While ramping out at sample end, hold position so the mixer keeps emitting the
|
||||||
// clamped last-sample value with decaying gain — no further advance, no re-trigger
|
// clamped last-sample value with decaying gain — no further advance, no re-trigger
|
||||||
@@ -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.
|
// 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)
|
val toneG = (row.effect == EffectOp.OP_G || row.effect == EffectOp.OP_L)
|
||||||
when (row.note) {
|
when (row.note) {
|
||||||
// No note but an instrument byte is present: latch the instrument so
|
// No note but an instrument byte is present: latch the instrument and
|
||||||
// the *next* note-only trigger picks up the right sample. Trackers
|
// re-seed the channel volume from the new sample's Default Note Volume.
|
||||||
// call this an "instrument-only retrigger"; in MOD/S3M/IT the sample
|
// PT, FT2, IT and Schism all do this — pt2_replayer.c:1086 writes
|
||||||
// keeps playing, but the channel's instrument reference advances.
|
// ch->n_volume = s->volume on every sample-byte row regardless of note;
|
||||||
0xFFFF -> { if (row.instrment != 0) voice.instrumentId = row.instrment }
|
// 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
|
// 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
|
// begins (foreground-voice fade path at line ~2380). The voice deactivates when
|
||||||
// fadeoutVolume reaches 0, or immediately if FT2-mode fadeStep == 0. Setting
|
// 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).
|
// 1 $xx00 — Global behaviour flags byte in the high byte (see TAUD_NOTE_EFFECTS.md §1).
|
||||||
// bits 0-1 (ff): 0=linear pitch, 1=Amiga period, 2=linear frequency (Hz/tick),
|
// bits 0-1 (ff): 0=linear pitch, 1=Amiga period, 2=linear frequency (Hz/tick),
|
||||||
// 3=reserved
|
// 3=reserved
|
||||||
|
// bits 2-3 (rr): 0=Fast Sinc, 1=none, 2=Amiga 500, 3=Amiga 1200
|
||||||
// Panning law is fixed to the equal-energy; no runtime selection.
|
// Panning law is fixed to the equal-energy; no runtime selection.
|
||||||
val flags = rawArg ushr 8
|
val flags = rawArg ushr 8
|
||||||
ts.toneMode = flags and 3
|
playhead.updateTrackerGlobalBehaviour(flags)
|
||||||
}
|
}
|
||||||
EffectOp.OP_8 -> {
|
EffectOp.OP_8 -> {
|
||||||
// 8 $xyzz — Bitcrusher. See TAUD_NOTE_EFFECTS.md §8.
|
// 8 $xyzz — Bitcrusher. See TAUD_NOTE_EFFECTS.md §8.
|
||||||
@@ -2421,6 +2530,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
val sub = (arg ushr 12) and 0xF
|
val sub = (arg ushr 12) and 0xF
|
||||||
val x = (arg ushr 8) and 0xF
|
val x = (arg ushr 8) and 0xF
|
||||||
when (sub) {
|
when (sub) {
|
||||||
|
0x0 -> {
|
||||||
|
// S $0000 = LED on, S $0100 = LED off (PT E00 / E01 — pt2_replayer.c:608
|
||||||
|
// computes filterOn = (cmd & 1) ^ 1, so x=0 → on, x=1 → off).
|
||||||
|
// Only meaningful in Amiga interpolation modes; on linear / no-interp the
|
||||||
|
// filter chain is bypassed so writes are silent no-ops.
|
||||||
|
if (ts.interpolationMode == INTERP_A500 || ts.interpolationMode == INTERP_A1200) {
|
||||||
|
ts.ledFilterOn = (x == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
0x1 -> voice.glissandoOn = (x != 0)
|
0x1 -> voice.glissandoOn = (x != 0)
|
||||||
0x2 -> {
|
0x2 -> {
|
||||||
voice.noteVal = (voice.noteVal + FINETUNE_OFFSET[x]).coerceIn(1, 0xFFFD)
|
voice.noteVal = (voice.noteVal + FINETUNE_OFFSET[x]).coerceIn(1, 0xFFFD)
|
||||||
@@ -2830,16 +2948,26 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
val gvol = playhead.globalVolume / 255.0
|
val gvol = playhead.globalVolume / 255.0
|
||||||
val mvol = playhead.mixingVolume / 255.0
|
val mvol = playhead.mixingVolume / 255.0
|
||||||
for (voice in ts.voices) {
|
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 voiceInst = instruments[voice.instrumentId]
|
||||||
val s = applyTaudVoiceFx(voice, applyVoiceFilter(voice, fetchTrackerSample(voice, voiceInst)))
|
val s = applyTaudVoiceFx(voice, applyVoiceFilter(voice, fetchTrackerSample(voice, voiceInst, ts.interpolationMode)))
|
||||||
val instGv = voiceInst.instGlobalVolume / 255.0
|
val instGv = voiceInst.instGlobalVolume / 255.0
|
||||||
// Volume swing bias (random per-trigger, ±randomVolBias of 0..255 units folded into the 0..63 row volume).
|
// Volume swing bias (random per-trigger, ±randomVolBias of 0..255 units folded into the 0..63 row volume).
|
||||||
val swingScale = 1.0 + voice.randomVolBias / 255.0
|
val swingScale = 1.0 + voice.randomVolBias / 255.0
|
||||||
// Volume envelope is bypassed (treated as unity) when S $77 has disabled it.
|
// Volume envelope is bypassed (treated as unity) when S $77 has disabled it.
|
||||||
val effEnvVol = if (voice.volEnvOn) voice.envVolume else 1.0
|
val effEnvVol = if (voice.volEnvOn) voice.envVolume else 1.0
|
||||||
val vol = effEnvVol * voice.fadeoutVolume * (voice.rowVolume / 63.0) *
|
// Split the gain stack so the soundscope can see the voice amplitude independently
|
||||||
swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0
|
// 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 pan = if (voice.hasPanEnv && voice.panEnvOn) {
|
||||||
val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255)
|
val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255)
|
||||||
(voice.channelPan + envPanRaw - 128 + voice.randomPanBias).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
|
if (voice.rampOutSamples == 0) voice.active = false
|
||||||
g
|
g
|
||||||
} else 1.0
|
} 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
|
mixL += s * vol * lGain * rampGain
|
||||||
mixR += s * vol * rGain * rampGain
|
mixR += s * vol * rGain * rampGain
|
||||||
}
|
}
|
||||||
@@ -2863,7 +2997,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
for (bg in ts.backgroundVoices) {
|
for (bg in ts.backgroundVoices) {
|
||||||
if (!bg.active || bg.muted) continue
|
if (!bg.active || bg.muted) continue
|
||||||
val bgInst = instruments[bg.instrumentId]
|
val bgInst = instruments[bg.instrumentId]
|
||||||
val s = applyTaudVoiceFx(bg, applyVoiceFilter(bg, fetchTrackerSample(bg, bgInst)))
|
val s = applyTaudVoiceFx(bg, applyVoiceFilter(bg, fetchTrackerSample(bg, bgInst, ts.interpolationMode)))
|
||||||
val instGv = bgInst.instGlobalVolume / 255.0
|
val instGv = bgInst.instGlobalVolume / 255.0
|
||||||
val swingScale = 1.0 + bg.randomVolBias / 255.0
|
val swingScale = 1.0 + bg.randomVolBias / 255.0
|
||||||
val effEnvVol = if (bg.volEnvOn) bg.envVolume else 1.0
|
val effEnvVol = if (bg.volEnvOn) bg.envVolume else 1.0
|
||||||
@@ -2886,6 +3020,40 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
mixR += s * vol * rGain * rampGain
|
mixR += s * vol * rGain * rampGain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Amiga interpolation modes: post-mix LPF chain (matches pt2-clone Paula stage).
|
||||||
|
// INTERP_A500 applies the 1-pole RC LPF (~4421 Hz). INTERP_A1200 has a cutoff
|
||||||
|
// above Nyquist so its LPF is bypassed. The 2-pole "LED" filter (~3091 Hz, Q≈0.66)
|
||||||
|
// is added on either Amiga mode when ts.ledFilterOn (S $0000 = on, S $0100 = off).
|
||||||
|
// No-op for INTERP_DEFAULT and INTERP_NONE so non-Amiga modes pay no cost.
|
||||||
|
when (ts.interpolationMode) {
|
||||||
|
INTERP_A500 -> {
|
||||||
|
ts.amigaLPStateL = mixL * AMIGA_A500_A0 + ts.amigaLPStateL * AMIGA_A500_B1
|
||||||
|
ts.amigaLPStateR = mixR * AMIGA_A500_A0 + ts.amigaLPStateR * AMIGA_A500_B1
|
||||||
|
mixL = ts.amigaLPStateL
|
||||||
|
mixR = ts.amigaLPStateR
|
||||||
|
if (ts.ledFilterOn) {
|
||||||
|
val sl = ts.amigaLEDStateL; val sr = ts.amigaLEDStateR
|
||||||
|
val outL = mixL * AMIGA_LED_A1 + sl[0] * AMIGA_LED_A2 + sl[1] * AMIGA_LED_A1 - sl[2] * AMIGA_LED_B1 - sl[3] * AMIGA_LED_B2
|
||||||
|
val outR = mixR * AMIGA_LED_A1 + sr[0] * AMIGA_LED_A2 + sr[1] * AMIGA_LED_A1 - sr[2] * AMIGA_LED_B1 - sr[3] * AMIGA_LED_B2
|
||||||
|
sl[1] = sl[0]; sl[0] = mixL; sl[3] = sl[2]; sl[2] = outL
|
||||||
|
sr[1] = sr[0]; sr[0] = mixR; sr[3] = sr[2]; sr[2] = outR
|
||||||
|
mixL = outL; mixR = outR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
INTERP_A1200 -> {
|
||||||
|
// A1200 1-pole LPF cutoff (~34 kHz) is above Nyquist at SAMPLING_RATE = 32 kHz,
|
||||||
|
// so it is bypassed (matches pt2_paula.c: useLowpassFilter = false).
|
||||||
|
if (ts.ledFilterOn) {
|
||||||
|
val sl = ts.amigaLEDStateL; val sr = ts.amigaLEDStateR
|
||||||
|
val outL = mixL * AMIGA_LED_A1 + sl[0] * AMIGA_LED_A2 + sl[1] * AMIGA_LED_A1 - sl[2] * AMIGA_LED_B1 - sl[3] * AMIGA_LED_B2
|
||||||
|
val outR = mixR * AMIGA_LED_A1 + sr[0] * AMIGA_LED_A2 + sr[1] * AMIGA_LED_A1 - sr[2] * AMIGA_LED_B1 - sr[3] * AMIGA_LED_B2
|
||||||
|
sl[1] = sl[0]; sl[0] = mixL; sl[3] = sl[2]; sl[2] = outL
|
||||||
|
sr[1] = sr[0]; sr[0] = mixR; sr[3] = sr[2]; sr[2] = outR
|
||||||
|
mixL = outL; mixR = outR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ts.mixLeft[n] = mixL.toFloat().coerceIn(-1.0f, 1.0f)
|
ts.mixLeft[n] = mixL.toFloat().coerceIn(-1.0f, 1.0f)
|
||||||
ts.mixRight[n] = mixR.toFloat().coerceIn(-1.0f, 1.0f)
|
ts.mixRight[n] = mixR.toFloat().coerceIn(-1.0f, 1.0f)
|
||||||
}
|
}
|
||||||
@@ -3254,6 +3422,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
|
|
||||||
// Effect-recall memory for this voice.
|
// Effect-recall memory for this voice.
|
||||||
val mem = MemorySlots()
|
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 {
|
class TrackerState {
|
||||||
@@ -3272,6 +3446,21 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
// 3 = reserved
|
// 3 = reserved
|
||||||
var toneMode = 0
|
var toneMode = 0
|
||||||
|
|
||||||
|
// Interpolation mode (TAUD_NOTE_EFFECTS.md §1, bits 2-3 of global behaviour flags).
|
||||||
|
// 0=Fast Sinc default, 1=none, 2=Amiga 500, 3=Amiga 1200. See AudioAdapter.INTERP_*.
|
||||||
|
var interpolationMode = INTERP_DEFAULT
|
||||||
|
// Amiga "LED" 2-pole LPF on/off (S $0000 = on, S $0100 = off; PT E00/E01).
|
||||||
|
// Only applies when interpolationMode is INTERP_A500 or INTERP_A1200.
|
||||||
|
var ledFilterOn = false
|
||||||
|
|
||||||
|
// Per-playhead Amiga filter state. Live on the post-mix stereo bus so voice
|
||||||
|
// come/go does not reset filter history. All zeroed on resetParams().
|
||||||
|
var amigaLPStateL = 0.0
|
||||||
|
var amigaLPStateR = 0.0
|
||||||
|
// 2-pole biquad delay line: [in_z1, in_z2, out_z1, out_z2] for L and R.
|
||||||
|
val amigaLEDStateL = DoubleArray(4)
|
||||||
|
val amigaLEDStateR = DoubleArray(4)
|
||||||
|
|
||||||
// Pending row-end events (set during a row by B/C; consumed at row end).
|
// Pending row-end events (set during a row by B/C; consumed at row end).
|
||||||
var pendingOrderJump = -1 // -1 = none; otherwise the order index to jump to
|
var pendingOrderJump = -1 // -1 = none; otherwise the order index to jump to
|
||||||
var pendingRowJump = -1 // -1 = none; otherwise the row index for the next pattern
|
var pendingRowJump = -1 // -1 = none; otherwise the row index for the next pattern
|
||||||
@@ -3319,6 +3508,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
var pcmQueueSizeIndex: Int = 0,
|
var pcmQueueSizeIndex: Int = 0,
|
||||||
val audioDevice: OpenALBufferedAudioDevice,
|
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)
|
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).
|
// 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 {
|
7 -> if (isPcmMode) { pcmUpload = true } else {
|
||||||
initialGlobalFlags = byte
|
initialGlobalFlags = byte
|
||||||
trackerState?.let { ts ->
|
updateTrackerGlobalBehaviour(initialGlobalFlags)
|
||||||
ts.toneMode = byte and 3
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
8 -> { bpm = byte + 25 }
|
8 -> { bpm = byte + 25 }
|
||||||
9 -> { tickRate = byte }
|
9 -> { tickRate = byte }
|
||||||
@@ -3417,6 +3611,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
ts.sexWinningChannel = -1
|
ts.sexWinningChannel = -1
|
||||||
ts.finePatternDelayExtra = 0
|
ts.finePatternDelayExtra = 0
|
||||||
ts.toneMode = initialGlobalFlags and 3
|
ts.toneMode = initialGlobalFlags and 3
|
||||||
|
ts.interpolationMode = (initialGlobalFlags ushr 2) and 3
|
||||||
|
ts.ledFilterOn = false
|
||||||
|
ts.amigaLPStateL = 0.0; ts.amigaLPStateR = 0.0
|
||||||
|
ts.amigaLEDStateL.fill(0.0); ts.amigaLEDStateR.fill(0.0)
|
||||||
ts.voices.forEach {
|
ts.voices.forEach {
|
||||||
it.active = false
|
it.active = false
|
||||||
it.channelVolume = 0x3F
|
it.channelVolume = 0x3F
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import com.badlogic.gdx.graphics.Color
|
|||||||
import com.badlogic.gdx.graphics.g2d.SpriteBatch
|
import com.badlogic.gdx.graphics.g2d.SpriteBatch
|
||||||
import net.torvald.reflection.extortField
|
import net.torvald.reflection.extortField
|
||||||
import net.torvald.terrarum.modulecomputers.virtualcomputer.tvd.toUint
|
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_ACTIVE3
|
||||||
|
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_HIGHLIGHT
|
||||||
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_HIGHLIGHT2
|
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_HIGHLIGHT2
|
||||||
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_WELL
|
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_WELL
|
||||||
import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT
|
import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT
|
||||||
@@ -18,6 +21,7 @@ import java.util.BitSet
|
|||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.ln
|
||||||
import kotlin.math.roundToInt
|
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) {
|
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
|
// Per-playhead view mode: 0=detailed pattern, 1=abridged pattern (stub), 2=super-abridged (stub),
|
||||||
private val scopeMode = IntArray(4)
|
// 3=cuesheet detail, 4=per-voice waveform
|
||||||
|
private val scopeMode = IntArray(4) { 4 }
|
||||||
private val scopeScrollHorz = IntArray(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() {
|
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 guiClickLatched = arrayOf(false, false, false, false, false, false, false, false)
|
||||||
private var guiKeypressLatched = BitSet(256)
|
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() {
|
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 (Gdx.input.isButtonPressed(Buttons.LEFT)) {
|
||||||
if (!guiClickLatched[Buttons.LEFT]) {
|
if (!guiClickLatched[Buttons.LEFT]) {
|
||||||
val mx = Gdx.input.x - x
|
val panel = panelAtMouse(mx, my)
|
||||||
val my = Gdx.input.y - y
|
if (panel >= 0) {
|
||||||
|
selectedPlayhead = panel
|
||||||
if (mx in 117..629) {
|
} else if (mouseInBigScope(mx, my)) {
|
||||||
for (i in 0..3) {
|
scopeMode[selectedPlayhead] =
|
||||||
val syTop = h - 7 - 115 * i - 8 * FONT.H
|
(scopeMode[selectedPlayhead] + 1) % SCOPE_MODE_COUNT
|
||||||
val syBot = h - 3 - 115 * i
|
|
||||||
if (my in syTop..syBot) {
|
|
||||||
scopeMode[3 - i] = (scopeMode[3 - i] + 1) and 3
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guiClickLatched[Buttons.LEFT] = true
|
guiClickLatched[Buttons.LEFT] = true
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
guiClickLatched[Buttons.LEFT] = false
|
guiClickLatched[Buttons.LEFT] = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── RIGHT click on the big scope: cycle scope mode backward. ────────────────
|
||||||
if (Gdx.input.isButtonPressed(Buttons.RIGHT)) {
|
if (Gdx.input.isButtonPressed(Buttons.RIGHT)) {
|
||||||
if (!guiClickLatched[Buttons.RIGHT]) {
|
if (!guiClickLatched[Buttons.RIGHT]) {
|
||||||
val mx = Gdx.input.x - x
|
if (mouseInBigScope(mx, my)) {
|
||||||
val my = Gdx.input.y - y
|
scopeMode[selectedPlayhead] =
|
||||||
|
(scopeMode[selectedPlayhead] + SCOPE_MODE_COUNT - 1) % SCOPE_MODE_COUNT
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guiClickLatched[Buttons.RIGHT] = true
|
guiClickLatched[Buttons.RIGHT] = true
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
guiClickLatched[Buttons.RIGHT] = false
|
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 (Gdx.input.isKeyPressed(Input.Keys.LEFT)) {
|
||||||
if (!guiKeypressLatched[Input.Keys.LEFT]) {
|
if (!guiKeypressLatched[Input.Keys.LEFT]) {
|
||||||
val mx = Gdx.input.x - x
|
scopeScrollHorz[selectedPlayhead] =
|
||||||
val my = Gdx.input.y - y
|
(scopeScrollHorz[selectedPlayhead] - 1).coerceIn(0, 14)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guiKeypressLatched[Input.Keys.LEFT] = true
|
guiKeypressLatched[Input.Keys.LEFT] = true
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
guiKeypressLatched[Input.Keys.LEFT] = false
|
guiKeypressLatched[Input.Keys.LEFT] = false
|
||||||
}
|
}
|
||||||
if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) {
|
if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) {
|
||||||
if (!guiKeypressLatched[Input.Keys.RIGHT]) {
|
if (!guiKeypressLatched[Input.Keys.RIGHT]) {
|
||||||
val mx = Gdx.input.x - x
|
scopeScrollHorz[selectedPlayhead] =
|
||||||
val my = Gdx.input.y - y
|
(scopeScrollHorz[selectedPlayhead] + 1).coerceIn(0, 14)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guiKeypressLatched[Input.Keys.RIGHT] = true
|
guiKeypressLatched[Input.Keys.RIGHT] = true
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
guiKeypressLatched[Input.Keys.RIGHT] = false
|
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
|
val adev = parent.currentlyPersistentVM?.vm?.peripheralTable?.getOrNull(cardIndex ?: -1)?.peripheral as? AudioAdapter
|
||||||
|
|
||||||
if (adev != null) {
|
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 {
|
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
|
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) {
|
for (i in 0..3) {
|
||||||
val ahead = adev.extortField<Array<AudioAdapter.Playhead>>("playheads")!![i]
|
drawStatusLCD(adev, playheads[i], batch, i,
|
||||||
drawSoundscope(adev, ahead, batch, i, 117f, 5f + 115 * i)
|
statusX(i).toFloat() + 9f, statusY.toFloat() + 9f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
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!
|
// NOTE: Samples count for PCM mode is drawn by drawSoundscope() function, not this one!
|
||||||
|
|
||||||
batch.inUse {
|
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
|
batch.color = Color.WHITE
|
||||||
// PLAY icon
|
// PLAY icon (shifted right to make room for the playhead tag)
|
||||||
if (ahead.isPlaying)
|
if (ahead.isPlaying)
|
||||||
FONT.draw(batch, STR_PLAY, x, y)
|
FONT.draw(batch, STR_PLAY, x + 21, y)
|
||||||
FONT.draw(batch, if (ahead.isPcmMode) "PCM" else "TRACKER", x + 21, y)
|
FONT.draw(batch, if (ahead.isPcmMode) "PCM" else "TRACKER", x + 42, y)
|
||||||
|
|
||||||
// PCM Mode labels
|
// PCM Mode labels
|
||||||
if (ahead.isPcmMode) {
|
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)
|
FONT.draw(batch, "Tickrate", x, y + 6*FONT.H)
|
||||||
|
|
||||||
batch.color = COL_ACTIVE3
|
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.masterVolume}", x + 84, y + 3*FONT.H)
|
||||||
FONT.drawRalign(batch, "${ahead.masterPan}", x + 84, y + 4*FONT.H)
|
FONT.drawRalign(batch, "${ahead.masterPan}", x + 84, y + 4*FONT.H)
|
||||||
FONT.drawRalign(batch, "${ahead.bpm}", x + 84, y + 5*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 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 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 VOL_SYM = arrayOf('@','^','&',' ')
|
||||||
private val PAN_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 gdxadev = ahead.audioDevice
|
||||||
val bytes = gdxadev.extortField<ByteArray>("bytes")
|
val bytes = gdxadev.extortField<ByteArray>("bytes")
|
||||||
val bytesLen = gdxadev.extortField<Int>("bytesLength")!!
|
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 {
|
batch.inUse {
|
||||||
if (ahead.isPcmMode && bytes != null) {
|
if (ahead.isPcmMode && bytes != null) {
|
||||||
val smpCnt = bytesLen / 4 - 1
|
val smpCnt = bytesLen / 4 - 1
|
||||||
|
|
||||||
for (s in 0..511) {
|
try {
|
||||||
val i = (smpCnt * (s / 511.0)).roundToInt().and(0xfffffe)
|
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 smpL =
|
||||||
val smpR = (bytes[i*4+2].toUint() or bytes[i*4+3].toUint().shl(8)).u16Tos16().toDouble().div(32767)
|
(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 smpLH = smpL * envelopeHalfHeight
|
||||||
val smpRH = smpR * envelopeHalfHeight
|
val smpRH = smpR * envelopeHalfHeight
|
||||||
|
|
||||||
val smpLHi = bipolarFloor(smpLH)
|
val smpLHi = bipolarFloor(smpLH)
|
||||||
val smpRHi = bipolarFloor(smpRH)
|
val smpRHi = bipolarFloor(smpRH)
|
||||||
val smpLHi2 = bipolarCeil(smpLH)
|
val smpLHi2 = bipolarCeil(smpLH)
|
||||||
val smpRHi2 = bipolarCeil(smpRH)
|
val smpRHi2 = bipolarCeil(smpRH)
|
||||||
|
|
||||||
val smpLHe = abs(smpLH - smpLHi).toFloat()
|
val smpLHe = abs(smpLH - smpLHi).toFloat()
|
||||||
val smpRHe = abs(smpRH - smpRHi).toFloat()
|
val smpRHe = abs(smpRH - smpRHi).toFloat()
|
||||||
|
|
||||||
// antialias in y-axis
|
// antialias in y-axis
|
||||||
if (smpLHi != smpLHi2) {
|
if (smpLHi != smpLHi2) {
|
||||||
batch.color = COL_SOUNDSCOPE_FORE.cpy().mul(smpLHe)
|
batch.color = COL_SOUNDSCOPE_FORE.cpy().mul(smpLHe)
|
||||||
batch.fillRect(x + s, y + 27, 1, smpLHi2)
|
batch.fillRect(x + s, y + lCenterY, 1, smpLHi2)
|
||||||
}
|
}
|
||||||
if (smpRHi != smpRHi2) {
|
if (smpRHi != smpRHi2) {
|
||||||
batch.color = COL_SOUNDSCOPE_FORE.cpy().mul(smpRHe)
|
batch.color = COL_SOUNDSCOPE_FORE.cpy().mul(smpRHe)
|
||||||
batch.fillRect(x + s, y + 81, 1, smpRHi2)
|
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
|
// PCM Samples count — drawn inside the scope (top-left) since the status
|
||||||
batch.color = COL_SOUNDSCOPE_FORE
|
// panels no longer sit beside it in the new single-scope layout.
|
||||||
batch.fillRect(x + s, y + 27, 1, smpLHi)
|
batch.color = Color.WHITE
|
||||||
batch.fillRect(x + s, y + 81, 1, smpRHi)
|
FONT.draw(batch, "Samples", x + 4, y + patOffY)
|
||||||
|
batch.color = COL_ACTIVE3
|
||||||
|
FONT.draw(batch, "${smpCnt + 1}", x + 4 + 8 * FONT.W, y + patOffY)
|
||||||
}
|
}
|
||||||
|
catch (_: ArrayIndexOutOfBoundsException) {}
|
||||||
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)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Tracker pattern visualiser.
|
// Tracker pattern visualiser.
|
||||||
@@ -320,11 +392,13 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
|||||||
val ts = ahead.trackerState
|
val ts = ahead.trackerState
|
||||||
if (ts == null) {
|
if (ts == null) {
|
||||||
batch.color = COL_SOUNDSCOPE_FORE
|
batch.color = COL_SOUNDSCOPE_FORE
|
||||||
FONT.draw(batch, "No tracker state", x, y + 4)
|
FONT.draw(batch, "No tracker state", x, y + patOffY)
|
||||||
} else {
|
} else {
|
||||||
val cuePos = ts.cuePos
|
val cuePos = ts.cuePos
|
||||||
val rowIdx = ts.rowIndex
|
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
|
val PTN_MAX_ROWS = 63
|
||||||
|
|
||||||
when (scopeMode[index]) {
|
when (scopeMode[index]) {
|
||||||
@@ -338,11 +412,11 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
|||||||
val ci = cueFirst + r
|
val ci = cueFirst + r
|
||||||
if (ci > 1023) break
|
if (ci > 1023) break
|
||||||
val here = ci == cuePos
|
val here = ci == cuePos
|
||||||
val ry = y + 4 + r * TINY.H
|
val ry = y + patOffY + r * TINY.H
|
||||||
|
|
||||||
if (here) {
|
if (here) {
|
||||||
batch.color = COL_TRACKER_ROW
|
batch.color = COL_TRACKER_ROW
|
||||||
batch.fillRect(x, ry, 512, TINY.H)
|
batch.fillRect(x, ry, w, TINY.H)
|
||||||
}
|
}
|
||||||
|
|
||||||
var cx = x
|
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 0: Detailed pattern with colour-coded fields ────────────
|
||||||
// ── Mode 1: Abridged pattern with colour-coded fields ────────────
|
// ── Mode 1: Abridged pattern with colour-coded fields ────────────
|
||||||
// ── Mode 2: Super-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
|
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
|
||||||
TINY.draw(batch,
|
TINY.draw(batch,
|
||||||
"${if (here) ">" else " "}${ci.toString(16).padStart(3, '0').uppercase()}",
|
"${if (here) ">" else " "}${ci.toString(16).padStart(3, '0').uppercase()}",
|
||||||
x, y + 4 + r * TINY.H)
|
x, y + patOffY + r * TINY.H)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vertical separator
|
// Vertical separator
|
||||||
batch.color = COL_SOUNDSCOPE_FORE
|
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
|
// 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
|
val ri = rowFirst + r
|
||||||
if (ri > PTN_MAX_ROWS) break
|
if (ri > PTN_MAX_ROWS) break
|
||||||
val here = ri == rowIdx
|
val here = ri == rowIdx
|
||||||
val ry = y + 4 + r * TINY.H
|
val ry = y + patOffY + r * TINY.H
|
||||||
|
|
||||||
if (here) {
|
if (here) {
|
||||||
batch.color = COL_TRACKER_ROW
|
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
|
var cx = patX
|
||||||
|
|||||||
Reference in New Issue
Block a user