/** * TAUT views module — Samples viewer + Instruments viewer + live-play blob/cursor. * * Extracted verbatim from taut.js on 2026-06-21 (one contiguous block: SAMPLES * VIEWER + INSTRUMENTS VIEWER + LIVE-PLAY BLOB). Runs in-process in taut.js's * context via init(HUB). Read-only engine constants come in through HUB.C; engine * helper functions through HUB; live engine state (song / panel / playback mode) * through HUB getters. The shared blob/cursor primitives stay intra-module here * (both viewers use them) — only the engine<->views calls cross HUB. * * \uXXXX escapes are preserved byte-for-byte from the original (copied, not * retyped) — TSVM's string parser is not Unicode. */ const win = require("wintex") const keys = require("keysym") const taud = require("taud") function init(HUB) { const C = HUB.C const { SCRW, SCRH, CELL_PH, CELL_PW, VERT, NUM_VOICES, PLAYHEAD, PLAYMODE_NONE, PTNVIEW_HEIGHT, PTNVIEW_OFFSET_Y, SLIDER_TW_SMALL, SLIDER_TW_WIDE, VIEW_INSTRMNT, VIEW_SAMPLES, sym, fullPathObj, songsMeta, colBackPtn, colBLACK, colHighlight, colInst, colScrollBar, colSep, colStatus, colTabActive, colTabBarBack, colTabBarBack2, colTabBarOrn, colTabInactive, colVoiceHdr, colVol, colWHITE, } = C const { noteToStr, fillLine, drawControlHint, openInlineNumEdit, addPanelMouseRegion, switchToPanel, } = HUB // SAMPLES VIEWER ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // The Samples tab is an internal viewer: sample list on the left, properties + // "used by" instrument list + waveform graphics on the right, and an Edit // button that opens the in-process openSampleEdit modal (below). // // Sample identity in .taud is derived from (samplePtr, sampleLen) inside the // 256-byte instrument records (terranmon.txt §"Instrument bin"). Conversion // scripts pack samples into the 8 MB pool in slot order, so sorting unique // pointers ascending lines up with SNam[i] in the project-data block. // Peripheral memory window offsets, from terranmon.txt:1994-1999. const TAUT_SBANK_SIZE = 524288 // 512 K window for sample bin const TAUT_INST_WINDOW_OFF = 720896 // instrument bin starts here in peri space const TAUT_INST_RECORD_SIZE = 256 const TAUT_INST_COUNT = 256 // slots 0..255; slot 0 is unused // Read one 256-byte instrument record straight out of the audio adapter. function readInstRecord(slot) { const memBase = audio.getMemAddr() const base = TAUT_INST_WINDOW_OFF + slot * TAUT_INST_RECORD_SIZE const rec = new Uint8Array(TAUT_INST_RECORD_SIZE) for (let i = 0; i < TAUT_INST_RECORD_SIZE; i++) { rec[i] = sys.peek(memBase - (base + i)) & 0xFF } return rec } // Decode the fields the viewer actually cares about. Offsets from terranmon.txt:2071+. function decodeInstRecord(rec) { const samplePtr = (rec[0]) | (rec[1] << 8) | (rec[2] << 16) | (rec[3] * 0x1000000) const sampleLen = rec[4] | (rec[5] << 8) const c4Rate = rec[6] | (rec[7] << 8) const playStart = rec[8] | (rec[9] << 8) const loopStart = rec[10] | (rec[11] << 8) const loopEnd = rec[12] | (rec[13] << 8) const sampleFlags = rec[14] const instGV = rec[171] const defNoteVol = rec[196] const detune = rec[184] | (rec[185] << 8) return { samplePtr, sampleLen, c4Rate, playStart, loopStart, loopEnd, sampleFlags, instGV, defNoteVol, detune } } // Scan all 256 instruments and build the deduped sample list. Each returned // entry is { ptr, len, c4Rate, playStart, loopStart, loopEnd, sampleFlags, // usedBy[], name }. usedBy is a list of instrument slot numbers (1..255). let samplesCache = null // Ixmp ("instrument extra samples") introspection — present once the host VM // exposes the patch read-back API. On an un-rebuilt host it's absent and the // Samples tab simply lists the base-record samples (no patch samples). const hasIxmpAPI = (typeof audio !== 'undefined' && typeof audio.getInstrumentPatchCount === 'function' && typeof audio.getInstrumentPatches === 'function') // Per-patch on-wire length from its version byte (terranmon.txt §Ixmp; mirrors // taud.mjs#patchLen / AudioJSR223Delegate). 31 common bytes + present blocks. function ixmpPatchLen(ver) { return 31 + ((ver & 0x80) ? 15 : 0) // x: extra-base-info (flags1+flags2+fadeout+cutoff+reson+atten) + ((ver & 0x02) ? 54 : 0) // v: volume envelope + ((ver & 0x04) ? 54 : 0) // p: panning envelope + ((ver & 0x08) ? 54 : 0) // f: filter envelope + ((ver & 0x10) ? 54 : 0) // P: pitch envelope } // Walk instrument `slot`'s Ixmp patches; invoke cb(samplePtr, sampleLen, extra) per // patch. Patch common-byte layout (terranmon.txt §Ixmp): u32 ptr@7, u16 len@11, // u16 playStart@13, loopStart@15, loopEnd@17, rate@19, u8 loopMode@23. No-op without API. function forEachIxmpPatchSample(slot, cb) { if (!hasIxmpAPI) return if (audio.getInstrumentPatchCount(slot) <= 0) return const b = audio.getInstrumentPatches(slot) if (!b || b.length < 31) return const u16 = (o) => (b[o] & 0xFF) | ((b[o+1] & 0xFF) << 8) let o = 0 while (o + 31 <= b.length) { const ver = b[o] & 0xFF const len = ixmpPatchLen(ver) if (o + len > b.length) break const ptr = (b[o+7] & 0xFF) | ((b[o+8] & 0xFF) << 8) | ((b[o+9] & 0xFF) << 16) | ((b[o+10] & 0xFF) * 0x1000000) cb(ptr, u16(o+11), { c4Rate: u16(o+19), playStart: u16(o+13), loopStart: u16(o+15), loopEnd: u16(o+17), sampleFlags: b[o+23] & 0xFF }) o += len } } // Count an instrument's EXTRA samples: distinct Ixmp patch samples (by ptr:len) that differ // from the base record's own sample. Drives the Gen.1 "… et al. (N extra samples)" hint for // multisample (SF2-derived) instruments. 0 when the host lacks the Ixmp API or the instrument // is single-sampled. Patches that re-use the base sample (e.g. velocity layers sharing one // slice but with their own envelopes) are NOT counted — "samples", not "patches". function instExtraSampleCount(slot, basePtr, baseLen) { if (!hasIxmpAPI) return 0 const seen = {} let n = 0 forEachIxmpPatchSample(slot, (ptr, len) => { if (len === 0) return if (ptr === basePtr && len === baseLen) return const k = ptr + ':' + len if (!seen[k]) { seen[k] = true; n++ } }) return n } function buildSampleIndex() { const byPtr = new Map() const addSample = (slot, ptr, len, extra) => { if (len === 0) return const key = ptr + ':' + len if (!byPtr.has(key)) { byPtr.set(key, Object.assign({ ptr: ptr, len: len, c4Rate: 0, playStart: 0, loopStart: 0, loopEnd: 0, sampleFlags: 0, usedBy: [], name: '' }, extra || {})) } const e = byPtr.get(key) if (e.usedBy.indexOf(slot) < 0) e.usedBy.push(slot) } for (let i = 1; i < TAUT_INST_COUNT; i++) { const rec = readInstRecord(i) // Metainstruments (samplePtr high 16 bits == 0xFFFF) carry no sample of their // own — only a layer table — so skip their bogus base pointer here. if (((rec[2] | (rec[3] << 8)) & 0xFFFF) !== 0xFFFF) { const d = decodeInstRecord(rec) addSample(i, d.samplePtr, d.sampleLen, { c4Rate: d.c4Rate, playStart: d.playStart, loopStart: d.loopStart, loopEnd: d.loopEnd, sampleFlags: d.sampleFlags }) } // Ixmp patch samples (extra multisamples that velocity/key layers reference). forEachIxmpPatchSample(i, (ptr, slen, ex) => addSample(i, ptr, slen, ex)) } const list = Array.from(byPtr.values()).sort((a, b) => a.ptr - b.ptr) const names = (songsMeta && songsMeta.sampleNames) || [] for (let i = 0; i < list.length; i++) { // SNam is slot-indexed (entry 0 unused); converters keep sample order // identical to pool order, so list[i] corresponds to names[i+1]. const n = names[i + 1] list[i].name = (n != null) ? n : '' } return list } function refreshSamplesCache() { samplesCache = buildSampleIndex() } // ── Layout ─────────────────────────────────────────────────────────────────── // Panel area is rows PTNVIEW_OFFSET_Y .. SCRH-1 (the hint bar lives at SCRH). // Columns mirror the Patterns tab: list body | scroll-bar col | VERT separator | right pane. const SMP_LIST_X = 1 const SMP_LIST_BODY_W = 27 // text width of one list row const SMP_LIST_W = SMP_LIST_BODY_W + 1 // body + 1-col scroll indicator const SMP_LIST_SCROLL_X = SMP_LIST_X + SMP_LIST_BODY_W // scroll-indicator column const SMP_LIST_Y = PTNVIEW_OFFSET_Y const SMP_LIST_H = PTNVIEW_HEIGHT // full panel height const SMP_SEP_X = SMP_LIST_X + SMP_LIST_W // vertical separator column const SMP_RIGHT_X = SMP_SEP_X + 1 const SMP_RIGHT_Y = PTNVIEW_OFFSET_Y const SMP_PROP_H = 10 // rows 5..14 const SMP_USED_Y = SMP_RIGHT_Y + SMP_PROP_H // header row const SMP_USED_HDR_H = 1 const SMP_USED_LIST_H = 5 const SMP_WAVE_Y = SMP_USED_Y + SMP_USED_HDR_H + SMP_USED_LIST_H // row 21 const SMP_BTN_Y = SCRH - 1 // bottom-most panel row, reserved for Edit button const SMP_WAVE_H_ROWS = SMP_BTN_Y - SMP_WAVE_Y // visual rows used by the waveform const colSmpListBg = colBackPtn const colSmpListSel = colHighlight const colSmpListNumFg = colInst const colSmpListNameFg = colStatus const colSmpPropLabel = colVoiceHdr const colSmpPropValue = colWHITE const colSmpUsedHdr = colVoiceHdr const colSmpUsedFg = colInst const colSmpWaveLine = 77 // bright cyan-ish; visible on dark bg const colSmpWaveMid = 246 // dim grey for zero-line const colSmpWaveFunk = 221 // orange — loop bytes live-inverted by funk repeat (S$Fx) // Funk-repeat introspection API (getVoiceFunkSpeed / getInstrumentFunkMask) ships with this // feature; on an un-rebuilt host VM it's absent and the waveform stays the stored sample. const hasFunkAPI = (typeof audio !== 'undefined' && typeof audio.getVoiceFunkSpeed === 'function' && typeof audio.getInstrumentFunkMask === 'function') let smpListScroll = 0 let smpListCursor = 0 // followCursor=true (keyboard nav) scrolls the view to keep the cursor visible; // false (full redraw / free wheel scroll) leaves the scroll where it is, only // clamping it to the valid range — so a wheel scroll can move the view without // moving the selection (mirrors the Advanced Edit list). function clampSamplesCursor(followCursor = true) { const n = samplesCache ? samplesCache.length : 0 if (smpListCursor < 0) smpListCursor = 0 if (smpListCursor >= n) smpListCursor = Math.max(0, n - 1) if (followCursor) { if (smpListCursor < smpListScroll) smpListScroll = smpListCursor if (smpListCursor >= smpListScroll + SMP_LIST_H) smpListScroll = smpListCursor - SMP_LIST_H + 1 } const maxS = Math.max(0, n - SMP_LIST_H) if (smpListScroll > maxS) smpListScroll = maxS if (smpListScroll < 0) smpListScroll = 0 } function drawSamplesListColumn() { const n = samplesCache ? samplesCache.length : 0 for (let row = 0; row < SMP_LIST_H; row++) { const idx = smpListScroll + row const y = SMP_LIST_Y + row con.move(y, SMP_LIST_X) if (idx >= n) { con.color_pair(colSmpListNameFg, colSmpListBg) print(' '.repeat(SMP_LIST_BODY_W)) continue } const s = samplesCache[idx] const isSel = (idx === smpListCursor) const back = isSel ? colSmpListSel : colSmpListBg const numStr = (idx + 1).toString(16).toUpperCase().padStart(2, '0') const nameRaw = (s.name && s.name.length) ? s.name : '(sample ' + (idx + 1) + ')' const nameW = SMP_LIST_BODY_W - 6 // ' NN name ' totals 6 + N chars const nameStr = (nameRaw.length > nameW ? nameRaw.substring(0, nameW) : nameRaw.padEnd(nameW)) con.color_pair(colSmpListNumFg, back); print(' ' + numStr + ' ') con.color_pair(colSmpListNameFg, back); print(' ') con.color_pair(isSel ? colWHITE : colSmpListNameFg, back); print(nameStr) con.color_pair(colSmpListNameFg, back); print(' ') } // scroll indicator on the rightmost column of the list area (left of the separator) if (n > SMP_LIST_H) { const maxScroll = n - SMP_LIST_H const indPos = (maxScroll === 0) ? 0 : ((smpListScroll * (SMP_LIST_H - 1) / maxScroll) | 0) for (let r = 0; r < SMP_LIST_H; r++) { con.move(SMP_LIST_Y + r, SMP_LIST_SCROLL_X) con.color_pair(colScrollBar, colSmpListBg) let scrollChar = (r == 0) ? sym.taut_scrollgutter_top : (r == SMP_LIST_H - 1) ? sym.taut_scrollgutter_bot : sym.taut_scrollgutter_mid if (r == indPos) scrollChar += 3; con.addch(scrollChar) } } else { for (let r = 0; r < SMP_LIST_H; r++) { con.move(SMP_LIST_Y + r, SMP_LIST_SCROLL_X) con.color_pair(colStatus, colSmpListBg); print(' ') } } } function drawSamplesSeparator() { con.color_pair(colSep, colBackPtn) for (let y = SMP_LIST_Y; y < SCRH; y++) { con.move(y, SMP_SEP_X); con.prnch(VERT) } } function loopModeName(flags) { const lp = flags & 3 const sus = (flags >>> 2) & 1 const names = ['none', 'forward', 'pingpong', 'oneshot'] return names[lp] + (sus ? ' (sustain)' : '') } function drawSamplesProperties() { const rightW = SCRW - SMP_RIGHT_X + 1 // Clear right side for (let r = 0; r < SMP_PROP_H + SMP_USED_HDR_H + SMP_USED_LIST_H; r++) { con.move(SMP_RIGHT_Y + r, SMP_RIGHT_X) con.color_pair(colSmpPropValue, colBackPtn) print(' '.repeat(rightW)) } const n = samplesCache ? samplesCache.length : 0 if (n === 0) { con.move(SMP_RIGHT_Y, SMP_RIGHT_X) con.color_pair(colSmpPropLabel, colBackPtn) print('No samples in this project.') return } const s = samplesCache[smpListCursor] if (!s) return const rows = [ ['Sample #', (smpListCursor + 1).toString(16).toUpperCase().padStart(2, '0') + ' ($' + s.ptr.toString(16).toUpperCase().padStart(6, '0') + ')'], ['Name', s.name && s.name.length ? s.name : '(unnamed)'], ['Length', s.len + ' bytes ($' + s.len.toString(16).toUpperCase().padStart(4, '0') + ')'], ['Rate@C4', s.c4Rate + ' Hz'], ['Play st.', '$' + s.playStart.toString(16).toUpperCase().padStart(4, '0')], ['Loop', loopModeName(s.sampleFlags) + ' [$' + s.loopStart.toString(16).toUpperCase().padStart(4, '0') + '..$' + s.loopEnd.toString(16).toUpperCase().padStart(4, '0') + ']'], ['Bank', ((s.ptr / TAUT_SBANK_SIZE) | 0) + '/15'], ['Used by', s.usedBy.length + ' instrument' + (s.usedBy.length === 1 ? '' : 's')], ] for (let i = 0; i < rows.length; i++) { const y = SMP_RIGHT_Y + i con.move(y, SMP_RIGHT_X) con.color_pair(colSmpPropLabel, colBackPtn) print((rows[i][0] + ' ').substring(0, 10)) con.color_pair(colSmpPropValue, colBackPtn) const v = rows[i][1] const valMax = rightW - 11 print(v.length > valMax ? v.substring(0, valMax) : v) } } // Vertical scroll for the "Used by instruments" list (small in this viewer). let smpUsedScroll = 0 function drawSamplesUsedBy() { const s = (samplesCache && samplesCache[smpListCursor]) || null const used = s ? s.usedBy : [] const names = (songsMeta && songsMeta.instNames) || [] const visible = SMP_USED_LIST_H const rightW = SCRW - SMP_RIGHT_X + 1 con.move(SMP_USED_Y, SMP_RIGHT_X) con.color_pair(colSmpUsedHdr, colBackPtn) print(`Used by instruments (${used.length}):`.padEnd(rightW)) if (smpUsedScroll > Math.max(0, used.length - visible)) smpUsedScroll = Math.max(0, used.length - visible) if (smpUsedScroll < 0) smpUsedScroll = 0 for (let r = 0; r < visible; r++) { const y = SMP_USED_Y + 1 + r con.move(y, SMP_RIGHT_X) con.color_pair(colSmpPropValue, colBackPtn) const idx = smpUsedScroll + r if (idx >= used.length) { print(' '.repeat(rightW)) continue } const slot = used[idx] const iname = names[slot] || '(unnamed)' const numStr = '$' + slot.toString(16).toUpperCase().padStart(2, '0') con.color_pair(colSmpUsedFg, colBackPtn) print(' ' + numStr + ' ') con.color_pair(colSmpPropValue, colBackPtn) const nameW = rightW - 5 print(iname.length > nameW ? iname.substring(0, nameW) : iname.padEnd(nameW)) } } // ── Waveform rendering ────────────────────────────────────────────────────── // Renders one sample under the right panel as baseline-filled bars (each bar is // a plotRect anchored at the zero line, extending to the sample amplitude), // using the graphics layer. Samples are unsigned 8-bit; bank-switch is required // because only 512 K of the 8 MB pool is mapped at a time. We restore bank 0 // (the playback-expected default) when done. // Pixel rect occupied by the waveform inside the Samples viewer. Both the // waveform painter and the leave-Samples cleanup need to reach for the same // geometry, so it lives in one helper. function sampleWaveformRect() { return { x: (SMP_RIGHT_X - 1) * CELL_PW, y: (SMP_WAVE_Y - 1) * CELL_PH, w: (SCRW - SMP_RIGHT_X + 1) * CELL_PW, h: SMP_WAVE_H_ROWS * CELL_PH, } } function clearSampleWaveformArea() { const r = sampleWaveformRect() graphics.plotRect(r.x-2, r.y-2, r.w+4, r.h+4, 255) // 255 = transparent } // Instrument slot of an active voice that's funk-repeating (S$Fx) one of the sample's `usedBy` // instruments, or -1. Returns -1 when not playing / no funking voice / API absent. Drives the // per-frame repaint cadence only — the *displayed* mask comes from funkMaskForSample, which also // honours masks that persist after the funking voice has gone idle. function findFunkInstForSample(usedBy) { if (!hasFunkAPI) return -1 const numVox = (HUB.getSong() && HUB.getSong().numVoices) ? HUB.getSong().numVoices : NUM_VOICES for (let v = 0; v < numVox; v++) { if (!audio.getVoiceActive(PLAYHEAD, v)) continue if (audio.getVoiceFunkSpeed(PLAYHEAD, v) <= 0) continue const inst = audio.getVoiceInstrument(PLAYHEAD, v) if (usedBy.indexOf(inst) >= 0) return inst } return -1 } // Funk XOR mask to DISPLAY for this sample, or null. The per-instrument mask persists in the engine // for the whole playback session (cleared only on stop-and-replay), so once a loop has been // funk-inverted the overlay must stay even after the funking voice goes idle — matching ProTracker, // whose destructive EFx edits never revert until the song is reloaded. Prefer an actively-funking // instrument (its mask is live this frame); otherwise show any usedBy instrument that still carries // a non-empty mask from earlier in the session. function funkMaskForSample(usedBy, activeInst) { if (!hasFunkAPI) return null if (activeInst > 0) { const m = audio.getInstrumentFunkMask(activeInst) if (m && m.length > 0) return m } for (let i = 0; i < usedBy.length; i++) { const m = audio.getInstrumentFunkMask(usedBy[i]) if (m && m.length > 0) return m } return null } // Whether a voice was actively funk-repeating the displayed sample on the last paint. Drives the // per-frame repaint cadence in tickFunkWaveform (repaint while the live mask changes, plus one // settling frame after it stops). The painted overlay itself persists — the engine keeps the mask. let funkWaveLast = false function drawSampleWaveform() { const r = sampleWaveformRect() const wx0 = r.x, wy0 = r.y, wW = r.w, wH = r.h // Clear waveform area to transparent (255 = transparent against text bg) clearSampleWaveformArea() const s = (samplesCache && samplesCache[smpListCursor]) || null if (!s || s.len === 0) { funkWaveLast = false; return } // Funk-repeat overlay. The per-instrument XOR mask flips loop-region bytes by 0xFF and persists // in the engine until stop-and-replay, so the overlay must remain even after the voice that // funked the sample goes idle — matching ProTracker's destructive EFx, whose inverted bytes // never revert until the song is reloaded. We therefore key the overlay off the persisted mask, // not off a currently-active funking voice. funkLE is clamped to the snapshot mask's coverage so // the bit lookup can never run off the (host) array. let funkMask = null, funkLS = 0, funkLE = 0 let activeFunk = false if (HUB.getPlaybackMode() !== PLAYMODE_NONE && s.loopEnd > s.loopStart) { const activeInst = findFunkInstForSample(s.usedBy) activeFunk = (activeInst > 0) const m = funkMaskForSample(s.usedBy, activeInst) if (m) { funkMask = m funkLS = s.loopStart funkLE = Math.min(s.loopEnd, funkLS + m.length * 8) } } funkWaveLast = activeFunk const memBase = audio.getMemAddr() const prevBank = audio.getSampleBank() || 0 let curBank = -1 // Zero line and value→y mapping (unsigned 8-bit: 255 → top, 0 → bottom). const baseY = wy0 + (wH >>> 1) const yOf = (v) => wy0 + (((wH * (255 - v)) / 255) | 0) // Read sample byte p (0..len-1) applying the live funk-flip overlay; sets the // shared `flippedAny` flag whenever a byte was inverted by the funk mask. let flippedAny = false const readByte = (p) => { const abs = s.ptr + p const bank = (abs / TAUT_SBANK_SIZE) | 0 if (bank !== curBank) { audio.setSampleBank(bank); curBank = bank } let v = sys.peek(memBase - (abs - bank * TAUT_SBANK_SIZE)) & 0xFF if (funkMask !== null && p >= funkLS && p < funkLE) { const k = p - funkLS if ((funkMask[k >>> 3] >>> (k & 7)) & 1) { v ^= 0xFF; flippedAny = true } } return v } // Zero/baseline line graphics.plotRect(wx0, baseY, wW, 1, colSmpWaveMid) // Per-sample bar width: how many pixels each sample spans, at least 1px. const rectW = Math.max(1, Math.ceil(wW / s.len)) if (s.len <= wW) { // Fewer samples than pixels: one baseline-filled bar per sample. for (let i = 0; i < s.len; i++) { flippedAny = false const yv = yOf(readByte(i)) const top = Math.min(baseY, yv) graphics.plotRect(wx0 + ((i * wW / s.len) | 0), top, rectW, Math.max(1, Math.abs(baseY - yv)), flippedAny ? colSmpWaveFunk : colSmpWaveLine) } } else { // More samples than pixels: reduce each 1px column to its min/max and // fill from the baseline through the envelope (a solid filled waveform). for (let col = 0; col < wW; col++) { const start = (col * s.len / wW) | 0 const end = Math.min(s.len, (((col + 1) * s.len / wW) | 0)) if (end <= start) continue const step = Math.max(1, ((end - start) / 8) | 0) let mn = 255, mx = 0 flippedAny = false for (let p = start; p < end; p += step) { const v = readByte(p) if (v < mn) mn = v if (v > mx) mx = v } const yTop = Math.min(baseY, yOf(mx)) const yBot = Math.max(baseY, yOf(mn)) graphics.plotRect(wx0 + col, yTop, 1, Math.max(1, yBot - yTop + 1), flippedAny ? colSmpWaveFunk : colSmpWaveLine) } } // Restore bank 0 for playback (engine expects bank 0 as default) audio.setSampleBank(prevBank) } // Per-frame driver: while a voice is funk-repeating the displayed sample, repaint the waveform // each frame so the overlay tracks the live mask. One settling repaint fires after funk stops // (funkWaveLast); the persisted overlay then stays until the engine clears the mask on replay. function tickFunkWaveform() { if (HUB.getPanel() !== VIEW_SAMPLES) { funkWaveLast = false; return } const s = (samplesCache && samplesCache[smpListCursor]) || null const funking = !!(s && s.len > 0 && HUB.getPlaybackMode() !== PLAYMODE_NONE && findFunkInstForSample(s.usedBy) > 0) if (funking || funkWaveLast) drawSampleWaveform() } function computeSampleRAMBytes() { if (!samplesCache) return 0 let total = 0 for (let i = 0; i < samplesCache.length; i++) total += samplesCache[i].len return total } // 16 banks x 524288 = 8 MB = 8192k. Hardcoded to match the user-visible budget. const SMP_RAM_MAX_K = 8192 function formatSampleRamK(bytes) { const k = bytes / 1024 return (k < 10 ? k.toFixed(2) : k < 100 ? k.toFixed(1) : Math.round(k).toString()) } function drawSamplesRamFooter() { const bytes = computeSampleRAMBytes() const ramStr = formatSampleRamK(bytes) + 'k / ' + SMP_RAM_MAX_K + 'k' const y = PTNVIEW_OFFSET_Y//SMP_RIGHT_Y + SMP_PROP_H - 1 con.move(y, SCRW - 13) // con.color_pair(colSmpPropLabel, colBackPtn) // print(('Sample RAM' + ' ').substring(0, 10)) con.color_pair(colSmpPropValue, colBackPtn) print(ramStr) } function drawSamplesEditButton() { const y = SMP_BTN_Y con.move(y, SMP_RIGHT_X) con.color_pair(colSmpUsedHdr, colBackPtn) print('[ E ]') con.color_pair(colSmpPropValue, colBackPtn) const label = ' Edit sample' print(label) const rest = SCRW - (SMP_RIGHT_X + 5 + label.length) + 1 if (rest > 0) print(' '.repeat(rest)) } function clearSamplesPanel() { // Panel area only — leave the hint row (SCRH) alone; drawControlHint owns it. for (let y = PTNVIEW_OFFSET_Y; y < SCRH; y++) fillLine(y, colSmpPropValue, colBackPtn) } function drawSamplesContents(wo) { if (samplesCache === null) refreshSamplesCache() clampSamplesCursor(false) // respect the current scroll (free wheel scroll) clearSamplesPanel() drawSamplesListColumn() drawSamplesSeparator() drawSamplesProperties() drawSamplesRamFooter() drawSamplesUsedBy() drawSampleWaveform() drawSamplesEditButton() // The list column just repainted col 1 with a leading space on every row, // so any prior blob is gone — invalidate the cache, then re-stamp blobs // immediately when playback is live so the user does not see a one-frame gap. invalidateSamplesBlob() if (HUB.getPlaybackMode() !== PLAYMODE_NONE) drawSamplesPlayBlobs() // Same reasoning for the waveform playhead cursor — drawSampleWaveform just // wiped the area, so its prior column is irrelevant. Re-stamp if playing. invalidateSmpCursor() if (HUB.getPlaybackMode() !== PLAYMODE_NONE) drawSampleCursor() } // Jump into the in-process instrument viewer with the cursor parked on `instSlot`. // `instSlotToIdx` is the {slot → cache index} map built by refreshInstrumentsCache; // when the slot isn't in the cache (rare — empty slot with no name), we fall back // to cursor 0 instead of failing the switch. function launchInstrumentViewerFor(instSlot) { if (instrumentsCache === null) refreshInstrumentsCache() const idx = (instSlotToIdx && instSlotToIdx[instSlot] != null) ? instSlotToIdx[instSlot] : -1 if (idx >= 0) instListCursor = idx clampInstrumentsCursor() switchToPanel(VIEW_INSTRMNT) } function samplesInput(wo, event) { if (event[0] !== 'key_down') return const keysym = event[1] const keyJustHit = (1 == event[2]) const shiftDown = (event.includes(59) || event.includes(60)) const moveDelta = shiftDown ? 8 : 1 const n = samplesCache ? samplesCache.length : 0 if (n === 0) { if (keysym === 'e' || keysym === 'E') { openSampleEdit(-1) } return } if (keysym === '') { smpListCursor -= moveDelta; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return } if (keysym === '') { smpListCursor += moveDelta; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return } if (keysym === '') { smpListCursor -= SMP_LIST_H; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return } if (keysym === '') { smpListCursor += SMP_LIST_H; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return } if (keysym === '') { smpListCursor = 0; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return } if (keysym === '') { smpListCursor = n - 1; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return } if (keysym === 'e' || keysym === 'E') { openSampleEdit(smpListCursor) return } if (keysym === '\n') { // Open the first instrument that uses this sample in the (stub) inst viewer const s = samplesCache[smpListCursor] if (s && s.usedBy.length > 0) { launchInstrumentViewerFor(s.usedBy[0]) } return } } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // END SAMPLES VIEWER ///////////////////////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // INSTRUMENTS VIEWER ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // Mirrors the Samples tab skeleton: list on the left, multi-tabbed property pane // on the right. Tabs are General / Volume / Panning / Pitch / Filter — the latter // four each carry an envelope graph rendered through the graphics layer. Pitch and // Filter edit the two pf-envelope slots, routed by each slot's m-bit. // // All field offsets/encodings follow terranmon.txt §"Instrument bin" (offsets // 0..196). Envelope nodes (offsets 21 / 71 / 121) are 25 × {value u8, time u8} // where the time byte is a 3.5 unsigned minifloat — converted here using the // same decoder formula as ThreeFiveMinifloat.kt / taud_common.py MINUFLOAT_LUT. // 3.5 unsigned minifloat: exp = bits 7..5 (0..7), mant = bits 4..0 (0..31). // exp == 0 : value = mant / 256 (smallest non-zero step = 1/256 s) // exp > 0 : value = (mant + 32) * 2^(exp - 9) (max = 15.75 s at 0xFF) function envTimeFromByte(b) { const exp = (b >>> 5) & 7 const mant = b & 31 return (exp === 0) ? (mant / 256) : ((mant + 32) * Math.pow(2, exp - 9)) } // Decode one of the three envelopes from the 256-byte instrument record. `kind` // selects the node array (vol/pan/pf) and the LOOP/SUSTAIN word locations. // nodes: {value, durByte, durSec} array, truncated at the first dur=0 node // (the terminator — see terranmon.txt §envelope nodes "0 = hold"). // terminatorIdx: index of the terminator, or -1 if all 25 slots are walked. function decodeEnvelope(rec, kind) { const isPf = (kind === 'pf' || kind === 'pf2') const nodeBase = (kind === 'vol') ? 21 : (kind === 'pan') ? 71 : (kind === 'pf2') ? 201 : 121 const loopOff = (kind === 'vol') ? 15 : (kind === 'pan') ? 17 : (kind === 'pf2') ? 197 : 19 const sustOff = (kind === 'vol') ? 189 : (kind === 'pan') ? 191 : (kind === 'pf2') ? 199 : 193 const valMask = (kind === 'vol') ? 0x3F : 0xFF const loopWord = rec[loopOff] | (rec[loopOff + 1] << 8) const sustWord = rec[sustOff] | (rec[sustOff + 1] << 8) const present = ((loopWord >>> 13) & 1) === 1 const loopEnable = ((loopWord >>> 5) & 1) === 1 const loopStart = (loopWord >>> 8) & 0x1F const loopEnd = (loopWord) & 0x1F const carry = ((loopWord >>> 6) & 1) === 1 const panUseDef = (kind === 'pan') && (((loopWord >>> 7) & 1) === 1) const pfFilter = isPf && (((loopWord >>> 7) & 1) === 1) const sustEnable = ((sustWord >>> 5) & 1) === 1 const sustStart = (sustWord >>> 8) & 0x1F const sustEnd = (sustWord) & 0x1F const nodes = [] let terminatorIdx = -1 for (let i = 0; i < 25; i++) { const value = rec[nodeBase + i * 2] & valMask const durByte = rec[nodeBase + i * 2 + 1] & 0xFF const durSec = envTimeFromByte(durByte) nodes.push({ value, durByte, durSec }) if (durByte === 0) { terminatorIdx = i; break } } return { kind, present, loopEnable, loopStart, loopEnd, carry, panUseDef, pfFilter, sustEnable, sustStart, sustEnd, nodes, terminatorIdx, valueMax: valMask, loopOff, sustOff, nodeBase // byte offsets — the editor pokes these directly } } // Decode a Metainstrument record (terranmon.txt §"Metainstrument definition"): // byte0 = type (0 = layered), byte1 = layer count, bytes2-3 = 0xFFFF identifier, // then `count` 10-byte layer descriptors from byte4. Each: u8 instIdx, u8 mixOctet // (Perceptually-Significant-Octet dB, 159 = unity), s16 detune (4096-TET), // u16 pitchStart, u16 pitchEnd, u8 volStart, u8 volEnd (0..63). function decodeMetaRecord(rec) { const count = rec[1] & 0xFF const layers = [] let o = 4 for (let i = 0; i < count && o + 10 <= 256; i++, o += 10) { let det = rec[o+2] | (rec[o+3] << 8); if (det >= 0x8000) det -= 0x10000 layers.push({ instIdx: rec[o] & 0xFF, mixOctet: rec[o+1] & 0xFF, detune: det, pitchStart: rec[o+4] | (rec[o+5] << 8), pitchEnd: rec[o+6] | (rec[o+7] << 8), volStart: rec[o+8] & 0x3F, volEnd: rec[o+9] & 0x3F }) } return { isMeta: true, metaType: rec[0] & 0xFF, layers } } // True when a 256-byte record is a Metainstrument (samplePtr high 16 bits == 0xFFFF). function recordIsMeta(rec) { return ((rec[2] | (rec[3] << 8)) & 0xFFFF) === 0xFFFF } // Decode the full 256-byte instrument record into a structured object suitable // for display. Field offsets/encodings track terranmon.txt §"Instrument bin". function decodeInstFull(rec) { if (recordIsMeta(rec)) return decodeMetaRecord(rec) const samplePtr = (rec[0]) | (rec[1] << 8) | (rec[2] << 16) | (rec[3] * 0x1000000) const sampleLen = rec[4] | (rec[5] << 8) const c4Rate = rec[6] | (rec[7] << 8) const playStart = rec[8] | (rec[9] << 8) const sLoopStart = rec[10] | (rec[11] << 8) const sLoopEnd = rec[12] | (rec[13] << 8) const sampleFlags = rec[14] const igv = rec[171] const fadeoutLo = rec[172] const fadeoutHi = rec[173] const fadeout = fadeoutLo | ((fadeoutHi & 0x0F) << 8) const volSwing = rec[174] const vibSpeed = rec[175] const vibSweep = rec[176] const defPan = rec[177] const pitchPanCenter = rec[178] | (rec[179] << 8) let pitchPanSep = rec[180]; if (pitchPanSep >= 128) pitchPanSep -= 256 const panSwing = rec[181] const defCutoff = rec[182] const defReso = rec[183] // Filter interpretation mode — byte 173 bit 4 (terranmon §Instrument bin). false = IT (8-bit // cutoff/resonance in bytes 182/183), true = SoundFont (16-bit: cutoff cents in 182<<8|252, // resonance centibels in 183<<8|253). Mirrors AudioAdapter.TaudInst.filterSfMode. const filterSfMode = ((fadeoutHi >>> 4) & 1) === 1 const defCutoff16 = (rec[182] << 8) | rec[252] const defReso16 = (rec[183] << 8) | rec[253] let detune = rec[184] | (rec[185] << 8); if (detune >= 0x8000) detune -= 0x10000 const instFlag = rec[186] // NNA UI value: 0..3 = traditional (bits 0-1); 4 = Key Lift (bit 5 set, // bits 0-1 = 00 — the 0b100 "Nnn" pattern, terranmon byte 186). const nna = ((instFlag >>> 5) & 1) ? 4 : (instFlag & 3) const vibWaveform = (instFlag >>> 2) & 7 const vibDepth = rec[187] const vibRate = rec[188] const dcByte = rec[195] const dct = dcByte & 3 const dca = (dcByte >>> 2) & 3 const defNoteVol = rec[196] // Two pf-envelope slots (slot 1 bytes 19/121/193, slot 2 bytes 197/201/199). // Route each into the pitch or filter role by its m-bit (LOOP-word bit 7): // 0 = pitch, 1 = filter — mirrors AudioAdapter.resolveActiveEnvelopes (a present // slot wins its role; slot 2 is processed last). Empty roles bind to the free // complementary slot so the Pitch/Filter tabs can create one in-place; on a // fully-blank instrument the defaults match midi2taud's fixed convention // (slot 1 = filter, slot 2 = pitch — see project_midi2taud), resolved filter-first. const pfEnv = decodeEnvelope(rec, 'pf') const pf2Env = decodeEnvelope(rec, 'pf2') let pitchEnv = null, filterEnv = null if (pfEnv.present) { if (pfEnv.pfFilter) filterEnv = pfEnv; else pitchEnv = pfEnv } if (pf2Env.present) { if (pf2Env.pfFilter) filterEnv = pf2Env; else pitchEnv = pf2Env } if (!filterEnv) filterEnv = (pitchEnv === pfEnv) ? pf2Env : pfEnv if (!pitchEnv) pitchEnv = (filterEnv === pf2Env) ? pfEnv : pf2Env return { samplePtr, sampleLen, c4Rate, playStart, sLoopStart, sLoopEnd, sampleFlags, igv, fadeout, volSwing, vibSpeed, vibSweep, defPan, pitchPanCenter, pitchPanSep, panSwing, defCutoff, defReso, filterSfMode, defCutoff16, defReso16, detune, nna, vibWaveform, vibDepth, vibRate, dct, dca, defNoteVol, volEnv: decodeEnvelope(rec, 'vol'), panEnv: decodeEnvelope(rec, 'pan'), pfEnv, pf2Env, pitchEnv, filterEnv } } // Scan slots 1..255. Keep any slot that either has a non-empty sample length // or a project-data INam entry. Returns a flat list — UI cursor walks this, // not raw slot numbers — and a {slot → cacheIdx} reverse map for // launchInstrumentViewerFor's jump-to-slot path. let instrumentsCache = null let instSlotToIdx = null function buildInstrumentIndex() { const list = [] const names = (songsMeta && songsMeta.instNames) || [] for (let i = 1; i < TAUT_INST_COUNT; i++) { const rec = readInstRecord(i) const sampleLen = rec[4] | (rec[5] << 8) const nm = names[i] || '' if (sampleLen === 0 && nm === '') continue list.push({ slot: i, name: nm, decoded: decodeInstFull(rec) }) } instSlotToIdx = {} for (let i = 0; i < list.length; i++) instSlotToIdx[list[i].slot] = i return list } function refreshInstrumentsCache() { instrumentsCache = buildInstrumentIndex() } // ── Layout ───────────────────────────────────────────────────────────────── const INST_LIST_X = 1 const INST_LIST_BODY_W = 27 const INST_LIST_W = INST_LIST_BODY_W + 1 const INST_LIST_SCROLL_X = INST_LIST_X + INST_LIST_BODY_W const INST_LIST_Y = PTNVIEW_OFFSET_Y const INST_LIST_H = PTNVIEW_HEIGHT const INST_SEP_X = INST_LIST_X + INST_LIST_W const INST_RIGHT_X = INST_SEP_X + 1 const INST_RIGHT_Y = PTNVIEW_OFFSET_Y const INST_RIGHT_W = SCRW - INST_RIGHT_X + 1 const INST_BTN_Y = SCRH - 1 const INST_TAB_Y = INST_RIGHT_Y // tab strip row const INST_BODY_Y = INST_RIGHT_Y + 2 // first content row const INST_BODY_H = INST_BTN_Y - INST_BODY_Y // content rows (excludes button) // General tab content does not fit in the 24-row body area of an 80x32 terminal, // so it splits into two pages (sample/volume/panning on page 1; // filter/vibrato/note-actions/tuning on page 2). const INST_TAB_NAMES = ['Gen.1', 'Gen.2', 'Volume', 'Pan', 'Pitch', 'Filter'] const INST_TAB_GEN1 = 0, INST_TAB_GEN2 = 1, INST_TAB_VOL = 2, INST_TAB_PAN = 3, INST_TAB_PIT = 4, INST_TAB_FILT = 5 const colInstListBg = colBackPtn const colInstListSel = colHighlight const colInstListNumFg = colInst const colInstListNameFg = colStatus const colInstGroupHdr = colVoiceHdr const colInstLabel = colStatus const colInstValue = colWHITE const colInstHighlight = colVol const colInstEnvLine = 77 // bright cyan-ish, same as sample wave const colInstEnvNode = 198 // pink-ish — node markers stand out from line const colInstEnvAxis = 246 // dim grey for zero/center line const colInstEnvHair = 251 // darker grey — quarter-point hairlines (dashed) const colInstEnvLoop = 220 // muted yellow-orange — loop range band const colInstEnvSust = 145 // muted yellow-green — loop range band const colInstEnvLoopSuper= 230 // muted yellow-orange — loop range band const colInstEnvSustSuper= 155 // muted yellow-green — loop range band let instListScroll = 0 let instListCursor = 0 let instSubTab = INST_TAB_GEN1 // followCursor: see clampSamplesCursor — false = free wheel scroll without moving // the selection. function clampInstrumentsCursor(followCursor = true) { const n = instrumentsCache ? instrumentsCache.length : 0 if (instListCursor < 0) instListCursor = 0 if (instListCursor >= n) instListCursor = Math.max(0, n - 1) if (followCursor) { if (instListCursor < instListScroll) instListScroll = instListCursor if (instListCursor >= instListScroll + INST_LIST_H) instListScroll = instListCursor - INST_LIST_H + 1 } const maxS = Math.max(0, n - INST_LIST_H) if (instListScroll > maxS) instListScroll = maxS if (instListScroll < 0) instListScroll = 0 } function drawInstrumentsListColumn() { const n = instrumentsCache ? instrumentsCache.length : 0 for (let row = 0; row < INST_LIST_H; row++) { const idx = instListScroll + row const y = INST_LIST_Y + row con.move(y, INST_LIST_X) if (idx >= n) { con.color_pair(colInstListNameFg, colInstListBg) print(' '.repeat(INST_LIST_BODY_W)) continue } const e = instrumentsCache[idx] const isSel = (idx === instListCursor) const back = isSel ? colInstListSel : colInstListBg const numStr = e.slot.toString(16).toUpperCase().padStart(2, '0') const nameRaw = (e.name && e.name.length) ? e.name : '(instrument $' + numStr + ')' const nameW = INST_LIST_BODY_W - 6 const nameStr = (nameRaw.length > nameW ? nameRaw.substring(0, nameW) : nameRaw.padEnd(nameW)) con.color_pair(colInstListNumFg, back); print(' ' + numStr + ' ') con.color_pair(colInstListNameFg, back); print(' ') con.color_pair(isSel ? colWHITE : colInstListNameFg, back); print(nameStr) con.color_pair(colInstListNameFg, back); print(' ') } // scroll indicator column if (n > INST_LIST_H) { const maxScroll = n - INST_LIST_H const indPos = (maxScroll === 0) ? 0 : ((instListScroll * (INST_LIST_H - 1) / maxScroll) | 0) for (let r = 0; r < INST_LIST_H; r++) { con.move(INST_LIST_Y + r, INST_LIST_SCROLL_X) con.color_pair(colScrollBar, colInstListBg) let scrollChar = (r == 0) ? sym.taut_scrollgutter_top : (r == INST_LIST_H - 1) ? sym.taut_scrollgutter_bot : sym.taut_scrollgutter_mid if (r == indPos) scrollChar += 3; con.addch(scrollChar) } } else { for (let r = 0; r < INST_LIST_H; r++) { con.move(INST_LIST_Y + r, INST_LIST_SCROLL_X) con.color_pair(colStatus, colInstListBg); print(' ') } } } function drawInstrumentsSeparator() { con.color_pair(colSep, colBackPtn) for (let y = INST_LIST_Y; y < SCRH; y++) { con.move(y, INST_SEP_X); con.prnch(VERT) } } // Geometry helper for one tab chip in the right-pane tab strip. Tabs partition // INST_RIGHT_W into 4 equal-width chips with a 1-col gap at each boundary; the // click handler uses the same formula in reverse to map cx → tab index. function instTabRect(tabIdx) { const slotW = (INST_RIGHT_W / INST_TAB_NAMES.length) | 0 return { x: INST_RIGHT_X + tabIdx * slotW, y: INST_TAB_Y, w: slotW } } function drawInstrumentsTabStrip() { // background row for the tab strip con.move(INST_TAB_Y, INST_RIGHT_X) con.color_pair(colTabBarOrn, colTabBarBack) print(' '.repeat(INST_RIGHT_W)) for (let i = 0; i < INST_TAB_NAMES.length; i++) { const r = instTabRect(i) const active = (instSubTab === i) const fg = active ? colTabActive : colTabInactive const bg = active ? colTabBarBack2 : colTabBarBack con.move(r.y, r.x) con.color_pair(fg, bg) const lbl = INST_TAB_NAMES[i] const pad = Math.max(0, r.w - lbl.length) const padL = pad >>> 1 const padR = pad - padL let colFore = active ? colTabActive : colTabInactive let colBack = active ? colTabBarBack2 : colTabBarBack let colFore2 = active ? colTabBarBack2 : colTabBarBack let colBack2 = active ? colTabBarBack : colTabBarBack let spcL = active ? sym.leftshade : ' ' let spcR = active ? sym.rightshade : ' ' con.color_pair(colFore2, colBack2); print(spcL) con.color_pair(colFore, colBack); print(' '.repeat(padL-1) + lbl + ' '.repeat(padR-1)) con.color_pair(colFore2, colBack2); print(spcR) } // 1-row gap under the tabs con.move(INST_TAB_Y + 1, INST_RIGHT_X) con.color_pair(colInstValue, colBackPtn) print(' '.repeat(INST_RIGHT_W)) } // Clear the right-pane body area (tab content rows + button row). function clearInstrumentsBody() { for (let r = 0; r < INST_BODY_H + 1; r++) { con.move(INST_BODY_Y + r, INST_RIGHT_X) con.color_pair(colInstValue, colBackPtn) print(' '.repeat(INST_RIGHT_W)) } } // ── Text helpers ─────────────────────────────────────────────────────────── function _hex(n, w) { return n.toString(16).toUpperCase().padStart(w, '0') } function _signed(n) { return (n >= 0 ? '+' : '') + n } function loopModeNameInst(flags) { const lp = flags & 3 const sus = (flags >>> 2) & 1 const names = ['None', 'Forward', 'Pingpong', 'Oneshot'] return names[lp] + (sus ? ' (sustain)' : '') } // Clickable button-group option lists. NNA's 5th option is Key Lift (flag bit 5, // the 0b100 pattern: MIDI-exact key-up — envelope jumps to the release nodes); // DCT uses every value; DCA's 4th slot is reserved (dropped); vibrato exposes // the 5 engine-supported waves (sine/ramp-dn/square/random/ramp-up — see // AudioAdapter.advanceAutoVibrato). const NNA_NAMES = ['Off', 'Cut', 'Cont.', 'Fade', 'Lift'] const DCT_NAMES = ['Never', 'Note', 'Sample', 'Inst.'] const DCA_OPTIONS = ['Cut', 'Off', 'Fade'] // Filter interpretation mode (base byte 173 bit 4): IT all-pole vs SoundFont biquad. const FILTER_MODE_OPTIONS = ['ImpulseTracker', 'SoundFont2'] const VIB_WF_OPTIONS = ['\u00D8\u00D9', '\u00A5\u00A6', '\u00B4\u00B4', '\u00F3\u00F3', '\u00B5\u00B6']//['Sine', 'Ramp-dn', 'Square', 'Random', 'Ramp-up'] // Place a value at column INST_RIGHT_X + labelW. Labels are colour // colInstLabel; values are colInstValue. Truncates to fit INST_RIGHT_W. function drawLabelRow(y, label, value, labelW) { if (labelW == null) labelW = 12 con.move(y, INST_RIGHT_X) con.color_pair(colInstLabel, colBackPtn) print((label + ' '.repeat(labelW)).substring(0, labelW)) con.color_pair(colInstValue, colBackPtn) const maxV = INST_RIGHT_W - labelW const v = (value == null) ? '' : String(value) print(v.length > maxV ? v.substring(0, maxV) : v) } function drawGroupHeader(y, title) { con.move(y, INST_RIGHT_X) con.color_pair(colInstGroupHdr, colBackPtn) const txt = '\u00FB\u00FB ' + title + ' ' const dashes = Math.max(0, INST_RIGHT_W - txt.length) print(txt + `\u00FB`.repeat(dashes)) } // ── Inline value sliders (Gen.1 / Gen.2 knob editing) ────────────────────── // A horizontal slider painted alongside a numeric field. The knob is one 7-px // cell wide and slides with per-pixel precision via the sym.slider1..7 glyphs // (slider1 = knob snug in one cell; slider2..7 straddle two cells at a 1..6 px // offset). The trough is a flat colBLACK bar capped by inverse-video round pads // (0xAB left, 0xAA right). Two trough widths only: small (10) and wide (20). // // Clicking/dragging a trough drives the knob: the label updates live as the knob // moves, and the instrument byte(s) are written only on mouse release (see // runSliderDrag). instSliders is rebuilt on every Gen.1/Gen.2 body repaint and // hit-tested by the panel's slider mouse region. const SLIDER_LABEL_W = 10 const SLIDER_END_COL = SCRW - 1 // common right edge const SLIDER_SMALL_SX = SLIDER_END_COL - (SLIDER_TW_SMALL + 1) // small left-pad col const SLIDER_WIDE_SX = SLIDER_END_COL - (SLIDER_TW_WIDE + 1) // wide left-pad col const SLIDER_VALUE_W = SLIDER_SMALL_SX - (INST_RIGHT_X + SLIDER_LABEL_W) const SLIDER_NUM_X = INST_RIGHT_X + SLIDER_LABEL_W // editable raw-number capsule (left-cap col) const sliderGlyphs = [sym.slider1, sym.slider2, sym.slider3, sym.slider4, sym.slider5, sym.slider6, sym.slider7] // Rebuilt by drawInstTabGeneral1/2; each entry is // { y, sx, tw, troughLeftPx, min, max, render(val), commit(val) }. let instSliders = [] // Rebuilt by drawInstTabGeneral2 (radio button groups) and the envelope tabs // (checkboxes); hit-tested by the panel body mouse region. Cleared every redraw, // so they only ever hold the currently-shown tab's widgets. // instButtons: { y, x, w, value, commit(value) } // instCheckboxes: { y, xs, xe, off, bit } (off = instrument byte, bit index) let instButtons = [] let instCheckboxes = [] // Paint the trough + knob for value-fraction `frac` (0..1) at (y, sx). function drawSlider(y, sx, tw, frac) { const pmax = (tw - 1) * CELL_PW const p = Math.round((frac < 0 ? 0 : frac > 1 ? 1 : frac) * pmax) const cell = (p / CELL_PW) | 0 const sub = p - cell * CELL_PW const cells = new Array(tw).fill(' ') if (sub === 0) cells[cell] = sliderGlyphs[0] else { const g = sliderGlyphs[sub] // 2-char glyph straddling cell..cell+1 cells[cell] = g[0] if (cell + 1 < tw) cells[cell + 1] = g[1] } con.color_pair(colBLACK, colStatus); con.move(y, sx); con.prnch(0xAB) con.color_pair(colStatus, colBLACK); con.move(y, sx + 1); print(cells.join('')) con.color_pair(colBLACK, colStatus); con.move(y, sx + tw + 1); con.prnch(0xAA) } // Pixel X (mouse) → quantised slider value, knob centred under the cursor. function sliderMouseToVal(s, pxX) { const pmax = (s.tw - 1) * CELL_PW let knob = Math.round((pxX - s.troughLeftPx) - CELL_PW / 2) if (knob < 0) knob = 0 if (knob > pmax) knob = pmax const frac = (pmax === 0) ? 0 : knob / pmax let v = Math.round(s.min + frac * (s.max - s.min)) if (v < s.min) v = s.min if (v > s.max) v = s.max return v } // Write byte pairs [[offset, value], ...] into instrument `slot`'s peripheral // record. The audio adapter decodes these live, so edits take effect at once. function instWriteBytes(slot, pairs) { const memBase = audio.getMemAddr() const base = TAUT_INST_WINDOW_OFF + slot * TAUT_INST_RECORD_SIZE for (let i = 0; i < pairs.length; i++) { sys.poke(memBase - (base + pairs[i][0]), pairs[i][1] & 0xFF) } HUB.markUnsaved() } // Drag interaction: live label updates while held, commit on release, ESC cancels. function runSliderDrag(s, downEvent) { let val = sliderMouseToVal(s, downEvent[1]) let committed = false s.render(val) let dragging = true while (dragging) { input.withEvent(e => { const t = e[0] if (t === 'mouse_move') { const nv = sliderMouseToVal(s, e[1]) if (nv !== val) { val = nv; s.render(val) } } else if (t === 'mouse_up') { dragging = false; committed = true } else if (t === 'key_down' && e[1] === '') { dragging = false } // mouse_down echo and other events are ignored during a drag }) } if (committed) s.commit(val) if (s.repaint) s.repaint(); else drawInstrumentsContents() } // Annotation helpers — short context shown next to the raw-number capsule // (the capsule itself already shows the decimal value). Kept terse for the // narrow value field. function annHex(v) { return '$' + _hex(v, 2) } function annFilter(v) { return (v === 0xFF) ? 'off' : '$' + _hex(v, 2) } function annFadeout(v) { if (v <= 0) return 'none' if (v >= 1024) return 'cut' return '~' + Math.round(1024 / v) + 't' } // SF-mode filter annotations. Cutoff is SoundFont absolute cents → Hz // (8.176·2^(cents/1200), matching AudioAdapter.refreshVoiceFilter); resonance is // centibels → dB (cb/10). Kept ≤6 cols to fit the narrow value field. function annSfCutoff(v) { if (v >= 0xFFFF) return 'off' const hz = 8.176 * Math.pow(2, v / 1200) if (hz >= 10000) return Math.round(hz / 1000) + 'k' if (hz >= 1000) return (hz / 1000).toFixed(1) + 'k' return Math.round(hz) + '' } function annSfReso(v) { if (v >= 0xFFFF) return 'flat' const db = v / 10 return (db >= 10 ? Math.round(db) : db.toFixed(1)) + 'dB' } // Draw an editable raw-number field: a black (col 240) capsule with CP437 // half-block end caps (0xDD left, 0xDE right). The black-bg + cap scheme marks // the field as "type a number here". `x` is the left-cap column; `digits` number // cells follow (left-aligned, space-padded), then the right cap. function drawNumCapsule(y, x, digits, numStr) { con.color_pair(colBackPtn, colBLACK); con.move(y, x); con.prnch(0xDD) con.color_pair(colInstValue, colBLACK); con.move(y, x + 1) print((numStr + ' '.repeat(digits)).substring(0, digits)) con.color_pair(colBackPtn, colBLACK); con.move(y, x + 1 + digits); con.prnch(0xDE) } // Emit a small-slider row: label, editable raw-number capsule, annotation, knob. // `ann(val)` returns the short annotation (or null); `encode(val)` returns the // byte pairs to poke on commit. function sliderRow(y, e, label, val0, min, max, ann, encode, reupload) { const sx = SLIDER_SMALL_SX, tw = SLIDER_TW_SMALL const digits = Math.max(String(min).length, String(max).length) const nx = SLIDER_NUM_X, nw = digits + 2 const annX = nx + nw, annW = sx - annX // fill up to the slider's left pad const render = (val) => { const knob = (val < min) ? min : (val > max) ? max : val // clamp position only con.move(y, INST_RIGHT_X) con.color_pair(colInstLabel, colBackPtn) print((label + ' '.repeat(SLIDER_LABEL_W)).substring(0, SLIDER_LABEL_W)) drawNumCapsule(y, nx, digits, String(val)) con.move(y, annX); con.color_pair(colInstValue, colBackPtn) const a = ann ? (' ' + ann(val)) : '' print((a + ' '.repeat(annW)).substring(0, annW)) drawSlider(y, sx, tw, (max === min) ? 0 : (knob - min) / (max - min)) } render(val0) instSliders.push({ y, sx, tw, troughLeftPx: sx * CELL_PW, min, max, render, numY: y, numX: nx, numW: nw, ndig: digits, // raw-number capsule geometry val: val0, // base for wheel ±1 / edit prefill (clamped on use) commit: (v) => { if (reupload) { // Metainstrument: a live poke is invisible — getByte serves the cached // metaRaw and setByte uses the normal-record layout. So read the current // record, splice in the edited byte(s), and re-upload; loadRecord then // re-parses metaRaw + the layer table. const rec = Array.prototype.slice.call(readInstRecord(e.slot)) const pairs = encode(v) for (let k = 0; k < pairs.length; k++) rec[pairs[k][0]] = pairs[k][1] & 0xFF audio.uploadInstrument(e.slot, rec) HUB.markUnsaved() } else { instWriteBytes(e.slot, encode(v)) } e.decoded = decodeInstFull(readInstRecord(e.slot)) } }) } // Emit the wide two-row Detune slider: knob on `y`, cents readout on `y+1`. // The field is a full signed 16-bit, but the knob's interactive range is the // practical ±4096 (one octave). An out-of-range stored value still displays // truthfully (its true number + cents), with the knob pinned to the nearer end; // it is snapped into range the instant the user drags or wheels the knob. function detuneRow(y, e, val0) { const sx = SLIDER_WIDE_SX, tw = SLIDER_TW_WIDE const min = -4096, max = 4096 const digits = 6 // fits a full signed 16-bit display const nx = INST_RIGHT_X + 4, nw = digits + 2 const render = (val) => { const knob = (val < min) ? min : (val > max) ? max : val // clamp position only con.move(y, INST_RIGHT_X) con.color_pair(colInstLabel, colBackPtn) print((' Detune:' + ' '.repeat(20)).substring(0, sx - INST_RIGHT_X)) drawSlider(y, sx, tw, (knob - min) / (max - min)) // Readout row: editable raw-number capsule + cents. con.move(y + 1, INST_RIGHT_X); con.color_pair(colInstValue, colBackPtn); print(' ') drawNumCapsule(y + 1, nx, digits, String(val)) const cents = val * 1200 / 4096 // 1 octave = 4096 TET steps = 1200 cents con.move(y + 1, nx + nw); con.color_pair(colInstValue, colBackPtn) const s = ' (' + cents.toFixed(1) + ' cents, 4096-TET)' print((s + ' '.repeat(INST_RIGHT_W)).substring(0, SCRW - (nx + nw) + 1)) } render(val0) instSliders.push({ y, sx, tw, troughLeftPx: sx * CELL_PW, min, max, render, numY: y + 1, numX: nx, numW: nw, ndig: digits, // capsule on the readout row val: val0, // true value; snapped into range on interact commit: (v) => { instWriteBytes(e.slot, [[184, v & 0xFF], [185, (v >> 8) & 0xFF]]); e.decoded = decodeInstFull(readInstRecord(e.slot)) } }) } // Hit-test the live instSliders list (Gen.1/Gen.2 only). Separate tests for the // knob trough (drag / wheel) and the raw-number capsule (click-to-edit / wheel). // Sliders are live on the Gen.1/Gen.2 tabs, and on the Metainstrument layer view // (which registers per-layer Mix/Detune sliders regardless of sub-tab). function instSlidersActive() { if (instSubTab === INST_TAB_GEN1 || instSubTab === INST_TAB_GEN2) return true const e = instrumentsCache && instrumentsCache[instListCursor] return !!(e && e.decoded && e.decoded.isMeta) } function sliderTroughAt(cy, cx) { if (!instSlidersActive()) return null for (let i = 0; i < instSliders.length; i++) { const s = instSliders[i] if (cy === s.y && cx >= s.sx && cx <= s.sx + s.tw + 1) return s } return null } function sliderCapsuleAt(cy, cx) { if (!instSlidersActive()) return null for (let i = 0; i < instSliders.length; i++) { const s = instSliders[i] if (cy === s.numY && cx >= s.numX && cx < s.numX + s.numW) return s } return null } // Hit-test the live instButtons / instCheckboxes lists. Rebuilt every body // redraw, so they only hold the current tab's widgets — no subtab gate needed. function instButtonAt(cy, cx) { for (let i = 0; i < instButtons.length; i++) { const b = instButtons[i] if (cy === b.y && cx >= b.x && cx < b.x + b.w) return b } return null } function instCheckboxAt(cy, cx) { for (let i = 0; i < instCheckboxes.length; i++) { const c = instCheckboxes[i] if (cy === c.y && cx >= c.xs && cx <= c.xe) return c } return null } // Open the inline number editor over a slider's capsule; commit clamps to range. function editSliderNumber(s) { const nv = openInlineNumEdit(s.numY, s.numX + 1, s.ndig, s.val, s.min, s.max) if (nv !== null) { s.val = nv; s.commit(nv) } drawInstrumentsContents() // repaint (restores capsule styling; reflects new value) } // ── Pill buttons & checkboxes (instrument property toggles) ───────────────── // Reuse the input-field "capsule" look (drawNumCapsule) as a tappable control: a // pill with CP437 half-block end caps that blend the fill colour into the panel // background. Unselected = black fill / white text; selected = white fill / black // text. Used as radio-style enum pickers (NNA/DCT/DCA/vibrato wave) and, in // checkbox form, for the envelope boolean flags. // Read-modify-write a `width`-bit field at `shift` of instrument byte `off`, // preserving the surrounding bits. Re-reads first so a concurrent engine write // isn't clobbered, then refreshes the decoded cache. function instWriteField(e, off, shift, width, v) { const mask = ((1 << width) - 1) << shift const rec = readInstRecord(e.slot) const nb = (rec[off] & ~mask) | ((v << shift) & mask) instWriteBytes(e.slot, [[off, nb]]) e.decoded = decodeInstFull(readInstRecord(e.slot)) } // Flip a single bit of instrument byte `off` (checkbox click). function toggleInstBit(e, off, bit) { const rec = readInstRecord(e.slot) instWriteBytes(e.slot, [[off, rec[off] ^ (1 << bit)]]) e.decoded = decodeInstFull(readInstRecord(e.slot)) } // Draw one pill button at (y, x). Cap scheme mirrors drawNumCapsule so it reads // as the same "interactive field" affordance. Returns the pill's total width // (2 caps + a 1-space-padded label). function drawButton(y, x, label, selected) { const fill = selected ? colWHITE : colBLACK const txt = selected ? colBLACK : colInstValue const inner = ' ' + label + ' ' con.color_pair(colBackPtn, fill); con.move(y, x); con.prnch(0xDD) con.color_pair(txt, fill); con.move(y, x + 1); print(inner) con.color_pair(colBackPtn, fill); con.move(y, x + 1 + inner.length); con.prnch(0xDE) return inner.length + 2 } // Emit a labelled radio-button group: a label, then one pill per option (the // active one selected). Pills wrap to the next row when they would overrun the // right pane (vibrato's 5 waves need this). Each pill is registered into // instButtons with commit(optionIndex). Returns the number of rows consumed. const BTN_GROUP_LABEL_W = 8 function buttonGroupRow(y, label, options, current, commit) { con.move(y, INST_RIGHT_X); con.color_pair(colInstLabel, colBackPtn) print((label + ' '.repeat(BTN_GROUP_LABEL_W)).substring(0, BTN_GROUP_LABEL_W)) const x0 = INST_RIGHT_X + BTN_GROUP_LABEL_W let x = x0, rows = 1 for (let i = 0; i < options.length; i++) { const w = options[i].length + 4 // ' ' + label + ' ' + 2 caps if (x !== x0 && x + w - 1 > SCRW) { y++; rows++; x = x0 } // wrap to next row drawButton(y, x, options[i], i === current) instButtons.push({ y, x, w, value: i, commit }) x += w + 1 // 1-col gap between pills } return rows } // Draw "label" (glyph at column x+labelW) and register the label+glyph // span as a clickable toggle of byte `off` bit `bit`. Returns the column just // past the glyph, so callers can append trailing text there. `onToggle`, when // given, replaces the default single-bit flip (used by the Pitch/Filter Present // box, which must also stamp the slot's pitch/filter m-bit). function drawCheckbox(y, x, label, labelW, checked, off, bit, onToggle) { con.move(y, x); con.color_pair(colInstLabel, colBackPtn) print((label + ' '.repeat(labelW)).substring(0, labelW)) const gx = x + labelW con.move(y, gx); con.color_pair(colInstValue, colBackPtn) print(checked ? sym.ticked : sym.unticked) instCheckboxes.push({ y, xs: x, xe: gx, off, bit, onToggle }) return gx + 1 } // ── Tab body: General (page 1 + page 2) ─────────────────────────────────── // Page 1 (Gen.1): // Sample binding — sample link, length, c4Rate, play/loop positions, loop mode // Volume — IGV, default note vol, fadeout, vol swing // Panning — default pan + "use" flag, pitch-pan centre/separation, pan swing // Page 2 (Gen.2): // Filter — default cutoff/resonance // Vibrato — waveform, speed, depth, sweep, rate // Note actions — NNA, DCT/DCA // Tuning — signed 4096-TET detune offset // // Two pages because the 80x32 terminal's 24-row body cannot hold every field at // once; the user explicitly OK'd this split. function drawInstTabGeneral1(e) { const d = e.decoded let y = INST_BODY_Y const sampleNames = (songsMeta && songsMeta.sampleNames) || [] // Map decoded.samplePtr+len back to a sample-name slot (best-effort: same // dedup convention as buildSampleIndex). let sampleLabel = '(none)' if (d.sampleLen > 0) { // Walk samplesCache if it's been built; otherwise fall back to slot 0. if (samplesCache === null) refreshSamplesCache() let smpIdx = -1 for (let i = 0; i < samplesCache.length; i++) { if (samplesCache[i].ptr === d.samplePtr && samplesCache[i].len === d.sampleLen) { smpIdx = i; break } } if (smpIdx >= 0) { const sn = sampleNames[smpIdx + 1] || '' sampleLabel = '$' + _hex(smpIdx + 1, 2) + (sn.length ? ' ' + sn : ' (unnamed)') } else { sampleLabel = '@$' + _hex(d.samplePtr, 6) } } // Multisample (Ixmp) instruments bind extra samples beyond the base record; flag that // inline with "… et al." and a wrapped count line, so the single "Sample:" field isn't // mistaken for the whole instrument. const extraN = instExtraSampleCount(e.slot, d.samplePtr, d.sampleLen) drawGroupHeader(y++, 'Sample binding') let smpVal = sampleLabel if (extraN > 0) { // Truncate the base label first so the multi-byte doubledot escape in the suffix is // never cut by drawLabelRow's own length clamp (which would garble the TTY stream). const suffix = ' ' + sym.doubledot + ' et al.' const maxBase = (INST_RIGHT_W - 12) - suffix.length if (smpVal.length > maxBase) smpVal = smpVal.substring(0, maxBase) smpVal += suffix } drawLabelRow(y++, ' Sample:', smpVal) if (extraN > 0) drawLabelRow(y++, '', '(' + extraN + ' extra sample' + (extraN === 1 ? '' : 's') + ')') drawLabelRow(y++, ' Length:', d.sampleLen + ' bytes ($' + _hex(d.sampleLen, 4) + ') Rate@C4: ' + d.c4Rate + ' Hz') drawLabelRow(y++, ' Play st:', '$' + _hex(d.playStart, 4)) drawLabelRow(y++, ' Loop:', loopModeNameInst(d.sampleFlags) + ' [$' + _hex(d.sLoopStart, 4) + '..$' + _hex(d.sLoopEnd, 4) + ']') y++ drawGroupHeader(y++, 'Volume') sliderRow(y++, e, ' Inst.GV:', d.igv, 0, 255, annHex, (v) => [[171, v]]) sliderRow(y++, e, ' DefNote:', d.defNoteVol, 0, 255, annHex, (v) => [[196, v]]) sliderRow(y++, e, ' Fadeout:', d.fadeout, 0, 1024, annFadeout, (v) => [[172, v & 0xFF], [173, (v >> 8) & 0x0F]]) sliderRow(y++, e, ' Swing:', d.volSwing, 0, 255, annHex, (v) => [[174, v]]) y++ drawGroupHeader(y++, 'Panning') sliderRow(y++, e, ' Default:', d.defPan, 0, 255, annHex, (v) => [[177, v]]) sliderRow(y++, e, ' Sep:', d.pitchPanSep, -128, 127, null, (v) => [[180, v & 0xFF]]) sliderRow(y++, e, ' Swing:', d.panSwing, 0, 255, annHex, (v) => [[181, v]]) con.move(y, INST_RIGHT_X); con.color_pair(colInstLabel, colBackPtn) print((' PPanCnt:' + ' '.repeat(12)).substring(0, 12)) con.move(y, INST_RIGHT_X + 12); con.color_pair(colInstValue, colBackPtn) print('$' + _hex(d.pitchPanCenter, 4) + ' ') // "Use default pan" mirrors the Pan tab's UseDef checkbox (pan loopWord bit 7). const ppx = drawCheckbox(y, INST_RIGHT_X + 21, 'Use:', 5, d.panEnv.panUseDef, 17, 7) con.move(y, ppx); con.color_pair(colInstValue, colBackPtn) print(d.panEnv.panUseDef ? ' on' : ' off') y++ } function drawInstTabGeneral2(e) { const d = e.decoded let y = INST_BODY_Y drawGroupHeader(y++, 'Filter') // Filter mode — base byte 173 bit 4 (false=IT, true=SoundFont). The two modes use // different value widths, so the cutoff/resonance sliders below switch range, writeback // bytes and annotation with the mode. Toggling re-reads the record (drawInstrumentsContents // re-runs after commit), so the sliders re-render in the new mode. Note: toggling does not // convert the stored numbers — IT byte 182 becomes the SF cutoff high byte, etc. y += buttonGroupRow(y, ' Mode:', FILTER_MODE_OPTIONS, d.filterSfMode ? 1 : 0, (v) => instWriteField(e, 173, 4, 1, v)) if (d.filterSfMode) { // SoundFont: cutoff = absolute cents (high byte 182, low byte 252), resonance = // centibels above DC gain (high byte 183, low byte 253). Slider spans the SF2-spec // initialFilterFc range (1500..13500 cents ≈ 40 Hz..20 kHz) and Q's 0..96 dB (0..960 cB). sliderRow(y++, e, ' Cutoff:', d.defCutoff16, 1500, 13500, annSfCutoff, (v) => [[182, (v >> 8) & 0xFF], [252, v & 0xFF]]) sliderRow(y++, e, ' Reso:', d.defReso16, 0, 960, annSfReso, (v) => [[183, (v >> 8) & 0xFF], [253, v & 0xFF]]) } else { // ImpulseTracker: 8-bit cutoff/resonance (byte 182/183); 0xFF = off. sliderRow(y++, e, ' Cutoff:', d.defCutoff, 0, 255, annFilter, (v) => [[182, v]]) sliderRow(y++, e, ' Reso:', d.defReso, 0, 255, annFilter, (v) => [[183, v]]) } y++ drawGroupHeader(y++, 'Vibrato') // Vibrato waveform — instFlag (byte 186) bits 2..4. y += buttonGroupRow(y, ' Wave:', VIB_WF_OPTIONS, d.vibWaveform & 7, (v) => instWriteField(e, 186, 2, 3, v)) sliderRow(y++, e, ' Speed:', d.vibSpeed, 0, 255, annHex, (v) => [[175, v]]) sliderRow(y++, e, ' Depth:', d.vibDepth, 0, 255, annHex, (v) => [[187, v]]) sliderRow(y++, e, ' Sweep:', d.vibSweep, 0, 255, annHex, (v) => [[176, v]]) sliderRow(y++, e, ' Rate:', d.vibRate, 0, 255, annHex, (v) => [[188, v]]) y++ drawGroupHeader(y++, 'Note actions') // NNA — instFlag (byte 186) bits 0..1; DCT/DCA — dcByte (byte 195) bits 0..1 / 2..3. y += buttonGroupRow(y, ' NNA:', NNA_NAMES, d.nna, (v) => { instWriteField(e, 186, 5, 1, v === 4 ? 1 : 0) // Key Lift bit instWriteField(e, 186, 0, 2, v === 4 ? 0 : v) // traditional nn }) y += buttonGroupRow(y, ' DCT:', DCT_NAMES, d.dct & 3, (v) => instWriteField(e, 195, 0, 2, v)) y += buttonGroupRow(y, ' DCA:', DCA_OPTIONS, d.dca & 3, (v) => instWriteField(e, 195, 2, 2, v)) y++ drawGroupHeader(y++, 'Tuning') detuneRow(y, e, d.detune) y += 2 } // ── Envelope rendering (shared by Volume/Panning/Pitch tabs) ─────────────── // Pick a "nice" time-grid interval for `totalTime` (seconds). Aims for at most // ~8 vertical hairlines, choosing from a fixed ladder so the number rendered // next to "Total:" reads cleanly (no 0.157s grids). The smallest viable step // covers fast envelopes (~50 ms); the top of the ladder covers the 15.75 s // maximum the 3.5 minifloat can encode. function pickEnvTimeGrid(totalTime) { const ladder = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0] for (let i = 0; i < ladder.length; i++) { if (totalTime / ladder[i] <= 8) return ladder[i] } return ladder[ladder.length - 1] } // Pixel rect of the envelope-graph area for the given tab content row range. // Width spans the full right pane; height is the lower half of the body area. function instEnvelopeRect() { const graphRowY = INST_BODY_Y + 7 // 7 rows of text above the graph const x = (INST_RIGHT_X - 1) * CELL_PW const y = (graphRowY - 1) * CELL_PH const w = INST_RIGHT_W * CELL_PW const h = (INST_BTN_Y - graphRowY) * CELL_PH return { x, y, w, h, graphRowY } } // Clear graphics overlay over the right-pane envelope graph. Called by // drawInstrumentsContents on every redraw and by switchToPanel when leaving // the instrument viewer (mirrors clearSampleWaveformArea for the same reason). function clearInstrumentsEnvelopeArea() { const r = instEnvelopeRect() graphics.plotRect(r.x-2, r.y-2, r.w+4, r.h+4, 255) // Also clear the row of text that the graph overlays would otherwise visually // smudge — the body redraw paints these rows blank anyway, but switchToPanel // bypasses the body redraw on exit. } // Bresenham line via plotPixel. Used to connect envelope nodes. function envPlotLine(x0, y0, x1, y1, col) { let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1 let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1 let err = dx + dy // Guard against pathological inputs; envelope coords are screen-bound. let safety = 4096 while (safety-- > 0) { graphics.plotPixel(x0, y0, col) if (x0 === x1 && y0 === y1) break const e2 = 2 * err if (e2 >= dy) { err += dy; x0 += sx } if (e2 <= dx) { err += dx; y0 += sy } } } // Draw the envelope chart for one envelope (vol/pan/pf). Plots: // • Quarter-point dashed hairlines as a faint reference grid. // • Solid axis line (bottom for vol, mid for pan/pitch). // • Loop / sustain wrap regions as faint vertical bands behind the curve. // • Polyline through all active nodes; each node a 3×3 marker. // Time axis: cumulative durSec across nodes, scaled to fit graph width. // Value axis: 0 at bottom, env.valueMax at top. function drawEnvelopeGraph(env, rectOverride) { const r = rectOverride || instEnvelopeRect() if (!rectOverride) clearInstrumentsEnvelopeArea() // clear (caller clears its own area when overriding) // Dashed reference hairlines at quarter points of the value range. Drawn // first so the solid axis line / loop bands / polyline can stack on top. // For pan/pitch the 50% level is the main axis; we skip it here to keep // the solid line visually distinct from the dashes. const hairFracs = (env.kind === 'vol') ? [0.25, 0.5, 0.75] : [0.25, 0.75] for (let fi = 0; fi < hairFracs.length; fi++) { const yy = r.y + r.h - 1 - ((hairFracs[fi] * (r.h - 1)) | 0) for (let xx = r.x; xx < r.x + r.w; xx += 6) { graphics.plotRect(xx, yy, 2, 1, colInstEnvHair) } } // Solid axis line — bottom of graph for vol, mid for pan/pitch. if (env.kind !== 'vol') { const midY = r.y + (r.h >>> 1) graphics.plotRect(r.x, midY, r.w, 1, colInstEnvAxis) } else { graphics.plotRect(r.x, r.y + r.h - 1, r.w, 1, colInstEnvAxis) } // No envelope to draw when there are zero active nodes (shouldn't happen // for well-formed records, but be defensive). const lastIdx = (env.terminatorIdx >= 0) ? env.terminatorIdx : (env.nodes.length - 1) if (lastIdx < 0) return // Cumulative time of each node (node 0 is at t=0; node i is at sum of // dur[0..i-1] for i>=1). The terminator's own dur is 0 so it lands at // the sum of the preceding nodes. const xs = new Array(lastIdx + 1) let acc = 0 xs[0] = 0 for (let i = 1; i <= lastIdx; i++) { acc += env.nodes[i - 1].durSec xs[i] = acc } // When total time is 0 (single-node held envelope), give the x-axis a // tiny non-zero span so node 0 still renders at the left edge. const totalTime = Math.max(acc, 1e-6) const valueMax = env.valueMax || 0xFF const pxX = (t) => r.x + Math.min(r.w - 1, Math.max(0, ((t / totalTime) * (r.w - 1)) | 0)) const pxY = (v) => r.y + r.h - 1 - Math.min(r.h - 1, Math.max(0, ((v / valueMax) * (r.h - 1)) | 0)) // Vertical time-grid hairlines. Same dashed style as the value-axis // hairlines (2 px on, 4 px off) but oriented vertically; spacing comes // from pickEnvTimeGrid so we never spam more than ~8 lines across the // graph regardless of envelope duration. if (acc > 0) { const grid = pickEnvTimeGrid(totalTime) for (let t = grid; t < totalTime; t += grid) { const xx = pxX(t) for (let yy = r.y; yy < r.y + r.h; yy += 6) { graphics.plotRect(xx, yy, 1, 2, colInstEnvHair) } } } // Loop / sustain bands behind the polyline. if (env.loopEnable && env.loopStart <= lastIdx && env.loopEnd <= lastIdx) { const x0 = pxX(xs[env.loopStart]) const x1 = pxX(xs[env.loopEnd]) const bw = Math.max(1, x1 - x0) graphics.plotRect(x0, r.y, bw, r.h, colInstEnvLoop, 2) // start & end hairline graphics.plotRect(x0, r.y, 1, r.h, colInstEnvLoopSuper) graphics.plotRect(x1, r.y, 1, r.h, colInstEnvLoopSuper) } if (env.sustEnable && env.sustStart <= lastIdx && env.sustEnd <= lastIdx) { const x0 = pxX(xs[env.sustStart]) const x1 = pxX(xs[env.sustEnd]) const bw = Math.max(1, x1 - x0) graphics.plotRect(x0, r.y, bw, r.h, colInstEnvSust, 2) // start & end hairline graphics.plotRect(x0, r.y, 1, r.h, colInstEnvSustSuper) graphics.plotRect(x1, r.y, 1, r.h, colInstEnvSustSuper) } // Polyline through the envelope. for (let i = 0; i < lastIdx; i++) { envPlotLine(pxX(xs[i]), pxY(env.nodes[i].value), pxX(xs[i + 1]), pxY(env.nodes[i + 1].value), colInstEnvLine) } // Node markers (3×3 squares centred on the node coordinate). for (let i = 0; i <= lastIdx; i++) { const cx = pxX(xs[i]), cy = pxY(env.nodes[i].value) graphics.plotRect(cx - 1, cy - 1, 3, 3, colInstEnvNode) } } // Common envelope-tab body: a few lines of summary text above the graph, then // the envelope graph. `extraCb`, when given, is a per-kind extra checkbox // descriptor { label, checked, onText, offText } (e.g. pan's "Use default pan"). // Present / Carry / Loop / Sustain (+ that extra flag) are clickable checkboxes // wired to their backing bits. Bit map (see decodeEnvelope): loopWord = // rec[loopOff] | rec[loopOff+1]<<8, so Present is high-byte bit 5 (loopWord bit // 13); Carry/Loop/extra are loopOff bits 6/5/7; Sustain is sustOff bit 5. The // byte offsets come from the decoded env (slot-aware: the pitch and filter roles // live in either of the two pf-slots — bytes 19.. or 197..). `role` // ('pitch'/'filter') makes the Present toggle also stamp the slot's m-bit so a // freshly-enabled role routes to the right target. function drawInstTabEnvelope(e, env, kindLabel, extraCb, role) { let y = INST_BODY_Y const loopOff = env.loopOff const sustOff = env.sustOff drawGroupHeader(y++, kindLabel + ' envelope') // Present (P bit) — loopWord bit 13 lives in the high byte (loopOff+1) bit 5. // For a pitch/filter role, enabling Present must also set the slot's m-bit // (loopOff bit 7: 0 = pitch, 1 = filter) so the engine routes it correctly. const presentToggle = role ? (() => { const rec = readInstRecord(e.slot) let lo = rec[loopOff], hi = rec[loopOff + 1] hi ^= (1 << 5) // flip Present if (role === 'filter') lo |= (1 << 7); else lo &= ~(1 << 7) // stamp m-bit instWriteBytes(e.slot, [[loopOff, lo], [loopOff + 1, hi]]) e.decoded = decodeInstFull(readInstRecord(e.slot)) }) : null let px = drawCheckbox(y, INST_RIGHT_X, ' Present:', 12, env.present, loopOff + 1, 5, presentToggle) con.move(y, px); con.color_pair(colInstValue, colBackPtn) print(env.present ? ' yes (P=1)' : ' no (P=0)') y++ // Node count + Carry checkbox share one row so the text block stays ≤ 7 rows // (the envelope graph below starts at INST_BODY_Y + 7). const realCount = (env.terminatorIdx >= 0) ? (env.terminatorIdx + 1) : env.nodes.length con.move(y, INST_RIGHT_X); con.color_pair(colInstLabel, colBackPtn) print((' Nodes:' + ' '.repeat(12)).substring(0, 12)) con.move(y, INST_RIGHT_X + 12); con.color_pair(colInstValue, colBackPtn) print((realCount + ' / 25' + ' '.repeat(8)).substring(0, 8)) drawCheckbox(y, INST_RIGHT_X + 21, 'Carry:', 7, env.carry, loopOff, 6) y++ // Loop enable (+ range when on) let lx = drawCheckbox(y, INST_RIGHT_X, ' Loop:', 12, env.loopEnable, loopOff, 5) con.move(y, lx); con.color_pair(colInstValue, colBackPtn) print(env.loopEnable ? (' [' + env.loopStart + '..' + env.loopEnd + ']') : ' off') y++ // Sustain enable (+ range when on) let sx = drawCheckbox(y, INST_RIGHT_X, ' Sustain:', 12, env.sustEnable, sustOff, 5) con.move(y, sx); con.color_pair(colInstValue, colBackPtn) print(env.sustEnable ? (' [' + env.sustStart + '..' + env.sustEnd + ']') : ' off') y++ // Per-kind extra flag (Pan: use-default-pan) — rides loopWord bit 7 (loopOff // bit 7). The pf-slots use that same bit as the pitch/filter m-bit, which the // tab itself now owns (see presentToggle), so they pass no extraCb. if (extraCb) { let ex = drawCheckbox(y, INST_RIGHT_X, extraCb.label, 12, extraCb.checked, loopOff, 7) con.move(y, ex); con.color_pair(colInstValue, colBackPtn) print(' ' + (extraCb.checked ? extraCb.onText : extraCb.offText)) y++ } // Total envelope length + the time-grid step the graph below uses, so the // dashed vertical hairlines have a readable scale. const lastIdx = (env.terminatorIdx >= 0) ? env.terminatorIdx : (env.nodes.length - 1) let totalSec = 0 for (let i = 0; i < lastIdx; i++) totalSec += env.nodes[i].durSec const gridStep = pickEnvTimeGrid(Math.max(totalSec, 1e-6)) drawLabelRow(y++, ' Length:', totalSec.toFixed(3) + ' s (grid ' + gridStep + ' s)') drawEnvelopeGraph(env) } function drawInstTabVolume(e) { drawInstTabEnvelope(e, e.decoded.volEnv, 'Volume', null) } function drawInstTabPanning(e) { drawInstTabEnvelope(e, e.decoded.panEnv, 'Panning', { label: ' UseDef:', checked: e.decoded.panEnv.panUseDef, onText: 'on (chan-pan source: byte $B1)', offText: 'off (chan-pan source: byte $B1)' }) } // Pitch and Filter each get their own tab now (the record carries two pf-slots, // one per role — see decodeInstFull). Each tab edits whichever slot its role // resolved to; the Present toggle stamps the slot's m-bit for that role. function drawInstTabPitch(e) { drawInstTabEnvelope(e, e.decoded.pitchEnv, 'Pitch', null, 'pitch') } function drawInstTabFilter(e) { drawInstTabEnvelope(e, e.decoded.filterEnv, 'Filter', null, 'filter') } // Metainstrument view (terranmon.txt §"Metainstrument definition"): the record // carries no sample of its own — only a layer table fanned out at trigger time. // One row per layer: target instrument, mix volume (Perceptually-Significant // octet; 159 = unity), sample detune (4096-TET → cents), and the pitch × velocity // rectangle that gates the layer. function metaMixAnn(v) { return (v === 159) ? 'unity' : ('$' + _hex(v, 2)) } function metaDetAnn(v) { const c = v * 1200 / 4096; return (c >= 0 ? '+' : '') + c.toFixed(0) + 'c' } function drawInstTabMeta(e) { const d = e.decoded let y = INST_BODY_Y drawGroupHeader(y++, 'Metainstrument (' + d.layers.length + ' layer' + (d.layers.length === 1 ? '' : 's') + ')') // Each layer gets a read-only context line (target inst + pitch/vel rect) plus an // editable Mix-volume and Detune slider (registered in instSliders, so mouse drag / // wheel / click-to-type all work; commit re-uploads the record so the engine re-parses // the layer table). Fit as many as the body allows. const rowsPerLayer = 3 const avail = INST_BTN_Y - y - 1 const shown = Math.min(d.layers.length, Math.max(1, (avail / rowsPerLayer) | 0)) for (let i = 0; i < shown; i++) { const L = d.layers[i] const o = 4 + i * 10 // byte offset of this layer's descriptor const rect = 'pitch ' + noteToStr(L.pitchStart) + sym.doubledot + noteToStr(L.pitchEnd) + ' vel ' + L.volStart + sym.doubledot + L.volEnd con.move(y, INST_RIGHT_X); con.color_pair(colInstGroupHdr, colBackPtn) print((' L' + i + ' \u008426u inst $' + _hex(L.instIdx, 2) + ' ' + rect + ' '.repeat(INST_RIGHT_W)) .substring(0, INST_RIGHT_W)) y++ sliderRow(y++, e, ' Mix:', L.mixOctet, 0, 255, metaMixAnn, (v) => [[o + 1, v & 0xFF]], true) sliderRow(y++, e, ' Detune:', L.detune, -4096, 4096, metaDetAnn, (v) => [[o + 2, v & 0xFF], [o + 3, (v >> 8) & 0xFF]], true) } if (shown < d.layers.length) { con.move(y, INST_RIGHT_X); con.color_pair(colInstGroupHdr, colBackPtn) print(` ${sym.doubledot}${sym.doubledot} ` + (d.layers.length - shown) + ' more layer(s) (resize / not shown)') } } // ── Edit button (bottom row) ─────────────────────────────────────────────── function drawInstrumentsEditButton() { const y = INST_BTN_Y con.move(y, INST_RIGHT_X) con.color_pair(colInstGroupHdr, colBackPtn); print('[ E ]') con.color_pair(colInstValue, colBackPtn) const label = ' Advanced Edit' print(label) const rest = INST_RIGHT_W - (5 + label.length) if (rest > 0) print(' '.repeat(rest)) } function clearInstrumentsPanel() { for (let y = PTNVIEW_OFFSET_Y; y < SCRH; y++) fillLine(y, colInstValue, colBackPtn) } function drawInstrumentsContents(wo) { if (instrumentsCache === null) refreshInstrumentsCache() clampInstrumentsCursor(false) // respect the current scroll (free wheel scroll) instSliders.length = 0 // rebuilt by the Gen.1/Gen.2 body drawers below instButtons.length = 0 // rebuilt by Gen.2 button groups instCheckboxes.length = 0 // rebuilt by Gen.1 / envelope-tab checkboxes clearInstrumentsPanel() drawInstrumentsListColumn() drawInstrumentsSeparator() drawInstrumentsTabStrip() const n = instrumentsCache ? instrumentsCache.length : 0 if (n === 0) { con.move(INST_BODY_Y, INST_RIGHT_X) con.color_pair(colInstGroupHdr, colBackPtn) print('No instruments in this project.') // wipe any old envelope graph clearInstrumentsEnvelopeArea() drawInstrumentsEditButton() return } const e = instrumentsCache[instListCursor] // Body redraw wipes its rows before re-rendering, so don't paint the graph // until after the text tabs are drawn — otherwise plotRect-555 fill at the // end of the body redraw would erase the graph again. clearInstrumentsEnvelopeArea() // Metainstruments have no sample/envelopes — show their layer table on every // sub-tab (the Gen/env drawers would read absent fields and mis-render). if (e.decoded.isMeta) drawInstTabMeta(e) else if (instSubTab === INST_TAB_GEN1) drawInstTabGeneral1(e) else if (instSubTab === INST_TAB_GEN2) drawInstTabGeneral2(e) else if (instSubTab === INST_TAB_VOL) drawInstTabVolume(e) else if (instSubTab === INST_TAB_PAN) drawInstTabPanning(e) else if (instSubTab === INST_TAB_PIT) drawInstTabPitch(e) else drawInstTabFilter(e) drawInstrumentsEditButton() // List redraw wiped col 1 across every row — invalidate, then re-stamp // immediately while playing so the live indicator isn't blank for a frame. invalidateInstrumentsBlob() if (HUB.getPlaybackMode() !== PLAYMODE_NONE) drawInstrumentsPlayBlobs() // Envelope-graph cursor: the panel rebuild wiped any prior hairline; invalidate // and re-stamp so the user doesn't see it blink off on tab / inst switches. invalidateEnvCursor() if (HUB.getPlaybackMode() !== PLAYMODE_NONE) drawEnvelopeCursor() } function instrumentsInput(wo, event) { if (event[0] !== 'key_down') return const keysym = event[1] const keyJustHit = (1 == event[2]) const shiftDown = (event.includes(59) || event.includes(60)) const moveDelta = shiftDown ? 8 : 1 const n = instrumentsCache ? instrumentsCache.length : 0 if (n === 0) { if (keysym === 'e' || keysym === 'E') { openAdvancedInstEdit(-1) } return } if (keysym === '') { instListCursor -= moveDelta; clampInstrumentsCursor(); drawInstrumentsContents(); return } if (keysym === '') { instListCursor += moveDelta; clampInstrumentsCursor(); drawInstrumentsContents(); return } if (keysym === '') { instListCursor -= INST_LIST_H; clampInstrumentsCursor(); drawInstrumentsContents(); return } if (keysym === '') { instListCursor += INST_LIST_H; clampInstrumentsCursor(); drawInstrumentsContents(); return } if (keysym === '') { instListCursor = 0; clampInstrumentsCursor(); drawInstrumentsContents(); return } if (keysym === '') { instListCursor = n - 1; clampInstrumentsCursor(); drawInstrumentsContents(); return } // Tab cycling. / walk subtab, mirroring the IT mouse-tab feel. if (keysym === '') { instSubTab = (instSubTab + INST_TAB_NAMES.length - 1) % INST_TAB_NAMES.length; drawInstrumentsContents(); return } if (keysym === '') { instSubTab = (instSubTab + 1) % INST_TAB_NAMES.length; drawInstrumentsContents(); return } // Number keys 1..6 jump directly to a tab. Convenient when arrow keys are taken. if (keysym === '1') { instSubTab = INST_TAB_GEN1; drawInstrumentsContents(); return } if (keysym === '2') { instSubTab = INST_TAB_GEN2; drawInstrumentsContents(); return } if (keysym === '3') { instSubTab = INST_TAB_VOL; drawInstrumentsContents(); return } if (keysym === '4') { instSubTab = INST_TAB_PAN; drawInstrumentsContents(); return } if (keysym === '5') { instSubTab = INST_TAB_PIT; drawInstrumentsContents(); return } if (keysym === '6') { instSubTab = INST_TAB_FILT; drawInstrumentsContents(); return } if (keysym === 'e' || keysym === 'E') { const e = instrumentsCache[instListCursor] if (e) openAdvancedInstEdit(e.slot) return } } function registerInstrumentsMouse() { // Left list addPanelMouseRegion(INST_LIST_X, INST_LIST_Y, INST_SEP_X - INST_LIST_X, INST_LIST_H, { onClick: (cy, cx, btn) => { if (btn !== 1) return const n = instrumentsCache ? instrumentsCache.length : 0 const target = instListScroll + (cy - INST_LIST_Y) if (target < 0 || target >= n) return instListCursor = target clampInstrumentsCursor() drawInstrumentsContents() }, onWheel: (cy, cx, dy) => { // free scroll: move the view, not the selection (cursor may scroll off) const n = instrumentsCache ? instrumentsCache.length : 0 const maxS = Math.max(0, n - INST_LIST_H) instListScroll = Math.max(0, Math.min(maxS, instListScroll + dy * 3)) drawInstrumentsContents() } }) // Right-pane tab strip: clicking a chip selects that tab. for (let i = 0; i < INST_TAB_NAMES.length; i++) { const idx = i const r = instTabRect(i) addPanelMouseRegion(r.x, r.y, r.w, 1, { onClick: (cy, cx, btn) => { if (btn !== 1) return instSubTab = idx drawInstrumentsContents() } }) } // Edit button addPanelMouseRegion(INST_RIGHT_X, INST_BTN_Y, 22, 1, { onClick: (cy, cx, btn) => { if (btn !== 1) return const n = instrumentsCache ? instrumentsCache.length : 0 const slot = (n > 0) ? instrumentsCache[instListCursor].slot : -1 openAdvancedInstEdit(slot) } }) // Slider body (Gen.1 / Gen.2): one region that hit-tests the live instSliders // list. Click the raw-number capsule to type a value; click/drag the knob to // slide; wheel over either nudges by ±1 (wheel up = +1) and commits each notch. addPanelMouseRegion(INST_RIGHT_X, INST_BODY_Y, INST_RIGHT_W, INST_BODY_H, { onClick: (cy, cx, btn, ev) => { if (btn !== 1) return const e = instrumentsCache ? instrumentsCache[instListCursor] : null const cb = instCheckboxAt(cy, cx) if (cb) { if (e) { if (cb.onToggle) cb.onToggle(e); else toggleInstBit(e, cb.off, cb.bit); drawInstrumentsContents() } return } const b = instButtonAt(cy, cx) if (b) { b.commit(b.value); drawInstrumentsContents(); return } const c = sliderCapsuleAt(cy, cx) if (c) { editSliderNumber(c); return } const s = sliderTroughAt(cy, cx) if (s) runSliderDrag(s, ev) }, onWheel: (cy, cx, dy) => { const s = sliderTroughAt(cy, cx) || sliderCapsuleAt(cy, cx) if (!s) return const nv = Math.max(s.min, Math.min(s.max, s.val + (dy < 0 ? 1 : -1))) if (nv === s.val) return s.val = nv s.render(nv) s.commit(nv) } }) } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // END INSTRUMENTS VIEWER ///////////////////////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // LIVE-PLAY BLOB (Samples / Instruments column 1) ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // Per-row marker painted in column 1 of the Samples / Instruments list while a // voice is actively sounding the corresponding sample / instrument. The glyph // (sym.blob1..blob10) tracks the loudest active voice that references the row; // sym.blob0 wipes the cell. The glyph FOREGROUND is colour-coded by polyphony — // the number of notes (live voices + NNA ghosts) currently sounding the row — // via a green→yellow→orange→red heat ramp. Per-row last-drawn (level, colour // bucket) is cached so the per-frame repaint only redraws rows that changed — // mirrors the bounded-work pattern used by drawVoiceMeters(). const smpBlobPrev = new Array(SMP_LIST_H).fill(-1) const instBlobPrev = new Array(INST_LIST_H).fill(-1) // Polyphony heat ramp for the blob foreground: more simultaneously-sounding notes → hotter. const colBlobPoly1 = 34 // blue — 1 note const colBlobPoly2 = 76 // green — 2 note const colBlobPoly3 = 230 // yellow — 3 notes const colBlobPoly4 = 221 // orange — 4 notes const colBlobPoly5 = 211 // red — 5+ notes const blobPolyCols = [colBlobPoly1, colBlobPoly2, colBlobPoly3, colBlobPoly4, colBlobPoly5] // Note count → ramp bucket: 0 (silent), 1..3 verbatim, 4+ saturates at 4. function blobPolyBucket(count) { if (count <= 0) return 0 return count >= 5 ? 5 : count } // getActiveNoteCounts (the foreground+ghost polyphony API) ships with this feature; on an // un-rebuilt host VM it's absent and blobs fall back to the plain number-column colour. const hasNoteCountAPI = (typeof audio !== 'undefined' && typeof audio.getActiveNoteCounts === 'function') // getVoiceSamplePtr/Length expose the sample a voice is ACTUALLY sounding (the resolved Ixmp // patch sample, not just the instrument's base record). When present, the Samples blobs and // waveform cursor key off the true (ptr,len) so a multisample instrument only lights / cursors // the one sample currently playing. Absent on an un-rebuilt host → fall back to instrument match // (every sample the playing instrument references lights up, the old behaviour). const hasVoiceSampleAPI = (typeof audio !== 'undefined' && typeof audio.getVoiceSamplePtr === 'function' && typeof audio.getVoiceSampleLength === 'function') // getVoiceEnvFilter{Index,Time} expose the filter-envelope playhead for the new // Filter tab's live cursor. Absent on an un-rebuilt host → the Filter graph still // draws, only the moving cursor is skipped (see envBundleForCurrentTab). const hasFilterEnvAPI = (typeof audio !== 'undefined' && typeof audio.getVoiceEnvFilterIndex === 'function' && typeof audio.getVoiceEnvFilterTime === 'function') function invalidateSamplesBlob() { for (let i = 0; i < smpBlobPrev.length; i++) smpBlobPrev[i] = -1 } function invalidateInstrumentsBlob() { for (let i = 0; i < instBlobPrev.length; i++) instBlobPrev[i] = -1 } // Walks the live voice slots and returns {instrumentId → loudest effective volume}. // Silent / inactive voices are skipped. Volumes are 0.0..1.0. function activeInstVolumes() { const out = {} const numVox = (HUB.getSong() && HUB.getSong().numVoices) ? HUB.getSong().numVoices : NUM_VOICES for (let v = 0; v < numVox; v++) { if (!audio.getVoiceActive(PLAYHEAD, v)) continue const inst = audio.getVoiceInstrument(PLAYHEAD, v) if (!inst) continue const vol = audio.getVoiceEffectiveVolume(PLAYHEAD, v) || 0 if (!(vol > 0)) continue if (!(inst in out) || out[inst] < vol) out[inst] = vol } return out } // Per-SAMPLE live stats keyed by "ptr:len" → { vol, count } across the voices ACTUALLY // sounding that exact sample (max effective volume + voice count for the polyphony heat ramp). // Only meaningful when hasVoiceSampleAPI; lets the Samples tab light just the playing sample of // a multisample instrument rather than every sample it references. function activeSampleStats() { const out = {} const numVox = (HUB.getSong() && HUB.getSong().numVoices) ? HUB.getSong().numVoices : NUM_VOICES for (let v = 0; v < numVox; v++) { if (!audio.getVoiceActive(PLAYHEAD, v)) continue const len = audio.getVoiceSampleLength(PLAYHEAD, v) if (len <= 0) continue const key = audio.getVoiceSamplePtr(PLAYHEAD, v) + ':' + len const vol = audio.getVoiceEffectiveVolume(PLAYHEAD, v) || 0 let s = out[key] if (!s) s = out[key] = { vol: 0, count: 0 } if (vol > s.vol) s.vol = vol s.count++ } return out } // 0.0 → 0 (clear); (0, 1] → 1..10 via ceil so the quietest audible voice still shows blob1. function blobLevelForVolume(v) { if (!(v > 0)) return 0 let lvl = Math.ceil(v * 10) if (lvl < 1) lvl = 1 if (lvl > 10) lvl = 10 return lvl } function drawSamplesPlayBlobs() { if (HUB.getPanel() !== VIEW_SAMPLES || !samplesCache) return const playing = (HUB.getPlaybackMode() !== PLAYMODE_NONE) // Prefer the per-sample stats (lights only the actually-sounding sample of a multisample // instrument); fall back to the instrument-volume match on hosts without getVoiceSamplePtr. const useSmp = playing && hasVoiceSampleAPI const smpStats = useSmp ? activeSampleStats() : null const instVols = (playing && !useSmp) ? activeInstVolumes() : null const counts = (playing && !useSmp && hasNoteCountAPI) ? audio.getActiveNoteCounts(PLAYHEAD) : null const n = samplesCache.length for (let row = 0; row < SMP_LIST_H; row++) { const idx = smpListScroll + row let level = 0, poly = 0 if (playing && idx < n) { const s = samplesCache[idx] if (useSmp) { const st = smpStats[s.ptr + ':' + s.len] if (st) { level = blobLevelForVolume(st.vol); poly = blobPolyBucket(st.count) } } else { const ub = s.usedBy let m = 0, c = 0 for (let j = 0; j < ub.length; j++) { const w = instVols[ub[j]] || 0 if (w > m) m = w if (counts) c += counts[ub[j]] || 0 } level = blobLevelForVolume(m) poly = blobPolyBucket(c) } } // Ghost-only rows have notes sounding but no exposed foreground volume — floor the glyph // to blob1 so the colour-coded marker is still visible. if (poly > 0 && level === 0) level = 1 const key = level * 8 + poly // cache combines glyph level + colour bucket if (smpBlobPrev[row] === key) continue const isSel = (idx === smpListCursor) const back = isSel ? colSmpListSel : colSmpListBg const fg = (poly > 0) ? blobPolyCols[poly - 1] : colSmpListNumFg con.move(SMP_LIST_Y + row, SMP_LIST_X) con.color_pair(fg, back) print(sym['blob' + level]) smpBlobPrev[row] = key } } function drawInstrumentsPlayBlobs() { if (HUB.getPanel() !== VIEW_INSTRMNT || !instrumentsCache) return const playing = (HUB.getPlaybackMode() !== PLAYMODE_NONE) const instVols = playing ? activeInstVolumes() : null const counts = (playing && hasNoteCountAPI) ? audio.getActiveNoteCounts(PLAYHEAD) : null const n = instrumentsCache.length for (let row = 0; row < INST_LIST_H; row++) { const idx = instListScroll + row let level = 0, poly = 0 if (playing && idx < n) { const slot = instrumentsCache[idx].slot level = blobLevelForVolume(instVols[slot] || 0) poly = blobPolyBucket(counts ? (counts[slot] || 0) : 0) } // Ghost-only rows sound but expose no foreground volume — floor to blob1 so the colour shows. if (poly > 0 && level === 0) level = 1 const key = level * 8 + poly if (instBlobPrev[row] === key) continue const isSel = (idx === instListCursor) const back = isSel ? colInstListSel : colInstListBg const fg = (poly > 0) ? blobPolyCols[poly - 1] : colInstListNumFg con.move(INST_LIST_Y + row, INST_LIST_X) con.color_pair(fg, back) print(sym['blob' + level]) instBlobPrev[row] = key } } // ── Playback-position cursor (sample waveform + vol/pan/pitch envelope graphs) ─────────────── // Vertical hairline glyph painted in the text layer at the column closest to the live // playback position of EVERY voice that's sounding the displayed sample / instrument — one // hairline per voice. Sub-pixel offset within the cell picks between vhairline1..vhairline7 // (vhairlineN draws the line N pixels from the cell's left edge; vhairline4 is the cell centre // on a 7-px cell). When several voices want the same text column they are resolved quiet→loud, // so the loudest voice's hairline wins the shared column. const colPlayCursor = 215 // same hue used for the timeline play row // Last-drawn state so each frame only repaints when something actually moved. Now that we draw // one hairline per voice, *Cols is the list of text columns currently stamped and *Sig is a // signature of the resolved per-column glyphs (so we can detect when any hairline moved). // envCursorPrev{Tab,Inst}: the (tab, instrument-slot) the env hairlines belong to. let envCursorPrevCols = [] let envCursorPrevSig = '' let envCursorPrevTab = -1 let envCursorPrevInst = -1 let smpCursorPrevCols = [] let smpCursorPrevSig = '' let smpCursorPrevIdx = -1 function invalidateEnvCursor() { envCursorPrevCols = []; envCursorPrevSig = ''; envCursorPrevTab = -1; envCursorPrevInst = -1 } function invalidateSmpCursor() { smpCursorPrevCols = []; smpCursorPrevSig = ''; smpCursorPrevIdx = -1 } // Map a pixel-space X coordinate to (text-column, vhairline glyph) such that the glyph's // drawn line lands within ±½ a sub-pixel of xPix. Cell pixels are 1-indexed positions // (left edge = pos 1), matching the vhairlineN naming. function pixelToHairline(xPix) { const col0 = Math.floor(xPix / CELL_PW) let pos = xPix - col0 * CELL_PW + 1 // 1..CELL_PW if (pos < 1) pos = 1 if (pos > 7) pos = 7 return { col: col0 + 1, hair: sym['vhairline' + pos] } } // All active voices currently bound to `slot` (1..255), as {voice, vol}. Empty if none. function activeVoicesForInstSlot(slot) { const out = [] const numVox = (HUB.getSong() && HUB.getSong().numVoices) ? HUB.getSong().numVoices : NUM_VOICES for (let v = 0; v < numVox; v++) { if (!audio.getVoiceActive(PLAYHEAD, v)) continue if (audio.getVoiceInstrument(PLAYHEAD, v) !== slot) continue out.push({ voice: v, vol: audio.getVoiceEffectiveVolume(PLAYHEAD, v) || 0 }) } return out } // All active voices currently sounding the samplesCache entry `s`, as {voice, vol}. When the // host exposes the per-voice active sample (hasVoiceSampleAPI), match on the true (ptr,len) so a // voice playing a DIFFERENT sample of an instrument that also references `s` is excluded — its // samplePos would normalise against the wrong length and paint a bogus cursor. Without the API, // fall back to matching any voice on an instrument in `s.usedBy` (the old behaviour). function activeVoicesForSample(s) { const out = [] const numVox = (HUB.getSong() && HUB.getSong().numVoices) ? HUB.getSong().numVoices : NUM_VOICES for (let v = 0; v < numVox; v++) { if (!audio.getVoiceActive(PLAYHEAD, v)) continue if (hasVoiceSampleAPI) { if (audio.getVoiceSampleLength(PLAYHEAD, v) !== s.len) continue if (audio.getVoiceSamplePtr(PLAYHEAD, v) !== s.ptr) continue } else { if (s.usedBy.indexOf(audio.getVoiceInstrument(PLAYHEAD, v)) < 0) continue } out.push({ voice: v, vol: audio.getVoiceEffectiveVolume(PLAYHEAD, v) || 0 }) } return out } // Collapse a list of {col, hair, vol} hairline hits into a per-column glyph map, resolving // shared columns quiet→loud so the loudest voice's hairline wins. Returns { cols, colMap, sig } // where cols is the sorted column list, colMap maps col→glyph, and sig detects visual changes. function resolveHairlineHits(hits) { hits.sort((a, b) => a.vol - b.vol) const colMap = {} for (let i = 0; i < hits.length; i++) colMap[hits[i].col] = hits[i].hair const cols = Object.keys(colMap).map(Number).sort((a, b) => a - b) let sig = '' for (let i = 0; i < cols.length; i++) sig += cols[i] + ':' + colMap[cols[i]] + ',' return { cols, colMap, sig } } // Pull the envelope object + JSR223 getter pair for the active inst sub-tab. Returns null // for tabs without a graph (Gen.1 / Gen.2) AND for Metainstruments, whose decoded record has // no vol/pan/pf envelopes (it carries a layer table instead) — drawInstTabMeta renders every // sub-tab for them, so without this guard the VOL/PAN/PIT branches return { env: undefined } // and the cursor walker dereferences env.terminatorIdx on undefined. function envBundleForCurrentTab(e) { if (e.decoded.isMeta) return null if (instSubTab === INST_TAB_VOL) return { env: e.decoded.volEnv, idxFn: 'getVoiceEnvVolIndex', timeFn: 'getVoiceEnvVolTime' } if (instSubTab === INST_TAB_PAN) return { env: e.decoded.panEnv, idxFn: 'getVoiceEnvPanIndex', timeFn: 'getVoiceEnvPanTime' } if (instSubTab === INST_TAB_PIT) return { env: e.decoded.pitchEnv, idxFn: 'getVoiceEnvPitchIndex', timeFn: 'getVoiceEnvPitchTime' } if (instSubTab === INST_TAB_FILT) return { env: e.decoded.filterEnv, // Filter-env playhead getters ship with this feature; on an un-rebuilt host VM // they're absent — the graph still draws, only the live play-cursor is skipped. idxFn: hasFilterEnvAPI ? 'getVoiceEnvFilterIndex' : null, timeFn: hasFilterEnvAPI ? 'getVoiceEnvFilterTime' : null } return null } // First/last text row covered by the inst-tab envelope graph. Mirrors instEnvelopeRect() // in text-coord units so we know which rows to stamp / erase the hairline on. function envGraphTextRows() { const graphRowY = INST_BODY_Y + 7 return { y0: graphRowY, y1: INST_BTN_Y - 1 } } // Same idea for the Samples-tab waveform. function smpWaveTextRows() { return { y0: SMP_WAVE_Y, y1: SMP_BTN_Y - 1 } } function paintEnvCursorAt(col, hairSym) { const rng = envGraphTextRows() con.color_pair(colPlayCursor, 255) for (let y = rng.y0; y <= rng.y1; y++) { con.move(y, col) print(hairSym) } } function eraseEnvCursorIfAny() { if (envCursorPrevCols.length === 0) return const rng = envGraphTextRows() con.color_pair(colInstValue, 255) for (let k = 0; k < envCursorPrevCols.length; k++) { const col = envCursorPrevCols[k] for (let y = rng.y0; y <= rng.y1; y++) { con.move(y, col) print(' ') } } envCursorPrevCols = [] envCursorPrevSig = '' envCursorPrevTab = -1 envCursorPrevInst = -1 } function paintSmpCursorAt(col, hairSym) { const rng = smpWaveTextRows() con.color_pair(colPlayCursor, 255) for (let y = rng.y0; y <= rng.y1; y++) { con.move(y, col) print(hairSym) } } function eraseSmpCursorIfAny() { if (smpCursorPrevCols.length === 0) return const rng = smpWaveTextRows() con.color_pair(colSmpPropValue, 255) for (let k = 0; k < smpCursorPrevCols.length; k++) { const col = smpCursorPrevCols[k] for (let y = rng.y0; y <= rng.y1; y++) { con.move(y, col) print(' ') } } smpCursorPrevCols = [] smpCursorPrevSig = '' smpCursorPrevIdx = -1 } function drawEnvelopeCursor() { if (HUB.getPanel() !== VIEW_INSTRMNT) { invalidateEnvCursor(); return } if (!instrumentsCache || instrumentsCache.length === 0) { eraseEnvCursorIfAny(); return } const e = instrumentsCache[instListCursor] if (!e) { eraseEnvCursorIfAny(); return } const bundle = envBundleForCurrentTab(e) // Gen.1 / Gen.2 (and Metainstruments) have no envelope graph — wipe any stale hairline and // bail. The !bundle.env check also covers a malformed record whose env slot is missing. if (!bundle || !bundle.env) { eraseEnvCursorIfAny(); return } const env = bundle.env const lastIdx = (env.terminatorIdx >= 0) ? env.terminatorIdx : (env.nodes.length - 1) if (lastIdx < 0) { eraseEnvCursorIfAny(); return } const hits = [] if (HUB.getPlaybackMode() !== PLAYMODE_NONE && bundle.idxFn) { // Cumulative time at each node (mirrors xs[] in drawEnvelopeGraph) — shared by all voices. let acc = 0 const xs = new Array(lastIdx + 1) xs[0] = 0 for (let i = 1; i <= lastIdx; i++) { acc += env.nodes[i - 1].durSec; xs[i] = acc } const totalTime = Math.max(acc, 1e-6) const r = instEnvelopeRect() const voices = activeVoicesForInstSlot(e.slot) for (let k = 0; k < voices.length; k++) { const v = voices[k].voice const envIdx = audio[bundle.idxFn](PLAYHEAD, v) const envTime = audio[bundle.timeFn](PLAYHEAD, v) if (envIdx < 0) continue const ei = Math.max(0, Math.min(lastIdx, envIdx)) const segLen = (ei < lastIdx) ? env.nodes[ei].durSec : 0 const tInto = Math.max(0, Math.min(segLen, envTime)) const elapsed = xs[ei] + tInto const xPix = r.x + Math.min(r.w - 1, Math.max(0, ((elapsed / totalTime) * (r.w - 1)) | 0)) const sel = pixelToHairline(xPix) hits.push({ col: sel.col, hair: sel.hair, vol: voices[k].vol }) } } const res = resolveHairlineHits(hits) if (res.sig === envCursorPrevSig && envCursorPrevTab === instSubTab && envCursorPrevInst === e.slot) return eraseEnvCursorIfAny() if (res.cols.length > 0) { for (let i = 0; i < res.cols.length; i++) paintEnvCursorAt(res.cols[i], res.colMap[res.cols[i]]) envCursorPrevCols = res.cols envCursorPrevSig = res.sig envCursorPrevTab = instSubTab envCursorPrevInst = e.slot } } function drawSampleCursor() { if (HUB.getPanel() !== VIEW_SAMPLES) { invalidateSmpCursor(); return } if (!samplesCache || samplesCache.length === 0) { eraseSmpCursorIfAny(); return } const s = samplesCache[smpListCursor] if (!s || s.len <= 0) { eraseSmpCursorIfAny(); return } const hits = [] if (HUB.getPlaybackMode() !== PLAYMODE_NONE) { const r = sampleWaveformRect() const voices = activeVoicesForSample(s) for (let k = 0; k < voices.length; k++) { const pos = audio.getVoiceSamplePos(PLAYHEAD, voices[k].voice) if (pos < 0) continue const norm = Math.max(0, Math.min(1, pos / s.len)) const xPix = r.x + Math.min(r.w - 1, Math.max(0, (norm * (r.w - 1)) | 0)) const sel = pixelToHairline(xPix) hits.push({ col: sel.col, hair: sel.hair, vol: voices[k].vol }) } } const res = resolveHairlineHits(hits) if (res.sig === smpCursorPrevSig && smpCursorPrevIdx === smpListCursor) return eraseSmpCursorIfAny() if (res.cols.length > 0) { for (let i = 0; i < res.cols.length; i++) paintSmpCursorAt(res.cols[i], res.colMap[res.cols[i]]) smpCursorPrevCols = res.cols smpCursorPrevSig = res.sig smpCursorPrevIdx = smpListCursor } } // Samples-panel mouse regions (analogue of registerInstrumentsMouse). Moved into // the module on 2026-06-21 because it reads samples-private state (SMP_* geometry, // smpListCursor / smpListScroll / smpUsedScroll, samplesCache); the engine's // rebuildPanelMouseRegions calls it via the HUB.views alias. function registerSamplesMouse() { // Left list (incl. scroll-indicator column, but excluding the separator). addPanelMouseRegion(SMP_LIST_X, SMP_LIST_Y, SMP_SEP_X - SMP_LIST_X, SMP_LIST_H, { onClick: (cy, cx, btn) => { if (btn !== 1) return const n = samplesCache ? samplesCache.length : 0 const target = smpListScroll + (cy - SMP_LIST_Y) if (target < 0 || target >= n) return smpListCursor = target smpUsedScroll = 0 clampSamplesCursor() drawSamplesContents() }, onWheel: (cy, cx, dy) => { // free scroll: move the view, not the selection (cursor may scroll off) const n = samplesCache ? samplesCache.length : 0 const maxS = Math.max(0, n - SMP_LIST_H) smpListScroll = Math.max(0, Math.min(maxS, smpListScroll + dy * 3)) drawSamplesContents() } }) // Right "Used by" list: click launches inst viewer for that slot addPanelMouseRegion(SMP_RIGHT_X, SMP_USED_Y + 1, SCRW - SMP_RIGHT_X + 1, SMP_USED_LIST_H, { onClick: (cy, cx, btn) => { if (btn !== 1) return const s = samplesCache && samplesCache[smpListCursor] if (!s) return const idx = smpUsedScroll + (cy - (SMP_USED_Y + 1)) if (idx < 0 || idx >= s.usedBy.length) return launchInstrumentViewerFor(s.usedBy[idx]) }, onWheel: (cy, cx, dy) => { const s = samplesCache && samplesCache[smpListCursor] if (!s) return smpUsedScroll += dy drawSamplesUsedBy() } }) // Bottom-row Edit button addPanelMouseRegion(SMP_RIGHT_X, SMP_BTN_Y, 18, 1, { onClick: (cy, cx, btn) => { if (btn !== 1) return openSampleEdit(smpListCursor) } }) } // Pre-formatted "N (Kk/Mk)" sample-RAM summary for the engine's Project panel, // which used to read samplesCache.length / SMP_RAM_MAX_K directly (both private here). function sampleRamSummary() { const count = (samplesCache || []).length return `${count} (${formatSampleRamK(computeSampleRAMBytes())}k/${SMP_RAM_MAX_K}k)` } // ── In-process sub-editors ────────────────────────────────────────────────── // Replace the old taut_sampleedit / taut_instredit separate programs. Each runs // its own modal input loop (nested inside the panel's input handler, like // openInlineHexEdit). Going in-process means we DON'T call stopPlayback on entry, // so sound keeps playing while the editor is open — the audio engine advances // autonomously; updatePlayback() is only the on-screen sync. We deliberately do // NOT tick updatePlayback here, because currentPanel is still VIEW_SAMPLES / // VIEW_INSTRMNT and it would repaint the viewer's blobs / cursor on top of the // editor UI. The Advanced Edit instead draws its OWN live playing-region // visualisation each spin via the optional onTick callback (reading the voice-state // API directly, repainting only the blob cells — NOT updatePlayback). On exit the // editors refresh the cache and repaint the parent viewer via HUB.drawAll(). // // Mouse: callers clear the panel mouse regions on entry and register their own (so a // click in the editor area never hits the stale viewer regions); HUB.dispatchMouseEvent // then routes to the editor's own regions PLUS the always-on transport regions // (MOUSE_PANEL.concat(MOUSE_GLOBAL)), so transport play/stop works while editing. // Keys are NOT gated on keyJustHit, so held keys auto-repeat (e.g. Up/Dn to scroll). function editorModalLoop(onKey, onTick) { // The raw-keyboard grab set by the main loop persists while we are nested here // (same as openInlineHexEdit / the popups), so no need to re-assert it per spin. // A mouse click on a tab (transport stays on the same panel) switches currentPanel // via the global tab regions; detect that and close the editor so the new panel // isn't drawn underneath a still-running modal (the teardown then paints it). const startPanel = HUB.getPanel() let done = false, swallow = true const finish = () => { done = true } while (!done) { input.withEvent(ev => { if (swallow && (ev[0] === 'key_down' || ev[0] === 'mouse_down')) { swallow = false; return } if (HUB.dispatchMouseEvent(ev)) { if (HUB.getPanel() !== startPanel) finish(); return } if (ev[0] !== 'key_down') return const ks = ev[1] if (ks === '' || ks === '' || ks === '') { if (1 === ev[2]) finish(); return } onKey(ks, finish, (1 === ev[2])) }) if (!done && onTick) onTick() // don't overpaint the new panel after a switch } } const SMP_EDIT_TOOLS = [ { key: 'L', label: 'Load .raw / .wav from disk' }, { key: 'S', label: 'Save current sample to disk' }, { key: 'D', label: 'Draw waveform freehand' }, { key: 'X', label: 'Crop / trim selection' }, { key: 'R', label: 'Resample' }, { key: 'V', label: 'Reverse' }, { key: 'N', label: 'Normalise to peak' }, { key: 'F', label: 'Fade in / out' }, ] function openSampleEdit(slot) { const SAMPLE_IDX = (slot !== undefined && slot >= 0) ? (slot | 0) : smpListCursor const Y = PTNVIEW_OFFSET_Y const cStatus = 253, cContent = 240, cHdr = 230, cEmph = 211, cDim = 246, cBack = 255, cSel = 41 let toolCursor = 0 const drawTools = () => { const x = 5, y0 = Y + 4 con.move(y0, x); con.color_pair(cHdr, cBack); print('Editing actions') con.move(y0 + 1, x); con.color_pair(cDim, cBack); print('-'.repeat(16)) for (let i = 0; i < SMP_EDIT_TOOLS.length; i++) { const y = y0 + 3 + i, t = SMP_EDIT_TOOLS[i], sel = (i === toolCursor), back = sel ? cSel : cBack con.move(y, x); con.color_pair(cHdr, back); print(' ' + t.key + ' ') con.color_pair(cStatus, back); print(' ') con.color_pair(sel ? cEmph : cStatus, back) const w = SCRW - x - 6 print(t.label.length > w ? t.label.substring(0, w) : t.label.padEnd(w)) } } const flashAction = (idx) => { const t = SMP_EDIT_TOOLS[idx]; if (!t) return con.move(SCRH - 2, 5); con.color_pair(cEmph, cBack) print(('Action: ' + t.label + ' (stub, no-op)').padEnd(SCRW - 8)) } // frame for (let y = Y; y < SCRH; y++) { con.move(y, 1); con.color_pair(cContent, cBack); print(' '.repeat(SCRW)) } con.move(Y + 1, 3); con.color_pair(cHdr, cBack); print('[ Sample Editor ] ') con.color_pair(cEmph, cBack); print('Sample ') con.color_pair(cStatus, cBack) print(SAMPLE_IDX >= 0 ? ('#' + (SAMPLE_IDX + 1).toString(16).toUpperCase().padStart(2, '0')) : '(none)') con.move(Y + 2, 3); con.color_pair(cDim, cBack); print('stub editor - actions below are placeholders only.') drawTools() // hints con.move(SCRH, 1); con.color_pair(cStatus, cBack); print(' '.repeat(SCRW - 1)) con.move(SCRH, 1) con.color_pair(cHdr, cBack); print('Up/Dn '); con.color_pair(cStatus, cBack); print('Tool ') con.color_pair(cHdr, cBack); print('Enter '); con.color_pair(cStatus, cBack); print('Apply ') con.color_pair(cHdr, cBack); print('Esc/Tab '); con.color_pair(cStatus, cBack); print('Back') // No clickable regions of our own yet — clear the (now-covered) Samples-viewer // regions so a click in the editor doesn't hit them; transport still works. HUB.clearPanelMouseRegions() editorModalLoop((ks, finish, first) => { if (ks === '') { if (toolCursor > 0) toolCursor--; drawTools(); return } if (ks === '') { if (toolCursor < SMP_EDIT_TOOLS.length - 1) toolCursor++; drawTools(); return } if (!first) return // the rest are discrete; ignore key-repeat if (ks === '\n') { flashAction(toolCursor); return } for (let i = 0; i < SMP_EDIT_TOOLS.length; i++) { if (ks === SMP_EDIT_TOOLS[i].key.toLowerCase() || ks === SMP_EDIT_TOOLS[i].key) { toolCursor = i; drawTools(); flashAction(i); return } } }) // teardown: sample data may have changed -> rebuild + repaint the parent viewer. refreshSamplesCache() clampSamplesCursor() HUB.drawAll() HUB.rebuildPanelMouseRegions() } // Full Ixmp patch parse (byte layout per AudioJSR223Delegate.uploadInstrumentPatches: // 31 common bytes, then optional blocks x[15] v[54] p[54] f[54] P[54] in that order; // the existing forEachIxmpPatchSample only reads a subset). Returns null without the // host patch API, [] when the instrument has no patches. function decodeIxmpPatches(slot) { if (!hasIxmpAPI) return null if (audio.getInstrumentPatchCount(slot) <= 0) return [] const b = audio.getInstrumentPatches(slot) if (!b || b.length < 31) return [] const u8 = (o) => b[o] & 0xFF const u16 = (o) => (b[o] & 0xFF) | ((b[o+1] & 0xFF) << 8) const s16 = (o) => { const v = u16(o); return v >= 0x8000 ? v - 0x10000 : v } const u32 = (o) => (b[o] & 0xFF) | ((b[o+1] & 0xFF) << 8) | ((b[o+2] & 0xFF) << 16) | ((b[o+3] & 0xFF) * 0x1000000) const out = [] let o = 0 while (o + 31 <= b.length) { const ver = u8(o), len = ixmpPatchLen(ver) if (o + len > b.length) break let hasExtra = false, fadeoutStep = 0, extraCutoff = 0xFF, extraResonance = 0xFF, extraAtten = 0, filterSfMode = false if (ver & 0x80) { // 'x' block is always first after the common bytes const xp = o + 31 filterSfMode = (u8(xp) & 0x01) !== 0 fadeoutStep = u16(xp + 8) extraCutoff = u16(xp + 10) extraResonance = u16(xp + 12) extraAtten = u8(xp + 14) hasExtra = true } // Optional v/p/f/P envelope blocks follow the (optional) x block, in order. // Parse each present block into the same shape decodeEnvelope produces so the // graph renderer can draw it (filter→'pf', pitch→'pf2' roles). let bp = o + 31 + (hasExtra ? 15 : 0) const hasVol = (ver&0x02)!==0, hasPan = (ver&0x04)!==0, hasFil = (ver&0x08)!==0, hasPit = (ver&0x10)!==0 let volEnv = null, panEnv = null, filterEnv = null, pitchEnv = null if (hasVol) { volEnv = patchEnvFromBlock(b, bp, 'vol'); bp += 54 } if (hasPan) { panEnv = patchEnvFromBlock(b, bp, 'pan'); bp += 54 } if (hasFil) { filterEnv = patchEnvFromBlock(b, bp, 'pf'); bp += 54 } if (hasPit) { pitchEnv = patchEnvFromBlock(b, bp, 'pf2'); bp += 54 } out.push({ kind: 'patch', ver, pitchStart: u16(o+1), pitchEnd: u16(o+3), volStart: u8(o+5), volEnd: u8(o+6), ptr: u32(o+7), len: u16(o+11), playStart: u16(o+13), loopStart: u16(o+15), loopEnd: u16(o+17), rate: u16(o+19), detune: s16(o+21), loopMode: u8(o+23), pan: u8(o+24), noteVol: u8(o+25), vibSpeed: u8(o+26), vibSweep: u8(o+27), vibDepth: u8(o+28), vibRate: u8(o+29), vibWave: u8(o+30), hasExtra, fadeoutStep, filterSfMode, extraCutoff, extraResonance, extraAtten, hasVol, hasPan, hasFil, hasPit, volEnv, panEnv, filterEnv, pitchEnv, }) o += len } return out } // Reconstruct a decodeEnvelope-shaped object from one 54-byte patch envelope block // (loop word, sustain word, 25 value/dur node pairs) by staging it into a synthetic // 256-byte record at the offsets decodeEnvelope reads for that kind, then reusing // decodeEnvelope (so the bit-parsing / valueMax / pf-role logic isn't duplicated). function patchEnvFromBlock(b, off, kind) { const loopOff = (kind==='vol')?15 : (kind==='pan')?17 : (kind==='pf')?19 : 197 const sustOff = (kind==='vol')?189 : (kind==='pan')?191 : (kind==='pf')?193 : 199 const nodeBase = (kind==='vol')?21 : (kind==='pan')?71 : (kind==='pf')?121 : 201 const rec = new Array(256).fill(0) rec[loopOff] = b[off] & 0xFF; rec[loopOff+1] = b[off+1] & 0xFF rec[sustOff] = b[off+2] & 0xFF; rec[sustOff+1] = b[off+3] & 0xFF for (let i = 0; i < 50; i++) rec[nodeBase + i] = b[off + 4 + i] & 0xFF return decodeEnvelope(rec, kind) } function advSampleName(ptr, len) { const c = samplesCache || [] for (let i = 0; i < c.length; i++) if (c[i].ptr === ptr && c[i].len === len) return c[i].name || '' return '' } function advSampleLabel(ptr, len) { const nm = advSampleName(ptr, len) return (nm ? '"' + nm + '" ' : '') + '($' + (ptr >>> 0).toString(16).toUpperCase().padStart(6, '0') + ', ' + len + 'B)' } function advInstName(slot) { const names = (songsMeta && songsMeta.instNames) || [] return names[slot] || '' } function hx4(n) { return (n & 0xFFFF).toString(16).toUpperCase().padStart(4, '0') } function signedC(detune) { const c = detune * 1200 / 4096; return (c >= 0 ? '+' : '') + c.toFixed(0) + 'c' } function ovr(val, isDefault) { return isDefault ? '--' : ('$' + (val & 0xFF).toString(16).toUpperCase().padStart(2, '0')) } // Advanced Edit — read-only comprehensive visualiser of an instrument's Ixmp // patch layout (keyzones / velocity layers over Pitch x Volume), with a live // overlay of the currently-sounding voices. Layout: patch list (left, scrollable, // with live blobs) + zone map (top-right) + selected-patch detail + envelope graph // (bottom-right). Mouse-aware (patches + transport). See plan Step 2. function openAdvancedInstEdit(slot) { const SLOT = (slot !== undefined && slot >= 0) ? (slot | 0) : -1 const Y = PTNVIEW_OFFSET_Y - 1 // start one row above the normal panel top (row 4), 1 row taller const cHdr = colVoiceHdr, cStatus = colStatus, cDim = colSep, cBack = 255 // ── geometry ──────────────────────────────────────────────────────────── const LIST_X = 1, LIST_W = 22 const LIST_BLOB_X = LIST_X // col 1: live-play blob const LIST_TEXT_X = LIST_X + 1 // col 1 = blob, last col = scroll gutter const LIST_TEXT_W = LIST_W - 2 const LIST_SCROLL_X = LIST_X + LIST_W - 1 const SEP_X = LIST_X + LIST_W const R_X = SEP_X + 2 const LIST_Y = Y + 2 const LIST_H = SCRH - LIST_Y // list fills down to the row above the hint const MAP_X = R_X + 3 // 3 cols of vol-axis labels const MAP_W = SCRW - MAP_X + 1 const MAP_Y = Y + 3 const MAP_H = 8 const MAP_BOT = MAP_Y + MAP_H - 1 const DET_Y = MAP_BOT + 3 const ENV_HDR_Y = DET_Y + 5 // env tab-strip row const ENV_INFO_Y = DET_Y + 6 // env length / grid info row (like the base inst panel) const ENV_TOP_Y = DET_Y + 7 // first env-graph / wavescope text row const ENV_RECT = { x: (R_X - 1) * CELL_PW, y: (ENV_TOP_Y - 1) * CELL_PH, w: (SCRW - R_X + 1) * CELL_PW, h: (SCRH - ENV_TOP_Y) * CELL_PH } // base palette for non-selected patch rects (background colours; black labels) const PAL = [150, 180, 110, 215, 141, 80, 209, 116] const baseBg = colBackPtn, baseFg = cDim // env-section tabs: 4 envelopes + the sample wavescope. Reserve ~8 cols at the // right of the tab row for the "(patch)/(base)" source label (item 1). const ENV_TABS = ['Vol', 'Pan', 'Filter', 'Pitch', 'Wave'] const ENV_WAVE = 4 const ENV_TABW = Math.max(8, ((SCRW - R_X + 1 - 8) / ENV_TABS.length) | 0) const ENV_SRC_X = R_X + ENV_TABS.length * ENV_TABW + 1 const ENV_IDXFN = ['getVoiceEnvVolIndex', 'getVoiceEnvPanIndex', 'getVoiceEnvFilterIndex', 'getVoiceEnvPitchIndex'] const ENV_TIMEFN = ['getVoiceEnvVolTime', 'getVoiceEnvPanTime', 'getVoiceEnvFilterTime', 'getVoiceEnvPitchTime'] // ── model ───────────────────────────────────────────────────────────────── const rec = (SLOT >= 0) ? readInstRecord(SLOT) : null const isMeta = rec ? recordIsMeta(rec) : false const meta = isMeta ? decodeMetaRecord(rec) : null const base = (rec && !isMeta) ? decodeInstFull(rec) : null const patches = (rec && !isMeta) ? decodeIxmpPatches(SLOT) : null const noApi = (!isMeta && patches === null) const baseEnvs = base ? [base.volEnv, base.panEnv, base.filterEnv, base.pitchEnv] : [null, null, null, null] // Unified zone list (each: pitchStart/End, volStart/End, kind, label, detail src). const zones = [] if (isMeta) { meta.layers.forEach((L) => zones.push({ kind: 'layer', layer: L, pitchStart: L.pitchStart, pitchEnd: L.pitchEnd, volStart: L.volStart, volEnd: L.volEnd })) } else if (rec) { (patches || []).forEach((p) => zones.push(p)) // base fallback entry — drawn as the backdrop, listed last zones.push({ kind: 'base', pitchStart: 0, pitchEnd: 0xFFFF, volStart: 0, volEnd: 63 }) } let selIdx = 0, listScroll = 0, envKind = 0 // ── pitch range (union of real zones; base alone -> whole-map backdrop) ──── let minP = Infinity, maxP = -Infinity zones.forEach((z) => { if (z.kind !== 'base') { if (z.pitchStart < minP) minP = z.pitchStart; if (z.pitchEnd > maxP) maxP = z.pitchEnd } }) if (!isFinite(minP)) { minP = 0x1000; maxP = 0x9000 } if (maxP <= minP) maxP = minP + 1 const colOf = (note) => { let c = MAP_X + Math.round((note - minP) / (maxP - minP) * (MAP_W - 1)) if (c < MAP_X) c = MAP_X; if (c > MAP_X + MAP_W - 1) c = MAP_X + MAP_W - 1 return c } // map-rect of each non-base zone (precomputed for fill + hit-test) const rectOf = (z) => ({ cx0: colOf(z.pitchStart), cx1: colOf(z.pitchEnd), ry0: MAP_Y + Math.round((63 - Math.min(63, z.volEnd)) / 63 * (MAP_H - 1)), ry1: MAP_Y + Math.round((63 - Math.max(0, z.volStart)) / 63 * (MAP_H - 1)), }) const rects = zones.map((z) => z.kind === 'base' ? null : rectOf(z)) // zone index covering a map cell (first matching non-base rect), or -1 = base const zoneAtCell = (col, row) => { for (let i = 0; i < zones.length; i++) { const r = rects[i]; if (!r) continue if (col >= r.cx0 && col <= r.cx1 && row >= r.ry0 && row <= r.ry1) return i } return -1 } const baseIdx = () => { for (let i = 0; i < zones.length; i++) if (zones[i].kind === 'base') return i; return -1 } const zoneBg = (i) => (i < 0) ? baseBg : (i === selIdx) ? colHighlight : PAL[i % PAL.length] const zoneFg = (i) => (i < 0) ? baseFg : (i === selIdx) ? colWHITE : colBLACK function clampList() { if (selIdx < listScroll) listScroll = selIdx if (selIdx >= listScroll + LIST_H) listScroll = selIdx - LIST_H + 1 const maxS = Math.max(0, zones.length - LIST_H) if (listScroll > maxS) listScroll = maxS if (listScroll < 0) listScroll = 0 } // selected zone's envelope for the current envKind (patch's own, else base's). function envForSel() { const z = zones[selIdx] if (!z) return null if (z.kind === 'layer') return null if (z.kind === 'patch') { const e = [z.volEnv, z.panEnv, z.filterEnv, z.pitchEnv][envKind] if (e) return { env: e, src: 'patch' } } return baseEnvs[envKind] ? { env: baseEnvs[envKind], src: 'base' } : null } // ── drawing ──────────────────────────────────────────────────────────────── function clearPanel() { for (let y = Y; y < SCRH; y++) { con.move(y, 1); con.color_pair(cStatus, cBack); print(' '.repeat(SCRW)) } } function drawHeader() { const nm = (SLOT >= 0) ? (advInstName(SLOT)) : '' con.move(Y, LIST_X); con.color_pair(cHdr, cBack) let h = 'Advanced Edit' if (SLOT >= 0) h += ' Inst $' + SLOT.toString(16).toUpperCase().padStart(2,'0') + (nm ? ' "' + nm + '"' : '') if (isMeta) h += ' Metainstrument ' + meta.layers.length + ' layers' else if (rec) h += ' ' + (patches ? patches.length : 0) + ' patches' print(h.substring(0, SCRW - 1)) // separator con.color_pair(cDim, cBack) for (let y = LIST_Y; y < SCRH; y++) { con.move(y, SEP_X); con.prnch(VERT) } } function drawList() { // NOTE: does not clampList() — wheel scroll is free (selection may scroll off). // Keyboard / click selection calls clampList via redrawSel to keep it visible. con.move(Y + 1, LIST_X); con.color_pair(cHdr, cBack); print((isMeta ? 'Layers' : 'Patches').padEnd(LIST_W)) const n = zones.length, maxS = Math.max(0, n - LIST_H) const thumb = (n > LIST_H && maxS > 0) ? ((listScroll * (LIST_H - 1) / maxS) | 0) : -1 for (let r = 0; r < LIST_H; r++) { const i = listScroll + r const y = LIST_Y + r const sel = (i === selIdx && i < n), back = sel ? colHighlight : cBack // blob column (col 1) — refreshLiveVoices paints the glyph during playback; // here we just lay down the row background so the selection bar is continuous. con.move(y, LIST_BLOB_X); con.color_pair(sel ? colWHITE : cStatus, back); print(' ') con.move(y, LIST_TEXT_X) if (i >= n) { con.color_pair(cStatus, cBack); print(' '.repeat(LIST_TEXT_W)) } else { const z = zones[i] if (z.kind === 'base') { con.color_pair(sel ? colWHITE : cStatus, back) print(('base ' + (base ? advSampleLabel(base.samplePtr, base.sampleLen) : '')).padEnd(LIST_TEXT_W).substring(0, LIST_TEXT_W)) } else { // blue, zero-padded index (matching the Samples / Instruments lists), then name const numStr = i.toString(16).toUpperCase().padStart(2, '0') const name = (z.kind === 'layer') ? ('>$' + z.layer.instIdx.toString(16).toUpperCase().padStart(2, '0') + ' ' + advInstName(z.layer.instIdx)) : advSampleLabel(z.ptr, z.len) con.color_pair(colInst, back); print(numStr + ' ') con.color_pair(sel ? colWHITE : cStatus, back) print(name.padEnd(LIST_TEXT_W - 3).substring(0, LIST_TEXT_W - 3)) } } // scroll gutter con.move(y, LIST_SCROLL_X) if (n > LIST_H) { con.color_pair(colScrollBar, cBack) let g = (r === 0) ? sym.taut_scrollgutter_top : (r === LIST_H - 1) ? sym.taut_scrollgutter_bot : sym.taut_scrollgutter_mid if (r === thumb) g += 3 con.addch(g) } else { con.color_pair(cStatus, cBack); print(' ') } } } function drawMap() { // axis label row con.move(Y + 1, R_X); con.color_pair(cHdr, cBack); print('vel' + sym.middot + 'PITCH ' + sym.middot + sym.middot + '>') // vol axis labels (63 top, 0 bottom) con.color_pair(cDim, cBack) con.move(MAP_Y, R_X); print('63') con.move(MAP_BOT, R_X); print(' 0') // base backdrop for (let row = MAP_Y; row <= MAP_BOT; row++) { con.move(row, MAP_X); con.color_pair(baseFg, baseBg); print(' '.repeat(MAP_W)) } // patch / layer rects for (let i = 0; i < zones.length; i++) { const r = rects[i]; if (!r) continue const bg = zoneBg(i), fg = zoneFg(i) for (let row = r.ry0; row <= r.ry1; row++) { con.move(row, r.cx0); con.color_pair(fg, bg); print(' '.repeat(r.cx1 - r.cx0 + 1)) } con.move(r.ry0, r.cx0); con.color_pair(fg, bg); print(i.toString(16).toUpperCase().substring(0, Math.max(1, r.cx1 - r.cx0 + 1))) } // pitch labels under the map: leftmost + rightmost note names con.move(MAP_BOT + 1, MAP_X); con.color_pair(cDim, cBack) const lo = (noteToStr(minP) || '').trim(), hi = (noteToStr(maxP) || '').trim() print(lo.padEnd(MAP_W - hi.length) + hi) } function drawDetail() { for (let y = DET_Y; y < ENV_HDR_Y; y++) { con.move(y, R_X); con.color_pair(cStatus, cBack); print(' '.repeat(SCRW - R_X + 1)) } const z = zones[selIdx]; if (!z) return const W = SCRW - R_X const put = (dy, fg, s) => { con.move(DET_Y + dy, R_X); con.color_pair(fg, cBack); print(String(s).substring(0, W)) } const rng = (a, b) => (noteToStr(a) || '').trim() + '-' + (noteToStr(b) || '').trim() if (noApi) { put(0, cStatus, 'Patch read-back unavailable on this host VM.'); put(1, cDim, 'Showing base sample only.'); } if (z.kind === 'layer') { const L = z.layer put(0, cHdr, 'Layer ' + selIdx.toString(16).toUpperCase() + ' -> Inst $' + L.instIdx.toString(16).toUpperCase().padStart(2,'0') + ' ' + advInstName(L.instIdx)) put(1, cStatus,'Pitch ' + rng(L.pitchStart, L.pitchEnd) + ' Vol ' + L.volStart + '-' + L.volEnd) const cents = (L.detune * 1200 / 4096) put(2, cStatus,'Mix octet ' + L.mixOctet + (L.mixOctet === 159 ? ' (unity)' : '') + ' Detune ' + (cents >= 0 ? '+' : '') + cents.toFixed(0) + 'c') return } if (z.kind === 'base') { if (!base) return put(0, cHdr, 'Base sample ' + advSampleLabel(base.samplePtr, base.sampleLen)) put(1, cStatus,'Rate@C4 ' + base.c4Rate + 'Hz Loop ' + loopModeName(base.sampleFlags) + ' [' + hx4(base.sLoopStart) + '..' + hx4(base.sLoopEnd) + ']') put(2, cStatus,'(fallback for notes/vels no patch covers)') return } // patch put(0, cHdr, 'Patch ' + selIdx.toString(16).toUpperCase() + ' ' + advSampleLabel(z.ptr, z.len)) put(1, cStatus, 'Pitch ' + rng(z.pitchStart, z.pitchEnd) + ' Vol ' + z.volStart + '-' + z.volEnd + ' Rate ' + z.rate + 'Hz Det ' + signedC(z.detune)) put(2, cStatus, 'Loop ' + loopModeName(z.loopMode) + ' [' + hx4(z.loopStart) + '..' + hx4(z.loopEnd) + '] Pan ' + ovr(z.pan, z.pan === 0xFF) + ' NoteVol ' + ovr(z.noteVol, z.noteVol === 0)) const envs = (z.hasVol?'V':sym.middot) + (z.hasPan?'P':sym.middot) + (z.hasFil?'F':sym.middot) + (z.hasPit?'p':sym.middot) let xline = 'Env ' + envs if (z.hasExtra) xline += ' ' + (z.filterSfMode ? 'SF' : 'IT') + ' Cut ' + hx4(z.extraCutoff) + ' Q ' + hx4(z.extraResonance) + ' Fade $' + z.fadeoutStep.toString(16).toUpperCase() + (z.extraAtten ? ' Att ' + z.extraAtten : '') put(3, cStatus, xline) put(4, cDim, 'Vibr ' + (z.vibWave === 0xFF ? 'base' : ('w' + z.vibWave + ' spd' + z.vibSpeed + ' dep' + z.vibDepth))) } // The selected zone's sample (for the wavescope), or null for a meta layer. function selSample() { const z = zones[selIdx]; if (!z) return null if (z.kind === 'patch') return { ptr: z.ptr, len: z.len, loopStart: z.loopStart, loopEnd: z.loopEnd } if (z.kind === 'base' && base) return { ptr: base.samplePtr, len: base.sampleLen, loopStart: base.sLoopStart, loopEnd: base.sLoopEnd } return null } // Compact sample-waveform (wavescope) — a standalone min/max filled draw into the // env-section rect (does NOT reuse the funk-aware viewer drawSampleWaveform). The // live play position is overlaid by drawEnvCursor (wave branch). function drawAdvWave(smp, r) { const wx0 = r.x, wy0 = r.y, wW = r.w, wH = r.h const baseY = wy0 + (wH >>> 1) const yOf = (v) => wy0 + (((wH * (255 - v)) / 255) | 0) const memBase = audio.getMemAddr() const prevBank = audio.getSampleBank() || 0 let curBank = -1 const readByte = (p) => { const abs = smp.ptr + p const bank = (abs / TAUT_SBANK_SIZE) | 0 if (bank !== curBank) { audio.setSampleBank(bank); curBank = bank } return sys.peek(memBase - (abs - bank * TAUT_SBANK_SIZE)) & 0xFF } graphics.plotRect(wx0, baseY, wW, 1, colSmpWaveMid) // zero line if (smp.len <= wW) { const rectW = Math.max(1, Math.ceil(wW / smp.len)) for (let i = 0; i < smp.len; i++) { const yv = yOf(readByte(i)), top = Math.min(baseY, yv) graphics.plotRect(wx0 + ((i * wW / smp.len) | 0), top, rectW, Math.max(1, Math.abs(baseY - yv)), colSmpWaveLine) } } else { for (let col = 0; col < wW; col++) { const start = (col * smp.len / wW) | 0, end = Math.min(smp.len, (((col + 1) * smp.len / wW) | 0)) if (end <= start) continue const step = Math.max(1, ((end - start) / 8) | 0) let mn = 255, mx = 0 for (let p = start; p < end; p += step) { const v = readByte(p); if (v < mn) mn = v; if (v > mx) mx = v } const yT = yOf(mx), yB = yOf(mn) graphics.plotRect(wx0 + col, Math.min(yT, yB), 1, Math.max(1, Math.abs(yB - yT)), colSmpWaveLine) } } audio.setSampleBank(prevBank) // restore active bank } // Env-section tab strip (clickable). 4 envelopes + Wave. Matches the instruments // viewer's drawInstrumentsTabStrip style (shade-cap edges, colTabActive/Inactive). function drawEnvTabs() { con.move(ENV_HDR_Y, R_X); con.color_pair(colTabBarOrn, colTabBarBack); print(' '.repeat(SCRW - R_X + 1)) for (let i = 0; i < ENV_TABS.length; i++) { const x = R_X + i * ENV_TABW, active = (i === envKind) const lbl = ENV_TABS[i] const pad = Math.max(0, ENV_TABW - lbl.length), padL = pad >>> 1, padR = pad - padL const colFore = active ? colTabActive : colTabInactive const colBack = active ? colTabBarBack2 : colTabBarBack const colFore2 = active ? colTabBarBack2 : colTabBarBack const spcL = active ? sym.leftshade : ' ' const spcR = active ? sym.rightshade : ' ' con.move(ENV_HDR_Y, x) con.color_pair(colFore2, colTabBarBack); print(spcL) con.color_pair(colFore, colBack); print(' '.repeat(Math.max(0, padL - 1)) + lbl + ' '.repeat(Math.max(0, padR - 1))) con.color_pair(colFore2, colTabBarBack); print(spcR) } } // Envelope graph / wavescope (graphics overlay) under the tab strip. The play // cursor is painted on top each tick (drawEnvCursor); bg 255 = transparent, so // the text tabs / cursor sit over the graphics. let envCurCols = [], envCurSig = '~' function clearEnvGraphics() { graphics.plotRect(ENV_RECT.x - 2, ENV_RECT.y - 2, ENV_RECT.w + 4, ENV_RECT.h + 4, 255) } function drawEnvGraph() { envCurCols = []; envCurSig = '~' clearEnvGraphics() for (let y = ENV_INFO_Y; y < SCRH; y++) { con.move(y, R_X); con.color_pair(cStatus, cBack); print(' '.repeat(SCRW - R_X + 1)) } drawEnvTabs() if (envKind === ENV_WAVE) { const smp = selSample() if (!smp || smp.len === 0) { con.move(ENV_TOP_Y, R_X); con.color_pair(cDim, cBack); print('(no sample)') } else { con.move(ENV_INFO_Y, R_X); con.color_pair(cDim, cBack); print('Length ' + smp.len + ' B') drawAdvWave(smp, ENV_RECT) } return } const sel = envForSel() // source indicator on the tab row (item 1) — the graph rows host the play // cursor, which would otherwise erase it. con.move(ENV_HDR_Y, ENV_SRC_X); con.color_pair(colTabInactive, colTabBarBack) print((sel ? '(' + sel.src + ')' : '').padEnd(SCRW - ENV_SRC_X + 1).substring(0, SCRW - ENV_SRC_X + 1)) if (!sel || !sel.env || !sel.env.present) { con.move(ENV_TOP_Y - 1, R_X); con.color_pair(cDim, cBack) print(zones[selIdx] && zones[selIdx].kind === 'layer' ? '(see the layer instrument)' : 'no ' + ENV_TABS[envKind].toLowerCase() + ' envelope') return } // length + time-grid step, like the base instrument panel's envelope tab const env = sel.env const lastIdx = (env.terminatorIdx >= 0) ? env.terminatorIdx : (env.nodes.length - 1) let totalSec = 0 for (let i = 0; i < lastIdx; i++) totalSec += env.nodes[i].durSec con.move(ENV_INFO_Y, R_X); con.color_pair(cDim, cBack) print('Length ' + totalSec.toFixed(3) + ' s grid ' + pickEnvTimeGrid(Math.max(totalSec, 1e-6)) + ' s') drawEnvelopeGraph(sel.env, ENV_RECT) } function drawEnvCursor() { // Compute the displayed env's playhead hairline column(s), then repaint only // when they change (sig guard, like the viewer's drawEnvelopeCursor) so the // busy-loop doesn't flicker the hairline every spin. let cols = [], colMap = {} const playing = HUB.getPlaybackMode() !== PLAYMODE_NONE if (SLOT >= 0 && playing && envKind === ENV_WAVE) { // Wavescope: hairline at each voice's sample play position (getVoiceSamplePos). const smp = selSample() if (smp && smp.len > 0) { const hits = [] const voices = activeVoicesForInstSlot(SLOT) for (let k = 0; k < voices.length; k++) { const v = voices[k].voice if (audio.getVoiceSamplePtr(PLAYHEAD, v) !== smp.ptr || audio.getVoiceSampleLength(PLAYHEAD, v) !== smp.len) continue const pos = audio.getVoiceSamplePos(PLAYHEAD, v); if (pos < 0) continue const xPix = ENV_RECT.x + Math.min(ENV_RECT.w - 1, Math.max(0, ((pos / smp.len) * (ENV_RECT.w - 1)) | 0)) const h = pixelToHairline(xPix) hits.push({ col: h.col, hair: h.hair, vol: voices[k].vol }) } const res = resolveHairlineHits(hits); cols = res.cols; colMap = res.colMap } } else if (SLOT >= 0 && playing && envKind !== ENV_WAVE) { const sel = envForSel() const okFilter = !(envKind === 2 && !hasFilterEnvAPI) if (sel && sel.env && sel.env.present && okFilter) { const env = sel.env const lastIdx = (env.terminatorIdx >= 0) ? env.terminatorIdx : (env.nodes.length - 1) if (lastIdx >= 0) { let acc = 0; const xs = new Array(lastIdx + 1); xs[0] = 0 for (let i = 1; i <= lastIdx; i++) { acc += env.nodes[i - 1].durSec; xs[i] = acc } const totalTime = Math.max(acc, 1e-6) const idxFn = ENV_IDXFN[envKind], timeFn = ENV_TIMEFN[envKind] const hits = [] const voices = activeVoicesForInstSlot(SLOT) for (let k = 0; k < voices.length; k++) { const v = voices[k].voice const ei0 = audio[idxFn](PLAYHEAD, v); if (ei0 < 0) continue const ei = Math.max(0, Math.min(lastIdx, ei0)) const segLen = (ei < lastIdx) ? env.nodes[ei].durSec : 0 const tInto = Math.max(0, Math.min(segLen, audio[timeFn](PLAYHEAD, v))) const xPix = ENV_RECT.x + Math.min(ENV_RECT.w - 1, Math.max(0, (((xs[ei] + tInto) / totalTime) * (ENV_RECT.w - 1)) | 0)) const h = pixelToHairline(xPix) hits.push({ col: h.col, hair: h.hair, vol: voices[k].vol }) } const res = resolveHairlineHits(hits); cols = res.cols; colMap = res.colMap } } } const sig = cols.map((c) => c + colMap[c]).join(',') if (sig === envCurSig) return for (let k = 0; k < envCurCols.length; k++) { // erase old hairlines (transparent) con.color_pair(cStatus, cBack) for (let y = ENV_TOP_Y; y < SCRH; y++) { con.move(y, envCurCols[k]); print(' ') } } for (let c = 0; c < cols.length; c++) { // paint new con.color_pair(colPlayCursor, cBack) for (let y = ENV_TOP_Y; y < SCRH; y++) { con.move(y, cols[c]); print(colMap[cols[c]]) } } envCurCols = cols.slice(); envCurSig = sig } function drawHint() { con.move(SCRH, 1); con.color_pair(cStatus, cBack); print(' '.repeat(SCRW - 1)) con.move(SCRH, 1) con.color_pair(cHdr, cBack); print('Up/Dn '); con.color_pair(cStatus, cBack); print((isMeta ? 'Layer ' : 'Patch ')) con.color_pair(cHdr, cBack); print(sym.panle + sym.panri + ' '); con.color_pair(cStatus, cBack); print('Tab ') con.color_pair(cHdr, cBack); print(sym.playhead + ' '); con.color_pair(cStatus, cBack); print('voice ') con.color_pair(cHdr, cBack); print('Esc '); con.color_pair(cStatus, cBack); print('Back') } // ── live overlay (onTick): map blobs + patch-list blobs + env/wave cursor ── // voicePeak[v] = { note, peak } tracks the spawn-volume proxy: the PEAK effective // volume since the note started (reset on note change). The map blob's Y uses this // so it pins to the trigger velocity instead of drifting down as the env decays. let liveSig = '~' const voicePeak = [] function refreshLiveVoices() { if (SLOT < 0 || zones.length === 0) { drawEnvCursor(); return } const song = HUB.getSong() const nv = (song && song.numVoices) ? song.numVoices : NUM_VOICES const playing = (HUB.getPlaybackMode() !== PLAYMODE_NONE) const blobs = [] const litVol = new Array(zones.length).fill(0) // max CURRENT eff vol per zone (brightness) const litCnt = new Array(zones.length).fill(0) // sounding-voice count per zone (heat) for (let v = 0; v < nv; v++) { if (!playing || !audio.getVoiceActive(PLAYHEAD, v) || audio.getVoiceInstrument(PLAYHEAD, v) !== SLOT) { voicePeak[v] = null; continue } const note = audio.getVoiceNote(PLAYHEAD, v) const eff = audio.getVoiceEffectiveVolume(PLAYHEAD, v) || 0 let pk = voicePeak[v] if (!pk || pk.note !== note) pk = voicePeak[v] = { note, peak: eff } else if (eff > pk.peak) pk.peak = eff const sv = Math.round(pk.peak * 63) // spawn-volume proxy blobs.push({ col: colOf(note), row: MAP_Y + Math.round((63 - Math.min(63, Math.max(0, sv))) / 63 * (MAP_H - 1)) }) // which patch-list row is this voice sounding? (match the playing sample) const sp = audio.getVoiceSamplePtr(PLAYHEAD, v), sl = audio.getVoiceSampleLength(PLAYHEAD, v) let zi = -1 for (let i = 0; i < zones.length; i++) { const z = zones[i]; if (z.kind === 'patch' && z.ptr === sp && z.len === sl) { zi = i; break } } if (zi < 0) zi = baseIdx() if (zi >= 0) { if (eff > litVol[zi]) litVol[zi] = eff; litCnt[zi]++ } } const sig = blobs.map((b) => b.col + ':' + b.row).sort().join(',') + '|' + litVol.map((x, i) => blobLevelForVolume(x) + 'x' + litCnt[i]).join(',') if (sig !== liveSig) { liveSig = sig drawMap() // clears prior map blobs for (let k = 0; k < blobs.length; k++) { const b = blobs[k] con.move(b.row, b.col); con.color_pair(colWHITE, zoneBg(zoneAtCell(b.col, b.row))); print(sym.playhead) } // patch-list blobs in col 1: glyph shape = volume level, fg = polyphony heat. for (let r = 0; r < LIST_H; r++) { const i = listScroll + r; if (i >= zones.length) break const lvl = Math.max(litCnt[i] > 0 ? 1 : 0, blobLevelForVolume(litVol[i])) const bucket = blobPolyBucket(litCnt[i]) con.move(LIST_Y + r, LIST_BLOB_X) con.color_pair(bucket > 0 ? blobPolyCols[bucket - 1] : cStatus, (i === selIdx) ? colHighlight : cBack) print(lvl > 0 ? sym['blob' + lvl] : ' ') } } drawEnvCursor() } // ── mouse ─────────────────────────────────────────────────────────────────── const redrawSel = () => { clampList(); drawList(); drawMap(); drawDetail(); drawEnvGraph(); liveSig = '~' } function registerMouse() { HUB.clearPanelMouseRegions() HUB.addPanelMouseRegion(LIST_X, LIST_Y, LIST_W, LIST_H, { onClick: (cy, cx, btn) => { if (btn !== 1) return const i = listScroll + (cy - LIST_Y) if (i >= 0 && i < zones.length) { selIdx = i; redrawSel() } }, onWheel: (cy, cx, dy) => { const maxS = Math.max(0, zones.length - LIST_H) listScroll = Math.max(0, Math.min(maxS, listScroll + dy * 3)) drawList() }, }) HUB.addPanelMouseRegion(MAP_X, MAP_Y, MAP_W, MAP_H, { onClick: (cy, cx, btn) => { if (btn !== 1) return let zi = zoneAtCell(cx, cy); if (zi < 0) zi = baseIdx() if (zi >= 0) { selIdx = zi; redrawSel() } }, }) // env/wave tab strip (click), + wheel anywhere in the graph body to cycle for (let t = 0; t < ENV_TABS.length; t++) { const tx = R_X + t * ENV_TABW HUB.addPanelMouseRegion(tx, ENV_HDR_Y, ENV_TABW, 1, { onClick: (cy, cx, btn) => { if (btn === 1) { envKind = t; drawEnvGraph(); liveSig = '~' } }, }) } HUB.addPanelMouseRegion(R_X, ENV_TOP_Y, SCRW - R_X + 1, SCRH - ENV_TOP_Y, { onWheel: (cy, cx, dy) => { envKind = (envKind + (dy < 0 ? 1 : ENV_TABS.length - 1)) % ENV_TABS.length; drawEnvGraph(); liveSig = '~' }, }) } // ── compose ──────────────────────────────────────────────────────────────── // Wipe ALL graphics in the panel area first — the instruments viewer leaves an // envelope graph (graphics overlay at instEnvelopeRect, higher than our ENV_RECT) // when entered from a Vol/Pan/... tab; clearPanel only clears text, so those pixels // would linger. 255 = transparent. graphics.plotRect(0, (Y - 1) * CELL_PH, SCRW * CELL_PW, (SCRH - Y + 1) * CELL_PH, 255) clearPanel(); clearEnvGraphics(); drawHeader(); drawList() if (rec && !noApi || isMeta) drawMap() else if (noApi) { con.move(MAP_Y, MAP_X); con.color_pair(cDim, cBack); print('(patch map unavailable on this host)') } drawDetail(); drawEnvGraph(); drawHint() registerMouse() editorModalLoop((ks, finish, first) => { if (zones.length === 0) return if (ks === '') { if (selIdx > 0) { selIdx--; redrawSel() } return } if (ks === '') { if (selIdx < zones.length - 1) { selIdx++; redrawSel() } return } if (ks === '') { selIdx = Math.max(0, selIdx - LIST_H); redrawSel(); return } if (ks === '') { selIdx = Math.min(zones.length - 1, selIdx + LIST_H); redrawSel(); return } if (ks === '') { selIdx = 0; redrawSel(); return } if (ks === '') { selIdx = zones.length - 1; redrawSel(); return } if (!first) return if (ks === '') { envKind = (envKind + ENV_TABS.length - 1) % ENV_TABS.length; drawEnvGraph(); liveSig = '~'; return } if (ks === '') { envKind = (envKind + 1) % ENV_TABS.length; drawEnvGraph(); liveSig = '~'; return } }, refreshLiveVoices) clearEnvGraphics() // don't leave the graph over the restored viewer refreshInstrumentsCache() clampInstrumentsCursor() HUB.drawAll() HUB.rebuildPanelMouseRegions() } return { drawSamplesContents, samplesInput, drawInstrumentsContents, instrumentsInput, refreshSamplesCache, refreshInstrumentsCache, drawSamplesPlayBlobs, drawInstrumentsPlayBlobs, drawSampleCursor, drawEnvelopeCursor, tickFunkWaveform, clearSampleWaveformArea, clearInstrumentsEnvelopeArea, clampSamplesCursor, drawSamplesUsedBy, computeSampleRAMBytes, formatSampleRamK, launchInstrumentViewerFor, registerInstrumentsMouse, registerSamplesMouse, sampleRamSummary, drawSlider, drawNumCapsule, runSliderDrag, } } exports = { init }