react-native-davoice 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +319 -0
  2. package/TTSRNBridge.podspec +38 -0
  3. package/android/.gradle/8.9/checksums/checksums.lock +0 -0
  4. package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
  5. package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
  6. package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
  7. package/android/.gradle/8.9/gc.properties +0 -0
  8. package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  9. package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
  10. package/android/.gradle/vcs-1/gc.properties +0 -0
  11. package/android/build.gradle +47 -0
  12. package/android/libs/com/davoice/tts/1.0.0/tts-1.0.0.aar +0 -0
  13. package/android/libs/com/davoice/tts/1.0.0/tts-1.0.0.aar.md5 +1 -0
  14. package/android/libs/com/davoice/tts/1.0.0/tts-1.0.0.aar.sha1 +1 -0
  15. package/android/libs/com/davoice/tts/1.0.0/tts-1.0.0.pom +38 -0
  16. package/android/libs/com/davoice/tts/1.0.0/tts-1.0.0.pom.md5 +1 -0
  17. package/android/libs/com/davoice/tts/1.0.0/tts-1.0.0.pom.sha1 +1 -0
  18. package/android/settings.gradle +2 -0
  19. package/android/src/main/AndroidManifest.xml +14 -0
  20. package/android/src/main/java/com/davoice/rn/DaVoicePackage.java +29 -0
  21. package/android/src/main/java/com/davoice/stt/rn/STTModule.kt +208 -0
  22. package/android/src/main/java/com/davoice/tts/rn/DaVoiceTTSBridge.java +733 -0
  23. package/android/src/main/libs/MyLibrary-release.aar +0 -0
  24. package/app.plugin.js +60 -0
  25. package/ios/STTRNBridge/STTBridge.h +7 -0
  26. package/ios/STTRNBridge/STTBridge.m +130 -0
  27. package/ios/SpeechBridge/SpeechBridge.h +7 -0
  28. package/ios/SpeechBridge/SpeechBridge.m +761 -0
  29. package/ios/TTSRNBridge/DaVoiceTTSBridge.h +7 -0
  30. package/ios/TTSRNBridge/DaVoiceTTSBridge.m +177 -0
  31. package/ios/TTSRNBridge/DavoiceTTS.xcframework/Info.plist +44 -0
  32. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64/DavoiceTTS.framework/DavoiceTTS +0 -0
  33. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64/DavoiceTTS.framework/Headers/DavoiceTTS-Swift.h +424 -0
  34. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64/DavoiceTTS.framework/Info.plist +0 -0
  35. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64/DavoiceTTS.framework/Modules/DavoiceTTS.swiftmodule/arm64-apple-ios.abi.json +13253 -0
  36. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64/DavoiceTTS.framework/Modules/DavoiceTTS.swiftmodule/arm64-apple-ios.private.swiftinterface +213 -0
  37. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64/DavoiceTTS.framework/Modules/DavoiceTTS.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  38. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64/DavoiceTTS.framework/Modules/DavoiceTTS.swiftmodule/arm64-apple-ios.swiftinterface +213 -0
  39. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64/DavoiceTTS.framework/Modules/module.modulemap +4 -0
  40. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/DavoiceTTS +0 -0
  41. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/Headers/DavoiceTTS-Swift.h +844 -0
  42. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/Info.plist +0 -0
  43. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/Modules/DavoiceTTS.swiftmodule/arm64-apple-ios-simulator.abi.json +13253 -0
  44. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/Modules/DavoiceTTS.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +213 -0
  45. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/Modules/DavoiceTTS.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  46. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/Modules/DavoiceTTS.swiftmodule/arm64-apple-ios-simulator.swiftinterface +213 -0
  47. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/Modules/DavoiceTTS.swiftmodule/x86_64-apple-ios-simulator.abi.json +13253 -0
  48. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/Modules/DavoiceTTS.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +213 -0
  49. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/Modules/DavoiceTTS.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  50. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/Modules/DavoiceTTS.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +213 -0
  51. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/Modules/module.modulemap +4 -0
  52. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/_CodeSignature/CodeDirectory +0 -0
  53. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/_CodeSignature/CodeRequirements +0 -0
  54. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/_CodeSignature/CodeRequirements-1 +0 -0
  55. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/_CodeSignature/CodeResources +282 -0
  56. package/ios/TTSRNBridge/DavoiceTTS.xcframework/ios-arm64_x86_64-simulator/DavoiceTTS.framework/_CodeSignature/CodeSignature +0 -0
  57. package/ios/TTSRNBridge/libphonemes.a +0 -0
  58. package/ios/TTSRNBridge/libucd.a +0 -0
  59. package/package.json +46 -0
  60. package/react-native.config.js +10 -0
  61. package/speech/index.ts +1055 -0
  62. package/stt/index.d.ts +54 -0
  63. package/stt/index.ts +222 -0
  64. package/tts/DaVoiceTTSBridge.d.ts +18 -0
  65. package/tts/DaVoiceTTSBridge.js +71 -0
  66. package/tts/index.d.ts +3 -0
  67. package/tts/index.js +4 -0
