Files
paseo/packages/expo-two-way-audio/ios/ExpoTwoWayAudioModule.swift
T
Mohamed Boudra 6e1ec21c6a 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
2026-10-17 12:53:06 +07:00

230 lines
7.3 KiB
Swift

import ExpoModulesCore
let ON_MIC_DATA_EVENT_NAME = "onMicrophoneData"
let ON_INPUT_VOLUME_LEVEL_EVENT_NAME = "onInputVolumeLevelData"
let ON_OUTPUT_VOLUME_LEVEL_EVENT_NAME = "onOutputVolumeLevelData"
let ON_RECORDING_CHANGE_EVENT_NAME = "onRecordingChange"
let ON_AUDIO_INTERRUPTION_EVENT_NAME = "onAudioInterruption"
public class ExpoTwoWayAudioModule: Module {
private var audioEngine: AudioEngine?
public func definition() -> ModuleDefinition {
Name("ExpoTwoWayAudio")
OnCreate {
let permissionsManager = self.appContext?.permissions
EXPermissionsMethodsDelegate.register(
[
MicrophonePermissionRequester()
],
withPermissionsManager: permissionsManager
)
}
AsyncFunction("initialize") { () -> Bool in
do {
if self.audioEngine != nil {
return true
}
self.audioEngine = try AudioEngine()
self.setupMicrophoneCallback()
self.setupInputAudioLevelCallback()
self.setupOutputAudioLevelCallback()
self.setupAudioInterruptionCallback()
return true
} catch {
print("Failed to initialize AudioEngine: \(error)")
return false
}
}
// Safety net for the JS-driven release in `stopCapture`/`processQueue`: never leave
// the non-mixing `.playAndRecord` session active while backgrounded, or iOS
// re-asserts it on the next foreground and kills the user's music again.
OnAppEntersBackground {
guard self.audioEngine?.isRecording == false else { return }
self.audioEngine?.releaseAudioSession()
}
Function("releaseAudioSession") {
self.audioEngine?.releaseAudioSession()
}
Function("isRecording") { () -> Bool in
guard let audioEngine = self.audioEngine else {
print("AudioEngine not initialized")
return false
}
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")
return false
}
let isRecording = audioEngine.toggleRecording(val)
self.sendEvent(
ON_RECORDING_CHANGE_EVENT_NAME,
[
"data": isRecording
])
return isRecording
}
Function("getMicrophoneModeIOS") { () -> String in
if #available(iOS 15.0, *) {
let mode = AVCaptureDevice.preferredMicrophoneMode.rawValue
var micMode = ""
switch mode {
case 1:
micMode = MicrophoneMode.wideSpectrum.rawValue
case 2:
micMode = MicrophoneMode.voiceIsolation.rawValue
default:
micMode = MicrophoneMode.standard.rawValue
}
return micMode
}
print("Please update your ios")
return ""
}
Function("setMicrophoneModeIOS") {
if #available(iOS 15, *) {
if AVCaptureDevice.preferredMicrophoneMode != .voiceIsolation {
AVCaptureDevice.showSystemUserInterface(.microphoneModes)
}
} else {
print("This code only runs on iOS 14 and lower")
}
}
Function("tearDown") {
self.audioEngine?.tearDown()
self.audioEngine = nil
}
Function("restart") {
self.audioEngine?.resumeRecordingAndPlayer()
self.sendEvent(
ON_RECORDING_CHANGE_EVENT_NAME,
[
"data": audioEngine?.isRecording
])
}
Function("playPCMData") { (pcmData: Data) in
self.audioEngine?.playPCMData(pcmData)
}
Function("bypassVoiceProcessing") { (bypass: Bool) in
self.audioEngine?.bypassVoiceProcessing(bypass)
}
Function("isPlaying") { () -> Bool in
return self.audioEngine?.isPlaying ?? false
}
Function("stopPlayback") {
self.audioEngine?.stopPlayback()
}
Function("pausePlayback") {
self.audioEngine?.pausePlayback()
}
Function("resumePlayback") {
self.audioEngine?.resumePlayback()
}
AsyncFunction("getMicrophonePermissionsAsync") { (promise: Promise) in
EXPermissionsMethodsDelegate.getPermissionWithPermissionsManager(
self.appContext?.permissions,
withRequester: MicrophonePermissionRequester.self,
resolve: promise.resolver,
reject: promise.legacyRejecter
)
}
AsyncFunction("requestMicrophonePermissionsAsync") { (promise: Promise) in
EXPermissionsMethodsDelegate.askForPermission(
withPermissionsManager: self.appContext?.permissions,
withRequester: MicrophonePermissionRequester.self,
resolve: promise.resolver,
reject: promise.legacyRejecter
)
}
// Define the events that can be emitted
Events([
ON_MIC_DATA_EVENT_NAME,
ON_INPUT_VOLUME_LEVEL_EVENT_NAME,
ON_OUTPUT_VOLUME_LEVEL_EVENT_NAME,
ON_RECORDING_CHANGE_EVENT_NAME,
ON_AUDIO_INTERRUPTION_EVENT_NAME,
])
}
private func setupMicrophoneCallback() {
audioEngine?.onMicDataCallback = { [weak self] data in
self?.sendEvent(
ON_MIC_DATA_EVENT_NAME,
[
"data": data
])
}
}
private func setupInputAudioLevelCallback() {
audioEngine?.onInputVolumeCallback = { [weak self] level in
self?.sendEvent(
ON_INPUT_VOLUME_LEVEL_EVENT_NAME,
[
"data": level
])
}
}
private func setupOutputAudioLevelCallback() {
audioEngine?.onOutputVolumeCallback = { [weak self] level in
self?.sendEvent(
ON_OUTPUT_VOLUME_LEVEL_EVENT_NAME,
[
"data": level
])
}
}
private func setupAudioInterruptionCallback() {
audioEngine?.onAudioInterruptionCallback = { [weak self] data in
self?.sendEvent(
ON_AUDIO_INTERRUPTION_EVENT_NAME,
[
"data": data
])
self?.sendEvent(
ON_RECORDING_CHANGE_EVENT_NAME,
[
"data": self?.audioEngine?.isRecording
])
}
}
enum MicrophoneMode: String, Enumerable {
case standard
case voiceIsolation
case wideSpectrum
}
}