// 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 [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