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