feat(transcribe): English UI default, language switcher (NO/UK/PL), fix 504 timeout

- Default UI language to English; lang switcher (EN/NO/UK/PL) persisted in localStorage
- Rename 'rettssak/tingrett' preset to 'Mediation / legal meeting' — court recording is illegal
- Add Ukrainian (uk) and Polish (pl) as selectable audio transcription languages
- TRANSCRIBE_I18N translation object drives all status messages, labels, and trace text
- Apache ProxyTimeout raised to 1800s on server (was 300s — caused 504 on large files)
- set_time_limit(0) + ignore_user_abort(true) in api/transcribe.php
- applyTranscribeI18n() patches data-i18n / data-i18n-placeholder / data-i18n-aria attrs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:47:32 +02:00
parent 26f4e2231b
commit c77efa241c
4 changed files with 405 additions and 70 deletions
+36
View File
@@ -1300,6 +1300,42 @@ p {
margin-bottom: 0.35rem;
}
/* ─── UI Language switcher ────────────────────────────────────────────────── */
.lang-switcher {
display: flex;
align-items: center;
gap: 0.375rem;
padding-bottom: 0.625rem;
margin-bottom: 0.25rem;
border-bottom: 1px solid var(--line);
}
.lang-btn {
background: var(--bg);
border: 1px solid var(--line);
border-radius: 9999px;
color: var(--muted);
cursor: pointer;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.03em;
padding: 0.2rem 0.55rem;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.lang-btn:hover {
background: var(--soft-teal);
border-color: var(--teal);
color: var(--teal);
}
.lang-btn.is-active {
background: var(--teal);
border-color: var(--teal);
color: #fff;
}
.vocab-btn {
font-size: 0.78rem;
padding: 0.2rem 0.6rem;
+314 -27
View File
@@ -9,11 +9,299 @@ let lastTranscriptData = null;
const VOCAB_PRESETS = {
barnerett: 'Barnevernet, Fylkesnemnda, barnevernloven, barneloven, barnets beste, samvær, foreldreansvar, omsorgsovertakelse, sakkyndig, advokat, prosessfullmektig, dommer, vitne, tolk, bistandsadvokat, fosterforeldre, fosterhjem, akuttvedtak, statsforvalter, Bufetat, saksbehandler, rettslig medhold, begjæring, samtykke, tilsynsfører',
rettssak: 'Tingretten, lagmannsretten, Høyesterett, statsadvokat, aktor, forsvarer, tiltalte, fornærmede, stevning, tilsvar, prosesskriv, rettsbok, bevisføring, anke, dom, kjennelse, rettsmekling, forlik, saksøker, saksøkte, vitne, ed, prosessfullmektig',
mediation: 'rettsmekling, meklingsnemnd, familievernkontor, mekling, forlik, meklingsprotokoll, foreldreplan, avtale, prosessfullmektig, advokat, dommer, vitne, sakkyndig, statsforvalter, barnevernloven, barneloven, barnets beste, samvær, foreldreansvar, omsorgsovertakelse, tilsvar, stevning, kjennelse, anke',
generell: 'bokmål, nynorsk, statsforvalter, kommunen, forvaltning, klage, vedtak, rettigheter, plikter, protokoll, referat, rapport, dokumentasjon, velferd',
custom: '',
};
let uiLang = localStorage.getItem('dbn-ui-lang') || 'en';
const TRANSCRIBE_I18N = {
en: {
engine: 'Engine',
engineGpuLabel: 'GPU (cuttlefish RTX 3060)',
engineOpenaiLabel: 'OpenAI Whisper API',
engineAzureLabel: 'Azure AI Speech (nb-NO)',
apiKey: 'API Key',
apiKeyHint: 'Used for this request only, never stored. Max 25MB.',
region: 'Region',
model: 'Model',
modelFastest: 'Fastest',
modelBalanced: 'Balanced',
modelBest: 'Best quality',
transcribeLang: 'Audio language',
autoDetectHint: '(may confuse nb/da/sv)',
speakers: 'Speakers',
identifySpeakers: 'Identify speakers',
speakersCount: 'Count',
speakersPlaceholder: 'auto',
speakersAriaLabel: 'Expected number of speakers',
vocabulary: 'Vocabulary',
vocabPresetChildWelfare: 'Child welfare / CPS',
vocabPresetMediation: 'Mediation / legal meeting',
vocabPresetGeneral: 'General Norwegian',
vocabPresetCustom: 'Custom',
vocabPlaceholder: 'Technical terms and names for Whisper to recognise, e.g. Barnevernet, mediation, family services…',
vocabHint: 'Helps Whisper recognise technical terms. Not included in the transcript.',
uploadAria: 'Audio upload',
uploadDrop: 'Drop audio file(s) here, or',
uploadBrowse: 'browse',
uploadHint: 'max 200MB per file',
uploadAddFiles: '+ Add files',
uploadClearQueue: '× Clear queue',
expertSettings: 'Advanced settings',
task: 'Task',
taskTranscribe: 'Transcribe',
taskTranslate: 'Translate to English',
beamSize: 'Beam size',
beamFastest: '(fastest)',
beamBest: '(best)',
vadFilter: 'VAD filter',
vadFilterLabel: 'Remove silence',
run: 'Run',
running: 'Transcribing…',
runningOther: 'Running…',
readyTitle: 'Ready',
readyDesc: 'Select a tool, run a request, and the result appears here.',
noFileSelected: 'Select at least one audio file before transcribing.',
missingOpenaiKey: 'Enter a valid OpenAI API key (sk-…) before running.',
openaiFileTooLarge: (f) => `OpenAI Whisper has a 25 MB limit. Use the GPU engine for ${f}.`,
missingAzureKey: 'Enter an Azure Speech API key before running.',
clipLabel: (i, total) => total > 1 ? `Clip ${i}/${total}` : 'Transcribing',
transcribeFailed: (s) => `Transcription failed (HTTP ${s}).`,
errorLabel: (clip) => `Error ${clip}`,
filesSkipped: (names) => `Skipped: ${names}`,
fileSizeExceeded: (name, mb) => `${name} (${mb} MB — max 200 MB)`,
filesInQueue: (n) => `${n} file${n !== 1 ? 's' : ''} in queue.`,
done: (n, dur) => n > 1 ? `Done · ${n} clips · Total audio: ${dur}` : `Done · Audio: ${dur}`,
traceUploadLabel: (clip, eng) => `${clip} — uploading to ${eng}`,
traceUploadDetail: (eng) => eng === 'gpu' ? 'Sending audio to cuttlefish GPU…' : `Sending audio to ${eng}`,
traceProcessingLabel: (clip, eng) => `${clip}${eng} transcribing`,
traceProcessingDetail: (eng) => eng === 'gpu' ? 'Whisper transcribing. Large files take 13 minutes.' : `${eng} processing audio.`,
traceStillLabel: (clip) => `${clip} — still processing…`,
traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `${m}m ${s}s elapsed — working through the audio.` : `${e}s elapsed — processing.`; },
},
no: {
engine: 'Motor',
engineGpuLabel: 'GPU (cuttlefish RTX 3060)',
engineOpenaiLabel: 'OpenAI Whisper API',
engineAzureLabel: 'Azure AI Speech (nb-NO)',
apiKey: 'API-nøkkel',
apiKeyHint: 'Brukes kun for denne forespørselen, lagres aldri. Maks 25MB.',
region: 'Region',
model: 'Modell',
modelFastest: 'Raskest',
modelBalanced: 'Balansert',
modelBest: 'Beste kvalitet',
transcribeLang: 'Språk i lydfil',
autoDetectHint: '(kan forveksle nb/da/sv)',
speakers: 'Talere',
identifySpeakers: 'Skill ut talere',
speakersCount: 'Antall',
speakersPlaceholder: 'auto',
speakersAriaLabel: 'Forventet antall talere',
vocabulary: 'Ordliste',
vocabPresetChildWelfare: 'Barnerett / CPS',
vocabPresetMediation: 'Mekling / møter',
vocabPresetGeneral: 'Generell norsk',
vocabPresetCustom: 'Egendefinert',
vocabPlaceholder: 'Fagord og navn Whisper skal gjenkjenne, f.eks. Barnevernet, Fylkesnemnda, mekling…',
vocabHint: 'Hjelper Whisper gjenkjenne fagtermer. Ikke inkludert i utskriften.',
uploadAria: 'Lydopplasting',
uploadDrop: 'Slipp lydfil(er) her, eller',
uploadBrowse: 'bla',
uploadHint: 'maks 200MB per fil',
uploadAddFiles: '+ Legg til filer',
uploadClearQueue: '× Tøm kø',
expertSettings: 'Ekspertinnstillinger',
task: 'Oppgave',
taskTranscribe: 'Transkriber',
taskTranslate: 'Oversett til engelsk',
beamSize: 'Beam size',
beamFastest: '(raskest)',
beamBest: '(best)',
vadFilter: 'VAD-filter',
vadFilterLabel: 'Fjern stillhet',
run: 'Kjør',
running: 'Transkriberer…',
runningOther: 'Kjører…',
readyTitle: 'Klar',
readyDesc: 'Velg et verktøy, kjør en forespørsel, og svaret vises her.',
noFileSelected: 'Velg minst én lydfil før transkripsjon.',
missingOpenaiKey: 'Legg inn en gyldig OpenAI API-nøkkel (sk-…) før du kjører.',
openaiFileTooLarge: (f) => `OpenAI Whisper har 25 MB-grense. Bruk GPU-motor for ${f}.`,
missingAzureKey: 'Legg inn Azure Speech API-nøkkel før du kjører.',
clipLabel: (i, total) => total > 1 ? `Klipp ${i}/${total}` : 'Transkriberer',
transcribeFailed: (s) => `Transkripsjon feilet (HTTP ${s}).`,
errorLabel: (clip) => `Feil ${clip}`,
filesSkipped: (names) => `Hoppet over: ${names}`,
fileSizeExceeded: (name, mb) => `${name} (${mb} MB — maks 200 MB)`,
filesInQueue: (n) => `${n} fil${n !== 1 ? 'er' : ''} i køen.`,
done: (n, dur) => n > 1 ? `Ferdig · ${n} klipp · Total lyd: ${dur}` : `Ferdig · Lyd: ${dur}`,
traceUploadLabel: (clip, eng) => `${clip} — laster opp til ${eng}`,
traceUploadDetail: (eng) => eng === 'gpu' ? 'Sender lyd til cuttlefish GPU…' : `Sender lyd til ${eng}`,
traceProcessingLabel: (clip, eng) => `${clip}${eng} transkriberer`,
traceProcessingDetail: (eng) => eng === 'gpu' ? 'Whisper transkriberer. Store filer tar 13 minutter.' : `${eng} behandler lyden.`,
traceStillLabel: (clip) => `${clip} — behandler fortsatt…`,
traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `${m} min ${s}s gått — jobber gjennom lyden.` : `${e}s gått — behandler.`; },
},
uk: {
engine: 'Рушій',
engineGpuLabel: 'GPU (cuttlefish RTX 3060)',
engineOpenaiLabel: 'OpenAI Whisper API',
engineAzureLabel: 'Azure AI Speech (nb-NO)',
apiKey: 'API-ключ',
apiKeyHint: 'Використовується лише для цього запиту, ніколи не зберігається. Макс 25 МБ.',
region: 'Регіон',
model: 'Модель',
modelFastest: 'Найшвидша',
modelBalanced: 'Збалансована',
modelBest: 'Найкраща якість',
transcribeLang: 'Мова аудіо',
autoDetectHint: '(може плутати nb/da/sv)',
speakers: 'Мовці',
identifySpeakers: 'Визначити мовців',
speakersCount: 'Кількість',
speakersPlaceholder: 'auto',
speakersAriaLabel: 'Очікувана кількість мовців',
vocabulary: 'Словник',
vocabPresetChildWelfare: 'Охорона дітей / CPS',
vocabPresetMediation: 'Медіація / зустріч',
vocabPresetGeneral: 'Загальна норвезька',
vocabPresetCustom: 'Власний',
vocabPlaceholder: 'Терміни та імена для Whisper, напр. Barnevernet, медіація…',
vocabHint: 'Допомагає Whisper розпізнати терміни. Не включається до транскрипту.',
uploadAria: 'Завантаження аудіо',
uploadDrop: 'Перетягніть файл(u) сюди, або',
uploadBrowse: 'огляд',
uploadHint: 'макс 200 МБ на файл',
uploadAddFiles: '+ Додати файли',
uploadClearQueue: '× Очистити чергу',
expertSettings: 'Розширені налаштування',
task: 'Завдання',
taskTranscribe: 'Транскрибувати',
taskTranslate: 'Перекласти англійською',
beamSize: 'Розмір пучка',
beamFastest: '(найшвидший)',
beamBest: '(найкращий)',
vadFilter: 'VAD-фільтр',
vadFilterLabel: 'Видалити тишу',
run: 'Запустити',
running: 'Транскрибування…',
runningOther: 'Виконання…',
readyTitle: 'Готово',
readyDesc: 'Виберіть інструмент, запустіть запит — результат з'явиться тут.',
noFileSelected: 'Виберіть хоч б один аудіофайл перед транскрибуванням.',
missingOpenaiKey: 'Введіть дійсний ключ OpenAI API (sk-) перед запуском.',
openaiFileTooLarge: (f) => `OpenAI Whisper має обмеження 25 МБ. Використовуйте GPU для ${f}.`,
missingAzureKey: 'Введіть ключ Azure Speech API перед запуском.',
clipLabel: (i, total) => total > 1 ? `Кліп ${i}/${total}` : 'Транскрибування',
transcribeFailed: (s) => `Транскрибування не вдалося (HTTP ${s}).`,
errorLabel: (clip) => `Помилка ${clip}`,
filesSkipped: (names) => `Пропущено: ${names}`,
fileSizeExceeded: (name, mb) => `${name} (${mb} МБ — макс 200 МБ)`,
filesInQueue: (n) => `${n} файл${n !== 1 ? 'ів' : ''} у черзі.`,
done: (n, dur) => n > 1 ? `Готово · ${n} кліпи · Загальне аудіо: ${dur}` : `Готово · Аудіо: ${dur}`,
traceUploadLabel: (clip, eng) => `${clip} — завантаження до ${eng}`,
traceUploadDetail: (eng) => eng === 'gpu' ? 'Відправка аудіо на cuttlefish GPU' : `Відправка аудіо до ${eng}…`,
traceProcessingLabel: (clip, eng) => `${clip} — ${eng} транскрибує`,
traceProcessingDetail: (eng) => eng === 'gpu' ? 'Whisper транскрибує. Великі файли займають 13 хвилини.' : `${eng} обробляє аудіо.`,
traceStillLabel: (clip) => `${clip} — ще обробляється…`,
traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `Минуло ${m} хв ${s} с — обробка.` : `Минуло ${e} с — обробка.`; },
},
pl: {
engine: 'Silnik',
engineGpuLabel: 'GPU (cuttlefish RTX 3060)',
engineOpenaiLabel: 'OpenAI Whisper API',
engineAzureLabel: 'Azure AI Speech (nb-NO)',
apiKey: 'Klucz API',
apiKeyHint: 'Używany tylko dla tego żądania, nigdy nie przechowywany. Maks 25MB.',
region: 'Region',
model: 'Model',
modelFastest: 'Najszybszy',
modelBalanced: 'Zrównoważony',
modelBest: 'Najlepsza jakość',
transcribeLang: 'Język audio',
autoDetectHint: '(może mylić nb/da/sv)',
speakers: 'Mówcy',
identifySpeakers: 'Rozróżnij mówców',
speakersCount: 'Liczba',
speakersPlaceholder: 'auto',
speakersAriaLabel: 'Oczekiwana liczba mówców',
vocabulary: 'Słownik',
vocabPresetChildWelfare: 'Opieka nad dziećmi / CPS',
vocabPresetMediation: 'Mediacja / spotkanie',
vocabPresetGeneral: 'Ogólny norweski',
vocabPresetCustom: 'Własny',
vocabPlaceholder: 'Terminy i nazwy dla Whisper, np. Barnevernet, mediacja',
vocabHint: 'Pomaga Whisper rozpoznać terminy. Nie jest uwzględniony w transkrypcji.',
uploadAria: 'Prześylanie audio',
uploadDrop: 'Upuść plik(i) audio tutaj lub',
uploadBrowse: 'przeglądaj',
uploadHint: 'maks 200MB na plik',
uploadAddFiles: '+ Dodaj pliki',
uploadClearQueue: '× Wyczyść kolejkę',
expertSettings: 'Ustawienia zaawansowane',
task: 'Zadanie',
taskTranscribe: 'Transkrybuj',
taskTranslate: 'Przetłumacz na angielski',
beamSize: 'Rozmiar wiązki',
beamFastest: '(najszybszy)',
beamBest: '(najlepszy)',
vadFilter: 'Filtr VAD',
vadFilterLabel: 'Usuń ciszę',
run: 'Uruchom',
running: 'Transkrybowanie',
runningOther: 'Uruchamianie',
readyTitle: 'Gotowe',
readyDesc: 'Wybierz narzędzie, uruchom żądanie wynik pojawi się tutaj.',
noFileSelected: 'Wybierz co najmniej jeden plik audio przed transkrypcją.',
missingOpenaiKey: 'Wprowadź prawidłowy klucz API OpenAI (sk-) przed uruchomieniem.',
openaiFileTooLarge: (f) => `OpenAI Whisper ma limit 25 MB. Użyj silnika GPU dla ${f}.`,
missingAzureKey: 'Wprowadź klucz Azure Speech API przed uruchomieniem.',
clipLabel: (i, total) => total > 1 ? `Klip ${i}/${total}` : 'Transkrybowanie',
transcribeFailed: (s) => `Transkrypcja nie powiodła się (HTTP ${s}).`,
errorLabel: (clip) => `Błąd ${clip}`,
filesSkipped: (names) => `Pominięto: ${names}`,
fileSizeExceeded: (name, mb) => `${name} (${mb} MB — maks 200 MB)`,
filesInQueue: (n) => `${n} plik${n !== 1 ? 'i' : ''} w kolejce.`,
done: (n, dur) => n > 1 ? `Gotowe · ${n} klipy · Łączne audio: ${dur}` : `Gotowe · Audio: ${dur}`,
traceUploadLabel: (clip, eng) => `${clip} — przesyłanie do ${eng}`,
traceUploadDetail: (eng) => eng === 'gpu' ? 'Wysyłanie audio do cuttlefish GPU' : `Wysyłanie audio do ${eng}…`,
traceProcessingLabel: (clip, eng) => `${clip} — ${eng} transkrybuje`,
traceProcessingDetail: (eng) => eng === 'gpu' ? 'Whisper transkrybuje. Duże pliki zajmują 13 minuty.' : `${eng} przetwarza audio.`,
traceStillLabel: (clip) => `${clip} — nadal przetwarza…`,
traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `Minęło ${m} min ${s} s — przetwarzanie audio.` : `Minęło ${e} s — przetwarzanie.`; },
},
};
function currentUiT(key, ...args) {
const t = TRANSCRIBE_I18N[uiLang] || TRANSCRIBE_I18N.en;
const val = (key in t) ? t[key] : TRANSCRIBE_I18N.en[key];
if (typeof val === 'function') return val(...args);
return val ?? key;
}
function applyTranscribeI18n(lang) {
uiLang = lang;
localStorage.setItem('dbn-ui-lang', lang);
document.querySelectorAll('[data-i18n]').forEach((el) => {
const text = currentUiT(el.dataset.i18n);
if (text != null) el.textContent = text;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
const text = currentUiT(el.dataset.i18nPlaceholder);
if (text != null) el.placeholder = text;
});
document.querySelectorAll('[data-i18n-aria]').forEach((el) => {
const text = currentUiT(el.dataset.i18nAria);
if (text != null) el.setAttribute('aria-label', text);
});
document.querySelectorAll('.lang-btn').forEach((btn) => {
btn.classList.toggle('is-active', btn.dataset.lang === lang);
});
}
const tools = {
ask: {
kind: 'Source-grounded Legal Ask',
@@ -137,6 +425,10 @@ document.addEventListener('DOMContentLoaded', () => {
setupAudio();
setupTranscribeControls();
setupVocabPresets();
document.querySelectorAll('.lang-btn').forEach((btn) => {
btn.addEventListener('click', () => applyTranscribeI18n(btn.dataset.lang));
});
applyTranscribeI18n(uiLang);
els.results.addEventListener('click', (e) => {
if (e.target.closest('#exportCsvBtn')) exportTimelineCSV(lastTimelineEvents);
if (e.target.closest('#dlTxt')) downloadTranscriptTxt();
@@ -443,8 +735,8 @@ function setBusy(isBusy) {
const button = document.querySelector('#runButton');
button.disabled = isBusy;
button.textContent = isBusy
? (state.activeTool === 'transcribe' ? 'Transkriberer...' : 'Kjører...')
: 'Kjør';
? (state.activeTool === 'transcribe' ? currentUiT('running') : currentUiT('runningOther'))
: currentUiT('run');
}
function currentLanguage() {
@@ -591,7 +883,7 @@ function currentTask() {
async function runTranscribe() {
if (!audioQueue.length) {
els.status.textContent = 'Velg minst én lydfil før transkripsjon.';
els.status.textContent = currentUiT('noFileSelected');
return;
}
@@ -600,19 +892,19 @@ async function runTranscribe() {
if (engine === 'openai') {
const key = document.getElementById('openaiKeyInput')?.value?.trim();
if (!key || !key.startsWith('sk-')) {
els.status.textContent = 'Legg inn en gyldig OpenAI API-nøkkel (sk-…) før du kjører.';
els.status.textContent = currentUiT('missingOpenaiKey');
return;
}
const oversized = audioQueue.find((item) => item.file.size > 25 * 1024 * 1024);
if (oversized) {
els.status.textContent = `OpenAI Whisper har 25 MB-grense. Bruk GPU-motor for ${oversized.file.name}.`;
els.status.textContent = currentUiT('openaiFileTooLarge', oversized.file.name);
return;
}
}
if (engine === 'azure') {
const key = document.getElementById('azureKeyInput')?.value?.trim();
if (!key) {
els.status.textContent = 'Legg inn Azure Speech API-nøkkel før du kjører.';
els.status.textContent = currentUiT('missingAzureKey');
return;
}
}
@@ -642,7 +934,7 @@ async function runTranscribe() {
const startTime = Date.now();
let elapsed = 0;
const clipLabel = total > 1 ? `Klipp ${i + 1}/${total}` : 'Transkriberer';
const clipLabel = currentUiT('clipLabel', i + 1, total);
els.status.textContent = `${clipLabel}`;
const timer = setInterval(() => {
@@ -684,7 +976,7 @@ async function runTranscribe() {
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.ok) {
throw new Error(data.error?.message || `Transkripsjon feilet (HTTP ${resp.status}).`);
throw new Error(data.error?.message || currentUiT('transcribeFailed', resp.status));
}
clearInterval(timer);
@@ -708,7 +1000,7 @@ async function runTranscribe() {
item.status = 'error';
renderAudioQueue();
els.status.textContent = `${clipLabel}: ${err.message}`;
renderTrace([{ label: `Feil ${clipLabel}`, detail: err.message, status: 'warning' }]);
renderTrace([{ label: currentUiT('errorLabel', clipLabel), detail: err.message, status: 'warning' }]);
setBusy(false);
return;
}
@@ -733,28 +1025,23 @@ async function runTranscribe() {
const totalMin = Math.floor(totalSec / 60);
const remSec = totalSec % 60;
const durLabel = totalMin > 0 ? `${totalMin}m ${remSec}s` : `${totalSec}s`;
const clipCount = total > 1 ? ` · ${total} klipp` : '';
els.status.textContent = `Ferdig${clipCount} · Total lyd: ${durLabel}`;
els.status.textContent = currentUiT('done', total, durLabel);
setBusy(false);
}
function updateTranscribeTrace(elapsed, engine, clipLabel = 'Transkriberer') {
function updateTranscribeTrace(elapsed, engine, clipLabel) {
if (!clipLabel) clipLabel = currentUiT('clipLabel', 1, 1);
const engineLabel = engine === 'openai' ? 'OpenAI API' : engine === 'azure' ? 'Azure Speech' : 'Whisper GPU';
let label, detail;
if (elapsed < 10) {
label = `${clipLabel} — laster opp til ${engineLabel}`;
detail = engine === 'gpu' ? 'Sender lyd til cuttlefish GPU…' : `Sender lyd til ${engineLabel}`;
label = currentUiT('traceUploadLabel', clipLabel, engineLabel);
detail = currentUiT('traceUploadDetail', engine);
} else if (elapsed < 60) {
label = `${clipLabel}${engineLabel} transkriberer`;
detail = engine === 'gpu'
? 'Whisper transkriberer. Store filer tar 13 minutter.'
: `${engineLabel} behandler lyden.`;
} else if (elapsed < 120) {
label = `${clipLabel} — behandler fortsatt…`;
detail = `${Math.floor(elapsed / 60)} min gått — ${engineLabel} jobber gjennom lyden.`;
label = currentUiT('traceProcessingLabel', clipLabel, engineLabel);
detail = currentUiT('traceProcessingDetail', engine);
} else {
label = `${clipLabel} — behandler fortsatt…`;
detail = `${Math.floor(elapsed / 60)} min ${pad2(elapsed % 60)}s — lange opptak tar flere minutter.`;
label = currentUiT('traceStillLabel', clipLabel);
detail = currentUiT('traceStillDetail', elapsed);
}
renderTrace([{ label, detail, status: 'running' }]);
}
@@ -955,7 +1242,7 @@ function handleAudioFiles(fileList) {
}
const sizeMB = file.size / 1024 / 1024;
if (sizeMB > 200) {
skipped.push(`${file.name} (${sizeMB.toFixed(1)} MB — maks 200 MB)`);
skipped.push(currentUiT('fileSizeExceeded', file.name, sizeMB.toFixed(1)));
return;
}
audioQueue.push({ file, status: 'pending', result: null });
@@ -963,9 +1250,9 @@ function handleAudioFiles(fileList) {
});
if (skipped.length) {
els.status.textContent = `Hoppet over: ${skipped.join(', ')}`;
els.status.textContent = currentUiT('filesSkipped', skipped.join(', '));
} else if (added > 0) {
els.status.textContent = `${audioQueue.length} fil${audioQueue.length !== 1 ? 'er' : ''} i køen.`;
els.status.textContent = currentUiT('filesInQueue', audioQueue.length);
}
renderAudioQueue();