From 2ac084acd7b4d68917e0746289730a0b7c3de9a4 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Thu, 16 Apr 2026 02:05:21 +0900 Subject: [PATCH] psg.mjs --- .gitignore | 2 + assets/disk0/tvdos/include/gl.mjs | 8 +- assets/disk0/tvdos/include/pcm.mjs | 7 +- assets/disk0/tvdos/include/psg.mjs | 341 +++++++++++++++++++++ assets/disk0/tvdos/include/seqread.mjs | 5 +- assets/disk0/tvdos/include/seqreadtape.mjs | 5 + assets/disk0/tvdos/include/wintex.mjs | 5 + 7 files changed, 367 insertions(+), 6 deletions(-) create mode 100644 assets/disk0/tvdos/include/psg.mjs diff --git a/.gitignore b/.gitignore index 8937e86..e03e791 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,5 @@ assets/disk0/home/basic/* assets/disk0/movtestimg/*.jpg assets/disk0/*.mov assets/diskMediabin/* + +video_encoder/* diff --git a/assets/disk0/tvdos/include/gl.mjs b/assets/disk0/tvdos/include/gl.mjs index 13fa4dc..d97a6a0 100644 --- a/assets/disk0/tvdos/include/gl.mjs +++ b/assets/disk0/tvdos/include/gl.mjs @@ -1,7 +1,7 @@ -/* -TVDOS Graphics Library - -Has no affiliation with OpenGL by Khronos Group +/** + * LibGL — TVDOS Graphics Library + * Has no affiliation with OpenGL by Khronos Group + * @author CuriousTorvald */ diff --git a/assets/disk0/tvdos/include/pcm.mjs b/assets/disk0/tvdos/include/pcm.mjs index 674d587..891bf43 100644 --- a/assets/disk0/tvdos/include/pcm.mjs +++ b/assets/disk0/tvdos/include/pcm.mjs @@ -1,3 +1,8 @@ +/** + * LibPCM — PCM decoder for TSVM + * @author CuriousTorvald + */ + const HW_SAMPLING_RATE = 32000 function printdbg(s) { if (0) serial.println(s) } function printvis(s) { if (0) println(s) } @@ -29,7 +34,7 @@ function s16Tou8(i) { } function u16Tos16(i) { return (i > 32767) ? i - 65536 : i } function randomRound(k) { - let rnd = (Math.random() + Math.random()) / 2.0 // this produces triangular distribution + let rnd = Math.random() // note to self: no triangular here return (rnd < (k - (k|0))) ? Math.ceil(k) : Math.floor(k) } function lerp(start, end, x) { diff --git a/assets/disk0/tvdos/include/psg.mjs b/assets/disk0/tvdos/include/psg.mjs new file mode 100644 index 0000000..85ef209 --- /dev/null +++ b/assets/disk0/tvdos/include/psg.mjs @@ -0,0 +1,341 @@ +/** + * LibPSG — PSG emulator and mixer for TSVM + * Software-mixes various PSG channels and sends them to sound device as PCM + * @author CuriousTorvald + */ + +const HW_SAMPLING_RATE = 32000 + +function clamp(val, low, hi) { return (val < low) ? low : (val > hi) ? hi : val } +function clampS16(i) { return clamp(i, -32768, 32767) } +const uNybToSnyb = [0,1,2,3,4,5,6,7,-8,-7,-6,-5,-4,-3,-2,-1] +// returns: [unsigned high, unsigned low, signed high, signed low] +function getNybbles(b) { return [b >> 4, b & 15, uNybToSnyb[b >> 4], uNybToSnyb[b & 15]] } +function s8Tou8(i) { return i + 128 } +function s16Tou8(i) { +// return s8Tou8((i >> 8) & 255) + // apply dithering + let ufval = (i / 65536.0) + 0.5 + let ival = randomRound(ufval * 255.0) + return ival|0 +} +function u16Tos16(i) { return (i > 32767) ? i - 65536 : i } +function randomRound(k) { + let rnd = Math.random() // note to self: no triangular here + return (rnd < (k - (k|0))) ? Math.ceil(k) : Math.floor(k) +} +function lerp(start, end, x) { + return (1 - x) * start + x * end +} +function lerpAndRound(start, end, x) { + return Math.round(lerp(start, end, x)) +} + + + +// output format: immediately uploadable into TSVM audio adapter + +// ── Internal helpers ──────────────────────────────────────────────────────── + +function secToSamples(sec) { return Math.round(HW_SAMPLING_RATE * sec) } + +function isNative(buf) { return buf.native } + +function readU8(buf, ch, i) { + return isNative(buf) ? (sys.peek(buf[ch] + i) & 255) : buf[ch][i] +} +function writeU8(buf, ch, i, v) { + if (isNative(buf)) sys.poke(buf[ch] + i, v) + else buf[ch][i] = v +} + +// ── Buffer management ─────────────────────────────────────────────────────── + +function makeBuffer(length) { + // returns [Uint8Array, Uint8Array] (stereo) that will be used to collect samples made by LibPSG. + // Length: seconds. Number of elements: round(HW_SAMPLING_RATE * length) + const n = secToSamples(length) + const L = new Uint8Array(n) + const R = new Uint8Array(n) + L.fill(128) + R.fill(128) + return { 0: L, 1: R, samples: n, native: false } +} + +function makeBufferNative(length) { + // returns native buffer object (stereo) that will be used to collect samples made by LibPSG. + // Length: seconds. Number of elements: round(HW_SAMPLING_RATE * length) + // Free with freeBufferNative() when done. + const n = secToSamples(length) + const L = sys.malloc(n); sys.memset(L, 128, n) + const R = sys.malloc(n); sys.memset(R, 128, n) + return { 0: L, 1: R, samples: n, native: true } +} + +function freeBufferNative(buf) { + sys.free(buf[0]) + sys.free(buf[1]) +} + +function clearBuffer(buf, offsetSec, lengthSec) { + // Re-silence a buffer region (fill with 128) for re-use across frames. + const start = (offsetSec != null) ? secToSamples(offsetSec) : 0 + const total = (lengthSec != null) ? secToSamples(lengthSec) : (buf.samples - start) + for (let i = 0; i < total; i++) { + writeU8(buf, 0, start + i, 128) + writeU8(buf, 1, start + i, 128) + } +} + +// ── Shared mix core ───────────────────────────────────────────────────────── + +// sampleFn(i) must return a float in [-1, 1]. +// Mixing maths: decode u8 → s16, apply op, clamp, dither back to u8. +function mixInto(buf, lengthSec, offsetSec, op, amp, pan, sampleFn) { + const startIdx = secToSamples(offsetSec) + const n = secToSamples(lengthSec) + // Linear pan law: centre (pan=0) → both channels at full amp + const gainL = Math.max(0, Math.min(1, 1.0 - pan)) + const gainR = Math.max(0, Math.min(1, 1.0 + pan)) + const opCode = (op === 'sub') ? 1 : (op === 'mul') ? 2 : 0 // default: add + for (let i = 0; i < n; i++) { + const v = sampleFn(i) // oscillator value in [-1, 1] + const oscBase = v * amp * 32767 + const oscL = Math.round(oscBase * gainL) | 0 + const oscR = Math.round(oscBase * gainR) | 0 + for (let ch = 0; ch < 2; ch++) { + const osc = (ch === 0) ? oscL : oscR + const cur = (readU8(buf, ch, startIdx + i) - 128) << 8 + let out + switch (opCode) { + case 0: out = cur + osc; break + case 1: out = cur - osc; break + case 2: out = (cur * osc) >> 15; break + } + writeU8(buf, ch, startIdx + i, s16Tou8(clampS16(out))) + } + } +} + +// ── Waveform generators ───────────────────────────────────────────────────── + +function makeSquare(buf, length, offset, freq, duty, op, amp, pan) { + // buffer: [Uint8Array, Uint8Array] or native buffer + // length: in seconds + // offset: in seconds + // duty: 0.0 to 1.0. default 0.5 (fraction of period where output is +1) + // freq: Hz + // op: add / mul / sub; default: add + // amp: 0.0 to 1.0; default: 0.5 + // pan: -1.0 to 1.0; default: 0.0 + if (duty == null) duty = 0.5 + if (op == null) op = 'add' + if (amp == null) amp = 0.5 + if (pan == null) pan = 0.0 + mixInto(buf, length, offset, op, amp, pan, function(i) { + const t = offset + i / HW_SAMPLING_RATE + const phase = (t * freq) % 1 + return (phase < duty) ? 1.0 : -1.0 + }) +} + +function makeTriangle(buf, length, offset, freq, duty, op, amp, pan) { + // buffer: [Uint8Array, Uint8Array] or native buffer + // length: in seconds + // offset: in seconds + // duty: skew. -1.0 = falling sawtooth, 0.0 = symmetric triangle, 1.0 = rising sawtooth + // freq: Hz + // op: add / mul / sub; default: add + // amp: 0.0 to 1.0; default: 0.5 + // pan: -1.0 to 1.0; default: 0.0 + if (duty == null) duty = 0.0 + if (op == null) op = 'add' + if (amp == null) amp = 0.5 + if (pan == null) pan = 0.0 + // riseFrac: fraction of period spent rising from -1 to +1 + // 0.0 → falling saw, 0.5 → symmetric triangle, 1.0 → rising saw + const riseFrac = (duty + 1.0) * 0.5 + mixInto(buf, length, offset, op, amp, pan, function(i) { + const t = offset + i / HW_SAMPLING_RATE + const phase = (t * freq) % 1 + if (riseFrac <= 0) { + return 1.0 - 2.0 * phase // falling saw + } else if (riseFrac >= 1) { + return -1.0 + 2.0 * phase // rising saw + } else if (phase < riseFrac) { + return -1.0 + 2.0 * (phase / riseFrac) // rising slope + } else { + return 1.0 - 2.0 * ((phase - riseFrac) / (1.0 - riseFrac)) // falling slope + } + }) +} + +function makeAliasedTriangle(buf, length, offset, freq, duty, op, amp, pan) { + // buffer: [Uint8Array, Uint8Array] or native buffer + // Famicom-style triangle — output is quantised to 16 DAC levels (4-bit, NES APU style). + // The staircase quantisation introduces harmonics that mimic NES character. + // length: in seconds + // offset: in seconds + // duty: skew. -1.0 = falling sawtooth, 0.0 = symmetric triangle, 1.0 = rising sawtooth + // freq: Hz + // op: add / mul / sub; default: add + // amp: 0.0 to 1.0; default: 0.5 + // pan: -1.0 to 1.0; default: 0.0 + if (duty == null) duty = 0.0 + if (op == null) op = 'add' + if (amp == null) amp = 0.5 + if (pan == null) pan = 0.0 + const riseFrac = (duty + 1.0) * 0.5 + mixInto(buf, length, offset, op, amp, pan, function(i) { + const t = offset + i / HW_SAMPLING_RATE + const phase = (t * freq) % 1 + let v + if (riseFrac <= 0) { + v = 1.0 - 2.0 * phase + } else if (riseFrac >= 1) { + v = -1.0 + 2.0 * phase + } else if (phase < riseFrac) { + v = -1.0 + 2.0 * (phase / riseFrac) + } else { + v = 1.0 - 2.0 * ((phase - riseFrac) / (1.0 - riseFrac)) + } + // Quantise to 16 levels (NES triangle 4-bit DAC: 0..15 → -1..+1) + const level = Math.max(0, Math.min(15, Math.round((v + 1.0) * 7.5))) + return level / 7.5 - 1.0 + }) +} + +// ── LFSR helpers (for noise types 1 and 2) ───────────────────────────────── + +function lfsrStep(state, mode) { + // mode 0 (long/NES mode 0): feedback tap at bit 1; period 32767 + // mode 1 (short/NES mode 1): feedback tap at bit 6; period 93 (metallic/tonal) + const bit0 = state & 1 + const bitTap = (mode === 0) ? (state >> 1) & 1 : (state >> 6) & 1 + const feed = bit0 ^ bitTap + return ((feed << 14) | (state >> 1)) & 0x7FFF +} + +function lfsrAdvance(state, steps, mode) { + for (let k = 0; k < steps; k++) state = lfsrStep(state, mode) + return state +} + +// NES APU documented LFSR periods +const LFSR_PERIOD_LONG = 32767 // mode 0 +const LFSR_PERIOD_SHORT = 93 // mode 1 + +function makeNoise(buf, length, offset, freq, type, op, amp, pan) { + // buffer: [Uint8Array, Uint8Array] or native buffer + // length: in seconds + // offset: in seconds + // type: + // -1: 8-bit white noise (random float per period, sample-and-hold) + // 0: 1-bit white noise (random ±1 per period, sample-and-hold) + // 1: 1-bit LFSR long mode — NES mode 0, tap=bit0^bit1, period 32767 (full-spectrum) + // 2: 1-bit LFSR short mode — NES mode 1, tap=bit0^bit6, period 93 (metallic/tonal) + // freq: Hz (clock rate of the noise generator) + // op: add / mul / sub; default: add + // amp: 0.0 to 1.0; default: 0.5 + // pan: -1.0 to 1.0; default: 0.0 + // + // LFSR types (1 and 2) are deterministic given (offset, freq): calling with + // monotonically advancing offset values produces a seamless noise stream + // across frames. White noise types (-1, 0) are random per call. + if (op == null) op = 'add' + if (amp == null) amp = 0.5 + if (pan == null) pan = 0.0 + + if (type === -1) { + // 8-bit white: new random float in [-1, 1] each clock period + let prevClock = -1 + let noiseVal = 0.0 + mixInto(buf, length, offset, op, amp, pan, function(i) { + const currentClock = Math.floor((offset + i / HW_SAMPLING_RATE) * freq) | 0 + if (currentClock !== prevClock) { + prevClock = currentClock + noiseVal = Math.random() * 2.0 - 1.0 + } + return noiseVal + }) + } else if (type === 0) { + // 1-bit white: random ±1 each clock period + let prevClock = -1 + let noiseVal = 1.0 + mixInto(buf, length, offset, op, amp, pan, function(i) { + const currentClock = Math.floor((offset + i / HW_SAMPLING_RATE) * freq) | 0 + if (currentClock !== prevClock) { + prevClock = currentClock + noiseVal = (Math.random() >= 0.5) ? 1.0 : -1.0 + } + return noiseVal + }) + } else { + // LFSR-based noise (types 1 and 2) + const mode = (type === 2) ? 1 : 0 + const period = (mode === 0) ? LFSR_PERIOD_LONG : LFSR_PERIOD_SHORT + // Advance to deterministic position for this offset so consecutive frame + // calls with advancing offsets produce a seamless noise stream. + const startClock = Math.floor(offset * freq) | 0 + let lfsr = lfsrAdvance(1, startClock % period, mode) + let prevClock = startClock + mixInto(buf, length, offset, op, amp, pan, function(i) { + const currentClock = Math.floor((offset + i / HW_SAMPLING_RATE) * freq) | 0 + const delta = currentClock - prevClock + if (delta > 0) { + const steps = delta % period + if (steps > 0) lfsr = lfsrAdvance(lfsr, steps, mode) + prevClock = currentClock + } + return (lfsr & 1) ? 1.0 : -1.0 + }) + } +} + +// ── Send to audio hardware ────────────────────────────────────────────────── + +function sendBuffer(buf, playhead, offsetSec, lengthSec, stagingPtr) { + // Interleaves the L and R channels into a staging region (LRLRLR…) and uploads + // to the audio adapter pcmBin via the standard putPcmDataByPtr pipeline. + // + // offsetSec: start of region to send (default: 0) + // lengthSec: duration to send (default: entire buffer from offsetSec) + // stagingPtr: optional caller-owned native buffer (≥ min(chunk, 32768) * 2 bytes). + // Pass a pre-allocated pointer to avoid malloc/free per call — + // useful for the per-frame tvnes pattern. + // + // The function auto-chunks at 32768 stereo samples (pcmBin capacity). + // Blocks briefly if the audio queue is saturated (queue depth > 2). + const start = (offsetSec != null) ? secToSamples(offsetSec) : 0 + const total = (lengthSec != null) ? secToSamples(lengthSec) : (buf.samples - start) + const MAX_CHUNK = 32768 // pcmBin = 65536 bytes; stereo → max 32768 samples per upload + const ownsStaging = (stagingPtr == null) + if (ownsStaging) stagingPtr = sys.malloc(Math.min(total, MAX_CHUNK) * 2) + + let remaining = total + let cursor = start + while (remaining > 0) { + const take = Math.min(remaining, MAX_CHUNK) + // Interleave L, R into staging buffer + for (let i = 0; i < take; i++) { + sys.poke(stagingPtr + 2 * i, readU8(buf, 0, cursor + i)) + sys.poke(stagingPtr + 2 * i + 1, readU8(buf, 1, cursor + i)) + } + // Wait for room in the playback queue (mirrors playwav.js idiom) + while (audio.getPosition(playhead) > 2) sys.sleep(2) + audio.putPcmDataByPtr(stagingPtr, take * 2, 0) + audio.setSampleUploadLength(playhead, take * 2) + audio.startSampleUpload(playhead) + remaining -= take + cursor += take + } + + if (ownsStaging) sys.free(stagingPtr) +} + +exports = { + HW_SAMPLING_RATE, + makeBuffer, makeBufferNative, freeBufferNative, clearBuffer, + makeSquare, makeTriangle, makeAliasedTriangle, makeNoise, + sendBuffer +} diff --git a/assets/disk0/tvdos/include/seqread.mjs b/assets/disk0/tvdos/include/seqread.mjs index 0a780ae..e79db8c 100644 --- a/assets/disk0/tvdos/include/seqread.mjs +++ b/assets/disk0/tvdos/include/seqread.mjs @@ -1,4 +1,7 @@ - +/** + * LibSeqread — sequentially read files from disk drive + * @author CuriousTorvald + */ let readCount = 0 let port = undefined let fileHeader = new Uint8Array(4096) diff --git a/assets/disk0/tvdos/include/seqreadtape.mjs b/assets/disk0/tvdos/include/seqreadtape.mjs index 54b51e7..ca7e198 100644 --- a/assets/disk0/tvdos/include/seqreadtape.mjs +++ b/assets/disk0/tvdos/include/seqreadtape.mjs @@ -1,3 +1,8 @@ +/** + * LibSeqread extension for Tape Drive — sequentially read tape + * @author CuriousTorvald + */ + // Sequential reader for HSDPA TAPE devices // Unlike seqread.mjs which is limited to 4096 bytes per read due to serial communication, // this module can read larger chunks efficiently from HSDPA devices. diff --git a/assets/disk0/tvdos/include/wintex.mjs b/assets/disk0/tvdos/include/wintex.mjs index 8c3bbee..7064dbb 100644 --- a/assets/disk0/tvdos/include/wintex.mjs +++ b/assets/disk0/tvdos/include/wintex.mjs @@ -1,3 +1,8 @@ +/** + * WinTex — TUI window management and renderer + * @author CuriousTorvald + */ + class WindowObject { constructor(x, y, w, h, inputProcessor, drawContents, title, drawFrame) {