Skip to content

Android/Quest: copy models out of the APK on first use (StreamingAssets copy-out) - #3

Open
BernardMasika wants to merge 1 commit into
mainfrom
fix/android-copyout
Open

Android/Quest: copy models out of the APK on first use (StreamingAssets copy-out)#3
BernardMasika wants to merge 1 commit into
mainfrom
fix/android-copyout

Conversation

@BernardMasika

Copy link
Copy Markdown

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 from StreamingAssets to persistentDataPath, but no such code exists anywhere in the plugin (this is the runtime half of BUILD-001 in memory/todo.md). Any consumer targeting Quest gets FileNotFoundException: Kokoro ONNX model not found from paths that look correct.

Root cause

On Android, Application.streamingAssetsPath is a jar:file://…!/assets URL pointing inside the compressed APK. File.Exists, Directory.Exists, and ONNX Runtime's native InferenceSession(path) loader can't read it — the only access Android offers is UnityWebRequest. Everything TTS-side resolves paths with Path.Combine(Application.streamingAssetsPath, …) and reads them with System.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 by System.IO/native loaders on every platform:

  • Desktop / iOS / any Editor: passthrough to Application.streamingAssetsPath. Zero behaviour change.
  • Android player: streams the APK entry to persistentDataPath/SautiAssets/<relativePath> via UnityWebRequest + DownloadHandlerFile (the file goes straight to disk — never a whole-model managed byte[]; the Kokoro ONNX alone is ~90 MB and Quest RAM is precious). Later launches hit the cache and return instantly.
  • The cache root is stamped with Application.version and wiped on mismatch, so an app update never serves the previous release's models.
  • Copies are atomic-ish (.part file, then File.Move) and concurrent resolves of the same path share one in-flight copy.

SautiSpeaker gains EnsureRunnerAsync(), awaited from SpeakAsync, which resolves the profile's model, optional tokenizer, and the profile's voice .bin before constructing KokoroTtsRunner — the runner itself stays Unity-free and untouched. The synchronous EnsureRunner() keeps working everywhere it worked before, and on Android now throws a directing error (use EnsureRunnerAsync/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 AvailableVoiceIds to 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

  • STT: whisper.unity already reads Android StreamingAssets via UnityWebRequest internally (FileUtils.ReadFile), so GGML models need no Sauti-side copy.
  • LLM: LLMUnity manages its own model paths and Android handling.
  • RAG paths (SautiKnowledgeConfig): left as-is pending the knowledge.db format rework (B2) — dead code either way today.
  • Build-time model subsetting (the other half of BUILD-001) remains open.

Offline guarantee

UnityWebRequest appearing in Runtime/ 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 the jar:file:// URL — no network URL, no endpoint, offline guarantee unchanged. README wording updated the same way.

Verification

  • 6 new EditMode tests pin the passthrough contract (identity path for existing files, null-vs-throw for missing, slash normalisation, CopyRequired false 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.
  • The Android copy branch can only be proven on device; that on-Quest run is this PR's acceptance gate before merge (same policy as fix(stt): ship GGML Whisper models — the shipped ONNX exports can never load #2).

FYI

Docs updated: per-platform.md (file-system table + network section), architecture.md (both Android caveats), README privacy bullet, package CHANGELOG. No package.json version bump in the PR — same reasoning as #1/#2, versioning happens on merge.

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).
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.

1 participant