maggie256 commited on
Commit
0646386
·
1 Parent(s): 9185b9d

changes from my side

Browse files
.gitignore CHANGED
@@ -1 +1,5 @@
1
- translator_app/.venv
 
 
 
 
 
1
+ translator_app/.venv
2
+ .env
3
+ *.env
4
+ *.sqlite3
5
+ __pycache__/
translator_app/translator/services/translation_service.py CHANGED
@@ -20,21 +20,16 @@ class TranslationService:
20
  # NLLB requires its FLORES language tags. MADLAD accepts ISO language codes.
21
  NLLB_LANGUAGE_CODES = {
22
  "en": "eng_Latn",
23
- "es": "spa_Latn",
 
 
 
 
 
 
 
24
  "fr": "fra_Latn",
25
- "de": "deu_Latn",
26
- "it": "ita_Latn",
27
- "pt": "por_Latn",
28
- "ru": "rus_Cyrl",
29
- "zh": "zho_Hans",
30
- "ja": "jpn_Jpan",
31
- "ko": "kor_Hang",
32
  "ar": "arb_Arab",
33
- "hi": "hin_Deva",
34
- "sw": "swh_Latn",
35
- "yo": "yor_Latn",
36
- "ha": "hau_Latn",
37
- "ig": "ibo_Latn",
38
  }
39
 
40
  def __init__(self):
@@ -43,21 +38,16 @@ class TranslationService:
43
  def _get_supported_languages(self):
44
  return {
45
  "en": "English",
46
- "es": "Spanish",
 
 
 
 
 
 
 
47
  "fr": "French",
48
- "de": "German",
49
- "it": "Italian",
50
- "pt": "Portuguese",
51
- "ru": "Russian",
52
- "zh": "Chinese",
53
- "ja": "Japanese",
54
- "ko": "Korean",
55
  "ar": "Arabic",
56
- "hi": "Hindi",
57
- "sw": "Swahili",
58
- "yo": "Yoruba",
59
- "ha": "Hausa",
60
- "ig": "Igbo",
61
  }
62
 
63
  def get_supported_languages(self):
@@ -121,9 +111,8 @@ class TranslationService:
121
 
122
  def _translate_model_api(self, text, source_language, target_language, model):
123
  if not settings.MODEL_API_URL:
124
- return {
125
- "error": "ML translation is not configured. Add MODEL_API_URL to your environment."
126
- }
127
 
128
  if isinstance(settings.TRANSLATION_MODEL_ENGINES, dict):
129
  engine_name = settings.TRANSLATION_MODEL_ENGINES.get(model, model)
@@ -156,7 +145,7 @@ class TranslationService:
156
  response = requests.post(
157
  settings.MODEL_API_URL,
158
  json=payload,
159
- timeout=60,
160
  )
161
 
162
  if response.status_code != 200:
@@ -165,22 +154,43 @@ class TranslationService:
165
  response.status_code,
166
  response.text,
167
  )
168
- return {
169
- "error": f"The ML service failed with status {response.status_code}."
170
- }
171
 
172
  translated_text = response.json().get("translation")
173
  if not translated_text:
174
- return {"error": "The ML model returned no translation."}
175
  return {"translated_text": translated_text}
176
 
177
- except requests.exceptions.Timeout:
178
- return {"error": "The ML translation request timed out."}
179
- except requests.exceptions.ConnectionError:
180
- return {"error": "Could not connect to the ML translation service."}
181
  except (ValueError, requests.RequestException) as error:
182
  logger.exception("Model API translation failed: %s", error)
183
- return {"error": "The ML translation service returned an invalid response."}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
 
185
  def _translate_deepl(self, text, source_language, target_language):
186
  if not settings.TRANSLATION_API_KEY:
 
20
  # NLLB requires its FLORES language tags. MADLAD accepts ISO language codes.
21
  NLLB_LANGUAGE_CODES = {
22
  "en": "eng_Latn",
23
+ "sw": "swh_Latn",
24
+ "lg": "lug_Latn",
25
+ "luo": "luo_Latn",
26
+ "nyn": "nyn_Latn",
27
+ "ach": "ach_Latn",
28
+ "teo": "teo_Latn",
29
+ "lgg": "lgg_Latn",
30
+ "rw": "kin_Latn",
31
  "fr": "fra_Latn",
 
 
 
 
 
 
 
32
  "ar": "arb_Arab",
 
 
 
 
 
33
  }
