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:
parent
650d39d598
commit
b2fedcfd4a
4 changed files with 63 additions and 27 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,19 +56,18 @@ 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) {
|
||||
LaunchedEffect(beepTick) {
|
||||
if (beepTick > 0L) {
|
||||
pulseTrigger = true
|
||||
delay(80)
|
||||
pulseTrigger = false
|
||||
delay(BpmCalculator.intervalMs(bpm) - 80)
|
||||
}
|
||||
} else {
|
||||
pulseTrigger = false
|
||||
}
|
||||
LaunchedEffect(isPlaying) {
|
||||
if (!isPlaying) pulseTrigger = false
|
||||
}
|
||||
|
||||
val pulseScale by animateFloatAsState(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue