From 6f5f646e5ced6ee536156df0615aaee5736f1bd0 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Mon, 22 Jun 2026 03:06:07 +0900 Subject: [PATCH] taut: inst patch view polishing --- assets/disk0/tvdos/bin/taut.js | 1 + assets/disk0/tvdos/bin/taut_views.mjs | 495 ++++++++++++++++++++++---- 2 files changed, 427 insertions(+), 69 deletions(-) diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index c04f40f..ba14561 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -3615,6 +3615,7 @@ HUB.openInlineNumEdit = openInlineNumEdit HUB.addPanelMouseRegion = addPanelMouseRegion HUB.clearPanelMouseRegions = clearPanelMouseRegions HUB.rebuildPanelMouseRegions = rebuildPanelMouseRegions +HUB.dispatchMouseEvent = dispatchMouseEvent HUB.switchToPanel = switchToPanel HUB.getSong = () => song HUB.getPlaybackMode = () => playbackMode diff --git a/assets/disk0/tvdos/bin/taut_views.mjs b/assets/disk0/tvdos/bin/taut_views.mjs index f4872ba..5ebd461 100644 --- a/assets/disk0/tvdos/bin/taut_views.mjs +++ b/assets/disk0/tvdos/bin/taut_views.mjs @@ -225,13 +225,21 @@ const hasFunkAPI = (typeof audio !== 'undefined' && let smpListScroll = 0 let smpListCursor = 0 -function clampSamplesCursor() { +// 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 (smpListCursor < smpListScroll) smpListScroll = smpListCursor - if (smpListCursor >= smpListScroll + SMP_LIST_H) - smpListScroll = smpListCursor - SMP_LIST_H + 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 } @@ -592,7 +600,7 @@ function clearSamplesPanel() { function drawSamplesContents(wo) { if (samplesCache === null) refreshSamplesCache() - clampSamplesCursor() + clampSamplesCursor(false) // respect the current scroll (free wheel scroll) clearSamplesPanel() drawSamplesListColumn() drawSamplesSeparator() @@ -890,13 +898,19 @@ let instListScroll = 0 let instListCursor = 0 let instSubTab = INST_TAB_GEN1 -function clampInstrumentsCursor() { +// 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 (instListCursor < instListScroll) instListScroll = instListCursor - if (instListCursor >= instListScroll + INST_LIST_H) - instListScroll = instListCursor - INST_LIST_H + 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 } @@ -1573,9 +1587,9 @@ function envPlotLine(x0, y0, x1, y1, col) { // • Polyline through all active nodes; each node a 3×3 marker. // Time axis: cumulative durSec across nodes, scaled to fit graph width. // Value axis: 0 at bottom, env.valueMax at top. -function drawEnvelopeGraph(env) { - const r = instEnvelopeRect() - clearInstrumentsEnvelopeArea() // clear +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. @@ -1815,7 +1829,7 @@ function clearInstrumentsPanel() { function drawInstrumentsContents(wo) { if (instrumentsCache === null) refreshInstrumentsCache() - clampInstrumentsCursor() + 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 @@ -1909,8 +1923,10 @@ function registerInstrumentsMouse() { drawInstrumentsContents() }, onWheel: (cy, cx, dy) => { - instListCursor += dy * 3 - clampInstrumentsCursor() + // 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() } }) @@ -2407,9 +2423,10 @@ function registerSamplesMouse() { drawSamplesContents() }, onWheel: (cy, cx, dy) => { - smpListCursor += dy * 3 - clampSamplesCursor() - smpUsedScroll = 0 + // 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() } }) @@ -2458,20 +2475,31 @@ function sampleRamSummary() { // 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 (ev[0] !== 'key_down' || 1 !== ev[2]) return + if (HUB.dispatchMouseEvent(ev)) { if (HUB.getPanel() !== startPanel) finish(); return } + if (ev[0] !== 'key_down') return const ks = ev[1] - if (ks === '' || ks === '' || ks === '') { finish(); return } - onKey(ks, finish) + if (ks === '' || ks === '' || ks === '') { if (1 === ev[2]) finish(); return } + onKey(ks, finish, (1 === ev[2])) }) - if (onTick) onTick() + if (!done && onTick) onTick() // don't overpaint the new panel after a switch } } @@ -2526,9 +2554,14 @@ function openSampleEdit(slot) { con.color_pair(cHdr, cBack); print('Enter '); con.color_pair(cStatus, cBack); print('Apply ') con.color_pair(cHdr, cBack); print('Esc/Tab '); con.color_pair(cStatus, cBack); print('Back') - editorModalLoop((ks, finish) => { + // No clickable regions of our own yet — clear the (now-covered) Samples-viewer + // regions so a click in the editor doesn't hit them; transport still works. + HUB.clearPanelMouseRegions() + + editorModalLoop((ks, finish, first) => { if (ks === '') { if (toolCursor > 0) toolCursor--; drawTools(); return } if (ks === '') { if (toolCursor < SMP_EDIT_TOOLS.length - 1) toolCursor++; drawTools(); return } + if (!first) return // the rest are discrete; ignore key-repeat if (ks === '\n') { flashAction(toolCursor); return } for (let i = 0; i < SMP_EDIT_TOOLS.length; i++) { if (ks === SMP_EDIT_TOOLS[i].key.toLowerCase() || ks === SMP_EDIT_TOOLS[i].key) { @@ -2572,6 +2605,16 @@ function decodeIxmpPatches(slot) { 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), @@ -2579,13 +2622,28 @@ function decodeIxmpPatches(slot) { 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: (ver&0x02)!==0, hasPan: (ver&0x04)!==0, hasFil: (ver&0x08)!==0, hasPit: (ver&0x10)!==0, + 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 || '' @@ -2605,29 +2663,47 @@ function ovr(val, isDefault) { return isDefault ? '--' : ('$' + (val & 0xFF).toS // 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) + -// zone map (top-right) + selected-patch detail (bottom-right). See plan Step 2. +// 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 + 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 - 1 - LIST_Y - const MAP_X = R_X + 3 // 3 cols of vol-axis labels + 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 @@ -2636,18 +2712,19 @@ function openAdvancedInstEdit(slot) { 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, i) => zones.push({ kind: 'layer', layer: L, + 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 + let selIdx = 0, listScroll = 0, envKind = 0 // ── pitch range (union of real zones; base alone -> whole-map backdrop) ──── let minP = Infinity, maxP = -Infinity @@ -2676,8 +2753,28 @@ function openAdvancedInstEdit(slot) { } 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() { @@ -2696,20 +2793,44 @@ function openAdvancedInstEdit(slot) { 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 = r // no scroll for now; lists are short + const i = listScroll + r const y = LIST_Y + r - con.move(y, LIST_X) - if (i >= zones.length) { con.color_pair(cStatus, cBack); print(' '.repeat(LIST_W)); continue } - const z = zones[i], sel = (i === selIdx) - const back = sel ? colHighlight : cBack - let lbl - if (z.kind === 'base') lbl = 'base ' + (base ? advSampleLabel(base.samplePtr, base.sampleLen) : '') - else if (z.kind === 'layer') lbl = i.toString(16).toUpperCase() + ' >$' + z.layer.instIdx.toString(16).toUpperCase().padStart(2,'0') + ' ' + advInstName(z.layer.instIdx) - else lbl = i.toString(16).toUpperCase() + ' ' + advSampleLabel(z.ptr, z.len) - con.color_pair(sel ? colWHITE : cStatus, back) - print((' ' + lbl).padEnd(LIST_W).substring(0, LIST_W)) + 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() { @@ -2734,7 +2855,7 @@ function openAdvancedInstEdit(slot) { print(lo.padEnd(MAP_W - hi.length) + hi) } function drawDetail() { - for (let y = DET_Y; y < SCRH; y++) { con.move(y, R_X); con.color_pair(cStatus, cBack); print(' '.repeat(SCRW - R_X + 1)) } + 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)) } @@ -2765,56 +2886,292 @@ function openAdvancedInstEdit(slot) { 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.playhead + ' '); con.color_pair(cStatus, cBack); print('live voice ') + 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 voice overlay (onTick): blob at each sounding voice's (note, vol) ── + // ── 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) return + 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 = [] - if (HUB.getPlaybackMode() !== PLAYMODE_NONE) { - for (let v = 0; v < nv; v++) { - if (!audio.getVoiceActive(PLAYHEAD, v)) continue - if (audio.getVoiceInstrument(PLAYHEAD, v) !== SLOT) continue - const vol = Math.round((audio.getVoiceEffectiveVolume(PLAYHEAD, v) || 0) * 63) - blobs.push({ col: colOf(audio.getVoiceNote(PLAYHEAD, v)), row: MAP_Y + Math.round((63 - Math.min(63, Math.max(0, vol))) / 63 * (MAP_H - 1)), lvl: vol }) + 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] : ' ') } } - const sig = blobs.map((b) => b.col + ':' + b.row).sort().join(',') - if (sig === liveSig) return - liveSig = sig - drawMap() // clears prior 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) + 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 ──────────────────────────────────────────────────────────────── - clearPanel(); drawHeader(); drawList() + // 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(); drawHint() + drawDetail(); drawEnvGraph(); drawHint() + registerMouse() - const redrawSel = () => { drawList(); drawMap(); drawDetail(); liveSig = '~' } - editorModalLoop((ks, finish) => { + editorModalLoop((ks, finish, first) => { if (zones.length === 0) return - if (ks === '') { selIdx = (selIdx - 1 + zones.length) % zones.length; redrawSel(); return } - if (ks === '') { selIdx = (selIdx + 1) % zones.length; redrawSel(); return } - if (ks === '') { selIdx = 0; redrawSel(); return } - if (ks === '') { selIdx = zones.length - 1; redrawSel(); return } + if (ks === '') { if (selIdx > 0) { selIdx--; redrawSel() } return } + if (ks === '') { if (selIdx < zones.length - 1) { selIdx++; redrawSel() } return } + if (ks === '') { selIdx = Math.max(0, selIdx - LIST_H); redrawSel(); return } + if (ks === '') { selIdx = Math.min(zones.length - 1, selIdx + LIST_H); redrawSel(); return } + if (ks === '') { selIdx = 0; redrawSel(); return } + if (ks === '') { selIdx = zones.length - 1; redrawSel(); return } + if (!first) return + if (ks === '') { envKind = (envKind + ENV_TABS.length - 1) % ENV_TABS.length; drawEnvGraph(); liveSig = '~'; return } + if (ks === '') { envKind = (envKind + 1) % ENV_TABS.length; drawEnvGraph(); liveSig = '~'; return } }, refreshLiveVoices) + clearEnvGraphics() // don't leave the graph over the restored viewer refreshInstrumentsCache() clampInstrumentsCursor() HUB.drawAll()