@gmessier/nitro-speech 0.3.3 → 0.4.1

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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -148
  3. package/android/build.gradle +0 -1
  4. package/android/src/main/cpp/cpp-adapter.cpp +5 -1
  5. package/android/src/main/java/com/margelo/nitro/nitrospeech/HybridNitroSpeech.kt +2 -0
  6. package/android/src/main/java/com/margelo/nitro/nitrospeech/recognizer/AutoStopper.kt +82 -18
  7. package/android/src/main/java/com/margelo/nitro/nitrospeech/recognizer/HybridRecognizer.kt +118 -30
  8. package/android/src/main/java/com/margelo/nitro/nitrospeech/recognizer/Logger.kt +16 -0
  9. package/android/src/main/java/com/margelo/nitro/nitrospeech/recognizer/RecognitionListenerSession.kt +35 -24
  10. package/ios/{BufferUtil.swift → Audio/AudioBufferConverter.swift} +3 -34
  11. package/ios/Audio/AudioLevelTracker.swift +60 -0
  12. package/ios/Coordinator.swift +105 -0
  13. package/ios/Engines/AnalyzerEngine.swift +241 -0
  14. package/ios/Engines/DictationRuntime.swift +67 -0
  15. package/ios/Engines/RecognizerEngine.swift +315 -0
  16. package/ios/Engines/SFSpeechEngine.swift +119 -0
  17. package/ios/Engines/SpeechRuntime.swift +58 -0
  18. package/ios/Engines/TranscriberRuntimeProtocol.swift +21 -0
  19. package/ios/HybridNitroSpeech.swift +1 -10
  20. package/ios/HybridRecognizer.swift +142 -191
  21. package/ios/LocaleManager.swift +73 -0
  22. package/ios/{AppStateObserver.swift → Shared/AppStateObserver.swift} +1 -2
  23. package/ios/Shared/AutoStopper.swift +147 -0
  24. package/ios/Shared/HapticImpact.swift +24 -0
  25. package/ios/Shared/Log.swift +41 -0
  26. package/ios/Shared/Permissions.swift +59 -0
  27. package/ios/Shared/Utils.swift +58 -0
  28. package/lib/NitroSpeech.d.ts +2 -0
  29. package/lib/NitroSpeech.js +2 -0
  30. package/lib/Recognizer/RecognizerRef.d.ts +7 -0
  31. package/lib/Recognizer/RecognizerRef.js +16 -0
  32. package/lib/Recognizer/SpeechRecognizer.d.ts +8 -0
  33. package/lib/Recognizer/SpeechRecognizer.js +9 -0
  34. package/lib/Recognizer/methods.d.ts +9 -0
  35. package/lib/Recognizer/methods.js +33 -0
  36. package/lib/Recognizer/types.d.ts +6 -0
  37. package/lib/Recognizer/types.js +1 -0
  38. package/lib/Recognizer/useRecognizer.d.ts +16 -0
  39. package/lib/Recognizer/useRecognizer.js +71 -0
  40. package/lib/Recognizer/useRecognizerIsActive.d.ts +25 -0
  41. package/lib/Recognizer/useRecognizerIsActive.js +40 -0
  42. package/lib/Recognizer/useVoiceInputVolume.d.ts +25 -0
  43. package/lib/Recognizer/useVoiceInputVolume.js +52 -0
  44. package/lib/index.d.ts +7 -0
  45. package/lib/index.js +7 -0
  46. package/lib/specs/NitroSpeech.nitro.d.ts +8 -0
  47. package/lib/specs/NitroSpeech.nitro.js +1 -0
  48. package/lib/specs/Recognizer.nitro.d.ts +97 -0
  49. package/lib/specs/Recognizer.nitro.js +1 -0
  50. package/lib/specs/SpeechRecognitionConfig.d.ts +162 -0
  51. package/lib/specs/SpeechRecognitionConfig.js +1 -0
  52. package/lib/specs/VolumeChangeEvent.d.ts +31 -0
  53. package/lib/specs/VolumeChangeEvent.js +1 -0
  54. package/nitro.json +0 -4
  55. package/nitrogen/generated/android/NitroSpeech+autolinking.cmake +2 -2
  56. package/nitrogen/generated/android/NitroSpeechOnLoad.cpp +4 -2
  57. package/nitrogen/generated/android/c++/JFunc_void_VolumeChangeEvent.hpp +78 -0
  58. package/nitrogen/generated/android/c++/JFunc_void_std__vector_std__string_.hpp +14 -14
  59. package/nitrogen/generated/android/c++/JHybridRecognizerSpec.cpp +73 -19
  60. package/nitrogen/generated/android/c++/JHybridRecognizerSpec.hpp +8 -4
  61. package/nitrogen/generated/android/c++/JIosPreset.hpp +58 -0
  62. package/nitrogen/generated/android/c++/JMutableSpeechRecognitionConfig.hpp +79 -0
  63. package/nitrogen/generated/android/c++/{JSpeechToTextParams.hpp → JSpeechRecognitionConfig.hpp} +48 -30
  64. package/nitrogen/generated/android/c++/JVolumeChangeEvent.hpp +65 -0
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrospeech/Func_void_VolumeChangeEvent.kt +80 -0
  66. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrospeech/HybridRecognizerSpec.kt +22 -5
  67. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrospeech/IosPreset.kt +23 -0
  68. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrospeech/MutableSpeechRecognitionConfig.kt +76 -0
  69. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrospeech/SpeechRecognitionConfig.kt +121 -0
  70. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrospeech/VolumeChangeEvent.kt +61 -0
  71. package/nitrogen/generated/ios/NitroSpeech-Swift-Cxx-Bridge.cpp +46 -30
  72. package/nitrogen/generated/ios/NitroSpeech-Swift-Cxx-Bridge.hpp +211 -69
  73. package/nitrogen/generated/ios/NitroSpeech-Swift-Cxx-Umbrella.hpp +13 -3
  74. package/nitrogen/generated/ios/c++/HybridRecognizerSpecSwift.hpp +49 -9
  75. package/nitrogen/generated/ios/swift/Func_void_VolumeChangeEvent.swift +46 -0
  76. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  77. package/nitrogen/generated/ios/swift/HybridRecognizerSpec.swift +7 -3
  78. package/nitrogen/generated/ios/swift/HybridRecognizerSpec_cxx.swift +78 -18
  79. package/nitrogen/generated/ios/swift/IosPreset.swift +40 -0
  80. package/nitrogen/generated/ios/swift/MutableSpeechRecognitionConfig.swift +118 -0
  81. package/nitrogen/generated/ios/swift/{SpeechToTextParams.swift → SpeechRecognitionConfig.swift} +108 -43
  82. package/nitrogen/generated/ios/swift/VolumeChangeEvent.swift +52 -0
  83. package/nitrogen/generated/shared/c++/HybridRecognizerSpec.cpp +5 -1
  84. package/nitrogen/generated/shared/c++/HybridRecognizerSpec.hpp +18 -7
  85. package/nitrogen/generated/shared/c++/IosPreset.hpp +76 -0
  86. package/nitrogen/generated/shared/c++/MutableSpeechRecognitionConfig.hpp +105 -0
  87. package/nitrogen/generated/shared/c++/{SpeechToTextParams.hpp → SpeechRecognitionConfig.hpp} +39 -20
  88. package/nitrogen/generated/shared/c++/VolumeChangeEvent.hpp +91 -0
  89. package/package.json +15 -16
  90. package/src/NitroSpeech.ts +5 -0
  91. package/src/Recognizer/RecognizerRef.ts +27 -0
  92. package/src/Recognizer/SpeechRecognizer.ts +10 -0
  93. package/src/Recognizer/methods.ts +45 -0
  94. package/src/Recognizer/types.ts +34 -0
  95. package/src/Recognizer/useRecognizer.ts +87 -0
  96. package/src/Recognizer/useRecognizerIsActive.ts +49 -0
  97. package/src/Recognizer/useVoiceInputVolume.ts +65 -0
  98. package/src/index.ts +13 -182
  99. package/src/specs/NitroSpeech.nitro.ts +2 -163
  100. package/src/specs/Recognizer.nitro.ts +113 -0
  101. package/src/specs/SpeechRecognitionConfig.ts +167 -0
  102. package/src/specs/VolumeChangeEvent.ts +31 -0
  103. package/android/proguard-rules.pro +0 -1
  104. package/ios/AnylyzerTranscriber.swift +0 -331
  105. package/ios/AutoStopper.swift +0 -69
  106. package/ios/HapticImpact.swift +0 -32
  107. package/ios/LegacySpeechRecognizer.swift +0 -161
  108. package/lib/commonjs/index.js +0 -145
  109. package/lib/commonjs/index.js.map +0 -1
  110. package/lib/commonjs/package.json +0 -1
  111. package/lib/commonjs/specs/NitroSpeech.nitro.js +0 -6
  112. package/lib/commonjs/specs/NitroSpeech.nitro.js.map +0 -1
  113. package/lib/module/index.js +0 -138
  114. package/lib/module/index.js.map +0 -1
  115. package/lib/module/package.json +0 -1
  116. package/lib/module/specs/NitroSpeech.nitro.js +0 -4
  117. package/lib/module/specs/NitroSpeech.nitro.js.map +0 -1
  118. package/lib/tsconfig.tsbuildinfo +0 -1
  119. package/lib/typescript/index.d.ts +0 -50
  120. package/lib/typescript/index.d.ts.map +0 -1
  121. package/lib/typescript/specs/NitroSpeech.nitro.d.ts +0 -162
  122. package/lib/typescript/specs/NitroSpeech.nitro.d.ts.map +0 -1
  123. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrospeech/SpeechToTextParams.kt +0 -68
