diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index 35ed03c..3b15ec8 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -870,6 +870,22 @@ function loadTaudSongList(filePath) { } let projectName = '' + // 0x1E-separated UTF-8 strings; slot 0 is always present (typically empty) + // because converters write a leading separator. Read all entries that exist. + const instNames = [] + const sampleNames = [] + + function parseNameTable(payloadStart, secLen) { + const out = [] + let s = '' + for (let k = 0; k < secLen; k++) { + const b = sys.peek(ptr + payloadStart + k) & 0xFF + if (b === 0x1E) { out.push(s); s = '' } + else { s += String.fromCharCode(b) } + } + out.push(s) + return out + } // Parse Project Data section (\x1ETaudPrJ) for song names / project name. // See terranmon.txt "Project Data" / "sMet" for the format. @@ -900,6 +916,16 @@ function loadTaudSongList(filePath) { } projectName = s } + // 'INam' = 0x49,0x4E,0x61,0x6D + else if (fc0 === 0x49 && fc1 === 0x4E && fc2 === 0x61 && fc3 === 0x6D) { + const names = parseNameTable(payloadStart, secLen) + for (let k = 0; k < names.length; k++) instNames[k] = names[k] + } + // 'SNam' = 0x53,0x4E,0x61,0x6D + else if (fc0 === 0x53 && fc1 === 0x4E && fc2 === 0x61 && fc3 === 0x6D) { + const names = parseNameTable(payloadStart, secLen) + for (let k = 0; k < names.length; k++) sampleNames[k] = names[k] + } // 'sMet' = 0x73,0x4D,0x65,0x74 else if (fc0 === 0x73 && fc1 === 0x4D && fc2 === 0x65 && fc3 === 0x74) { let q = payloadStart @@ -939,7 +965,7 @@ function loadTaudSongList(filePath) { } sys.free(ptr) - return { numSongs, projectName, songs } + return { numSongs, projectName, songs, instNames, sampleNames } } @@ -1493,7 +1519,17 @@ function drawControlHint() { ['sep'], ['!','Help'], ] - let hintElems = [hintElemTimeline, hintElemOrders, hintElemPatterns, hintElemExternal, hintElemExternal, hintElemProject, hintElemExternal] + const hintElemSamples = [ + [`\u008428u\u008429u`,'Nav'], + ['sep'], + ['e','Edit'], + ['ent','View inst'], + ['sep'], + ['tab','Panel'], + ['sep'], + ['!','Help'], + ] + let hintElems = [hintElemTimeline, hintElemOrders, hintElemPatterns, hintElemSamples, hintElemExternal, hintElemProject, hintElemExternal] let hintElemPat = [hintElemEditNoteValue, hintElemEditInstValue, hintElemEditVolEff, hintElemEditPanEff, hintElemEditFxSym, hintElemEditFxVal] // erase current line @@ -1921,6 +1957,7 @@ function switchSong(newIndex) { currentSongIndex = newIndex song = loadTaud(fullPathObj.full, newIndex) + refreshSamplesCache() const newPitchIdx = songsMeta.songs[newIndex].pitchPresetIdx PITCH_PRESET_IDX = (newPitchIdx != null && pitchTablePresets[newPitchIdx]) @@ -3021,7 +3058,438 @@ function projectInput(wo, event) { function externalPanelInput(wo, event) {} -const panelSamples = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, externalPanelInput, makeExternalPanelDraw('taut_sampleedit'), undefined, ()=>{}) +///////////////////////////////////////////////////////////////////////////////////////////////////////////// +// 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 launches the external taut_sampleedit sub-program. +// +// Sample identity in .taud is derived from (samplePtr, sampleLen) inside the +// 256-byte instrument records (terranmon.txt §"Instrument bin"). Conversion +// scripts pack samples into the 8 MB pool in slot order, so sorting unique +// pointers ascending lines up with SNam[i] in the project-data block. + +// Peripheral memory window offsets, from terranmon.txt:1994-1999. +const TAUT_SBANK_SIZE = 524288 // 512 K window for sample bin +const TAUT_INST_WINDOW_OFF = 720896 // instrument bin starts here in peri space +const TAUT_INST_RECORD_SIZE = 256 +const TAUT_INST_COUNT = 256 // slots 0..255; slot 0 is unused + +// Read one 256-byte instrument record straight out of the audio adapter. +function readInstRecord(slot) { + const memBase = audio.getMemAddr() + const base = TAUT_INST_WINDOW_OFF + slot * TAUT_INST_RECORD_SIZE + const rec = new Uint8Array(TAUT_INST_RECORD_SIZE) + for (let i = 0; i < TAUT_INST_RECORD_SIZE; i++) { + rec[i] = sys.peek(memBase - (base + i)) & 0xFF + } + return rec +} + +// Decode the fields the viewer actually cares about. Offsets from terranmon.txt:2071+. +function decodeInstRecord(rec) { + const samplePtr = (rec[0]) | (rec[1] << 8) | (rec[2] << 16) | (rec[3] * 0x1000000) + const sampleLen = rec[4] | (rec[5] << 8) + const c4Rate = rec[6] | (rec[7] << 8) + const playStart = rec[8] | (rec[9] << 8) + const loopStart = rec[10] | (rec[11] << 8) + const loopEnd = rec[12] | (rec[13] << 8) + const sampleFlags = rec[14] + const instGV = rec[171] + const defNoteVol = rec[196] + const detune = rec[184] | (rec[185] << 8) + return { + samplePtr, sampleLen, c4Rate, playStart, loopStart, loopEnd, + sampleFlags, instGV, defNoteVol, detune + } +} + +// Scan all 256 instruments and build the deduped sample list. Each returned +// entry is { ptr, len, c4Rate, playStart, loopStart, loopEnd, sampleFlags, +// usedBy[], name }. usedBy is a list of instrument slot numbers (1..255). +let samplesCache = null + +function buildSampleIndex() { + const byPtr = new Map() + for (let i = 1; i < TAUT_INST_COUNT; i++) { + const d = decodeInstRecord(readInstRecord(i)) + if (d.sampleLen === 0) continue + const key = d.samplePtr + ':' + d.sampleLen + if (!byPtr.has(key)) { + byPtr.set(key, { + ptr: d.samplePtr, + len: d.sampleLen, + c4Rate: d.c4Rate, + playStart: d.playStart, + loopStart: d.loopStart, + loopEnd: d.loopEnd, + sampleFlags:d.sampleFlags, + usedBy: [], + name: '' + }) + } + byPtr.get(key).usedBy.push(i) + } + 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 = 26 // 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 + +let smpListScroll = 0 +let smpListCursor = 0 + +function clampSamplesCursor() { + const n = samplesCache ? samplesCache.length : 0 + if (smpListCursor < 0) smpListCursor = 0 + if (smpListCursor >= n) smpListCursor = Math.max(0, n - 1) + if (smpListCursor < smpListScroll) smpListScroll = smpListCursor + if (smpListCursor >= smpListScroll + SMP_LIST_H) + smpListScroll = smpListCursor - SMP_LIST_H + 1 + if (smpListScroll < 0) smpListScroll = 0 +} + +function drawSamplesListColumn() { + const n = samplesCache ? samplesCache.length : 0 + for (let row = 0; row < SMP_LIST_H; row++) { + const idx = smpListScroll + row + const y = SMP_LIST_Y + row + con.move(y, SMP_LIST_X) + if (idx >= n) { + con.color_pair(colSmpListNameFg, colSmpListBg) + print(' '.repeat(SMP_LIST_BODY_W)) + continue + } + const s = samplesCache[idx] + const isSel = (idx === smpListCursor) + const back = isSel ? colSmpListSel : colSmpListBg + const numStr = (idx + 1).toString(16).toUpperCase().padStart(2, '0') + const nameRaw = (s.name && s.name.length) ? s.name : '(sample ' + (idx + 1) + ')' + const nameW = SMP_LIST_BODY_W - 6 // ' NN name ' totals 6 + N chars + const nameStr = (nameRaw.length > nameW ? nameRaw.substring(0, nameW) : nameRaw.padEnd(nameW)) + con.color_pair(colSmpListNumFg, back); print(' ' + numStr + ' ') + con.color_pair(colSmpListNameFg, back); print(' ') + con.color_pair(isSel ? colWHITE : colSmpListNameFg, back); print(nameStr) + con.color_pair(colSmpListNameFg, back); print(' ') + } + // scroll indicator on the rightmost column of the list area (left of the separator) + if (n > SMP_LIST_H) { + const maxScroll = n - SMP_LIST_H + const indPos = (maxScroll === 0) ? 0 : ((smpListScroll * (SMP_LIST_H - 1) / maxScroll) | 0) + for (let r = 0; r < SMP_LIST_H; r++) { + con.move(SMP_LIST_Y + r, SMP_LIST_SCROLL_X) + con.color_pair(colStatus, colSmpListBg) + print(r === indPos ? sym.ticked : sym.unticked) + } + } 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 rightW = SCRW - SMP_RIGHT_X + 1 + con.move(SMP_USED_Y, SMP_RIGHT_X) + con.color_pair(colSmpUsedHdr, colBackPtn) + print('Used by instruments:'.padEnd(rightW)) + + const s = (samplesCache && samplesCache[smpListCursor]) || null + const used = s ? s.usedBy : [] + const names = (songsMeta && songsMeta.instNames) || [] + const visible = SMP_USED_LIST_H + + 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 a min/max envelope, 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, r.y, r.w, r.h, 255) // 255 = transparent +} + +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) + graphics.plotRect(wx0, wy0, wW, wH, 255) + + const s = (samplesCache && samplesCache[smpListCursor]) || null + if (!s || s.len === 0) return + + const bankIdxFirst = (s.ptr / TAUT_SBANK_SIZE) | 0 + const bankOff = s.ptr - bankIdxFirst * TAUT_SBANK_SIZE + const memBase = audio.getMemAddr() + const prevBank = audio.getSampleBank() || 0 + + // Centre line + graphics.plotRect(wx0, wy0 + (wH >>> 1), wW, 1, colSmpWaveMid) + + // Walk the sample at one column per output pixel. For each column we read + // a chunk and reduce to min/max; vertical extent comes from (max-min). + // Bank switching is per-step: each output column may straddle banks. + const samplesPerCol = Math.max(1, (s.len / wW) | 0) + let pos = 0 // byte offset into the sample, 0..len-1 + let curBank = -1 + 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 + + let mn = 255, mx = 0 + // Step in coarse strides for speed when samples are long. + const step = Math.max(1, ((end - start) / 8) | 0) + for (let p = start; p < end; p += step) { + const abs = s.ptr + p + const bank = (abs / TAUT_SBANK_SIZE) | 0 + if (bank !== curBank) { + audio.setSampleBank(bank) + curBank = bank + } + const off = abs - bank * TAUT_SBANK_SIZE + const v = sys.peek(memBase - off) & 0xFF + if (v < mn) mn = v + if (v > mx) mx = v + } + // unsigned 8-bit → centred around 128 + const yTop = wy0 + ((wH * (255 - mx)) / 255) | 0 + const yBot = wy0 + ((wH * (255 - mn)) / 255) | 0 + const h = Math.max(1, yBot - yTop + 1) + graphics.plotRect(wx0 + col, yTop, 1, h, colSmpWaveLine) + } + + // Restore bank 0 for playback (engine expects bank 0 as default) + audio.setSampleBank(prevBank) +} + +function drawSamplesEditButton() { + const y = SMP_BTN_Y + con.move(y, SMP_RIGHT_X) + con.color_pair(colSmpUsedHdr, colBackPtn) + print('[ E ]') + con.color_pair(colSmpPropValue, colBackPtn) + const label = ' Edit sample' + print(label) + const rest = SCRW - (SMP_RIGHT_X + 5 + label.length) + 1 + if (rest > 0) print(' '.repeat(rest)) +} + +function clearSamplesPanel() { + // Panel area only — leave the hint row (SCRH) alone; drawControlHint owns it. + for (let y = PTNVIEW_OFFSET_Y; y < SCRH; y++) fillLine(y, colSmpPropValue, colBackPtn) +} + +function drawSamplesContents(wo) { + if (samplesCache === null) refreshSamplesCache() + clampSamplesCursor() + clearSamplesPanel() + drawSamplesListColumn() + drawSamplesSeparator() + drawSamplesProperties() + drawSamplesUsedBy() + drawSampleWaveform() + drawSamplesEditButton() +} + +let pendingEditorLaunch = null // { progName, args[] } + +function requestEditorLaunch(progName, args) { + pendingEditorLaunch = { progName, args: args || [] } +} + +// Stub: instrument viewer will live in taut.js once implemented; until then, +// VIEW_INSTRMNT still routes through the external taut_instredit editor. The +// `_G.TAUT.UI.PRELOAD_INST` hand-off is read by whichever piece grows into the +// viewer first. +function launchInstrumentViewerFor(instSlot) { + _G.TAUT.UI.PRELOAD_INST = instSlot + // Re-use the panel-switch machinery the Tab key uses. + switchToPanel(VIEW_INSTRMNT) +} + +function samplesInput(wo, event) { + if (event[0] !== 'key_down') return + const keysym = event[1] + const keyJustHit = (1 == event[2]) + const shiftDown = (event.includes(59) || event.includes(60)) + const moveDelta = shiftDown ? 8 : 1 + + const n = samplesCache ? samplesCache.length : 0 + if (n === 0) { + if (keysym === 'e' || keysym === 'E') { + requestEditorLaunch('taut_sampleedit', [fullPathObj.full, VIEW_SAMPLES, -1]) + } + return + } + + if (keysym === '') { smpListCursor -= moveDelta; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return } + if (keysym === '') { smpListCursor += moveDelta; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return } + if (keysym === '') { smpListCursor -= SMP_LIST_H; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return } + if (keysym === '') { smpListCursor += SMP_LIST_H; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return } + if (keysym === '') { smpListCursor = 0; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return } + if (keysym === '') { smpListCursor = n - 1; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return } + + if (keysym === 'e' || keysym === 'E') { + requestEditorLaunch('taut_sampleedit', [fullPathObj.full, VIEW_SAMPLES, smpListCursor]) + return + } + + if (keysym === '\n') { + // Open the first instrument that uses this sample in the (stub) inst viewer + const s = samplesCache[smpListCursor] + if (s && s.usedBy.length > 0) { + launchInstrumentViewerFor(s.usedBy[0]) + } + return + } +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////// +// END SAMPLES VIEWER +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +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, ()=>{}) const panelFile = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, externalPanelInput, makeExternalPanelDraw('taut_fileop'), undefined, ()=>{}) @@ -3706,6 +4174,7 @@ drawAll() resetAudioDevice() taud.uploadTaudFile(fullPathObj.full, currentSongIndex, PLAYHEAD) +refreshSamplesCache() audio.setMasterVolume(PLAYHEAD, 255) audio.setMasterPan(PLAYHEAD, 128) let initialTrackerMixerflags = audio.getTrackerMixerFlags(PLAYHEAD) @@ -3713,7 +4182,7 @@ let initialGlobalVolume = audio.getSongGlobalVolume(PLAYHEAD) let initialMixingVolume = audio.getSongMixingVolume(PLAYHEAD) function isExternalPanel(p) { - return p === VIEW_SAMPLES || p === VIEW_INSTRMNT || p === VIEW_FILE + return p === VIEW_INSTRMNT || p === VIEW_FILE } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -3808,9 +4277,11 @@ function addGlobalMouseRegion(x, y, w, h, handlers) { MOUSE_GLOBAL.push(Object.a function switchToPanel(newPanel) { if (newPanel === currentPanel) return const wasTimeline = (currentPanel === VIEW_TIMELINE) + const wasSamples = (currentPanel === VIEW_SAMPLES) currentPanel = newPanel applyMuteTransition(currentPanel) if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters() + if (wasSamples && currentPanel !== VIEW_SAMPLES) clearSampleWaveformArea() if (isExternalPanel(currentPanel)) { clearPanelMouseRegions() con.clear(); drawAlwaysOnElems(); drawControlHint() @@ -3870,9 +4341,56 @@ function rebuildPanelMouseRegions() { if (currentPanel === VIEW_TIMELINE) registerTimelineMouse() else if (currentPanel === VIEW_CUES) registerOrdersMouse() else if (currentPanel === VIEW_PATTERN_DETAILS) registerPatternsMouse() + else if (currentPanel === VIEW_SAMPLES) registerSamplesMouse() else if (currentPanel === VIEW_PROJECT) registerProjectMouse() } +function registerSamplesMouse() { + // Left list (incl. scroll-indicator column, but excluding the separator). + addPanelMouseRegion(SMP_LIST_X, SMP_LIST_Y, SMP_SEP_X - SMP_LIST_X, SMP_LIST_H, { + onClick: (cy, cx, btn) => { + if (btn !== 1) return + const n = samplesCache ? samplesCache.length : 0 + const target = smpListScroll + (cy - SMP_LIST_Y) + if (target < 0 || target >= n) return + smpListCursor = target + smpUsedScroll = 0 + clampSamplesCursor() + drawSamplesContents() + }, + onWheel: (cy, cx, dy) => { + smpListCursor += dy * 3 + clampSamplesCursor() + smpUsedScroll = 0 + drawSamplesContents() + } + }) + // Right "Used by" list: click launches inst viewer for that slot + addPanelMouseRegion(SMP_RIGHT_X, SMP_USED_Y + 1, SCRW - SMP_RIGHT_X + 1, SMP_USED_LIST_H, { + onClick: (cy, cx, btn) => { + if (btn !== 1) return + const s = samplesCache && samplesCache[smpListCursor] + if (!s) return + const idx = smpUsedScroll + (cy - (SMP_USED_Y + 1)) + if (idx < 0 || idx >= s.usedBy.length) return + launchInstrumentViewerFor(s.usedBy[idx]) + }, + onWheel: (cy, cx, dy) => { + const s = samplesCache && samplesCache[smpListCursor] + if (!s) return + smpUsedScroll += dy + drawSamplesUsedBy() + } + }) + // Bottom-row Edit button + addPanelMouseRegion(SMP_RIGHT_X, SMP_BTN_Y, 18, 1, { + onClick: (cy, cx, btn) => { + if (btn !== 1) return + requestEditorLaunch('taut_sampleedit', [fullPathObj.full, VIEW_SAMPLES, smpListCursor]) + } + }) +} + function registerTimelineMouse() { addPanelMouseRegion(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, { onClick: (cy, cx, btn) => { @@ -4097,11 +4615,13 @@ while (!exitFlag) { if (keyJustHit && keysym === "") { const wasTimeline = (currentPanel === VIEW_TIMELINE) + const wasSamples = (currentPanel === VIEW_SAMPLES) currentPanel = (currentPanel + (shiftDown ? -1 : 1)) if (currentPanel < 0) currentPanel += panels.length currentPanel = currentPanel % panels.length applyMuteTransition(currentPanel) if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters() + if (wasSamples && currentPanel !== VIEW_SAMPLES) clearSampleWaveformArea() if (isExternalPanel(currentPanel)) { // Redraw header now so the tab highlight is visible immediately, // but defer the actual sub-program launch to after withEvent returns. @@ -4150,6 +4670,45 @@ while (!exitFlag) { } } + // Launch the sample / instrument editor as a sub-program. Same deferral + // reason as pendingExternalDraw: avoid leaking the trigger key into the + // sub-program's own withEvent loop. The editor sets NEXTPANEL on exit + // (typically back to its parent viewer), so the loop above will repaint. + if (pendingEditorLaunch) { + const { progName, args } = pendingEditorLaunch + pendingEditorLaunch = null + stopPlayback() + clearSampleWaveformArea() + clearPanelMouseRegions() + con.clear(); drawAlwaysOnElems(); drawControlHint() + _G.TAUT.UI.NEXTPANEL = undefined + _G.shell.execute(`${progName} ${args.join(' ')}`) + // After the editor returns, instruments / samples may have changed — + // rebuild the deduped sample list before redrawing whatever panel comes next. + refreshSamplesCache() + if (_G.TAUT.UI.NEXTPANEL === undefined || _G.TAUT.UI.NEXTPANEL === null) { + // Editor declined to switch panels — repaint the current panel. + rebuildPanelMouseRegions() + drawAll() + } else { + while (_G.TAUT.UI.NEXTPANEL !== undefined && _G.TAUT.UI.NEXTPANEL !== null) { + const wasTimeline = (currentPanel === VIEW_TIMELINE) + currentPanel = _G.TAUT.UI.NEXTPANEL + _G.TAUT.UI.NEXTPANEL = undefined + applyMuteTransition(currentPanel) + if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters() + if (isExternalPanel(currentPanel)) { + clearPanelMouseRegions() + con.clear(); drawAlwaysOnElems(); drawControlHint() + redrawPanel() + } else { + rebuildPanelMouseRegions() + drawAll() + } + } + } + } + if (playbackMode !== PLAYMODE_NONE) updatePlayback() } diff --git a/assets/disk0/tvdos/bin/taut_sampleedit.js b/assets/disk0/tvdos/bin/taut_sampleedit.js index b4a2cd0..0feb376 100644 --- a/assets/disk0/tvdos/bin/taut_sampleedit.js +++ b/assets/disk0/tvdos/bin/taut_sampleedit.js @@ -1,18 +1,23 @@ /** - * TAUT Sample Editor - * Sub-program launched by taut.js when the Samples tab is active. - * Rows 1-3 are owned by the parent; this program draws rows 4+. + * TAUT Sample Editor (stub) + * Sub-program launched from taut.js's Samples viewer. Rows 1-3 are owned by + * the parent; this program draws rows 4+. * - * exec_args[1] = path to .taud file - * Sets _G.TAUT.UI.NEXTPANEL before returning to request a panel switch. + * exec_args: + * [1] = path to .taud file + * [2] = parent panel index (where to return) + * [3] = sample index to preload (-1 if none) + * + * Sets _G.TAUT.UI.NEXTPANEL on return to request a panel switch back. * * Created by minjaesong on 2026-04-27 + * Stub editing UI added on 2026-05-26 */ const win = require("wintex") -const PANEL_COUNT = 7 -const MY_PANEL = 3 // VIEW_SAMPLES +const PARENT_PANEL = (exec_args[2] !== undefined) ? (exec_args[2] | 0) : 3 // VIEW_SAMPLES +const SAMPLE_IDX = (exec_args[3] !== undefined) ? (exec_args[3] | 0) : -1 const [SCRH, SCRW] = con.getmaxyx() const PANEL_Y = 4 @@ -21,38 +26,122 @@ const PANEL_H = SCRH - PANEL_Y const colStatus = 253 const colContent = 240 const colHdr = 230 +const colEmph = 211 +const colDim = 246 +const colBack = 255 +const colSel = 41 -function drawSampleEditContents(wo) { +// Stub editor "fields": pretend toolbar. None of these write anything yet. +const TOOLS = [ + { key: 'L', label: 'Load .raw / .wav from disk' }, + { key: 'S', label: 'Save current sample to disk' }, + { key: 'D', label: 'Draw waveform freehand' }, + { key: 'X', label: 'Crop / trim selection' }, + { key: 'R', label: 'Resample' }, + { key: 'V', label: 'Reverse' }, + { key: 'N', label: 'Normalise to peak' }, + { key: 'F', label: 'Fade in / out' }, +] + +let toolCursor = 0 + +function drawSampleEditFrame() { for (let y = PANEL_Y; y < SCRH; y++) { con.move(y, 1) - con.color_pair(colContent, 255) + con.color_pair(colContent, colBack) print(' '.repeat(SCRW)) } + // Title con.move(PANEL_Y + 1, 3) - con.color_pair(colHdr, 255) - print('[ Sample Editor ]') - con.move(PANEL_Y + 3, 3) - con.color_pair(colStatus, 255) - print('placeholder — not yet implemented') + con.color_pair(colHdr, colBack); print('[ Sample Editor ] ') + con.color_pair(colEmph, colBack); print('Sample ') + con.color_pair(colStatus, colBack) + if (SAMPLE_IDX >= 0) print('#' + (SAMPLE_IDX + 1).toString(16).toUpperCase().padStart(2, '0')) + else print('(none)') + + con.move(PANEL_Y + 2, 3) + con.color_pair(colDim, colBack) + print('stub editor — actions below are placeholders only.') +} + +function drawToolList() { + const x = 5 + const y0 = PANEL_Y + 4 + con.move(y0, x) + con.color_pair(colHdr, colBack); print('Editing actions') + con.move(y0 + 1, x) + con.color_pair(colDim, colBack); print('-'.repeat(16)) + + for (let i = 0; i < TOOLS.length; i++) { + const y = y0 + 3 + i + const t = TOOLS[i] + const sel = (i === toolCursor) + const back = sel ? colSel : colBack + con.move(y, x) + con.color_pair(colHdr, back); print(' ' + t.key + ' ') + con.color_pair(colStatus, back); print(' ') + con.color_pair(sel ? colEmph : colStatus, back) + const w = SCRW - x - 6 + const lbl = t.label.length > w ? t.label.substring(0, w) : t.label.padEnd(w) + print(lbl) + } + + // Drawing-area placeholder on the right + const dx = 38 + const dy0 = PANEL_Y + 4 + const dw = SCRW - dx - 2 + const dh = SCRH - dy0 - 2 + con.move(dy0, dx) + con.color_pair(colHdr, colBack); print('Waveform editor') + con.move(dy0 + 1, dx) + con.color_pair(colDim, colBack); print('-'.repeat(16)) + + // Empty drawing rectangle made of dots + for (let r = 0; r < dh; r++) { + con.move(dy0 + 3 + r, dx) + con.color_pair(colDim, colBack) + if (r === (dh >>> 1)) print('-'.repeat(dw)) // zero line + else print(' '.repeat(dw)) + } + con.move(dy0 + 3 + (dh >>> 1) + 1, dx) + con.color_pair(colDim, colBack) + print('(drawing surface — not yet implemented)') } function drawHints() { con.move(SCRH, 1) - con.color_pair(colStatus, 255) + con.color_pair(colStatus, colBack) print(' '.repeat(SCRW - 1)) con.move(SCRH, 1) - con.color_pair(colHdr, 255); print('Tab ') - con.color_pair(colStatus, 255); print('Panel') + con.color_pair(colHdr, colBack); print('„28u„29u ') + con.color_pair(colStatus, colBack); print('Tool ') + con.color_pair(colHdr, colBack); print('Enter ') + con.color_pair(colStatus, colBack); print('Apply ') + con.color_pair(colHdr, colBack); print('Esc/Tab ') + con.color_pair(colStatus, colBack); print('Back to viewer') +} + +function flashAction(idx) { + const t = TOOLS[idx] + if (!t) return + con.move(SCRH - 2, 5) + con.color_pair(colEmph, colBack) + print(('Action: ' + t.label + ' (stub, no-op)').padEnd(SCRW - 8)) } function sampleEditInput(wo, event) { - // placeholder — no interaction yet + // wintex panel input — wired up but the loop below handles keys directly. } -const panel = new win.WindowObject(1, PANEL_Y, SCRW, PANEL_H, sampleEditInput, drawSampleEditContents, undefined, ()=>{}) +function drawAll() { + drawSampleEditFrame() + drawToolList() + drawHints() +} + +const panel = new win.WindowObject(1, PANEL_Y, SCRW, PANEL_H, sampleEditInput, drawAll, undefined, ()=>{}) panel.drawContents() -drawHints() let done = false while (!done) { @@ -60,17 +149,32 @@ while (!done) { if (event[0] !== 'key_down') return const keysym = event[1] const keyJustHit = (1 == event[2]) - const shiftDown = (event.includes(59) || event.includes(60)) if (!keyJustHit) return - if (keysym === '') { - _G.TAUT.UI.NEXTPANEL = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT + if (keysym === '' || keysym === '') { + _G.TAUT.UI.NEXTPANEL = PARENT_PANEL done = true return } - panel.processInput(event) + if (keysym === '') { if (toolCursor > 0) toolCursor--; drawToolList(); return } + if (keysym === '') { if (toolCursor < TOOLS.length-1) toolCursor++; drawToolList(); return } + + if (keysym === '\n') { + flashAction(toolCursor) + return + } + + // Direct key shortcuts + for (let i = 0; i < TOOLS.length; i++) { + if (keysym === TOOLS[i].key.toLowerCase() || keysym === TOOLS[i].key) { + toolCursor = i + drawToolList() + flashAction(i) + return + } + } }) } diff --git a/assets/disk0/tvdos/bin/tautfont.kra b/assets/disk0/tvdos/bin/tautfont.kra index 6765b05..edd39ea 100644 --- a/assets/disk0/tvdos/bin/tautfont.kra +++ b/assets/disk0/tvdos/bin/tautfont.kra @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a00dd17f453dac23befc326d5e133f01687feddc4696fb57bc64239883e89ed -size 138435 +oid sha256:c37c5609b06ff135231819b7fe15b8497d5f5fb2de41801b4ec1d99bc5f75d3e +size 148484 diff --git a/terranmon.txt b/terranmon.txt index 398bda9..f7688eb 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2409,6 +2409,8 @@ TODO: [x] expose song table on UI (test with `insaniq2.taud`) [x] 0x0000 - no-op; 0x0001 - key-off; 0x0002 - note-cut 0x0010..0x001F - INT0..INTF [ ] establish hooks for the interrupts + [ ] Samples and Instruments view (viewer on taut.js; editor on separate .js) + follow the ImpulseTracker design first, then improve from there TODO - list of demo songs that MUST ship with Microtone: * 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes