Android/Quest: copy models out of the APK on first use (StreamingAssets copy-out) - #3
Open
BernardMasika wants to merge 1 commit into
Open
Android/Quest: copy models out of the APK on first use (StreamingAssets copy-out)#3BernardMasika wants to merge 1 commit into
BernardMasika wants to merge 1 commit into
Conversation
On Android (including Quest), StreamingAssets lives inside the compressed APK, so File.Exists / Directory.Exists / ONNX Runtime's native file loaders all fail on it. No copy-out code existed, so no Sauti model could load on device, despite the documented copy-on-first-launch contract (per-platform.md, architecture.md, README all describe it). New runtime API: SautiStreamingAssets.ResolveFileAsync(relativePath) returns a System.IO-readable absolute path on every platform. Desktop/iOS/Editor: passthrough to Application.streamingAssetsPath (no behaviour change). Android player: streams the APK entry to persistentDataPath/SautiAssets/<relativePath> via UnityWebRequest + DownloadHandlerFile (no whole-model managed byte[] - the Kokoro ONNX is ~90 MB), atomically (.part then move), deduplicating concurrent resolves. The cache is stamped with Application.version and wiped on app updates so stale models never survive an upgrade. SautiSpeaker gains EnsureRunnerAsync(), awaited from SpeakAsync, which resolves the profile's model, optional tokenizer, and voice .bin before constructing KokoroTtsRunner (the runner itself stays Unity-free and unchanged). Only the profile's own voice is copied - Android cannot enumerate StreamingAssets directories; other voices copy on their first use. The synchronous EnsureRunner() now throws a directing error on Android instead of a misleading file-not-found. Samples~/06-vr-quest-npc resolves its TTS paths through the new API. STT deliberately untouched: whisper.unity already reads Android StreamingAssets via UnityWebRequest internally (FileUtils.ReadFile), so GGML models need no Sauti-side copy. LLMUnity likewise manages its own model paths. RAG paths stay as-is pending the B2 rework. UnityWebRequest in Runtime reads only the app's own APK (jar:file://) - no network I/O; the offline guarantee and its docs wording were updated to say exactly that. 6 new EditMode tests pin the passthrough contract (identity path, null-vs-throw for missing files, slash normalisation). The Android branch is device-verified separately (Quest build gate for this PR).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Symptom
On Android — which includes every Quest headset — no Sauti model loads at all. The docs (README "Privacy & offline-first",
docs/designer-guide/per-platform.md"File-system access",docs/developer-guide/architecture.md) all describe a copy-on-first-launch fromStreamingAssetstopersistentDataPath, but no such code exists anywhere in the plugin (this is the runtime half ofBUILD-001inmemory/todo.md). Any consumer targeting Quest getsFileNotFoundException: Kokoro ONNX model not foundfrom paths that look correct.Root cause
On Android,
Application.streamingAssetsPathis ajar:file://…!/assetsURL pointing inside the compressed APK.File.Exists,Directory.Exists, and ONNX Runtime's nativeInferenceSession(path)loader can't read it — the only access Android offers isUnityWebRequest. Everything TTS-side resolves paths withPath.Combine(Application.streamingAssetsPath, …)and reads them withSystem.IO, so the whole stack fails at the first file check.Fix
New runtime API —
SautiStreamingAssets.ResolveFileAsync(relativePath, required, ct)returns an absolute path readable bySystem.IO/native loaders on every platform:Application.streamingAssetsPath. Zero behaviour change.persistentDataPath/SautiAssets/<relativePath>viaUnityWebRequest+DownloadHandlerFile(the file goes straight to disk — never a whole-model managedbyte[]; the Kokoro ONNX alone is ~90 MB and Quest RAM is precious). Later launches hit the cache and return instantly.Application.versionand wiped on mismatch, so an app update never serves the previous release's models..partfile, thenFile.Move) and concurrent resolves of the same path share one in-flight copy.SautiSpeakergainsEnsureRunnerAsync(), awaited fromSpeakAsync, which resolves the profile's model, optional tokenizer, and the profile's voice.binbefore constructingKokoroTtsRunner— the runner itself stays Unity-free and untouched. The synchronousEnsureRunner()keeps working everywhere it worked before, and on Android now throws a directing error (useEnsureRunnerAsync/SpeakAsync) instead of a misleading file-not-found.One deliberate limitation: only the profile's own voice is copied, because Android cannot enumerate StreamingAssets directories (the APK has no directory entries). A different profile's voice copies on its own first use. If we ever want
AvailableVoiceIdsto be complete on device, that needs a build-time index file — left out until something needs it.Samples~/06-vr-quest-npc(the Quest sample) resolves its TTS paths through the new API.Deliberately out of scope
UnityWebRequestinternally (FileUtils.ReadFile), so GGML models need no Sauti-side copy.SautiKnowledgeConfig): left as-is pending the knowledge.db format rework (B2) — dead code either way today.BUILD-001) remains open.Offline guarantee
UnityWebRequestappearing inRuntime/contradicted the per-platform doc's "grep for WebRequest finds nothing" privacy claim, so that section now says exactly what's true: the request reads only the app's own APK via thejar:file://URL — no network URL, no endpoint, offline guarantee unchanged. README wording updated the same way.Verification
CopyRequiredfalse in Editor). Full Sauti suite (Sauti.Tests.Editor+Sauti.Tests.InstallGuard): 72/72 pass in a consuming project (Unity 6000.0.70f1), compile clean. Desktop TTS is a pure passthrough (same paths, same runner ctor), verified by the unchanged suite.FYI
Docs updated:
per-platform.md(file-system table + network section),architecture.md(both Android caveats), README privacy bullet, package CHANGELOG. Nopackage.jsonversion bump in the PR — same reasoning as #1/#2, versioning happens on merge.