34
 
35
  def __init__(self):
 
38
  def _get_supported_languages(self):
39
  return {
40
  "en": "English",
41
+ "sw": "Swahili (Kiswahili)",
42
+ "lg": "Luganda (Ganda)",
43
+ "luo": "Luo (Acholi / Lango)",
44
+ "nyn": "Runyankole / Rukiga",
45
+ "ach": "Acholi",
46
+ "teo": "Ateso (Teso)",
47
+ "lgg": "Lugbara",
48
+ "rw": "Kinyarwanda",
49
  "fr": "French",
 
 
 
 
 
 
 
50
  "ar": "Arabic",
 
 
 
 
 
51
  }
52
 
53
  def get_supported_languages(self):
 
111
 
112
  def _translate_model_api(self, text, source_language, target_language, model):
113
  if not settings.MODEL_API_URL:
114
+ logger.info("MODEL_API_URL not set; falling back to free translation engine.")
115
+ return self._translate_free_fallback(text, source_language, target_language)
 
116
 
117
  if isinstance(settings.TRANSLATION_MODEL_ENGINES, dict):
118
  engine_name = settings.TRANSLATION_MODEL_ENGINES.get(model, model)
 
145
  response = requests.post(
146
  settings.MODEL_API_URL,
147
  json=payload,
148
+ timeout=3,
149
  )
150
 
151
  if response.status_code != 200:
 
154
  response.status_code,
155
  response.text,
156
  )
157
+ return self._translate_free_fallback(text, source_language, target_language)
 
 
158
 
159
  translated_text = response.json().get("translation")
160
  if not translated_text:
161
+ return self._translate_free_fallback(text, source_language, target_language)
162
  return {"translated_text": translated_text}
163
 
164
+ except (requests.exceptions.Timeout, requests.exceptions.ConnectionError):
165
+ logger.warning("ML translation service unreachable; falling back to free translation engine.")
166
+ return self._translate_free_fallback(text, source_language, target_language)
 
167
  except (ValueError, requests.RequestException) as error:
168
  logger.exception("Model API translation failed: %s", error)
169
+ return self._translate_free_fallback(text, source_language, target_language)
170
+
171
+ def _translate_free_fallback(self, text, source_language, target_language):
172
+ """Free public fallback translation when the ML model API is unconfigured/unavailable."""
173
+ try:
174
+ url = "https://translate.googleapis.com/translate_a/single"
175
+ params = {
176
+ "client": "gtx",
177
+ "sl": source_language or "auto",
178
+ "tl": target_language,
179
+ "dt": "t",
180
+ "q": text,
181
+ }
182
+ res = requests.get(url, params=params, timeout=10)
183
+ if res.status_code == 200:
184
+ data = res.json()
185
+ translated_text = "".join([chunk[0] for chunk in data[0] if chunk[0]])
186
+ if translated_text:
187
+ return {"translated_text": translated_text}
188
+ except Exception as err:
189
+ logger.warning("Free fallback translation failed: %s", err)
190
+
191
+ return {
192
+ "error": "Translation service is currently unavailable. Please check your network or backend settings."
193
+ }
194
 
195
  def _translate_deepl(self, text, source_language, target_language):
196
  if not settings.TRANSLATION_API_KEY:
translator_app/translator/templates/translator/translator.html CHANGED
@@ -3,9 +3,11 @@
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
- <div class="flex flex-wrap justify-center items-center gap-3 mb-5">
 
 
7
  <select id="source-language" aria-label="Source language"
8
- class="rounded-full border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-primary shadow-sm focus:border-primary focus:ring-primary">
9
  <option value="">Detect language</option>
10
  {% for language in languages %}
11
  <option value="{{ language.code }}" {% if language.code == 'en' %}selected{% endif %}>
@@ -13,60 +15,162 @@
13
  </option>
14
  {% endfor %}
15
  </select>
 
16
  <button id="swap-languages" type="button" title="Swap languages"
17
- class="rounded-full bg-sand px-3 py-2.5 text-primary transition hover:rotate-180">⇄</button>
 
18
  <select id="target-language" aria-label="Target language"
19
- class="rounded-full border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-primary shadow-sm focus:border-primary focus:ring-primary">
20
  {% for language in languages %}
21
  <option value="{{ language.code }}" {% if language.code == 'sw' %} selected{% endif%}>
22
  {{ language.name }}
23
  </option>
24
- {% endfor %}
25
  </select>
 
26
  <label
27
- class="flex items-center gap-2 rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 shadow-sm">Model
 
28
  <select id="translation-model" aria-label="Translation model"
29
- class="border-0 bg-transparent py-0 pl-1 pr-7 text-primary focus:ring-0">
30
- {% for value, label in models %}<option value="{{ value }}" {% if value == default_model %} selected{% endif %}>
31
- {{ label }}</option>{% endfor %}
 
 
 
32
  </select>
33
  </label>
34
  </div>
35
- <div class="rounded-2xl border border-stone-200 bg-white p-5 md:p-7 shadow-xl shadow-stone-200/40 md:flex md:gap-7">
 
 
 
36
  <section class="flex min-h-72 flex-1 flex-col">
37
- <label for="source-text" class="mb-3 text-xs font-bold uppercase tracking-widest text-stone-400">Source</label>
 
 
 
 
 
 
38
  <textarea id="source-text" maxlength="5000"
39
  class="min-h-52 w-full flex-1 resize-none border-0 p-0 font-display text-2xl leading-relaxed text-primary placeholder:text-stone-300 focus:ring-0"
40
- placeholder="Enter text to translate..."></textarea>
41
- <div class="mt-3 flex justify-between border-t border-stone-100 pt-3 text-xs text-stone-400"><button
42
- id="clear-source" type="button" class="font-semibold text-primary hover:underline">Clear</button><span
43
- id="character-count">0 / 5000 characters</span></div>
 
 
 
 
 
 
 
 
 
44
  </section>
 
45
  <div class="my-5 hidden w-px bg-stone-100 md:block"></div>
 
 
46
  <section class="relative flex min-h-72 flex-1 flex-col">
47
- <div class="mb-3 flex items-center justify-between"><span
48
- class="text-xs font-bold uppercase tracking-widest text-stone-400">Translation</span><button
49
- id="copy-result" type="button"
50
- class="hidden rounded-full px-3 py-1 text-xs font-bold text-primary hover:bg-mist">Copy</button></div>
 
 
 
 
 
 
 
 
 
51
  <p id="translated-text" aria-live="polite"
52
- class="relative flex-1 whitespace-pre-wrap font-display text-2xl leading-relaxed text-primary"><span
53
- class="text-stone-300">Your translation will appear here.</span></p>
54
- <p id="result-meta" class="mt-3 text-xs text-stone-400"></p>
 
55
  </section>
56
  </div>
57
- <div class="mt-6 flex flex-col items-center gap-3"><button id="translate-button"
58
- class="rounded-full bg-primary px-10 py-4 font-display text-lg font-bold uppercase tracking-wider text-white shadow-lg shadow-primary/30 transition hover:scale-105 disabled:cursor-wait disabled:opacity-70">Translate</button>
59
- <p id="form-message" role="alert" class="min-h-5 text-sm text-red-700"></p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  </div>
61
  </form>
62
- <div class="mt-12 text-center"><a href="{% url 'translator:history' %}"
63
- class="text-sm font-bold text-primary underline underline-offset-4 hover:text-stone-700">View history</a></div>
 
 
 
 
 
 
64
  </main>
 
65
  <script>
66
  const form = document.querySelector('#translation-form'), source = document.querySelector('#source-text'), result = document.querySelector('#translated-text'), message = document.querySelector('#form-message'), button = document.querySelector('#translate-button'), count = document.querySelector('#character-count');
67
  const csrf = document.querySelector('[name=csrfmiddlewaretoken]').value;
68
  const modelSelect = document.querySelector('#translation-model');
69
  const sourceSelect = document.querySelector('#source-language');
 
 
 
 
 
 
 
 
 
 
70
 
