Files
tsvm/assets/disk0/tvdos/bin/playtaud.js
2026-05-22 12:35:02 +09:00

1056 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// playtaud — Taud music player + visualiser for TVDOS.
//
// "An industrial control panel for impossible music."
// See ../taud_music_player_with_visualiser.md for the design spec.
const taud = require('taud')
// ── Format constants ────────────────────────────────────────────────────────
const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64]
const TAUD_HEADER_SIZE = 32
const TAUD_SONG_ENTRY = 32
const PATTERN_SIZE = 512
const ROWS_PER_PAT = 64
const NUM_CUES = 1024
const CUE_SIZE = 32
const NUM_VOICES = 20
const CUE_EMPTY = 0xFFF
// Cue instruction bytes (cue offset 30).
const CUE_NOP = 0x00
const CUE_HALT = 0x01
const CUE_LEN = 0x02
const CUE_BAK = 0x80
const CUE_FWD = 0x90
const CUE_JMP = 0xF0
// Pattern cell sentinels.
const NOTE_NOP = 0x0000
const NOTE_KEYOFF = 0x0001
const NOTE_CUT = 0x0002
// Instrument archetypes.
const ARCH_DRUM = 0
const ARCH_BASS = 1
const ARCH_PAD = 2
const ARCH_LEAD = 3
const ARCH_METAL = 4
// ── Layout ──────────────────────────────────────────────────────────────────
// 80 cols × 32 rows. All rows/cols are 1-indexed (TVDOS con convention).
const COLS = 80
const ROWS = 32
const ROW_TOP_BORDER = 1
const ROW_TITLE = 2
const ROW_STATUS = 3
const ROW_ORDER_SEP = 4
const ROW_ORDER = 5
const ROW_TONAL_SEP = 6
const ROW_TONAL_TOP = 7
const ROW_TONAL_BOT = 27 // allow visuals to invade the top row of drums
const ROW_DRUMS_TOP = 27
const ROW_DRUMS_BOT = 29 // 3-row percussion strip, flush against canvas
const ROW_STEREO = 30
const ROW_TICK = 31
const ROW_BOT_BORDER = 32
// Inside-the-border columns are 2..79. Lane width = 78.
const COL_INSIDE_L = 2
const COL_INSIDE_R = 79
const LANE_W = 78
// BASS / PAD / LEAD / METAL all share the same continuous pitch canvas — they
// differ only in glyph and colour ramp. DRUM keeps its own atonal strip since
// percussion has no meaningful pitch.
const TONAL_LANE = { top: ROW_TONAL_TOP, bot: ROW_TONAL_BOT }
const LANE_BY_ARCH = {
[ARCH_LEAD] : TONAL_LANE,
[ARCH_METAL]: TONAL_LANE,
[ARCH_PAD] : TONAL_LANE,
[ARCH_BASS] : TONAL_LANE,
[ARCH_DRUM] : { top: ROW_DRUMS_TOP, bot: ROW_DRUMS_BOT }
}
// Pitch range pinned to the musically useful span. Notes outside clamp to the
// canvas top / bottom. C2..C9 covers what trackers actually play; full
// 0..0xFFFF would compress everything into the middle band.
const PITCH_RANGE_LO = 0x2000 // ~C2
const PITCH_RANGE_HI = 0xA000 // ~C9
// Colours — TSVM palette indices. Picked to read as amber/CRT chrome with
// archetype-coded events. Background-transparent (255) lets the cell colour
// fall through to the terminal default for ergonomic resize behaviour.
const COL_BG = 0 // solid black panel background
const COL_BORDER = 250 // light grey panel chrome
const COL_LABEL = 220 // amber panel label
const COL_DIM = 235 // muted text
const COL_TITLE = 230 // bright white-yellow song title
const COL_VALUE = 254 // bright white numeric values
const COL_TICK_LIVE = 46 // green tick light
const COL_TICK_DEAD = 22 // dim green
const COL_ORDER_PAST = 235
const COL_ORDER_CUR = 226 // bright yellow active cue
const COL_ORDER_FUT = 250
const COL_ORDER_HALT = 196 // red HALT marker
const COL_ARCH = {
[ARCH_LEAD] : [220,214,208,202], // amber→orange decay ramp
[ARCH_METAL]: [201,199,197,89 ], // bright magenta→deep
[ARCH_PAD] : [117, 75, 33, 17], // sky→deep blue
[ARCH_BASS] : [202,166,130, 94], // orange→burnt umber
[ARCH_DRUM] : [254,250,246,240] // white→grey
}
// ── Argument parsing ────────────────────────────────────────────────────────
if (!exec_args[1] || exec_args[1] === '-h' || exec_args[1] === '--help') {
println("Usage: playtaud <file.taud> [songIndex]")
println(" Plays a Taud tracker module with a text-mode visualiser.")
println(" Hold Backspace to exit.")
return 0
}
const filePath = _G.shell.resolvePathInput(exec_args[1]).full
const songArg = exec_args[2] ? (exec_args[2] | 0) : 0
// ── File parsing ────────────────────────────────────────────────────────────
//
// We parse the Taud file in JS to keep our own copies of patterns/cues for the
// visualiser to consult on every row change, then hand the heavy lifting
// (sample+inst upload, pattern upload, cue upload, playhead config) over to
// libtaud.uploadTaudFile. Reading the file twice is fine — these modules are
// small (≤1 MB compressed).
function _peekU32LE(ptr, off) {
return ((sys.peek(ptr+off) & 0xFF) ) |
((sys.peek(ptr+off+1) & 0xFF) << 8 ) |
((sys.peek(ptr+off+2) & 0xFF) << 16 ) |
((sys.peek(ptr+off+3) & 0xFF) * 0x1000000)
}
function parseTaud(path, songIndex) {
const fh = files.open(path)
if (!fh.exists) throw Error("playtaud: file not found: " + path)
const fileSize = fh.size
const ptr = sys.malloc(fileSize)
fh.pread(ptr, fileSize, 0)
fh.close()
for (let i = 0; i < 8; i++) {
if ((sys.peek(ptr + i) & 0xFF) !== TAUD_MAGIC[i]) {
sys.free(ptr)
throw Error("playtaud: bad Taud magic")
}
}
const numSongs = sys.peek(ptr + 9) & 0xFF
const compSize = _peekU32LE(ptr, 10)
const projOff = _peekU32LE(ptr, 14)
if (songIndex < 0 || songIndex >= numSongs) {
sys.free(ptr)
throw Error("playtaud: song index " + songIndex + " of " + numSongs)
}
const songTableOff = TAUD_HEADER_SIZE + compSize
const entryOff = songTableOff + songIndex * TAUD_SONG_ENTRY
const songOff = _peekU32LE(ptr, entryOff)
const numVoices = sys.peek(ptr + entryOff + 4) & 0xFF
const numPats = (sys.peek(ptr + entryOff + 5) & 0xFF) |
((sys.peek(ptr + entryOff + 6) & 0xFF) << 8)
const bpm = (sys.peek(ptr + entryOff + 7) & 0xFF) + 25
const tickRate = sys.peek(ptr + entryOff + 8) & 0xFF
const patCompSize = _peekU32LE(ptr, entryOff + 18)
const cueCompSize = _peekU32LE(ptr, entryOff + 22)
// Decompress patterns into JS arrays.
const patBinSize = numPats * PATTERN_SIZE
const patBinPtr = sys.malloc(patBinSize)
gzip.decompFromTo(ptr + songOff, patCompSize, patBinPtr)
const patterns = new Array(numPats)
for (let p = 0; p < numPats; p++) {
const buf = new Uint8Array(PATTERN_SIZE)
for (let k = 0; k < PATTERN_SIZE; k++)
buf[k] = sys.peek(patBinPtr + p * PATTERN_SIZE + k) & 0xFF
patterns[p] = buf
}
sys.free(patBinPtr)
// Decompress cue sheet. Find last non-empty cue for the order strip.
const cueSheetSize = NUM_CUES * CUE_SIZE
const cuePtr = sys.malloc(cueSheetSize)
gzip.decompFromTo(ptr + songOff + patCompSize, cueCompSize, cuePtr)
const cues = new Array(NUM_CUES)
let lastCue = -1
for (let c = 0; c < NUM_CUES; c++) {
const ptns = new Array(NUM_VOICES)
for (let i = 0; i < 10; i++) {
const lo = sys.peek(cuePtr + c * CUE_SIZE + i) & 0xFF
const mi = sys.peek(cuePtr + c * CUE_SIZE + 10 + i) & 0xFF
const hi = sys.peek(cuePtr + c * CUE_SIZE + 20 + i) & 0xFF
ptns[i*2] = ((hi >> 4) << 8) | ((mi >> 4) << 4) | (lo >> 4)
ptns[i*2+1] = ((hi & 0xF) << 8) | ((mi & 0xF) << 4) | (lo & 0xF)
}
const i30 = sys.peek(cuePtr + c * CUE_SIZE + 30) & 0xFF
const i31 = sys.peek(cuePtr + c * CUE_SIZE + 31) & 0xFF
const cue = { ptns: ptns, i30: i30, i31: i31 }
cues[c] = cue
let occupied = (i30 !== CUE_NOP)
if (!occupied) {
for (let v = 0; v < NUM_VOICES; v++) {
if (ptns[v] !== CUE_EMPTY) { occupied = true; break }
}
}
if (occupied) lastCue = c
// HALT terminates traversal — anything past it is unreachable.
if (i30 === CUE_HALT) break
}
if (lastCue < 0) lastCue = 0
// Decode an 0x1E-separated name table into a 256-slot array. Names in the
// file are slot-indexed starting at slot 0 (typically blank); trailing
// empty slots are trimmed in the source so we top up with '' to length 256.
function decodeNameTable(payload, secLen) {
const out = new Array(256)
for (let i = 0; i < 256; i++) out[i] = ''
let slot = 0
let buf = ''
for (let k = 0; k < secLen; k++) {
const b = sys.peek(ptr + payload + k) & 0xFF
if (b === 0x1E) {
if (slot < 256) out[slot] = buf
slot++; buf = ''
if (slot >= 256) break
} else {
buf += String.fromCharCode(b)
}
}
if (slot < 256) out[slot] = buf
return out
}
// Optional project data: song name, composer, instrument names, sample names.
let projName = '', songName = '', composer = ''
let instNames = new Array(256); for (let i = 0; i < 256; i++) instNames[i] = ''
let sampleNames = new Array(256); for (let i = 0; i < 256; i++) sampleNames[i] = ''
if (projOff !== 0 && projOff + 16 <= fileSize) {
const projMagic = [0x1E,0x54,0x61,0x75,0x64,0x50,0x72,0x4A]
let ok = true
for (let i = 0; i < 8; i++) {
if ((sys.peek(ptr + projOff + i) & 0xFF) !== projMagic[i]) { ok = false; break }
}
if (ok) {
let p = projOff + 16
while (p + 8 <= fileSize) {
const fc = String.fromCharCode(
sys.peek(ptr + p) & 0xFF, sys.peek(ptr + p+1) & 0xFF,
sys.peek(ptr + p+2) & 0xFF, sys.peek(ptr + p+3) & 0xFF)
const secLen = _peekU32LE(ptr, p + 4)
const payload = p + 8
if (payload + secLen > fileSize) break
if (fc === 'PNam') {
let s = ''
for (let k = 0; k < secLen; k++) {
const b = sys.peek(ptr + payload + k) & 0xFF
if (b === 0) break
s += String.fromCharCode(b)
}
projName = s
}
else if (fc === 'INam') { instNames = decodeNameTable(payload, secLen) }
else if (fc === 'SNam') { sampleNames = decodeNameTable(payload, secLen) }
else if (fc === 'sMet') {
let q = payload
const qEnd = payload + secLen
while (q + 5 <= qEnd) {
const idx = sys.peek(ptr + q) & 0xFF
const subLen = _peekU32LE(ptr, q + 1)
const subStart = q + 5
if (subStart + subLen > qEnd) break
let r = subStart + 4 // skip notation(2)+pri(1)+sec(1)
const strs = ['','','']
for (let si = 0; si < 3 && r < subStart + subLen; si++) {
let s = ''
while (r < subStart + subLen) {
const b = sys.peek(ptr + r) & 0xFF; r++
if (b === 0) break
s += String.fromCharCode(b)
}
strs[si] = s
}
if (idx === songIndex) {
songName = strs[0]
composer = strs[1]
}
q = subStart + subLen
}
}
p = payload + secLen
}
}
}
sys.free(ptr)
return {
path, songIndex, numSongs, numVoices, numPats,
bpm, tickRate,
patterns, cues, lastCue,
projName, songName, composer,
instNames, sampleNames
}
}
const song = parseTaud(filePath, songArg)
// ── Hand the file to the audio adapter ─────────────────────────────────────
audio.resetParams(0)
audio.purgeQueue(0)
taud.uploadTaudFile(filePath, songArg, 0)
// ── Instrument archetype classification ─────────────────────────────────────
//
// The visualiser needs each instrument to be classified as DRUM / BASS / PAD /
// LEAD / METAL. We can't run a full FFT in TVDOS JS at startup, but the
// archetype is determined by a small set of proxies that map cleanly off the
// instrument record + a 1024-byte sample probe:
//
// - sampleLength < 4 KiB and not looped → DRUM (one-shot percussion)
// - looped + low natural pitch → BASS
// - looped + slow attack envelope → PAD
// - high zero-crossing rate → METAL (bright / FM-like)
// - everything else → LEAD
//
// Empty instrument slots (samplePtr == 0 and sampleLength == 0) are skipped.
const SND_BASE = audio.getBaseAddr()
const SND_MEM = audio.getMemAddr()
const INST_REC_SIZE = 256
const INST_BASE_OFF = 720896 // peripheral mem offset of instrument bin
function readInstByte(slot, byteOff) {
return sys.peek(SND_MEM - (INST_BASE_OFF + slot * INST_REC_SIZE + byteOff)) & 0xFF
}
function readInstU16(slot, byteOff) {
return readInstByte(slot, byteOff) | (readInstByte(slot, byteOff + 1) << 8)
}
function readInstU32(slot, byteOff) {
return readInstByte(slot, byteOff) |
(readInstByte(slot, byteOff + 1) << 8) |
(readInstByte(slot, byteOff + 2) << 16) |
(readInstByte(slot, byteOff + 3) * 0x1000000)
}
const SAMPLE_BANK_SIZE = 524288
function probeSample(samplePtr, sampleLen) {
// Read up to PROBE_LEN bytes starting at samplePtr inside the 8 MB pool,
// bank-switching the visible window as needed. Returns {zcr, rms} where
// zcr ∈ [0,1] is the fraction of adjacent samples that change sign.
const PROBE_LEN = Math.min(sampleLen, 1024)
if (PROBE_LEN < 2) return { zcr: 0, rms: 0 }
const savedBank = audio.getSampleBank()
let prevSign = 0
let crosses = 0
let sumSq = 0
let lastBank = -1
for (let i = 0; i < PROBE_LEN; i++) {
const abs = samplePtr + i
const bank = (abs / SAMPLE_BANK_SIZE) | 0
const winOff = abs - bank * SAMPLE_BANK_SIZE
if (bank !== lastBank) {
audio.setSampleBank(bank)
lastBank = bank
}
const b = sys.peek(SND_MEM - winOff) & 0xFF
const s = b - 128
sumSq += s * s
const sign = s >= 0 ? 1 : -1
if (i > 0 && sign !== prevSign) crosses++
prevSign = sign
}
if (savedBank !== null && savedBank !== undefined && savedBank !== lastBank)
audio.setSampleBank(savedBank)
const zcr = crosses / (PROBE_LEN - 1)
const rms = Math.sqrt(sumSq / PROBE_LEN) / 128
return { zcr, rms }
}
// Keyword priors over instrument / sample names. Tested first because a name
// like "Kick 808" should override the acoustic heuristic (a looped clean kick
// sample reads like BASS to the spectral probe but is unambiguously percussion
// to a human). Order matters: drum and metal beat bass / lead so compound
// names like "kick bass" or "metal pad" are classified by the more specific
// keyword. All matches are substring tests against the lower-cased
// concatenation of instrument name + sample name.
const NAME_RULES = [
{ arch: ARCH_DRUM, words: [
'kick','snare','hat','drum','perc','clap','cymb','tom','ride',
'crash','rim','clave','cowbell','shaker','tamb','conga','bongo',
'snr','kik','bdrum','sdrum','kit','break','909','707','606',
' bd',' sd',' hh',' bd1',' bd2',' sd1',' sd2',' sn',' tst',' tsk'
]},
{ arch: ARCH_METAL, words: [
'metal','ring mod','ringmod','noise','glass','chime','clang',
'sweep','riser','blip','zap','laser','bitcrush','crush','fm ','xwav'
]},
{ arch: ARCH_BASS, words: [
'bass','sub','808','b-line','bassline','b.s.'
]},
{ arch: ARCH_PAD, words: [
'pad','string','choir','atmo','ambient','warm','wash','organ',
'vox','vocal',' voc','ahh','ohh','strg','aero',' wind','rhodes'
]},
{ arch: ARCH_LEAD, words: [
'lead','solo','saw','pulse','synth','piano',' pno','guitar',
' gtr','horn','brass',' sax','trumpet','flute','pluck','melody',
'square','triangle'
]}
]
function classifyByName(nameStr) {
if (!nameStr) return null
// Pad with leading space so " bd" / " gtr" word-edge matchers fire when
// the abbreviation starts the name.
const t = ' ' + nameStr.toLowerCase() + ' '
for (let i = 0; i < NAME_RULES.length; i++) {
const rule = NAME_RULES[i]
for (let j = 0; j < rule.words.length; j++) {
if (t.indexOf(rule.words[j]) >= 0) return rule.arch
}
}
return null
}
function classifyByAcoustic(slot, samplePtr, sampleLen, c4Rate) {
const flags = readInstByte(slot, 14)
const loopMode = flags & 0x03 // 0 = no loop, 1 = forward, 2 = pingpong, 3 = oneshot
const looped = (loopMode === 1 || loopMode === 2)
// Walk the volume envelope. Each point: u16 value (low byte 0..63) +
// u16 offset (only low byte's minifloat used for timing). Coarse attack
// estimate = number of envelope nodes before the peak.
let peakVal = 0, peakIdx = 0
for (let i = 0; i < 25; i++) {
const v = readInstByte(slot, 21 + i * 2) & 0x3F
if (v > peakVal) { peakVal = v; peakIdx = i }
}
const attackSlow = peakIdx >= 3
const attackFast = peakIdx === 0
const { zcr, rms } = probeSample(samplePtr, sampleLen)
if (sampleLen < 4096 && !looped) return ARCH_DRUM
if (zcr > 0.30 && rms > 0.10) return ARCH_METAL
if (c4Rate > 0 && c4Rate < 4000 && rms > 0.05) return ARCH_BASS
if (looped && attackSlow) return ARCH_PAD
if (looped && attackFast && peakVal >= 40) return ARCH_LEAD
if (!looped && sampleLen >= 4096) return ARCH_LEAD
return ARCH_LEAD
}
function classifyInstrument(slot) {
const samplePtr = readInstU32(slot, 0)
const sampleLen = readInstU16(slot, 4)
const c4Rate = readInstU16(slot, 6)
if (sampleLen === 0) return null // empty slot
// Name-based prior first — a kick called "Kick" is a kick even if its
// envelope/spectrum could read as something else. Falls back to the
// acoustic heuristic when name has no keyword hits.
const nameHit = classifyByName(song.instNames[slot] + ' ' + song.sampleNames[slot])
if (nameHit !== null) return nameHit
return classifyByAcoustic(slot, samplePtr, sampleLen, c4Rate)
}
const archByInst = new Uint8Array(256) // 0 = drum by default; we mask with a presence array
const instPresent = new Uint8Array(256)
for (let slot = 1; slot < 256; slot++) {
const arch = classifyInstrument(slot)
if (arch !== null) {
archByInst[slot] = arch
instPresent[slot] = 1
}
}
audio.setSampleBank(0) // restore the bank window to bank 0 after probing
// ── Console setup ───────────────────────────────────────────────────────────
con.curs_set(0)
con.clear()
function mvprn(row, col, ch) { con.mvaddch(row, col, ch) }
function mvtext(row, col, s) { con.move(row, col); print(s) }
function colour(fg, bg) { con.color_pair(fg, bg) }
// Box-drawing constants (CP437).
const BX_TL = 0xC9, BX_TR = 0xBB, BX_BL = 0xC8, BX_BR = 0xBC // ╔ ╗ ╚ ╝
const BX_V = 0xBA, BX_H = 0xCD // ║ ═
const SEP_L = 0xC7, SEP_R = 0xB6 // ╟ ╢ — T into double-bar side, single dashes between
function drawSeparator(row, label) {
// ╟── LABEL ─── ... ─╢ across the full width. Side T-pieces overwrite the
// ║ side bars at columns 1 / COLS on the separator row.
colour(COL_BORDER, COL_BG)
mvprn(row, 1, SEP_L)
for (let x = 2; x < COLS; x++) mvprn(row, x, BX_H)
mvprn(row, COLS, SEP_R)
if (label) {
colour(COL_LABEL, COL_BG)
mvtext(row, 5, ' ' + label + ' ')
}
}
function drawFrame() {
colour(COL_BORDER, COL_BG)
// Top border with embedded "TAUD" label.
mvprn(ROW_TOP_BORDER, 1, BX_TL)
for (let x = 2; x < COLS; x++) mvprn(ROW_TOP_BORDER, x, BX_H)
mvprn(ROW_TOP_BORDER, COLS, BX_TR)
colour(COL_LABEL, COL_BG)
mvtext(ROW_TOP_BORDER, 4, ' TAUD ')
colour(COL_DIM, COL_BG)
mvtext(ROW_TOP_BORDER, COLS - 7, ' v0.1 ')
// Bottom border + exit hint.
colour(COL_BORDER, COL_BG)
mvprn(ROW_BOT_BORDER, 1, BX_BL)
for (let x = 2; x < COLS; x++) mvprn(ROW_BOT_BORDER, x, BX_H)
mvprn(ROW_BOT_BORDER, COLS, BX_BR)
colour(COL_DIM, COL_BG)
mvtext(ROW_BOT_BORDER, 4, ' Hold BkSp to exit ')
// Side bars.
colour(COL_BORDER, COL_BG)
for (let r = 2; r < ROWS; r++) {
mvprn(r, 1, BX_V)
mvprn(r, COLS, BX_V)
}
// Internal separators. No DRUMS separator — the percussion strip sits
// flush against the pitch canvas bottom (drums are visually distinct via
// their scatter glyphs and need no chrome to be readable).
drawSeparator(ROW_ORDER_SEP, "ORDER")
drawSeparator(ROW_TONAL_SEP, "VISUALS")
}
function clearInside(row) {
colour(COL_DIM, COL_BG)
con.move(row, COL_INSIDE_L)
print(' '.repeat(LANE_W))
}
function drawTitle() {
clearInside(ROW_TITLE)
let title = song.songName || (song.projName || song.path.split('/').pop())
if (title.length > 60) title = title.substring(0, 57) + '...'
colour(COL_TITLE, COL_BG)
mvtext(ROW_TITLE, COL_INSIDE_L + 1, title)
if (song.composer) {
const composerStr = 'by ' + song.composer
const x = COL_INSIDE_R - composerStr.length
if (x > COL_INSIDE_L + title.length + 2) {
colour(COL_DIM, COL_BG)
mvtext(ROW_TITLE, x, composerStr)
}
}
}
function pad(n, w) {
let s = '' + n
while (s.length < w) s = ' ' + s
return s
}
let lastStatus = ''
function drawStatus(curCue) {
const bpm = audio.getBPM(0) || song.bpm
const tick = audio.getTickRate(0) || song.tickRate
const cueStr = pad(curCue, 3) + '/' + pad(song.lastCue, 3)
const s = 'BPM ' + pad(bpm,3) + ' Tick ' + pad(tick,2) +
' Voices ' + pad(song.numVoices,2) + ' Cue ' + cueStr
if (s === lastStatus) return
lastStatus = s
clearInside(ROW_STATUS)
colour(COL_VALUE, COL_BG)
mvtext(ROW_STATUS, COL_INSIDE_L + 1, s)
// Progress dashes on the right side of the status row.
const total = song.lastCue + 1
const frac = total > 1 ? curCue / (total - 1) : 0
const barW = 22
const bx0 = COL_INSIDE_R - barW
colour(COL_DIM, COL_BG)
for (let i = 0; i < barW; i++) {
const filled = i < Math.round(frac * barW)
colour(filled ? COL_ORDER_CUR : COL_DIM, COL_BG)
mvprn(ROW_STATUS, bx0 + i, filled ? 0x7C /*│*/ : 0x2E /*.*/)
}
}
// ── Order strip ─────────────────────────────────────────────────────────────
// Each cue gets one column. When the song is short enough to fit (≤ LANE_W
// cues), we show the lot; otherwise we centre a window around the current cue
// so it never moves off-screen.
let orderState = { lastCue: -2, lastLeft: -1 }
function drawOrderStrip(curCue) {
const total = song.lastCue + 1
let left = 0
if (total <= LANE_W) {
left = 0
} else {
// Centre window on curCue.
left = curCue - (LANE_W >> 1)
if (left < 0) left = 0
if (left + LANE_W > total) left = total - LANE_W
}
if (curCue === orderState.lastCue && left === orderState.lastLeft) return
orderState.lastCue = curCue
orderState.lastLeft = left
clearInside(ROW_ORDER)
for (let i = 0; i < LANE_W; i++) {
const c = left + i
if (c >= total) break
const cue = song.cues[c]
let ch = 0x7C // │ default future
let fg = COL_ORDER_FUT
if (c < curCue) { ch = 0xB3 /*│*/; fg = COL_ORDER_PAST }
else if (c === curCue) { ch = 0xDB /*█*/; fg = COL_ORDER_CUR }
if (cue.i30 === CUE_HALT) {
ch = 0xD8 /*Ø*/ // halt marker
fg = COL_ORDER_HALT
} else if ((cue.i30 & 0xF0) === CUE_JMP) {
ch = 0xAA /*ª*/ // jump
} else if ((cue.i30 & 0xF0) === CUE_BAK || (cue.i30 & 0xF0) === CUE_FWD) {
ch = 0xF7 /*≈ ish*/
}
colour(fg, COL_BG)
mvprn(ROW_ORDER, COL_INSIDE_L + i, ch)
}
}
// ── Event-driven visualiser ─────────────────────────────────────────────────
//
// One event slot per tracker voice. A real note (≥ 0x0020) on a row creates a
// new event in that voice's slot (replacing whatever was there). The event
// then lives as long as the engine reports the voice as active — so a long
// sustained pad persists while its envelope holds, a short percussion sample
// dies as soon as its volume envelope hits zero, and a retrigger immediately
// replaces the old event with a fresh one.
//
// Per render frame we sample the live state (volume / pan / noteVal), update
// each event's `peakVol`, and derive a stage:
// - ATTACK : ageFrames < 3 → bolder glyphs, brightest colour
// - SUSTAIN : volFrac > 0.6 → standard glyphs, mid colour
// - RELEASE : volFrac ≤ 0.6 → lighter glyphs, dim colour
//
// volFrac = liveVol / peakVol — i.e. how loud the voice is *relative to its
// own attack peak*, which decouples brightness from per-instrument loudness
// differences and turns the colour ramp into a faithful envelope tracer.
const STAGE_ATTACK = 0
const STAGE_SUSTAIN = 1
const STAGE_RELEASE = 2
const ATTACK_FRAMES = 3
const SUSTAIN_VOL_FLOOR = 0.6
const events = new Array(NUM_VOICES)
for (let v = 0; v < NUM_VOICES; v++) events[v] = null
function noteValToLanePitchY(note, top, bot) {
// 4096-TET notes are already log-scaled (256 units = 1 semitone, 4096 =
// 1 octave). We clamp to PITCH_RANGE_LO..PITCH_RANGE_HI and divide the
// canvas evenly so each row is ~1/3 of an octave (with 18 rows over 7
// octaves) — high notes at the top, low at the bottom.
const laneH = bot - top + 1
const n = Math.max(PITCH_RANGE_LO, Math.min(PITCH_RANGE_HI, note))
const norm = (n - PITCH_RANGE_LO) / (PITCH_RANGE_HI - PITCH_RANGE_LO)
let pos = Math.floor(norm * laneH)
if (pos < 0) pos = 0
if (pos > laneH - 1) pos = laneH - 1
return bot - pos
}
function panToCol(pan) {
// pan 0..255 → COL_INSIDE_L+1 .. COL_INSIDE_R-1
const inner = LANE_W - 2
let x = COL_INSIDE_L + 1 + Math.round((pan / 255) * inner)
if (x < COL_INSIDE_L + 1) x = COL_INSIDE_L + 1
if (x > COL_INSIDE_R - 1) x = COL_INSIDE_R - 1
return x
}
// Per-voice last-row state, so we only spawn an event on transitions.
const voiceLastNote = new Int32Array(NUM_VOICES)
const voiceLastInst = new Uint8Array(NUM_VOICES)
for (let v = 0; v < NUM_VOICES; v++) { voiceLastNote[v] = -1 }
let lastSeenCue = -1
let lastSeenRow = -1
function spawnEventsForRow(cueIdx, rowIdx) {
const cue = song.cues[cueIdx]
if (!cue) return
for (let v = 0; v < song.numVoices; v++) {
const patIdx = cue.ptns[v]
if (patIdx === CUE_EMPTY) { voiceLastNote[v] = -1; continue }
if (patIdx >= song.numPats) continue
const pat = song.patterns[patIdx]
if (!pat) continue
const off = rowIdx * 8
const note = pat[off] | (pat[off + 1] << 8)
const inst = pat[off + 2]
const panB = pat[off + 4]
const panSel = (panB >> 6) & 3
const panVal = panB & 0x3F
// Only real notes (≥ 0x0020) trigger events.
if (note < 0x0020) continue
// Resolve the effective instrument: if the cell carries inst=0, reuse
// the previous instrument bound to this voice.
const effInst = inst !== 0 ? inst : voiceLastInst[v]
if (effInst === 0) continue
const arch = archByInst[effInst]
let pan = 128
if (panSel === 0) pan = (panVal / 63 * 255) | 0
const livePan = audio.getVoiceEffectivePan(0, v)
if (typeof livePan === 'number' && livePan !== 128) pan = livePan
// Replace whatever was in voice v's slot. peakVol seeds at 0 and is
// tracked per-frame so the colour ramp normalises by attack peak,
// not by an arbitrary 0..1 absolute scale.
events[v] = {
arch: arch, instrId: effInst, voice: v,
note: note, pan: pan,
ageFrames: 0,
peakVol: 0,
glyphSeed: (cueIdx * 64 + rowIdx + v * 13) & 0xFFFF
}
voiceLastNote[v] = note
voiceLastInst[v] = effInst
}
}
// ── Per-lane rendering ──────────────────────────────────────────────────────
//
// The renderer is structured as: each frame, blank the visualiser rows of all
// five lanes, walk the active events, and draw each into its lane. Painting
// is reasonably cheap because (a) the bordered side columns stay untouched,
// and (b) blanking inside is two cells per row × ~14 rows = 30 cells.
function blankLanes() {
colour(COL_DIM, COL_BG)
const blank = ' '.repeat(LANE_W)
for (let r = ROW_TONAL_TOP; r <= ROW_TONAL_BOT; r++)
mvtext(r, COL_INSIDE_L, blank)
for (let r = ROW_DRUMS_TOP; r <= ROW_DRUMS_BOT; r++)
mvtext(r, COL_INSIDE_L, blank)
}
function envColour(arch, volFrac) {
// Brightest entry at volFrac == 1 (envelope peak); dimmest at silence.
const ramp = COL_ARCH[arch]
const dim = 1 - Math.max(0, Math.min(1, volFrac))
let idx = Math.floor(dim * ramp.length)
if (idx >= ramp.length) idx = ramp.length - 1
return ramp[idx]
}
function drawEventDrum(ev, stage, volFrac) {
const lane = LANE_BY_ARCH[ARCH_DRUM]
const cx = panToCol(ev.pan)
const laneH = lane.bot - lane.top + 1
const seed = ev.glyphSeed
colour(envColour(ARCH_DRUM, volFrac), COL_BG)
// Radial scatter — three points around centre, deterministic per-frame
// shuffle off glyphSeed so the eye reads the burst as motion rather than
// a static cluster. Number of points contracts in RELEASE.
const age = ev.ageFrames
const xs = [
cx - 2 - ((seed >> age) & 3),
cx + 1 + ((seed >> (age + 3)) & 3),
cx + ((seed >> (age + 6)) & 1) - ((seed >> (age + 8)) & 1)
]
const ys = [
lane.top + ((seed >> age) & 1),
lane.bot - ((seed >> (age + 4)) & 1),
lane.top + ((seed >> (age + 2)) % laneH)
]
const glyphs = stage === STAGE_ATTACK ? [0x2A,0x2A,0x2A] // bright * burst
: stage === STAGE_SUSTAIN ? [0xF9,0x2A,0x2E] // · * .
: [0xF9,0x2E,0xF9] // · . · (sparse)
const points = stage === STAGE_RELEASE ? 2 : 3
for (let i = 0; i < points; i++) {
if (xs[i] >= COL_INSIDE_L && xs[i] <= COL_INSIDE_R)
mvprn(ys[i], xs[i], glyphs[(i + age) % 3])
}
}
function drawEventBass(ev, stage, volFrac, liveNote) {
const lane = LANE_BY_ARCH[ARCH_BASS]
const cx = panToCol(ev.pan)
colour(envColour(ARCH_BASS, volFrac), COL_BG)
const note = (liveNote !== null && liveNote > 0) ? liveNote : ev.note
const y = noteValToLanePitchY(note, lane.top, lane.bot)
// Slab width tracks the envelope: full block during ATTACK, half-block in
// SUSTAIN, thin core in RELEASE. Volume-scaled width carries the visual
// "weight" of a heavy decaying bass.
const baseW = stage === STAGE_ATTACK ? 4
: stage === STAGE_SUSTAIN ? 3
: 2
const halfW = Math.max(1, Math.floor(baseW * Math.max(volFrac, 0.25)))
const glyph = stage === STAGE_ATTACK ? 0xDB /*█*/
: stage === STAGE_SUSTAIN ? 0xDC /*▄*/
: 0xDF /*▀*/
for (let dx = -halfW; dx <= halfW; dx++) {
const x = cx + dx
if (x >= COL_INSIDE_L && x <= COL_INSIDE_R)
mvprn(y, x, glyph)
}
}
function drawEventPad(ev, stage, volFrac, liveNote) {
const lane = LANE_BY_ARCH[ARCH_PAD]
const cx = panToCol(ev.pan)
colour(envColour(ARCH_PAD, volFrac), COL_BG)
// Fog hugging the pitch row. Centre weight (▓) tracks the envelope —
// softens to ▒ then ░ as the pad releases. Spread grows slowly with age
// so long sustains don't pile into a single bright spot.
const note = (liveNote !== null && liveNote > 0) ? liveNote : ev.note
const y = noteValToLanePitchY(note, lane.top, lane.bot)
const spread = 1 + Math.min(3, ev.ageFrames >> 3)
const core = stage === STAGE_ATTACK ? 0xB2 /*▓*/
: stage === STAGE_SUSTAIN ? 0xB1 /*▒*/
: 0xB0 /*░*/
const flank = stage === STAGE_RELEASE ? 0xB0 : 0xB1
const halo = stage === STAGE_RELEASE ? 0x20 : 0xB0
const glyphs = [halo, flank, core, flank, halo]
for (let i = -2; i <= 2; i++) {
const x = cx + i * spread
if (x >= COL_INSIDE_L && x <= COL_INSIDE_R)
mvprn(y, x, glyphs[i + 2])
}
}
function drawEventLead(ev, stage, volFrac, livePan, liveNote) {
const lane = LANE_BY_ARCH[ARCH_LEAD]
// Live pan/note enable the stair-stepped vibrato motion the spec wants.
const pan = (livePan !== null && livePan !== undefined) ? livePan : ev.pan
const note = (liveNote !== null && liveNote > 0) ? liveNote : ev.note
const cx = panToCol(pan)
colour(envColour(ARCH_LEAD, volFrac), COL_BG)
const y = noteValToLanePitchY(note, lane.top, lane.bot)
// Tail length tracks how long the voice has been audible, capped at 8.
// In ATTACK the tail is empty (just the head); in SUSTAIN it grows out
// to its full length; in RELEASE it dissolves to dots.
const tailLen = stage === STAGE_ATTACK ? 0
: Math.min(8, Math.floor(ev.ageFrames / 2))
const trailChar = stage === STAGE_SUSTAIN ? 0xCD /*═*/
: stage === STAGE_RELEASE ? 0xF9 /*·*/
: 0xC4 /*─*/
for (let i = 1; i <= tailLen; i++) {
const x = cx - i
if (x >= COL_INSIDE_L && x <= COL_INSIDE_R)
mvprn(y, x, trailChar)
}
const head = stage === STAGE_ATTACK ? 0xFE /*■*/
: stage === STAGE_RELEASE ? 0x09 /*°*/
: 0x6F /*o*/
mvprn(y, cx, head)
}
function drawEventMetal(ev, stage, volFrac, liveNote) {
const lane = LANE_BY_ARCH[ARCH_METAL]
const cx = panToCol(ev.pan)
const note = (liveNote !== null && liveNote > 0) ? liveNote : ev.note
colour(envColour(ARCH_METAL, volFrac), COL_BG)
const yBase = noteValToLanePitchY(note, lane.top, lane.bot)
// ╱╲╱╲ angular pair, stepping diagonally each frame. Width contracts in
// RELEASE so a decaying metallic ping shrinks into a single sharp tick
// rather than holding its full silhouette to the end.
const step = ev.ageFrames % 4
const offs = [0, 1, 0, -1]
const y = Math.max(lane.top, Math.min(lane.bot, yBase + offs[step]))
const reach = stage === STAGE_RELEASE ? 1 : 2
for (let i = -reach; i <= reach; i++) {
const x = cx + i
if (x < COL_INSIDE_L || x > COL_INSIDE_R) continue
mvprn(y, x, ((i + step) & 1) ? 0x2F /*/*/ : 0x5C /*\\*/)
}
}
function renderEvents() {
blankLanes()
for (let v = 0; v < song.numVoices; v++) {
const ev = events[v]
if (!ev) continue
// The engine's `active` flag is the source of truth — set by note-on,
// cleared by note-cut, sample-end, envelope-end-of-decay, or NNA cut.
// Once it drops, the voice is genuinely silent so the visual goes too.
if (!audio.getVoiceActive(0, v)) { events[v] = null; continue }
const liveVol = audio.getVoiceEffectiveVolume(0, v) || 0
const livePan = audio.getVoiceEffectivePan(0, v)
const liveNote = audio.getVoiceNote(0, v)
if (liveVol > ev.peakVol) ev.peakVol = liveVol
ev.ageFrames++
// volFrac normalises by attack peak — sustain holds near 1.0,
// release walks toward 0. Floor on the denominator avoids division
// blowups when an event is born and read on the same frame
// (peakVol == 0 until the first audible sample).
const denom = ev.peakVol > 0.01 ? ev.peakVol : 0.01
const volFrac = Math.max(0, Math.min(1, liveVol / denom))
const stage = ev.ageFrames < ATTACK_FRAMES ? STAGE_ATTACK
: volFrac > SUSTAIN_VOL_FLOOR ? STAGE_SUSTAIN
: STAGE_RELEASE
switch (ev.arch) {
case ARCH_DRUM: drawEventDrum (ev, stage, volFrac); break
case ARCH_BASS: drawEventBass (ev, stage, volFrac, liveNote); break
case ARCH_PAD: drawEventPad (ev, stage, volFrac, liveNote); break
case ARCH_LEAD: drawEventLead (ev, stage, volFrac, livePan, liveNote); break
case ARCH_METAL: drawEventMetal(ev, stage, volFrac, liveNote); break
}
}
}
// ── Stereo bar + tick lights ────────────────────────────────────────────────
function drawStereo() {
// Aggregate per-voice live volume by stereo position into a 76-wide bar.
const W = LANE_W
const bins = new Float32Array(W)
for (let v = 0; v < song.numVoices; v++) {
if (!audio.getVoiceActive(0, v)) continue
const vol = Math.pow(audio.getVoiceEffectiveVolume(0, v) || 0, 0.125)
if (vol <= 0) continue
const pan = audio.getVoiceEffectivePan(0, v)
let col = Math.round((pan / 255) * (W - 1))
if (col < 0) col = 0
if (col >= W) col = W - 1
// Gaussian-ish 7-cell spread so individual voices don't read as single
// spikes — the eye reads bars best with some neighbouring mass.
bins[col] += vol
if (col >= 3) bins[col - 3] += vol * 0.05
if (col >= 2) bins[col - 2] += vol * 0.3
if (col >= 1) bins[col - 1] += vol * 0.75
if (col < W - 1) bins[col + 1] += vol * 0.75
if (col < W - 2) bins[col + 2] += vol * 0.3
if (col < W - 3) bins[col + 3] += vol * 0.05
}
const stairs = [0x20, 0xB0, 0xB1, 0xB2, 0xDB] // space ░ ▒ ▓ █
for (let i = 0; i < W; i++) {
const v = bins[i]
let idx = Math.min(stairs.length - 1, Math.floor(v * 1.6))
if (idx <= 0) idx = 0
const fg = idx === 0 ? COL_DIM
: idx === 1 ? COL_ARCH[ARCH_PAD][3]
: idx === 2 ? COL_ARCH[ARCH_PAD][2]
: idx === 3 ? COL_ARCH[ARCH_PAD][1]
: COL_ARCH[ARCH_PAD][0]
colour(fg, COL_BG)
mvprn(ROW_STEREO, COL_INSIDE_L + i, stairs[idx])
}
}
// Tick indicator: row of lights, one per tick within the current row.
let tickLightsLast = -1
function drawTickLights(tickInRow, tickRate) {
if (tickInRow === tickLightsLast) return
tickLightsLast = tickInRow
clearInside(ROW_TICK)
const N = Math.min(tickRate, 24)
colour(COL_DIM, COL_BG)
mvtext(ROW_TICK, COL_INSIDE_L + 1, 'TICK ')
for (let i = 0; i < N; i++) {
const lit = i < tickInRow
colour(lit ? COL_TICK_LIVE : COL_TICK_DEAD, COL_BG)
mvprn(ROW_TICK, COL_INSIDE_L + 6 + i * 2, lit ? 0xFE /*■*/ : 0xF9 /*·*/)
}
// Voice activity counter on the right.
let nActive = 0
for (let v = 0; v < song.numVoices; v++) {
if (audio.getVoiceActive(0, v)) nActive++
}
colour(COL_DIM, COL_BG)
const s = 'ACTIVE ' + pad(nActive, 2) + '/' + pad(song.numVoices, 2)
mvtext(ROW_TICK, COL_INSIDE_R - s.length, s)
}
// ── Initial paint ───────────────────────────────────────────────────────────
drawFrame()
drawTitle()
drawStatus(0)
drawOrderStrip(0)
// ── Playback ────────────────────────────────────────────────────────────────
audio.setCuePosition(0, 0)
audio.setTrackerRow(0, 0)
audio.setMasterVolume(0, 255)
audio.play(0)
let stopReq = false
let errorlevel = 0
// Track tick boundaries by polling at ~30 Hz. The Taud engine doesn't expose
// a per-tick counter, so we synthesise one by counting render frames between
// row-changes and scaling against the song's tickRate — this is good enough
// for the tick-light pulse and event ageing, and stays in lock-step with the
// row index since both the renderer and the engine advance off the same wall
// clock.
let ticksPerRow = Math.max(1, song.tickRate)
let synthTick = 0 // tick within current row, 0..ticksPerRow-1
try {
while (audio.isPlaying(0) && !stopReq) {
// Backspace polling (mirrors playtad).
sys.poke(-40, 1)
if (sys.peek(-41) === 67) stopReq = true
const curCue = audio.getCuePosition(0)
const curRow = audio.getTrackerRow(0)
if (curCue !== lastSeenCue || curRow !== lastSeenRow) {
// Row boundary — spawn new events, reset synthetic tick counter.
spawnEventsForRow(curCue, curRow)
lastSeenCue = curCue
lastSeenRow = curRow
synthTick = 0
// Pull a fresh tickRate read here in case a T effect changed it
// mid-song.
ticksPerRow = Math.max(1, audio.getTickRate(0) || song.tickRate)
} else {
// Same row — advance the synthetic tick counter against wall time.
// Tick period (ms) = (60000 / BPM) / 24 ... but the spec is
// engine-internal. We approximate via tickRate frames per row
// and the row-boundary cadence we last observed.
synthTick++
if (synthTick >= ticksPerRow) synthTick = ticksPerRow - 1
}
// Event ageing happens inside renderEvents() now — it ticks ageFrames,
// updates peakVol from the live mixer reading, and retires voices the
// engine has marked inactive.
drawStatus(curCue)
drawOrderStrip(curCue)
renderEvents()
drawStereo()
drawTickLights(synthTick, ticksPerRow)
sys.sleep((2500 / audio.getBPM(0))|0) // one visual frame = one tick
}
}
catch (e) {
printerrln(e)
errorlevel = 1
}
finally {
audio.stop(0)
con.move(ROW_BOT_BORDER + 1, 1)
con.curs_set(1)
}
return errorlevel