Spaces:
Sleeping
Sleeping
elevenlabs addition
Browse files- README.md +282 -14
- translator_app/translator/__pycache__/urls.cpython-314.pyc +0 -0
- translator_app/translator/__pycache__/views.cpython-314.pyc +0 -0
- translator_app/translator/services/__pycache__/translation_service.cpython-314.pyc +0 -0
- translator_app/translator/services/language_support.py +89 -0
- translator_app/translator/services/translation_service.py +7 -0
- translator_app/translator/templates/translator/translator.html +113 -7
- translator_app/translator/urls.py +2 -1
- translator_app/translator/views.py +75 -1
- translator_app/translator_project/__pycache__/settings.cpython-314.pyc +0 -0
- translator_app/translator_project/settings.py +9 -0
README.md
CHANGED
|
@@ -1,6 +1,219 @@
|
|
| 1 |
# The Translator App
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
```bash
|
| 6 |
cd translator_app
|
|
@@ -11,23 +224,78 @@ python manage.py migrate
|
|
| 11 |
python manage.py runserver
|
| 12 |
```
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
```env
|
| 22 |
-
|
| 23 |
-
TRANSLATION_DEFAULT_MODEL=nllb_600m
|
| 24 |
```
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
| 1 |
# The Translator App
|
| 2 |
|
| 3 |
+
A Django web application for text translation, with a **separate backend model
|
| 4 |
+
API** (FastAPI) in `backend/` that serves translation engines and model
|
| 5 |
+
inference.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. System overview
|
| 10 |
+
|
| 11 |
+
This repository contains two runtime components:
|
| 12 |
+
|
| 13 |
+
| Component | Location | Responsibility |
|
| 14 |
+
|---|---|---|
|
| 15 |
+
| Frontend + app logic | `translator_app/` (Django) | UI, form submission, model selection, translation history persistence, cache, and orchestration of translation requests |
|
| 16 |
+
| Model-serving API | `backend/` (FastAPI) | Exposes `/api/translate`, `/api/engines`, `/api/languages`, and routes requests to available providers (NLLB/MADLAD/Ollama/Gemini/Groq) |
|
| 17 |
+
|
| 18 |
+
High-level request flow:
|
| 19 |
+
|
| 20 |
+
1. User submits text on Django page (`/`).
|
| 21 |
+
2. Django `translate_api` view validates input and calls `TranslationService`.
|
| 22 |
+
3. `TranslationService` maps app language codes to FLORES-200 codes and calls `MODEL_API_URL`.
|
| 23 |
+
4. FastAPI backend dispatches to the selected provider/engine.
|
| 24 |
+
5. Django stores translation in `TranslationHistory` and returns JSON to the UI.
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## 2. Repository structure
|
| 29 |
+
|
| 30 |
+
```text
|
| 31 |
+
.
|
| 32 |
+
├── translator_app/ # Django app (user-facing application)
|
| 33 |
+
│ ├── translator_project/ # Django project config
|
| 34 |
+
│ ├── translator/ # App: models, views, services, templates
|
| 35 |
+
│ └── manage.py
|
| 36 |
+
├── backend/ # FastAPI model-serving API
|
| 37 |
+
│ ├── main.py # API entrypoint
|
| 38 |
+
│ ├── providers/ # Translation engine implementations
|
| 39 |
+
│ ├── languages.py # FLORES-200 language map
|
| 40 |
+
│ ├── convert_model.py # NLLB/MADLAD conversion helper
|
| 41 |
+
│ └── deploy_hf.py # Hugging Face Space deployment helper
|
| 42 |
+
├── Dockerfile # Root Docker build (backend-focused)
|
| 43 |
+
├── docker-compose.yml # Local backend compose setup
|
| 44 |
+
└── entrypoint.sh # Runtime model conversion + uvicorn start
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## 3. Django application (`translator_app/`)
|
| 50 |
+
|
| 51 |
+
### 3.1 Core URLs
|
| 52 |
+
|
| 53 |
+
- `/` → translation UI (`translator.views.translator`)
|
| 54 |
+
- `/api/translate/` → AJAX translation endpoint (`translator.views.translate_api`)
|
| 55 |
+
- `/history/` → translation history list (`translator.views.history`)
|
| 56 |
+
- `/history/<id>/delete/` → delete one entry
|
| 57 |
+
- `/history/clear/` → clear all entries
|
| 58 |
+
|
| 59 |
+
### 3.2 Models
|
| 60 |
+
|
| 61 |
+
Defined in `translator/models.py`:
|
| 62 |
+
|
| 63 |
+
- `Language`: curated/active language list used by UI when present.
|
| 64 |
+
- `TranslationHistory`: persisted history with source text, translated text,
|
| 65 |
+
source/target language FK, selected model, and metadata.
|
| 66 |
+
- `TranslationCache`: hash-based cache for repeated translations.
|
| 67 |
+
|
| 68 |
+
Notable behavior:
|
| 69 |
+
|
| 70 |
+
- `TranslationHistory.save()` auto-computes `character_count` and `word_count`.
|
| 71 |
+
- History is ordered newest-first and indexed by `(user, -created_at)`.
|
| 72 |
+
|
| 73 |
+
### 3.3 Views and orchestration
|
| 74 |
+
|
| 75 |
+
`translator/views.py` coordinates UI + backend translation:
|
| 76 |
+
|
| 77 |
+
- `_languages()`:
|
| 78 |
+
- uses DB `Language` rows where `is_active=True`;
|
| 79 |
+
- falls back to `TranslationService().get_supported_languages()`.
|
| 80 |
+
- `translator()` renders model picker and language selectors.
|
| 81 |
+
- `translate_api()`:
|
| 82 |
+
- validates JSON payload and limits text to 5000 chars;
|
| 83 |
+
- validates selected model against `TRANSLATION_MODELS`;
|
| 84 |
+
- calls `TranslationService.translate(...)`;
|
| 85 |
+
- persists `TranslationHistory`;
|
| 86 |
+
- returns translated text + model label.
|
| 87 |
+
|
| 88 |
+
### 3.4 Service layer
|
| 89 |
+
|
| 90 |
+
`translator/services/translation_service.py`:
|
| 91 |
+
|
| 92 |
+
- Detects source language with `langdetect` when source is not provided.
|
| 93 |
+
- Converts app language codes (`en`, `sw`, etc.) to FLORES-200 (`eng_Latn`,
|
| 94 |
+
`swh_Latn`) for the backend API.
|
| 95 |
+
- Uses cache (`TranslationCache`) keyed by text + source + target + model.
|
| 96 |
+
- Supports:
|
| 97 |
+
- model API path (via `MODEL_API_URL`), and
|
| 98 |
+
- optional `deepl_fallback` path when `TRANSLATION_API_KEY` is configured.
|
| 99 |
+
|
| 100 |
+
### 3.5 Templates/UI behavior
|
| 101 |
+
|
| 102 |
+
Templates in `translator/templates/translator/`:
|
| 103 |
+
|
| 104 |
+
- `base.html`: shell layout and Tailwind CDN styling.
|
| 105 |
+
- `translator.html`: main translation page with JS submit flow.
|
| 106 |
+
- `history.html`: persisted history view with delete/clear actions.
|
| 107 |
+
|
| 108 |
+
Client-side behavior in `translator.html`:
|
| 109 |
+
|
| 110 |
+
- POSTs JSON to `/api/translate/` with CSRF header.
|
| 111 |
+
- Sends selected `model`, `source_language`, `target_language`, and text.
|
| 112 |
+
- Disables source selector when model is `madlad` (MADLAD auto-detect source).
|
| 113 |
+
|
| 114 |
+
---
|
| 115 |
+
|
| 116 |
+
## 4. Backend model API (`backend/`)
|
| 117 |
+
|
| 118 |
+
### 4.1 API endpoints (`backend/main.py`)
|
| 119 |
+
|
| 120 |
+
- `GET /api/health` → service status + default available engine.
|
| 121 |
+
- `GET /api/engines` → all engines with metadata and availability.
|
| 122 |
+
- `GET /api/languages` → language list (from FLORES-200 map).
|
| 123 |
+
- `POST /api/translate` → translation endpoint.
|
| 124 |
+
- `GET /scalar` → Scalar API documentation UI.
|
| 125 |
+
|
| 126 |
+
`/api/translate` request shape:
|
| 127 |
+
|
| 128 |
+
```json
|
| 129 |
+
{
|
| 130 |
+
"text": "Hello world",
|
| 131 |
+
"source": "eng_Latn",
|
| 132 |
+
"target": "swh_Latn",
|
| 133 |
+
"engine": "nllb"
|
| 134 |
+
}
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
Response shape:
|
| 138 |
+
|
| 139 |
+
```json
|
| 140 |
+
{
|
| 141 |
+
"translation": "Habari dunia",
|
| 142 |
+
"source": "eng_Latn",
|
| 143 |
+
"target": "swh_Latn",
|
| 144 |
+
"engine": "nllb"
|
| 145 |
+
}
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
### 4.2 Provider architecture
|
| 149 |
+
|
| 150 |
+
Providers implement `TranslationProvider` (`backend/providers/base.py`).
|
| 151 |
+
|
| 152 |
+
Registry in `backend/providers/__init__.py` includes:
|
| 153 |
+
|
| 154 |
+
- `nllb` (NLLB-200 600M)
|
| 155 |
+
- `nllb_1_3b` (NLLB-200 1.3B)
|
| 156 |
+
- `madlad` (MADLAD-400 3B)
|
| 157 |
+
- `ollama` (local Ollama model)
|
| 158 |
+
- `gemini` (Google API)
|
| 159 |
+
- `groq_qwen` and `groq` (Groq API models)
|
| 160 |
+
|
| 161 |
+
Availability logic:
|
| 162 |
+
|
| 163 |
+
- local engines → available when required local model/service exists.
|
| 164 |
+
- API engines → available when server key exists (or key passed by request
|
| 165 |
+
headers for BYOK flow in FastAPI endpoint).
|
| 166 |
+
|
| 167 |
+
### 4.3 Language system
|
| 168 |
+
|
| 169 |
+
`backend/languages.py` provides a full FLORES-200 code map used by local/API
|
| 170 |
+
providers. Django only exposes a curated subset in app-level UI defaults.
|
| 171 |
+
|
| 172 |
+
### 4.4 Model conversion
|
| 173 |
+
|
| 174 |
+
`backend/convert_model.py` converts Hugging Face checkpoints to CTranslate2
|
| 175 |
+
int8 models (faster/lower-memory inference). Conversion target path defaults to:
|
| 176 |
+
|
| 177 |
+
- `models/nllb-200-distilled-600M-int8`
|
| 178 |
+
|
| 179 |
+
Environment overrides allow converting additional models (e.g. 1.3B, MADLAD).
|
| 180 |
+
|
| 181 |
+
---
|
| 182 |
+
|
| 183 |
+
## 5. Configuration
|
| 184 |
+
|
| 185 |
+
### 5.1 Django (`translator_app/.env`)
|
| 186 |
+
|
| 187 |
+
Important settings loaded by `translator_project/settings.py`:
|
| 188 |
+
|
| 189 |
+
- `MODEL_API_URL` (**required** for model API translation path)
|
| 190 |
+
- `TRANSLATION_DEFAULT_MODEL` (default: `nllb`)
|
| 191 |
+
- `TRANSLATION_API_KEY` (optional; enables `deepl_fallback`)
|
| 192 |
+
- `TRANSLATION_API_URL` (optional; DeepL-compatible endpoint override)
|
| 193 |
+
|
| 194 |
+
Model IDs configured in Django:
|
| 195 |
+
|
| 196 |
+
- `nllb`
|
| 197 |
+
- `nllb_1_3b`
|
| 198 |
+
- `madlad`
|
| 199 |
+
- `deepl_fallback` (only when fallback key configured)
|
| 200 |
+
|
| 201 |
+
### 5.2 Backend (`backend/.env`)
|
| 202 |
+
|
| 203 |
+
See `backend/.env.example` for optional engine keys:
|
| 204 |
+
|
| 205 |
+
- `GEMINI_API_KEY`
|
| 206 |
+
- `GROQ_API_KEY`
|
| 207 |
+
- `OLLAMA_HOST`, `OLLAMA_MODEL`
|
| 208 |
+
- `CT2_DEVICE`, `CT2_COMPUTE_TYPE`
|
| 209 |
+
- `MADLAD_MODEL_DIR`, related model paths
|
| 210 |
+
- `CORS_ORIGINS`
|
| 211 |
+
|
| 212 |
+
---
|
| 213 |
+
|
| 214 |
+
## 6. Local development
|
| 215 |
+
|
| 216 |
+
### 6.1 Run Django app
|
| 217 |
|
| 218 |
```bash
|
| 219 |
cd translator_app
|
|
|
|
| 224 |
python manage.py runserver
|
| 225 |
```
|
| 226 |
|
| 227 |
+
App URL: `http://127.0.0.1:8000/`
|
| 228 |
+
|
| 229 |
+
### 6.2 Run backend API (direct)
|
| 230 |
+
|
| 231 |
+
```bash
|
| 232 |
+
cd backend
|
| 233 |
+
python -m venv .venv
|
| 234 |
+
source .venv/bin/activate
|
| 235 |
+
pip install -r requirements.txt
|
| 236 |
+
uvicorn main:app --host 0.0.0.0 --port 7860
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
API URL: `http://127.0.0.1:7860/api/health`
|
| 240 |
+
|
| 241 |
+
### 6.3 Run backend API via Docker Compose
|
| 242 |
+
|
| 243 |
+
From repo root:
|
| 244 |
+
|
| 245 |
+
```bash
|
| 246 |
+
docker compose up --build backend
|
| 247 |
+
```
|
| 248 |
+
|
| 249 |
+
Compose maps port `7860` and mounts persistent model/cache volumes.
|
| 250 |
+
|
| 251 |
+
---
|
| 252 |
+
|
| 253 |
+
## 7. Deployment (Hugging Face Space)
|
| 254 |
|
| 255 |
+
Backend is designed to deploy as a Docker Space (UI stays in Django app).
|
| 256 |
|
| 257 |
+
Use helper:
|
| 258 |
+
|
| 259 |
+
```bash
|
| 260 |
+
HF_TOKEN=hf_your_write_token \
|
| 261 |
+
SPACE_ID=your-hf-user/translator-model-api \
|
| 262 |
+
python backend/deploy_hf.py
|
| 263 |
+
```
|
| 264 |
+
|
| 265 |
+
Detailed deployment notes are in `backend/HUGGINGFACE_DEPLOYMENT.md`.
|
| 266 |
+
|
| 267 |
+
After deployment, set Django:
|
| 268 |
|
| 269 |
```env
|
| 270 |
+
MODEL_API_URL=https://<your-space>.hf.space/api/translate
|
|
|
|
| 271 |
```
|
| 272 |
|
| 273 |
+
---
|
| 274 |
+
|
| 275 |
+
## 8. Data and persistence
|
| 276 |
+
|
| 277 |
+
- Django DB: `translator_app/db.sqlite3`
|
| 278 |
+
- Translation history and cache live in Django DB.
|
| 279 |
+
- Backend model artifacts are stored under configured model directories
|
| 280 |
+
(often mounted as Docker volumes in local/containerized runs).
|
| 281 |
+
|
| 282 |
+
---
|
| 283 |
+
|
| 284 |
+
## 9. Current test coverage
|
| 285 |
+
|
| 286 |
+
`translator/tests.py` currently covers:
|
| 287 |
+
|
| 288 |
+
- translation page renders model selector
|
| 289 |
+
- translation API saves history record
|
| 290 |
+
- translation API rejects missing text
|
| 291 |
+
|
| 292 |
+
---
|
| 293 |
+
|
| 294 |
+
## 10. Key integration contract (Django ↔ backend)
|
| 295 |
+
|
| 296 |
+
For end-to-end translation to work:
|
| 297 |
|
| 298 |
+
1. Django `TRANSLATION_MODEL_ENGINES` model IDs must map to backend provider IDs.
|
| 299 |
+
2. Django must map UI language codes to FLORES-200 before calling backend.
|
| 300 |
+
3. `MODEL_API_URL` must point at backend `/api/translate`.
|
| 301 |
+
4. Selected model is persisted in `TranslationHistory.translation_model` and shown in history.
|
translator_app/translator/__pycache__/urls.cpython-314.pyc
CHANGED
|
Binary files a/translator_app/translator/__pycache__/urls.cpython-314.pyc and b/translator_app/translator/__pycache__/urls.cpython-314.pyc differ
|
|
|
translator_app/translator/__pycache__/views.cpython-314.pyc
CHANGED
|
Binary files a/translator_app/translator/__pycache__/views.cpython-314.pyc and b/translator_app/translator/__pycache__/views.cpython-314.pyc differ
|
|
|
translator_app/translator/services/__pycache__/translation_service.cpython-314.pyc
CHANGED
|
Binary files a/translator_app/translator/services/__pycache__/translation_service.cpython-314.pyc and b/translator_app/translator/services/__pycache__/translation_service.cpython-314.pyc differ
|
|
|
translator_app/translator/services/language_support.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# translator/services/language_support.py
|
| 2 |
+
"""
|
| 3 |
+
Explicit per-model language support.
|
| 4 |
+
|
| 5 |
+
This is deliberately separate from `TranslationService.NLLB_LANGUAGE_CODES`
|
| 6 |
+
in translation_service.py. That dict answers "does the model API have a wire
|
| 7 |
+
code for this language at all". This file answers a different question:
|
| 8 |
+
"does this specific model actually produce usable output for this language",
|
| 9 |
+
which is what was silently broken.
|
| 10 |
+
|
| 11 |
+
TWO CONCRETE BUGS THIS FIXES
|
| 12 |
+
-----------------------------
|
| 13 |
+
1. "luo" has no valid FLORES-200 / SALT tag. ISO 639-2 "luo" is a
|
| 14 |
+
*macrolanguage* umbrella covering Dholuo, Lango, Alur, Adhola and Acholi
|
| 15 |
+
as distinct languages - it was never a real NLLB/MADLAD language code.
|
| 16 |
+
That's why translation requests tagged "luo" against nllb / nllb_1_3b /
|
| 17 |
+
madlad were failing outright: "luo_Latn" doesn't exist on the model's
|
| 18 |
+
side. If you meant Acholi, that's already covered correctly by "ach".
|
| 19 |
+
If you specifically need Dholuo/Lango/Alur, none of your current local
|
| 20 |
+
models support them - only the Google/DeepL fallback paths can.
|
| 21 |
+
|
| 22 |
+
2. Your language roster (English, Swahili, Luganda, Runyankole, Acholi,
|
| 23 |
+
Ateso, Lugbara) lines up closely with the Sunbird SALT East African
|
| 24 |
+
language project rather than any generic multilingual checkpoint. If
|
| 25 |
+
your deployed HF Space is a SALT-style fine-tune of NLLB, MADLAD-400
|
| 26 |
+
(a separate, generically-trained Google model) very likely was never
|
| 27 |
+
fine-tuned on those five Ugandan low-resource languages at all, even
|
| 28 |
+
though the UI happily offered them for every model.
|
| 29 |
+
|
| 30 |
+
HOW TO VERIFY / MAINTAIN THIS
|
| 31 |
+
------------------------------
|
| 32 |
+
This table is a best-effort starting point, not a guarantee - I can't see
|
| 33 |
+
your actual deployed HF Space's training data. Two ways to firm it up:
|
| 34 |
+
a) If your Space exposes a /languages or /health endpoint listing what
|
| 35 |
+
it was actually fine-tuned/evaluated on, wire `_languages()` in
|
| 36 |
+
views.py to read it live instead of hardcoding this table.
|
| 37 |
+
b) Otherwise, test each (model, language) pair once against your Space
|
| 38 |
+
and adjust the sets below accordingly - a wrong entry here just means
|
| 39 |
+
a language gets offered when it shouldn't (or hidden when it's fine),
|
| 40 |
+
not a crash, since translate_api() falls back to Google either way.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
# None = unrestricted (accepts any language pair the frontend can offer).
|
| 44 |
+
MODEL_LANGUAGE_SUPPORT = {
|
| 45 |
+
"nllb": {"en", "sw", "lg", "nyn", "ach", "teo", "lgg", "rw", "fr", "ar"},
|
| 46 |
+
"nllb_1_3b": {"en", "sw", "lg", "nyn", "ach", "teo", "lgg", "rw", "fr", "ar"},
|
| 47 |
+
# Not fine-tuned on the five Ugandan low-resource languages - confirm
|
| 48 |
+
# against your Space and trim further if it's even narrower than this.
|
| 49 |
+
"madlad": {"en", "sw", "rw", "fr", "ar"},
|
| 50 |
+
# DeepL has no coverage at all for sw/lg/nyn/ach/teo/lgg/rw.
|
| 51 |
+
"deepl_fallback": {"en", "fr", "ar"},
|
| 52 |
+
"google_fallback": None,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
FALLBACK_MODEL = "google_fallback"
|
| 56 |
+
|
| 57 |
+
# ElevenLabs eleven_multilingual_v2's real language list (per their docs as
|
| 58 |
+
# of mid-2026). None of your East African languages are on it - only en/fr/ar
|
| 59 |
+
# from your roster are. Requests for anything else should never hit the
|
| 60 |
+
# ElevenLabs API; fall back to the browser's speechSynthesis instead.
|
| 61 |
+
ELEVENLABS_MULTILINGUAL_V2_LANGUAGES = {
|
| 62 |
+
"en", "zh", "es", "hi", "pt", "fr", "de", "ja", "ar", "ko", "id", "it",
|
| 63 |
+
"nl", "tr", "pl", "sv", "fil", "ms", "ru", "ro", "uk", "el", "cs", "da",
|
| 64 |
+
"fi", "bg", "hr", "sk", "ta",
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def supported_codes(model):
|
| 69 |
+
"""Set of codes a model supports, or None if unrestricted."""
|
| 70 |
+
return MODEL_LANGUAGE_SUPPORT.get(model, set())
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def model_supports(model, *codes):
|
| 74 |
+
"""True if `model` supports every non-empty code given."""
|
| 75 |
+
allowed = supported_codes(model)
|
| 76 |
+
if allowed is None:
|
| 77 |
+
return True
|
| 78 |
+
return all(code in allowed for code in codes if code)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def resolve_model(requested_model, source_code, target_code):
|
| 82 |
+
"""
|
| 83 |
+
Return (model_to_use, fell_back). If the requested model can't handle
|
| 84 |
+
this language pair, silently route to the Google fallback rather than
|
| 85 |
+
erroring, and tell the caller that happened.
|
| 86 |
+
"""
|
| 87 |
+
if model_supports(requested_model, source_code, target_code):
|
| 88 |
+
return requested_model, False
|
| 89 |
+
return FALLBACK_MODEL, True
|
translator_app/translator/services/translation_service.py
CHANGED
|
@@ -87,6 +87,13 @@ class TranslationService:
|
|
| 87 |
|
| 88 |
if model == "deepl_fallback":
|
| 89 |
outcome = self._translate_deepl(text, source_language, target_language)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
elif model in settings.TRANSLATION_MODEL_ENGINES:
|
| 91 |
outcome = self._translate_model_api(
|
| 92 |
text, source_language, target_language, model
|
|
|
|
| 87 |
|
| 88 |
if model == "deepl_fallback":
|
| 89 |
outcome = self._translate_deepl(text, source_language, target_language)
|
| 90 |
+
elif model == "google_fallback":
|
| 91 |
+
# Explicit, user-selectable Google fallback. Reuses the exact
|
| 92 |
+
# same free-fallback call already used internally elsewhere in
|
| 93 |
+
# this class - no new translation logic introduced.
|
| 94 |
+
outcome = self._translate_free_fallback(
|
| 95 |
+
text, source_language, target_language
|
| 96 |
+
)
|
| 97 |
elif model in settings.TRANSLATION_MODEL_ENGINES:
|
| 98 |
outcome = self._translate_model_api(
|
| 99 |
text, source_language, target_language, model
|
translator_app/translator/templates/translator/translator.html
CHANGED
|
@@ -3,6 +3,8 @@
|
|
| 3 |
<main class="flex-1 w-full max-w-5xl mx-auto px-5 pb-12">
|
| 4 |
<form id="translation-form">
|
| 5 |
{% csrf_token %}
|
|
|
|
|
|
|
| 6 |
|
| 7 |
<!-- Header Control Bar -->
|
| 8 |
<div class="flex flex-wrap justify-center items-center gap-3 mb-6">
|
|
@@ -172,13 +174,62 @@
|
|
| 172 |
const listenResultBtn = document.querySelector('#listen-result');
|
| 173 |
const copyBtn = document.querySelector('#copy-result');
|
| 174 |
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
const isMadlad = modelSelect.value === 'madlad';
|
| 177 |
sourceSelect.disabled = isMadlad;
|
| 178 |
if (isMadlad) sourceSelect.value = '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
}
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
function updateSourceControls() {
|
| 184 |
count.textContent = `${source.value.length} / 5000 characters`;
|
|
@@ -205,7 +256,10 @@
|
|
| 205 |
};
|
| 206 |
|
| 207 |
// Text-to-Speech Audio Reader
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
| 209 |
if (!('speechSynthesis' in window) || !text || !text.trim()) return;
|
| 210 |
window.speechSynthesis.cancel();
|
| 211 |
const utterance = new SpeechSynthesisUtterance(text);
|
|
@@ -213,8 +267,55 @@
|
|
| 213 |
window.speechSynthesis.speak(utterance);
|
| 214 |
}
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
// Speech-to-Text Voice Dictation Assistant
|
| 220 |
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
@@ -331,7 +432,12 @@
|
|
| 331 |
if (!response.ok) throw Error(data.error || 'Translation could not be completed.');
|
| 332 |
|
| 333 |
result.textContent = data.translated_text;
|
| 334 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
copyBtn.classList.remove('hidden');
|
| 336 |
copyBtn.classList.add('inline-flex');
|
| 337 |
listenResultBtn.classList.remove('hidden');
|
|
|
|
| 3 |
<main class="flex-1 w-full max-w-5xl mx-auto px-5 pb-12">
|
| 4 |
<form id="translation-form">
|
| 5 |
{% csrf_token %}
|
| 6 |
+
{{ model_language_support_json|json_script:"model-language-support" }}
|
| 7 |
+
{{ elevenlabs_languages_json|json_script:"elevenlabs-languages" }}
|
| 8 |
|
| 9 |
<!-- Header Control Bar -->
|
| 10 |
<div class="flex flex-wrap justify-center items-center gap-3 mb-6">
|
|
|
|
| 174 |
const listenResultBtn = document.querySelector('#listen-result');
|
| 175 |
const copyBtn = document.querySelector('#copy-result');
|
| 176 |
|
| 177 |
+
// --- Model-aware language filtering ------------------------------------
|
| 178 |
+
// modelLanguageSupport: { model_code: [lang codes] | null (unrestricted) }
|
| 179 |
+
const modelLanguageSupport = JSON.parse(document.querySelector('#model-language-support').textContent);
|
| 180 |
+
const elevenlabsLanguages = new Set(JSON.parse(document.querySelector('#elevenlabs-languages').textContent));
|
| 181 |
+
|
| 182 |
+
function supportedSet(model) {
|
| 183 |
+
const codes = modelLanguageSupport[model];
|
| 184 |
+
return codes === null || codes === undefined ? null : new Set(codes);
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// Hide (not remove) options unsupported by the current model, so swapping
|
| 188 |
+
// models back later doesn't lose the full list. Always keeps "Detect
|
| 189 |
+
// language" (empty value) visible in the source select.
|
| 190 |
+
function filterSelectForModel(selectEl, allowed) {
|
| 191 |
+
let selectedStillValid = false;
|
| 192 |
+
Array.from(selectEl.options).forEach(opt => {
|
| 193 |
+
const ok = !opt.value || allowed === null || allowed.has(opt.value);
|
| 194 |
+
opt.hidden = !ok;
|
| 195 |
+
opt.disabled = !ok;
|
| 196 |
+
if (ok && opt.value === selectEl.value) selectedStillValid = true;
|
| 197 |
+
});
|
| 198 |
+
if (!selectedStillValid) {
|
| 199 |
+
const firstOk = Array.from(selectEl.options).find(o => !o.hidden && o.value);
|
| 200 |
+
if (firstOk) selectEl.value = firstOk.value;
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
function syncLanguagesForModel() {
|
| 205 |
const isMadlad = modelSelect.value === 'madlad';
|
| 206 |
sourceSelect.disabled = isMadlad;
|
| 207 |
if (isMadlad) sourceSelect.value = '';
|
| 208 |
+
|
| 209 |
+
const allowed = supportedSet(modelSelect.value);
|
| 210 |
+
filterSelectForModel(targetSelect, allowed);
|
| 211 |
+
if (!isMadlad) filterSelectForModel(sourceSelect, allowed);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// If the person picks a language the current model can't handle, hop the
|
| 215 |
+
// model dropdown to Google fallback automatically rather than letting the
|
| 216 |
+
// request fail server-side.
|
| 217 |
+
function ensureModelSupportsSelection() {
|
| 218 |
+
const allowed = supportedSet(modelSelect.value);
|
| 219 |
+
if (allowed === null) return;
|
| 220 |
+
const srcOk = modelSelect.value === 'madlad' || !sourceSelect.value || allowed.has(sourceSelect.value);
|
| 221 |
+
const tgtOk = !targetSelect.value || allowed.has(targetSelect.value);
|
| 222 |
+
if (!srcOk || !tgtOk) {
|
| 223 |
+
modelSelect.value = 'google_fallback';
|
| 224 |
+
syncLanguagesForModel();
|
| 225 |
+
message.textContent = 'Switched to Google Translate (fallback) - the previous model doesn\'t support this language.';
|
| 226 |
+
}
|
| 227 |
}
|
| 228 |
+
|
| 229 |
+
modelSelect.addEventListener('change', syncLanguagesForModel);
|
| 230 |
+
sourceSelect.addEventListener('change', ensureModelSupportsSelection);
|
| 231 |
+
targetSelect.addEventListener('change', ensureModelSupportsSelection);
|
| 232 |
+
syncLanguagesForModel();
|
| 233 |
|
| 234 |
function updateSourceControls() {
|
| 235 |
count.textContent = `${source.value.length} / 5000 characters`;
|
|
|
|
| 256 |
};
|
| 257 |
|
| 258 |
// Text-to-Speech Audio Reader
|
| 259 |
+
// Tries ElevenLabs first (server-side call, key never touches the
|
| 260 |
+
// browser); falls back to the browser's built-in speechSynthesis when
|
| 261 |
+
// ElevenLabs has no voice for the language, or the request fails.
|
| 262 |
+
function speakWithBrowser(text, langCode) {
|
| 263 |
if (!('speechSynthesis' in window) || !text || !text.trim()) return;
|
| 264 |
window.speechSynthesis.cancel();
|
| 265 |
const utterance = new SpeechSynthesisUtterance(text);
|
|
|
|
| 267 |
window.speechSynthesis.speak(utterance);
|
| 268 |
}
|
| 269 |
|
| 270 |
+
let currentAudio = null;
|
| 271 |
+
|
| 272 |
+
async function speakText(text, langCode, triggerBtn) {
|
| 273 |
+
if (!text || !text.trim()) return;
|
| 274 |
+
const lang = (langCode || '').toLowerCase();
|
| 275 |
+
|
| 276 |
+
// Skip the network round trip entirely when we already know ElevenLabs
|
| 277 |
+
// has no voice for this language.
|
| 278 |
+
if (!lang || !elevenlabsLanguages.has(lang)) {
|
| 279 |
+
speakWithBrowser(text, lang || 'en');
|
| 280 |
+
return;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
const originalLabel = triggerBtn ? triggerBtn.innerHTML : null;
|
| 284 |
+
if (triggerBtn) {
|
| 285 |
+
triggerBtn.disabled = true;
|
| 286 |
+
triggerBtn.innerHTML = '⏳ Loading...';
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
try {
|
| 290 |
+
const response = await fetch("{% url 'translator:tts_api' %}", {
|
| 291 |
+
method: 'POST',
|
| 292 |
+
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf },
|
| 293 |
+
body: JSON.stringify({ text, language: lang }),
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
if (!response.ok) {
|
| 297 |
+
// Unsupported language, unconfigured key, or API failure - fall
|
| 298 |
+
// back quietly rather than surfacing an error for a "Listen" click.
|
| 299 |
+
speakWithBrowser(text, lang);
|
| 300 |
+
return;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
const blob = await response.blob();
|
| 304 |
+
if (currentAudio) currentAudio.pause();
|
| 305 |
+
currentAudio = new Audio(URL.createObjectURL(blob));
|
| 306 |
+
currentAudio.play();
|
| 307 |
+
} catch (err) {
|
| 308 |
+
speakWithBrowser(text, lang);
|
| 309 |
+
} finally {
|
| 310 |
+
if (triggerBtn) {
|
| 311 |
+
triggerBtn.disabled = false;
|
| 312 |
+
triggerBtn.innerHTML = originalLabel;
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
listenSourceBtn.onclick = () => speakText(source.value, sourceSelect.value || 'en', listenSourceBtn);
|
| 318 |
+
listenResultBtn.onclick = () => speakText(result.textContent, targetSelect.value || 'sw', listenResultBtn);
|
| 319 |
|
| 320 |
// Speech-to-Text Voice Dictation Assistant
|
| 321 |
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
|
|
| 432 |
if (!response.ok) throw Error(data.error || 'Translation could not be completed.');
|
| 433 |
|
| 434 |
result.textContent = data.translated_text;
|
| 435 |
+
if (data.model_used && data.model_used !== modelSelect.value) {
|
| 436 |
+
modelSelect.value = data.model_used;
|
| 437 |
+
syncLanguagesForModel();
|
| 438 |
+
}
|
| 439 |
+
const fallbackNote = data.fell_back_to_google ? ' (auto-switched: selected model doesn\'t support this language pair)' : '';
|
| 440 |
+
document.querySelector('#result-meta').textContent = `Engine: ${data.model_label || 'Translation'}${fallbackNote} · Saved to history`;
|
| 441 |
copyBtn.classList.remove('hidden');
|
| 442 |
copyBtn.classList.add('inline-flex');
|
| 443 |
listenResultBtn.classList.remove('hidden');
|
translator_app/translator/urls.py
CHANGED
|
@@ -9,6 +9,7 @@ urlpatterns = [
|
|
| 9 |
path('', views.translator, name='translator'),
|
| 10 |
path('history/', views.history, name='history'),
|
| 11 |
path('api/translate/', views.translate_api, name='translate_api'),
|
|
|
|
| 12 |
path('history/<int:pk>/delete/', views.delete_history, name='delete_history'),
|
| 13 |
path('history/clear/', views.clear_history, name='clear_history'),
|
| 14 |
-
]
|
|
|
|
| 9 |
path('', views.translator, name='translator'),
|
| 10 |
path('history/', views.history, name='history'),
|
| 11 |
path('api/translate/', views.translate_api, name='translate_api'),
|
| 12 |
+
path('api/tts/', views.text_to_speech, name='tts_api'),
|
| 13 |
path('history/<int:pk>/delete/', views.delete_history, name='delete_history'),
|
| 14 |
path('history/clear/', views.clear_history, name='clear_history'),
|
| 15 |
+
]
|
translator_app/translator/views.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
| 1 |
import json
|
| 2 |
|
|
|
|
| 3 |
from django.conf import settings
|
| 4 |
-
from django.http import JsonResponse
|
| 5 |
from django.shortcuts import get_object_or_404, redirect, render
|
| 6 |
from django.views.decorators.http import require_POST
|
| 7 |
|
| 8 |
from .models import Language, TranslationHistory
|
|
|
|
| 9 |
from .services.translation_service import TranslationService
|
| 10 |
|
| 11 |
|
|
@@ -27,10 +29,16 @@ def _languages():
|
|
| 27 |
|
| 28 |
def translator(request):
|
| 29 |
languages = _languages()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
return render(request, 'translator/translator.html', {
|
| 31 |
'languages': languages,
|
| 32 |
'models': settings.TRANSLATION_MODELS,
|
| 33 |
'default_model': settings.TRANSLATION_DEFAULT_MODEL,
|
|
|
|
|
|
|
| 34 |
})
|
| 35 |
|
| 36 |
|
|
@@ -68,6 +76,14 @@ def translate_api(request):
|
|
| 68 |
if model not in allowed_models:
|
| 69 |
return JsonResponse({'error': 'Choose a valid translation model.'}, status=400)
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
service = TranslationService()
|
| 72 |
result = service.translate(text, target_code, source_code or None, model=model)
|
| 73 |
if result.get('error'):
|
|
@@ -97,9 +113,67 @@ def translate_api(request):
|
|
| 97 |
translation_model=model,
|
| 98 |
)
|
| 99 |
result['model_label'] = allowed_models[model]
|
|
|
|
|
|
|
| 100 |
return JsonResponse(result)
|
| 101 |
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
@require_POST
|
| 104 |
def delete_history(request, pk):
|
| 105 |
get_object_or_404(TranslationHistory, pk=pk).delete()
|
|
|
|
| 1 |
import json
|
| 2 |
|
| 3 |
+
import requests
|
| 4 |
from django.conf import settings
|
| 5 |
+
from django.http import HttpResponse, JsonResponse
|
| 6 |
from django.shortcuts import get_object_or_404, redirect, render
|
| 7 |
from django.views.decorators.http import require_POST
|
| 8 |
|
| 9 |
from .models import Language, TranslationHistory
|
| 10 |
+
from .services import language_support
|
| 11 |
from .services.translation_service import TranslationService
|
| 12 |
|
| 13 |
|
|
|
|
| 29 |
|
| 30 |
def translator(request):
|
| 31 |
languages = _languages()
|
| 32 |
+
model_language_support = {
|
| 33 |
+
model: (sorted(codes) if codes is not None else None)
|
| 34 |
+
for model, codes in language_support.MODEL_LANGUAGE_SUPPORT.items()
|
| 35 |
+
}
|
| 36 |
return render(request, 'translator/translator.html', {
|
| 37 |
'languages': languages,
|
| 38 |
'models': settings.TRANSLATION_MODELS,
|
| 39 |
'default_model': settings.TRANSLATION_DEFAULT_MODEL,
|
| 40 |
+
'model_language_support_json': model_language_support,
|
| 41 |
+
'elevenlabs_languages_json': sorted(language_support.ELEVENLABS_MULTILINGUAL_V2_LANGUAGES),
|
| 42 |
})
|
| 43 |
|
| 44 |
|
|
|
|
| 76 |
if model not in allowed_models:
|
| 77 |
return JsonResponse({'error': 'Choose a valid translation model.'}, status=400)
|
| 78 |
|
| 79 |
+
# MADLAD auto-detects and ignores any provided source; skip the source
|
| 80 |
+
# side of this check for it so we don't fallback unnecessarily.
|
| 81 |
+
check_source = None if model == 'madlad' else (source_code or None)
|
| 82 |
+
model, fell_back = language_support.resolve_model(model, check_source, target_code)
|
| 83 |
+
if model not in allowed_models:
|
| 84 |
+
# Shouldn't happen (google_fallback is always registered), but stay safe.
|
| 85 |
+
return JsonResponse({'error': 'No translation model supports this language pair.'}, status=422)
|
| 86 |
+
|
| 87 |
service = TranslationService()
|
| 88 |
result = service.translate(text, target_code, source_code or None, model=model)
|
| 89 |
if result.get('error'):
|
|
|
|
| 113 |
translation_model=model,
|
| 114 |
)
|
| 115 |
result['model_label'] = allowed_models[model]
|
| 116 |
+
result['model_used'] = model
|
| 117 |
+
result['fell_back_to_google'] = fell_back
|
| 118 |
return JsonResponse(result)
|
| 119 |
|
| 120 |
|
| 121 |
+
@require_POST
|
| 122 |
+
def text_to_speech(request):
|
| 123 |
+
if not settings.ELEVENLABS_API_KEY:
|
| 124 |
+
return JsonResponse(
|
| 125 |
+
{'error': 'Text-to-speech is not configured on the server.'}, status=503
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
try:
|
| 129 |
+
payload = json.loads(request.body)
|
| 130 |
+
except (TypeError, json.JSONDecodeError):
|
| 131 |
+
return JsonResponse({'error': 'Send a valid JSON request.'}, status=400)
|
| 132 |
+
|
| 133 |
+
text = (payload.get('text') or '').strip()
|
| 134 |
+
lang = (payload.get('language') or '').lower()
|
| 135 |
+
|
| 136 |
+
if not text:
|
| 137 |
+
return JsonResponse({'error': 'No text to read aloud.'}, status=400)
|
| 138 |
+
text = text[:5000]
|
| 139 |
+
|
| 140 |
+
# Fail fast client-side instead of burning an API call: ElevenLabs'
|
| 141 |
+
# multilingual model has no voice for these languages yet.
|
| 142 |
+
if lang and lang not in language_support.ELEVENLABS_MULTILINGUAL_V2_LANGUAGES:
|
| 143 |
+
return JsonResponse(
|
| 144 |
+
{
|
| 145 |
+
'error': 'ElevenLabs does not have a voice for this language yet.',
|
| 146 |
+
'unsupported_language': True,
|
| 147 |
+
},
|
| 148 |
+
status=422,
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
try:
|
| 152 |
+
response = requests.post(
|
| 153 |
+
settings.ELEVENLABS_TTS_URL.format(voice_id=settings.ELEVENLABS_VOICE_ID),
|
| 154 |
+
headers={
|
| 155 |
+
'xi-api-key': settings.ELEVENLABS_API_KEY,
|
| 156 |
+
'Content-Type': 'application/json',
|
| 157 |
+
'Accept': 'audio/mpeg',
|
| 158 |
+
},
|
| 159 |
+
json={
|
| 160 |
+
'text': text,
|
| 161 |
+
'model_id': settings.ELEVENLABS_MODEL_ID,
|
| 162 |
+
},
|
| 163 |
+
timeout=20,
|
| 164 |
+
)
|
| 165 |
+
except requests.RequestException:
|
| 166 |
+
return JsonResponse({'error': 'Speech service is unreachable.'}, status=502)
|
| 167 |
+
|
| 168 |
+
if response.status_code != 200:
|
| 169 |
+
logger_message = response.text[:300] if response.text else ''
|
| 170 |
+
return JsonResponse(
|
| 171 |
+
{'error': 'Speech generation failed.', 'detail': logger_message}, status=502
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
return HttpResponse(response.content, content_type='audio/mpeg')
|
| 175 |
+
|
| 176 |
+
|
| 177 |
@require_POST
|
| 178 |
def delete_history(request, pk):
|
| 179 |
get_object_or_404(TranslationHistory, pk=pk).delete()
|
translator_app/translator_project/__pycache__/settings.cpython-314.pyc
CHANGED
|
Binary files a/translator_app/translator_project/__pycache__/settings.cpython-314.pyc and b/translator_app/translator_project/__pycache__/settings.cpython-314.pyc differ
|
|
|
translator_app/translator_project/settings.py
CHANGED
|
@@ -109,6 +109,7 @@ TRANSLATION_MODELS = (
|
|
| 109 |
("nllb", "NLLB-200 (600M · Fast)"),
|
| 110 |
("nllb_1_3b", "NLLB-200 (1.3B · Accurate)"),
|
| 111 |
("madlad", "MADLAD-400 (3B · Large)"),
|
|
|
|
| 112 |
)
|
| 113 |
TRANSLATION_MODEL_ENGINES = {
|
| 114 |
"nllb": "nllb",
|
|
@@ -126,3 +127,11 @@ if TRANSLATION_API_KEY:
|
|
| 126 |
TRANSLATION_MODELS += (("deepl_fallback", "DeepL"),)
|
| 127 |
# Or for Google Translate:
|
| 128 |
# TRANSLATION_API_URL = 'https://translation.googleapis.com/language/translate/v2'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
("nllb", "NLLB-200 (600M · Fast)"),
|
| 110 |
("nllb_1_3b", "NLLB-200 (1.3B · Accurate)"),
|
| 111 |
("madlad", "MADLAD-400 (3B · Large)"),
|
| 112 |
+
("google_fallback", "Google Translate"),
|
| 113 |
)
|
| 114 |
TRANSLATION_MODEL_ENGINES = {
|
| 115 |
"nllb": "nllb",
|
|
|
|
| 127 |
TRANSLATION_MODELS += (("deepl_fallback", "DeepL"),)
|
| 128 |
# Or for Google Translate:
|
| 129 |
# TRANSLATION_API_URL = 'https://translation.googleapis.com/language/translate/v2'
|
| 130 |
+
|
| 131 |
+
# ElevenLabs text-to-speech. Key must only ever live server-side (env var) -
|
| 132 |
+
# never send it to the browser. Get a key at https://elevenlabs.io/app/settings/api-keys
|
| 133 |
+
# and a voice ID from https://elevenlabs.io/app/voice-library
|
| 134 |
+
ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY")
|
| 135 |
+
ELEVENLABS_VOICE_ID = os.getenv("ELEVENLABS_VOICE_ID", "21m00Tcm4TlvDq8ikWAM") # default: "Rachel"
|
| 136 |
+
ELEVENLABS_MODEL_ID = os.getenv("ELEVENLABS_MODEL_ID", "eleven_multilingual_v2")
|
| 137 |
+
ELEVENLABS_TTS_URL = "https://api.elevenlabs.io/v1/text-to-speech/{voice_id}"
|