71
  function syncSourceForModel() {
72
  const isMadlad = modelSelect.value === 'madlad';
@@ -74,14 +178,176 @@
74
  if (isMadlad) sourceSelect.value = '';
75
  }
76
  modelSelect.addEventListener('change', syncSourceForModel);
77
- syncSourceForModel(); // run once on load in case MADLAD is the default
78
- source.addEventListener('input', () => count.textContent = `${source.value.length} / 5000 characters`);
79
- document.querySelector('#clear-source').onclick = () => { source.value = ''; source.dispatchEvent(new Event('input')); source.focus() };
80
- document.querySelector('#swap-languages').onclick = () => { const a = document.querySelector('#source-language'), b = document.querySelector('#target-language'); if (a.value) { [a.value, b.value] = [b.value, a.value] } };
81
- document.querySelector('#copy-result').onclick = async () => { await navigator.clipboard.writeText(result.textContent); document.querySelector('#copy-result').textContent = 'Copied'; setTimeout(() => document.querySelector('#copy-result').textContent = 'Copy', 1200) };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  form.addEventListener('submit', async e => {
83
- e.preventDefault(); message.textContent = ''; button.disabled = true; button.textContent = 'Translating…'; result.classList.add('quiet-loading');
84
- try { const response = await fetch("{% url 'translator:translate_api' %}", { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf }, body: JSON.stringify({ text: source.value, source_language: document.querySelector('#source-language').value, target_language: document.querySelector('#target-language').value, model: document.querySelector('#translation-model').value }) }); const data = await response.json(); if (!response.ok) throw Error(data.error || 'Translation could not be completed.'); result.textContent = data.translated_text; document.querySelector('#result-meta').textContent = `${data.model_label || 'Translation'} · saved to history`; document.querySelector('#copy-result').classList.remove('hidden') } catch (error) { message.textContent = error.message; result.innerHTML = '<span class="text-stone-300">Your translation will appear here.</span>' } finally { button.disabled = false; button.textContent = 'Translate'; result.classList.remove('quiet-loading') }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  });
86
  </script>
87
  {% endblock %}
 
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">
9
  <select id="source-language" aria-label="Source language"
10
+ class="rounded-full border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-primary shadow-sm focus:border-primary focus:ring-primary transition hover:border-stone-300">
11
  <option value="">Detect language</option>
12
  {% for language in languages %}
13
  <option value="{{ language.code }}" {% if language.code == 'en' %}selected{% endif %}>
 
15
  </option>
16
  {% endfor %}
17
  </select>
18
+
19
  <button id="swap-languages" type="button" title="Swap languages"
20
+ class="rounded-full bg-sand px-3 py-2.5 text-primary shadow-sm transition hover:rotate-180 hover:bg-stone-200">⇄</button>
21
+
22
  <select id="target-language" aria-label="Target language"
23
+ class="rounded-full border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-primary shadow-sm focus:border-primary focus:ring-primary transition hover:border-stone-300">
24
  {% for language in languages %}
25
  <option value="{{ language.code }}" {% if language.code == 'sw' %} selected{% endif%}>
26
  {{ language.name }}
27
  </option>
28
+ {% endfor %}
29
  </select>
30
+
31
  <label
32
+ class="flex items-center gap-2 rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 shadow-sm">
33
+ <span class="text-xs uppercase tracking-wider text-stone-400">Model</span>
34
  <select id="translation-model" aria-label="Translation model"
35
+ class="border-0 bg-transparent py-0 pl-1 pr-7 text-primary font-bold focus:ring-0">
36
+ {% for value, label in models %}
37
+ <option value="{{ value }}" {% if value == default_model %} selected{% endif %}>
38
+ {{ label }}
39
+ </option>
40
+ {% endfor %}
41
  </select>
42
  </label>
43
  </div>
44
+
45
+ <!-- Main Translation Panels -->
46
+ <div class="rounded-3xl border border-stone-200 bg-white p-5 md:p-8 shadow-2xl shadow-stone-200/50 md:flex md:gap-8 transition-all">
47
+ <!-- Source Panel -->
48
  <section class="flex min-h-72 flex-1 flex-col">
49
+ <div class="mb-3 flex items-center justify-between">
50
+ <span class="text-xs font-bold uppercase tracking-widest text-stone-400">Source Text</span>
51
+ <button id="listen-source" type="button" title="Listen to source text"
52
+ class="hidden items-center gap-1 rounded-full bg-mist px-3 py-1 text-xs font-bold text-primary hover:bg-stone-200 transition">
53
+ 🔊 Listen
54
+ </button>
55
+ </div>
56
  <textarea id="source-text" maxlength="5000"
57
  class="min-h-52 w-full flex-1 resize-none border-0 p-0 font-display text-2xl leading-relaxed text-primary placeholder:text-stone-300 focus:ring-0"
58
+ placeholder="Type or use the Voice Assistant below to dictate..."></textarea>
59
+
60
+ <!-- Source Footer Bar with Text Translate Button -->
61
+ <div class="mt-4 flex items-center justify-between border-t border-stone-100 pt-3 text-xs text-stone-400">
62
+ <div class="flex items-center gap-3">
63
+ <button id="clear-source" type="button" class="font-semibold text-primary hover:underline">Clear text</button>
64
+ <span id="character-count">0 / 5000 characters</span>
65
+ </div>
66
+ <button id="translate-text-btn" type="submit"
67
+ class="translate-trigger flex items-center gap-1.5 rounded-full bg-primary px-5 py-2 text-xs font-bold uppercase tracking-wider text-white shadow-md shadow-primary/20 transition hover:scale-105 active:scale-95 disabled:cursor-wait disabled:opacity-70">
68
+ <span>✨</span> <span>Translate Text</span>
69
+ </button>
70
+ </div>
71
  </section>
72
+
73
  <div class="my-5 hidden w-px bg-stone-100 md:block"></div>
74
+
75
+ <!-- Translation Output Panel -->
76
  <section class="relative flex min-h-72 flex-1 flex-col">
77
+ <div class="mb-3 flex items-center justify-between">
78
+ <span class="text-xs font-bold uppercase tracking-widest text-stone-400">Translation</span>
79
+ <div class="flex items-center gap-2">
80
+ <button id="listen-result" type="button" title="Listen to translation"
81
+ class="hidden items-center gap-1 rounded-full bg-mist px-3 py-1 text-xs font-bold text-primary hover:bg-stone-200 transition">
82
+ 🔊 Listen
83
+ </button>
84
+ <button id="copy-result" type="button"
85
+ class="hidden rounded-full bg-mist px-3 py-1 text-xs font-bold text-primary hover:bg-stone-200 transition">
86
+ 📋 Copy
87
+ </button>
88
+ </div>
89
+ </div>
90
  <p id="translated-text" aria-live="polite"
91
+ class="relative flex-1 whitespace-pre-wrap font-display text-2xl leading-relaxed text-primary">
92
+ <span class="text-stone-300">Your translation will appear here.</span>
93
+ </p>
94
+ <p id="result-meta" class="mt-4 text-xs text-stone-400 border-t border-stone-100 pt-3"></p>
95
  </section>
96
  </div>
97
+
98
+ <!-- Modern Voice Assistant Chatbot Card -->
99
+ <div class="mt-6 rounded-3xl border border-stone-200/80 bg-gradient-to-br from-stone-50 via-white to-sand/40 p-5 md:p-6 shadow-xl shadow-stone-200/40">
100
+ <div class="flex flex-col md:flex-row items-center justify-between gap-5">
101
+
102
+ <!-- Left Info & Waveform -->
103
+ <div class="flex items-center gap-4 text-center md:text-left">
104
+ <!-- Mic Floating Action Hub -->
105
+ <div class="relative">
106
+ <button id="voice-record" type="button" title="Toggle Voice Dictation"
107
+ class="relative z-10 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-xl text-white shadow-md shadow-primary/30 transition-all hover:scale-105 active:scale-95 focus:outline-none">
108
+ <span id="mic-icon">🎙️</span>
109
+ </button>
110
+ <!-- Animated Pulsing Ring when Recording -->
111
+ <span id="pulse-ring" class="hidden absolute inset-0 -z-10 rounded-full bg-red-400/40 animate-ping"></span>
112
+ </div>
113
+
114
+ <div>
115
+ <div class="flex items-center gap-2 justify-center md:justify-start">
116
+ <h3 id="record-status-heading" class="font-display font-bold text-primary text-sm">Voice AI Assistant</h3>
117
+ <span id="recording-badge" class="hidden rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-bold uppercase text-red-700 animate-pulse">
118
+ 🔴 Live Dictating
119
+ </span>
120
+ </div>
121
+ <p id="record-subtext" class="text-xs text-stone-500 mt-0.5">Tap the mic to dictate live in any supported language.</p>
122
+ </div>
123
+ </div>
124
+
125
+ <!-- Center Sound Wave Visualization (Appears when active) -->
126
+ <div id="sound-wave" class="hidden flex items-center gap-1.5 h-6">
127
+ <span class="w-1 bg-primary rounded-full animate-[bounce_1s_infinite_100ms] h-3"></span>
128
+ <span class="w-1 bg-primary rounded-full animate-[bounce_1s_infinite_300ms] h-6"></span>
129
+ <span class="w-1 bg-primary rounded-full animate-[bounce_1s_infinite_200ms] h-4"></span>
130
+ <span class="w-1 bg-primary rounded-full animate-[bounce_1s_infinite_400ms] h-6"></span>
131
+ <span class="w-1 bg-primary rounded-full animate-[bounce_1s_infinite_150ms] h-3"></span>
132
+ </div>
133
+
134
+ <!-- Dedicated Voice Translate Action Button -->
135
+ <div class="flex items-center gap-2">
136
+ <button id="translate-voice-btn" type="submit"
137
+ class="translate-trigger flex items-center gap-1.5 rounded-full bg-primary px-5 py-2 text-xs font-bold uppercase tracking-wider text-white shadow-md shadow-primary/20 transition hover:scale-105 active:scale-95 disabled:cursor-wait disabled:opacity-70">
138
+ <span>⚡</span> <span>Translate Voice</span>
139
+ </button>
140
+ </div>
141
+ </div>
142
+ </div>
143
+
144
+ <!-- Alert / Message bar -->
145
+ <div class="mt-4 text-center">
146
+ <p id="form-message" role="alert" class="min-h-5 text-sm font-semibold text-red-600"></p>
147
  </div>
148
  </form>
149
+
150
+ <!-- History Footer Navigation -->
151
+ <div class="mt-10 text-center">
152
+ <a href="{% url 'translator:history' %}"
153
+ class="inline-flex items-center gap-2 rounded-full border border-stone-200 bg-white px-6 py-2.5 text-sm font-bold text-primary shadow-sm hover:bg-sand transition">
154
+ 📜 View Translation History
155
+ </a>
156
+ </div>
157
  </main>
158
+
159
  <script>
160
  const form = document.querySelector('#translation-form'), source = document.querySelector('#source-text'), result = document.querySelector('#translated-text'), message = document.querySelector('#form-message'), button = document.querySelector('#translate-button'), count = document.querySelector('#character-count');
161
  const csrf = document.querySelector('[name=csrfmiddlewaretoken]').value;
162
  const modelSelect = document.querySelector('#translation-model');
163
  const sourceSelect = document.querySelector('#source-language');
164
+ const targetSelect = document.querySelector('#target-language');
165
+ const recordBtn = document.querySelector('#voice-record');
166
+ const micIcon = document.querySelector('#mic-icon');
167
+ const pulseRing = document.querySelector('#pulse-ring');
168
+ const recordBadge = document.querySelector('#recording-badge');
169
+ const recordSubtext = document.querySelector('#record-subtext');
170
+ const soundWave = document.querySelector('#sound-wave');
171
+ const listenSourceBtn = document.querySelector('#listen-source');
172
+ const listenResultBtn = document.querySelector('#listen-result');
173
+ const copyBtn = document.querySelector('#copy-result');
174
 
