taut: realtime waveform update for funk repeat simulation

This commit is contained in:
minjaesong
2026-05-29 14:02:55 +09:00
parent f863f6230d
commit 43e5baadf4
2 changed files with 84 additions and 4 deletions

View File

@@ -3217,6 +3217,13 @@ const colSmpUsedHdr = colVoiceHdr
const colSmpUsedFg = colInst
const colSmpWaveLine = 77 // bright cyan-ish; visible on dark bg
const colSmpWaveMid = 246 // dim grey for zero-line
const colSmpWaveFunk = 221 // orange — loop bytes live-inverted by funk repeat (S$Fx)
// Funk-repeat introspection API (getVoiceFunkSpeed / getInstrumentFunkMask) ships with this
// feature; on an un-rebuilt host VM it's absent and the waveform stays the stored sample.
const hasFunkAPI = (typeof audio !== 'undefined' &&
typeof audio.getVoiceFunkSpeed === 'function' &&
typeof audio.getInstrumentFunkMask === 'function')
let smpListScroll = 0
let smpListCursor = 0
@@ -3394,6 +3401,25 @@ function clearSampleWaveformArea() {
graphics.plotRect(r.x-2, r.y-2, r.w+4, r.h+4, 255) // 255 = transparent
}
// 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
// overlay (and its realtime redraw) only engages while a note is actually being funk-repeated.
function findFunkInstForSample(usedBy) {
if (!hasFunkAPI) return -1
const numVox = (song && song.numVoices) ? song.numVoices : NUM_VOICES
for (let v = 0; v < numVox; v++) {
if (!audio.getVoiceActive(PLAYHEAD, v)) continue
if (audio.getVoiceFunkSpeed(PLAYHEAD, v) <= 0) continue
const inst = audio.getVoiceInstrument(PLAYHEAD, v)
if (usedBy.indexOf(inst) >= 0) return inst
}
return -1
}
// Whether the last drawSampleWaveform() painted a live funk overlay. Lets the per-frame driver
// (tickFunkWaveform) repaint once more when funk stops, restoring the stored waveform.
let funkWaveLast = false
function drawSampleWaveform() {
const r = sampleWaveformRect()
const wx0 = r.x, wy0 = r.y, wW = r.w, wH = r.h
@@ -3402,7 +3428,25 @@ function drawSampleWaveform() {
clearSampleWaveformArea()
const s = (samplesCache && samplesCache[smpListCursor]) || null
if (!s || s.len === 0) return
if (!s || s.len === 0) { funkWaveLast = false; return }
// Funk-repeat live overlay: only while playing AND a voice is funk-repeating this sample.
// The mask flips loop-region bytes by 0xFF; we apply it to the displayed bytes and tint the
// affected columns so it's visibly the live effect, not the stored sample. 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
if (playbackMode !== PLAYMODE_NONE) {
const fi = findFunkInstForSample(s.usedBy)
if (fi > 0) {
const m = audio.getInstrumentFunkMask(fi)
if (m && m.length > 0 && s.loopEnd > s.loopStart) {
funkMask = m
funkLS = s.loopStart
funkLE = Math.min(s.loopEnd, funkLS + m.length * 8)
}
}
}
funkWaveLast = (funkMask !== null)
const bankIdxFirst = (s.ptr / TAUT_SBANK_SIZE) | 0
const bankOff = s.ptr - bankIdxFirst * TAUT_SBANK_SIZE
@@ -3423,7 +3467,7 @@ function drawSampleWaveform() {
const end = Math.min(s.len, (((col + 1) * s.len / wW) | 0))
if (end <= start) continue
let mn = 255, mx = 0
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)
for (let p = start; p < end; p += step) {
@@ -3434,7 +3478,11 @@ function drawSampleWaveform() {
curBank = bank
}
const off = abs - bank * TAUT_SBANK_SIZE
const v = sys.peek(memBase - off) & 0xFF
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 > mx) mx = v
}
@@ -3442,13 +3490,24 @@ function drawSampleWaveform() {
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)
graphics.plotRect(wx0 + col, yTop, 1, h, flipped ? colSmpWaveFunk : colSmpWaveLine)
}
// Restore bank 0 for playback (engine expects bank 0 as default)
audio.setSampleBank(prevBank)
}
// 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
// (funkWaveLast) to restore the stored waveform.
function tickFunkWaveform() {
if (currentPanel !== VIEW_SAMPLES) { funkWaveLast = false; return }
const s = (samplesCache && samplesCache[smpListCursor]) || null
const funking = !!(s && s.len > 0 && playbackMode !== PLAYMODE_NONE &&
findFunkInstForSample(s.usedBy) > 0)
if (funking || funkWaveLast) drawSampleWaveform()
}
function drawSamplesEditButton() {
const y = SMP_BTN_Y
con.move(y, SMP_RIGHT_X)
@@ -4817,6 +4876,7 @@ function stopPlayback() {
// pass ourselves so stale blobs / hairlines don't linger on Samples / Instruments.
drawSamplesPlayBlobs()
drawInstrumentsPlayBlobs()
tickFunkWaveform() // restore the stored waveform now that funk repeat has stopped
drawSampleCursor()
drawEnvelopeCursor()
}
@@ -4834,6 +4894,7 @@ function updatePlayback() {
// playbackMode is NONE now → these paint a final blob0 / clear-cursor pass.
drawSamplesPlayBlobs()
drawInstrumentsPlayBlobs()
tickFunkWaveform() // restore the stored waveform now that playback has stopped
drawSampleCursor()
drawEnvelopeCursor()
return
@@ -4842,6 +4903,7 @@ function updatePlayback() {
drawVoiceMeters()
drawSamplesPlayBlobs()
drawInstrumentsPlayBlobs()
tickFunkWaveform() // realtime funk-repeat overlay (no-op unless funking this sample)
drawSampleCursor()
drawEnvelopeCursor()

View File

@@ -163,6 +163,24 @@ class AudioJSR223Delegate(private val vm: VM) {
return counts
}
/** Funk-repeat (S$Fx) speed currently driving the voice: 0 = off, otherwise the per-tick
* accumulator increment. A non-zero value on an active voice means the voice is live-inverting
* its instrument's loop region right now — visualisers can use this to gate the funk overlay. */
fun getVoiceFunkSpeed(playhead: Int, voice: Int): Int {
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0
if (!v.active) return 0
return v.funkSpeed
}
/** Snapshot of an instrument's funk-repeat XOR mask (one bit per loop-region byte; a set bit
* flips that byte by 0xFF during playback). Returns the mask bytes as ints (0..255), or an
* empty array when the instrument has never been funk-repeated. The render thread mutates the
* live mask, so this returns a copy — the caller gets a stable single-frame view. */
fun getInstrumentFunkMask(slot: Int): IntArray {
val mask = getFirstSnd()?.instruments?.get(slot and 0xFF)?.funkMask ?: return IntArray(0)
return IntArray(mask.size) { mask[it].toInt() and 0xFF }
}
/** Live noteVal (0..65535, 4096-TET) of the foreground voice — the value the mixer is using
* *right now* including any in-flight vibrato / arpeggio / portamento delta. Returns 0 for
* inactive voices. */