mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
refactor(mobile): drop the unused playback path from the dictation audio module (#39293)
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
# Orca Two Way Audio
|
||||
|
||||
Vendored Expo module for capturing and playing PCM audio data in the Orca mobile app (iOS and Android).
|
||||
Vendored Expo module for capturing PCM microphone audio in the Orca mobile app (iOS and Android). Orca uses it for voice dictation; the upstream playback path was removed because nothing played audio through it.
|
||||
|
||||
The aim of the module is to facilitate creating real-time conversational apps. The following features are provided:
|
||||
The following features are provided:
|
||||
|
||||
- Request audio recording permissions
|
||||
- Get clean (applying Acoustic Echo Cancelling) microphone samples in PCM format (1 channel 16 bit at 16kHz)
|
||||
- Play audio samples in PCM format (1 channel 16 bit at 16kHz). Playback happens through main speaker unless external audio sources are connected.
|
||||
- Provide volume level both for the input and output samples. Float between 0 and 1.
|
||||
- Provide the input volume level. Float between 0 and 1.
|
||||
- [iOS only] Get microphone mode and prompt user to select a microphone mode.
|
||||
|
||||
## Installation
|
||||
@@ -39,22 +38,6 @@ npm i @orca/expo-two-way-audio
|
||||
|
||||
```
|
||||
|
||||
1. Play audio
|
||||
|
||||
> [!NOTE]
|
||||
> The sample below uses the `buffer` module:
|
||||
> `npm install buffer`
|
||||
|
||||
```JSX
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
// As an example, play pcm data hardcoded in a variable.
|
||||
const audioChunk = "SOME PCM DATA BASE64 ENCODED HERE"
|
||||
const buffer = Buffer.from(audioChunk, "base64");
|
||||
const pcmData = new Uint8Array(buffer);
|
||||
playPCMData(pcmData);
|
||||
```
|
||||
|
||||
1. Get microphone samples
|
||||
|
||||
```JSX
|
||||
|
||||
+4
-119
@@ -8,7 +8,6 @@ import android.media.AudioFocusRequest
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioRecord
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
@@ -16,10 +15,7 @@ import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import java.util.Queue
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.pow
|
||||
|
||||
|
||||
@@ -30,32 +26,22 @@ class AudioEngine (context: Context) {
|
||||
|
||||
private lateinit var audioRecord: AudioRecord
|
||||
private lateinit var audioManager: AudioManager
|
||||
private lateinit var audioTrack: AudioTrack
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
|
||||
private var audioDeviceCallback: android.media.AudioDeviceCallback? = null
|
||||
private val audioSampleQueue: Queue<ByteArray> = ConcurrentLinkedQueue()
|
||||
private val playbackRunning = AtomicBoolean(false)
|
||||
private var echoCanceler: AcousticEchoCanceler? = null
|
||||
private var noiseSuppressor: NoiseSuppressor? = null
|
||||
private val executorServiceMicrophone = Executors.newFixedThreadPool(1)
|
||||
private val executorServicePlayback = Executors.newFixedThreadPool(1)
|
||||
private var speakerDevice: AudioDeviceInfo? = null
|
||||
private var bridgeWindowStartedAtMs = System.currentTimeMillis()
|
||||
private var micEvents = 0
|
||||
private var micBytes = 0L
|
||||
private var playbackEvents = 0
|
||||
private var playbackQueuedBytes = 0L
|
||||
private var playbackWrites = 0
|
||||
private var playbackWriteBytes = 0L
|
||||
|
||||
var isRecording = false
|
||||
private var isRecordingBeforePause = false
|
||||
var isPlaying = false
|
||||
|
||||
var onMicDataCallback: ((ByteArray) -> Unit)? = null
|
||||
var onInputVolumeCallback: ((Float) -> Unit)? = null
|
||||
var onOutputVolumeCallback: ((Float) -> Unit)? = null
|
||||
var onAudioInterruptionCallback: ((String) -> Unit)? = null
|
||||
|
||||
init {
|
||||
@@ -70,18 +56,11 @@ class AudioEngine (context: Context) {
|
||||
}
|
||||
Log.d(
|
||||
"AudioEngine",
|
||||
"[bridge] $reason mic=$micEvents" +
|
||||
"ev/${micBytes}B playbackQueue=$playbackEvents" +
|
||||
"ev/${playbackQueuedBytes}B playbackWrites=$playbackWrites" +
|
||||
"ev/${playbackWriteBytes}B windowMs=$elapsedMs"
|
||||
"[bridge] $reason mic=${micEvents}ev/${micBytes}B windowMs=$elapsedMs"
|
||||
)
|
||||
bridgeWindowStartedAtMs = now
|
||||
micEvents = 0
|
||||
micBytes = 0
|
||||
playbackEvents = 0
|
||||
playbackQueuedBytes = 0
|
||||
playbackWrites = 0
|
||||
playbackWriteBytes = 0
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
@@ -111,29 +90,6 @@ class AudioEngine (context: Context) {
|
||||
} else {
|
||||
updateLegacyAudioRouting()
|
||||
}
|
||||
|
||||
val bufferSize = AudioTrack.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AUDIO_FORMAT
|
||||
)
|
||||
|
||||
audioTrack = AudioTrack(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AUDIO_FORMAT)
|
||||
.setSampleRate(SAMPLE_RATE)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build(),
|
||||
bufferSize,
|
||||
AudioTrack.MODE_STREAM,
|
||||
audioManager.generateAudioSessionId()
|
||||
).apply {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
@@ -195,10 +151,9 @@ class AudioEngine (context: Context) {
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
Log.d("AudioEngine", "Audio focus lost")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
pauseRecordingAndPlayer()
|
||||
pauseRecording()
|
||||
} else {
|
||||
stopRecording()
|
||||
stopPlayback()
|
||||
// Pre-Q stops for good rather than pausing, so the focus goes back too.
|
||||
abandonAudioFocus()
|
||||
}
|
||||
@@ -339,47 +294,6 @@ class AudioEngine (context: Context) {
|
||||
return isRecording
|
||||
}
|
||||
|
||||
fun playPCMData(data: ByteArray) {
|
||||
audioSampleQueue.add(data)
|
||||
playbackEvents += 1
|
||||
playbackQueuedBytes += data.size.toLong()
|
||||
flushBridgeStats("queue")
|
||||
if (playbackRunning.compareAndSet(false, true)) {
|
||||
playAudioFromSampleQueue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun playAudioFromSampleQueue() {
|
||||
executorServicePlayback.execute{
|
||||
isPlaying = true
|
||||
try {
|
||||
while (true){
|
||||
val data = audioSampleQueue.poll() ?: break
|
||||
playSample(data)
|
||||
val audioVolume = calculateRMSLevel(data)
|
||||
onOutputVolumeCallback?.invoke(audioVolume)
|
||||
}
|
||||
}catch (e: Exception){
|
||||
Log.e("AudioEngine", "Error playing audio", e)
|
||||
e.printStackTrace()
|
||||
}finally {
|
||||
playbackRunning.set(false)
|
||||
isPlaying = false
|
||||
onOutputVolumeCallback?.invoke(0.0F)
|
||||
if (audioSampleQueue.isNotEmpty() && playbackRunning.compareAndSet(false, true)) {
|
||||
playAudioFromSampleQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playSample(data: ByteArray) {
|
||||
val written = audioTrack.write(data, 0, data.size)
|
||||
playbackWrites += 1
|
||||
playbackWriteBytes += written.coerceAtLeast(0).toLong()
|
||||
flushBridgeStats("write")
|
||||
}
|
||||
|
||||
fun bypassVoiceProcessing(bypass: Boolean) {
|
||||
if (bypass) {
|
||||
echoCanceler?.enabled = false
|
||||
@@ -391,14 +305,13 @@ class AudioEngine (context: Context) {
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun pauseRecordingAndPlayer() {
|
||||
fun pauseRecording() {
|
||||
isRecordingBeforePause = isRecording
|
||||
isRecording = toggleRecording(false, clearPauseResume = false)
|
||||
audioTrack.pause()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun resumeRecordingAndPlayer() {
|
||||
fun resumeRecording() {
|
||||
requestAudioFocus()
|
||||
// Consume the flag: a second resume without an intervening pause must not reopen the microphone.
|
||||
val shouldResumeRecording = isRecordingBeforePause
|
||||
@@ -407,42 +320,15 @@ class AudioEngine (context: Context) {
|
||||
if (shouldResumeRecording) {
|
||||
isRecording = toggleRecording(true)
|
||||
}
|
||||
audioTrack.play()
|
||||
}
|
||||
|
||||
fun stopPlayback() {
|
||||
audioSampleQueue.clear()
|
||||
audioTrack.pause()
|
||||
audioTrack.flush()
|
||||
playbackRunning.set(false)
|
||||
isPlaying = false
|
||||
onOutputVolumeCallback?.invoke(0.0F)
|
||||
Log.d("AudioEngine", "Playback stopped")
|
||||
}
|
||||
|
||||
fun pausePlayback() {
|
||||
audioTrack.pause()
|
||||
Log.d("AudioEngine", "Playback paused")
|
||||
}
|
||||
|
||||
fun resumePlayback() {
|
||||
audioTrack.play()
|
||||
Log.d("AudioEngine", "Playback resumed")
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
fun tearDown() {
|
||||
stopRecording()
|
||||
if (::audioTrack.isInitialized) {
|
||||
audioTrack.stop()
|
||||
audioTrack.release()
|
||||
}
|
||||
echoCanceler?.release()
|
||||
echoCanceler = null
|
||||
noiseSuppressor?.release()
|
||||
noiseSuppressor = null
|
||||
audioSampleQueue.clear()
|
||||
playbackRunning.set(false)
|
||||
audioManager.mode = AudioManager.MODE_NORMAL
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
audioDeviceCallback?.let { audioManager.unregisterAudioDeviceCallback(it) }
|
||||
@@ -453,7 +339,6 @@ class AudioEngine (context: Context) {
|
||||
}
|
||||
abandonAudioFocus()
|
||||
executorServiceMicrophone.shutdownNow()
|
||||
executorServicePlayback.shutdownNow()
|
||||
}
|
||||
|
||||
|
||||
|
||||
+4
-4
@@ -6,13 +6,13 @@ import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
||||
class ExpoTwoWayAudioLifeCycleListener : ReactActivityLifecycleListener {
|
||||
override fun onPause(activity: Activity?) {
|
||||
super.onPause(activity)
|
||||
// At the moment background audio recording and playback is not supported
|
||||
ExpoTwoWayAudioModule.audioEngine?.pauseRecordingAndPlayer()
|
||||
// At the moment background audio recording is not supported
|
||||
ExpoTwoWayAudioModule.audioEngine?.pauseRecording()
|
||||
}
|
||||
|
||||
override fun onResume(activity: Activity?) {
|
||||
super.onResume(activity)
|
||||
// At the moment background audio recording and playback is not supported
|
||||
ExpoTwoWayAudioModule.audioEngine?.resumeRecordingAndPlayer()
|
||||
// At the moment background audio recording is not supported
|
||||
ExpoTwoWayAudioModule.audioEngine?.resumeRecording()
|
||||
}
|
||||
}
|
||||
|
||||
+1
-26
@@ -10,7 +10,6 @@ class ExpoTwoWayAudioModule : Module() {
|
||||
companion object {
|
||||
private const val ON_MIC_DATA_EVENT = "onMicrophoneData"
|
||||
private const val ON_INPUT_VOLUME_LEVEL_EVENT = "onInputVolumeLevelData"
|
||||
private const val ON_OUTPUT_VOLUME_LEVEL_EVENT = "onOutputVolumeLevelData"
|
||||
private const val ON_RECORDING_CHANGE_EVENT = "onRecordingChange"
|
||||
private const val ON_AUDIO_INTERRUPTION_EVENT = "onAudioInterruption"
|
||||
var audioEngine: AudioEngine? = null
|
||||
@@ -51,36 +50,16 @@ class ExpoTwoWayAudioModule : Module() {
|
||||
}
|
||||
|
||||
Function("restart") {
|
||||
audioEngine?.resumeRecordingAndPlayer()
|
||||
audioEngine?.resumeRecording()
|
||||
sendEvent(ON_RECORDING_CHANGE_EVENT, mapOf(
|
||||
"data" to (audioEngine?.isRecording ?: false)
|
||||
))
|
||||
}
|
||||
|
||||
Function("playPCMData") { data: kotlin.ByteArray ->
|
||||
audioEngine?.playPCMData(data)
|
||||
}
|
||||
|
||||
Function("bypassVoiceProcessing") { bypass: Boolean ->
|
||||
audioEngine?.bypassVoiceProcessing(bypass)
|
||||
}
|
||||
|
||||
Function("isPlaying") {
|
||||
audioEngine?.isPlaying ?: false
|
||||
}
|
||||
|
||||
Function("stopPlayback") {
|
||||
audioEngine?.stopPlayback()
|
||||
}
|
||||
|
||||
Function("pausePlayback") {
|
||||
audioEngine?.pausePlayback()
|
||||
}
|
||||
|
||||
Function("resumePlayback") {
|
||||
audioEngine?.resumePlayback()
|
||||
}
|
||||
|
||||
Function("getMicrophoneModeIOS") {
|
||||
throw UnsupportedOperationException("getMicrophoneModeIOS is only supported on iOS")
|
||||
}
|
||||
@@ -118,7 +97,6 @@ class ExpoTwoWayAudioModule : Module() {
|
||||
Events(
|
||||
ON_MIC_DATA_EVENT,
|
||||
ON_INPUT_VOLUME_LEVEL_EVENT,
|
||||
ON_OUTPUT_VOLUME_LEVEL_EVENT,
|
||||
ON_RECORDING_CHANGE_EVENT,
|
||||
ON_AUDIO_INTERRUPTION_EVENT
|
||||
)
|
||||
@@ -132,9 +110,6 @@ class ExpoTwoWayAudioModule : Module() {
|
||||
onInputVolumeCallback = { level ->
|
||||
sendEvent(ON_INPUT_VOLUME_LEVEL_EVENT, bundleOf("data" to level))
|
||||
}
|
||||
onOutputVolumeCallback = { level ->
|
||||
sendEvent(ON_OUTPUT_VOLUME_LEVEL_EVENT, bundleOf("data" to level))
|
||||
}
|
||||
onAudioInterruptionCallback = { data ->
|
||||
sendEvent(ON_AUDIO_INTERRUPTION_EVENT, bundleOf("data" to data))
|
||||
sendEvent(ON_RECORDING_CHANGE_EVENT, bundleOf(
|
||||
|
||||
@@ -3,7 +3,6 @@ import Foundation
|
||||
|
||||
class AudioEngine {
|
||||
private var avAudioEngine = AVAudioEngine()
|
||||
private var speechPlayer = AVAudioPlayerNode()
|
||||
private var engineConfigChangeObserver: Any?
|
||||
private var sessionInterruptionObserver: Any?
|
||||
private var mediaServicesResetObserver: Any?
|
||||
@@ -15,28 +14,16 @@ class AudioEngine {
|
||||
|
||||
public var onMicDataCallback: ((Data) -> Void)?
|
||||
public var onInputVolumeCallback: ((Float) -> Void)?
|
||||
public var onOutputVolumeCallback: ((Float) -> Void)?
|
||||
public var onAudioInterruptionCallback: ((String) -> Void)?
|
||||
|
||||
private var inputLevelTimer: Timer?
|
||||
private var outputLevelTimer: Timer?
|
||||
|
||||
private var inputBuffer = [Float](repeating: 0, count: 2048)
|
||||
private var outputBuffer = [Float](repeating: 0, count: 2048)
|
||||
private var inputBufferIndex = 0
|
||||
private var outputBufferIndex = 0
|
||||
|
||||
private var hasFirstInputBeenDiscarded = false
|
||||
private var discardRecording = false
|
||||
private var discardFirstInputMillis = 2000
|
||||
|
||||
enum AudioEngineError: Error {
|
||||
case audioFormatError
|
||||
}
|
||||
|
||||
init() throws {
|
||||
avAudioEngine.attach(speechPlayer)
|
||||
|
||||
guard let format = AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1) else {
|
||||
throw AudioEngineError.audioFormatError
|
||||
}
|
||||
@@ -121,25 +108,16 @@ class AudioEngine {
|
||||
|
||||
avAudioEngine.inputNode.isVoiceProcessingInputMuted = !isRecording
|
||||
|
||||
let output = avAudioEngine.outputNode
|
||||
let mainMixer = avAudioEngine.mainMixerNode
|
||||
|
||||
avAudioEngine.connect(speechPlayer, to: mainMixer, format: voiceIOFormat)
|
||||
avAudioEngine.connect(mainMixer, to: output, format: voiceIOFormat)
|
||||
// Voice processing runs input and output as one unit, so keep the output side wired even though nothing plays.
|
||||
avAudioEngine.connect(avAudioEngine.mainMixerNode, to: avAudioEngine.outputNode, format: voiceIOFormat)
|
||||
|
||||
input.installTap(onBus: 0, bufferSize: 2048, format: voiceIOFormat) { [weak self] buffer, when in
|
||||
// Skip mic processing while playback’s echo canceller adapts; see playPCMData.
|
||||
if self?.isRecording == true && self?.discardRecording == false {
|
||||
if self?.isRecording == true {
|
||||
self?.processMicrophoneBuffer(buffer)
|
||||
self?.updateInputVolume()
|
||||
}
|
||||
}
|
||||
|
||||
mainMixer.installTap(onBus: 0, bufferSize: 2048, format: voiceIOFormat) { [weak self] buffer, when in
|
||||
self?.processOutputBuffer(buffer)
|
||||
self?.updateOutputVolume()
|
||||
}
|
||||
|
||||
avAudioEngine.prepare()
|
||||
}
|
||||
|
||||
@@ -165,21 +143,6 @@ class AudioEngine {
|
||||
onMicDataCallback?(data)
|
||||
}
|
||||
|
||||
func processOutputBuffer(_ buffer: AVAudioPCMBuffer) {
|
||||
guard let channelData = buffer.floatChannelData?[0] else {
|
||||
print("Error: Could not access channel data")
|
||||
return
|
||||
}
|
||||
|
||||
let frameCount = Int(buffer.frameLength)
|
||||
|
||||
for i in 0..<frameCount {
|
||||
let floatSample = max(-1.0, min(1.0, channelData[i]))
|
||||
outputBuffer[outputBufferIndex] = floatSample
|
||||
outputBufferIndex = (outputBufferIndex + 1) % outputBuffer.count
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !isTornDown else { return }
|
||||
do {
|
||||
@@ -189,55 +152,6 @@ class AudioEngine {
|
||||
}
|
||||
}
|
||||
|
||||
func playPCMData(_ pcmData: Data) {
|
||||
guard !isTornDown else { return }
|
||||
// AEC misses the first playback chunks; briefly discard mic callbacks (without muting) while it adapts.
|
||||
if !hasFirstInputBeenDiscarded {
|
||||
self.hasFirstInputBeenDiscarded = true
|
||||
self.discardRecording = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(discardFirstInputMillis)) {
|
||||
self.discardRecording = false
|
||||
}
|
||||
}
|
||||
|
||||
guard let buffer = createBuffer(from: pcmData) else {
|
||||
print("Failed to create audio buffer")
|
||||
return
|
||||
}
|
||||
speechPlayer.scheduleBuffer(buffer)
|
||||
|
||||
if !speechPlayer.isPlaying {
|
||||
speechPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func createBuffer(from data: Data) -> AVAudioPCMBuffer? {
|
||||
let frameCount = UInt32(data.count) / 2 // 16-bit input = 2 bytes per frame
|
||||
|
||||
let format = AVAudioFormat(commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
interleaved: false)!
|
||||
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
buffer.frameLength = frameCount
|
||||
|
||||
data.withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) in
|
||||
if let sourcePtr = rawBufferPointer.baseAddress?.assumingMemoryBound(to: Int16.self),
|
||||
let destPtr = buffer.floatChannelData?[0] {
|
||||
for i in 0..<Int(frameCount) {
|
||||
destPtr[i] = Float(sourcePtr[i]) / Float(Int16.max)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
func bypassVoiceProcessing(_ bypass: Bool) {
|
||||
let input = avAudioEngine.inputNode
|
||||
input.isVoiceProcessingBypassed = bypass
|
||||
@@ -276,22 +190,13 @@ class AudioEngine {
|
||||
return isRecording
|
||||
}
|
||||
|
||||
func stopRecordingAndPlayer(clearInterruptionResume: Bool = true){
|
||||
func stopRecording(clearInterruptionResume: Bool = true){
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
print("Could not set the audio session to inactive: \(error)")
|
||||
}
|
||||
toggleRecording(false, preservingInterruptionIntent: !clearInterruptionResume)
|
||||
speechPlayer.stop()
|
||||
updateOutputVolume()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func resumeRecordingAndPlayer() -> Bool {
|
||||
guard toggleRecording(true) else { return false }
|
||||
speechPlayer.play()
|
||||
return true
|
||||
}
|
||||
|
||||
func tearDown() {
|
||||
@@ -299,31 +204,7 @@ class AudioEngine {
|
||||
isTornDown = true
|
||||
// Stop I/O first; deactivating a session with running I/O returns a busy error.
|
||||
avAudioEngine.stop()
|
||||
stopRecordingAndPlayer()
|
||||
}
|
||||
|
||||
var isPlaying: Bool {
|
||||
return speechPlayer.isPlaying
|
||||
}
|
||||
|
||||
func stopPlayback() {
|
||||
speechPlayer.stop()
|
||||
outputBuffer = [Float](repeating: 0, count: 2048)
|
||||
updateOutputVolume()
|
||||
print("Playback stopped")
|
||||
}
|
||||
|
||||
func pausePlayback() {
|
||||
speechPlayer.pause()
|
||||
print("Playback paused")
|
||||
}
|
||||
|
||||
func resumePlayback() {
|
||||
guard !isTornDown else { return }
|
||||
if !speechPlayer.isPlaying {
|
||||
speechPlayer.play()
|
||||
print("Playback resumed")
|
||||
}
|
||||
stopRecording()
|
||||
}
|
||||
|
||||
private func checkEngineIsRunning() {
|
||||
@@ -342,14 +223,14 @@ class AudioEngine {
|
||||
switch type {
|
||||
case .began:
|
||||
interruptionIntent.began(wasRecording: isRecording)
|
||||
self.stopRecordingAndPlayer(clearInterruptionResume: false)
|
||||
self.stopRecording(clearInterruptionResume: false)
|
||||
onAudioInterruptionCallback?("began")
|
||||
case .ended:
|
||||
let rawOptions = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
||||
let shouldResume = AVAudioSession.InterruptionOptions(rawValue: rawOptions).contains(.shouldResume)
|
||||
switch interruptionIntent.ended(shouldResume: shouldResume, isRecording: isRecording) {
|
||||
case .resume:
|
||||
let resumed = resumeRecordingAndPlayer()
|
||||
let resumed = toggleRecording(true)
|
||||
onAudioInterruptionCallback?(resumed ? "ended" : "blocked")
|
||||
case .blocked:
|
||||
onAudioInterruptionCallback?("blocked")
|
||||
@@ -357,7 +238,7 @@ class AudioEngine {
|
||||
break // A newer explicit start already owns the microphone.
|
||||
}
|
||||
@unknown default:
|
||||
stopRecordingAndPlayer()
|
||||
stopRecording()
|
||||
onAudioInterruptionCallback?("blocked")
|
||||
}
|
||||
}
|
||||
@@ -381,11 +262,6 @@ class AudioEngine {
|
||||
onInputVolumeCallback?(volume)
|
||||
}
|
||||
|
||||
private func updateOutputVolume() {
|
||||
let volume = calculateRMSLevel(from: outputBuffer)
|
||||
onOutputVolumeCallback?(volume)
|
||||
}
|
||||
|
||||
private func calculateRMSLevel(from buffer: [Float]) -> Float {
|
||||
let epsilon: Float = 1e-5 // To avoid log(0)
|
||||
let rmsValue = sqrt(buffer.reduce(0) { $0 + $1 * $1 } / Float(buffer.count))
|
||||
|
||||
@@ -3,7 +3,6 @@ import AVFoundation
|
||||
|
||||
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"
|
||||
|
||||
@@ -31,7 +30,6 @@ public class ExpoTwoWayAudioModule: Module {
|
||||
self.audioEngine = try AudioEngine()
|
||||
self.setupMicrophoneCallback()
|
||||
self.setupInputAudioLevelCallback()
|
||||
self.setupOutputAudioLevelCallback()
|
||||
self.setupAudioInterruptionCallback()
|
||||
return true
|
||||
} catch {
|
||||
@@ -98,7 +96,7 @@ public class ExpoTwoWayAudioModule: Module {
|
||||
}
|
||||
|
||||
Function("restart") {
|
||||
self.audioEngine?.resumeRecordingAndPlayer()
|
||||
_ = self.audioEngine?.toggleRecording(true)
|
||||
self.sendEvent(
|
||||
ON_RECORDING_CHANGE_EVENT_NAME,
|
||||
[
|
||||
@@ -107,30 +105,10 @@ public class ExpoTwoWayAudioModule: Module {
|
||||
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -152,7 +130,6 @@ public class ExpoTwoWayAudioModule: Module {
|
||||
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,
|
||||
])
|
||||
@@ -178,16 +155,6 @@ public class ExpoTwoWayAudioModule: Module {
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -6,7 +6,6 @@ type EventSubscription = {
|
||||
|
||||
type ExpoTwoWayAudioWebModule = {
|
||||
initialize: () => Promise<boolean>
|
||||
playPCMData: (audioData: Uint8Array) => void
|
||||
bypassVoiceProcessing: (bypass: boolean) => void
|
||||
toggleRecording: (val: boolean) => boolean
|
||||
isRecording: () => boolean
|
||||
@@ -16,10 +15,6 @@ type ExpoTwoWayAudioWebModule = {
|
||||
requestMicrophonePermissionsAsync: () => Promise<PermissionResponse>
|
||||
getMicrophoneModeIOS: () => null
|
||||
setMicrophoneModeIOS: () => void
|
||||
isPlaying: () => boolean
|
||||
stopPlayback: () => void
|
||||
pausePlayback: () => void
|
||||
resumePlayback: () => void
|
||||
addListener: (eventName: string, handler: (ev: unknown) => void) => EventSubscription
|
||||
}
|
||||
|
||||
@@ -35,7 +30,6 @@ const noop = () => undefined
|
||||
const ExpoTwoWayAudioModule: ExpoTwoWayAudioWebModule = {
|
||||
// QA browser has no shell microphone; page dictation uses platform/dictation-capture.web.ts native.audio.start|read|stop instead.
|
||||
initialize: async () => false,
|
||||
playPCMData: noop,
|
||||
bypassVoiceProcessing: noop,
|
||||
toggleRecording: () => false,
|
||||
isRecording: () => false,
|
||||
@@ -45,10 +39,6 @@ const ExpoTwoWayAudioModule: ExpoTwoWayAudioWebModule = {
|
||||
requestMicrophonePermissionsAsync: async () => deniedMicrophonePermission,
|
||||
getMicrophoneModeIOS: () => null,
|
||||
setMicrophoneModeIOS: noop,
|
||||
isPlaying: () => false,
|
||||
stopPlayback: noop,
|
||||
pausePlayback: noop,
|
||||
resumePlayback: noop,
|
||||
addListener: () => ({ remove: noop })
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,6 @@ export async function initialize() {
|
||||
return await ExpoTwoWayAudioModule.initialize()
|
||||
}
|
||||
|
||||
export function playPCMData(audioData: Uint8Array) {
|
||||
return ExpoTwoWayAudioModule.playPCMData(audioData)
|
||||
}
|
||||
|
||||
export function bypassVoiceProcessing(bypass: boolean) {
|
||||
return ExpoTwoWayAudioModule.bypassVoiceProcessing(bypass)
|
||||
}
|
||||
@@ -44,19 +40,3 @@ export function getMicrophoneModeIOS() {
|
||||
export function setMicrophoneModeIOS() {
|
||||
return ExpoTwoWayAudioModule.setMicrophoneModeIOS()
|
||||
}
|
||||
|
||||
export function isPlaying(): boolean {
|
||||
return ExpoTwoWayAudioModule.isPlaying()
|
||||
}
|
||||
|
||||
export function stopPlayback() {
|
||||
return ExpoTwoWayAudioModule.stopPlayback()
|
||||
}
|
||||
|
||||
export function pausePlayback() {
|
||||
return ExpoTwoWayAudioModule.pausePlayback()
|
||||
}
|
||||
|
||||
export function resumePlayback() {
|
||||
return ExpoTwoWayAudioModule.resumePlayback()
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface AudioInterruptionEvent {
|
||||
export interface ExpoTwoWayAudioEventMap {
|
||||
onMicrophoneData: MicrophoneDataEvent
|
||||
onInputVolumeLevelData: VolumeLevelEvent
|
||||
onOutputVolumeLevelData: VolumeLevelEvent
|
||||
onRecordingChange: RecordingChangeEvent
|
||||
onAudioInterruption: AudioInterruptionEvent
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
{
|
||||
"file": "packages/expo-two-way-audio/src/ExpoTwoWayAudioModule.web.ts",
|
||||
"reason": "Vendored with the module, not added for Route A. Nothing on the page reaches it any more: dictation's capture went behind src/platform/dictation-capture.web.ts, which asks the shell for the microphone, so the only importer of @orca/expo-two-way-audio is the native half of that seam. The file stays because it ships with the vendored module, and it answers denied microphone permission and no playback, which is what a browser outside the shell can honestly say."
|
||||
"reason": "Vendored with the module, not added for Route A. Nothing on the page reaches it any more: dictation's capture went behind src/platform/dictation-capture.web.ts, which asks the shell for the microphone, so the only importer of @orca/expo-two-way-audio is the native half of that seam. The file stays because it ships with the vendored module, and it answers denied microphone permission, which is what a browser outside the shell can honestly say."
|
||||
},
|
||||
{
|
||||
"file": "src/navigation/use-back-claim.web.ts",
|
||||
|
||||
Reference in New Issue
Block a user