// Common GUI for media player // Created by CuriousTorvald on 2025-09-30. // Subtitle display functions function clearSubtitleArea() { // Clear the subtitle area at the bottom of the screen // Text mode is 80x32, so clear the bottom few lines let oldFgColour = con.get_color_fore() let oldBgColour = con.get_color_back() con.color_pair(255, 255) // transparent to clear // Clear bottom 4 lines for subtitles for (let row = 28; row <= 31; row++) { con.move(row, 1) for (let col = 1; col <= 80; col++) { print(" ") } } con.color_pair(oldFgColour, oldBgColour) } function getVisualLength(line) { // Remove HTML tags and count the remaining text using unicode.strlen() const withoutTags = line.replace(/<\/?[bi]>/gi, '') return unicode.visualStrlen(withoutTags) } function displayFormattedLine(line, useUnicode) { // Parse line and handle and tags with colour changes // Default subtitle colour: yellow (231), formatted text: white (254) let i = 0 let inBoldOrItalic = false let buffer = "" // Accumulate characters for batch printing // Helper function to flush the buffer function flushBuffer() { if (buffer.length > 0) { useUnicode ? unicode.print(buffer) : print(buffer) buffer = "" } } // insert initial padding block con.color_pair(0, 255) con.prnch(0xDE) con.color_pair(231, 0) while (i < line.length) { if (i < line.length - 2 && line[i] === '<') { // Check for opening tags if (line.substring(i, i + 3).toLowerCase() === '' || line.substring(i, i + 3).toLowerCase() === '') { flushBuffer() // Flush before color change con.color_pair(254, 0) // Switch to white for formatted text inBoldOrItalic = true i += 3 } else if (i < line.length - 3 && (line.substring(i, i + 4).toLowerCase() === '' || line.substring(i, i + 4).toLowerCase() === '')) { flushBuffer() // Flush before color change con.color_pair(231, 0) // Switch back to yellow for normal text inBoldOrItalic = false i += 4 } else { // Not a formatting tag, add to buffer buffer += line[i] i++ } } else { // Regular character, add to buffer buffer += line[i] i++ } } // Flush any remaining buffered text flushBuffer() // insert final padding block con.color_pair(0, 255) con.prnch(0xDD) con.color_pair(231, 0) } function displaySubtitle(text, useUnicode = false, position = 0) { if (!text || text.length === 0) { clearSubtitleArea() return } // Set subtitle colours: yellow (231) on black (0) let oldFgColour = con.get_color_fore() let oldBgColour = con.get_color_back() con.color_pair(231, 0) // Split text into lines let lines = text.split('\n') // Calculate position based on subtitle position setting let startRow, startCol // Calculate visual length without formatting tags for positioning let longestLineLength = lines.map(s => getVisualLength(s)).sort().last() switch (position) { case 2: // center left case 6: // center right case 8: // dead center startRow = 16 - Math.floor(lines.length / 2) break case 3: // top left case 4: // top center case 5: // top right startRow = 2 break case 0: // bottom center case 1: // bottom left case 7: // bottom right default: startRow = 31 - lines.length startRow = 31 - lines.length startRow = 31 - lines.length // Default to bottom center } // Display each line for (let i = 0; i < lines.length; i++) { let line = lines[i].trim() if (line.length === 0) continue let row = startRow + i if (row < 1) row = 1 if (row > 32) row = 32 // Calculate column based on alignment switch (position) { case 1: // bottom left case 2: // center left case 3: // top left startCol = 1 break case 5: // top right case 6: // center right case 7: // bottom right startCol = Math.max(1, 78 - getVisualLength(line) - 2) break case 0: // bottom center case 4: // top center case 8: // dead center default: startCol = Math.max(1, Math.floor((80 - longestLineLength - 2) / 2) + 1) break } con.move(row, startCol) // Parse and display line with formatting tag support displayFormattedLine(line, useUnicode) } con.color_pair(oldFgColour, oldBgColour) } function emit(c) { return "\x84"+c+"u" } function formatTime(seconds) { const hours = Math.floor(seconds / 3600) const minutes = Math.floor((seconds % 3600) / 60) const secs = Math.floor(seconds % 60) return [hours, minutes, secs] .map(val => val.toString().padStart(2, '0')) .join(':') } function drawProgressBar(progress, width) { // Clamp progress between 0 and 1 progress = Math.max(0, Math.min(1, progress)); // Calculate position in "half-character" resolution const position = progress * width * 2; const charIndex = Math.floor(position / 2); const isRightHalf = (position % 2) >= 1; let bar = ''; for (let i = 0; i < width; i++) { if (i == charIndex) { bar += isRightHalf ? '\xDE' : '\xDD'; } else { bar += '\xC4'; } } return bar; } /* status = { videoRate: int, frameCount: int, totalFrames: int, fps: int, frameMode: String, qY: int, qCo: int, qCg: int, akku: float, fileName: String, fileOrd: int, currentStatus: int (0: stop/init, 1: play, 2: pause), resolution: string, colourSpace: string } */ function printBottomBar(status) { con.color_pair(253, 0) con.move(32, 1) const fullTimeInSec = status.totalFrames / status.fps const progress = status.frameCount / (status.totalFrames - 1) const elapsed = progress * fullTimeInSec const remaining = (1 - progress) * fullTimeInSec const BAR = '\xB3' const statIcon = [emit(0xFE), emit(0x10), emit(0x13)] let sLeft = `${emit(0x1E)}${status.fileOrd}${emit(0x1F)}${BAR}${statIcon[status.currentStatus]} ` let sRate = `${BAR}${(''+((status.videoRate/128)|0)).padStart(6, ' ')}` let timeElapsed = formatTime(elapsed) let timeRemaining = formatTime(remaining) let barWidth = 80 - (sLeft.length - 8 - ((status.currentStatus == 0) ? 1 : 0) + timeElapsed.length + timeRemaining.length + sRate.length) - 2 let bar = drawProgressBar(progress, barWidth) let s = sLeft + timeElapsed + ' ' + bar + ' ' + timeRemaining + sRate print(s);con.addch(0x4B) con.move(1, 1) } function printTopBar(status, moreInfo) { con.color_pair(253, 0) con.move(1) const BAR = '\xB3' if (moreInfo) { let filename = status.fileName.split("\\").pop() let sF = `F ${(''+status.frameCount).padStart((''+status.totalFrames).length, ' ')}${status.frameMode}/${status.totalFrames}` let sQ = `Q${(''+status.qY).padStart(4,' ')},${(''+status.qCo).padStart(2,' ')},${(''+status.qCg).padStart(2,' ')}` let sFPS = `${(status.frameCount / status.akku).toFixed(2)}f` let sRes = `${status.resolution}` let sCol = `${status.colourSpace}` let sLeft = sF + BAR + sQ + BAR + sFPS + BAR + sRes + BAR + sCol + BAR let filenameSpace = 80 - sLeft.length if (filename.length > filenameSpace) { filename = filename.slice(0, filenameSpace - 1) + '~' } let remainingSpc = filenameSpace - status.fileName.length let sRight = (remainingSpc > 0) ? ' '.repeat(filenameSpace - status.fileName.length + 3) : '' print(sLeft + filename + sRight) } else { let s = status.fileName if (s.length > 80) { s = s.slice(0, 79) + '~' } let spcs = 80 - s.length let spcsLeft = (spcs / 2)|0 let spcsRight = spcs - spcsLeft print(' '.repeat(spcsLeft)) print(s) print(' '.repeat(spcsRight)) } 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 // 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) // Sub-500 Hz residual — drops out of the wavelet modulator set on purpose, // but we keep its RMS around to drive the bass mark. let ag_bassEnergy = 0 // 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) let ag_lastBassFg = -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 } // Residual approximation in ag_workMid[0..len-1] holds the sub-500 Hz // energy that the modulator pipeline intentionally discards. Reuse it // to drive the bass mark. let bassSumSq = 0 for (let i = 0; i < len; i++) { const v = ag_workMid[i] bassSumSq += v * v } const bassRms = Math.sqrt(bassSumSq / len) * 1.8 ag_bassEnergy = bassRms > 1 ? 1 : bassRms } // ── Mini-AAlib (embedded, for the wavescope) ─────────────────────────────── // // Stripped port of `disk0/hopper/include/aa.mjs`, sized to one job: convert a // small pixel-space brightness buffer into ASCII glyphs with three monochrome // intensities (DIM / NORMAL / BOLD). No dither. No brightness / contrast / // gamma / inversion. No REVERSE / SPECIAL / BOLDFONT attribute support. // See aa.mjs for the full algorithm, credits (Jan Hubicka & the AA-group, // 1997), and the long-form comments — those are not duplicated here. // // Tables (params + 65536-entry LUT + filltable) are built once on first use // from the TSVM 7×14 font ROM, so the wavescope's glyph-selection matches the // brightness profile of the cells the hardware text mode actually paints. const AA_FONT_PATH = "A:/tvdos/tsvm.chr" const AA_NORMAL = 0 const AA_DIM = 1 const AA_BOLD = 2 const AA_NATTRS = 3 const AA_NCHARS = 256 * AA_NATTRS const AA_DIMMUL = 5.3 const AA_BOLDMUL = 2.7 const AA_MUL = 8 const AA_VAL = 13 // uniform-cell threshold const AA_PRIORITY = [4, 5, 3] // NORMAL, DIM, BOLD (matches aalib) let aa_font = null // { width, height, data } let aa_params = null // Uint16Array((NCHARS+1)*5) let aa_table = null // Uint16Array(65536) let aa_filltable = null // Uint16Array(256) function aa_loadFont() { if (aa_font) return aa_font const fh = files.open(AA_FONT_PATH) if (!fh.exists) throw Error("playgui: font ROM not found: " + AA_FONT_PATH) const blob = fh.bread() const FW = 7, FH = 14, ROM = 1920 if (blob.length !== ROM && blob.length !== ROM * 2) { throw Error("playgui: bad font ROM size " + blob.length) } const data = new Uint8Array(256 * FW * FH) const halves = blob.length / ROM const startHalf = (halves === 2) ? 0 : 1 for (let h = 0; h < halves; h++) { const romStart = h * ROM const charBase = (startHalf + h) * 128 for (let c = 0; c < 128; c++) { const srcBase = romStart + c * FH const dstBase = (charBase + c) * FW * FH for (let r = 0; r < FH; r++) { const b = blob[srcBase + r] & 0xFF for (let x = 0; x < FW; x++) { data[dstBase + r * FW + x] = ((b >> (6 - x)) & 1) ? 0xFF : 0x00 } } } } aa_font = { width: FW, height: FH, data: data } return aa_font } function aa_alowed(i) { const c = i & 0xff const attr = (i >>> 8) if (attr >= AA_NATTRS) return false // printable ASCII, space, or extended (>160) — keep AA_EIGHT chars so the // glyph palette includes the TSVM ROM's box-drawing / shade / dot range. if (!(c >= 33 && c <= 126) && c !== 0x20 && !(c > 160)) return false return true } // (NE, NW, SE, SW) brightness for glyph `code` under `attr`. Quadrant labelling // follows aalib's bit-numbering quirk; the LUT lookup later swaps the halves // back to natural orientation. See aa.mjs:_glyphValues for the long-form note. function aa_glyphValues(code, attr, out) { const fd = aa_font.data const fw = aa_font.width const fh = aa_font.height const base = code * fw * fh const halfW = fw >> 1 const halfH = fh >> 1 const leftW = halfW const topH = halfH let v1 = 0, v2 = 0, v3 = 0, v4 = 0 for (let r = 0; r < topH; r++) { const rowBase = base + r * fw for (let x = 0; x < leftW; x++) if (fd[rowBase + x]) v2++ for (let x = leftW; x < fw; x++) if (fd[rowBase + x]) v1++ } for (let r = topH; r < fh; r++) { const rowBase = base + r * fw for (let x = 0; x < leftW; x++) if (fd[rowBase + x]) v4++ for (let x = leftW; x < fw; x++) if (fd[rowBase + x]) v3++ } v1 *= AA_MUL; v2 *= AA_MUL; v3 *= AA_MUL; v4 *= AA_MUL if (attr === AA_DIM) { v1 = (v1 + 1) / AA_DIMMUL v2 = (v2 + 1) / AA_DIMMUL v3 = (v3 + 1) / AA_DIMMUL v4 = (v4 + 1) / AA_DIMMUL } else if (attr === AA_BOLD) { v1 *= AA_BOLDMUL v2 *= AA_BOLDMUL v3 *= AA_BOLDMUL v4 *= AA_BOLDMUL } out[0] = v1; out[1] = v2; out[2] = v3; out[3] = v4 } function aa_calcparams() { aa_loadFont() aa_params = new Uint16Array((AA_NCHARS + 1) * 5) const tmp = new Float64Array(4) let ma1 = 0, ma2 = 0, ma3 = 0, ma4 = 0, msum = 0 let mi1 = 50000, mi2 = 50000, mi3 = 50000, mi4 = 50000, misum = 50000 for (let i = 0; i < AA_NCHARS; i++) { if (!aa_alowed(i)) continue aa_glyphValues(i & 0xff, i >>> 8, tmp) const v1 = tmp[0], v2 = tmp[1], v3 = tmp[2], v4 = tmp[3] if (v1 > ma1) ma1 = v1 if (v2 > ma2) ma2 = v2 if (v3 > ma3) ma3 = v3 if (v4 > ma4) ma4 = v4 const s = v1 + v2 + v3 + v4 if (s > msum) msum = s if (v1 < mi1) mi1 = v1 if (v2 < mi2) mi2 = v2 if (v3 < mi3) mi3 = v3 if (v4 < mi4) mi4 = v4 if (s < misum) misum = s } msum -= misum mi1 = misum / 4; mi2 = misum / 4; mi3 = misum / 4; mi4 = misum / 4 ma1 = msum / 4; ma2 = msum / 4; ma3 = msum / 4; ma4 = msum / 4 for (let i = 0; i < AA_NCHARS; i++) { aa_glyphValues(i & 0xff, i >>> 8, tmp) const v1r = tmp[0], v2r = tmp[1], v3r = tmp[2], v4r = tmp[3] const sr = v1r + v2r + v3r + v4r let sum = Math.floor((sr - misum) * (1020 / msum) + 0.5) let v1 = Math.floor((v1r - mi1) * (255 / ma1) + 0.5) let v2 = Math.floor((v2r - mi2) * (255 / ma2) + 0.5) let v3 = Math.floor((v3r - mi3) * (255 / ma3) + 0.5) let v4 = Math.floor((v4r - mi4) * (255 / ma4) + 0.5) if (v1 > 255) v1 = 255; else if (v1 < 0) v1 = 0 if (v2 > 255) v2 = 255; else if (v2 < 0) v2 = 0 if (v3 > 255) v3 = 255; else if (v3 < 0) v3 = 0 if (v4 > 255) v4 = 255; else if (v4 < 0) v4 = 0 if (sum > 1020) sum = 1020; else if (sum < 0) sum = 0 aa_params[i * 5 + 0] = v1 aa_params[i * 5 + 1] = v2 aa_params[i * 5 + 2] = v3 aa_params[i * 5 + 3] = v4 aa_params[i * 5 + 4] = sum } } function aa_pow2(x) { return x * x } function aa_pos(i1, i2, i3, i4) { return (i1 << 12) + (i2 << 8) + (i3 << 4) + i4 } function aa_dist(i1, i2, i3, i4, i5, y1, y2, y3, y4, y5) { return 2 * (aa_pow2(i1 - y1) + aa_pow2(i2 - y2) + aa_pow2(i3 - y3) + aa_pow2(i4 - y4)) + aa_pow2(i5 - y5) } function aa_dist1(i1, i2, i3, i4, i5, y1, y2, y3, y4, y5) { return aa_pow2(i1 - y1) + aa_pow2(i2 - y2) + aa_pow2(i3 - y3) + aa_pow2(i4 - y4) + 2 * aa_pow2(i5 - y5) } function aa_mktable() { if (!aa_params) aa_calcparams() aa_table = new Uint16Array(65536) aa_filltable = new Uint16Array(256) const next = new Int32Array(65536) for (let i = 0; i < 65536; i++) next[i] = i let first = -1, last = -1 function add(i) { if (next[i] === i && last !== i) { if (last !== -1) { next[last] = i; last = i } else { last = first = i } } } for (let i = 0; i < AA_NCHARS; i++) { if (!aa_alowed(i)) continue const i1 = aa_params[i * 5 + 0] const i2 = aa_params[i * 5 + 1] const i3 = aa_params[i * 5 + 2] const i4 = aa_params[i * 5 + 3] const i5 = aa_params[i * 5 + 4] const p1 = i1 >> 4, p2 = i2 >> 4, p3 = i3 >> 4, p4 = i4 >> 4 const p = aa_pos(p1, p2, p3, p4) if (aa_table[p]) { const ex = aa_table[p] const ex1 = aa_params[ex * 5 + 0] const ex2 = aa_params[ex * 5 + 1] const ex3 = aa_params[ex * 5 + 2] const ex4 = aa_params[ex * 5 + 3] const ex5 = aa_params[ex * 5 + 4] const pp1 = (p1 << 4) | p1 const pp2 = (p2 << 4) | p2 const pp3 = (p3 << 4) | p3 const pp4 = (p4 << 4) | p4 const ppsum = pp1 + pp2 + pp3 + pp4 const dNew = aa_dist(i1, i2, i3, i4, i5, pp1, pp2, pp3, pp4, ppsum) const dOld = aa_dist(ex1, ex2, ex3, ex4, ex5, pp1, pp2, pp3, pp4, ppsum) if (dNew > dOld) continue if (dNew === dOld && AA_PRIORITY[(i >>> 8)] <= AA_PRIORITY[(ex >>> 8)]) continue } aa_table[p] = i add(p) } for (let q = 0; q < 256; q++) { let mindist = Infinity let best = 0 for (let i = 0; i < AA_NCHARS; i++) { if (!aa_alowed(i)) continue const d1 = aa_dist1(aa_params[i * 5 + 0], aa_params[i * 5 + 1], aa_params[i * 5 + 2], aa_params[i * 5 + 3], aa_params[i * 5 + 4], q, q, q, q, q * 4) if (d1 < mindist || (d1 === mindist && AA_PRIORITY[(i >>> 8)] > AA_PRIORITY[(best >>> 8)])) { aa_filltable[q] = i mindist = d1 best = i } } } // BFS propagation: claim neighbour slots that we cover better than whoever // got there first. Lifted verbatim from aamktabl.c via aa.mjs. while (true) { if (last !== -1) next[last] = last else break const blocked = last let i = first if (i === -1) break first = -1; last = -1 let prev do { const m0 = (i >> 12) & 15 const m1 = (i >> 8) & 15 const m2 = (i >> 4) & 15 const m3 = i & 15 const c = aa_table[i] const cp0 = aa_params[c * 5 + 0] const cp1 = aa_params[c * 5 + 1] const cp2 = aa_params[c * 5 + 2] const cp3 = aa_params[c * 5 + 3] const cp4 = aa_params[c * 5 + 4] for (let dm = 0; dm < 4; dm++) { for (let sgn = -1; sgn <= 1; sgn += 2) { let n0 = m0, n1 = m1, n2 = m2, n3 = m3 if (dm === 0) { n0 += sgn; if (n0 < 0 || n0 >= 16) continue } else if (dm === 1) { n1 += sgn; if (n1 < 0 || n1 >= 16) continue } else if (dm === 2) { n2 += sgn; if (n2 < 0 || n2 >= 16) continue } else { n3 += sgn; if (n3 < 0 || n3 >= 16) continue } const index = aa_pos(n0, n1, n2, n3) const ch = aa_table[index] if (ch === c || index === blocked) continue let replace = !ch if (!replace) { const ii1 = (n0 << 4) | n0 const ii2 = (n1 << 4) | n1 const ii3 = (n2 << 4) | n2 const ii4 = (n3 << 4) | n3 const iisum = ii1 + ii2 + ii3 + ii4 const dNew = aa_dist(ii1, ii2, ii3, ii4, iisum, cp0, cp1, cp2, cp3, cp4) const dOld = aa_dist(ii1, ii2, ii3, ii4, iisum, aa_params[ch * 5 + 0], aa_params[ch * 5 + 1], aa_params[ch * 5 + 2], aa_params[ch * 5 + 3], aa_params[ch * 5 + 4]) if (dNew < dOld) replace = true } if (replace) { aa_table[index] = c; add(index) } } } prev = i i = next[i] next[prev] = prev } while (i !== prev) } } // Render an imgW × imgH brightness buffer (imgW = scrW*2, imgH = scrH*2) into // per-cell (glyph, attr) outputs. No dither, no params. function aa_render(img, scrW, scrH, tbOut, attrOut) { if (!aa_table) aa_mktable() const tbl = aa_table const fill = aa_filltable const wi = scrW * 2 for (let y = 0; y < scrH; y++) { let pos = 2 * y * wi let pos1 = y * scrW for (let x = 0; x < scrW; x++) { const i1 = img[pos + 1] // NE const i2 = img[pos] // NW const i3 = img[pos + wi + 1] // SE const i4 = img[pos + wi] // SW const s = i1 + i2 + i3 + i4 const avg = s >> 2 let val if (Math.abs(i1 - avg) < AA_VAL && Math.abs(i2 - avg) < AA_VAL && Math.abs(i3 - avg) < AA_VAL && Math.abs(i4 - avg) < AA_VAL) { val = fill[avg] } else { val = tbl[((i2 >> 4) << 12) | ((i1 >> 4) << 8) | ((i4 >> 4) << 4) | (i3 >> 4)] } attrOut[pos1] = val >> 8 tbOut[pos1] = val & 0xff pos += 2 pos1 += 1 } } } // ── Wavescope (rows 3..5) ────────────────────────────────────────────────── // // Peak-detected envelope plotted into a 156×6 pixel buffer (2× cell res), // then converted to ASCII glyphs by the mini-AAlib above. Mid-signal only — // stereo info lives on the bottom bar. // // Three monochrome intensities pick out the wave's body / peaks: DIM cells // are the dim trace, NORMAL cells are the bulk of the waveform, BOLD cells // land on the brightest patches (full-blocked peaks). Amber → white ramp // mimics phosphor bloom. const AA_WAVE_W = AG_LANE_W // 78 cells const AA_WAVE_H = AG_ROW_WAVE_BOT - AG_ROW_WAVE_TOP + 1 // 3 cells const AA_WAVE_IW = AA_WAVE_W * 2 // 156 px const AA_WAVE_IH = AA_WAVE_H * 2 // 6 px const ag_waveImg = new Uint8Array(AA_WAVE_IW * AA_WAVE_IH) const ag_waveTb = new Uint8Array(AA_WAVE_W * AA_WAVE_H) const ag_waveAttr = new Uint8Array(AA_WAVE_W * AA_WAVE_H) // AA_NORMAL=0, AA_DIM=1, AA_BOLD=2 → amber phosphor palette. const AG_WAVE_FG = [166, 130, AG_COL_LABEL] function ag_drawWavescope() { const N = AG_SNAPSHOT_N const IW = AA_WAVE_IW const IH = AA_WAVE_IH const img = ag_waveImg img.fill(0) // Per-pixel-column envelope: vertical line from max to min sample value. const samplesPerCol = N / IW const yScale = (IH - 1) * 0.5 for (let c = 0; c < IW; 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 } // [-1, 1] → [0, IH-1]; +1 sits at the top, -1 at the bottom. let yT = ((1 - mx) * yScale + 0.5) | 0 let yB = ((1 - mn) * yScale + 0.5) | 0 if (yT < 0) yT = 0; else if (yT > IH - 1) yT = IH - 1 if (yB < 0) yB = 0; else if (yB > IH - 1) yB = IH - 1 for (let y = yT; y <= yB; y++) img[y * IW + c] = 0xFF } aa_render(img, AA_WAVE_W, AA_WAVE_H, ag_waveTb, ag_waveAttr) // Blit, skipping cells whose packed (attr<<8 | glyph) key is unchanged. for (let r = 0; r < AA_WAVE_H; r++) { for (let c = 0; c < AA_WAVE_W; c++) { const idx = r * AA_WAVE_W + c const att = ag_waveAttr[idx] const ch = ag_waveTb[idx] const key = (att << 8) | ch if (ag_waveGlyph[idx] === key) continue ag_waveGlyph[idx] = key ag_color(AG_WAVE_FG[att] || AG_COL_LABEL, AG_COL_BG) ag_mvprn(AG_ROW_WAVE_TOP + r, AG_COL_INSIDE_L + c, ch) } } } // ── 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 // Bass mark: 2×2 cell indicator pinned to the centre of the vectorscope so // the bass "subwoofer" sits underneath the beam's pivot point. Half-blocks // form a compact 16×16-pixel "dot" centred in the 16×32-pixel 2×2 area. const AG_BASS_VIS_R0 = AG_XY_CY - 1 const AG_BASS_VIS_C0 = AG_XY_CX - 1 const AG_BASS_VIS_R1 = AG_BASS_VIS_R0 + 1 const AG_BASS_VIS_C1 = AG_BASS_VIS_C0 + 1 const AG_BASS_SCR_R = AG_ROW_VIS_TOP + AG_BASS_VIS_R0 const AG_BASS_SCR_C = AG_COL_INSIDE_L + AG_BASS_VIS_C0 // 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 const inBassRow = (r === AG_BASS_VIS_R0 || r === AG_BASS_VIS_R1) for (let c = 0; c < AG_VIS_W; c++) { // Bass mark owns its 2×2 cells — let ag_drawBassMark() paint them. if (inBassRow && (c === AG_BASS_VIS_C0 || c === AG_BASS_VIS_C1)) continue 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) } } } // ── Bass mark (rows 29-30, cols 2-3) ─────────────────────────────────────── // Brightness-only indicator driven by the sub-500 Hz residual of the Haar // pyramid. Uses indices 1..4 of the beam palette so the dot never falls all // the way to background — a quiet track still shows a faint amber ember. function ag_drawBassMark() { let idx = (ag_bassEnergy * 4) | 0 if (idx > 3) idx = 3 if (idx < 0) idx = 0 const fg = AG_BEAM_PAL[idx + 1] if (fg === ag_lastBassFg) return ag_lastBassFg = fg ag_color(fg, AG_COL_BG) ag_mvprn(AG_BASS_SCR_R, AG_BASS_SCR_C, 0xDC) ag_mvprn(AG_BASS_SCR_R, AG_BASS_SCR_C + 1, 0xDC) ag_mvprn(AG_BASS_SCR_R + 1, AG_BASS_SCR_C, 0xDF) ag_mvprn(AG_BASS_SCR_R + 1, AG_BASS_SCR_C + 1, 0xDF) } // ── 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) ag_bassEnergy = 0 ag_lastBassFg = -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_drawBassMark() 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, audioInit, audioFeedPcm, audioSetProgress, audioRender, audioClose, audioIsExitRequested }