From 62601de531d57e264955c05b780ea878712014ad Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sun, 21 Jun 2026 22:23:45 +0900 Subject: [PATCH] taut: modularity refactor --- .../tvdos/bin/{monplay.js => playmon.js} | 0 assets/disk0/tvdos/bin/taut.js | 2927 +---------------- assets/disk0/tvdos/bin/taut_fileop.js | 77 - assets/disk0/tvdos/bin/taut_fileop.mjs | 40 + .../bin/{taut_helpmsg.js => taut_helpmsg.mjs} | 46 +- assets/disk0/tvdos/bin/taut_instredit.js | 77 - assets/disk0/tvdos/bin/taut_notationedit.js | 0 assets/disk0/tvdos/bin/taut_popups.mjs | 333 ++ assets/disk0/tvdos/bin/taut_sampleedit.js | 181 - assets/disk0/tvdos/bin/taut_views.mjs | 2577 +++++++++++++++ terranmon.txt | 3 + 11 files changed, 3100 insertions(+), 3161 deletions(-) rename assets/disk0/tvdos/bin/{monplay.js => playmon.js} (100%) delete mode 100644 assets/disk0/tvdos/bin/taut_fileop.js create mode 100644 assets/disk0/tvdos/bin/taut_fileop.mjs rename assets/disk0/tvdos/bin/{taut_helpmsg.js => taut_helpmsg.mjs} (84%) delete mode 100644 assets/disk0/tvdos/bin/taut_instredit.js delete mode 100644 assets/disk0/tvdos/bin/taut_notationedit.js create mode 100644 assets/disk0/tvdos/bin/taut_popups.mjs delete mode 100644 assets/disk0/tvdos/bin/taut_sampleedit.js create mode 100644 assets/disk0/tvdos/bin/taut_views.mjs diff --git a/assets/disk0/tvdos/bin/monplay.js b/assets/disk0/tvdos/bin/playmon.js similarity index 100% rename from assets/disk0/tvdos/bin/monplay.js rename to assets/disk0/tvdos/bin/playmon.js diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index a3f88ec..c04f40f 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -23,6 +23,25 @@ _G.TAUT = {}; _G.TAUT.UI = {}; _G.TAUT.UI.NEXTPANEL = undefined; +// ── Module hub ────────────────────────────────────────────────────────────── +// taut.js is split across sibling taut_*.mjs modules (see the humble-brewing-harp +// plan). They run IN-PROCESS in this context — not as separate shell programs — +// so playback stays live and the editors can read player state. Each module +// exports init(HUB) and returns its public functions. HUB carries: +// HUB.C — read-only constants the modules need +// HUB.S — shared MUTABLE state (cross-module reassignable scalars live +// here, so a module and taut.js mutate the same object) +// HUB. — refs to core helpers that stay in taut.js +// HUB. — back-refs to each module's exports, filled at init time +// Modules read HUB.* at CALL time, so forward / cross-module references resolve +// once every module has been init'd (which happens before the main loop). The +// hub is named HUB rather than ctx to avoid shadowing the many wintex-dialog +// callback params already called `ctx`. Sibling modules are required by absolute +// path so they live next to taut.js in \tvdos\bin\. +const HUB = { C: {}, S: {} } +const TAUT_BIN_DIR = `${_G.shell.getCurrentDrive()}:\\tvdos\\bin\\` +function requireTaut(name) { return require(TAUT_BIN_DIR + name + ".mjs") } + const sym = { /* accidentals */ accnull:"\u00A2\u00A3", @@ -2947,21 +2966,6 @@ const panelTimeline = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HE const panelOrders = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, ordersInput, drawOrdersContents, undefined, ()=>{}) const panelPatterns = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, patternsInput, drawPatternsContents, undefined, ()=>{}) -// External sub-program panels: drawContents launches the sub-program synchronously. -// The sub-program draws rows 4+ and does NOT touch rows 1-3 (drawn by taut.js before launch). -// On exit, the sub-program sets _G.TAUT.UI.NEXTPANEL to request a tab switch. -function makeExternalPanelDraw(progName) { - return function(wo) { - // stop any playback first - stopPlayback() - // update the top bar - drawAlwaysOnElems() - - _G.TAUT.UI.NEXTPANEL = undefined - _G.shell.execute(`${progName} ${fullPathObj.full} ${currentPanel}`) - } -} - // Row offsets (within the meta block at the top of the Project panel) of the editable rows. const PROJ_META_ROW_FLAGS = 6 const PROJ_META_ROW_GVOL = 7 @@ -3035,16 +3039,12 @@ function drawProjectContents(wo) { let toneModeStr = ['Linear pitch','Amiga pitch','Linear freq',''][mixerflag & 3] let intpModeStr = ['Default','None','A500','A1200','SNES','DPCM','',''][(mixerflag >>> 2) & 7] let flagStrSelected = [toneModeStr, intpModeStr] - const sampleCount = (samplesCache || []).length - const sampleKStr = formatSampleRamK(computeSampleRAMBytes()) - - let projMeta = { Filename: fullPathObj.string.split('\\').last(), ProjName: songsMeta.projectName || '(unnamed)', Patterns: `${song.numPats}/4095 ($${song.numPats.hex03()})`, Cues: `${song.lastActiveCue}/1024 ($${song.lastActiveCue.hex03()})`, - Samples: `${sampleCount} (${sampleKStr}k/${SMP_RAM_MAX_K}k)`, + Samples: sampleRamSummary(), Notation: pitchTablePresets[PITCH_PRESET_IDX].name, Flags: `${flagStrSelected.join(', ')} ($${mixerflag.hex02()})`, GlobalVol: `$${initialGlobalVolume.hex02()}`, @@ -3228,2379 +3228,16 @@ function projectInput(wo, event) { } } -function externalPanelInput(wo, event) {} - ///////////////////////////////////////////////////////////////////////////////////////////////////////////// -// SAMPLES VIEWER +// VIEWS (Samples + Instruments + live-play blob/cursor) ///////////////////////////////////////////////////////////////////////////////////////////////////////////// -// 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 launches the external taut_sampleedit sub-program. -// -// 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 - -function clampSamplesCursor() { - const n = samplesCache ? samplesCache.length : 0 - if (smpListCursor < 0) smpListCursor = 0 - if (smpListCursor >= n) smpListCursor = Math.max(0, n - 1) - if (smpListCursor < smpListScroll) smpListScroll = smpListCursor - if (smpListCursor >= smpListScroll + SMP_LIST_H) - smpListScroll = smpListCursor - SMP_LIST_H + 1 - 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 = (song && song.numVoices) ? song.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 (playbackMode !== 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 (currentPanel !== VIEW_SAMPLES) { funkWaveLast = false; return } - const s = (samplesCache && samplesCache[smpListCursor]) || null - const funking = !!(s && s.len > 0 && playbackMode !== 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() - 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 (playbackMode !== 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 (playbackMode !== PLAYMODE_NONE) drawSampleCursor() -} - -let pendingEditorLaunch = null // { progName, args[] } - -function requestEditorLaunch(progName, args) { - pendingEditorLaunch = { progName, args: args || [] } -} - -// 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') { - requestEditorLaunch('taut_sampleedit', [fullPathObj.full, VIEW_SAMPLES, -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') { - requestEditorLaunch('taut_sampleedit', [fullPathObj.full, VIEW_SAMPLES, 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 - -function clampInstrumentsCursor() { - const n = instrumentsCache ? instrumentsCache.length : 0 - if (instListCursor < 0) instListCursor = 0 - if (instListCursor >= n) instListCursor = Math.max(0, n - 1) - if (instListCursor < instListScroll) instListScroll = instListCursor - if (instListCursor >= instListScroll + INST_LIST_H) - instListScroll = instListCursor - INST_LIST_H + 1 - 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) - } - hasUnsavedChanges = true -} - -// 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) - hasUnsavedChanges = true - } 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) { - const r = instEnvelopeRect() - clearInstrumentsEnvelopeArea() // clear - - // 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() - 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 (playbackMode !== 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 (playbackMode !== 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') { - requestEditorLaunch('taut_instredit', [fullPathObj.full, VIEW_INSTRMNT, -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) requestEditorLaunch('taut_instredit', [fullPathObj.full, VIEW_INSTRMNT, 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) => { - instListCursor += dy * 3 - clampInstrumentsCursor() - 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 - requestEditorLaunch('taut_instredit', [fullPathObj.full, VIEW_INSTRMNT, 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 = (song && song.numVoices) ? song.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 = (song && song.numVoices) ? song.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 (currentPanel !== VIEW_SAMPLES || !samplesCache) return - const playing = (playbackMode !== 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 (currentPanel !== VIEW_INSTRMNT || !instrumentsCache) return - const playing = (playbackMode !== 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 = (song && song.numVoices) ? song.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 = (song && song.numVoices) ? song.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 (currentPanel !== 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 (playbackMode !== 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 (currentPanel !== 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 (playbackMode !== 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 - } -} - -const panelSamples = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, samplesInput, drawSamplesContents, undefined, ()=>{}) -const panelInstrmnt = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, instrumentsInput, drawInstrumentsContents, undefined, ()=>{}) -const panelProject = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, projectInput, drawProjectContents, undefined, ()=>{}) -const panelFile = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, externalPanelInput, makeExternalPanelDraw('taut_fileop'), undefined, ()=>{}) - -const panels = [panelTimeline, panelOrders, panelPatterns, panelSamples, panelInstrmnt, panelProject, panelFile] +// Extracted to taut_views.mjs (in-process). It is required + wired below, AFTER +// the PLAYBACK STATE section, because the module reads PLAYHEAD / PLAYMODE_NONE / +// NUM_VOICES via HUB.C and those are defined there. The panels[] array is built +// at that wiring point too, since panelSamples / panelInstrmnt need the module's +// draw / input functions. The Sample / Instrument editors are now in-process +// modals inside the module (openSampleEdit / openAdvancedInstEdit), so there is +// no editor-launch glue here any more. ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // PLAYBACK STATE @@ -5929,89 +3566,86 @@ const HELP_CONTENT_Y = HELP_POPUP_Y + 2 const HELP_CONTENT_W = HELP_POPUP_W - 6 const HELP_CONTENT_H = HELP_POPUP_H - 3 -// Pre-typeset every panel's help text. taut_helpmsg.js reads HELPMSG_WIDTH for -// the wrap width and stores ready-to-print display strings into MSG_BY_TABS. -_G.TAUT.HELPMSG_WIDTH = HELP_CONTENT_W -_G.shell.execute("taut_helpmsg") +// Pre-typeset every panel's help text. taut_helpmsg.mjs typesets at HELP_CONTENT_W +// (passed via HUB.C) and returns ready-to-print display strings in MSG_BY_TABS. +// In-process now — no separate shell program. +HUB.C.HELP_CONTENT_W = HELP_CONTENT_W +HUB.help = requireTaut("taut_helpmsg").init(HUB) -function openHelpPopup() { - const helpmsg = _G.TAUT.HELPMSG || {} - const lines = (helpmsg.MSG_BY_TABS && helpmsg.MSG_BY_TABS[currentPanel]) || [''] - const colText = helpmsg.COL_TEXT || colWHITE +// In-process modules read shared constants from HUB.C and delegate engine-state +// mutations through HUB callbacks, so the engine keeps owning currentPanel / +// cueIdx / patternIdx / mixer flags / the unsaved flag. The arrow wrappers resolve +// at call time, so referencing engine state defined later (applyGoto, +// initialTrackerMixerflags) is fine — modules only call them once playing. +Object.assign(HUB.C, { + sym, PANEL_NAMES, pitchTablePresets, + colWHITE, colPopupBack, colTabBarOrn, colTabBarBack, colTabInactive, + colPan, colInst, colStatus, colHighlight, colVoiceHdr, + HELP_CONTENT_W, HELP_CONTENT_H, +}) +HUB.getPanel = () => currentPanel +HUB.drawAll = () => drawAll() +HUB.applyGoto = (n) => applyGoto(n) +HUB.retuneAllPatterns = (idx, method) => retuneAllPatterns(idx, method) +HUB.getPitchPresetIdx = () => PITCH_PRESET_IDX +HUB.getMixerFlags = () => initialTrackerMixerflags +HUB.commitMixerFlags = (f) => { initialTrackerMixerflags = f; audio.setTrackerMixerFlags(PLAYHEAD, f); hasUnsavedChanges = true } +HUB.hasUnsavedChanges = () => hasUnsavedChanges - win.showDialog({ - title: `Help: ${PANEL_NAMES[currentPanel]}`, - drawFrame: popupDrawFrame, - colours: popupColours, - list: { - items: lines.map(l => ({ label: l })), - bg: colPopupBack, - height: HELP_CONTENT_H, - width: HELP_CONTENT_W+4, - scrollbarChars: popupScrollbarChars, - selectable: () => false, - renderItem: (ctx) => { - con.color_pair(colText, ctx.listBg) - con.move(ctx.y, ctx.x) - const line = (ctx.item.label != null ? ctx.item.label : '') - print(line.padEnd(ctx.w, ' ').substring(0, ctx.w)) - }, - }, - buttons: [{ label: 'OK', action: 'ok', default: true }], - onKey: (ks, _shift, ctx) => { - if (ks === '!' || ks === 'q') { ctx.close({ action: 'cancel' }); return true } - return false - }, - }) - drawAll() -} +HUB.popups = requireTaut("taut_popups").init(HUB) +const { openHelpPopup, openGotoPopup, openRetunePopup, openFlagsPopup, openConfirmQuit } = HUB.popups -///////////////////////////////////////////////////////////////////////////////////////////////////////////// -// SHARED POPUP CHROME -///////////////////////////////////////////////////////////////////////////////////////////////////////////// +// ── Views module (Samples + Instruments + blob/cursor) ────────────────────── +// Wired here (not at the VIEWS marker above) because the module reads PLAYHEAD / +// PLAYMODE_NONE / NUM_VOICES, defined in the PLAYBACK STATE section above. Expand +// HUB.C with the constants the views read, hand over the engine helpers + live- +// state getters they call, then init + alias. panels[] is built right after, as +// panelSamples / panelInstrmnt need the module's draw / input functions. +Object.assign(HUB.C, { + 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, fullPathObj, songsMeta, + colBackPtn, colBLACK, colScrollBar, colSep, colTabActive, colTabBarBack2, colVol, +}) +HUB.noteToStr = noteToStr +HUB.fillLine = fillLine +HUB.drawControlHint = drawControlHint +HUB.drawAlwaysOnElems = drawAlwaysOnElems +HUB.openInlineNumEdit = openInlineNumEdit +HUB.addPanelMouseRegion = addPanelMouseRegion +HUB.clearPanelMouseRegions = clearPanelMouseRegions +HUB.rebuildPanelMouseRegions = rebuildPanelMouseRegions +HUB.switchToPanel = switchToPanel +HUB.getSong = () => song +HUB.getPlaybackMode = () => playbackMode +HUB.markUnsaved = () => { hasUnsavedChanges = true } +// In-process editor modals (openSampleEdit / openAdvancedInstEdit) call this each +// frame to keep playback + blobs alive while open — the whole point of going +// in-process (the old separate programs called stopPlayback on entry). +HUB.tickPlayback = () => { if (playbackMode !== PLAYMODE_NONE) updatePlayback() } +HUB.stopPlayback = stopPlayback -// Custom window-frame painter passed to wintex showDialog as `drawFrame`. -// Paints a title bar at the top row, then fills the rest of the popup with -// `colPopupBack` (including the bottom row, so the spacing row below wintex's -// button strip stays painted). -const popupDrawFrame = (wo) => { - // draw header - con.move(wo.y, wo.x) - con.color_pair(colTabBarOrn, colTabBarBack) - print(`\u00FB`.repeat(wo.width)) +HUB.views = requireTaut("taut_views").init(HUB) +const { + drawSamplesContents, samplesInput, drawInstrumentsContents, instrumentsInput, + refreshSamplesCache, refreshInstrumentsCache, + drawSamplesPlayBlobs, drawInstrumentsPlayBlobs, drawSampleCursor, drawEnvelopeCursor, tickFunkWaveform, + clearSampleWaveformArea, clearInstrumentsEnvelopeArea, clampSamplesCursor, + drawSamplesUsedBy, computeSampleRAMBytes, formatSampleRamK, launchInstrumentViewerFor, + registerInstrumentsMouse, registerSamplesMouse, sampleRamSummary, + drawSlider, drawNumCapsule, runSliderDrag, +} = HUB.views - // imprint title - let titleWidth = wo.title.length - con.move(wo.y, wo.x + (((wo.width - titleWidth - 2) & 254) >>> 1)) - con.color_pair(colTabInactive, colTabBarBack); print(` ${wo.title} `) - - // fill content area (title row already painted above) - for (let r = 1; r < wo.height; r++) { - con.move(wo.y + r, wo.x) - con.color_pair(230, colPopupBack) - print(' '.repeat(wo.width)) - } -} - -// Taut's charset carries dedicated scrollbar glyphs at 0xBA..0xBF (empty -// top/mid/bottom caps 0xBA..0xBC, filled top/mid/bottom thumb 0xBD..0xBF). -// wintex defaults to the CP437-safe 0xBA/0xDB pair, so pass these to every -// list popup to render the scrollbar in taut's style. -const popupScrollbarChars = [0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF] - -// Standard colour palette shared by every taut popup so wintex's defaults blend -// with taut's popup chrome. -const popupColours = { - // fg: colStatus, - // bg: colPopupBack, - // fieldBg: 240, - // dimFg: colVoiceHdrMuted, - // hlFg: colWHITE, - // focusBg: colHighlight, - // listBg: colPopupBack, - // listSelBg: colHighlight, -} +HUB.fileop = requireTaut("taut_fileop").init(HUB) +const panelSamples = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, samplesInput, drawSamplesContents, undefined, ()=>{}) +const panelInstrmnt = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, instrumentsInput, drawInstrumentsContents, undefined, ()=>{}) +const panelProject = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, projectInput, drawProjectContents, undefined, ()=>{}) +const panelFile = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, HUB.fileop.input, HUB.fileop.drawContents, undefined, ()=>{}) +const panels = [panelTimeline, panelOrders, panelPatterns, panelSamples, panelInstrmnt, panelProject, panelFile] +// applyGoto stays in the engine \u2014 it mutates engine cursor state (cueIdx / +// ordersCursor / ordersScroll / patternIdx). taut_popups.mjs's Go-To dialog +// calls it through HUB.applyGoto. function applyGoto(num) { if (currentPanel === VIEW_TIMELINE) { cueIdx = num; clampCue() @@ -6026,223 +3660,14 @@ function applyGoto(num) { } } -function openConfirmQuit() { - const messageLines = ['Exit Microtone?'] - if (hasUnsavedChanges) messageLines.push('You have unsaved changes.') - - const res = win.showDialog({ - title: 'Quit?', - drawFrame: popupDrawFrame, - colours: popupColours, - message: messageLines, - buttons: [ - { label: 'Yes', action: 'yes' }, - { label: 'No', action: 'no', default: true }, - ], - onKey: (ks, _shift, ctx) => { - if (ks === 'y' || ks === 'Y') { ctx.close({ action: 'yes' }); return true } - if (ks === 'n' || ks === 'N') { ctx.close({ action: 'no' }); return true } - return false - }, - }) - - const result = (res.action === 'yes') - if (!result) drawAll() - return result -} - -function openGotoPopup() { - const prompts = ['Cue (hex):', 'Cue (hex):', 'Pattern (hex):'] - const promptStr = prompts[currentPanel] || 'Number:' - - const res = win.showDialog({ - title: 'Go To', - drawFrame: popupDrawFrame, - colours: popupColours, - fields: [{ label: promptStr, width: 4, maxLength: 3 }], - buttons: [ - { label: 'OK', action: 'ok' }, - { label: 'Cancel', action: 'cancel' }, - ], - }) - if (res.action === 'ok' && res.values[0]) { - const n = parseInt(res.values[0], 16) - if (!isNaN(n)) applyGoto(n) - } - drawAll() -} - -///////////////////////////////////////////////////////////////////////////////////////////////////////////// -// RETUNE POPUP -///////////////////////////////////////////////////////////////////////////////////////////////////////////// - -function openRetunePopup() { - const entries = Object.values(pitchTablePresets).sort((a, b) => a.index - b.index) - const n = entries.length - - // Foreground colour by tuning type (preset.t): - // 'd' = 12-tone family, 'M' = Macrotonal, 'm' = microtonal, '' = Raw. - const tuningTypeColour = { d: 230, M: colPan, m: colInst, '': colStatus } - - const methodLabels = { - pitch: 'Nearest-note', - delta: 'Nearest-delta', - cadence: 'Nearest-cadence', - harmonic: 'Nearest-harmonic', // this thing is cadence-aware (hopefully) - } - const methodCycle = ['pitch', 'harmonic', 'delta'/*, 'cadence'*/] - let method = 'pitch' - - let selIdx = entries.findIndex(p => p.index === PITCH_PRESET_IDX) - if (selIdx < 0) selIdx = 0 - - const items = entries.map(e => ({ label: e.name, preset: e })) - const listH = Math.min(n, 13) - const messageLines = [ - 'Select new tuning preset:', - 'Method: ' + methodLabels[method], - ] - - const res = win.showDialog({ - title: 'Retune', - drawFrame: popupDrawFrame, - colours: popupColours, - message: messageLines, - list: { - items: items, - height: listH, - width: 36, - cursor: selIdx, - scrollbarChars: popupScrollbarChars, - renderItem: (ctx) => { - const e = ctx.item.preset - const isCur = (e.index === PITCH_PRESET_IDX) - const fore = (e.t in tuningTypeColour) ? tuningTypeColour[e.t] : 230 - const useFg = (ctx.isCursor && ctx.focused) ? colWHITE : fore - const useBg = (ctx.isCursor && ctx.focused) ? colHighlight : ctx.listBg - con.color_pair(useFg, useBg) - con.move(ctx.y, ctx.x) - const marker = isCur ? sym.playhead : ' ' - let label = `${marker} ${e.name}` - if (label.length > ctx.w) label = label.substring(0, ctx.w) - else label = label.padEnd(ctx.w, ' ') - print(label) - }, - }, - buttons: [ - { label: 'OK', action: 'ok' }, - { label: 'Cancel', action: 'cancel' }, - ], - onKey: (ks, _shift, ctx) => { - if (ks === 'm' || ks === 'M') { - method = methodCycle[(methodCycle.indexOf(method) + 1) % methodCycle.length] - messageLines[1] = 'Method: ' + methodLabels[method] - ctx.render() - return true - } - return false - }, - }) - - if (res.action === 'ok' && res.listItem) { - const target = res.listItem.preset - if (target && target.index !== PITCH_PRESET_IDX) { - retuneAllPatterns(target.index, method) - } - } - - drawAll() -} - -///////////////////////////////////////////////////////////////////////////////////////////////////////////// -// MIXER FLAGS POPUP -///////////////////////////////////////////////////////////////////////////////////////////////////////////// - -function openFlagsPopup() { - const toneNames = ['Linear pitch', 'Amiga pitch', 'Linear freq'] - const intpNames = ['Default', 'None', 'A500', 'A1200', 'SNES', 'DPCM'] - - let toneMode = initialTrackerMixerflags & 3 - let intpMode = (initialTrackerMixerflags >>> 2) & 7 - if (toneMode >= toneNames.length) toneMode = 0 - if (intpMode >= intpNames.length) intpMode = 0 - - // Build list rows: headers + selectable radio options. - const items = [] - items.push({ label: 'Tone Mode:', kind: 'header' }) - toneNames.forEach((nm, i) => items.push({ label: nm, kind: 'tone', idx: i })) - items.push({ label: '', kind: 'spacer' }) - items.push({ label: 'Interpolation:', kind: 'header' }) - intpNames.forEach((nm, i) => items.push({ label: nm, kind: 'intp', idx: i })) - - const res = win.showDialog({ - title: 'Mixer Flags', - drawFrame: popupDrawFrame, - colours: popupColours, - list: { - items: items, - height: items.length, - width: 22, - drawWell: false, - showScrollbar: false, - scrollbarChars: popupScrollbarChars, - selectable: (it) => it.kind === 'tone' || it.kind === 'intp', - renderItem: (ctx) => { - const it = ctx.item - con.move(ctx.y, ctx.x) - if (it.kind === 'header') { - con.color_pair(colStatus, colPopupBack) - print(it.label.padEnd(ctx.w, ' ').substring(0, ctx.w)) - return - } - if (it.kind === 'spacer') { - con.color_pair(colStatus, colPopupBack) - print(' '.repeat(ctx.w)) - return - } - const isChecked = (it.kind === 'tone') - ? (toneMode === it.idx) - : (intpMode === it.idx) - const useBg = (ctx.isCursor && ctx.focused) ? colHighlight : colPopupBack - const useFg = isChecked ? colVoiceHdr : colWHITE - con.color_pair(useFg, useBg) - const line = ' ' + (isChecked ? sym.ticked : sym.unticked) + ' ' + it.label - print(line.padEnd(ctx.w, ' ').substring(0, ctx.w)) - }, - // Space and left-click toggle the radio; Enter commits via OK. - onActivate: (item, _idx, key) => { - if (key === ' ' || key === 'click') { - if (item.kind === 'tone') toneMode = item.idx - else if (item.kind === 'intp') intpMode = item.idx - return null - } - if (key === '\n') return 'ok' - return null - }, - }, - buttons: [ - { label: 'OK', action: 'ok' }, - { label: 'Cancel', action: 'cancel' }, - ], - }) - - if (res.action === 'ok') { - const newFlags = (initialTrackerMixerflags & ~0x1F) | - (toneMode & 3) | ((intpMode & 7) << 2) - if (newFlags !== initialTrackerMixerflags) { - initialTrackerMixerflags = newFlags - audio.setTrackerMixerFlags(PLAYHEAD, newFlags) - hasUnsavedChanges = true - } - } - - drawAll() -} - ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // INLINE HEX EDITOR ///////////////////////////////////////////////////////////////////////////////////////////////////////////// +// White digits on a dark field, used by openInlineNumEdit below. The views module +// has its own copy of this name; both are just colWHITE. +const colInstValue = colWHITE + // Overlay an editable hex field at (y, x) with `digits` digits, pre-filled from `initialValue`. // Returns the new integer on commit, or null on cancel. Reusable for pattern-grid edits. function openInlineHexEdit(y, x, digits, initialValue) { @@ -6402,9 +3827,6 @@ let initialTrackerMixerflags = audio.getTrackerMixerFlags(PLAYHEAD) let initialGlobalVolume = audio.getSongGlobalVolume(PLAYHEAD) let initialMixingVolume = audio.getSongMixingVolume(PLAYHEAD) -function isExternalPanel(p) { - return p === VIEW_FILE -} ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // MOUSE INPUT @@ -6505,14 +3927,8 @@ function switchToPanel(newPanel) { if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters() if (wasSamples && currentPanel !== VIEW_SAMPLES) clearSampleWaveformArea() if (wasInstrmnt && currentPanel !== VIEW_INSTRMNT) clearInstrumentsEnvelopeArea() - if (isExternalPanel(currentPanel)) { - clearPanelMouseRegions() - con.clear(); drawAlwaysOnElems(); drawControlHint() - pendingExternalDraw = true - } else { - rebuildPanelMouseRegions() - drawAll() - } + rebuildPanelMouseRegions() + drawAll() } // --- Tab bar regions (registered once; tab geometry is constant) --- @@ -6569,51 +3985,8 @@ function rebuildPanelMouseRegions() { else if (currentPanel === VIEW_PROJECT) registerProjectMouse() } -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) => { - smpListCursor += dy * 3 - clampSamplesCursor() - smpUsedScroll = 0 - 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 - requestEditorLaunch('taut_sampleedit', [fullPathObj.full, VIEW_SAMPLES, smpListCursor]) - } - }) -} +// registerSamplesMouse moved into taut_views.mjs (it reads samples-private state); +// rebuildPanelMouseRegions calls it via the HUB.views alias. function registerTimelineMouse() { addPanelMouseRegion(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, { @@ -6832,12 +4205,7 @@ registerTabRegions() registerTransportRegions() rebuildPanelMouseRegions() -// Launching a sub-program from inside an input.withEvent callback causes the triggering -// Tab event to leak into the sub-program's own withEvent call (the event hasn't been -// consumed yet when the callback is still executing). We avoid this by deferring the -// actual shell.execute until after withEvent returns. let exitFlag = false -let pendingExternalDraw = false while (!exitFlag) { // Fullscreen app: (re)assert the raw-keyboard grab each frame so cooked chars @@ -6873,16 +4241,8 @@ while (!exitFlag) { applyMuteTransition(currentPanel) if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters() if (wasSamples && currentPanel !== VIEW_SAMPLES) clearSampleWaveformArea() - if (isExternalPanel(currentPanel)) { - // Redraw header now so the tab highlight is visible immediately, - // but defer the actual sub-program launch to after withEvent returns. - clearPanelMouseRegions() - con.clear(); drawAlwaysOnElems(); drawControlHint() - pendingExternalDraw = true - } else { - rebuildPanelMouseRegions() - drawAll() - } + rebuildPanelMouseRegions() + drawAll() return } @@ -6899,66 +4259,9 @@ while (!exitFlag) { panels[currentPanel].processInput(event) }) - // Launch external sub-program OUTSIDE the withEvent callback so the triggering - // Tab event is fully consumed before the sub-program's event loop begins. - if (pendingExternalDraw) { - pendingExternalDraw = false - redrawPanel() - while (_G.TAUT.UI.NEXTPANEL !== undefined && _G.TAUT.UI.NEXTPANEL !== null) { - const wasTimeline = (currentPanel === VIEW_TIMELINE) - currentPanel = _G.TAUT.UI.NEXTPANEL - _G.TAUT.UI.NEXTPANEL = undefined - applyMuteTransition(currentPanel) - if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters() - if (isExternalPanel(currentPanel)) { - clearPanelMouseRegions() - con.clear(); drawAlwaysOnElems(); drawControlHint() - redrawPanel() - } else { - rebuildPanelMouseRegions() - drawAll() - } - } - } - - // Launch the sample / instrument editor as a sub-program. Same deferral - // reason as pendingExternalDraw: avoid leaking the trigger key into the - // sub-program's own withEvent loop. The editor sets NEXTPANEL on exit - // (typically back to its parent viewer), so the loop above will repaint. - if (pendingEditorLaunch) { - const { progName, args } = pendingEditorLaunch - pendingEditorLaunch = null - stopPlayback() - clearSampleWaveformArea() - clearPanelMouseRegions() - con.clear(); drawAlwaysOnElems(); drawControlHint() - _G.TAUT.UI.NEXTPANEL = undefined - _G.shell.execute(`${progName} ${args.join(' ')}`) - // After the editor returns, instruments / samples may have changed — - // rebuild the deduped sample list before redrawing whatever panel comes next. - refreshSamplesCache() - if (_G.TAUT.UI.NEXTPANEL === undefined || _G.TAUT.UI.NEXTPANEL === null) { - // Editor declined to switch panels — repaint the current panel. - rebuildPanelMouseRegions() - drawAll() - } else { - while (_G.TAUT.UI.NEXTPANEL !== undefined && _G.TAUT.UI.NEXTPANEL !== null) { - const wasTimeline = (currentPanel === VIEW_TIMELINE) - currentPanel = _G.TAUT.UI.NEXTPANEL - _G.TAUT.UI.NEXTPANEL = undefined - applyMuteTransition(currentPanel) - if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters() - if (isExternalPanel(currentPanel)) { - clearPanelMouseRegions() - con.clear(); drawAlwaysOnElems(); drawControlHint() - redrawPanel() - } else { - rebuildPanelMouseRegions() - drawAll() - } - } - } - } + // The sample / instrument editors and the File panel are all in-process now, + // so there is no deferred sub-program launch to drain here — the editors run + // their own modal loop (keeping playback live) directly from the viewer input. if (playbackMode !== PLAYMODE_NONE) updatePlayback() } diff --git a/assets/disk0/tvdos/bin/taut_fileop.js b/assets/disk0/tvdos/bin/taut_fileop.js deleted file mode 100644 index f145155..0000000 --- a/assets/disk0/tvdos/bin/taut_fileop.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * TAUT File Operations - * Sub-program launched by taut.js when the File tab is active. - * Rows 1-3 are owned by the parent; this program draws rows 4+. - * - * exec_args[1] = path to .taud file - * Sets _G.TAUT.UI.NEXTPANEL before returning to request a panel switch. - * - * Created by minjaesong on 2026-04-27 - */ - -const win = require("wintex") - -const PANEL_COUNT = 7 -const MY_PANEL = 6 // VIEW_FILE - -const [SCRH, SCRW] = con.getmaxyx() -const PANEL_Y = 4 -const PANEL_H = SCRH - PANEL_Y - -const colStatus = 253 -const colContent = 240 -const colHdr = 230 - -function drawFileOpContents(wo) { - for (let y = PANEL_Y; y < SCRH; y++) { - con.move(y, 1) - con.color_pair(colContent, 255) - print(' '.repeat(SCRW)) - } - con.move(PANEL_Y + 1, 3) - con.color_pair(colHdr, 255) - print('[ File ]') - con.move(PANEL_Y + 3, 3) - con.color_pair(colStatus, 255) - print('placeholder — not yet implemented') -} - -function drawHints() { - con.move(SCRH, 1) - con.color_pair(colStatus, 255) - print(' '.repeat(SCRW - 1)) - con.move(SCRH, 1) - con.color_pair(colHdr, 255); print('Tab ') - con.color_pair(colStatus, 255); print('Panel') -} - -function fileOpInput(wo, event) { - // placeholder — no interaction yet -} - -const panel = new win.WindowObject(1, PANEL_Y, SCRW, PANEL_H, fileOpInput, drawFileOpContents, undefined, ()=>{}) - -panel.drawContents() -drawHints() - -let done = false -while (!done) { - input.withEvent(event => { - if (event[0] !== 'key_down') return - const keysym = event[1] - const keyJustHit = (1 == event[2]) - const shiftDown = (event.includes(59) || event.includes(60)) - - if (!keyJustHit) return - - if (keysym === '') { - _G.TAUT.UI.NEXTPANEL = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT - done = true - return - } - - panel.processInput(event) - }) -} - -return 0 diff --git a/assets/disk0/tvdos/bin/taut_fileop.mjs b/assets/disk0/tvdos/bin/taut_fileop.mjs new file mode 100644 index 0000000..2ff258d --- /dev/null +++ b/assets/disk0/tvdos/bin/taut_fileop.mjs @@ -0,0 +1,40 @@ +/** + * TAUT File panel (in-process). + * + * Replaces the old taut_fileop.js sub-program. The File tab is now a normal + * in-process panel: init(HUB) returns { drawContents, input }, wired into a + * wintex WindowObject like the other panels. Tab / global keys are handled by + * taut.js's main loop, so input() is a no-op for now. + * + * Still a placeholder UI — file load / save lands here later. + * Converted from taut_fileop.js on 2026-06-21. + */ + +function init(HUB) { + const C = HUB.C + const SCRW = C.SCRW, SCRH = C.SCRH + const PANEL_Y = C.PTNVIEW_OFFSET_Y + const colStatus = C.colStatus, colHdr = C.colVoiceHdr + const colContent = 240 + + function drawContents(wo) { + for (let y = PANEL_Y; y < SCRH; y++) { + con.move(y, 1) + con.color_pair(colContent, 255) + print(' '.repeat(SCRW)) + } + con.move(PANEL_Y + 1, 3) + con.color_pair(colHdr, 255) + print('[ File ]') + con.move(PANEL_Y + 3, 3) + con.color_pair(colStatus, 255) + print('(not yet implemented)') + } + + // Main loop owns Tab and the global shortcuts; nothing panel-specific yet. + function input(wo, event) {} + + return { drawContents, input } +} + +exports = { init } diff --git a/assets/disk0/tvdos/bin/taut_helpmsg.js b/assets/disk0/tvdos/bin/taut_helpmsg.mjs similarity index 84% rename from assets/disk0/tvdos/bin/taut_helpmsg.js rename to assets/disk0/tvdos/bin/taut_helpmsg.mjs index 9ed54e7..43e858e 100644 --- a/assets/disk0/tvdos/bin/taut_helpmsg.js +++ b/assets/disk0/tvdos/bin/taut_helpmsg.mjs @@ -1,5 +1,18 @@ -if (!_G.TAUT) _G.TAUT = {}; -let help = {} +/** + * TAUT help-text module. + * + * In-process replacement for the old taut_helpmsg.js sub-program. Exports + * init(HUB) which typesets every panel's help text at HUB.C.HELP_CONTENT_W and + * returns { MSG_BY_TABS, typeset, COL_TEXT, COL_EMPH }. taut.js stores the result + * on HUB.help; openHelpPopup reads it. + * + * The help-text strings themselves are width-independent, so they live at module + * top level; only the rule width and final typesetting depend on HUB. + * + * Converted from taut_helpmsg.js (separate program) on 2026-06-21. The \uXXXX + * escapes are kept verbatim from the original — TSVM's string parser is not + * Unicode and treats raw bytes differently from escapes, so do not normalise them. + */ let ts = require("typesetter") @@ -145,16 +158,18 @@ Mixer flags define how should the mixer behave. //////////////////////////////////////////////////////////////////////////////////////////////////// // assemble help text pieces to complete help message +function init(HUB) { + const W = HUB.C.HELP_CONTENT_W -const HRULE = '' + '\u00B3'.repeat(_G.TAUT.HELPMSG_WIDTH) + '\n' + const HRULE = '' + '\u00B3'.repeat(W) + '\n' -// taut.js's popup uses (HELP_COL_TEXT on background) as the default colour pair. -// The shared typesetter module owns the palette and the markup expander. -function typeset(text) { - return ts.typeset(text, _G.TAUT.HELPMSG_WIDTH) -} + // taut.js's popup uses (HELP_COL_TEXT on background) as the default colour pair. + // The shared typesetter module owns the palette and the markup expander. + function typeset(text) { + return ts.typeset(text, W) + } -let helpMessages = [ // index: taut.js PANEL_NAMES + let helpMessages = [ // index: taut.js PANEL_NAMES /* Timeline */[helpJam, helpTimeline, helpCommon, helpNotation].join(HRULE), /* Cues */[helpCommon, helpNotation].join(HRULE), // placeholder /* Patterns */[helpCommon, helpNotation].join(HRULE), // placeholder @@ -164,9 +179,12 @@ let helpMessages = [ // index: taut.js PANEL_NAMES /* File */[helpCommon, helpNotation].join(HRULE), // placeholder ] -help.MSG_BY_TABS = helpMessages.map(it => typeset(it)) -help.typeset = typeset -help.COL_TEXT = ts.COL_TEXT -help.COL_EMPH = ts.COL_EMPH + return { + MSG_BY_TABS: helpMessages.map(it => typeset(it)), + typeset: typeset, + COL_TEXT: ts.COL_TEXT, + COL_EMPH: ts.COL_EMPH, + } +} -if (!_G.TAUT.HELPMSG) _G.TAUT.HELPMSG=help; +exports = { init } diff --git a/assets/disk0/tvdos/bin/taut_instredit.js b/assets/disk0/tvdos/bin/taut_instredit.js deleted file mode 100644 index 0334da0..0000000 --- a/assets/disk0/tvdos/bin/taut_instredit.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * TAUT Instrument Editor - * Sub-program launched by taut.js when the Instrmnt tab is active. - * Rows 1-3 are owned by the parent; this program draws rows 4+. - * - * exec_args[1] = path to .taud file - * Sets _G.TAUT.UI.NEXTPANEL before returning to request a panel switch. - * - * Created by minjaesong on 2026-04-27 - */ - -const win = require("wintex") - -const PANEL_COUNT = 7 -const MY_PANEL = 4 // VIEW_INSTRMNT - -const [SCRH, SCRW] = con.getmaxyx() -const PANEL_Y = 4 -const PANEL_H = SCRH - PANEL_Y - -const colStatus = 253 -const colContent = 240 -const colHdr = 230 - -function drawInstEditContents(wo) { - for (let y = PANEL_Y; y < SCRH; y++) { - con.move(y, 1) - con.color_pair(colContent, 255) - print(' '.repeat(SCRW)) - } - con.move(PANEL_Y + 1, 3) - con.color_pair(colHdr, 255) - print('[ Instrument Editor ]') - con.move(PANEL_Y + 3, 3) - con.color_pair(colStatus, 255) - print('placeholder — not yet implemented') -} - -function drawHints() { - con.move(SCRH, 1) - con.color_pair(colStatus, 255) - print(' '.repeat(SCRW - 1)) - con.move(SCRH, 1) - con.color_pair(colHdr, 255); print('Tab ') - con.color_pair(colStatus, 255); print('Panel') -} - -function instEditInput(wo, event) { - // placeholder — no interaction yet -} - -const panel = new win.WindowObject(1, PANEL_Y, SCRW, PANEL_H, instEditInput, drawInstEditContents, undefined, ()=>{}) - -panel.drawContents() -drawHints() - -let done = false -while (!done) { - input.withEvent(event => { - if (event[0] !== 'key_down') return - const keysym = event[1] - const keyJustHit = (1 == event[2]) - const shiftDown = (event.includes(59) || event.includes(60)) - - if (!keyJustHit) return - - if (keysym === '') { - _G.TAUT.UI.NEXTPANEL = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT - done = true - return - } - - panel.processInput(event) - }) -} - -return 0 diff --git a/assets/disk0/tvdos/bin/taut_notationedit.js b/assets/disk0/tvdos/bin/taut_notationedit.js deleted file mode 100644 index e69de29..0000000 diff --git a/assets/disk0/tvdos/bin/taut_popups.mjs b/assets/disk0/tvdos/bin/taut_popups.mjs new file mode 100644 index 0000000..695bee9 --- /dev/null +++ b/assets/disk0/tvdos/bin/taut_popups.mjs @@ -0,0 +1,333 @@ +/** + * TAUT popups module. + * + * In-process modal dialogs (help / go-to / retune / mixer-flags / confirm-quit) + * plus the shared popup chrome (frame painter, colour palette, scrollbar glyphs). + * Extracted from taut.js on 2026-06-21. + * + * These are pure UI: every engine-state mutation is delegated back through HUB + * callbacks (HUB.applyGoto, HUB.retuneAllPatterns, HUB.commitMixerFlags, …), so + * the engine keeps owning currentPanel / cueIdx / patternIdx / mixer flags / the + * unsaved-changes flag. init(HUB) returns the dialog openers and the chrome (so + * other in-process modules can reuse the same look). Read-only constants come in + * via HUB.C; \uXXXX escapes are kept verbatim (TSVM's string parser is not Unicode). + */ + +const win = require("wintex") + +function init(HUB) { + const C = HUB.C + const sym = C.sym + const PANEL_NAMES = C.PANEL_NAMES + const pitchTablePresets = C.pitchTablePresets + const colWHITE = C.colWHITE, colPopupBack = C.colPopupBack + const colTabBarOrn = C.colTabBarOrn, colTabBarBack = C.colTabBarBack + const colTabInactive = C.colTabInactive + const colPan = C.colPan, colInst = C.colInst, colStatus = C.colStatus + const colHighlight = C.colHighlight, colVoiceHdr = C.colVoiceHdr + const HELP_CONTENT_W = C.HELP_CONTENT_W, HELP_CONTENT_H = C.HELP_CONTENT_H + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + // SHARED POPUP CHROME + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + + // Custom window-frame painter passed to wintex showDialog as `drawFrame`. + // Paints a title bar at the top row, then fills the rest of the popup with + // `colPopupBack` (including the bottom row, so the spacing row below wintex's + // button strip stays painted). + const popupDrawFrame = (wo) => { + // draw header + con.move(wo.y, wo.x) + con.color_pair(colTabBarOrn, colTabBarBack) + print(`\u00FB`.repeat(wo.width)) + + // imprint title + let titleWidth = wo.title.length + con.move(wo.y, wo.x + (((wo.width - titleWidth - 2) & 254) >>> 1)) + con.color_pair(colTabInactive, colTabBarBack); print(` ${wo.title} `) + + // fill content area (title row already painted above) + for (let r = 1; r < wo.height; r++) { + con.move(wo.y + r, wo.x) + con.color_pair(230, colPopupBack) + print(' '.repeat(wo.width)) + } + } + + // Taut's charset carries dedicated scrollbar glyphs at 0xBA..0xBF (empty + // top/mid/bottom caps 0xBA..0xBC, filled top/mid/bottom thumb 0xBD..0xBF). + // wintex defaults to the CP437-safe 0xBA/0xDB pair, so pass these to every + // list popup to render the scrollbar in taut's style. + const popupScrollbarChars = [0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF] + + // Standard colour palette shared by every taut popup so wintex's defaults blend + // with taut's popup chrome. + const popupColours = { + // fg: colStatus, + // bg: colPopupBack, + // fieldBg: 240, + // dimFg: colVoiceHdrMuted, + // hlFg: colWHITE, + // focusBg: colHighlight, + // listBg: colPopupBack, + // listSelBg: colHighlight, + } + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + // HELP POPUP + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function openHelpPopup() { + const currentPanel = HUB.getPanel() + const helpmsg = HUB.help || {} + const lines = (helpmsg.MSG_BY_TABS && helpmsg.MSG_BY_TABS[currentPanel]) || [''] + const colText = helpmsg.COL_TEXT || colWHITE + + win.showDialog({ + title: `Help: ${PANEL_NAMES[currentPanel]}`, + drawFrame: popupDrawFrame, + colours: popupColours, + list: { + items: lines.map(l => ({ label: l })), + bg: colPopupBack, + height: HELP_CONTENT_H, + width: HELP_CONTENT_W+4, + scrollbarChars: popupScrollbarChars, + selectable: () => false, + renderItem: (ctx) => { + con.color_pair(colText, ctx.listBg) + con.move(ctx.y, ctx.x) + const line = (ctx.item.label != null ? ctx.item.label : '') + print(line.padEnd(ctx.w, ' ').substring(0, ctx.w)) + }, + }, + buttons: [{ label: 'OK', action: 'ok', default: true }], + onKey: (ks, _shift, ctx) => { + if (ks === '!' || ks === 'q') { ctx.close({ action: 'cancel' }); return true } + return false + }, + }) + HUB.drawAll() + } + + function openConfirmQuit() { + const messageLines = ['Exit Microtone?'] + if (HUB.hasUnsavedChanges()) messageLines.push('You have unsaved changes.') + + const res = win.showDialog({ + title: 'Quit?', + drawFrame: popupDrawFrame, + colours: popupColours, + message: messageLines, + buttons: [ + { label: 'Yes', action: 'yes' }, + { label: 'No', action: 'no', default: true }, + ], + onKey: (ks, _shift, ctx) => { + if (ks === 'y' || ks === 'Y') { ctx.close({ action: 'yes' }); return true } + if (ks === 'n' || ks === 'N') { ctx.close({ action: 'no' }); return true } + return false + }, + }) + + const result = (res.action === 'yes') + if (!result) HUB.drawAll() + return result + } + + function openGotoPopup() { + const currentPanel = HUB.getPanel() + const prompts = ['Cue (hex):', 'Cue (hex):', 'Pattern (hex):'] + const promptStr = prompts[currentPanel] || 'Number:' + + const res = win.showDialog({ + title: 'Go To', + drawFrame: popupDrawFrame, + colours: popupColours, + fields: [{ label: promptStr, width: 4, maxLength: 3 }], + buttons: [ + { label: 'OK', action: 'ok' }, + { label: 'Cancel', action: 'cancel' }, + ], + }) + if (res.action === 'ok' && res.values[0]) { + const n = parseInt(res.values[0], 16) + if (!isNaN(n)) HUB.applyGoto(n) + } + HUB.drawAll() + } + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + // RETUNE POPUP + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function openRetunePopup() { + const PITCH_PRESET_IDX = HUB.getPitchPresetIdx() + const entries = Object.values(pitchTablePresets).sort((a, b) => a.index - b.index) + const n = entries.length + + // Foreground colour by tuning type (preset.t): + // 'd' = 12-tone family, 'M' = Macrotonal, 'm' = microtonal, '' = Raw. + const tuningTypeColour = { d: 230, M: colPan, m: colInst, '': colStatus } + + const methodLabels = { + pitch: 'Nearest-note', + delta: 'Nearest-delta', + cadence: 'Nearest-cadence', + harmonic: 'Nearest-harmonic', // this thing is cadence-aware (hopefully) + } + const methodCycle = ['pitch', 'harmonic', 'delta'/*, 'cadence'*/] + let method = 'pitch' + + let selIdx = entries.findIndex(p => p.index === PITCH_PRESET_IDX) + if (selIdx < 0) selIdx = 0 + + const items = entries.map(e => ({ label: e.name, preset: e })) + const listH = Math.min(n, 13) + const messageLines = [ + 'Select new tuning preset:', + 'Method: ' + methodLabels[method], + ] + + const res = win.showDialog({ + title: 'Retune', + drawFrame: popupDrawFrame, + colours: popupColours, + message: messageLines, + list: { + items: items, + height: listH, + width: 36, + cursor: selIdx, + scrollbarChars: popupScrollbarChars, + renderItem: (ctx) => { + const e = ctx.item.preset + const isCur = (e.index === PITCH_PRESET_IDX) + const fore = (e.t in tuningTypeColour) ? tuningTypeColour[e.t] : 230 + const useFg = (ctx.isCursor && ctx.focused) ? colWHITE : fore + const useBg = (ctx.isCursor && ctx.focused) ? colHighlight : ctx.listBg + con.color_pair(useFg, useBg) + con.move(ctx.y, ctx.x) + const marker = isCur ? sym.playhead : ' ' + let label = `${marker} ${e.name}` + if (label.length > ctx.w) label = label.substring(0, ctx.w) + else label = label.padEnd(ctx.w, ' ') + print(label) + }, + }, + buttons: [ + { label: 'OK', action: 'ok' }, + { label: 'Cancel', action: 'cancel' }, + ], + onKey: (ks, _shift, ctx) => { + if (ks === 'm' || ks === 'M') { + method = methodCycle[(methodCycle.indexOf(method) + 1) % methodCycle.length] + messageLines[1] = 'Method: ' + methodLabels[method] + ctx.render() + return true + } + return false + }, + }) + + if (res.action === 'ok' && res.listItem) { + const target = res.listItem.preset + if (target && target.index !== PITCH_PRESET_IDX) { + HUB.retuneAllPatterns(target.index, method) + } + } + + HUB.drawAll() + } + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + // MIXER FLAGS POPUP + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function openFlagsPopup() { + const flags0 = HUB.getMixerFlags() + const toneNames = ['Linear pitch', 'Amiga pitch', 'Linear freq'] + const intpNames = ['Default', 'None', 'A500', 'A1200', 'SNES', 'DPCM'] + + let toneMode = flags0 & 3 + let intpMode = (flags0 >>> 2) & 7 + if (toneMode >= toneNames.length) toneMode = 0 + if (intpMode >= intpNames.length) intpMode = 0 + + // Build list rows: headers + selectable radio options. + const items = [] + items.push({ label: 'Tone Mode:', kind: 'header' }) + toneNames.forEach((nm, i) => items.push({ label: nm, kind: 'tone', idx: i })) + items.push({ label: '', kind: 'spacer' }) + items.push({ label: 'Interpolation:', kind: 'header' }) + intpNames.forEach((nm, i) => items.push({ label: nm, kind: 'intp', idx: i })) + + const res = win.showDialog({ + title: 'Mixer Flags', + drawFrame: popupDrawFrame, + colours: popupColours, + list: { + items: items, + height: items.length, + width: 22, + drawWell: false, + showScrollbar: false, + scrollbarChars: popupScrollbarChars, + selectable: (it) => it.kind === 'tone' || it.kind === 'intp', + renderItem: (ctx) => { + const it = ctx.item + con.move(ctx.y, ctx.x) + if (it.kind === 'header') { + con.color_pair(colStatus, colPopupBack) + print(it.label.padEnd(ctx.w, ' ').substring(0, ctx.w)) + return + } + if (it.kind === 'spacer') { + con.color_pair(colStatus, colPopupBack) + print(' '.repeat(ctx.w)) + return + } + const isChecked = (it.kind === 'tone') + ? (toneMode === it.idx) + : (intpMode === it.idx) + const useBg = (ctx.isCursor && ctx.focused) ? colHighlight : colPopupBack + const useFg = isChecked ? colVoiceHdr : colWHITE + con.color_pair(useFg, useBg) + const line = ' ' + (isChecked ? sym.ticked : sym.unticked) + ' ' + it.label + print(line.padEnd(ctx.w, ' ').substring(0, ctx.w)) + }, + // Space and left-click toggle the radio; Enter commits via OK. + onActivate: (item, _idx, key) => { + if (key === ' ' || key === 'click') { + if (item.kind === 'tone') toneMode = item.idx + else if (item.kind === 'intp') intpMode = item.idx + return null + } + if (key === '\n') return 'ok' + return null + }, + }, + buttons: [ + { label: 'OK', action: 'ok' }, + { label: 'Cancel', action: 'cancel' }, + ], + }) + + if (res.action === 'ok') { + const newFlags = (flags0 & ~0x1F) | + (toneMode & 3) | ((intpMode & 7) << 2) + if (newFlags !== flags0) { + HUB.commitMixerFlags(newFlags) + } + } + + HUB.drawAll() + } + + return { + openHelpPopup, openConfirmQuit, openGotoPopup, openRetunePopup, openFlagsPopup, + popupDrawFrame, popupColours, popupScrollbarChars, + } +} + +exports = { init } diff --git a/assets/disk0/tvdos/bin/taut_sampleedit.js b/assets/disk0/tvdos/bin/taut_sampleedit.js deleted file mode 100644 index 0feb376..0000000 --- a/assets/disk0/tvdos/bin/taut_sampleedit.js +++ /dev/null @@ -1,181 +0,0 @@ -/** - * TAUT Sample Editor (stub) - * Sub-program launched from taut.js's Samples viewer. Rows 1-3 are owned by - * the parent; this program draws rows 4+. - * - * exec_args: - * [1] = path to .taud file - * [2] = parent panel index (where to return) - * [3] = sample index to preload (-1 if none) - * - * Sets _G.TAUT.UI.NEXTPANEL on return to request a panel switch back. - * - * Created by minjaesong on 2026-04-27 - * Stub editing UI added on 2026-05-26 - */ - -const win = require("wintex") - -const PARENT_PANEL = (exec_args[2] !== undefined) ? (exec_args[2] | 0) : 3 // VIEW_SAMPLES -const SAMPLE_IDX = (exec_args[3] !== undefined) ? (exec_args[3] | 0) : -1 - -const [SCRH, SCRW] = con.getmaxyx() -const PANEL_Y = 4 -const PANEL_H = SCRH - PANEL_Y - -const colStatus = 253 -const colContent = 240 -const colHdr = 230 -const colEmph = 211 -const colDim = 246 -const colBack = 255 -const colSel = 41 - -// Stub editor "fields": pretend toolbar. None of these write anything yet. -const 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' }, -] - -let toolCursor = 0 - -function drawSampleEditFrame() { - for (let y = PANEL_Y; y < SCRH; y++) { - con.move(y, 1) - con.color_pair(colContent, colBack) - print(' '.repeat(SCRW)) - } - // Title - con.move(PANEL_Y + 1, 3) - con.color_pair(colHdr, colBack); print('[ Sample Editor ] ') - con.color_pair(colEmph, colBack); print('Sample ') - con.color_pair(colStatus, colBack) - if (SAMPLE_IDX >= 0) print('#' + (SAMPLE_IDX + 1).toString(16).toUpperCase().padStart(2, '0')) - else print('(none)') - - con.move(PANEL_Y + 2, 3) - con.color_pair(colDim, colBack) - print('stub editor — actions below are placeholders only.') -} - -function drawToolList() { - const x = 5 - const y0 = PANEL_Y + 4 - con.move(y0, x) - con.color_pair(colHdr, colBack); print('Editing actions') - con.move(y0 + 1, x) - con.color_pair(colDim, colBack); print('-'.repeat(16)) - - for (let i = 0; i < TOOLS.length; i++) { - const y = y0 + 3 + i - const t = TOOLS[i] - const sel = (i === toolCursor) - const back = sel ? colSel : colBack - con.move(y, x) - con.color_pair(colHdr, back); print(' ' + t.key + ' ') - con.color_pair(colStatus, back); print(' ') - con.color_pair(sel ? colEmph : colStatus, back) - const w = SCRW - x - 6 - const lbl = t.label.length > w ? t.label.substring(0, w) : t.label.padEnd(w) - print(lbl) - } - - // Drawing-area placeholder on the right - const dx = 38 - const dy0 = PANEL_Y + 4 - const dw = SCRW - dx - 2 - const dh = SCRH - dy0 - 2 - con.move(dy0, dx) - con.color_pair(colHdr, colBack); print('Waveform editor') - con.move(dy0 + 1, dx) - con.color_pair(colDim, colBack); print('-'.repeat(16)) - - // Empty drawing rectangle made of dots - for (let r = 0; r < dh; r++) { - con.move(dy0 + 3 + r, dx) - con.color_pair(colDim, colBack) - if (r === (dh >>> 1)) print('-'.repeat(dw)) // zero line - else print(' '.repeat(dw)) - } - con.move(dy0 + 3 + (dh >>> 1) + 1, dx) - con.color_pair(colDim, colBack) - print('(drawing surface — not yet implemented)') -} - -function drawHints() { - con.move(SCRH, 1) - con.color_pair(colStatus, colBack) - print(' '.repeat(SCRW - 1)) - con.move(SCRH, 1) - con.color_pair(colHdr, colBack); print('„28u„29u ') - con.color_pair(colStatus, colBack); print('Tool ') - con.color_pair(colHdr, colBack); print('Enter ') - con.color_pair(colStatus, colBack); print('Apply ') - con.color_pair(colHdr, colBack); print('Esc/Tab ') - con.color_pair(colStatus, colBack); print('Back to viewer') -} - -function flashAction(idx) { - const t = TOOLS[idx] - if (!t) return - con.move(SCRH - 2, 5) - con.color_pair(colEmph, colBack) - print(('Action: ' + t.label + ' (stub, no-op)').padEnd(SCRW - 8)) -} - -function sampleEditInput(wo, event) { - // wintex panel input — wired up but the loop below handles keys directly. -} - -function drawAll() { - drawSampleEditFrame() - drawToolList() - drawHints() -} - -const panel = new win.WindowObject(1, PANEL_Y, SCRW, PANEL_H, sampleEditInput, drawAll, undefined, ()=>{}) - -panel.drawContents() - -let done = false -while (!done) { - input.withEvent(event => { - if (event[0] !== 'key_down') return - const keysym = event[1] - const keyJustHit = (1 == event[2]) - - if (!keyJustHit) return - - if (keysym === '' || keysym === '') { - _G.TAUT.UI.NEXTPANEL = PARENT_PANEL - done = true - return - } - - if (keysym === '') { if (toolCursor > 0) toolCursor--; drawToolList(); return } - if (keysym === '') { if (toolCursor < TOOLS.length-1) toolCursor++; drawToolList(); return } - - if (keysym === '\n') { - flashAction(toolCursor) - return - } - - // Direct key shortcuts - for (let i = 0; i < TOOLS.length; i++) { - if (keysym === TOOLS[i].key.toLowerCase() || keysym === TOOLS[i].key) { - toolCursor = i - drawToolList() - flashAction(i) - return - } - } - }) -} - -return 0 diff --git a/assets/disk0/tvdos/bin/taut_views.mjs b/assets/disk0/tvdos/bin/taut_views.mjs new file mode 100644 index 0000000..7693387 --- /dev/null +++ b/assets/disk0/tvdos/bin/taut_views.mjs @@ -0,0 +1,2577 @@ +/** + * 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 + +function clampSamplesCursor() { + const n = samplesCache ? samplesCache.length : 0 + if (smpListCursor < 0) smpListCursor = 0 + if (smpListCursor >= n) smpListCursor = Math.max(0, n - 1) + if (smpListCursor < smpListScroll) smpListScroll = smpListCursor + if (smpListCursor >= smpListScroll + SMP_LIST_H) + smpListScroll = smpListCursor - SMP_LIST_H + 1 + 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() + 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 + +function clampInstrumentsCursor() { + const n = instrumentsCache ? instrumentsCache.length : 0 + if (instListCursor < 0) instListCursor = 0 + if (instListCursor >= n) instListCursor = Math.max(0, n - 1) + if (instListCursor < instListScroll) instListScroll = instListCursor + if (instListCursor >= instListScroll + INST_LIST_H) + instListScroll = instListCursor - INST_LIST_H + 1 + 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) { + const r = instEnvelopeRect() + clearInstrumentsEnvelopeArea() // clear + + // 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() + 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) => { + instListCursor += dy * 3 + clampInstrumentsCursor() + 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) => { + smpListCursor += dy * 3 + clampSamplesCursor() + smpUsedScroll = 0 + 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. (Step 2's Advanced Edit will draw its own live playing-region +// visualisation via HUB.tickPlayback / the voice-state API instead.) On exit the +// editors refresh the cache and repaint the parent viewer via HUB.drawAll(). +function editorModalLoop(onKey) { + // 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. + 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 (ev[0] !== 'key_down' || 1 !== ev[2]) return + const ks = ev[1] + if (ks === '' || ks === '' || ks === '') { finish(); return } + onKey(ks, finish) + }) + } +} + +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') + + editorModalLoop((ks, finish) => { + if (ks === '') { if (toolCursor > 0) toolCursor--; drawTools(); return } + if (ks === '') { if (toolCursor < SMP_EDIT_TOOLS.length - 1) toolCursor++; drawTools(); return } + 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() +} + +function openAdvancedInstEdit(slot) { + const SLOT = (slot !== undefined && slot >= 0) ? (slot | 0) : -1 + const Y = PTNVIEW_OFFSET_Y + const cStatus = 253, cContent = 240, cHdr = 230 + + for (let y = Y; y < SCRH; y++) { con.move(y, 1); con.color_pair(cContent, 255); print(' '.repeat(SCRW)) } + con.move(Y + 1, 3); con.color_pair(cHdr, 255); print('[ Instrument Editor ]') + con.move(Y + 3, 3); con.color_pair(cStatus, 255) + print(SLOT >= 0 ? ('Slot $' + SLOT.toString(16).toUpperCase().padStart(2, '0') + ' - Advanced Edit (not yet implemented)') + : 'Advanced Edit (not yet implemented)') + con.move(SCRH, 1); con.color_pair(cStatus, 255); print(' '.repeat(SCRW - 1)) + con.move(SCRH, 1); con.color_pair(cHdr, 255); print('Esc/Tab '); con.color_pair(cStatus, 255); print('Back') + + editorModalLoop((ks, finish) => {}) + + 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 } diff --git a/terranmon.txt b/terranmon.txt index ffa7285..c762445 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2952,6 +2952,9 @@ TODO: * DONE 2026-06-20. See `con.setFullscreen()` [x] Taud double the BPM ceiling * DONE 2026-06-20. BPM range 25..535 via `T $FFxx` + the song-table byte-8 high bit (tickrate now 7-bit). + [ ] Taud instrument: add a 'percussion' flag so retuner/transposer won't touch them + [ ] Zfm and Command: create rc files whenever they are missing + Zfm: connect *rc to edit.js, with its own highlight colour TODO - list of demo songs that MUST ship with Microtone: * 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes