Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enable c api for android ci #1635

Merged
merged 1 commit into from
Dec 20, 2024

Conversation

thewh1teagle
Copy link
Contributor

Resolve #1634

@csukuangfj
Copy link
Collaborator

Thank you for your contribution!

@csukuangfj csukuangfj merged commit 4681bdf into k2-fsa:master Dec 20, 2024
1 check passed
@csukuangfj
Copy link
Collaborator

Will release a new version on the coming Sunday this week.

@adem-rguez
Copy link

hello, great work!
i was wondering, where can i find the c api lib files for android? you added a note for that in release v1.10.36, but after going through android archives i can't find them, i tried using ohos .so files but they didn't work for android :'(..
thank you!

@csukuangfj
Copy link
Collaborator

@adem-rguez
Copy link

thank you, sadly it still didn't work for me, i am trying to do an android build with unity, the moment i call the a function from the dll the app crashes (in an apk android build), do you have any idea why?

@csukuangfj
Copy link
Collaborator

please post error logs.

otherwise, no one except you knows what is happening.

@adem-rguez
Copy link

that's the issue, the app quits, before leaving any log (only log before the the plugin functions is shown), at least that's what i observed from unity, i will see if i can get direct log using logcat

@adem-rguez
Copy link

adem-rguez commented Jan 7, 2025

so here is the logcat output:

2025/01/07 08:10:49.097 19118 21764 Info Unity before Init dll function!
2025/01/07 08:10:49.097 19118 21764 Info Unity  #0 0x7990657d48 (libunity.so) GetStacktrace(int) 0x44
2025/01/07 08:10:49.097 19118 21764 Info Unity  #1 0x7990bb25ac (libunity.so) DebugStringToFile(DebugStringToFileData const&) 0x234
2025/01/07 08:10:49.097 19118 21764 Info Unity  #2 0x7990210190 (libunity.so) DebugLogHandler::Internal_Log(LogType, LogOption, core::basic_string<char, core::StringStorageDefault<char> > const&, Object*) 0x9c
2025/01/07 08:10:49.097 19118 21764 Info Unity  #3 0x79902100a0 (libunity.so) DebugLogHandler_CUSTOM_Internal_Log(LogType, LogOption, ScriptingBackendNativeStringPtrOpaque*, ScriptingBackendNativeObjectPtrOpaque*) 0x128
2025/01/07 08:10:49.097 19118 21764 Info Unity  #4 0x798aba053c (libil2cpp.so) ? 0x0
2025/01/07 08:10:49.097 19118 21764 Info Unity  #5 0x798aaec8d4 (libil2cpp.so) ? 0x0
2025/01/07 08:10:49.097 19118 21764 Info Unity  #6 0x798aaec820 (libil2cpp.so) ? 0x0
2025/01/07 08:10:49.097 19118 21764 Info Unity  #7 0x7990520838 (libunity.so) scripting_method_invoke(ScriptingMethodPtr, ScriptingObjectPtr, ScriptingArguments&, ScriptingExceptionPtr*, bool) 0xb0
2025/01/07 08:10:49.097 19118 21764 Info Unity  #8 0x7990533398 (libunity.so) ScriptingInvocation::Invoke(ScriptingExceptionPtr*, bool) 0x9c
2025/01/07 08:10:49.097 19118 21764 Info Unity  #9 0x799053d154 (libunity.so) MonoBehaviour::InvokeMethodOrCoroutineChecked(ScriptingMethodPtr, ScriptingObjectPtr, ScriptingExceptionPtr*) 0x738
2025/01/07 08:10:49.097 19118 21764 Info Unity  #10 0x799053d37c (libun
2025/01/07 08:10:49.100 19118 21764 Debug nativeloader Load /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libsherpa-onnx-c-api.so using ns clns-4 from class loader (caller=/data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/base.apk): ok
2025/01/07 08:10:49.101 19118 21764 Warn sherpa-onnx OfflineTtsConfig(model=OfflineTtsModelConfig(vits=OfflineTtsVitsModelConfig(model="jar:file:///data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/base.apk!/assets/vits-piper-en_US-libritts_r-medium/en_US-libritts_r-medium.onnx", lexicon="", tokens="jar:file:///data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/base.apk!/assets/vits-piper-en_US-libritts_r-medium/tokens.txt", data_dir="jar:file:///data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/base.apk!/assets/espeak-ng-data", dict_dir="", noise_scale=0.667, noise_scale_w=0.8, length_scale=1), matcha=OfflineTtsMatchaModelConfig(acoustic_model="", vocoder="", lexicon="", tokens="", data_dir="", dict_dir="", noise_scale=0.667, length_scale=1), num_threads=1, debug=True, provider="cpu"), rule_fsts="", rule_fars="", max_num_sentences=5)
2025/01/07 08:10:49.101 19118 21764 Warn sherpa-onnx --vits-model: 'jar:file:///data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/base.apk!/assets/vits-piper-en_US-libritts_r-medium/en_US-libritts_r-medium.onnx' does not exist
2025/01/07 08:10:49.101 19118 21764 Warn sherpa-onnx Errors in config
2025/01/07 08:10:49.106 19118 21764 Error CRASH *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
2025/01/07 08:10:49.106 19118 21764 Error CRASH Version '2022.3.55f1 (9f374180d209)', Build type 'Development', Scripting Backend 'il2cpp', CPU 'arm64-v8a'
2025/01/07 08:10:49.106 19118 21764 Error CRASH Build fingerprint: 'OPPO/CPH2371EEA/OPD4A1L1:13/TP1A.220905.001/R.1ade725-3a70:user/release-keys'
2025/01/07 08:10:49.106 19118 21764 Error CRASH Revision: '0'
2025/01/07 08:10:49.106 19118 21764 Error CRASH ABI: 'arm64'
2025/01/07 08:10:49.106 19118 21764 Error CRASH Timestamp: 2025-01-07 08:10:49.106611479+0100
2025/01/07 08:10:49.106 19118 21764 Error CRASH pid: 19118, tid: 21764, name: UnityMain  >>> com.DefaultCompany.TTSDemo <<<
2025/01/07 08:10:49.106 19118 21764 Error CRASH uid: 10503
2025/01/07 08:10:49.106 19118 21764 Error CRASH signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr --------
2025/01/07 08:10:49.106 19118 21764 Error CRASH Cause: null pointer dereference
2025/01/07 08:10:49.106 19118 21764 Error CRASH     x0  0000000000000000  x1  000000785829cc70  x2  0000007858200000  x3  0000000000000003
2025/01/07 08:10:49.106 19118 21764 Error CRASH     x4  000000000000009c  x5  09060c0606000000  x6  00008006060c0609  x7  6f71646751ff6473
2025/01/07 08:10:49.106 19118 21764 Error CRASH     x8  000000781cdfe048  x9  625105d9b302f096  x10 000000000000009c  x11 0000000000000001
2025/01/07 08:10:49.106 19118 21764 Error CRASH     x12 0000000000000004  x13 09b3ddf772f3d8ef  x14 0000000000000006  x15 ffffffffffffffff
2025/01/07 08:10:49.106 19118 21764 Error CRASH     x16 0000007ab6d098f0  x17 0000007ab6cf9800  x18 0000007940e9e138  x19 0000000000000000
2025/01/07 08:10:49.106 19118 21764 Error CRASH     x20 000000798bdda000  x21 000000798bcf7108  x22 0000007a06dc4960  x23 000000798bcf5e58
2025/01/07 08:10:49.106 19118 21764 Error CRASH     x24 000000798bd10f00  x25 000000798bcf7d90  x26 000000798bd1f458  x27 0000007a06dc48d0
2025/01/07 08:10:49.106 19118 21764 Error CRASH     x28 0000007a06dc4838  x29 0000007a06dc4950
2025/01/07 08:10:49.106 19118 21764 Error CRASH     lr  000000798aba1fd0  sp  0000007a06dc4740  pc  000000781cdfe048  pst 0000000060001000
2025/01/07 08:10:49.106 19118 21764 Error CRASH backtrace:
2025/01/07 08:10:49.106 19118 21764 Error CRASH       #00 pc 00000000001cc048  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libsherpa-onnx-c-api.so (SherpaOnnxOfflineTtsSampleRate) (BuildId: 085f02850ae50e2fbf8897e1b2f65e3f4ce7f8c1)
2025/01/07 08:10:49.106 19118 21764 Error CRASH       #01 pc 0000000000d62fcc  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libil2cpp.so (BuildId: e89cd621ac49346ff2352b397db175980a63ca83)
2025/01/07 08:10:49.106 19118 21764 Error CRASH       #02 pc 0000000000d61590  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libil2cpp.so (BuildId: e89cd621ac49346ff2352b397db175980a63ca83)
2025/01/07 08:10:49.106 19118 21764 Error CRASH       #03 pc 0000000000cad8d4  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libil2cpp.so (BuildId: e89cd621ac49346ff2352b397db175980a63ca83)
2025/01/07 08:10:49.106 19118 21764 Error CRASH       #04 pc 0000000000cad820  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libil2cpp.so (BuildId: e89cd621ac49346ff2352b397db175980a63ca83)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #05 pc 00000000006b0838  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libunity.so (scripting_method_invoke(ScriptingMethodPtr, ScriptingObjectPtr, ScriptingArguments&, ScriptingExceptionPtr*, bool)+176) (BuildId: b2cc86a340fea31d)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #06 pc 00000000006c3398  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libunity.so (ScriptingInvocation::Invoke(ScriptingExceptionPtr*, bool)+156) (BuildId: b2cc86a340fea31d)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #07 pc 00000000006cd154  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libunity.so (MonoBehaviour::InvokeMethodOrCoroutineChecked(ScriptingMethodPtr, ScriptingObjectPtr, ScriptingExceptionPtr*)+1848) (BuildId: b2cc86a340fea31d)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #08 pc 00000000006cd37c  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libunity.so (MonoBehaviour::InvokeMethodOrCoroutineChecked(ScriptingMethodPtr, ScriptingObjectPtr)+68) (BuildId: b2cc86a340fea31d)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #09 pc 00000000006cdae0  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libunity.so (MonoBehaviour::DelayedStartCall(Object*, void*)+48) (BuildId: b2cc86a340fea31d)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #10 pc 00000000004a6bd8  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libunity.so (DelayedCallManager::Update(int)+560) (BuildId: b2cc86a340fea31d)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #11 pc 0000000000568294  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libunity.so (BuildId: b2cc86a340fea31d)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #12 pc 000000000055df38  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libunity.so (ExecutePlayerLoop(NativePlayerLoopSystem*)+132) (BuildId: b2cc86a340fea31d)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #13 pc 000000000055df78  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libunity.so (ExecutePlayerLoop(NativePlayerLoopSystem*)+196) (BuildId: b2cc86a340fea31d)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #14 pc 000000000055e290  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libunity.so (PlayerLoop()+324) (BuildId: b2cc86a340fea31d)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #15 pc 0000000000775b38  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libunity.so (UnityPlayerLoop()+836) (BuildId: b2cc86a340fea31d)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #16 pc 0000000000793f08  /data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/lib/arm64/libunity.so (nativeRender(_JNIEnv*, _jobject*)+84) (BuildId: b2cc86a340fea31d)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #17 pc 0000000000383d70  /apex/com.android.art/lib64/libart.so (BuildId: c35c9ebf7bb06435e4b31977d87bd5d5)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #18 pc 000000000036d574  /apex/com.android.art/lib64/libart.so (BuildId: c35c9ebf7bb06435e4b31977d87bd5d5)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #19 pc 0000000000366a80  /apex/com.android.art/lib64/libart.so (BuildId: c35c9ebf7bb06435e4b31977d87bd5d5)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #20 pc 000000000076e870  /apex/com.android.art/lib64/libart.so (BuildId: c35c9ebf7bb06435e4b31977d87bd5d5)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #21 pc 00000000003863d8  /apex/com.android.art/lib64/libart.so (BuildId: c35c9ebf7bb06435e4b31977d87bd5d5)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #22 pc 0000000000358eec  /apex/com.android.art/lib64/libart.so (BuildId: c35c9ebf7bb06435e4b31977d87bd5d5)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #23 pc 0000000000367314  /apex/com.android.art/lib64/libart.so (BuildId: c35c9ebf7bb06435e4b31977d87bd5d5)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #24 pc 000000000076e870  /apex/com.android.art/lib64/libart.so (BuildId: c35c9ebf7bb06435e4b31977d87bd5d5)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #25 pc 00000000003863d8  /apex/com.android.art/lib64/libart.so (BuildId: c35c9ebf7bb06435e4b31977d87bd5d5)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #26 pc 0000000000358278  /apex/com.android.art/lib64/libart.so (BuildId: c35c9ebf7bb06435e4b31977d87bd5d5)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #27 pc 0000000000383e98  /apex/com.android.art/lib64/libart.so (BuildId: c35c9ebf7bb06435e4b31977d87bd5d5)
2025/01/07 08:10:49.107 19118 21764 Error CRASH       #28 pc 00000000020c03e4  /memfd:jit-cache (deleted)
2025/01/07 08:10:49.250 19118 21764 Error CRASH Tombstone written to: /storage/emulated/0/Android/data/com.DefaultCompany.TTSDemo/files/tombstone_00
2025/01/07 08:10:49.250 19118 21764 Error CRASH Forwarding signal 11
0001/01/01 00:00:00.000 -1 -1 Info  --------- beginning of crash
2025/01/07 08:10:49.250 19118 21764 Fatal libc Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 in tid 21764 (UnityMain), pid 19118 (Company.TTSDemo)

here is also the part of the code that calls that function (the crash happens when i call SherpaOnnxCreateOfflineTts but i assume it would be the same for the rest of the functions as well):
image

plugins placement in unity:
image

and their import settings (the same for the 3 .so files):
image

i will be waiting for you reply or if you need further details, thank you!

@csukuangfj
Copy link
Collaborator

csukuangfj commented Jan 7, 2025

from the log
2025/01/07 08:10:49.101 19118 21764 Warn sherpa-onnx --vits-model: 'jar:file:///data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/base.apk!/assets/vits-piper-en_US-libritts_r-medium/en_US-libritts_r-medium.onnx' does not exist

Please check that the file is placed in the correct location.

@adem-rguez
Copy link

i see, the file is indeed placed correctly, but now i understand the problem, unity packs the location its in (StreamingAssets folder) differently in android, and IO operations won't be able to access it like that.. this is bad, i will need to find an alternative way to load the models on android

@csukuangfj
Copy link
Collaborator

2025/01/07 08:10:49.101 19118 21764 Warn sherpa-onnx OfflineTtsConfig(model=OfflineTtsModelConfig(vits=OfflineTtsVitsModelConfig(model="jar:file:///data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/base.apk!/assets/vits-piper-en_US-libritts_r-medium/en_US-libritts_r-medium.onnx", lexicon="", tokens="jar:file:///data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/base.apk!/assets/vits-piper-en_US-libritts_r-medium/tokens.txt", data_dir="jar:file:///data/app/~~WyAgEqOao5vbYriEPRFWHA==/com.DefaultCompany.TTSDemo-4rvsvhpnnzXxgQ-XnAidcw==/base.apk!/assets/espeak-ng-data", dict_dir="", noise_scale=0.667, noise_scale_w=0.8, length_scale=1), matcha=OfflineTtsMatchaModelConfig(acoustic_model="", vocoder="", lexicon="", tokens="", data_dir="", dict_dir="", noise_scale=0.667, length_scale=1), num_threads=1, debug=True, provider="cpu"), rule_fsts="", rule_fars="", max_num_sentences=5)

Here is the log from sherpa-onnx.

It shows you have indeed passed some parameters to sherpa-onnx's C API.

Please pay attention to the path.

jar:file:///data/app/

The C++ code uses fopen and fread-like functions to read files.

I don't know what the meaning of jar:file://

@csukuangfj
Copy link
Collaborator

StreamingAssets folder) differently in android, and IO operations won't be able to access it like that..

Can you first copy the files from assets into disk?

@csukuangfj
Copy link
Collaborator

Or does unity provide C or C++ APIs to read files from assets? If so, we can change sherpa-onnx to support that as long as you can help to test the changes.

@adem-rguez
Copy link

adem-rguez commented Jan 7, 2025

i think it's possible using AndroidJavaClass class in unity, but the easiest way would be to copy the models into a readable location once on android, there is way to load such files in unity using UnityWebRequest, load them from the StreamingAssets location, and copy them somewhere differently (on a readable path), then i can pass the new path to the sherpa plugin, and this has to be done only the first time you open the apk, this is the best work-around usually, if you want i can provide the code i will write for that later :)

@csukuangfj
Copy link
Collaborator

Once it works for you, it would be great if you can post the code here so that others won't get the same issues.

@adem-rguez
Copy link

i am unsure about the espeak data path however, how is the plugin reading it?

@csukuangfj
Copy link
Collaborator

It is the same as the model, e.g., the same as tokens.txt

Just copy the folder from the assets to some locations and pass the path to sherpa-onnx.

@csukuangfj
Copy link
Collaborator

By the way, you need to copy all the files from espeak-ng-data directory and you need to keep the structure of epseak-ng-data.

espeak-ng-data also contains sub-directories.

@adem-rguez
Copy link

okay, working on it

@adem-rguez
Copy link

Good news! i got it to work, here is an apk to test, i will provide a cleaned version of the code involved later in the day, and hopefully make an asset store package after that with more automated features like voice models download and easy integration of sherpa-onnx in unity, having packages for this in game engines such as unity and unreal engine is the way to make this super project super popular! great work @csukuangfj!
apk arm64: https://drive.google.com/file/d/1-xcQAMGCp-6fiwR8KuY1fJvmuN0wn0Pv/view?usp=sharing

@csukuangfj
Copy link
Collaborator

Thanks!

@adem-rguez
Copy link

question: is token.txt file common for all models, or does each model have a separate one?

@adem-rguez
Copy link

adem-rguez commented Jan 8, 2025

Good Morning,
i will be sharing the code needed to make sherpa-onnx tts work on unity specifically on android builds!
the problem is that unity merges the model files that we need for the tts in that jar file, making it not possible to pass the path directly to the sherpa-onnx plugin, and if you do the app will crash/quit!
there are work arounds for this, the simplest is using UnityWebRequest (similar to www of .net) to copy the files from the archive location in streamingAssets into a readable location and pass the new location to the sherpa-onnx plugin,
to do this, i created StreamingAssetsApi scripts which take care of such operations.
first on the unity editor side we have a script that records the hierarchy of the streamingAssets folder and writes it in a text file in the streaming assets (think of it as an index page of a book):

using UnityEngine;
using UnityEditor;
using System.IO;
using System.Collections.Generic;

public static class StreamingAssetsHierarchyBuilder
{
    // The output text file name:
    private const string OUTPUT_FILE_NAME = "StreamingAssetsHierarchy.txt";


    // Add a menu item to generate manually.
    // You could also run this from a build processor or any other Editor event.
    [MenuItem("BuildTools/Generate StreamingAssets Hierarchy")]
    public static void GenerateHierarchyFile()
    {
        // 1) The root folder in the Unity project for streaming assets
        string streamingAssetsRoot = Application.dataPath + "/StreamingAssets";
        if (!Directory.Exists(streamingAssetsRoot))
        {
            Debug.LogWarning("No StreamingAssets folder found. Creating one...");
            Directory.CreateDirectory(streamingAssetsRoot);
        }

        // 2) Collect all relative file paths inside streamingAssetsRoot
        List<string> allFiles = new List<string>();
        RecursivelyCollectFiles(streamingAssetsRoot, streamingAssetsRoot, allFiles);

        // 3) Write them to a text file
        // We’ll place it at the root of StreamingAssets for easy access
        string outputPath = Path.Combine(streamingAssetsRoot, OUTPUT_FILE_NAME);
        File.WriteAllLines(outputPath, allFiles);

        Debug.Log($"[StreamingAssetsHierarchyBuilder] Generated {allFiles.Count} entries in {OUTPUT_FILE_NAME}");
        // Refresh so Unity sees the new/updated text file
        AssetDatabase.Refresh();
    }

    /// <summary>
    /// Recursively scans 'currentPath' for files and subfolders,
    /// and appends their relative paths (relative to 'rootPath') to 'results'.
    /// </summary>
    private static void RecursivelyCollectFiles(string rootPath, string currentPath, List<string> results)
    {
        // Grab all files in this folder
        string[] files = Directory.GetFiles(currentPath);
        foreach (var file in files)
        {
            // Skip meta files
            if (file.EndsWith(".meta")) 
                continue;

            // Make a relative path from the root of StreamingAssets
            // e.g. root = c:\Project\Assets\StreamingAssets
            // file = c:\Project\Assets\StreamingAssets\Models\Foo\bar.txt
            // relative = Models/Foo/bar.txt
            string relativePath = file.Substring(rootPath.Length + 1)
                                    .Replace("\\", "/");
            results.Add(relativePath);
        }

        // Recurse subfolders
        string[] directories = Directory.GetDirectories(currentPath);
        foreach (var dir in directories)
        {
            // Skip .meta or system folders if any
            if (dir.EndsWith(".meta"))
                continue;

            RecursivelyCollectFiles(rootPath, dir, results);
        }
    }
}

with this we will have a unity function that shows in the editor like this:
image
so obviously to use it we click BuildsTools > Generate StreamingAssets Hierarchy.
this creates the following file that contains the streamingAssets Hierarchy:
image
and it contains something like this:
image

Optionally we can make unity call this function automatically each time we do a build:

using System.Collections;
using System.Collections.Generic;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEngine;

public class PreBuildHierarchyUpdate : IPreprocessBuildWithReport
{
    public int callbackOrder => 0;

    public void OnPreprocessBuild(BuildReport report)
    {
        StreamingAssetsHierarchyBuilder.GenerateHierarchyFile();
    }
}

now on the android side, the StreamingAssetsApi script contains functions to copy a file or a directory recursively from the streaming assets to different directory, along with function that use the hierarchy file to get a list of files in a certain directory or sub directory.

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;

public static class StreamingAssetsAPI
{
    private const string HIERARCHY_FILE = "StreamingAssetsHierarchy.txt";


    /// <summary>
    /// Wraps an IEnumerator-based Unity coroutine in a Task,
    /// allowing you to 'await' it in an async method.
    /// </summary>
    public static Task RunCoroutine(this MonoBehaviour runner, IEnumerator coroutine)
    {
        var tcs = new TaskCompletionSource<bool>();
        runner.StartCoroutine(RunCoroutineInternal(coroutine, tcs));
        return tcs.Task;
    }

    private static IEnumerator RunCoroutineInternal(IEnumerator coroutine, TaskCompletionSource<bool> tcs)
    {
        yield return coroutine;
        tcs.SetResult(true);
    }


    /// <summary>
    /// Retrieves the hierarchy of files (relative paths) under a given subfolder in
    /// StreamingAssets, as defined in <see cref="HIERARCHY_FILE"/>. 
    /// 
    /// This method is NOT a coroutine itself. Instead, it starts an internal 
    /// coroutine to load and filter the hierarchy. Once loading finishes, 
    /// the provided <paramref name="onComplete"/> callback is invoked with 
    /// the resulting list of file paths. If there is an error, it passes <c>null</c>.
    /// 
    /// <para>Usage example:</para>
    /// <code>
    /// void Start()
    /// {
    ///     // Suppose 'this' is a MonoBehaviour
    ///     var api = new StreamingAssetsHierarchyAPI();
    ///     api.GetHierarchy(this, "Models", (files) =>
    ///     {
    ///         if (files == null)
    ///         {
    ///             Debug.LogError("Failed to retrieve files!");
    ///             return;
    ///         }
    ///         Debug.Log("Received " + files.Count + " files in 'Models'.");
    ///     });
    /// }
    /// </code>
    /// </summary>
    /// <param name="runner">A MonoBehaviour used to start the internal coroutine.</param>
    /// <param name="subfolder">
    /// The subfolder (relative to StreamingAssets root) to filter by. 
    /// If empty or null, it returns the entire hierarchy.
    /// </param>
    /// <param name="onComplete">
    /// Callback invoked once the list of files is ready. If an error occurs, <c>null</c> is passed.
    /// </param>
    public static void GetHierarchy( this MonoBehaviour runner, string subfolder, Action<List<string>> onComplete)
    {
        // Validate runner
        if (runner == null)
        {
            Debug.LogError("[StreamingAssetsHierarchyAPI] No MonoBehaviour provided to start coroutine!");
            onComplete?.Invoke(null);
            return;
        }

        // Start the internal coroutine
        runner.StartCoroutine(GetHierarchyCoroutine(subfolder, onComplete));
    }

    // The coroutine that actually fetches the hierarchy file
    public static IEnumerator GetHierarchyCoroutine(string subfolder, Action<List<string>> onComplete = null)
    {
        // 1) Load the entire hierarchy from the text file
        yield return GetHierarchyForSubfolder(subfolder, (list) =>
        {
            // This callback is invoked once the text is loaded & filtered
            onComplete?.Invoke(list);
        });
    }

    /// <summary>
    /// Reads the entire hierarchy from the generated file,
    /// filters for those starting with 'subfolder',
    /// and returns their relative paths through <paramref name="callback"/>.
    /// 
    /// Typically you won't call this method directly; instead, use <see cref="GetHierarchy"/>.
    /// 
    /// Example usage manually (if you wanted a coroutine):
    /// <code>
    /// yield return StartCoroutine(
    ///     StreamingAssetsHierarchyAPI.GetHierarchyForSubfolder("Models", (list) =&gt; { ... })
    /// );
    /// </code>
    /// </summary>
    /// <param name="subfolder">
    /// The subfolder (relative to StreamingAssets root) to filter by. 
    /// If empty, returns all paths.
    /// </param>
    /// <param name="callback">
    /// Invoked with a list of paths, or <c>null</c> if there's an error.
    /// </param>
    public static IEnumerator GetHierarchyForSubfolder(string subfolder, Action<List<string>> callback)
    {
        string path = Path.Combine(Application.streamingAssetsPath, HIERARCHY_FILE);

        using (UnityWebRequest www = UnityWebRequest.Get(path))
        {
            yield return www.SendWebRequest();

            if (www.result != UnityWebRequest.Result.Success)
            {
                Debug.LogError($"Failed to load {HIERARCHY_FILE}: {www.error}");
                callback?.Invoke(null);
                yield break;
            }

            // Parse lines
            string fileContent = www.downloadHandler.text;
            string[] allLines = fileContent.Split(
                new char[] { '\r', '\n' },
                StringSplitOptions.RemoveEmptyEntries
            );

            List<string> matched = new List<string>();

            if (string.IsNullOrEmpty(subfolder))
                subfolder = "";

            // We'll unify to forward slashes
            subfolder = subfolder.Replace("\\", "/").Trim().TrimEnd('/');

            foreach (var line in allLines)
            {
                // e.g. "Models/en_US-libritts_r-medium.onnx"
                // If subfolder is "Models", check if line.StartsWith("Models/")
                if (subfolder.Length == 0 || line.StartsWith(subfolder + "/"))
                {
                    matched.Add(line);
                }
            }

            callback?.Invoke(matched);
        }
    }


    /// <summary>
    /// Copies a single file from StreamingAssets (relative path) to a specified 
    /// local filesystem path. This uses UnityWebRequest to handle jar:file:// 
    /// URIs on Android.
    /// 
    /// <para>Example usage:</para>
    /// <code>
    /// yield return StreamingAssetsAPI.CopyOneFile(
    ///     "Models/data.json", 
    ///     "/storage/emulated/0/Android/data/com.example.myapp/files/data.json",
    ///     success => 
    ///     {
    ///         Debug.Log(success ? "File copied!" : "Copy failed.");
    ///     }
    /// );
    /// </code>
    /// </summary>
    /// <param name="relativeFilePath">Path within StreamingAssets. E.g. "Models/data.json".</param>
    /// <param name="destinationFullPath">Full local file path to write to.</param>
    /// <param name="onComplete">Invoked with true if copy succeeded, false on error.</param>
    public static IEnumerator CopyFile( string relativeFilePath, string destinationFullPath, Action<bool> onComplete = null)
    {
        // Build full path to the file in StreamingAssets
        string srcUrl = Path.Combine(Application.streamingAssetsPath, relativeFilePath);

        using (UnityWebRequest www = UnityWebRequest.Get(srcUrl))
        {
            yield return www.SendWebRequest();

            if (www.result != UnityWebRequest.Result.Success)
            {
                Debug.LogError($"[CopyOneFile] Failed to get {relativeFilePath}: {www.error}");
                onComplete?.Invoke(false);
                yield break;
            }

            // Ensure the directory of destinationFullPath exists
            string parentDir = Path.GetDirectoryName(destinationFullPath);
            if (!Directory.Exists(parentDir))
            {
                Directory.CreateDirectory(parentDir);
            }

            // Write the file
            byte[] data = www.downloadHandler.data;
            File.WriteAllBytes(destinationFullPath, data);

            Debug.Log($"[CopyOneFile] Copied {relativeFilePath} -> {destinationFullPath}");
            onComplete?.Invoke(true);
        }
    }

    /// <summary>
    /// Recursively copies *all files* from a given subfolder in StreamingAssets
    /// into the specified local directory (e.g., persistentDataPath).
    /// 
    /// It uses <see cref="GetHierarchyForSubfolder"/> to find all files,
    /// then calls <see cref="CopyOneFile"/> for each. 
    /// 
    /// Example usage:
    /// <code>
    /// yield return StreamingAssetsAPI.CopyDirectory(
    ///     runner: this,
    ///     subfolder: "Models",
    ///     localRoot: Path.Combine(Application.persistentDataPath, "Models"),
    ///     onComplete: () => { Debug.Log("Directory copied!"); }
    /// );
    /// </code>
    /// </summary>
    /// <param name="subfolder">Which subfolder in StreamingAssets to copy. E.g. "Models".</param>
    /// <param name="localRoot">
    /// The local directory path (e.g. persistentDataPath/Models) where files are written.
    /// </param>
    /// <param name="onComplete">Optional callback invoked when done.</param>
    public static IEnumerator CopyDirectory(string subfolder, string localRoot, Action onComplete = null )
    {
        // 1) Get the hierarchy for that subfolder
        bool done = false;
        List<string> fileList = null;

        yield return GetHierarchyForSubfolder(subfolder, list =>
        {
            fileList = list;
            done = true;
        });

        // Wait for callback
        while (!done) 
            yield return null;

        if (fileList == null)
        {
            Debug.LogError($"[CopyDirectory] Could not retrieve hierarchy for {subfolder}.");
            onComplete?.Invoke();
            yield break;
        }

        // e.g. fileList might contain ["Models/foo.txt", "Models/subdir/bar.json", ...]

        // 2) Copy each file
        for (int i = 0; i < fileList.Count; i++)
        {
            string relPath = fileList[i];
            // We want to remove "Models/" if subfolder = "Models"
            // so we only get the portion after that prefix
            string suffix = relPath;

            // unify slashes
            suffix = suffix.Replace("\\", "/");
            subfolder = subfolder.Replace("\\", "/").Trim().TrimEnd('/');

            if (subfolder.Length > 0 && suffix.StartsWith(subfolder + "/"))
            {
                // remove "Models/" prefix
                suffix = suffix.Substring(subfolder.Length + 1);
            }

            // Build destination path
            string dst = Path.Combine(localRoot, suffix);

            // yield return CopyOneFile:
            yield return CopyFile(relPath, dst);
        }

        Debug.Log($"[CopyDirectory] Copied {fileList.Count} files from '{subfolder}' to '{localRoot}'");
        onComplete?.Invoke();
    }
}

the StreamingAssetsApi uses coroutines to handle the copy operations, which you can call using the StartCoroutine function in a MonoBehaviour script, but also has an async await wrapper functions for this to run the coroutines using an awaitable RunCoroutine Function, for example:

await this.RunCoroutine( StreamingAssetsAPI.CopyDirectory( modelsDir, // subfolder in StreamingAssets BuildPath( modelsDir ), () => { Debug.Log( modelsDir+ ": Directory copied!"); } ) );

now that we have the streamingAssetsApi implemented, we can test the sherpa-onnx tts at runtime using the following script:

using UnityEngine;
using System;
using System.Runtime.InteropServices;
using SherpaOnnx;
using System.Text;
using UnityEngine.UI;
using System.IO;
using System.Collections;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;  // For Thread
using System.Text.RegularExpressions;

public class TTS : MonoBehaviour
{
    [Header("VITS Model Settings")]
    public string modelPath;      // e.g., "vits_generator.onnx"
    public string tokensPath;     // e.g., "tokens.txt"
    public string lexiconPath;    // e.g., "lexicon.txt" (if needed by your model)
    public string dictDirPath;    // e.g., "dict" folder (if needed)

    [Header("VITS Tuning Parameters")]
    [Range(0f, 1f)] public float noiseScale = 0.667f;
    [Range(0f, 1f)] public float noiseScaleW = 0.8f;
    [Range(0.5f, 2f)] public float lengthScale = 1.0f;

    [Header("Offline TTS Config")]
    public int numThreads = 1;
    public bool debugMode = false;
    public string provider = "cpu";  // could be "cpu", "cuda", etc.
    public int maxNumSentences = 1;

    [Header("UI for Testing")]
    public Button generateButton;
    public InputField inputField; // Or Text/TextMeshPro input
    public float speed = 1.0f;    // Speed factor for TTS
    public int speakerId = 0;     // If the model has multiple speakers

    // If you want to see "streaming" attempt
    [SerializeField] private bool streamAudio = false;

    // If true, we'll split text by sentences and generate each one on a background thread
    [SerializeField] private bool splitSentencesAsync = true;

    private OfflineTts offlineTts;

    // We'll reuse one AudioSource for sentence-by-sentence playback
    private AudioSource sentenceAudioSource;

    // For streaming approach
    private ConcurrentQueue<float> streamingBuffer = new ConcurrentQueue<float>();
    private int samplesRead = 0;
    private AudioSource streamingAudioSource;
    private AudioClip streamingClip;

    //put voice models directory path relative to the streaming assets folder
    [SerializeField] private string modelsDir;
    //put espeak-ng data directory path relative to the streaming assets folder
    [SerializeField] private string espeakDir;

    async void Start()
    {
        generateButton.gameObject.SetActive(false);
        //Log Cat log: 2025/01/07 09:56:38.283 6672 7831 Info Unity [CopyDirectory] Copied 355 files from 'espeak-ng-data' to '/storage/emulated/0/Android/data/com.DefaultCompany.TTSDemo/files/espeak-ng-data'
        if( Application.platform == RuntimePlatform.Android )
        {
            Debug.Log("running android copy process!");
            await this.RunCoroutine( StreamingAssetsAPI.CopyDirectory(
            modelsDir,      // subfolder in StreamingAssets
            BuildPath( modelsDir ),
            () => { Debug.Log( modelsDir+ ": Directory copied!"); } ) );

            await this.RunCoroutine( StreamingAssetsAPI.CopyDirectory(
            espeakDir,      // subfolder in StreamingAssets
            BuildPath( espeakDir ),
            () => { Debug.Log( espeakDir +": Directory copied!"); } ) );

        }


        // 1. Prepare the VITS model config
        var vitsConfig = new OfflineTtsVitsModelConfig
        {
            Model = BuildPath(modelPath),
            Lexicon = BuildPath(lexiconPath),
            Tokens = BuildPath(tokensPath),
            DataDir = BuildPath(espeakDir),
            DictDir = BuildPath(dictDirPath),

            NoiseScale = noiseScale,
            NoiseScaleW = noiseScaleW,
            LengthScale = lengthScale
        };

        // 2. Wrap it inside the ModelConfig
        var modelConfig = new OfflineTtsModelConfig
        {
            Vits = vitsConfig,
            NumThreads = numThreads,
            Debug = debugMode ? 1 : 0,
            Provider = provider
        };

        // 3. Create the top-level OfflineTtsConfig
        var ttsConfig = new OfflineTtsConfig
        {
            Model = modelConfig,
            RuleFsts = "",
            MaxNumSentences = maxNumSentences,
            RuleFars = ""
        };

        // 4. Instantiate the OfflineTts object
        Debug.Log("will create offline tts now!");
        offlineTts = new OfflineTts(ttsConfig);
        Debug.Log($"OfflineTts created! SampleRate: {offlineTts.SampleRate}, NumSpeakers: {offlineTts.NumSpeakers}");

        // Create a dedicated AudioSource for sentence-by-sentence playback
        sentenceAudioSource = gameObject.AddComponent<AudioSource>();
        sentenceAudioSource.playOnAwake = false;
        sentenceAudioSource.loop = false;

        // 5. Hook up a button to test TTS
        if (generateButton != null)
        {
            generateButton.gameObject.SetActive(true);

            generateButton.onClick.AddListener(() =>
            {
                if (inputField == null || string.IsNullOrWhiteSpace(inputField.text))
                {
                    Debug.LogWarning("No text to synthesize!");
                    return;
                }
                Speak();
            });
        }
    }

    public void Speak()
    {
        // If we want the sentence-by-sentence approach in a background thread:
        if (splitSentencesAsync)
        {
            StartCoroutine(CoPlayTextBySentenceAsync(inputField.text));
        }
        else
        {
            // The old single-shot approach or streaming approach
            if (streamAudio)
                PlayTextStreamed(inputField.text);
            else
                PlayText(inputField.text);
        }
    }

    /// <summary>
    /// 1) Splits the text into sentences using multiple delimiters,
    /// 2) For each sentence, spawns a background thread to generate TTS,
    /// 3) Waits for generation to finish (without freezing the main thread),
    /// 4) Plays the resulting clip in order.
    /// </summary>
    private IEnumerator CoPlayTextBySentenceAsync(string text)
    {
        // More delimiters: period, question mark, exclamation, semicolon, colon
        // We also handle multiple punctuation in a row, etc.
        // This uses Regex to split on punctuation [.!?;:]+ 
        // Then trim the results and remove empties.
        string[] sentences = Regex.Split(text, @"[\.!\?;:]+")
            .Select(s => s.Trim())
            .Where(s => s.Length > 0)
            .ToArray();

        if (sentences.Length == 0)
        {
            Debug.LogWarning("No valid sentences found in input text.");
            yield break;
        }

        foreach (string sentence in sentences)
        {
            Debug.Log($"[Background TTS] Generating: \"{sentence}\"");
            
            // Prepare a place to store the generated float[] 
            float[] generatedSamples = null;
            bool generationDone = false;

            // Run .Generate(...) on a background thread
            Thread t = new Thread(() =>
            {
                // Generate the audio for this sentence
                OfflineTtsGeneratedAudio generated = offlineTts.Generate(sentence, speed, speakerId);
                generatedSamples = generated.Samples;
                generationDone = true;
            });
            t.Start();

            // Wait until the thread signals it's done
            yield return new WaitUntil(() => generationDone);

            // Back on the main thread, we create the AudioClip and play it
            if (generatedSamples == null || generatedSamples.Length == 0)
            {
                Debug.LogWarning("Generated empty audio for a sentence. Skipping...");
                continue;
            }

            AudioClip clip = AudioClip.Create(
                "SherpaOnnxTTS-SentenceAsync",
                generatedSamples.Length,
                1,
                offlineTts.SampleRate,
                false
            );
            clip.SetData(generatedSamples, 0);

            sentenceAudioSource.clip = clip;
            sentenceAudioSource.Play();
            Debug.Log($"Playing sentence: \"{sentence}\"  length = {clip.length:F2}s");

            // Wait until playback finishes
            while (sentenceAudioSource.isPlaying)
                yield return null;
        }

        Debug.Log("All sentences have been generated (background) and played sequentially.");
    }

    /// <summary>
    /// Single-shot generation on the main thread (blocks Unity for large inputs).
    /// </summary>
    private void PlayText(string text)
    {
        Debug.Log($"Generating TTS for text: '{text}'");
        OfflineTtsGeneratedAudio generated = offlineTts.Generate(text, speed, speakerId);

        float[] pcmSamples = generated.Samples;
        if (pcmSamples == null || pcmSamples.Length == 0)
        {
            Debug.LogError("SherpaOnnx TTS returned empty PCM data.");
            return;
        }

        AudioClip clip = AudioClip.Create(
            "SherpaOnnxTTS",
            pcmSamples.Length,
            1,
            offlineTts.SampleRate,
            false
        );
        clip.SetData(pcmSamples, 0);

        var audioSource = gameObject.AddComponent<AudioSource>();
        audioSource.playOnAwake = false;
        audioSource.clip = clip;
        audioSource.Play();

        Debug.Log($"TTS clip of length {clip.length:F2}s is now playing.");
    }

    /// <summary>
    /// Attempted "streaming" approach. The callback is called only once in practice
    /// for the entire waveform, so it doesn't truly stream partial chunks.
    /// </summary>
    private void PlayTextStreamed(string text)
    {
        Debug.Log($"[Streaming] Generating TTS for text: '{text}'");

        int sampleRate = offlineTts.SampleRate;
        int maxAudioLengthInSamples = sampleRate * 300; // 5 min

        streamingClip = AudioClip.Create(
            "SherpaOnnxTTS-Streamed",
            maxAudioLengthInSamples,
            1,
            sampleRate,
            true,
            OnAudioRead,
            OnAudioSetPosition
        );

        if (streamingAudioSource == null)
            streamingAudioSource = gameObject.AddComponent<AudioSource>();

        streamingAudioSource.playOnAwake = false;
        streamingAudioSource.clip = streamingClip;
        streamingAudioSource.loop = false;

        streamingBuffer = new ConcurrentQueue<float>();
        samplesRead = 0;

        streamingAudioSource.Play();

        // This calls your callback, but typically only once for the entire wave
        offlineTts.GenerateWithCallback(text, speed, speakerId, MyTtsChunkCallback);

        Debug.Log("[Streaming] Playback started; awaiting streamed samples...");
    }

    private int MyTtsChunkCallback(System.IntPtr samplesPtr, int numSamples)
    {
        Debug.Log("chunk callback");
        if (numSamples <= 0)
            return 0;

        float[] chunk = new float[numSamples];
        System.Runtime.InteropServices.Marshal.Copy(samplesPtr, chunk, 0, numSamples);

        foreach (float sample in chunk)
            streamingBuffer.Enqueue(sample);

        return 0; 
    }

    private void OnAudioRead(float[] data)
    {
        for (int i = 0; i < data.Length; i++)
        {
            if (streamingBuffer.TryDequeue(out float sample))
            {
                data[i] = sample;
                samplesRead++;
            }
            else
            {
                data[i] = 0f; // fill silence
            }
        }
    }

    private void OnAudioSetPosition(int newPosition)
    {
        Debug.Log($"[Streaming] OnAudioSetPosition => {newPosition}");
    }

    /// <summary>
    /// Utility: Only call Path.Combine if 'relativePath' is not null/empty. Otherwise, return "".
    /// </summary>
    private string BuildPath(string relativePath)
    {
        if (string.IsNullOrEmpty(relativePath))
        {
            return "";
        }
        if( Application.platform == RuntimePlatform.Android )
        {
            return Path.Combine(Application.persistentDataPath, relativePath);
        }
        else
            return Path.Combine(Application.streamingAssetsPath, relativePath);
    }

    private void OnDestroy()
    {
        // Cleanup TTS resources
        if (offlineTts != null)
        {
            offlineTts.Dispose();
            offlineTts = null;
        }
    }
}

now you need to fill the inspector values:
image

the important ones are the paths, make sure to provide the paths relative to the streamingAssets Folder. and make sure that those files actually exist.
this way you have a function unity android build!
drawback:

sadly the first time you open the app on android you will have to wait for a few seconds for the streamingAssetsApi to do its work, it's possible to simply cross-reference the files (see if they exist in the destination location) the second time you open the app which allow us to not repeat the waiting process.

if you have a different work around for the streaming assets issue, i'd be happy to hear it :)
Enjoy!

@csukuangfj
Copy link
Collaborator

csukuangfj commented Jan 8, 2025

Good Morning, i will be sharing the code needed to make sherpa-onnx tts work on unity specifically on android builds! the problem is that unity merges the model files that we need for the tts in that jar file, making it not possible to pass the path directly to the sherpa-onnx plugin, and if you do the app will crash/quit! there are work arounds for this, the simplest is using UnityWebRequest (similar to www of .net) to copy the files from the archive location in streamingAssets into a readable location and pass the new location to the sherpa-onnx plugin, to do this, i created StreamingAssetsApi scripts which take care of such operations. first on the unity editor side we have a script that records the hierarchy of the streamingAssets folder and writes it in a text file in the streaming assets (think of it as an index page of a book):

using UnityEngine;
using UnityEditor;
using System.IO;
using System.Collections.Generic;

public static class StreamingAssetsHierarchyBuilder
{
    // The output text file name:
    private const string OUTPUT_FILE_NAME = "StreamingAssetsHierarchy.txt";


    // Add a menu item to generate manually.
    // You could also run this from a build processor or any other Editor event.
    [MenuItem("BuildTools/Generate StreamingAssets Hierarchy")]
    public static void GenerateHierarchyFile()
    {
        // 1) The root folder in the Unity project for streaming assets
        string streamingAssetsRoot = Application.dataPath + "/StreamingAssets";
        if (!Directory.Exists(streamingAssetsRoot))
        {
            Debug.LogWarning("No StreamingAssets folder found. Creating one...");
            Directory.CreateDirectory(streamingAssetsRoot);
        }

        // 2) Collect all relative file paths inside streamingAssetsRoot
        List<string> allFiles = new List<string>();
        RecursivelyCollectFiles(streamingAssetsRoot, streamingAssetsRoot, allFiles);

        // 3) Write them to a text file
        // We’ll place it at the root of StreamingAssets for easy access
        string outputPath = Path.Combine(streamingAssetsRoot, OUTPUT_FILE_NAME);
        File.WriteAllLines(outputPath, allFiles);

        Debug.Log($"[StreamingAssetsHierarchyBuilder] Generated {allFiles.Count} entries in {OUTPUT_FILE_NAME}");
        // Refresh so Unity sees the new/updated text file
        AssetDatabase.Refresh();
    }

    /// <summary>
    /// Recursively scans 'currentPath' for files and subfolders,
    /// and appends their relative paths (relative to 'rootPath') to 'results'.
    /// </summary>
    private static void RecursivelyCollectFiles(string rootPath, string currentPath, List<string> results)
    {
        // Grab all files in this folder
        string[] files = Directory.GetFiles(currentPath);
        foreach (var file in files)
        {
            // Skip meta files
            if (file.EndsWith(".meta")) 
                continue;

            // Make a relative path from the root of StreamingAssets
            // e.g. root = c:\Project\Assets\StreamingAssets
            // file = c:\Project\Assets\StreamingAssets\Models\Foo\bar.txt
            // relative = Models/Foo/bar.txt
            string relativePath = file.Substring(rootPath.Length + 1)
                                    .Replace("\\", "/");
            results.Add(relativePath);
        }

        // Recurse subfolders
        string[] directories = Directory.GetDirectories(currentPath);
        foreach (var dir in directories)
        {
            // Skip .meta or system folders if any
            if (dir.EndsWith(".meta"))
                continue;

            RecursivelyCollectFiles(rootPath, dir, results);
        }
    }
}

with this we will have a unity function that shows in the editor like this: image so obviously to use it we click BuildsTools > Generate StreamingAssets Hierarchy. this creates the following file that contains the streamingAssets Hierarchy: image and it contains something like this: image

Optionally we can make unity call this function automatically each time we do a build:

using System.Collections;
using System.Collections.Generic;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEngine;

public class PreBuildHierarchyUpdate : IPreprocessBuildWithReport
{
    public int callbackOrder => 0;

    public void OnPreprocessBuild(BuildReport report)
    {
        StreamingAssetsHierarchyBuilder.GenerateHierarchyFile();
    }
}

now on the android side, the StreamingAssetsApi script contains functions to copy a file or a directory recursively from the streaming assets to different directory, along with function that use the hierarchy file to get a list of files in a certain directory or sub directory.

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;

public static class StreamingAssetsAPI
{
    private const string HIERARCHY_FILE = "StreamingAssetsHierarchy.txt";


    /// <summary>
    /// Wraps an IEnumerator-based Unity coroutine in a Task,
    /// allowing you to 'await' it in an async method.
    /// </summary>
    public static Task RunCoroutine(this MonoBehaviour runner, IEnumerator coroutine)
    {
        var tcs = new TaskCompletionSource<bool>();
        runner.StartCoroutine(RunCoroutineInternal(coroutine, tcs));
        return tcs.Task;
    }

    private static IEnumerator RunCoroutineInternal(IEnumerator coroutine, TaskCompletionSource<bool> tcs)
    {
        yield return coroutine;
        tcs.SetResult(true);
    }


    /// <summary>
    /// Retrieves the hierarchy of files (relative paths) under a given subfolder in
    /// StreamingAssets, as defined in <see cref="HIERARCHY_FILE"/>. 
    /// 
    /// This method is NOT a coroutine itself. Instead, it starts an internal 
    /// coroutine to load and filter the hierarchy. Once loading finishes, 
    /// the provided <paramref name="onComplete"/> callback is invoked with 
    /// the resulting list of file paths. If there is an error, it passes <c>null</c>.
    /// 
    /// <para>Usage example:</para>
    /// <code>
    /// void Start()
    /// {
    ///     // Suppose 'this' is a MonoBehaviour
    ///     var api = new StreamingAssetsHierarchyAPI();
    ///     api.GetHierarchy(this, "Models", (files) =>
    ///     {
    ///         if (files == null)
    ///         {
    ///             Debug.LogError("Failed to retrieve files!");
    ///             return;
    ///         }
    ///         Debug.Log("Received " + files.Count + " files in 'Models'.");
    ///     });
    /// }
    /// </code>
    /// </summary>
    /// <param name="runner">A MonoBehaviour used to start the internal coroutine.</param>
    /// <param name="subfolder">
    /// The subfolder (relative to StreamingAssets root) to filter by. 
    /// If empty or null, it returns the entire hierarchy.
    /// </param>
    /// <param name="onComplete">
    /// Callback invoked once the list of files is ready. If an error occurs, <c>null</c> is passed.
    /// </param>
    public static void GetHierarchy( this MonoBehaviour runner, string subfolder, Action<List<string>> onComplete)
    {
        // Validate runner
        if (runner == null)
        {
            Debug.LogError("[StreamingAssetsHierarchyAPI] No MonoBehaviour provided to start coroutine!");
            onComplete?.Invoke(null);
            return;
        }

        // Start the internal coroutine
        runner.StartCoroutine(GetHierarchyCoroutine(subfolder, onComplete));
    }

    // The coroutine that actually fetches the hierarchy file
    public static IEnumerator GetHierarchyCoroutine(string subfolder, Action<List<string>> onComplete = null)
    {
        // 1) Load the entire hierarchy from the text file
        yield return GetHierarchyForSubfolder(subfolder, (list) =>
        {
            // This callback is invoked once the text is loaded & filtered
            onComplete?.Invoke(list);
        });
    }

    /// <summary>
    /// Reads the entire hierarchy from the generated file,
    /// filters for those starting with 'subfolder',
    /// and returns their relative paths through <paramref name="callback"/>.
    /// 
    /// Typically you won't call this method directly; instead, use <see cref="GetHierarchy"/>.
    /// 
    /// Example usage manually (if you wanted a coroutine):
    /// <code>
    /// yield return StartCoroutine(
    ///     StreamingAssetsHierarchyAPI.GetHierarchyForSubfolder("Models", (list) =&gt; { ... })
    /// );
    /// </code>
    /// </summary>
    /// <param name="subfolder">
    /// The subfolder (relative to StreamingAssets root) to filter by. 
    /// If empty, returns all paths.
    /// </param>
    /// <param name="callback">
    /// Invoked with a list of paths, or <c>null</c> if there's an error.
    /// </param>
    public static IEnumerator GetHierarchyForSubfolder(string subfolder, Action<List<string>> callback)
    {
        string path = Path.Combine(Application.streamingAssetsPath, HIERARCHY_FILE);

        using (UnityWebRequest www = UnityWebRequest.Get(path))
        {
            yield return www.SendWebRequest();

            if (www.result != UnityWebRequest.Result.Success)
            {
                Debug.LogError($"Failed to load {HIERARCHY_FILE}: {www.error}");
                callback?.Invoke(null);
                yield break;
            }

            // Parse lines
            string fileContent = www.downloadHandler.text;
            string[] allLines = fileContent.Split(
                new char[] { '\r', '\n' },
                StringSplitOptions.RemoveEmptyEntries
            );

            List<string> matched = new List<string>();

            if (string.IsNullOrEmpty(subfolder))
                subfolder = "";

            // We'll unify to forward slashes
            subfolder = subfolder.Replace("\\", "/").Trim().TrimEnd('/');

            foreach (var line in allLines)
            {
                // e.g. "Models/en_US-libritts_r-medium.onnx"
                // If subfolder is "Models", check if line.StartsWith("Models/")
                if (subfolder.Length == 0 || line.StartsWith(subfolder + "/"))
                {
                    matched.Add(line);
                }
            }

            callback?.Invoke(matched);
        }
    }


    /// <summary>
    /// Copies a single file from StreamingAssets (relative path) to a specified 
    /// local filesystem path. This uses UnityWebRequest to handle jar:file:// 
    /// URIs on Android.
    /// 
    /// <para>Example usage:</para>
    /// <code>
    /// yield return StreamingAssetsAPI.CopyOneFile(
    ///     "Models/data.json", 
    ///     "/storage/emulated/0/Android/data/com.example.myapp/files/data.json",
    ///     success => 
    ///     {
    ///         Debug.Log(success ? "File copied!" : "Copy failed.");
    ///     }
    /// );
    /// </code>
    /// </summary>
    /// <param name="relativeFilePath">Path within StreamingAssets. E.g. "Models/data.json".</param>
    /// <param name="destinationFullPath">Full local file path to write to.</param>
    /// <param name="onComplete">Invoked with true if copy succeeded, false on error.</param>
    public static IEnumerator CopyFile( string relativeFilePath, string destinationFullPath, Action<bool> onComplete = null)
    {
        // Build full path to the file in StreamingAssets
        string srcUrl = Path.Combine(Application.streamingAssetsPath, relativeFilePath);

        using (UnityWebRequest www = UnityWebRequest.Get(srcUrl))
        {
            yield return www.SendWebRequest();

            if (www.result != UnityWebRequest.Result.Success)
            {
                Debug.LogError($"[CopyOneFile] Failed to get {relativeFilePath}: {www.error}");
                onComplete?.Invoke(false);
                yield break;
            }

            // Ensure the directory of destinationFullPath exists
            string parentDir = Path.GetDirectoryName(destinationFullPath);
            if (!Directory.Exists(parentDir))
            {
                Directory.CreateDirectory(parentDir);
            }

            // Write the file
            byte[] data = www.downloadHandler.data;
            File.WriteAllBytes(destinationFullPath, data);

            Debug.Log($"[CopyOneFile] Copied {relativeFilePath} -> {destinationFullPath}");
            onComplete?.Invoke(true);
        }
    }

    /// <summary>
    /// Recursively copies *all files* from a given subfolder in StreamingAssets
    /// into the specified local directory (e.g., persistentDataPath).
    /// 
    /// It uses <see cref="GetHierarchyForSubfolder"/> to find all files,
    /// then calls <see cref="CopyOneFile"/> for each. 
    /// 
    /// Example usage:
    /// <code>
    /// yield return StreamingAssetsAPI.CopyDirectory(
    ///     runner: this,
    ///     subfolder: "Models",
    ///     localRoot: Path.Combine(Application.persistentDataPath, "Models"),
    ///     onComplete: () => { Debug.Log("Directory copied!"); }
    /// );
    /// </code>
    /// </summary>
    /// <param name="subfolder">Which subfolder in StreamingAssets to copy. E.g. "Models".</param>
    /// <param name="localRoot">
    /// The local directory path (e.g. persistentDataPath/Models) where files are written.
    /// </param>
    /// <param name="onComplete">Optional callback invoked when done.</param>
    public static IEnumerator CopyDirectory(string subfolder, string localRoot, Action onComplete = null )
    {
        // 1) Get the hierarchy for that subfolder
        bool done = false;
        List<string> fileList = null;

        yield return GetHierarchyForSubfolder(subfolder, list =>
        {
            fileList = list;
            done = true;
        });

        // Wait for callback
        while (!done) 
            yield return null;

        if (fileList == null)
        {
            Debug.LogError($"[CopyDirectory] Could not retrieve hierarchy for {subfolder}.");
            onComplete?.Invoke();
            yield break;
        }

        // e.g. fileList might contain ["Models/foo.txt", "Models/subdir/bar.json", ...]

        // 2) Copy each file
        for (int i = 0; i < fileList.Count; i++)
        {
            string relPath = fileList[i];
            // We want to remove "Models/" if subfolder = "Models"
            // so we only get the portion after that prefix
            string suffix = relPath;

            // unify slashes
            suffix = suffix.Replace("\\", "/");
            subfolder = subfolder.Replace("\\", "/").Trim().TrimEnd('/');

            if (subfolder.Length > 0 && suffix.StartsWith(subfolder + "/"))
            {
                // remove "Models/" prefix
                suffix = suffix.Substring(subfolder.Length + 1);
            }

            // Build destination path
            string dst = Path.Combine(localRoot, suffix);

            // yield return CopyOneFile:
            yield return CopyFile(relPath, dst);
        }

        Debug.Log($"[CopyDirectory] Copied {fileList.Count} files from '{subfolder}' to '{localRoot}'");
        onComplete?.Invoke();
    }
}

the StreamingAssetsApi uses coroutines to handle the copy operations, which you can call using the StartCoroutine function in a MonoBehaviour script, but also has an async await wrapper functions for this to run the coroutines using an awaitable RunCoroutine Function, for example:

await this.RunCoroutine( StreamingAssetsAPI.CopyDirectory( modelsDir, // subfolder in StreamingAssets BuildPath( modelsDir ), () => { Debug.Log( modelsDir+ ": Directory copied!"); } ) );

now that we have the streamingAssetsApi implemented, we can test the sherpa-onnx tts at runtime using the following script:

using UnityEngine;
using System;
using System.Runtime.InteropServices;
using SherpaOnnx;
using System.Text;
using UnityEngine.UI;
using System.IO;
using System.Collections;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;  // For Thread
using System.Text.RegularExpressions;

public class TTS : MonoBehaviour
{
    [Header("VITS Model Settings")]
    public string modelPath;      // e.g., "vits_generator.onnx"
    public string tokensPath;     // e.g., "tokens.txt"
    public string lexiconPath;    // e.g., "lexicon.txt" (if needed by your model)
    public string dictDirPath;    // e.g., "dict" folder (if needed)

    [Header("VITS Tuning Parameters")]
    [Range(0f, 1f)] public float noiseScale = 0.667f;
    [Range(0f, 1f)] public float noiseScaleW = 0.8f;
    [Range(0.5f, 2f)] public float lengthScale = 1.0f;

    [Header("Offline TTS Config")]
    public int numThreads = 1;
    public bool debugMode = false;
    public string provider = "cpu";  // could be "cpu", "cuda", etc.
    public int maxNumSentences = 1;

    [Header("UI for Testing")]
    public Button generateButton;
    public InputField inputField; // Or Text/TextMeshPro input
    public float speed = 1.0f;    // Speed factor for TTS
    public int speakerId = 0;     // If the model has multiple speakers

    // If you want to see "streaming" attempt
    [SerializeField] private bool streamAudio = false;

    // If true, we'll split text by sentences and generate each one on a background thread
    [SerializeField] private bool splitSentencesAsync = true;

    private OfflineTts offlineTts;

    // We'll reuse one AudioSource for sentence-by-sentence playback
    private AudioSource sentenceAudioSource;

    // For streaming approach
    private ConcurrentQueue<float> streamingBuffer = new ConcurrentQueue<float>();
    private int samplesRead = 0;
    private AudioSource streamingAudioSource;
    private AudioClip streamingClip;

    //put voice models directory path relative to the streaming assets folder
    [SerializeField] private string modelsDir;
    //put espeak-ng data directory path relative to the streaming assets folder
    [SerializeField] private string espeakDir;

    async void Start()
    {
        generateButton.gameObject.SetActive(false);
        //Log Cat log: 2025/01/07 09:56:38.283 6672 7831 Info Unity [CopyDirectory] Copied 355 files from 'espeak-ng-data' to '/storage/emulated/0/Android/data/com.DefaultCompany.TTSDemo/files/espeak-ng-data'
        if( Application.platform == RuntimePlatform.Android )
        {
            Debug.Log("running android copy process!");
            await this.RunCoroutine( StreamingAssetsAPI.CopyDirectory(
            modelsDir,      // subfolder in StreamingAssets
            BuildPath( modelsDir ),
            () => { Debug.Log( modelsDir+ ": Directory copied!"); } ) );

            await this.RunCoroutine( StreamingAssetsAPI.CopyDirectory(
            espeakDir,      // subfolder in StreamingAssets
            BuildPath( espeakDir ),
            () => { Debug.Log( espeakDir +": Directory copied!"); } ) );

        }


        // 1. Prepare the VITS model config
        var vitsConfig = new OfflineTtsVitsModelConfig
        {
            Model = BuildPath(modelPath),
            Lexicon = BuildPath(lexiconPath),
            Tokens = BuildPath(tokensPath),
            DataDir = BuildPath(espeakDir),
            DictDir = BuildPath(dictDirPath),

            NoiseScale = noiseScale,
            NoiseScaleW = noiseScaleW,
            LengthScale = lengthScale
        };

        // 2. Wrap it inside the ModelConfig
        var modelConfig = new OfflineTtsModelConfig
        {
            Vits = vitsConfig,
            NumThreads = numThreads,
            Debug = debugMode ? 1 : 0,
            Provider = provider
        };

        // 3. Create the top-level OfflineTtsConfig
        var ttsConfig = new OfflineTtsConfig
        {
            Model = modelConfig,
            RuleFsts = "",
            MaxNumSentences = maxNumSentences,
            RuleFars = ""
        };

        // 4. Instantiate the OfflineTts object
        Debug.Log("will create offline tts now!");
        offlineTts = new OfflineTts(ttsConfig);
        Debug.Log($"OfflineTts created! SampleRate: {offlineTts.SampleRate}, NumSpeakers: {offlineTts.NumSpeakers}");

        // Create a dedicated AudioSource for sentence-by-sentence playback
        sentenceAudioSource = gameObject.AddComponent<AudioSource>();
        sentenceAudioSource.playOnAwake = false;
        sentenceAudioSource.loop = false;

        // 5. Hook up a button to test TTS
        if (generateButton != null)
        {
            generateButton.gameObject.SetActive(true);

            generateButton.onClick.AddListener(() =>
            {
                if (inputField == null || string.IsNullOrWhiteSpace(inputField.text))
                {
                    Debug.LogWarning("No text to synthesize!");
                    return;
                }
                Speak();
            });
        }
    }

    public void Speak()
    {
        // If we want the sentence-by-sentence approach in a background thread:
        if (splitSentencesAsync)
        {
            StartCoroutine(CoPlayTextBySentenceAsync(inputField.text));
        }
        else
        {
            // The old single-shot approach or streaming approach
            if (streamAudio)
                PlayTextStreamed(inputField.text);
            else
                PlayText(inputField.text);
        }
    }

    /// <summary>
    /// 1) Splits the text into sentences using multiple delimiters,
    /// 2) For each sentence, spawns a background thread to generate TTS,
    /// 3) Waits for generation to finish (without freezing the main thread),
    /// 4) Plays the resulting clip in order.
    /// </summary>
    private IEnumerator CoPlayTextBySentenceAsync(string text)
    {
        // More delimiters: period, question mark, exclamation, semicolon, colon
        // We also handle multiple punctuation in a row, etc.
        // This uses Regex to split on punctuation [.!?;:]+ 
        // Then trim the results and remove empties.
        string[] sentences = Regex.Split(text, @"[\.!\?;:]+")
            .Select(s => s.Trim())
            .Where(s => s.Length > 0)
            .ToArray();

        if (sentences.Length == 0)
        {
            Debug.LogWarning("No valid sentences found in input text.");
            yield break;
        }

        foreach (string sentence in sentences)
        {
            Debug.Log($"[Background TTS] Generating: \"{sentence}\"");
            
            // Prepare a place to store the generated float[] 
            float[] generatedSamples = null;
            bool generationDone = false;

            // Run .Generate(...) on a background thread
            Thread t = new Thread(() =>
            {
                // Generate the audio for this sentence
                OfflineTtsGeneratedAudio generated = offlineTts.Generate(sentence, speed, speakerId);
                generatedSamples = generated.Samples;
                generationDone = true;
            });
            t.Start();

            // Wait until the thread signals it's done
            yield return new WaitUntil(() => generationDone);

            // Back on the main thread, we create the AudioClip and play it
            if (generatedSamples == null || generatedSamples.Length == 0)
            {
                Debug.LogWarning("Generated empty audio for a sentence. Skipping...");
                continue;
            }

            AudioClip clip = AudioClip.Create(
                "SherpaOnnxTTS-SentenceAsync",
                generatedSamples.Length,
                1,
                offlineTts.SampleRate,
                false
            );
            clip.SetData(generatedSamples, 0);

            sentenceAudioSource.clip = clip;
            sentenceAudioSource.Play();
            Debug.Log($"Playing sentence: \"{sentence}\"  length = {clip.length:F2}s");

            // Wait until playback finishes
            while (sentenceAudioSource.isPlaying)
                yield return null;
        }

        Debug.Log("All sentences have been generated (background) and played sequentially.");
    }

    /// <summary>
    /// Single-shot generation on the main thread (blocks Unity for large inputs).
    /// </summary>
    private void PlayText(string text)
    {
        Debug.Log($"Generating TTS for text: '{text}'");
        OfflineTtsGeneratedAudio generated = offlineTts.Generate(text, speed, speakerId);

        float[] pcmSamples = generated.Samples;
        if (pcmSamples == null || pcmSamples.Length == 0)
        {
            Debug.LogError("SherpaOnnx TTS returned empty PCM data.");
            return;
        }

        AudioClip clip = AudioClip.Create(
            "SherpaOnnxTTS",
            pcmSamples.Length,
            1,
            offlineTts.SampleRate,
            false
        );
        clip.SetData(pcmSamples, 0);

        var audioSource = gameObject.AddComponent<AudioSource>();
        audioSource.playOnAwake = false;
        audioSource.clip = clip;
        audioSource.Play();

        Debug.Log($"TTS clip of length {clip.length:F2}s is now playing.");
    }

    /// <summary>
    /// Attempted "streaming" approach. The callback is called only once in practice
    /// for the entire waveform, so it doesn't truly stream partial chunks.
    /// </summary>
    private void PlayTextStreamed(string text)
    {
        Debug.Log($"[Streaming] Generating TTS for text: '{text}'");

        int sampleRate = offlineTts.SampleRate;
        int maxAudioLengthInSamples = sampleRate * 300; // 5 min

        streamingClip = AudioClip.Create(
            "SherpaOnnxTTS-Streamed",
            maxAudioLengthInSamples,
            1,
            sampleRate,
            true,
            OnAudioRead,
            OnAudioSetPosition
        );

        if (streamingAudioSource == null)
            streamingAudioSource = gameObject.AddComponent<AudioSource>();

        streamingAudioSource.playOnAwake = false;
        streamingAudioSource.clip = streamingClip;
        streamingAudioSource.loop = false;

        streamingBuffer = new ConcurrentQueue<float>();
        samplesRead = 0;

        streamingAudioSource.Play();

        // This calls your callback, but typically only once for the entire wave
        offlineTts.GenerateWithCallback(text, speed, speakerId, MyTtsChunkCallback);

        Debug.Log("[Streaming] Playback started; awaiting streamed samples...");
    }

    private int MyTtsChunkCallback(System.IntPtr samplesPtr, int numSamples)
    {
        Debug.Log("chunk callback");
        if (numSamples <= 0)
            return 0;

        float[] chunk = new float[numSamples];
        System.Runtime.InteropServices.Marshal.Copy(samplesPtr, chunk, 0, numSamples);

        foreach (float sample in chunk)
            streamingBuffer.Enqueue(sample);

        return 0; 
    }

    private void OnAudioRead(float[] data)
    {
        for (int i = 0; i < data.Length; i++)
        {
            if (streamingBuffer.TryDequeue(out float sample))
            {
                data[i] = sample;
                samplesRead++;
            }
            else
            {
                data[i] = 0f; // fill silence
            }
        }
    }

    private void OnAudioSetPosition(int newPosition)
    {
        Debug.Log($"[Streaming] OnAudioSetPosition => {newPosition}");
    }

    /// <summary>
    /// Utility: Only call Path.Combine if 'relativePath' is not null/empty. Otherwise, return "".
    /// </summary>
    private string BuildPath(string relativePath)
    {
        if (string.IsNullOrEmpty(relativePath))
        {
            return "";
        }
        if( Application.platform == RuntimePlatform.Android )
        {
            return Path.Combine(Application.persistentDataPath, relativePath);
        }
        else
            return Path.Combine(Application.streamingAssetsPath, relativePath);
    }

    private void OnDestroy()
    {
        // Cleanup TTS resources
        if (offlineTts != null)
        {
            offlineTts.Dispose();
            offlineTts = null;
        }
    }
}

now you need to fill the inspector values: image

the important ones are the paths, make sure to provide the paths relative to the streamingAssets Folder. and make sure that those files actually exist. this way you have a function unity android build! drawback:

sadly the first time you open the app on android you will have to wait for a few seconds for the streamingAssetsApi to do its work, it's possible to simply cross-reference the files (see if they exist in the destination location) the second time you open the app which allow us to not repeat the waiting process.

if you have a different work around for the streaming assets issue, i'd be happy to hear it :) Enjoy!

Thank you for sharing the detailed solution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add release for android C API
3 participants