From c630dc369487c3b5c6f613d6a070b598f52d5e50 Mon Sep 17 00:00:00 2001 From: Reactorcoremeltdown Date: Thu, 16 Jul 2026 15:55:33 +0200 Subject: [PATCH] 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 --- app/src/main/cpp/native_audio.cpp | 18 +++--- .../sizzletracker/audio/AudioEngine.kt | 59 +++++++++++-------- .../sizzletracker/audio/Effects.kt | 51 ++++++++++++---- .../sizzletracker/audio/MasterRecorder.kt | 40 +++++++------ .../sizzletracker/model/Toolbox.kt | 2 + .../sizzletracker/ui/toolbox/DelayEditor.kt | 55 +++++++++++++++++ 6 files changed, 165 insertions(+), 60 deletions(-) diff --git a/app/src/main/cpp/native_audio.cpp b/app/src/main/cpp/native_audio.cpp index b2670f8..8f7590e 100644 --- a/app/src/main/cpp/native_audio.cpp +++ b/app/src/main/cpp/native_audio.cpp @@ -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(env->NewGlobalRef(env->NewFloatArray(g_frames))); + // Interleaved stereo: 2 samples per frame. + g_buffer = static_cast(env->NewGlobalRef(env->NewFloatArray(g_frames * 2))); if (!openAndStartStream()) { env->DeleteGlobalRef(g_buffer); diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt index 210cd29..b441909 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt @@ -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) } } diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Effects.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Effects.kt index ea2a877..c91ad0d 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Effects.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Effects.kt @@ -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" } } } diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/MasterRecorder.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/MasterRecorder.kt index 678acbf..a490c69 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/MasterRecorder.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/MasterRecorder.kt @@ -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()) } diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Toolbox.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Toolbox.kt index 00befb7..ea778f9 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Toolbox.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Toolbox.kt @@ -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)) } }, ), diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/DelayEditor.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/DelayEditor.kt index b5c4cf2..43ec0e9 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/DelayEditor.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/DelayEditor.kt @@ -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" } }