@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.
- package/LICENSE +21 -0
- package/README.md +176 -148
- package/android/build.gradle +0 -1
- package/android/src/main/cpp/cpp-adapter.cpp +5 -1
- package/android/src/main/java/com/margelo/nitro/nitrospeech/HybridNitroSpeech.kt +2 -0
- package/android/src/main/java/com/margelo/nitro/nitrospeech/recognizer/AutoStopper.kt +82 -18
- package/android/src/main/java/com/margelo/nitro/nitrospeech/recognizer/HybridRecognizer.kt +118 -30
- package/android/src/main/java/com/margelo/nitro/nitrospeech/recognizer/Logger.kt +16 -0
- package/android/src/main/java/com/margelo/nitro/nitrospeech/recognizer/RecognitionListenerSession.kt +35 -24
- package/ios/{BufferUtil.swift → Audio/AudioBufferConverter.swift} +3 -34
- package/ios/Audio/AudioLevelTracker.swift +60 -0
- package/ios/Coordinator.swift +105 -0
- package/ios/Engines/AnalyzerEngine.swift +241 -0
- package/ios/Engines/DictationRuntime.swift +67 -0
- package/ios/Engines/RecognizerEngine.swift +315 -0
- package/ios/Engines/SFSpeechEngine.swift +119 -0
- package/ios/Engines/SpeechRuntime.swift +58 -0
- package/ios/Engines/TranscriberRuntimeProtocol.swift +21 -0
- package/ios/HybridNitroSpeech.swift +1 -10
- package/ios/HybridRecognizer.swift +142 -191
- package/ios/LocaleManager.swift +73 -0
- package/ios/{AppStateObserver.swift → Shared/AppStateObserver.swift} +1 -2
- package/ios/Shared/AutoStopper.swift +147 -0
- package/ios/Shared/HapticImpact.swift +24 -0
- package/ios/Shared/Log.swift +41 -0
- package/ios/Shared/Permissions.swift +59 -0
- package/ios/Shared/Utils.swift +58 -0
- package/lib/NitroSpeech.d.ts +2 -0
- package/lib/NitroSpeech.js +2 -0
- package/lib/Recognizer/RecognizerRef.d.ts +7 -0
- package/lib/Recognizer/RecognizerRef.js +16 -0
- package/lib/Recognizer/SpeechRecognizer.d.ts +8 -0
- package/lib/Recognizer/SpeechRecognizer.js +9 -0
- package/lib/Recognizer/methods.d.ts +9 -0
- package/lib/Recognizer/methods.js +33 -0
- package/lib/Recognizer/types.d.ts +6 -0
- package/lib/Recognizer/types.js +1 -0
- package/lib/Recognizer/useRecognizer.d.ts +16 -0
- package/lib/Recognizer/useRecognizer.js +71 -0
- package/lib/Recognizer/useRecognizerIsActive.d.ts +25 -0
- package/lib/Recognizer/useRecognizerIsActive.js +40 -0
- package/lib/Recognizer/useVoiceInputVolume.d.ts +25 -0
- package/lib/Recognizer/useVoiceInputVolume.js +52 -0
- package/lib/index.d.ts +7 -0
- package/lib/index.js +7 -0
- package/lib/specs/NitroSpeech.nitro.d.ts +8 -0
- package/lib/specs/NitroSpeech.nitro.js +1 -0
- package/lib/specs/Recognizer.nitro.d.ts +97 -0
- package/lib/specs/Recognizer.nitro.js +1 -0
- package/lib/specs/SpeechRecognitionConfig.d.ts +162 -0
- package/lib/specs/SpeechRecognitionConfig.js +1 -0
- package/lib/specs/VolumeChangeEvent.d.ts +31 -0
- package/lib/specs/VolumeChangeEvent.js +1 -0
- package/nitro.json +0 -4
- package/nitrogen/generated/android/NitroSpeech+autolinking.cmake +2 -2
- package/nitrogen/generated/android/NitroSpeechOnLoad.cpp +4 -2
- package/nitrogen/generated/android/c++/JFunc_void_VolumeChangeEvent.hpp +78 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_std__string_.hpp +14 -14
- package/nitrogen/generated/android/c++/JHybridRecognizerSpec.cpp +73 -19
- package/nitrogen/generated/android/c++/JHybridRecognizerSpec.hpp +8 -4
- package/nitrogen/generated/android/c++/JIosPreset.hpp +58 -0
- package/nitrogen/generated/android/c++/JMutableSpeechRecognitionConfig.hpp +79 -0
- package/nitrogen/generated/android/c++/{JSpeechToTextParams.hpp → JSpeechRecognitionConfig.hpp} +48 -30
- package/nitrogen/generated/android/c++/JVolumeChangeEvent.hpp +65 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrospeech/Func_void_VolumeChangeEvent.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrospeech/HybridRecognizerSpec.kt +22 -5
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrospeech/IosPreset.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrospeech/MutableSpeechRecognitionConfig.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrospeech/SpeechRecognitionConfig.kt +121 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrospeech/VolumeChangeEvent.kt +61 -0
- package/nitrogen/generated/ios/NitroSpeech-Swift-Cxx-Bridge.cpp +46 -30
- package/nitrogen/generated/ios/NitroSpeech-Swift-Cxx-Bridge.hpp +211 -69
- package/nitrogen/generated/ios/NitroSpeech-Swift-Cxx-Umbrella.hpp +13 -3
- package/nitrogen/generated/ios/c++/HybridRecognizerSpecSwift.hpp +49 -9
- package/nitrogen/generated/ios/swift/Func_void_VolumeChangeEvent.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridRecognizerSpec.swift +7 -3
- package/nitrogen/generated/ios/swift/HybridRecognizerSpec_cxx.swift +78 -18
- package/nitrogen/generated/ios/swift/IosPreset.swift +40 -0
- package/nitrogen/generated/ios/swift/MutableSpeechRecognitionConfig.swift +118 -0
- package/nitrogen/generated/ios/swift/{SpeechToTextParams.swift → SpeechRecognitionConfig.swift} +108 -43
- package/nitrogen/generated/ios/swift/VolumeChangeEvent.swift +52 -0
- package/nitrogen/generated/shared/c++/HybridRecognizerSpec.cpp +5 -1
- package/nitrogen/generated/shared/c++/HybridRecognizerSpec.hpp +18 -7
- package/nitrogen/generated/shared/c++/IosPreset.hpp +76 -0
- package/nitrogen/generated/shared/c++/MutableSpeechRecognitionConfig.hpp +105 -0
- package/nitrogen/generated/shared/c++/{SpeechToTextParams.hpp → SpeechRecognitionConfig.hpp} +39 -20
- package/nitrogen/generated/shared/c++/VolumeChangeEvent.hpp +91 -0
- package/package.json +15 -16
- package/src/NitroSpeech.ts +5 -0
- package/src/Recognizer/RecognizerRef.ts +27 -0
- package/src/Recognizer/SpeechRecognizer.ts +10 -0
- package/src/Recognizer/methods.ts +45 -0
- package/src/Recognizer/types.ts +34 -0
- package/src/Recognizer/useRecognizer.ts +87 -0
- package/src/Recognizer/useRecognizerIsActive.ts +49 -0
- package/src/Recognizer/useVoiceInputVolume.ts +65 -0
- package/src/index.ts +13 -182
- package/src/specs/NitroSpeech.nitro.ts +2 -163
- package/src/specs/Recognizer.nitro.ts +113 -0
- package/src/specs/SpeechRecognitionConfig.ts +167 -0
- package/src/specs/VolumeChangeEvent.ts +31 -0
- package/android/proguard-rules.pro +0 -1
- package/ios/AnylyzerTranscriber.swift +0 -331
- package/ios/AutoStopper.swift +0 -69
- package/ios/HapticImpact.swift +0 -32
- package/ios/LegacySpeechRecognizer.swift +0 -161
- package/lib/commonjs/index.js +0 -145
- package/lib/commonjs/index.js.map +0 -1
- package/lib/commonjs/package.json +0 -1
- package/lib/commonjs/specs/NitroSpeech.nitro.js +0 -6
- package/lib/commonjs/specs/NitroSpeech.nitro.js.map +0 -1
- package/lib/module/index.js +0 -138
- package/lib/module/index.js.map +0 -1
- package/lib/module/package.json +0 -1
- package/lib/module/specs/NitroSpeech.nitro.js +0 -4
- package/lib/module/specs/NitroSpeech.nitro.js.map +0 -1
- package/lib/tsconfig.tsbuildinfo +0 -1
- package/lib/typescript/index.d.ts +0 -50
- package/lib/typescript/index.d.ts.map +0 -1
- package/lib/typescript/specs/NitroSpeech.nitro.d.ts +0 -162
- package/lib/typescript/specs/NitroSpeech.nitro.d.ts.map +0 -1
- 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
|
-
|
|
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: ((
|
|
13
|
+
var onVolumeChange: ((VolumeChangeEvent) -> Void)?
|
|
19
14
|
|
|
20
|
-
|
|
15
|
+
private let coordinator = Coordinator()
|
|
16
|
+
private var paramsHash: String?
|
|
17
|
+
private var engine: RecognizerEngine?
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
isStopping = true
|
|
65
|
-
|
|
66
|
-
self.stopHapticFeedback()
|
|
45
|
+
engine?.stop()
|
|
67
46
|
}
|
|
68
47
|
|
|
69
|
-
|
|
70
|
-
|
|
48
|
+
func resetAutoFinishTime() {
|
|
49
|
+
engine?.updateSession(resetTimer: true)
|
|
71
50
|
}
|
|
72
51
|
|
|
73
52
|
func addAutoFinishTime(additionalTimeMs: Double?) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
144
|
+
|
|
145
|
+
func readyForSpeech() {
|
|
146
|
+
self.lg.log("[HR -> onReadyForSpeech]")
|
|
147
|
+
self.onReadyForSpeech?()
|
|
153
148
|
}
|
|
154
149
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
HapticImpact(style: .medium).trigger()
|
|
205
|
-
}
|
|
169
|
+
|
|
170
|
+
func permissionDenied() {
|
|
171
|
+
self.lg.log("[onPermissionDenied]")
|
|
172
|
+
self.onPermissionDenied?()
|
|
206
173
|
}
|
|
207
174
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|