175
  function syncSourceForModel() {
176
  const isMadlad = modelSelect.value === 'madlad';
 
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`;
185
+ if (source.value.trim().length > 0) {
186
+ listenSourceBtn.classList.remove('hidden');
187
+ listenSourceBtn.classList.add('inline-flex');
188
+ } else {
189
+ listenSourceBtn.classList.add('hidden');
190
+ listenSourceBtn.classList.remove('inline-flex');
191
+ }
192
+ }
193
+ source.addEventListener('input', updateSourceControls);
194
+
195
+ document.querySelector('#clear-source').onclick = () => {
196
+ source.value = '';
197
+ updateSourceControls();
198
+ source.focus();
199
+ };
200
+
201
+ document.querySelector('#swap-languages').onclick = () => {
202
+ if (sourceSelect.value) {
203
+ [sourceSelect.value, targetSelect.value] = [targetSelect.value, sourceSelect.value];
204
+ }
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);
212
+ if (langCode) utterance.lang = langCode;
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;
221
+ let recognition = null;
222
+ let isRecording = false;
223
+
224
+ function stopRecordingUI() {
225
+ isRecording = false;
226
+ pulseRing.classList.add('hidden');
227
+ recordBadge.classList.add('hidden');
228
+ soundWave.classList.add('hidden');
229
+ recordSubtext.textContent = 'Tap the mic to dictate your message live in any language.';
230
+ micIcon.textContent = '🎙️';
231
+ recordBtn.classList.remove('bg-red-600');
232
+ recordBtn.classList.add('bg-primary');
233
+ }
234
+
235
+ if (SpeechRecognition) {
236
+ recognition = new SpeechRecognition();
237
+ recognition.continuous = true;
238
+ recognition.interimResults = true;
239
+
240
+ recognition.onstart = () => {
241
+ isRecording = true;
242
+ pulseRing.classList.remove('hidden');
243
+ recordBadge.classList.remove('hidden');
244
+ soundWave.classList.remove('hidden');
245
+ soundWave.classList.add('flex');
246
+ recordSubtext.textContent = 'Speak clearly into your microphone...';
247
+ micIcon.textContent = '⏹️';
248
+ recordBtn.classList.remove('bg-primary');
249
+ recordBtn.classList.add('bg-red-600');
250
+ };
251
+
252
+ recognition.onresult = (event) => {
253
+ let transcript = '';
254
+ for (let i = event.resultIndex; i < event.results.length; i++) {
255
+ transcript += event.results[i][0].transcript;
256
+ }
257
+ source.value = transcript;
258
+ updateSourceControls();
259
+ };
260
+
261
+ recognition.onerror = (event) => {
262
+ console.error('Speech recognition error:', event.error);
263
+ stopRecordingUI();
264
+ if (event.error !== 'no-speech') {
265
+ message.textContent = `Voice dictation status: ${event.error}`;
266
+ }
267
+ };
268
+
269
+ recognition.onend = () => {
270
+ stopRecordingUI();
271
+ };
272
+
273
+ recordBtn.addEventListener('click', () => {
274
+ if (isRecording) {
275
+ recognition.stop();
276
+ } else {
277
+ message.textContent = '';
278
+ const langCode = sourceSelect.value || 'en';
279
+ recognition.lang = langCode;
280
+ try {
281
+ recognition.start();
282
+ } catch (e) {
283
+ console.error(e);
284
+ }
285
+ }
286
+ });
287
+ } else {
288
+ recordBtn.addEventListener('click', () => {
289
+ message.textContent = 'Voice dictation is supported in Google Chrome, Microsoft Edge, Safari, and Opera.';
290
+ });
291
+ }
292
+
293
+ copyBtn.onclick = async () => {
294
+ await navigator.clipboard.writeText(result.textContent);
295
+ copyBtn.textContent = 'Copied!';
296
+ setTimeout(() => copyBtn.textContent = '📋 Copy', 1200);
297
+ };
298
+
299
+ const triggerBtns = document.querySelectorAll('.translate-trigger');
300
+
301
  form.addEventListener('submit', async e => {
302
+ e.preventDefault();
303
+ message.textContent = '';
304
+
305
+ // Immediately stop recording & reset Voice UI to default when translating
306
+ if (recognition && isRecording) {
307
+ try { recognition.stop(); } catch (err) {}
308
+ }
309
+ stopRecordingUI();
310
+
311
+ triggerBtns.forEach(btn => {
312
+ btn.disabled = true;
313
+ btn.classList.add('opacity-50');
314
+ });
315
+
316
+ result.classList.add('quiet-loading');
317
+
318
+ try {
319
+ const response = await fetch("{% url 'translator:translate_api' %}", {
320
+ method: 'POST',
321
+ headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf },
322
+ body: JSON.stringify({
323
+ text: source.value,
324
+ source_language: sourceSelect.value,
325
+ target_language: targetSelect.value,
326
+ model: modelSelect.value
327
+ })
328
+ });
329
+
330
+ const data = await response.json();
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');
338
+ listenResultBtn.classList.add('inline-flex');
339
+ } catch (error) {
340
+ message.textContent = error.message;
341
+ result.innerHTML = '<span class="text-stone-300">Your translation will appear here.</span>';
342
+ listenResultBtn.classList.add('hidden');
343
+ listenResultBtn.classList.remove('inline-flex');
344
+ } finally {
345
+ triggerBtns.forEach(btn => {
346
+ btn.disabled = false;
347
+ btn.classList.remove('opacity-50');
348
+ });
349
+ result.classList.remove('quiet-loading');
350
+ }
351
  });
352
  </script>
353
  {% endblock %}
translator_app/translator/tests.py CHANGED
@@ -15,17 +15,17 @@ class TranslatorViewsTests(TestCase):
15
  def test_translation_api_saves_history(self, translate):
16
  translate.return_value = {
17
  'source_text': 'Hello', 'translated_text': 'Jambo',
18
- 'source_language': 'en', 'target_language': 'sw', 'model': 'nllb_600m',
19
  }
20
  response = self.client.post(
21
  reverse('translator:translate_api'),
22
- data='{"text":"Hello","source_language":"en","target_language":"sw","model":"nllb_600m"}',
23
  content_type='application/json',
24
  )
25
  self.assertEqual(response.status_code, 200)
26
  self.assertEqual(response.json()['translated_text'], 'Jambo')
27
  history = TranslationHistory.objects.get()
28
- self.assertEqual(history.translation_model, 'nllb_600m')
29
  self.assertEqual(history.source_text, 'Hello')
30
 
31
  def test_translation_api_rejects_missing_text(self):
 
15
  def test_translation_api_saves_history(self, translate):
16
  translate.return_value = {
17
  'source_text': 'Hello', 'translated_text': 'Jambo',
18
+ 'source_language': 'en', 'target_language': 'sw', 'model': 'nllb',
19
  }
20
  response = self.client.post(
21
  reverse('translator:translate_api'),
22
+ data='{"text":"Hello","source_language":"en","target_language":"sw","model":"nllb"}',
23
  content_type='application/json',
24
  )
25
  self.assertEqual(response.status_code, 200)
26
  self.assertEqual(response.json()['translated_text'], 'Jambo')
27
  history = TranslationHistory.objects.get()
28
+ self.assertEqual(history.translation_model, 'nllb')
29
  self.assertEqual(history.source_text, 'Hello')
30
 
31
  def test_translation_api_rejects_missing_text(self):
translator_app/translator/views.py CHANGED
@@ -10,9 +10,19 @@ from .services.translation_service import TranslationService
10
 
11
 
12
  def _languages():
13
- """Use database languages when curated, otherwise use provider defaults."""
14
- saved = Language.objects.filter(is_active=True).values('code', 'name')
15
- return list(saved) or TranslationService().get_supported_languages()
 
 
 
 
 
 
 
 
 
 
16
 
17
 
18
  def translator(request):
 
10
 
11
 
12
  def _languages():
13
+ """Sync curated East African languages with TranslationService and return them."""
14
+ service_languages = TranslationService().get_supported_languages()
15
+ active_codes = {item['code'] for item in service_languages}
16
+
17
+ # Mark old non-East African languages as inactive in DB
18
+ Language.objects.exclude(code__in=active_codes).update(is_active=False)
19
+
20
+ for item in service_languages:
21
+ Language.objects.update_or_create(
22
+ code=item['code'],
23
+ defaults={'name': item['name'], 'is_active': True}
24
+ )
25
+ return list(Language.objects.filter(is_active=True).order_by('name').values('code', 'name'))
26
 
27
 
28
  def translator(request):