react-native-davoice-tts 1.0.388 → 1.0.390

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.
@@ -2,7 +2,7 @@ require 'json'
2
2
 
3
3
  Pod::Spec.new do |s|
4
4
  s.name = "TTSRNBridge"
5
- s.version = "1.0.343" # Update to your package version
5
+ s.version = "1.0.347" # Update to your package version
6
6
  s.summary = "TTS for React Native."
7
7
  s.description = <<-DESC
8
8
  A React Native module for tts .
@@ -22,6 +22,22 @@ static void SBDebugLog(NSString *format, ...)
22
22
  va_end(args);
23
23
  }
24
24
 
25
+ // Stable on-disk location for a downloaded http(s) asset, keyed by URL. Metro asset
26
+ // URLs carry a content hash, so the same URL means the same bytes and the file can be
27
+ // reused across calls and launches instead of re-downloading (e.g. on every wake-word
28
+ // ping sound in a debug build) every time.
29
+ static NSString *SBCachedFilePathForRemoteAsset(NSString *urlString) {
30
+ NSString *ext = [NSURL URLWithString:urlString].pathExtension;
31
+ if (ext.length == 0) ext = @"wav";
32
+ unsigned long long h = 1469598103934665603ULL; // FNV-1a: deterministic across launches
33
+ for (NSUInteger i = 0; i < urlString.length; i++) {
34
+ h ^= (unsigned long long)[urlString characterAtIndex:i];
35
+ h *= 1099511628211ULL;
36
+ }
37
+ NSString *name = [NSString stringWithFormat:@"rn_asset_%016llx.%@", h, ext];
38
+ return [NSTemporaryDirectory() stringByAppendingPathComponent:name];
39
+ }
40
+
25
41
  static NSData *SB_Base64Decode(NSString *b64) {
26
42
  if (!b64 || (id)b64 == [NSNull null]) return nil;
27
43
  return [[NSData alloc] initWithBase64EncodedString:b64 options:0];
@@ -137,6 +153,34 @@ static AVAudioPCMBuffer *SB_MakeMonoF32Buffer(NSData *raw,
137
153
  return buf;
138
154
  }
139
155
 
156
+ // The STT/DaVoiceTTS core is main-queue affine: it schedules its own internal
157
+ // polling on the main queue and mutates AVAudioEngine/task state without a lock
158
+ // guarding every access, so every touch of self.stt from this bridge must also
159
+ // stay on main -- mixing threads here races that internal state. -methodQueue
160
+ // below keeps every RCT_EXPORT_METHOD call on main already; this guard exists
161
+ // so a future call site added off that path (a completion handler, a hop to a
162
+ // background queue, etc.) fails loudly instead of silently reintroducing that
163
+ // race. DEBUG asserts at the violating frame; release logs once via NSLog
164
+ // rather than RCTLogError, since RCTLogError is a no-op in a release build
165
+ // with no log function installed (RCTLog.mm gates on RCT_DEBUG || logFunction).
166
+ static void SBReportOffMainQueueSTTAccess(const char *where)
167
+ {
168
+ NSLog(@"[SpeechBridge] self.stt accessed off the main queue in %s. STT/DaVoiceTTS "
169
+ @"is main-queue-affine; see -methodQueue.", where);
170
+ }
171
+
172
+ // dispatch_assert_queue, not [NSThread isMainThread]: a dispatch_sync from main onto
173
+ // another serial queue runs on the main THREAD while off the main QUEUE, which
174
+ // isMainThread would incorrectly pass.
175
+ #if DEBUG
176
+ #define SBAssertSTTOnMainQueue() dispatch_assert_queue(dispatch_get_main_queue())
177
+ #else
178
+ #define SBAssertSTTOnMainQueue() \
179
+ do { \
180
+ if (![NSThread isMainThread]) { SBReportOffMainQueueSTTAccess(__PRETTY_FUNCTION__); } \
181
+ } while (0)
182
+ #endif
183
+
140
184
  @interface SpeechBridge () <STTDelegate>
141
185
  @property (nonatomic, strong, nullable) STT *stt;
142
186
  @property (nonatomic, strong, nullable) DaVoiceTTS *tts;
@@ -152,6 +196,47 @@ static AVAudioPCMBuffer *SB_MakeMonoF32Buffer(NSData *raw,
152
196
 
153
197
  @implementation SpeechBridge
154
198
 
199
+ // Explicit accessors so the main-queue guard covers every read and write of
200
+ // self.stt. Explicit accessors disable autosynthesis, hence the @synthesize.
201
+ @synthesize stt = _stt;
202
+
203
+ - (STT *)stt
204
+ {
205
+ SBAssertSTTOnMainQueue();
206
+ return _stt;
207
+ }
208
+
209
+ - (void)setStt:(STT *)stt
210
+ {
211
+ SBAssertSTTOnMainQueue();
212
+ _stt = stt;
213
+ }
214
+
215
+ // Single teardown path for the STT instance.
216
+ //
217
+ // Every call site used to be `[self.stt destroySpeech:nil]; self.stt = nil;`, which
218
+ // cleared the ivar only after the teardown call had already started, so other
219
+ // main-queue work could observe a half-destroyed STT through self.stt in between.
220
+ // Clearing the ivar first closes that window; the strong local keeps the instance
221
+ // alive for the duration of the call.
222
+ //
223
+ // The completion is deliberately nil, matching every existing call site. Passing a
224
+ // block that captures the instance would hand its lifetime to the framework: the
225
+ // final release would then happen wherever the framework drops the block, so
226
+ // -[STT dealloc] and the AVAudioEngine teardown inside it could run off the main
227
+ // queue -- the exact affinity violation this file exists to prevent, and it would
228
+ // not trip the guard above, because dealloc does not go through the property.
229
+ - (void)tearDownSTT
230
+ {
231
+ SBAssertSTTOnMainQueue();
232
+ STT *dyingSTT = _stt;
233
+ if (!dyingSTT) { return; }
234
+ _stt = nil;
235
+ @try {
236
+ [dyingSTT destroySpeech:nil];
237
+ } @catch (__unused id e) {}
238
+ }
239
+
155
240
  RCT_EXPORT_MODULE(SpeechBridge)
156
241
 
157
242
  // We emit the union of STT + TTS events
@@ -178,15 +263,42 @@ RCT_EXPORT_MODULE(SpeechBridge)
178
263
 
179
264
  - (void)dealloc
180
265
  {
181
- // destroy in the safe order: TTS STT
182
- if (_tts) { [_tts destroy]; _tts = nil; }
183
- if (_stt) { [_stt destroySpeech:nil]; _stt = nil; }
266
+ // dealloc is not guaranteed to run on the main queue -- the last release can come
267
+ // from a bridge invalidation on any thread -- and [DaVoiceTTS destroy] /
268
+ // [STT destroySpeech:] are the same main-affine calls SBAssertSTTOnMainQueue exists
269
+ // to keep on main. Exempting dealloc from that rule would put full audio teardown
270
+ // off main at the one site where it is least recoverable, so hop instead. Only the
271
+ // locals are captured, never self, so this is safe from inside dealloc; the
272
+ // destroy order stays TTS then STT.
273
+ DaVoiceTTS *dyingTTS = _tts;
274
+ STT *dyingSTT = _stt;
275
+ _tts = nil;
276
+ _stt = nil;
277
+ if (!dyingTTS && !dyingSTT) { return; }
278
+
279
+ void (^teardown)(void) = ^{
280
+ @try { [dyingTTS destroy]; } @catch (__unused id e) {}
281
+ @try { [dyingSTT destroySpeech:nil]; } @catch (__unused id e) {}
282
+ };
283
+ if ([NSThread isMainThread]) {
284
+ teardown();
285
+ } else {
286
+ dispatch_async(dispatch_get_main_queue(), teardown);
287
+ }
184
288
  }
185
289
 
186
290
  #pragma mark - STTDelegate (forward all events)
187
291
 
188
292
  - (void)stt:(STT *)stt didEmitEvent:(NSString *)name body:(NSDictionary *)body
189
293
  {
294
+ // Ignore events from an STT we have already torn down. A dying instance stays our
295
+ // delegate until it deallocs, so without this check a trailing onSpeechStart from
296
+ // the old instance could latch sttEngineHot while the NEW recognizer (created by a
297
+ // fresh initAll()/initWithoutModel() racing the old instance's teardown) has not
298
+ // actually started -- reporting init success to JS before voice input is live.
299
+ // Compared against the ivar directly, not self.stt: this is invoked from the
300
+ // framework's own callback thread and must not trip the main-queue guard.
301
+ if (stt != _stt) { return; }
190
302
  // Use the first onSpeechStart as the “engine hot” latch.
191
303
  if ([name isEqualToString:@"onSpeechStart"] && !self.sttEngineHot) {
192
304
  self.sttEngineHot = YES;
@@ -279,6 +391,16 @@ RCT_EXPORT_MODULE(SpeechBridge)
279
391
  };
280
392
  }
281
393
 
394
+ // Ticks on a private queue, delivers the verdict on main.
395
+ //
396
+ // The tick only reads self.sttEngineHot (atomic) and a clock -- no UIKit, no self.stt
397
+ // -- so it has no reason to occupy the main queue, while timeoutMs defaults to
398
+ // 7200000ms above, meaning an engine that never reports hot would otherwise leave a
399
+ // 50ms main-queue timer running for two hours. The completion is different: it is
400
+ // only ever invoked from initAll()/initWithoutModel(), where it writes
401
+ // self.initializing/self.initialized and resolves/rejects the caller's JS promise, so
402
+ // it is hopped back to main to keep that state single-threaded and to respect
403
+ // SBAssertSTTOnMainQueue on any self.stt access inside a caller's completion.
282
404
  - (void)waitForSTTEngineHotWithTimeoutMs:(NSNumber *)timeoutMs
283
405
  completion:(void (^)(BOOL ok))completion
284
406
  {
@@ -287,26 +409,39 @@ RCT_EXPORT_MODULE(SpeechBridge)
287
409
 
288
410
  CFTimeInterval deadline = CACurrentMediaTime() + MAX(0.1, t.doubleValue / 1000.0);
289
411
 
412
+ static dispatch_queue_t pollQueue;
413
+ static dispatch_once_t pollQueueOnce;
414
+ dispatch_once(&pollQueueOnce, ^{
415
+ pollQueue = dispatch_queue_create("com.davoice.SpeechBridge.enginehot", DISPATCH_QUEUE_SERIAL);
416
+ });
417
+
418
+ void (^deliver)(BOOL) = ^(BOOL ok) {
419
+ dispatch_async(dispatch_get_main_queue(), ^{ completion(ok); });
420
+ };
421
+
290
422
  __weak typeof(self) weakSelf = self;
291
423
  __block void (^poll)(void) = ^{
292
424
  __strong typeof(weakSelf) strongSelf = weakSelf;
293
425
  if (!strongSelf) {
294
- completion(NO);
426
+ poll = nil;
427
+ deliver(NO);
295
428
  return;
296
429
  }
297
430
  if (strongSelf.sttEngineHot) {
298
- completion(YES);
431
+ poll = nil;
432
+ deliver(YES);
299
433
  return;
300
434
  }
301
435
  if (CACurrentMediaTime() >= deadline) {
302
- completion(NO);
436
+ poll = nil;
437
+ deliver(NO);
303
438
  return;
304
439
  }
305
440
  dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)),
306
- dispatch_get_main_queue(), poll);
441
+ pollQueue, poll);
307
442
  };
