A local speech-to-ASL accessibility tool. Converts live spoken English into animated ASL sign language displayed on a realistic 3D avatar.
Stack: Python FastAPI (port 8000) + React/Vite (port 5173) · AMD RX 7900 GRE (ROCm/gfx1100) · 100% local · zero cost
| Tool | Purpose | Install |
|---|---|---|
| ROCm | GPU runtime for AMD | amdgpu-install from repo.radeon.com |
| Ollama 0.18+ | gemma3:12b inference | ollama.com |
| whisper.cpp | Transcription (HIP build) | see below |
| uv | Python env/package manager | astral.sh/uv |
| bun | JS runtime/package manager | bun.sh |
Add these lines to /etc/environment:
HSA_OVERRIDE_GFX_VERSION=11.0.0
This tells ROCm to treat the RX 7900 GRE (gfx1100) as a supported target. Without it both Ollama and whisper.cpp fall back to CPU.
whisper.cpp must be built with the ROCm HIP backend enabled. A build without -DGGML_HIP=ON will silently use CPU and miss the 700 ms latency target by an order of magnitude.
cd whisper.cpp
rm -rf build && mkdir build && cd build
# Configure with HIP — use hipcc as the compiler
HSA_OVERRIDE_GFX_VERSION=11.0.0 cmake .. \
-DGGML_HIP=ON \
-DCMAKE_C_COMPILER=/opt/rocm/bin/hipcc \
-DCMAKE_CXX_COMPILER=/opt/rocm/bin/hipcc \
-DCMAKE_BUILD_TYPE=Release
cmake --build . --config Release -j$(nproc)
# Download the medium model (~1.5 GB)
cd ..
bash models/download-ggml-model.sh mediumVerify GPU use:
HSA_OVERRIDE_GFX_VERSION=11.0.0 ./build/bin/whisper-cli \
-m models/ggml-medium.bin \
-f samples/jfk.wav \
--no-timestampsExpected output includes whisper_backend_init_gpu: using device XXXXX (not no GPU found). Transcription must complete in under 1 second.
# Pull the model once
ollama pull gemma3:12b
# Verify GPU execution — response should appear in under 5 seconds
ollama run gemma3:12b "Say hello in one word"
# Confirm GPU (not CPU) in the process list
ollama psUses uv for fast, reproducible Python environments.
cd backend
# Create venv and install dependencies
uv venv
uv pip install -r requirements.txt
# Run (activate venv first, or prefix with uv run)
source .venv/bin/activate
uvicorn main:app --reload --port 8000Health check:
curl localhost:8000/health
# Expected: {"whisper":"ok","ollama":"ok","latency_ms":...}Uses bun for fast installs and dev server.
cd frontend
bun install
bun run dev # starts on :5173Open http://localhost:5173 — both status indicators must show green.
Phase 1 adds the full Spacebar push-to-talk path from browser microphone capture to backend transcription.
- Hold
Spacebarto start recording in the browser (MediaRecorder+getUserMedia). - Release
Spacebarto stop recording and send audio toPOST /api/transcribe. - Backend runs whisper.cpp and returns:
{ "text": "Good morning how are you", "latency_ms": 620.4 }- UI displays transcript text and latency bar values:
Transcription= whisper subprocess latencyRound-trip= client observed time from key release to response rendered
- Endpoint:
POST /transcribe - Request:
multipart/form-data, field namefile - Success:
{ "text": string, "latency_ms": number } - Error:
422with{ "detail": "..." }for whisper execution errors
With backend running on :8000:
curl -X POST http://localhost:8000/transcribe -F "file=@whisper.cpp/samples/jfk.wav"Expected: JSON with non-null latency_ms and a transcription string.
Phase 2 adds gloss translation and out-of-vocabulary (OOV) resolution between transcription and keypoint lookup.
-
POST /gloss:- Input:
{ "text": string } - Output:
{ "tokens": GlossToken[], "latency_ms": number } - Flow:
- Translate English text to ASL gloss with
gemma3:12b - Strip
<think>...</think>from model output - Annotate tokens against
backend/data/wlasl_vocabulary.json - If OOV exists, run constrained rewrite using top vocabulary list
- Translate English text to ASL gloss with
- Input:
-
POST /resolve_oov:- Input:
{ "tokens": string[], "oov_tokens": string[] } - Output:
{ "tokens": GlossToken[], "latency_ms": number }
- Input:
GlossToken shape:
{
"token": "WEATHER",
"status": "in_library | rewritten | fingerspell",
"original": "UNFORTUNATELY"
}usePipeline.jsnow exposes bothtranscribe(audioBlob)andgloss(text)- New
GlossPanelrenders persistent chips:- green:
in_library - yellow:
rewritten(tooltip shows original token) - red:
fingerspell
- green:
- App flow now chains
gloss()immediately after transcript is set
With backend running on :8000:
curl -s -X POST http://localhost:8000/gloss \
-H "Content-Type: application/json" \
-d '{"text": "I want to go to the store tomorrow"}' | python3 -m json.toolExpected: uppercase token chips with statuses and endpoint latency_ms.
Phase 3 adds the offline WLASL keypoint pipeline and live POST /keypoints lookup endpoint.
Use a separate uv project so heavy extraction dependencies do not impact backend runtime:
cd scripts
uv syncscripts/pyproject.toml dependencies:
mediapipeopencv-pythonnumpy
cd scripts
uv run python extract_keypoints.py <path/to/WLASL_v0.3.json> <path/to/wlasl/videos> [output_json]Default output is backend/data/wlasl_keypoints.json.
cd scripts
uv run python ingest_sqlite.py <path/to/wlasl_keypoints.json> [db_path]Default DB output is backend/data/wlasl_keypoints.db.
sqlite3 backend/data/wlasl_keypoints.db "SELECT frame_count FROM signs WHERE gloss='HELLO';"Expected: a number between 20 and 80.
-
POST /keypoints- Input:
{ "tokens": GlossToken[] } - Output:
{ "sequences": KeypointSequence[], "latency_ms": number } fingerspelltokens are expanded into letter sequences with 4 neutral frames between letters.
Phase 4 replaces the avatar placeholder with a live Three.js scene that loads a ReadyPlayerMe GLB and drives both arms with CCD IK.
cd frontend bun add three mathjsNotes:
- Import addons from
three/addons/...paths. mathjsis installed now for Phase 5 spline work.
- Place the ReadyPlayerMe export at
frontend/public/avatar.glb. - Vite serves this at
/avatar.glb.
frontend/src/components/AvatarScene.jsx- Owns all Three.js setup and render loop.
- Loads
/avatar.glbwithGLTFLoader.loadAsync. - Builds
CCDIKSolverchains for left and right arms. - Uses
ResizeObserveron the panel container. - Includes
DEV_IK_TESToval wrist target animation for gate validation.
frontend/src/lib/boneMap.js- Exports MediaPipe to ReadyPlayerMe bone mapping for pose and hand joints.
frontend/src/App.jsx- Replaces Phase 4 placeholder with
<AvatarScene />.
- Replaces Phase 4 placeholder with
cd frontend bun run dev- Gate 4.1: Confirm
renderer.info.render.frameincrements roughly 60 frames per second. - Gate 4.2: Avatar is visible with no console errors and required arm bone names are present.
- Gate 4.3: Hardcoded right wrist IK target moves wrist to the fixed location.
- Gate 4.4:
DEV_IK_TESToval motion moves the right arm smoothly without snapping.
- Input:
Phase 5 adds the full keypoint-driven playback path: gloss tokens now fetch keyframes, keyframes are upsampled with a natural cubic spline, transitions are inserted between signs, and AvatarScene consumes queued frames each render tick.
frontend/src/lib/spline.js- Natural cubic spline with
mathjslusolve(). upsample(values, targetCount)for scalar tracks.upsampleFrame(frames, targetCount)for pose + hand landmarks.buildTransitionFrames(framesA, framesB, transitionCount)using 3 tail + 3 head source frames.
- Natural cubic spline with
frontend/src/hooks/useAnimationQueue.jsAnimationQueuewithadd(),tick(),clear(), andisPlaying.- Module-level frozen
NEUTRAL_FRAME. - Transition insertion plus neutral buffer insertion for long wrist jumps.
frontend/src/components/AvatarScene.jsx- Accepts
queueRefprop. - Computes shoulder midpoint once after avatar load.
- Applies wrist IK targets and finger rotations from queue frames.
- Uses renderer
setAnimationLoop()andqueue.tick(deltaMs)playback.
- Accepts
frontend/src/hooks/usePipeline.js- Adds
fetchKeypoints(tokens)via/api/keypoints.
- Adds
frontend/src/App.jsx- Creates and passes animation queue ref.
- Clears queue on new push-to-talk result.
- Chains
gloss -> keypoints -> queue.add(...)for each resolved token.
cd frontend
bun run dev- Gate 5.1: HELLO sign plays and wrists track expected targets.
- Gate 5.2: spline-upsampled playback is visibly smoother than raw frame stepping.
- Gate 5.3: HELLO -> THANK-YOU plays without snapping at boundary.
- Gate 5.4: 5 queued signs play continuously without timing gaps.
| Phase | Description | Gate | Status |
|---|---|---|---|
| 0.1 | ROCm / GPU | rocminfo | grep gfx1100 |
Pass |
| 0.2 | Ollama + gemma3:12b | ollama ps shows GPU |
Pass |
| 0.3 | whisper.cpp HIP | transcription < 1s, GPU backend active | Rebuilding |
| 0.4 | FastAPI /health |
curl :8000/health returns ok |
Pass |
| 0.5 | React status panel | :5173 shows green dots |
Pass |
| 2.1 | Gloss translation | /gloss returns clean uppercase tokens |
In Progress |
| 2.2 | OOV detection | OOV words are non-green statuses | In Progress |
| 2.3 | OOV rewrite | /resolve_oov rewrites to known tokens when possible |
In Progress |
| 2.4 | Visual chips | Gloss chip colors and tooltip render in UI | In Progress |
| 3.1 | Sign library ingest | HELLO frame count is in 20-80 | In Progress |
[Browser :5173]
Spacebar held → MediaRecorder → WAV blob
Spacebar up → POST /transcribe → English text
→ POST /gloss → ASL gloss chips (green/yellow/red)
→ POST /keypoints → keyframe arrays
→ cubic spline → animation queue
Three.js loop → CCDIKSolver drives avatar bones @ 60fps
[FastAPI :8000]
/health → binary check + ollama list
/transcribe → whisper.cpp subprocess → text + latency_ms
/gloss → gemma3:12b via Ollama → gloss tokens (strip_thinking applied)
/resolve_oov → gemma3:12b semantic rewrite with vocab constraint
/keypoints → SQLite lookup → keyframe arrays
| Layer | Target |
|---|---|
| Whisper (transcription) | ≤ 700 ms |
| gemma3:12b (gloss) | ≤ 800 ms |
| gemma3:12b (OOV rewrite) | ≤ 600 ms |
| SQLite keypoint lookup | ≤ 50 ms |
| Total to first sign | ≤ 2.5 s |