@iternio/react-native-auto-play 0.5.5 → 0.5.6

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/README.md CHANGED
@@ -1074,7 +1074,8 @@ CarPlayDashboard.setButtons([
1074
1074
 
1075
1075
  - **Broken exceptions with `react-native-skia`**: When using `react-native-skia` exceptions on iOS are not reported correctly. This is fixed since version `2.4.19` of `react-native-skia`. For more details, see this [pull request](https://github.com/Shopify/react-native-skia/pull/3595) and [issue](https://github.com/Shopify/react-native-skia/issues/3635).
1076
1076
  - **AppState on iOS**: The `AppState` module from React Native does not work correctly on iOS because this library uses scenes, which are not supported by the stock `AppState` module. This library provides a custom state listener that works for both Android and iOS. Use `HybridAutoPlay.addListenerRenderState` instead of `AppState`.
1077
- - **Timers stop on screen lock**: iOS stops all timers when the device's main screen is turned off. To ensure timers continue to run (which is often necessary for background tasks related to autoplay), a patch for `react-native` is required. A patch is included in the root `patches/` directory and can be applied using `patch-package`.
1077
+ - **Timers stop on screen lock**: iOS stops all timers when the device main screen is turned off. To ensure timers continue to run (which is often necessary for background tasks related to autoplay), a patch for `react-native` is required. A patch is included in the root `patches/` directory and can be applied using `patch-package`.
1078
+ In case you are using Expo SDK >= 56 make sure to set `buildReactNativeFromSource` to `true` in your app config for [expo-build-properties](https://docs.expo.dev/versions/latest/sdk/build-properties/#sharedbuildconfigfields), otherwise the patch can't be applied.
1078
1079
  - **expo-splash-screen stuck on iOS**: The `expo-splash-screen` module is broken on iOS because it does not support scenes, which are used by this library. This can cause the splash screen to be stuck on either the mobile device or on CarPlay. To fix this, a patch for `expo-splash-screen` is included in the root `patches/` directory and can be applied using `patch-package`. After applying the patch, you can hide the splash screen for a specific scene by passing the module name to the `hide` or `hideAsync` function. The module name can be one of the values from the `AutoPlayModules` enum or the UUID of a cluster screen.
1079
1080
  ```tsx
1080
1081
  import { hideAsync } from 'expo-splash-screen';
@@ -86,7 +86,7 @@ class VoiceInputManager(
86
86
  throw IllegalStateException("Audio focus request denied")
87
87
  }
88
88
  try {
89
- startSoundUri?.let { playSound(it) }
89
+ val startSoundJob = startSoundUri?.let { uri -> scope.launch { playSound(uri) } }
90
90
  val result = if (preferSpeechToText) {
91
91
  val context = NitroModules.applicationContext ?: throw IllegalArgumentException()
92
92
  if (SpeechRecognizer.isRecognitionAvailable(context)) {
@@ -106,6 +106,7 @@ class VoiceInputManager(
106
106
  } else {
107
107
  startPCM(silenceThresholdMs, maxDurationMs, onChunk)
108
108
  }
109
+ startSoundJob?.join()
109
110
  if (cancelledByUser) throw VoiceInputCancelledException()
110
111
  endSoundUri?.let { playSound(it) }
111
112
  return result
@@ -3,9 +3,7 @@ import CarPlay
3
3
  import NitroModules
4
4
  import Speech
5
5
 
6
- /// Keeps itself and the player alive until playback finishes.
7
- /// Needed because AVAudioPlayer.delegate is weak, so without an external strong
8
- /// reference the delegate (and player) would be released immediately after play().
6
+ /// Retains the player and itself until playback finishes — AVAudioPlayer.delegate is weak.
9
7
  private final class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate, @unchecked Sendable {
10
8
  private let onFinish: () -> Void
11
9
  private var keepAlive: AudioPlayerDelegate?
@@ -28,8 +26,7 @@ private final class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate, @unche
28
26
  func audioPlayerDecodeErrorDidOccur(_: AVAudioPlayer, error _: Error?) { finish() }
29
27
  }
30
28
 
31
- /// Wraps CheckedContinuation so it can only be resumed once even when
32
- /// shared between a stop() call and an async recognition task callback.
29
+ /// CheckedContinuation wrapper that can only be resumed once, safe across concurrent stop() and recognition callbacks.
33
30
  private final class ResultBox: @unchecked Sendable {
34
31
  private var continuation: CheckedContinuation<VoiceInputResult, Error>?
35
32
  private let lock = NSLock()
@@ -53,8 +50,7 @@ private final class ResultBox: @unchecked Sendable {
53
50
  }
54
51
  }
55
52
 
56
- /// Captures audio from the car microphone and buffers raw 16 kHz / 16-bit / mono PCM,
57
- /// or transcribes it via SFSpeechRecognizer when preferSpeechToText is true.
53
+ /// Records 16 kHz / 16-bit mono PCM from the car mic, or transcribes via SFSpeechRecognizer.
58
54
  class VoiceInputManager {
59
55
  private var audioEngine: AVAudioEngine?
60
56
  private var voiceControlTemplate: CPVoiceControlTemplate?
@@ -62,7 +58,6 @@ class VoiceInputManager {
62
58
  private var samples: [Int16] = []
63
59
  private var isStopping = false
64
60
  private var cancelledByUser = false
65
- private var isIgnoringSamples = false
66
61
  private let stopLock = NSLock()
67
62
 
68
63
  // STT
@@ -72,6 +67,7 @@ class VoiceInputManager {
72
67
  // Timing
73
68
  private var recordingStart: Date?
74
69
  private var silenceStart: Date?
70
+ private var firstBufferContinuation: CheckedContinuation<Void, Never>?
75
71
 
76
72
  private static let sampleRate: Double = 16_000
77
73
  private static let tapBufferSize: AVAudioFrameCount = 4_096
@@ -103,6 +99,13 @@ class VoiceInputManager {
103
99
  stopLock.withLock {
104
100
  cancelledByUser = false
105
101
  }
102
+ // Single session for the full flow (start sound + recording + end sound); defer deactivates once at the end.
103
+ let session = AVAudioSession.sharedInstance()
104
+ try session.setCategory(.playAndRecord, mode: .measurement, options: [])
105
+ try session.setActive(true)
106
+ defer {
107
+ try? session.setActive(false, options: .notifyOthersOnDeactivation)
108
+ }
106
109
 
107
110
  let result = try await withCheckedThrowingContinuation { cont in
108
111
  let box = ResultBox(cont)
@@ -128,12 +131,17 @@ class VoiceInputManager {
128
131
  return
129
132
  }
130
133
 
131
- // Mic is open — present template then play start sound.
132
- // isIgnoringSamples discards tap buffers during the sound so it isn't recorded.
133
- // recordingStart is set after the sound so silence/max-duration timers are accurate.
134
- self.stopLock.withLock { self.isIgnoringSamples = startSoundUri != nil }
135
- Task {
136
- if let interfaceController = interfaceController {
134
+ // Start sound fires immediately; template is deferred until the first tap buffer so the mic indicator is already on.
135
+ if let uri = startSoundUri {
136
+ Task { await self.playSound(uri: uri) }
137
+ }
138
+ if let interfaceController = interfaceController {
139
+ Task {
140
+ await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
141
+ self.stopLock.withLock { self.firstBufferContinuation = cont }
142
+ }
143
+ // Skip if stop() fired before the first buffer — cleanup already dismissed.
144
+ guard !self.stopLock.withLock({ self.isStopping }) else { return }
137
145
  await self.presentVoiceTemplate(
138
146
  interfaceController: interfaceController,
139
147
  listeningText: listeningText,
@@ -141,13 +149,6 @@ class VoiceInputManager {
141
149
  listeningImageRepeats: listeningImageRepeats
142
150
  )
143
151
  }
144
- if let uri = startSoundUri {
145
- await self.playSound(uri: uri, setupSession: false)
146
- }
147
- self.stopLock.withLock {
148
- self.isIgnoringSamples = false
149
- self.recordingStart = Date()
150
- }
151
152
  }
152
153
  }
153
154
 
@@ -158,14 +159,9 @@ class VoiceInputManager {
158
159
  return result
159
160
  }
160
161
 
161
- private func playSound(uri: String, setupSession: Bool = true) async {
162
+ private func playSound(uri: String) async {
162
163
  guard let url = URL(string: uri) else { return }
163
164
  do {
164
- let session = AVAudioSession.sharedInstance()
165
- if setupSession {
166
- try session.setCategory(.playback, mode: .default)
167
- try session.setActive(true)
168
- }
169
165
  // URLSession handles both http:// (Metro dev server) and file:// (release bundle)
170
166
  let (data, _) = try await URLSession.shared.data(from: url)
171
167
  await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
@@ -206,8 +202,7 @@ class VoiceInputManager {
206
202
  stopLock.unlock()
207
203
 
208
204
  if wasSTTMode {
209
- // endAudio() causes the recognition task to fire its final result,
210
- // which resumes the box. Engine teardown happens there too.
205
+ // endAudio() triggers the final recognition result, which resumes the box and tears down the engine.
211
206
  capturedRequest?.endAudio()
212
207
  }
213
208
  else {
@@ -236,10 +231,6 @@ class VoiceInputManager {
236
231
  throw VoiceInputError.microphonePermissionDenied
237
232
  }
238
233
 
239
- let session = AVAudioSession.sharedInstance()
240
- try session.setCategory(.playAndRecord, mode: .measurement, options: [])
241
- try session.setActive(true)
242
-
243
234
  var activeRecognitionRequest: SFSpeechAudioBufferRecognitionRequest? = nil
244
235
 
245
236
  if preferSpeechToText, SFSpeechRecognizer.authorizationStatus() == .authorized,
@@ -311,9 +302,9 @@ class VoiceInputManager {
311
302
  throw VoiceInputError.converterUnavailable
312
303
  }
313
304
 
314
- recordingStart = nil
305
+ recordingStart = Date()
315
306
  silenceStart = nil
316
- isIgnoringSamples = false
307
+ firstBufferContinuation = nil
317
308
 
318
309
  inputNode.installTap(
319
310
  onBus: 0,
@@ -324,11 +315,14 @@ class VoiceInputManager {
324
315
 
325
316
  self.stopLock.lock()
326
317
  let stopping = self.isStopping
327
- let ignoringSamples = self.isIgnoringSamples
328
318
  let recordingStartSnapshot = self.recordingStart
319
+ let firstBufferCont = self.firstBufferContinuation
320
+ self.firstBufferContinuation = nil
329
321
  self.stopLock.unlock()
330
322
 
331
- guard !stopping, !ignoringSamples else { return }
323
+ firstBufferCont?.resume()
324
+
325
+ guard !stopping else { return }
332
326
 
333
327
  // Feed STT if active
334
328
  activeRecognitionRequest?.append(buffer)
@@ -378,8 +372,7 @@ class VoiceInputManager {
378
372
  return
379
373
  }
380
374
 
381
- // Silence detection — skip during warm-up so the pipeline has time
382
- // to stabilise before we start measuring amplitude
375
+ // Silence detection — skip during warm-up to let the pipeline stabilise.
383
376
  if let start = recordingStartSnapshot,
384
377
  now.timeIntervalSince(start) * 1000 >= VoiceInputManager.warmupMs
385
378
  {
@@ -417,7 +410,13 @@ class VoiceInputManager {
417
410
  recognitionRequest = nil
418
411
  recordingStart = nil
419
412
  silenceStart = nil
420
- try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
413
+ // Drain firstBufferContinuation so the template Task doesn't hang if stop() fired before the first buffer.
414
+ let pendingCont = stopLock.withLock { () -> CheckedContinuation<Void, Never>? in
415
+ let c = firstBufferContinuation
416
+ firstBufferContinuation = nil
417
+ return c
418
+ }
419
+ pendingCont?.resume()
421
420
  if let interfaceController {
422
421
  dismissVoiceTemplate(interfaceController: interfaceController)
423
422
  }
@@ -432,15 +431,10 @@ class VoiceInputManager {
432
431
  // CPVoiceControlState enforces a maximum image size of 150x150 points.
433
432
  private static let voiceImageMaxSize = CGSize(width: 150, height: 150)
434
433
 
435
- // CPVoiceControlState also enforces a 0.3s–5s animation cycle; the 0.3s floor is applied
436
- // by the system regardless of what we pass, so we only need to clamp our own ceiling.
434
+ // CPVoiceControlState enforces a 0.3s–5s animation cycle; the 0.3s floor is system-applied, clamp only the ceiling.
437
435
  private static let maxVoiceImageCycleDuration: TimeInterval = 5.0
438
436
 
439
- // Bypasses RCTConvert for asset images: it collapses animated UIImages to a single frame
440
- // via CGImage during scale adjustment. Parser.decodeImage preserves animation frames by
441
- // walking every frame in the source via ImageIO — UIImage(data:) never builds a multi-frame
442
- // .images array itself, for GIF, APNG, or WebP. Tinting is skipped for animated images since
443
- // frames cannot be tinted individually.
437
+ // Uses Parser.decodeImage instead of RCTConvert to preserve animation frames for GIF/APNG/WebP.
444
438
  private func loadVoiceImage(image: Variant_GlyphImage_AssetImage_RemoteImage?, traitCollection: UITraitCollection)
445
439
  -> UIImage?
446
440
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iternio/react-native-auto-play",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "Android Auto and Apple CarPlay for react-native",
5
5
  "main": "lib/index",
6
6
  "module": "lib/index",