MUDRA+ is a prototype on-device ASL input accelerator: a small, high-accuracy sign vocabulary plus autocomplete, so a short spoken sentence costs a few signs instead of a whole sentence of them. Hand tracking, sentence composition, transcription and memory all run on the phone — no server, no API key, no account.
The scope is deliberately narrow, and worth stating before any of the engineering.
The system has no opinion about grammar it hasn't been shown. It proposes candidate sentences for a signer's own recurring phrases; the person chooses one, every time, before anything is spoken.
This doesn't attempt it. The design assumes a person driving a tool for their own words, with a confirmation step they control — not a machine speaking on anyone's behalf.
Five bundled single-hand static poses, plus whatever the user records. The interesting question isn't vocabulary size — it's how far a reliable vocabulary gets you when autocomplete carries the rest.
Every figure on this page is an engineering benchmark. The ergonomics — hold duration, the capture gate, whether the candidate sentences are worth choosing between — are the next thing to evaluate, with signers.
Signing a full English sentence is expensive twice over: physically, and statistically.
If per-sign recognition accuracy is p, the chance an n-sign
sentence survives end to end is roughly pn. The exponent is the
problem:
| Signs needed | p = 0.95 | p = 0.90 |
|---|---|---|
| 8 — full sentence | 0.66 | 0.43 |
| 5 | 0.77 | 0.59 |
| 3 — gloss key + autocomplete | 0.86 | 0.73 |
A model of the failure mode, not a measurement — but it is why the architecture is shaped this way.
Don't sign the sentence — sign the key. ME NEED METFORMIN is enough to
retrieve or generate the full phrasing, so the recognised sequence stays short.
A small set of distinct, well-separated poses is far easier to get right than a large
one full of near-collisions — and a rejection threshold means the system answers
UNKNOWN rather than guessing.
A classifier's output layer is fixed to the vocabulary it was trained on. Adding one sign means collecting data, retraining, re-exporting and shipping a new model — incompatible with the thing this demo most wants to test: a user adding their own sign in a second and using it on the next frame. So detection for the demo is geometric:
21 MediaPipe landmarks
→ normalise around the wrist (translation- and scale-invariant)
→ 72-dimension feature vector
→ RMS Euclidean distance to every template
→ nearest match, or UNKNOWN above τ = 0.42
sign-embeddings/ implements the fallback — reuse an internal layer of a
trained TFLite model as an embedding extractor, match by cosine similarity — for when
template matching runs out of headroom. Built and tested, not wired in, because the demo
doesn't need it.
Glosses aren't English: no articles, no tense, no register. That's a language task, so a language model does it rather than the vision path. But the LLM is the slowest and hottest component, so the architecture minimises how often it runs — an exact sequence already confirmed returns from memory in about a microsecond, a sequence whose candidates the user has chosen from before returns from sentence memory, and only a genuinely new sequence reaches the 3B. That ordering is also what keeps the demo interactive under sustained camera load.
Every box runs on the phone. The only network traffic possible is a LAN socket to a second phone you pair yourself.
no-hand), hand-to-camera
distance (too-far), steadiness across the hold window
(unstable) — surfaced as "Move your hand closer", "Hold your hand steady",
rather than silently storing a bad template.Android exposes no public API for injecting audio into a cellular call, which constrains the whole output stage. Two routes are implemented:
| Route | How | Trade-off |
|---|---|---|
| Acoustic coupling | Speakerphone; the call's own open mic picks up the TTS output | Works on any carrier call with no infrastructure; lower quality, and handset echo cancellation can suppress it |
| LAN relay | zeroconf/mDNS discovery + TCP socket; sender transmits text, receiver speaks it and streams a transcript back | Clean audio, no cloud telephony; both phones must share a network |
Two stores, different jobs — one keyed on the gloss sequence, one on what the user actually picked.
Records which candidate the user picked per exact gloss sequence and resurfaces it ahead of fresh generation, as app-private JSON with atomic writes. The Memory screen reviews and forgets entries. A sequence can legitimately mean different things on different occasions, so picking a different sentence later adds an option rather than replacing the earlier one — and once a sequence has enough remembered, the LLM isn't called for it again.
A standalone retrieval engine implemented twice, Kotlin (Room/SQLite) and TypeScript, for corpora larger than a JSON file should hold. Dependency-free and independently tested.
Float64Array; tokens in >60 % of
memories are skipped unless the query has nothing else; unknown glosses get single-edit
repair (METFORMIM → METFORMIN). The top 48 candidates are then scored
0.45·weightedJaccard + 0.25·queryCoverage + 0.30·lcsRatio — the LCS term makes
sign order count. Ties break deterministically.The practical effect: the slow path is a one-time cost per phrase. Repeat use of the same phrase is a hash lookup, which is also what keeps the phone cool under sustained camera load.
One vision model, one language model, one speech model — and the component doing sign matching isn't a model at all.
| Model | Artifact | Runtime | Role |
|---|---|---|---|
| MediaPipe Hand Landmarker | hand_landmarker.task |
MediaPipe Tasks, native, GPU-delegated | 21 3-D landmarks per frame — the only vision model in the live path |
| Qwen2.5-3B-Instruct | qwen2.5-3b-instruct-q4_k_m.gguf · ~2.0 GB |
llama.rn (llama.cpp), CPU/GPU |
Gloss sequence → candidate sentences; contextual replies |
| Whisper tiny | ggml-tiny.bin · ~75 MB |
whisper.rn (whisper.cpp) |
On-device transcription of the other party |
| Android TTS | system voices | react-native-tts |
Speaks the confirmed sentence |
| Gesture templates | custom_gestures.json + user file |
pure TypeScript — no model | 21 landmarks + 72 features per sign; 5 bundled, user-extensible at runtime |
| Embedding extractor not wired in | sign_embed.tflite |
TFLite | Cosine-similarity custom-sign path in sign-embeddings/ |
No cloud inference: there is no OpenAI, Anthropic or Gemini key in the live app. GGUF choice
per RAM tier (1.5B at 6 GB, 3B at 8 GB, 7B at 12 GB+) is documented in
llm-testbed/, the harness used to benchmark candidates on the actual device.
Q4_K_M at ~2 GB fits comfortably in the iQOO 15's RAM, so the demo uses a 3B rather
than the 1.5B originally planned, offloaded with n_gpu_layers: 99.
The memory layer opens a PerformanceHintManager session per lookup thread,
declaring a ~2 ms target. On a big.LITTLE part a microsecond-scale burst otherwise
finishes on an efficiency core before a reactive governor reacts.
The write-behind thread runs at THREAD_PRIORITY_BACKGROUND so SQLite never
contends with the camera pipeline for prime cores. Room opens WAL with
synchronous = NORMAL on its own executor.
Continuous camera plus continuous LLM is what heats the phone, so the design optimises for fewer LLM invocations rather than faster ones — which is what both memory stores are for.
Stated plainly, because it is easy to overclaim: inference is configured with
n_gpu_layers: 99 and the Android build ships llama.cpp's Hexagon HTP
libraries, but whether the HTP backend actually engages depends on the
llama.rn build and the device — so treat NPU offload as
available-but-unverified, not a measured win. OriginOS performance-mode toggles aren't
app-accessible APIs either; ADPF is the supported route to the same scheduler.
Completed at the end of the hackathon. Demo-quality, and specific about which parts are which.
| Area | State | Detail |
|---|---|---|
| Hand tracking, template matching, runtime sign capture | working | Real camera, native MediaPipe plugin, on-device matching |
| Candidate generation (Qwen2.5-3B) | working | Bundled for the release build |
| Translation memory, both ports | working | 46 tests; flat-scaling lookups measured |
| Sentence memory | working | Remembers which candidate was picked, per sequence |
| LAN relay, transcription, TTS | working | Real connection state; mic pauses while speaking |
| Acoustic coupling into a carrier call | caveat | Subject to handset echo cancellation |
| Vocabulary | by design | 5 bundled static single-hand poses + user recordings |
| Two-handed / motion signs | not implemented | Next on the list |
| Embedding-based custom signs | built, unused | Module tested; template matching suffices today |
| Cloud telephony | non-goal | Deliberately not built |
sign-embeddings if template matching saturates.A physical Android device is required — the camera path can't be demoed on an emulator.
Node 18+, JDK 17, Android SDK 35, NDK 26.1.10909125, a device on Android 7.0+ with USB debugging.
The hand landmarker and Whisper tiny are committed to the repo. Only the ~2 GB
.gguf has to be fetched.
Signing needs the camera, the receive side needs the mic. Nothing is recorded or uploaded either way.
git clone https://github.com/xreedev/mudra.git
cd mudra/app
npm install
# one-time: the LLM weights (Qwen2.5-3B-Instruct Q4_K_M, from Hugging Face)
adb shell mkdir -p /sdcard/Android/data/com.mudraapp/files/models
adb push qwen2.5-3b-instruct-q4_k_m.gguf /sdcard/Android/data/com.mudraapp/files/models/
npm start # Metro
npm run android # build + install
For a release APK the model is bundled instead of pushed — drop it into
android/app/src/main/assets/models/ and the app extracts it on first launch.
On Windows, scripts/build-release-with-llm.ps1 automates that, plus the
MAX_PATH and asset-compression workarounds a multi-GB asset needs.
Both phones on the same Wi-Fi: one opens Call, the other
Receive. Discovery is zeroconf/mDNS (aslrelay), the session runs
over TCP on port 12345. Guest networks with client isolation block it — a phone hotspot is the
reliable fallback. Without a relay it still works on one phone, over speakerphone.
Each builds and tests on a laptop — no emulator, no device, no network:
cd memory-layer && ./gradlew :core:test # Kotlin engine, no Android SDK needed
cd memory-layer-rn && npm install && npm test
cd sign-embeddings && npm install && npm test
| Path | Contents |
|---|---|
app/ | The React Native app — camera, recognition, LLM, speech, relay, five screens |
app/src/recognition/ | Matcher, capture gate, duplicate detection, user gesture store, live-hand hooks |
app/src/llm/ | Provider interface, llama.rn provider, prompts, sentence memory |
app/src/speech/ | TTS, voice recorder, Whisper transcriber |
app/src/relay/ | zeroconf discovery, TCP sender/receiver |
app/android/.../recognition/ | Native MediaPipe frame-processor plugin |
memory-layer/, memory-layer-rn/ | The translation-memory engine, Kotlin and TypeScript |
sign-embeddings/ | TFLite-embedding custom-sign path (built, not wired in) |
llm-testbed/ | Standalone app for benchmarking GGUFs on the device |
Full setup, troubleshooting and the technical detail behind every claim on this page is in the README.