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",