From 4ea9ade060d3ce937e77c9965b0be7e5966d07e8 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Wed, 13 May 2026 03:27:22 +0900 Subject: [PATCH] taut.js: retuning function --- assets/disk0/tvdos/bin/taut.js | 209 ++++++++++++++++++++++++- assets/disk0/tvdos/bin/taut_helpmsg.js | 4 + terranmon.txt | 3 +- 3 files changed, 212 insertions(+), 4 deletions(-) diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index ef0e56f..f0736e9 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -223,10 +223,11 @@ const colEffOp = 220 const colEffArg = 231 const colBackPtn = 255 -let PITCH_PRESET_IDX = 240 // TODO read from the Project Data section of the .taud +let PITCH_PRESET_IDX = 120 // TODO read from the Project Data section of the .taud let beatDivPrimary = 4 // TODO read from the Project Data section of the .taud let beatDivSecondary = 16 let hasUnsavedChanges = false +let patternsOutOfSync = false // in-memory song.patterns has edits not yet pushed to the audio adapter // pitchSymLut[pitchInOct] = [symString, octaveOffset] // octaveOffset is 1 when pitchInOct is closer to the next octave's root (wraps up) than to any table entry. @@ -254,6 +255,48 @@ function rebuildPitchLut() { } rebuildPitchLut() +// Remap every note in every pattern of the current song so that the lower +// 12 bits snap to the nearest entry in `newIdx`'s pitch table, then switch +// PITCH_PRESET_IDX. Special note values (empty/cut/keyoff) are left alone. +// Pitches closer to the next octave's root (0x1000) than to any table entry +// wrap up by one octave (mirrors rebuildPitchLut's octaveOffset logic). +function retuneAllPatterns(newIdx) { + const preset = pitchTablePresets[newIdx] + if (!preset) return + const table = preset.table + if (table.length > 0) { + for (let p = 0; p < song.numPats; p++) { + const ptn = song.patterns[p] + for (let row = 0; row < ROWS_PER_PAT; row++) { + const off = 8 * row + const note = ptn[off] | (ptn[off+1] << 8) + if (note === 0xFFFF || note === 0xFFFE || note === 0x0000) continue + let octave = (note >>> 12) & 0xF + const pitch = note & 0xFFF + let best = 0, bestDist = 0x1000 + for (let i = 0; i < table.length; i++) { + const d = Math.abs(pitch - table[i]) + if (d < bestDist) { bestDist = d; best = i } + } + let newPitch + if ((0x1000 - pitch) < bestDist) { + if (octave < 0xF) { octave += 1; newPitch = 0 } + else { newPitch = table[table.length - 1] } + } else { + newPitch = table[best] + } + const newNote = ((octave & 0xF) << 12) | (newPitch & 0xFFF) + ptn[off] = newNote & 0xFF + ptn[off+1] = (newNote >>> 8) & 0xFF + } + } + hasUnsavedChanges = true + patternsOutOfSync = true + } + PITCH_PRESET_IDX = newIdx + rebuildPitchLut() +} + Number.prototype.hex02 = function() { return this.toString(16).toUpperCase().padStart(2,'0') } @@ -1514,6 +1557,7 @@ function switchSong(newIndex) { song = loadTaud(fullPathObj.full, newIndex) taud.uploadTaudFile(fullPathObj.full, newIndex, PLAYHEAD) + patternsOutOfSync = false audio.setMasterVolume(PLAYHEAD, 255) audio.setMasterPan(PLAYHEAD, 128) initialTrackerMixerflags = audio.getTrackerMixerFlags(PLAYHEAD) @@ -2508,7 +2552,7 @@ function drawProjectSongList() { ` BPM${s.bpm.toString().padStart(3,'0')} tk${s.tickRate.dec02()}` + ` g${s.songGlobalVolume.hex02()}` - con.color_pair(isActive ? colBrand : colVoiceHdr, back) + con.color_pair(isActive ? colWHITE : colVoiceHdr, back) print(`${marker} ${numStr} ${nameStr} ${meta}`) } @@ -2624,6 +2668,20 @@ function restoreFullSongParams() { previewActive = false } +// Push in-memory song.patterns to the audio adapter if local edits haven't been +// uploaded yet. Called by every start-play entry point so playback always reflects +// the current editor state (e.g. after Retune). +function reuploadPatternsIfNeeded() { + if (!patternsOutOfSync) return + const patBytes = new Array(PATTERN_SIZE) + for (let p = 0; p < song.numPats; p++) { + const ptn = song.patterns[p] + for (let k = 0; k < PATTERN_SIZE; k++) patBytes[k] = ptn[k] & 0xFF + audio.uploadPattern(p, patBytes) + } + patternsOutOfSync = false +} + // Adjust the live tick rate by `delta`. The engine still honours 'A' (set speed) effects, // which will overwrite this value when their row is hit during playback. function nudgeTickRate(delta) { @@ -2636,6 +2694,7 @@ function nudgeTickRate(delta) { function startPlaySong() { restoreFullSongParams() + reuploadPatternsIfNeeded() audio.stop(PLAYHEAD) audio.setCuePosition(PLAYHEAD, cueIdx) audio.setTrackerRow(PLAYHEAD, 0) @@ -2649,6 +2708,7 @@ function startPlaySong() { function startPlayCue() { restoreFullSongParams() + reuploadPatternsIfNeeded() audio.stop(PLAYHEAD) audio.setCuePosition(PLAYHEAD, cueIdx) audio.setTrackerRow(PLAYHEAD, 0) @@ -2663,6 +2723,7 @@ function startPlayCue() { function startPlayRow(fromRow, fromCue) { restoreFullSongParams() + reuploadPatternsIfNeeded() if (fromRow === undefined) fromRow = cursorRow if (fromCue === undefined) fromCue = cueIdx audio.stop(PLAYHEAD) @@ -2678,6 +2739,7 @@ function startPlayRow(fromRow, fromCue) { function startPlayPattern() { if (song.numPats === 0) return + reuploadPatternsIfNeeded() audio.stop(PLAYHEAD) audio.setBPM(PLAYHEAD, song.bpm) audio.uploadCue(PREVIEW_CUE_IDX, buildPreviewCue(patternIdx)) @@ -2693,6 +2755,7 @@ function startPlayPattern() { function startPlayPatternRow() { if (song.numPats === 0) return + reuploadPatternsIfNeeded() audio.stop(PLAYHEAD) audio.setBPM(PLAYHEAD, song.bpm) audio.uploadCue(PREVIEW_CUE_IDX, buildPreviewCue(patternIdx)) @@ -2910,7 +2973,6 @@ function openHelpPopup() { while (!done) { input.withEvent(ev => { if (ev[0] !== 'key_down') return - //if (1 !== ev[2]) return // allow continuous scroll by key repeat if (eventJustReceived) { eventJustReceived = false; return } const ks = ev[1] @@ -3081,6 +3143,141 @@ function openGotoPopup() { drawAll() } +///////////////////////////////////////////////////////////////////////////////////////////////////////////// +// RETUNE POPUP +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +function openRetunePopup() { + const entries = Object.values(pitchTablePresets).sort((a, b) => a.index - b.index) + const n = entries.length + + const pw = 44 + const listH = Math.min(n, 12) + const ph = listH + 5 + const px = ((SCRW - pw) / 2 | 0) + 1 + const py = ((SCRH - ph) / 2 | 0) + const listX = px + 2 + const listY = py + 2 + const listW = pw - 4 + + const popup = new win.WindowObject(px, py, pw, ph, ()=>{}, ()=>{}, 'Retune', popupDrawFrame) + popup.isHighlighted = true + popup.titleBack = colPopupBack + + let sel = entries.findIndex(p => p.index === PITCH_PRESET_IDX) + if (sel < 0) sel = 0 + let scroll = (sel >= listH) ? Math.min(Math.max(0, n - listH), sel - (listH >>> 1)) : 0 + + const repaint = () => { + con.color_pair(230, colPopupBack) + popup.drawFrame() + + con.move(py + 1, px + 2) + con.color_pair(colWHITE, colPopupBack) + print('Select new tuning preset:') + + for (let r = 0; r < listH; r++) { + const idx = scroll + r + con.move(listY + r, listX) + if (idx >= n) { + con.color_pair(230, colPopupBack) + print(' '.repeat(listW)) + continue + } + const e = entries[idx] + const isSel = (idx === sel) + const isCur = (e.index === PITCH_PRESET_IDX) + const back = isSel ? colHighlight : colPopupBack + const fore = isSel ? colWHITE : (isCur ? colWHITE : 230) + const marker = isCur ? sym.playhead : ' ' + let label = `${marker} ${e.index.toString().padStart(5, ' ')} ${e.name}` + if (label.length > listW) label = label.substring(0, listW) + else label = label.padEnd(listW) + con.color_pair(fore, back) + print(label) + } + + if (n > listH) { + const maxScroll = n - listH + const indPos = (maxScroll === 0) ? 0 : ((scroll * (listH - 1) / maxScroll) | 0) + con.color_pair(colStatus, colPopupBack) + for (let r = 0; r < listH; r++) { + con.move(listY + r, px + pw - 2) + let trough = (r === 0) ? 0xBA : (r === listH - 1) ? 0xBC : 0xBB + print(String.fromCharCode(r === indPos ? (trough + 3) : trough)) + } + } + + con.move(py + ph - 2, px + 2) + con.color_pair(colVoiceHdr, colPopupBack) + print(`\u008418u `) + con.color_pair(colStatus, colPopupBack) + print(`Sel `) + con.color_pair(colVoiceHdr, colPopupBack) + print(`en `) + con.color_pair(colStatus, colPopupBack) + print(`OK `) + con.color_pair(colVoiceHdr, colPopupBack) + print(`Q `) + con.color_pair(colStatus, colPopupBack) + print(`Cancel`) + + con.color_pair(colStatus, 255) + } + + repaint() + + let done = false + let confirmed = false + let eventJustReceived = true + + while (!done) { + input.withEvent(ev => { + if (ev[0] !== 'key_down') return + if (eventJustReceived) { eventJustReceived = false; return } + const ks = ev[1] + + if (ks === 'Q') { done = true } + else if (ks === '\n') { confirmed = true; done = true } + else if (ks === '') { + if (sel > 0) { + sel-- + if (sel < scroll) scroll = sel + repaint() + } + } else if (ks === '') { + if (sel < n - 1) { + sel++ + if (sel >= scroll + listH) scroll = sel - listH + 1 + repaint() + } + } else if (ks === '') { + sel = 0; scroll = 0; repaint() + } else if (ks === '') { + sel = n - 1; scroll = Math.max(0, n - listH); repaint() + } else if (ks === '') { + sel = Math.max(0, sel - listH) + scroll = Math.max(0, scroll - listH) + if (sel < scroll) scroll = sel + repaint() + } else if (ks === '') { + sel = Math.min(n - 1, sel + listH) + if (sel >= scroll + listH) scroll = Math.min(Math.max(0, n - listH), sel - listH + 1) + repaint() + } + }) + } + + if (confirmed) { + const target = entries[sel] + if (target && target.index !== PITCH_PRESET_IDX) { + retuneAllPatterns(target.index) + } + } + + drawAll() +} + clampCursor(); clampVoice(); clampCue(); clampOrdersHoriz(); clampPatternIdx(); clampPatternGrid() drawAll() @@ -3110,6 +3307,12 @@ while (!exitFlag) { const keyJustHit = (1 == event[2]) const shiftDown = (event.includes(59) || event.includes(60)) + if (keyJustHit && shiftDown && event.includes(keys.Q) && + (currentPanel === VIEW_TIMELINE || currentPanel === VIEW_PATTERN_DETAILS)) { + openRetunePopup() + return + } + if (keyJustHit && keysym === "q") { if (openConfirmQuit()) exitFlag = true return diff --git a/assets/disk0/tvdos/bin/taut_helpmsg.js b/assets/disk0/tvdos/bin/taut_helpmsg.js index 141835c..4a87dcc 100644 --- a/assets/disk0/tvdos/bin/taut_helpmsg.js +++ b/assets/disk0/tvdos/bin/taut_helpmsg.js @@ -113,6 +113,10 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using \u00B7${'\u00B8'.repeat(11)}\u00B9 &demisharp; ♯ &doublesharp; &triplesharp; &quadsharp; &demiflat; ♭ &doubleflat; &tripleflat;  &accuptick;  &accupup;  &accdntick;  &accdndn; C  c  cx x  xx B  b  bb bbb ^  ^^ v  vv + + GLOBAL EDIT +\u00B7${'\u00B8'.repeat(11)}\u00B9 +&bul;Q : retune current song into different tuning ` //////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/terranmon.txt b/terranmon.txt index d1ce314..73d89b5 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2400,7 +2400,7 @@ TODO: [x] physical_presence order 0x1F chn 2: note cuts unexpectedly fast — engine fix [x] GSLINGER order 0x03 chn 1: L 0100 fades unexpectedly fast? — converter fix [x] do not reset tickspeed on pattern view play / add key to modify tick speed ('[' down/']' up) - [ ] expose song table on UI (test with `insaniq2.taud`) + [x] expose song table on UI (test with `insaniq2.taud`) TODO - list of demo songs that MUST ship with Microtone: * 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes @@ -2694,6 +2694,7 @@ prefixes: * PCom. Project author. Encoding: UTF-8 * PCpr. Project copyright string. Encoding: UTF-8 * PNam. Project name. Encoding: UTF-8 +* Pmsg. Project message. Encoding: UTF-8 * INam. Instrument name table. Strings separated by 0x1E