diff --git a/CLAUDE.md b/CLAUDE.md index 1808efd..2d4e47b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,7 @@ algorithms with file:line citations, and add an entry here. ### Key Technologies - **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 - **GraalVM**: JavaScript execution engine for running programs in the VM - **LWJGL**: Native library bindings diff --git a/assets/disk0/tvdos/bin/monplay.js b/assets/disk0/tvdos/bin/monplay.js new file mode 100644 index 0000000..cb1f52c --- /dev/null +++ b/assets/disk0/tvdos/bin/monplay.js @@ -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 +// +// 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 ") + 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 diff --git a/terranmon.txt b/terranmon.txt index 9632439..3e3cfce 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -113,10 +113,30 @@ MMIO 90 RO: BMS calculated battery percentage where 255 is 100% 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 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) 2048..4075 RW: Used by the hypervisor @@ -3202,7 +3222,7 @@ Endianness: Little C4 @ 262 Hz. Modern Chinese a-ak tuning convention 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 RAM image of Pattern Bin/Cue Sheet diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt index 420be38..931f8b9 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt @@ -3,6 +3,7 @@ package net.torvald.tsvm.peripheral import com.badlogic.gdx.Gdx import com.badlogic.gdx.Input import com.badlogic.gdx.InputProcessor +import com.badlogic.gdx.backends.lwjgl3.audio.OpenALLwjgl3Audio import com.badlogic.gdx.math.Vector2 import com.badlogic.gdx.utils.viewport.Viewport import net.torvald.AddressOverflowException @@ -14,6 +15,7 @@ import net.torvald.tsvm.isNonZero import net.torvald.tsvm.toInt import java.util.concurrent.atomic.AtomicInteger import kotlin.experimental.and +import kotlin.math.floor 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 bmsIsBatteryOperated = false + /** Built-in beeper / PSG speaker (MMIO 93..97). See terranmon.txt §93..97. */ + private val beeper = Beeper() + init { //blockTransferPorts[1].attachDevice(TestFunctionGenerator()) //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)) 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 1024..2047 -> peripheralFast[addr - 1024] @@ -221,6 +231,9 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor { 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 1024..2047 -> peripheralFast[addr - 1024] = byte @@ -296,6 +309,7 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor { } override fun dispose() { + beeper.dispose() blockTransferRx.forEach { it.destroy() } blockTransferTx.forEach { it.destroy() } peripheralFast.destroy() @@ -483,3 +497,149 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor { 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 + } +}