taut: persistent funk visualisation

This commit is contained in:
minjaesong
2026-05-30 01:33:28 +09:00
parent 9e8af96c32
commit 1d3b5ce8aa

View File

@@ -3379,10 +3379,11 @@ function drawSamplesUsedBy() {
} }
// ── Waveform rendering ────────────────────────────────────────────────────── // ── Waveform rendering ──────────────────────────────────────────────────────
// Renders one sample under the right panel as a min/max envelope, using the // Renders one sample under the right panel as baseline-filled bars (each bar is
// graphics layer. Samples are unsigned 8-bit; bank-switch is required because // a plotRect anchored at the zero line, extending to the sample amplitude),
// only 512 K of the 8 MB pool is mapped at a time. We restore bank 0 (the // using the graphics layer. Samples are unsigned 8-bit; bank-switch is required
// playback-expected default) when done. // 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 // 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 // waveform painter and the leave-Samples cleanup need to reach for the same
@@ -3402,8 +3403,9 @@ function clearSampleWaveformArea() {
} }
// Instrument slot of an active voice that's funk-repeating (S$Fx) one of the sample's `usedBy` // Instrument slot of an active voice that's funk-repeating (S$Fx) one of the sample's `usedBy`
// instruments, or -1. Returns -1 when not playing / no funking voice / API absent — so the // instruments, or -1. Returns -1 when not playing / no funking voice / API absent. Drives the
// overlay (and its realtime redraw) only engages while a note is actually being funk-repeated. // per-frame repaint cadence only — the *displayed* mask comes from funkMaskForSample, which also
// honours masks that persist after the funking voice has gone idle.
function findFunkInstForSample(usedBy) { function findFunkInstForSample(usedBy) {
if (!hasFunkAPI) return -1 if (!hasFunkAPI) return -1
const numVox = (song && song.numVoices) ? song.numVoices : NUM_VOICES const numVox = (song && song.numVoices) ? song.numVoices : NUM_VOICES
@@ -3416,8 +3418,28 @@ function findFunkInstForSample(usedBy) {
return -1 return -1
} }
// Whether the last drawSampleWaveform() painted a live funk overlay. Lets the per-frame driver // Funk XOR mask to DISPLAY for this sample, or null. The per-instrument mask persists in the engine
// (tickFunkWaveform) repaint once more when funk stops, restoring the stored waveform. // for the whole playback session (cleared only on stop-and-replay), so once a loop has been
// funk-inverted the overlay must stay even after the funking voice goes idle — matching ProTracker,
// whose destructive EFx edits never revert until the song is reloaded. Prefer an actively-funking
// instrument (its mask is live this frame); otherwise show any usedBy instrument that still carries
// a non-empty mask from earlier in the session.
function funkMaskForSample(usedBy, activeInst) {
if (!hasFunkAPI) return null
if (activeInst > 0) {
const m = audio.getInstrumentFunkMask(activeInst)
if (m && m.length > 0) return m
}
for (let i = 0; i < usedBy.length; i++) {
const m = audio.getInstrumentFunkMask(usedBy[i])
if (m && m.length > 0) return m
}
return null
}
// Whether a voice was actively funk-repeating the displayed sample on the last paint. Drives the
// per-frame repaint cadence in tickFunkWaveform (repaint while the live mask changes, plus one
// settling frame after it stops). The painted overlay itself persists — the engine keeps the mask.
let funkWaveLast = false let funkWaveLast = false
function drawSampleWaveform() { function drawSampleWaveform() {
@@ -3430,67 +3452,85 @@ function drawSampleWaveform() {
const s = (samplesCache && samplesCache[smpListCursor]) || null const s = (samplesCache && samplesCache[smpListCursor]) || null
if (!s || s.len === 0) { funkWaveLast = false; return } if (!s || s.len === 0) { funkWaveLast = false; return }
// Funk-repeat live overlay: only while playing AND a voice is funk-repeating this sample. // Funk-repeat overlay. The per-instrument XOR mask flips loop-region bytes by 0xFF and persists
// The mask flips loop-region bytes by 0xFF; we apply it to the displayed bytes and tint the // in the engine until stop-and-replay, so the overlay must remain even after the voice that
// affected columns so it's visibly the live effect, not the stored sample. funkLE is clamped // funked the sample goes idle — matching ProTracker's destructive EFx, whose inverted bytes
// to the snapshot mask's coverage so the bit lookup can never run off the (host) array. // never revert until the song is reloaded. We therefore key the overlay off the persisted mask,
// not off a currently-active funking voice. funkLE is clamped to the snapshot mask's coverage so
// the bit lookup can never run off the (host) array.
let funkMask = null, funkLS = 0, funkLE = 0 let funkMask = null, funkLS = 0, funkLE = 0
if (playbackMode !== PLAYMODE_NONE) { let activeFunk = false
const fi = findFunkInstForSample(s.usedBy) if (playbackMode !== PLAYMODE_NONE && s.loopEnd > s.loopStart) {
if (fi > 0) { const activeInst = findFunkInstForSample(s.usedBy)
const m = audio.getInstrumentFunkMask(fi) activeFunk = (activeInst > 0)
if (m && m.length > 0 && s.loopEnd > s.loopStart) { const m = funkMaskForSample(s.usedBy, activeInst)
if (m) {
funkMask = m funkMask = m
funkLS = s.loopStart funkLS = s.loopStart
funkLE = Math.min(s.loopEnd, funkLS + m.length * 8) funkLE = Math.min(s.loopEnd, funkLS + m.length * 8)
} }
} }
} funkWaveLast = activeFunk
funkWaveLast = (funkMask !== null)
const bankIdxFirst = (s.ptr / TAUT_SBANK_SIZE) | 0
const bankOff = s.ptr - bankIdxFirst * TAUT_SBANK_SIZE
const memBase = audio.getMemAddr() const memBase = audio.getMemAddr()
const prevBank = audio.getSampleBank() || 0 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 let curBank = -1
// Zero line and value→y mapping (unsigned 8-bit: 255 → top, 0 → bottom).
const baseY = wy0 + (wH >>> 1)
const yOf = (v) => wy0 + (((wH * (255 - v)) / 255) | 0)
// Read sample byte p (0..len-1) applying the live funk-flip overlay; sets the
// shared `flippedAny` flag whenever a byte was inverted by the funk mask.
let flippedAny = false
const readByte = (p) => {
const abs = s.ptr + p
const bank = (abs / TAUT_SBANK_SIZE) | 0
if (bank !== curBank) { audio.setSampleBank(bank); curBank = bank }
let v = sys.peek(memBase - (abs - bank * TAUT_SBANK_SIZE)) & 0xFF
if (funkMask !== null && p >= funkLS && p < funkLE) {
const k = p - funkLS
if ((funkMask[k >>> 3] >>> (k & 7)) & 1) { v ^= 0xFF; flippedAny = true }
}
return v
}
// Zero/baseline line
graphics.plotRect(wx0, baseY, wW, 1, colSmpWaveMid)
// Per-sample bar width: how many pixels each sample spans, at least 1px.
const rectW = Math.max(1, Math.ceil(wW / s.len))
if (s.len <= wW) {
// Fewer samples than pixels: one baseline-filled bar per sample.
for (let i = 0; i < s.len; i++) {
flippedAny = false
const yv = yOf(readByte(i))
const top = Math.min(baseY, yv)
graphics.plotRect(wx0 + ((i * wW / s.len) | 0), top, rectW,
Math.max(1, Math.abs(baseY - yv)),
flippedAny ? colSmpWaveFunk : colSmpWaveLine)
}
} else {
// More samples than pixels: reduce each 1px column to its min/max and
// fill from the baseline through the envelope (a solid filled waveform).
for (let col = 0; col < wW; col++) { for (let col = 0; col < wW; col++) {
const start = (col * s.len / wW) | 0 const start = (col * s.len / wW) | 0
const end = Math.min(s.len, (((col + 1) * s.len / wW) | 0)) const end = Math.min(s.len, (((col + 1) * s.len / wW) | 0))
if (end <= start) continue if (end <= start) continue
let mn = 255, mx = 0, flipped = false
// Step in coarse strides for speed when samples are long.
const step = Math.max(1, ((end - start) / 8) | 0) const step = Math.max(1, ((end - start) / 8) | 0)
let mn = 255, mx = 0
flippedAny = false
for (let p = start; p < end; p += step) { for (let p = start; p < end; p += step) {
const abs = s.ptr + p const v = readByte(p)
const bank = (abs / TAUT_SBANK_SIZE) | 0
if (bank !== curBank) {
audio.setSampleBank(bank)
curBank = bank
}
const off = abs - bank * TAUT_SBANK_SIZE
let v = sys.peek(memBase - off) & 0xFF
if (funkMask !== null && p >= funkLS && p < funkLE) {
const k = p - funkLS
if ((funkMask[k >>> 3] >>> (k & 7)) & 1) { v ^= 0xFF; flipped = true }
}
if (v < mn) mn = v if (v < mn) mn = v
if (v > mx) mx = v if (v > mx) mx = v
} }
// unsigned 8-bit → centred around 128 const yTop = Math.min(baseY, yOf(mx))
const yTop = wy0 + ((wH * (255 - mx)) / 255) | 0 const yBot = Math.max(baseY, yOf(mn))
const yBot = wy0 + ((wH * (255 - mn)) / 255) | 0 graphics.plotRect(wx0 + col, yTop, 1, Math.max(1, yBot - yTop + 1),
const h = Math.max(1, yBot - yTop + 1) flippedAny ? colSmpWaveFunk : colSmpWaveLine)
graphics.plotRect(wx0 + col, yTop, 1, h, flipped ? colSmpWaveFunk : colSmpWaveLine) }
} }
// Restore bank 0 for playback (engine expects bank 0 as default) // Restore bank 0 for playback (engine expects bank 0 as default)
@@ -3498,8 +3538,8 @@ function drawSampleWaveform() {
} }
// Per-frame driver: while a voice is funk-repeating the displayed sample, repaint the waveform // Per-frame driver: while a voice is funk-repeating the displayed sample, repaint the waveform
// each frame so the overlay tracks the live mask. One extra repaint fires after funk stops // each frame so the overlay tracks the live mask. One settling repaint fires after funk stops
// (funkWaveLast) to restore the stored waveform. // (funkWaveLast); the persisted overlay then stays until the engine clears the mask on replay.
function tickFunkWaveform() { function tickFunkWaveform() {
if (currentPanel !== VIEW_SAMPLES) { funkWaveLast = false; return } if (currentPanel !== VIEW_SAMPLES) { funkWaveLast = false; return }
const s = (samplesCache && samplesCache[smpListCursor]) || null const s = (samplesCache && samplesCache[smpListCursor]) || null