mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
new visualiser for pcm
This commit is contained in:
@@ -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 <file.mp2> [-i]
|
||||||
|
|
||||||
|
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||||
if (!SND_BASE_ADDR) return 10
|
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 MP2_CHANNELMODES = ["Stereo", "Joint", "Dual", "Mono"]
|
||||||
|
|
||||||
const pcm = require("pcm")
|
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) }
|
function printdbg(s) { if (0) serial.println(s) }
|
||||||
|
|
||||||
|
|
||||||
class SequentialFileBuffer {
|
class SequentialFileBuffer {
|
||||||
|
|
||||||
constructor(path, offset, length) {
|
constructor(path, offset, length) {
|
||||||
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
||||||
|
|
||||||
this.path = path
|
this.path = path
|
||||||
this.file = files.open(path)
|
this.file = files.open(path)
|
||||||
|
|
||||||
this.offset = offset || 0
|
this.offset = offset || 0
|
||||||
this.originalOffset = offset
|
this.originalOffset = offset
|
||||||
this.length = length || this.file.size
|
this.length = length || this.file.size
|
||||||
|
|
||||||
this.seq = require("seqread")
|
this.seq = require("seqread")
|
||||||
this.seq.prepare(path)
|
this.seq.prepare(path)
|
||||||
}
|
}
|
||||||
|
readBytes(size, ptr) { return this.seq.readBytes(size, ptr) }
|
||||||
readBytes(size, ptr) {
|
get fileHeader() { return this.seq.fileHeader }
|
||||||
return this.seq.readBytes(size, ptr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
readStr(n) {
|
const filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||||
let ptr = this.seq.readBytes(n)
|
const FILE_SIZE = filebuf.length
|
||||||
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()
|
|
||||||
}*/
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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 FRAME_SIZE = audio.mp2GetInitialFrameSize(filebuf.fileHeader)
|
||||||
const MEDIA_BITRATE = MP2_BITRATES[filebuf.fileHeader[2] >>> 4]
|
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
|
let decodedLength = 0
|
||||||
|
|
||||||
|
const bufRealTimeLen = 36 // one MP2 frame at 32 kHz ≈ 36 ms
|
||||||
//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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
audio.resetParams(0)
|
audio.resetParams(0)
|
||||||
audio.purgeQueue(0)
|
audio.purgeQueue(0)
|
||||||
audio.setPcmMode(0)
|
audio.setPcmMode(0)
|
||||||
audio.setPcmQueueCapacityIndex(0, 2) // queue size is now 8
|
audio.setPcmQueueCapacityIndex(0, 2)
|
||||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
||||||
audio.setMasterVolume(0, 255)
|
audio.setMasterVolume(0, 255)
|
||||||
audio.play(0)
|
audio.play(0)
|
||||||
|
|
||||||
|
|
||||||
//let mp2context = audio.mp2Init()
|
|
||||||
audio.mp2Init()
|
audio.mp2Init()
|
||||||
|
|
||||||
// decode frame
|
function bytesToSec(i) { return i / (FRAME_SIZE * 1000 / bufRealTimeLen) }
|
||||||
let t1 = sys.nanoTime()
|
|
||||||
let bufRealTimeLen = 36
|
if (interactive) {
|
||||||
|
const tag = "MP2"
|
||||||
|
const title = `${filebuf.file.name} ${MEDIA_CHANNEL} ${MEDIA_BITRATE}kbps`
|
||||||
|
gui.audioInit({ title, tag })
|
||||||
|
}
|
||||||
|
|
||||||
let stopPlay = false
|
let stopPlay = false
|
||||||
let errorlevel = 0
|
let errorlevel = 0
|
||||||
try {
|
try {
|
||||||
while (bytes_left > 0 && !stopPlay) {
|
while (bytes_left > 0 && !stopPlay) {
|
||||||
|
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
if (interactive) {
|
|
||||||
sys.poke(-40, 1)
|
|
||||||
if (sys.peek(-41) == 67) {
|
|
||||||
stopPlay = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
printPlayBar()
|
|
||||||
|
|
||||||
|
|
||||||
filebuf.readBytes(FRAME_SIZE, SND_BASE_ADDR - 2368)
|
filebuf.readBytes(FRAME_SIZE, SND_BASE_ADDR - 2368)
|
||||||
audio.mp2Decode()
|
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) {
|
if (audio.getPosition(0) >= QUEUE_MAX) {
|
||||||
while (audio.getPosition(0) >= (QUEUE_MAX >>> 1)) {
|
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)
|
sys.sleep(bufRealTimeLen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
audio.mp2UploadDecoded(0)
|
audio.mp2UploadDecoded(0)
|
||||||
|
|
||||||
|
if (interactive) {
|
||||||
|
gui.audioSetProgress(decodedLength / FILE_SIZE,
|
||||||
|
bytesToSec(decodedLength), bytesToSec(FILE_SIZE))
|
||||||
|
gui.audioRender()
|
||||||
|
}
|
||||||
sys.sleep(10)
|
sys.sleep(10)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
bytes_left -= FRAME_SIZE
|
bytes_left -= FRAME_SIZE
|
||||||
decodedLength += FRAME_SIZE
|
decodedLength += FRAME_SIZE
|
||||||
}
|
}
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
printerrln(e)
|
printerrln(e)
|
||||||
errorlevel = 1
|
errorlevel = 1
|
||||||
|
} finally {
|
||||||
|
if (interactive) {
|
||||||
|
if (mp2VisScratch) sys.free(mp2VisScratch)
|
||||||
|
gui.audioClose()
|
||||||
}
|
}
|
||||||
finally {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorlevel
|
return errorlevel
|
||||||
@@ -1,196 +1,81 @@
|
|||||||
// usage: playpcm audiofile.pcm [/i]
|
// playpcm — raw PCMu8 stereo player with the shared playgui visualiser.
|
||||||
let fileeeee = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
// Usage: playpcm <file.pcm> [-i]
|
||||||
let filename = fileeeee.fullPath
|
|
||||||
function printdbg(s) { if (0) serial.println(s) }
|
|
||||||
|
|
||||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
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 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 seqread = require("seqread")
|
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 BLOCK_SIZE = 4096
|
||||||
let INFILE_BLOCK_SIZE = BLOCK_SIZE
|
const INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||||
const QUEUE_MAX = 8 // according to the spec
|
const QUEUE_MAX = 8
|
||||||
|
|
||||||
let nChannels = 2
|
const samplingRate = pcm.HW_SAMPLING_RATE
|
||||||
let samplingRate = pcm.HW_SAMPLING_RATE;
|
const byterate = 2 * samplingRate
|
||||||
let blockSize = 2;
|
|
||||||
let bitsPerSample = 8;
|
|
||||||
let byterate = 2*samplingRate;
|
|
||||||
let comments = {};
|
|
||||||
let readPtr = undefined
|
|
||||||
let decodePtr = undefined
|
|
||||||
|
|
||||||
function bytesToSec(i) {
|
function bytesToSec(i) { return i / byterate }
|
||||||
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)
|
|
||||||
|
|
||||||
|
seqread.prepare(filePath)
|
||||||
|
|
||||||
|
const readPtr = sys.malloc(BLOCK_SIZE)
|
||||||
audio.resetParams(0)
|
audio.resetParams(0)
|
||||||
audio.purgeQueue(0)
|
audio.purgeQueue(0)
|
||||||
audio.setPcmMode(0)
|
audio.setPcmMode(0)
|
||||||
audio.setMasterVolume(0, 255)
|
audio.setMasterVolume(0, 255)
|
||||||
|
|
||||||
let readLength = 1
|
|
||||||
|
|
||||||
function printPlayBar() {
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
let currently = seqread.getReadCount()
|
gui.audioInit({
|
||||||
let total = FILE_SIZE
|
title: `${fileHandle.name} Raw PCM 32kHz Stereo`,
|
||||||
|
tag: "PCM"
|
||||||
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 stopPlay = false
|
||||||
let errorlevel = 0
|
let errorlevel = 0
|
||||||
|
let readLength = 1
|
||||||
try {
|
try {
|
||||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
||||||
if (interactive) {
|
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
sys.poke(-40, 1)
|
|
||||||
if (sys.peek(-41) == 67) {
|
|
||||||
stopPlay = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const queueSize = audio.getPosition(0)
|
||||||
let queueSize = audio.getPosition(0)
|
|
||||||
if (queueSize <= 1) {
|
if (queueSize <= 1) {
|
||||||
|
|
||||||
printPlayBar()
|
|
||||||
|
|
||||||
// upload four samples for lag-safely
|
|
||||||
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
||||||
let remainingBytes = FILE_SIZE - seqread.getReadCount()
|
const remainingBytes = FILE_SIZE - seqread.getReadCount()
|
||||||
|
|
||||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||||
if (readLength <= 0) {
|
if (readLength <= 0) break
|
||||||
printdbg(`readLength = ${readLength}`)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
printdbg(`offset: ${seqread.getReadCount()}/${FILE_SIZE}; readLength: ${readLength}`)
|
|
||||||
|
|
||||||
seqread.readBytes(readLength, readPtr)
|
seqread.readBytes(readLength, readPtr)
|
||||||
|
|
||||||
|
// Raw PCMu8 stereo — sampleCount = bytes / 2.
|
||||||
|
if (interactive) gui.audioFeedPcm(readPtr, readLength >> 1)
|
||||||
|
|
||||||
audio.putPcmDataByPtr(0, readPtr, readLength, 0)
|
audio.putPcmDataByPtr(0, readPtr, readLength, 0)
|
||||||
audio.setSampleUploadLength(0, readLength)
|
audio.setSampleUploadLength(0, readLength)
|
||||||
audio.startSampleUpload(0)
|
audio.startSampleUpload(0)
|
||||||
|
|
||||||
|
|
||||||
if (repeat > 1) sys.sleep(10)
|
if (repeat > 1) sys.sleep(10)
|
||||||
|
|
||||||
printPlayBar()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
audio.play(0)
|
audio.play(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
let remainingBytes = FILE_SIZE - seqread.getReadCount()
|
if (interactive) {
|
||||||
printdbg(`readLength = ${readLength}; remainingBytes2 = ${remainingBytes}; seqread.getReadCount() = ${seqread.getReadCount()};`)
|
const cur = seqread.getReadCount()
|
||||||
|
gui.audioSetProgress(cur / FILE_SIZE, bytesToSec(cur), bytesToSec(FILE_SIZE))
|
||||||
|
gui.audioRender()
|
||||||
|
}
|
||||||
sys.sleep(10)
|
sys.sleep(10)
|
||||||
}
|
}
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
printerrln(e)
|
printerrln(e)
|
||||||
errorlevel = 1
|
errorlevel = 1
|
||||||
}
|
} finally {
|
||||||
finally {
|
|
||||||
//audio.stop(0)
|
|
||||||
if (readPtr !== undefined) sys.free(readPtr)
|
if (readPtr !== undefined) sys.free(readPtr)
|
||||||
if (decodePtr !== undefined) sys.free(decodePtr)
|
if (interactive) gui.audioClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorlevel
|
return errorlevel
|
||||||
|
|
||||||
|
|||||||
@@ -1,114 +1,66 @@
|
|||||||
|
// playtad — TAD (TSVM Advanced Audio) player with the shared playgui visualiser.
|
||||||
|
// Usage: playtad <file.tad> [-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_BASE_ADDR = audio.getBaseAddr()
|
||||||
const SND_MEM_ADDR = audio.getMemAddr()
|
const SND_MEM_ADDR = audio.getMemAddr()
|
||||||
// tadInputBin lives at audio-local offset 917504 and tadDecodedBin at 983040
|
// tadInputBin at offset 917504, tadDecodedBin at 983040. Both addressed via
|
||||||
// (post-bef85f6 memory map; the old 262144 offset now hits the enlarged sampleBin).
|
// negative pointers — peripheral memory grows toward 0.
|
||||||
const TAD_INPUT_ADDR = SND_MEM_ADDR - 917504 // TAD input buffer (matches TAV packet 0x24)
|
const TAD_INPUT_ADDR = SND_MEM_ADDR - 917504
|
||||||
const TAD_DECODED_ADDR = SND_MEM_ADDR - 983040 // TAD decoded buffer
|
const TAD_DECODED_ADDR = SND_MEM_ADDR - 983040
|
||||||
|
|
||||||
if (!SND_BASE_ADDR) return 10
|
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") {
|
||||||
if (!exec_args[1] || exec_args[1] == "-h" || exec_args[1] == "--help") {
|
serial.println("Usage: playtad <file.tad> [-i | -d]")
|
||||||
serial.println("Usage: playtad <file.tad> [-i | -d] [quality]")
|
serial.println(" -i Interactive mode (visualiser + progress bar)")
|
||||||
serial.println(" -i Interactive mode (progress bar, press Backspace to exit)")
|
serial.println(" -d Dump first three chunks for debugging")
|
||||||
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")
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
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 dumpCoeffs = exec_args[2] && exec_args[2].toLowerCase() === "-d"
|
||||||
const dumpCoeffs = exec_args[2] && exec_args[2].toLowerCase() == "-d"
|
const gui = interactive ? require("playgui") : null
|
||||||
|
|
||||||
function printdbg(s) { if (0) serial.println(s) }
|
|
||||||
|
|
||||||
|
|
||||||
class SequentialFileBuffer {
|
class SequentialFileBuffer {
|
||||||
|
constructor(path) {
|
||||||
constructor(path, offset, length) {
|
|
||||||
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
||||||
|
|
||||||
this.path = path
|
this.path = path
|
||||||
this.file = files.open(path)
|
this.file = files.open(path)
|
||||||
|
this.length = this.file.size
|
||||||
this.offset = offset || 0
|
|
||||||
this.originalOffset = offset
|
|
||||||
this.length = length || this.file.size
|
|
||||||
|
|
||||||
this.seq = require("seqread")
|
this.seq = require("seqread")
|
||||||
this.seq.prepare(path)
|
this.seq.prepare(path)
|
||||||
}
|
}
|
||||||
|
readBytes(size, ptr) { return this.seq.readBytes(size, ptr) }
|
||||||
readBytes(size, ptr) {
|
|
||||||
return this.seq.readBytes(size, ptr)
|
|
||||||
}
|
|
||||||
|
|
||||||
readByte() {
|
readByte() {
|
||||||
let ptr = this.seq.readBytes(1)
|
const ptr = this.seq.readBytes(1)
|
||||||
let val = sys.peek(ptr)
|
const val = sys.peek(ptr)
|
||||||
sys.free(ptr)
|
sys.free(ptr)
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
readShort() {
|
readShort() {
|
||||||
let ptr = this.seq.readBytes(2)
|
const ptr = this.seq.readBytes(2)
|
||||||
let val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8)
|
const val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8)
|
||||||
sys.free(ptr)
|
sys.free(ptr)
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
readInt() {
|
readInt() {
|
||||||
let ptr = this.seq.readBytes(4)
|
const 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 val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8) | (sys.peek(ptr + 2) << 16) | (sys.peek(ptr + 3) << 24)
|
||||||
sys.free(ptr)
|
sys.free(ptr)
|
||||||
return val
|
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) {
|
unread(diff) {
|
||||||
let newSkipLen = this.seq.getReadCount() - diff
|
const newSkipLen = this.seq.getReadCount() - diff
|
||||||
this.seq.prepare(this.path)
|
this.seq.prepare(this.path)
|
||||||
this.seq.skip(newSkipLen)
|
this.seq.skip(newSkipLen)
|
||||||
}
|
}
|
||||||
|
rewind() { this.seq.prepare(this.path) }
|
||||||
rewind() {
|
getReadCount() { return this.seq.getReadCount() }
|
||||||
this.seq.prepare(this.path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
seek(p) {
|
const filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Read TAD chunk header to determine format
|
|
||||||
let filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
|
||||||
const FILE_SIZE = filebuf.length
|
const FILE_SIZE = filebuf.length
|
||||||
|
|
||||||
if (FILE_SIZE < 7) {
|
if (FILE_SIZE < 7) {
|
||||||
@@ -116,12 +68,12 @@ if (FILE_SIZE < 7) {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read first chunk header (standalone TAD format: no TAV wrapper)
|
// Peek the first chunk header so we know the chunk size for the rough bytes-
|
||||||
let firstSampleCount = filebuf.readShort()
|
// to-seconds conversion shown in the progress bar.
|
||||||
let firstMaxIndex = filebuf.readByte()
|
const firstSampleCount = filebuf.readShort()
|
||||||
let firstPayloadSize = filebuf.readInt()
|
const firstMaxIndex = filebuf.readByte()
|
||||||
|
const firstPayloadSize = filebuf.readInt()
|
||||||
|
|
||||||
// Validate first chunk
|
|
||||||
if (firstSampleCount < 0 || firstSampleCount > 65536) {
|
if (firstSampleCount < 0 || firstSampleCount > 65536) {
|
||||||
serial.println(`ERROR: Invalid sample count ${firstSampleCount}. File may be corrupted.`)
|
serial.println(`ERROR: Invalid sample count ${firstSampleCount}. File may be corrupted.`)
|
||||||
return 1
|
return 1
|
||||||
@@ -135,148 +87,68 @@ if (firstPayloadSize < 1 || firstPayloadSize > 65536) {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rewind to start
|
|
||||||
filebuf.rewind()
|
filebuf.rewind()
|
||||||
|
|
||||||
// Calculate approximate frame info
|
const AVG_CHUNK_SIZE = 7 + firstPayloadSize
|
||||||
const AVG_CHUNK_SIZE = 7 + firstPayloadSize // TAD header (2+1+4) + payload
|
|
||||||
const SAMPLE_RATE = 32000
|
const SAMPLE_RATE = 32000
|
||||||
const bufRealTimeLen = Math.floor((firstSampleCount / SAMPLE_RATE) * 1000) // milliseconds per chunk
|
const bufRealTimeLen = Math.floor((firstSampleCount / SAMPLE_RATE) * 1000)
|
||||||
|
|
||||||
if (dumpCoeffs) {
|
if (dumpCoeffs) {
|
||||||
serial.println(`TAD Coefficient Dump Mode`)
|
serial.println(`TAD Coefficient Dump Mode`)
|
||||||
serial.println(`File: ${filebuf.file.name}`)
|
serial.println(`File: ${filebuf.file.name}`)
|
||||||
serial.println(`First chunk header:`)
|
serial.println(`First chunk: ${firstSampleCount} samples, Q${firstMaxIndex}, ${firstPayloadSize} bytes payload`)
|
||||||
serial.println(` Sample Count: ${firstSampleCount}`)
|
|
||||||
serial.println(` Max Index: ${firstMaxIndex}`)
|
|
||||||
serial.println(` Payload Size: ${firstPayloadSize} bytes`)
|
|
||||||
serial.println(`Chunk Duration: ${bufRealTimeLen} ms`)
|
serial.println(`Chunk Duration: ${bufRealTimeLen} ms`)
|
||||||
serial.println(``)
|
serial.println(``)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let bytes_left = FILE_SIZE
|
let bytes_left = FILE_SIZE
|
||||||
let decodedLength = 0
|
let decodedLength = 0
|
||||||
let chunkNumber = 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
|
|
||||||
|
|
||||||
function bytesToSec(i) {
|
function bytesToSec(i) {
|
||||||
// Approximate: use first chunk's ratio
|
|
||||||
return Math.round((i / FILE_SIZE) * (FILE_SIZE / AVG_CHUNK_SIZE) * (bufRealTimeLen / 1000))
|
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.resetParams(0)
|
||||||
audio.purgeQueue(0)
|
audio.purgeQueue(0)
|
||||||
audio.setPcmMode(0)
|
audio.setPcmMode(0)
|
||||||
audio.setPcmQueueCapacityIndex(0, 2) // queue size is now 8
|
audio.setPcmQueueCapacityIndex(0, 2)
|
||||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
||||||
audio.setMasterVolume(0, 255)
|
audio.setMasterVolume(0, 255)
|
||||||
audio.play(0)
|
audio.play(0)
|
||||||
|
|
||||||
|
if (interactive) {
|
||||||
|
gui.audioInit({
|
||||||
|
title: `${filebuf.file.name} TAD Q${firstMaxIndex} ${SAMPLE_RATE/1000}kHz`,
|
||||||
|
tag: "TAD"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let stopPlay = false
|
let stopPlay = false
|
||||||
let errorlevel = 0
|
let errorlevel = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (bytes_left > 0 && !stopPlay) {
|
while (bytes_left > 0 && !stopPlay) {
|
||||||
|
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
|
|
||||||
if (interactive) {
|
const sampleCount = filebuf.readShort()
|
||||||
sys.poke(-40, 1)
|
const maxIndex = filebuf.readByte()
|
||||||
if (sys.peek(-41) == 67) { // Backspace key
|
const payloadSize = filebuf.readInt()
|
||||||
stopPlay = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
if (sampleCount < 0 || sampleCount > 65536) {
|
||||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid sample count ${sampleCount}. File may be corrupted.`)
|
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid sample count ${sampleCount}.`)
|
||||||
errorlevel = 1
|
errorlevel = 1; break
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if (maxIndex < 0 || maxIndex > 255) {
|
if (maxIndex < 0 || maxIndex > 255) {
|
||||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid max index ${maxIndex}. File may be corrupted.`)
|
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid max index ${maxIndex}.`)
|
||||||
errorlevel = 1
|
errorlevel = 1; break
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if (payloadSize < 1 || payloadSize > 65536) {
|
if (payloadSize < 1 || payloadSize > 65536) {
|
||||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid payload size ${payloadSize}. File may be corrupted.`)
|
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid payload size ${payloadSize}.`)
|
||||||
errorlevel = 1
|
errorlevel = 1; break
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if (payloadSize + 7 > bytes_left) {
|
if (payloadSize + 7 > bytes_left) {
|
||||||
serial.println(`ERROR: Chunk ${chunkNumber}: Chunk size ${payloadSize + 7} exceeds remaining file size ${bytes_left}`)
|
serial.println(`ERROR: Chunk ${chunkNumber}: Chunk size exceeds remaining file size.`)
|
||||||
errorlevel = 1
|
errorlevel = 1; break
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dumpCoeffs && chunkNumber < 3) {
|
if (dumpCoeffs && chunkNumber < 3) {
|
||||||
@@ -284,80 +156,59 @@ try {
|
|||||||
serial.println(` Sample Count: ${sampleCount}`)
|
serial.println(` Sample Count: ${sampleCount}`)
|
||||||
serial.println(` Max Index: ${maxIndex}`)
|
serial.println(` Max Index: ${maxIndex}`)
|
||||||
serial.println(` Payload Size: ${payloadSize} bytes`)
|
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
|
// Read entire chunk (header + payload) into TAD input buffer.
|
||||||
// This allows reading the complete chunk (header + payload) in one call
|
|
||||||
filebuf.unread(7)
|
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()
|
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)
|
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) {
|
if (!dumpCoeffs) {
|
||||||
// Sleep for the duration of the audio chunk to pace playback
|
// TAD chunks are typically 1 s long, so feeding the visualiser
|
||||||
// This prevents uploading everything at once
|
// once would freeze it for ~1 s. Walk the chunk in 2048-sample
|
||||||
sys.sleep(bufRealTimeLen)
|
// 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
|
const chunkSize = 7 + payloadSize
|
||||||
let chunkSize = 7 + payloadSize
|
|
||||||
bytes_left -= chunkSize
|
bytes_left -= chunkSize
|
||||||
decodedLength += chunkSize
|
decodedLength += chunkSize
|
||||||
chunkNumber++
|
chunkNumber++
|
||||||
|
|
||||||
// Limit coefficient dump to first 3 chunks
|
|
||||||
if (dumpCoeffs && chunkNumber >= 3) {
|
if (dumpCoeffs && chunkNumber >= 3) {
|
||||||
serial.println(`... (remaining chunks omitted)`)
|
serial.println(`... (remaining chunks omitted)`)
|
||||||
// Keep playing but don't dump more
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
printerrln(e)
|
printerrln(e)
|
||||||
errorlevel = 1
|
errorlevel = 1
|
||||||
}
|
} finally {
|
||||||
finally {
|
if (interactive) gui.audioClose()
|
||||||
if (interactive) {
|
|
||||||
con.move(cy + 3, 1)
|
|
||||||
con.curs_set(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorlevel
|
return errorlevel
|
||||||
|
|||||||
@@ -512,7 +512,6 @@ function drawFrame() {
|
|||||||
colour(COL_LABEL, COL_BG)
|
colour(COL_LABEL, COL_BG)
|
||||||
mvtext(ROW_TOP_BORDER, 4, ' TAUD ')
|
mvtext(ROW_TOP_BORDER, 4, ' TAUD ')
|
||||||
colour(COL_DIM, COL_BG)
|
colour(COL_DIM, COL_BG)
|
||||||
mvtext(ROW_TOP_BORDER, COLS - 7, ' v0.1 ')
|
|
||||||
|
|
||||||
// Bottom border + exit hint.
|
// Bottom border + exit hint.
|
||||||
colour(COL_BORDER, COL_BG)
|
colour(COL_BORDER, COL_BG)
|
||||||
@@ -725,7 +724,7 @@ function spawnEventsForRow(cueIdx, rowIdx) {
|
|||||||
note: note, pan: pan,
|
note: note, pan: pan,
|
||||||
ageFrames: 0,
|
ageFrames: 0,
|
||||||
peakVol: 0,
|
peakVol: 0,
|
||||||
glyphSeed: (cueIdx * 64 + rowIdx + v * 13) & 0xFFFF
|
glyphSeed: (cueIdx * 64 + rowIdx + v * 1280) & 0xFFFF
|
||||||
}
|
}
|
||||||
voiceLastNote[v] = note
|
voiceLastNote[v] = note
|
||||||
voiceLastInst[v] = effInst
|
voiceLastInst[v] = effInst
|
||||||
|
|||||||
@@ -1,255 +1,134 @@
|
|||||||
// usage: playwav audiofile.wav [/i]
|
// playwav — WAV (LPCM/ADPCM) player with the shared playgui visualiser.
|
||||||
let fileeeee = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
// Usage: playwav <file.wav> [-i]
|
||||||
let filename = fileeeee.fullPath
|
|
||||||
function printdbg(s) { if (0) serial.println(s) }
|
const fileHandle = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||||
|
const filePath = fileHandle.fullPath
|
||||||
|
|
||||||
const WAV_FORMATS = ["LPCM", "ADPCM"]
|
const WAV_FORMATS = ["LPCM", "ADPCM"]
|
||||||
const WAV_CHANNELS = ["Mono", "Stereo", "3ch", "Quad", "4.1", "5.1", "6.1", "7.1"]
|
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 interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||||
|
|
||||||
const seqread = require("seqread")
|
const seqread = require("seqread")
|
||||||
const pcm = require("pcm")
|
const pcm = require("pcm")
|
||||||
|
const gui = interactive ? require("playgui") : null
|
||||||
|
|
||||||
|
function printdbg(s) { if (0) serial.println(s) }
|
||||||
|
|
||||||
function printComments() {
|
|
||||||
for (const [key, value] of Object.entries(comments)) {
|
|
||||||
printdbg(`Wave Comment ${key}: ${value}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function GCD(a, b) {
|
function GCD(a, b) {
|
||||||
a = Math.abs(a)
|
a = Math.abs(a); b = Math.abs(b)
|
||||||
b = Math.abs(b)
|
if (b > a) { const t = a; a = b; b = t }
|
||||||
if (b > a) {var temp = a; a = b; b = temp}
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (b == 0) return a
|
if (b === 0) return a
|
||||||
a %= b
|
a %= b
|
||||||
if (a == 0) return b
|
if (a === 0) return b
|
||||||
b %= a
|
b %= a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function LCM(a, b) { return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b)) }
|
||||||
|
|
||||||
function LCM(a, b) {
|
seqread.prepare(filePath)
|
||||||
return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b))
|
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")
|
||||||
|
|
||||||
|
|
||||||
//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")
|
|
||||||
}
|
|
||||||
|
|
||||||
let BLOCK_SIZE = 0
|
let BLOCK_SIZE = 0
|
||||||
let INFILE_BLOCK_SIZE = 0
|
let INFILE_BLOCK_SIZE = 0
|
||||||
const QUEUE_MAX = 8 // according to the spec
|
const QUEUE_MAX = 8
|
||||||
|
|
||||||
let pcmType;
|
let pcmType, nChannels, samplingRate, blockSize, bitsPerSample, byterate
|
||||||
let nChannels;
|
let adpcmSamplesPerBlock
|
||||||
let samplingRate;
|
let readPtr, decodePtr
|
||||||
let blockSize;
|
const comments = {}
|
||||||
let bitsPerSample;
|
|
||||||
let byterate;
|
|
||||||
let comments = {};
|
|
||||||
let adpcmSamplesPerBlock;
|
|
||||||
let readPtr = undefined
|
|
||||||
let decodePtr = undefined
|
|
||||||
|
|
||||||
function bytesToSec(i) {
|
function bytesToSec(i) {
|
||||||
if (adpcmSamplesPerBlock) {
|
if (adpcmSamplesPerBlock) {
|
||||||
let newByteRate = samplingRate
|
const generatedSamples = i / blockSize * adpcmSamplesPerBlock
|
||||||
let generatedSamples = i / blockSize * adpcmSamplesPerBlock
|
return generatedSamples / samplingRate
|
||||||
return generatedSamples / newByteRate
|
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
return i / byterate
|
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() {
|
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 (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 "playable!"
|
||||||
}
|
}
|
||||||
// @return decoded sample length (not count!)
|
|
||||||
function decodeInfilePcm(inPtr, outPtr, inputLen) {
|
function decodeInfilePcm(inPtr, outPtr, inputLen) {
|
||||||
// LPCM
|
if (pcmType === 1)
|
||||||
if (1 == pcmType)
|
|
||||||
return pcm.decodeLPCM(inPtr, outPtr, inputLen, { nChannels, bitsPerSample, samplingRate, blockSize })
|
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 })
|
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
|
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
|
let errorlevel = 0
|
||||||
// read chunks loop
|
|
||||||
try {
|
try {
|
||||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE - 8) {
|
while (!stopPlay && seqread.getReadCount() < FILE_SIZE - 8) {
|
||||||
let chunkName = seqread.readFourCC()
|
const chunkName = seqread.readFourCC()
|
||||||
let chunkSize = seqread.readInt()
|
const chunkSize = seqread.readInt()
|
||||||
printdbg(`Reading '${chunkName}' at ${seqread.getReadCount() - 8}`)
|
printdbg(`Reading '${chunkName}' at ${seqread.getReadCount() - 8}`)
|
||||||
|
|
||||||
// here be lotsa if-else
|
if (chunkName === "fmt ") {
|
||||||
if ("fmt " == chunkName) {
|
|
||||||
pcmType = seqread.readShort()
|
pcmType = seqread.readShort()
|
||||||
nChannels = seqread.readShort()
|
nChannels = seqread.readShort()
|
||||||
samplingRate = seqread.readInt()
|
samplingRate = seqread.readInt()
|
||||||
byterate = seqread.readInt()
|
byterate = seqread.readInt()
|
||||||
blockSize = seqread.readShort()
|
blockSize = seqread.readShort()
|
||||||
bitsPerSample = seqread.readShort()
|
bitsPerSample = seqread.readShort()
|
||||||
if (pcmType != 2) {
|
if (pcmType !== 2) {
|
||||||
seqread.skip(chunkSize - 16)
|
seqread.skip(chunkSize - 16)
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
seqread.skip(2)
|
seqread.skip(2)
|
||||||
adpcmSamplesPerBlock = seqread.readShort()
|
adpcmSamplesPerBlock = seqread.readShort()
|
||||||
seqread.skip(chunkSize - (16 + 4))
|
seqread.skip(chunkSize - (16 + 4))
|
||||||
}
|
}
|
||||||
|
|
||||||
// define BLOCK_SIZE as integer multiple of blockSize, for LPCM
|
if (pcmType === 1) {
|
||||||
// ADPCM will be decoded per-block basis
|
const incr = LCM(blockSize, samplingRate / GCD(samplingRate, pcm.HW_SAMPLING_RATE))
|
||||||
if (1 == pcmType) {
|
while (BLOCK_SIZE < 4096) BLOCK_SIZE += incr
|
||||||
// get GCD of given values; this wll make resampling headache-free
|
INFILE_BLOCK_SIZE = BLOCK_SIZE * bitsPerSample / 8
|
||||||
let blockSizeIncrement = LCM(blockSize, samplingRate / GCD(samplingRate, pcm.HW_SAMPLING_RATE))
|
} else if (pcmType === 2) {
|
||||||
|
|
||||||
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
|
BLOCK_SIZE = blockSize
|
||||||
INFILE_BLOCK_SIZE = BLOCK_SIZE
|
INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||||
}
|
}
|
||||||
|
|
||||||
printdbg(`Format: ${pcmType}, Channels: ${nChannels}, Rate: ${samplingRate}, BitDepth: ${bitsPerSample}`)
|
if (interactive) {
|
||||||
printdbg(`BLOCK_SIZE=${BLOCK_SIZE}, INFILE_BLOCK_SIZE=${INFILE_BLOCK_SIZE}`)
|
const tag = "WAV"
|
||||||
printPlayerShell()
|
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 ("LIST" == chunkName) {
|
}
|
||||||
let startOffset = seqread.getReadCount()
|
else if (chunkName === "LIST") {
|
||||||
let subChunkName = seqread.readFourCC()
|
const startOffset = seqread.getReadCount()
|
||||||
|
const subChunkName = seqread.readFourCC()
|
||||||
while (seqread.getReadCount() < startOffset + chunkSize) {
|
while (seqread.getReadCount() < startOffset + chunkSize) {
|
||||||
if ("INFO" == subChunkName) {
|
if (subChunkName === "INFO") {
|
||||||
let key = seqread.readFourCC()
|
let key = seqread.readFourCC()
|
||||||
let valueLen = seqread.readInt()
|
let valueLen = seqread.readInt()
|
||||||
|
while (key.charCodeAt(0) === 0) {
|
||||||
// f-you WAVE encoders with nonstandard behaviours
|
const kbytes = [key.charCodeAt(1), key.charCodeAt(2), key.charCodeAt(3), valueLen & 255]
|
||||||
// related: https://stackoverflow.com/questions/49537639/riff-icmt-tag-size-doesnt-seem-to-match-data
|
const klen = [(valueLen >>> 8) & 255, (valueLen >>> 16) & 255, (valueLen >>> 24) & 255, seqread.readOneByte()]
|
||||||
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)
|
key = String.fromCharCode.apply(null, kbytes)
|
||||||
valueLen = klen[0] | (klen[1] << 8) | (klen[2] << 16) | (klen[3] << 24)
|
valueLen = klen[0] | (klen[1] << 8) | (klen[2] << 16) | (klen[3] << 24)
|
||||||
}
|
}
|
||||||
|
comments[key] = seqread.readString(valueLen)
|
||||||
printdbg(`Reading LIST INFO ${key}[${[0,1,2,3].map((i)=>"0x"+key.charCodeAt(i).toString(16).padStart(2,'0'))}] (${valueLen} bytes): `)
|
} else {
|
||||||
|
|
||||||
|
|
||||||
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())
|
seqread.skip(startOffset + chunkSize - seqread.getReadCount())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
printComments()
|
|
||||||
}
|
}
|
||||||
else if ("data" == chunkName) {
|
else if (chunkName === "data") {
|
||||||
let startOffset = seqread.getReadCount()
|
const startOffset = seqread.getReadCount()
|
||||||
|
const reason = checkIfPlayable()
|
||||||
printdbg(`WAVE size: ${chunkSize}, startOffset=${startOffset}`)
|
if (reason !== "playable!") throw Error("WAVE not playable: " + reason)
|
||||||
// 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)
|
|
||||||
|
|
||||||
|
readPtr = sys.malloc(pcmType === 2 ? BLOCK_SIZE : BLOCK_SIZE * bitsPerSample / 8)
|
||||||
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
||||||
|
|
||||||
audio.resetParams(0)
|
audio.resetParams(0)
|
||||||
@@ -259,35 +138,20 @@ while (!stopPlay && seqread.getReadCount() < FILE_SIZE - 8) {
|
|||||||
|
|
||||||
let readLength = 1
|
let readLength = 1
|
||||||
while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) {
|
while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) {
|
||||||
if (interactive) {
|
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
sys.poke(-40, 1)
|
|
||||||
if (sys.peek(-41) == 67) {
|
|
||||||
stopPlay = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
printPlayBar(startOffset)
|
if (audio.getPosition(0) <= 1) {
|
||||||
|
|
||||||
let queueSize = audio.getPosition(0)
|
|
||||||
if (queueSize <= 1) {
|
|
||||||
|
|
||||||
|
|
||||||
// upload four samples for lag-safely
|
|
||||||
for (let repeat = 0; repeat < QUEUE_MAX; repeat++) {
|
for (let repeat = 0; repeat < QUEUE_MAX; repeat++) {
|
||||||
let remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
const remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
||||||
|
|
||||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||||
if (readLength <= 0) {
|
if (readLength <= 0) break
|
||||||
printdbg(`readLength = ${readLength}`)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
printdbg(`offset: ${seqread.getReadCount()}/${FILE_SIZE + 8}; readLength: ${readLength}`)
|
|
||||||
|
|
||||||
seqread.readBytes(readLength, readPtr)
|
seqread.readBytes(readLength, readPtr)
|
||||||
|
const decodedSampleLength = decodeInfilePcm(readPtr, decodePtr, readLength)
|
||||||
|
|
||||||
let decodedSampleLength = decodeInfilePcm(readPtr, decodePtr, readLength)
|
// Hand the decoded PCMu8 stereo block to the visualiser
|
||||||
printdbg(` decodedSampleLength: ${decodedSampleLength}`)
|
// before queueing — the buffer is reused next iteration.
|
||||||
|
if (interactive) gui.audioFeedPcm(decodePtr, decodedSampleLength >> 1)
|
||||||
|
|
||||||
audio.putPcmDataByPtr(0, decodePtr, decodedSampleLength, 0)
|
audio.putPcmDataByPtr(0, decodePtr, decodedSampleLength, 0)
|
||||||
audio.setSampleUploadLength(0, decodedSampleLength)
|
audio.setSampleUploadLength(0, decodedSampleLength)
|
||||||
@@ -295,14 +159,15 @@ while (!stopPlay && seqread.getReadCount() < FILE_SIZE - 8) {
|
|||||||
|
|
||||||
sys.spin()
|
sys.spin()
|
||||||
}
|
}
|
||||||
|
|
||||||
audio.play(0)
|
audio.play(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
let remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
if (interactive) {
|
||||||
printdbg(`readLength = ${readLength}; remainingBytes2 = ${remainingBytes}; seqread.getReadCount() = ${seqread.getReadCount()}; startOffset + chunkSize = ${startOffset + chunkSize}`)
|
const cur = seqread.getReadCount() - startOffset
|
||||||
|
const tot = FILE_SIZE - startOffset - 8
|
||||||
|
gui.audioSetProgress(cur / tot, bytesToSec(cur), bytesToSec(tot))
|
||||||
|
gui.audioRender()
|
||||||
|
}
|
||||||
sys.sleep(10)
|
sys.sleep(10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,20 +175,15 @@ while (!stopPlay && seqread.getReadCount() < FILE_SIZE - 8) {
|
|||||||
seqread.skip(chunkSize)
|
seqread.skip(chunkSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
|
||||||
printdbg(`remainingBytes2 = ${remainingBytes}`)
|
|
||||||
sys.spin()
|
sys.spin()
|
||||||
}
|
}
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
printerrln(e)
|
printerrln(e)
|
||||||
errorlevel = 1
|
errorlevel = 1
|
||||||
}
|
} finally {
|
||||||
finally {
|
|
||||||
//audio.stop(0)
|
|
||||||
if (readPtr !== undefined) sys.free(readPtr)
|
if (readPtr !== undefined) sys.free(readPtr)
|
||||||
if (decodePtr !== undefined) sys.free(decodePtr)
|
if (decodePtr !== undefined) sys.free(decodePtr)
|
||||||
|
if (interactive) gui.audioClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorlevel
|
return errorlevel
|
||||||
|
|||||||
@@ -281,9 +281,611 @@ function printTopBar(status, moreInfo) {
|
|||||||
con.move(1, 1)
|
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 = {
|
exports = {
|
||||||
clearSubtitleArea,
|
clearSubtitleArea,
|
||||||
displaySubtitle,
|
displaySubtitle,
|
||||||
printTopBar,
|
printTopBar,
|
||||||
printBottomBar
|
printBottomBar,
|
||||||
|
audioInit,
|
||||||
|
audioFeedPcm,
|
||||||
|
audioSetProgress,
|
||||||
|
audioRender,
|
||||||
|
audioClose,
|
||||||
|
audioIsExitRequested
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user