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.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
}
}
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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,