feat(US-04, US-05): implement BpmCalculator and AudioEngine

BpmCalculator provides interval math (60000 / bpm) and volume
clamping (0.0-1.0). AudioEngine drives AudioTrack on a dedicated
HandlerThread, scheduling beeps via postDelayed for battery-
efficient background playback.

Audio focus is intentionally never requested so beeps play
alongside other apps without ducking (US-05).
This commit is contained in:
Jeena 2026-03-09 07:50:28 +00:00
parent 8e08a53d30
commit def69f1a46
2 changed files with 113 additions and 0 deletions

View file

@ -0,0 +1,105 @@
package net.jeena.pacer
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import android.os.Handler
import android.os.HandlerThread
/**
* 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).
*/
class AudioEngine {
companion object {
private const val BEEP_DURATION_MS = 40
}
private var handlerThread: HandlerThread? = null
private var handler: Handler? = null
private var audioTrack: AudioTrack? = null
@Volatile private var currentBpm: Int = 120
@Volatile private var currentVolume: Float = 1f
@Volatile private var playing: Boolean = false
val isPlaying: Boolean get() = playing
fun start(bpm: Int, volume: Float) {
if (playing) return
currentBpm = bpm
currentVolume = BpmCalculator.clampVolume(volume)
playing = true
val minBufferSize = AudioTrack.getMinBufferSize(
BeepGenerator.SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
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)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(BeepGenerator.SAMPLE_RATE)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build()
)
.setBufferSizeInBytes(minBufferSize)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
audioTrack?.setVolume(currentVolume)
audioTrack?.play()
handlerThread = HandlerThread("PacerAudio").also { it.start() }
handler = Handler(handlerThread!!.looper)
scheduleBeep()
}
fun stop() {
playing = false
handler?.removeCallbacksAndMessages(null)
handlerThread?.quitSafely()
handlerThread = null
handler = null
audioTrack?.stop()
audioTrack?.release()
audioTrack = null
}
fun setBpm(bpm: Int) {
currentBpm = bpm
}
fun setVolume(volume: Float) {
currentVolume = BpmCalculator.clampVolume(volume)
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 playBeep() {
val samples = BeepGenerator.generateBeep(BEEP_DURATION_MS)
audioTrack?.write(samples, 0, samples.size)
}
}

View file

@ -0,0 +1,8 @@
package net.jeena.pacer
object BpmCalculator {
fun intervalMs(bpm: Int): Long = 60_000L / bpm
fun clampVolume(volume: Float): Float = volume.coerceIn(0f, 1f)
}