Keep Android dictation running through audio interruptions (#7200)

* fix(android): preserve dictation across audio interruptions

Playback focus loss does not mean microphone capture is unavailable. Keep dictation alive across media takeovers and wait for Android input silencing to clear, while preserving conversation interruption protection.

* test(android): verify dictation clears its selected audio route
This commit is contained in:
Mohamed Boudra
2026-10-17 12:53:06 +07:00
committed by GitHub
parent f472c9d1f4
commit 6e1ec21c6a
26 changed files with 479 additions and 111 deletions
+22 -5
View File
@@ -300,16 +300,33 @@ done
### Android audio focus interruptions
Voice mode uses the custom `expo-two-way-audio` Android module, so incoming calls and other system audio owners must be tested with emulator/system commands, not a JS-only test. To verify that voice resume handles denied audio focus without crashing:
Use native instrumentation for audio interruptions. JS-only tests cannot prove Android focus,
input silencing, or route recovery. The module's `android/src/androidTest` suite runs through
Gradle's `connectedDebugAndroidTest` task and uses real `AudioManager` and `AudioRecord` instances.
Run the microphone-privacy cases on Android 12 or newer.
Verify these distinct outcomes:
- Dictation continues when another media owner takes permanent, temporary, or ducking focus.
- Disabling microphone access pauses audio delivery without discarding the dictation. Enabling
access resumes it. Starting dictation while access is disabled waits for the same recovery.
- Returning from music controls resumes dictation without pausing music again. Cancelling a
backgrounded recording must prevent the lifecycle callback from restarting it.
- Two-way voice still stops coherently on focus loss. Keep the incoming-call check below: it
exercises denied focus during lifecycle resume, beyond an ordinary media takeover.
```bash
adb shell am start -n sh.paseo/.MainActivity
# Start voice mode in an existing composer, then background Paseo with Home.
# Start voice mode in an existing composer, then background Paseo.
adb emu gsm call 5551234
# Foreground Paseo while the call is still ringing.
# Foreground Paseo while the call is still ringing, then end the simulated call.
adb emu gsm cancel 5551234
```
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
Expected result: no `RuntimeException: Audio focus request failed`, and voice mode stops or pauses
coherently. Repeat with dictation: it must retain captured audio and recover microphone access.
Use browser/device automation to operate the app; the commands above only control emulator telephony.
This fix requires a rebuilt native app. An OTA update on an older binary retains legacy capture.
### Releasing the audio session when idle
@@ -2,6 +2,7 @@ export interface AudioEngineCallbacks {
onCaptureData(pcm: Uint8Array): void;
onVolumeLevel(level: number): void;
onInterruption?(): void;
onCaptureAvailabilityChange?(available: boolean): void;
onError?(error: Error): void;
}
@@ -25,3 +26,8 @@ export interface AudioEngine {
clearQueue(): void;
isPlaying(): boolean;
}
export interface AudioEngineOptions {
traceLabel?: string;
captureMode?: "conversation" | "dictation";
}
+18 -3
View File
@@ -1,4 +1,9 @@
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
import type {
AudioEngine,
AudioEngineCallbacks,
AudioEngineOptions,
AudioPlaybackSource,
} from "./audio-engine-types";
import { createAudioPlayer, setAudioModeAsync } from "expo-audio";
import { File, Paths } from "expo-file-system";
@@ -8,7 +13,7 @@ import { playPcm16 } from "./pcm";
export function createAudioEngine(
callbacks: AudioEngineCallbacks,
_options?: { traceLabel?: string },
options?: AudioEngineOptions,
): AudioEngine {
const native = require("@getpaseo/expo-two-way-audio");
@@ -47,6 +52,12 @@ export function createAudioEngine(
const interruptionSubscription = native.addExpoTwoWayAudioEventListener(
"onAudioInterruption",
(event: { data: string }) => {
if (event.data === "capture_paused" || event.data === "capture_resumed") {
if (refs.captureActive) {
callbacks.onCaptureAvailabilityChange?.(event.data === "capture_resumed");
}
return;
}
if (event.data !== "blocked") {
return;
}
@@ -180,7 +191,10 @@ export function createAudioEngine(
try {
await ensureMicrophonePermission();
await ensureInitialized();
const isRecording = native.toggleRecording(true);
const isRecording =
options?.captureMode === "dictation"
? native.startDictation()
: native.toggleRecording(true);
if (!isRecording) {
throw new Error(
"Microphone capture could not start because Android audio focus is unavailable.",
@@ -201,6 +215,7 @@ export function createAudioEngine(
refs.captureActive = false;
refs.muted = false;
callbacks.onVolumeLevel(0);
callbacks.onCaptureAvailabilityChange?.(true);
releaseSessionIfIdle();
},
+7 -2
View File
@@ -1,6 +1,11 @@
import { parsePcmSampleRate, resampleToPcm16 } from "./pcm";
import { isElectronRuntime } from "@/desktop/host";
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
import type {
AudioEngine,
AudioEngineCallbacks,
AudioEngineOptions,
AudioPlaybackSource,
} from "./audio-engine-types";
import { createPlaybackQueue } from "./playback";
@@ -46,7 +51,7 @@ async function decodeAudioData(context: AudioContext, buffer: ArrayBuffer): Prom
export function createAudioEngine(
callbacks: AudioEngineCallbacks,
_options?: { traceLabel?: string },
_options?: AudioEngineOptions,
): AudioEngine {
const refs: {
playbackContext: AudioContext | null;
@@ -87,7 +87,9 @@ export function DictationControls({
<View style={styles.meterWrapper}>
<VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
</View>
<Text style={timerTextStyle}>{formatDuration(duration)}</Text>
<Text style={timerTextStyle}>
{status === "paused" ? t("message.dictation.paused") : formatDuration(duration)}
</Text>
<View style={styles.actionGroup}>
<Pressable
onPress={handleCancel}
@@ -210,6 +212,11 @@ export function DictationOverlay({
/>
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
</View>
{status === "paused" ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{t("message.dictation.paused")}
</Text>
) : null}
{isFailed ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{errorText
@@ -14,6 +14,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
const onErrorRef = useRef(config.onError);
const onInterruptionRef = useRef(config.onInterruption);
const [volume, setVolume] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const engineRef = useRef<ReturnType<typeof createAudioEngine> | null>(null);
const getOrCreateEngine = useCallback(() => {
@@ -21,20 +22,24 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
return engineRef.current;
}
engineRef.current = createAudioEngine({
onCaptureData: (pcm) => {
onPcmSegmentRef.current(Buffer.from(pcm).toString("base64"));
engineRef.current = createAudioEngine(
{
onCaptureData: (pcm) => {
onPcmSegmentRef.current(Buffer.from(pcm).toString("base64"));
},
onVolumeLevel: (level) => {
setVolume(level);
},
onError: (error) => {
onErrorRef.current?.(error);
},
onInterruption: () => {
onInterruptionRef.current?.();
},
onCaptureAvailabilityChange: (available) => setIsPaused(!available),
},
onVolumeLevel: (level) => {
setVolume(level);
},
onError: (error) => {
onErrorRef.current?.(error);
},
onInterruption: () => {
onInterruptionRef.current?.();
},
});
{ captureMode: "dictation" },
);
return engineRef.current;
}, []);
@@ -67,5 +72,6 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
start,
stop,
volume,
isPaused,
};
}
@@ -8,4 +8,5 @@ export interface DictationAudioSource {
start: () => Promise<void>;
stop: () => Promise<void>;
volume: number;
isPaused: boolean;
}
@@ -442,6 +442,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
},
stop,
volume,
isPaused: false,
}),
[start, stop, volume],
);
@@ -1,6 +1,6 @@
import { i18n } from "@/i18n/i18next";
export type DictationStatus = "idle" | "recording" | "uploading" | "failed";
export type DictationStatus = "idle" | "recording" | "paused" | "uploading" | "failed";
export interface UseDictationOptions {
client: import("@getpaseo/client/internal/daemon-client").DaemonClient | null;
+1 -1
View File
@@ -461,7 +461,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
volume: audio.volume,
duration,
error,
status,
status: status === "recording" && audio.isPaused ? "paused" : status,
startDictation,
cancelDictation,
confirmDictation,
+1
View File
@@ -360,6 +360,7 @@ export const ar: TranslationResources = {
details: "تفاصيل",
},
dictation: {
paused: "تمت مقاطعة الميكروفون. في انتظار الاستئناف…",
start: "بدء الإملاء الصوتي",
cancel: "إلغاء الإملاء",
retry: "أعد محاولة الإملاء",
+1
View File
@@ -357,6 +357,7 @@ export const en = {
details: "Details",
},
dictation: {
paused: "Microphone interrupted. Waiting to resume…",
start: "Start voice dictation",
cancel: "Cancel dictation",
retry: "Retry dictation",
+1
View File
@@ -365,6 +365,7 @@ export const es: TranslationResources = {
details: "Detalles",
},
dictation: {
paused: "Micrófono interrumpido. Esperando para reanudar…",
start: "Iniciar dictado de voz",
cancel: "Cancelar dictado",
retry: "Reintentar el dictado",
+1
View File
@@ -365,6 +365,7 @@ export const fr: TranslationResources = {
details: "Détails",
},
dictation: {
paused: "Microphone interrompu. En attente de reprise…",
start: "Démarrer la dictée vocale",
cancel: "Annuler la dictée",
retry: "Relancer la dictée",
+1
View File
@@ -365,6 +365,7 @@ export const ja: TranslationResources = {
details: "詳細",
},
dictation: {
paused: "マイクが中断されました。再開を待っています…",
start: "音声入力を開始",
cancel: "音声入力をキャンセル",
retry: "音声入力を再試行",
+1
View File
@@ -361,6 +361,7 @@ export const ko: TranslationResources = {
details: "세부 정보",
},
dictation: {
paused: "마이크가 중단되었습니다. 재개를 기다리는 중…",
start: "음성 받아쓰기 시작",
cancel: "받아쓰기 취소",
retry: "받아쓰기 다시 시도",
+1
View File
@@ -364,6 +364,7 @@ export const ptBR: TranslationResources = {
details: "Detalhes",
},
dictation: {
paused: "Microfone interrompido. Aguardando para retomar…",
start: "Iniciar ditado por voz",
cancel: "Cancelar ditado",
retry: "Tentar ditado novamente",
+1
View File
@@ -364,6 +364,7 @@ export const ru: TranslationResources = {
details: "Подробности",
},
dictation: {
paused: "Микрофон прерван. Ожидание возобновления…",
start: "Начать диктовку голосом",
cancel: "Отменить диктовку",
retry: "Повторить диктовку",
+1
View File
@@ -360,6 +360,7 @@ export const zhCN: TranslationResources = {
details: "详情",
},
dictation: {
paused: "麦克风已中断,正在等待恢复…",
start: "开始语音听写",
cancel: "取消听写",
retry: "重试听写",
@@ -36,8 +36,14 @@ android {
defaultConfig {
versionCode 1
versionName "0.1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
lintOptions {
abortOnError false
}
}
dependencies {
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
}
@@ -0,0 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application>
<activity android:name="expo.modules.twowayaudio.AudioEngineTestActivity"
android:exported="true" />
</application>
</manifest>
@@ -0,0 +1,223 @@
package expo.modules.twowayaudio
import AudioEngine
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.filters.SdkSuppress
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
class AudioEngineTestActivity : Activity()
/** Real AudioManager and AudioRecord: focus loss is delivered by Android, never injected. */
@RunWith(AndroidJUnit4::class)
class AudioEngineTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private lateinit var activity: Activity
private lateinit var engine: AudioEngine
private lateinit var audio: AudioManager
private val packets = AtomicInteger()
private val events = CopyOnWriteArrayList<String>()
private val requests = mutableListOf<AudioFocusRequest>()
@Before fun setUp() {
val context = instrumentation.context
shell("pm grant ${context.packageName} android.permission.RECORD_AUDIO")
activity = instrumentation.startActivitySync(
Intent(context, AudioEngineTestActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
instrumentation.waitForIdleSync()
audio = activity.getSystemService(Context.AUDIO_SERVICE) as AudioManager
main {
engine = AudioEngine(activity)
engine.onMicDataCallback = { packets.incrementAndGet() }
engine.onAudioInterruptionCallback = { events.add(it) }
}
}
@After fun tearDown() {
shell("cmd sensor_privacy disable 0 microphone")
main {
requests.forEach { audio.abandonAudioFocusRequest(it) }
engine.tearDown()
activity.finish()
}
}
@Test fun dictationContinuesWhenMusicTakesFocus() {
assertDictationSurvives(AudioManager.AUDIOFOCUS_GAIN)
}
@Test fun dictationContinuesWhenFocusIsTemporarilyLost() {
assertDictationSurvives(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
}
@Test fun dictationContinuesWhenAnotherAppRequestsDucking() {
assertDictationSurvives(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
}
@Test fun conversationStillStopsOnFocusLoss() {
main { assertTrue(engine.toggleRecording(true)) }
await("conversation microphone data") { packets.get() > 2 }
takeFocus(AudioManager.AUDIOFOCUS_GAIN)
await("terminal conversation interruption") { events.contains("blocked") }
assertFalse(engine.isRecording)
}
@Test fun dictationStartsWhileMusicOwnsFocusAndCanBeStartedAgain() {
takeFocus(AudioManager.AUDIOFOCUS_GAIN)
startDictation()
main { engine.toggleRecording(false); engine.releaseAudioSession() }
val stoppedAt = packets.get()
startDictation()
await("new recording data") { packets.get() > stoppedAt + 2 }
assertFalse(events.contains("blocked"))
}
@Test fun cancellingWhileBackgroundedDoesNotRestartOnResume() {
startDictation()
main {
engine.pauseRecordingAndPlayer()
engine.toggleRecording(false)
engine.releaseAudioSession()
engine.resumeRecordingAndPlayer()
}
assertFalse(engine.isRecording)
}
@Test fun dictationKeepsItsCaptureModeAfterBackgroundResume() {
startDictation()
main { engine.pauseRecordingAndPlayer(); engine.resumeRecordingAndPlayer() }
val resumedAt = packets.get()
takeFocus(AudioManager.AUDIOFOCUS_GAIN)
await("resumed dictation data after focus loss") { packets.get() > resumedAt + 3 }
assertTrue(engine.isRecording)
assertFalse(events.contains("blocked"))
}
@Test @SdkSuppress(minSdkVersion = 31)
fun microphonePrivacyInterruptionPreservesCaptureAndRecovers() {
startDictation()
shell("cmd sensor_privacy enable 0 microphone")
await("input paused") { events.contains("capture_paused") }
assertTrue(engine.isRecording)
assertFalse(events.contains("blocked"))
// Once Android reports silencing, do not enqueue artificial silence for transcription.
SystemClock.sleep(100)
val pausedAt = packets.get()
SystemClock.sleep(150)
assertEquals(pausedAt, packets.get())
shell("cmd sensor_privacy disable 0 microphone")
await("input resumed") { events.contains("capture_resumed") }
await("microphone data after recovery") { packets.get() > pausedAt + 3 }
assertTrue(engine.isRecording)
assertEquals(listOf("capture_paused", "capture_resumed"), events.toList())
}
@Test @SdkSuppress(minSdkVersion = 31)
fun startingWithMicrophonePrivacyEnabledWaitsAndRecovers() {
shell("cmd sensor_privacy enable 0 microphone")
main { assertTrue(engine.startDictation()) }
await("initial input paused") { events.contains("capture_paused") }
assertTrue(engine.isRecording)
assertFalse(events.contains("blocked"))
shell("cmd sensor_privacy disable 0 microphone")
await("initial input resumed") { events.contains("capture_resumed") }
await("microphone data") { packets.get() > 3 }
}
@Test fun resumingDictationDoesNotTakeFocusBackFromMusic() {
startDictation()
main { engine.pauseRecordingAndPlayer() }
val changes = CopyOnWriteArrayList<Int>()
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setOnAudioFocusChangeListener({ changes.add(it) }, Handler(Looper.getMainLooper()))
.build()
requests.add(request)
main { assertEquals(AudioManager.AUDIOFOCUS_REQUEST_GRANTED, audio.requestAudioFocus(request)) }
val before = packets.get()
main { engine.resumeRecordingAndPlayer() }
await("resumed microphone data") { packets.get() > before + 3 }
assertTrue(engine.isRecording)
assertTrue("Music lost focus on dictation resume: $changes", changes.isEmpty())
}
@Test @SdkSuppress(minSdkVersion = 31)
fun stoppingDictationReleasesTheCommunicationRoute() {
startDictation()
await("selected communication route") { hasCommunicationRoute() }
main { engine.toggleRecording(false); engine.releaseAudioSession() }
await("normal audio mode") { audio.mode == AudioManager.MODE_NORMAL }
await("cleared communication route") { !hasCommunicationRoute() }
assertFalse(engine.isRecording)
}
private fun assertDictationSurvives(gain: Int) {
startDictation()
val before = packets.get()
val request = takeFocus(gain)
await("microphone data while focus is lost") { packets.get() > before + 3 }
assertTrue(engine.isRecording)
assertFalse(events.contains("blocked"))
val during = packets.get()
main { audio.abandonAudioFocusRequest(request) }
await("microphone data after focus release") { packets.get() > during + 3 }
assertTrue(engine.isRecording)
assertFalse(events.contains("blocked"))
}
private fun startDictation() {
val before = packets.get()
main { assertTrue(engine.startDictation()) }
await("initial microphone data") { packets.get() > before + 3 }
}
private fun takeFocus(gain: Int): AudioFocusRequest {
val request = AudioFocusRequest.Builder(gain)
.setAudioAttributes(AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build())
.setOnAudioFocusChangeListener({}, Handler(Looper.getMainLooper()))
.build()
requests.add(request)
main { assertEquals(AudioManager.AUDIOFOCUS_REQUEST_GRANTED, audio.requestAudioFocus(request)) }
return request
}
private fun main(action: () -> Unit) = instrumentation.runOnMainSync(action)
private fun await(description: String, condition: () -> Boolean) {
val deadline = SystemClock.elapsedRealtime() + 5000
while (!condition() && SystemClock.elapsedRealtime() < deadline) SystemClock.sleep(20)
assertTrue("Timed out waiting for $description; events=$events packets=${packets.get()}", condition())
}
// communicationDevice reports the effective system device even after our selection is cleared.
// Inspect only current route clients, never the historical routing event log.
private fun hasCommunicationRoute(): Boolean {
val dump = shell("dumpsys audio")
assertTrue("Missing Android communication route diagnostics", dump.contains("Communication route clients:"))
return dump.lineSequence().any {
it.trimStart().startsWith("[CommunicationRouteClient: mUid: ${android.os.Process.myUid()} ")
}
}
private fun shell(command: String): String =
instrumentation.uiAutomation.executeShellCommand(command).use {
android.os.ParcelFileDescriptor.AutoCloseInputStream(it).readBytes().toString(Charsets.UTF_8)
}
}
@@ -6,12 +6,14 @@ import android.media.AudioFocusRequest
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioRecordingConfiguration
import android.media.AudioTrack
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.NoiseSuppressor
import android.os.Build
import android.os.PowerManager
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.annotation.RequiresApi
import java.util.LinkedList
@@ -34,7 +36,26 @@ class AudioEngine (context: Context) {
private var noiseSuppressor: NoiseSuppressor? = null
private val executorServiceMicrophone = Executors.newFixedThreadPool(1)
private val executorServicePlayback = Executors.newFixedThreadPool(1)
private var speakerDevice: AudioDeviceInfo? = null
private enum class CaptureMode { CONVERSATION, DICTATION }
private var captureMode = CaptureMode.CONVERSATION
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile private var captureSilenced = false
private val recordingCallback = object : AudioManager.AudioRecordingCallback() {
override fun onRecordingConfigChanged(configs: MutableList<AudioRecordingConfiguration>) {
if (!isRecording || !::audioRecord.isInitialized) return
val config = configs.firstOrNull { it.clientAudioSessionId == audioRecord.audioSessionId }
?: return
setCaptureSilenced(config.isClientSilenced)
}
}
private val deviceCallback = object : android.media.AudioDeviceCallback() {
override fun onAudioDevicesAdded(devices: Array<out AudioDeviceInfo>) {
if (communicationRouteActive) updateAudioRouting()
}
override fun onAudioDevicesRemoved(devices: Array<out AudioDeviceInfo>) {
if (communicationRouteActive) updateAudioRouting()
}
}
private var communicationRouteActive = false
private var bridgeWindowStartedAtMs = System.currentTimeMillis()
private var micEvents = 0
@@ -44,7 +65,7 @@ class AudioEngine (context: Context) {
private var playbackWrites = 0
private var playbackWriteBytes = 0L
var isRecording = false
@Volatile var isRecording = false
private var isRecordingBeforePause = false
var isPlaying = false
@@ -83,28 +104,9 @@ class AudioEngine (context: Context) {
@SuppressLint("NewApi")
private fun initializeAudio(context:Context) {
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
activateCommunicationRoute()
if (!requestAudioFocus()) {
handleAudioFocusBlocked()
}
// Listen for changes in audio routing
audioManager.registerAudioDeviceCallback(object:android.media.AudioDeviceCallback(){
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
Log.d("AudioEngine", "onAudioDevicesAdded")
super.onAudioDevicesAdded(addedDevices)
if (communicationRouteActive) {
updateAudioRouting()
}
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
Log.d("AudioEngine", "onAudioDevicesRemoved")
super.onAudioDevicesRemoved(removedDevices)
if (communicationRouteActive) {
updateAudioRouting()
}
}
}, null)
// Initialization must not interrupt music before a capture/playback operation owns a session.
audioManager.registerAudioDeviceCallback(deviceCallback, mainHandler)
audioManager.registerAudioRecordingCallback(recordingCallback, mainHandler)
val bufferSize = AudioTrack.getMinBufferSize(
SAMPLE_RATE,
@@ -125,49 +127,42 @@ class AudioEngine (context: Context) {
bufferSize,
AudioTrack.MODE_STREAM,
audioManager.generateAudioSessionId()
).apply {
play()
}
)
}
@SuppressLint("NewApi")
private fun updateAudioRouting() {
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
var isExternalDeviceConnected = false
var selectedDevice: AudioDeviceInfo? = null
for (device in devices) {
if (device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) {
speakerDevice = device
}
if (device.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
device.type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
isExternalDeviceConnected = true
selectedDevice = device
break
} else if (device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) {
selectedDevice = device
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Use the modern API for Android S and above
try {
selectedDevice?.let {
audioManager.setCommunicationDevice(it)
}
}catch (e:Exception){
Log.e("AudioEngine", "Error setting communication device. Using speaker")
speakerDevice?.let {
audioManager.setCommunicationDevice(it)
}
val devices = audioManager.availableCommunicationDevices
// A2DP is a music output, not a microphone route. Android chooses the matching
// input for a valid communication sink (including SCO and BLE headsets).
val selected = devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
it.type == AudioDeviceInfo.TYPE_BLE_HEADSET }
?: devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
it.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
it.type == AudioDeviceInfo.TYPE_USB_HEADSET }
?: devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }
val routed = try {
selected != null && audioManager.setCommunicationDevice(selected)
} catch (_: IllegalArgumentException) {
// The device disconnected between enumeration and selection.
false
}
if (!routed) audioManager.clearCommunicationDevice()
} else {
// Fall back to deprecated method for older Android versions
@Suppress("DEPRECATION")
audioManager.isSpeakerphoneOn = !isExternalDeviceConnected
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
// Before Android 12, A2DP signals a connected headset; start SCO to open its mic.
val bluetooth = devices.any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP }
@Suppress("DEPRECATION")
if (bluetooth) audioManager.startBluetoothSco() else audioManager.stopBluetoothSco()
@Suppress("DEPRECATION")
audioManager.isSpeakerphoneOn = !bluetooth && !devices.any {
it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
it.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
it.type == AudioDeviceInfo.TYPE_USB_HEADSET
}
}
}
@@ -186,6 +181,8 @@ class AudioEngine (context: Context) {
} else {
@Suppress("DEPRECATION")
audioManager.isSpeakerphoneOn = false
@Suppress("DEPRECATION")
audioManager.stopBluetoothSco()
}
audioManager.mode = AudioManager.MODE_NORMAL
}
@@ -245,8 +242,8 @@ class AudioEngine (context: Context) {
audioFocusRequest = null
}
val focusRequest =
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
lateinit var focusRequest: AudioFocusRequest
focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
@@ -255,6 +252,7 @@ class AudioEngine (context: Context) {
)
.setAcceptsDelayedFocusGain(true)
.setOnAudioFocusChangeListener { focusChange ->
if (audioFocusRequest !== focusRequest) return@setOnAudioFocusChangeListener
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
Log.d("AudioEngine", "Audio focus gained")
@@ -286,6 +284,14 @@ class AudioEngine (context: Context) {
}
private fun handleAudioFocusBlocked() {
// Focus governs playback. Dictation keeps its AudioRecord alive; Android's
// recording configuration reports real input silencing and restores it after calls.
if (captureMode == CaptureMode.DICTATION) {
stopPlayback()
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
audioFocusRequest = null
return
}
if (isRecording) {
stopRecording()
}
@@ -306,11 +312,11 @@ class AudioEngine (context: Context) {
@RequiresApi(Build.VERSION_CODES.Q)
@SuppressLint("MissingPermission")
private fun startRecording(): Boolean {
private fun startRecording(requestFocus: Boolean = true): Boolean {
activateCommunicationRoute()
if (!requestAudioFocus()) {
if (requestFocus && !requestAudioFocus()) {
handleAudioFocusBlocked()
return false
if (captureMode == CaptureMode.CONVERSATION) return false
}
val bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
@@ -344,17 +350,27 @@ class AudioEngine (context: Context) {
audioRecord.startRecording()
isRecording = true
startMicSampleTap()
setCaptureSilenced(audioRecord.activeRecordingConfiguration?.isClientSilenced ?: false)
startMicSampleTap(audioRecord)
return true
}
private fun startMicSampleTap(){
private fun setCaptureSilenced(silenced: Boolean) {
if (captureSilenced == silenced) return
captureSilenced = silenced
if (silenced) onInputVolumeCallback?.invoke(0.0F)
onAudioInterruptionCallback?.invoke(if (silenced) "capture_paused" else "capture_resumed")
}
private fun startMicSampleTap(recorder: AudioRecord){
executorServiceMicrophone.execute {
val buffer = ByteArray(1024)
try {
while (isRecording) {
val read = audioRecord.read(buffer, 0, buffer.size)
if (read > 0) {
while (isRecording && audioRecord === recorder) {
val read = recorder.read(buffer, 0, buffer.size)
if (!isRecording || audioRecord !== recorder) break
if (read < 0) throw IllegalStateException("AudioRecord read failed: $read")
if (read > 0 && !captureSilenced) {
val data = buffer.copyOf(read)
micEvents += 1
micBytes += data.size.toLong()
@@ -367,9 +383,13 @@ class AudioEngine (context: Context) {
Log.d("AudioEngine", "Mic sample tap stopped.")
}catch (e: Exception){
Log.e("AudioEngine", "Error reading mic sample data", e)
isRecording = false
tearDown()
throw e
mainHandler.post {
if (isRecording && audioRecord === recorder) {
stopRecording()
releaseAudioSession()
onAudioInterruptionCallback?.invoke("blocked")
}
}
}
}
}
@@ -379,16 +399,31 @@ class AudioEngine (context: Context) {
isRecording = false
if (audioRecord.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
audioRecord.stop()
audioRecord.release()
}
audioRecord.release()
echoCanceler?.release()
echoCanceler = null
noiseSuppressor?.release()
noiseSuppressor = null
setCaptureSilenced(false)
onInputVolumeCallback?.invoke(0.0F)
}
@RequiresApi(Build.VERSION_CODES.Q)
fun startDictation(): Boolean {
isRecordingBeforePause = false
if (isRecording) return true
captureMode = CaptureMode.DICTATION
return startRecording()
}
@RequiresApi(Build.VERSION_CODES.Q)
fun toggleRecording(value: Boolean): Boolean {
isRecordingBeforePause = false
if (value == isRecording) return isRecording
if (value) {
captureMode = CaptureMode.CONVERSATION
return startRecording()
} else {
stopRecording()
@@ -401,6 +436,7 @@ class AudioEngine (context: Context) {
@SuppressLint("NewApi")
fun playPCMData(data: ByteArray) {
acquireAudioSessionIfNeeded()
audioTrack.play()
audioSampleQueue.add(data)
playbackEvents += 1
playbackQueuedBytes += data.size.toLong()
@@ -464,7 +500,7 @@ class AudioEngine (context: Context) {
@RequiresApi(Build.VERSION_CODES.Q)
fun pauseRecordingAndPlayer() {
isRecordingBeforePause = isRecording
isRecording = toggleRecording(false)
stopRecording()
audioTrack.pause()
}
@@ -478,16 +514,18 @@ class AudioEngine (context: Context) {
if (!wasRecordingBeforePause && !isPlaying) {
return
}
activateCommunicationRoute()
if (!requestAudioFocus()) {
handleAudioFocusBlocked()
return
isRecordingBeforePause = false
if (wasRecordingBeforePause) {
// Returning from music controls must not pause music the user explicitly resumed.
if (!startRecording(requestFocus = captureMode == CaptureMode.CONVERSATION)) return
} else {
activateCommunicationRoute()
if (!requestAudioFocus()) {
handleAudioFocusBlocked()
return
}
}
isRecording = toggleRecording(wasRecordingBeforePause)
if (wasRecordingBeforePause && !isRecording) {
return
}
audioTrack.play()
if (captureMode == CaptureMode.CONVERSATION) audioTrack.play()
}
fun stopPlayback() {
@@ -520,7 +558,11 @@ class AudioEngine (context: Context) {
audioManager.abandonAudioFocusRequest(request)
}
audioFocusRequest = null
audioManager.unregisterAudioDeviceCallback(deviceCallback)
audioManager.unregisterAudioRecordingCallback(recordingCallback)
audioTrack.release()
executorServiceMicrophone.shutdownNow()
executorServicePlayback.shutdownNow()
}
@@ -42,6 +42,12 @@ class ExpoTwoWayAudioModule : Module() {
audioEngine?.isRecording ?: false
}
Function("startDictation") {
val isRecording = audioEngine?.startDictation() ?: false
sendEvent(ON_RECORDING_CHANGE_EVENT, mapOf("data" to isRecording))
isRecording
}
Function("toggleRecording") { value: Boolean ->
audioEngine?.let { engine ->
val isRecording = engine.toggleRecording(value)
@@ -59,6 +59,12 @@ public class ExpoTwoWayAudioModule: Module {
return audioEngine.isRecording
}
Function("startDictation") { () -> Bool in
let isRecording = self.audioEngine?.toggleRecording(true) ?? false
self.sendEvent(ON_RECORDING_CHANGE_EVENT_NAME, ["data": isRecording])
return isRecording
}
Function("toggleRecording") { (val: Bool) -> Bool in
guard let audioEngine = self.audioEngine else {
print("AudioEngine not initialized")
+10
View File
@@ -17,6 +17,16 @@ export function toggleRecording(val: boolean): boolean {
return ExpoTwoWayAudioModule.toggleRecording(val);
}
/** Capture speech without treating another app's playback focus as a microphone failure. */
export function startDictation(): boolean {
// COMPAT(dictationCapture): added after v0.11.2, remove after 2027-01-16 once
// the native binary floor includes startDictation. Older OTA clients retain legacy capture.
if (typeof ExpoTwoWayAudioModule.startDictation !== "function") {
return ExpoTwoWayAudioModule.toggleRecording(true);
}
return ExpoTwoWayAudioModule.startDictation();
}
export function isRecording(): boolean {
return ExpoTwoWayAudioModule.isRecording();
}