@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.
- package/README.md +317 -20
- package/android/src/main/java/app/capgo/speechrecognition/Constants.java +2 -0
- package/android/src/main/java/app/capgo/speechrecognition/SpeechRecognitionPlugin.java +983 -137
- package/dist/docs.json +392 -21
- package/dist/esm/definitions.d.ts +178 -3
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web.d.ts +5 -1
- package/dist/esm/web.js +12 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +12 -0
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +12 -0
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/SpeechRecognitionPlugin/SpeechAnalyzerRecognitionSession.swift +437 -0
- package/ios/Sources/SpeechRecognitionPlugin/SpeechRecognitionPlugin.swift +622 -70
- package/package.json +2 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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":
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
141
|
-
|
|
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
|
|
151
|
-
|
|
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
|
-
|
|
342
|
+
emitListeningState(state: "started", sessionId: sessionId, reason: restarting ? .results : .userStart, status: "started")
|
|
175
343
|
} catch {
|
|
176
|
-
call
|
|
177
|
-
|
|
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 {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
200
|
-
self.
|
|
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
|
-
|
|
205
|
-
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
self.audioEngine.stop()
|
|
222
|
-
}
|
|
511
|
+
private func clearLegacyRecognitionResources() {
|
|
512
|
+
if audioEngine.isRunning {
|
|
513
|
+
audioEngine.stop()
|
|
514
|
+
}
|
|
223
515
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
516
|
+
if hasInstalledTap {
|
|
517
|
+
audioEngine.inputNode.removeTap(onBus: 0)
|
|
518
|
+
hasInstalledTap = false
|
|
519
|
+
}
|
|
228
520
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
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
|
}
|