@edkimmel/expo-audio-stream 0.4.2 → 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/android/src/main/java/expo/modules/audiostream/ExpoPlayAudioStreamModule.kt +5 -0
- package/android/src/main/java/expo/modules/audiostream/pipeline/AudioPipeline.kt +92 -2
- package/android/src/main/java/expo/modules/audiostream/pipeline/PipelineIntegration.kt +14 -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 +14 -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 +14 -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 +15 -0
- package/src/types.ts +9 -0
package/build/types.d.ts
CHANGED
|
@@ -103,9 +103,17 @@ export interface RecordingConfig {
|
|
|
103
103
|
enableProcessing?: boolean;
|
|
104
104
|
pointsPerSecond?: number;
|
|
105
105
|
onAudioStream?: (event: AudioDataEvent) => Promise<void>;
|
|
106
|
+
/** Fired when the native layer reports a mid-recording error (e.g. system
|
|
107
|
+
* interruption like Siri or a phone call). The consumer should treat the
|
|
108
|
+
* recording session as terminated and clean up. */
|
|
109
|
+
onError?: (event: MicrophoneErrorEvent) => void;
|
|
106
110
|
/** Optional frequency band crossover configuration. */
|
|
107
111
|
frequencyBandConfig?: FrequencyBandConfig;
|
|
108
112
|
}
|
|
113
|
+
export interface MicrophoneErrorEvent {
|
|
114
|
+
code: string;
|
|
115
|
+
message: string;
|
|
116
|
+
}
|
|
109
117
|
export interface Chunk {
|
|
110
118
|
text: string;
|
|
111
119
|
timestamp: [number, number | null];
|
package/build/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/build/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAOA,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,OAAO,EAAE,SAAS;IAClB,gBAAgB,EAAE,iBAAiB;IACnC,YAAY,EAAE,cAAc;CACpB,CAAC;AAgCX,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,SAAS,EAAE,WAAW;IACtB,SAAS,EAAE,WAAW;CACd,CAAC","sourcesContent":["export type RecordingEncodingType =\n | 'pcm_32bit'\n | 'pcm_16bit'\n | 'pcm_8bit';\nexport type SampleRate = 16000 | 24000 | 44100 | 48000;\nexport type BitDepth = 8 | 16 | 32;\n\nexport const PlaybackModes = {\n REGULAR: 'regular',\n VOICE_PROCESSING: 'voiceProcessing',\n CONVERSATION: 'conversation',\n} as const;\n/**\n * Defines different playback modes for audio processing\n */\nexport type PlaybackMode =\n (typeof PlaybackModes)[keyof typeof PlaybackModes];\n\n/**\n * Configuration for buffered audio streaming\n */\nexport interface BufferedStreamConfig {\n /**\n * Turn ID for queue management\n */\n turnId: string;\n\n /**\n * Audio encoding format\n */\n encoding?: Encoding;\n\n /**\n * Buffer configuration options\n */\n bufferConfig?: Partial<IAudioBufferConfig>;\n\n /**\n * Callback for buffer health updates\n */\n onBufferHealth?: (metrics: IBufferHealthMetrics) => void;\n}\n\nexport const EncodingTypes = {\n PCM_F32LE: 'pcm_f32le',\n PCM_S16LE: 'pcm_s16le',\n} as const;\n\n/**\n * Defines different encoding formats for audio data\n */\nexport type Encoding =\n (typeof EncodingTypes)[keyof typeof EncodingTypes];\n\n/** RMS energy per frequency band, range [0, 1]. */\nexport interface FrequencyBands {\n low: number;\n mid: number;\n high: number;\n}\n\n/** Crossover frequency configuration for band analysis. */\nexport interface FrequencyBandConfig {\n /** Low/mid crossover in Hz (default 300). */\n lowCrossoverHz?: number;\n /** Mid/high crossover in Hz (default 2000). */\n highCrossoverHz?: number;\n}\n\n/**\n * Smart buffering mode options\n */\nexport type SmartBufferMode =\n | 'conservative'\n | 'balanced'\n | 'aggressive'\n | 'adaptive';\n\n/**\n * Network condition indicators for smart buffering\n */\nexport interface NetworkConditions {\n latency?: number; // Round-trip time in ms\n jitter?: number; // Network jitter in ms\n packetLoss?: number; // Packet loss percentage (0-100)\n bandwidth?: number; // Available bandwidth estimate\n}\n\n/**\n * Smart buffering configuration\n */\nexport interface SmartBufferConfig {\n mode: SmartBufferMode;\n networkConditions?: NetworkConditions;\n adaptiveThresholds?: {\n highLatencyMs?: number; // Threshold to enable aggressive buffering\n highJitterMs?: number; // Threshold to increase buffer size\n packetLossPercent?: number; // Threshold to enable buffering\n };\n}\n\nexport interface StartRecordingResult {\n fileUri: string;\n mimeType: string;\n channels?: number;\n bitDepth?: BitDepth;\n sampleRate?: SampleRate;\n}\n\nexport interface AudioDataEvent {\n data: string | Float32Array;\n data16kHz?: string | Float32Array;\n position: number;\n fileUri: string;\n eventDataSize: number;\n totalSize: number;\n soundLevel?: number;\n /** Frequency band RMS energy, present when recording is active. */\n frequencyBands?: FrequencyBands;\n}\n\nexport interface RecordingConfig {\n sampleRate?: SampleRate; // Sample rate for recording\n channels?: 1 | 2; // 1 or 2 (MONO or STEREO)\n encoding?: RecordingEncodingType; // Encoding type for the recording\n interval?: number; // Interval in milliseconds at which to emit recording data\n\n // Optional parameters for audio processing\n enableProcessing?: boolean; // Boolean to enable/disable audio processing (default is false)\n pointsPerSecond?: number; // Number of data points to extract per second of audio (default is 1000)\n onAudioStream?: (event: AudioDataEvent) => Promise<void>; // Callback function to handle audio stream\n /** Optional frequency band crossover configuration. */\n frequencyBandConfig?: FrequencyBandConfig;\n}\n\nexport interface Chunk {\n text: string;\n timestamp: [number, number | null];\n}\n\nexport interface TranscriberData {\n id: string;\n isBusy: boolean;\n text: string;\n startTime: number;\n endTime: number;\n chunks: Chunk[];\n}\n\nexport interface AudioRecording {\n fileUri: string;\n filename: string;\n durationMs: number;\n size: number;\n channels: number;\n bitDepth: BitDepth;\n sampleRate: SampleRate;\n mimeType: string;\n transcripts?: TranscriberData[];\n wavPCMData?: Float32Array; // Full PCM data for the recording in WAV format (only on web, for native use the fileUri)\n}\n\n// Audio Jitter Buffer Types\n\n/**\n * Configuration for audio buffer management\n */\nexport interface IAudioBufferConfig {\n targetBufferMs: number; // Target buffer size in milliseconds\n minBufferMs: number; // Minimum buffer size before underrun handling\n maxBufferMs: number; // Maximum buffer size before overrun handling\n frameIntervalMs: number; // Expected frame interval in milliseconds\n}\n\n/**\n * Audio payload for playback containing base64 encoded audio data\n */\nexport interface IAudioPlayPayload {\n audioData: string; // Base64 encoded PCM audio data\n isFirst?: boolean; // True if this is the first chunk in a stream\n isFinal?: boolean; // True if this is the final chunk in a stream\n}\n\n/**\n * Processed audio frame with metadata\n */\nexport interface IAudioFrame {\n sequenceNumber: number; // Sequential frame number\n data: IAudioPlayPayload; // Original audio payload\n duration: number; // Estimated frame duration in milliseconds\n timestamp: number; // Frame timestamp when processed\n}\n\n/**\n * Buffer health states for quality monitoring\n */\nexport type BufferHealthState =\n | 'idle'\n | 'healthy'\n | 'degraded'\n | 'critical';\n\n/**\n * Comprehensive buffer health and quality metrics\n */\nexport interface IBufferHealthMetrics {\n currentBufferMs: number; // Current buffer level in milliseconds\n targetBufferMs: number; // Target buffer level in milliseconds\n underrunCount: number; // Total number of buffer underruns\n overrunCount: number; // Total number of buffer overruns\n averageJitter: number; // Average network jitter in milliseconds\n bufferHealthState: BufferHealthState; // Current buffer health assessment\n adaptiveAdjustmentsCount: number; // Number of adaptive adjustments made\n}\n\n/**\n * Interface for audio buffer management\n */\nexport interface IAudioBufferManager {\n enqueueFrames(audioData: IAudioPlayPayload): void;\n startPlayback(): void;\n stopPlayback(): void;\n isPlaying(): boolean;\n getHealthMetrics(): IBufferHealthMetrics;\n updateConfig(config: Partial<IAudioBufferConfig>): void;\n applyAdaptiveAdjustments(): void;\n destroy(): void;\n getCurrentBufferMs(): number;\n}\n\n/**\n * Interface for frame processing\n */\nexport interface IFrameProcessor {\n parseChunk(payload: IAudioPlayPayload): IAudioFrame[];\n reset(): void;\n}\n\n/**\n * Interface for quality monitoring\n */\nexport interface IQualityMonitor {\n recordFrameArrival(timestamp: number): void;\n recordUnderrun(): void;\n recordOverrun(): void;\n updateBufferLevel(bufferMs: number): void;\n getMetrics(): IBufferHealthMetrics;\n getBufferHealthState(\n isPlaying: boolean,\n currentLatencyMs: number\n ): BufferHealthState;\n getRecommendedAdjustment(): number;\n reset(): void;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAOA,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,OAAO,EAAE,SAAS;IAClB,gBAAgB,EAAE,iBAAiB;IACnC,YAAY,EAAE,cAAc;CACpB,CAAC;AAgCX,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,SAAS,EAAE,WAAW;IACtB,SAAS,EAAE,WAAW;CACd,CAAC","sourcesContent":["export type RecordingEncodingType =\n | 'pcm_32bit'\n | 'pcm_16bit'\n | 'pcm_8bit';\nexport type SampleRate = 16000 | 24000 | 44100 | 48000;\nexport type BitDepth = 8 | 16 | 32;\n\nexport const PlaybackModes = {\n REGULAR: 'regular',\n VOICE_PROCESSING: 'voiceProcessing',\n CONVERSATION: 'conversation',\n} as const;\n/**\n * Defines different playback modes for audio processing\n */\nexport type PlaybackMode =\n (typeof PlaybackModes)[keyof typeof PlaybackModes];\n\n/**\n * Configuration for buffered audio streaming\n */\nexport interface BufferedStreamConfig {\n /**\n * Turn ID for queue management\n */\n turnId: string;\n\n /**\n * Audio encoding format\n */\n encoding?: Encoding;\n\n /**\n * Buffer configuration options\n */\n bufferConfig?: Partial<IAudioBufferConfig>;\n\n /**\n * Callback for buffer health updates\n */\n onBufferHealth?: (metrics: IBufferHealthMetrics) => void;\n}\n\nexport const EncodingTypes = {\n PCM_F32LE: 'pcm_f32le',\n PCM_S16LE: 'pcm_s16le',\n} as const;\n\n/**\n * Defines different encoding formats for audio data\n */\nexport type Encoding =\n (typeof EncodingTypes)[keyof typeof EncodingTypes];\n\n/** RMS energy per frequency band, range [0, 1]. */\nexport interface FrequencyBands {\n low: number;\n mid: number;\n high: number;\n}\n\n/** Crossover frequency configuration for band analysis. */\nexport interface FrequencyBandConfig {\n /** Low/mid crossover in Hz (default 300). */\n lowCrossoverHz?: number;\n /** Mid/high crossover in Hz (default 2000). */\n highCrossoverHz?: number;\n}\n\n/**\n * Smart buffering mode options\n */\nexport type SmartBufferMode =\n | 'conservative'\n | 'balanced'\n | 'aggressive'\n | 'adaptive';\n\n/**\n * Network condition indicators for smart buffering\n */\nexport interface NetworkConditions {\n latency?: number; // Round-trip time in ms\n jitter?: number; // Network jitter in ms\n packetLoss?: number; // Packet loss percentage (0-100)\n bandwidth?: number; // Available bandwidth estimate\n}\n\n/**\n * Smart buffering configuration\n */\nexport interface SmartBufferConfig {\n mode: SmartBufferMode;\n networkConditions?: NetworkConditions;\n adaptiveThresholds?: {\n highLatencyMs?: number; // Threshold to enable aggressive buffering\n highJitterMs?: number; // Threshold to increase buffer size\n packetLossPercent?: number; // Threshold to enable buffering\n };\n}\n\nexport interface StartRecordingResult {\n fileUri: string;\n mimeType: string;\n channels?: number;\n bitDepth?: BitDepth;\n sampleRate?: SampleRate;\n}\n\nexport interface AudioDataEvent {\n data: string | Float32Array;\n data16kHz?: string | Float32Array;\n position: number;\n fileUri: string;\n eventDataSize: number;\n totalSize: number;\n soundLevel?: number;\n /** Frequency band RMS energy, present when recording is active. */\n frequencyBands?: FrequencyBands;\n}\n\nexport interface RecordingConfig {\n sampleRate?: SampleRate; // Sample rate for recording\n channels?: 1 | 2; // 1 or 2 (MONO or STEREO)\n encoding?: RecordingEncodingType; // Encoding type for the recording\n interval?: number; // Interval in milliseconds at which to emit recording data\n\n // Optional parameters for audio processing\n enableProcessing?: boolean; // Boolean to enable/disable audio processing (default is false)\n pointsPerSecond?: number; // Number of data points to extract per second of audio (default is 1000)\n onAudioStream?: (event: AudioDataEvent) => Promise<void>; // Callback function to handle audio stream\n /** Fired when the native layer reports a mid-recording error (e.g. system\n * interruption like Siri or a phone call). The consumer should treat the\n * recording session as terminated and clean up. */\n onError?: (event: MicrophoneErrorEvent) => void;\n /** Optional frequency band crossover configuration. */\n frequencyBandConfig?: FrequencyBandConfig;\n}\n\nexport interface MicrophoneErrorEvent {\n code: string;\n message: string;\n}\n\nexport interface Chunk {\n text: string;\n timestamp: [number, number | null];\n}\n\nexport interface TranscriberData {\n id: string;\n isBusy: boolean;\n text: string;\n startTime: number;\n endTime: number;\n chunks: Chunk[];\n}\n\nexport interface AudioRecording {\n fileUri: string;\n filename: string;\n durationMs: number;\n size: number;\n channels: number;\n bitDepth: BitDepth;\n sampleRate: SampleRate;\n mimeType: string;\n transcripts?: TranscriberData[];\n wavPCMData?: Float32Array; // Full PCM data for the recording in WAV format (only on web, for native use the fileUri)\n}\n\n// Audio Jitter Buffer Types\n\n/**\n * Configuration for audio buffer management\n */\nexport interface IAudioBufferConfig {\n targetBufferMs: number; // Target buffer size in milliseconds\n minBufferMs: number; // Minimum buffer size before underrun handling\n maxBufferMs: number; // Maximum buffer size before overrun handling\n frameIntervalMs: number; // Expected frame interval in milliseconds\n}\n\n/**\n * Audio payload for playback containing base64 encoded audio data\n */\nexport interface IAudioPlayPayload {\n audioData: string; // Base64 encoded PCM audio data\n isFirst?: boolean; // True if this is the first chunk in a stream\n isFinal?: boolean; // True if this is the final chunk in a stream\n}\n\n/**\n * Processed audio frame with metadata\n */\nexport interface IAudioFrame {\n sequenceNumber: number; // Sequential frame number\n data: IAudioPlayPayload; // Original audio payload\n duration: number; // Estimated frame duration in milliseconds\n timestamp: number; // Frame timestamp when processed\n}\n\n/**\n * Buffer health states for quality monitoring\n */\nexport type BufferHealthState =\n | 'idle'\n | 'healthy'\n | 'degraded'\n | 'critical';\n\n/**\n * Comprehensive buffer health and quality metrics\n */\nexport interface IBufferHealthMetrics {\n currentBufferMs: number; // Current buffer level in milliseconds\n targetBufferMs: number; // Target buffer level in milliseconds\n underrunCount: number; // Total number of buffer underruns\n overrunCount: number; // Total number of buffer overruns\n averageJitter: number; // Average network jitter in milliseconds\n bufferHealthState: BufferHealthState; // Current buffer health assessment\n adaptiveAdjustmentsCount: number; // Number of adaptive adjustments made\n}\n\n/**\n * Interface for audio buffer management\n */\nexport interface IAudioBufferManager {\n enqueueFrames(audioData: IAudioPlayPayload): void;\n startPlayback(): void;\n stopPlayback(): void;\n isPlaying(): boolean;\n getHealthMetrics(): IBufferHealthMetrics;\n updateConfig(config: Partial<IAudioBufferConfig>): void;\n applyAdaptiveAdjustments(): void;\n destroy(): void;\n getCurrentBufferMs(): number;\n}\n\n/**\n * Interface for frame processing\n */\nexport interface IFrameProcessor {\n parseChunk(payload: IAudioPlayPayload): IAudioFrame[];\n reset(): void;\n}\n\n/**\n * Interface for quality monitoring\n */\nexport interface IQualityMonitor {\n recordFrameArrival(timestamp: number): void;\n recordUnderrun(): void;\n recordOverrun(): void;\n updateBufferLevel(bufferMs: number): void;\n getMetrics(): IBufferHealthMetrics;\n getBufferHealthState(\n isPlaying: boolean,\n currentLatencyMs: number\n ): BufferHealthState;\n getRecommendedAdjustment(): number;\n reset(): void;\n}\n"]}
|
package/ios/AudioPipeline.swift
CHANGED
|
@@ -17,6 +17,7 @@ protocol PipelineListener: AnyObject {
|
|
|
17
17
|
func onZombieDetected(stalledMs: Int64)
|
|
18
18
|
func onUnderrun(count: Int)
|
|
19
19
|
func onDrained(turnId: String)
|
|
20
|
+
func onPlaybackStopped(turnId: String)
|
|
20
21
|
func onAudioFocusLost()
|
|
21
22
|
func onAudioFocusResumed()
|
|
22
23
|
func onFrequencyBands(low: Float, mid: Float, high: Float)
|
|
@@ -84,6 +85,10 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
84
85
|
/// This prevents duplicate chains and stale callbacks from re-entering after a rebuild.
|
|
85
86
|
private var scheduleGeneration: Int = 0
|
|
86
87
|
|
|
88
|
+
/// Pending PlaybackStopped dispatch — cancelled on new turn / disconnect.
|
|
89
|
+
/// Always mutate from the main queue to avoid races with the drain timer.
|
|
90
|
+
private var pendingPlaybackStoppedWork: DispatchWorkItem?
|
|
91
|
+
|
|
87
92
|
// ── Timers ──────────────────────────────────────────────────────────
|
|
88
93
|
private var stateTimer: DispatchSourceTimer?
|
|
89
94
|
private var zombieTimer: DispatchSourceTimer?
|
|
@@ -122,7 +127,7 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
122
127
|
// Connect / Disconnect
|
|
123
128
|
// ════════════════════════════════════════════════════════════════════
|
|
124
129
|
|
|
125
|
-
func connect() {
|
|
130
|
+
func connect() throws {
|
|
126
131
|
guard !running else {
|
|
127
132
|
Logger.debug("[\(AudioPipeline.TAG)] connect() called while already running — ignoring")
|
|
128
133
|
return
|
|
@@ -197,12 +202,17 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
197
202
|
} catch {
|
|
198
203
|
Logger.debug("[\(AudioPipeline.TAG)] connect() failed: \(error)")
|
|
199
204
|
setState(.error)
|
|
200
|
-
listener?.onError(code: "CONNECT_FAILED", message: error.localizedDescription)
|
|
201
205
|
disconnect()
|
|
206
|
+
throw error
|
|
202
207
|
}
|
|
203
208
|
}
|
|
204
209
|
|
|
205
210
|
func disconnect() {
|
|
211
|
+
// Cancel any pending PlaybackStopped before tearing down.
|
|
212
|
+
// DispatchWorkItem.cancel is thread-safe; we may be on any queue here.
|
|
213
|
+
pendingPlaybackStoppedWork?.cancel()
|
|
214
|
+
pendingPlaybackStoppedWork = nil
|
|
215
|
+
|
|
206
216
|
running = false
|
|
207
217
|
// Invalidate all in-flight completion handlers before detaching.
|
|
208
218
|
scheduleGeneration += 1
|
|
@@ -365,6 +375,9 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
365
375
|
lastReportedUnderrunCount = 0
|
|
366
376
|
setState(.streaming)
|
|
367
377
|
frequencyBandAnalyzer?.reset()
|
|
378
|
+
DispatchQueue.main.async { [weak self] in
|
|
379
|
+
self?.cancelPendingPlaybackStopped()
|
|
380
|
+
}
|
|
368
381
|
}
|
|
369
382
|
|
|
370
383
|
// ── Decode base64 → PCM shorts ──────────────────────────────────
|
|
@@ -407,6 +420,9 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
407
420
|
lastReportedUnderrunCount = 0
|
|
408
421
|
setState(.idle)
|
|
409
422
|
frequencyBandAnalyzer?.reset()
|
|
423
|
+
DispatchQueue.main.async { [weak self] in
|
|
424
|
+
self?.cancelPendingPlaybackStopped()
|
|
425
|
+
}
|
|
410
426
|
}
|
|
411
427
|
|
|
412
428
|
// ════════════════════════════════════════════════════════════════════
|
|
@@ -433,6 +449,18 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
433
449
|
]
|
|
434
450
|
}
|
|
435
451
|
|
|
452
|
+
/// Current platform output latency in milliseconds.
|
|
453
|
+
///
|
|
454
|
+
/// Reads `AVAudioSession.sharedInstance().outputLatency`. The value
|
|
455
|
+
/// reflects total HW output latency to the speaker and changes on
|
|
456
|
+
/// audio route changes (built-in vs. Bluetooth vs. wired).
|
|
457
|
+
///
|
|
458
|
+
/// Returns 0 if not running.
|
|
459
|
+
func outputLatencyMs() -> Double {
|
|
460
|
+
guard running else { return 0 }
|
|
461
|
+
return AVAudioSession.sharedInstance().outputLatency * 1000.0
|
|
462
|
+
}
|
|
463
|
+
|
|
436
464
|
// ════════════════════════════════════════════════════════════════════
|
|
437
465
|
// Scheduling loop
|
|
438
466
|
// ════════════════════════════════════════════════════════════════════
|
|
@@ -548,11 +576,48 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
548
576
|
if buf.isDrained() && currentState == .draining {
|
|
549
577
|
if let tid = turnId {
|
|
550
578
|
listener?.onDrained(turnId: tid)
|
|
579
|
+
schedulePlaybackStopped(turnId: tid)
|
|
551
580
|
}
|
|
552
581
|
setState(.idle)
|
|
553
582
|
}
|
|
554
583
|
}
|
|
555
584
|
|
|
585
|
+
/// Schedule a `PlaybackStopped` event approximately `outputLatencyMs`
|
|
586
|
+
/// after `Drained`. Cancels any previously pending dispatch.
|
|
587
|
+
///
|
|
588
|
+
/// Approximation note: at drain detection, up to `PRE_SCHEDULE_COUNT`
|
|
589
|
+
/// pre-scheduled buffers may still be in the player node's chain — but
|
|
590
|
+
/// they read silence from the empty jitter buffer, so the last audible
|
|
591
|
+
/// sample stops emitting roughly `outputLatency` after this point.
|
|
592
|
+
private func schedulePlaybackStopped(turnId: String) {
|
|
593
|
+
pendingPlaybackStoppedWork?.cancel()
|
|
594
|
+
pendingPlaybackStoppedWork = nil
|
|
595
|
+
|
|
596
|
+
let latencyMs = outputLatencyMs()
|
|
597
|
+
let work = DispatchWorkItem { [weak self] in
|
|
598
|
+
guard let self = self else { return }
|
|
599
|
+
self.pendingPlaybackStoppedWork = nil
|
|
600
|
+
self.listener?.onPlaybackStopped(turnId: turnId)
|
|
601
|
+
}
|
|
602
|
+
pendingPlaybackStoppedWork = work
|
|
603
|
+
|
|
604
|
+
if latencyMs > 0 {
|
|
605
|
+
DispatchQueue.main.asyncAfter(
|
|
606
|
+
deadline: .now() + .milliseconds(Int(latencyMs.rounded())),
|
|
607
|
+
execute: work
|
|
608
|
+
)
|
|
609
|
+
} else {
|
|
610
|
+
DispatchQueue.main.async(execute: work)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/// Cancel any pending PlaybackStopped dispatch. Call from turn-boundary
|
|
615
|
+
/// transitions (new pushAudio, invalidateTurn) and disconnect.
|
|
616
|
+
private func cancelPendingPlaybackStopped() {
|
|
617
|
+
pendingPlaybackStoppedWork?.cancel()
|
|
618
|
+
pendingPlaybackStoppedWork = nil
|
|
619
|
+
}
|
|
620
|
+
|
|
556
621
|
// ════════════════════════════════════════════════════════════════════
|
|
557
622
|
// Zombie detection
|
|
558
623
|
// ════════════════════════════════════════════════════════════════════
|
|
@@ -48,6 +48,7 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, Pipeline
|
|
|
48
48
|
PipelineIntegration.EVENT_ZOMBIE_DETECTED,
|
|
49
49
|
PipelineIntegration.EVENT_UNDERRUN,
|
|
50
50
|
PipelineIntegration.EVENT_DRAINED,
|
|
51
|
+
PipelineIntegration.EVENT_PLAYBACK_STOPPED,
|
|
51
52
|
PipelineIntegration.EVENT_AUDIO_FOCUS_LOST,
|
|
52
53
|
PipelineIntegration.EVENT_AUDIO_FOCUS_RESUMED,
|
|
53
54
|
PipelineIntegration.EVENT_FREQUENCY_BANDS,
|
|
@@ -209,6 +210,15 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, Pipeline
|
|
|
209
210
|
|
|
210
211
|
promise.resolve(result)
|
|
211
212
|
} catch {
|
|
213
|
+
// Reset session + engine state so a subsequent connect can recover.
|
|
214
|
+
// Without this, a partial failure (e.g. setActive denial after the
|
|
215
|
+
// iOS local-network permission prompt) leaves the session stuck and
|
|
216
|
+
// every retry fails the same way.
|
|
217
|
+
self._pipelineIntegration?.removeAsDelegate(from: self.sharedAudioEngine)
|
|
218
|
+
self._pipelineIntegration?.disconnect()
|
|
219
|
+
self.sharedAudioEngine.teardown()
|
|
220
|
+
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
221
|
+
self.isAudioSessionInitialized = false
|
|
212
222
|
promise.reject("PIPELINE_CONNECT_ERROR", error.localizedDescription)
|
|
213
223
|
}
|
|
214
224
|
}
|
|
@@ -248,6 +258,10 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, Pipeline
|
|
|
248
258
|
Function("getPipelineState") { () -> String in
|
|
249
259
|
return self.pipelineIntegration.getState()
|
|
250
260
|
}
|
|
261
|
+
|
|
262
|
+
Function("getPipelineOutputLatencyMs") { () -> Double in
|
|
263
|
+
return self.pipelineIntegration.outputLatencyMs()
|
|
264
|
+
}
|
|
251
265
|
}
|
|
252
266
|
|
|
253
267
|
private func ensureAudioSessionInitialized(settings recordingSettings: RecordingSettings? = nil) throws {
|
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
|
@@ -135,6 +135,20 @@ export interface PipelineDrainedEvent {
|
|
|
135
135
|
turnId: string;
|
|
136
136
|
}
|
|
137
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
|
+
|
|
138
152
|
/** Payload for `PipelineAudioFocusLost` (empty — presence is the signal). */
|
|
139
153
|
export type PipelineAudioFocusLostEvent = Record<string, never>;
|
|
140
154
|
|
|
@@ -155,6 +169,7 @@ export interface PipelineEventMap {
|
|
|
155
169
|
PipelineZombieDetected: PipelineZombieDetectedEvent;
|
|
156
170
|
PipelineUnderrun: PipelineUnderrunEvent;
|
|
157
171
|
PipelineDrained: PipelineDrainedEvent;
|
|
172
|
+
PipelinePlaybackStopped: PipelinePlaybackStoppedEvent;
|
|
158
173
|
PipelineAudioFocusLost: PipelineAudioFocusLostEvent;
|
|
159
174
|
PipelineAudioFocusResumed: PipelineAudioFocusResumedEvent;
|
|
160
175
|
PipelineFrequencyBands: PipelineFrequencyBandsEvent;
|