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:
parent
8e08a53d30
commit
def69f1a46
2 changed files with 113 additions and 0 deletions
105
app/src/main/kotlin/net/jeena/pacer/AudioEngine.kt
Normal file
105
app/src/main/kotlin/net/jeena/pacer/AudioEngine.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
8
app/src/main/kotlin/net/jeena/pacer/BpmCalculator.kt
Normal file
8
app/src/main/kotlin/net/jeena/pacer/BpmCalculator.kt
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue