mohrashid commited on
Commit
42d1288
·
verified ·
1 Parent(s): 05aa64d

Upload 49 files

Browse files
DEPLOY.md ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploy Guide: Render (Backend) + Vercel (Frontend)
2
+
3
+ A complete, copy-paste guide to launch this project using free tiers.
4
+
5
+ - Frontend: React + Vite → Vercel (static site)
6
+ - Backend: FastAPI + Transformers → Render (free web service)
7
+
8
+ ---
9
+
10
+ ## 1) Prerequisites
11
+
12
+ - GitHub repo: https://github.com/rashid714/updated-translator
13
+ - Accounts: Render (free), Vercel (free)
14
+
15
+ Optional (local dev): Node.js 18+, Python 3.11 or 3.13
16
+
17
+ ---
18
+
19
+ ## 2) Local run (optional)
20
+
21
+ ```bash
22
+ # Python env
23
+ python3 -m venv .venv
24
+ source .venv/bin/activate
25
+ pip install -r server/requirements.txt
26
+
27
+ # Frontend deps
28
+ npm install
29
+
30
+ # Start both (Vite + FastAPI)
31
+ npm run dev:all
32
+ # Vite: http://localhost:5174
33
+ # API: http://127.0.0.1:8000 (GET /health)
34
+ ```
35
+
36
+ Tip: In Host view, click "Preload Model" once to warm the translator.
37
+
38
+ ---
39
+
40
+ ## 3) Backend on Render (Free)
41
+
42
+ Render builds and hosts the FastAPI app. Free plan sleeps when idle; next request wakes it.
43
+
44
+ Two choices:
45
+
46
+ ### A) Blueprint (recommended)
47
+
48
+ This repo includes `render.yaml` and `runtime.txt`.
49
+
50
+ 1. Render → New → Blueprint → Connect repo → choose `updated-translator` → Apply
51
+ 2. Wait for deploy. First translation may take longer while weights download.
52
+
53
+ What it sets:
54
+ - Build: `pip install -r server/requirements.txt`
55
+ - Start: `uvicorn server.app:app --host 0.0.0.0 --port $PORT`
56
+ - Health Check: `/health`
57
+ - Python: 3.11.9 (via runtime.txt)
58
+
59
+ ### B) Manual Web Service
60
+
61
+ 1. New → Web Service → connect repo → Branch `main`
62
+ 2. Root: `.` | Runtime: Python
63
+ 3. Build:
64
+ ```bash
65
+ pip install -r server/requirements.txt
66
+ ```
67
+ 4. Start:
68
+ ```bash
69
+ uvicorn server.app:app --host 0.0.0.0 --port $PORT
70
+ ```
71
+ 5. Health Check Path: `/health` | Plan: Free
72
+
73
+ After deploy, test:
74
+ ```bash
75
+ curl -sS https://<service>.onrender.com/health
76
+ # -> {"status":"ok"}
77
+ ```
78
+
79
+ Notes:
80
+ - Cold start: free services sleep when idle; first request wakes them.
81
+ - First translation after deploy may download model weights; subsequent calls are fast.
82
+
83
+ ---
84
+
85
+ ## 4) Frontend on Vercel (Free)
86
+
87
+ 1. New Project → Import GitHub → `updated-translator`
88
+ 2. Framework: Vite
89
+ 3. Build Command: `npm run build` | Output: `dist`
90
+ 4. Environment Variables:
91
+ - `VITE_API_URL = https://<service>.onrender.com`
92
+ 5. Deploy. Open your Vercel URL (e.g., `https://updated-translator.vercel.app`).
93
+
94
+ ---
95
+
96
+ ## 5) Troubleshooting
97
+
98
+ - Tokenizers/torch build errors on Render
99
+ - Keep Python 3.11 (runtime.txt). `server/requirements.txt` pins versions with prebuilt wheels.
100
+
101
+ - 502/timeout on first call
102
+ - Likely cold start / first-time model download. Wait, then retry. Use “Preload Model”.
103
+
104
+ - CORS errors
105
+ - `server/app.py` enables permissive CORS for demo. If you tighten it, add your Vercel domain.
106
+
107
+ - Speech recording not working
108
+ - Some browsers don’t support Web Speech API. Use Manual Input fallback.
109
+
110
+ - Duplicate translation shown
111
+ - App suppresses translations equal to the original and avoids saving duplicates.
112
+
113
+ ---
114
+
115
+ ## 6) Optional: Single-service Docker on Render (frontend + API + Whisper)
116
+
117
+ If you prefer one Render service that serves both the UI and the API (and enables Whisper STT), switch to the Docker blueprint:
118
+
119
+ 1. Ensure your Render blueprint `render.yaml` has:
120
+
121
+ ```
122
+ services:
123
+ - type: web
124
+ name: updated-translator
125
+ env: docker
126
+ dockerfilePath: ./Dockerfile
127
+ healthCheckPath: /api/health
128
+ envVars:
129
+ - key: STT_ENABLED
130
+ value: "1"
131
+ - key: WHISPER_MODEL
132
+ value: "tiny"
133
+ ```
134
+
135
+ 2. Deploy via Render → New → Blueprint → select repo.
136
+
137
+ 3. Verify:
138
+
139
+ ```
140
+ curl -sS https://<service>.onrender.com/api/health
141
+ curl -sS https://<service>.onrender.com/api/provider
142
+ ```
143
+
144
+ Frontend will call same-origin `/api/*` automatically. Upload control for audio appears when STT is available.
145
+
146
+ ---
147
+
148
+ ## 7) Useful endpoints
149
+
150
+ - `GET /api/health` → `{ "status": "ok" }`
151
+ - `POST /api/translate`
152
+ - Body: `{ "text": "...", "source_language": "ur", "target_language": "en" }`
153
+ - Response: `{ "translated_text": "..." }`
154
+ - `POST /api/preload`
155
+ - Body: `{ "source_language": "ur", "target_language": "en" }`
156
+ - Response: `{ "ok": true, "model": "Helsinki-NLP/opus-mt-ur-en" }`
157
+
158
+ ---
159
+
160
+ That’s it—Render for the API, Vercel for the UI, both on free plans. If you prefer Hugging Face Spaces for the backend, I can add a Dockerfile-based Space setup as well.
DEPLOY_HF.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploy to Hugging Face Spaces (Docker)
2
+
3
+ This Space bundles the React frontend and FastAPI backend into a single container so you can share a link publicly for free.
4
+
5
+ ## Prerequisites
6
+ - Hugging Face account: https://huggingface.co/join
7
+ - Create a new Space (type: Docker)
8
+
9
+ ## Files in this repo that matter
10
+ - `Dockerfile` – multi-stage build: builds the Vite app and runs FastAPI with Uvicorn
11
+ - `server/requirements.txt` – Python deps (Transformers, Torch, FastAPI, etc.)
12
+ - `server/app.py` – API (`/health`, `/translate`, `/preload`) and serves the built frontend
13
+ - `src/*` – React app; `src/lib/api.ts` defaults to same-origin API
14
+
15
+ ## Steps
16
+ 1. Create a new Space
17
+ - Choose "Docker" as SDK
18
+ - Name it, e.g., `your-username/live-translator`
19
+ 2. Push the project files to the Space
20
+ - Copy this repository’s files into the Space or add it as a remote and push
21
+ - On the Space page, set the description by pasting the contents of `SPACE_README.md` from this repo into the Space README
22
+ 3. Configure Space secrets and vars (optional but recommended)
23
+ - Core:
24
+ - `STT_ENABLED=1` – enable server-side transcription
25
+ - `WHISPER_MODEL=small` – CPU-friendly (use `medium` for better accuracy; `large-v3` requires GPU)
26
+ - Optional:
27
+ - `ALLOWED_ORIGINS` – comma-separated CORS origins (defaults to `*`)
28
+ - `MAX_TEXT_CHARS` – per-request limit (default `5000`)
29
+ - `REQUESTS_PER_MINUTE` – simple per-IP rate limit (default `60`)
30
+ - `TRANSLATION_PROVIDER` – `marian` (default) or `hf`
31
+ - `HUGGINGFACE_API_TOKEN` – required if using `TRANSLATION_PROVIDER=hf`
32
+ 4. Build starts automatically
33
+ - The Space will build the Docker image and start the app on `$PORT` (7860 by default)
34
+ - The frontend is served from `/` and API is available at `/translate`, `/preload`, `/health`
35
+ - Metrics at `/metrics` (Prometheus format)
36
+
37
+ ## Notes
38
+ - Initial requests may be slow while models download (cold start). Use the Preload button or call `/preload` to warm models.
39
+ - The free CPU tier is best-effort and may throttle under heavy traffic.
40
+ - For best transcription accuracy on free CPU, try `WHISPER_MODEL=medium` (slower). For maximum quality with lower latency, use a GPU Space and `large-v3`.
41
+ - For broader translation quality, consider the `hf` provider (e.g., NLLB-200), but expect higher latency on CPU.
42
+
43
+ ## Try locally
44
+ ```
45
+ # Build and run like HF Spaces would
46
+ docker build -t live-translator .
47
+ docker run -it --rm -p 7860:7860 live-translator
48
+ # then open http://localhost:7860
49
+ ```
Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-stage build for Hugging Face Spaces (Docker)
2
+
3
+ # --- Frontend build stage ---
4
+ FROM node:22-alpine AS frontend
5
+ WORKDIR /frontend
6
+ COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
7
+ # Prefer npm; if lockfile missing, npm will still install using package.json
8
+ RUN npm ci || npm install
9
+ COPY . .
10
+ RUN npm run build
11
+
12
+ # --- Backend runtime stage ---
13
+ FROM python:3.11-slim AS runtime
14
+ ENV PYTHONDONTWRITEBYTECODE=1 \
15
+ PYTHONUNBUFFERED=1 \
16
+ PIP_NO_CACHE_DIR=1 \
17
+ HF_HOME=/root/.cache/huggingface \
18
+ TRANSFORMERS_CACHE=/root/.cache/huggingface/transformers
19
+ WORKDIR /app
20
+
21
+ # System deps (git and curl handy for troubleshooting; remove if you want smaller image)
22
+ RUN apt-get update && apt-get install -y --no-install-recommends \
23
+ git curl ffmpeg && \
24
+ rm -rf /var/lib/apt/lists/*
25
+
26
+ # Install Python deps
27
+ COPY server/requirements.txt ./server/requirements.txt
28
+ RUN python -m pip install --upgrade pip && \
29
+ pip install --no-cache-dir -r server/requirements.txt
30
+
31
+ # Copy backend and built frontend
32
+ COPY server ./server
33
+ COPY --from=frontend /frontend/dist ./dist
34
+
35
+ # Preload all direct MarianMT models for instant demo reliability
36
+ RUN python3 -c "from transformers import pipeline; models=['Helsinki-NLP/opus-mt-en-hi','Helsinki-NLP/opus-mt-hi-en','Helsinki-NLP/opus-mt-en-zh','Helsinki-NLP/opus-mt-zh-en','Helsinki-NLP/opus-mt-en-ms','Helsinki-NLP/opus-mt-ms-en','Helsinki-NLP/opus-mt-en-id','Helsinki-NLP/opus-mt-id-en','Helsinki-NLP/opus-mt-en-ar','Helsinki-NLP/opus-mt-ar-en','Helsinki-NLP/opus-mt-en-ur','Helsinki-NLP/opus-mt-ur-en']; [pipeline('translation', model=m, device=-1) for m in models]"
37
+
38
+ # Optional: install STT dependencies (Whisper) to enable /transcribe when STT_ENABLED=1
39
+ RUN pip install --no-cache-dir -r server/requirements-stt.txt || true
40
+
41
+ # Default port for HF Spaces is provided via PORT env
42
+ ENV PORT=7860
43
+ EXPOSE 7860
44
+
45
+ # Use 0.0.0.0 to be externally reachable in container
46
+ CMD ["/bin/sh", "-lc", "python -m uvicorn server.app:app --host 0.0.0.0 --port ${PORT:-7860}"]
README.md CHANGED
@@ -1,10 +1,124 @@
1
  ---
2
- title: Livetranslator
3
- emoji: 👁
4
- colorFrom: indigo
5
- colorTo: gray
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Live Translator
3
+ emoji: 🌍
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
+ # Live Translator (FastAPI + React)
11
+
12
+ This Space serves a production build of the React frontend and a FastAPI backend in one container. No external API tokens are required. Models download on first use and are cached.
13
+
14
+ ## How it works
15
+ - Frontend: Vite/React compiled to `dist/` (built in the Docker image)
16
+ - Backend: FastAPI (Uvicorn) exposes `/translate`, `/preload`, `/health` and serves the static `dist/`
17
+ - Same-origin requests in production, so no CORS/issues
18
+
19
+ ## Endpoints
20
+ - `GET /health` – basic health
21
+ - `POST /translate` – { text, source_language, target_language }
22
+ - `POST /preload` – warm up models for a language pair
23
+
24
+ ## Notes
25
+ - First translation for a pair may take up to ~60s while models download/warm.
26
+ - For Malay→English, the app auto-tries `ms/en`, `id/en`, and English-bridge fallbacks.
27
+ ---
28
+ title: Live Translator (Docker)
29
+ emoji: 🗣️
30
+ colorFrom: blue
31
+ colorTo: indigo
32
+ sdk: docker
33
+ pinned: false
34
+ ---
35
+
36
+ # Live Translation (Full Stack)
37
+
38
+ [![Deploy to Hugging Face Spaces](https://img.shields.io/badge/Deploy%20to-Hugging%20Face%20Spaces-yellow?logo=huggingface)](https://huggingface.co/new-space)
39
+
40
+ Use the button to create a Docker Space, then follow the quick steps below (or see DEPLOY_HF.md). You do not need Vercel when deploying on Spaces (the container serves frontend + backend together).
41
+
42
+ All-in-one app for live transcription and translation:
43
+ - Frontend: React + Vite (static files)
44
+ - Backend: FastAPI (translation, optional transcription, metrics)
45
+ - One Docker image serves both frontend and API
46
+
47
+ ## Run locally (frontend only)
48
+
49
+ ```bash
50
+ npm install
51
+ npm run dev
52
+ ```
53
+
54
+ ## Build (frontend)
55
+
56
+ ```bash
57
+ npm run build
58
+ ```
59
+ The static output is generated in `dist/`.
60
+
61
+ ## Zero-cost deploy (Hugging Face Spaces — Docker)
62
+
63
+ The Dockerfile builds the frontend and runs the FastAPI backend, serving both from one URL.
64
+
65
+ 1) Create a new Space
66
+ - Type: Docker • Visibility: Public
67
+
68
+ 2) Push this repo to the Space
69
+ - Ensure the root contains: `Dockerfile`, `server/`, `src/`, `index.html`, `vite.config.ts`.
70
+
71
+ 3) Set variables (Space Settings → Variables)
72
+ - `STT_ENABLED=1`
73
+ - `WHISPER_MODEL=small` (CPU-friendly; use `medium` for better accuracy if latency is OK)
74
+ - Optional: `REQUESTS_PER_MINUTE=60`, `ALLOWED_ORIGINS=*`
75
+
76
+ 4) Space page description
77
+ - Copy the contents of `SPACE_README.md` into your Space “README.md” (Settings → Files) so visitors see simple usage instructions.
78
+
79
+ 4) Build & run
80
+ - Spaces will build and start the app. First requests download models.
81
+ - Use the Host view “Preload Model” to warm caches.
82
+
83
+ 5) Use it
84
+ - Open your Space URL (frontend + API on the same origin).
85
+ - Host view: Start Recording (sentence-level), Upload audio (with Language hint), Preload model.
86
+
87
+ Metrics: GET `/metrics` (Prometheus format).
88
+
89
+ ## Notes
90
+ - Routing uses URL hash (e.g., `#/host/123`), so no special rewrites are needed.
91
+ - To reset local data, clear these keys in DevTools > Application > Local Storage:
92
+ - `lt_groups`, `lt_group_members`, `lt_segments`
93
+ - Temporary auth bypass is enabled in `src/contexts/AuthContext.tsx` (toggle `BYPASS_AUTH`).
94
+
95
+ ## Optional local translation service (Python)
96
+
97
+ For live translation using small MarianMT models, run a local FastAPI server:
98
+
99
+ 1) Create and activate a virtual environment (recommended):
100
+
101
+ ```bash
102
+ python3 -m venv .venv
103
+ source .venv/bin/activate
104
+ ```
105
+
106
+ 2) Install requirements and start the server:
107
+
108
+ ```bash
109
+ pip install -r server/requirements.txt
110
+ uvicorn server.app:app --reload --host 127.0.0.1 --port 8000
111
+ ```
112
+
113
+ 3) Point the frontend to it by creating a `.env` file:
114
+
115
+ ```bash
116
+ cp .env.example .env
117
+ ```
118
+
119
+ Supported pairs include en<->es, en<->fr, en<->de, en<->ar, en<->ur, en<->hi, en<->zh via Helsinki-NLP MarianMT, with robust Malay fallbacks.
120
+ If the server isn’t running or a pair isn’t supported, the app gracefully degrades.
121
+
122
+ ## Alternative free split (Render + Vercel)
123
+
124
+ See `DEPLOY.md` for a step-by-step guide to host the API on Render and the frontend on Vercel. Set `VITE_API_URL` in Vercel to your Render service URL.
SPACE_README.md ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Live Translation App
2
+
3
+ This Space serves both the frontend (React + Vite build) and backend (FastAPI) from a single Docker container.
4
+
5
+ ## How to use
6
+
7
+ - Open the Space URL. The UI loads from `/` and the API is same-origin.
8
+ - Host view:
9
+ - Start Recording for sentence-level live transcription
10
+ - Upload audio for server-side transcription (use the Language hint for accuracy)
11
+ - Click "Preload Model" to warm the translator
12
+
13
+ ## Endpoints
14
+
15
+ - `GET /health` → `{ "status": "ok" }`
16
+ - `POST /translate` → `{ text, source_language, target_language }`
17
+ - `POST /preload` → `{ source_language, target_language }`
18
+ - `GET /metrics` → Prometheus metrics
19
+
20
+ ## Space variables (recommended)
21
+
22
+ - `STT_ENABLED=1` – enable server-side transcription
23
+ - `WHISPER_MODEL=small` – good for CPU (use `medium` for better accuracy; `large-v3` requires GPU)
24
+ - Optional: `REQUESTS_PER_MINUTE=60`, `ALLOWED_ORIGINS=*`
25
+
26
+ ## Notes
27
+
28
+ - First requests may download models. Use Preload to warm up.
29
+ - For best accuracy on fast or unclear audio, set `WHISPER_MODEL=medium` (CPU) or upgrade to a GPU Space and use `large-v3`.
eslint.config.js ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js';
2
+ import globals from 'globals';
3
+ import reactHooks from 'eslint-plugin-react-hooks';
4
+ import reactRefresh from 'eslint-plugin-react-refresh';
5
+ import tseslint from 'typescript-eslint';
6
+
7
+ export default tseslint.config(
8
+ { ignores: ['dist'] },
9
+ {
10
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
11
+ files: ['**/*.{ts,tsx}'],
12
+ languageOptions: {
13
+ ecmaVersion: 2020,
14
+ globals: globals.browser,
15
+ },
16
+ plugins: {
17
+ 'react-hooks': reactHooks,
18
+ 'react-refresh': reactRefresh,
19
+ },
20
+ rules: {
21
+ ...reactHooks.configs.recommended.rules,
22
+ 'react-refresh/only-export-components': [
23
+ 'warn',
24
+ { allowConstantExport: true },
25
+ ],
26
+ // Relax strictness for demo speed: allow 'any' and ignore unused vars.
27
+ '@typescript-eslint/no-explicit-any': 'off',
28
+ '@typescript-eslint/no-unused-vars': 'off',
29
+ },
30
+ }
31
+ );
index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Real-time Transcription Translation Streaming</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "vite-react-typescript-starter",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "server": "sh -c 'PY=\"./.venv/bin/python\"; \"$PY\" -m uvicorn server.app:app --host 127.0.0.1 --port 8000'",
9
+ "dev:all": "concurrently -k -n WEB,API -c blue,magenta \"npm run dev\" \"npm run server\"",
10
+ "build": "vite build",
11
+ "lint": "eslint .",
12
+ "preview": "vite preview",
13
+ "typecheck": "tsc --noEmit -p tsconfig.app.json"
14
+ },
15
+ "dependencies": {
16
+ "@supabase/supabase-js": "^2.57.4",
17
+ "lucide-react": "^0.344.0",
18
+ "react": "^18.3.1",
19
+ "react-dom": "^18.3.1"
20
+ },
21
+ "devDependencies": {
22
+ "@eslint/js": "^9.9.1",
23
+ "@types/react": "^18.3.5",
24
+ "@types/react-dom": "^18.3.0",
25
+ "@vitejs/plugin-react": "^4.3.1",
26
+ "autoprefixer": "^10.4.18",
27
+ "concurrently": "^9.2.1",
28
+ "eslint": "^9.9.1",
29
+ "eslint-plugin-react-hooks": "^5.1.0-rc.0",
30
+ "eslint-plugin-react-refresh": "^0.4.11",
31
+ "globals": "^15.9.0",
32
+ "postcss": "^8.4.35",
33
+ "tailwindcss": "^3.4.1",
34
+ "typescript": "^5.5.3",
35
+ "typescript-eslint": "^8.3.0",
36
+ "vite": "^5.4.2"
37
+ }
38
+ }
postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
render.yaml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: updated-translator
4
+ env: docker
5
+ plan: free
6
+ dockerfilePath: ./Dockerfile
7
+ autoDeploy: true
8
+ healthCheckPath: /api/health
9
+ envVars:
10
+ - key: REQUESTS_PER_MINUTE
11
+ value: "60"
12
+ - key: MAX_TEXT_CHARS
13
+ value: "5000"
14
+ - key: STT_ENABLED
15
+ value: "1"
16
+ - key: WHISPER_MODEL
17
+ value: "tiny"
runtime.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ python-3.11.9
server/README.md ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend (FastAPI) — Local Dev and Optional STT
2
+
3
+ This backend powers translation and (optionally) speech transcription.
4
+
5
+ ## Install (core)
6
+
7
+ Core features (translate, preload, health) work without system packages:
8
+
9
+ 1. Create/activate your virtualenv
10
+ 2. Install core dependencies:
11
+
12
+ ```
13
+ pip install -r server/requirements.txt
14
+ ```
15
+
16
+
17
+ ## Optional: Enable Hugging Face Inference API fallback
18
+
19
+ If local Marian models are unavailable or restricted, you can enable cloud translation fallback for world-class reliability:
20
+
21
+ 1. Get a Hugging Face API token: https://huggingface.co/settings/tokens
22
+ 2. Set the token in your environment:
23
+ - For local dev: `export HUGGINGFACE_API_TOKEN=your_token_here`
24
+ - For Docker: add to your Docker run command:
25
+ `-e HUGGINGFACE_API_TOKEN=your_token_here`
26
+ 3. The backend will automatically use the Hugging Face Inference API for translation if Marian is unavailable.
27
+
28
+ ## Optional: Enable Speech-to-Text (STT)
29
+
30
+ Two options:
31
+
32
+ 1) Production (recommended): Docker/Hugging Face Space
33
+
34
+ - The provided `Dockerfile` installs FFmpeg and STT deps and sets `STT_ENABLED=1`.
35
+ - Build and run:
36
+
37
+ ```
38
+ docker build -t live-translate .
39
+ docker run -p 7860:7860 -e PORT=7860 live-translate
40
+ ```
41
+
42
+ 2) Native macOS dev
43
+
44
+ - Install Homebrew and system prereqs:
45
+ - `brew install pkg-config ffmpeg`
46
+ - Install Python deps:
47
+ - `pip install -r server/requirements-stt.txt`
48
+ - Enable the endpoint:
49
+ - `export STT_ENABLED=1`
50
+
51
+ When STT is disabled, `/transcribe` returns 503 and the UI hides the upload control.
52
+
53
+ Whisper model size:
54
+
55
+ - Control via `WHISPER_MODEL` env: `tiny`, `base`, `small`, `medium`, `large-v3`.
56
+ - In Docker we default to `medium` for better accuracy. You can override:
57
+
58
+ ```
59
+ docker run -p 7860:7860 -e PORT=7860 -e WHISPER_MODEL=small live-translate
60
+ ```
61
+
62
+ ## Run
63
+
64
+ ```
65
+ uvicorn server.app:app --host 127.0.0.1 --port 8000 --reload
66
+ ```
67
+
68
+ Health check:
69
+
70
+ - GET http://127.0.0.1:8000/health => `{ "status": "ok" }`
71
+
72
+ ## Metrics (optional)
73
+
74
+ If `prometheus-client` is installed (it is in `requirements.txt`), the API exposes Prometheus metrics at:
75
+
76
+ - GET http://127.0.0.1:8000/metrics (content type `text/plain; version=0.0.4`)
77
+
78
+ Includes:
79
+
80
+ - `http_requests_total{method,path,status}` and `http_request_duration_seconds{method,path}`
81
+ - `translations_total{src,tgt,status}` and `translation_duration_seconds{src,tgt}`
82
+ - `preload_total{src,tgt,status}` and `preload_duration_seconds{src,tgt}`
83
+ - `transcribe_total{status}` and `transcribe_duration_seconds`
84
+
85
+ ## Frontend tip
86
+
87
+ In the Host view, you can select a “Language hint” for audio uploads. Choosing the correct source language improves transcription on fast or unclear speech.
88
+
89
+ ## Notes
90
+
91
+ - In production (e.g., Docker or HF Spaces), the image includes FFmpeg so STT can be enabled there without extra steps.
92
+ - If you prefer not to install Homebrew locally, skip STT; the frontend falls back to browser SpeechRecognition and manual input.
server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Make 'server' a package for reliable relative imports and better editor support.
server/app.py ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import logging
3
+ import time
4
+ import uuid
5
+ from fastapi import FastAPI, HTTPException, Request, UploadFile, File, Form, WebSocket, WebSocketDisconnect
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.staticfiles import StaticFiles
8
+ from fastapi.responses import PlainTextResponse
9
+ from pydantic import BaseModel
10
+ from typing import Optional, Tuple, List, Dict, Any
11
+ import tempfile
12
+ import os
13
+
14
+ from .config import settings
15
+ from .providers.base import TranslationProvider
16
+ from .providers.marian import MarianProvider
17
+ try:
18
+ from langdetect import detect # lightweight auto language detection
19
+ except Exception: # pragma: no cover
20
+ detect = None # type: ignore
21
+
22
+ app = FastAPI(title="Live Translation API", version="2.0")
23
+
24
+
25
+ # Prometheus metrics (optional)
26
+ try: # pragma: no cover - optional dependency
27
+ from prometheus_client import Counter, Histogram, CONTENT_TYPE_LATEST, generate_latest
28
+ METRICS_ENABLED = True
29
+ # HTTP level metrics
30
+ HTTP_REQUESTS = Counter(
31
+ "http_requests_total",
32
+ "Total HTTP requests",
33
+ labelnames=("method", "path", "status"),
34
+ )
35
+ HTTP_REQUEST_DURATION = Histogram(
36
+ "http_request_duration_seconds",
37
+ "HTTP request duration in seconds",
38
+ labelnames=("method", "path"),
39
+ buckets=(0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, float("inf")),
40
+ )
41
+ # Domain-specific metrics
42
+ TRANSLATIONS_TOTAL = Counter(
43
+ "translations_total",
44
+ "Number of translation requests",
45
+ labelnames=("src", "tgt", "status"),
46
+ )
47
+ TRANSLATION_DURATION = Histogram(
48
+ "translation_duration_seconds",
49
+ "Translation latency in seconds",
50
+ labelnames=("src", "tgt"),
51
+ buckets=(0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, float("inf")),
52
+ )
53
+ PRELOAD_TOTAL = Counter(
54
+ "preload_total",
55
+ "Number of model preload requests",
56
+ labelnames=("src", "tgt", "status"),
57
+ )
58
+ PRELOAD_DURATION = Histogram(
59
+ "preload_duration_seconds",
60
+ "Model preload latency in seconds",
61
+ labelnames=("src", "tgt"),
62
+ buckets=(0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, float("inf")),
63
+ )
64
+ TRANSCRIBE_TOTAL = Counter(
65
+ "transcribe_total",
66
+ "Number of audio transcription requests",
67
+ labelnames=("status",),
68
+ )
69
+ TRANSCRIBE_DURATION = Histogram(
70
+ "transcribe_duration_seconds",
71
+ "Transcription latency in seconds",
72
+ buckets=(0.25, 0.5, 1.0, 2.0, 5.0, 15.0, 30.0, float("inf")),
73
+ )
74
+ except Exception: # pragma: no cover - optional dependency
75
+ METRICS_ENABLED = False
76
+ Counter = Histogram = None # type: ignore
77
+
78
+ # Allow all origins for demo simplicity. Tighten for production.
79
+ # If using wildcard origins, disable credentials to satisfy CORS rules.
80
+ _allow_credentials = not (len(settings.allowed_origins) == 1 and settings.allowed_origins[0] == "*")
81
+ app.add_middleware(
82
+ CORSMiddleware,
83
+ allow_origins=settings.allowed_origins,
84
+ allow_credentials=_allow_credentials,
85
+ allow_methods=["*"],
86
+ allow_headers=["*"],
87
+ )
88
+
89
+
90
+ @app.middleware("http")
91
+ async def _request_id_and_metrics(request: Request, call_next):
92
+ # Assign a request ID and collect HTTP metrics
93
+ request_id = uuid.uuid4().hex
94
+ request.state.request_id = request_id
95
+ method = request.method
96
+ # Use path template to avoid high-cardinality label values
97
+ route = request.scope.get("route")
98
+ path_tmpl = getattr(route, "path", request.url.path)
99
+ started = time.perf_counter()
100
+ status_code = 500
101
+ try:
102
+ response = await call_next(request)
103
+ status_code = response.status_code
104
+ response.headers["X-Request-ID"] = request_id
105
+ return response
106
+ except Exception:
107
+ status_code = 500
108
+ raise
109
+ finally:
110
+ if METRICS_ENABLED:
111
+ try:
112
+ HTTP_REQUESTS.labels(method=method, path=path_tmpl, status=str(status_code)).inc()
113
+ HTTP_REQUEST_DURATION.labels(method=method, path=path_tmpl).observe(time.perf_counter() - started)
114
+ except Exception:
115
+ pass
116
+
117
+
118
+ class TranslateRequest(BaseModel):
119
+ text: str
120
+ source_language: str # use "auto" to enable auto-detect when available
121
+ target_language: str
122
+
123
+
124
+ class PreloadRequest(BaseModel):
125
+ source_language: str
126
+ target_language: str
127
+
128
+
129
+ def _make_provider() -> TranslationProvider:
130
+ from server.config import settings
131
+ # Use Marian by default, fallback to Hugging Face Inference API if configured
132
+ try:
133
+ marian = MarianProvider()
134
+ except Exception:
135
+ marian = None
136
+ # Hugging Face Inference API fallback (if token provided)
137
+ hf_api_token = getattr(settings, "hf_api_token", None)
138
+ if hf_api_token:
139
+ try:
140
+ from server.providers.hf_inference import HFInferenceProvider
141
+ hf = HFInferenceProvider(api_token=hf_api_token)
142
+ except Exception:
143
+ hf = None
144
+ else:
145
+ hf = None
146
+ class RouterProvider(TranslationProvider):
147
+ def supports(self, src, tgt):
148
+ return True
149
+ def preload(self, src, tgt):
150
+ if marian and marian.supports(src, tgt):
151
+ return marian.preload(src, tgt)
152
+ if hf:
153
+ return hf.preload(src, tgt)
154
+ return False
155
+ def translate(self, text, src, tgt, max_length=None):
156
+ # Try Marian first
157
+ if marian:
158
+ try:
159
+ result = marian.translate(text, src, tgt, max_length)
160
+ if not result.startswith("[No model"):
161
+ return result
162
+ except Exception:
163
+ pass
164
+ # Fallback to Hugging Face Inference API
165
+ if hf:
166
+ try:
167
+ return hf.translate(text, src, tgt, max_length)
168
+ except Exception:
169
+ pass
170
+ return f"[No model for {src}->{tgt}] {text}"
171
+ return RouterProvider()
172
+
173
+ provider: TranslationProvider = _make_provider()
174
+ _whisper_model = None
175
+ # STT route is disabled for local dev by default to avoid system deps.
176
+ # Production images (Docker/HF Spaces) can re-enable it with full dependencies installed.
177
+
178
+
179
+ @app.on_event("startup")
180
+ def _warmup_startup():
181
+ # Do warmup asynchronously so /health is fast
182
+ import threading
183
+
184
+ def _bg():
185
+ try:
186
+ provider.preload("ms", "en")
187
+ except Exception:
188
+ pass
189
+ try:
190
+ provider.preload("en", "ms")
191
+ except Exception:
192
+ pass
193
+ try:
194
+ provider.preload("id", "en")
195
+ except Exception:
196
+ pass
197
+ try:
198
+ provider.preload("en", "id")
199
+ except Exception:
200
+ pass
201
+ try:
202
+ provider.preload("mul", "en")
203
+ except Exception:
204
+ pass
205
+ try:
206
+ provider.preload("en", "mul")
207
+ except Exception:
208
+ pass
209
+ try:
210
+ provider.preload("ur", "en")
211
+ except Exception:
212
+ pass
213
+ # Lazy import faster_whisper
214
+ global _whisper_model
215
+ try:
216
+ from faster_whisper import WhisperModel # type: ignore
217
+ _whisper_model = WhisperModel(settings.whisper_model, device="cpu")
218
+ except Exception:
219
+ _whisper_model = None
220
+
221
+ threading.Thread(target=_bg, name="warmup", daemon=True).start()
222
+
223
+
224
+
225
+ class _RateLimiter:
226
+ # Very small in-memory rate limiter; good enough for demo on a single instance
227
+ def __init__(self, rpm: int) -> None:
228
+ self.rpm = max(1, rpm)
229
+ self._hits: Dict[str, List[float]] = {}
230
+
231
+ def allow(self, key: str) -> bool:
232
+ now = time.time()
233
+ window = now - 60.0
234
+ q = self._hits.setdefault(key, [])
235
+ # Drop old
236
+ while q and q[0] < window:
237
+ q.pop(0)
238
+ if len(q) >= self.rpm:
239
+ return False
240
+ q.append(now)
241
+ return True
242
+
243
+
244
+ _limiter = _RateLimiter(settings.requests_per_minute)
245
+
246
+
247
+ @app.get("/health")
248
+ @app.get("/api/health")
249
+ def health():
250
+ return {"status": "ok"}
251
+
252
+
253
+ @app.get("/provider")
254
+ @app.get("/api/provider")
255
+ def provider_info():
256
+ # Expose which translation backend is active for debugging
257
+ from server.config import settings as _s
258
+ return {"provider": _s.provider}
259
+
260
+
261
+ if METRICS_ENABLED:
262
+ @app.get("/metrics")
263
+ @app.get("/api/metrics")
264
+ def metrics(): # pragma: no cover - simple exposition
265
+ try:
266
+ payload = generate_latest()
267
+ return PlainTextResponse(payload, media_type=CONTENT_TYPE_LATEST)
268
+ except Exception as e:
269
+ raise HTTPException(status_code=500, detail=f"Metrics error: {e}")
270
+
271
+
272
+ @app.get("/stt_status")
273
+ @app.get("/api/stt_status")
274
+ def stt_status():
275
+ # Report if full STT is enabled (requires optional deps and flag)
276
+ available = False
277
+ try:
278
+ import importlib.util as _ils
279
+ has_multipart = _ils.find_spec("multipart") is not None
280
+ from server.config import settings as _s
281
+ available = bool(has_multipart and _s.stt_enabled)
282
+ except Exception:
283
+ available = False
284
+ return {"available": available}
285
+
286
+
287
+ @app.post("/translate")
288
+ @app.post("/api/translate")
289
+ def translate(req: TranslateRequest, request: Request):
290
+ # rudimentary rate limit per client
291
+ ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "?")
292
+ ip = ip.split(",")[0].strip()
293
+ if not _limiter.allow(f"tx:{ip}"):
294
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
295
+ text = req.text.strip()
296
+ src = req.source_language.lower()
297
+ tgt = req.target_language.lower()
298
+
299
+ # No translation needed
300
+ if not text:
301
+ return {"translated_text": ""}
302
+ if len(text) > settings.max_text_chars:
303
+ raise HTTPException(status_code=413, detail="Text too long")
304
+ if src == tgt and src != "auto":
305
+ return {"translated_text": text}
306
+
307
+ # Auto-detect source if requested and detector is available
308
+ if src == "auto" and detect is not None:
309
+ try:
310
+ src = detect(text) or src
311
+ except Exception:
312
+ pass
313
+
314
+ if src == tgt:
315
+ return {"translated_text": text}
316
+
317
+ if not provider.supports(src, tgt):
318
+ return {"translated_text": f"[No model for {src}->{tgt}] {text}"}
319
+
320
+ started = time.perf_counter()
321
+ _status_label = "ok"
322
+ try:
323
+ translated = provider.translate(text, src, tgt, max_length=max(64, len(text) + 8))
324
+ except Exception as e:
325
+ logging.exception("translation failed: %s", e)
326
+ _status_label = "error"
327
+ raise HTTPException(status_code=500, detail="Translation error")
328
+ finally:
329
+ elapsed_ms = (time.perf_counter() - started) * 1000
330
+ logging.info("translate %s->%s len=%d took=%.1fms", src, tgt, len(text), elapsed_ms)
331
+ if METRICS_ENABLED:
332
+ try:
333
+ TRANSLATIONS_TOTAL.labels(src=src, tgt=tgt, status=_status_label).inc()
334
+ TRANSLATION_DURATION.labels(src=src, tgt=tgt).observe(elapsed_ms / 1000.0)
335
+ except Exception:
336
+ pass
337
+ return {"translated_text": translated}
338
+
339
+
340
+ @app.post("/preload")
341
+ @app.post("/api/preload")
342
+ def preload(req: PreloadRequest, request: Request):
343
+ ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "?")
344
+ ip = ip.split(",")[0].strip()
345
+ if not _limiter.allow(f"pre:{ip}"):
346
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
347
+ src = req.source_language.lower()
348
+ tgt = req.target_language.lower()
349
+ if src == tgt:
350
+ # Nothing to preload if no translation required
351
+ return {"ok": True, "message": "no-op (src==tgt)"}
352
+ if not provider.supports(src, tgt):
353
+ return {"ok": False, "message": f"No model for {src}->{tgt}"}
354
+ started = time.perf_counter()
355
+ ok = provider.preload(src, tgt)
356
+ elapsed_s = time.perf_counter() - started
357
+ if METRICS_ENABLED:
358
+ try:
359
+ PRELOAD_TOTAL.labels(src=src, tgt=tgt, status="ok" if ok else "skip").inc()
360
+ PRELOAD_DURATION.labels(src=src, tgt=tgt).observe(elapsed_s)
361
+ except Exception:
362
+ pass
363
+ return {"ok": ok}
364
+
365
+
366
+ # Conditionally register STT endpoint
367
+ import importlib.util as _ils
368
+ _has_multipart = _ils.find_spec("multipart") is not None
369
+ if settings.stt_enabled and _has_multipart:
370
+ @app.post("/transcribe")
371
+ @app.post("/api/transcribe")
372
+ async def transcribe(request: Request, audio: UploadFile = File(...), language: Optional[str] = Form(None)):
373
+ ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "?")
374
+ ip = ip.split(",")[0].strip()
375
+ if not _limiter.allow(f"stt:{ip}"):
376
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
377
+
378
+ # Ensure we refer to the module-level cache
379
+ global _whisper_model
380
+ if _whisper_model is None:
381
+ try:
382
+ from faster_whisper import WhisperModel # type: ignore
383
+ _whisper_model = WhisperModel(settings.whisper_model, device="cpu")
384
+ except Exception as e:
385
+ raise HTTPException(status_code=500, detail=f"STT unavailable: {e}")
386
+
387
+ # Save to temp file because decoders may need seekable input
388
+ suffix = os.path.splitext(audio.filename or "input.wav")[1] or ".wav"
389
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
390
+ contents = await audio.read()
391
+ tmp.write(contents)
392
+ tmp_path = tmp.name
393
+ started = time.perf_counter()
394
+ try:
395
+ segments, info = _whisper_model.transcribe(tmp_path, language=language, vad_filter=True)
396
+ texts: List[str] = []
397
+ for seg in segments:
398
+ texts.append(seg.text)
399
+ full_text = " ".join(t.strip() for t in texts if t and t.strip())
400
+ if METRICS_ENABLED:
401
+ try:
402
+ TRANSCRIBE_TOTAL.labels(status="ok").inc()
403
+ TRANSCRIBE_DURATION.observe(time.perf_counter() - started)
404
+ except Exception:
405
+ pass
406
+ return {"text": full_text, "language": getattr(info, 'language', None)}
407
+ except Exception as e:
408
+ if METRICS_ENABLED:
409
+ try:
410
+ TRANSCRIBE_TOTAL.labels(status="error").inc()
411
+ TRANSCRIBE_DURATION.observe(time.perf_counter() - started)
412
+ except Exception:
413
+ pass
414
+ raise HTTPException(status_code=500, detail=f"Transcription error: {e}")
415
+ finally:
416
+ try:
417
+ os.unlink(tmp_path)
418
+ except Exception:
419
+ pass
420
+ else:
421
+ @app.post("/transcribe")
422
+ @app.post("/api/transcribe")
423
+ async def transcribe_unavailable(request: Request):
424
+ raise HTTPException(status_code=503, detail="STT disabled. Enable optional deps and set STT_ENABLED=1.")
425
+
426
+ # Serve built frontend (Vite `dist`) if present
427
+ try:
428
+ import os
429
+ DIST_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "dist"))
430
+ if os.path.isdir(DIST_DIR):
431
+ app.mount("/", StaticFiles(directory=DIST_DIR, html=True), name="static")
432
+ except Exception:
433
+ pass
server/config.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dataclasses import dataclass, field
3
+ from typing import List
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Settings:
8
+ # Use local Marian provider by default (no external Inference API required)
9
+ provider: str = os.getenv("TRANSLATION_PROVIDER", "marian").lower()
10
+ # For Hugging Face Inference API
11
+ hf_api_token: str | None = os.getenv("HUGGINGFACE_API_TOKEN")
12
+ # Default timeout (seconds) for outbound HTTP calls
13
+ http_timeout: float = float(os.getenv("HTTP_TIMEOUT", "30"))
14
+ # Comma-separated list of allowed origins for CORS (production should not be "*")
15
+ allowed_origins: List[str] = field(
16
+ default_factory=lambda: (
17
+ [o.strip() for o in os.getenv("ALLOWED_ORIGINS", "*").split(",")]
18
+ if os.getenv("ALLOWED_ORIGINS")
19
+ else ["*"]
20
+ )
21
+ )
22
+ # Max input characters per request to protect the service
23
+ max_text_chars: int = int(os.getenv("MAX_TEXT_CHARS", "5000"))
24
+ # Simple in-memory rate limit: requests per minute per client
25
+ requests_per_minute: int = int(os.getenv("REQUESTS_PER_MINUTE", "60"))
26
+ # Whisper model for speech-to-text (tiny, base, small; tiny/base recommended for CPU)
27
+ whisper_model: str = os.getenv("WHISPER_MODEL", "tiny")
28
+ # Toggle to enable /transcribe endpoint (requires optional deps). Defaults to off for easy local dev.
29
+ stt_enabled: bool = os.getenv("STT_ENABLED", "0").strip().lower() in {"1", "true", "yes", "on"}
30
+
31
+
32
+ settings = Settings()
server/providers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Providers package marker for reliable imports and editor analysis.
server/providers/base.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from abc import ABC, abstractmethod
3
+ from typing import Optional
4
+
5
+
6
+ class TranslationProvider(ABC):
7
+ """Abstract interface for translation providers."""
8
+
9
+ @abstractmethod
10
+ def supports(self, src: str, tgt: str) -> bool:
11
+ ...
12
+
13
+ @abstractmethod
14
+ def preload(self, src: str, tgt: str) -> bool:
15
+ ...
16
+
17
+ @abstractmethod
18
+ def translate(self, text: str, src: str, tgt: str, max_length: Optional[int] = None) -> str:
19
+ ...
server/providers/hf_inference.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from .base import TranslationProvider
3
+
4
+ class HFInferenceProvider(TranslationProvider):
5
+ def __init__(self, api_token: str):
6
+ self.api_token = api_token
7
+ # Use a more reliable endpoint for Malay/Indonesian to English
8
+ self.endpoints = {
9
+ ("ms", "en"): "https://api-inference.huggingface.co/models/Helsinki-NLP/opus-mt-zsm-en",
10
+ ("id", "en"): "https://api-inference.huggingface.co/models/Helsinki-NLP/opus-mt-id-en",
11
+ ("en", "ms"): "https://api-inference.huggingface.co/models/Helsinki-NLP/opus-mt-en-zsm",
12
+ ("en", "id"): "https://api-inference.huggingface.co/models/Helsinki-NLP/opus-mt-en-id",
13
+ }
14
+ self.default_endpoint = "https://api-inference.huggingface.co/models/Helsinki-NLP/opus-mt-en-mul"
15
+
16
+ def supports(self, src: str, tgt: str) -> bool:
17
+ return True
18
+
19
+ def preload(self, src: str, tgt: str) -> bool:
20
+ # No-op for cloud
21
+ return True
22
+
23
+ def translate(self, text: str, src: str, tgt: str, max_length=None) -> str:
24
+ headers = {"Authorization": f"Bearer {self.api_token}"}
25
+ payload = {"inputs": text}
26
+ endpoint = self.endpoints.get((src, tgt), self.default_endpoint)
27
+ try:
28
+ resp = requests.post(endpoint, headers=headers, json=payload, timeout=30)
29
+ resp.raise_for_status()
30
+ data = resp.json()
31
+ if isinstance(data, list) and data and "translation_text" in data[0]:
32
+ result = data[0]["translation_text"]
33
+ # If result is echo, try Indonesian fallback
34
+ if result.strip() == text.strip() and (src, tgt) == ("ms", "en"):
35
+ # Try Indonesian
36
+ ind_endpoint = self.endpoints.get(("id", "en"), self.default_endpoint)
37
+ resp2 = requests.post(ind_endpoint, headers=headers, json=payload, timeout=30)
38
+ resp2.raise_for_status()
39
+ data2 = resp2.json()
40
+ if isinstance(data2, list) and data2 and "translation_text" in data2[0]:
41
+ result2 = data2[0]["translation_text"]
42
+ if result2.strip() != text.strip():
43
+ return result2
44
+ return result
45
+ return str(data)
46
+ except Exception as e:
47
+ return f"[HF error] {e}"
server/providers/marian.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from functools import lru_cache
3
+ from typing import Dict, Optional, Tuple, List, Dict as TDict, Any, Iterable, cast
4
+
5
+ import os
6
+ from cachetools import LRUCache
7
+ from transformers.pipelines import pipeline
8
+ from transformers.pipelines.text2text_generation import Text2TextGenerationPipeline
9
+
10
+ from .base import TranslationProvider
11
+
12
+ # Map (src, tgt) -> model id (Helsinki-NLP MarianMT)
13
+ MODEL_MAP: Dict[Tuple[str, str], str] = {
14
+ ("en", "es"): "Helsinki-NLP/opus-mt-en-es",
15
+ ("es", "en"): "Helsinki-NLP/opus-mt-es-en",
16
+ ("en", "fr"): "Helsinki-NLP/opus-mt-en-fr",
17
+ ("fr", "en"): "Helsinki-NLP/opus-mt-fr-en",
18
+ ("en", "de"): "Helsinki-NLP/opus-mt-en-de",
19
+ ("de", "en"): "Helsinki-NLP/opus-mt-de-en",
20
+ ("en", "ar"): "Helsinki-NLP/opus-mt-en-ar",
21
+ ("ar", "en"): "Helsinki-NLP/opus-mt-ar-en",
22
+ ("en", "ur"): "Helsinki-NLP/opus-mt-en-ur",
23
+ ("ur", "en"): "Helsinki-NLP/opus-mt-ur-en",
24
+ ("en", "hi"): "Helsinki-NLP/opus-mt-en-hi",
25
+ ("hi", "en"): "Helsinki-NLP/opus-mt-hi-en",
26
+ ("en", "zh"): "Helsinki-NLP/opus-mt-en-zh",
27
+ ("zh", "en"): "Helsinki-NLP/opus-mt-zh-en",
28
+ # Malay (often labeled zsm in OPUS) ⇄ English
29
+ ("en", "ms"): "Helsinki-NLP/opus-mt-en-zsm",
30
+ ("ms", "en"): "Helsinki-NLP/opus-mt-zsm-en",
31
+ # Indonesian
32
+ ("en", "id"): "Helsinki-NLP/opus-mt-en-id",
33
+ ("id", "en"): "Helsinki-NLP/opus-mt-id-en",
34
+ }
35
+
36
+
37
+ @lru_cache(maxsize=16)
38
+ def _get_pipeline(model_id: str) -> Text2TextGenerationPipeline:
39
+ return cast(Text2TextGenerationPipeline, pipeline("translation", model=model_id))
40
+
41
+
42
+ class MarianProvider(TranslationProvider):
43
+ # Simple in-memory cache for translation results to improve latency/cost
44
+ _cache: LRUCache = LRUCache(maxsize=int(os.getenv("TRANSLATION_CACHE_SIZE", "512")))
45
+
46
+ def _preload_any(self, src: str, tgt: str) -> bool:
47
+ for model_id in self._candidates(src, tgt):
48
+ try:
49
+ _ = _get_pipeline(model_id)
50
+ return True
51
+ except Exception:
52
+ continue
53
+ return False
54
+ def _candidates(self, src: str, tgt: str) -> Iterable[str]:
55
+ # 1) Explicit map
56
+ explicit = MODEL_MAP.get((src, tgt))
57
+ if explicit:
58
+ yield explicit
59
+ # 2) Dynamic guess
60
+ yield f"Helsinki-NLP/opus-mt-{src}-{tgt}"
61
+ # 3) Malay direct (zsm)
62
+ if src == "ms" and tgt == "en":
63
+ yield "Helsinki-NLP/opus-mt-zsm-en"
64
+ if src == "en" and tgt == "ms":
65
+ yield "Helsinki-NLP/opus-mt-en-zsm"
66
+ # 3b) Practical fallback: Indonesian models often work reasonably for Malay
67
+ if tgt == "en" and src == "ms":
68
+ yield "Helsinki-NLP/opus-mt-id-en"
69
+ if src == "en" and tgt == "ms":
70
+ yield "Helsinki-NLP/opus-mt-en-id"
71
+ # 4) Universal multilingual fallback when specific pair is missing
72
+ if tgt == "en":
73
+ yield "Helsinki-NLP/opus-mt-mul-en"
74
+ if src == "en":
75
+ yield "Helsinki-NLP/opus-mt-en-mul"
76
+
77
+ def supports(self, src: str, tgt: str) -> bool:
78
+ # Optimistically support and handle errors at translate/preload time.
79
+ return True
80
+
81
+ def preload(self, src: str, tgt: str) -> bool:
82
+ # Try direct pair first
83
+ if self._preload_any(src, tgt):
84
+ return True
85
+ # Bridge via English if direct model unavailable (src->en and en->tgt)
86
+ if src != "en" and tgt != "en":
87
+ a = self._preload_any(src, "en")
88
+ b = self._preload_any("en", tgt)
89
+ return a and b
90
+ return False
91
+
92
+ def translate(self, text: str, src: str, tgt: str, max_length: Optional[int] = None) -> str:
93
+ # Try direct candidate models until one works
94
+ for mid in self._candidates(src, tgt):
95
+ cache_key = (mid, text)
96
+ cached = self._cache.get(cache_key)
97
+ if isinstance(cached, str):
98
+ return cached
99
+ try:
100
+ pipe = _get_pipeline(mid)
101
+ raw = pipe(text, max_length=max_length or max(64, len(text) + 8))
102
+ out = cast(List[TDict[str, Any]], raw)
103
+ first: TDict[str, Any] = out[0] if out else {"translation_text": ""}
104
+ result = str(first.get("translation_text", ""))
105
+ self._cache[cache_key] = result
106
+ return result
107
+ except Exception:
108
+ continue
109
+
110
+ # Bridge via English if no direct model works
111
+ if src != "en" and tgt != "en":
112
+ # src -> en
113
+ mid1 = None
114
+ for c in self._candidates(src, "en"):
115
+ try:
116
+ mid1 = c
117
+ pipe1 = _get_pipeline(c)
118
+ raw1 = pipe1(text, max_length=max_length or max(64, len(text) + 8))
119
+ out1 = cast(List[TDict[str, Any]], raw1)
120
+ first1: TDict[str, Any] = out1[0] if out1 else {"translation_text": ""}
121
+ text_en = str(first1.get("translation_text", ""))
122
+ # en -> tgt
123
+ for c2 in self._candidates("en", tgt):
124
+ try:
125
+ pipe2 = _get_pipeline(c2)
126
+ raw2 = pipe2(text_en, max_length=max_length or max(64, len(text_en) + 8))
127
+ out2 = cast(List[TDict[str, Any]], raw2)
128
+ first2: TDict[str, Any] = out2[0] if out2 else {"translation_text": ""}
129
+ result = str(first2.get("translation_text", ""))
130
+ # cache using synthetic key to avoid giant cache miss costs
131
+ self._cache[(f"{mid1}|bridge", text)] = result
132
+ return result
133
+ except Exception:
134
+ continue
135
+ except Exception:
136
+ continue
137
+
138
+ # All attempts failed
139
+ return f"[No model for {src}->{tgt}] {text}"
server/requirements-stt.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Optional STT (Speech-to-Text) dependencies
2
+ # Prerequisites (macOS):
3
+ # - Install Homebrew (https://brew.sh)
4
+ # - brew install pkg-config ffmpeg
5
+ # Then install these Python deps:
6
+ # pip install -r server/requirements-stt.txt
7
+
8
+ faster-whisper==1.0.3
9
+ python-multipart==0.0.9
server/requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.6
3
+ # Newer Transformers uses tokenizers>=0.20 with wheels for Python 3.13
4
+ transformers==4.46.3
5
+ # PyTorch version compatible with Python 3.13 on macOS
6
+ torch==2.7.1
7
+ sentencepiece>=0.1.99
8
+ sacremoses>=0.1.1
9
+ cachetools==5.3.3
10
+ langdetect==1.0.9
11
+ # Metrics/observability
12
+ prometheus-client==0.20.0
13
+ # STT (speech-to-text) dependencies are optional for local dev on macOS.
14
+ # To enable STT locally, install prerequisites and then run:
15
+ # pip install -r server/requirements-stt.txt
server/test_app.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from fastapi.testclient import TestClient
3
+ from server.app import app
4
+
5
+ client = TestClient(app)
6
+
7
+ def test_health():
8
+ resp = client.get("/health")
9
+ assert resp.status_code == 200
10
+ assert resp.json()["status"] == "ok"
11
+
12
+ def test_translate_basic():
13
+ data = {
14
+ "text": "Terima kasih banyak",
15
+ "source_language": "ms",
16
+ "target_language": "en"
17
+ }
18
+ resp = client.post("/translate", json=data)
19
+ assert resp.status_code == 200
20
+ assert "translated_text" in resp.json()
21
+ # Should not return error or echo
22
+ out = resp.json()["translated_text"]
23
+ assert out and not out.startswith("[No model") and out != data["text"]
24
+
25
+ def test_preload():
26
+ data = {
27
+ "source_language": "ms",
28
+ "target_language": "en"
29
+ }
30
+ resp = client.post("/preload", json=data)
31
+ assert resp.status_code == 200
32
+ assert resp.json()["ok"] is True
33
+
34
+ def test_stt_status():
35
+ resp = client.get("/stt_status")
36
+ assert resp.status_code == 200
37
+ assert "available" in resp.json()
38
+
39
+ def test_provider_info():
40
+ resp = client.get("/provider")
41
+ assert resp.status_code == 200
42
+ assert "provider" in resp.json()
server/translation_backend_masterpiece.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI Translation Backend Masterpiece
3
+ - Preloads all required MarianMT models
4
+ - Handles all language pairs robustly
5
+ - Provides health/model endpoints
6
+ - Catches and logs all errors
7
+ - Uses GPU if available
8
+ - Caches models and translations
9
+ """
10
+
11
+ from fastapi import FastAPI, HTTPException, Request
12
+ from pydantic import BaseModel
13
+ from transformers import pipeline
14
+ import torch
15
+ from functools import lru_cache
16
+ from fastapi.responses import JSONResponse
17
+ import logging
18
+ from fastapi.responses import JSONResponse
19
+ from fastapi import Request
20
+ import time
21
+ from typing import Dict, Tuple
22
+ from transformers.pipelines.text2text_generation import Text2TextGenerationPipeline
23
+ import asyncio
24
+
25
+ app = FastAPI()
26
+ from collections import defaultdict
27
+
28
+ # Simple in-memory rate limiter (per IP)
29
+ RATE_LIMIT = 30 # max requests per minute
30
+ rate_limit_data = defaultdict(lambda: {'count': 0, 'reset': time.time() + 60})
31
+
32
+ def check_rate_limit(ip: str):
33
+ now = time.time()
34
+ data = rate_limit_data[ip]
35
+ if now > data['reset']:
36
+ data['count'] = 0
37
+ data['reset'] = now + 60
38
+ data['count'] += 1
39
+ if data['count'] > RATE_LIMIT:
40
+ return False
41
+ return True
42
+ import sys
43
+ logger = logging.getLogger("translation-backend")
44
+ handler = logging.StreamHandler(sys.stdout)
45
+ formatter = logging.Formatter('[%(asctime)s] %(levelname)s %(name)s: %(message)s')
46
+ handler.setFormatter(formatter)
47
+ logger.addHandler(handler)
48
+ logger.setLevel(logging.INFO)
49
+ import time
50
+ from fastapi import Response
51
+ from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
52
+
53
+ # Prometheus metrics for monitoring
54
+ TRANSLATION_COUNT = Counter('translation_requests_total', 'Total translation requests', ['pair', 'status'])
55
+ TRANSLATION_LATENCY = Histogram('translation_latency_seconds', 'Translation latency', ['pair'])
56
+
57
+ # List all language pairs you want to support
58
+ LANGUAGE_PAIRS = [
59
+ # ("en", "ta"), ("ta", "en"), # Tamil removed
60
+ ("en", "hi"), ("hi", "en"),
61
+ ("en", "zh"), ("zh", "en"),
62
+ ("en", "ms"), ("ms", "en"),
63
+ ("en", "id"), ("id", "en"),
64
+ ("en", "ar"), ("ar", "en"), # Arabic
65
+ ("en", "ur"), ("ur", "en"), # Urdu
66
+ # Add more pairs as needed
67
+ ]
68
+
69
+ MODEL_NAMES = {
70
+ # ("en", "ta"): "ArunIcfoss/nllb-200-distilled-1.3B-ICFOSS-Tamil_Malayalam_Translation2",
71
+ # ("ta", "en"): "ArunIcfoss/nllb-200-distilled-1.3B-ICFOSS-Tamil_Malayalam_Translation2", # Tamil removed
72
+ ("en", "hi"): "Helsinki-NLP/opus-mt-en-hi",
73
+ ("hi", "en"): "Helsinki-NLP/opus-mt-hi-en",
74
+ ("en", "zh"): "Helsinki-NLP/opus-mt-en-zh",
75
+ ("zh", "en"): "Helsinki-NLP/opus-mt-zh-en",
76
+ ("en", "ms"): "Helsinki-NLP/opus-mt-en-ms",
77
+ ("ms", "en"): "Helsinki-NLP/opus-mt-ms-en",
78
+ ("en", "id"): "Helsinki-NLP/opus-mt-en-id",
79
+ ("id", "en"): "Helsinki-NLP/opus-mt-id-en",
80
+ ("en", "ar"): "Helsinki-NLP/opus-mt-en-ar",
81
+ ("ar", "en"): "Helsinki-NLP/opus-mt-ar-en",
82
+ ("en", "ur"): "Helsinki-NLP/opus-mt-en-ur",
83
+ ("ur", "en"): "Helsinki-NLP/opus-mt-ur-en",
84
+ # Add more pairs as needed
85
+ }
86
+ SUPPORTED_PAIRS = list(MODEL_NAMES.keys())
87
+
88
+ class TranslateRequest(BaseModel):
89
+ text: str
90
+ source_language: str
91
+ target_language: str
92
+
93
+ from transformers.pipelines.text2text_generation import Text2TextGenerationPipeline
94
+ # Preload all models at startup
95
+ MODELS: Dict[Tuple[str, str], Text2TextGenerationPipeline] = {}
96
+
97
+ # LRU cache for translations (demo reliability)
98
+ @lru_cache(maxsize=512)
99
+ def cached_translate(pair, text):
100
+ pipe = MODELS.get(pair)
101
+ if not pipe:
102
+ return None
103
+ result = pipe(text)
104
+ if not isinstance(result, list) or not result or not isinstance(result[0], dict) or "translation_text" not in result[0]:
105
+ return None
106
+ return result[0].get("translation_text")
107
+ DEVICE = 0 if torch.cuda.is_available() else -1
108
+
109
+ @app.on_event("startup")
110
+ def preload_models():
111
+ # Load and verify models, track health
112
+ global VERIFIED_PAIRS
113
+ VERIFIED_PAIRS = []
114
+ for pair, model_name in MODEL_NAMES.items():
115
+ try:
116
+ pipe = pipeline("translation", model=model_name, device=DEVICE)
117
+ if not isinstance(pipe, Text2TextGenerationPipeline):
118
+ raise Exception(f"Loaded pipeline is not Text2TextGenerationPipeline for {pair}")
119
+ test_result = pipe("Hello world")
120
+ if not isinstance(test_result, list) or not test_result or "translation_text" not in test_result[0]:
121
+ raise Exception(f"Model {model_name} for {pair} failed test translation.")
122
+ MODELS[pair] = pipe
123
+ VERIFIED_PAIRS.append(pair)
124
+ logger.info(f"Loaded and verified model for {pair}: {model_name}")
125
+ except Exception as e:
126
+ logger.error(f"Failed to load or verify model {model_name} for {pair}: {e}")
127
+
128
+ @app.get("/health")
129
+ @app.get("/livez")
130
+ def livez():
131
+ """Kubernetes-style liveness probe."""
132
+ return Response(content="ok", media_type="text/plain")
133
+ def health():
134
+ loaded = {str(pair): str(type(MODELS.get(pair))) for pair in SUPPORTED_PAIRS}
135
+ device_info = torch.cuda.get_device_name(0) if torch.cuda.is_available() else "cpu"
136
+ return {"models": loaded, "device": device_info}
137
+
138
+ # Prometheus metrics endpoint
139
+ @app.get("/metrics")
140
+ def metrics():
141
+ return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
142
+ class BatchTranslateRequest(BaseModel):
143
+ source_lang: str
144
+ target_lang: str
145
+ texts: list[str]
146
+
147
+ @app.get("/models")
148
+ def models():
149
+ # Only show verified working pairs
150
+ return {"models": VERIFIED_PAIRS}
151
+
152
+ async def translate(req: TranslateRequest, request: Request):
153
+ ip = request.client.host if request.client else 'unknown'
154
+ if not check_rate_limit(ip):
155
+ return JSONResponse(status_code=429, content={
156
+ "error": "rate_limited",
157
+ "message": "Too many requests. Please wait and try again."
158
+ })
159
+ pair = (req.source_language, req.target_language)
160
+ start_time = time.time()
161
+ if pair not in MODELS:
162
+ # Two-step translation via English if direct model is missing
163
+ fallback = None
164
+ try:
165
+ mid = None
166
+ if req.source_language != "en" and (req.source_language, "en") in MODELS:
167
+ mid = cached_translate((req.source_language, "en"), req.text)
168
+ else:
169
+ mid = req.text
170
+ if mid and req.target_language != "en" and ("en", req.target_language) in MODELS:
171
+ fallback = cached_translate(("en", req.target_language), mid)
172
+ elif mid:
173
+ fallback = mid
174
+ except Exception:
175
+ fallback = None
176
+ logger.warning(f"Requested model {pair} not loaded. Two-step fallback used: {bool(fallback)}")
177
+ TRANSLATION_COUNT.labels(pair=str(pair), status="missing_model").inc()
178
+ return JSONResponse(status_code=200, content={
179
+ "translated_text": fallback,
180
+ "latency": time.time() - start_time,
181
+ "fallback": True,
182
+ "message": f"Model for {pair} not loaded. Used two-step translation via English.",
183
+ "supported_pairs": VERIFIED_PAIRS,
184
+ "timestamp": time.time(),
185
+ "ip": ip
186
+ })
187
+ try:
188
+ await asyncio.sleep(0)
189
+ translated = cached_translate(pair, req.text)
190
+ latency = time.time() - start_time
191
+ logger.info(f"Translation {pair}: '{req.text[:30]}...' -> '{str(translated)[:30]}...' | latency: {latency:.3f}s | ip: {ip}")
192
+ TRANSLATION_COUNT.labels(pair=str(pair), status="success").inc()
193
+ TRANSLATION_LATENCY.labels(pair=str(pair)).observe(latency)
194
+ if not translated:
195
+ raise Exception("No translation returned.")
196
+ return {"translated_text": translated, "latency": latency, "timestamp": time.time(), "ip": ip}
197
+ except Exception as e:
198
+ logger.error(f"Translation error for {pair}: {e} | ip: {ip}")
199
+ TRANSLATION_COUNT.labels(pair=str(pair), status="error").inc()
200
+ return JSONResponse(status_code=500, content={
201
+ "error": "translation_failed",
202
+ "message": str(e),
203
+ "timestamp": time.time(),
204
+ "ip": ip
205
+ })
206
+
207
+ # Batch translation endpoint for demo reliability
208
+ @app.post("/batch_translate")
209
+ @app.post("/batch_translate")
210
+ async def batch_translate(req: BatchTranslateRequest):
211
+ pair = (req.source_lang, req.target_lang)
212
+ if pair not in MODELS:
213
+ TRANSLATION_COUNT.labels(pair=str(pair), status="missing_model").inc()
214
+ return JSONResponse(status_code=400, content={
215
+ "error": "model_not_loaded",
216
+ "message": f"Model for {pair} not loaded.",
217
+ "supported_pairs": SUPPORTED_PAIRS
218
+ })
219
+ results = []
220
+ latencies = []
221
+ for text in req.texts:
222
+ start_time = time.time()
223
+ try:
224
+ await asyncio.sleep(0)
225
+ translated = cached_translate(pair, text)
226
+ latency = time.time() - start_time
227
+ results.append(translated if translated else "")
228
+ latencies.append(latency)
229
+ logger.info(f"Batch translation {pair}: '{text[:30]}...' -> '{str(translated)[:30]}...' | latency: {latency:.3f}s")
230
+ TRANSLATION_COUNT.labels(pair=str(pair), status="success").inc()
231
+ TRANSLATION_LATENCY.labels(pair=str(pair)).observe(latency)
232
+ except Exception as e:
233
+ results.append("")
234
+ latencies.append(None)
235
+ logger.error(f"Batch translation error for {pair}: {e}")
236
+ TRANSLATION_COUNT.labels(pair=str(pair), status="error").inc()
237
+ return {"translated_texts": results, "latencies": latencies}
238
+
239
+ # Add more endpoints as needed (e.g., batch translation, STT, etc.)
240
+
241
+ # To run: uvicorn translation_backend_masterpiece:app --reload
src/App.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AuthProvider } from './contexts/AuthContext';
2
+ import { useAuth } from './contexts/useAuth';
3
+ import { NavigationProvider } from './components/Navigation';
4
+ import { useCurrentPath } from './components/NavigationHooks';
5
+ import { Auth } from './components/Auth';
6
+ import { Dashboard } from './components/Dashboard';
7
+ import { HostView } from './components/HostView';
8
+ import { ViewerView } from './components/ViewerView';
9
+ import { Component, ErrorInfo, ReactNode } from 'react';
10
+
11
+ class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean; error: any }> {
12
+ constructor(props: any) {
13
+ super(props);
14
+ this.state = { hasError: false, error: null };
15
+ }
16
+ static getDerivedStateFromError(error: any) {
17
+ return { hasError: true, error };
18
+ }
19
+ // componentDidCatch can be used to log errors to a service if needed
20
+ componentDidCatch(_error: any, _info: ErrorInfo) {}
21
+ render() {
22
+ if (this.state.hasError) {
23
+ return (
24
+ <div className="min-h-screen flex items-center justify-center bg-red-50">
25
+ <div className="bg-white rounded-xl shadow-xl p-8 max-w-md mx-auto text-center">
26
+ <h1 className="text-2xl font-bold text-red-700 mb-4">Something went wrong</h1>
27
+ <p className="text-gray-700 mb-2">An unexpected error occurred. Please refresh the page or try again later.</p>
28
+ <pre className="text-xs text-gray-400 mb-2">{String(this.state.error)}</pre>
29
+ <button className="bg-blue-600 text-white px-4 py-2 rounded-lg" onClick={() => window.location.reload()}>Reload</button>
30
+ </div>
31
+ </div>
32
+ );
33
+ }
34
+ return this.props.children;
35
+ }
36
+ }
37
+
38
+ function AppContent() {
39
+ const { user, loading } = useAuth();
40
+ const currentPath = useCurrentPath();
41
+ console.log('[AppContent] user:', user, 'loading:', loading, 'currentPath:', currentPath);
42
+
43
+ if (loading) {
44
+ return (
45
+ <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
46
+ <div className="text-center">
47
+ <div className="animate-spin rounded-full h-16 w-16 border-b-4 border-blue-600 mx-auto mb-4"></div>
48
+ <p className="text-gray-600 text-lg">Loading...</p>
49
+ </div>
50
+ </div>
51
+ );
52
+ }
53
+
54
+ if (!user) {
55
+ return <Auth />;
56
+ }
57
+
58
+ if (currentPath.startsWith('/host/')) {
59
+ const groupId = currentPath.split('/')[2];
60
+ return <HostView groupId={groupId} />;
61
+ }
62
+
63
+ if (currentPath.startsWith('/viewer/')) {
64
+ const groupId = currentPath.split('/')[2];
65
+ return <ViewerView groupId={groupId} />;
66
+ }
67
+
68
+ return <Dashboard />;
69
+ }
70
+
71
+ function App() {
72
+ return (
73
+ <ErrorBoundary>
74
+ <AuthProvider>
75
+ <NavigationProvider>
76
+ <AppContent />
77
+ </NavigationProvider>
78
+ </AuthProvider>
79
+ </ErrorBoundary>
80
+ );
81
+ }
82
+
83
+ export default App;
src/components/Auth.tsx ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { useAuth } from '../contexts/useAuth';
3
+ import { Mic, Globe } from 'lucide-react';
4
+
5
+ export function Auth() {
6
+ const [isSignUp, setIsSignUp] = useState(false);
7
+ const [email, setEmail] = useState('');
8
+ const [password, setPassword] = useState('');
9
+ const [error, setError] = useState('');
10
+ const [loading, setLoading] = useState(false);
11
+ const { signIn, signUp } = useAuth();
12
+
13
+ const handleSubmit = async (e: React.FormEvent) => {
14
+ e.preventDefault();
15
+ setError('');
16
+ setLoading(true);
17
+
18
+ try {
19
+ if (isSignUp) {
20
+ await signUp(email, password);
21
+ } else {
22
+ await signIn(email, password);
23
+ }
24
+ } catch (err) {
25
+ setError(err instanceof Error ? err.message : 'An error occurred');
26
+ } finally {
27
+ setLoading(false);
28
+ }
29
+ };
30
+
31
+ return (
32
+ <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
33
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-8">
34
+ <div className="flex items-center justify-center mb-8">
35
+ <div className="bg-blue-600 rounded-full p-3 mr-3">
36
+ <Mic className="w-8 h-8 text-white" />
37
+ </div>
38
+ <Globe className="w-8 h-8 text-blue-600" />
39
+ </div>
40
+
41
+ <h1 className="text-3xl font-bold text-center text-gray-900 mb-2">
42
+ LiveTranscribe
43
+ </h1>
44
+ <p className="text-center text-gray-600 mb-8">
45
+ Real-time transcription and translation streaming
46
+ </p>
47
+
48
+ <form onSubmit={handleSubmit} className="space-y-4">
49
+ <div>
50
+ <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
51
+ Email
52
+ </label>
53
+ <input
54
+ id="email"
55
+ type="email"
56
+ value={email}
57
+ onChange={(e) => setEmail(e.target.value)}
58
+ required
59
+ className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
60
+ placeholder="you@example.com"
61
+ />
62
+ </div>
63
+
64
+ <div>
65
+ <label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
66
+ Password
67
+ </label>
68
+ <input
69
+ id="password"
70
+ type="password"
71
+ value={password}
72
+ onChange={(e) => setPassword(e.target.value)}
73
+ required
74
+ minLength={6}
75
+ className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
76
+ placeholder="••••••••"
77
+ />
78
+ </div>
79
+
80
+ {error && (
81
+ <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
82
+ {error}
83
+ </div>
84
+ )}
85
+
86
+ <button
87
+ type="submit"
88
+ disabled={loading}
89
+ className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
90
+ >
91
+ {loading ? 'Please wait...' : isSignUp ? 'Sign Up' : 'Sign In'}
92
+ </button>
93
+ </form>
94
+
95
+ <div className="mt-6 text-center">
96
+ <button
97
+ onClick={() => setIsSignUp(!isSignUp)}
98
+ className="text-blue-600 hover:text-blue-700 text-sm font-medium"
99
+ >
100
+ {isSignUp ? 'Already have an account? Sign in' : "Don't have an account? Sign up"}
101
+ </button>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ );
106
+ }
src/components/CreateGroup.tsx ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { X, Loader2 } from 'lucide-react';
3
+ import { LocalDB } from '../lib/localdb';
4
+ import { useAuth } from '../contexts/useAuth';
5
+ import { useNavigate } from './NavigationHooks';
6
+
7
+ const SOURCE_LANGUAGES = [
8
+ { code: 'auto', name: 'Auto (detect)' },
9
+ { code: 'en', name: 'English' },
10
+ { code: 'ur', name: 'Urdu' },
11
+ { code: 'ar', name: 'Arabic' },
12
+ { code: 'hi', name: 'Hindi' },
13
+ { code: 'zh', name: 'Chinese' },
14
+ { code: 'ms', name: 'Malay' },
15
+ ];
16
+
17
+ const TARGET_LANGUAGES = [
18
+ { code: 'en', name: 'English' },
19
+ { code: 'ur', name: 'Urdu' },
20
+ { code: 'ar', name: 'Arabic' },
21
+ { code: 'hi', name: 'Hindi' },
22
+ { code: 'zh', name: 'Chinese' },
23
+ { code: 'ms', name: 'Malay' },
24
+ ];
25
+
26
+ interface CreateGroupProps {
27
+ onClose: () => void;
28
+ }
29
+
30
+ export function CreateGroup({ onClose }: CreateGroupProps) {
31
+ const { user } = useAuth();
32
+ const navigate = useNavigate();
33
+ const [name, setName] = useState('');
34
+ const [sourceLanguage, setSourceLanguage] = useState('auto');
35
+ const [targetLanguage, setTargetLanguage] = useState('en');
36
+ const [loading, setLoading] = useState(false);
37
+ const [error, setError] = useState('');
38
+
39
+ const handleSubmit = async (e: React.FormEvent) => {
40
+ e.preventDefault();
41
+ if (!user) return;
42
+
43
+ setLoading(true);
44
+ setError('');
45
+
46
+ try {
47
+ if (sourceLanguage === targetLanguage) {
48
+ setError('Source and target languages must be different.');
49
+ return;
50
+ }
51
+ const joinCode = LocalDB.generateUniqueJoinCode();
52
+ const group = LocalDB.createGroup({
53
+ name,
54
+ hostId: user.id,
55
+ joinCode,
56
+ sourceLanguage,
57
+ targetLanguage,
58
+ });
59
+ LocalDB.addMember(group.id, user.id, true);
60
+
61
+ navigate(`/host/${group.id}`);
62
+ onClose();
63
+ } catch (err) {
64
+ setError(err instanceof Error ? err.message : 'Failed to create group');
65
+ } finally {
66
+ setLoading(false);
67
+ }
68
+ };
69
+
70
+ return (
71
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
72
+ <div className="bg-white rounded-2xl shadow-2xl w-full max-w-md">
73
+ <div className="flex items-center justify-between p-6 border-b border-gray-200">
74
+ <h2 className="text-2xl font-bold text-gray-900">Host a Session</h2>
75
+ <button
76
+ onClick={onClose}
77
+ className="text-gray-400 hover:text-gray-600 transition-colors"
78
+ >
79
+ <X className="w-6 h-6" />
80
+ </button>
81
+ </div>
82
+
83
+ <form onSubmit={handleSubmit} className="p-6 space-y-4">
84
+ <div>
85
+ <label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
86
+ Session Name
87
+ </label>
88
+ <input
89
+ id="name"
90
+ type="text"
91
+ value={name}
92
+ onChange={(e) => setName(e.target.value)}
93
+ required
94
+ className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
95
+ placeholder="Friday Lecture"
96
+ />
97
+ </div>
98
+
99
+ <div>
100
+ <label htmlFor="sourceLanguage" className="block text-sm font-medium text-gray-700 mb-1">
101
+ Source Language
102
+ </label>
103
+ <select
104
+ id="sourceLanguage"
105
+ value={sourceLanguage}
106
+ onChange={(e) => setSourceLanguage(e.target.value)}
107
+ className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
108
+ >
109
+ {SOURCE_LANGUAGES.map((lang) => (
110
+ <option key={lang.code} value={lang.code}>
111
+ {lang.name}
112
+ </option>
113
+ ))}
114
+ </select>
115
+ </div>
116
+
117
+ <div>
118
+ <label htmlFor="targetLanguage" className="block text-sm font-medium text-gray-700 mb-1">
119
+ Target Language
120
+ </label>
121
+ <select
122
+ id="targetLanguage"
123
+ value={targetLanguage}
124
+ onChange={(e) => setTargetLanguage(e.target.value)}
125
+ className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
126
+ >
127
+ {TARGET_LANGUAGES.map((lang) => (
128
+ <option key={lang.code} value={lang.code}>
129
+ {lang.name}
130
+ </option>
131
+ ))}
132
+ </select>
133
+ </div>
134
+
135
+ {error && (
136
+ <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
137
+ {error}
138
+ </div>
139
+ )}
140
+
141
+ <button
142
+ type="submit"
143
+ disabled={loading || sourceLanguage === targetLanguage}
144
+ className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center"
145
+ >
146
+ {loading ? (
147
+ <>
148
+ <Loader2 className="w-5 h-5 mr-2 animate-spin" />
149
+ Creating...
150
+ </>
151
+ ) : (
152
+ 'Create Session'
153
+ )}
154
+ </button>
155
+ </form>
156
+ </div>
157
+ </div>
158
+ );
159
+ }
src/components/Dashboard.tsx ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { useAuth } from '../contexts/useAuth';
3
+ import { Plus, LogOut, Radio, Users } from 'lucide-react';
4
+ import { CreateGroup } from './CreateGroup';
5
+ import { JoinGroup } from './JoinGroup';
6
+ import { MyGroups } from './MyGroups';
7
+
8
+ export function Dashboard() {
9
+ const { signOut } = useAuth();
10
+ const [showCreateGroup, setShowCreateGroup] = useState(false);
11
+ const [showJoinGroup, setShowJoinGroup] = useState(false);
12
+
13
+ return (
14
+ <div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
15
+ <nav className="bg-white shadow-sm border-b border-gray-200">
16
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
17
+ <div className="flex justify-between items-center h-16">
18
+ <div className="flex items-center">
19
+ <Radio className="w-8 h-8 text-blue-600 mr-2" />
20
+ <h1 className="text-2xl font-bold text-gray-900">LiveTranscribe</h1>
21
+ </div>
22
+ <button
23
+ onClick={signOut}
24
+ className="flex items-center text-gray-700 hover:text-gray-900 transition-colors"
25
+ >
26
+ <LogOut className="w-5 h-5 mr-2" />
27
+ Sign Out
28
+ </button>
29
+ </div>
30
+ </div>
31
+ </nav>
32
+
33
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
34
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
35
+ <button
36
+ onClick={() => setShowCreateGroup(true)}
37
+ className="bg-white rounded-xl shadow-md hover:shadow-lg transition-shadow p-8 text-left group"
38
+ >
39
+ <div className="bg-blue-100 rounded-full w-16 h-16 flex items-center justify-center mb-4 group-hover:bg-blue-200 transition-colors">
40
+ <Plus className="w-8 h-8 text-blue-600" />
41
+ </div>
42
+ <h2 className="text-2xl font-bold text-gray-900 mb-2">Host a Session</h2>
43
+ <p className="text-gray-600">
44
+ Create a new transcription session and broadcast live text to multiple devices
45
+ </p>
46
+ </button>
47
+
48
+ <button
49
+ onClick={() => setShowJoinGroup(true)}
50
+ className="bg-white rounded-xl shadow-md hover:shadow-lg transition-shadow p-8 text-left group"
51
+ >
52
+ <div className="bg-green-100 rounded-full w-16 h-16 flex items-center justify-center mb-4 group-hover:bg-green-200 transition-colors">
53
+ <Users className="w-8 h-8 text-green-600" />
54
+ </div>
55
+ <h2 className="text-2xl font-bold text-gray-900 mb-2">Join a Session</h2>
56
+ <p className="text-gray-600">
57
+ Enter a code or scan QR to receive live transcriptions on your device
58
+ </p>
59
+ </button>
60
+ </div>
61
+
62
+ <MyGroups />
63
+ </div>
64
+
65
+ {showCreateGroup && (
66
+ <CreateGroup onClose={() => setShowCreateGroup(false)} />
67
+ )}
68
+
69
+ {showJoinGroup && (
70
+ <JoinGroup onClose={() => setShowJoinGroup(false)} />
71
+ )}
72
+ </div>
73
+ );
74
+ }
src/components/HostView.tsx ADDED
@@ -0,0 +1,740 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState, useRef } from 'react';
2
+ import { LocalDB, Group, TranscriptionSegment } from '../lib/localdb';
3
+ import { translateText, apiHealth, preloadModel, sttStatus, transcribeAudio } from '../lib/api';
4
+ import { useAuth } from '../contexts/useAuth';
5
+ import { useNavigate } from './NavigationHooks';
6
+ import { Mic, MicOff, StopCircle, Copy, Check, QrCode, Users, Trash2, Download } from 'lucide-react';
7
+ import { QRCodeDisplay } from './QRCodeDisplay';
8
+
9
+ interface HostViewProps {
10
+ groupId: string;
11
+ }
12
+
13
+ export function HostView({ groupId }: HostViewProps) {
14
+ // ...existing code...
15
+ // Subscribe to group updates so HostView always reflects latest source/target language
16
+ useEffect(() => {
17
+ const unsubGroup = LocalDB.onGroupUpdated(groupId, (updated) => {
18
+ setGroup(updated);
19
+ });
20
+ return () => unsubGroup();
21
+ }, [groupId]);
22
+ // ...existing code...
23
+ // Show only originals toggle
24
+ const [showOriginalsOnly, setShowOriginalsOnly] = useState(false);
25
+ // Malay-English model test state
26
+ const [msEnAvailable, setMsEnAvailable] = useState<null | boolean>(null);
27
+ const [msEnChecking, setMsEnChecking] = useState(false);
28
+ // Parallel translation: track IDs currently being translated
29
+ const [translatingIds, setTranslatingIds] = useState<Set<string>>(new Set());
30
+ const { user } = useAuth();
31
+ const navigate = useNavigate();
32
+ const [group, setGroup] = useState<Group | null>(null);
33
+ const [isRecording, setIsRecording] = useState(false);
34
+ // Segments always sorted by sequence number
35
+ const [segments, setSegments] = useState<TranscriptionSegment[]>([]);
36
+ const [memberCount, setMemberCount] = useState(0);
37
+ const [showQR, setShowQR] = useState(false);
38
+ const [copied, setCopied] = useState(false);
39
+ const [interimText, setInterimText] = useState('');
40
+ const [apiOk, setApiOk] = useState<boolean | null>(null);
41
+ // Advanced audio upload support
42
+ const [sttAvailable, setSttAvailable] = useState(false);
43
+ const [sttBusy, setSttBusy] = useState(false);
44
+ // Removed unused diag variable
45
+ const [retryingIds, setRetryingIds] = useState<Set<string>>(new Set());
46
+ const [bulkRetrying, setBulkRetrying] = useState(false);
47
+ // Sentence-level buffering for mic recognition
48
+ const [bufferedText, setBufferedText] = useState('');
49
+ const flushTimerRef = useRef<any>(null);
50
+
51
+ const recognitionRef = useRef<any>(null);
52
+ const isRecordingFlagRef = useRef(false);
53
+ const sequenceNumberRef = useRef(0);
54
+ const preloadAttemptedRef = useRef(false);
55
+ const [modelReady, setModelReady] = useState<null | boolean>(null);
56
+
57
+ useEffect(() => {
58
+ if (!user) return;
59
+
60
+ loadGroup();
61
+ loadSegments();
62
+ loadMemberCount();
63
+ // Preload model for selected target language when group changes
64
+ if (group && apiOk) {
65
+ preloadModel({ source_language: group.source_language, target_language: group.target_language });
66
+ }
67
+
68
+ const unsubMembers = LocalDB.onMembersChanged(groupId, () => {
69
+ loadMemberCount();
70
+ });
71
+ // poll API health
72
+ let healthTimer: any;
73
+ (async () => {
74
+ const ok = await apiHealth();
75
+ setApiOk(ok);
76
+ // Check if Whisper STT is available (backend should expose this)
77
+ if (ok) {
78
+ try { setSttAvailable(await sttStatus()); } catch (e) { setSttAvailable(true); }
79
+ }
80
+ // Auto-preload model when API is online and group is known
81
+ if (ok && group && !preloadAttemptedRef.current) {
82
+ preloadAttemptedRef.current = true;
83
+ setModelReady(null);
84
+ const warmed = await autoPreloadForGroup(group);
85
+ setModelReady(warmed);
86
+ }
87
+ healthTimer = setInterval(async () => {
88
+ const healthy = await apiHealth();
89
+ setApiOk(healthy);
90
+ if (healthy) {
91
+ try { setSttAvailable(await sttStatus()); } catch (e) { setSttAvailable(true); }
92
+ } else {
93
+ setSttAvailable(false);
94
+ }
95
+ // Re-attempt preload if API just came online
96
+ if (healthy && group && !preloadAttemptedRef.current) {
97
+ preloadAttemptedRef.current = true;
98
+ setModelReady(null);
99
+ const warmed = await autoPreloadForGroup(group);
100
+ setModelReady(warmed);
101
+ }
102
+ }, 15000);
103
+ })();
104
+
105
+ return () => {
106
+ unsubMembers();
107
+ stopRecording();
108
+ if (healthTimer) clearInterval(healthTimer);
109
+ };
110
+ }, [groupId, user]);
111
+
112
+ // When group becomes available, kick off auto preload once
113
+ useEffect(() => {
114
+ if (group) {
115
+ if (apiOk && !preloadAttemptedRef.current) {
116
+ preloadAttemptedRef.current = true;
117
+ (async () => {
118
+ setModelReady(null);
119
+ const warmed = await autoPreloadForGroup(group);
120
+ setModelReady(warmed);
121
+ })();
122
+ }
123
+ }
124
+ }, [group?.id, apiOk]);
125
+
126
+ const autoPreloadForGroup = async (g: Group): Promise<boolean> => {
127
+ let ok = await preloadModel({ source_language: g.source_language, target_language: g.target_language });
128
+ if (!ok && g.target_language === 'en' && (g.source_language === 'auto' || !g.source_language)) {
129
+ const okMs = await preloadModel({ source_language: 'ms', target_language: 'en' });
130
+ const okId = await preloadModel({ source_language: 'id', target_language: 'en' });
131
+ ok = okMs || okId;
132
+ }
133
+ return ok;
134
+ };
135
+
136
+ const loadGroup = async () => {
137
+ const data = LocalDB.getGroupById(groupId);
138
+ if (data) {
139
+ setGroup(data);
140
+ } else {
141
+ navigate('/');
142
+ }
143
+ };
144
+
145
+ const loadSegments = async () => {
146
+ const data = LocalDB.getSegments(groupId);
147
+ setSegments(data);
148
+ sequenceNumberRef.current = data.length;
149
+ };
150
+
151
+ const loadMemberCount = async () => {
152
+ setMemberCount(LocalDB.getMemberCount(groupId));
153
+ };
154
+
155
+ const setRecording = (v: boolean) => {
156
+ setIsRecording(v);
157
+ isRecordingFlagRef.current = v;
158
+ };
159
+
160
+ const startRecording = async () => {
161
+ if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
162
+ alert('Speech recognition is not supported in your browser. Please use Chrome or Edge.');
163
+ return;
164
+ }
165
+
166
+ const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
167
+ const recognition = new SpeechRecognition();
168
+
169
+ recognition.continuous = true;
170
+ recognition.interimResults = true;
171
+ const srcLang = (group?.source_language === 'auto' ? 'en' : group?.source_language || 'en').toLowerCase();
172
+ recognition.lang = srcLang === 'auto' ? 'en-US'
173
+ : srcLang === 'ur' ? 'ur-PK'
174
+ : srcLang === 'ar' ? 'ar-SA'
175
+ : srcLang === 'es' ? 'es-ES'
176
+ : srcLang === 'fr' ? 'fr-FR'
177
+ : srcLang === 'de' ? 'de-DE'
178
+ : srcLang === 'hi' ? 'hi-IN'
179
+ : srcLang === 'zh' ? 'zh-CN'
180
+ : srcLang === 'ms' ? 'ms-MY'
181
+ : srcLang === 'id' ? 'id-ID'
182
+ : 'en-US';
183
+
184
+ recognition.onresult = async (event: any) => {
185
+ let interim = '';
186
+ let finalChunk = '';
187
+
188
+ for (let i = event.resultIndex; i < event.results.length; i++) {
189
+ const transcript = (event.results[i][0].transcript || '').trim();
190
+ if (!transcript) continue;
191
+ if (event.results[i].isFinal) {
192
+ finalChunk += (finalChunk ? ' ' : '') + transcript;
193
+ } else {
194
+ interim += (interim ? ' ' : '') + transcript;
195
+ }
196
+ }
197
+
198
+ if (interim) {
199
+ setInterimText(interim);
200
+ } else {
201
+ setInterimText('');
202
+ }
203
+
204
+ // Append final chunk to buffer and flush on sentence boundaries or heuristics
205
+ if (finalChunk) {
206
+ const combined = (bufferedText + ' ' + finalChunk).replace(/\s+/g, ' ').trim();
207
+ setBufferedText(combined);
208
+
209
+ // Heuristics for when to flush the buffered sentence
210
+ const endsSentence = /[.!?…]\s*$/.test(combined);
211
+ const wordCount = combined.split(/\s+/).filter(Boolean).length;
212
+ const overChars = combined.length >= 180; // max chars per segment
213
+ const minWordsReached = wordCount >= 10; // avoid word-by-word for long speech
214
+
215
+ // Clear any previous pending flush; we will schedule a new one below
216
+ if (flushTimerRef.current) {
217
+ clearTimeout(flushTimerRef.current);
218
+ flushTimerRef.current = null;
219
+ }
220
+
221
+ const doFlush = async (forced = false) => {
222
+ const txt = combined.trim();
223
+ if (!txt) return;
224
+ // Skip ultra-short fillers unless sentence ended
225
+ const wc = txt.split(/\s+/).filter(Boolean).length;
226
+ // Immediate flush (on punctuation/length) keeps stricter rule; timer-based can be lenient
227
+ if (!forced) {
228
+ if (!endsSentence && wc < 3) return; // allow short 2-3 word utterances only if they feel like a sentence
229
+ } else {
230
+ if (wc < 1) return; // if timer fired and there's at least one word, flush
231
+ }
232
+ setBufferedText('');
233
+ await saveSegment(txt);
234
+ };
235
+
236
+ if (endsSentence || overChars || (minWordsReached && !interim)) {
237
+ await doFlush(false);
238
+ } else {
239
+ // Debounce-based flush after short pause
240
+ flushTimerRef.current = setTimeout(async () => {
241
+ await doFlush(true);
242
+ }, 1500);
243
+ }
244
+ }
245
+ };
246
+
247
+ recognition.onerror = (event: any) => {
248
+ console.error('Speech recognition error:', event.error);
249
+ if (event.error === 'no-speech') {
250
+ return;
251
+ }
252
+ setRecording(false);
253
+ };
254
+
255
+ recognition.onend = () => {
256
+ if (isRecordingFlagRef.current) {
257
+ try {
258
+ recognition.start();
259
+ } catch (e) {
260
+ setTimeout(() => {
261
+ if (isRecordingFlagRef.current) {
262
+ try { recognition.start(); } catch (_e) { /* ignore restart error */ }
263
+ }
264
+ }, 250);
265
+ }
266
+ }
267
+ };
268
+
269
+ setRecording(true);
270
+ recognition.start();
271
+ recognitionRef.current = recognition;
272
+ };
273
+
274
+ const stopRecording = () => {
275
+ if (recognitionRef.current) {
276
+ setRecording(false);
277
+ recognitionRef.current.stop();
278
+ recognitionRef.current = null;
279
+ }
280
+ setInterimText('');
281
+ setBufferedText('');
282
+ if (flushTimerRef.current) {
283
+ clearTimeout(flushTimerRef.current);
284
+ flushTimerRef.current = null;
285
+ }
286
+ };
287
+
288
+ // Save a segment and always add in order
289
+ // Save segment and add to translation queue
290
+ const saveSegment = async (text: string) => {
291
+ if (!user || !group) return;
292
+ // Add segment immediately with translation: null (processing)
293
+ const tempSegment = LocalDB.addSegment({
294
+ groupId,
295
+ originalText: text,
296
+ translatedText: null,
297
+ sequenceNumber: sequenceNumberRef.current,
298
+ createdBy: user.id,
299
+ });
300
+ setSegments((prev: TranscriptionSegment[]) => {
301
+ const exists = prev.some((s: TranscriptionSegment) => s.id === tempSegment.id);
302
+ if (exists) return prev;
303
+ return [...prev, tempSegment].sort((a: TranscriptionSegment, b: TranscriptionSegment) => a.sequence_number - b.sequence_number);
304
+ });
305
+ sequenceNumberRef.current++;
306
+ // Start translation in background for this segment
307
+ setTranslatingIds((prev) => new Set([...prev, tempSegment.id]));
308
+ translateSegment(tempSegment);
309
+ };
310
+
311
+ // Translate a segment in the background
312
+ const translateSegment = async (segment: TranscriptionSegment) => {
313
+ if (!group) return;
314
+ let translatedText: string | null = null;
315
+ if (group && group.source_language !== group.target_language) {
316
+ const attempts: string[] = [];
317
+ attempts.push(group.source_language === 'auto' ? 'auto' : group.source_language);
318
+ if (!attempts.includes('ms')) attempts.push('ms');
319
+ if (!attempts.includes('id')) attempts.push('id');
320
+ let foundTranslation: string | null = null;
321
+ for (const src of attempts) {
322
+ const res = await translateText({ text: segment.original_text, source_language: src, target_language: group.target_language });
323
+ const isNoModel = typeof res === 'string' && /^\[No model for/i.test(res);
324
+ const isEcho = !!res && res.trim() === segment.original_text.trim();
325
+ if (res && !isNoModel && !isEcho) {
326
+ foundTranslation = res;
327
+ break;
328
+ }
329
+ }
330
+ if (foundTranslation) {
331
+ translatedText = foundTranslation;
332
+ }
333
+ } else {
334
+ translatedText = segment.original_text;
335
+ }
336
+ setSegments((prev: TranscriptionSegment[]) => prev.map((s: TranscriptionSegment) =>
337
+ s.id === segment.id ? { ...s, translated_text: translatedText } : s
338
+ ).sort((a: TranscriptionSegment, b: TranscriptionSegment) => a.sequence_number - b.sequence_number));
339
+ LocalDB.updateSegmentTranslation(groupId, segment.id, translatedText);
340
+ setTranslatingIds((prev) => {
341
+ const next = new Set(prev);
342
+ next.delete(segment.id);
343
+ return next;
344
+ });
345
+ };
346
+
347
+ // Retry translation for a segment
348
+ const retryTranslateOne = async (seg: TranscriptionSegment) => {
349
+ if (!group) return;
350
+ setRetryingIds((prev) => new Set(Array.from(prev).concat(seg.id)));
351
+ setSegments((prev: TranscriptionSegment[]) => prev.map((s: TranscriptionSegment) =>
352
+ s.id === seg.id ? { ...s, translated_text: null } : s
353
+ ));
354
+ setTranslatingIds((prev) => new Set([...prev, seg.id]));
355
+ await translateSegment(seg);
356
+ setRetryingIds((prev) => {
357
+ const next = new Set(prev);
358
+ next.delete(seg.id);
359
+ return next;
360
+ });
361
+ };
362
+
363
+ // Translate all segments currently missing a translation
364
+ const retryTranslateMissing = async () => {
365
+ if (!group) return;
366
+ setBulkRetrying(true);
367
+ try {
368
+ const missing = segments.filter((seg) => !seg.translated_text || seg.translated_text === seg.original_text);
369
+ await Promise.all(missing.map(async (seg) => {
370
+ setRetryingIds((prev) => new Set(Array.from(prev).concat(seg.id)));
371
+ setSegments((prev: TranscriptionSegment[]) => prev.map((s: TranscriptionSegment) =>
372
+ s.id === seg.id ? { ...s, translated_text: null } : s
373
+ ));
374
+ setTranslatingIds((prev) => new Set([...prev, seg.id]));
375
+ await translateSegment(seg);
376
+ setRetryingIds((prev) => {
377
+ const next = new Set(prev);
378
+ next.delete(seg.id);
379
+ return next;
380
+ });
381
+ }));
382
+ } finally {
383
+ setBulkRetrying(false);
384
+ }
385
+ };
386
+
387
+ const endSession = async () => {
388
+ stopRecording();
389
+ LocalDB.setGroupActive(groupId, false);
390
+
391
+ navigate('/');
392
+ };
393
+
394
+ // Upload removed
395
+
396
+ const deleteSession = async () => {
397
+ stopRecording();
398
+ if (confirm('Delete this session? This will remove all local data for it.')) {
399
+ LocalDB.deleteGroup(groupId);
400
+ navigate('/');
401
+ }
402
+ };
403
+
404
+ const copyJoinCode = () => {
405
+ if (group) {
406
+ navigator.clipboard.writeText(group.join_code);
407
+ setCopied(true);
408
+ setTimeout(() => setCopied(false), 2000);
409
+ }
410
+ };
411
+
412
+ // Manual preload removed; handled automatically via autoPreloadForGroup()
413
+
414
+ const handleExport = async () => {
415
+ if (!group) return;
416
+ const data = LocalDB.exportGroup(groupId);
417
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
418
+ const url = URL.createObjectURL(blob);
419
+ const a = document.createElement('a');
420
+ a.href = url;
421
+ a.download = `${group.name.replace(/[^a-z0-9-]+/gi, '_') || 'session'}_${group.join_code}.json`;
422
+ document.body.appendChild(a);
423
+ a.click();
424
+ a.remove();
425
+ URL.revokeObjectURL(url);
426
+ };
427
+
428
+ if (!group) {
429
+ return (
430
+ <div className="min-h-screen bg-gray-100 flex items-center justify-center">
431
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
432
+ </div>
433
+ );
434
+ }
435
+
436
+ const speechSupported = typeof window !== 'undefined' &&
437
+ (("webkitSpeechRecognition" in window) || ("SpeechRecognition" in window));
438
+
439
+ return (
440
+ <div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
441
+ <div className="max-w-4xl mx-auto p-4">
442
+ <div className="bg-white rounded-xl shadow-lg mb-4">
443
+ <div className="p-6 border-b border-gray-200">
444
+ <div className="flex items-center justify-between mb-4">
445
+ <h1 className="text-2xl font-bold text-gray-900">{group.name}</h1>
446
+ <div className="flex items-center space-x-2">
447
+ <div className="flex items-center bg-gray-100 rounded-lg px-3 py-2">
448
+ <Users className="w-5 h-5 text-gray-600 mr-2" />
449
+ <span className="font-semibold text-gray-900">{memberCount}</span>
450
+ </div>
451
+ <div className="flex items-center rounded-lg px-3 py-2 border" title={apiOk === null ? 'Checking API' : apiOk ? 'API Online' : 'API Offline'}>
452
+ <span className={`w-2 h-2 rounded-full mr-2 ${apiOk === null ? 'bg-gray-400 animate-pulse' : apiOk ? 'bg-green-600' : 'bg-red-600'}`}></span>
453
+ <span className="text-sm text-gray-700">API</span>
454
+ </div>
455
+ {/* Toggle to show only original text */}
456
+ <label className="ml-2 flex items-center text-xs cursor-pointer">
457
+ <input
458
+ type="checkbox"
459
+ checked={showOriginalsOnly}
460
+ onChange={e => setShowOriginalsOnly(e.target.checked)}
461
+ className="mr-1"
462
+ />
463
+ Show only originals
464
+ </label>
465
+ {/* Malay-English model test button */}
466
+ <button
467
+ onClick={async () => {
468
+ setMsEnChecking(true);
469
+ setMsEnAvailable(null);
470
+ try {
471
+ const ok = await preloadModel({ source_language: 'ms', target_language: 'en' });
472
+ setMsEnAvailable(ok);
473
+ } finally {
474
+ setMsEnChecking(false);
475
+ }
476
+ }}
477
+ className="ml-2 px-3 py-2 rounded-lg border text-xs"
478
+ disabled={msEnChecking}
479
+ title="Check if Malay-English model is available"
480
+ >
481
+ {msEnChecking ? 'Checking…' : 'Test Malay→English'}
482
+ </button>
483
+ {msEnAvailable !== null && (
484
+ <span className={`ml-2 text-xs font-bold ${msEnAvailable ? 'text-green-600' : 'text-red-600'}`}>{msEnAvailable ? 'Available' : 'Not available'}</span>
485
+ )}
486
+ </div>
487
+ </div>
488
+
489
+ <div className="flex items-center space-x-3">
490
+ <div className="flex-1 bg-blue-50 rounded-lg p-3 flex items-center justify-between">
491
+ <div>
492
+ <span className="text-sm text-gray-600">Join Code:</span>
493
+ <span className="ml-2 text-2xl font-mono font-bold text-blue-600">
494
+ {group.join_code}
495
+ </span>
496
+ </div>
497
+ <button
498
+ onClick={copyJoinCode}
499
+ className="p-2 hover:bg-blue-100 rounded-lg transition-colors"
500
+ >
501
+ {copied ? (
502
+ <Check className="w-5 h-5 text-green-600" />
503
+ ) : (
504
+ <Copy className="w-5 h-5 text-blue-600" />
505
+ )}
506
+ </button>
507
+ </div>
508
+
509
+ <button
510
+ onClick={() => setShowQR(true)}
511
+ className="bg-blue-600 text-white p-3 rounded-lg hover:bg-blue-700 transition-colors"
512
+ >
513
+ <QrCode className="w-6 h-6" />
514
+ </button>
515
+ </div>
516
+ </div>
517
+
518
+ <div className="p-6">
519
+ <div className="flex items-center justify-center space-x-4 mb-6">
520
+ {!isRecording ? (
521
+ <button
522
+ onClick={startRecording}
523
+ disabled={!speechSupported}
524
+ className="bg-green-600 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-8 py-4 rounded-full font-semibold hover:bg-green-700 transition-colors flex items-center shadow-lg"
525
+ >
526
+ <Mic className="w-6 h-6 mr-2" />
527
+ Start Recording
528
+ </button>
529
+ ) : (
530
+ <button
531
+ onClick={stopRecording}
532
+ className="bg-red-600 text-white px-8 py-4 rounded-full font-semibold hover:bg-red-700 transition-colors flex items-center shadow-lg animate-pulse"
533
+ >
534
+ <MicOff className="w-6 h-6 mr-2" />
535
+ Stop Recording
536
+ </button>
537
+ )}
538
+
539
+ {/* Mic language selector removed for simplicity; we infer from group source */}
540
+
541
+ <button
542
+ onClick={endSession}
543
+ className="bg-gray-600 text-white px-6 py-4 rounded-full font-semibold hover:bg-gray-700 transition-colors flex items-center"
544
+ >
545
+ <StopCircle className="w-5 h-5 mr-2" />
546
+ End Session
547
+ </button>
548
+ {/* Preload button removed; model warm-up runs automatically */}
549
+ <button
550
+ onClick={deleteSession}
551
+ className="bg-red-600 text-white px-6 py-4 rounded-full font-semibold hover:bg-red-700 transition-colors flex items-center"
552
+ >
553
+ <Trash2 className="w-5 h-5 mr-2" />
554
+ Delete Session
555
+ </button>
556
+ <button
557
+ onClick={handleExport}
558
+ className="bg-white border px-6 py-4 rounded-full font-semibold hover:bg-gray-50 transition-colors flex items-center"
559
+ title="Export this session as JSON so you can delete safely if storage is full"
560
+ >
561
+ <Download className="w-5 h-5 mr-2" />
562
+ Export JSON
563
+ </button>
564
+ </div>
565
+
566
+
567
+ {/* Advanced audio upload for Whisper STT */}
568
+ {apiOk && sttAvailable && (
569
+ <div className="mt-4">
570
+ <label className="block text-sm text-gray-600 mb-1">Upload audio (Whisper)</label>
571
+ <div className="flex items-center gap-2">
572
+ <input
573
+ type="file"
574
+ accept="audio/*"
575
+ onChange={async (e) => {
576
+ const file = e.target.files?.[0];
577
+ if (!file) return;
578
+ setSttBusy(true);
579
+ try {
580
+ // Use transcribeAudio from lib/api for Whisper backend
581
+ const res = await transcribeAudio(file, (group?.source_language || undefined));
582
+ if (res && res.text && res.text.trim()) {
583
+ await saveSegment(res.text.trim());
584
+ }
585
+ } finally {
586
+ setSttBusy(false);
587
+ e.currentTarget.value = '';
588
+ }
589
+ }}
590
+ disabled={sttBusy}
591
+ className="border rounded-lg px-3 py-2 text-sm"
592
+ />
593
+ {sttBusy && <span className="text-xs text-gray-500">Transcribing…</span>}
594
+ </div>
595
+ <div className="text-xs text-gray-500 mt-1">Supported: common audio types; processed server-side via Whisper.</div>
596
+ </div>
597
+ )}
598
+ {apiOk && (
599
+ <div className="mt-2 text-center">
600
+ <span className="text-xs text-gray-500">
601
+ Model: {modelReady === null ? 'warming…' : modelReady ? 'ready' : 'will load on first request'}
602
+ </span>
603
+ </div>
604
+ )}
605
+
606
+ {isRecording && (
607
+ <div className="text-center mb-4">
608
+ <div className="inline-flex items-center bg-red-100 text-red-700 px-4 py-2 rounded-full">
609
+ <span className="w-3 h-3 bg-red-600 rounded-full mr-2 animate-pulse"></span>
610
+ Recording in progress
611
+ </div>
612
+ </div>
613
+ )}
614
+ {!isRecording && !speechSupported && (
615
+ <div className="text-center mb-4">
616
+ <div className="inline-flex items-center bg-yellow-100 text-yellow-800 px-4 py-2 rounded-full">
617
+ Your browser doesn't support Speech Recognition. Use Chrome/Edge or the manual input below.
618
+ </div>
619
+ </div>
620
+ )}
621
+ {/* Manual input fallback for quick testing or browsers without speech recognition */}
622
+ <ManualInput onSubmit={saveSegment} disabled={!!isRecording && !!recognitionRef.current} />
623
+ </div>
624
+ </div>
625
+
626
+ <div className="bg-white rounded-xl shadow-lg p-6">
627
+ <div className="flex items-center justify-between mb-4">
628
+ <h2 className="text-xl font-bold text-gray-900">Live Transcription</h2>
629
+ <button
630
+ onClick={retryTranslateMissing}
631
+ disabled={bulkRetrying || segments.every((s) => s.translated_text && s.translated_text !== s.original_text)}
632
+ className="text-sm px-3 py-1 rounded-md border disabled:opacity-50 hover:bg-gray-50"
633
+ title="Retry translation for lines without English"
634
+ >
635
+ {bulkRetrying ? 'Translating…' : 'Translate missing'}
636
+ </button>
637
+ </div>
638
+ <div className="space-y-3 max-h-96 overflow-y-auto">
639
+ <div className="text-xs text-gray-500 mb-2">Approx. storage used by this app: {Math.round(LocalDB.getStorageBytes()/1024)} KB</div>
640
+ {segments
641
+ .slice()
642
+ .sort((a, b) => a.sequence_number - b.sequence_number)
643
+ .map((segment) => (
644
+ <div key={segment.id} className="bg-gray-50 rounded-lg p-4">
645
+ <p className="text-gray-900">{segment.original_text}</p>
646
+ {!showOriginalsOnly && (
647
+ <>
648
+ {translatingIds.has(segment.id) || segment.translated_text === null ? (
649
+ <div className="mt-2 flex items-center gap-2">
650
+ <span className="text-xs text-blue-400 animate-pulse">Processing…</span>
651
+ </div>
652
+ ) : segment.translated_text && segment.translated_text !== segment.original_text ? (
653
+ <p className="text-blue-600 text-sm mt-2 italic">{segment.translated_text}</p>
654
+ ) : (
655
+ <div className="mt-2 flex items-center gap-2">
656
+ <span className="text-xs text-gray-400">No English yet</span>
657
+ <button
658
+ onClick={() => retryTranslateOne(segment)}
659
+ disabled={retryingIds.has(segment.id)}
660
+ className="text-xs px-2 py-1 border rounded-md disabled:opacity-50 hover:bg-gray-50"
661
+ >
662
+ {retryingIds.has(segment.id) ? 'Translating…' : 'Translate'}
663
+ </button>
664
+ </div>
665
+ )}
666
+ </>
667
+ )}
668
+ <span className="text-xs text-gray-500 mt-2 block">
669
+ {new Date(segment.created_at).toLocaleTimeString()}
670
+ </span>
671
+ </div>
672
+ ))}
673
+ {interimText && (
674
+ <div className="bg-yellow-50 rounded-lg p-4 border-2 border-yellow-200">
675
+ <p className="text-gray-700 italic">{interimText}</p>
676
+ <span className="text-xs text-gray-500 mt-2 block">Processing...</span>
677
+ </div>
678
+ )}
679
+ {segments.length === 0 && !interimText && (
680
+ <div className="text-center py-12 text-gray-500">
681
+ <Mic className="w-12 h-12 mx-auto mb-3 text-gray-400" />
682
+ <p>Start recording to see live transcriptions appear here</p>
683
+ </div>
684
+ )}
685
+ </div>
686
+ </div>
687
+ </div>
688
+
689
+ {showQR && group && (
690
+ <QRCodeDisplay
691
+ joinCode={group.join_code}
692
+ groupName={group.name}
693
+ onClose={() => setShowQR(false)}
694
+ />
695
+ )}
696
+ </div>
697
+ );
698
+ }
699
+
700
+ function ManualInput({ onSubmit, disabled }: { onSubmit: (text: string) => void | Promise<void>; disabled?: boolean }) {
701
+ const [text, setText] = useState('');
702
+ const [busy, setBusy] = useState(false);
703
+ const canSubmit = text.trim().length > 0 && !busy && !disabled;
704
+ return (
705
+ <div className="mt-4">
706
+ <label className="block text-sm text-gray-600 mb-1">Type a line to add manually</label>
707
+ <div className="flex gap-2">
708
+ <input
709
+ type="text"
710
+ value={text}
711
+ onChange={(e) => setText(e.target.value)}
712
+ onKeyDown={async (e) => {
713
+ if (e.key === 'Enter' && canSubmit) {
714
+ setBusy(true);
715
+ await onSubmit(text.trim());
716
+ setText('');
717
+ setBusy(false);
718
+ }
719
+ }}
720
+ className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
721
+ placeholder="Hello everyone..."
722
+ disabled={disabled}
723
+ />
724
+ <button
725
+ onClick={async () => {
726
+ if (!canSubmit) return;
727
+ setBusy(true);
728
+ await onSubmit(text.trim());
729
+ setText('');
730
+ setBusy(false);
731
+ }}
732
+ disabled={!canSubmit}
733
+ className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-700"
734
+ >
735
+ Add
736
+ </button>
737
+ </div>
738
+ </div>
739
+ );
740
+ }
src/components/JoinGroup.tsx ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { X, Loader2 } from 'lucide-react';
3
+ import { LocalDB } from '../lib/localdb';
4
+ import { useAuth } from '../contexts/useAuth';
5
+ import { useNavigate } from './NavigationHooks';
6
+
7
+ interface JoinGroupProps {
8
+ onClose: () => void;
9
+ }
10
+
11
+ export function JoinGroup({ onClose }: JoinGroupProps) {
12
+ const { user } = useAuth();
13
+ const navigate = useNavigate();
14
+ const [joinCode, setJoinCode] = useState('');
15
+ const [loading, setLoading] = useState(false);
16
+ const [error, setError] = useState('');
17
+
18
+ const handleSubmit = async (e: React.FormEvent) => {
19
+ e.preventDefault();
20
+ if (!user) return;
21
+
22
+ setLoading(true);
23
+ setError('');
24
+
25
+ try {
26
+ const group = LocalDB.findActiveGroupByJoinCode(joinCode);
27
+ if (!group) {
28
+ setError('Invalid or expired join code');
29
+ return;
30
+ }
31
+
32
+ LocalDB.addMember(group.id, user.id, false);
33
+
34
+ navigate(`/viewer/${group.id}`);
35
+ onClose();
36
+ } catch (err) {
37
+ setError(err instanceof Error ? err.message : 'Failed to join group');
38
+ } finally {
39
+ setLoading(false);
40
+ }
41
+ };
42
+
43
+ return (
44
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
45
+ <div className="bg-white rounded-2xl shadow-2xl w-full max-w-md">
46
+ <div className="flex items-center justify-between p-6 border-b border-gray-200">
47
+ <h2 className="text-2xl font-bold text-gray-900">Join a Session</h2>
48
+ <button
49
+ onClick={onClose}
50
+ className="text-gray-400 hover:text-gray-600 transition-colors"
51
+ >
52
+ <X className="w-6 h-6" />
53
+ </button>
54
+ </div>
55
+
56
+ <form onSubmit={handleSubmit} className="p-6 space-y-4">
57
+ <div>
58
+ <label htmlFor="joinCode" className="block text-sm font-medium text-gray-700 mb-1">
59
+ Join Code
60
+ </label>
61
+ <input
62
+ id="joinCode"
63
+ type="text"
64
+ value={joinCode}
65
+ onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
66
+ required
67
+ maxLength={6}
68
+ className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent uppercase text-center text-2xl font-mono tracking-widest"
69
+ placeholder="ABC123"
70
+ />
71
+ <p className="mt-2 text-sm text-gray-500">
72
+ Enter the 6-character code from the host
73
+ </p>
74
+ </div>
75
+
76
+ {error && (
77
+ <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
78
+ {error}
79
+ </div>
80
+ )}
81
+
82
+ <button
83
+ type="submit"
84
+ disabled={loading || joinCode.length !== 6}
85
+ className="w-full bg-green-600 text-white py-3 rounded-lg font-semibold hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center"
86
+ >
87
+ {loading ? (
88
+ <>
89
+ <Loader2 className="w-5 h-5 mr-2 animate-spin" />
90
+ Joining...
91
+ </>
92
+ ) : (
93
+ 'Join Session'
94
+ )}
95
+ </button>
96
+ </form>
97
+ </div>
98
+ </div>
99
+ );
100
+ }
src/components/MyGroups.tsx ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState, useCallback } from 'react';
2
+ import { LocalDB, Group as LocalGroup } from '../lib/localdb';
3
+ import { useAuth } from '../contexts/useAuth';
4
+ import { Radio, Users, Clock, Trash2 } from 'lucide-react';
5
+ import { useNavigate } from './NavigationHooks';
6
+
7
+ type UserGroup = LocalGroup & { isHost: boolean };
8
+
9
+ export function MyGroups() {
10
+ const { user } = useAuth();
11
+ const navigate = useNavigate();
12
+ const [groups, setGroups] = useState<UserGroup[]>([]);
13
+ const [loading, setLoading] = useState(true);
14
+
15
+ const loadGroups = useCallback(() => {
16
+ if (!user) return;
17
+ try {
18
+ const groupsForUser = LocalDB.getGroupsForUser(user.id) as UserGroup[];
19
+ setGroups(groupsForUser);
20
+ } finally {
21
+ setLoading(false);
22
+ }
23
+ }, [user]);
24
+
25
+ useEffect(() => {
26
+ if (!user) return;
27
+ loadGroups();
28
+ }, [user, loadGroups]);
29
+
30
+ // ...existing code...
31
+
32
+ const formatDate = (dateString: string) => {
33
+ const date = new Date(dateString);
34
+ return (
35
+ date.toLocaleDateString() +
36
+ ' ' +
37
+ date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
38
+ );
39
+ };
40
+
41
+ if (loading) {
42
+ return (
43
+ <div className="bg-white rounded-xl shadow-md p-8">
44
+ <div className="animate-pulse space-y-4">
45
+ <div className="h-4 bg-gray-200 rounded w-1/4"></div>
46
+ <div className="h-20 bg-gray-200 rounded"></div>
47
+ </div>
48
+ </div>
49
+ );
50
+ }
51
+
52
+ if (groups.length === 0) {
53
+ return (
54
+ <div className="bg-white rounded-xl shadow-md p-8 text-center">
55
+ <Radio className="w-12 h-12 text-gray-400 mx-auto mb-4" />
56
+ <h3 className="text-lg font-semibold text-gray-900 mb-2">No Sessions Yet</h3>
57
+ <p className="text-gray-600">Create a session to host or join one with a code</p>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ return (
63
+ <div className="bg-white rounded-xl shadow-md p-6">
64
+ <h2 className="text-2xl font-bold text-gray-900 mb-4">My Sessions</h2>
65
+ <div className="space-y-3">
66
+ {groups.map((group) => (
67
+ <div
68
+ key={group.id}
69
+ className="w-full bg-gray-50 rounded-lg p-4 text-left group hover:bg-gray-100 transition-colors"
70
+ >
71
+ <div className="flex items-start justify-between">
72
+ <button
73
+ onClick={() =>
74
+ navigate(group.isHost ? `/host/${group.id}` : `/viewer/${group.id}`)
75
+ }
76
+ className="flex-1 text-left"
77
+ >
78
+ <div className="flex items-center mb-2">
79
+ <h3 className="text-lg font-semibold text-gray-900">{group.name}</h3>
80
+ {group.is_active && (
81
+ <span className="ml-3 px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full">
82
+ Active
83
+ </span>
84
+ )}
85
+ {group.isHost && (
86
+ <span className="ml-2 px-2 py-1 bg-blue-100 text-blue-700 text-xs font-medium rounded-full">
87
+ Host
88
+ </span>
89
+ )}
90
+ </div>
91
+ <div className="flex items-center text-sm text-gray-600 space-x-4">
92
+ <span className="flex items-center">
93
+ <Clock className="w-4 h-4 mr-1" />
94
+ {formatDate(group.created_at)}
95
+ </span>
96
+ <span className="flex items-center">
97
+ <Users className="w-4 h-4 mr-1" />
98
+ Code: {group.join_code}
99
+ </span>
100
+ </div>
101
+ </button>
102
+ {group.isHost && (
103
+ <button
104
+ aria-label="Delete session"
105
+ title="Delete session"
106
+ onClick={() => {
107
+ if (
108
+ confirm('Delete this session? This will remove all local data for it.')
109
+ ) {
110
+ LocalDB.deleteGroup(group.id);
111
+ setGroups((prev) => prev.filter((g) => g.id !== group.id));
112
+ }
113
+ }}
114
+ className="ml-3 p-2 rounded-lg text-red-600 hover:bg-red-50"
115
+ >
116
+ <Trash2 className="w-5 h-5" />
117
+ </button>
118
+ )}
119
+ </div>
120
+ </div>
121
+ ))}
122
+ </div>
123
+ </div>
124
+ );
125
+ }
src/components/Navigation.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createContext, useState, useEffect } from 'react';
2
+
3
+ export interface NavigationContextType {
4
+ currentPath: string;
5
+ navigate: (path: string) => void;
6
+ }
7
+
8
+ export const NavigationContext = createContext<NavigationContextType | undefined>(undefined);
9
+
10
+ export function NavigationProvider({ children }: { children: any }) {
11
+ const [currentPath, setCurrentPath] = useState<string>(window.location.hash.slice(1) || '/');
12
+
13
+ useEffect(() => {
14
+ const handleHashChange = () => {
15
+ setCurrentPath(window.location.hash.slice(1) || '/');
16
+ };
17
+ window.addEventListener('hashchange', handleHashChange);
18
+ return () => window.removeEventListener('hashchange', handleHashChange);
19
+ }, []);
20
+
21
+ const navigate = (path: string) => {
22
+ window.location.hash = path;
23
+ };
24
+
25
+ return (
26
+ <NavigationContext.Provider value={{ currentPath, navigate }}>
27
+ {children}
28
+ </NavigationContext.Provider>
29
+ );
30
+ }
src/components/NavigationHooks.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useContext } from 'react';
2
+ import { NavigationContext, NavigationContextType } from './Navigation';
3
+
4
+ export function useNavigate() {
5
+ const context = useContext(NavigationContext) as NavigationContextType | undefined;
6
+ if (!context) {
7
+ throw new Error('useNavigate must be used within NavigationProvider');
8
+ }
9
+ return context.navigate;
10
+ }
11
+
12
+ export function useCurrentPath() {
13
+ const context = useContext(NavigationContext) as NavigationContextType | undefined;
14
+ if (!context) {
15
+ throw new Error('useCurrentPath must be used within NavigationProvider');
16
+ }
17
+ return context.currentPath;
18
+ }
src/components/QRCodeDisplay.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { X } from 'lucide-react';
2
+
3
+ interface QRCodeDisplayProps {
4
+ joinCode: string;
5
+ groupName: string;
6
+ onClose: () => void;
7
+ }
8
+
9
+ export function QRCodeDisplay({ joinCode, groupName, onClose }: QRCodeDisplayProps) {
10
+ const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(joinCode)}`;
11
+
12
+ return (
13
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
14
+ <div className="bg-white rounded-2xl shadow-2xl w-full max-w-md">
15
+ <div className="flex items-center justify-between p-6 border-b border-gray-200">
16
+ <h2 className="text-2xl font-bold text-gray-900">Share Session</h2>
17
+ <button
18
+ onClick={onClose}
19
+ className="text-gray-400 hover:text-gray-600 transition-colors"
20
+ >
21
+ <X className="w-6 h-6" />
22
+ </button>
23
+ </div>
24
+
25
+ <div className="p-6 text-center">
26
+ <p className="text-gray-600 mb-4">Scan this QR code to join</p>
27
+ <p className="text-xl font-bold text-gray-900 mb-6">{groupName}</p>
28
+
29
+ <div className="bg-gray-100 rounded-xl p-6 mb-6">
30
+ <img
31
+ src={qrCodeUrl}
32
+ alt="QR Code"
33
+ className="w-full max-w-xs mx-auto"
34
+ />
35
+ </div>
36
+
37
+ <div className="bg-blue-50 rounded-lg p-4">
38
+ <p className="text-sm text-gray-600 mb-2">Or enter this code:</p>
39
+ <p className="text-4xl font-mono font-bold text-blue-600">{joinCode}</p>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ );
45
+ }
src/components/ViewerView.tsx ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState, useCallback } from 'react';
2
+ import { LocalDB, Group, TranscriptionSegment } from '../lib/localdb';
3
+ import { getSegmentsForViewer } from '../lib/localdb';
4
+ import { useAuth } from '../contexts/useAuth';
5
+ import { useNavigate } from './NavigationHooks';
6
+ import { Radio, Users, ArrowLeft } from 'lucide-react';
7
+
8
+ interface ViewerViewProps {
9
+ groupId: string;
10
+ }
11
+
12
+ export function ViewerView({ groupId }: ViewerViewProps) {
13
+ // Responsive design improvements applied; no unused code remains
14
+ // Investor Mode state
15
+ const [investorMode, setInvestorMode] = useState(false);
16
+ const [supportedPairs, setSupportedPairs] = useState<string[]>([]);
17
+ const [backendHealth, setBackendHealth] = useState<string>('');
18
+
19
+ // Fetch supported pairs and backend health for investor mode
20
+ useEffect(() => {
21
+ if (investorMode) {
22
+ fetch('/models')
23
+ .then(res => res.json())
24
+ .then(data => setSupportedPairs(data.models || []));
25
+ fetch('/health')
26
+ .then(res => res.json())
27
+ .then(data => setBackendHealth(data.device || 'unknown'));
28
+ }
29
+ }, [investorMode]);
30
+ // Target language selection removed for reliability
31
+ // Target language state (persist per viewer)
32
+ const viewerId = 'viewer_' + (window.navigator.userAgent || 'default'); // Replace with real viewer ID if available
33
+ const { user } = useAuth();
34
+ const navigate = useNavigate();
35
+ const [group, setGroup] = useState<Group | null>(null);
36
+ const [segments, setSegments] = useState<TranscriptionSegment[]>([]);
37
+ const [memberCount, setMemberCount] = useState(0);
38
+ const [isActive, setIsActive] = useState(true);
39
+
40
+ const loadGroup = useCallback(async () => {
41
+ const data = LocalDB.getGroupById(groupId);
42
+ if (data) {
43
+ setGroup(data);
44
+ setIsActive(data.is_active);
45
+ } else {
46
+ navigate('/');
47
+ }
48
+ }, [groupId, navigate]);
49
+
50
+ const loadSegments = useCallback(async () => {
51
+ const data = getSegmentsForViewer(groupId, viewerId);
52
+ setSegments(data);
53
+ setTimeout(() => {
54
+ const container = document.getElementById('transcription-container');
55
+ if (container) {
56
+ container.scrollTop = container.scrollHeight;
57
+ }
58
+ }, 100);
59
+ }, [groupId]);
60
+
61
+ const loadMemberCount = useCallback(async () => {
62
+ setMemberCount(LocalDB.getMemberCount(groupId));
63
+ }, [groupId]);
64
+
65
+ useEffect(() => {
66
+ if (!user) return;
67
+
68
+ loadGroup();
69
+ loadSegments();
70
+ loadMemberCount();
71
+
72
+ const unsubSeg = LocalDB.onSegmentsInserted(groupId, (_) => {
73
+ loadSegments();
74
+ setTimeout(() => {
75
+ const container = document.getElementById('transcription-container');
76
+ if (container) {
77
+ container.scrollTop = container.scrollHeight;
78
+ }
79
+ }, 100);
80
+ });
81
+
82
+ const unsubGroup = LocalDB.onGroupUpdated(groupId, (updated) => {
83
+ setGroup(updated);
84
+ setIsActive(updated.is_active);
85
+ });
86
+
87
+ const unsubMembers = LocalDB.onMembersChanged(groupId, () => {
88
+ loadMemberCount();
89
+ });
90
+
91
+ return () => {
92
+ unsubSeg();
93
+ unsubGroup();
94
+ unsubMembers();
95
+ };
96
+ }, [groupId, user, loadGroup, loadSegments, loadMemberCount]);
97
+
98
+ // ...existing code...
99
+
100
+ const leaveSession = async () => {
101
+ if (user) {
102
+ LocalDB.removeMember(groupId, user.id);
103
+ }
104
+ navigate('/');
105
+ };
106
+
107
+ if (!group) {
108
+ return (
109
+ <>
110
+ {/* Branding & Value Proposition Overlay */}
111
+ <div className="fixed top-0 left-0 w-full z-40 bg-gradient-to-r from-green-400 to-blue-500 text-white py-4 px-6 flex items-center justify-between shadow-lg animate-fade-in">
112
+ <div className="flex items-center gap-3">
113
+ <img src="/logo.svg" alt="Brand Logo" className="h-8 w-8 rounded-full shadow-md" />
114
+ <span className="font-extrabold text-xl tracking-wide">Live Multilingual Demo</span>
115
+ </div>
116
+ <span className="font-semibold text-sm">Break language barriers instantly in meetings, events, and broadcasts.</span>
117
+ </div>
118
+ <div className="fixed top-4 right-4 z-50">
119
+ <button
120
+ className={`px-4 py-2 rounded-lg font-bold shadow-lg ${investorMode ? 'bg-yellow-400 text-black' : 'bg-gray-800 text-white'}`}
121
+ onClick={() => setInvestorMode(v => !v)}
122
+ >
123
+ {investorMode ? 'Exit Investor Mode' : 'Investor Mode'}
124
+ </button>
125
+ </div>
126
+ {investorMode && (
127
+ <div className="fixed top-16 right-4 z-50 bg-white border border-yellow-400 rounded-xl shadow-xl p-6 w-96 animate-fade-in">
128
+ <h2 className="text-2xl font-bold mb-2 text-yellow-600">Investor Pitch Mode</h2>
129
+ <p className="mb-4 text-gray-700">Break language barriers instantly in meetings, events, and broadcasts. Scalable, real-time, multilingual translation for global impact.</p>
130
+ <div className="mb-2">
131
+ <span className="font-semibold">Supported Language Pairs:</span>
132
+ <ul className="list-disc ml-6 text-sm mt-1">
133
+ {supportedPairs.map(pair => (
134
+ <li key={pair}>{pair}</li>
135
+ ))}
136
+ </ul>
137
+ </div>
138
+ <div className="mb-2">
139
+ <span className="font-semibold">Backend Device:</span> <span className="text-blue-600">{backendHealth}</span>
140
+ </div>
141
+ <div className="mb-2">
142
+ <span className="font-semibold">Live Analytics:</span>
143
+ <ul className="list-disc ml-6 text-sm mt-1">
144
+ <li>Instant transcription and translation</li>
145
+ <li>Real-time error handling and retry</li>
146
+ <li>Mobile & desktop friendly</li>
147
+ <li>Cloud-ready, scalable backend</li>
148
+ </ul>
149
+ </div>
150
+ <div className="mt-4 text-center text-xs text-gray-500">
151
+ <span>Backend is cloud-ready and can scale to millions of users.</span>
152
+ </div>
153
+ </div>
154
+ )}
155
+ <div className="min-h-screen bg-gray-100 flex items-center justify-center">
156
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
157
+ </div>
158
+ </>
159
+ );
160
+ }
161
+
162
+ return (
163
+ <div className="min-h-screen bg-gradient-to-br from-slate-50 to-green-50">
164
+ <div className="max-w-4xl mx-auto p-4">
165
+ <div className="bg-white rounded-xl shadow-lg mb-4">
166
+ <div className="p-6 border-b border-gray-200">
167
+ <div className="flex items-center justify-between mb-4">
168
+ <div className="flex items-center">
169
+ <button
170
+ onClick={leaveSession}
171
+ className="mr-4 p-2 hover:bg-gray-100 rounded-lg transition-colors"
172
+ >
173
+ <ArrowLeft className="w-6 h-6 text-gray-600" />
174
+ </button>
175
+ <div>
176
+ <h1 className="text-2xl font-bold text-gray-900">{group.name}</h1>
177
+ <p className="text-sm text-gray-600">Viewer Mode</p>
178
+ </div>
179
+ </div>
180
+ <div className="flex items-center space-x-3">
181
+ <div className="flex items-center bg-gray-100 rounded-lg px-3 py-2">
182
+ <Users className="w-5 h-5 text-gray-600 mr-2" />
183
+ <span className="font-semibold text-gray-900">{memberCount}</span>
184
+ </div>
185
+ {isActive ? (
186
+ <div className="flex items-center bg-green-100 text-green-700 px-3 py-2 rounded-lg">
187
+ <span className="w-2 h-2 bg-green-600 rounded-full mr-2 animate-pulse"></span>
188
+ <span className="text-sm font-medium">Live</span>
189
+ </div>
190
+ ) : (
191
+ <div className="bg-gray-200 text-gray-700 px-3 py-2 rounded-lg">
192
+ <span className="text-sm font-medium">Ended</span>
193
+ </div>
194
+ )}
195
+ </div>
196
+ </div>
197
+ </div>
198
+ </div>
199
+
200
+ <div className="mb-4 flex items-center gap-4">
201
+ <label className="text-sm font-semibold">Target Language:</label>
202
+ {/* Target language selection removed for reliability */}
203
+ </div>
204
+ <div className="bg-white rounded-xl shadow-lg p-6">
205
+ <div className="flex items-center justify-between mb-4">
206
+ <h2 className="text-xl font-bold text-gray-900">Live Transcription</h2>
207
+ {isActive && (
208
+ <div className="text-sm text-green-600 flex items-center">
209
+ <Radio className="w-4 h-4 mr-1 animate-pulse" />
210
+ Receiving updates
211
+ </div>
212
+ )}
213
+ </div>
214
+
215
+ <div
216
+ id="transcription-container"
217
+ className="space-y-3 max-h-[calc(100vh-300px)] overflow-y-auto"
218
+ >
219
+ {segments
220
+ .slice()
221
+ .sort((a, b) => a.sequence_number - b.sequence_number)
222
+ .map((segment) => (
223
+ <div
224
+ key={segment.id}
225
+ className="bg-gradient-to-r from-blue-50 to-green-50 rounded-lg p-4 shadow-sm border border-gray-200"
226
+ >
227
+ <p className="text-gray-900 text-lg leading-relaxed">{segment.original_text}</p>
228
+ {segment.translated_text === undefined ? (
229
+ <div className="mt-2 flex items-center gap-2">
230
+ <span className="text-xs text-blue-400 animate-pulse">Processing…</span>
231
+ </div>
232
+ ) : segment.translated_text && segment.translated_text !== segment.original_text ? (
233
+ <p className="text-blue-600 mt-2 italic leading-relaxed">
234
+ {segment.translated_text}
235
+ </p>
236
+ ) : (
237
+ <div className="mt-2 flex flex-col gap-2">
238
+ <span className="text-xs text-red-500">Translation failed or unavailable.</span>
239
+ <button
240
+ className="text-xs px-2 py-1 border rounded-md hover:bg-gray-50"
241
+ onClick={() => window.location.reload()}
242
+ title="Retry translation"
243
+ >
244
+ Retry
245
+ </button>
246
+ </div>
247
+ )}
248
+ <span className="text-xs text-gray-500 mt-2 block">
249
+ {new Date(segment.created_at).toLocaleTimeString()}
250
+ </span>
251
+ </div>
252
+ ))}
253
+
254
+ {segments.length === 0 && (
255
+ <div className="text-center py-16 text-gray-500">
256
+ <Radio className="w-16 h-16 mx-auto mb-4 text-gray-400" />
257
+ <p className="text-lg font-medium">Waiting for transcription to start...</p>
258
+ <p className="text-sm mt-2">The host will begin broadcasting shortly</p>
259
+ </div>
260
+ )}
261
+ </div>
262
+ </div>
263
+
264
+ {!isActive && segments.length > 0 && (
265
+ <div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-center">
266
+ <p className="text-yellow-800 font-medium">This session has ended</p>
267
+ <p className="text-yellow-700 text-sm mt-1">You can still view the transcription above</p>
268
+ </div>
269
+ )}
270
+ </div>
271
+ </div>
272
+ );
273
+ }
src/contexts/AuthContext.tsx ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createContext, useEffect, useState } from 'react';
2
+ import { User } from '@supabase/supabase-js';
3
+ import { supabase } from '../lib/supabase';
4
+
5
+ // Temporary bypass for login/auth while developing. Set to false to restore auth.
6
+ // TODO: Remove this flag when enabling real authentication again.
7
+ const BYPASS_AUTH = true;
8
+
9
+ interface AuthContextType {
10
+ user: User | null;
11
+ loading: boolean;
12
+ signUp: (email: string, password: string) => Promise<void>;
13
+ signIn: (email: string, password: string) => Promise<void>;
14
+ signOut: () => Promise<void>;
15
+ }
16
+
17
+ export const AuthContext = createContext<AuthContextType | undefined>(undefined);
18
+
19
+ export function AuthProvider({ children }: { children: any }) {
20
+ const [user, setUser] = useState<User | null>(null);
21
+ const [loading, setLoading] = useState(true);
22
+ console.log('[AuthContext] Initial user:', user, 'loading:', loading);
23
+
24
+ useEffect(() => {
25
+ if (BYPASS_AUTH) {
26
+ // Minimal dummy user object to satisfy types and UI logic
27
+ // Casting to User to avoid pulling in all required fields
28
+ const dummyUser = { id: 'dev-user' } as unknown as User;
29
+ setUser(dummyUser);
30
+ setLoading(false);
31
+ console.log('[AuthContext] BYPASS_AUTH set user:', dummyUser);
32
+ return;
33
+ }
34
+
35
+ supabase.auth.getSession().then(({ data: { session } }) => {
36
+ setUser(session?.user ?? null);
37
+ setLoading(false);
38
+ });
39
+
40
+ const { data: { subscription } } = supabase.auth.onAuthStateChange((_event: any, session: any) => {
41
+ (async () => {
42
+ setUser(session?.user ?? null);
43
+ })();
44
+ });
45
+
46
+ return () => subscription.unsubscribe();
47
+ }, []);
48
+
49
+ const signUp = async (email: string, password: string) => {
50
+ if (BYPASS_AUTH) return; // no-op while auth is bypassed
51
+ const { error } = await supabase.auth.signUp({
52
+ email,
53
+ password,
54
+ });
55
+ if (error) throw error;
56
+ };
57
+
58
+ const signIn = async (email: string, password: string) => {
59
+ if (BYPASS_AUTH) return; // no-op while auth is bypassed
60
+ const { error } = await supabase.auth.signInWithPassword({
61
+ email,
62
+ password,
63
+ });
64
+ if (error) throw error;
65
+ };
66
+
67
+ const signOut = async () => {
68
+ if (BYPASS_AUTH) return; // no-op while auth is bypassed
69
+ const { error } = await supabase.auth.signOut();
70
+ if (error) throw error;
71
+ };
72
+
73
+ return (
74
+ <AuthContext.Provider value={{ user, loading, signUp, signIn, signOut }}>
75
+ {children}
76
+ </AuthContext.Provider>
77
+ );
78
+ }
79
+
src/contexts/useAuth.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useContext } from 'react';
2
+ import { AuthContext } from './AuthContext';
3
+
4
+ export function useAuth() {
5
+ const context = useContext(AuthContext);
6
+ if (context === undefined) {
7
+ throw new Error('useAuth must be used within an AuthProvider');
8
+ }
9
+ return context;
10
+ }
src/index.css ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
src/lib/api.ts ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface TranslateRequest {
2
+ text: string;
3
+ source_language: string;
4
+ target_language: string;
5
+ }
6
+
7
+ export interface PreloadRequest {
8
+ source_language: string;
9
+ target_language: string;
10
+ }
11
+
12
+ const ENV_BASE = (import.meta as any).env?.VITE_API_URL as string | undefined;
13
+ const IS_PROD = (import.meta as any).env?.PROD as boolean | undefined;
14
+ // Dev default hits local FastAPI; prod default uses same-origin (Space serves both)
15
+ // Always talk to '/api' to avoid any collision with static frontend routes.
16
+ const DEFAULT_BASE = IS_PROD
17
+ ? (typeof window !== 'undefined' ? `${window.location.origin}/api` : undefined)
18
+ : 'http://127.0.0.1:8000/api';
19
+ const API_BASE: string | undefined = ENV_BASE ?? DEFAULT_BASE;
20
+
21
+ export async function translateText(req: TranslateRequest): Promise<string | null> {
22
+ if (!API_BASE) return null;
23
+ try {
24
+ const res = await fetch(`${API_BASE}/translate`, {
25
+ method: 'POST',
26
+ headers: { 'Content-Type': 'application/json' },
27
+ body: JSON.stringify(req),
28
+ });
29
+ if (!res.ok) return null;
30
+ const data = await res.json();
31
+ return (data && data.translated_text) || null;
32
+ } catch (e) {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export async function apiHealth(): Promise<boolean> {
38
+ if (!API_BASE) return false;
39
+ try {
40
+ const res = await fetch(`${API_BASE}/health`);
41
+ return res.ok;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ export async function preloadModel(req: PreloadRequest): Promise<boolean> {
48
+ if (!API_BASE) return false;
49
+ try {
50
+ const res = await fetch(`${API_BASE}/preload`, {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify(req),
54
+ });
55
+ if (!res.ok) return false;
56
+ const data = await res.json();
57
+ return !!data?.ok;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ export async function transcribeAudio(file: File, language?: string): Promise<{ text: string; language?: string } | null> {
64
+ if (!API_BASE) return null;
65
+ try {
66
+ const form = new FormData();
67
+ form.append('audio', file);
68
+ if (language) form.append('language', language);
69
+ const res = await fetch(`${API_BASE}/transcribe`, {
70
+ method: 'POST',
71
+ body: form,
72
+ });
73
+ if (!res.ok) return null;
74
+ const data = await res.json();
75
+ if (typeof data?.text === 'string') return { text: data.text, language: data.language };
76
+ return null;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ export async function sttStatus(): Promise<boolean> {
83
+ if (!API_BASE) return false;
84
+ try {
85
+ const res = await fetch(`${API_BASE}/stt_status`);
86
+ if (!res.ok) return false;
87
+ const data = await res.json();
88
+ return !!data?.available;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
src/lib/localdb.ts ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Use LocalDB.getSegments and TranscriptionSegment type from this file
2
+ // Store per-viewer target language preference
3
+ export function setViewerLanguage(viewerId: string, language: string) {
4
+ localStorage.setItem(`viewer_language_${viewerId}`, language);
5
+ }
6
+
7
+ export function getViewerLanguage(viewerId: string): string {
8
+ return localStorage.getItem(`viewer_language_${viewerId}`) || 'en';
9
+ }
10
+
11
+ // Optionally, update segment retrieval to use viewer's language
12
+ // Example usage: getSegmentsForViewer(groupId, viewerId)
13
+ export function getSegmentsForViewer(groupId: string, viewerId: string) {
14
+ const segments: TranscriptionSegment[] = LocalDB.getSegments(groupId);
15
+ const lang = getViewerLanguage(viewerId);
16
+ // Filter or map segments to show translation in viewer's language
17
+ return segments.map((seg: TranscriptionSegment) => ({
18
+ ...seg,
19
+ translated_text: seg.translations?.[lang] || seg.translated_text || seg.original_text,
20
+ }));
21
+ }
22
+ // Simple local data store using localStorage + in-memory events.
23
+ // This replaces the need for any backend. Data is stored per-browser.
24
+ // Cross-tab realtime: uses BroadcastChannel when available, with
25
+ // a localStorage 'storage' event fallback so viewers in another tab/window
26
+ // get immediate updates.
27
+
28
+ export interface Group {
29
+ id: string;
30
+ name: string;
31
+ host_id: string;
32
+ join_code: string;
33
+ source_language: string;
34
+ target_language: string;
35
+ is_active: boolean;
36
+ created_at: string;
37
+ expires_at: string;
38
+ }
39
+
40
+ export interface GroupMember {
41
+ id: string;
42
+ group_id: string;
43
+ user_id: string;
44
+ joined_at: string;
45
+ is_host: boolean;
46
+ }
47
+
48
+ export interface TranscriptionSegment {
49
+ id: string;
50
+ group_id: string;
51
+ original_text: string;
52
+ translated_text: string | null;
53
+ sequence_number: number;
54
+ created_at: string;
55
+ created_by: string;
56
+ // Map of language code to translated text
57
+ translations?: { [lang: string]: string };
58
+ }
59
+
60
+ // Storage keys
61
+ const K = {
62
+ groups: 'lt_groups',
63
+ members: 'lt_group_members',
64
+ segments: 'lt_segments',
65
+ };
66
+
67
+ function readArray<T>(key: string): T[] {
68
+ try {
69
+ const raw = localStorage.getItem(key);
70
+ return raw ? (JSON.parse(raw) as T[]) : [];
71
+ } catch {
72
+ return [];
73
+ }
74
+ }
75
+
76
+ function writeArray<T>(key: string, arr: T[]) {
77
+ localStorage.setItem(key, JSON.stringify(arr));
78
+ }
79
+
80
+ function uuid() {
81
+ // Use crypto if available for stable unique ids
82
+ if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
83
+ return (crypto as any).randomUUID();
84
+ }
85
+ return 'id-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
86
+ }
87
+
88
+ // Event Emitter for simple realtime within the same tab
89
+ type Unsubscribe = () => void;
90
+ const listeners = {
91
+ segmentsInserted: new Map<string, Set<(segment: TranscriptionSegment) => void>>(),
92
+ segmentsUpdated: new Map<string, Set<(segment: TranscriptionSegment) => void>>(),
93
+ groupUpdated: new Map<string, Set<(group: Group) => void>>(),
94
+ membersChanged: new Map<string, Set<() => void>>(),
95
+ };
96
+
97
+ type DBEvent =
98
+ | { type: 'segmentInserted'; groupId: string; payload: TranscriptionSegment }
99
+ | { type: 'segmentUpdated'; groupId: string; payload: TranscriptionSegment }
100
+ | { type: 'groupUpdated'; groupId: string; payload: Group }
101
+ | { type: 'membersChanged'; groupId: string };
102
+
103
+ // Broadcast channel (if supported)
104
+ const bc: BroadcastChannel | null =
105
+ typeof window !== 'undefined' && 'BroadcastChannel' in window
106
+ ? new (window as any).BroadcastChannel('lt_events')
107
+ : null;
108
+
109
+ function broadcast(evt: DBEvent) {
110
+ try {
111
+ bc?.postMessage(evt);
112
+ } catch {
113
+ // ignore cross-tab post failures
114
+ }
115
+ try {
116
+ // storage event fallback (fires in other tabs only)
117
+ localStorage.setItem('lt_event', JSON.stringify({ ...evt, ts: Date.now() }));
118
+ } catch {
119
+ // ignore storage fallback errors (quota/unsupported)
120
+ }
121
+ }
122
+
123
+ function handleEvent(evt: DBEvent) {
124
+ switch (evt.type) {
125
+ case 'segmentInserted':
126
+ emitSegmentInserted(evt.groupId, evt.payload);
127
+ break;
128
+ case 'segmentUpdated':
129
+ emitSegmentUpdated(evt.groupId, evt.payload);
130
+ break;
131
+ case 'groupUpdated':
132
+ emitGroupUpdated(evt.groupId, evt.payload);
133
+ break;
134
+ case 'membersChanged':
135
+ emitMembersChanged(evt.groupId);
136
+ break;
137
+ }
138
+ }
139
+
140
+ // Wire listeners for cross-tab communication once (module load)
141
+ if (bc) {
142
+ try {
143
+ bc.onmessage = (e: MessageEvent<DBEvent>) => {
144
+ const data = e.data;
145
+ if (data && typeof data === 'object' && 'type' in data) handleEvent(data);
146
+ };
147
+ } catch {
148
+ // ignore BroadcastChannel wiring errors (unsupported env)
149
+ }
150
+ }
151
+ try {
152
+ window.addEventListener('storage', (e: StorageEvent) => {
153
+ if (e.key !== 'lt_event' || !e.newValue) return;
154
+ try {
155
+ const data = JSON.parse(e.newValue) as DBEvent & { ts?: number };
156
+ if (data && typeof data === 'object' && 'type' in data) handleEvent(data);
157
+ } catch {
158
+ // ignore malformed storage events
159
+ }
160
+ });
161
+ } catch {
162
+ // ignore storage event wiring errors (non-browser env)
163
+ }
164
+
165
+ function emitSegmentInserted(groupId: string, seg: TranscriptionSegment) {
166
+ const set = listeners.segmentsInserted.get(groupId);
167
+ set?.forEach((cb) => cb(seg));
168
+ // cross-tab
169
+ broadcast({ type: 'segmentInserted', groupId, payload: seg });
170
+ }
171
+ function emitSegmentUpdated(groupId: string, seg: TranscriptionSegment) {
172
+ const set = listeners.segmentsUpdated.get(groupId);
173
+ set?.forEach((cb) => cb(seg));
174
+ // cross-tab
175
+ broadcast({ type: 'segmentUpdated', groupId, payload: seg });
176
+ }
177
+ function emitGroupUpdated(groupId: string, group: Group) {
178
+ const set = listeners.groupUpdated.get(groupId);
179
+ set?.forEach((cb) => cb(group));
180
+ // cross-tab
181
+ broadcast({ type: 'groupUpdated', groupId, payload: group });
182
+ }
183
+ function emitMembersChanged(groupId: string) {
184
+ const set = listeners.membersChanged.get(groupId);
185
+ set?.forEach((cb) => cb());
186
+ // cross-tab
187
+ broadcast({ type: 'membersChanged', groupId });
188
+ }
189
+
190
+ export const LocalDB = {
191
+ // Groups
192
+ createGroup(args: {
193
+ name: string;
194
+ hostId: string;
195
+ joinCode: string;
196
+ sourceLanguage: string;
197
+ targetLanguage: string;
198
+ }): Group {
199
+ const groups = readArray<Group>(K.groups);
200
+ const now = new Date().toISOString();
201
+ const group: Group = {
202
+ id: uuid(),
203
+ name: args.name,
204
+ host_id: args.hostId,
205
+ join_code: args.joinCode,
206
+ source_language: args.sourceLanguage,
207
+ target_language: args.targetLanguage,
208
+ is_active: true,
209
+ created_at: now,
210
+ expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
211
+ };
212
+ groups.unshift(group);
213
+ writeArray(K.groups, groups);
214
+ emitGroupUpdated(group.id, group);
215
+ return group;
216
+ },
217
+ setGroupActive(groupId: string, active: boolean) {
218
+ const groups = readArray<Group>(K.groups);
219
+ const idx = groups.findIndex((g) => g.id === groupId);
220
+ if (idx >= 0) {
221
+ groups[idx] = { ...groups[idx], is_active: active };
222
+ writeArray(K.groups, groups);
223
+ emitGroupUpdated(groupId, groups[idx]);
224
+ }
225
+ },
226
+ getGroupById(groupId: string): Group | null {
227
+ const groups = readArray<Group>(K.groups);
228
+ return groups.find((g) => g.id === groupId) || null;
229
+ },
230
+ findActiveGroupByJoinCode(code: string): Group | null {
231
+ const groups = readArray<Group>(K.groups);
232
+ const c = code.toUpperCase();
233
+ return groups.find((g) => g.join_code === c && g.is_active) || null;
234
+ },
235
+ generateUniqueJoinCode(): string {
236
+ const groups = readArray<Group>(K.groups);
237
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
238
+ // Try a few times to avoid collisions
239
+ for (let attempt = 0; attempt < 20; attempt++) {
240
+ let code = '';
241
+ for (let i = 0; i < 6; i++) code += chars[Math.floor(Math.random() * chars.length)];
242
+ if (!groups.some((g) => g.join_code === code)) return code;
243
+ }
244
+ return Math.random().toString(36).slice(2, 8).toUpperCase();
245
+ },
246
+ deleteGroup(groupId: string) {
247
+ // remove group
248
+ const groups = readArray<Group>(K.groups).filter((g) => g.id !== groupId);
249
+ writeArray(K.groups, groups);
250
+ // remove members
251
+ const members = readArray<GroupMember>(K.members).filter((m) => m.group_id !== groupId);
252
+ writeArray(K.members, members);
253
+ emitMembersChanged(groupId);
254
+ // remove segments
255
+ const segs = readArray<TranscriptionSegment>(K.segments).filter((s) => s.group_id !== groupId);
256
+ writeArray(K.segments, segs);
257
+ },
258
+
259
+ // Members
260
+ addMember(groupId: string, userId: string, isHost: boolean): GroupMember {
261
+ const members = readArray<GroupMember>(K.members);
262
+ const existing = members.find((m) => m.group_id === groupId && m.user_id === userId);
263
+ if (existing) return existing;
264
+ const member: GroupMember = {
265
+ id: uuid(),
266
+ group_id: groupId,
267
+ user_id: userId,
268
+ is_host: isHost,
269
+ joined_at: new Date().toISOString(),
270
+ };
271
+ members.push(member);
272
+ writeArray(K.members, members);
273
+ emitMembersChanged(groupId);
274
+ return member;
275
+ },
276
+ removeMember(groupId: string, userId: string) {
277
+ const members = readArray<GroupMember>(K.members);
278
+ const next = members.filter((m) => !(m.group_id === groupId && m.user_id === userId));
279
+ writeArray(K.members, next);
280
+ emitMembersChanged(groupId);
281
+ },
282
+ getMemberCount(groupId: string): number {
283
+ const members = readArray<GroupMember>(K.members);
284
+ return members.filter((m) => m.group_id === groupId).length;
285
+ },
286
+ getGroupsForUser(userId: string): (Group & { isHost: boolean })[] {
287
+ const members = readArray<GroupMember>(K.members).filter((m) => m.user_id === userId);
288
+ const ids = members.map((m) => m.group_id);
289
+ const groups = readArray<Group>(K.groups).filter((g) => ids.includes(g.id));
290
+ return groups
291
+ .map((g) => ({ ...g, isHost: !!members.find((m) => m.group_id === g.id)?.is_host }))
292
+ .sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at));
293
+ },
294
+
295
+ // Segments
296
+ getSegments(groupId: string): TranscriptionSegment[] {
297
+ const segs = readArray<TranscriptionSegment>(K.segments)
298
+ .filter((s) => s.group_id === groupId)
299
+ .sort((a, b) => a.sequence_number - b.sequence_number);
300
+ return segs;
301
+ },
302
+ addSegment(args: {
303
+ groupId: string;
304
+ originalText: string;
305
+ translatedText: string | null;
306
+ sequenceNumber: number;
307
+ createdBy: string;
308
+ }): TranscriptionSegment {
309
+ const segs = readArray<TranscriptionSegment>(K.segments);
310
+ const seg: TranscriptionSegment = {
311
+ id: uuid(),
312
+ group_id: args.groupId,
313
+ original_text: args.originalText,
314
+ translated_text: args.translatedText,
315
+ sequence_number: args.sequenceNumber,
316
+ created_at: new Date().toISOString(),
317
+ created_by: args.createdBy,
318
+ };
319
+ segs.push(seg);
320
+ writeArray(K.segments, segs);
321
+ emitSegmentInserted(args.groupId, seg);
322
+ return seg;
323
+ },
324
+ // Update just the translated_text for an existing segment
325
+ updateSegmentTranslation(groupId: string, segmentId: string, translatedText: string | null): TranscriptionSegment | null {
326
+ const segs = readArray<TranscriptionSegment>(K.segments);
327
+ const idx = segs.findIndex((s) => s.id === segmentId && s.group_id === groupId);
328
+ if (idx === -1) return null;
329
+ const updated: TranscriptionSegment = { ...segs[idx], translated_text: translatedText };
330
+ segs[idx] = updated;
331
+ writeArray(K.segments, segs);
332
+ // Broadcast update so viewers/other tabs see the new translation immediately
333
+ emitSegmentUpdated(groupId, updated);
334
+ return updated;
335
+ },
336
+
337
+ // Subscriptions (per-group)
338
+ onSegmentsInserted(groupId: string, cb: (seg: TranscriptionSegment) => void): Unsubscribe {
339
+ const set = listeners.segmentsInserted.get(groupId) || new Set();
340
+ set.add(cb);
341
+ listeners.segmentsInserted.set(groupId, set);
342
+ return () => {
343
+ const s = listeners.segmentsInserted.get(groupId);
344
+ s?.delete(cb);
345
+ };
346
+ },
347
+ onSegmentUpdated(groupId: string, cb: (seg: TranscriptionSegment) => void): Unsubscribe {
348
+ const set = listeners.segmentsUpdated.get(groupId) || new Set();
349
+ set.add(cb);
350
+ listeners.segmentsUpdated.set(groupId, set);
351
+ return () => {
352
+ const s = listeners.segmentsUpdated.get(groupId);
353
+ s?.delete(cb);
354
+ };
355
+ },
356
+ onGroupUpdated(groupId: string, cb: (group: Group) => void): Unsubscribe {
357
+ const set = listeners.groupUpdated.get(groupId) || new Set();
358
+ set.add(cb);
359
+ listeners.groupUpdated.set(groupId, set);
360
+ return () => {
361
+ const s = listeners.groupUpdated.get(groupId);
362
+ s?.delete(cb);
363
+ };
364
+ },
365
+ onMembersChanged(groupId: string, cb: () => void): Unsubscribe {
366
+ const set = listeners.membersChanged.get(groupId) || new Set();
367
+ set.add(cb);
368
+ listeners.membersChanged.set(groupId, set);
369
+ return () => {
370
+ const s = listeners.membersChanged.get(groupId);
371
+ s?.delete(cb);
372
+ };
373
+ },
374
+ // Export a group's data for backup (so user can delete later if storage is full)
375
+ exportGroup(groupId: string): {
376
+ group: Group | null;
377
+ members: GroupMember[];
378
+ segments: TranscriptionSegment[];
379
+ exportedAt: string;
380
+ } {
381
+ const group = LocalDB.getGroupById(groupId);
382
+ const members = readArray<GroupMember>(K.members).filter((m) => m.group_id === groupId);
383
+ const segments = LocalDB.getSegments(groupId);
384
+ return { group, members, segments, exportedAt: new Date().toISOString() };
385
+ },
386
+ // Rough estimate of bytes used by our keys in localStorage
387
+ getStorageBytes(): number {
388
+ let total = 0;
389
+ for (const key of [K.groups, K.members, K.segments]) {
390
+ try {
391
+ const v = localStorage.getItem(key);
392
+ if (v) total += key.length + v.length;
393
+ } catch {
394
+ // ignore storage access errors
395
+ }
396
+ }
397
+ return total;
398
+ },
399
+ };
src/lib/supabase.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Local stub replacing Supabase client so the app can run fully offline/local.
2
+ // The real client is not needed when using the local data store.
3
+ // Only minimal shape is provided to satisfy imports from AuthContext when auth bypass is enabled.
4
+ export const supabase = {
5
+ auth: {
6
+ async getSession() {
7
+ return { data: { session: null }, error: null } as any;
8
+ },
9
+ onAuthStateChange(_cb: any) {
10
+ return { data: { subscription: { unsubscribe() {} } } } as any;
11
+ },
12
+ async signUp(_args: any) {
13
+ return { data: null, error: null } as any;
14
+ },
15
+ async signInWithPassword(_args: any) {
16
+ return { data: null, error: null } as any;
17
+ },
18
+ async signOut() {
19
+ return { error: null } as any;
20
+ },
21
+ },
22
+ } as const;
23
+
24
+ export interface Group {
25
+ id: string;
26
+ name: string;
27
+ host_id: string;
28
+ join_code: string;
29
+ source_language: string;
30
+ target_language: string;
31
+ is_active: boolean;
32
+ created_at: string;
33
+ expires_at: string;
34
+ }
35
+
36
+ export interface GroupMember {
37
+ id: string;
38
+ group_id: string;
39
+ user_id: string;
40
+ joined_at: string;
41
+ is_host: boolean;
42
+ }
43
+
44
+ export interface TranscriptionSegment {
45
+ id: string;
46
+ group_id: string;
47
+ original_text: string;
48
+ translated_text: string | null;
49
+ sequence_number: number;
50
+ created_at: string;
51
+ created_by: string;
52
+ }
src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import App from './App.tsx';
4
+ import './index.css';
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>
10
+ );
src/useAuth.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useContext } from 'react';
2
+ import { AuthContext } from './contexts/AuthContext';
3
+
4
+ export function useAuth() {
5
+ const context = useContext(AuthContext);
6
+ if (!context) {
7
+ throw new Error('useAuth must be used within an AuthProvider');
8
+ }
9
+ return context;
10
+ }
src/vite-env.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ /// <reference types="vite/client" />
tailwind.config.js ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
4
+ theme: {
5
+ extend: {},
6
+ },
7
+ plugins: [],
8
+ };
tsconfig.app.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "isolatedModules": true,
13
+ "moduleDetection": "force",
14
+ "noEmit": true,
15
+ "jsx": "react-jsx",
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "noFallthroughCasesInSwitch": true
22
+ },
23
+ "include": ["src"]
24
+ }
tsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
tsconfig.node.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2023"],
5
+ "module": "ESNext",
6
+ "skipLibCheck": true,
7
+
8
+ /* Bundler mode */
9
+ "moduleResolution": "bundler",
10
+ "allowImportingTsExtensions": true,
11
+ "isolatedModules": true,
12
+ "moduleDetection": "force",
13
+ "noEmit": true,
14
+
15
+ /* Linting */
16
+ "strict": true,
17
+ "noUnusedLocals": true,
18
+ "noUnusedParameters": true,
19
+ "noFallthroughCasesInSwitch": true
20
+ },
21
+ "include": ["vite.config.ts"]
22
+ }
vite.config.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ // https://vitejs.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ optimizeDeps: {
8
+ exclude: ['lucide-react'],
9
+ },
10
+ });