diff --git a/app/src/main/kotlin/net/jeena/pacer/AudioEngine.kt b/app/src/main/kotlin/net/jeena/pacer/AudioEngine.kt index 154c779..7017918 100644 --- a/app/src/main/kotlin/net/jeena/pacer/AudioEngine.kt +++ b/app/src/main/kotlin/net/jeena/pacer/AudioEngine.kt @@ -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 + } + } } } diff --git a/app/src/main/kotlin/net/jeena/pacer/BeepGenerator.kt b/app/src/main/kotlin/net/jeena/pacer/BeepGenerator.kt index 311bc84..ffb4232 100644 --- a/app/src/main/kotlin/net/jeena/pacer/BeepGenerator.kt +++ b/app/src/main/kotlin/net/jeena/pacer/BeepGenerator.kt @@ -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 } diff --git a/app/src/main/kotlin/net/jeena/pacer/PacerService.kt b/app/src/main/kotlin/net/jeena/pacer/PacerService.kt index 59e8459..3222327 100644 --- a/app/src/main/kotlin/net/jeena/pacer/PacerService.kt +++ b/app/src/main/kotlin/net/jeena/pacer/PacerService.kt @@ -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) } diff --git a/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt b/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt index d2f69ae..6600bde 100644 --- a/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt +++ b/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt @@ -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,20 +56,19 @@ 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) { - pulseTrigger = true - delay(80) - pulseTrigger = false - delay(BpmCalculator.intervalMs(bpm) - 80) - } - } else { + LaunchedEffect(beepTick) { + if (beepTick > 0L) { + pulseTrigger = true + delay(80) pulseTrigger = false } } + LaunchedEffect(isPlaying) { + if (!isPlaying) pulseTrigger = false + } val pulseScale by animateFloatAsState( targetValue = if (pulseTrigger) 1.25f else 1f,