mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 16:04:43 +00:00
* 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
230 lines
7.3 KiB
Swift
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
|
|
}
|
|
}
|