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:
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user