@@ -0,0 +1,733 @@
1
+ // android/src/main/java/com/davoice/tts/rn/DaVoiceTTSBridge.java
2
+ package com.davoice.tts.rn;
3
+
4
+ import androidx.annotation.Nullable;
5
+ import com.facebook.react.bridge.Promise;
6
+ import com.facebook.react.bridge.ReactApplicationContext;
7
+ import com.facebook.react.bridge.ReactContextBaseJavaModule;
8
+ import com.facebook.react.bridge.ReactMethod;
9
+ import com.facebook.react.bridge.ReadableMap;
10
+ import com.facebook.react.bridge.WritableMap;
11
+ import com.facebook.react.modules.core.DeviceEventManagerModule;
12
+ // ADD
13
+ import android.net.Uri;
14
+ import java.io.File;
15
+ import java.io.FileOutputStream;
16
+ import java.nio.ByteBuffer;
17
+ import java.nio.ByteOrder;
18
+ import java.util.Locale;
19
+ import android.util.Base64;
20
+ import android.media.MediaPlayer;
21
+ import android.content.Context;
22
+
23
+ import android.util.Log;
24
+ import android.content.res.AssetFileDescriptor;
25
+ import java.io.IOException;
26
+ import java.net.URL;
27
+ import java.net.URLConnection;
28
+
29
+ import com.davoice.tts.DaVoiceTTSInterface;
30
+ import com.davoice.tts.LicenseManager;
31
+
32
+
33
+ public class DaVoiceTTSBridge extends ReactContextBaseJavaModule {
34
+
35
+ private final DaVoiceTTSInterface tts;
36
+ private final ReactApplicationContext reactCtx;
37
+ final String TAG = "TTS";
38
+
39
+ public DaVoiceTTSBridge(ReactApplicationContext context) {
40
+ super(context);
41
+ this.reactCtx = context;
42
+ this.tts = new DaVoiceTTSInterface(context);
43
+ }
44
+
45
+ @Override
46
+ public String getName() { return "DaVoiceTTSBridge"; }
47
+
48
+ private void sendEvent(String name, @Nullable WritableMap params) {
49
+ reactCtx.runOnUiQueueThread(() ->
50
+ reactCtx
51
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
52
+ .emit(name, params)
53
+ );
54
+ }
55
+
56
+ @ReactMethod
57
+ public void setLicense(String licenseKey, Promise promise) {
58
+ try {
59
+ if (licenseKey == null || licenseKey.trim().isEmpty()) {
60
+ promise.reject("invalid_args", "Missing licenseKey");
61
+ return;
62
+ }
63
+ promise.resolve(tts.setLicenseKey(licenseKey));
64
+ } catch (Exception e) {
65
+ promise.reject("LicenseError", e.getMessage(), e);
66
+ }
67
+ }
68
+
69
+ @ReactMethod
70
+ public void isLicenseValid(String licenseKey, Promise promise) {
71
+ try {
72
+ if (licenseKey == null || licenseKey.trim().isEmpty()) {
73
+ promise.reject("invalid_args", "Missing licenseKey");
74
+ return;
75
+ }
76
+ promise.resolve(LicenseManager.isLicenseValid(licenseKey));
77
+ } catch (Exception e) {
78
+ promise.reject("LicenseError", e.getMessage(), e);
79
+ }
80
+ }
81
+
82
+ @ReactMethod
83
+ public void initTTS(ReadableMap config, Promise promise) {
84
+ // DEBUG CODE
85
+ // try {
86
+ // final String fileName = config.getString("model");
87
+ // // Basic input debug
88
+ // Log.i(TAG, "ASSETDBG model raw input='" + fileName + "'");
89
+ // final String pkg = reactCtx.getPackageName();
90
+ // Log.i(TAG, "ASSETDBG packageName=" + pkg);
91
+ // // RN sometimes passes "assets_xxx" (no extension). Print normalized variants.
92
+ // final String[] candidates = new String[] {
93
+ // fileName,
94
+ // fileName + ".mp3",
95
+ // fileName + ".wav",
96
+ // fileName + ".dm",
97
+ // fileName + ".onnx",
98
+ // fileName + ".json",
99
+ // };
100
+ // Log.i(TAG, "ASSETDBG candidates=" + java.util.Arrays.toString(candidates));
101
+
102
+ // int res =
103
+ // reactCtx.getResources().getIdentifier(fileName, "raw", pkg);
104
+
105
+ // if (res != 0) {
106
+ // Log.i(TAG, "ASSETDBG res!=0" + res);
107
+
108
+ // android.content.res.AssetFileDescriptor afd = reactCtx.getResources().openRawResourceFd(res);
109
+ // Log.i(TAG, "ASSETDBG res!=0 afd == " + afd);
110
+ // afd.close();
111
+ // }
112
+ // else {
113
+ // Log.i(TAG, "ASSETDBG res==0" + res);
114
+ // }
115
+
116
+ // // Now the important part: res/raw identifier lookup (this is what RN-Sound relies on)
117
+ // for (String name : candidates) {
118
+ // try {
119
+ // int resId = reactCtx.getResources().getIdentifier(name, "raw", pkg);
120
+ // Log.i(TAG, "ASSETDBG getIdentifier(name='" + name + "', type='raw') => resId=" + resId);
121
+
122
+ // if (resId != 0) {
123
+ // // Print extra details about the resource
124
+ // try {
125
+ // String entry = reactCtx.getResources().getResourceEntryName(resId);
126
+ // String type = reactCtx.getResources().getResourceTypeName(resId);
127
+ // String full = reactCtx.getResources().getResourceName(resId);
128
+ // Log.i(TAG, "ASSETDBG resource: entry=" + entry + " type=" + type + " full=" + full);
129
+ // } catch (Exception ignored) {
130
+ // Log.i(TAG, "ASSETDBG resource name lookup failed (non-fatal) resId=" + resId);
131
+ // }
132
+
133
+ // // Try openRawResourceFd (works for non-compressed raw)
134
+ // try {
135
+ // android.content.res.AssetFileDescriptor afd =
136
+ // reactCtx.getResources().openRawResourceFd(resId);
137
+
138
+ // if (afd == null) {
139
+ // Log.w(TAG, "ASSETDBG openRawResourceFd returned NULL for resId=" + resId + " (likely compressed) -> will try openRawResource()");
140
+ // } else {
141
+ // Log.i(TAG, "ASSETDBG openRawResourceFd OK resId=" + resId
142
+ // + " startOffset=" + afd.getStartOffset()
143
+ // + " length=" + afd.getLength()
144
+ // + " fdValid=" + (afd.getFileDescriptor() != null));
145
+ // afd.close();
146
+ // }
147
+ // } catch (Exception e) {
148
+ // Log.w(TAG, "ASSETDBG openRawResourceFd FAILED resId=" + resId + " err=" + e.getMessage(), e);
149
+ // }
150
+
151
+ // // Always try openRawResource() too (works even if compressed)
152
+ // try (java.io.InputStream in = reactCtx.getResources().openRawResource(resId)) {
153
+ // byte[] head = new byte[32];
154
+ // int n = in.read(head);
155
+ // Log.i(TAG, "ASSETDBG openRawResource OK resId=" + resId + " firstBytesRead=" + n);
156
+ // } catch (Exception e) {
157
+ // Log.w(TAG, "ASSETDBG openRawResource FAILED resId=" + resId + " err=" + e.getMessage(), e);
158
+ // }
159
+ // }
160
+ // } catch (Exception e) {
161
+ // Log.w(TAG, "ASSETDBG getIdentifier loop exception name='" + name + "' err=" + e.getMessage(), e);
162
+ // }
163
+ // }
164
+ // } catch (Exception e) {
165
+ // Log.w(TAG, "ASSETDBG list assets failed: " + e.getMessage());
166
+ // }
167
+ try {
168
+ if (config == null || !config.hasKey("model") || config.isNull("model")) {
169
+ promise.reject("invalid_config", "Missing required 'model' key");
170
+ return;
171
+ }
172
+ final String modelPathOrURL = config.getString("model");
173
+ final String modelExt =
174
+ (config.hasKey("modelExt") && !config.isNull("modelExt"))
175
+ ? config.getString("modelExt") : null;
176
+
177
+ // ✅ FIX: keep old behavior for plain asset-relative paths like "model.onnx" or "models/model.onnx"
178
+ // (i.e. files you manually put under android/app/src/main/assets or any merged asset pack).
179
+ final String s = (modelPathOrURL == null) ? "" : modelPathOrURL.trim();
180
+ if (s.isEmpty()) {
181
+ promise.reject("invalid_config", "Empty 'model'");
182
+ return;
183
+ }
184
+
185
+ final boolean looksLikePlainAssetPath =
186
+ !s.contains("://") &&
187
+ !s.startsWith("/") &&
188
+ !s.toLowerCase(Locale.US).startsWith("file:") &&
189
+ !s.toLowerCase(Locale.US).startsWith("content:") &&
190
+ !s.toLowerCase(Locale.US).startsWith("asset:") &&
191
+ // ✅ IMPORTANT: RN release resource names are NOT AssetManager assets (res/raw)
192
+ !s.startsWith("assets_") && !s.startsWith("src_assets_");
193
+
194
+
195
+ final String modelName;
196
+ if (looksLikePlainAssetPath) {
197
+ File f = resolvePlainModelToFile(s, modelExt != null ? modelExt : "onnx");
198
+ if (f != null) {
199
+ modelName = f.getAbsolutePath(); // pass a real filesystem path to the engine
200
+ } else {
201
+ // Last resort: keep old behavior (in case engine itself can open via AssetManager)
202
+ modelName = s;
203
+ }
204
+ } else {
205
+ // Use provided extension when RN gave us assets_* (no ext), otherwise fall back safely.
206
+ final String ext = (modelExt != null && !modelExt.isEmpty()) ? modelExt : "onnx";
207
+ final File modelFile = resolveToLocalFileForRead(s, ext);
208
+ if (modelFile == null || !modelFile.exists()) {
209
+ promise.reject("model_missing", "Model file missing/unresolvable: " + s);
210
+ return;
211
+ }
212
+ modelName = modelFile.getAbsolutePath();
213
+ }
214
+
215
+ // fire RN event when the last utterance finishes
216
+ tts.setOnFinishedSpeakingListenerJava(() -> sendEvent("onFinishedSpeaking", null));
217
+
218
+ DaVoiceTTSInterface.Config cfg = new DaVoiceTTSInterface.Config(
219
+ modelName,
220
+ "en-US",
221
+ "phonemes_dir"
222
+ );
223
+ boolean ok = tts.initTTS(cfg);
224
+ if (!ok) {
225
+ promise.reject("InitFailed", "Engine/TTS init failed");
226
+ return;
227
+ }
228
+ promise.resolve(true);
229
+ } catch (Exception e) {
230
+ promise.reject("InitError", e.getMessage(), e);
231
+ }
232
+ }
233
+
234
+ @ReactMethod
235
+ public void initTTSPlaybackOnly(Promise promise) {
236
+ try {
237
+ // Always wire completion events for JS queueing.
238
+ tts.setOnFinishedSpeakingListenerJava(() -> sendEvent("onFinishedSpeaking", null));
239
+
240
+ boolean ok = true;
241
+ // Keep backwards compatibility with older TTS AARs that don't have initPlaybackOnly().
242
+ try {
243
+ java.lang.reflect.Method m = tts.getClass().getMethod("initPlaybackOnly");
244
+ Object out = m.invoke(tts);
245
+ if (out instanceof Boolean) ok = (Boolean) out;
246
+ } catch (NoSuchMethodException noSuchMethod) {
247
+ Log.w(TAG, "initTTSPlaybackOnly: initPlaybackOnly() not found on TTS interface; using lazy playback fallback");
248
+ }
249
+
250
+ if (!ok) {
251
+ promise.reject("InitFailed", "Playback-only TTS init failed");
252
+ return;
253
+ }
254
+ promise.resolve(true);
255
+ } catch (Exception e) {
256
+ promise.reject("InitError", e.getMessage(), e);
257
+ }
258
+ }
259
+
260
+ private @Nullable File tryGetModelsPackAssetsPath(Context ctx) {
261
+ try {
262
+ Class<?> factoryClz = Class.forName("com.google.android.play.core.assetpacks.AssetPackManagerFactory");
263
+ java.lang.reflect.Method getInstance = factoryClz.getMethod("getInstance", Context.class);
264
+ Object apm = getInstance.invoke(null, ctx.getApplicationContext());
265
+
266
+ Class<?> apmClz = Class.forName("com.google.android.play.core.assetpacks.AssetPackManager");
267
+ java.lang.reflect.Method getPackLocation = apmClz.getMethod("getPackLocation", String.class);
268
+ Object loc = getPackLocation.invoke(apm, "models_pack");
269
+ if (loc == null) return null;
270
+
271
+ Class<?> locClz = Class.forName("com.google.android.play.core.assetpacks.AssetPackLocation");
272
+ java.lang.reflect.Method assetsPath = locClz.getMethod("assetsPath");
273
+ String path = (String) assetsPath.invoke(loc);
274
+ if (path == null) return null;
275
+ return new File(path);
276
+ } catch (Throwable t) {
277
+ // Play Core not on classpath or not installed -> ignore
278
+ return null;
279
+ }
280
+ }
281
+
282
+ /** models_pack → absolute file (if installed), else null */
283
+ @Nullable
284
+ private File getFileFromAssetPack(String relative) {
285
+ try {
286
+ // assetsPath is the root of the "assets" folder inside the models_pack
287
+ File assetsRoot = tryGetModelsPackAssetsPath(reactCtx);
288
+ if (assetsRoot == null) return null; // pack not present → caller will fall back
289
+
290
+ // Accept both "model.onnx" and "models/model.onnx"
291
+ String rel = relative.startsWith("models/") ? relative : ("models/" + relative);
292
+ File f = new File(assetsRoot, rel);
293
+ return f.exists() ? f : null;
294
+ } catch (Throwable t) {
295
+ Log.w(TAG, "getFileFromAssetPack failed: " + t.getMessage());
296
+ return null;
297
+ }
298
+ }
299
+
300
+ /** If in res/raw, copy to cache and return File; else null. */
301
+ @Nullable
302
+ private File tryResolveFromResRawToCache(String nameMaybeWithExt, @Nullable String defaultExt) {
303
+ // Accept "model.onnx" or "model"
304
+ String rawName = nameMaybeWithExt;
305
+ if (rawName == null) return null;
306
+ rawName = rawName.trim();
307
+ if (rawName.isEmpty()) return null;
308
+ int dot = rawName.lastIndexOf('.');
309
+ if (dot > 0) rawName = rawName.substring(0, dot);
310
+ return copyRawResourceToCache(rawName, defaultExt);
311
+ }
312
+
313
+ /** For plain names, resolve to an absolute File using res/raw or asset-pack. */
314
+ @Nullable
315
+ private File resolvePlainModelToFile(String plain, @Nullable String defaultExt) {
316
+ // 1) res/raw (RN may map bundled assets there in release)
317
+ File fromRaw = tryResolveFromResRawToCache(plain, defaultExt);
318
+ if (fromRaw != null && fromRaw.exists()) return fromRaw;
319
+
320
+ // 2) fast-follow asset pack
321
+ File fromPack = getFileFromAssetPack(plain);
322
+ if (fromPack != null && fromPack.exists()) return fromPack;
323
+
324
+ return null;
325
+ }
326
+
327
+ @ReactMethod
328
+ public void speak(String text, int speakerId, double speed, Promise promise) {
329
+ try {
330
+ float s = (float) speed;
331
+ if (!Float.isFinite(s) || s <= 0.0f) s = 1.0f;
332
+
333
+ // ✅ 3 args only
334
+ tts.speak(text, speakerId, s);
335
+
336
+ promise.resolve(null);
337
+ } catch (Exception e) {
338
+ promise.reject("SpeakError", e.getMessage(), e);
339
+ }
340
+ }
341
+ private File copyRawResourceToCache(String rawName, @Nullable String defaultExt) {
342
+ try {
343
+ String pkg = reactCtx.getPackageName();
344
+ int resId = reactCtx.getResources().getIdentifier(rawName, "raw", pkg);
345
+
346
+ Log.d(TAG, "copyRawResourceToCache: rawName=" + rawName + " pkg=" + pkg + " resId=" + resId);
347
+
348
+ if (resId == 0) return null;
349
+
350
+ String ext = (defaultExt != null && !defaultExt.isEmpty()) ? defaultExt : "bin";
351
+ File out = new File(
352
+ reactCtx.getCacheDir(),
353
+ "rn_raw_" + rawName + "_" + System.currentTimeMillis() + "." + ext
354
+ );
355
+
356
+ try (java.io.InputStream in = reactCtx.getResources().openRawResource(resId);
357
+ FileOutputStream fos = new FileOutputStream(out)) {
358
+ byte[] buf = new byte[8192];
359
+ int len;
360
+ while ((len = in.read(buf)) != -1) fos.write(buf, 0, len);
361
+ }
362
+
363
+ Log.d(TAG, "copyRawResourceToCache: wrote " + out.getAbsolutePath() + " size=" + out.length());
364
+ return out;
365
+ } catch (Exception e) {
366
+ Log.e(TAG, "copyRawResourceToCache FAILED rawName=" + rawName + " err=" + e.getMessage(), e);
367
+ return null;
368
+ }
369
+ }
370
+
371
+ private File tryCopyRawToCacheIfExists(String name, @Nullable String defaultExt) {
372
+ if (name == null) return null;
373
+ final String s = name.trim();
374
+ if (s.isEmpty()) return null;
375
+
376
+ // Only reasonable resource entry names (no scheme, no slashes)
377
+ if (s.contains("://") || s.contains("/") || s.contains("\\") || s.startsWith(".")) return null;
378
+
379
+ // IMPORTANT: res/raw entry names have NO extension in Android resources
380
+ // If caller passed "foo.wav", strip extension for resource lookup.
381
+ String rawName = s;
382
+ int dot = rawName.lastIndexOf('.');
383
+ if (dot > 0) rawName = rawName.substring(0, dot);
384
+
385
+ File out = copyRawResourceToCache(rawName, defaultExt);
386
+ return (out != null && out.exists()) ? out : null;
387
+ }
388
+
389
+ // Resolve pathOrURL -> local readable File (supports http(s), asset:/, file://, plain path)
390
+ // defaultExt can be "wav", "onnx", etc (used for tmp file naming when extension missing)
391
+ private File resolveToLocalFileForRead(String pathOrURL, @Nullable String defaultExt) throws Exception {
392
+ if (pathOrURL == null) return null;
393
+ final String s = pathOrURL.trim();
394
+ if (s.isEmpty()) return null;
395
+
396
+ // 0) RN bundled assets on Android are usually in res/raw and resolve to a resource entry name
397
+ // like "assets_*" OR "src_assets_*" (no extension). Try res/raw first.
398
+ {
399
+ File out = tryCopyRawToCacheIfExists(s, defaultExt);
400
+ if (out != null) {
401
+ Log.i(TAG, "resolveToLocalFileForRead: copied res/raw '" + s + "' -> " + out.getAbsolutePath());
402
+ return out;
403
+ }
404
+ }
405
+
406
+ // 0) RN bundled assets resolve to res/raw names like "assets_cashregistersound" (NO extension).
407
+ // They are NOT APK /assets files. Handle exactly like react-native-sound: Resources.getIdentifier(..., "raw", ...)
408
+ if (s.startsWith("assets_")) {
409
+ File out = copyRawResourceToCache(s, defaultExt);
410
+ if (out != null && out.exists()) {
411
+ Log.i(TAG, "resolveToLocalFileForRead: copied res/raw '" + s + "' -> " + out.getAbsolutePath());
412
+ return out;
413
+ }
414
+ Log.w(TAG, "resolveToLocalFileForRead: res/raw lookup failed for '" + s + "'");
415
+ return null;
416
+ }
417
+ // 1) http(s) -> download into cache
418
+ if (isHttpOrHttps(s)) {
419
+ URL url = new URL(s);
420
+ String ext = guessExtFromUrl(s, defaultExt);
421
+ File out = new File(reactCtx.getCacheDir(), "rn_dl_" + System.currentTimeMillis() + (ext != null ? ("." + ext) : ""));
422
+ try (java.io.InputStream in = url.openStream();
423
+ FileOutputStream fos = new FileOutputStream(out)) {
424
+ byte[] buf = new byte[8192];
425
+ int len;
426
+ while ((len = in.read(buf)) != -1) fos.write(buf, 0, len);
427
+ }
428
+ return out;
429
+ }
430
+ String assetName = null;
431
+
432
+ // RN "asset:/" form
433
+ if (s.startsWith("asset:/") || s.startsWith("asset://")) {
434
+ assetName = s.replaceFirst("^asset:/+", ""); // handles asset:/ and asset://
435
+ } else if (s.startsWith("file:")) {
436
+ // Android asset URL forms
437
+ // - file:///android_asset/foo.wav => path "/android_asset/foo.wav", host null
438
+ // - file://android_asset/foo.wav => host "android_asset", path "/foo.wav"
439
+ Uri u = Uri.parse(s);
440
+ if (u != null) {
441
+ String host = u.getHost(); // "android_asset" in file://android_asset/...
442
+ String path = u.getPath(); // "/android_asset/..." OR "/foo.wav"
443
+ if (path != null) {
444
+ if ("android_asset".equalsIgnoreCase(host)) {
445
+ // file://android_asset/foo.wav => path "/foo.wav"
446
+ assetName = path.startsWith("/") ? path.substring(1) : path;
447
+ } else if (path.startsWith("/android_asset/")) {
448
+ // file:///android_asset/foo.wav => path "/android_asset/foo.wav"
449
+ assetName = path.substring("/android_asset/".length());
450
+ }
451
+ }
452
+ }
453
+ }
454
+
455
+ if (assetName != null) {
456
+ // normalize
457
+ assetName = Uri.decode(assetName);
458
+ while (assetName.startsWith("/")) assetName = assetName.substring(1);
459
+
460
+ String ext = guessExtFromName(assetName, defaultExt);
461
+ File out = new File(
462
+ reactCtx.getCacheDir(),
463
+ "rn_asset_" + System.currentTimeMillis() + (ext != null ? ("." + ext) : "")
464
+ );
465
+
466
+ try (java.io.InputStream in = reactCtx.getAssets().open(assetName);
467
+ FileOutputStream fos = new FileOutputStream(out)) {
468
+ byte[] buf = new byte[8192];
469
+ int len;
470
+ while ((len = in.read(buf)) != -1) fos.write(buf, 0, len);
471
+ }
472
+ return out;
473
+ }
474
+
475
+ // 3) file:// -> local file path
476
+ String path = s;
477
+ if (isFileUrl(s)) {
478
+ Uri u = Uri.parse(s);
479
+ path = (u != null) ? u.getPath() : null;
480
+ }
481
+ if (path == null) return null;
482
+ return new File(path);
483
+ }
484
+
485
+ private static String guessExtFromUrl(String url, @Nullable String defaultExt) {
486
+ try {
487
+ String p = Uri.parse(url).getPath();
488
+ return guessExtFromName(p, defaultExt);
489
+ } catch (Exception ignored) {
490
+ return defaultExt;
491
+ }
492
+ }
493
+
494
+ private static String guessExtFromName(@Nullable String name, @Nullable String defaultExt) {
495
+ if (name == null) return defaultExt;
496
+ int dot = name.lastIndexOf('.');
497
+ if (dot >= 0 && dot + 1 < name.length()) {
498
+ String ext = name.substring(dot + 1);
499
+ return ext.isEmpty() ? defaultExt : ext;
500
+ }
501
+ return defaultExt;
502
+ }
503
+
504
+ @ReactMethod
505
+ public void stopSpeaking(Promise promise) {
506
+ try {
507
+ tts.stopSpeaking();
508
+ promise.resolve(null);
509
+ } catch (Exception e) {
510
+ promise.reject("StopError", e.getMessage(), e);
511
+ }
512
+ }
513
+
514
+ @ReactMethod
515
+ public void destroy(Promise promise) {
516
+ try {
517
+ // stop & free native resources; after this, JS should create a fresh instance before use
518
+ tts.destroy();
519
+ promise.resolve(null);
520
+ } catch (Exception e) {
521
+ promise.reject("DestroyError", e.getMessage(), e);
522
+ }
523
+ }
524
+ // ADD
525
+ private static boolean isHttpOrHttps(String s) {
526
+ if (s == null) return false;
527
+ String ls = s.toLowerCase(Locale.US);
528
+ return ls.startsWith("http://") || ls.startsWith("https://");
529
+ }
530
+ // ADD
531
+ private static boolean isFileUrl(String s) {
532
+ if (s == null) return false;
533
+ String ls = s.toLowerCase(Locale.US);
534
+ return ls.startsWith("file://");
535
+ }
536
+ // ADD
537
+ private static byte[] b64(String base64) throws Exception {
538
+ return Base64.decode(base64, Base64.DEFAULT);
539
+ }
540
+ // ADD
541
+ private static byte[] intLE(int v) {
542
+ return new byte[] {
543
+ (byte)(v & 0xFF),
544
+ (byte)((v >> 8) & 0xFF),
545
+ (byte)((v >> 16) & 0xFF),
546
+ (byte)((v >> 24) & 0xFF)
547
+ };
548
+ }
549
+ // ADD
550
+ private static byte[] shortLE(short v) {
551
+ return new byte[] {
552
+ (byte)(v & 0xFF),
553
+ (byte)((v >> 8) & 0xFF)
554
+ };
555
+ }
556
+ // ADD
557
+ private static void writeWavF32(File out, float[] pcm, int sampleRate) throws Exception {
558
+ int numChannels = 1;
559
+ int bitsPerSample = 32;
560
+ int byteRate = sampleRate * numChannels * (bitsPerSample / 8);
561
+ int subchunk2Size = pcm.length * numChannels * (bitsPerSample / 8);
562
+ int chunkSize = 36 + subchunk2Size;
563
+
564
+ try (FileOutputStream fos = new FileOutputStream(out)) {
565
+ fos.write(new byte[] { 'R','I','F','F' });
566
+ fos.write(intLE(chunkSize));
567
+ fos.write(new byte[] { 'W','A','V','E' });
568
+
569
+ fos.write(new byte[] { 'f','m','t',' ' });
570
+ fos.write(intLE(16));
571
+ fos.write(shortLE((short) 3)); // IEEE float
572
+ fos.write(shortLE((short) numChannels));
573
+ fos.write(intLE(sampleRate));
574
+ fos.write(intLE(byteRate));
575
+ fos.write(shortLE((short) (numChannels * (bitsPerSample / 8))));
576
+ fos.write(shortLE((short) bitsPerSample));
577
+
578
+ fos.write(new byte[] { 'd','a','t','a' });
579
+ fos.write(intLE(subchunk2Size));
580
+
581
+ ByteBuffer bb = ByteBuffer.allocate(pcm.length * 4).order(ByteOrder.LITTLE_ENDIAN);
582
+ for (float v : pcm) bb.putFloat(v);
583
+ fos.write(bb.array());
584
+ }
585
+ }
586
+ // ADD
587
+ private static void mixI16InterleavedToMonoF32(short[] src, int channels, float[] dst) {
588
+ int frames = dst.length, idx = 0;
589
+ for (int f = 0; f < frames; f++) {
590
+ int acc = 0;
591
+ for (int ch = 0; ch < channels; ch++) acc += src[idx++];
592
+ dst[f] = (acc / (float) channels) / 32768.0f;
593
+ }
594
+ }
595
+ // ADD
596
+ private static void mixI16PlanarToMonoF32(short[] src, int frames, int channels, float[] dst) {
597
+ int plane = frames;
598
+ for (int f = 0; f < frames; f++) {
599
+ int acc = 0;
600
+ for (int ch = 0; ch < channels; ch++) acc += src[ch * plane + f];
601
+ dst[f] = (acc / (float) channels) / 32768.0f;
602
+ }
603
+ }
604
+ // ADD
605
+ private static void mixF32InterleavedToMono(float[] src, int channels, float[] dst) {
606
+ int frames = dst.length, idx = 0;
607
+ for (int f = 0; f < frames; f++) {
608
+ float acc = 0f;
609
+ for (int ch = 0; ch < channels; ch++) acc += src[idx++];
610
+ dst[f] = acc / channels;
611
+ }
612
+ }
613
+ // ADD
614
+ private static void mixF32PlanarToMono(float[] src, int frames, int channels, float[] dst) {
615
+ int plane = frames;
616
+ for (int f = 0; f < frames; f++) {
617
+ float acc = 0f;
618
+ for (int ch = 0; ch < channels; ch++) acc += src[ch * plane + f];
619
+ dst[f] = acc / channels;
620
+ }
621
+ }
622
+
623
+ @ReactMethod
624
+ public void playWav(String pathOrURL, boolean markAsLast, Promise promise) {
625
+ final String TAG = "TTS";
626
+ Log.d(TAG, "playWav() called with: " + pathOrURL + " | markAsLast=" + markAsLast);
627
+ // 1️⃣ Handle RN dev server URLs (http://localhost:8081/...) by downloading them to cache first
628
+ try {
629
+ File f = resolveToLocalFileForRead(pathOrURL, "wav");
630
+ if (f == null) {
631
+ promise.reject("bad_path", "Empty/invalid pathOrURL");
632
+ return;
633
+ }
634
+ if (!f.exists()) {
635
+ promise.reject("file_missing", "WAV file does not exist at path: " + f.getAbsolutePath());
636
+ return;
637
+ }
638
+ Log.d(TAG, "Playing WAV from: " + f.getAbsolutePath());
639
+ tts.playWav(f, markAsLast);
640
+ promise.resolve("queued");
641
+ } catch (Exception e) {
642
+ Log.e(TAG, "PlayWavError: " + e.getMessage(), e);
643
+ promise.reject("PlayWavError", e.getMessage(), e);
644
+ }
645
+
646
+ }
647
+
648
+ @ReactMethod
649
+ public void playBuffer(ReadableMap desc, Promise promise) {
650
+ try {
651
+ if (desc == null ||
652
+ !desc.hasKey("base64") || desc.isNull("base64") ||
653
+ !desc.hasKey("sampleRate") || desc.isNull("sampleRate") ||
654
+ !desc.hasKey("format") || desc.isNull("format")) {
655
+ promise.reject("invalid_args", "Missing one of base64/sampleRate/format");
656
+ return;
657
+ }
658
+ final String base64 = desc.getString("base64");
659
+ final int sampleRate = desc.getInt("sampleRate");
660
+ final String format = desc.getString("format");
661
+ final int channels = desc.hasKey("channels") && !desc.isNull("channels") ? desc.getInt("channels") : 1;
662
+ final boolean interleaved = desc.hasKey("interleaved") && !desc.isNull("interleaved") ? desc.getBoolean("interleaved") : true;
663
+ final boolean markAsLast = desc.hasKey("markAsLast") && !desc.isNull("markAsLast") ? desc.getBoolean("markAsLast") : true;
664
+ if (sampleRate <= 0 || channels <= 0) {
665
+ promise.reject("bad_params", "sampleRate and channels must be > 0");
666
+ return;
667
+ }
668
+
669
+ final byte[] raw = b64(base64);
670
+ if (raw == null || raw.length == 0) {
671
+ promise.reject("bad_base64", "Could not decode base64 payload");
672
+ return;
673
+ }
674
+
675
+ float[] mono;
676
+ if ("i16".equalsIgnoreCase(format)) {
677
+ if (raw.length % 2 != 0) {
678
+ promise.reject("bad_buffer", "i16 payload length not multiple of 2");
679
+ return;
680
+ }
681
+ int frames = (raw.length / 2) / Math.max(1, channels);
682
+ mono = new float[frames];
683
+
684
+ short[] s = new short[raw.length / 2];
685
+ ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN);
686
+ for (int i = 0; i < s.length; i++) s[i] = bb.getShort();
687
+
688
+ if (channels == 1) {
689
+ for (int i = 0; i < frames; i++) mono[i] = s[i] / 32768.0f;
690
+ } else if (interleaved) {
691
+ mixI16InterleavedToMonoF32(s, channels, mono);
692
+ } else {
693
+ mixI16PlanarToMonoF32(s, frames, channels, mono);
694
+ }
695
+ } else if ("f32".equalsIgnoreCase(format)) {
696
+ if (raw.length % 4 != 0) {
697
+ promise.reject("bad_buffer", "f32 payload length not multiple of 4");
698
+ return;
699
+ }
700
+ int totalFloats = raw.length / 4;
701
+ float[] f = new float[totalFloats];
702
+ ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN);
703
+ for (int i = 0; i < totalFloats; i++) f[i] = bb.getFloat();
704
+
705
+ int frames = totalFloats / Math.max(1, channels);
706
+ mono = new float[frames];
707
+
708
+ if (channels == 1) {
709
+ System.arraycopy(f, 0, mono, 0, frames);
710
+ } else if (interleaved) {
711
+ mixF32InterleavedToMono(f, channels, mono);
712
+ } else {
713
+ mixF32PlanarToMono(f, frames, channels, mono);
714
+ }
715
+ } else {
716
+ promise.reject("bad_format", "format must be 'i16' or 'f32'");
717
+ return;
718
+ }
719
+
720
+ File out = new File(reactCtx.getCacheDir(), "extbuf_" + System.currentTimeMillis() + ".wav");
721
+ writeWavF32(out, mono, sampleRate);
722
+
723
+ tts.playWav(out, markAsLast);
724
+ promise.resolve("queued");
725
+ } catch (Exception e) {
726
+ promise.reject("PlayBufferError", e.getMessage(), e);
727
+ }
728
+ }
729
+
730
+ // RN event API stubs (required by RN)
731
+ @ReactMethod public void addListener(String eventName) { /* no-op */ }
732
+ @ReactMethod public void removeListeners(double count) { /* no-op */ }
733
+ }