From ddc4a228092e4ee5cec79782542700c646e46356 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sat, 20 Jun 2026 04:23:00 +0900 Subject: [PATCH] beeper: extended arp --- assets/disk0/tvdos/bin/monplay.js | 72 +++++++++++-------- terranmon.txt | 4 +- .../net/torvald/tsvm/peripheral/IOSpace.kt | 60 +++++++++------- 3 files changed, 78 insertions(+), 58 deletions(-) diff --git a/assets/disk0/tvdos/bin/monplay.js b/assets/disk0/tvdos/bin/monplay.js index b793d2e..353dce7 100644 --- a/assets/disk0/tvdos/bin/monplay.js +++ b/assets/disk0/tvdos/bin/monplay.js @@ -1,7 +1,7 @@ // monplay.js -- Monotone (.mon) music player for the built-in beeper. // // Reads a MONOTONE module and renders it, on the fly, to the beeper -// (IOSpace MMIO 93..97). All eight Monotone note effects are supported. +// (IOSpace MMIO 93..99). All eight Monotone note effects are supported. // The module's simultaneous voices are multiplexed onto the beeper's // hardware arpeggio; when the notes fall outside what the hardware // arpeggiator can express, the multiplex is done in software instead. @@ -14,30 +14,37 @@ // --------------------------------------------------------------------------- // 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 +// 94..99 -> PPPPPPPP / pppppp_QQ / qqAABBCC / aaaaaaaa / bbbbbbbb / cccccccc +// AA/BB/CC are the high two bits of the 10-bit arpeggio deltas A/B/C. // 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_EXT = -97 // MMIO 96: qqAABBCC (high 2 bits of A/B/C) +const BEEP_A = -98 // MMIO 97: aaaaaaaa (A low 8 bits) +const BEEP_B = -99 // MMIO 98: bbbbbbbb (B low 8 bits) +const BEEP_C = -100 // MMIO 99: cccccccc (C low 8 bits) const BEEP_HALFCLOCK = (3579545.4545454545 / 16.0) / 2 // f = BEEP_HALFCLOCK / divider const DIVIDER_MAX = 0x3FFF // 14-bit +const ARG_MAX = 0x3FF // 10-bit arpeggio delta -const QQ_NONE = 0, QQ_TWO = 2, QQ_THREE = 3 // beeper note-effect (QQ field) +const QQ_NONE = 0, QQ_FOUR = 1, QQ_TWO = 2, QQ_THREE = 3 // beeper note-effect (QQ field) -function uploadBeeper(divider, effect, a, b) { +function uploadBeeper(divider, effect, a, b, c) { if (divider < 0) divider = 0 if (divider > DIVIDER_MAX) divider = DIVIDER_MAX + a &= ARG_MAX; b &= ARG_MAX; c &= ARG_MAX sys.poke(BEEP_P_HI, (divider >> 6) & 0xFF) sys.poke(BEEP_P_LO, ((divider & 0x3F) << 2) | (effect & 3)) + sys.poke(BEEP_EXT, (((a >> 8) & 3) << 4) | (((b >> 8) & 3) << 2) | ((c >> 8) & 3)) sys.poke(BEEP_A, a & 0xFF) sys.poke(BEEP_B, b & 0xFF) + sys.poke(BEEP_C, c & 0xFF) sys.peek(BEEP_UPLOAD) // strobe: commit the staged command } -function silenceBeeper() { uploadBeeper(0, QQ_NONE, 0, 0) } +function silenceBeeper() { uploadBeeper(0, QQ_NONE, 0, 0, 0) } // Hz -> beeper frequency divider. function freqToDivider(hz) { @@ -92,23 +99,28 @@ const intervalHz = (interval) => NOTESHZ[clampInterval(interval)] // base divider must be the LARGEST (lowest pitch) and the others are reached by // subtraction. Returns either a single hardware command {sw:false, cmd:[...]} // or, when the notes don't fit, a software-arpeggio plan {sw:true, dividers:[...]}. -// 1 note -> effect 0 -// 2 notes -> effect 2 (16-bit delta: always expressible) -// 3 notes -> effect 3 (two 8-bit deltas: only when both <= 255) -// otherwise (3 wide / 4+ voices) -> software arpeggio over ALL the notes +// 1 note -> effect 0 (none) +// 2 notes -> effect 2 (single 10-bit delta A: only when <= 1023) +// 3 notes -> effect 3 (two 10-bit deltas: only when both <= 1023) +// 4 notes -> effect 1 (three 10-bit deltas: only when all <= 1023) +// otherwise (wide chords / 5+ voices) -> software arpeggio over ALL the notes // --------------------------------------------------------------------------- function planMultiplex(dividers) { const ds = Array.from(new Set(dividers)).sort((x, y) => y - x) // descending - if (ds.length === 0) return { sw: false, cmd: [0, QQ_NONE, 0, 0] } - if (ds.length === 1) return { sw: false, cmd: [ds[0], QQ_NONE, 0, 0] } + if (ds.length === 0) return { sw: false, cmd: [0, QQ_NONE, 0, 0, 0] } + if (ds.length === 1) return { sw: false, cmd: [ds[0], QQ_NONE, 0, 0, 0] } if (ds.length === 2) { - const diff = ds[0] - ds[1] - return { sw: false, cmd: [ds[0], QQ_TWO, diff & 0xFF, (diff >> 8) & 0xFF] } + const a = ds[0] - ds[1] + if (a <= ARG_MAX) return { sw: false, cmd: [ds[0], QQ_TWO, a, 0, 0] } } if (ds.length === 3) { const a = ds[0] - ds[1], b = ds[1] - ds[2] - if (a <= 0xFF && b <= 0xFF) return { sw: false, cmd: [ds[0], QQ_THREE, a, b] } + if (a <= ARG_MAX && b <= ARG_MAX) return { sw: false, cmd: [ds[0], QQ_THREE, a, b, 0] } + } + if (ds.length === 4) { + const a = ds[0] - ds[1], b = ds[1] - ds[2], c = ds[2] - ds[3] + if (a <= ARG_MAX && b <= ARG_MAX && c <= ARG_MAX) return { sw: false, cmd: [ds[0], QQ_FOUR, a, b, c] } } return { sw: true, dividers: ds } // out of hardware range -> software } @@ -129,24 +141,25 @@ const fmtNote = (div) => { } // The notes a (hardware) beeper command actually cycles through. -function playedDividers(div, effect, a, b) { +function playedDividers(div, effect, a, b, c) { if (div === 0) return [] - if (effect === QQ_TWO) return [div, div - ((b << 8) | a)] + if (effect === QQ_TWO) return [div, div - a] if (effect === QQ_THREE) return [div, div - a, div - a - b] + if (effect === QQ_FOUR) return [div, div - a, div - a - b, div - a - b - c] return [div] } // One human-readable line for the command uploaded this tick. swInfo, when set, // describes the software-arpeggio rotation: {idx, n, all:[dividers]}. function describeCommand(cmd, swInfo) { - const div = cmd[0], eff = cmd[1], a = cmd[2], b = cmd[3] + const div = cmd[0], eff = cmd[1], a = cmd[2], b = cmd[3], c = cmd[4] if (swInfo) { const notes = swInfo.all.map((d, i) => (i === swInfo.idx) ? `[${fmtNote(d).substring(1,6)}]` : fmtNote(d)).join(" ") return `sw${swInfo.idx + 1}/${swInfo.n}`.padEnd(6) + " " + notes } if (div === 0) return "silent" - const label = (eff === QQ_THREE) ? "arp3" : (eff === QQ_TWO) ? "arp2" : "tone" - return label.padEnd(6) + " " + playedDividers(div, eff, a, b).map(fmtNote).join(" ") + const label = (eff === QQ_FOUR) ? "arp4" : (eff === QQ_THREE) ? "arp3" : (eff === QQ_TWO) ? "arp2" : "tone" + return label.padEnd(6) + " " + playedDividers(div, eff, a, b, c).map(fmtNote).join(" ") } // --------------------------------------------------------------------------- @@ -308,9 +321,8 @@ function applyTickEffects(v, t) { const sleepUntil = (nano) => { const ms = (nano - sys.nanoTime()) / 1e6; if (ms >= 1) sys.sleep(Math.floor(ms)) } -function cmdToInt(cmd) { - return cmd[0] | (cmd[1] << 8) | (cmd[2] << 16) | (cmd[3] << 24); -} +// Change-detection key for the trace: a command is [divider, eff, a, b, c]. +function cmdKey(cmd) { return cmd.join(",") } // --------------------------------------------------------------------------- // Render loop @@ -326,7 +338,7 @@ const checkStop = () => { return stopReq } -let oldDiv = 0xFFFFFFFF +let oldKey = "" try { let o = 0 @@ -350,17 +362,17 @@ try { let cmd, swInfo = null if (plan.sw) { const idx = swPhase % plan.dividers.length - cmd = [plan.dividers[idx], QQ_NONE, 0, 0] + cmd = [plan.dividers[idx], QQ_NONE, 0, 0, 0] swInfo = { idx: idx, n: plan.dividers.length, all: plan.dividers } swPhase++ } else { cmd = plan.cmd } - uploadBeeper(cmd[0], cmd[1], cmd[2], cmd[3]) + uploadBeeper(cmd[0], cmd[1], cmd[2], cmd[3], cmd[4]) - let cmdInt = cmdToInt(cmd) + let key = cmdKey(cmd) - if (oldDiv != cmdInt) { + if (oldKey !== key) { println(`${String(globalTick).padStart(6, '0')} ` + `c${String(o).padStart(2)} r${String(row).padStart(2)} t${String(t).padStart(2)} ` + describeCommand(cmd, swInfo)) @@ -370,7 +382,7 @@ try { nextTick += TICK_NANO - oldDiv = cmdInt + oldKey = key sleepUntil(nextTick) } diff --git a/terranmon.txt b/terranmon.txt index 46a6d0d..7d51180 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -126,9 +126,9 @@ MMIO 0: no sound QQ: note effect 00: none - 10: two-note argeggio (rate = 60 Hz) + 10: two-note arpeggio (rate = 60 Hz) tick 1: base note at divisor P is played - tick 2: second note at divisor (P - (B << 8 | A)) is played + tick 2: second note at divisor (P - 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 diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt index e96e7bf..0092341 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt @@ -69,7 +69,7 @@ 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. */ + /** Built-in beeper / PSG speaker (MMIO 93..99). See terranmon.txt §93..99. */ private val beeper = Beeper() init { @@ -149,10 +149,10 @@ 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 + // 93 RO: reading uploads the staged command (94..99) 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 94..99 -> beeper.readCommand(adi - 94) in 2048L..4075L -> hyveArea[addr.toInt() - 2048] @@ -231,8 +231,8 @@ 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) + // 94..99 RW: beeper command staging. Takes effect on the next read of MMIO 93. + in 94..99 -> beeper.writeCommand(adi - 94, byte) in 2048L..4075L -> hyveArea[addr.toInt() - 2048] = byte @@ -499,12 +499,13 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor { } /** - * Built-in beeper / PSG speaker (terranmon.txt §93..97). + * Built-in beeper / PSG speaker (terranmon.txt §93..99). * * 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"). + * divider over a 3579545/16 Hz master clock, with optional 60 Hz arpeggio + * note-effects (two-, three- or four-note). The six command bytes (MMIO 94..99) + * 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. @@ -517,21 +518,24 @@ private class Beeper { // 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 + private const val MASTER_CLOCK = 3579545.4545454545 / 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 + private const val AMPLITUDE = 8192 // ~ -12 dBFS; square waves are loud } - // MMIO 94..97 write-staging registers: PPPPPPPP / pppppp_QQ / AAAAAAAA / BBBBBBBB - private val cmd = ByteArray(4) + // MMIO 94..99 write-staging registers: + // PPPPPPPP / pppppp_QQ / qqAABBCC / aaaaaaaa / bbbbbbbb / cccccccc + // where AA/BB/CC are the high two bits of the 10-bit arpeggio deltas A/B/C. + private val cmd = ByteArray(6) // 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 effect = 0 // QQ note-effect: 0 none, 1 four-note, 2 two-note, 3 three-note + @Volatile private var argA = 0 // A (10-bit divisor delta) + @Volatile private var argB = 0 // B (10-bit divisor delta) + @Volatile private var argC = 0 // C (10-bit divisor delta) @Volatile private var running = false private var renderThread: Thread? = null @@ -541,16 +545,18 @@ private class Beeper { fun readCommand(index: Int): Byte = cmd[index] /** - * Latch MMIO 94..97 into the live tone and (lazily) start playback. Returns the + * Latch MMIO 94..99 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 + val hi = cmd[0].toInt() and 255 // PPPPPPPP + val lo = cmd[1].toInt() and 255 // pppppp_QQ + val ext = cmd[2].toInt() and 255 // qqAABBCC: high two bits of A/B/C 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 + argA = (((ext ushr 4) and 0b11) shl 8) or (cmd[3].toInt() and 255) // 10-bit A + argB = (((ext ushr 2) and 0b11) shl 8) or (cmd[4].toInt() and 255) // 10-bit B + argC = (((ext ) and 0b11) shl 8) or (cmd[5].toInt() and 255) // 10-bit C if (divider != 0) ensureStarted() return (if (divider != 0) 1 else 0).toByte() } @@ -586,19 +592,21 @@ private class Beeper { * 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) + // 10: two-note arpeggio — base / (P - A). + 2 -> if (arpTick and 1L == 0L) divider else divider - 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 } + // 01: four-note arpeggio — base / (P - A) / (P - A - B) / (P - A - B - C). + 1 -> when ((arpTick % 4L).toInt()) { + 0 -> divider; 1 -> divider - argA; 2 -> divider - argA - argB; else -> divider - argA - argB - argC + } // 00: no effect. else -> divider } private fun renderLoop() { val buf = ShortArray(CHUNK) - val hiSample = AMPLITUDE.toShort() + val hiSample = (AMPLITUDE-1).toShort() val loSample = (-AMPLITUDE).toShort() var phase = 0.0 var arpSample = 0