diff --git a/CHANGELOG.md b/CHANGELOG.md
index c4f4301..75b562c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,36 @@
-# Changelog
-
+# Changelog
+
+## 2.1.5 (2025-09-12)
+
+* fix: avoid crash on ios when multiple starts are thrown ([1aa44be](https://github.com/riderodd/react-native-vosk/commit/1aa44be))
+* fix: Fix format rate error on iOS (#104) ([d8fe8b0](https://github.com/riderodd/react-native-vosk/commit/d8fe8b0)), closes [#104](https://github.com/riderodd/react-native-vosk/issues/104)
+* fix: ios pcm bitrate is now compatible with device ([3e822fe](https://github.com/riderodd/react-native-vosk/commit/3e822fe))
+* fix: yarn lockfile (#155) ([507e67d](https://github.com/riderodd/react-native-vosk/commit/507e67d)), closes [#155](https://github.com/riderodd/react-native-vosk/issues/155)
+* #67: replace file:// in name path, have a more descriptive log (#79) ([4e756da](https://github.com/riderodd/react-native-vosk/commit/4e756da)), closes [#67](https://github.com/riderodd/react-native-vosk/issues/67) [#79](https://github.com/riderodd/react-native-vosk/issues/79)
+* Add Expo support for react-native-vosk (#151) ([fec6fb9](https://github.com/riderodd/react-native-vosk/commit/fec6fb9)), closes [#151](https://github.com/riderodd/react-native-vosk/issues/151)
+* Create dependabot.yml (#71) ([8634eba](https://github.com/riderodd/react-native-vosk/commit/8634eba)), closes [#71](https://github.com/riderodd/react-native-vosk/issues/71)
+* fix crashes (#143) ([cc9ea78](https://github.com/riderodd/react-native-vosk/commit/cc9ea78)), closes [#143](https://github.com/riderodd/react-native-vosk/issues/143)
+* Fix expo ios model path (#156) ([e2877d9](https://github.com/riderodd/react-native-vosk/commit/e2877d9)), closes [#156](https://github.com/riderodd/react-native-vosk/issues/156)
+* Fix Expo plugin configuration and dependencies (#152) ([3fa62f7](https://github.com/riderodd/react-native-vosk/commit/3fa62f7)), closes [#152](https://github.com/riderodd/react-native-vosk/issues/152)
+* Fix/expo ios (#154) ([cbdf9eb](https://github.com/riderodd/react-native-vosk/commit/cbdf9eb)), closes [#154](https://github.com/riderodd/react-native-vosk/issues/154)
+* Fix/update version (#106) ([eeebd52](https://github.com/riderodd/react-native-vosk/commit/eeebd52)), closes [#106](https://github.com/riderodd/react-native-vosk/issues/106)
+* Try to make expo plugin works (#153) ([b54f844](https://github.com/riderodd/react-native-vosk/commit/b54f844)), closes [#153](https://github.com/riderodd/react-native-vosk/issues/153)
+* Update dependabot.yml (#72) ([4013cf3](https://github.com/riderodd/react-native-vosk/commit/4013cf3)), closes [#72](https://github.com/riderodd/react-native-vosk/issues/72)
+* Update issue templates (#76) ([9e4ac88](https://github.com/riderodd/react-native-vosk/commit/9e4ac88)), closes [#76](https://github.com/riderodd/react-native-vosk/issues/76)
+* Update to TurboModules with breaking changes (#150) ([4869ff0](https://github.com/riderodd/react-native-vosk/commit/4869ff0)), closes [#150](https://github.com/riderodd/react-native-vosk/issues/150)
+* Update Vosk Android library and model loading method (#149) ([3248919](https://github.com/riderodd/react-native-vosk/commit/3248919)), closes [#149](https://github.com/riderodd/react-native-vosk/issues/149)
+* docs: add Copilot Coding Agent onboarding instructions (#148) ([1881c34](https://github.com/riderodd/react-native-vosk/commit/1881c34)), closes [#148](https://github.com/riderodd/react-native-vosk/issues/148)
+* chore: release 0.3.2 (#80) ([37ec42a](https://github.com/riderodd/react-native-vosk/commit/37ec42a)), closes [#80](https://github.com/riderodd/react-native-vosk/issues/80)
+* chore: updated release it deps (#105) ([b73429c](https://github.com/riderodd/react-native-vosk/commit/b73429c)), closes [#105](https://github.com/riderodd/react-native-vosk/issues/105)
+* chore(deps-dev): bump @react-native/metro-config from 0.76.1 to 0.79.2 (#118) ([a14e95a](https://github.com/riderodd/react-native-vosk/commit/a14e95a)), closes [#118](https://github.com/riderodd/react-native-vosk/issues/118)
+
+
+### BREAKING CHANGE
+
+* the model is not searched in the android project by default anymore
+
+* chore: release 1.0.0
+
## 2.1.4 (2025-09-12)
* chore: add changelog.md ([12de6fa](https://github.com/riderodd/react-native-vosk/commit/12de6fa))
@@ -28,4 +59,4 @@
* the model is not searched in the android project by default anymore
-* chore: release 1.0.0
+* chore: release 1.0.0
diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock
index 8371da0..a7bea9c 100644
--- a/example/ios/Podfile.lock
+++ b/example/ios/Podfile.lock
@@ -1835,7 +1835,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - react-native-vosk (1.0.0):
+ - react-native-vosk (2.1.4):
- boost
- DoubleConversion
- fast_float
@@ -2643,7 +2643,7 @@ SPEC CHECKSUMS:
React-Mapbuffer: 91e0eab42a6ae7f3e34091a126d70fc53bd3823e
React-microtasksnativemodule: 1ead4fe154df3b1ba34b5a9e35ef3c4bdfa72ccb
react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616
- react-native-vosk: d6e75b7aeab1b3ce0beeee5aec2c4adaa898ef7b
+ react-native-vosk: 25bd5e5a7b3b1197ebf56c65e783c9852cc04fa8
React-NativeModulesApple: eff2eba56030eb0d107b1642b8f853bc36a833ac
React-oscompat: b12c633e9c00f1f99467b1e0e0b8038895dae436
React-perflogger: 58d12c4e5df1403030c97b9c621375c312cca454
diff --git a/ios/Vosk.mm b/ios/Vosk.mm
index dda9f9a..f80a2b8 100644
--- a/ios/Vosk.mm
+++ b/ios/Vosk.mm
@@ -1,268 +1,382 @@
-#import "Vosk.h"
-#import
-#import
-#import "RNVoskModel.h"
-#import "Vosk-API.h"
-
-// Specific key to detect execution on the processing queue
-static void *kVoskProcessingQueueKey = &kVoskProcessingQueueKey;
-
-@implementation Vosk {
- // État interne migré depuis Swift
- RNVoskModel *_Nullable _currentModel;
- VoskRecognizer *_Nullable _recognizer;
- AVAudioEngine *_audioEngine;
- AVAudioInputNode *_inputNode;
- AVAudioFormat *_formatInput;
- dispatch_queue_t _processingQueue;
- NSString *_Nullable _lastPartial;
- dispatch_source_t _Nullable _timeoutSource; // high-performance GCD timer
- // removed _hasListener management: we always emit
- BOOL _isRunning; // protects use of recognizer after stop
-} RCT_EXPORT_MODULE()
-
-
-- (instancetype)init {
- if ((self = [super init])) {
- _processingQueue = dispatch_queue_create("recognizerQueue", DISPATCH_QUEUE_SERIAL);
- dispatch_queue_set_specific(_processingQueue, kVoskProcessingQueueKey, kVoskProcessingQueueKey, NULL);
- _audioEngine = [AVAudioEngine new];
- _inputNode = _audioEngine.inputNode;
- _formatInput = [_inputNode inputFormatForBus:0];
- _recognizer = NULL;
- _currentModel = nil;
- _lastPartial = nil;
- _timeoutSource = nil;
- _isRunning = NO;
- }
- return self;
-}
-
-- (void)dealloc {
- if (_recognizer) {
- vosk_recognizer_free(_recognizer);
- _recognizer = NULL;
- }
-}
-
-- (NSArray *)supportedEvents {
- return @[ @"onError", @"onResult", @"onFinalResult", @"onPartialResult", @"onTimeout" ];
-}
-
-- (void)loadModel:(nonnull NSString *)path resolve:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
- // Unload the current model if any
- _currentModel = nil;
- NSError *err = nil;
- RNVoskModel *model = [[RNVoskModel alloc] initWithName:path error:&err];
- if (model && !err) {
- _currentModel = model;
- resolve(nil);
- } else {
- reject(@"loadModel", err.localizedDescription ?: @"Failed to load model", err);
- }
-}
-
-// Make the options nullable to avoid issues with codegen and optional fields
-- (void)start:(JS::NativeVosk::VoskOptions *_Nullable)options resolve:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
- if (_currentModel == nil) {
- reject(@"start", @"Model not loaded", nil);
- return;
- }
-
- AVAudioSession *audioSession = [AVAudioSession sharedInstance];
-
- // Extract options (grammar, timeout) from the codegen structure
- NSArray *grammar = nil;
- double timeoutMs = -1;
-
- if (options && options->grammar()) {
- auto gVec = options->grammar();
- if (gVec) {
- NSMutableArray *tmp = [NSMutableArray new];
- size_t count = gVec->size();
- for (size_t i = 0; i < count; ++i) {
- NSString *s = gVec->at(static_cast(i));
- if (s) [tmp addObject:s];
- }
- grammar = tmp.count > 0 ? tmp : nil;
- }
- }
-
- if (options && options->timeout()) {
- timeoutMs = *(options->timeout());
- }
-
- CFAbsoluteTime tStart = CFAbsoluteTimeGetCurrent();
- @try {
- // Configure audio session
- NSError *err = nil;
- if (@available(iOS 10.0, *)) {
- [audioSession setCategory:AVAudioSessionCategoryRecord mode:AVAudioSessionModeMeasurement options:0 error:&err];
- } else {
- [audioSession setCategory:AVAudioSessionCategoryRecord error:&err];
- }
- if (err) { @throw [NSException exceptionWithName:@"AudioSession" reason:err.localizedDescription userInfo:nil]; }
- [audioSession setActive:YES error:&err];
- if (err) { @throw [NSException exceptionWithName:@"AudioSession" reason:err.localizedDescription userInfo:nil]; }
- _formatInput = [_inputNode inputFormatForBus:0];
- const double sampleRate = (_formatInput.sampleRate > 0) ? _formatInput.sampleRate : 16000.0;
- const uint32_t channelCount = 1;
- const AVAudioCommonFormat commonFmt = AVAudioPCMFormatInt16;
- const AVAudioFrameCount bufferSize = (AVAudioFrameCount)(sampleRate / 10.0);
- _isRunning = YES;
-
- __weak __typeof(self) weakSelf = self;
-
- // Asynchronous creation of recognizer (expensive) on processing queue
- dispatch_async(_processingQueue, ^{
- __strong __typeof(self) self = weakSelf; if (!self || !self->_isRunning) return;
- CFAbsoluteTime tRec0 = CFAbsoluteTimeGetCurrent();
- if (grammar != nil && grammar.count > 0) {
- NSError *jsonErr = nil;
- NSData *jsonGrammar = [NSJSONSerialization dataWithJSONObject:grammar options:0 error:&jsonErr];
- if (jsonGrammar && !jsonErr) {
- std::string grammarStd((const char *)[jsonGrammar bytes], [jsonGrammar length]);
- self->_recognizer = vosk_recognizer_new_grm(self->_currentModel.model, (float)sampleRate, grammarStd.c_str());
- } else {
- self->_recognizer = vosk_recognizer_new(self->_currentModel.model, (float)sampleRate);
- }
- } else {
- self->_recognizer = vosk_recognizer_new(self->_currentModel.model, (float)sampleRate);
- }
- CFAbsoluteTime tRec1 = CFAbsoluteTimeGetCurrent();
- NSLog(@"[Vosk] Recognizer init: %.2f ms", (tRec1 - tRec0)*1000.0);
- if (!self->_recognizer || !self->_isRunning) return;
-
- dispatch_async(dispatch_get_main_queue(), ^{
- if (!self->_isRunning) return;
- AVAudioFormat *formatPcm = [[AVAudioFormat alloc] initWithCommonFormat:commonFmt sampleRate:sampleRate channels:channelCount interleaved:YES];
- if (!formatPcm) return;
- __weak __typeof(self) weakSelfTap = self;
- [self->_inputNode installTapOnBus:0 bufferSize:bufferSize format:formatPcm block:^(AVAudioPCMBuffer *buffer, AVAudioTime *when) {
- __strong __typeof(self) self = weakSelfTap; if (!self) return; if (!self->_isRunning) return;
- dispatch_async(self->_processingQueue, ^{
- if (!self->_isRunning) return; VoskRecognizer *recognizer = self->_recognizer; if (!recognizer) return;
- if (!buffer.int16ChannelData || !buffer.int16ChannelData[0]) return;
- int dataLen = (int)(buffer.frameLength * 2);
- int endOfSpeech = vosk_recognizer_accept_waveform(recognizer, (const char *)buffer.int16ChannelData[0], (int32_t)dataLen);
- const char *cstr = endOfSpeech == 1 ? vosk_recognizer_result(recognizer) : vosk_recognizer_partial_result(recognizer);
- NSString *json = cstr ? [NSString stringWithUTF8String:cstr] : nil;
- dispatch_async(dispatch_get_main_queue(), ^{
- if (!json) return; NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding]; NSDictionary *parsed = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil;
- if (![parsed isKindOfClass:[NSDictionary class]]) { if (endOfSpeech == 1) { [self emitOnResult:json]; } else { [self emitOnPartialResult:json]; } return; }
- NSString *text = parsed[@"text"]; NSString *partial = parsed[@"partial"]; if (endOfSpeech == 1) { if (text.length > 0) [self emitOnResult:text]; } else { if (partial.length > 0 && (!self->_lastPartial || ![self->_lastPartial isEqualToString:partial])) { [self emitOnPartialResult:partial]; } self->_lastPartial = partial ?: self->_lastPartial; }
- });
- });
- }];
- [self->_audioEngine prepare];
- [audioSession requestRecordPermission:^(BOOL granted) {
- if (!granted) return; NSError *startErr = nil; [self->_audioEngine startAndReturnError:&startErr];
- }];
- });
- });
-
- // GCD timer for timeout
- if (timeoutMs >= 0) {
- _timeoutSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, _processingQueue);
- if (_timeoutSource) {
- uint64_t delayNs = (uint64_t)(timeoutMs * 1000000.0); // ms -> ns
- dispatch_source_set_timer(_timeoutSource, dispatch_time(DISPATCH_TIME_NOW, delayNs), DISPATCH_TIME_FOREVER, 5 * NSEC_PER_MSEC);
- __weak __typeof(self) weakSelfTimeout = self;
- dispatch_source_set_event_handler(_timeoutSource, ^{
- __strong __typeof(self) selfT = weakSelfTimeout; if (!selfT) return; if (!selfT->_isRunning) return;
- [selfT stopInternalWithoutEvents:YES];
- dispatch_async(dispatch_get_main_queue(), ^{ [selfT emitOnTimeout]; });
- });
- dispatch_resume(_timeoutSource);
- }
- }
-
- CFAbsoluteTime tEnd = CFAbsoluteTimeGetCurrent();
- NSLog(@"[Vosk] start() sync phase: %.2f ms", (tEnd - tStart)*1000.0);
- // early resolve to release JS quickly
- resolve(nil);
- return;
- } @catch (NSException *ex) {
- // Log and emit error
- NSLog(@"Error starting audio engine: %@", ex.reason);
- [self emitOnError:[NSString stringWithFormat:@"Unable to start AVAudioEngine %@", ex.reason ?: @""]];
- if (_recognizer) {
- vosk_recognizer_free(_recognizer);
- _recognizer = NULL;
- }
- reject(@"start", ex.reason ?: @"Unknown error", nil);
- }
-}
-
-- (void)stop {
- if (!_isRunning) return; // idempotent
- [self stopInternalWithoutEvents:NO];
-}
-
-- (void)unload {
- if (_isRunning) {
- [self stopInternalWithoutEvents:NO];
- }
- _currentModel = nil;
-}
-
-- (void)addListener:(nonnull NSString *)eventType {
-
-}
-
-
-- (void)removeListeners:(double)count {
-
-}
-
-
-- (std::shared_ptr)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
- return std::make_shared(params);
-}
-
-// Internal cleanup
-- (void)stopInternalWithoutEvents:(BOOL)withoutEvents {
- @try {
- [_inputNode removeTapOnBus:0];
- } @catch (...) {}
-
- if (!_isRunning) {
- // already stopped
- }
- _isRunning = NO; // prevents new buffers from being processed
-
- if (_audioEngine.isRunning) {
- [_audioEngine stop];
- if (!withoutEvents) {
- [self emitOnFinalResult:_lastPartial];
- }
- _lastPartial = nil;
- }
- // Recognizer cleanup after draining processing queue, without deadlock if already on the queue
- if (dispatch_get_specific(kVoskProcessingQueueKey)) {
- if (_recognizer) {
- vosk_recognizer_free(_recognizer);
- _recognizer = NULL;
- }
- } else {
- dispatch_sync(_processingQueue, ^{
- if (self->_recognizer) {
- vosk_recognizer_free(self->_recognizer);
- self->_recognizer = NULL;
- }
- });
- }
- if (_timeoutSource) { dispatch_source_cancel(_timeoutSource); _timeoutSource = nil; }
-
- AVAudioSession *audioSession = [AVAudioSession sharedInstance];
- NSError *err = nil;
- if (@available(iOS 10.0, *)) {
- [audioSession setCategory:AVAudioSessionCategoryPlayback mode:AVAudioSessionModeDefault options:0 error:&err];
- } else {
- [audioSession setCategory:AVAudioSessionCategoryPlayback error:&err];
- }
- [audioSession setActive:NO withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:&err];
-}
-
-@end
-
+#import "Vosk.h"
+#import "RNVoskModel.h"
+#import "Vosk-API.h"
+#import
+#import
+#include // added for float32 -> int16 conversion
+
+// Specific key to detect execution on the processing queue
+static void *kVoskProcessingQueueKey = &kVoskProcessingQueueKey;
+
+@implementation Vosk {
+ // État interne migré depuis Swift
+ RNVoskModel *_Nullable _currentModel;
+ VoskRecognizer *_Nullable _recognizer;
+ AVAudioEngine *_audioEngine;
+ AVAudioInputNode *_inputNode;
+ AVAudioFormat *_formatInput;
+ dispatch_queue_t _processingQueue;
+ NSString *_Nullable _lastPartial;
+ dispatch_source_t _Nullable _timeoutSource; // high-performance GCD timer
+ // removed _hasListener management: we always emit
+ BOOL _isRunning; // protects use of recognizer after stop
+ BOOL _isStarting; // prevents concurrent start
+ BOOL _tapInstalled; // track tap installation
+}
+RCT_EXPORT_MODULE()
+
+- (instancetype)init {
+ if ((self = [super init])) {
+ _processingQueue =
+ dispatch_queue_create("recognizerQueue", DISPATCH_QUEUE_SERIAL);
+ dispatch_queue_set_specific(_processingQueue, kVoskProcessingQueueKey,
+ kVoskProcessingQueueKey, NULL);
+ _audioEngine = [AVAudioEngine new];
+ _inputNode = _audioEngine.inputNode;
+ _formatInput = [_inputNode inputFormatForBus:0];
+ _recognizer = NULL;
+ _currentModel = nil;
+ _lastPartial = nil;
+ _timeoutSource = nil;
+ _isRunning = NO;
+ _isStarting = NO;
+ _tapInstalled = NO;
+ }
+ return self;
+}
+
+- (void)dealloc {
+ if (_recognizer) {
+ vosk_recognizer_free(_recognizer);
+ _recognizer = NULL;
+ }
+}
+
+- (NSArray *)supportedEvents {
+ return @[
+ @"onError", @"onResult", @"onFinalResult", @"onPartialResult", @"onTimeout"
+ ];
+}
+
+- (void)loadModel:(nonnull NSString *)path
+ resolve:(nonnull RCTPromiseResolveBlock)resolve
+ reject:(nonnull RCTPromiseRejectBlock)reject {
+ // Unload the current model if any
+ _currentModel = nil;
+ NSError *err = nil;
+ RNVoskModel *model = [[RNVoskModel alloc] initWithName:path error:&err];
+ if (model && !err) {
+ _currentModel = model;
+ resolve(nil);
+ } else {
+ reject(@"loadModel", err.localizedDescription ?: @"Failed to load model",
+ err);
+ }
+}
+
+// Make the options nullable to avoid issues with codegen and optional fields
+- (void)start:(JS::NativeVosk::VoskOptions *_Nullable)options
+ resolve:(nonnull RCTPromiseResolveBlock)resolve
+ reject:(nonnull RCTPromiseRejectBlock)reject {
+ if (_currentModel == nil) {
+ reject(@"start", @"Model not loaded", nil);
+ return;
+ }
+ if (_isStarting || _isRunning) {
+ reject(@"start", @"Recognizer already starting or running", nil);
+ return;
+ }
+ _isStarting = YES;
+ AVAudioSession *audioSession = [AVAudioSession sharedInstance];
+
+ // Extract options (grammar, timeout) from the codegen structure
+ NSArray *grammar = nil;
+ double timeoutMs = -1;
+ if (options && options->grammar()) {
+ auto gVec = options->grammar();
+ if (gVec) {
+ NSMutableArray *tmp = [NSMutableArray new];
+ size_t count = gVec->size();
+ for (size_t i = 0; i < count; ++i) {
+ NSString *s = gVec->at(static_cast(i));
+ if (s) [tmp addObject:s];
+ }
+ grammar = tmp.count > 0 ? tmp : nil;
+ }
+ }
+ if (options && options->timeout()) {
+ timeoutMs = *(options->timeout());
+ }
+
+ CFAbsoluteTime tStart = CFAbsoluteTimeGetCurrent();
+ // Configure session category first (allowed even before permission)
+ NSError *catErr = nil;
+ @try {
+ if (@available(iOS 10.0, *)) {
+ [audioSession setCategory:AVAudioSessionCategoryRecord
+ mode:AVAudioSessionModeMeasurement
+ options:0
+ error:&catErr];
+ } else {
+ [audioSession setCategory:AVAudioSessionCategoryRecord error:&catErr];
+ }
+ if (catErr) {
+ NSString *msg = [NSString stringWithFormat:@"Audio session category error: %@", catErr.localizedDescription];
+ [self emitOnError:msg];
+ reject(@"start", msg, catErr);
+ return;
+ }
+ } @catch (NSException *ex) {
+ NSString *msg = [NSString stringWithFormat:@"Exception setting category: %@", ex.reason ?: @"unknown"];
+ [self emitOnError:msg];
+ reject(@"start", msg, nil);
+ return;
+ }
+
+ // Request permission BEFORE doing heavy work
+ [audioSession requestRecordPermission:^(BOOL granted) {
+ if (!granted) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ NSString *msg = @"Microphone permission denied";
+ [self emitOnError:msg];
+ reject(@"start", msg, nil);
+ });
+ return;
+ }
+ dispatch_async(dispatch_get_main_queue(), ^{ // proceed on main
+ NSError *actErr = nil;
+ if (![audioSession setActive:YES error:&actErr]) {
+ NSString *msg = [NSString stringWithFormat:@"Failed to activate audio session: %@", actErr.localizedDescription];
+ [self emitOnError:msg];
+ reject(@"start", msg, actErr);
+ return;
+ }
+
+ self->_formatInput = [self->_inputNode inputFormatForBus:0];
+ const double sampleRate = (self->_formatInput.sampleRate > 0) ? self->_formatInput.sampleRate : 16000.0;
+ const AVAudioFrameCount bufferSize = (AVAudioFrameCount)(sampleRate / 10.0);
+ self->_isRunning = YES;
+
+ __weak __typeof(self) weakSelf = self;
+ dispatch_async(self->_processingQueue, ^{ // recognizer init off main
+ __strong __typeof(self) self = weakSelf;
+ if (!self || !self->_isRunning) return;
+ CFAbsoluteTime tRec0 = CFAbsoluteTimeGetCurrent();
+ if (grammar != nil && grammar.count > 0) {
+ NSError *jsonErr = nil;
+ NSData *jsonGrammar = [NSJSONSerialization dataWithJSONObject:grammar options:0 error:&jsonErr];
+ if (jsonGrammar && !jsonErr) {
+ std::string grammarStd((const char *)[jsonGrammar bytes], [jsonGrammar length]);
+ self->_recognizer = vosk_recognizer_new_grm(self->_currentModel.model, (float)sampleRate, grammarStd.c_str());
+ } else {
+ self->_recognizer = vosk_recognizer_new(self->_currentModel.model, (float)sampleRate);
+ }
+ } else {
+ self->_recognizer = vosk_recognizer_new(self->_currentModel.model, (float)sampleRate);
+ }
+ CFAbsoluteTime tRec1 = CFAbsoluteTimeGetCurrent();
+ NSLog(@"[Vosk] Recognizer init: %.2f ms", (tRec1 - tRec0) * 1000.0);
+ if (!self->_recognizer) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self emitOnError:@"Recognizer initialization failed (null)"];
+ reject(@"start", @"Recognizer initialization failed", nil);
+ self->_isStarting = NO;
+ });
+ return; // promise already resolved later below or not? -> we won't resolve now
+ }
+
+ dispatch_async(dispatch_get_main_queue(), ^{ // install tap + start engine
+ if (!self->_isRunning) { self->_isStarting = NO; return; }
+ if (self->_tapInstalled) {
+ NSLog(@"[Vosk] Tap already installed – skipping reinstall");
+ self->_isStarting = NO;
+ resolve(nil);
+ return;
+ }
+ AVAudioFormat *tapFormat = [self->_inputNode inputFormatForBus:0];
+ if (!tapFormat) {
+ [self emitOnError:@"tapFormat is nil; cannot install tap"];
+ [self stopInternalWithoutEvents:YES];
+ self->_isStarting = NO;
+ return;
+ }
+ BOOL invalidSR = tapFormat.sampleRate <= 0.0;
+ BOOL invalidCh = tapFormat.channelCount == 0;
+ if (invalidSR || invalidCh) {
+ NSLog(@"[Vosk] Invalid input format (sr=%.1f ch=%u) -> using nil format", tapFormat.sampleRate, (unsigned int)tapFormat.channelCount);
+ tapFormat = nil;
+ }
+ __weak __typeof(self) weakSelfTap = self;
+ @try {
+ [self->_inputNode installTapOnBus:0 bufferSize:bufferSize format:tapFormat block:^(AVAudioPCMBuffer *buffer, AVAudioTime *when) {
+ __strong __typeof(self) self = weakSelfTap; if (!self) return; if (!self->_isRunning) return;
+ dispatch_async(self->_processingQueue, ^{
+ if (!self->_isRunning) return; VoskRecognizer *recognizer = self->_recognizer; if (!recognizer) return;
+ AVAudioFrameCount frames = buffer.frameLength; if (frames == 0) return;
+ int accepted = 0;
+ if (buffer.int16ChannelData && buffer.int16ChannelData[0]) {
+ int dataLen = (int)(frames * sizeof(int16_t));
+ accepted = vosk_recognizer_accept_waveform(recognizer, (const char *)buffer.int16ChannelData[0], (int32_t)dataLen);
+ } else if (buffer.floatChannelData && buffer.floatChannelData[0]) {
+ float *const *floatChannels = buffer.floatChannelData; std::vector pcm16; pcm16.resize(frames); float *ch0 = floatChannels[0];
+ for (AVAudioFrameCount i = 0; i < frames; ++i) { float s = ch0[i]; if (s > 1.f) s = 1.f; else if (s < -1.f) s = -1.f; pcm16[i] = (int16_t)lrintf(s * 32767.f); }
+ int dataLen = (int)(frames * sizeof(int16_t));
+ accepted = vosk_recognizer_accept_waveform(recognizer, (const char *)pcm16.data(), (int32_t)dataLen);
+ } else { return; }
+
+ const char *cstr = NULL;
+ BOOL isFinal = NO;
+ if (accepted) { // end of utterance recognized per Vosk API
+ cstr = vosk_recognizer_result(recognizer);
+ isFinal = YES;
+ } else {
+ cstr = vosk_recognizer_partial_result(recognizer);
+ isFinal = NO;
+ }
+ NSString *json = cstr ? [NSString stringWithUTF8String:cstr] : nil;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (!json) return;
+ NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding];
+ NSDictionary *parsed = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil;
+ if (![parsed isKindOfClass:[NSDictionary class]]) {
+ if (isFinal) { [self emitOnResult:json]; } else { [self emitOnPartialResult:json]; }
+ return;
+ }
+ NSString *text = parsed[@"text"]; NSString *partial = parsed[@"partial"];
+ if (isFinal) {
+ if (text.length > 0) { [self emitOnResult:text]; }
+ self->_lastPartial = nil; // reset after final
+ } else {
+ if (partial.length > 0 && (!self->_lastPartial || ![self->_lastPartial isEqualToString:partial])) {
+ [self emitOnPartialResult:partial];
+ }
+ self->_lastPartial = partial ?: self->_lastPartial;
+ }
+ });
+ });
+ }];
+ self->_tapInstalled = YES;
+ } @catch (NSException *ex) {
+ [self emitOnError:[NSString stringWithFormat:@"Failed to install tap: %@", ex.reason ?: @"unknown"]];
+ [self stopInternalWithoutEvents:YES];
+ self->_isStarting = NO;
+ reject(@"start", @"Failed to install tap", nil);
+ return;
+ }
+ [self->_audioEngine prepare];
+ NSError *startErr = nil;
+ if (![self->_audioEngine startAndReturnError:&startErr]) {
+ NSString *msg = [NSString stringWithFormat:@"Failed to start audio engine: %@", startErr.localizedDescription];
+ [self emitOnError:msg];
+ // clean
+ [self stopInternalWithoutEvents:YES];
+ reject(@"start", msg, startErr); // still reject if not yet resolved
+ self->_isStarting = NO;
+ return;
+ }
+
+ // Timeout timer after engine started
+ if (timeoutMs >= 0) {
+ self->_timeoutSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, self->_processingQueue);
+ if (self->_timeoutSource) {
+ uint64_t delayNs = (uint64_t)(timeoutMs * 1000000.0);
+ __weak __typeof(self) weakSelfTimeout = self;
+ dispatch_source_set_timer(self->_timeoutSource, dispatch_time(DISPATCH_TIME_NOW, delayNs), DISPATCH_TIME_FOREVER, 5 * NSEC_PER_MSEC);
+ dispatch_source_set_event_handler(self->_timeoutSource, ^{ __strong __typeof(self) selfT = weakSelfTimeout; if (!selfT || !selfT->_isRunning) return; [selfT stopInternalWithoutEvents:YES]; dispatch_async(dispatch_get_main_queue(), ^{ [selfT emitOnTimeout]; }); });
+ dispatch_resume(self->_timeoutSource);
+ }
+ }
+
+ CFAbsoluteTime tEnd = CFAbsoluteTimeGetCurrent();
+ NSLog(@"[Vosk] start() total after permission: %.2f ms", (tEnd - tStart) * 1000.0);
+ // Resolve promise (success path)
+ self->_isStarting = NO;
+ resolve(nil);
+ });
+ });
+ });
+ }];
+}
+
+- (void)stop {
+ if (!_isRunning)
+ return; // idempotent
+ [self stopInternalWithoutEvents:NO];
+}
+
+- (void)unload {
+ if (_isRunning) {
+ [self stopInternalWithoutEvents:NO];
+ }
+ _currentModel = nil;
+}
+
+- (void)addListener:(nonnull NSString *)eventType {
+}
+
+- (void)removeListeners:(double)count {
+}
+
+- (std::shared_ptr)getTurboModule:
+ (const facebook::react::ObjCTurboModule::InitParams &)params {
+ return std::make_shared(params);
+}
+
+// Internal cleanup
+- (void)stopInternalWithoutEvents:(BOOL)withoutEvents {
+ @try {
+ [_inputNode removeTapOnBus:0];
+ } @catch (...) {
+ }
+
+ if (!_isRunning) {
+ // already stopped
+ }
+ _isRunning = NO; // prevents new buffers from being processed
+ _isStarting = NO;
+ _tapInstalled = NO;
+
+ if (_audioEngine.isRunning) {
+ [_audioEngine stop];
+ if (!withoutEvents) {
+ [self emitOnFinalResult:_lastPartial];
+ }
+ _lastPartial = nil;
+ }
+ // Recognizer cleanup after draining processing queue, without deadlock if
+ // already on the queue
+ if (dispatch_get_specific(kVoskProcessingQueueKey)) {
+ if (_recognizer) {
+ vosk_recognizer_free(_recognizer);
+ _recognizer = NULL;
+ }
+ } else {
+ dispatch_sync(_processingQueue, ^{
+ if (self->_recognizer) {
+ vosk_recognizer_free(self->_recognizer);
+ self->_recognizer = NULL;
+ }
+ });
+ }
+ if (_timeoutSource) {
+ dispatch_source_cancel(_timeoutSource);
+ _timeoutSource = nil;
+ }
+
+ AVAudioSession *audioSession = [AVAudioSession sharedInstance];
+ NSError *err = nil;
+ if (@available(iOS 10.0, *)) {
+ [audioSession setCategory:AVAudioSessionCategoryPlayback
+ mode:AVAudioSessionModeDefault
+ options:0
+ error:&err];
+ } else {
+ [audioSession setCategory:AVAudioSessionCategoryPlayback error:&err];
+ }
+ [audioSession
+ setActive:NO
+ withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation
+ error:&err];
+}
+
+@end
diff --git a/package.json b/package.json
index 15a9941..cc31733 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "react-native-vosk",
- "version": "2.1.4",
+ "version": "2.1.5",
"description": "Speech recognition module for react native using Vosk library",
"types": "lib/typescript/src/index.d.ts",
"main": "lib/commonjs/index.js",