diff --git a/app/src/main/kotlin/net/jeena/pacer/AudioEngine.kt b/app/src/main/kotlin/net/jeena/pacer/AudioEngine.kt new file mode 100644 index 0000000..154c779 --- /dev/null +++ b/app/src/main/kotlin/net/jeena/pacer/AudioEngine.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/net/jeena/pacer/BpmCalculator.kt b/app/src/main/kotlin/net/jeena/pacer/BpmCalculator.kt new file mode 100644 index 0000000..24c04f1 --- /dev/null +++ b/app/src/main/kotlin/net/jeena/pacer/BpmCalculator.kt @@ -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) +}