@edkimmel/expo-audio-stream 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NATIVE_EVENTS.md +37 -0
- package/README.md +13 -0
- package/android/src/main/java/expo/modules/audiostream/ExpoPlayAudioStreamModule.kt +5 -0
- package/android/src/main/java/expo/modules/audiostream/pipeline/AudioPipeline.kt +152 -10
- package/android/src/main/java/expo/modules/audiostream/pipeline/PipelineIntegration.kt +16 -0
- package/build/events.d.ts +4 -0
- package/build/events.d.ts.map +1 -1
- package/build/events.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +8 -3
- package/build/index.js.map +1 -1
- package/build/pipeline/index.d.ts +14 -1
- package/build/pipeline/index.d.ts.map +1 -1
- package/build/pipeline/index.js +15 -0
- package/build/pipeline/index.js.map +1 -1
- package/build/pipeline/types.d.ts +34 -0
- package/build/pipeline/types.d.ts.map +1 -1
- package/build/pipeline/types.js.map +1 -1
- package/build/types.d.ts +8 -0
- package/build/types.d.ts.map +1 -1
- package/build/types.js.map +1 -1
- package/ios/AudioPipeline.swift +67 -2
- package/ios/ExpoPlayAudioStreamModule.swift +40 -0
- package/ios/Microphone.swift +116 -25
- package/ios/PipelineIntegration.swift +11 -1
- package/package.json +1 -2
- package/plugin/build/index.js +5 -0
- package/plugin/src/index.ts +5 -0
- package/src/events.ts +4 -0
- package/src/index.ts +12 -2
- package/src/pipeline/index.ts +17 -0
- package/src/pipeline/types.ts +36 -0
- package/src/types.ts +9 -0
package/ios/Microphone.swift
CHANGED
|
@@ -28,6 +28,14 @@ class Microphone {
|
|
|
28
28
|
private var isSilent: Bool = false
|
|
29
29
|
private var frequencyBandAnalyzer: FrequencyBandAnalyzer?
|
|
30
30
|
private var frequencyBandConfig: (lowCrossoverHz: Float, highCrossoverHz: Float)?
|
|
31
|
+
|
|
32
|
+
/// Interval (in ms) the consumer last requested. Used to rebuild the tap
|
|
33
|
+
/// with the same cadence when resuming after an interruption.
|
|
34
|
+
private var lastIntervalMs: Int = 100
|
|
35
|
+
/// Set when an audio session interruption begins while recording is active.
|
|
36
|
+
/// Cleared either by `stopRecording` (consumer chose disconnect) or by the
|
|
37
|
+
/// `.ended` handler after a successful auto-resume.
|
|
38
|
+
private var pendingInterruptionResume: Bool = false
|
|
31
39
|
|
|
32
40
|
init() {
|
|
33
41
|
NotificationCenter.default.addObserver(
|
|
@@ -36,8 +44,18 @@ class Microphone {
|
|
|
36
44
|
name: AVAudioSession.routeChangeNotification,
|
|
37
45
|
object: nil
|
|
38
46
|
)
|
|
47
|
+
NotificationCenter.default.addObserver(
|
|
48
|
+
self,
|
|
49
|
+
selector: #selector(handleInterruption),
|
|
50
|
+
name: AVAudioSession.interruptionNotification,
|
|
51
|
+
object: nil
|
|
52
|
+
)
|
|
39
53
|
}
|
|
40
|
-
|
|
54
|
+
|
|
55
|
+
deinit {
|
|
56
|
+
NotificationCenter.default.removeObserver(self)
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
/// Handles audio route changes (e.g. headphones connected/disconnected)
|
|
42
60
|
/// - Parameter notification: The notification object containing route change information
|
|
43
61
|
@objc private func handleRouteChange(notification: Notification) {
|
|
@@ -46,7 +64,7 @@ class Microphone {
|
|
|
46
64
|
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
|
|
47
65
|
return
|
|
48
66
|
}
|
|
49
|
-
|
|
67
|
+
|
|
50
68
|
Logger.debug("[Microphone] Route is changed \(reason)")
|
|
51
69
|
|
|
52
70
|
switch reason {
|
|
@@ -55,7 +73,7 @@ class Microphone {
|
|
|
55
73
|
stopRecording(resolver: nil)
|
|
56
74
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
57
75
|
guard let self = self, let settings = self.recordingSettings else { return }
|
|
58
|
-
|
|
76
|
+
|
|
59
77
|
_ = startRecording(settings: self.recordingSettings!, intervalMilliseconds: 100, frequencyBandConfig: self.frequencyBandConfig)
|
|
60
78
|
}
|
|
61
79
|
}
|
|
@@ -65,6 +83,96 @@ class Microphone {
|
|
|
65
83
|
break
|
|
66
84
|
}
|
|
67
85
|
}
|
|
86
|
+
|
|
87
|
+
/// Handles audio session interruptions (e.g. Siri, phone call, alarm).
|
|
88
|
+
///
|
|
89
|
+
/// On `.began` we stop the engine (iOS deactivates the session regardless)
|
|
90
|
+
/// but keep `isRecording = true` and emit an `INTERRUPTED` error so the
|
|
91
|
+
/// consumer can decide:
|
|
92
|
+
/// - Call `stopRecording` to disconnect explicitly (clears the resume flag).
|
|
93
|
+
/// - Do nothing to let the library auto-resume when the system gives the
|
|
94
|
+
/// mic back on `.ended`.
|
|
95
|
+
@objc private func handleInterruption(notification: Notification) {
|
|
96
|
+
guard let info = notification.userInfo,
|
|
97
|
+
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
98
|
+
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
switch type {
|
|
103
|
+
case .began:
|
|
104
|
+
Logger.debug("[Microphone] Audio session interruption began")
|
|
105
|
+
guard isRecording else { return }
|
|
106
|
+
// Tear down the engine but leave isRecording true so the consumer's
|
|
107
|
+
// intent is preserved. The .ended branch will decide whether to
|
|
108
|
+
// resume based on whether stopRecording was called in between.
|
|
109
|
+
pendingInterruptionResume = true
|
|
110
|
+
if let engine = audioEngine {
|
|
111
|
+
engine.inputNode.removeTap(onBus: 0)
|
|
112
|
+
engine.stop()
|
|
113
|
+
}
|
|
114
|
+
delegate?.onMicrophoneError("INTERRUPTED", "Audio session interrupted by system")
|
|
115
|
+
case .ended:
|
|
116
|
+
Logger.debug("[Microphone] Audio session interruption ended")
|
|
117
|
+
guard pendingInterruptionResume else { return }
|
|
118
|
+
pendingInterruptionResume = false
|
|
119
|
+
// iOS uses AVAudioSessionInterruptionOptionKey.shouldResume to hint
|
|
120
|
+
// whether the app can safely reactivate. Absent means another app
|
|
121
|
+
// took over (e.g. an ongoing call) — in that case we surface a
|
|
122
|
+
// terminal error instead of attempting a doomed restart.
|
|
123
|
+
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
|
124
|
+
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
|
125
|
+
guard options.contains(.shouldResume) else {
|
|
126
|
+
Logger.debug("[Microphone] System did not signal shouldResume — treating as terminal stop")
|
|
127
|
+
isRecording = false
|
|
128
|
+
delegate?.onMicrophoneError(
|
|
129
|
+
"RESUME_DENIED",
|
|
130
|
+
"System did not permit microphone resume after interruption"
|
|
131
|
+
)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
do {
|
|
135
|
+
try AVAudioSession.sharedInstance().setActive(true)
|
|
136
|
+
try installTapAndStartEngine(intervalMilliseconds: lastIntervalMs)
|
|
137
|
+
Logger.debug("[Microphone] Auto-resumed after interruption")
|
|
138
|
+
} catch {
|
|
139
|
+
Logger.debug("[Microphone] Auto-resume failed: \(error.localizedDescription)")
|
|
140
|
+
// Engine couldn't restart — surface as terminal stop.
|
|
141
|
+
isRecording = false
|
|
142
|
+
delegate?.onMicrophoneError(
|
|
143
|
+
"RESUME_FAILED",
|
|
144
|
+
"Could not restart microphone after interruption: \(error.localizedDescription)"
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
@unknown default:
|
|
148
|
+
break
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Installs the audio tap on the input node and starts the engine.
|
|
153
|
+
/// Shared between fresh `startRecording` and post-interruption resume.
|
|
154
|
+
private func installTapAndStartEngine(intervalMilliseconds: Int) throws {
|
|
155
|
+
let hardwareFormat = audioEngine.inputNode.inputFormat(forBus: 0)
|
|
156
|
+
let intervalSamples = AVAudioFrameCount(
|
|
157
|
+
Double(intervalMilliseconds) / 1000.0 * hardwareFormat.sampleRate
|
|
158
|
+
)
|
|
159
|
+
let tapBufferSize = max(intervalSamples, 256)
|
|
160
|
+
|
|
161
|
+
audioEngine.inputNode.installTap(onBus: 0, bufferSize: tapBufferSize, format: nil) { [weak self] (buffer, time) in
|
|
162
|
+
guard let self = self else { return }
|
|
163
|
+
|
|
164
|
+
guard buffer.frameLength > 0 else {
|
|
165
|
+
Logger.debug("Error: received empty buffer in tap callback")
|
|
166
|
+
self.delegate?.onMicrophoneError("READ_ERROR", "Received empty audio buffer")
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
self.processAudioBuffer(buffer)
|
|
171
|
+
self.lastBufferTime = time
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try audioEngine.start()
|
|
175
|
+
}
|
|
68
176
|
|
|
69
177
|
func toggleSilence(isSilent: Bool) {
|
|
70
178
|
Logger.debug("[Microphone] toggleSilence")
|
|
@@ -101,6 +209,7 @@ class Microphone {
|
|
|
101
209
|
recordingSettings = newSettings // Update the class property with the new settings
|
|
102
210
|
|
|
103
211
|
self.frequencyBandConfig = frequencyBandConfig
|
|
212
|
+
self.lastIntervalMs = intervalMilliseconds
|
|
104
213
|
// Analyzer uses the desired (target) sample rate, not hardware rate
|
|
105
214
|
let targetRate = Int(settings.desiredSampleRate ?? settings.sampleRate)
|
|
106
215
|
let fbConfig = frequencyBandConfig ?? (lowCrossoverHz: Float(300), highCrossoverHz: Float(2000))
|
|
@@ -110,30 +219,9 @@ class Microphone {
|
|
|
110
219
|
highCrossoverHz: fbConfig.highCrossoverHz
|
|
111
220
|
)
|
|
112
221
|
|
|
113
|
-
// Compute tap buffer size from interval so Core Audio delivers at the right cadence
|
|
114
|
-
let intervalSamples = AVAudioFrameCount(
|
|
115
|
-
Double(intervalMilliseconds) / 1000.0 * hardwareFormat.sampleRate
|
|
116
|
-
)
|
|
117
|
-
let tapBufferSize = max(intervalSamples, 256) // floor at 256 frames (~5ms at 48kHz)
|
|
118
|
-
|
|
119
|
-
// Pass nil for format to use the hardware's native format, avoiding format mismatch crashes.
|
|
120
|
-
// Core Audio does not support format conversion (e.g. Float32 -> Int16) on the tap itself.
|
|
121
|
-
audioEngine.inputNode.installTap(onBus: 0, bufferSize: tapBufferSize, format: nil) { [weak self] (buffer, time) in
|
|
122
|
-
guard let self = self else { return }
|
|
123
|
-
|
|
124
|
-
guard buffer.frameLength > 0 else {
|
|
125
|
-
Logger.debug("Error: received empty buffer in tap callback")
|
|
126
|
-
self.delegate?.onMicrophoneError("READ_ERROR", "Received empty audio buffer")
|
|
127
|
-
return
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
self.processAudioBuffer(buffer)
|
|
131
|
-
self.lastBufferTime = time
|
|
132
|
-
}
|
|
133
|
-
|
|
134
222
|
do {
|
|
135
223
|
startTime = Date()
|
|
136
|
-
try
|
|
224
|
+
try installTapAndStartEngine(intervalMilliseconds: intervalMilliseconds)
|
|
137
225
|
isRecording = true
|
|
138
226
|
Logger.debug("Debug: Recording started successfully.")
|
|
139
227
|
return StartRecordingResult(
|
|
@@ -151,6 +239,9 @@ class Microphone {
|
|
|
151
239
|
}
|
|
152
240
|
|
|
153
241
|
public func stopRecording(resolver promise: Promise?) {
|
|
242
|
+
// Clear resume intent up-front so a stopRecording call during an
|
|
243
|
+
// active interruption window cancels the auto-resume on .ended.
|
|
244
|
+
pendingInterruptionResume = false
|
|
154
245
|
guard self.isRecording else {
|
|
155
246
|
if let promiseResolver = promise {
|
|
156
247
|
promiseResolver.resolve(nil)
|
|
@@ -20,6 +20,7 @@ class PipelineIntegration: PipelineListener {
|
|
|
20
20
|
static let EVENT_ZOMBIE_DETECTED = "PipelineZombieDetected"
|
|
21
21
|
static let EVENT_UNDERRUN = "PipelineUnderrun"
|
|
22
22
|
static let EVENT_DRAINED = "PipelineDrained"
|
|
23
|
+
static let EVENT_PLAYBACK_STOPPED = "PipelinePlaybackStopped"
|
|
23
24
|
static let EVENT_AUDIO_FOCUS_LOST = "PipelineAudioFocusLost"
|
|
24
25
|
static let EVENT_AUDIO_FOCUS_RESUMED = "PipelineAudioFocusResumed"
|
|
25
26
|
static let EVENT_FREQUENCY_BANDS = "PipelineFrequencyBands"
|
|
@@ -72,7 +73,7 @@ class PipelineIntegration: PipelineListener {
|
|
|
72
73
|
sharedEngine: sharedEngine,
|
|
73
74
|
listener: self
|
|
74
75
|
)
|
|
75
|
-
p.connect()
|
|
76
|
+
try p.connect()
|
|
76
77
|
pipeline = p
|
|
77
78
|
|
|
78
79
|
return [
|
|
@@ -152,6 +153,11 @@ class PipelineIntegration: PipelineListener {
|
|
|
152
153
|
return pipeline?.getState().rawValue ?? PipelineState.idle.rawValue
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
/// Current platform output latency in milliseconds. Returns 0 if not connected.
|
|
157
|
+
func outputLatencyMs() -> Double {
|
|
158
|
+
return pipeline?.outputLatencyMs() ?? 0
|
|
159
|
+
}
|
|
160
|
+
|
|
155
161
|
/// Register the pipeline as a delegate on the shared engine.
|
|
156
162
|
/// Called by the module after connect() so route changes and interruptions
|
|
157
163
|
/// are forwarded to the AudioPipeline instance.
|
|
@@ -206,6 +212,10 @@ class PipelineIntegration: PipelineListener {
|
|
|
206
212
|
sendEvent(PipelineIntegration.EVENT_DRAINED, ["turnId": turnId])
|
|
207
213
|
}
|
|
208
214
|
|
|
215
|
+
func onPlaybackStopped(turnId: String) {
|
|
216
|
+
sendEvent(PipelineIntegration.EVENT_PLAYBACK_STOPPED, ["turnId": turnId])
|
|
217
|
+
}
|
|
218
|
+
|
|
209
219
|
func onAudioFocusLost() {
|
|
210
220
|
sendEvent(PipelineIntegration.EVENT_AUDIO_FOCUS_LOST, [:])
|
|
211
221
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edkimmel/expo-audio-stream",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Expo Play Audio Stream module",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"types": "build/index.d.ts",
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
"build": "expo-module build",
|
|
10
10
|
"clean": "expo-module clean",
|
|
11
11
|
"lint": "expo-module lint",
|
|
12
|
-
"test": "expo-module test",
|
|
13
12
|
"prepare": "expo-module prepare && husky || true",
|
|
14
13
|
"prepublishOnly": "expo-module prepublishOnly",
|
|
15
14
|
"expo-module": "expo-module",
|
package/plugin/build/index.js
CHANGED
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const config_plugins_1 = require("@expo/config-plugins");
|
|
4
4
|
const MICROPHONE_USAGE = 'Allow $(PRODUCT_NAME) to access your microphone';
|
|
5
|
+
// AVFoundation enumerates AirPlay/Continuity audio devices on the local network
|
|
6
|
+
// even though we don't use them — without this description, iOS shows a generic
|
|
7
|
+
// prompt and a denial leaves the audio session unable to activate.
|
|
8
|
+
const LOCAL_NETWORK_USAGE = 'Allow $(PRODUCT_NAME) to discover audio devices on your local network';
|
|
5
9
|
const withRecordingPermission = (config, existingPerms) => {
|
|
6
10
|
if (!existingPerms) {
|
|
7
11
|
console.warn('No previous permissions provided');
|
|
8
12
|
}
|
|
9
13
|
config = (0, config_plugins_1.withInfoPlist)(config, (config) => {
|
|
10
14
|
config.modResults['NSMicrophoneUsageDescription'] = config.modResults['NSMicrophoneUsageDescription'] || MICROPHONE_USAGE;
|
|
15
|
+
config.modResults['NSLocalNetworkUsageDescription'] = config.modResults['NSLocalNetworkUsageDescription'] || LOCAL_NETWORK_USAGE;
|
|
11
16
|
// Add audio to UIBackgroundModes to allow background audio recording
|
|
12
17
|
const existingBackgroundModes = config.modResults.UIBackgroundModes || [];
|
|
13
18
|
if (!existingBackgroundModes.includes('audio')) {
|
package/plugin/src/index.ts
CHANGED
|
@@ -6,6 +6,10 @@ import {
|
|
|
6
6
|
} from '@expo/config-plugins'
|
|
7
7
|
|
|
8
8
|
const MICROPHONE_USAGE = 'Allow $(PRODUCT_NAME) to access your microphone'
|
|
9
|
+
// AVFoundation enumerates AirPlay/Continuity audio devices on the local network
|
|
10
|
+
// even though we don't use them — without this description, iOS shows a generic
|
|
11
|
+
// prompt and a denial leaves the audio session unable to activate.
|
|
12
|
+
const LOCAL_NETWORK_USAGE = 'Allow $(PRODUCT_NAME) to discover audio devices on your local network'
|
|
9
13
|
|
|
10
14
|
const withRecordingPermission: ConfigPlugin<{
|
|
11
15
|
microphonePermission: string
|
|
@@ -15,6 +19,7 @@ const withRecordingPermission: ConfigPlugin<{
|
|
|
15
19
|
}
|
|
16
20
|
config = withInfoPlist(config, (config) => {
|
|
17
21
|
config.modResults['NSMicrophoneUsageDescription'] = config.modResults['NSMicrophoneUsageDescription'] || MICROPHONE_USAGE
|
|
22
|
+
config.modResults['NSLocalNetworkUsageDescription'] = config.modResults['NSLocalNetworkUsageDescription'] || LOCAL_NETWORK_USAGE
|
|
18
23
|
|
|
19
24
|
// Add audio to UIBackgroundModes to allow background audio recording
|
|
20
25
|
const existingBackgroundModes =
|
package/src/events.ts
CHANGED
|
@@ -21,6 +21,10 @@ export interface AudioEventPayload {
|
|
|
21
21
|
streamUuid: string;
|
|
22
22
|
soundLevel?: number;
|
|
23
23
|
frequencyBands?: { low: number; mid: number; high: number };
|
|
24
|
+
/** Set by native when a mid-recording error occurs (interruption, read failure).
|
|
25
|
+
* When present, `encoded` is absent and the recording is no longer active. */
|
|
26
|
+
error?: string;
|
|
27
|
+
errorMessage?: string;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export const DeviceReconnectedReasons = {
|
package/src/index.ts
CHANGED
|
@@ -59,9 +59,12 @@ export class ExpoPlayAudioStream {
|
|
|
59
59
|
}> {
|
|
60
60
|
let subscription: Subscription | undefined;
|
|
61
61
|
try {
|
|
62
|
-
const { onAudioStream, ...options } = recordingConfig;
|
|
62
|
+
const { onAudioStream, onError, ...options } = recordingConfig;
|
|
63
63
|
|
|
64
|
-
if (
|
|
64
|
+
if (
|
|
65
|
+
(onAudioStream && typeof onAudioStream == "function") ||
|
|
66
|
+
(onError && typeof onError == "function")
|
|
67
|
+
) {
|
|
65
68
|
subscription = addAudioEventListener(
|
|
66
69
|
async (event: AudioEventPayload) => {
|
|
67
70
|
const {
|
|
@@ -72,7 +75,13 @@ export class ExpoPlayAudioStream {
|
|
|
72
75
|
encoded,
|
|
73
76
|
soundLevel,
|
|
74
77
|
frequencyBands,
|
|
78
|
+
error,
|
|
79
|
+
errorMessage,
|
|
75
80
|
} = event;
|
|
81
|
+
if (error) {
|
|
82
|
+
onError?.({ code: error, message: errorMessage ?? "" });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
76
85
|
if (!encoded) {
|
|
77
86
|
console.error(
|
|
78
87
|
`[ExpoPlayAudioStream] Encoded audio data is missing`
|
|
@@ -259,6 +268,7 @@ export type {
|
|
|
259
268
|
PipelineZombieDetectedEvent,
|
|
260
269
|
PipelineUnderrunEvent,
|
|
261
270
|
PipelineDrainedEvent,
|
|
271
|
+
PipelinePlaybackStoppedEvent,
|
|
262
272
|
PipelineAudioFocusLostEvent,
|
|
263
273
|
PipelineAudioFocusResumedEvent,
|
|
264
274
|
} from "./pipeline";
|
package/src/pipeline/index.ts
CHANGED
|
@@ -103,6 +103,22 @@ export class Pipeline {
|
|
|
103
103
|
return ExpoPlayAudioStreamModule.getPipelineTelemetry() as PipelineTelemetry;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Query the platform's current output latency — i.e., how long after a
|
|
108
|
+
* sample is written to the native buffer before it actually leaves the
|
|
109
|
+
* speaker.
|
|
110
|
+
*
|
|
111
|
+
* Value can change mid-session, notably on audio route changes such as
|
|
112
|
+
* switching from built-in speaker to Bluetooth (Bluetooth typically adds
|
|
113
|
+
* 100+ ms). **Always query at the moment you care; do not cache.**
|
|
114
|
+
*
|
|
115
|
+
* Returns 0 if the pipeline is not connected or the platform cannot
|
|
116
|
+
* report a value.
|
|
117
|
+
*/
|
|
118
|
+
static getOutputLatencyMs(): number {
|
|
119
|
+
return ExpoPlayAudioStreamModule.getPipelineOutputLatencyMs() as number;
|
|
120
|
+
}
|
|
121
|
+
|
|
106
122
|
// ════════════════════════════════════════════════════════════════════════
|
|
107
123
|
// Event subscriptions
|
|
108
124
|
// ════════════════════════════════════════════════════════════════════════
|
|
@@ -211,6 +227,7 @@ export type {
|
|
|
211
227
|
PipelineZombieDetectedEvent,
|
|
212
228
|
PipelineUnderrunEvent,
|
|
213
229
|
PipelineDrainedEvent,
|
|
230
|
+
PipelinePlaybackStoppedEvent,
|
|
214
231
|
PipelineAudioFocusLostEvent,
|
|
215
232
|
PipelineAudioFocusResumedEvent,
|
|
216
233
|
} from './types';
|
package/src/pipeline/types.ts
CHANGED
|
@@ -6,6 +6,18 @@ import { PlaybackMode, FrequencyBandConfig, FrequencyBands } from "../types";
|
|
|
6
6
|
|
|
7
7
|
// ── Connect ─────────────────────────────────────────────────────────────────
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* How the pipeline's playback should coexist with other audio on the device.
|
|
11
|
+
*
|
|
12
|
+
* - `'mixWithOthers'` (default): plays alongside other apps without
|
|
13
|
+
* interrupting them. On Android no audio focus is requested. Best for
|
|
14
|
+
* sound effects and short clips.
|
|
15
|
+
* - `'duckOthers'`: requests audio focus with ducking. Other apps lower
|
|
16
|
+
* their volume but keep playing.
|
|
17
|
+
* - `'doNotMix'`: requests exclusive audio focus. Other apps pause.
|
|
18
|
+
*/
|
|
19
|
+
export type PipelineAudioMode = 'mixWithOthers' | 'duckOthers' | 'doNotMix';
|
|
20
|
+
|
|
9
21
|
/** Options passed to `connectPipeline()`. */
|
|
10
22
|
export interface ConnectPipelineOptions {
|
|
11
23
|
/** Sample rate in Hz (default 24000). */
|
|
@@ -25,6 +37,15 @@ export interface ConnectPipelineOptions {
|
|
|
25
37
|
frequencyBandIntervalMs?: number;
|
|
26
38
|
/** Optional frequency band crossover configuration. */
|
|
27
39
|
frequencyBandConfig?: FrequencyBandConfig;
|
|
40
|
+
/**
|
|
41
|
+
* How pipeline playback should coexist with other apps' audio.
|
|
42
|
+
* Default is `'mixWithOthers'` (matches expo-audio).
|
|
43
|
+
*
|
|
44
|
+
* Note: this is a **behavior change** vs. prior versions of this library,
|
|
45
|
+
* which effectively used `'doNotMix'`. Pass `'doNotMix'` explicitly to
|
|
46
|
+
* preserve that old behavior.
|
|
47
|
+
*/
|
|
48
|
+
audioMode?: PipelineAudioMode;
|
|
28
49
|
}
|
|
29
50
|
|
|
30
51
|
/** Result returned from a successful `connectPipeline()` call. */
|
|
@@ -114,6 +135,20 @@ export interface PipelineDrainedEvent {
|
|
|
114
135
|
turnId: string;
|
|
115
136
|
}
|
|
116
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Payload for `PipelinePlaybackStopped`.
|
|
140
|
+
*
|
|
141
|
+
* Fired when the last sample physically leaves the speaker, approximately
|
|
142
|
+
* `outputLatencyMs` after `PipelineDrained` for the same turn. Pairs with
|
|
143
|
+
* `PipelinePlaybackStarted` (start-of-emission ↔ end-of-emission).
|
|
144
|
+
*
|
|
145
|
+
* Note: this is a physical-world milestone, distinct from `state: 'idle'`
|
|
146
|
+
* (the pipeline-state-machine value reported via `PipelineStateChanged`).
|
|
147
|
+
*/
|
|
148
|
+
export interface PipelinePlaybackStoppedEvent {
|
|
149
|
+
turnId: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
117
152
|
/** Payload for `PipelineAudioFocusLost` (empty — presence is the signal). */
|
|
118
153
|
export type PipelineAudioFocusLostEvent = Record<string, never>;
|
|
119
154
|
|
|
@@ -134,6 +169,7 @@ export interface PipelineEventMap {
|
|
|
134
169
|
PipelineZombieDetected: PipelineZombieDetectedEvent;
|
|
135
170
|
PipelineUnderrun: PipelineUnderrunEvent;
|
|
136
171
|
PipelineDrained: PipelineDrainedEvent;
|
|
172
|
+
PipelinePlaybackStopped: PipelinePlaybackStoppedEvent;
|
|
137
173
|
PipelineAudioFocusLost: PipelineAudioFocusLostEvent;
|
|
138
174
|
PipelineAudioFocusResumed: PipelineAudioFocusResumedEvent;
|
|
139
175
|
PipelineFrequencyBands: PipelineFrequencyBandsEvent;
|
package/src/types.ts
CHANGED
|
@@ -129,10 +129,19 @@ export interface RecordingConfig {
|
|
|
129
129
|
enableProcessing?: boolean; // Boolean to enable/disable audio processing (default is false)
|
|
130
130
|
pointsPerSecond?: number; // Number of data points to extract per second of audio (default is 1000)
|
|
131
131
|
onAudioStream?: (event: AudioDataEvent) => Promise<void>; // Callback function to handle audio stream
|
|
132
|
+
/** Fired when the native layer reports a mid-recording error (e.g. system
|
|
133
|
+
* interruption like Siri or a phone call). The consumer should treat the
|
|
134
|
+
* recording session as terminated and clean up. */
|
|
135
|
+
onError?: (event: MicrophoneErrorEvent) => void;
|
|
132
136
|
/** Optional frequency band crossover configuration. */
|
|
133
137
|
frequencyBandConfig?: FrequencyBandConfig;
|
|
134
138
|
}
|
|
135
139
|
|
|
140
|
+
export interface MicrophoneErrorEvent {
|
|
141
|
+
code: string;
|
|
142
|
+
message: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
136
145
|
export interface Chunk {
|
|
137
146
|
text: string;
|
|
138
147
|
timestamp: [number, number | null];
|