beeper: extended arp

This commit is contained in:
minjaesong
2026-06-20 04:23:00 +09:00
parent 6eb73355ca
commit ddc4a22809
3 changed files with 78 additions and 58 deletions

View File

@@ -1,7 +1,7 @@
// monplay.js -- Monotone (.mon) music player for the built-in beeper. // monplay.js -- Monotone (.mon) music player for the built-in beeper.
// //
// Reads a MONOTONE module and renders it, on the fly, to the 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 // The module's simultaneous voices are multiplexed onto the beeper's
// hardware arpeggio; when the notes fall outside what the hardware // hardware arpeggio; when the notes fall outside what the hardware
// arpeggiator can express, the multiplex is done in software instead. // 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): // Beeper hardware (IOSpace). MMIO byte m is reached at JS address -(m+1):
// 93 RO -> reading uploads the staged command (the strobe) // 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. // The square wave is f = (3579545/16) / (2 * divider); divider 0 = silence.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const BEEP_UPLOAD = -94 // read MMIO 93 to upload const BEEP_UPLOAD = -94 // read MMIO 93 to upload
const BEEP_P_HI = -95 // MMIO 94: PPPPPPPP const BEEP_P_HI = -95 // MMIO 94: PPPPPPPP
const BEEP_P_LO = -96 // MMIO 95: pppppp_QQ const BEEP_P_LO = -96 // MMIO 95: pppppp_QQ
const BEEP_A = -97 // MMIO 96: A const BEEP_EXT = -97 // MMIO 96: qqAABBCC (high 2 bits of A/B/C)
const BEEP_B = -98 // MMIO 97: B 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 BEEP_HALFCLOCK = (3579545.4545454545 / 16.0) / 2 // f = BEEP_HALFCLOCK / divider
const DIVIDER_MAX = 0x3FFF // 14-bit 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 < 0) divider = 0
if (divider > DIVIDER_MAX) divider = DIVIDER_MAX 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_HI, (divider >> 6) & 0xFF)
sys.poke(BEEP_P_LO, ((divider & 0x3F) << 2) | (effect & 3)) 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_A, a & 0xFF)
sys.poke(BEEP_B, b & 0xFF) sys.poke(BEEP_B, b & 0xFF)
sys.poke(BEEP_C, c & 0xFF)
sys.peek(BEEP_UPLOAD) // strobe: commit the staged command 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. // Hz -> beeper frequency divider.
function freqToDivider(hz) { 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 // base divider must be the LARGEST (lowest pitch) and the others are reached by
// subtraction. Returns either a single hardware command {sw:false, cmd:[...]} // subtraction. Returns either a single hardware command {sw:false, cmd:[...]}
// or, when the notes don't fit, a software-arpeggio plan {sw:true, dividers:[...]}. // or, when the notes don't fit, a software-arpeggio plan {sw:true, dividers:[...]}.
// 1 note -> effect 0 // 1 note -> effect 0 (none)
// 2 notes -> effect 2 (16-bit delta: always expressible) // 2 notes -> effect 2 (single 10-bit delta A: only when <= 1023)
// 3 notes -> effect 3 (two 8-bit deltas: only when both <= 255) // 3 notes -> effect 3 (two 10-bit deltas: only when both <= 1023)
// otherwise (3 wide / 4+ voices) -> software arpeggio over ALL the notes // 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) { function planMultiplex(dividers) {
const ds = Array.from(new Set(dividers)).sort((x, y) => y - x) // descending 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 === 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] } if (ds.length === 1) return { sw: false, cmd: [ds[0], QQ_NONE, 0, 0, 0] }
if (ds.length === 2) { if (ds.length === 2) {
const diff = ds[0] - ds[1] const a = ds[0] - ds[1]
return { sw: false, cmd: [ds[0], QQ_TWO, diff & 0xFF, (diff >> 8) & 0xFF] } if (a <= ARG_MAX) return { sw: false, cmd: [ds[0], QQ_TWO, a, 0, 0] }
} }
if (ds.length === 3) { if (ds.length === 3) {
const a = ds[0] - ds[1], b = ds[1] - ds[2] 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 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. // 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 (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_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] return [div]
} }
// One human-readable line for the command uploaded this tick. swInfo, when set, // One human-readable line for the command uploaded this tick. swInfo, when set,
// describes the software-arpeggio rotation: {idx, n, all:[dividers]}. // describes the software-arpeggio rotation: {idx, n, all:[dividers]}.
function describeCommand(cmd, swInfo) { 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) { if (swInfo) {
const notes = swInfo.all.map((d, i) => (i === swInfo.idx) ? `[${fmtNote(d).substring(1,6)}]` : fmtNote(d)).join(" ") 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 return `sw${swInfo.idx + 1}/${swInfo.n}`.padEnd(6) + " " + notes
} }
if (div === 0) return "silent" if (div === 0) return "silent"
const label = (eff === QQ_THREE) ? "arp3" : (eff === QQ_TWO) ? "arp2" : "tone" const label = (eff === QQ_FOUR) ? "arp4" : (eff === QQ_THREE) ? "arp3" : (eff === QQ_TWO) ? "arp2" : "tone"
return label.padEnd(6) + " " + playedDividers(div, eff, a, b).map(fmtNote).join(" ") 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)) } const sleepUntil = (nano) => { const ms = (nano - sys.nanoTime()) / 1e6; if (ms >= 1) sys.sleep(Math.floor(ms)) }
function cmdToInt(cmd) { // Change-detection key for the trace: a command is [divider, eff, a, b, c].
return cmd[0] | (cmd[1] << 8) | (cmd[2] << 16) | (cmd[3] << 24); function cmdKey(cmd) { return cmd.join(",") }
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Render loop // Render loop
@@ -326,7 +338,7 @@ const checkStop = () => {
return stopReq return stopReq
} }
let oldDiv = 0xFFFFFFFF let oldKey = ""
try { try {
let o = 0 let o = 0
@@ -350,17 +362,17 @@ try {
let cmd, swInfo = null let cmd, swInfo = null
if (plan.sw) { if (plan.sw) {
const idx = swPhase % plan.dividers.length 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 } swInfo = { idx: idx, n: plan.dividers.length, all: plan.dividers }
swPhase++ swPhase++
} else { } else {
cmd = plan.cmd 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')} ` + println(`${String(globalTick).padStart(6, '0')} ` +
`c${String(o).padStart(2)} r${String(row).padStart(2)} t${String(t).padStart(2)} ` + `c${String(o).padStart(2)} r${String(row).padStart(2)} t${String(t).padStart(2)} ` +
describeCommand(cmd, swInfo)) describeCommand(cmd, swInfo))
@@ -370,7 +382,7 @@ try {
nextTick += TICK_NANO nextTick += TICK_NANO
oldDiv = cmdInt oldKey = key
sleepUntil(nextTick) sleepUntil(nextTick)
} }

View File

@@ -126,9 +126,9 @@ MMIO
0: no sound 0: no sound
QQ: note effect QQ: note effect
00: none 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 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) 11: three-note arpeggio (rate = 60 Hz)
tick 1: base note at divisor P is played tick 1: base note at divisor P is played
tick 2: second note at divisor (P - A) is played tick 2: second note at divisor (P - A) is played

