diff --git a/assets/disk0/tvdos/bin/playmp2.js b/assets/disk0/tvdos/bin/playmp2.js index 1d3a446..36a4893 100644 --- a/assets/disk0/tvdos/bin/playmp2.js +++ b/assets/disk0/tvdos/bin/playmp2.js @@ -1,209 +1,122 @@ -const SND_BASE_ADDR = audio.getBaseAddr() +// playmp2 — MPEG-1/2 Audio Layer II player with the shared playgui visualiser. +// Usage: playmp2 [-i] +const SND_BASE_ADDR = audio.getBaseAddr() if (!SND_BASE_ADDR) return 10 -const MP2_BITRATES = ["???", 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384] +const MP2_BITRATES = ["???", 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384] const MP2_CHANNELMODES = ["Stereo", "Joint", "Dual", "Mono"] + const pcm = require("pcm") -const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i" +const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i" +const gui = interactive ? require("playgui") : null + function printdbg(s) { if (0) serial.println(s) } - class SequentialFileBuffer { - constructor(path, offset, length) { if (Array.isArray(path)) throw Error("arg #1 is path(string), not array") - this.path = path this.file = files.open(path) - this.offset = offset || 0 this.originalOffset = offset this.length = length || this.file.size - this.seq = require("seqread") this.seq.prepare(path) } - - readBytes(size, ptr) { - return this.seq.readBytes(size, ptr) - } - - readStr(n) { - let ptr = this.seq.readBytes(n) - let s = '' - for (let i = 0; i < n; i++) { - if (i >= this.length) break - s += String.fromCharCode(sys.peek(ptr + i)) - } - sys.free(ptr) - return s - } - - unread(diff) { - let newSkipLen = this.seq.getReadCount() - diff - this.seq.prepare(this.path) - this.seq.skip(newSkipLen) - } - - rewind() { - this.seq.prepare(this.path) - } - - seek(p) { - this.seq.prepare(this.path) - this.seq.skip(p) - } - - get byteLength() { - return this.length - } - - get fileHeader() { - return this.seq.fileHeader - } - - /*get remaining() { - return this.length - this.getReadCount() - }*/ + readBytes(size, ptr) { return this.seq.readBytes(size, ptr) } + get fileHeader() { return this.seq.fileHeader } } - - - - -let filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full) -const FILE_SIZE = filebuf.length// - 100 -const FRAME_SIZE = audio.mp2GetInitialFrameSize(filebuf.fileHeader) +const filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full) +const FILE_SIZE = filebuf.length +const FRAME_SIZE = audio.mp2GetInitialFrameSize(filebuf.fileHeader) const MEDIA_BITRATE = MP2_BITRATES[filebuf.fileHeader[2] >>> 4] -const MEDIA_CHANNEL_MODE = MP2_CHANNELMODES[filebuf.fileHeader[3] >>> 6] +const MEDIA_CHANNEL = MP2_CHANNELMODES[filebuf.fileHeader[3] >>> 6] +// mediaDecodedBin sits at MMIO offset 64 in the audio peripheral and holds +// 2304 bytes (1152 stereo u8 samples per MP2 frame). Peripheral memory grows +// toward 0 so the canonical pointer is SND_BASE_ADDR - 64. +// +// IMPORTANT: single-byte sys.peek on this address hits AudioAdapter.peek() +// which maps the lower offsets to sampleBin, not mediaDecodedBin (the +// MMIO/Memory-Space split — see CLAUDE.md). To get the decoded PCM into the +// visualiser, we sys.memcpy mediaDecodedBin → a RAM scratch buffer; memcpy +// uses VM.getDev internally which DOES route the MMIO read correctly. +// +// VM.getDev's range check on mediaDecodedBin (relPtrInDev) is half-open and +// won't let us copy the full 2304 bytes — we copy 2302 (one stereo sample +// short of the frame, invisible at visualiser resolution). +const MP2_DECODED_ADDR = SND_BASE_ADDR - 64 +const MP2_VIS_COPY_BYTES = 2302 +const MP2_VIS_SAMPLE_COUNT = MP2_VIS_COPY_BYTES >> 1 // 1151 +const mp2VisScratch = interactive ? sys.malloc(MP2_VIS_COPY_BYTES) : 0 -let bytes_left = FILE_SIZE +let bytes_left = FILE_SIZE let decodedLength = 0 - -//serial.println(`Frame size: ${FRAME_SIZE}`) - - -con.curs_set(0) -let [__, CONSOLE_WIDTH] = con.getmaxyx() -if (interactive) { - let [cy, cx] = con.getyx() - // file name - con.mvaddch(cy, 1) - con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5) - print(filebuf.file.name) - con.prnch(0xC6);con.prnch(0xCD) - print("\x84205u".repeat(CONSOLE_WIDTH - 26 - filebuf.file.name.length)) - con.prnch(0xB5) - print("Hold Bksp to Exit") - con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB) - - // L R pillar - con.prnch(0xBA) - con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA) - - // media info - let mediaInfoStr = `MP2 ${MEDIA_CHANNEL_MODE} ${MEDIA_BITRATE}kbps` - con.move(cy+2,1) - con.prnch(0xC8) - print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length)) - con.prnch(0xB5) - print(mediaInfoStr) - con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC) - - con.move(cy+1, 2) -} -let [cy, cx] = con.getyx() -let paintWidth = CONSOLE_WIDTH - 20 -function bytesToSec(i) { - // using fixed value: FRAME_SIZE(216) bytes for 36 ms on sampling rate 32000 Hz - return i / (FRAME_SIZE * 1000 / bufRealTimeLen) -} -function secToReadable(n) { - let mins = ''+((n/60)|0) - let secs = ''+(n % 60) - return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}` -} -function printPlayBar(currently) { - if (interactive) { - let currently = decodedLength - let total = FILE_SIZE - - let currentlySec = Math.round(bytesToSec(currently)) - let totalSec = Math.round(bytesToSec(total)) - - con.move(cy, 3) - print(' '.repeat(15)) - con.move(cy, 3) - - print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`) - - con.move(cy, 17) - print(' ') - let progressbar = '\x84196u'.repeat(paintWidth + 1) - print(progressbar) - - con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB) - } -} - - +const bufRealTimeLen = 36 // one MP2 frame at 32 kHz ≈ 36 ms audio.resetParams(0) audio.purgeQueue(0) audio.setPcmMode(0) -audio.setPcmQueueCapacityIndex(0, 2) // queue size is now 8 +audio.setPcmQueueCapacityIndex(0, 2) const QUEUE_MAX = audio.getPcmQueueCapacity(0) audio.setMasterVolume(0, 255) audio.play(0) - - -//let mp2context = audio.mp2Init() audio.mp2Init() -// decode frame -let t1 = sys.nanoTime() -let bufRealTimeLen = 36 +function bytesToSec(i) { return i / (FRAME_SIZE * 1000 / bufRealTimeLen) } + +if (interactive) { + const tag = "MP2" + const title = `${filebuf.file.name} ${MEDIA_CHANNEL} ${MEDIA_BITRATE}kbps` + gui.audioInit({ title, tag }) +} + let stopPlay = false let errorlevel = 0 try { while (bytes_left > 0 && !stopPlay) { - - if (interactive) { - sys.poke(-40, 1) - if (sys.peek(-41) == 67) { - stopPlay = true - } - } - - printPlayBar() - + if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break } filebuf.readBytes(FRAME_SIZE, SND_BASE_ADDR - 2368) audio.mp2Decode() + // After decode, 1152 PCMu8 stereo samples sit in mediaDecodedBin + // (MMIO). Bounce them through RAM so single-byte peek in the + // visualiser pipeline can reach them — see MP2_DECODED_ADDR notes. + if (interactive) { + sys.memcpy(MP2_DECODED_ADDR, mp2VisScratch, MP2_VIS_COPY_BYTES) + gui.audioFeedPcm(mp2VisScratch, MP2_VIS_SAMPLE_COUNT) + } + if (audio.getPosition(0) >= QUEUE_MAX) { while (audio.getPosition(0) >= (QUEUE_MAX >>> 1)) { - printdbg(`Queue full, waiting until the queue has some space (${audio.getPosition(0)}/${QUEUE_MAX})`) + if (interactive) gui.audioRender() sys.sleep(bufRealTimeLen) } } audio.mp2UploadDecoded(0) + + if (interactive) { + gui.audioSetProgress(decodedLength / FILE_SIZE, + bytesToSec(decodedLength), bytesToSec(FILE_SIZE)) + gui.audioRender() + } sys.sleep(10) - - - bytes_left -= FRAME_SIZE + bytes_left -= FRAME_SIZE decodedLength += FRAME_SIZE } -} -catch (e) { +} catch (e) { printerrln(e) errorlevel = 1 -} -finally { +} finally { + if (interactive) { + if (mp2VisScratch) sys.free(mp2VisScratch) + gui.audioClose() + } } -return errorlevel \ No newline at end of file +return errorlevel diff --git a/assets/disk0/tvdos/bin/playpcm.js b/assets/disk0/tvdos/bin/playpcm.js index e91565e..e83a183 100644 --- a/assets/disk0/tvdos/bin/playpcm.js +++ b/assets/disk0/tvdos/bin/playpcm.js @@ -1,196 +1,81 @@ -// usage: playpcm audiofile.pcm [/i] -let fileeeee = files.open(_G.shell.resolvePathInput(exec_args[1]).full) -let filename = fileeeee.fullPath -function printdbg(s) { if (0) serial.println(s) } +// playpcm — raw PCMu8 stereo player with the shared playgui visualiser. +// Usage: playpcm [-i] -const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i" -const pcm = require("pcm") -const FILE_SIZE = files.open(filename).size - - - -function printComments() { - for (const [key, value] of Object.entries(comments)) { - printdbg(`${key}: ${value}`) - } -} - -function GCD(a, b) { - a = Math.abs(a) - b = Math.abs(b) - if (b > a) {var temp = a; a = b; b = temp} - while (true) { - if (b == 0) return a - a %= b - if (a == 0) return b - b %= a - } -} - -function LCM(a, b) { - return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b)) -} - - - -//println("Reading...") -//serial.println("!!! READING") +const fileHandle = files.open(_G.shell.resolvePathInput(exec_args[1]).full) +const filePath = fileHandle.fullPath +const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i" +const pcm = require("pcm") const seqread = require("seqread") -seqread.prepare(filename) - - - - +const gui = interactive ? require("playgui") : null +const FILE_SIZE = files.open(filePath).size let BLOCK_SIZE = 4096 -let INFILE_BLOCK_SIZE = BLOCK_SIZE -const QUEUE_MAX = 8 // according to the spec +const INFILE_BLOCK_SIZE = BLOCK_SIZE +const QUEUE_MAX = 8 -let nChannels = 2 -let samplingRate = pcm.HW_SAMPLING_RATE; -let blockSize = 2; -let bitsPerSample = 8; -let byterate = 2*samplingRate; -let comments = {}; -let readPtr = undefined -let decodePtr = undefined +const samplingRate = pcm.HW_SAMPLING_RATE +const byterate = 2 * samplingRate -function bytesToSec(i) { - return i / byterate -} -function secToReadable(n) { - let mins = ''+((n/60)|0) - let secs = ''+(n % 60) - return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}` -} - -let stopPlay = false -con.curs_set(0) -let [__, CONSOLE_WIDTH] = con.getmaxyx() -if (interactive) { - let [cy, cx] = con.getyx() - // file name - con.mvaddch(cy, 1) - con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5) - print(fileeeee.name) - con.prnch(0xC6);con.prnch(0xCD) - print("\x84205u".repeat(CONSOLE_WIDTH - 26 - fileeeee.name.length)) - con.prnch(0xB5) - print("Hold Bksp to Exit") - con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB) - - // L R pillar - con.prnch(0xBA) - con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA) - - // media info - let mediaInfoStr = `Raw PCM 512kbps` - con.move(cy+2,1) - con.prnch(0xC8) - print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length)) - con.prnch(0xB5) - print(mediaInfoStr) - con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC) - - con.move(cy+1, 2) -} -let [cy, cx] = con.getyx() -let paintWidth = CONSOLE_WIDTH - 20 -// read chunks loop -readPtr = sys.malloc(BLOCK_SIZE * bitsPerSample / 8) -decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate) +function bytesToSec(i) { return i / byterate } +seqread.prepare(filePath) +const readPtr = sys.malloc(BLOCK_SIZE) audio.resetParams(0) audio.purgeQueue(0) audio.setPcmMode(0) audio.setMasterVolume(0, 255) -let readLength = 1 - -function printPlayBar() { - if (interactive) { - let currently = seqread.getReadCount() - let total = FILE_SIZE - - let currentlySec = Math.round(bytesToSec(currently)) - let totalSec = Math.round(bytesToSec(total)) - - con.move(cy, 3) - print(' '.repeat(15)) - con.move(cy, 3) - - print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`) - - con.move(cy, 17) - print(' ') - let progressbar = '\x84196u'.repeat(paintWidth + 1) - print(progressbar) - - con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB) - } +if (interactive) { + gui.audioInit({ + title: `${fileHandle.name} Raw PCM 32kHz Stereo`, + tag: "PCM" + }) } + +let stopPlay = false let errorlevel = 0 +let readLength = 1 try { -while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) { - if (interactive) { - sys.poke(-40, 1) - if (sys.peek(-41) == 67) { - stopPlay = true - } - } + while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) { + if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break } + const queueSize = audio.getPosition(0) + if (queueSize <= 1) { + for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) { + const remainingBytes = FILE_SIZE - seqread.getReadCount() + readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE + if (readLength <= 0) break - let queueSize = audio.getPosition(0) - if (queueSize <= 1) { + seqread.readBytes(readLength, readPtr) - printPlayBar() + // Raw PCMu8 stereo — sampleCount = bytes / 2. + if (interactive) gui.audioFeedPcm(readPtr, readLength >> 1) - // upload four samples for lag-safely - for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) { - let remainingBytes = FILE_SIZE - seqread.getReadCount() + audio.putPcmDataByPtr(0, readPtr, readLength, 0) + audio.setSampleUploadLength(0, readLength) + audio.startSampleUpload(0) - readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE - if (readLength <= 0) { - printdbg(`readLength = ${readLength}`) - break + if (repeat > 1) sys.sleep(10) } - - printdbg(`offset: ${seqread.getReadCount()}/${FILE_SIZE}; readLength: ${readLength}`) - - seqread.readBytes(readLength, readPtr) - - audio.putPcmDataByPtr(0, readPtr, readLength, 0) - audio.setSampleUploadLength(0, readLength) - audio.startSampleUpload(0) - - - if (repeat > 1) sys.sleep(10) - - printPlayBar() + audio.play(0) } - audio.play(0) + if (interactive) { + const cur = seqread.getReadCount() + gui.audioSetProgress(cur / FILE_SIZE, bytesToSec(cur), bytesToSec(FILE_SIZE)) + gui.audioRender() + } + sys.sleep(10) } - - let remainingBytes = FILE_SIZE - seqread.getReadCount() - printdbg(`readLength = ${readLength}; remainingBytes2 = ${remainingBytes}; seqread.getReadCount() = ${seqread.getReadCount()};`) - - - sys.sleep(10) -} -} -catch (e) { +} catch (e) { printerrln(e) errorlevel = 1 -} -finally { - //audio.stop(0) +} finally { if (readPtr !== undefined) sys.free(readPtr) - if (decodePtr !== undefined) sys.free(decodePtr) + if (interactive) gui.audioClose() } return errorlevel - diff --git a/assets/disk0/tvdos/bin/playtad.js b/assets/disk0/tvdos/bin/playtad.js index d43f076..8c64d4d 100644 --- a/assets/disk0/tvdos/bin/playtad.js +++ b/assets/disk0/tvdos/bin/playtad.js @@ -1,114 +1,66 @@ +// playtad — TAD (TSVM Advanced Audio) player with the shared playgui visualiser. +// Usage: playtad [-i | -d] +// -i Interactive mode (visualiser + progress bar; hold Backspace to exit) +// -d Dump mode (print the first three chunks to serial for debugging) + const SND_BASE_ADDR = audio.getBaseAddr() -const SND_MEM_ADDR = audio.getMemAddr() -// tadInputBin lives at audio-local offset 917504 and tadDecodedBin at 983040 -// (post-bef85f6 memory map; the old 262144 offset now hits the enlarged sampleBin). -const TAD_INPUT_ADDR = SND_MEM_ADDR - 917504 // TAD input buffer (matches TAV packet 0x24) -const TAD_DECODED_ADDR = SND_MEM_ADDR - 983040 // TAD decoded buffer +const SND_MEM_ADDR = audio.getMemAddr() +// tadInputBin at offset 917504, tadDecodedBin at 983040. Both addressed via +// negative pointers — peripheral memory grows toward 0. +const TAD_INPUT_ADDR = SND_MEM_ADDR - 917504 +const TAD_DECODED_ADDR = SND_MEM_ADDR - 983040 if (!SND_BASE_ADDR) return 10 -// Check for help flag or missing arguments -if (!exec_args[1] || exec_args[1] == "-h" || exec_args[1] == "--help") { - serial.println("Usage: playtad [-i | -d] [quality]") - serial.println(" -i Interactive mode (progress bar, press Backspace to exit)") - serial.println(" -d Dump mode (show first 3 chunks with payload hex and decoded samples)") - serial.println("") - serial.println("Examples:") - serial.println(" playtad audio.tad -i # Play with progress bar") - serial.println(" playtad audio.tad -d # Dump first 3 chunks for debugging") +if (!exec_args[1] || exec_args[1] === "-h" || exec_args[1] === "--help") { + serial.println("Usage: playtad [-i | -d]") + serial.println(" -i Interactive mode (visualiser + progress bar)") + serial.println(" -d Dump first three chunks for debugging") return 0 } -const pcm = require("pcm") -const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i" -const dumpCoeffs = exec_args[2] && exec_args[2].toLowerCase() == "-d" - -function printdbg(s) { if (0) serial.println(s) } - +const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i" +const dumpCoeffs = exec_args[2] && exec_args[2].toLowerCase() === "-d" +const gui = interactive ? require("playgui") : null class SequentialFileBuffer { - - constructor(path, offset, length) { + constructor(path) { if (Array.isArray(path)) throw Error("arg #1 is path(string), not array") - this.path = path this.file = files.open(path) - - this.offset = offset || 0 - this.originalOffset = offset - this.length = length || this.file.size - + this.length = this.file.size this.seq = require("seqread") this.seq.prepare(path) } - - readBytes(size, ptr) { - return this.seq.readBytes(size, ptr) - } - + readBytes(size, ptr) { return this.seq.readBytes(size, ptr) } readByte() { - let ptr = this.seq.readBytes(1) - let val = sys.peek(ptr) + const ptr = this.seq.readBytes(1) + const val = sys.peek(ptr) sys.free(ptr) return val } - readShort() { - let ptr = this.seq.readBytes(2) - let val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8) + const ptr = this.seq.readBytes(2) + const val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8) sys.free(ptr) return val } - readInt() { - let ptr = this.seq.readBytes(4) - let val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8) | (sys.peek(ptr + 2) << 16) | (sys.peek(ptr + 3) << 24) + const ptr = this.seq.readBytes(4) + const val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8) | (sys.peek(ptr + 2) << 16) | (sys.peek(ptr + 3) << 24) sys.free(ptr) return val } - - readStr(n) { - let ptr = this.seq.readBytes(n) - let s = '' - for (let i = 0; i < n; i++) { - if (i >= this.length) break - s += String.fromCharCode(sys.peek(ptr + i)) - } - sys.free(ptr) - return s - } - unread(diff) { - let newSkipLen = this.seq.getReadCount() - diff + const newSkipLen = this.seq.getReadCount() - diff this.seq.prepare(this.path) this.seq.skip(newSkipLen) } - - rewind() { - this.seq.prepare(this.path) - } - - seek(p) { - this.seq.prepare(this.path) - this.seq.skip(p) - } - - get byteLength() { - return this.length - } - - get fileHeader() { - return this.seq.fileHeader - } - - getReadCount() { - return this.seq.getReadCount() - } + rewind() { this.seq.prepare(this.path) } + getReadCount() { return this.seq.getReadCount() } } - -// Read TAD chunk header to determine format -let filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full) +const filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full) const FILE_SIZE = filebuf.length if (FILE_SIZE < 7) { @@ -116,12 +68,12 @@ if (FILE_SIZE < 7) { return 1 } -// Read first chunk header (standalone TAD format: no TAV wrapper) -let firstSampleCount = filebuf.readShort() -let firstMaxIndex = filebuf.readByte() -let firstPayloadSize = filebuf.readInt() +// Peek the first chunk header so we know the chunk size for the rough bytes- +// to-seconds conversion shown in the progress bar. +const firstSampleCount = filebuf.readShort() +const firstMaxIndex = filebuf.readByte() +const firstPayloadSize = filebuf.readInt() -// Validate first chunk if (firstSampleCount < 0 || firstSampleCount > 65536) { serial.println(`ERROR: Invalid sample count ${firstSampleCount}. File may be corrupted.`) return 1 @@ -135,148 +87,68 @@ if (firstPayloadSize < 1 || firstPayloadSize > 65536) { return 1 } -// Rewind to start filebuf.rewind() -// Calculate approximate frame info -const AVG_CHUNK_SIZE = 7 + firstPayloadSize // TAD header (2+1+4) + payload -const SAMPLE_RATE = 32000 -const bufRealTimeLen = Math.floor((firstSampleCount / SAMPLE_RATE) * 1000) // milliseconds per chunk +const AVG_CHUNK_SIZE = 7 + firstPayloadSize +const SAMPLE_RATE = 32000 +const bufRealTimeLen = Math.floor((firstSampleCount / SAMPLE_RATE) * 1000) if (dumpCoeffs) { serial.println(`TAD Coefficient Dump Mode`) serial.println(`File: ${filebuf.file.name}`) - serial.println(`First chunk header:`) - serial.println(` Sample Count: ${firstSampleCount}`) - serial.println(` Max Index: ${firstMaxIndex}`) - serial.println(` Payload Size: ${firstPayloadSize} bytes`) + serial.println(`First chunk: ${firstSampleCount} samples, Q${firstMaxIndex}, ${firstPayloadSize} bytes payload`) serial.println(`Chunk Duration: ${bufRealTimeLen} ms`) serial.println(``) } - -let bytes_left = FILE_SIZE +let bytes_left = FILE_SIZE let decodedLength = 0 -let chunkNumber = 0 - - -con.curs_set(0) -let [__, CONSOLE_WIDTH] = con.getmaxyx() -if (interactive) { - let [cy, cx] = con.getyx() - // file name - con.mvaddch(cy, 1) - con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5) - print(filebuf.file.name) - con.prnch(0xC6);con.prnch(0xCD) - print("\x84205u".repeat(CONSOLE_WIDTH - 26 - filebuf.file.name.length)) - con.prnch(0xB5) - print("Hold Bksp to Exit") - con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB) - - // L R pillar - con.prnch(0xBA) - con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA) - - // media info - let mediaInfoStr = `TAD Q${firstMaxIndex} ${SAMPLE_RATE/1000}kHz` - con.move(cy+2,1) - con.prnch(0xC8) - print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length)) - con.prnch(0xB5) - print(mediaInfoStr) - con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC) - - con.move(cy+1, 2) -} -let [cy, cx] = con.getyx() -let paintWidth = CONSOLE_WIDTH - 20 +let chunkNumber = 0 function bytesToSec(i) { - // Approximate: use first chunk's ratio return Math.round((i / FILE_SIZE) * (FILE_SIZE / AVG_CHUNK_SIZE) * (bufRealTimeLen / 1000)) } -function secToReadable(n) { - let mins = ''+((n/60)|0) - let secs = ''+(n % 60) - return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}` -} - -function printPlayBar() { - if (interactive) { - let currently = decodedLength - let total = FILE_SIZE - - let currentlySec = bytesToSec(currently) - let totalSec = bytesToSec(total) - - con.move(cy, 3) - print(' '.repeat(15)) - con.move(cy, 3) - - print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`) - - con.move(cy, 17) - print(' ') - let progressbar = '\x84196u'.repeat(paintWidth + 1) - print(progressbar) - - con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB) - } -} - - audio.resetParams(0) audio.purgeQueue(0) audio.setPcmMode(0) -audio.setPcmQueueCapacityIndex(0, 2) // queue size is now 8 +audio.setPcmQueueCapacityIndex(0, 2) const QUEUE_MAX = audio.getPcmQueueCapacity(0) audio.setMasterVolume(0, 255) audio.play(0) +if (interactive) { + gui.audioInit({ + title: `${filebuf.file.name} TAD Q${firstMaxIndex} ${SAMPLE_RATE/1000}kHz`, + tag: "TAD" + }) +} let stopPlay = false let errorlevel = 0 - try { while (bytes_left > 0 && !stopPlay) { + if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break } - if (interactive) { - sys.poke(-40, 1) - if (sys.peek(-41) == 67) { // Backspace key - stopPlay = true - } - } + const sampleCount = filebuf.readShort() + const maxIndex = filebuf.readByte() + const payloadSize = filebuf.readInt() - printPlayBar() - - // Read TAD chunk header (standalone TAD format) - // Format: [sample_count][max_index][payload_size][payload] - let sampleCount = filebuf.readShort() - let maxIndex = filebuf.readByte() - let payloadSize = filebuf.readInt() - - // Validate every chunk (not just first one) if (sampleCount < 0 || sampleCount > 65536) { - serial.println(`ERROR: Chunk ${chunkNumber}: Invalid sample count ${sampleCount}. File may be corrupted.`) - errorlevel = 1 - break + serial.println(`ERROR: Chunk ${chunkNumber}: Invalid sample count ${sampleCount}.`) + errorlevel = 1; break } if (maxIndex < 0 || maxIndex > 255) { - serial.println(`ERROR: Chunk ${chunkNumber}: Invalid max index ${maxIndex}. File may be corrupted.`) - errorlevel = 1 - break + serial.println(`ERROR: Chunk ${chunkNumber}: Invalid max index ${maxIndex}.`) + errorlevel = 1; break } if (payloadSize < 1 || payloadSize > 65536) { - serial.println(`ERROR: Chunk ${chunkNumber}: Invalid payload size ${payloadSize}. File may be corrupted.`) - errorlevel = 1 - break + serial.println(`ERROR: Chunk ${chunkNumber}: Invalid payload size ${payloadSize}.`) + errorlevel = 1; break } if (payloadSize + 7 > bytes_left) { - serial.println(`ERROR: Chunk ${chunkNumber}: Chunk size ${payloadSize + 7} exceeds remaining file size ${bytes_left}`) - errorlevel = 1 - break + serial.println(`ERROR: Chunk ${chunkNumber}: Chunk size exceeds remaining file size.`) + errorlevel = 1; break } if (dumpCoeffs && chunkNumber < 3) { @@ -284,80 +156,59 @@ try { serial.println(` Sample Count: ${sampleCount}`) serial.println(` Max Index: ${maxIndex}`) serial.println(` Payload Size: ${payloadSize} bytes`) - serial.println(` Bytes remaining in file: ${bytes_left}`) } - // Rewind 7 bytes to re-read the header along with payload - // This allows reading the complete chunk (header + payload) in one call + // Read entire chunk (header + payload) into TAD input buffer. filebuf.unread(7) + filebuf.readBytes(7 + payloadSize, TAD_INPUT_ADDR) - // Read entire chunk (header + payload) to TAD input buffer - // This matches TAV's approach for packet 0x24 - let totalChunkSize = 7 + payloadSize - filebuf.readBytes(totalChunkSize, TAD_INPUT_ADDR) - - if (dumpCoeffs && chunkNumber < 3) { - // Dump first 32 bytes of compressed payload (skip 7-byte header) - serial.print(` Compressed data (first 32 bytes): `) - for (let i = 0; i < Math.min(32, payloadSize); i++) { - let b = sys.peek(TAD_INPUT_ADDR + 7 + i) - serial.print(`${(b & 0xFF).toString(16).padStart(2, '0')} `) - } - serial.println('') - } - - // Decode TAD chunk audio.tadDecode() - - if (dumpCoeffs && chunkNumber < 3) { - // After decoding, the decoded PCMu8 samples are in tadDecodedBin - serial.println(` Decoded ${sampleCount} samples`) - - // Dump first 16 decoded samples (PCMu8 stereo interleaved) - serial.print(` Decoded (first 16 L samples): `) - for (let i = 0; i < 16; i++) { - serial.print(`${sys.peek(TAD_DECODED_ADDR + i * 2) & 0xFF} `) - } - serial.println('') - serial.print(` Decoded (first 16 R samples): `) - for (let i = 0; i < 16; i++) { - serial.print(`${sys.peek(TAD_DECODED_ADDR + i * 2 + 1) & 0xFF} `) - } - serial.println('') - serial.println('') - } - - // Upload decoded audio to queue audio.tadUploadDecoded(0, sampleCount) + // After upload tadDecodedBin still holds the chunk until the next + // tadDecode call, so it's safe to keep slicing samples out of it + // during the playback wait below. if (!dumpCoeffs) { - // Sleep for the duration of the audio chunk to pace playback - // This prevents uploading everything at once - sys.sleep(bufRealTimeLen) + // TAD chunks are typically 1 s long, so feeding the visualiser + // once would freeze it for ~1 s. Walk the chunk in 2048-sample + // slices (~64 ms each at 32 kHz) so the wavescope and XY-scope + // stay in step with what the audio engine is actually playing. + const chunkMs = Math.floor((sampleCount / SAMPLE_RATE) * 1000) + const TAD_VIS_SLICE = 2048 + if (interactive) { + gui.audioSetProgress(decodedLength / FILE_SIZE, + bytesToSec(decodedLength), bytesToSec(FILE_SIZE)) + let sliceOff = 0 + while (sliceOff < sampleCount && !stopPlay) { + if (gui.audioIsExitRequested()) { stopPlay = true; break } + const sliceN = Math.min(TAD_VIS_SLICE, sampleCount - sliceOff) + // tadDecodedBin is negative-addressed: sample i sits at + // TAD_DECODED_ADDR - i*2. audioFeedPcm flips the read + // direction for negative ptrs internally. + gui.audioFeedPcm(TAD_DECODED_ADDR - sliceOff * 2, sliceN) + gui.audioRender() + sys.sleep(Math.floor((sliceN / SAMPLE_RATE) * 1000)) + sliceOff += sliceN + } + } else { + sys.sleep(chunkMs) + } } - // Chunk size = header (7 bytes) + payload - let chunkSize = 7 + payloadSize - bytes_left -= chunkSize + const chunkSize = 7 + payloadSize + bytes_left -= chunkSize decodedLength += chunkSize chunkNumber++ - // Limit coefficient dump to first 3 chunks if (dumpCoeffs && chunkNumber >= 3) { serial.println(`... (remaining chunks omitted)`) - // Keep playing but don't dump more } } -} -catch (e) { +} catch (e) { printerrln(e) errorlevel = 1 -} -finally { - if (interactive) { - con.move(cy + 3, 1) - con.curs_set(1) - } +} finally { + if (interactive) gui.audioClose() } return errorlevel diff --git a/assets/disk0/tvdos/bin/playtaud.js b/assets/disk0/tvdos/bin/playtaud.js index 37c5fbd..7348358 100644 --- a/assets/disk0/tvdos/bin/playtaud.js +++ b/assets/disk0/tvdos/bin/playtaud.js @@ -512,7 +512,6 @@ function drawFrame() { colour(COL_LABEL, COL_BG) mvtext(ROW_TOP_BORDER, 4, ' TAUD ') colour(COL_DIM, COL_BG) - mvtext(ROW_TOP_BORDER, COLS - 7, ' v0.1 ') // Bottom border + exit hint. colour(COL_BORDER, COL_BG) @@ -725,7 +724,7 @@ function spawnEventsForRow(cueIdx, rowIdx) { note: note, pan: pan, ageFrames: 0, peakVol: 0, - glyphSeed: (cueIdx * 64 + rowIdx + v * 13) & 0xFFFF + glyphSeed: (cueIdx * 64 + rowIdx + v * 1280) & 0xFFFF } voiceLastNote[v] = note voiceLastInst[v] = effInst diff --git a/assets/disk0/tvdos/bin/playwav.js b/assets/disk0/tvdos/bin/playwav.js index 59f0add..0358857 100644 --- a/assets/disk0/tvdos/bin/playwav.js +++ b/assets/disk0/tvdos/bin/playwav.js @@ -1,329 +1,189 @@ -// usage: playwav audiofile.wav [/i] -let fileeeee = files.open(_G.shell.resolvePathInput(exec_args[1]).full) -let filename = fileeeee.fullPath +// playwav — WAV (LPCM/ADPCM) player with the shared playgui visualiser. +// Usage: playwav [-i] + +const fileHandle = files.open(_G.shell.resolvePathInput(exec_args[1]).full) +const filePath = fileHandle.fullPath + +const WAV_FORMATS = ["LPCM", "ADPCM"] +const WAV_CHANNELS = ["Mono", "Stereo", "3ch", "Quad", "4.1", "5.1", "6.1", "7.1"] +const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i" + +const seqread = require("seqread") +const pcm = require("pcm") +const gui = interactive ? require("playgui") : null + function printdbg(s) { if (0) serial.println(s) } -const WAV_FORMATS = ["LPCM", "ADPCM"] -const WAV_CHANNELS = ["Mono", "Stereo", "3ch", "Quad", "4.1", "5.1", "6.1", "7.1"] -const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i" -const seqread = require("seqread") -const pcm = require("pcm") - - - -function printComments() { - for (const [key, value] of Object.entries(comments)) { - printdbg(`Wave Comment ${key}: ${value}`) - } -} - function GCD(a, b) { - a = Math.abs(a) - b = Math.abs(b) - if (b > a) {var temp = a; a = b; b = temp} + a = Math.abs(a); b = Math.abs(b) + if (b > a) { const t = a; a = b; b = t } while (true) { - if (b == 0) return a + if (b === 0) return a a %= b - if (a == 0) return b + if (a === 0) return b b %= a } } +function LCM(a, b) { return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b)) } -function LCM(a, b) { - return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b)) -} - - - -//println("Reading...") -//serial.println("!!! READING") - -seqread.prepare(filename) - - - - -// decode header -if (seqread.readFourCC() != "RIFF") { - throw Error("File not RIFF") -} - -const FILE_SIZE = seqread.readInt() // size from "WAVEfmt" - -if (seqread.readFourCC() != "WAVE") { - throw Error("File is RIFF but not WAVE") -} +seqread.prepare(filePath) +if (seqread.readFourCC() !== "RIFF") throw Error("File not RIFF") +const FILE_SIZE = seqread.readInt() +if (seqread.readFourCC() !== "WAVE") throw Error("File is RIFF but not WAVE") let BLOCK_SIZE = 0 let INFILE_BLOCK_SIZE = 0 -const QUEUE_MAX = 8 // according to the spec +const QUEUE_MAX = 8 -let pcmType; -let nChannels; -let samplingRate; -let blockSize; -let bitsPerSample; -let byterate; -let comments = {}; -let adpcmSamplesPerBlock; -let readPtr = undefined -let decodePtr = undefined +let pcmType, nChannels, samplingRate, blockSize, bitsPerSample, byterate +let adpcmSamplesPerBlock +let readPtr, decodePtr +const comments = {} function bytesToSec(i) { if (adpcmSamplesPerBlock) { - let newByteRate = samplingRate - let generatedSamples = i / blockSize * adpcmSamplesPerBlock - return generatedSamples / newByteRate - } - else { - return i / byterate + const generatedSamples = i / blockSize * adpcmSamplesPerBlock + return generatedSamples / samplingRate } + return i / byterate } -function secToReadable(n) { - let mins = ''+((n/60)|0) - let secs = ''+(n % 60) - return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}` -} + function checkIfPlayable() { - if (pcmType != 1 && pcmType != 2) return `PCM Type not LPCM/ADPCM (${pcmType})` + if (pcmType !== 1 && pcmType !== 2) return `PCM Type not LPCM/ADPCM (${pcmType})` if (nChannels < 1 || nChannels > 2) return `Audio not mono/stereo but instead has ${nChannels} channels` - if (pcmType != 1 && samplingRate != pcm.HW_SAMPLING_RATE) return `Format is ADPCM but sampling rate is not ${pcm.HW_SAMPLING_RATE}: ${samplingRate}` + if (pcmType !== 1 && samplingRate !== pcm.HW_SAMPLING_RATE) + return `Format is ADPCM but sampling rate is not ${pcm.HW_SAMPLING_RATE}: ${samplingRate}` return "playable!" } -// @return decoded sample length (not count!) + function decodeInfilePcm(inPtr, outPtr, inputLen) { - // LPCM - if (1 == pcmType) + if (pcmType === 1) return pcm.decodeLPCM(inPtr, outPtr, inputLen, { nChannels, bitsPerSample, samplingRate, blockSize }) - else if (2 == pcmType) + if (pcmType === 2) return pcm.decodeMS_ADPCM(inPtr, outPtr, inputLen, { nChannels }) - else - throw Error(`PCM Type not LPCM or ADPCM (${pcmType})`) + throw Error(`PCM Type not LPCM or ADPCM (${pcmType})`) } + let stopPlay = false - - -con.curs_set(0) -let [__, CONSOLE_WIDTH] = con.getmaxyx() -function printPlayerShell() { -if (interactive) { - let [cy, cx] = con.getyx() - // file name - con.mvaddch(cy, 1) - con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5) - print(fileeeee.name) - con.prnch(0xC6);con.prnch(0xCD) - print("\x84205u".repeat(CONSOLE_WIDTH - 26 - fileeeee.name.length)) - con.prnch(0xB5) - print("Hold Bksp to Exit") - con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB) - - // L R pillar - con.prnch(0xBA) - con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA) - - // media info - let mediaInfoStr = `WAV ${WAV_FORMATS[pcmType-1]} ${WAV_CHANNELS[nChannels-1]} ${byterate*0.008*(pcmType == 2 ? 2 : 1)}kbps` - con.move(cy+2,1) - con.prnch(0xC8) - print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length)) - con.prnch(0xB5) - print(mediaInfoStr) - con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC) - - con.move(cy+1, 2) -} -} -let [cy, cx] = con.getyx(); cy++ -let paintWidth = CONSOLE_WIDTH - 20 -function printPlayBar(startOffset) { - if (interactive) { - let currently = seqread.getReadCount() - startOffset - let total = FILE_SIZE - startOffset - 8 - - let currentlySec = Math.round(bytesToSec(currently)) - let totalSec = Math.round(bytesToSec(total)) - - con.move(cy, 3) - print(' '.repeat(15)) - con.move(cy, 3) - - print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`) - - con.move(cy, 17) - print(' ') - let progressbar = '\x84196u'.repeat(paintWidth + 1) - print(progressbar) - - con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB) - } -} let errorlevel = 0 -// read chunks loop -try { -while (!stopPlay && seqread.getReadCount() < FILE_SIZE - 8) { - let chunkName = seqread.readFourCC() - let chunkSize = seqread.readInt() - printdbg(`Reading '${chunkName}' at ${seqread.getReadCount() - 8}`) - // here be lotsa if-else - if ("fmt " == chunkName) { - pcmType = seqread.readShort() - nChannels = seqread.readShort() - samplingRate = seqread.readInt() - byterate = seqread.readInt() - blockSize = seqread.readShort() - bitsPerSample = seqread.readShort() - if (pcmType != 2) { - seqread.skip(chunkSize - 16) +try { + while (!stopPlay && seqread.getReadCount() < FILE_SIZE - 8) { + const chunkName = seqread.readFourCC() + const chunkSize = seqread.readInt() + printdbg(`Reading '${chunkName}' at ${seqread.getReadCount() - 8}`) + + if (chunkName === "fmt ") { + pcmType = seqread.readShort() + nChannels = seqread.readShort() + samplingRate = seqread.readInt() + byterate = seqread.readInt() + blockSize = seqread.readShort() + bitsPerSample = seqread.readShort() + if (pcmType !== 2) { + seqread.skip(chunkSize - 16) + } else { + seqread.skip(2) + adpcmSamplesPerBlock = seqread.readShort() + seqread.skip(chunkSize - (16 + 4)) + } + + if (pcmType === 1) { + const incr = LCM(blockSize, samplingRate / GCD(samplingRate, pcm.HW_SAMPLING_RATE)) + while (BLOCK_SIZE < 4096) BLOCK_SIZE += incr + INFILE_BLOCK_SIZE = BLOCK_SIZE * bitsPerSample / 8 + } else if (pcmType === 2) { + BLOCK_SIZE = blockSize + INFILE_BLOCK_SIZE = BLOCK_SIZE + } + + if (interactive) { + const tag = "WAV" + const title = fileHandle.name + + ` ${WAV_FORMATS[pcmType-1]} ${WAV_CHANNELS[nChannels-1]} ${byterate*0.008*(pcmType === 2 ? 2 : 1)}kbps` + gui.audioInit({ title, tag }) + } + } + else if (chunkName === "LIST") { + const startOffset = seqread.getReadCount() + const subChunkName = seqread.readFourCC() + while (seqread.getReadCount() < startOffset + chunkSize) { + if (subChunkName === "INFO") { + let key = seqread.readFourCC() + let valueLen = seqread.readInt() + while (key.charCodeAt(0) === 0) { + const kbytes = [key.charCodeAt(1), key.charCodeAt(2), key.charCodeAt(3), valueLen & 255] + const klen = [(valueLen >>> 8) & 255, (valueLen >>> 16) & 255, (valueLen >>> 24) & 255, seqread.readOneByte()] + key = String.fromCharCode.apply(null, kbytes) + valueLen = klen[0] | (klen[1] << 8) | (klen[2] << 16) | (klen[3] << 24) + } + comments[key] = seqread.readString(valueLen) + } else { + seqread.skip(startOffset + chunkSize - seqread.getReadCount()) + } + } + } + else if (chunkName === "data") { + const startOffset = seqread.getReadCount() + const reason = checkIfPlayable() + if (reason !== "playable!") throw Error("WAVE not playable: " + reason) + + readPtr = sys.malloc(pcmType === 2 ? BLOCK_SIZE : BLOCK_SIZE * bitsPerSample / 8) + decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate) + + audio.resetParams(0) + audio.purgeQueue(0) + audio.setPcmMode(0) + audio.setMasterVolume(0, 255) + + let readLength = 1 + while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) { + if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break } + + if (audio.getPosition(0) <= 1) { + for (let repeat = 0; repeat < QUEUE_MAX; repeat++) { + const remainingBytes = FILE_SIZE - 8 - seqread.getReadCount() + readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE + if (readLength <= 0) break + + seqread.readBytes(readLength, readPtr) + const decodedSampleLength = decodeInfilePcm(readPtr, decodePtr, readLength) + + // Hand the decoded PCMu8 stereo block to the visualiser + // before queueing — the buffer is reused next iteration. + if (interactive) gui.audioFeedPcm(decodePtr, decodedSampleLength >> 1) + + audio.putPcmDataByPtr(0, decodePtr, decodedSampleLength, 0) + audio.setSampleUploadLength(0, decodedSampleLength) + audio.startSampleUpload(0) + + sys.spin() + } + audio.play(0) + } + + if (interactive) { + const cur = seqread.getReadCount() - startOffset + const tot = FILE_SIZE - startOffset - 8 + gui.audioSetProgress(cur / tot, bytesToSec(cur), bytesToSec(tot)) + gui.audioRender() + } + sys.sleep(10) + } } else { - seqread.skip(2) - adpcmSamplesPerBlock = seqread.readShort() - seqread.skip(chunkSize - (16 + 4)) + seqread.skip(chunkSize) } - // define BLOCK_SIZE as integer multiple of blockSize, for LPCM - // ADPCM will be decoded per-block basis - if (1 == pcmType) { - // get GCD of given values; this wll make resampling headache-free - let blockSizeIncrement = LCM(blockSize, samplingRate / GCD(samplingRate, pcm.HW_SAMPLING_RATE)) - - while (BLOCK_SIZE < 4096) { - BLOCK_SIZE += blockSizeIncrement // for rate 44100, BLOCK_SIZE will be 4116 - } - INFILE_BLOCK_SIZE = BLOCK_SIZE * bitsPerSample / 8 // for rate 44100, INFILE_BLOCK_SIZE will be 8232 - } - else if (2 == pcmType) { - BLOCK_SIZE = blockSize - INFILE_BLOCK_SIZE = BLOCK_SIZE - } - - printdbg(`Format: ${pcmType}, Channels: ${nChannels}, Rate: ${samplingRate}, BitDepth: ${bitsPerSample}`) - printdbg(`BLOCK_SIZE=${BLOCK_SIZE}, INFILE_BLOCK_SIZE=${INFILE_BLOCK_SIZE}`) - printPlayerShell() + sys.spin() } - else if ("LIST" == chunkName) { - let startOffset = seqread.getReadCount() - let subChunkName = seqread.readFourCC() - while (seqread.getReadCount() < startOffset + chunkSize) { - if ("INFO" == subChunkName) { - let key = seqread.readFourCC() - let valueLen = seqread.readInt() - - // f-you WAVE encoders with nonstandard behaviours - // related: https://stackoverflow.com/questions/49537639/riff-icmt-tag-size-doesnt-seem-to-match-data - while (0 == key.charCodeAt(0)) { - printdbg(`Previous key had more zero bytes padded than its marked length, skipping one byte...`) - - let kbytes = [key.charCodeAt(1), key.charCodeAt(2), key.charCodeAt(3), valueLen & 255] - let klen = [(valueLen >>> 8) & 255, (valueLen >>> 16) & 255, (valueLen >>> 24) & 255, seqread.readOneByte()] - - key = String.fromCharCode.apply(null, kbytes) - valueLen = klen[0] | (klen[1] << 8) | (klen[2] << 16) | (klen[3] << 24) - } - - printdbg(`Reading LIST INFO ${key}[${[0,1,2,3].map((i)=>"0x"+key.charCodeAt(i).toString(16).padStart(2,'0'))}] (${valueLen} bytes): `) - - - let value = seqread.readString(valueLen) - printdbg(" |"+value) - comments[key] = value - } - else { - printdbg(`LIST skip subchunk ${subChunkName} (${startOffset + chunkSize - seqread.getReadCount()} bytes)`) - seqread.skip(startOffset + chunkSize - seqread.getReadCount()) - } - } - printComments() - } - else if ("data" == chunkName) { - let startOffset = seqread.getReadCount() - - printdbg(`WAVE size: ${chunkSize}, startOffset=${startOffset}`) - // check if the format is actually playable - let unplayableReason = checkIfPlayable() - if (unplayableReason != "playable!") throw Error("WAVE not playable: "+unplayableReason) - - if (pcmType == 2) - readPtr = sys.malloc(BLOCK_SIZE) - else - readPtr = sys.malloc(BLOCK_SIZE * bitsPerSample / 8) - - decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate) - - audio.resetParams(0) - audio.purgeQueue(0) - audio.setPcmMode(0) - audio.setMasterVolume(0, 255) - - let readLength = 1 - while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) { - if (interactive) { - sys.poke(-40, 1) - if (sys.peek(-41) == 67) { - stopPlay = true - } - } - - printPlayBar(startOffset) - - let queueSize = audio.getPosition(0) - if (queueSize <= 1) { - - - // upload four samples for lag-safely - for (let repeat = 0; repeat < QUEUE_MAX; repeat++) { - let remainingBytes = FILE_SIZE - 8 - seqread.getReadCount() - - readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE - if (readLength <= 0) { - printdbg(`readLength = ${readLength}`) - break - } - - printdbg(`offset: ${seqread.getReadCount()}/${FILE_SIZE + 8}; readLength: ${readLength}`) - - seqread.readBytes(readLength, readPtr) - - let decodedSampleLength = decodeInfilePcm(readPtr, decodePtr, readLength) - printdbg(` decodedSampleLength: ${decodedSampleLength}`) - - audio.putPcmDataByPtr(0, decodePtr, decodedSampleLength, 0) - audio.setSampleUploadLength(0, decodedSampleLength) - audio.startSampleUpload(0) - - sys.spin() - } - - audio.play(0) - } - - let remainingBytes = FILE_SIZE - 8 - seqread.getReadCount() - printdbg(`readLength = ${readLength}; remainingBytes2 = ${remainingBytes}; seqread.getReadCount() = ${seqread.getReadCount()}; startOffset + chunkSize = ${startOffset + chunkSize}`) - - - sys.sleep(10) - } - } - else { - seqread.skip(chunkSize) - } - - - let remainingBytes = FILE_SIZE - 8 - seqread.getReadCount() - printdbg(`remainingBytes2 = ${remainingBytes}`) - sys.spin() -} -} -catch (e) { +} catch (e) { printerrln(e) errorlevel = 1 -} -finally { - //audio.stop(0) - if (readPtr !== undefined) sys.free(readPtr) +} finally { + if (readPtr !== undefined) sys.free(readPtr) if (decodePtr !== undefined) sys.free(decodePtr) + if (interactive) gui.audioClose() } return errorlevel diff --git a/assets/disk0/tvdos/include/playgui.mjs b/assets/disk0/tvdos/include/playgui.mjs index 184a476..1697dd9 100644 --- a/assets/disk0/tvdos/include/playgui.mjs +++ b/assets/disk0/tvdos/include/playgui.mjs @@ -281,9 +281,611 @@ function printTopBar(status, moreInfo) { con.move(1, 1) } +// ── Audio player visualiser ───────────────────────────────────────────────── +// Shared by playwav/playmp2/playpcm/playtad. Design follows +// `assets/playwav_visualiser_design_2_for_tsvm.md`: +// * 3-row ASCII wavescope (mid signal envelope) on rows 3..5 +// * 22-col progress dashes on the right side of the song-title row +// * 24-row XY-scope + wavelet-modulated persistence visualiser on rows 7..30 +// * stereo energy bar on row 31 +// +// The visualiser fuses two displays the design doc calls complementary: +// * XY-scope geometry (rotated 45° so L plots along the `\` diagonal and R +// along `/`) gives spatial motion and stereo image. +// * Haar wavelet features (transient / noise / sustain energies) steer the +// beam's behaviour — transients evaporate it and emit sparks, sustained +// content lets trails breathe longer, mid noise jitters the beam. +// +// The wavelet is therefore a *modulator*, not a renderer. No FFT, no pitch +// tracking, no per-frame allocation in the hot loop. + +const AG_COLS = 80 +const AG_ROWS = 32 +const AG_COL_INSIDE_L = 2 +const AG_COL_INSIDE_R = 79 +const AG_LANE_W = 78 + +const AG_ROW_TOP_BORDER = 1 +const AG_ROW_TITLE = 2 +const AG_ROW_WAVE_TOP = 3 +const AG_ROW_WAVE_BOT = 5 // 3-row wavescope +const AG_ROW_VIS_SEP = 6 +const AG_ROW_VIS_TOP = 7 +const AG_ROW_VIS_BOT = 30 // 24-row wavelet visualiser +const AG_ROW_STEREO = 31 +const AG_ROW_BOT_BORDER = 32 + +const AG_VIS_H = AG_ROW_VIS_BOT - AG_ROW_VIS_TOP + 1 // 24 +const AG_VIS_W = AG_LANE_W // 78 + +// Palette (TSVM 256-colour indices) +const AG_COL_BG = 0 +const AG_COL_BORDER = 250 +const AG_COL_LABEL = 220 +const AG_COL_DIM = 235 +const AG_COL_TITLE = 230 +const AG_COL_VALUE = 254 +const AG_COL_PROG_ON = 226 // bright yellow (matches Taud) + +// Box-drawing constants (CP437) +const AG_BX_TL = 0xC9, AG_BX_TR = 0xBB, AG_BX_BL = 0xC8, AG_BX_BR = 0xBC +const AG_BX_V = 0xBA, AG_BX_H = 0xCD +const AG_SEP_L = 0xC7, AG_SEP_R = 0xB6 + +// Half-block glyphs for wavescope +const AG_HB_NONE = 0x20 // ' ' +const AG_HB_TOP = 0xDF // '▀' +const AG_HB_BOT = 0xDC // '▄' +const AG_HB_BOTH = 0xDB // '█' + +// Density stairs for visualiser + stereo bar +const AG_STAIRS = [0x20, 0xB0, 0xB1, 0xB2, 0xDB] // ' ', ░, ▒, ▓, █ + +// Electron-beam colour ramp. Index 0 = silent (background), last = freshly +// drawn beam. Amber-on-black mimics analog vector-scope CRT phosphor — the +// glyph shape carries the spatial information, the colour ramp carries age. +const AG_BEAM_PAL = [AG_COL_BG, 94, 130, 166, 220] + +// Five wavelet levels (Haar decomp). These are used only as modulators — +// they never get rendered as bars. Indexing: +// AG_WL_TRANSIENT — top-octave detail (8 kHz..16 kHz at 32 kHz Fs). +// Spikes on percussion attacks, vocal consonants, cymbals. +// AG_WL_NOISE — upper-mid detail (4..8 kHz). Drives beam jitter. +// AG_WL_BODY — mid detail (2..4 kHz). +// AG_WL_TONAL — lower-mid detail (1..2 kHz). +// AG_WL_BASS — low detail (0.5..1 kHz). Slows the decay (sustain). +const AG_N_BANDS = 5 +const AG_WL_TRANSIENT = 0 +const AG_WL_NOISE = 1 +const AG_WL_BODY = 2 +const AG_WL_TONAL = 3 +const AG_WL_BASS = 4 + +// Stereo bar colour ramp (5 levels) — uses the tonal blue gradient so the +// stereo strip reads as the "ground" beneath the wavelet cloud. +const AG_STEREO_COL = [AG_COL_DIM, 17, 33, 75, 117] + +// ── State ─────────────────────────────────────────────────────────────────── +// +// All state lives in module scope so a player just does: +// const gui = require('playgui') +// gui.audioInit({...}) +// while (...) { ...; gui.audioFeedPcm(ptr, n); gui.audioRender(); } +// gui.audioClose() +// +// Multiple concurrent players in one process are not supported — but TVDOS +// only runs one foreground command at a time, so that's fine. + +const AG_SNAPSHOT_N = 1024 // power of 2; covers ~32 ms at 32 kHz +const ag_snapL = new Float32Array(AG_SNAPSHOT_N) +const ag_snapR = new Float32Array(AG_SNAPSHOT_N) + +const AG_WORK_N = AG_SNAPSHOT_N // scratch buffers for Haar pyramid +const ag_workMid = new Float32Array(AG_WORK_N) +const ag_workTmp = new Float32Array(AG_WORK_N >> 1) +const ag_bandEnergy = new Float32Array(AG_N_BANDS) + +// Persistence buffer — float intensity per cell, plus the glyph last written +// there. Decay shrinks intensity each frame; new beam samples overwrite the +// glyph and bump intensity. +const ag_persist = new Float32Array(AG_VIS_H * AG_VIS_W) +const ag_persistGlyph = new Int16Array(AG_VIS_H * AG_VIS_W) + +// Skip-redraw cache — only emit a cell when its glyph or colour changes. +const ag_cellGlyph = new Int16Array(AG_VIS_H * AG_VIS_W).fill(-1) +const ag_cellFg = new Int16Array(AG_VIS_H * AG_VIS_W).fill(-1) +const ag_waveGlyph = new Int16Array(AG_LANE_W * 3).fill(-1) +const ag_stereoGlyph = new Int16Array(AG_LANE_W).fill(-1) +const ag_stereoFg = new Int16Array(AG_LANE_W).fill(-1) + +// Render rate-limiter — playmp2 spins ~32 Hz, playtad ~1 Hz, playwav ~100 Hz +// at decode time. Clamp visual refresh to 20 Hz so each caller can spam +// audioRender() without worrying about pacing. +let ag_lastRenderNs = 0 +const AG_RENDER_INTERVAL_NS = 50 * 1000 * 1000 // 50 ms + +// Latest progress fraction so we redraw the bar only when it changes. +let ag_lastProgressIdx = -1 +let ag_lastTimeStr = '' + +// Init params held for re-use during render. +let ag_initParams = null + +function ag_color(fg, bg) { con.color_pair(fg, bg) } +function ag_mvprn(row, col, ch) { con.mvaddch(row, col, ch) } +function ag_mvtext(row, col, s) { con.move(row, col); print(s) } + +function ag_pad(n, w) { + let s = '' + n + while (s.length < w) s = ' ' + s + return s +} + +function ag_secToReadable(n) { + const mins = ('' + ((n / 60) | 0)).padStart(2, '0') + const secs = ('' + (n % 60)).padStart(2, '0') + return mins + ':' + secs +} + +function ag_drawSeparator(row, label) { + ag_color(AG_COL_BORDER, AG_COL_BG) + ag_mvprn(row, 1, AG_SEP_L) + for (let x = 2; x < AG_COLS; x++) ag_mvprn(row, x, AG_BX_H) + ag_mvprn(row, AG_COLS, AG_SEP_R) + if (label) { + ag_color(AG_COL_LABEL, AG_COL_BG) + ag_mvtext(row, 5, ' ' + label + ' ') + } +} + +function ag_drawFrame() { + // Top border with embedded format tag. + ag_color(AG_COL_BORDER, AG_COL_BG) + ag_mvprn(AG_ROW_TOP_BORDER, 1, AG_BX_TL) + for (let x = 2; x < AG_COLS; x++) ag_mvprn(AG_ROW_TOP_BORDER, x, AG_BX_H) + ag_mvprn(AG_ROW_TOP_BORDER, AG_COLS, AG_BX_TR) + if (ag_initParams.tag) { + ag_color(AG_COL_LABEL, AG_COL_BG) + ag_mvtext(AG_ROW_TOP_BORDER, 4, ' ' + ag_initParams.tag + ' ') + } + + // Bottom border with exit hint. + ag_color(AG_COL_BORDER, AG_COL_BG) + ag_mvprn(AG_ROW_BOT_BORDER, 1, AG_BX_BL) + for (let x = 2; x < AG_COLS; x++) ag_mvprn(AG_ROW_BOT_BORDER, x, AG_BX_H) + ag_mvprn(AG_ROW_BOT_BORDER, AG_COLS, AG_BX_BR) + ag_color(AG_COL_DIM, AG_COL_BG) + ag_mvtext(AG_ROW_BOT_BORDER, 4, ' Hold BkSp to exit ') + + // Side bars. + ag_color(AG_COL_BORDER, AG_COL_BG) + for (let r = 2; r < AG_ROWS; r++) { + ag_mvprn(r, 1, AG_BX_V) + ag_mvprn(r, AG_COLS, AG_BX_V) + } + + // Inner separator over the visualiser canvas. The wavescope strip sits + // flush against the title row — no separator there. + ag_drawSeparator(AG_ROW_VIS_SEP, 'VISUALS') +} + +function ag_clearInside(row) { + ag_color(AG_COL_DIM, AG_COL_BG) + con.move(row, AG_COL_INSIDE_L) + print(' '.repeat(AG_LANE_W)) +} + +function ag_drawTitle() { + ag_clearInside(AG_ROW_TITLE) + let title = ag_initParams.title || '' + // Reserve 24 cols on the right for time string + progress bar. + if (title.length > AG_LANE_W - 26) title = title.substring(0, AG_LANE_W - 29) + '...' + ag_color(AG_COL_TITLE, AG_COL_BG) + ag_mvtext(AG_ROW_TITLE, AG_COL_INSIDE_L + 1, title) +} + +// Progress: time string + 22-wide dashes ramp (matches playtaud). Called by +// the player via audioSetProgress; redraws only when something changed. +function ag_drawProgress(progress, elapsedSec, totalSec) { + const barW = 22 + const bx0 = AG_COL_INSIDE_R - barW + const filled = Math.round(progress * barW) + + const timeStr = ag_secToReadable(elapsedSec) + '/' + ag_secToReadable(totalSec) + if (timeStr !== ag_lastTimeStr) { + ag_lastTimeStr = timeStr + ag_color(AG_COL_VALUE, AG_COL_BG) + ag_mvtext(AG_ROW_TITLE, bx0 - timeStr.length - 1, timeStr) + } + + if (filled === ag_lastProgressIdx) return + ag_lastProgressIdx = filled + + for (let i = 0; i < barW; i++) { + const lit = i < filled + ag_color(lit ? AG_COL_PROG_ON : AG_COL_DIM, AG_COL_BG) + ag_mvprn(AG_ROW_TITLE, bx0 + i, lit ? 0x7C /*│*/ : 0x2E /*.*/) + } +} + +// ── PCM ingestion ─────────────────────────────────────────────────────────── +// +// feedPcm copies the most recent SNAPSHOT_N samples from a PCMu8-stereo- +// interleaved buffer into our snapshot. `ptr` can be a positive heap address +// (LPCM/ADPCM decoded buffer, raw PCM) or a negative peripheral address (TAD +// decoded buffer, MP2 mediaDecodedBin) — TSVM peripheral memory grows toward +// 0, so reads use a signed step `vec`. + +function audioFeedPcm(ptr, sampleCount) { + if (!sampleCount) return + const vec = ptr >= 0 ? 1 : -1 + const inv128 = 1 / 128 + + if (sampleCount >= AG_SNAPSHOT_N) { + // Take last AG_SNAPSHOT_N samples — discard the rest. + const start = sampleCount - AG_SNAPSHOT_N + for (let i = 0; i < AG_SNAPSHOT_N; i++) { + const off = (start + i) * 2 * vec + ag_snapL[i] = ((sys.peek(ptr + off) & 0xFF) - 128) * inv128 + ag_snapR[i] = ((sys.peek(ptr + off + vec) & 0xFF) - 128) * inv128 + } + } else { + // Shift snapshot left by `sampleCount` and append all new samples. + const shift = sampleCount + const keep = AG_SNAPSHOT_N - shift + for (let i = 0; i < keep; i++) { + ag_snapL[i] = ag_snapL[i + shift] + ag_snapR[i] = ag_snapR[i + shift] + } + for (let i = 0; i < shift; i++) { + const off = i * 2 * vec + ag_snapL[keep + i] = ((sys.peek(ptr + off) & 0xFF) - 128) * inv128 + ag_snapR[keep + i] = ((sys.peek(ptr + off + vec) & 0xFF) - 128) * inv128 + } + } +} + +// ── Wavelet analysis ─────────────────────────────────────────────────────── +// +// In-place Haar decomposition. Five levels on 1024 samples gives band +// passes (at 32 kHz): [8k..16k], [4k..8k], [2k..4k], [1k..2k], [500..1k]. +// Sub-500 Hz ends up in the approximation and is intentionally dropped — +// otherwise the bass would dominate every track. + +function ag_analyseHaar() { + // mid = (L + R) / 2 + for (let i = 0; i < AG_SNAPSHOT_N; i++) { + ag_workMid[i] = (ag_snapL[i] + ag_snapR[i]) * 0.5 + } + let len = AG_SNAPSHOT_N + const SQ_HALF = 0.70710678 // 1/sqrt(2) keeps L2 norm + for (let lv = 0; lv < AG_N_BANDS; lv++) { + const half = len >> 1 + let sumSq = 0 + for (let i = 0; i < half; i++) { + const a = ag_workMid[i * 2] + const b = ag_workMid[i * 2 + 1] + const lo = (a + b) * SQ_HALF + const hi = (a - b) * SQ_HALF + ag_workMid[i] = lo + ag_workTmp[i] = hi + sumSq += hi * hi + } + // Higher-freq levels naturally have weaker energy in music; scale + // each band by an empirical gain so all five read at comparable + // brightness on typical material. + const gain = 3.0 + lv * 1.5 + const rms = Math.sqrt(sumSq / half) * gain + ag_bandEnergy[lv] = rms > 1 ? 1 : rms + len = half + } +} + +// ── Wavescope (rows 3..5) ────────────────────────────────────────────────── +// +// Peak-detected envelope: each column shows the range [min, max] of its slice +// of the snapshot using half-block characters for 6 vertical sub-positions. +// Mid-signal only — for stereo information you read the bottom bar. + +function ag_drawWavescope() { + const N = AG_SNAPSHOT_N + const samplesPerCol = N / AG_LANE_W + // 6 sub-positions: 0..5 from top to bottom. + for (let c = 0; c < AG_LANE_W; c++) { + const s = (c * samplesPerCol) | 0 + const e = (((c + 1) * samplesPerCol) | 0) + let mn = 1.0, mx = -1.0 + for (let i = s; i < e; i++) { + const v = (ag_snapL[i] + ag_snapR[i]) * 0.5 + if (v < mn) mn = v + if (v > mx) mx = v + } + // Map [-1, 1] → [0, 5] (top..bottom). +1 → 0, -1 → 5. + let yMax = ((1 - mx) * 0.5 * 6) | 0 + let yMin = ((1 - mn) * 0.5 * 6) | 0 + if (yMax < 0) yMax = 0; if (yMax > 5) yMax = 5 + if (yMin < 0) yMin = 0; if (yMin > 5) yMin = 5 + // yMax is the top of the bar (smaller y = higher up), yMin is bottom. + for (let row = 0; row < 3; row++) { + const subTop = row * 2 + const subBot = row * 2 + 1 + const hitTop = (yMax <= subTop) && (yMin >= subTop) + const hitBot = (yMax <= subBot) && (yMin >= subBot) + let g = AG_HB_NONE + if (hitTop && hitBot) g = AG_HB_BOTH + else if (hitTop) g = AG_HB_TOP + else if (hitBot) g = AG_HB_BOT + const idx = row * AG_LANE_W + c + if (ag_waveGlyph[idx] === g) continue + ag_waveGlyph[idx] = g + ag_color(AG_COL_LABEL, AG_COL_BG) + ag_mvprn(AG_ROW_WAVE_TOP + row, AG_COL_INSIDE_L + c, g) + } + } +} + +// ── XY-scope persistence visualiser (rows 7..30) ─────────────────────────── +// +// 45°-rotated vectorscope, standard convention. Each PCM sample plots at +// col = centre_col + (L − R) · SX +// row = centre_row + (L + R) · SY +// giving the four canonical traces: +// in-phase mono (L = R) → vertical line ((L−R)=0, (L+R) varies) +// out-of-phase mono (L=−R) → horizontal line ((L+R)=0, (L−R) varies) +// pure L (R = 0) → lower-right diagonal — the `\` axis +// pure R (L = 0) → lower-left diagonal — the `/` axis +// (Positive mono sits below centre because screen row increases downward.) +// The glyph per cell follows channel dominance, the cell's intensity is +// bumped on every hit, and a global decay shrinks stale traces back to zero. +// +// Wavelet energies are used as *modulators* — the design's central idea: +// +// transient → faster decay + scattered spark emission +// bass/tonal → slower decay (sustained content breathes longer) +// noise → small jitter on plot position (texture fuzz) +// +// TSVM terminal cells are ~2:1 (taller than wide); SX is set to ~2×SY so the +// scope reads roughly circular under steady mono content. + +const AG_XY_CX = AG_VIS_W >> 1 // centre column inside visualiser canvas +const AG_XY_CY = AG_VIS_H >> 1 // centre row +const AG_XY_SX = 18 // (L−R) → horizontal extent ±36 cells +const AG_XY_SY = 9 // (L+R) → vertical extent ±18 cells + +// Glyphs. +const AG_G_DOT = 0xFA // · +const AG_G_BSL = 0x5C // \\ +const AG_G_FSL = 0x2F // / +const AG_G_XCR = 0x58 // X +const AG_G_SPK = 0x2A // * +const AG_G_HBAR = 0xC4 // ─ + +function ag_updateXYScope() { + // Wavelet-driven modulators, all in [0, 1]. + const transient = ag_bandEnergy[AG_WL_TRANSIENT] + const noise = ag_bandEnergy[AG_WL_NOISE] + const sustain = ag_bandEnergy[AG_WL_BASS] * 0.6 + ag_bandEnergy[AG_WL_TONAL] * 0.4 + + // Decay: base 0.93, longer for sustained content, much shorter for sharp + // transients. Clamped so a screaming hi-hat never freezes the trails and + // a deep pad never overflows. + let decay = 0.93 + 0.05 * (sustain > 1 ? 1 : sustain) + - 0.10 * (transient > 1 ? 1 : transient) + if (decay < 0.72) decay = 0.72 + if (decay > 0.985) decay = 0.985 + + // Decay all cells. + for (let i = 0; i < ag_persist.length; i++) { + ag_persist[i] *= decay + } + + // Plot every sample in the snapshot. Step 1 keeps lines continuous + // visually; with 1024 samples per ~50 ms frame, most cells get multiple + // hits and the persistence builds the "beam" silhouette. + const SX = AG_XY_SX + const SY = AG_XY_SY + const cx = AG_XY_CX + const cy = AG_XY_CY + const jitterAmt = noise * 0.06 // noise-driven beam fuzz + const plotBoost = 0.05 + + for (let i = 0; i < AG_SNAPSHOT_N; i++) { + const L = ag_snapL[i] + const R = ag_snapR[i] + const mono = L + R // vertical axis ∈ [-2, 2] + const side = L - R // horizontal axis ∈ [-2, 2] + // Wavelet-driven jitter is symmetric — substitute a deterministic + // pseudo-random by mixing the snapshot index so we don't churn the + // shared Math.random() PRNG 1024× per frame. + const jx = (((i * 1103515245 + 12345) & 0xFFFF) / 65536 - 0.5) * jitterAmt + const jy = (((i * 1664525 + 1013904223) & 0xFFFF) / 65536 - 0.5) * jitterAmt + let col = cx + ((side + jx) * SX) | 0 + let row = cy + ((mono + jy) * SY) | 0 + if (col < 0 || col >= AG_VIS_W || row < 0 || row >= AG_VIS_H) continue + + const absL = L < 0 ? -L : L + const absR = R < 0 ? -R : R + let glyph + if (absL + absR < 0.04) { + glyph = AG_G_DOT + } else if (absL > absR * 1.25) { + glyph = AG_G_BSL // L-dominant → \ + } else if (absR > absL * 1.25) { + glyph = AG_G_FSL // R-dominant → / + } else { + glyph = AG_G_XCR // mixed → X + } + + const idx = row * AG_VIS_W + col + let nv = ag_persist[idx] + plotBoost + if (nv > 1.0) nv = 1.0 + ag_persist[idx] = nv + ag_persistGlyph[idx] = glyph + } + + // Transient spark emission — when high-freq energy peaks, scatter a few + // bright `*` glyphs across the canvas. Cap at ~32 sparks to stay cheap. + if (transient > 0.32) { + const nSparks = ((transient - 0.32) * 60) | 0 + for (let s = 0; s < nSparks && s < 32; s++) { + const c = (Math.random() * AG_VIS_W) | 0 + const r = (Math.random() * AG_VIS_H) | 0 + const idx = r * AG_VIS_W + c + if (ag_persist[idx] < 0.85) ag_persist[idx] = 0.85 + ag_persistGlyph[idx] = AG_G_SPK + } + } +} + +function ag_drawVisualiser() { + for (let r = 0; r < AG_VIS_H; r++) { + const rowOff = r * AG_VIS_W + const screenY = AG_ROW_VIS_TOP + r + for (let c = 0; c < AG_VIS_W; c++) { + const idx = rowOff + c + const e = ag_persist[idx] + let levelIdx = (e * 5) | 0 + if (levelIdx > 4) levelIdx = 4 + if (levelIdx < 0) levelIdx = 0 + const glyph = (levelIdx === 0) ? 0x20 : ag_persistGlyph[idx] + const fg = AG_BEAM_PAL[levelIdx] + if (ag_cellGlyph[idx] === glyph && ag_cellFg[idx] === fg) continue + ag_cellGlyph[idx] = glyph + ag_cellFg[idx] = fg + ag_color(fg, AG_COL_BG) + ag_mvprn(screenY, AG_COL_INSIDE_L + c, glyph) + } + } +} + +// ── Stereo energy bar (row 31) ───────────────────────────────────────────── +// +// Same idea as playtaud.drawStereo() but driven by raw PCM: for each sample, +// pan = side/|mid| → bin index, energy = sqrt(|mid|+|side|). Gaussian-ish +// 7-cell spread so individual sample clusters read as bars, not single spikes. + +function ag_drawStereo() { + const W = AG_LANE_W + const bins = new Float32Array(W) + const N = AG_SNAPSHOT_N + + for (let i = 0; i < N; i++) { + const L = ag_snapL[i] + const R = ag_snapR[i] + const mid = (L + R) * 0.5 + const side = (L - R) * 0.5 + const absM = mid < 0 ? -mid : mid + const absS = side < 0 ? -side : side + // Pan estimate, clamped — `side/|mid|` blows up near silence so we + // floor the denominator. This is a coarse stereo image, not a + // calibrated readout. + let pan = side / (absM + 0.02) + if (pan < -1) pan = -1; else if (pan > 1) pan = 1 + const energy = Math.pow(absM + absS, 0.5) + if (energy <= 0) continue + + let col = ((pan + 1) * 0.5 * (W - 1)) | 0 + if (col < 0) col = 0; else if (col >= W) col = W - 1 + bins[col] += energy + if (col >= 3) bins[col - 3] += energy * 0.05 + if (col >= 2) bins[col - 2] += energy * 0.3 + if (col >= 1) bins[col - 1] += energy * 0.75 + if (col < W - 1) bins[col + 1] += energy * 0.75 + if (col < W - 2) bins[col + 2] += energy * 0.3 + if (col < W - 3) bins[col + 3] += energy * 0.05 + } + // Calibrated for "typical" 32 kHz × 1024-sample snapshot at modest level. + const norm = 8.0 / N + for (let i = 0; i < W; i++) { + const v = bins[i] * norm + let idx = (v * 1.6) | 0 + if (idx > 4) idx = 4 + if (idx < 0) idx = 0 + const glyph = AG_STAIRS[idx] + const fg = AG_STEREO_COL[idx] + if (ag_stereoGlyph[i] === glyph && ag_stereoFg[i] === fg) continue + ag_stereoGlyph[i] = glyph + ag_stereoFg[i] = fg + ag_color(fg, AG_COL_BG) + ag_mvprn(AG_ROW_STEREO, AG_COL_INSIDE_L + i, glyph) + } +} + +// ── Public API ───────────────────────────────────────────────────────────── +// +// audioInit({ title, tag }): paint the static frame. +// title : song title shown on row 2 (left) +// tag : 3-5 char format label embedded in the top border (e.g. "WAV", "MP2") +// +// audioFeedPcm(ptr, sampleCount): hand the visualiser a fresh slice of +// PCMu8-stereo-interleaved samples (typically the freshly decoded chunk). +// +// audioSetProgress(progress, elapsedSec, totalSec): update the title-row +// progress bar. Cheap — only redraws on change. +// +// audioRender(): repaint wavescope + visualiser + stereo bar from the latest +// snapshot. Internally rate-limited to ~20 Hz so callers can invoke +// liberally without juggling frame timing. +// +// audioClose(): restore cursor + move out of the panel for a clean exit. + +function audioInit(params) { + ag_initParams = params || {} + ag_lastRenderNs = 0 + ag_lastProgressIdx = -1 + ag_lastTimeStr = '' + for (let i = 0; i < ag_snapL.length; i++) { ag_snapL[i] = 0; ag_snapR[i] = 0 } + for (let i = 0; i < ag_persist.length; i++) ag_persist[i] = 0 + ag_persistGlyph.fill(0x20) + ag_cellGlyph.fill(-1); ag_cellFg.fill(-1) + ag_waveGlyph.fill(-1) + ag_stereoGlyph.fill(-1); ag_stereoFg.fill(-1) + + con.curs_set(0) + con.clear() + ag_drawFrame() + ag_drawTitle() +} + +function audioSetProgress(progress, elapsedSec, totalSec) { + if (progress < 0) progress = 0; else if (progress > 1) progress = 1 + ag_drawProgress(progress, elapsedSec | 0, totalSec | 0) +} + +function audioRender() { + const now = sys.nanoTime() + if (now - ag_lastRenderNs < AG_RENDER_INTERVAL_NS) return + ag_lastRenderNs = now + + ag_analyseHaar() + ag_updateXYScope() + ag_drawWavescope() + ag_drawVisualiser() + ag_drawStereo() +} + +function audioClose() { + con.move(AG_ROW_BOT_BORDER + 1, 1) + con.curs_set(1) +} + +// ── Exit polling ─────────────────────────────────────────────────────────── +// Mirror the Backspace-to-quit convention already in playtaud. + +function audioIsExitRequested() { + sys.poke(-40, 1) + return sys.peek(-41) === 67 +} + exports = { clearSubtitleArea, displaySubtitle, printTopBar, - printBottomBar + printBottomBar, + audioInit, + audioFeedPcm, + audioSetProgress, + audioRender, + audioClose, + audioIsExitRequested } \ No newline at end of file