taut: inst viewer wip

This commit is contained in:
minjaesong
2026-05-26 23:29:31 +09:00
parent a7db53e81c
commit 5f873fa2d1

View File

@@ -1529,7 +1529,18 @@ function drawControlHint() {
['sep'], ['sep'],
['!','Help'], ['!','Help'],
] ]
let hintElems = [hintElemTimeline, hintElemOrders, hintElemPatterns, hintElemSamples, hintElemExternal, hintElemProject, hintElemExternal] const hintElemInstruments = [
[`\u008426u\u008427u`,'Nav'],
[`\u008428u\u008429u`,'Tab'],
[`1${sym.doubledot}5`,'Jump tab'],
['sep'],
['e','Edit'],
['sep'],
['tab','Panel'],
['sep'],
['!','Help'],
]
let hintElems = [hintElemTimeline, hintElemOrders, hintElemPatterns, hintElemSamples, hintElemInstruments, hintElemProject, hintElemExternal]
let hintElemPat = [hintElemEditNoteValue, hintElemEditInstValue, hintElemEditVolEff, hintElemEditPanEff, hintElemEditFxSym, hintElemEditFxVal] let hintElemPat = [hintElemEditNoteValue, hintElemEditInstValue, hintElemEditVolEff, hintElemEditPanEff, hintElemEditFxSym, hintElemEditFxVal]
// erase current line // erase current line
@@ -3438,13 +3449,15 @@ function requestEditorLaunch(progName, args) {
pendingEditorLaunch = { progName, args: args || [] } pendingEditorLaunch = { progName, args: args || [] }
} }
// Stub: instrument viewer will live in taut.js once implemented; until then, // Jump into the in-process instrument viewer with the cursor parked on `instSlot`.
// VIEW_INSTRMNT still routes through the external taut_instredit editor. The // `instSlotToIdx` is the {slot → cache index} map built by refreshInstrumentsCache;
// `_G.TAUT.UI.PRELOAD_INST` hand-off is read by whichever piece grows into the // when the slot isn't in the cache (rare — empty slot with no name), we fall back
// viewer first. // to cursor 0 instead of failing the switch.
function launchInstrumentViewerFor(instSlot) { function launchInstrumentViewerFor(instSlot) {
_G.TAUT.UI.PRELOAD_INST = instSlot if (instrumentsCache === null) refreshInstrumentsCache()
// Re-use the panel-switch machinery the Tab key uses. const idx = (instSlotToIdx && instSlotToIdx[instSlot] != null) ? instSlotToIdx[instSlot] : -1
if (idx >= 0) instListCursor = idx
clampInstrumentsCursor()
switchToPanel(VIEW_INSTRMNT) switchToPanel(VIEW_INSTRMNT)
} }
@@ -3489,10 +3502,729 @@ function samplesInput(wo, event) {
// END SAMPLES VIEWER // END SAMPLES VIEWER
///////////////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////////////
const panelSamples = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, samplesInput, drawSamplesContents, undefined, ()=>{})
const panelInstrmnt = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, externalPanelInput, makeExternalPanelDraw('taut_instredit'), undefined, ()=>{}) /////////////////////////////////////////////////////////////////////////////////////////////////////////////
const panelProject = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, projectInput, drawProjectContents, undefined, ()=>{}) // INSTRUMENTS VIEWER
const panelFile = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, externalPanelInput, makeExternalPanelDraw('taut_fileop'), undefined, ()=>{}) /////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Mirrors the Samples tab skeleton: list on the left, multi-tabbed property pane
// on the right. Tabs are General / Volume / Panning / Pitch — the latter three
// each carry an envelope graph rendered through the graphics layer.
//
// 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 nodeBase = (kind === 'vol') ? 21 : (kind === 'pan') ? 71 : 121
const loopOff = (kind === 'vol') ? 15 : (kind === 'pan') ? 17 : 19
const sustOff = (kind === 'vol') ? 189 : (kind === 'pan') ? 191 : 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 = (kind === 'pf') && (((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
}
}
// 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) {
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]
let detune = rec[184] | (rec[185] << 8); if (detune >= 0x8000) detune -= 0x10000
const instFlag = rec[186]
const nna = 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]
return {
samplePtr, sampleLen, c4Rate, playStart, sLoopStart, sLoopEnd, sampleFlags,
igv, fadeout, volSwing, vibSpeed, vibSweep, defPan,
pitchPanCenter, pitchPanSep, panSwing, defCutoff, defReso,
detune, nna, vibWaveform, vibDepth, vibRate, dct, dca, defNoteVol,
volEnv: decodeEnvelope(rec, 'vol'),
panEnv: decodeEnvelope(rec, 'pan'),
pfEnv: decodeEnvelope(rec, 'pf')
}
}
// 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 = 26
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']
const INST_TAB_GEN1 = 0, INST_TAB_GEN2 = 1, INST_TAB_VOL = 2, INST_TAB_PAN = 3, INST_TAB_PIT = 4
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 = 161 // muted red — sustain range band
let instListScroll = 0
let instListCursor = 0
let instSubTab = INST_TAB_GEN1
function clampInstrumentsCursor() {
const n = instrumentsCache ? instrumentsCache.length : 0
if (instListCursor < 0) instListCursor = 0
if (instListCursor >= n) instListCursor = Math.max(0, n - 1)
if (instListCursor < instListScroll) instListScroll = instListCursor
if (instListCursor >= instListScroll + INST_LIST_H)
instListScroll = instListCursor - INST_LIST_H + 1
if (instListScroll < 0) instListScroll = 0
}
function drawInstrumentsListColumn() {
const n = instrumentsCache ? instrumentsCache.length : 0
for (let row = 0; row < INST_LIST_H; row++) {
const idx = instListScroll + row
const y = INST_LIST_Y + row
con.move(y, INST_LIST_X)
if (idx >= n) {
con.color_pair(colInstListNameFg, colInstListBg)
print(' '.repeat(INST_LIST_BODY_W))
continue
}
const e = instrumentsCache[idx]
const isSel = (idx === instListCursor)
const back = isSel ? colInstListSel : colInstListBg
const numStr = e.slot.toString(16).toUpperCase().padStart(2, '0')
const nameRaw = (e.name && e.name.length) ? e.name : '(instrument $' + numStr + ')'
const nameW = INST_LIST_BODY_W - 6
const nameStr = (nameRaw.length > nameW ? nameRaw.substring(0, nameW) : nameRaw.padEnd(nameW))
con.color_pair(colInstListNumFg, back); print(' ' + numStr + ' ')
con.color_pair(colInstListNameFg, back); print(' ')
con.color_pair(isSel ? colWHITE : colInstListNameFg, back); print(nameStr)
con.color_pair(colInstListNameFg, back); print(' ')
}
// scroll indicator column
if (n > INST_LIST_H) {
const maxScroll = n - INST_LIST_H
const indPos = (maxScroll === 0) ? 0 : ((instListScroll * (INST_LIST_H - 1) / maxScroll) | 0)
for (let r = 0; r < INST_LIST_H; r++) {
con.move(INST_LIST_Y + r, INST_LIST_SCROLL_X)
con.color_pair(colStatus, colInstListBg)
print(r === indPos ? sym.ticked : sym.unticked)
}
} 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
print(' '.repeat(padL) + lbl + ' '.repeat(padR))
}
// 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)' : '')
}
const NNA_NAMES = ['Cut', 'Off', 'Continue', 'Fade']
const DCT_NAMES = ['off', 'note', 'sample', 'instrument']
const DCA_NAMES = ['Cut', 'Off', 'Fade', 'reserved']
const VIB_WF_NAMES = ['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 = title + ' '
const dashes = Math.max(0, INST_RIGHT_W - txt.length)
print(txt + `\u00FB`.repeat(dashes))
}
// ── 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)
}
}
drawGroupHeader(y++, 'Sample binding')
drawLabelRow(y++, ' Sample:', sampleLabel)
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')
drawLabelRow(y++, ' Inst. GV:', _hex(d.igv, 2) + ' (' + d.igv + '/255)')
drawLabelRow(y++, ' DefNote:', _hex(d.defNoteVol, 2) + ' (' + d.defNoteVol + '/255' +
(d.defNoteVol === 0 ? ' legacy: row default 63' : '') + ')')
let fadeStr
if (d.fadeout === 0) fadeStr = '0 (no fade)'
else if (d.fadeout >= 1024) fadeStr = d.fadeout + ' (1-tick cut)'
else {
const ticks = (1024 / d.fadeout)
fadeStr = d.fadeout + ' (~' + ticks.toFixed(1) + ' ticks)'
}
drawLabelRow(y++, ' Fadeout:', fadeStr)
drawLabelRow(y++, ' Swing:', _hex(d.volSwing, 2))
y++
drawGroupHeader(y++, 'Panning')
drawLabelRow(y++, ' Default:', _hex(d.defPan, 2) + ' Use: ' +
(d.panEnv.panUseDef ? sym.ticked + ' on' : sym.unticked + ' off'))
drawLabelRow(y++, ' PPanCtr:', '$' + _hex(d.pitchPanCenter, 4) + ' Sep: ' + _signed(d.pitchPanSep))
drawLabelRow(y++, ' Swing:', _hex(d.panSwing, 2))
}
function drawInstTabGeneral2(e) {
const d = e.decoded
let y = INST_BODY_Y
drawGroupHeader(y++, 'Filter')
drawLabelRow(y++, ' Cutoff:', (d.defCutoff === 0xFF ? 'off' : ('$' + _hex(d.defCutoff, 2) +
' (' + d.defCutoff + '/254)')))
drawLabelRow(y++, ' Reso:', (d.defReso === 0xFF ? 'off' : ('$' + _hex(d.defReso, 2) +
' (' + d.defReso + '/254)')))
y++
drawGroupHeader(y++, 'Vibrato')
drawLabelRow(y++, ' Waveform:', VIB_WF_NAMES[d.vibWaveform & 7])
drawLabelRow(y++, ' Speed:', _hex(d.vibSpeed, 2) + ' Depth: ' + _hex(d.vibDepth, 2))
drawLabelRow(y++, ' Sweep:', _hex(d.vibSweep, 2) + ' Rate: ' + _hex(d.vibRate, 2))
y++
drawGroupHeader(y++, 'Note actions')
drawLabelRow(y++, ' NNA:', NNA_NAMES[d.nna & 3])
drawLabelRow(y++, ' DCT:', DCT_NAMES[d.dct & 3])
drawLabelRow(y++, ' DCA:', DCA_NAMES[d.dca & 3])
y++
drawGroupHeader(y++, 'Tuning')
const detStr = _signed(d.detune) + ' (' + (d.detune / 0x1000).toFixed(3) + ' octave, 4096-TET)'
drawLabelRow(y++, ' Detune:', detStr)
}
// ── 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, r.y, r.w, r.h, 255)
// Also clear the row of text that the graph overlays would otherwise visually
// smudge — the body redraw paints these rows blank anyway, but switchToPanel
// bypasses the body redraw on exit.
}
// Bresenham line via plotPixel. Used to connect envelope nodes.
function envPlotLine(x0, y0, x1, y1, col) {
let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1
let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1
let err = dx + dy
// Guard against pathological inputs; envelope coords are screen-bound.
let safety = 4096
while (safety-- > 0) {
graphics.plotPixel(x0, y0, col)
if (x0 === x1 && y0 === y1) break
const e2 = 2 * err
if (e2 >= dy) { err += dy; x0 += sx }
if (e2 <= dx) { err += dx; y0 += sy }
}
}
// Draw the envelope chart for one envelope (vol/pan/pf). Plots:
// • Quarter-point dashed hairlines as a faint reference grid.
// • Solid axis line (bottom for vol, mid for pan/pitch).
// • Loop / sustain wrap regions as faint vertical bands behind the curve.
// • Polyline through all active nodes; each node a 3×3 marker.
// Time axis: cumulative durSec across nodes, scaled to fit graph width.
// Value axis: 0 at bottom, env.valueMax at top.
function drawEnvelopeGraph(env) {
const r = instEnvelopeRect()
graphics.plotRect(r.x, r.y, r.w, r.h, 255) // clear
// Dashed reference hairlines at quarter points of the value range. Drawn
// first so the solid axis line / loop bands / polyline can stack on top.
// For pan/pitch the 50% level is the main axis; we skip it here to keep
// the solid line visually distinct from the dashes.
const hairFracs = (env.kind === 'vol') ? [0.25, 0.5, 0.75] : [0.25, 0.75]
for (let fi = 0; fi < hairFracs.length; fi++) {
const yy = r.y + r.h - 1 - ((hairFracs[fi] * (r.h - 1)) | 0)
for (let xx = r.x; xx < r.x + r.w; xx += 6) {
graphics.plotRect(xx, yy, 2, 1, colInstEnvHair)
}
}
// Solid axis line — bottom of graph for vol, mid for pan/pitch.
if (env.kind !== 'vol') {
const midY = r.y + (r.h >>> 1)
graphics.plotRect(r.x, midY, r.w, 1, colInstEnvAxis)
} else {
graphics.plotRect(r.x, r.y + r.h - 1, r.w, 1, colInstEnvAxis)
}
// No envelope to draw when there are zero active nodes (shouldn't happen
// for well-formed records, but be defensive).
const lastIdx = (env.terminatorIdx >= 0) ? env.terminatorIdx : (env.nodes.length - 1)
if (lastIdx < 0) return
// Cumulative time of each node (node 0 is at t=0; node i is at sum of
// dur[0..i-1] for i>=1). The terminator's own dur is 0 so it lands at
// the sum of the preceding nodes.
const xs = new Array(lastIdx + 1)
let acc = 0
xs[0] = 0
for (let i = 1; i <= lastIdx; i++) {
acc += env.nodes[i - 1].durSec
xs[i] = acc
}
// When total time is 0 (single-node held envelope), give the x-axis a
// tiny non-zero span so node 0 still renders at the left edge.
const totalTime = Math.max(acc, 1e-6)
const valueMax = env.valueMax || 0xFF
const pxX = (t) => r.x + Math.min(r.w - 1, Math.max(0, ((t / totalTime) * (r.w - 1)) | 0))
const pxY = (v) => r.y + r.h - 1 - Math.min(r.h - 1, Math.max(0, ((v / valueMax) * (r.h - 1)) | 0))
// Vertical time-grid hairlines. Same dashed style as the value-axis
// hairlines (2 px on, 4 px off) but oriented vertically; spacing comes
// from pickEnvTimeGrid so we never spam more than ~8 lines across the
// graph regardless of envelope duration.
if (acc > 0) {
const grid = pickEnvTimeGrid(totalTime)
for (let t = grid; t < totalTime; t += grid) {
const xx = pxX(t)
for (let yy = r.y; yy < r.y + r.h; yy += 6) {
graphics.plotRect(xx, yy, 1, 2, colInstEnvHair)
}
}
}
// Loop / sustain bands behind the polyline.
if (env.loopEnable && env.loopStart <= lastIdx && env.loopEnd <= lastIdx) {
const x0 = pxX(xs[env.loopStart])
const x1 = pxX(xs[env.loopEnd])
const bw = Math.max(1, x1 - x0)
graphics.plotRect(x0, r.y, bw, r.h, colInstEnvLoop)
}
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)
}
// 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.
// `extra` is an array of additional [label, value] rows specific to this kind
// (e.g. pan's "Use default pan" flag).
function drawInstTabEnvelope(e, env, kindLabel, extra) {
let y = INST_BODY_Y
drawGroupHeader(y++, kindLabel + ' envelope')
drawLabelRow(y++, ' Present:', (env.present ? sym.ticked + ' yes (P=1)' : sym.unticked + ' no (P=0)'))
const realCount = (env.terminatorIdx >= 0) ? (env.terminatorIdx + 1) : env.nodes.length
drawLabelRow(y++, ' Nodes:', realCount + ' / 25' +
(env.carry ? ' Carry: ' + sym.ticked : ' Carry: ' + sym.unticked))
drawLabelRow(y++, ' Loop:', env.loopEnable
? (sym.ticked + ' [' + env.loopStart + '..' + env.loopEnd + ']')
: (sym.unticked + ' off'))
drawLabelRow(y++, ' Sustain:', env.sustEnable
? (sym.ticked + ' [' + env.sustStart + '..' + env.sustEnd + ']')
: (sym.unticked + ' off'))
if (extra) {
for (let i = 0; i < extra.length; i++) {
drawLabelRow(y++, ' ' + extra[i][0], extra[i][1])
}
}
// 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') }
function drawInstTabPanning(e) {
drawInstTabEnvelope(e, e.decoded.panEnv, 'Panning', [
['UseDef:', (e.decoded.panEnv.panUseDef ? sym.ticked + ' on' : sym.unticked + ' off') +
' (chan-pan source: byte $B1)']
])
}
function drawInstTabPitch(e) {
const env = e.decoded.pfEnv
drawInstTabEnvelope(e, env, env.pfFilter ? 'Filter' : 'Pitch', [
['Mode:', env.pfFilter ? 'filter cutoff' : 'pitch']
])
}
// ── 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 = ' Edit instrument'
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()
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()
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 drawInstTabPitch(e)
drawInstrumentsEditButton()
}
function instrumentsInput(wo, event) {
if (event[0] !== 'key_down') return
const keysym = event[1]
const keyJustHit = (1 == event[2])
const shiftDown = (event.includes(59) || event.includes(60))
const moveDelta = shiftDown ? 8 : 1
const n = instrumentsCache ? instrumentsCache.length : 0
if (n === 0) {
if (keysym === 'e' || keysym === 'E') {
requestEditorLaunch('taut_instredit', [fullPathObj.full, VIEW_INSTRMNT, -1])
}
return
}
if (keysym === '<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..5 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 === 'e' || keysym === 'E') {
const e = instrumentsCache[instListCursor]
if (e) requestEditorLaunch('taut_instredit', [fullPathObj.full, VIEW_INSTRMNT, e.slot])
return
}
}
function registerInstrumentsMouse() {
// Left list
addPanelMouseRegion(INST_LIST_X, INST_LIST_Y, INST_SEP_X - INST_LIST_X, INST_LIST_H, {
onClick: (cy, cx, btn) => {
if (btn !== 1) return
const n = instrumentsCache ? instrumentsCache.length : 0
const target = instListScroll + (cy - INST_LIST_Y)
if (target < 0 || target >= n) return
instListCursor = target
clampInstrumentsCursor()
drawInstrumentsContents()
},
onWheel: (cy, cx, dy) => {
instListCursor += dy * 3
clampInstrumentsCursor()
drawInstrumentsContents()
}
})
// Right-pane tab strip: clicking a chip selects that tab.
for (let i = 0; i < INST_TAB_NAMES.length; i++) {
const idx = i
const r = instTabRect(i)
addPanelMouseRegion(r.x, r.y, r.w, 1, {
onClick: (cy, cx, btn) => {
if (btn !== 1) return
instSubTab = idx
drawInstrumentsContents()
}
})
}
// Edit button
addPanelMouseRegion(INST_RIGHT_X, INST_BTN_Y, 22, 1, {
onClick: (cy, cx, btn) => {
if (btn !== 1) return
const n = instrumentsCache ? instrumentsCache.length : 0
const slot = (n > 0) ? instrumentsCache[instListCursor].slot : -1
requestEditorLaunch('taut_instredit', [fullPathObj.full, VIEW_INSTRMNT, slot])
}
})
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// END INSTRUMENTS VIEWER
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
const panelSamples = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, samplesInput, drawSamplesContents, undefined, ()=>{})
const panelInstrmnt = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, instrumentsInput, drawInstrumentsContents, undefined, ()=>{})
const panelProject = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, projectInput, drawProjectContents, undefined, ()=>{})
const panelFile = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, externalPanelInput, makeExternalPanelDraw('taut_fileop'), undefined, ()=>{})
const panels = [panelTimeline, panelOrders, panelPatterns, panelSamples, panelInstrmnt, panelProject, panelFile] const panels = [panelTimeline, panelOrders, panelPatterns, panelSamples, panelInstrmnt, panelProject, panelFile]
@@ -4182,7 +4914,7 @@ let initialGlobalVolume = audio.getSongGlobalVolume(PLAYHEAD)
let initialMixingVolume = audio.getSongMixingVolume(PLAYHEAD) let initialMixingVolume = audio.getSongMixingVolume(PLAYHEAD)
function isExternalPanel(p) { function isExternalPanel(p) {
return p === VIEW_INSTRMNT || p === VIEW_FILE return p === VIEW_FILE
} }
///////////////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -4278,10 +5010,12 @@ function switchToPanel(newPanel) {
if (newPanel === currentPanel) return if (newPanel === currentPanel) return
const wasTimeline = (currentPanel === VIEW_TIMELINE) const wasTimeline = (currentPanel === VIEW_TIMELINE)
const wasSamples = (currentPanel === VIEW_SAMPLES) const wasSamples = (currentPanel === VIEW_SAMPLES)
const wasInstrmnt = (currentPanel === VIEW_INSTRMNT)
currentPanel = newPanel currentPanel = newPanel
applyMuteTransition(currentPanel) applyMuteTransition(currentPanel)
if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters() if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters()
if (wasSamples && currentPanel !== VIEW_SAMPLES) clearSampleWaveformArea() if (wasSamples && currentPanel !== VIEW_SAMPLES) clearSampleWaveformArea()
if (wasInstrmnt && currentPanel !== VIEW_INSTRMNT) clearInstrumentsEnvelopeArea()
if (isExternalPanel(currentPanel)) { if (isExternalPanel(currentPanel)) {
clearPanelMouseRegions() clearPanelMouseRegions()
con.clear(); drawAlwaysOnElems(); drawControlHint() con.clear(); drawAlwaysOnElems(); drawControlHint()
@@ -4342,6 +5076,7 @@ function rebuildPanelMouseRegions() {
else if (currentPanel === VIEW_CUES) registerOrdersMouse() else if (currentPanel === VIEW_CUES) registerOrdersMouse()
else if (currentPanel === VIEW_PATTERN_DETAILS) registerPatternsMouse() else if (currentPanel === VIEW_PATTERN_DETAILS) registerPatternsMouse()
else if (currentPanel === VIEW_SAMPLES) registerSamplesMouse() else if (currentPanel === VIEW_SAMPLES) registerSamplesMouse()
else if (currentPanel === VIEW_INSTRMNT) registerInstrumentsMouse()
else if (currentPanel === VIEW_PROJECT) registerProjectMouse() else if (currentPanel === VIEW_PROJECT) registerProjectMouse()
} }