fix: eliminate audio distortion and sync animation to audio

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.
This commit is contained in:
Jeena 2026-03-09 11:02:36 +00:00
parent 650d39d598
commit b2fedcfd4a
4 changed files with 63 additions and 27 deletions

View file

@ -5,19 +5,30 @@ import android.media.AudioFormat
import android.media.AudioTrack import android.media.AudioTrack
import android.os.Handler import android.os.Handler
import android.os.HandlerThread import android.os.HandlerThread
import android.os.Looper
/** /**
* Plays 880 Hz beeps at a given BPM using AudioTrack. * Plays 880 Hz beeps at a given BPM using AudioTrack.
* *
* Audio focus is intentionally never requested so beeps play alongside * Audio focus is intentionally never requested so beeps play alongside
* other apps (e.g. AntennaPod) without ducking or interruption (US-05). * 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 { class AudioEngine {
companion object { companion object {
private const val BEEP_DURATION_MS = 40 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 handlerThread: HandlerThread? = null
private var handler: Handler? = null private var handler: Handler? = null
private var audioTrack: AudioTrack? = null private var audioTrack: AudioTrack? = null
@ -42,7 +53,6 @@ class AudioEngine {
audioTrack = AudioTrack.Builder() audioTrack = AudioTrack.Builder()
.setAudioAttributes( .setAudioAttributes(
// USAGE_MEDIA with no requestAudioFocus call = plays alongside other audio (US-05)
AudioAttributes.Builder() AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA) .setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
@ -64,8 +74,7 @@ class AudioEngine {
handlerThread = HandlerThread("PacerAudio").also { it.start() } handlerThread = HandlerThread("PacerAudio").also { it.start() }
handler = Handler(handlerThread!!.looper) handler = Handler(handlerThread!!.looper)
handler?.post(::audioLoop)
scheduleBeep()
} }
fun stop() { fun stop() {
@ -88,18 +97,28 @@ class AudioEngine {
audioTrack?.setVolume(currentVolume) audioTrack?.setVolume(currentVolume)
} }
private fun scheduleBeep() { private fun audioLoop() {
handler?.post(object : Runnable { val silenceChunk = ShortArray(BeepGenerator.SAMPLE_RATE * SILENCE_CHUNK_MS / 1000)
override fun run() {
if (!playing) return
playBeep()
handler?.postDelayed(this, BpmCalculator.intervalMs(currentBpm))
}
})
}
private fun playBeep() { while (playing) {
val samples = BeepGenerator.generateBeep(BEEP_DURATION_MS) val bpm = currentBpm
audioTrack?.write(samples, 0, samples.size) 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
}
}
} }
} }

View file

@ -8,12 +8,22 @@ object BeepGenerator {
const val SAMPLE_RATE = 44100 const val SAMPLE_RATE = 44100
const val FREQUENCY = 880.0 const val FREQUENCY = 880.0
private const val ATTACK_MS = 5
private const val RELEASE_MS = 10
fun generateBeep(durationMs: Int): ShortArray { fun generateBeep(durationMs: Int): ShortArray {
val numSamples = SAMPLE_RATE * durationMs / 1000 val numSamples = SAMPLE_RATE * durationMs / 1000
val attackSamples = SAMPLE_RATE * ATTACK_MS / 1000
val releaseSamples = SAMPLE_RATE * RELEASE_MS / 1000
val samples = ShortArray(numSamples) val samples = ShortArray(numSamples)
for (i in 0 until numSamples) { for (i in 0 until numSamples) {
val angle = 2.0 * PI * FREQUENCY * i / SAMPLE_RATE 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 return samples
} }

View file

@ -10,6 +10,9 @@ import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.setValue
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
class PacerService : Service() { class PacerService : Service() {
@ -28,6 +31,10 @@ class PacerService : Service() {
var isRunning = false var isRunning = false
private set 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) = fun startIntent(context: Context, bpm: Int, volume: Float) =
Intent(context, PacerService::class.java).apply { Intent(context, PacerService::class.java).apply {
action = ACTION_START action = ACTION_START
@ -87,6 +94,7 @@ class PacerService : Service() {
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
} }
acquireWakeLock() acquireWakeLock()
audioEngine.onBeep = { beepTick++ }
audioEngine.start(bpm, volume) audioEngine.start(bpm, volume)
} }

View file

@ -35,7 +35,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import net.jeena.pacer.BpmCalculator import net.jeena.pacer.PacerService
import net.jeena.pacer.PacerViewModel import net.jeena.pacer.PacerViewModel
import net.jeena.pacer.ui.theme.Green80 import net.jeena.pacer.ui.theme.Green80
@ -56,20 +56,19 @@ fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) {
val bpm = viewModel.bpm val bpm = viewModel.bpm
val volume = viewModel.volume 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) } var pulseTrigger by remember { mutableStateOf(false) }
LaunchedEffect(isPlaying, bpm) { LaunchedEffect(beepTick) {
if (isPlaying) { if (beepTick > 0L) {
while (true) { pulseTrigger = true
pulseTrigger = true delay(80)
delay(80)
pulseTrigger = false
delay(BpmCalculator.intervalMs(bpm) - 80)
}
} else {
pulseTrigger = false pulseTrigger = false
} }
} }
LaunchedEffect(isPlaying) {
if (!isPlaying) pulseTrigger = false
}
val pulseScale by animateFloatAsState( val pulseScale by animateFloatAsState(
targetValue = if (pulseTrigger) 1.25f else 1f, targetValue = if (pulseTrigger) 1.25f else 1f,