From b2fedcfd4ace6d94d4e6eef6b937c135b11a3d86 Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 9 Mar 2026 11:02:36 +0000 Subject: [PATCH] fix: eliminate audio distortion and sync animation to audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BeepGenerator: add 5ms attack / 10ms release envelope so the waveform ramps in/out cleanly instead of cutting off mid-cycle, which caused the audible click/distortion. AudioEngine: replace postDelayed scheduling with a continuous write loop — each iteration writes the full beep followed by silence in 10ms chunks. This keeps the AudioTrack stream full and eliminates underrun noise. onBeep callback fires on the main thread just before each beep is queued. PacerService: wire onBeep to increment beepTick (Compose state). PacerScreen: replace the independent BPM timer with beepTick so the pulse ring animation is driven by the actual audio callback, eliminating the drift at slow paces. --- .../kotlin/net/jeena/pacer/AudioEngine.kt | 49 +++++++++++++------ .../kotlin/net/jeena/pacer/BeepGenerator.kt | 12 ++++- .../kotlin/net/jeena/pacer/PacerService.kt | 8 +++ .../kotlin/net/jeena/pacer/ui/PacerScreen.kt | 21 ++++---- 4 files changed, 63 insertions(+), 27 deletions(-) diff --git a/app/src/main/kotlin/net/jeena/pacer/AudioEngine.kt b/app/src/main/kotlin/net/jeena/pacer/AudioEngine.kt index 154c779..7017918 100644 --- a/app/src/main/kotlin/net/jeena/pacer/AudioEngine.kt +++ b/app/src/main/kotlin/net/jeena/pacer/AudioEngine.kt @@ -5,19 +5,30 @@ import android.media.AudioFormat import android.media.AudioTrack import android.os.Handler import android.os.HandlerThread +import android.os.Looper /** * Plays 880 Hz beeps at a given BPM using AudioTrack. * * Audio focus is intentionally never requested so beeps play alongside * other apps (e.g. AntennaPod) without ducking or interruption (US-05). + * + * Each iteration writes a full interval buffer (beep + silence) to the + * AudioTrack in one shot, keeping the stream continuous and glitch-free. + * The [onBeep] callback fires on the main thread just before each beep. */ class AudioEngine { companion object { private const val BEEP_DURATION_MS = 40 + private const val SILENCE_CHUNK_MS = 10 } + private val mainHandler = Handler(Looper.getMainLooper()) + + /** Called on the main thread immediately before each beep is queued. */ + var onBeep: (() -> Unit)? = null + private var handlerThread: HandlerThread? = null private var handler: Handler? = null private var audioTrack: AudioTrack? = null @@ -42,7 +53,6 @@ class AudioEngine { audioTrack = AudioTrack.Builder() .setAudioAttributes( - // USAGE_MEDIA with no requestAudioFocus call = plays alongside other audio (US-05) AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) @@ -64,8 +74,7 @@ class AudioEngine { handlerThread = HandlerThread("PacerAudio").also { it.start() } handler = Handler(handlerThread!!.looper) - - scheduleBeep() + handler?.post(::audioLoop) } fun stop() { @@ -88,18 +97,28 @@ class AudioEngine { audioTrack?.setVolume(currentVolume) } - private fun scheduleBeep() { - handler?.post(object : Runnable { - override fun run() { - if (!playing) return - playBeep() - handler?.postDelayed(this, BpmCalculator.intervalMs(currentBpm)) - } - }) - } + private fun audioLoop() { + val silenceChunk = ShortArray(BeepGenerator.SAMPLE_RATE * SILENCE_CHUNK_MS / 1000) - private fun playBeep() { - val samples = BeepGenerator.generateBeep(BEEP_DURATION_MS) - audioTrack?.write(samples, 0, samples.size) + while (playing) { + val bpm = currentBpm + val intervalMs = BpmCalculator.intervalMs(bpm).toInt() + + // Notify UI before writing so animation fires in sync with audio + mainHandler.post { onBeep?.invoke() } + + // Write beep + val beep = BeepGenerator.generateBeep(BEEP_DURATION_MS) + audioTrack?.write(beep, 0, beep.size) + + // Write silence for the rest of the interval in small chunks so + // we can respond to stop() quickly + var silenceRemaining = BeepGenerator.SAMPLE_RATE * (intervalMs - BEEP_DURATION_MS) / 1000 + while (silenceRemaining > 0 && playing) { + val toWrite = minOf(silenceChunk.size, silenceRemaining) + audioTrack?.write(silenceChunk, 0, toWrite) + silenceRemaining -= toWrite + } + } } } diff --git a/app/src/main/kotlin/net/jeena/pacer/BeepGenerator.kt b/app/src/main/kotlin/net/jeena/pacer/BeepGenerator.kt index 311bc84..ffb4232 100644 --- a/app/src/main/kotlin/net/jeena/pacer/BeepGenerator.kt +++ b/app/src/main/kotlin/net/jeena/pacer/BeepGenerator.kt @@ -8,12 +8,22 @@ object BeepGenerator { const val SAMPLE_RATE = 44100 const val FREQUENCY = 880.0 + private const val ATTACK_MS = 5 + private const val RELEASE_MS = 10 + fun generateBeep(durationMs: Int): ShortArray { val numSamples = SAMPLE_RATE * durationMs / 1000 + val attackSamples = SAMPLE_RATE * ATTACK_MS / 1000 + val releaseSamples = SAMPLE_RATE * RELEASE_MS / 1000 val samples = ShortArray(numSamples) for (i in 0 until numSamples) { val angle = 2.0 * PI * FREQUENCY * i / SAMPLE_RATE - samples[i] = (sin(angle) * Short.MAX_VALUE).toInt().toShort() + val envelope = when { + i < attackSamples -> i.toDouble() / attackSamples + i >= numSamples - releaseSamples -> (numSamples - i).toDouble() / releaseSamples + else -> 1.0 + } + samples[i] = (sin(angle) * Short.MAX_VALUE * envelope).toInt().toShort() } return samples } diff --git a/app/src/main/kotlin/net/jeena/pacer/PacerService.kt b/app/src/main/kotlin/net/jeena/pacer/PacerService.kt index 59e8459..3222327 100644 --- a/app/src/main/kotlin/net/jeena/pacer/PacerService.kt +++ b/app/src/main/kotlin/net/jeena/pacer/PacerService.kt @@ -10,6 +10,9 @@ import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import android.os.PowerManager +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.setValue import androidx.core.app.NotificationCompat class PacerService : Service() { @@ -28,6 +31,10 @@ class PacerService : Service() { var isRunning = false private set + // Incremented on the main thread before each beep — observed by PacerScreen + var beepTick by mutableLongStateOf(0L) + private set + fun startIntent(context: Context, bpm: Int, volume: Float) = Intent(context, PacerService::class.java).apply { action = ACTION_START @@ -87,6 +94,7 @@ class PacerService : Service() { startForeground(NOTIFICATION_ID, notification) } acquireWakeLock() + audioEngine.onBeep = { beepTick++ } audioEngine.start(bpm, volume) } diff --git a/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt b/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt index d2f69ae..6600bde 100644 --- a/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt +++ b/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.delay -import net.jeena.pacer.BpmCalculator +import net.jeena.pacer.PacerService import net.jeena.pacer.PacerViewModel import net.jeena.pacer.ui.theme.Green80 @@ -56,20 +56,19 @@ fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) { val bpm = viewModel.bpm val volume = viewModel.volume - // Pulse trigger: fires once per beat when playing + // Driven by the actual audio callback — guaranteed in sync with the beep + val beepTick = PacerService.beepTick var pulseTrigger by remember { mutableStateOf(false) } - LaunchedEffect(isPlaying, bpm) { - if (isPlaying) { - while (true) { - pulseTrigger = true - delay(80) - pulseTrigger = false - delay(BpmCalculator.intervalMs(bpm) - 80) - } - } else { + LaunchedEffect(beepTick) { + if (beepTick > 0L) { + pulseTrigger = true + delay(80) pulseTrigger = false } } + LaunchedEffect(isPlaying) { + if (!isPlaying) pulseTrigger = false + } val pulseScale by animateFloatAsState( targetValue = if (pulseTrigger) 1.25f else 1f,