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.media.AudioTrack
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.HandlerThread
|
import android.os.HandlerThread
|
||||||
|
import android.os.Looper
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plays 880 Hz beeps at a given BPM using AudioTrack.
|
* Plays 880 Hz beeps at a given BPM using AudioTrack.
|
||||||
*
|
*
|
||||||
* Audio focus is intentionally never requested so beeps play alongside
|
* Audio focus is intentionally never requested so beeps play alongside
|
||||||
* other apps (e.g. AntennaPod) without ducking or interruption (US-05).
|
* 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 {
|
class AudioEngine {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val BEEP_DURATION_MS = 40
|
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 handlerThread: HandlerThread? = null
|
||||||
private var handler: Handler? = null
|
private var handler: Handler? = null
|
||||||
private var audioTrack: AudioTrack? = null
|
private var audioTrack: AudioTrack? = null
|
||||||
|
|
@ -42,7 +53,6 @@ class AudioEngine {
|
||||||
|
|
||||||
audioTrack = AudioTrack.Builder()
|
audioTrack = AudioTrack.Builder()
|
||||||
.setAudioAttributes(
|
.setAudioAttributes(
|
||||||
// USAGE_MEDIA with no requestAudioFocus call = plays alongside other audio (US-05)
|
|
||||||
AudioAttributes.Builder()
|
AudioAttributes.Builder()
|
||||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||||
|
|
@ -64,8 +74,7 @@ class AudioEngine {
|
||||||
|
|
||||||
handlerThread = HandlerThread("PacerAudio").also { it.start() }
|
handlerThread = HandlerThread("PacerAudio").also { it.start() }
|
||||||
handler = Handler(handlerThread!!.looper)
|
handler = Handler(handlerThread!!.looper)
|
||||||
|
handler?.post(::audioLoop)
|
||||||
scheduleBeep()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
|
@ -88,18 +97,28 @@ class AudioEngine {
|
||||||
audioTrack?.setVolume(currentVolume)
|
audioTrack?.setVolume(currentVolume)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scheduleBeep() {
|
private fun audioLoop() {
|
||||||
handler?.post(object : Runnable {
|
val silenceChunk = ShortArray(BeepGenerator.SAMPLE_RATE * SILENCE_CHUNK_MS / 1000)
|
||||||
override fun run() {
|
|
||||||
if (!playing) return
|
|
||||||
playBeep()
|
|
||||||
handler?.postDelayed(this, BpmCalculator.intervalMs(currentBpm))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun playBeep() {
|
while (playing) {
|
||||||
val samples = BeepGenerator.generateBeep(BEEP_DURATION_MS)
|
val bpm = currentBpm
|
||||||
audioTrack?.write(samples, 0, samples.size)
|
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 SAMPLE_RATE = 44100
|
||||||
const val FREQUENCY = 880.0
|
const val FREQUENCY = 880.0
|
||||||
|
|
||||||
|
private const val ATTACK_MS = 5
|
||||||
|
private const val RELEASE_MS = 10
|
||||||
|
|
||||||
fun generateBeep(durationMs: Int): ShortArray {
|
fun generateBeep(durationMs: Int): ShortArray {
|
||||||
val numSamples = SAMPLE_RATE * durationMs / 1000
|
val numSamples = SAMPLE_RATE * durationMs / 1000
|
||||||
|
val attackSamples = SAMPLE_RATE * ATTACK_MS / 1000
|
||||||
|
val releaseSamples = SAMPLE_RATE * RELEASE_MS / 1000
|
||||||
val samples = ShortArray(numSamples)
|
val samples = ShortArray(numSamples)
|
||||||
for (i in 0 until numSamples) {
|
for (i in 0 until numSamples) {
|
||||||
val angle = 2.0 * PI * FREQUENCY * i / SAMPLE_RATE
|
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
|
return samples
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ import android.content.pm.ServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
class PacerService : Service() {
|
class PacerService : Service() {
|
||||||
|
|
@ -28,6 +31,10 @@ class PacerService : Service() {
|
||||||
var isRunning = false
|
var isRunning = false
|
||||||
private set
|
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) =
|
fun startIntent(context: Context, bpm: Int, volume: Float) =
|
||||||
Intent(context, PacerService::class.java).apply {
|
Intent(context, PacerService::class.java).apply {
|
||||||
action = ACTION_START
|
action = ACTION_START
|
||||||
|
|
@ -87,6 +94,7 @@ class PacerService : Service() {
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
acquireWakeLock()
|
acquireWakeLock()
|
||||||
|
audioEngine.onBeep = { beepTick++ }
|
||||||
audioEngine.start(bpm, volume)
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import net.jeena.pacer.BpmCalculator
|
import net.jeena.pacer.PacerService
|
||||||
import net.jeena.pacer.PacerViewModel
|
import net.jeena.pacer.PacerViewModel
|
||||||
import net.jeena.pacer.ui.theme.Green80
|
import net.jeena.pacer.ui.theme.Green80
|
||||||
|
|
||||||
|
|
@ -56,20 +56,19 @@ fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) {
|
||||||
val bpm = viewModel.bpm
|
val bpm = viewModel.bpm
|
||||||
val volume = viewModel.volume
|
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) }
|
var pulseTrigger by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(isPlaying, bpm) {
|
LaunchedEffect(beepTick) {
|
||||||
if (isPlaying) {
|
if (beepTick > 0L) {
|
||||||
while (true) {
|
pulseTrigger = true
|
||||||
pulseTrigger = true
|
delay(80)
|
||||||
delay(80)
|
|
||||||
pulseTrigger = false
|
|
||||||
delay(BpmCalculator.intervalMs(bpm) - 80)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
pulseTrigger = false
|
pulseTrigger = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(isPlaying) {
|
||||||
|
if (!isPlaying) pulseTrigger = false
|
||||||
|
}
|
||||||
|
|
||||||
val pulseScale by animateFloatAsState(
|
val pulseScale by animateFloatAsState(
|
||||||
targetValue = if (pulseTrigger) 1.25f else 1f,
|
targetValue = if (pulseTrigger) 1.25f else 1f,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue