mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-22 04:04:04 +09:00
taut: inst patch view
This commit is contained in:
@@ -2454,10 +2454,11 @@ function sampleRamSummary() {
|
||||
// autonomously; updatePlayback() is only the on-screen sync. We deliberately do
|
||||
// NOT tick updatePlayback here, because currentPanel is still VIEW_SAMPLES /
|
||||
// VIEW_INSTRMNT and it would repaint the viewer's blobs / cursor on top of the
|
||||
// editor UI. (Step 2's Advanced Edit will draw its own live playing-region
|
||||
// visualisation via HUB.tickPlayback / the voice-state API instead.) On exit the
|
||||
// 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().
|
||||
function editorModalLoop(onKey) {
|
||||
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.
|
||||
let done = false, swallow = true
|
||||
@@ -2470,6 +2471,7 @@ function editorModalLoop(onKey) {
|
||||
if (ks === '<ESC>' || ks === '<ESCAPE>' || ks === '<TAB>') { finish(); return }
|
||||
onKey(ks, finish)
|
||||
})
|
||||
if (onTick) onTick()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2542,20 +2544,276 @@ function openSampleEdit(slot) {
|
||||
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
|
||||
}
|
||||
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: (ver&0x02)!==0, hasPan: (ver&0x04)!==0, hasFil: (ver&0x08)!==0, hasPit: (ver&0x10)!==0,
|
||||
})
|
||||
o += len
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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) +
|
||||
// zone map (top-right) + selected-patch detail (bottom-right). See plan Step 2.
|
||||
function openAdvancedInstEdit(slot) {
|
||||
const SLOT = (slot !== undefined && slot >= 0) ? (slot | 0) : -1
|
||||
const Y = PTNVIEW_OFFSET_Y
|
||||
const cStatus = 253, cContent = 240, cHdr = 230
|
||||
const cHdr = colVoiceHdr, cStatus = colStatus, cDim = colSep, cBack = 255
|
||||
|
||||
for (let y = Y; y < SCRH; y++) { con.move(y, 1); con.color_pair(cContent, 255); print(' '.repeat(SCRW)) }
|
||||
con.move(Y + 1, 3); con.color_pair(cHdr, 255); print('[ Instrument Editor ]')
|
||||
con.move(Y + 3, 3); con.color_pair(cStatus, 255)
|
||||
print(SLOT >= 0 ? ('Slot $' + SLOT.toString(16).toUpperCase().padStart(2, '0') + ' - Advanced Edit (not yet implemented)')
|
||||
: 'Advanced Edit (not yet implemented)')
|
||||
con.move(SCRH, 1); con.color_pair(cStatus, 255); print(' '.repeat(SCRW - 1))
|
||||
con.move(SCRH, 1); con.color_pair(cHdr, 255); print('Esc/Tab '); con.color_pair(cStatus, 255); print('Back')
|
||||
// ── geometry ────────────────────────────────────────────────────────────
|
||||
const LIST_X = 1, LIST_W = 22
|
||||
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 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
|
||||
|
||||
editorModalLoop((ks, finish) => {})
|
||||
// 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
|
||||
|
||||
// ── 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)
|
||||
|
||||
// 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,
|
||||
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
|
||||
|
||||
// ── 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 zoneBg = (i) => (i < 0) ? baseBg : (i === selIdx) ? colHighlight : PAL[i % PAL.length]
|
||||
const zoneFg = (i) => (i < 0) ? baseFg : (i === selIdx) ? colWHITE : colBLACK
|
||||
|
||||
// ── 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() {
|
||||
con.move(Y + 1, LIST_X); con.color_pair(cHdr, cBack); print((isMeta ? 'Layers' : 'Patches').padEnd(LIST_W))
|
||||
for (let r = 0; r < LIST_H; r++) {
|
||||
const i = r // no scroll for now; lists are short
|
||||
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))
|
||||
}
|
||||
}
|
||||
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 < SCRH; 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)))
|
||||
}
|
||||
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('Esc '); con.color_pair(cStatus, cBack); print('Back')
|
||||
}
|
||||
|
||||
// ── live voice overlay (onTick): blob at each sounding voice's (note, vol) ──
|
||||
let liveSig = '~'
|
||||
function refreshLiveVoices() {
|
||||
if (SLOT < 0 || zones.length === 0) return
|
||||
const song = HUB.getSong()
|
||||
const nv = (song && song.numVoices) ? song.numVoices : NUM_VOICES
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
// ── compose ────────────────────────────────────────────────────────────────
|
||||
clearPanel(); 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()
|
||||
|
||||
const redrawSel = () => { drawList(); drawMap(); drawDetail(); liveSig = '~' }
|
||||
editorModalLoop((ks, finish) => {
|
||||
if (zones.length === 0) return
|
||||
if (ks === '<UP>') { selIdx = (selIdx - 1 + zones.length) % zones.length; redrawSel(); return }
|
||||
if (ks === '<DOWN>') { selIdx = (selIdx + 1) % zones.length; redrawSel(); return }
|
||||
if (ks === '<HOME>') { selIdx = 0; redrawSel(); return }
|
||||
if (ks === '<END>') { selIdx = zones.length - 1; redrawSel(); return }
|
||||
}, refreshLiveVoices)
|
||||
|
||||
refreshInstrumentsCache()
|
||||
clampInstrumentsCursor()
|
||||
|
||||
Reference in New Issue
Block a user