Spaces:
Sleeping
Sleeping
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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 125 |
-
|
| 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=
|
| 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
|
| 175 |
return {"translated_text": translated_text}
|
| 176 |
|
| 177 |
-
except requests.exceptions.Timeout:
|
| 178 |
-
|
| 179 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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">
|
|
|
|
| 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 %}
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
| 32 |
</select>
|
| 33 |
</label>
|
| 34 |
</div>
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
| 36 |
<section class="flex min-h-72 flex-1 flex-col">
|
| 37 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
<p id="translated-text" aria-live="polite"
|
| 52 |
-
class="relative flex-1 whitespace-pre-wrap font-display text-2xl leading-relaxed text-primary">
|
| 53 |
-
|
| 54 |
-
<
|
|
|
|
| 55 |
</section>
|
| 56 |
</div>
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
</div>
|
| 61 |
</form>
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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();
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
form.addEventListener('submit', async e => {
|
| 83 |
-
e.preventDefault();
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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': '
|
| 19 |
}
|
| 20 |
response = self.client.post(
|
| 21 |
reverse('translator:translate_api'),
|
| 22 |
-
data='{"text":"Hello","source_language":"en","target_language":"sw","model":"
|
| 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, '
|
| 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 |
-
"""
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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):
|