From 74cba0a893e71d5c1a33e39c61c2787c03a2075c Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sat, 9 May 2026 18:15:05 +0900 Subject: [PATCH] Soundscope for tracker --- .../torvald/tsvm/peripheral/AudioAdapter.kt | 32 +++- .../src/net/torvald/tsvm/AudioMenu.kt | 152 +++++++++++++++++- 2 files changed, 178 insertions(+), 6 deletions(-) diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 2d31801..0fe870f 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -125,6 +125,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { internal val DBGPRN = false const val SAMPLING_RATE = 32000 const val TRACKER_CHUNK = 512 + // Per-voice soundscope ring-buffer length. Power of two so wrap-around is a single AND. + // Sized at 2× the soundscope width so the AudioMenu waveform view always has spare + // samples on either side of the centre to search for a stable trigger point. + const val SCOPE_BUFFER_SIZE = 1024 // Mixer-private background-voice pool size per playhead. NNA "Continue/Note Off/Note Fade" // ghosts displaced foreground voices into this pool; oldest is evicted on overflow. const val MAX_BG_VOICES = 64 @@ -2928,7 +2932,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { val gvol = playhead.globalVolume / 255.0 val mvol = playhead.mixingVolume / 255.0 for (voice in ts.voices) { - if (!voice.active || voice.muted) continue + if (!voice.active || voice.muted) { + // Keep the soundscope flat between notes / while muted so the AudioMenu + // does not show stale waveform data once the voice goes silent. + voice.scopeBuffer[voice.scopeWritePos] = 0f + voice.scopeWritePos = (voice.scopeWritePos + 1) and (SCOPE_BUFFER_SIZE - 1) + continue + } val voiceInst = instruments[voice.instrumentId] val s = applyTaudVoiceFx(voice, applyVoiceFilter(voice, fetchTrackerSample(voice, voiceInst, ts.interpolationMode))) val instGv = voiceInst.instGlobalVolume / 255.0 @@ -2936,8 +2946,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { val swingScale = 1.0 + voice.randomVolBias / 255.0 // Volume envelope is bypassed (treated as unity) when S $77 has disabled it. val effEnvVol = if (voice.volEnvOn) voice.envVolume else 1.0 - val vol = effEnvVol * voice.fadeoutVolume * (voice.rowVolume / 63.0) * - swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0 + // Split the gain stack so the soundscope can see the voice amplitude independently + // of the playhead-wide faders (master / mixing / global volume). + val perVoiceGain = effEnvVol * voice.fadeoutVolume * (voice.rowVolume / 63.0) * + swingScale * instGv + val globalGain = gvol * mvol * playhead.masterVolume / 255.0 + val vol = perVoiceGain * globalGain val pan = if (voice.hasPanEnv && voice.panEnvOn) { val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255) (voice.channelPan + envPanRaw - 128 + voice.randomPanBias).coerceIn(0, 255) @@ -2953,6 +2967,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { if (voice.rampOutSamples == 0) voice.active = false g } else 1.0 + // Per-voice soundscope capture — the voice's actual mono contribution before pan + // and before the playhead-global faders. Includes envelope, fadeout, tremolo, + // sample-end ramp-out and channel volume so the AudioMenu shows what the voice is + // really doing, not the raw instrument sample. + voice.scopeBuffer[voice.scopeWritePos] = (s * perVoiceGain * rampGain).toFloat() + voice.scopeWritePos = (voice.scopeWritePos + 1) and (SCOPE_BUFFER_SIZE - 1) mixL += s * vol * lGain * rampGain mixR += s * vol * rGain * rampGain } @@ -3386,6 +3406,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { // Effect-recall memory for this voice. val mem = MemorySlots() + + // AudioMenu soundscope ring buffer. Holds the most recent post-FX, pre-pan voice + // sample values for visualisation only — not consumed by the mixer. Size is a + // power of two so the write-position wrap is a simple AND. + val scopeBuffer = FloatArray(SCOPE_BUFFER_SIZE) + var scopeWritePos = 0 } class TrackerState { diff --git a/tsvm_executable/src/net/torvald/tsvm/AudioMenu.kt b/tsvm_executable/src/net/torvald/tsvm/AudioMenu.kt index 462ddc5..79344b4 100644 --- a/tsvm_executable/src/net/torvald/tsvm/AudioMenu.kt +++ b/tsvm_executable/src/net/torvald/tsvm/AudioMenu.kt @@ -18,6 +18,7 @@ import java.util.BitSet import kotlin.math.abs import kotlin.math.ceil import kotlin.math.floor +import kotlin.math.ln import kotlin.math.roundToInt /** @@ -25,9 +26,11 @@ import kotlin.math.roundToInt */ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMenu(parent, x, y, w, h) { - // Per-playhead view mode: 0=detailed pattern, 1=abridged pattern (stub), 2=super-abridged (stub), 3=cuesheet detail + // Per-playhead view mode: 0=detailed pattern, 1=abridged pattern (stub), 2=super-abridged (stub), + // 3=cuesheet detail, 4=per-voice waveform private val scopeMode = IntArray(4) private val scopeScrollHorz = IntArray(4) + private val SCOPE_MODE_COUNT = 5 override fun show() { } @@ -50,7 +53,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe val syTop = h - 7 - 115 * i - 8 * FONT.H val syBot = h - 3 - 115 * i if (my in syTop..syBot) { - scopeMode[3 - i] = (scopeMode[3 - i] + 1) and 3 + scopeMode[3 - i] = (scopeMode[3 - i] + 1) % SCOPE_MODE_COUNT break } } @@ -72,7 +75,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe val syTop = h - 7 - 115 * i - 8 * FONT.H val syBot = h - 3 - 115 * i if (my in syTop..syBot) { - scopeMode[3 - i] = (scopeMode[3 - i] - 1) and 3 + scopeMode[3 - i] = (scopeMode[3 - i] + SCOPE_MODE_COUNT - 1) % SCOPE_MODE_COUNT break } } @@ -261,6 +264,64 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe private fun bipolarCeil(d: Double) = (if (d >= 0.0) ceil(d) else floor(d)).toInt() private fun bipolarFloor(d: Double) = (if (d >= 0.0) floor(d) else ceil(d)).toInt() + /** + * Find the most-recent rising-edge zero crossing in [buf] that has at least + * [cellW]/2 samples of context on either side, and return its position as a + * sub-sample-accurate "age" (samples since the oldest sample at [writePos]). + * Returns -1.0 if no usable crossing exists — the caller should then fall back + * to a free-running display. + */ + private fun findTriggerAge(buf: FloatArray, writePos: Int, cellW: Int): Double { + val bufSize = buf.size + val mask = bufSize - 1 + val halfW = cellW / 2 + val maxAge = bufSize - halfW // exclusive: rightmost trigger that still has cellW/2 right-side samples + val minAge = halfW // inclusive: leftmost trigger that still has cellW/2 left-side samples + if (maxAge - 1 <= minAge) return -1.0 // cell is too wide vs the buffer + + // Walk newest → oldest within the search window. The most-recent crossing gives + // the freshest snapshot on the right of the trigger, so the eye sees the least lag. + var newer = buf[(writePos + maxAge - 1) and mask] + for (age in maxAge - 2 downTo minAge) { + val older = buf[(writePos + age) and mask] + if (older < 0f && newer >= 0f) { + // Linear interpolation between the two bracketing samples. + val denom = (newer - older) + val frac = if (denom > 1e-9f) (-older) / denom else 0f + return age + frac.toDouble() + } + newer = older + } + return -1.0 + } + + /** + * Pick a cols × rows grid for `n` waveform cells inside an `areaW × areaH` box. + * Optimises for cell aspect close to [targetAspect] (in log-space, so 6:1 and 1.5:1 + * are penalised equally relative to 3:1) and lightly penalises wasted cells. Wide + * scope areas naturally get more columns than rows; tall ones flip the other way. + */ + private fun pickWaveformGrid(n: Int, areaW: Int, areaH: Int): IntArray { + val targetAspect = 3.0 + val wastePenalty = 0.3 + var bestCols = 1 + var bestRows = n + var bestScore = Double.POSITIVE_INFINITY + for (cols in 1..n) { + val rows = (n + cols - 1) / cols + val cellW = areaW.toDouble() / cols + val cellH = areaH.toDouble() / rows + val aspect = cellW / cellH + val score = abs(ln(aspect / targetAspect)) + wastePenalty * (cols * rows - n) + if (score < bestScore) { + bestScore = score + bestCols = cols + bestRows = rows + } + } + return intArrayOf(bestCols, bestRows) + } + private val VOX_PER_VIEW = arrayOf(6,20,20) private val VOL_SYM = arrayOf('@','^','&',' ') private val PAN_SYM = arrayOf('@','<','>',' ') @@ -376,6 +437,91 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe } } + // ── Mode 4: Per-voice waveform ─────────────────────────────────── + // Tile one waveform cell per "currently used" voice (cue-sheet + // pattern number != 0xFFF). The soundscope area is wide and short, + // so a cols × rows grid uses the space far better than a vertical + // stack — pickWaveformGrid() picks a layout that keeps cells roughly + // 3:1 wide while minimising empty slots. + 4 -> { + val cuePats = IntArray(20) { vi -> readCuePat12(audio, cuePos, vi) } + val activeVoiceIndices = (0 until 20).filter { cuePats[it] != 0xFFF } + if (activeVoiceIndices.isEmpty()) { + batch.color = COL_SOUNDSCOPE_FORE + FONT.draw(batch, "No active voices", x, y + 4) + } else { + val scopeH = 8 * FONT.H + 4 + val scopeW = 512 + val n = activeVoiceIndices.size + val grid = pickWaveformGrid(n, scopeW, scopeH) + val cols = grid[0] + val rows = grid[1] + val cellW = scopeW / cols + val cellH = scopeH / rows + val halfH = ((cellH - 2) / 2).coerceAtLeast(1) + val voices = ts.voices + val drawLabel = cellH >= TINY.H + 1 && cellW >= 12 + + // Faint grid separators between cells. + batch.color = COL_TRACKER_ROW + for (r in 1 until rows) batch.fillRect(x, y + r * cellH, scopeW, 1) + for (c in 1 until cols) batch.fillRect(x + c * cellW, y, 1, scopeH) + + for ((slot, vi) in activeVoiceIndices.withIndex()) { + val voice = voices.getOrNull(vi) ?: continue + val col = slot % cols + val row = slot / cols + val cellX = x + col * cellW + val cellY = y + row * cellH + val centerY = cellY + cellH / 2 + + // baseline + batch.color = COL_TRACKER_ROW + batch.fillRect(cellX, centerY, cellW, 1) + + // waveform — anchor the cell centre on the most recent + // sub-sample-accurate rising-edge zero crossing so that + // periodic signals appear stationary (oscilloscope trigger). + // Falls back to a free-running, oldest→newest sweep when no + // usable trigger is found (e.g. silent voice or sub-sub-Hz tone). + batch.color = COL_VOICE_PALETTE[vi % COL_VOICE_PALETTE.size] + val buf = voice.scopeBuffer + val bufSize = buf.size + val mask = bufSize - 1 + val writePos = voice.scopeWritePos + val centerCol = cellW / 2 + val triggerAge = findTriggerAge(buf, writePos, cellW) + val freeRunStep = (bufSize - 1).toDouble() / (cellW - 1).coerceAtLeast(1) + for (sx in 0 until cellW) { + val readAge = if (triggerAge >= 0.0) + triggerAge + (sx - centerCol).toDouble() + else + sx * freeRunStep + val baseAge = floor(readAge).toInt() + val frac = (readAge - baseAge).toFloat() + val a = buf[(writePos + baseAge) and mask] + val b = buf[(writePos + baseAge + 1) and mask] + val v = ((1f - frac) * a + frac * b).coerceIn(-1f, 1f) + val h = (v * halfH).roundToInt() + if (h == 0) { + batch.fillRect(cellX + sx, centerY, 1, 1) + } else if (h > 0) { + batch.fillRect(cellX + sx, centerY, 1, h) + } else { + batch.fillRect(cellX + sx, centerY + h, 1, -h) + } + } + + // voice index label (top-left of cell), only when there is room + if (drawLabel) { + batch.color = COL_VOICE_PALETTE[vi % COL_VOICE_PALETTE.size] + TINY.draw(batch, vi.toString(16).padStart(2, '0').uppercase(), + cellX + 1, cellY) + } + } + } + } + // ── Mode 0: Detailed pattern with colour-coded fields ──────────── // ── Mode 1: Abridged pattern with colour-coded fields ──────────── // ── Mode 2: Super-abridged pattern with colour-coded fields ────────────