mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-19 19:04:05 +09:00
tsvm: beeper speaker
This commit is contained in:
@@ -86,6 +86,7 @@ algorithms with file:line citations, and add an entry here.
|
|||||||
### Key Technologies
|
### Key Technologies
|
||||||
|
|
||||||
- **Kotlin/Java**: Primary implementation language
|
- **Kotlin/Java**: Primary implementation language
|
||||||
|
- `kotlinc` exists at `/home/torvald/idea-IU-261.23567.138/plugins/Kotlin/kotlinc/bin/kotlinc`
|
||||||
- **LibGDX**: Graphics and windowing framework
|
- **LibGDX**: Graphics and windowing framework
|
||||||
- **GraalVM**: JavaScript execution engine for running programs in the VM
|
- **GraalVM**: JavaScript execution engine for running programs in the VM
|
||||||
- **LWJGL**: Native library bindings
|
- **LWJGL**: Native library bindings
|
||||||
|
|||||||
216
assets/disk0/tvdos/bin/monplay.js
Normal file
216
assets/disk0/tvdos/bin/monplay.js
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
// monplay.js -- Monotone (.mon) test music player.
|
||||||
|
//
|
||||||
|
// Reads a MONOTONE module and renders it, on the fly, to the built-in beeper
|
||||||
|
// (IOSpace MMIO 93..97). Per the brief: all .mon note effects are IGNORED
|
||||||
|
// except the arpeggio (0xy), and the module's (up to 3) simultaneous voices
|
||||||
|
// are MULTIPLEXED onto the beeper's hardware arpeggio effect.
|
||||||
|
//
|
||||||
|
// usage: monplay <file.mon>
|
||||||
|
//
|
||||||
|
// Format reference: reference_materials/monotone-tracker-parser-lua/ and
|
||||||
|
// reference_materials/MONOTONE/MTSRC/MT_PLAY.PAS .
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Beeper hardware (IOSpace). MMIO byte m is reached at JS address -(m+1):
|
||||||
|
// 93 RO -> reading uploads the staged command (the strobe)
|
||||||
|
// 94..97 -> PPPPPPPP / pppppp_QQ / AAAAAAAA / BBBBBBBB
|
||||||
|
// The square wave is f = (3579545/16) / (2 * divider); divider 0 = silence.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const BEEP_UPLOAD = -94 // read MMIO 93 to upload
|
||||||
|
const BEEP_P_HI = -95 // MMIO 94: PPPPPPPP
|
||||||
|
const BEEP_P_LO = -96 // MMIO 95: pppppp_QQ
|
||||||
|
const BEEP_A = -97 // MMIO 96: A
|
||||||
|
const BEEP_B = -98 // MMIO 97: B
|
||||||
|
|
||||||
|
const BEEP_HALFCLOCK = 3579545 / 16 / 2 // f = BEEP_HALFCLOCK / divider
|
||||||
|
const DIVIDER_MAX = 0x3FFF // 14-bit
|
||||||
|
const A0_HZ = 27.5 // MONOTONE note index 1 == A0 == 27.5 Hz
|
||||||
|
|
||||||
|
// Beeper note effects (QQ field)
|
||||||
|
const QQ_NONE = 0, QQ_TWO = 2, QQ_THREE = 3
|
||||||
|
|
||||||
|
function uploadBeeper(divider, effect, a, b) {
|
||||||
|
if (divider < 0) divider = 0
|
||||||
|
if (divider > DIVIDER_MAX) divider = DIVIDER_MAX
|
||||||
|
sys.poke(BEEP_P_HI, (divider >> 6) & 0xFF)
|
||||||
|
sys.poke(BEEP_P_LO, ((divider & 0x3F) << 2) | (effect & 3))
|
||||||
|
sys.poke(BEEP_A, a & 0xFF)
|
||||||
|
sys.poke(BEEP_B, b & 0xFF)
|
||||||
|
sys.peek(BEEP_UPLOAD) // strobe: commit the staged command
|
||||||
|
}
|
||||||
|
function silenceBeeper() { uploadBeeper(0, QQ_NONE, 0, 0) }
|
||||||
|
|
||||||
|
// MONOTONE note index (1 = A0) -> beeper frequency divider.
|
||||||
|
function noteToDivider(note) {
|
||||||
|
const hz = A0_HZ * Math.pow(2, (note - 1) / 12)
|
||||||
|
let d = Math.round(BEEP_HALFCLOCK / hz)
|
||||||
|
if (d < 1) d = 1
|
||||||
|
if (d > DIVIDER_MAX) d = DIVIDER_MAX
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a beeper command that multiplexes the currently-sounding voices.
|
||||||
|
//
|
||||||
|
// The hardware arpeggio plays note0 then note0 minus a (positive) offset, so the
|
||||||
|
// base divider must be the LARGEST (lowest pitch) and the others are reached by
|
||||||
|
// subtraction:
|
||||||
|
// 2 notes -> effect 2, 16-bit delta (always exact)
|
||||||
|
// 3 notes -> effect 3, two 8-bit deltas (exact only when both deltas <= 255)
|
||||||
|
// When three widely-spaced notes don't fit effect 3's 8-bit deltas we keep the
|
||||||
|
// two extremes (bass + melody, correct pitch) via effect 2 rather than play three
|
||||||
|
// wrong pitches.
|
||||||
|
function buildCommand(dividers) {
|
||||||
|
// de-duplicate, then sort descending (largest divider == lowest pitch first)
|
||||||
|
const ds = Array.from(new Set(dividers)).sort((x, y) => y - x)
|
||||||
|
|
||||||
|
if (ds.length === 0) return [0, QQ_NONE, 0, 0]
|
||||||
|
if (ds.length === 1) return [ds[0], QQ_NONE, 0, 0]
|
||||||
|
if (ds.length === 2) {
|
||||||
|
const diff = ds[0] - ds[1] // >= 0
|
||||||
|
return [ds[0], QQ_TWO, diff & 0xFF, (diff >> 8) & 0xFF]
|
||||||
|
}
|
||||||
|
|
||||||
|
// >= 3 voices: keep the lowest, a middle, and the highest.
|
||||||
|
const lo = ds[0], hi = ds[ds.length - 1], mid = ds[ds.length >> 1]
|
||||||
|
const a = lo - mid, b = mid - hi
|
||||||
|
if (a <= 0xFF && b <= 0xFF) return [lo, QQ_THREE, a, b]
|
||||||
|
|
||||||
|
// Too wide for effect 3's 8-bit deltas: fall back to bass + melody.
|
||||||
|
const diff = lo - hi
|
||||||
|
return [lo, QQ_TWO, diff & 0xFF, (diff >> 8) & 0xFF]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Load and parse the .mon file
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const pathArg = exec_args[1]
|
||||||
|
if (!pathArg) {
|
||||||
|
println("usage: monplay <file.mon>")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const full = _G.shell.resolvePathInput(pathArg).full
|
||||||
|
const FILE_LENGTH = files.open(full).size
|
||||||
|
|
||||||
|
const seqread = require("seqread")
|
||||||
|
seqread.prepare(full)
|
||||||
|
const buf = seqread.readBytes(FILE_LENGTH)
|
||||||
|
const B = (off) => sys.peek(buf + off) & 255 // byte at file offset
|
||||||
|
|
||||||
|
// magic: 0x08 "MONOTONE"
|
||||||
|
const MAGIC = [0x08, 0x4D, 0x4F, 0x4E, 0x4F, 0x54, 0x4F, 0x4E, 0x45]
|
||||||
|
if (!MAGIC.every((m, i) => B(i) === m)) {
|
||||||
|
println("Not a MONOTONE file: " + full)
|
||||||
|
sys.free(buf)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const SONG_LEN = B(0x5C) // number of orders (informational)
|
||||||
|
const VOICES = B(0x5D)
|
||||||
|
if (VOICES < 1 || VOICES > 8) {
|
||||||
|
println("Bad voice count: " + VOICES)
|
||||||
|
sys.free(buf)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order list: 0x5F.. , 0xFF-terminated (max 256 entries).
|
||||||
|
const orders = []
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
const p = B(0x5F + i)
|
||||||
|
if (p === 0xFF) break
|
||||||
|
orders.push(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern data: 64 rows x VOICES x 2 bytes, voice-interleaved, little-endian,
|
||||||
|
// stored sequentially from 0x15F regardless of the order list.
|
||||||
|
const PATTERN_ROWS = 0x40
|
||||||
|
const PATTERN_BASE = 0x15F
|
||||||
|
const PATTERN_SIZE = PATTERN_ROWS * 2 * VOICES
|
||||||
|
const cellWord = (pattern, row, voice) => {
|
||||||
|
const off = PATTERN_BASE + pattern * PATTERN_SIZE + (row * VOICES + voice) * 2
|
||||||
|
return B(off) | (B(off + 1) << 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MT_PLAY.PAS: 60 Hz tick, tempo (ticks/row) = max(voices, 4).
|
||||||
|
const TICK_HZ = 60
|
||||||
|
const TICK_NANO = 1e9 / TICK_HZ
|
||||||
|
const TICKS_PER_ROW = Math.max(VOICES, 4)
|
||||||
|
|
||||||
|
println(`MONOTONE: ${full}`)
|
||||||
|
println(` voices ${VOICES}, orders ${orders.length} (songlen ${SONG_LEN}), ` +
|
||||||
|
`${TICKS_PER_ROW} ticks/row @ ${TICK_HZ}Hz`)
|
||||||
|
println(" (Ctrl+Shift+T+R to stop)")
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Playback state (per voice)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const NOTE_OFF = 0x7F
|
||||||
|
const voiceNote = new Array(VOICES).fill(0) // held note (1..0x7E)
|
||||||
|
const voiceOn = new Array(VOICES).fill(false) // is the voice sounding?
|
||||||
|
const voiceArpX = new Array(VOICES).fill(0) // arpeggio 2nd-note offset
|
||||||
|
const voiceArpY = new Array(VOICES).fill(0) // arpeggio 3rd-note offset
|
||||||
|
|
||||||
|
// Latch a new row of cells. All effects are ignored except arpeggio (0xy):
|
||||||
|
// effect type = eff>>6, arpeggio is type 0 with nonzero args x=(eff>>3)&7, y=eff&7.
|
||||||
|
function applyRow(pattern, row) {
|
||||||
|
for (let v = 0; v < VOICES; v++) {
|
||||||
|
const w = cellWord(pattern, row, v)
|
||||||
|
const note = w >> 9
|
||||||
|
const eff = w & 0x1FF
|
||||||
|
|
||||||
|
if (note === NOTE_OFF) voiceOn[v] = false
|
||||||
|
else if (note >= 1 && note <= 0x7E) { voiceOn[v] = true; voiceNote[v] = note }
|
||||||
|
// note === 0 -> continue holding the previous note
|
||||||
|
|
||||||
|
if (eff !== 0 && (eff >> 6) === 0) { voiceArpX[v] = (eff >> 3) & 7; voiceArpY[v] = eff & 7 }
|
||||||
|
else { voiceArpX[v] = 0; voiceArpY[v] = 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A voice's effective note this tick, honouring its arpeggio (base / +x / +y).
|
||||||
|
function effectiveNote(v, tickInRow) {
|
||||||
|
let n = voiceNote[v]
|
||||||
|
if (voiceArpX[v] !== 0 || voiceArpY[v] !== 0) {
|
||||||
|
const phase = tickInRow % 3
|
||||||
|
if (phase === 1) n += voiceArpX[v]
|
||||||
|
else if (phase === 2) n += voiceArpY[v]
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopRequested = () => (sys.peek(-49) & 1) !== 0 // MMIO 48 bit0 = SIGTERM
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render loop
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let nextTick = sys.nanoTime()
|
||||||
|
try {
|
||||||
|
let o = 0
|
||||||
|
while (o < orders.length) {
|
||||||
|
const pattern = orders[o]
|
||||||
|
for (let row = 0; row < PATTERN_ROWS; row++) {
|
||||||
|
applyRow(pattern, row)
|
||||||
|
for (let t = 0; t < TICKS_PER_ROW; t++) {
|
||||||
|
if (stopRequested()) return 0
|
||||||
|
|
||||||
|
const dividers = []
|
||||||
|
for (let v = 0; v < VOICES; v++) {
|
||||||
|
if (voiceOn[v] && voiceNote[v] >= 1) dividers.push(noteToDivider(effectiveNote(v, t)))
|
||||||
|
}
|
||||||
|
const cmd = buildCommand(dividers)
|
||||||
|
uploadBeeper(cmd[0], cmd[1], cmd[2], cmd[3])
|
||||||
|
|
||||||
|
nextTick += TICK_NANO
|
||||||
|
const waitMs = (nextTick - sys.nanoTime()) / 1e6
|
||||||
|
if (waitMs >= 1) sys.sleep(Math.floor(waitMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
silenceBeeper()
|
||||||
|
sys.free(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
@@ -113,10 +113,30 @@ MMIO
|
|||||||
90 RO: BMS calculated battery percentage where 255 is 100%
|
90 RO: BMS calculated battery percentage where 255 is 100%
|
||||||
91 RO: BMS battery voltage multiplied by 10 (127 = "12.7 V")
|
91 RO: BMS battery voltage multiplied by 10 (127 = "12.7 V")
|
||||||
|
|
||||||
92 RW: Memory Mapping
|
92 RO: System Memory Configuration
|
||||||
0: 8 MB Core, 8 MB Hardware-reserved, 7 card slots
|
0: 8 MB Core, 8 MB Hardware-reserved, 7 card slots
|
||||||
1: 12 MB Core, 4 MB Hardware-reserved, 3 card slots (HW addr 131072..1048575 cannot be reclaimed though)
|
1: 12 MB Core, 4 MB Hardware-reserved, 3 card slots (HW addr 131072..1048575 cannot be reclaimed though)
|
||||||
|
|
||||||
|
93 RO: Set beeper status (aka upload beeper command)
|
||||||
|
READING causes the side effect (and returns beeper status — 1 if a tone is currently sounding, 0 otherwise). WRITING DOES NOTHING
|
||||||
|
94..97 RW: Beeper command
|
||||||
|
0bPPPPPPPP 0bpppppp_QQ 0bAAAAAAAA 0bBBBBBBBB
|
||||||
|
|
||||||
|
PPPPPPPPpppppp: frequency divider (master clock: 3579545 / 16 Hz), determines pitch.
|
||||||
|
0: no sound
|
||||||
|
QQ: note effect
|
||||||
|
00: none
|
||||||
|
01: fixed arpeggio (rate = 60 Hz, second note is always divisor (P >>> 1))
|
||||||
|
10: two-note argeggio (rate = 60 Hz)
|
||||||
|
tick 1: base note at divisor P is played
|
||||||
|
tick 2: second note at divisor (P - (B << 8 | A)) is played
|
||||||
|
11: three-note arpeggio (rate = 60 Hz)
|
||||||
|
tick 1: base note at divisor P is played
|
||||||
|
tick 2: second note at divisor (P - A) is played
|
||||||
|
tick 3: third note at divisor (P - A - B) is played
|
||||||
|
A/B: note effect arguments
|
||||||
|
|
||||||
|
|
||||||
1024..2047 RW: Reserved for integrated peripherals (e.g. built-in status display)
|
1024..2047 RW: Reserved for integrated peripherals (e.g. built-in status display)
|
||||||
|
|
||||||
2048..4075 RW: Used by the hypervisor
|
2048..4075 RW: Used by the hypervisor
|
||||||
@@ -3202,7 +3222,7 @@ Endianness: Little
|
|||||||
C4 @ 262 Hz. Modern Chinese a-ak tuning convention
|
C4 @ 262 Hz. Modern Chinese a-ak tuning convention
|
||||||
C4 @ 311 Hz. Korean hyang-ak tuning standard (ROK National Gugak Center)
|
C4 @ 311 Hz. Korean hyang-ak tuning standard (ROK National Gugak Center)
|
||||||
|
|
||||||
For your reference, tracker default tuning at A4 is 439.526 Hz (8363*2^(3/4) / 32)
|
For your reference, tracker default tuning at A4 is 439.548 Hz ((3579545/428)*2^(3/4) / 32)
|
||||||
|
|
||||||
## Pattern Bin and Cue Sheet
|
## Pattern Bin and Cue Sheet
|
||||||
RAM image of Pattern Bin/Cue Sheet
|
RAM image of Pattern Bin/Cue Sheet
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package net.torvald.tsvm.peripheral
|
|||||||
import com.badlogic.gdx.Gdx
|
import com.badlogic.gdx.Gdx
|
||||||
import com.badlogic.gdx.Input
|
import com.badlogic.gdx.Input
|
||||||
import com.badlogic.gdx.InputProcessor
|
import com.badlogic.gdx.InputProcessor
|
||||||
|
import com.badlogic.gdx.backends.lwjgl3.audio.OpenALLwjgl3Audio
|
||||||
import com.badlogic.gdx.math.Vector2
|
import com.badlogic.gdx.math.Vector2
|
||||||
import com.badlogic.gdx.utils.viewport.Viewport
|
import com.badlogic.gdx.utils.viewport.Viewport
|
||||||
import net.torvald.AddressOverflowException
|
import net.torvald.AddressOverflowException
|
||||||
@@ -14,6 +15,7 @@ import net.torvald.tsvm.isNonZero
|
|||||||
import net.torvald.tsvm.toInt
|
import net.torvald.tsvm.toInt
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import kotlin.experimental.and
|
import kotlin.experimental.and
|
||||||
|
import kotlin.math.floor
|
||||||
|
|
||||||
class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
||||||
|
|
||||||
@@ -67,6 +69,9 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
|||||||
private var bmsHasBattery = false
|
private var bmsHasBattery = false
|
||||||
private var bmsIsBatteryOperated = false
|
private var bmsIsBatteryOperated = false
|
||||||
|
|
||||||
|
/** Built-in beeper / PSG speaker (MMIO 93..97). See terranmon.txt §93..97. */
|
||||||
|
private val beeper = Beeper()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
//blockTransferPorts[1].attachDevice(TestFunctionGenerator())
|
//blockTransferPorts[1].attachDevice(TestFunctionGenerator())
|
||||||
//blockTransferPorts[0].attachDevice(TestDiskDrive(vm, 0, File("assets")))
|
//blockTransferPorts[0].attachDevice(TestDiskDrive(vm, 0, File("assets")))
|
||||||
@@ -144,6 +149,11 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
|||||||
89L -> ((acpiShutoff.toInt(7)) or (bmsIsBatteryOperated.toInt(3)) or (bmsHasBattery.toInt(1))
|
89L -> ((acpiShutoff.toInt(7)) or (bmsIsBatteryOperated.toInt(3)) or (bmsHasBattery.toInt(1))
|
||||||
or bmsIsCharging.toInt()).toByte()
|
or bmsIsCharging.toInt()).toByte()
|
||||||
|
|
||||||
|
// 93 RO: reading uploads the staged command (94..97) into the live tone and
|
||||||
|
// returns the beeper status (bit 0 = a tone is currently sounding).
|
||||||
|
93L -> beeper.upload()
|
||||||
|
in 94..97 -> beeper.readCommand(adi - 94)
|
||||||
|
|
||||||
in 2048L..4075L -> hyveArea[addr.toInt() - 2048]
|
in 2048L..4075L -> hyveArea[addr.toInt() - 2048]
|
||||||
|
|
||||||
in 1024..2047 -> peripheralFast[addr - 1024]
|
in 1024..2047 -> peripheralFast[addr - 1024]
|
||||||
@@ -221,6 +231,9 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
|||||||
acpiShutoff = byte.and(-128).isNonZero()
|
acpiShutoff = byte.and(-128).isNonZero()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 94..97 RW: beeper command staging. Takes effect on the next read of MMIO 93.
|
||||||
|
in 94..97 -> beeper.writeCommand(adi - 94, byte)
|
||||||
|
|
||||||
in 2048L..4075L -> hyveArea[addr.toInt() - 2048] = byte
|
in 2048L..4075L -> hyveArea[addr.toInt() - 2048] = byte
|
||||||
|
|
||||||
in 1024..2047 -> peripheralFast[addr - 1024] = byte
|
in 1024..2047 -> peripheralFast[addr - 1024] = byte
|
||||||
@@ -296,6 +309,7 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun dispose() {
|
override fun dispose() {
|
||||||
|
beeper.dispose()
|
||||||
blockTransferRx.forEach { it.destroy() }
|
blockTransferRx.forEach { it.destroy() }
|
||||||
blockTransferTx.forEach { it.destroy() }
|
blockTransferTx.forEach { it.destroy() }
|
||||||
peripheralFast.destroy()
|
peripheralFast.destroy()
|
||||||
@@ -483,3 +497,149 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in beeper / PSG speaker (terranmon.txt §93..97).
|
||||||
|
*
|
||||||
|
* A single square-wave tone generator modelled on the SN76489: a 14-bit frequency
|
||||||
|
* divider over a 3579545/16 Hz master clock, with optional 50 Hz arpeggio
|
||||||
|
* note-effects. The four command bytes (MMIO 94..97) are write staging; reading
|
||||||
|
* MMIO 93 latches them into the live tone ("upload beeper command").
|
||||||
|
*
|
||||||
|
* The OpenAL device and its render thread are created lazily on the first non-silent
|
||||||
|
* upload, so a headless VM (no LibGDX OpenAL backend) simply stays silent.
|
||||||
|
*/
|
||||||
|
private class Beeper {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SAMPLE_RATE = 48000
|
||||||
|
// SN76489 NTSC colourburst clock (3579545 Hz) after the chip's internal /16
|
||||||
|
// prescaler. The square wave toggles every `divider` master ticks, so one full
|
||||||
|
// period spans 2*divider ticks -> f = MASTER_CLOCK / (2 * divider).
|
||||||
|
// (divider 254 -> 440.4 Hz, matching real SN76489 hardware.)
|
||||||
|
private const val MASTER_CLOCK = 3579545.0 / 16.0
|
||||||
|
// Arpeggio note-effects step at 60 Hz: 48000 / 60 = 800 samples per step.
|
||||||
|
private const val SAMPLES_PER_ARP_TICK = SAMPLE_RATE / 60
|
||||||
|
private const val CHUNK = 512
|
||||||
|
private const val AMPLITUDE = 6000 // ~ -15 dBFS; square waves are loud
|
||||||
|
}
|
||||||
|
|
||||||
|
// MMIO 94..97 write-staging registers: PPPPPPPP / pppppp_QQ / AAAAAAAA / BBBBBBBB
|
||||||
|
private val cmd = ByteArray(4)
|
||||||
|
|
||||||
|
// Latched ("uploaded") live command, read by the render thread.
|
||||||
|
@Volatile private var divider = 0 // 14-bit frequency divider; 0 = no sound
|
||||||
|
@Volatile private var effect = 0 // QQ note-effect: 0 none, 1 fixed, 2 two-note, 3 three-note
|
||||||
|
@Volatile private var argA = 0 // A
|
||||||
|
@Volatile private var argB = 0 // B
|
||||||
|
|
||||||
|
@Volatile private var running = false
|
||||||
|
private var renderThread: Thread? = null
|
||||||
|
private var audioDevice: OpenALBufferedAudioDevice? = null
|
||||||
|
|
||||||
|
fun writeCommand(index: Int, byte: Byte) { cmd[index] = byte }
|
||||||
|
fun readCommand(index: Int): Byte = cmd[index]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Latch MMIO 94..97 into the live tone and (lazily) start playback. Returns the
|
||||||
|
* beeper status byte (bit 0 set while a tone is sounding). Invoked by a read of MMIO 93.
|
||||||
|
*/
|
||||||
|
fun upload(): Byte {
|
||||||
|
val hi = cmd[0].toInt() and 255 // PPPPPPPP
|
||||||
|
val lo = cmd[1].toInt() and 255 // pppppp_QQ
|
||||||
|
divider = (hi shl 6) or (lo ushr 2) // 14-bit frequency divider
|
||||||
|
effect = lo and 0b11 // QQ
|
||||||
|
argA = cmd[2].toInt() and 255 // A
|
||||||
|
argB = cmd[3].toInt() and 255 // B
|
||||||
|
if (divider != 0) ensureStarted()
|
||||||
|
return (if (divider != 0) 1 else 0).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized private fun ensureStarted() {
|
||||||
|
if (running) return
|
||||||
|
val audio = try { Gdx.audio } catch (e: Throwable) { null }
|
||||||
|
if (audio !is OpenALLwjgl3Audio) return // headless / no audio backend: stay silent
|
||||||
|
val bufSize = reflectIntField(audio, "deviceBufferSize", 1024)
|
||||||
|
val bufCount = reflectIntField(audio, "deviceBufferCount", 9)
|
||||||
|
try {
|
||||||
|
audioDevice = OpenALBufferedAudioDevice(audio, SAMPLE_RATE, true, bufSize, bufCount) {}
|
||||||
|
}
|
||||||
|
catch (e: Throwable) {
|
||||||
|
System.err.println("[Beeper] could not open audio device: $e")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
running = true
|
||||||
|
renderThread = Thread({ renderLoop() }, "BeeperRender").also {
|
||||||
|
it.isDaemon = true
|
||||||
|
it.uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { _, t -> t.printStackTrace() }
|
||||||
|
it.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reflectIntField(target: Any, name: String, fallback: Int): Int = try {
|
||||||
|
target.javaClass.getDeclaredField(name).let { it.isAccessible = true; it.getInt(target) }
|
||||||
|
}
|
||||||
|
catch (e: Throwable) { fallback }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the divisor for the current arpeggio step. A non-positive divisor (the
|
||||||
|
* subtraction effects can overshoot when A/B exceed P) is treated as silence.
|
||||||
|
*/
|
||||||
|
private fun divisorForTick(arpTick: Long): Int = when (effect) {
|
||||||
|
// 01: fixed arpeggio — alternate base / one octave up (P >>> 1).
|
||||||
|
1 -> if (arpTick and 1L == 0L) divider else divider ushr 1
|
||||||
|
// 10: two-note arpeggio — base / (P - (B<<8 | A)).
|
||||||
|
2 -> if (arpTick and 1L == 0L) divider else divider - ((argB shl 8) or argA)
|
||||||
|
// 11: three-note arpeggio — base / (P - A) / (P - A - B).
|
||||||
|
3 -> when ((arpTick % 3L).toInt()) { 0 -> divider; 1 -> divider - argA; else -> divider - argA - argB }
|
||||||
|
// 00: no effect.
|
||||||
|
else -> divider
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderLoop() {
|
||||||
|
val buf = ShortArray(CHUNK)
|
||||||
|
val hiSample = AMPLITUDE.toShort()
|
||||||
|
val loSample = (-AMPLITUDE).toShort()
|
||||||
|
var phase = 0.0
|
||||||
|
var arpSample = 0
|
||||||
|
var arpTick = 0L
|
||||||
|
while (running) {
|
||||||
|
try {
|
||||||
|
if (divider == 0) {
|
||||||
|
// Silent: stop feeding so the OpenAL source drains to quiet, then idle.
|
||||||
|
phase = 0.0; arpSample = 0; arpTick = 0L
|
||||||
|
Thread.sleep(4)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (i in 0 until CHUNK) {
|
||||||
|
val div = divisorForTick(arpTick)
|
||||||
|
if (div <= 0) {
|
||||||
|
buf[i] = 0
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
phase += (MASTER_CLOCK / (2.0 * div)) / SAMPLE_RATE
|
||||||
|
if (phase >= 1.0) phase -= floor(phase)
|
||||||
|
buf[i] = if (phase < 0.5) hiSample else loSample
|
||||||
|
}
|
||||||
|
if (++arpSample >= SAMPLES_PER_ARP_TICK) { arpSample = 0; arpTick++ }
|
||||||
|
}
|
||||||
|
// writeSamples blocks until a device buffer frees, pacing the loop in real time.
|
||||||
|
audioDevice?.writeSamples(buf, 0, CHUNK)
|
||||||
|
}
|
||||||
|
catch (e: InterruptedException) { break }
|
||||||
|
catch (e: Throwable) {
|
||||||
|
System.err.println("[Beeper] render error: $e")
|
||||||
|
try { Thread.sleep(4) } catch (_: InterruptedException) { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dispose() {
|
||||||
|
running = false
|
||||||
|
renderThread?.let { it.interrupt(); try { it.join(200) } catch (_: InterruptedException) {} }
|
||||||
|
renderThread = null
|
||||||
|
try { audioDevice?.stop() } catch (_: Throwable) {}
|
||||||
|
try { audioDevice?.dispose() } catch (_: Throwable) {}
|
||||||
|
audioDevice = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user