Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
<action android:name="android.intent.action.VIEW" />
<data android:scheme="nostrsigner" />
</intent>
<!-- Package visibility (Android 11+) for installed TTS engine discovery. -->
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>

<application
Expand Down
197 changes: 197 additions & 0 deletions app/src/main/java/dev/ipf/whitenoise/android/audio/AudioFocusOwner.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package dev.ipf.whitenoise.android.audio

import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager

/**
* Process-wide audio-focus arbiter shared by voice-note playback (#1479) and
* the future TTS controller (#1480). Only one owner holds focus at a time;
* handoff occurs only after the new owner is granted focus.
*/
object AudioFocusOwner {
enum class Owner {
Voice,
Tts,
}

val ttsSpeechAttributes: AudioAttributes =
AudioAttributes
.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()

private var audioManager: AudioManager? = null
private var focusRequest: AudioFocusRequest? = null
private var currentOwner: Owner? = null
private var onLoss: (() -> Unit)? = null
private var onSurrender: (() -> Unit)? = null
private var focusListener: AudioManager.OnAudioFocusChangeListener? = null
private var nextGeneration = 0L
private var currentGeneration = 0L
private var activeCallbacks = 0
private val focusLock = Any()

fun attach(context: Context) {
synchronized(focusLock) {
audioManager = context.applicationContext.getSystemService(AudioManager::class.java)
}
}

fun acquire(
owner: Owner,
audioAttributes: AudioAttributes,
focusGain: Int,
onFocusLoss: () -> Unit,
onOwnerSurrender: () -> Unit,
): Boolean {
val result =
synchronized(focusLock) {
acquireLocked(owner, audioAttributes, focusGain, onFocusLoss, onOwnerSurrender)
}
try {
result.previousOwnerSurrender?.invoke()
} catch (error: Throwable) {
runCatching {
synchronized(focusLock) {
if (currentOwner == owner) {
abandonFocusInternal()
}
}
}.exceptionOrNull()?.let(error::addSuppressed)
throw error
} finally {
if (result.previousOwnerSurrender != null) {
completeCallback()
}
}
return result.acquired
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private fun acquireLocked(
owner: Owner,
audioAttributes: AudioAttributes,
focusGain: Int,
onFocusLoss: () -> Unit,
onOwnerSurrender: () -> Unit,
): AcquisitionResult {
if (activeCallbacks > 0) {
return AcquisitionResult(acquired = false)
}
val am = audioManager
if (am == null) {
val previousSurrender = onSurrender.takeIf { currentOwner != null && currentOwner != owner }
if (previousSurrender != null) {
activeCallbacks += 1
}
currentOwner = owner
onLoss = onFocusLoss
onSurrender = onOwnerSurrender
return AcquisitionResult(acquired = true, previousOwnerSurrender = previousSurrender)
}
if (focusRequest != null && currentOwner == owner) {
onLoss = onFocusLoss
onSurrender = onOwnerSurrender
return AcquisitionResult(acquired = true)
}
val generation = ++nextGeneration
val listener =
AudioManager.OnAudioFocusChangeListener { change ->
val lossCallback =
synchronized(focusLock) {
if (generation != currentGeneration || currentOwner != owner) {
null
} else {
when (change) {
AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK,
-> onLoss
else -> null
}?.also { activeCallbacks += 1 }
}
}
try {
lossCallback?.invoke()
} finally {
if (lossCallback != null) {
completeCallback()
}
}
}
val req =
AudioFocusRequest
.Builder(focusGain)
.setAudioAttributes(audioAttributes)
.setOnAudioFocusChangeListener(listener)
.build()
val granted = am.requestAudioFocus(req) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
if (!granted) {
return AcquisitionResult(acquired = false)
}

val previousRequest = focusRequest
val previousSurrender = onSurrender.takeIf { currentOwner != null && currentOwner != owner }
if (previousSurrender != null) {
activeCallbacks += 1
}
focusRequest = req
currentOwner = owner
onLoss = onFocusLoss
onSurrender = onOwnerSurrender
focusListener = listener
currentGeneration = generation
previousRequest?.let(am::abandonAudioFocusRequest)
return AcquisitionResult(acquired = true, previousOwnerSurrender = previousSurrender)
}

private data class AcquisitionResult(
val acquired: Boolean,
val previousOwnerSurrender: (() -> Unit)? = null,
)

private fun completeCallback() {
synchronized(focusLock) {
check(activeCallbacks > 0)
activeCallbacks -= 1
}
}

fun acquireForTts(
onFocusLoss: () -> Unit,
onOwnerSurrender: () -> Unit,
): Boolean =
acquire(
owner = Owner.Tts,
audioAttributes = ttsSpeechAttributes,
focusGain = AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK,
onFocusLoss = onFocusLoss,
onOwnerSurrender = onOwnerSurrender,
)

fun release(owner: Owner) {
synchronized(focusLock) {
if (currentOwner != owner) return
abandonFocusInternal()
}
}

fun releaseTts() {
release(Owner.Tts)
}

private fun abandonFocusInternal() {
val request = focusRequest
audioManager?.let { am ->
request?.let { am.abandonAudioFocusRequest(it) }
}
focusRequest = null
currentOwner = null
onLoss = null
onSurrender = null
focusListener = null
currentGeneration = 0L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package dev.ipf.whitenoise.android.audio

import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaMetadataRetriever
import android.media.MediaPlayer
Expand Down Expand Up @@ -103,21 +102,21 @@ object VoicePlaybackController {
// only one prepared player can ever reach start()/assignment.
private val playSerializer = VoicePlaybackRequestSerializer()

private var audioManager: AudioManager? = null
private var focusRequest: AudioFocusRequest? = null
private val focusListener =
AudioManager.OnAudioFocusChangeListener { change ->
when (change) {
AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK,
-> pause()
}
}
private val speechAudioAttributes: AudioAttributes =
AudioAttributes
.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()

/** Call once from Application.onCreate so playback can request audio focus. */
fun attach(context: Context) {
audioManager = context.applicationContext.getSystemService(AudioManager::class.java)
AudioFocusOwner.attach(context)
}

/** Stops playback when another audio owner takes focus. */
internal fun stopForAudioHandoff() {
stop()
}

private data class CompletionCallback(
Expand Down Expand Up @@ -366,33 +365,17 @@ object VoicePlaybackController {
onFailure()
}.isSuccess

private fun requestFocus(): Boolean {
val am = audioManager ?: return true
// focusRequest is non-null only after AudioManager granted focus and is
// cleared when that request is abandoned. Reuse the held request so the
// same instance can be abandoned later instead of orphaning it.
if (focusRequest != null) return true
val attrs =
AudioAttributes
.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
val req =
AudioFocusRequest
.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
.setAudioAttributes(attrs)
.setOnAudioFocusChangeListener(focusListener)
.build()
val granted = am.requestAudioFocus(req) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
if (granted) focusRequest = req
return granted
}
private fun requestFocus(): Boolean =
AudioFocusOwner.acquire(
owner = AudioFocusOwner.Owner.Voice,
audioAttributes = speechAudioAttributes,
focusGain = AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK,
onFocusLoss = ::pause,
onOwnerSurrender = ::stopForAudioHandoff,
)

private fun abandonFocus() {
val am = audioManager ?: return
focusRequest?.let { am.abandonAudioFocusRequest(it) }
focusRequest = null
AudioFocusOwner.release(AudioFocusOwner.Owner.Voice)
}

/** Pause the active player (no-op if nothing is active). */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package dev.ipf.whitenoise.android.audio.tts

import android.speech.tts.TextToSpeech

/**
* Thin platform seam for [TextToSpeech] engine enumeration so unit tests can
* supply deterministic engine lists without constructing real instances.
*/
interface TtsEngineCatalog {
fun installedEngines(tts: TextToSpeech): List<TextToSpeech.EngineInfo>

fun defaultEnginePackage(tts: TextToSpeech): String?

/**
* Package Android actually connected for this [TextToSpeech] instance, when
* the public API can report it. Null means the active package cannot be
* verified (for example after a silent fallback bind).
*/
fun connectedEnginePackage(
tts: TextToSpeech,
requestedPackage: String?,
): String?
}

object AndroidTtsEngineCatalog : TtsEngineCatalog {
override fun installedEngines(tts: TextToSpeech): List<TextToSpeech.EngineInfo> = tts.engines

override fun defaultEnginePackage(tts: TextToSpeech): String? = tts.defaultEngine

override fun connectedEnginePackage(
tts: TextToSpeech,
requestedPackage: String?,
): String? = null
}
Loading
Loading