mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-20 19:24:04 +09:00
beeper: extended arp
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user