View File

@@ -69,7 +69,7 @@ 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. */ /** Built-in beeper / PSG speaker (MMIO 93..99). See terranmon.txt §93..99. */
private val beeper = Beeper() private val beeper = Beeper()
init { 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)) 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 // 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). // returns the beeper status (bit 0 = a tone is currently sounding).
93L -> beeper.upload() 93L -> beeper.upload()
in 94..97 -> beeper.readCommand(adi - 94) in 94..99 -> beeper.readCommand(adi - 94)
in 2048L..4075L -> hyveArea[addr.toInt() - 2048] in 2048L..4075L -> hyveArea[addr.toInt() - 2048]
@@ -231,8 +231,8 @@ 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. // 94..99 RW: beeper command staging. Takes effect on the next read of MMIO 93.
in 94..97 -> beeper.writeCommand(adi - 94, byte) in 94..99 -> beeper.writeCommand(adi - 94, byte)
in 2048L..4075L -> hyveArea[addr.toInt() - 2048] = 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 * 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 * divider over a 3579545/16 Hz master clock, with optional 60 Hz arpeggio
* note-effects. The four command bytes (MMIO 94..97) are write staging; reading * note-effects (two-, three- or four-note). The six command bytes (MMIO 94..99)
* MMIO 93 latches them into the live tone ("upload beeper command"). * 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 * 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. * 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 // prescaler. The square wave toggles every `divider` master ticks, so one full
// period spans 2*divider ticks -> f = MASTER_CLOCK / (2 * divider). // period spans 2*divider ticks -> f = MASTER_CLOCK / (2 * divider).
// (divider 254 -> 440.4 Hz, matching real SN76489 hardware.) // (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. // 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 SAMPLES_PER_ARP_TICK = SAMPLE_RATE / 60
private const val CHUNK = 512 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 // MMIO 94..99 write-staging registers:
private val cmd = ByteArray(4) // 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. // Latched ("uploaded") live command, read by the render thread.
@Volatile private var divider = 0 // 14-bit frequency divider; 0 = no sound @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 effect = 0 // QQ note-effect: 0 none, 1 four-note, 2 two-note, 3 three-note
@Volatile private var argA = 0 // A @Volatile private var argA = 0 // A (10-bit divisor delta)
@Volatile private var argB = 0 // B @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 @Volatile private var running = false
private var renderThread: Thread? = null private var renderThread: Thread? = null
@@ -541,16 +545,18 @@ private class Beeper {
fun readCommand(index: Int): Byte = cmd[index] 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. * beeper status byte (bit 0 set while a tone is sounding). Invoked by a read of MMIO 93.
*/ */
fun upload(): Byte { fun upload(): Byte {
val hi = cmd[0].toInt() and 255 // PPPPPPPP val hi = cmd[0].toInt() and 255 // PPPPPPPP
val lo = cmd[1].toInt() and 255 // pppppp_QQ 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 divider = (hi shl 6) or (lo ushr 2) // 14-bit frequency divider
effect = lo and 0b11 // QQ effect = lo and 0b11 // QQ
argA = cmd[2].toInt() and 255 // A argA = (((ext ushr 4) and 0b11) shl 8) or (cmd[3].toInt() and 255) // 10-bit A
argB = cmd[3].toInt() and 255 // B 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() if (divider != 0) ensureStarted()
return (if (divider != 0) 1 else 0).toByte() 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. * subtraction effects can overshoot when A/B exceed P) is treated as silence.
*/ */
private fun divisorForTick(arpTick: Long): Int = when (effect) { private fun divisorForTick(arpTick: Long): Int = when (effect) {
// 01: fixed arpeggio — alternate base / one octave up (P >>> 1). // 10: two-note arpeggio — base / (P - A).
1 -> if (arpTick and 1L == 0L) divider else divider ushr 1 2 -> if (arpTick and 1L == 0L) divider else divider - argA
// 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). // 11: three-note arpeggio — base / (P - A) / (P - A - B).
3 -> when ((arpTick % 3L).toInt()) { 0 -> divider; 1 -> divider - argA; else -> divider - argA - argB } 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. // 00: no effect.
else -> divider else -> divider
} }
private fun renderLoop() { private fun renderLoop() {
val buf = ShortArray(CHUNK) val buf = ShortArray(CHUNK)
val hiSample = AMPLITUDE.toShort() val hiSample = (AMPLITUDE-1).toShort()
val loSample = (-AMPLITUDE).toShort() val loSample = (-AMPLITUDE).toShort()
var phase = 0.0 var phase = 0.0
var arpSample = 0 var arpSample = 0