From 065e586cd66d9a1d278b0e8342efea05a94aaf0c Mon Sep 17 00:00:00 2001 From: minjaesong Date: Fri, 22 May 2026 05:12:48 +0900 Subject: [PATCH] taud player with visualiser --- assets/disk0/tvdos/bin/playtaud.js | 1062 +++++++++++++++++ assets/disk0/tvdos/bin/zfm.js | 2 +- .../net/torvald/tsvm/AudioJSR223Delegate.kt | 22 + 3 files changed, 1085 insertions(+), 1 deletion(-) create mode 100644 assets/disk0/tvdos/bin/playtaud.js diff --git a/assets/disk0/tvdos/bin/playtaud.js b/assets/disk0/tvdos/bin/playtaud.js new file mode 100644 index 0000000..a37a93d --- /dev/null +++ b/assets/disk0/tvdos/bin/playtaud.js @@ -0,0 +1,1062 @@ +// 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' + ]}, + { arch: ARCH_METAL, words: [ + 'metal','ring mod','ringmod','noise','glass','chime','clang', + 'sweep','riser','blip','zap','laser','bitcrush','crush','fm ' + ]}, + { 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 + + // Acoustic override: an unambiguously percussive sample (short one-shot, + // no loop) is treated as DRUM even when the name says otherwise. Modules + // sometimes label kicks "Bass" or label individual percussion hits with + // genre tags — the acoustic shape is the more reliable signal there. + const flags = readInstByte(slot, 14) + const loopMode = flags & 0x03 + const looped = (loopMode === 1 || loopMode === 2) + if (sampleLen < 4096 && !looped) return ARCH_DRUM + + // Name-based prior next — 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 = audio.getVoiceEffectiveVolume(0, v) || 0 + 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 3-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 > 0) bins[col - 1] += vol * 0.5 + if (col < W - 1) bins[col + 1] += vol * 0.5 + } + 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) +const frameIntervalMs = 33 +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(frameIntervalMs) + } +} +catch (e) { + printerrln(e) + errorlevel = 1 +} +finally { + audio.stop(0) + con.move(ROW_BOT_BORDER + 1, 1) + con.curs_set(1) +} + +return errorlevel diff --git a/assets/disk0/tvdos/bin/zfm.js b/assets/disk0/tvdos/bin/zfm.js index 6b7298a..9ebccd0 100644 --- a/assets/disk0/tvdos/bin/zfm.js +++ b/assets/disk0/tvdos/bin/zfm.js @@ -66,7 +66,7 @@ const EXEC_FUNS = { "txt": (f) => _G.shell.execute(`less "${f}"`), "md": (f) => _G.shell.execute(`less "${f}"`), "log": (f) => _G.shell.execute(`less "${f}"`), - "taud": (f) => _G.shell.execute(`microtone "${f}"`), + "taud": (f) => _G.shell.execute(`playtaud "${f}"`), } function makeExecFun(template) { diff --git a/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt b/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt index 54214dd..0868d7f 100644 --- a/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt +++ b/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt @@ -134,6 +134,28 @@ class AudioJSR223Delegate(private val vm: VM) { } else v.channelPan.coerceIn(0, 255) } + /** Whether the voice slot is currently sounding (i.e. owns an active sample). Mirrors + * `Voice.active` which is the source of truth for "is this voice contributing to the mix + * right now". Visualisers should treat this as the authoritative on/off bit. */ + fun getVoiceActive(playhead: Int, voice: Int): Boolean = + getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.active == true + + /** Live noteVal (0..65535, 4096-TET) of the foreground voice — the value the mixer is using + * *right now* including any in-flight vibrato / arpeggio / portamento delta. Returns 0 for + * inactive voices. */ + fun getVoiceNote(playhead: Int, voice: Int): Int { + val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0 + if (!v.active) return 0 + return v.noteVal and 0xFFFF + } + + /** Instrument id (0..255) currently bound to the voice slot, or 0 if the voice is inactive. */ + fun getVoiceInstrument(playhead: Int, voice: Int): Int { + val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0 + if (!v.active) return 0 + return v.instrumentId and 0xFF + } + /** Set the starting row for the next play call, resetting per-row timing and silencing active voices. */ fun setTrackerRow(playhead: Int, row: Int) { getPlayhead(playhead)?.trackerState?.let { ts ->