@@ -1,13 +1,8 @@
1
1
  import Foundation
2
- import Speech
3
2
  import NitroModules
4
- import os.log
5
- import AVFoundation
6
3
 
7
- class HybridRecognizer: HybridRecognizerSpec {
8
- internal let logger = Logger(subsystem: "com.margelo.nitro.nitrospeech", category: "Recognizer")
9
- internal static let defaultAutoFinishRecognitionMs = 8000.0
10
- internal static let speechRmsThreshold: Float = 0.005623
4
+ class HybridRecognizer: HybridRecognizerSpec {
5
+ var config: SpeechRecognitionConfig?
11
6
 
12
7
  var onReadyForSpeech: (() -> Void)?
13
8
  var onRecordingStopped: (() -> Void)?
@@ -15,228 +10,184 @@ class HybridRecognizer: HybridRecognizerSpec {
15
10
  var onAutoFinishProgress: ((Double) -> Void)?
16
11
  var onError: ((String) -> Void)?
17
12
  var onPermissionDenied: (() -> Void)?
18
- var onVolumeChange: ((Double) -> Void)?
13
+ var onVolumeChange: ((VolumeChangeEvent) -> Void)?
19
14
 
20
- internal var audioEngine: AVAudioEngine?
15
+ private let coordinator = Coordinator()
16
+ private var paramsHash: String?
17
+ private var engine: RecognizerEngine?
21
18
 
22
- internal var autoStopper: AutoStopper?
23
- internal var appStateObserver: AppStateObserver?
24
- internal var isActive: Bool = false
25
- internal var isStopping: Bool = false
26
- internal var config: SpeechToTextParams?
27
- internal var levelSmoothed: Float = 0
28
-
29
- func getIsActive() -> Bool {
30
- return self.isActive
19
+ override init() {
20
+ super.init()
21
+ self.coordinator.recognizerDelegate = self
31
22
  }
32
23
 
33
- func startListening(params: SpeechToTextParams) {
34
- if isActive {
35
- return
36
- }
37
-
38
- SFSpeechRecognizer.requestAuthorization { [weak self] authStatus in
39
- Task { @MainActor in
40
- guard let self = self else { return }
41
-
42
- self.config = params
43
-
44
- switch authStatus {
45
- case .authorized:
46
- self.requestMicrophonePermission()
47
- case .denied, .restricted:
48
- self.onPermissionDenied?()
49
- case .notDetermined:
50
- self.onError?("Speech recognition not determined")
51
- @unknown default:
52
- self.onError?("Unknown authorization status")
53
- }
54
- }
24
+ private let lg = Lg(prefix: "HybridRecognizer")
25
+
26
+ @discardableResult
27
+ func prewarm(defaultParams: SpeechRecognitionConfig?) -> Promise<Void> {
28
+ return Promise.async(.userInitiated) { [weak self] in
29
+ // Ensure correct engine is selected based on params and ios version
30
+ await self?.ensureEngine(params: defaultParams)
31
+ // try to preload assets and check if speech engine is available on OS level
32
+ await self?.engine?.prewarm(for: .prewarm)
55
33
  }
56
34
  }
57
-
58
- func dispose() {
59
- stopListening()
35
+
36
+ func startListening(params: SpeechRecognitionConfig?) {
37
+ Task {
38
+ // Ensure correct engine is selected based on params and ios version
39
+ await ensureEngine(params: params)
40
+ engine?.start()
41
+ }
60
42
  }
61
43
 
62
44
  func stopListening() {
63
- guard isActive, !isStopping else { return }
64
- isStopping = true
65
-
66
- self.stopHapticFeedback()
45
+ engine?.stop()
67
46
  }
68
47
 
69
- internal func handleInternalStopTrigger() {
70
- self.stopListening()
48
+ func resetAutoFinishTime() {
49
+ engine?.updateSession(resetTimer: true)
71
50
  }
72
51
 
73
52
  func addAutoFinishTime(additionalTimeMs: Double?) {
74
- guard isActive, !isStopping else { return }
75
-
76
- autoStopper?.indicateRecordingActivity(
77
- from: "refreshAutoFinish",
78
- addMsToThreshold: additionalTimeMs
79
- )
53
+ if let additionalTimeMs {
54
+ engine?.updateSession(addMsToTimer: additionalTimeMs)
55
+ } else {
56
+ // Reset timer to original baseline.
57
+ engine?.updateSession(resetTimer: true)
58
+ }
80
59
  }
81
60
 
82
- func updateAutoFinishTime(newTimeMs: Double, withRefresh: Bool?) {
83
- guard isActive, !isStopping else { return }
84
-
85
- autoStopper?.updateSilenceThreshold(newThresholdMs: newTimeMs)
86
-
87
- if withRefresh == true {
88
- autoStopper?.indicateRecordingActivity(
89
- from: "updateAutoFinishTime",
90
- addMsToThreshold: nil
91
- )
92
- }
61
+ func updateConfig(newConfig: MutableSpeechRecognitionConfig?, resetAutoFinishTime: Bool?) {
62
+ engine?.updateSession(
63
+ newConfig: newConfig,
64
+ resetTimer: resetAutoFinishTime
65
+ )
93
66
  }
94
67
 
95
- internal func requestMicrophonePermission() {}
96
-
97
- internal func startRecognitionSetup() -> Bool {
98
- isStopping = false
99
- isActive = true
100
-
101
- initAutoStop()
102
- monitorAppState()
103
- guard startAudioSession() else {
104
- cleanup(from: "startRecognitionSetup")
105
- return false
106
- }
107
-
108
- return true
68
+ func getIsActive() -> Bool {
69
+ engine?.isActive ?? false
109
70
  }
110
-
111
- internal func startRecognitionFeedback() {
112
- self.startHapticFeedback()
113
- autoStopper?.indicateRecordingActivity(
114
- from: "startListening",
115
- addMsToThreshold: nil
71
+
72
+ func getVoiceInputVolume() -> VolumeChangeEvent {
73
+ return engine?.getVoiceInputVolume() ?? VolumeChangeEvent(
74
+ smoothedVolume: 0,
75
+ rawVolume: 0,
76
+ db: nil
116
77
  )
117
- onReadyForSpeech?()
118
- onResult?([])
119
78
  }
120
79
 
121
- internal func startRecognition() {}
122
- internal func startRecognition() async {}
123
-
124
- internal func cleanup(from: String) {
125
- logger.info("cleanup called from: \(from)")
126
- deinitAutoStop()
127
- stopMonitorAppState()
128
- stopAudioSession()
129
- stopAudioEngine()
130
- levelSmoothed = 0
131
- isActive = false
132
- isStopping = false
133
- onVolumeChange?(0)
80
+ func getSupportedLocalesIOS() -> [String] {
81
+ return self.coordinator.getSupportedLocales()
134
82
  }
135
-
136
- internal func stopAudioEngine() {
137
- if let audioEngine = audioEngine, audioEngine.isRunning {
138
- audioEngine.stop()
83
+
84
+ private func ensureEngine(params: SpeechRecognitionConfig?) async {
85
+ // Remember new params
86
+ config = params
87
+ let hash = Utils.hashParams(params)
88
+ if engine != nil && hash == paramsHash {
89
+ lg.log("Reuse Engine")
90
+ // Engine is already correct
91
+ return
92
+ }
93
+ if hash != paramsHash {
94
+ // Initialize when trying to select new engine with new params
95
+ await coordinator.initialize()
96
+ paramsHash = hash
97
+ }
98
+ lg.log("hash: \(hash)")
99
+ // Try to select new engine
100
+ engine = coordinator.getEngine()
101
+ if engine == nil {
102
+ // Only wrong locale can wipe out all candidates
103
+ self.onError?("No recognition engine available for the requested locale")
104
+ return
139
105
  }
140
- audioEngine?.inputNode.removeTap(onBus: 0)
141
- audioEngine = nil
142
106
  }
143
-
144
- internal func monitorAppState() {
145
- appStateObserver = AppStateObserver { [weak self] in
146
- guard let self = self, self.isActive else { return }
147
- self.handleInternalStopTrigger()
107
+ }
108
+
109
+ protocol RecognizerDelegate: AnyObject {
110
+ var config: SpeechRecognitionConfig? { get }
111
+ func softlyUpdateConfig(newConfig: MutableSpeechRecognitionConfig?)
112
+ func reselectEngine(forPrewarm: Bool)
113
+ func readyForSpeech()
114
+ func recordingStopped()
115
+ func result (batches: [String])
116
+ func autoFinishProgress (timeLeftMs: Double)
117
+ func error (message: String)
118
+ func permissionDenied ()
119
+ func volumeChange (event: VolumeChangeEvent)
120
+ }
121
+
122
+ extension HybridRecognizer: RecognizerDelegate {
123
+ func softlyUpdateConfig(newConfig: MutableSpeechRecognitionConfig?) {
124
+ if let newConfig {
125
+ config = SpeechRecognitionConfig(
126
+ locale: config?.locale,
127
+ contextualStrings: config?.contextualStrings,
128
+ maskOffensiveWords: config?.maskOffensiveWords,
129
+ autoFinishRecognitionMs: newConfig.autoFinishRecognitionMs ?? config?.autoFinishRecognitionMs,
130
+ autoFinishProgressIntervalMs: newConfig.autoFinishProgressIntervalMs ?? config?.autoFinishProgressIntervalMs,
131
+ resetAutoFinishVoiceSensitivity: newConfig.resetAutoFinishVoiceSensitivity ?? config?.resetAutoFinishVoiceSensitivity,
132
+ disableRepeatingFilter: newConfig.disableRepeatingFilter ?? config?.disableRepeatingFilter,
133
+ startHapticFeedbackStyle: newConfig.startHapticFeedbackStyle ?? config?.startHapticFeedbackStyle,
134
+ stopHapticFeedbackStyle: newConfig.stopHapticFeedbackStyle ?? config?.stopHapticFeedbackStyle,
135
+ androidFormattingPreferQuality: config?.androidFormattingPreferQuality,
136
+ androidUseWebSearchModel: config?.androidUseWebSearchModel,
137
+ androidDisableBatchHandling: config?.androidDisableBatchHandling,
138
+ iosAddPunctuation: config?.iosAddPunctuation,
139
+ iosPreset: config?.iosPreset,
140
+ iosAtypicalSpeech: config?.iosAtypicalSpeech
141
+ )
148
142
  }
149
143
  }
150
- internal func stopMonitorAppState () {
151
- appStateObserver?.stop()
152
- appStateObserver = nil
144
+
145
+ func readyForSpeech() {
146
+ self.lg.log("[HR -> onReadyForSpeech]")
147
+ self.onReadyForSpeech?()
153
148
  }
154
149
 
155
- internal func initAutoStop() {
156
- autoStopper = AutoStopper(
157
- silenceThresholdMs: config?.autoFinishRecognitionMs ?? Self.defaultAutoFinishRecognitionMs,
158
- onProgress: { [weak self] timeLeftMs in
159
- self?.onAutoFinishProgress?(timeLeftMs)
160
- },
161
- onTimeout: { [weak self] in
162
- self?.handleInternalStopTrigger()
163
- }
164
- )
150
+ func recordingStopped() {
151
+ self.lg.log("[onRecordingStopped]")
152
+ self.onRecordingStopped?()
165
153
  }
166
- internal func deinitAutoStop () {
167
- autoStopper?.stop()
168
- autoStopper = nil
169
- }
170
-
171
- internal func startAudioSession() -> Bool {
172
- do {
173
- let audioSession = AVAudioSession.sharedInstance()
174
- try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers)
175
- // Without this, iOS may suppress haptics while recording.
176
- try audioSession.setAllowHapticsAndSystemSoundsDuringRecording(true)
177
- try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
178
- return true
179
- } catch {
180
- onError?("Failed to activate audio session: \(error.localizedDescription)")
181
- return false
182
- }
154
+
155
+ func result(batches: [String]) {
156
+ self.lg.log("[onResult] \(batches)")
157
+ self.onResult?(batches)
183
158
  }
184
- internal func stopAudioSession () {
185
- do {
186
- try AVAudioSession.sharedInstance().setActive(false)
187
- } catch {
188
- logger.info("Failed to deactivate audio session: \(error.localizedDescription)")
189
- return
190
- }
159
+
160
+ func autoFinishProgress(timeLeftMs: Double) {
161
+ self.lg.log("[onAutoFinishProgress] \(timeLeftMs)ms")
162
+ self.onAutoFinishProgress?(timeLeftMs)
191
163
  }
192
164
 
193
- internal func startHapticFeedback() {
194
- if let hapticStyle = config?.startHapticFeedbackStyle {
195
- HapticImpact(style: hapticStyle).trigger()
196
- } else {
197
- HapticImpact(style: .medium).trigger()
198
- }
165
+ func error(message: String) {
166
+ self.lg.log("[onError]")
167
+ self.onError?(message)
199
168
  }
200
- internal func stopHapticFeedback () {
201
- if let hapticStyle = config?.stopHapticFeedbackStyle {
202
- HapticImpact(style: hapticStyle).trigger()
203
- } else {
204
- HapticImpact(style: .medium).trigger()
205
- }
169
+
170
+ func permissionDenied() {
171
+ self.lg.log("[onPermissionDenied]")
172
+ self.onPermissionDenied?()
206
173
  }
207
174
 
208
- internal func trackPartialActivity() {
209
- if !self.isStopping {
210
- self.autoStopper?.indicateRecordingActivity(
211
- from: "partial results",
212
- addMsToThreshold: nil
213
- )
214
- }
175
+ func volumeChange(event: VolumeChangeEvent) {
176
+ // self.lg.log("[onVolumeChange] \(event.rawVolume)")
177
+ self.onVolumeChange?(event)
215
178
  }
216
-
217
- internal func repeatingFilter(text: String) -> String {
218
- var subStrings = text.split { $0.isWhitespace }.map { String($0) }
219
- var joiner = ""
220
- // 10 - arbitrary number of last substrings that is still unstable
221
- // and needs to be filtered. Prev substrings were handled earlier.
222
- if subStrings.count >= 10 {
223
- joiner = subStrings.prefix(subStrings.count - 9).joined(separator: " ")
224
- subStrings = Array(subStrings.suffix(10))
179
+
180
+ func reselectEngine(forPrewarm: Bool) {
181
+ // Remove failed engine from candidates
182
+ coordinator.reportEngineFailure()
183
+ // Reset active engine
184
+ engine = nil
185
+ // Try to prewarm with another candidate
186
+ if forPrewarm {
187
+ self.prewarm(defaultParams: config)
225
188
  } else {
226
- joiner = subStrings.first ?? ""
227
- }
228
- for i in subStrings.indices {
229
- if i == 0 { continue }
230
- // Always add number-contained strings
231
- if #available(iOS 16.0, *), subStrings[i].contains(/\d+/) {
232
- joiner += " \(subStrings[i])"
233
- continue
234
- }
235
-
236
- // Skip consecutive duplicate strings
237
- if subStrings[i] == subStrings[i-1] { continue }
238
- joiner += " \(subStrings[i])"
189
+ // Try to start with another candidate
190
+ self.startListening(params: config)
239
191
  }
240
- return joiner
241
192
  }
242
193
  }
@@ -0,0 +1,73 @@
1
+ import Foundation
2
+ import Speech
3
+
4
+ final class LocaleManager {
5
+ private let sfSpeechLocales = SFSpeechRecognizer.supportedLocales().map { $0.identifier }
6
+ private var speechLocales: [String]
7
+ private var dictationLocales: [String]
8
+ var supportedLocales: [String]
9
+ var SFLocale: Locale?
10
+ var speechLocale: Locale?
11
+ var dictationLocale: Locale?
12
+
13
+ private var equivalentsCountedFor: String?
14
+
15
+ init() async {
16
+ self.speechLocales = []
17
+ self.dictationLocales = []
18
+ self.supportedLocales = sfSpeechLocales
19
+
20
+ if #available(iOS 26.0, *) {
21
+ self.speechLocales = await SpeechTranscriber.supportedLocales.map {
22
+ $0.identifier
23
+ }
24
+ self.dictationLocales = await DictationTranscriber.supportedLocales.map {
25
+ $0.identifier
26
+ }
27
+ Log.log("[Coordinator] sfSpeechLocales: \(self.sfSpeechLocales)")
28
+ Log.log("[Coordinator] speechLocales: \(self.speechLocales)")
29
+ Log.log("[Coordinator] dictationLocales: \(self.dictationLocales)")
30
+ self.supportedLocales = Array(
31
+ Set(sfSpeechLocales)
32
+ .union(Set(speechLocales))
33
+ .union(Set(dictationLocales))
34
+ )
35
+ }
36
+ }
37
+
38
+ func ensureLocale(localeString: String?) async {
39
+ let identifier = localeString ?? "en-US"
40
+ if self.equivalentsCountedFor == identifier {
41
+ // All locales has been counted already, might be nil, but use them
42
+ Log.log("[Coordinator] ensureLocale: \(identifier) -> Already counted ")
43
+ return
44
+ }
45
+ if #available(iOS 26.0, *) {
46
+ let speechEquivalent = await SpeechTranscriber.supportedLocale(
47
+ equivalentTo: Locale(identifier: identifier)
48
+ )?.identifier
49
+ if let speechEquivalent, speechLocales.contains(speechEquivalent) {
50
+ self.speechLocale = Locale(identifier: speechEquivalent)
51
+ } else {
52
+ self.speechLocale = nil
53
+ }
54
+
55
+ let dictationEquivalent = await DictationTranscriber.supportedLocale(
56
+ equivalentTo: Locale(identifier: identifier)
57
+ )?.identifier
58
+ if let dictationEquivalent, self.dictationLocales.contains(dictationEquivalent) {
59
+ self.dictationLocale = Locale(identifier: dictationEquivalent)
60
+ } else {
61
+ self.dictationLocale = nil
62
+ }
63
+ }
64
+ if sfSpeechLocales.contains(identifier) {
65
+ self.SFLocale = Locale(identifier: identifier)
66
+ } else {
67
+ self.SFLocale = nil
68
+ }
69
+ self.equivalentsCountedFor = identifier
70
+ Log.log("[Coordinator] equivalents: speechLocale: \(self.speechLocale?.identifier), dictationLocale: \(self.dictationLocale?.identifier), SFLocale: \(self.SFLocale?.identifier)")
71
+ Log.log("[Coordinator] ensureLocale: \(identifier) -> New")
72
+ }
73
+ }
@@ -1,7 +1,7 @@
1
1
  import Foundation
2
2
  import UIKit
3
3
 
4
- class AppStateObserver {
4
+ final class AppStateObserver {
5
5
  private var observer: NSObjectProtocol?
6
6
  private let onResignActive: () -> Void
7
7
 
@@ -28,4 +28,3 @@ class AppStateObserver {
28
28
  stop()
29
29
  }
30
30
  }
31
-
@@ -0,0 +1,147 @@
1
+ import Foundation
2
+
3
+ final class AutoStopper {
4
+ private static let defaultSilenceThresholdMs = 8000.0
5
+ private static let defaultProgressIntervalMs = 1000.0
6
+ private static let minProgressIntervalMs = 50.0
7
+
8
+ private let lg = Lg(prefix: "AutoStopper", disable: false)
9
+
10
+ private let queue = DispatchQueue(label: "com.margelo.nitrospeech.autostopper")
11
+
12
+ private var silenceThresholdMs: Double
13
+ private var progressIntervalMs: Double
14
+ private var timeLeftMs: Double
15
+ private var isStopped = false
16
+ private var didTimeout = false
17
+ private var timer: DispatchSourceTimer?
18
+
19
+ private let onProgress: (Double) -> Void
20
+ private let onTimeout: () -> Void
21
+
22
+ init(
23
+ silenceThresholdMs: Double?,
24
+ progressIntervalMs: Double?,
25
+ onProgress: @escaping (Double) -> Void,
26
+ onTimeout: @escaping () -> Void
27
+ ) {
28
+ let threshold = Self.clampMs(silenceThresholdMs ?? Self.defaultSilenceThresholdMs)
29
+ let interval = Self.clampMs(progressIntervalMs ?? Self.defaultProgressIntervalMs)
30
+ self.silenceThresholdMs = threshold
31
+ self.progressIntervalMs = interval
32
+ self.timeLeftMs = threshold
33
+ self.onProgress = onProgress
34
+ self.onTimeout = onTimeout
35
+ }
36
+
37
+ deinit {
38
+ queue.sync {
39
+ stopLocked()
40
+ timeLeftMs = 0
41
+ }
42
+ }
43
+
44
+ func resetTimer(from: String) {
45
+ queue.async { [weak self] in
46
+ guard let self, !self.isStopped else { return }
47
+ lg.log("[resetTimer] from:\(from)")
48
+ self.didTimeout = false
49
+ self.timeLeftMs = self.silenceThresholdMs
50
+ self.startOrRescheduleTimerLocked()
51
+ if self.timeLeftMs > 0 {
52
+ self.onProgress(self.timeLeftMs)
53
+ }
54
+ }
55
+ }
56
+
57
+ func updateThreshold(_ newThresholdMs: Double, from: String) {
58
+ queue.async { [weak self] in
59
+ guard let self, !self.isStopped else { return }
60
+ lg.log("[updateThreshold] from:\(from) newThresholdMs:\(newThresholdMs)")
61
+ self.silenceThresholdMs = Self.clampMs(newThresholdMs)
62
+ }
63
+ }
64
+
65
+ func addMsOnce(_ extraMs: Double, from: String) {
66
+ queue.async { [weak self] in
67
+ guard let self, !self.isStopped, extraMs.isFinite else { return }
68
+ lg.log("[addMsOnce] from:\(from) extraMs:\(extraMs)")
69
+ self.timeLeftMs += extraMs
70
+ self.didTimeout = false
71
+ if self.timeLeftMs > 0, self.timer != nil {
72
+ self.onProgress(self.timeLeftMs)
73
+ }
74
+ }
75
+ }
76
+
77
+ func updateProgressInterval(_ newIntervalMs: Double, from: String) {
78
+ queue.async { [weak self] in
79
+ guard let self, !self.isStopped else { return }
80
+ lg.log("[updateProgressInterval] from:\(from) newIntervalMs:\(newIntervalMs)")
81
+ self.progressIntervalMs = Self.clampMs(newIntervalMs)
82
+ if self.timer != nil {
83
+ self.startOrRescheduleTimerLocked()
84
+ }
85
+ }
86
+ }
87
+
88
+ func stop() {
89
+ queue.async { [weak self] in
90
+ guard let self else { return }
91
+ self.stopLocked()
92
+ self.timeLeftMs = 0
93
+ }
94
+ }
95
+
96
+ private func startOrRescheduleTimerLocked() {
97
+ timer?.cancel()
98
+ timer = nil
99
+
100
+ let source = DispatchSource.makeTimerSource(queue: queue)
101
+ let intervalNs = UInt64(progressIntervalMs * 1_000_000)
102
+ source.schedule(
103
+ deadline: .now() + .nanoseconds(Int(intervalNs)),
104
+ repeating: .nanoseconds(Int(intervalNs))
105
+ )
106
+ source.setEventHandler { [weak self] in
107
+ self?.tickLocked()
108
+ }
109
+ timer = source
110
+ source.resume()
111
+ }
112
+
113
+ private func tickLocked() {
114
+ guard !isStopped else { return }
115
+ guard !didTimeout else { return }
116
+
117
+ timeLeftMs -= progressIntervalMs
118
+ if timeLeftMs > 0 {
119
+ lg.log("[onProgress] timeLeftMs:\(timeLeftMs)")
120
+ onProgress(timeLeftMs)
121
+ return
122
+ }
123
+
124
+ timeLeftMs = 0
125
+ didTimeout = true
126
+ cancelTimerLocked()
127
+ lg.log("[onTimeout]")
128
+ onTimeout()
129
+ }
130
+
131
+ private func stopLocked() {
132
+ isStopped = true
133
+ cancelTimerLocked()
134
+ }
135
+
136
+ private func cancelTimerLocked() {
137
+ timer?.cancel()
138
+ timer = nil
139
+ }
140
+
141
+ private static func clampMs(_ value: Double) -> Double {
142
+ if !value.isFinite {
143
+ return minProgressIntervalMs
144
+ }
145
+ return max(minProgressIntervalMs, value)
146
+ }
147
+ }
@@ -0,0 +1,24 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ enum HapticImpact {
5
+ static func trigger(with: HapticFeedbackStyle?) {
6
+ // Default behavior - medium
7
+ let style = with ?? HapticFeedbackStyle.medium
8
+ let hapticStyle: UIImpactFeedbackGenerator.FeedbackStyle? = switch style {
9
+ case .light:
10
+ UIImpactFeedbackGenerator.FeedbackStyle.light
11
+ case .medium:
12
+ UIImpactFeedbackGenerator.FeedbackStyle.medium
13
+ case .heavy:
14
+ UIImpactFeedbackGenerator.FeedbackStyle.heavy
15
+ case .none:
16
+ nil
17
+ }
18
+ if let hapticStyle {
19
+ let impactGenerator = UIImpactFeedbackGenerator(style: hapticStyle)
20
+ impactGenerator.prepare()
21
+ impactGenerator.impactOccurred()
22
+ }
23
+ }
24
+ }