308
443
 
309
- dispatch_async(dispatch_get_main_queue(), poll);
444
+ dispatch_async(pollQueue, poll);
310
445
  }
311
446
 
312
447
  RCT_EXPORT_METHOD(hasMicPermissions:(RCTPromiseResolveBlock)resolve
@@ -536,8 +671,7 @@ RCT_EXPORT_METHOD(initAll:(NSDictionary *)opts
536
671
 
537
672
  if (!modelURL) {
538
673
  self.initializing = NO;
539
- [self.stt destroySpeech:nil];
540
- self.stt = nil;
674
+ [self tearDownSTT];
541
675
  reject(@"bad_model", [NSString stringWithFormat:@"Could not resolve model path: %@", modelPath], nil);
542
676
  return;
543
677
  }
@@ -545,8 +679,7 @@ RCT_EXPORT_METHOD(initAll:(NSDictionary *)opts
545
679
  // Verify file exists for local file URLs (skip for bare .dm fallback — native core will search)
546
680
  if (!isBareDmName && modelURL.isFileURL && ![[NSFileManager defaultManager] fileExistsAtPath:modelURL.path]) {
547
681
  self.initializing = NO;
548
- [self.stt destroySpeech:nil];
549
- self.stt = nil;
682
+ [self tearDownSTT];
550
683
  reject(@"model_missing", [NSString stringWithFormat:@"Model file missing: %@", modelURL.path], nil);
551
684
  return;
552
685
  }
@@ -566,8 +699,7 @@ RCT_EXPORT_METHOD(initAll:(NSDictionary *)opts
566
699
  if (err || !tts) {
567
700
  self.initializing = NO;
568
701
  self.sttEngineHot = NO;
569
- [self.stt destroySpeech:nil];
570
- self.stt = nil;
702
+ [self tearDownSTT];
571
703
  reject(@"tts_init_failed", err.localizedDescription ?: @"TTS init failed", err);
572
704
  return;
573
705
  }
@@ -611,8 +743,7 @@ RCT_EXPORT_METHOD(initWithoutModel:(NSDictionary *)opts
611
743
  NSString *onboardingJsonPath = opts[@"onboardingJsonPath"];
612
744
  NSNumber *timeoutMs = opts[@"timeoutMs"];
613
745
 
614
- @try { [self.stt destroySpeech:nil]; } @catch (__unused id e) {}
615
- self.stt = nil;
746
+ [self tearDownSTT];
616
747
 
617
748
  [self ensureSTT];
618
749
  if (onboardingJsonPath && (id)onboardingJsonPath != [NSNull null] && onboardingJsonPath.length > 0) {
@@ -626,8 +757,7 @@ RCT_EXPORT_METHOD(initWithoutModel:(NSDictionary *)opts
626
757
  self.initializing = NO;
627
758
  self.initialized = NO;
628
759
  self.sttEngineHot = NO;
629
- @try { [self.stt destroySpeech:nil]; } @catch (__unused id e) {}
630
- self.stt = nil;
760
+ [self tearDownSTT];
631
761
  reject(@"stt_init_timeout", @"STT did not become ready before timeout", nil);
632
762
  return;
633
763
  }
@@ -802,8 +932,7 @@ RCT_EXPORT_METHOD(destroyAll:(RCTPromiseResolveBlock)resolve
802
932
  @try { [self.tts stopSpeaking]; [self.tts destroy]; } @catch (__unused id e) {}
803
933
  self.tts = nil;
804
934
 
805
- @try { [self.stt destroySpeech:nil]; } @catch (__unused id e) {}
806
- self.stt = nil;
935
+ [self tearDownSTT];
807
936
 
808
937
  self.sttEngineHot = NO;
809
938
  self.initialized = NO;
@@ -828,8 +957,7 @@ RCT_EXPORT_METHOD(destroyWihtouModel:(RCTPromiseResolveBlock)resolve
828
957
 
829
958
  self.initializing = YES;
830
959
 
831
- @try { [self.stt destroySpeech:nil]; } @catch (__unused id e) {}
832
- self.stt = nil;
960
+ [self tearDownSTT];
833
961
 
834
962
  self.sttEngineHot = NO;
835
963
  self.initialized = NO;
@@ -934,6 +1062,43 @@ RCT_EXPORT_METHOD(isRecognizing:(RCTResponseSenderBlock)callback)
934
1062
  if (callback) callback(@[@(running ? 1 : 0)]);
935
1063
  }
936
1064
 
1065
+ // Read-only diagnostics: captures AVAudioSession + STT/event-delivery state without
1066
+ // mutating anything, so it can be polled at any point in the lifecycle to compare a
1067
+ // healthy session against a stuck one.
1068
+ RCT_EXPORT_METHOD(sttHealthSnapshot:(RCTPromiseResolveBlock)resolve
1069
+ rejecter:(RCTPromiseRejectBlock)reject)
1070
+ {
1071
+ AVAudioSession *s = [AVAudioSession sharedInstance];
1072
+ NSMutableArray *inputs = [NSMutableArray array];
1073
+ for (AVAudioSessionPortDescription *p in s.currentRoute.inputs) { [inputs addObject:p.portType]; }
1074
+ NSMutableArray *outputs = [NSMutableArray array];
1075
+ for (AVAudioSessionPortDescription *p in s.currentRoute.outputs) { [outputs addObject:p.portType]; }
1076
+ resolve(@{
1077
+ // didEmitEvent: drops every STT callback when hasListeners is NO, and latches
1078
+ // sttEngineHot before that gate -- so the engine can report itself hot while JS
1079
+ // receives nothing. bridgePtr identifies which SpeechBridge instance is emitting,
1080
+ // to catch the case where JS subscribed to a different instance than the one
1081
+ // holding self.stt.
1082
+ @"hasListeners": @(self.hasListeners),
1083
+ @"sttEngineHot": @(self.sttEngineHot),
1084
+ @"bridgePtr": [NSString stringWithFormat:@"%p", self],
1085
+ @"hasStt": @(self.stt != nil),
1086
+ @"recognizing": @(self.stt ? [self.stt isRecognizing] : NO),
1087
+ @"initialized": @(self.initialized),
1088
+ @"initializing": @(self.initializing),
1089
+ @"category": s.category ?: @"",
1090
+ @"mode": s.mode ?: @"",
1091
+ @"categoryOptions": @(s.categoryOptions),
1092
+ @"inputAvailable": @(s.inputAvailable),
1093
+ @"sampleRate": @(s.sampleRate),
1094
+ @"inputChannels": @(s.inputNumberOfChannels),
1095
+ @"inputLatency": @(s.inputLatency),
1096
+ @"otherAudioPlaying": @(s.isOtherAudioPlaying),
1097
+ @"routeInputs": inputs,
1098
+ @"routeOutputs": outputs,
1099
+ });
1100
+ }
1101
+
937
1102
  RCT_EXPORT_METHOD(setAECEnabled:(BOOL)enabled
938
1103
  resolver:(RCTPromiseResolveBlock)resolve
939
1104
  rejecter:(RCTPromiseRejectBlock)reject)
@@ -997,35 +1162,83 @@ RCT_EXPORT_METHOD(playWav:(NSString *)pathOrURL
997
1162
  return;
998
1163
  }
999
1164
 
1000
- NSURL *fileURL = nil;
1165
+ // Steps 5 and 6 below (verify + queue on the TTS engine), shared by every branch,
1166
+ // including the http(s) one after its asynchronous download completes.
1167
+ __weak typeof(self) weakSelf = self;
1168
+ void (^playLocalFile)(NSURL *) = ^(NSURL *fileURL) {
1169
+ typeof(self) strongSelf = weakSelf;
1170
+ if (!strongSelf || !strongSelf.tts) {
1171
+ reject(@"no_tts", @"TTS went away before playback", nil);
1172
+ return;
1173
+ }
1174
+ if (!fileURL || ![[NSFileManager defaultManager] fileExistsAtPath:fileURL.path]) {
1175
+ reject(@"file_missing", [NSString stringWithFormat:@"File missing: %@", fileURL.path], nil);
1176
+ return;
1177
+ }
1178
+ SBDebugLog(@"[TTS] Playing file via DaVoiceTTS: %@", fileURL.path);
1179
+ [strongSelf.tts playWav:fileURL markAsLastUtterance:markAsLast.boolValue];
1180
+ resolve(@"queued");
1181
+ };
1001
1182
 
1002
- // 1️⃣ Handle http(s) URLs — download to temporary file first
1183
+ // 1️⃣ Handle http(s) URLs — download to a cached temporary file first.
1184
+ //
1185
+ // This used to be a synchronous [NSData dataWithContentsOfURL:] on this module's
1186
+ // method queue, which is the main queue (see -methodQueue). In a debug build every
1187
+ // require()'d asset (e.g. a wake-word ping sound) resolves to a Metro dev-server
1188
+ // URL, so each ping blocked the UI for the whole request; on a slow or dropped link
1189
+ // to the Metro host that meant a frozen app for as long as pings kept arriving.
1190
+ // Download off the main queue with a bounded timeout, cache by URL so each asset is
1191
+ // fetched once, then hop back to the method queue to play it.
1003
1192
  if ([pathOrURL hasPrefix:@"http://"] || [pathOrURL hasPrefix:@"https://"]) {
1004
- SBDebugLog(@"[TTS] Downloading asset from URL: %@", pathOrURL);
1005
1193
  NSURL *remoteURL = [NSURL URLWithString:pathOrURL];
1006
1194
  if (!remoteURL) {
1007
1195
  reject(@"bad_url", @"Invalid remote URL", nil);
1008
1196
  return;
1009
1197
  }
1010
1198
 
1011
- NSData *data = [NSData dataWithContentsOfURL:remoteURL];
1012
- if (!data) {
1013
- reject(@"download_failed", @"Failed to download remote asset", nil);
1199
+ NSString *cachedPath = SBCachedFilePathForRemoteAsset(pathOrURL);
1200
+ if ([[NSFileManager defaultManager] fileExistsAtPath:cachedPath]) {
1201
+ playLocalFile([NSURL fileURLWithPath:cachedPath]);
1014
1202
  return;
1015
1203
  }
1016
1204
 
1017
- NSString *tempName = [NSString stringWithFormat:@"rn_asset_%f.wav", [[NSDate date] timeIntervalSince1970]];
1018
- NSString *tempPath = [NSTemporaryDirectory() stringByAppendingPathComponent:tempName];
1019
- if (![data writeToFile:tempPath atomically:YES]) {
1020
- reject(@"write_failed", @"Failed to write temporary file", nil);
1021
- return;
1022
- }
1023
- fileURL = [NSURL fileURLWithPath:tempPath];
1024
- SBDebugLog(@"[TTS] Downloaded to temp file: %@", tempPath);
1205
+ SBDebugLog(@"[TTS] Downloading asset from URL: %@", pathOrURL);
1206
+ dispatch_queue_t methodQueue = [self methodQueue];
1207
+ NSURLSessionConfiguration *config = [NSURLSessionConfiguration ephemeralSessionConfiguration];
1208
+ config.timeoutIntervalForRequest = 8.0;
1209
+ config.timeoutIntervalForResource = 15.0;
1210
+ NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
1211
+ NSURLSessionDataTask *task = [session dataTaskWithURL:remoteURL
1212
+ completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
1213
+ [session finishTasksAndInvalidate];
1214
+ NSInteger status = [response isKindOfClass:[NSHTTPURLResponse class]]
1215
+ ? ((NSHTTPURLResponse *)response).statusCode : 200;
1216
+ BOOL downloaded = (error == nil && data.length > 0 && status < 400);
1217
+ BOOL written = downloaded && [data writeToFile:cachedPath atomically:YES];
1218
+ dispatch_async(methodQueue, ^{
1219
+ if (!downloaded) {
1220
+ NSString *why = error.localizedDescription ?: [NSString stringWithFormat:@"HTTP %ld", (long)status];
1221
+ reject(@"download_failed",
1222
+ [NSString stringWithFormat:@"Failed to download remote asset (%@)", why],
1223
+ error);
1224
+ return;
1225
+ }
1226
+ if (!written) {
1227
+ reject(@"write_failed", @"Failed to write temporary file", nil);
1228
+ return;
1229
+ }
1230
+ SBDebugLog(@"[TTS] Downloaded to cached file: %@", cachedPath);
1231
+ playLocalFile([NSURL fileURLWithPath:cachedPath]);
1232
+ });
1233
+ }];
1234
+ [task resume];
1235
+ return;
1025
1236
  }
1026
1237
 
1238
+ NSURL *fileURL = nil;
1239
+
1027
1240
  // 2️⃣ Handle bundled asset:/ paths (copied from main bundle)
1028
- else if ([pathOrURL hasPrefix:@"asset:/"]) {
1241
+ if ([pathOrURL hasPrefix:@"asset:/"]) {
1029
1242
  NSString *assetName = [pathOrURL stringByReplacingOccurrencesOfString:@"asset:/" withString:@""];
1030
1243
  SBDebugLog(@"[TTS] Detected bundled asset: %@", assetName);
1031
1244
  NSString *bundlePath = [[NSBundle mainBundle] pathForResource:[assetName stringByDeletingPathExtension]
@@ -1057,16 +1270,8 @@ RCT_EXPORT_METHOD(playWav:(NSString *)pathOrURL
1057
1270
  fileURL = [NSURL fileURLWithPath:pathOrURL];
1058
1271
  }
1059
1272
 
1060
- // 5️⃣ Verify existence
1061
- if (!fileURL || ![[NSFileManager defaultManager] fileExistsAtPath:fileURL.path]) {
1062
- reject(@"file_missing", [NSString stringWithFormat:@"File missing: %@", fileURL.path], nil);
1063
- return;
1064
- }
1065
-
1066
- // 6️⃣ Play through TTS engine (queued)
1067
- SBDebugLog(@"[TTS] Playing file via DaVoiceTTS: %@", fileURL.path);
1068
- [self.tts playWav:fileURL markAsLastUtterance:markAsLast.boolValue];
1069
- resolve(@"queued");
1273
+ // 5️⃣ + 6️⃣ Verify existence and play through the TTS engine (queued)
1274
+ playLocalFile(fileURL);
1070
1275
  }
1071
1276
 
1072
1277
  /// playBuffer(desc: { base64, sampleRate, channels?, interleaved?, format: "i16" | "f32", markAsLast? })
@@ -8,32 +8,32 @@
8
8
  <key>BinaryPath</key>
9
9
  <string>DavoiceTTS.framework/DavoiceTTS</string>
10
10
  <key>LibraryIdentifier</key>
11
- <string>ios-arm64</string>
11
+ <string>ios-arm64_x86_64-simulator</string>
12
12
  <key>LibraryPath</key>
13
13
  <string>DavoiceTTS.framework</string>
14
14
  <key>SupportedArchitectures</key>
15
15
  <array>
16
16
  <string>arm64</string>
17
+ <string>x86_64</string>
17
18
  </array>
18
19
  <key>SupportedPlatform</key>
19
20
  <string>ios</string>
21
+ <key>SupportedPlatformVariant</key>
22
+ <string>simulator</string>
20
23
  </dict>
21
24
  <dict>
22
25
  <key>BinaryPath</key>
23
26
  <string>DavoiceTTS.framework/DavoiceTTS</string>
24
27
  <key>LibraryIdentifier</key>
25
- <string>ios-arm64_x86_64-simulator</string>
28
+ <string>ios-arm64</string>
26
29
  <key>LibraryPath</key>
27
30
  <string>DavoiceTTS.framework</string>
28
31
  <key>SupportedArchitectures</key>
29
32
  <array>
30
33
  <string>arm64</string>
31
- <string>x86_64</string>
32
34
  </array>
33
35
  <key>SupportedPlatform</key>
34
36
  <string>ios</string>
35
- <key>SupportedPlatformVariant</key>
36
- <string>simulator</string>
37
37
  </dict>
38
38
  </array>
39
39
  <key>CFBundlePackageType</key>