@capgo/capacitor-speech-recognition 8.0.9 → 8.1.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.
@@ -9,15 +9,29 @@ private enum PermissionState: String {
9
9
  case prompt
10
10
  }
11
11
 
12
+ private enum ListeningReason: String {
13
+ case userStart
14
+ case userStop
15
+ case forceStop
16
+ case results
17
+ case silence
18
+ case error
19
+ case unknown
20
+ }
21
+
12
22
  @objc(SpeechRecognitionPlugin)
13
23
  public final class SpeechRecognitionPlugin: CAPPlugin, CAPBridgedPlugin {
14
- private let pluginVersion: String = "8.0.9"
24
+ private let pluginVersion = "8.0.10"
15
25
  public let identifier = "SpeechRecognitionPlugin"
16
26
  public let jsName = "SpeechRecognition"
17
27
  public let pluginMethods: [CAPPluginMethod] = [
18
28
  CAPPluginMethod(name: "available", returnType: CAPPluginReturnPromise),
29
+ CAPPluginMethod(name: "isOnDeviceRecognitionAvailable", returnType: CAPPluginReturnPromise),
19
30
  CAPPluginMethod(name: "start", returnType: CAPPluginReturnPromise),
20
31
  CAPPluginMethod(name: "stop", returnType: CAPPluginReturnPromise),
32
+ CAPPluginMethod(name: "forceStop", returnType: CAPPluginReturnPromise),
33
+ CAPPluginMethod(name: "getLastPartialResult", returnType: CAPPluginReturnPromise),
34
+ CAPPluginMethod(name: "setPTTState", returnType: CAPPluginReturnPromise),
21
35
  CAPPluginMethod(name: "getSupportedLanguages", returnType: CAPPluginReturnPromise),
22
36
  CAPPluginMethod(name: "isListening", returnType: CAPPluginReturnPromise),
23
37
  CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
@@ -29,11 +43,22 @@ public final class SpeechRecognitionPlugin: CAPPlugin, CAPBridgedPlugin {
29
43
  private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
30
44
  private var recognitionTask: SFSpeechRecognitionTask?
31
45
  private var speechRecognizer: SFSpeechRecognizer?
46
+ private var modernRecognitionSession: AnyObject?
32
47
  private var activeCall: CAPPluginCall?
33
48
  private var currentOptions: RecognitionOptions?
34
49
  private var hasInstalledTap = false
50
+ private var latestPartialMatches: [String] = []
51
+ private var pttButtonHeld = false
52
+ private var nextSessionId = 0
53
+ private var activeSessionId: Int?
54
+ private var pendingStopReason: ListeningReason?
55
+ private var emittedStoppingSessionId: Int?
56
+ private var accumulatedTranscript = ""
57
+ private var pendingContinuousRestartSessionId: Int?
58
+ private var continuousRestartWorkItem: DispatchWorkItem?
35
59
 
36
60
  private let maxDefaultResults = 5
61
+ private let continuousRestartDelay: TimeInterval = 0.1
37
62
 
38
63
  @objc func available(_ call: CAPPluginCall) {
39
64
  let locale = Locale(identifier: call.getString("language") ?? Locale.current.identifier)
@@ -41,15 +66,26 @@ public final class SpeechRecognitionPlugin: CAPPlugin, CAPBridgedPlugin {
41
66
  call.resolve(["available": recognizer?.isAvailable ?? false])
42
67
  }
43
68
 
69
+ @objc func isOnDeviceRecognitionAvailable(_ call: CAPPluginCall) {
70
+ let locale = Locale(identifier: call.getString("language") ?? Locale.current.identifier)
71
+ if #available(iOS 26.0, *) {
72
+ Task { @MainActor in
73
+ let isAvailable = await SpeechAnalyzerRecognitionSupport.supports(locale: locale)
74
+ call.resolve(["available": isAvailable])
75
+ }
76
+ return
77
+ }
78
+
79
+ call.resolve(["available": false])
80
+ }
81
+
44
82
  @objc func start(_ call: CAPPluginCall) {
45
- if self.audioEngine.isRunning || recognitionTask != nil {
46
- CAPLog.print("[SpeechRecognition] Attempted to start while already running")
83
+ if activeSessionId != nil || isRecognitionActive {
47
84
  call.reject("Speech recognition is already running.")
48
85
  return
49
86
  }
50
87
 
51
88
  guard isSpeechPermissionGranted else {
52
- CAPLog.print("[SpeechRecognition] Missing speech permission, rejecting start()")
53
89
  call.reject("Missing speech recognition permission.")
54
90
  return
55
91
  }
@@ -58,43 +94,138 @@ public final class SpeechRecognitionPlugin: CAPPlugin, CAPBridgedPlugin {
58
94
  language: call.getString("language") ?? Locale.current.identifier,
59
95
  maxResults: call.getInt("maxResults") ?? maxDefaultResults,
60
96
  partialResults: call.getBool("partialResults") ?? false,
61
- addPunctuation: call.getBool("addPunctuation") ?? false
97
+ addPunctuation: call.getBool("addPunctuation") ?? false,
98
+ useOnDeviceRecognition: call.getBool("useOnDeviceRecognition") ?? false,
99
+ continuousPTT: call.getBool("continuousPTT") ?? false
62
100
  )
63
101
 
64
- self.activeCall = call
65
- self.currentOptions = options
66
- CAPLog.print("[SpeechRecognition] Starting session | language=\(options.language) partialResults=\(options.partialResults) punctuation=\(options.addPunctuation)")
102
+ if options.continuousPTT && !options.partialResults {
103
+ call.reject("continuousPTT requires partialResults: true.")
104
+ return
105
+ }
106
+
107
+ nextSessionId += 1
108
+ let sessionId = nextSessionId
109
+ activeSessionId = sessionId
110
+ activeCall = call
111
+ currentOptions = options
112
+ latestPartialMatches = []
113
+ accumulatedTranscript = ""
114
+ pendingStopReason = nil
115
+ emittedStoppingSessionId = nil
116
+ cancelPendingContinuousRestart()
117
+
118
+ emitListeningState(
119
+ state: "startingListening",
120
+ sessionId: sessionId,
121
+ reason: .userStart
122
+ )
67
123
 
68
124
  AVAudioSession.sharedInstance().requestRecordPermission { granted in
69
125
  guard granted else {
70
- CAPLog.print("[SpeechRecognition] Microphone permission denied by user")
71
126
  DispatchQueue.main.async {
72
- call.reject("User denied microphone access.")
73
- self.cleanupRecognition(notifyStop: false)
127
+ guard self.activeSessionId == sessionId else {
128
+ return
129
+ }
130
+ self.emitErrorEvent(
131
+ code: "MICROPHONE_PERMISSION_DENIED",
132
+ message: "User denied microphone access.",
133
+ sessionId: sessionId
134
+ )
135
+ self.activeCall?.reject("User denied microphone access.")
136
+ self.activeCall = nil
137
+ self.finishSessionIfNeeded(sessionId: sessionId, reason: .error, errorCode: "MICROPHONE_PERMISSION_DENIED")
74
138
  }
75
139
  return
76
140
  }
77
141
 
78
142
  DispatchQueue.main.async {
79
- self.beginRecognition(call: call, options: options)
143
+ self.beginRecognition(call: call, options: options, sessionId: sessionId, restarting: false)
80
144
  }
81
145
  }
82
146
  }
83
147
 
84
148
  @objc func stop(_ call: CAPPluginCall) {
85
- CAPLog.print("[SpeechRecognition] stop() invoked")
86
- cleanupRecognition(notifyStop: true)
149
+ guard let sessionId = activeSessionId else {
150
+ call.resolve()
151
+ return
152
+ }
153
+
154
+ pendingStopReason = .userStop
155
+ emitStoppingIfNeeded(sessionId: sessionId, reason: .userStop, errorCode: nil)
156
+ rejectPendingStartCallIfNeeded(message: "Recognition stopped before final results were produced.")
157
+
158
+ if #available(iOS 26.0, *), let modernSession = modernRecognitionSession as? SpeechAnalyzerRecognitionSession {
159
+ Task { @MainActor in
160
+ await modernSession.stop()
161
+ self.finishSessionIfNeeded(sessionId: sessionId, reason: .userStop, errorCode: nil, sessionAlreadyStopped: true)
162
+ call.resolve()
163
+ }
164
+ return
165
+ }
166
+
167
+ clearLegacyRecognitionResources()
168
+ finishSessionIfNeeded(sessionId: sessionId, reason: .userStop, errorCode: nil, sessionAlreadyStopped: true)
169
+ call.resolve()
170
+ }
171
+
172
+ @objc func forceStop(_ call: CAPPluginCall) {
173
+ guard let sessionId = activeSessionId else {
174
+ call.resolve()
175
+ return
176
+ }
177
+
178
+ pendingStopReason = .forceStop
179
+ emitStoppingIfNeeded(sessionId: sessionId, reason: .forceStop, errorCode: nil)
180
+ rejectPendingStartCallIfNeeded(message: "Recognition force stopped before final results were produced.")
181
+
182
+ if !latestPartialMatches.isEmpty || !accumulatedTranscriptText().isEmpty {
183
+ emitPartialResultEvent(matches: latestPartialMatches, forced: true, includeAccumulatedText: true)
184
+ }
185
+
186
+ if #available(iOS 26.0, *), let modernSession = modernRecognitionSession as? SpeechAnalyzerRecognitionSession {
187
+ Task { @MainActor in
188
+ await modernSession.stop()
189
+ self.finishSessionIfNeeded(sessionId: sessionId, reason: .forceStop, errorCode: nil, sessionAlreadyStopped: true)
190
+ call.resolve()
191
+ }
192
+ return
193
+ }
194
+
195
+ clearLegacyRecognitionResources()
196
+ finishSessionIfNeeded(sessionId: sessionId, reason: .forceStop, errorCode: nil, sessionAlreadyStopped: true)
197
+ call.resolve()
198
+ }
199
+
200
+ @objc func getLastPartialResult(_ call: CAPPluginCall) {
201
+ let text = currentTranscriptText()
202
+ call.resolve([
203
+ "available": !text.isEmpty,
204
+ "text": text,
205
+ "matches": latestPartialMatches
206
+ ])
207
+ }
208
+
209
+ @objc func setPTTState(_ call: CAPPluginCall) {
210
+ let held = call.getBool("held") ?? false
211
+ pttButtonHeld = held
212
+ if held {
213
+ latestPartialMatches = []
214
+ accumulatedTranscript = ""
215
+ pendingStopReason = nil
216
+ cancelPendingContinuousRestart()
217
+ }
87
218
  call.resolve()
88
219
  }
89
220
 
90
221
  @objc func isListening(_ call: CAPPluginCall) {
91
- call.resolve(["listening": audioEngine.isRunning])
222
+ call.resolve(["listening": isRecognitionActive])
92
223
  }
93
224
 
94
225
  @objc func getSupportedLanguages(_ call: CAPPluginCall) {
95
226
  let identifiers = SFSpeechRecognizer
96
227
  .supportedLocales()
97
- .map { $0.identifier }
228
+ .map(\.identifier)
98
229
  .sorted()
99
230
  call.resolve(["languages": identifiers])
100
231
  }
@@ -129,16 +260,51 @@ public final class SpeechRecognitionPlugin: CAPPlugin, CAPBridgedPlugin {
129
260
  }
130
261
  }
131
262
 
132
- private func beginRecognition(call: CAPPluginCall, options: RecognitionOptions) {
133
- guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: options.language)) else {
134
- call.reject("Unsupported locale: \(options.language)")
135
- cleanupRecognition(notifyStop: false)
263
+ @objc func getPluginVersion(_ call: CAPPluginCall) {
264
+ call.resolve(["version": pluginVersion])
265
+ }
266
+
267
+ private func beginRecognition(call: CAPPluginCall?, options: RecognitionOptions, sessionId: Int, restarting: Bool) {
268
+ Task { @MainActor in
269
+ guard activeSessionId == sessionId else {
270
+ return
271
+ }
272
+
273
+ let locale = Locale(identifier: options.language)
274
+ if #available(iOS 26.0, *),
275
+ options.useOnDeviceRecognition,
276
+ await SpeechAnalyzerRecognitionSupport.supports(locale: locale) {
277
+ beginModernRecognition(call: call, options: options, locale: locale, sessionId: sessionId, restarting: restarting)
278
+ } else {
279
+ beginLegacyRecognition(call: call, options: options, locale: locale, sessionId: sessionId, restarting: restarting)
280
+ }
281
+ }
282
+ }
283
+
284
+ private func beginLegacyRecognition(
285
+ call: CAPPluginCall?,
286
+ options: RecognitionOptions,
287
+ locale: Locale,
288
+ sessionId: Int,
289
+ restarting: Bool
290
+ ) {
291
+ guard activeSessionId == sessionId else {
292
+ return
293
+ }
294
+
295
+ guard let recognizer = SFSpeechRecognizer(locale: locale) else {
296
+ call?.reject("Unsupported locale: \(options.language)")
297
+ emitErrorEvent(code: "UNSUPPORTED_LOCALE", message: "Unsupported locale: \(options.language)", sessionId: sessionId)
298
+ activeCall = nil
299
+ finishSessionIfNeeded(sessionId: sessionId, reason: .error, errorCode: "UNSUPPORTED_LOCALE")
136
300
  return
137
301
  }
138
302
 
139
303
  guard recognizer.isAvailable else {
140
- call.reject("Speech recognizer is currently unavailable.")
141
- cleanupRecognition(notifyStop: false)
304
+ call?.reject("Speech recognizer is currently unavailable.")
305
+ emitErrorEvent(code: "RECOGNIZER_UNAVAILABLE", message: "Speech recognizer is currently unavailable.", sessionId: sessionId)
306
+ activeCall = nil
307
+ finishSessionIfNeeded(sessionId: sessionId, reason: .error, errorCode: "RECOGNIZER_UNAVAILABLE")
142
308
  return
143
309
  }
144
310
 
@@ -147,8 +313,10 @@ public final class SpeechRecognitionPlugin: CAPPlugin, CAPBridgedPlugin {
147
313
  do {
148
314
  try configureAudioSession()
149
315
  } catch {
150
- call.reject("Failed to configure audio session: \(error.localizedDescription)")
151
- cleanupRecognition(notifyStop: false)
316
+ call?.reject("Failed to configure audio session: \(error.localizedDescription)")
317
+ emitErrorEvent(code: "AUDIO_SESSION_FAILED", message: error.localizedDescription, sessionId: sessionId)
318
+ activeCall = nil
319
+ finishSessionIfNeeded(sessionId: sessionId, reason: .error, errorCode: "AUDIO_SESSION_FAILED")
152
320
  return
153
321
  }
154
322
 
@@ -171,38 +339,164 @@ public final class SpeechRecognitionPlugin: CAPPlugin, CAPBridgedPlugin {
171
339
 
172
340
  do {
173
341
  try audioEngine.start()
174
- notifyListeners("listeningState", data: ["status": "started"])
342
+ emitListeningState(state: "started", sessionId: sessionId, reason: restarting ? .results : .userStart, status: "started")
175
343
  } catch {
176
- call.reject("Unable to start audio engine: \(error.localizedDescription)")
177
- cleanupRecognition(notifyStop: false)
344
+ call?.reject("Unable to start audio engine: \(error.localizedDescription)")
345
+ emitErrorEvent(code: "AUDIO_ENGINE_START_FAILED", message: error.localizedDescription, sessionId: sessionId)
346
+ activeCall = nil
347
+ finishSessionIfNeeded(sessionId: sessionId, reason: .error, errorCode: "AUDIO_ENGINE_START_FAILED")
178
348
  return
179
349
  }
180
350
 
181
- if options.partialResults {
351
+ if options.partialResults, let call {
182
352
  call.resolve()
353
+ activeCall = nil
183
354
  }
184
355
 
185
356
  recognitionTask = recognizer.recognitionTask(with: recognitionRequest) { [weak self] result, error in
186
- guard let self else { return }
187
- if let result {
188
- let matches = self.buildMatches(from: result, maxResults: options.maxResults)
189
- if options.partialResults {
190
- DispatchQueue.main.async {
191
- self.notifyListeners("partialResults", data: ["matches": matches])
192
- }
193
- } else if result.isFinal {
194
- DispatchQueue.main.async {
195
- self.activeCall?.resolve(["matches": matches])
357
+ guard let self else {
358
+ return
359
+ }
360
+
361
+ DispatchQueue.main.async {
362
+ guard self.activeSessionId == sessionId else {
363
+ return
364
+ }
365
+
366
+ if let result {
367
+ let matches = self.buildMatches(from: result, maxResults: options.maxResults)
368
+ self.latestPartialMatches = matches
369
+
370
+ if result.isFinal {
371
+ if self.prepareContinuousRestartIfNeeded(matches: matches, sessionId: sessionId) {
372
+ self.clearLegacyRecognitionResources()
373
+ self.scheduleContinuousRestart(sessionId: sessionId)
374
+ return
375
+ }
376
+
377
+ if options.partialResults {
378
+ self.emitPartialResultEvent(
379
+ matches: matches,
380
+ includeAccumulatedText: !self.accumulatedTranscriptText().isEmpty
381
+ )
382
+ } else {
383
+ let startCall = self.activeCall
384
+ self.activeCall = nil
385
+ startCall?.resolve(["matches": matches])
386
+ }
387
+
388
+ self.finishSessionIfNeeded(
389
+ sessionId: sessionId,
390
+ reason: self.pendingStopReason ?? .results,
391
+ errorCode: nil
392
+ )
393
+ } else if options.partialResults {
394
+ self.emitPartialResultEvent(matches: matches)
196
395
  }
197
396
  }
198
397
 
199
- if result.isFinal {
200
- self.cleanupRecognition(notifyStop: true)
398
+ if let error {
399
+ self.handleRecognitionError(error, sessionId: sessionId)
400
+ }
401
+ }
402
+ }
403
+ }
404
+
405
+ @available(iOS 26.0, *)
406
+ @MainActor
407
+ private func beginModernRecognition(
408
+ call: CAPPluginCall?,
409
+ options: RecognitionOptions,
410
+ locale: Locale,
411
+ sessionId: Int,
412
+ restarting: Bool
413
+ ) {
414
+ let session = SpeechAnalyzerRecognitionSession(
415
+ locale: locale,
416
+ maxResults: options.maxResults,
417
+ includePartialResults: options.partialResults
418
+ )
419
+ modernRecognitionSession = session
420
+
421
+ session.onListeningStarted = { [weak self, weak session] in
422
+ guard let self, let session, self.modernRecognitionSession === session, self.activeSessionId == sessionId else {
423
+ return
424
+ }
425
+
426
+ self.emitListeningState(state: "started", sessionId: sessionId, reason: restarting ? .results : .userStart, status: "started")
427
+ }
428
+
429
+ session.onListeningStopped = { [weak self, weak session] in
430
+ guard let self, let session, self.modernRecognitionSession === session, self.activeSessionId == sessionId else {
431
+ return
432
+ }
433
+
434
+ if self.pendingContinuousRestartSessionId == sessionId {
435
+ self.modernRecognitionSession = nil
436
+ self.scheduleContinuousRestart(sessionId: sessionId)
437
+ return
438
+ }
439
+
440
+ self.finishSessionIfNeeded(
441
+ sessionId: sessionId,
442
+ reason: self.pendingStopReason ?? .results,
443
+ errorCode: nil,
444
+ sessionAlreadyStopped: true
445
+ )
446
+ }
447
+
448
+ session.onResult = { [weak self, weak session] matches, isFinal in
449
+ guard let self, let session, self.modernRecognitionSession === session, self.activeSessionId == sessionId else {
450
+ return
451
+ }
452
+
453
+ self.latestPartialMatches = matches
454
+
455
+ if isFinal {
456
+ if self.prepareContinuousRestartIfNeeded(matches: matches, sessionId: sessionId) {
457
+ return
458
+ }
459
+
460
+ if options.partialResults {
461
+ self.emitPartialResultEvent(
462
+ matches: matches,
463
+ includeAccumulatedText: !self.accumulatedTranscriptText().isEmpty
464
+ )
465
+ } else {
466
+ let startCall = self.activeCall
467
+ self.activeCall = nil
468
+ startCall?.resolve(["matches": matches])
201
469
  }
470
+ self.pendingStopReason = self.pendingStopReason ?? .results
471
+ } else if options.partialResults {
472
+ self.emitPartialResultEvent(matches: matches)
202
473
  }
474
+ }
203
475
 
204
- if let error {
205
- self.handleRecognitionError(error)
476
+ session.onError = { [weak self, weak session] error in
477
+ guard let self, let session, self.modernRecognitionSession === session, self.activeSessionId == sessionId else {
478
+ return
479
+ }
480
+
481
+ self.handleRecognitionError(error, sessionId: sessionId)
482
+ }
483
+
484
+ Task { @MainActor [weak self, weak session] in
485
+ guard let self, let session, self.modernRecognitionSession === session, self.activeSessionId == sessionId else {
486
+ return
487
+ }
488
+
489
+ do {
490
+ try await session.start()
491
+ if options.partialResults, let call {
492
+ call.resolve()
493
+ self.activeCall = nil
494
+ }
495
+ } catch {
496
+ call?.reject(error.localizedDescription)
497
+ self.emitErrorEvent(code: "MODERN_START_FAILED", message: error.localizedDescription, sessionId: sessionId)
498
+ self.activeCall = nil
499
+ self.finishSessionIfNeeded(sessionId: sessionId, reason: .error, errorCode: "MODERN_START_FAILED")
206
500
  }
207
501
  }
208
502
  }
@@ -214,38 +508,91 @@ public final class SpeechRecognitionPlugin: CAPPlugin, CAPBridgedPlugin {
214
508
  try session.setActive(true, options: .notifyOthersOnDeactivation)
215
509
  }
216
510
 
217
- private func cleanupRecognition(notifyStop: Bool) {
218
- DispatchQueue.main.async {
219
- CAPLog.print("[SpeechRecognition] Cleaning up recognition resources")
220
- if self.audioEngine.isRunning {
221
- self.audioEngine.stop()
222
- }
511
+ private func clearLegacyRecognitionResources() {
512
+ if audioEngine.isRunning {
513
+ audioEngine.stop()
514
+ }
223
515
 
224
- if self.hasInstalledTap {
225
- self.audioEngine.inputNode.removeTap(onBus: 0)
226
- self.hasInstalledTap = false
227
- }
516
+ if hasInstalledTap {
517
+ audioEngine.inputNode.removeTap(onBus: 0)
518
+ hasInstalledTap = false
519
+ }
228
520
 
229
- self.recognitionRequest?.endAudio()
230
- self.recognitionRequest = nil
231
- self.recognitionTask?.cancel()
232
- self.recognitionTask = nil
233
- self.speechRecognizer = nil
234
- self.currentOptions = nil
235
- self.activeCall = nil
521
+ recognitionRequest?.endAudio()
522
+ recognitionRequest = nil
523
+ recognitionTask?.cancel()
524
+ recognitionTask = nil
525
+ speechRecognizer = nil
526
+ }
527
+
528
+ private func finishSessionIfNeeded(
529
+ sessionId: Int,
530
+ reason: ListeningReason,
531
+ errorCode: String?,
532
+ sessionAlreadyStopped: Bool = false
533
+ ) {
534
+ guard activeSessionId == sessionId else {
535
+ return
536
+ }
236
537
 
237
- if notifyStop {
238
- self.notifyListeners("listeningState", data: ["status": "stopped"])
538
+ emitStoppingIfNeeded(sessionId: sessionId, reason: reason, errorCode: errorCode)
539
+
540
+ activeSessionId = nil
541
+ currentOptions = nil
542
+ pendingStopReason = nil
543
+ emittedStoppingSessionId = nil
544
+ cancelPendingContinuousRestart()
545
+
546
+ if #available(iOS 26.0, *),
547
+ let modernSession = modernRecognitionSession as? SpeechAnalyzerRecognitionSession,
548
+ !sessionAlreadyStopped {
549
+ Task { @MainActor in
550
+ await modernSession.stop()
551
+ if self.modernRecognitionSession === modernSession {
552
+ self.modernRecognitionSession = nil
553
+ }
554
+ self.finalizeFinishedSession(sessionId: sessionId, reason: reason, errorCode: errorCode)
239
555
  }
556
+ return
557
+ }
558
+
559
+ if #available(iOS 26.0, *),
560
+ let modernSession = modernRecognitionSession as? SpeechAnalyzerRecognitionSession,
561
+ sessionAlreadyStopped,
562
+ modernRecognitionSession === modernSession {
563
+ modernRecognitionSession = nil
240
564
  }
565
+
566
+ finalizeFinishedSession(sessionId: sessionId, reason: reason, errorCode: errorCode)
241
567
  }
242
568
 
243
- private func handleRecognitionError(_ error: Error) {
244
- DispatchQueue.main.async {
245
- CAPLog.print("[SpeechRecognition] Error from recognizer: \(error.localizedDescription)")
246
- self.cleanupRecognition(notifyStop: true)
247
- self.activeCall?.reject(error.localizedDescription)
569
+ private func rejectPendingStartCallIfNeeded(message: String) {
570
+ guard let startCall = activeCall else {
571
+ return
248
572
  }
573
+
574
+ activeCall = nil
575
+ startCall.reject(message)
576
+ }
577
+
578
+ private func handleRecognitionError(_ error: Error, sessionId: Int) {
579
+ guard activeSessionId == sessionId else {
580
+ return
581
+ }
582
+
583
+ if pendingContinuousRestartSessionId == sessionId, pendingStopReason == nil {
584
+ return
585
+ }
586
+
587
+ let code = errorCode(for: error)
588
+ emitErrorEvent(code: code, message: error.localizedDescription, sessionId: sessionId)
589
+
590
+ if let startCall = activeCall, !(currentOptions?.partialResults ?? false) {
591
+ activeCall = nil
592
+ startCall.reject(error.localizedDescription)
593
+ }
594
+
595
+ finishSessionIfNeeded(sessionId: sessionId, reason: pendingStopReason ?? .error, errorCode: code)
249
596
  }
250
597
 
251
598
  private func buildMatches(from result: SFSpeechRecognitionResult, maxResults: Int) -> [String] {
@@ -256,6 +603,197 @@ public final class SpeechRecognitionPlugin: CAPPlugin, CAPBridgedPlugin {
256
603
  return matches
257
604
  }
258
605
 
606
+ private func finalizeFinishedSession(sessionId: Int, reason: ListeningReason, errorCode: String?) {
607
+ clearLegacyRecognitionResources()
608
+ resetContinuousPTTState()
609
+ notifyListeners("readyForNextSession", data: ["sessionId": sessionId])
610
+ emitListeningState(state: "stopped", sessionId: sessionId, reason: reason, errorCode: errorCode, status: "stopped")
611
+ }
612
+
613
+ private func prepareContinuousRestartIfNeeded(matches: [String], sessionId: Int) -> Bool {
614
+ guard shouldRestartContinuousPTT(sessionId: sessionId) else {
615
+ return false
616
+ }
617
+
618
+ if let text = matches.first?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
619
+ appendToAccumulatedTranscript(text)
620
+ }
621
+
622
+ pendingContinuousRestartSessionId = sessionId
623
+ emitPartialResultEvent(matches: matches, isRestarting: true)
624
+ latestPartialMatches = []
625
+ return true
626
+ }
627
+
628
+ private func shouldRestartContinuousPTT(sessionId: Int) -> Bool {
629
+ activeSessionId == sessionId &&
630
+ currentOptions?.continuousPTT == true &&
631
+ pttButtonHeld &&
632
+ pendingStopReason == nil
633
+ }
634
+
635
+ private func scheduleContinuousRestart(sessionId: Int) {
636
+ guard activeSessionId == sessionId, let options = currentOptions else {
637
+ pendingContinuousRestartSessionId = nil
638
+ return
639
+ }
640
+
641
+ cancelPendingContinuousRestart()
642
+
643
+ let workItem = DispatchWorkItem { [weak self] in
644
+ guard let self else {
645
+ return
646
+ }
647
+ guard self.pendingContinuousRestartSessionId == sessionId,
648
+ self.shouldRestartContinuousPTT(sessionId: sessionId)
649
+ else {
650
+ self.pendingContinuousRestartSessionId = nil
651
+ return
652
+ }
653
+
654
+ self.pendingContinuousRestartSessionId = nil
655
+ self.beginRecognition(call: nil, options: options, sessionId: sessionId, restarting: true)
656
+ }
657
+
658
+ continuousRestartWorkItem = workItem
659
+ DispatchQueue.main.asyncAfter(deadline: .now() + continuousRestartDelay, execute: workItem)
660
+ }
661
+
662
+ private func cancelPendingContinuousRestart() {
663
+ continuousRestartWorkItem?.cancel()
664
+ continuousRestartWorkItem = nil
665
+ }
666
+
667
+ private func resetContinuousPTTState() {
668
+ cancelPendingContinuousRestart()
669
+ pendingContinuousRestartSessionId = nil
670
+ accumulatedTranscript = ""
671
+ latestPartialMatches = []
672
+ }
673
+
674
+ private func emitPartialResultEvent(
675
+ matches: [String],
676
+ isRestarting: Bool = false,
677
+ forced: Bool = false,
678
+ includeAccumulatedText: Bool = false
679
+ ) {
680
+ var payload: [String: Any] = [:]
681
+
682
+ if !matches.isEmpty {
683
+ payload["matches"] = matches
684
+ }
685
+
686
+ if includeAccumulatedText {
687
+ let text = currentTranscriptText(currentMatches: matches)
688
+ if !text.isEmpty {
689
+ payload["accumulatedText"] = text
690
+ }
691
+ } else {
692
+ let accumulated = accumulatedTranscriptText()
693
+ if !accumulated.isEmpty {
694
+ payload["accumulated"] = accumulated
695
+ }
696
+ }
697
+
698
+ if isRestarting {
699
+ payload["isRestarting"] = true
700
+ }
701
+
702
+ if forced {
703
+ payload["forced"] = true
704
+ }
705
+
706
+ guard !payload.isEmpty else {
707
+ return
708
+ }
709
+
710
+ notifyListeners("partialResults", data: payload)
711
+ }
712
+
713
+ private func appendToAccumulatedTranscript(_ text: String) {
714
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
715
+ guard !trimmed.isEmpty else {
716
+ return
717
+ }
718
+
719
+ if accumulatedTranscript.isEmpty {
720
+ accumulatedTranscript = trimmed
721
+ } else {
722
+ accumulatedTranscript += " " + trimmed
723
+ }
724
+ }
725
+
726
+ private func accumulatedTranscriptText() -> String {
727
+ accumulatedTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
728
+ }
729
+
730
+ private func currentTranscriptText(currentMatches: [String]? = nil) -> String {
731
+ let accumulated = accumulatedTranscriptText()
732
+ let current = (currentMatches ?? latestPartialMatches)
733
+ .first?
734
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
735
+
736
+ if accumulated.isEmpty {
737
+ return current
738
+ }
739
+ if current.isEmpty {
740
+ return accumulated
741
+ }
742
+ return accumulated + " " + current
743
+ }
744
+
745
+ private func emitListeningState(
746
+ state: String,
747
+ sessionId: Int,
748
+ reason: ListeningReason,
749
+ errorCode: String? = nil,
750
+ status: String? = nil
751
+ ) {
752
+ var payload: [String: Any] = [
753
+ "state": state,
754
+ "sessionId": sessionId,
755
+ "reason": reason.rawValue
756
+ ]
757
+ if let errorCode {
758
+ payload["errorCode"] = errorCode
759
+ }
760
+ if let status {
761
+ payload["status"] = status
762
+ }
763
+ notifyListeners("listeningState", data: payload)
764
+ }
765
+
766
+ private func emitStoppingIfNeeded(sessionId: Int, reason: ListeningReason, errorCode: String?) {
767
+ guard emittedStoppingSessionId != sessionId else {
768
+ return
769
+ }
770
+
771
+ emittedStoppingSessionId = sessionId
772
+ emitListeningState(
773
+ state: "stoppingListening",
774
+ sessionId: sessionId,
775
+ reason: reason,
776
+ errorCode: errorCode
777
+ )
778
+ }
779
+
780
+ private func emitErrorEvent(code: String, message: String, sessionId: Int) {
781
+ notifyListeners("error", data: [
782
+ "code": code,
783
+ "message": message,
784
+ "sessionId": sessionId
785
+ ])
786
+ }
787
+
788
+ private func errorCode(for error: Error) -> String {
789
+ let nsError = error as NSError
790
+ let sanitizedDomain = nsError.domain
791
+ .replacingOccurrences(of: "[^A-Za-z0-9]+", with: "_", options: .regularExpression)
792
+ .trimmingCharacters(in: CharacterSet(charactersIn: "_"))
793
+ .uppercased()
794
+ return sanitizedDomain.isEmpty ? "IOS_\(nsError.code)" : "\(sanitizedDomain)_\(nsError.code)"
795
+ }
796
+
259
797
  private var isSpeechPermissionGranted: Bool {
260
798
  switch SFSpeechRecognizer.authorizationStatus() {
261
799
  case .authorized:
@@ -267,6 +805,22 @@ public final class SpeechRecognitionPlugin: CAPPlugin, CAPBridgedPlugin {
267
805
  }
268
806
  }
269
807
 
808
+ private var isRecognitionActive: Bool {
809
+ if activeSessionId != nil {
810
+ return true
811
+ }
812
+
813
+ if audioEngine.isRunning || recognitionTask != nil {
814
+ return true
815
+ }
816
+
817
+ if #available(iOS 26.0, *), modernRecognitionSession != nil {
818
+ return true
819
+ }
820
+
821
+ return false
822
+ }
823
+
270
824
  private var permissionState: PermissionState {
271
825
  let speechStatus = SFSpeechRecognizer.authorizationStatus()
272
826
  let micStatus = AVAudioSession.sharedInstance().recordPermission
@@ -281,10 +835,6 @@ public final class SpeechRecognitionPlugin: CAPPlugin, CAPBridgedPlugin {
281
835
 
282
836
  return .granted
283
837
  }
284
-
285
- @objc func getPluginVersion(_ call: CAPPluginCall) {
286
- call.resolve(["version": pluginVersion])
287
- }
288
838
  }
289
839
 
290
840
  private struct RecognitionOptions {
@@ -292,4 +842,6 @@ private struct RecognitionOptions {
292
842
  let maxResults: Int
293
843
  let partialResults: Bool
294
844
  let addPunctuation: Bool
845
+ let useOnDeviceRecognition: Bool
846
+ let continuousPTT: Bool
295
847
  }