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