arindae commited on
Commit
0c339bd
·
1 Parent(s): 0646386

elevenlabs addition

Browse files
README.md CHANGED
@@ -1,6 +1,219 @@
1
  # The Translator App
2
 
3
- ## Run locally
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  ```bash
6
  cd translator_app
@@ -11,23 +224,78 @@ python manage.py migrate
11
  python manage.py runserver
12
  ```
13
 
14
- Open http://127.0.0.1:8000/. The translation page has an API-backed submit
15
- flow, language selectors, model picker, and persisted history at `/history/`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
- ## Configure a provider
18
 
19
- Create `translator_app/.env` with a Hugging Face token before translating:
 
 
 
 
 
 
 
 
 
 
20
 
21
  ```env
22
- HF_TOKEN=hf_your_access_token
23
- TRANSLATION_DEFAULT_MODEL=nllb_600m
24
  ```
25
 
26
- The primary model picker is configured for NLLB-200 600M, NLLB-200 1.3B, and
27
- MADLAD-400 3B through Hugging Face Inference Providers. NLLB language codes
28
- are mapped in the service before a request is sent. The selected model is sent
29
- by the UI and saved with each history item.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
- For an optional DeepL-compatible fallback, additionally set
32
- `TRANSLATION_API_KEY` (and optionally `TRANSLATION_API_URL`). It will appear in
33
- the model picker only when configured.
 
 
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
- function syncSourceForModel() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  const isMadlad = modelSelect.value === 'madlad';
177
  sourceSelect.disabled = isMadlad;
178
  if (isMadlad) sourceSelect.value = '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  }
180
- modelSelect.addEventListener('change', syncSourceForModel);
181
- syncSourceForModel();
 
 
 
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
- function speakText(text, langCode) {
 
 
 
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
- listenSourceBtn.onclick = () => speakText(source.value, sourceSelect.value || 'en');
217
- listenResultBtn.onclick = () => speakText(result.textContent, targetSelect.value || 'sw');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- document.querySelector('#result-meta').textContent = `Engine: ${data.model_label || 'Translation'} · Saved to history`;
 
 
 
 
 
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}"