Make playback and recording stereo; add per-head delay balance

- fillBlock now outputs interleaved stereo; AudioTrack and native Oboe
  configured for 2 channels; MasterRecorder writes a stereo WAV.
- AudioEffect gains processStereo() (default mono-collapse); the tape delay
  overrides it to pan each of its 4 heads with a level-preserving linear pan
  (feedback still uses the mono sum, so loop behaviour is unchanged).
- New per-head balance param + a horizontal balance slider under each head's
  division fader (center-detented, L/C/R readout), with a revision read so it
  redraws.

Note: only the delay produces stereo width; other insert effects stay mono
and collapse upstream stereo, so place the delay last to keep its panning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-16 15:55:33 +02:00
parent 3a752b88ab
commit c630dc3694
6 changed files with 165 additions and 60 deletions

View File

@@ -75,16 +75,17 @@ public:
JNIEnv *env = callbackEnv();
if (!env || !g_callback || !g_renderMethod || !g_buffer) {
for (int i = 0; i < numFrames; ++i) out[i] = 0.0f;
for (int i = 0; i < numFrames * 2; ++i) out[i] = 0.0f; // stereo silence
return oboe::DataCallbackResult::Continue;
}
const int n = numFrames < g_frames ? numFrames : g_frames;
// Ask Kotlin to render n frames into the reused Java buffer ...
// Ask Kotlin to render n frames; fillBlock writes 2*n interleaved-stereo
// samples into the reused Java buffer ...
env->CallVoidMethod(g_callback, g_renderMethod, g_buffer, n);
// ... then copy them out to Oboe (and pad any remainder with silence — the
// Java buffer is sized to the stream capacity, so this should not trigger).
env->GetFloatArrayRegion(g_buffer, 0, n, out);
for (int i = n; i < numFrames; ++i) out[i] = 0.0f;
// ... then copy them out to Oboe (2 samples/frame) and pad any remainder with
// silence (the Java buffer is sized to the stream capacity, so rare).
env->GetFloatArrayRegion(g_buffer, 0, n * 2, out);
for (int i = n * 2; i < numFrames * 2; ++i) out[i] = 0.0f;
// Adaptive buffer sizing (timing path): a callback that renders slower than
// its deadline will drain the buffer; a couple in a row means we're about to
@@ -177,7 +178,7 @@ bool openAndStartStream() {
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
->setSharingMode(oboe::SharingMode::Shared)
->setFormat(oboe::AudioFormat::Float)
->setChannelCount(oboe::ChannelCount::Mono)
->setChannelCount(oboe::ChannelCount::Stereo)
->setSampleRate(g_sampleRate)
->setDataCallback(&g_dataCallback)
->setErrorCallback(&g_errorCallback);
@@ -225,7 +226,8 @@ Java_com_reactorcoremeltdown_sizzletracker_audio_NativeAudioBridge_nativeStart(
// min(numFrames, g_frames) frames, and low-latency bursts are far smaller.
g_frames = kMaxBufferFrames;
if (g_frames < framesPerCallback) g_frames = framesPerCallback;
g_buffer = static_cast<jfloatArray>(env->NewGlobalRef(env->NewFloatArray(g_frames)));
// Interleaved stereo: 2 samples per frame.
g_buffer = static_cast<jfloatArray>(env->NewGlobalRef(env->NewFloatArray(g_frames * 2)));
if (!openAndStartStream()) {
env->DeleteGlobalRef(g_buffer);

View File

@@ -218,14 +218,14 @@ class AudioEngine(
* it elapses, then finalizes the file. Voices and insert FX render every sample
* regardless of [playing], so the tail contains real decaying reverb/delay audio.
*/
private fun recordSample(proj: Project?, sample: Float) {
private fun recordSample(proj: Project?, left: Float, right: Float) {
val nowPlaying = playing
if (recWasPlaying && !nowPlaying) {
recTailRemaining = if (proj == null) 0
else (recTailBars * proj.timeSignature.linesPerBar * samplesPerLine(proj)).toInt()
}
recWasPlaying = nowPlaying
masterRecorder.write(sample)
masterRecorder.write(left, right)
if (!nowPlaying) {
if (recTailRemaining <= 0) {
recCapturing = false
@@ -346,8 +346,8 @@ class AudioEngine(
private fun startAudioTrack() {
val minBuf = AudioTrack.getMinBufferSize(
sampleRate, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_FLOAT,
).coerceAtLeast(BLOCK_FRAMES * 4)
sampleRate, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_FLOAT,
).coerceAtLeast(BLOCK_FRAMES * 2 * 4 * 2) // stereo float, a couple of blocks
track = AudioTrack.Builder()
.setAudioAttributes(
@@ -360,7 +360,7 @@ class AudioEngine(
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
.build(),
)
.setBufferSizeInBytes(minBuf)
@@ -462,19 +462,25 @@ class AudioEngine(
// ------------------------------------------------------------- render loop
/** The Kotlin AudioTrack fallback loop: fill a block and push it. */
private fun audioTrackLoop() {
val block = FloatArray(BLOCK_FRAMES)
val block = FloatArray(BLOCK_FRAMES * 2) // interleaved stereo (L,R per frame)
while (running) {
val t = track ?: break
fillBlock(block, BLOCK_FRAMES)
t.write(block, 0, BLOCK_FRAMES, AudioTrack.WRITE_BLOCKING)
t.write(block, 0, BLOCK_FRAMES * 2, AudioTrack.WRITE_BLOCKING)
}
}
/** Per-channel FX stereo scratch (audio-thread only): io[0]=L, io[1]=R. */
private val fxIo = FloatArray(2)
/**
* Render [frames] mono samples of the whole mix into [out]. This is the single
* source of truth for audio: it is called by the Kotlin AudioTrack loop AND by
* the native Oboe callback (via [NativeAudioBridge]), so both output backends
* produce identical sound. Runs on an audio thread — must not allocate.
* Render [frames] frames of the whole mix into [out] as INTERLEAVED STEREO —
* out[2*i] = left, out[2*i+1] = right, so [out] must hold at least frames*2
* samples. This is the single source of truth for audio: it is called by the
* Kotlin AudioTrack loop AND by the native Oboe callback (via [NativeAudioBridge]),
* so both backends produce identical sound. Runs on an audio thread — must not
* allocate. Voices are mono and feed a mono per-channel sum; stereo width comes
* from stereo-capable insert effects (the tape delay's per-head panning).
*/
fun fillBlock(out: FloatArray, frames: Int) {
val proj = project
@@ -491,11 +497,13 @@ class AudioEngine(
// Snapshot the volatile chains once so a concurrent rebuild can't swap them
// mid-block; indexing + array iteration below then allocate nothing.
val chains = channelChains
val io = fxIo
for (i in 0 until frames) {
if (playing && proj != null) advanceSequencer(proj)
var mix = 0f
var mixL = 0f
var mixR = 0f
if (proj != null) {
channelSum.fill(0f)
// Pools are laid out bus*POLYPHONY + i, so the bus is vi / POLYPHONY.
@@ -509,22 +517,27 @@ class AudioEngine(
}
for (ch in 0 until Pattern.TRACK_COUNT) {
val mc = proj.mixer.channels[ch]
var s = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
val mono = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
io[0] = mono; io[1] = mono
val chain = chains[ch]
for (u in chain) s = u.effect.process(s)
for (u in chain) u.effect.processStereo(io)
// Isolate a misbehaving effect: a non-finite sample from one
// channel's FX must not poison the whole master sum (which would
// silence everything until restart).
if (!s.isFinite()) s = 0f
// channel's FX must not poison the whole master sum.
var l = io[0]; var r = io[1]
if (!l.isFinite()) l = 0f
if (!r.isFinite()) r = 0f
// Optional per-bus soft-clip limiter (toolbar toggle).
if (mc.limiter) s = softClip(s)
mix += s
if (mc.limiter) { l = softClip(l); r = softClip(r) }
mixL += l; mixR += r
}
}
for (lv in liveVoices) mix += lv.render() * LIVE_GAIN
for (av in auditionSampleVoices) mix += av.render() * LIVE_GAIN
out[i] = softClip(mix * MASTER_GAIN)
if (recCapturing) recordSample(proj, out[i])
for (lv in liveVoices) { val v = lv.render() * LIVE_GAIN; mixL += v; mixR += v }
for (av in auditionSampleVoices) { val v = av.render() * LIVE_GAIN; mixL += v; mixR += v }
val oL = softClip(mixL * MASTER_GAIN)
val oR = softClip(mixR * MASTER_GAIN)
out[2 * i] = oL
out[2 * i + 1] = oR
if (recCapturing) recordSample(proj, oL, oR)
}
}

View File

@@ -29,6 +29,20 @@ interface AudioEffect {
/** Process one mono sample. */
fun process(x: Float): Float
/**
* Process one STEREO frame in place ([io] is a 2-element scratch: io[0]=L,
* io[1]=R). The default runs the mono [process] on the average of the two
* channels and writes it to both — so a mono effect passes audio through as
* mono, collapsing any upstream stereo width. Only effects that genuinely
* produce width (the tape delay's per-head panning) override this, so to keep
* a delay's stereo place it last in the chain.
*/
fun processStereo(io: FloatArray) {
val m = process((io[0] + io[1]) * 0.5f)
io[0] = m
io[1] = m
}
companion object {
fun create(type: ToolboxType, sampleRate: Int): AudioEffect? = when (type) {
ToolboxType.DELAY -> TapeDelay(sampleRate)
@@ -55,6 +69,8 @@ private class TapeDelay(private val sampleRate: Int) : AudioEffect {
private var writeIndex = 0
private val delaySamples = IntArray(DelayDivisions.HEADS) { sampleRate / 2 }
private val headOn = BooleanArray(DelayDivisions.HEADS)
private val balance = FloatArray(DelayDivisions.HEADS) // -1 = full left, +1 = full right
private val stereoScratch = FloatArray(2) // for the mono process() fallback
private var feedback = 0.4f
private var dryWet = 0.35f
// Tape character:
@@ -74,6 +90,7 @@ private class TapeDelay(private val sampleRate: Int) : AudioEffect {
val idx = slot.float(HEAD_DIV_KEYS[h], DelayDivisions.DEFAULT.toFloat()).toInt()
delaySamples[h] = (DelayDivisions.beatFraction(idx) * samplesPerBeat)
.toInt().coerceIn(1, buffer.size - 2)
balance[h] = slot.float(HEAD_BAL_KEYS[h], 0f).coerceIn(-1f, 1f)
}
feedback = slot.float("feedback", 0.4f).coerceIn(0f, DelayDivisions.MAX_FEEDBACK)
dryWet = slot.float("drywet", 0.35f).coerceIn(0f, 1f)
@@ -84,15 +101,24 @@ private class TapeDelay(private val sampleRate: Int) : AudioEffect {
flutterDepth = tape * sampleRate * 0.0015f // up to ~1.5 ms wow/flutter
}
// Mono path delegates to the stereo one (the chain always calls processStereo).
override fun process(x: Float): Float {
stereoScratch[0] = x; stereoScratch[1] = x
processStereo(stereoScratch)
return (stereoScratch[0] + stereoScratch[1]) * 0.5f
}
override fun processStereo(io: FloatArray) {
val x = (io[0] + io[1]) * 0.5f
flutterPhase += flutterInc
if (flutterPhase > 2 * PI) flutterPhase -= 2 * PI
val flutter = sin(flutterPhase).toFloat() * flutterDepth
// Average the active heads (fractional read + linear interpolation for the
// flutter offset). Averaging keeps the level — and the feedback loop gain —
// independent of how many heads are switched on.
var wet = 0f
// Read each active head (fractional read + linear interpolation for flutter)
// and pan it by its balance. Linear pan (gainL+gainR == 1) keeps the summed
// level — and the feedback loop gain — independent of head count and pan.
var wetL = 0f
var wetR = 0f
var active = 0
for (h in 0 until DelayDivisions.HEADS) {
if (!headOn[h]) continue
@@ -102,26 +128,31 @@ private class TapeDelay(private val sampleRate: Int) : AudioEffect {
val i0 = pos.toInt() % buffer.size
val i1 = (i0 + 1) % buffer.size
val frac = pos - pos.toInt()
wet += buffer[i0] + (buffer[i1] - buffer[i0]) * frac
val s = buffer[i0] + (buffer[i1] - buffer[i0]) * frac
val b = balance[h]
wetL += s * (1f - b) * 0.5f
wetR += s * (1f + b) * 0.5f
}
if (active > 0) wet /= active
if (active > 0) { wetL /= active; wetR /= active }
// Feedback path: soft saturation blended in so the small-signal gain stays 1,
// then tape darkening (a DC-unity one-pole low-pass). Neither raises loop gain.
var fb = wet * feedback
// Feedback uses the mono wet (wetL + wetR == the pre-pan head average), so the
// loop behaves exactly as the mono delay did regardless of panning.
var fb = (wetL + wetR) * feedback
fb += satBlend * (tanh(fb) - fb)
lpState += lpAlpha * (fb - lpState)
fb = lpState
buffer[writeIndex] = x + fb
writeIndex = (writeIndex + 1) % buffer.size
return x * (1f - dryWet) + wet * dryWet
io[0] = x * (1f - dryWet) + wetL * dryWet
io[1] = x * (1f - dryWet) + wetR * dryWet
}
private companion object {
const val FLUTTER_HZ = 5.0
val HEAD_ON_KEYS = Array(DelayDivisions.HEADS) { "head${it}On" }
val HEAD_DIV_KEYS = Array(DelayDivisions.HEADS) { "head${it}Div" }
val HEAD_BAL_KEYS = Array(DelayDivisions.HEADS) { "head${it}Bal" }
}
}

View File

@@ -26,7 +26,8 @@ import kotlin.concurrent.thread
*/
class MasterRecorder(private val sampleRate: Int) {
private val ring = FloatArray(sampleRate.coerceAtLeast(1)) // ~1 s of headroom
// Interleaved stereo (L,R,L,R…); ~1 s of headroom. Positions count samples.
private val ring = FloatArray((sampleRate * 2).coerceAtLeast(2))
@Volatile private var writePos = 0L // samples produced by the audio thread
@Volatile private var readPos = 0L // samples consumed by the writer thread
@Volatile private var capturing = false
@@ -56,14 +57,15 @@ class MasterRecorder(private val sampleRate: Int) {
return true
}
/** Audio thread: append one sample. Never allocates; drops if the ring is full
* (only possible if the writer thread starved, which shouldn't happen). */
fun write(sample: Float) {
/** Audio thread: append one stereo frame (L,R). Never allocates; drops the frame
* if the ring is full (only possible if the writer thread starved). */
fun write(left: Float, right: Float) {
if (!capturing) return
val w = writePos
if (w - readPos >= ring.size) return
ring[(w % ring.size).toInt()] = sample
writePos = w + 1
if (w - readPos > ring.size - 2) return
ring[(w % ring.size).toInt()] = left
ring[((w + 1) % ring.size).toInt()] = right
writePos = w + 2
}
/** Audio thread: stop accepting samples; the writer drains the rest and finalizes. */
@@ -74,7 +76,7 @@ class MasterRecorder(private val sampleRate: Int) {
try {
raf.setLength(0)
writeHeader(raf, 0) // placeholder sizes; patched at the end
var frames = 0L
var samples = 0L // total interleaved 16-bit samples written
val chunk = 4096
val bytes = ByteArray(chunk * 2)
while (true) {
@@ -89,7 +91,7 @@ class MasterRecorder(private val sampleRate: Int) {
bytes[i * 2 + 1] = ((v shr 8) and 0xFF).toByte()
}
raf.write(bytes, 0, n * 2)
frames += n
samples += n
readPos = r + n
} else if (!capturing) {
break // capture ended and the ring is fully drained
@@ -97,28 +99,28 @@ class MasterRecorder(private val sampleRate: Int) {
Thread.sleep(5) // wait for the audio thread to produce more
}
}
writeHeader(raf, frames) // rewrite the 44-byte header with the real sizes
writeHeader(raf, samples) // rewrite the 44-byte header with the real sizes
} finally {
raf.close()
}
return temp
}
// ---- WAV (mono, 16-bit PCM) header ----
private fun writeHeader(raf: RandomAccessFile, frames: Long) {
val dataSize = frames * 2 // 2 bytes/sample, mono
// ---- WAV (stereo, 16-bit PCM) header. [sampleCount] = total interleaved samples. ----
private fun writeHeader(raf: RandomAccessFile, sampleCount: Long) {
val dataSize = sampleCount * 2 // 2 bytes per sample
raf.seek(0)
raf.writeBytes("RIFF")
writeIntLE(raf, (36 + dataSize).toInt())
raf.writeBytes("WAVE")
raf.writeBytes("fmt ")
writeIntLE(raf, 16) // PCM fmt chunk size
writeShortLE(raf, 1) // format = PCM
writeShortLE(raf, 1) // channels = mono
writeIntLE(raf, 16) // PCM fmt chunk size
writeShortLE(raf, 1) // format = PCM
writeShortLE(raf, 2) // channels = stereo
writeIntLE(raf, sampleRate)
writeIntLE(raf, sampleRate * 2) // byte rate = sampleRate * blockAlign
writeShortLE(raf, 2) // block align = channels * bytesPerSample
writeShortLE(raf, 16) // bits per sample
writeIntLE(raf, sampleRate * 4) // byte rate = sampleRate * channels * bytesPerSample
writeShortLE(raf, 4) // block align = channels * bytesPerSample
writeShortLE(raf, 16) // bits per sample
raf.writeBytes("data")
writeIntLE(raf, dataSize.toInt())
}

View File

@@ -118,6 +118,8 @@ enum class ToolboxType(
add(ParamSpec("head${h}On", "Head ${h + 1} On", if (h == 0) 1f else 0f, 0f, 1f))
add(ParamSpec("head${h}Div", "Head ${h + 1} Div",
headDefaults[h].toFloat(), 0f, (DelayDivisions.size - 1).toFloat()))
// Per-head stereo balance: -1 = full left, 0 = centre, +1 = full right.
add(ParamSpec("head${h}Bal", "Head ${h + 1} Bal", 0f, -1f, 1f))
}
},
),

View File

@@ -135,5 +135,60 @@ private fun DelayHead(vm: AppViewModel, slot: ToolboxSlot, head: Int, rev: Int,
color = if (on) c.text else c.textDim,
fontFamily = FontFamily.Monospace, fontSize = 10.sp,
)
// Horizontal stereo balance for this head: left … centre … right.
HeadBalance(vm, slot, head, on)
}
}
/** A compact horizontal balance (pan) slider for one tape head. Drag/tap to set;
* snaps to centre near the middle. */
@Composable
private fun HeadBalance(vm: AppViewModel, slot: ToolboxSlot, head: Int, on: Boolean) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so the thumb/label refresh
val bal = slot.float("head${head}Bal", 0f).coerceIn(-1f, 1f)
fun setBal(x: Float, w: Float) {
var b = (x / w * 2f - 1f).coerceIn(-1f, 1f)
if (kotlin.math.abs(b) < 0.06f) b = 0f // centre detent
slot.set("head${head}Bal", b); vm.bumpForToolbar()
}
Canvas(
Modifier
.fillMaxWidth()
.height(16.dp)
.background(c.surface)
.border(1.dp, if (on) c.accent else c.grid, RectangleShape)
.pointerInput(head, slot.index) {
detectTapGestures { off -> setBal(off.x, size.width.toFloat()) }
}
.pointerInput(head, slot.index) {
detectDragGestures { change, _ -> change.consume(); setBal(change.position.x, size.width.toFloat()) }
},
) {
val w = size.width
val h = size.height
drawLine(c.grid, Offset(w / 2f, 0f), Offset(w / 2f, h), strokeWidth = 1f) // centre tick
val tx = ((bal + 1f) / 2f) * w
drawRect(
if (on) c.accent else c.textDim,
Offset((tx - 2f).coerceIn(0f, w - 4f), 0f), Size(4f, h),
)
}
Text(
balanceLabel(bal),
color = if (on) c.text else c.textDim,
fontFamily = FontFamily.Monospace, fontSize = 9.sp,
)
}
private fun balanceLabel(bal: Float): String {
val pct = kotlin.math.round(bal * 100f).toInt()
return when {
pct == 0 -> "C"
pct < 0 -> "L${-pct}"
else -> "R$pct"
}
}