diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index 4fb8276..b12b0dc 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -952,6 +952,8 @@ function loadTaudSongList(filePath) { const [SCRH, SCRW] = con.getmaxyx() const [SCRPW, SCRPH] = graphics.getPixelDimension() +const CELL_PW = (SCRPW / SCRW) | 0 // px per character column +const CELL_PH = (SCRPH / SCRH) | 0 // px per character row const PTNVIEW_OFFSET_X = 3 const PTNVIEW_OFFSET_Y = 5 const PTNVIEW_HEIGHT = SCRH - PTNVIEW_OFFSET_Y @@ -996,6 +998,15 @@ const colTabInactive = 45 const colWHITE = 239 const colBLACK = 240 +// Voice-header playback meters (volume bar grows from centre out; pan bar = centre tick + dot). +// Pixels are drawn beneath text — only the glyph foregrounds occlude the bars, so the bars sit +// on rows 0 and (cellH - 1) where the 7×14 glyph has the least foreground. +const METER_VOL_COL = 41 // colHighlight +const METER_PAN_TICK_COL = 6 // colColumnSep +const METER_PAN_DOT_COL = 239 // colWHITE +const METER_BAR_PAD = 2 // px gap from cell edges (each side) +const METER_TRANSPARENT = 255 + let separatorStyle = 0 const PATEDITOR_LIST_X = 1 @@ -1199,6 +1210,94 @@ function drawVoiceHeaders() { } drawSeparators(separatorStyle) + // Voice headers were just repainted with bg=255 (transparent), so any meter pixels + // beneath them survived the redraw — but the cached per-slot state may still match, + // which would skip the redraw on the next updatePlayback. Force a redraw by clearing + // the cache; the next updatePlayback re-emits any active bars. + invalidateVoiceMeters() +} + +// Per-slot cache of last-drawn meter state: { voice, vol, pan } or null when slot is clear. +// Indexed by slot index 0..VOCSIZE_TIMELINE_FULL-1 (never grows beyond 20 slots in practice). +const meterPrevSlot = new Array(20).fill(null) + +function invalidateVoiceMeters() { + for (let i = 0; i < meterPrevSlot.length; i++) meterPrevSlot[i] = null +} + +// Wipe the pixel strip used by the voice-header meters back to transparent (255). +// Called when leaving the Timeline panel or when playback stops. +function clearVoiceMeters() { + const yTop = (PTNVIEW_OFFSET_Y - 2) * CELL_PH + const yBot = (PTNVIEW_OFFSET_Y - 1) * CELL_PH - 1 + for (let x = 0; x < SCRPW; x++) { + graphics.plotPixel(x, yTop, METER_TRANSPARENT) + graphics.plotPixel(x, yBot, METER_TRANSPARENT) + } + invalidateVoiceMeters() +} + +/** + * Repaint the per-voice volume and pan indicators in the voice-header row. + * Volume: horizontal bar growing from the cell centre outward, length ∝ effective tracker + * volume (after envelopes, fadeout, vol-column/D/tremolo ramps, per-voice fader). Drawn on + * the cell's bottom pixel row. + * Pan: centre tick + a single dot offset by (pan-128)/128 × halfWidth. Drawn on the cell's + * top pixel row. + * Only redraws slots whose (voice, volPix, panPix) tuple has changed since the last call, + * so the work per frame stays bounded by actual movement. + */ +function drawVoiceMeters() { + if (playbackMode === PLAYMODE_NONE || currentPanel !== VIEW_TIMELINE) return + const yPan = (PTNVIEW_OFFSET_Y - 2) * CELL_PH // top pixel of header row + const yVol = (PTNVIEW_OFFSET_Y - 1) * CELL_PH - 1 // bottom pixel of header row + const slotPW = COLSIZE_TIMELINE_FULL * CELL_PW + const halfW = (slotPW >>> 1) - METER_BAR_PAD + + for (let c = 0; c < VOCSIZE_TIMELINE_FULL; c++) { + const voice = voiceOff + c + const slotX0 = (PTNVIEW_OFFSET_X + COLSIZE_TIMELINE_FULL * c - 1) * CELL_PW + const xCenter = slotX0 + (slotPW >>> 1) + const prev = meterPrevSlot[c] + + if (voice >= song.numVoices) { + if (prev !== null) { + for (let x = slotX0 + METER_BAR_PAD; x < slotX0 + slotPW - METER_BAR_PAD; x++) { + graphics.plotPixel(x, yPan, METER_TRANSPARENT) + graphics.plotPixel(x, yVol, METER_TRANSPARENT) + } + meterPrevSlot[c] = null + } + continue + } + + const volRaw = audio.getVoiceEffectiveVolume(PLAYHEAD, voice) || 0 + const panRaw = audio.getVoiceEffectivePan(PLAYHEAD, voice) + const volPix = Math.max(0, Math.min(halfW, Math.round(volRaw * halfW))) + // Pan range 0..255, centre 128 → map to ±halfW. + let panPix = Math.round((panRaw - 128) / 128 * halfW) + if (panPix < -halfW) panPix = -halfW + else if (panPix > halfW) panPix = halfW + + if (prev !== null && prev.voice === voice && prev.vol === volPix && prev.pan === panPix) continue + + // Clear both bar strips in this slot before redrawing. + for (let x = slotX0 + METER_BAR_PAD; x < slotX0 + slotPW - METER_BAR_PAD; x++) { + graphics.plotPixel(x, yPan, METER_TRANSPARENT) + graphics.plotPixel(x, yVol, METER_TRANSPARENT) + } + // Volume bar (grows from centre out). Silent voices show no bar. + if (volPix > 0) { + for (let dx = -volPix; dx <= volPix; dx++) { + graphics.plotPixel(xCenter + dx, yVol, METER_VOL_COL) + } + } + // Pan bar: faint centre tick, bright dot at pan position. + graphics.plotPixel(xCenter, yPan, METER_PAN_TICK_COL) + graphics.plotPixel(xCenter + panPix, yPan, METER_PAN_DOT_COL) + + meterPrevSlot[c] = { voice: voice, vol: volPix, pan: panPix } + } } // Sub-field layout for style-0 cells (shared by drawPatternRowAt and drawVoiceColumnAt) @@ -1710,6 +1809,9 @@ function setTimelineRowStyle(style) { COLSIZE_TIMELINE_FULL = TIMELINE_COLSIZES[style] VOCSIZE_TIMELINE_FULL = Math.floor((SCRW - 3) / COLSIZE_TIMELINE_FULL) SALVAGE_HORIZ_LEN = (VOCSIZE_TIMELINE_FULL - 1) * COLSIZE_TIMELINE_FULL + // Slot widths and per-slot voice mapping are about to change; wipe meter pixels so the + // narrower/wider layout doesn't leave stale bar fragments from the old slot widths. + clearVoiceMeters() clampVoice() drawAll() } @@ -3074,6 +3176,7 @@ function stopPlayback() { audio.stop(PLAYHEAD) playbackMode = PLAYMODE_NONE clampPatternGrid() + clearVoiceMeters() } function updatePlayback() { @@ -3085,9 +3188,12 @@ function updatePlayback() { drawPatternRowAt(cursorRow - scrollRow) else if (currentPanel === VIEW_PATTERN_DETAILS && song.numPats > 0) { simStateKey = ''; redrawPanel() } drawAlwaysOnElems() + clearVoiceMeters() return } + drawVoiceMeters() + const nowCue = audio.getCuePosition(PLAYHEAD) const nowRow = audio.getTrackerRow(PLAYHEAD) @@ -3791,10 +3897,12 @@ while (!exitFlag) { } if (keyJustHit && keysym === "") { + const wasTimeline = (currentPanel === VIEW_TIMELINE) currentPanel = (currentPanel + (shiftDown ? -1 : 1)) if (currentPanel < 0) currentPanel += panels.length currentPanel = currentPanel % panels.length applyMuteTransition(currentPanel) + if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters() if (isExternalPanel(currentPanel)) { // Redraw header now so the tab highlight is visible immediately, // but defer the actual sub-program launch to after withEvent returns. @@ -3825,9 +3933,11 @@ while (!exitFlag) { pendingExternalDraw = false redrawPanel() 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)) { con.clear(); drawAlwaysOnElems(); drawControlHint() redrawPanel() diff --git a/assets/disk0/tvdos/bin/taut_helpmsg.js b/assets/disk0/tvdos/bin/taut_helpmsg.js index 403ad3f..8eebf8e 100644 --- a/assets/disk0/tvdos/bin/taut_helpmsg.js +++ b/assets/disk0/tvdos/bin/taut_helpmsg.js @@ -67,13 +67,13 @@ a s d f g h j k let helpCommon = `COMMON CONTROLS \u00B7${'\u00B8'.repeat(15)}\u00B9 &bul;! : show this help message -&bul;Y : play the entire song from the current cue -&bul;U : play the current cue then stop -&bul;I : play the current row -&bul;O : stop the playback -&bul;tab : switch forward a tab -&bul;TAB : switch backward a tab -&bul;q : close µtone; +&bul;Y : plays the entire song from the current cue +&bul;U : plays the current cue then stop +&bul;I : plays the current row +&bul;O : stops the playback +&bul;tab : switchs forward a tab +&bul;TAB : switchs backward a tab +&bul;q : closes µtone; ` //////////////////////////////////////////////////////////////////////////////////////////////////// @@ -85,29 +85,29 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using  VIEW MODE \u00B7${'\u00B8'.repeat(9)}\u00B9 &bul;Note jamming : plays the note -&bul;&udlr; : move the viewing cursor by voices and rows -&bul;pg&updn; : go to previous/next cue -&bul;W&mdot;E&mdot;R : toggle timeline view mode. W-most detailed, R-most abridged -&bul;n : toggle soloing of the selected voice -&bul;m : toggle muting of the selected voice -&bul;[&mdot;] : change tick rate of playhead +&bul;&udlr; : moves the viewing cursor by voices and rows +&bul;pg&updn; : goes to previous/next cue +&bul;W&mdot;E&mdot;R : toggles timeline view mode. W-most detailed, R-most abridged +&bul;n : toggles soloing of the selected voice +&bul;m : toggles muting of the selected voice +&bul;[&mdot;] : changes tick rate of playhead  EDIT MODE \u00B7${'\u00B8'.repeat(9)}\u00B9 &bul;Note jamming : (note column) inserts the note -&bul;{&mdot;} : (note column) lower/raise a note by one octave (or period) -&bul;[&mdot;] : (note column) lower/raise a note by one unit -&bul;z : (note column) insert a key-off &keyoffsym; -&bul;x : (note column) insert a note-cut ¬ecutsym; -&bul;. : clear fields -&bul;bksp : delete one character on the selected column +&bul;{&mdot;} : (note column) lowers/raises a note by one octave (or period) +&bul;[&mdot;] : (note column) lowers/raises a note by one unit +&bul;z : (note column) inserts a key-off &keyoffsym; +&bul;x : (note column) inserts a note-cut ¬ecutsym; +&bul;. : clears fields +&bul;bksp : deletes one character on the selected column &bul;0&ddot;9 a&ddot;f : inserts a (hexa)decimal number &bul;0&ddot;9 a&ddot;z : (fx column) inserts an effect &bul;^&mdot;v : (volume column) slide up/down &bul;<&mdot;>: (panning column) slide left/right &bul;-&mdot;= : (vol/pan col) fine slide down/up -&bul;&udlr; : move the viewing cursor by columns and rows -&bul;pg&updn; : go to previous/next cue +&bul;&udlr; : moves the viewing cursor by columns and rows +&bul;pg&updn; : goes to previous/next cue  ACCIDENTALS \u00B7${'\u00B8'.repeat(11)}\u00B9 @@ -116,8 +116,27 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using  GLOBAL EDIT \u00B7${'\u00B8'.repeat(11)}\u00B9 -&bul;Q : retune current song into different tuning and strategy -     In general, nearest-note works best for macrotonals, nearest-harmonic and nearest-delta works best for highly microtonals (31+); 17- and 19-TET takes nearest-harmonic pretty well, while 22-TET seem to only benefit from the nearest-note +&bul;Q : retunes current song into different tuning and strategy. In general, nearest-note works best for macrotonals, nearest-harmonic and nearest-delta works best for highly microtonals (31+); 17- and 19-TET takes nearest-harmonic pretty well, while 22-TET seem to only benefit from the nearest-note +` + +let helpProjectFlags = `MIXER FLAGS +\u00B7${'\u00B8'.repeat(11)}\u00B9 +Mixer flags define how should the mixer behave. + + TONE MODE +\u00B7${'\u00B8'.repeat(9)}\u00B9 +&bul;Linear pitch : pitch shift effects operate on linear pitch scale. The default and recommended setting for a new project +&bul;Amiga pitch : pitch shift effects operate on Amiga period scale. Backwards compatible setting for MOD/S3M/XM/IT formats +&bul;Linear freq : pitch shift effects operate on linear frequency scale. Backwards compatible setting for MONOTONE format + + INTERPOLATION +\u00B7${'\u00B8'.repeat(13)}\u00B9 +&bul;Default : three-tap fast sinc interpolation. The default and recommended setting for a new project +&bul;None : zeroth-order hold +&bul;A500 : emulates what Paula chip of Amiga 500 does. S 0x00 effects only work with this and Amiga 1200 mode +&bul;A1200 : emulates what Paula chip of Amiga 1200 does +&bul;SNES : four-tap gaussian interpolation used by SNES +&bul;DPCM : simulates Differential Pulse Code Modulation used by NES ` //////////////////////////////////////////////////////////////////////////////////////////////////// @@ -380,13 +399,13 @@ function typeset(text, customWidth) { } let helpMessages = [ // index: taut.js PANEL_NAMES - [helpJam, helpTimeline, helpCommon, helpNotation].join(HRULE), - [helpCommon, helpNotation].join(HRULE), // placeholder - [helpCommon, helpNotation].join(HRULE), // placeholder - [helpCommon, helpNotation].join(HRULE), // placeholder - [helpCommon, helpNotation].join(HRULE), // placeholder - [helpCommon, helpNotation].join(HRULE), // placeholder - [helpCommon, helpNotation].join(HRULE), // placeholder + /* Timeline */[helpJam, helpTimeline, helpCommon, helpNotation].join(HRULE), + /* Cues */[helpCommon, helpNotation].join(HRULE), // placeholder + /* Patterns */[helpCommon, helpNotation].join(HRULE), // placeholder + /* Samples */[helpCommon, helpNotation].join(HRULE), // placeholder + /* Instruments */[helpCommon, helpNotation].join(HRULE), // placeholder + /* Project */[helpProjectFlags, helpCommon, helpNotation].join(HRULE), // placeholder + /* File */[helpCommon, helpNotation].join(HRULE), // placeholder ] help.MSG_BY_TABS = helpMessages.map(it => typeset(it)) diff --git a/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt b/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt index d27d705..54214dd 100644 --- a/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt +++ b/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt @@ -110,6 +110,30 @@ class AudioJSR223Delegate(private val vm: VM) { fun getVoiceFader(playhead: Int, voice: Int): Int = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.fader ?: 0 + /** Effective per-voice tracker volume (0.0..1.0) — what the mixer applies right now after the + * envelope, fadeout, vol-column / D-slide / tremolo ramp, and the host-owned per-voice fader, + * but BEFORE master/mixing/global volumes. Returns 0.0 for inactive voices. Mirrors the + * perVoiceGain assembled in the per-sample mix loop (AudioAdapter.kt:3201). */ + fun getVoiceEffectiveVolume(playhead: Int, voice: Int): Double { + val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0.0 + if (!v.active) return 0.0 + val effEnvVol = if (v.volEnvOn) v.envVolMix else 1.0 + val faderGain = (255 - v.fader) / 255.0 + return (effEnvVol * v.fadeoutVolume * v.currentMixVolume * faderGain).coerceIn(0.0, 1.0) + } + + /** Effective per-voice tracker pan (0..255, 128 = centre) — channelPan modulated by the pan + * envelope when it is active. Returns 128 (centre) for inactive voices. Mirrors the pan + * selection in the per-sample mix loop (AudioAdapter.kt:3205). */ + fun getVoiceEffectivePan(playhead: Int, voice: Int): Int { + val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 128 + if (!v.active) return 128 + return if (v.hasPanEnv && v.panEnvOn) { + val envPanRaw = (v.envPan * 255.0).toInt().coerceIn(0, 255) + (v.channelPan + envPanRaw - 128).coerceIn(0, 255) + } else v.channelPan.coerceIn(0, 255) + } + /** Set the starting row for the next play call, resetting per-row timing and silencing active voices. */ fun setTrackerRow(playhead: Int, row: Int) { getPlayhead(playhead)?.trackerState?.let { ts ->