mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
Soundscope for tracker
This commit is contained in:
@@ -125,6 +125,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
internal val DBGPRN = false
|
internal val DBGPRN = false
|
||||||
const val SAMPLING_RATE = 32000
|
const val SAMPLING_RATE = 32000
|
||||||
const val TRACKER_CHUNK = 512
|
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"
|
// 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.
|
// ghosts displaced foreground voices into this pool; oldest is evicted on overflow.
|
||||||
const val MAX_BG_VOICES = 64
|
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 gvol = playhead.globalVolume / 255.0
|
||||||
val mvol = playhead.mixingVolume / 255.0
|
val mvol = playhead.mixingVolume / 255.0
|
||||||
for (voice in ts.voices) {
|
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 voiceInst = instruments[voice.instrumentId]
|
||||||
val s = applyTaudVoiceFx(voice, applyVoiceFilter(voice, fetchTrackerSample(voice, voiceInst, ts.interpolationMode)))
|
val s = applyTaudVoiceFx(voice, applyVoiceFilter(voice, fetchTrackerSample(voice, voiceInst, ts.interpolationMode)))
|
||||||
val instGv = voiceInst.instGlobalVolume / 255.0
|
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
|
val swingScale = 1.0 + voice.randomVolBias / 255.0
|
||||||
// Volume envelope is bypassed (treated as unity) when S $77 has disabled it.
|
// Volume envelope is bypassed (treated as unity) when S $77 has disabled it.
|
||||||
val effEnvVol = if (voice.volEnvOn) voice.envVolume else 1.0
|
val effEnvVol = if (voice.volEnvOn) voice.envVolume else 1.0
|
||||||
val vol = effEnvVol * voice.fadeoutVolume * (voice.rowVolume / 63.0) *
|
// Split the gain stack so the soundscope can see the voice amplitude independently
|
||||||
swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0
|
// 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 pan = if (voice.hasPanEnv && voice.panEnvOn) {
|
||||||
val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255)
|
val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255)
|
||||||
(voice.channelPan + envPanRaw - 128 + voice.randomPanBias).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
|
if (voice.rampOutSamples == 0) voice.active = false
|
||||||
g
|
g
|
||||||
} else 1.0
|
} 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
|
mixL += s * vol * lGain * rampGain
|
||||||
mixR += s * vol * rGain * 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.
|
// Effect-recall memory for this voice.
|
||||||
val mem = MemorySlots()
|
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 {
|
class TrackerState {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import java.util.BitSet
|
|||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.ln
|
||||||
import kotlin.math.roundToInt
|
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) {
|
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 scopeMode = IntArray(4)
|
||||||
private val scopeScrollHorz = IntArray(4)
|
private val scopeScrollHorz = IntArray(4)
|
||||||
|
private val SCOPE_MODE_COUNT = 5
|
||||||
|
|
||||||
override fun show() {
|
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 syTop = h - 7 - 115 * i - 8 * FONT.H
|
||||||
val syBot = h - 3 - 115 * i
|
val syBot = h - 3 - 115 * i
|
||||||
if (my in syTop..syBot) {
|
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
|
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 syTop = h - 7 - 115 * i - 8 * FONT.H
|
||||||
val syBot = h - 3 - 115 * i
|
val syBot = h - 3 - 115 * i
|
||||||
if (my in syTop..syBot) {
|
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
|
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 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()
|
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 VOX_PER_VIEW = arrayOf(6,20,20)
|
||||||
private val VOL_SYM = arrayOf('@','^','&',' ')
|
private val VOL_SYM = arrayOf('@','^','&',' ')
|
||||||
private val PAN_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 0: Detailed pattern with colour-coded fields ────────────
|
||||||
// ── Mode 1: Abridged pattern with colour-coded fields ────────────
|
// ── Mode 1: Abridged pattern with colour-coded fields ────────────
|
||||||
// ── Mode 2: Super-abridged pattern with colour-coded fields ────────────
|
// ── Mode 2: Super-abridged pattern with colour-coded fields ────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user