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
+4 -1
View File
@@ -6,9 +6,12 @@ require_once __DIR__ . '/../includes/LegalTools.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
set_time_limit(0);
ignore_user_abort(true);
// ── Common params ─────────────────────────────────────────────────────────────
$validLangs = ['auto', 'no', 'nn', 'en', 'sv', 'da', 'de', 'fr', 'es', 'pl', 'fi', 'nl', 'it', 'pt', 'ru', 'ar', 'tr', 'zh', 'ja', 'ko'];
$validLangs = ['auto', 'no', 'nn', 'en', 'sv', 'da', 'de', 'fr', 'es', 'pl', 'uk', 'fi', 'nl', 'it', 'pt', 'ru', 'ar', 'tr', 'zh', 'ja', 'ko'];
$language = strtolower(trim((string)($_POST['language'] ?? 'auto')));
if (!in_array($language, $validLangs, true)) $language = 'auto';
+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();
+51 -42
View File
@@ -8,97 +8,106 @@ require_once __DIR__ . '/includes/layout.php';
?>
<form id="toolForm" class="tool-form">
<div class="lang-switcher" id="uiLangSwitcher" role="group" aria-label="UI language">
<button type="button" class="lang-btn is-active" data-lang="en">&#127468;&#127463; EN</button>
<button type="button" class="lang-btn" data-lang="no">&#127475;&#127476; NO</button>
<button type="button" class="lang-btn" data-lang="uk">&#127482;&#127462; UK</button>
<button type="button" class="lang-btn" data-lang="pl">&#127477;&#127473; PL</button>
</div>
<div class="control-row" id="engineControl">
<span class="control-label">Engine</span>
<label><input type="radio" name="engine" value="gpu" checked id="engineGpu"> GPU (cuttlefish RTX 3060)</label>
<label><input type="radio" name="engine" value="openai" id="engineOpenai"> OpenAI Whisper API</label>
<label><input type="radio" name="engine" value="azure" id="engineAzure"> Azure AI Speech (nb-NO)</label>
<span class="control-label" data-i18n="engine">Engine</span>
<label><input type="radio" name="engine" value="gpu" checked id="engineGpu"> <span data-i18n="engineGpuLabel">GPU (cuttlefish RTX 3060)</span></label>
<label><input type="radio" name="engine" value="openai" id="engineOpenai"> <span data-i18n="engineOpenaiLabel">OpenAI Whisper API</span></label>
<label><input type="radio" name="engine" value="azure" id="engineAzure"> <span data-i18n="engineAzureLabel">Azure AI Speech (nb-NO)</span></label>
</div>
<div class="control-row is-hidden" id="openaiKeyControl">
<span class="control-label">API Key</span>
<span class="control-label" data-i18n="apiKey">API Key</span>
<input type="password" id="openaiKeyInput" name="openai_key" placeholder="sk-…" class="byok-input" autocomplete="off">
<small class="control-hint inline-hint">Used for this request only, never stored. Max 25&thinsp;MB.</small>
<small class="control-hint inline-hint" data-i18n="apiKeyHint">Used for this request only, never stored. Max 25&thinsp;MB.</small>
</div>
<div class="control-row is-hidden" id="azureKeyControl">
<span class="control-label">API Key</span>
<span class="control-label" data-i18n="apiKey">API Key</span>
<input type="password" id="azureKeyInput" name="azure_key" placeholder="Azure Speech key" class="byok-input" autocomplete="off">
<span class="control-label" style="margin-left:1.25rem">Region</span>
<span class="control-label" style="margin-left:1.25rem" data-i18n="region">Region</span>
<input type="text" id="azureRegionInput" name="azure_region" placeholder="norwayeast" class="byok-input byok-input--short" value="norwayeast">
</div>
<div class="control-row" id="modelControl">
<span class="control-label">Model</span>
<label><input type="radio" name="model" value="small"> Raskest <small class="control-hint">(small)</small></label>
<label><input type="radio" name="model" value="medium"> Balansert <small class="control-hint">(medium)</small></label>
<label><input type="radio" name="model" value="large-v3" checked> Beste kvalitet &#9733; <small class="control-hint">(large-v3)</small></label>
<span class="control-label" data-i18n="model">Model</span>
<label><input type="radio" name="model" value="small"> <span data-i18n="modelFastest">Fastest</span> <small class="control-hint">(small)</small></label>
<label><input type="radio" name="model" value="medium"> <span data-i18n="modelBalanced">Balanced</span> <small class="control-hint">(medium)</small></label>
<label><input type="radio" name="model" value="large-v3" checked> <span data-i18n="modelBest">Best quality</span> &#9733; <small class="control-hint">(large-v3)</small></label>
</div>
<div class="control-row" id="transcribeLangControl">
<span class="control-label">Spr&aring;k</span>
<span class="control-label" data-i18n="transcribeLang">Audio language</span>
<label><input type="radio" name="transcribeLang" value="no" checked> Norsk (nb)</label>
<label><input type="radio" name="transcribeLang" value="nn"> Nynorsk</label>
<label><input type="radio" name="transcribeLang" value="en"> English</label>
<label><input type="radio" name="transcribeLang" value="pl"> Polski</label>
<label><input type="radio" name="transcribeLang" value="uk"> &#1059;&#1082;&#1088;&#1072;&#1111;&#1085;&#1089;&#1100;&#1082;&#1072;</label>
<label><input type="radio" name="transcribeLang" value="sv"> Svenska</label>
<label><input type="radio" name="transcribeLang" value="da"> Dansk</label>
<label><input type="radio" name="transcribeLang" value="de"> Deutsch</label>
<label><input type="radio" name="transcribeLang" value="fr"> Fran&ccedil;ais</label>
<label><input type="radio" name="transcribeLang" value="auto"> Auto-detect <small class="control-hint">(kan forveksle nb/da/sv)</small></label>
<label><input type="radio" name="transcribeLang" value="auto"> Auto-detect <small class="control-hint" data-i18n="autoDetectHint">(may confuse nb/da/sv)</small></label>
</div>
<div class="control-row" id="diarizeControl">
<span class="control-label">Talere</span>
<label><input type="checkbox" id="diarizeCheck" name="diarize"> Skill ut talere</label>
<span class="control-label" style="margin-left:1.25rem">Antall</span>
<input type="number" id="numSpeakersInput" name="num_speakers" min="2" max="20" placeholder="auto" class="num-speakers-input" aria-label="Forventet antall talere">
<span class="control-label" data-i18n="speakers">Speakers</span>
<label><input type="checkbox" id="diarizeCheck" name="diarize"> <span data-i18n="identifySpeakers">Identify speakers</span></label>
<span class="control-label" style="margin-left:1.25rem" data-i18n="speakersCount">Count</span>
<input type="number" id="numSpeakersInput" name="num_speakers" min="2" max="20" placeholder="auto" class="num-speakers-input" data-i18n-placeholder="speakersPlaceholder" data-i18n-aria="speakersAriaLabel" aria-label="Expected number of speakers">
</div>
<div class="expert-field" id="vocabControl">
<div class="vocab-presets" id="vocabPresets">
<span class="control-label">Ordliste</span>
<button type="button" class="vocab-btn" data-preset="barnerett">Barnerett / CPS</button>
<button type="button" class="vocab-btn" data-preset="rettssak">Rettssak / tingrett</button>
<button type="button" class="vocab-btn" data-preset="generell">Generell norsk</button>
<button type="button" class="vocab-btn" data-preset="custom">Egendefinert</button>
<span class="control-label" data-i18n="vocabulary">Vocabulary</span>
<button type="button" class="vocab-btn" data-preset="barnerett" data-i18n="vocabPresetChildWelfare">Child welfare / CPS</button>
<button type="button" class="vocab-btn" data-preset="mediation" data-i18n="vocabPresetMediation">Mediation / legal meeting</button>
<button type="button" class="vocab-btn" data-preset="generell" data-i18n="vocabPresetGeneral">General Norwegian</button>
<button type="button" class="vocab-btn" data-preset="custom" data-i18n="vocabPresetCustom">Custom</button>
</div>
<textarea id="initPromptInput" name="initial_prompt" rows="2" placeholder="Fagord og navn Whisper skal gjenkjenne, f.eks. Barnevernet, Fylkesnemnda, advokat, tingrett…" class="prompt-textarea"></textarea>
<p class="upload-hint">Hjelper Whisper gjenkjenne fagtermer. Ikke inkludert i utskriften.</p>
<textarea id="initPromptInput" name="initial_prompt" rows="2" placeholder="Technical terms and names for Whisper to recognise, e.g. Barnevernet, mediation, family services…" class="prompt-textarea" data-i18n-placeholder="vocabPlaceholder"></textarea>
<p class="upload-hint" data-i18n="vocabHint">Helps Whisper recognise technical terms. Not included in the transcript.</p>
</div>
<div class="upload-zone" id="audioZone" role="region" aria-label="Audio upload">
<div class="upload-zone" id="audioZone" role="region" aria-label="Audio upload" data-i18n-aria="uploadAria">
<input type="file" id="audioInput" accept="audio/*,video/mp4,video/webm" multiple aria-label="Choose audio files">
<div id="audioPrompt" class="upload-prompt">
<span class="upload-icon" aria-hidden="true">&#9654;</span>
<p>Slipp lydfil(er) her, eller <label for="audioInput" class="upload-browse">bla</label></p>
<p class="upload-hint"><strong>MP3</strong>, <strong>WAV</strong>, <strong>OGG</strong>, <strong>M4A</strong>, <strong>FLAC</strong>, <strong>WEBM</strong> &mdash; maks 200&thinsp;MB per fil</p>
<p><span data-i18n="uploadDrop">Drop audio file(s) here, or</span> <label for="audioInput" class="upload-browse" data-i18n="uploadBrowse">browse</label></p>
<p class="upload-hint"><strong>MP3</strong>, <strong>WAV</strong>, <strong>OGG</strong>, <strong>M4A</strong>, <strong>FLAC</strong>, <strong>WEBM</strong> &mdash; <span data-i18n="uploadHint">max 200&thinsp;MB per file</span></p>
</div>
<div id="audioFileInfo" class="upload-file is-hidden">
<ol id="audioQueueList" class="audio-queue-list"></ol>
<div class="audio-queue-actions">
<label for="audioInput" class="upload-browse">+ Legg til filer</label>
<button type="button" id="audioClear" class="upload-clear" aria-label="T&oslash;m k&oslash;">&times; T&oslash;m k&oslash;</button>
<label for="audioInput" class="upload-browse" data-i18n="uploadAddFiles">+ Add files</label>
<button type="button" id="audioClear" class="upload-clear" data-i18n="uploadClearQueue">&times; Clear queue</button>
</div>
</div>
</div>
<details class="expert-settings" id="expertSettings">
<summary class="expert-summary">Ekspertinnstillinger</summary>
<summary class="expert-summary" data-i18n="expertSettings">Advanced settings</summary>
<div class="expert-body">
<div class="control-row">
<span class="control-label">Oppgave</span>
<label><input type="radio" name="task" value="transcribe" checked> Transkriber</label>
<label><input type="radio" name="task" value="translate"> Oversett til engelsk</label>
<span class="control-label" data-i18n="task">Task</span>
<label><input type="radio" name="task" value="transcribe" checked> <span data-i18n="taskTranscribe">Transcribe</span></label>
<label><input type="radio" name="task" value="translate"> <span data-i18n="taskTranslate">Translate to English</span></label>
</div>
<div class="control-row">
<span class="control-label">Beam size</span>
<label><input type="radio" name="beam_size" value="1"> 1 <small class="control-hint">(raskest)</small></label>
<span class="control-label" data-i18n="beamSize">Beam size</span>
<label><input type="radio" name="beam_size" value="1"> 1 <small class="control-hint" data-i18n="beamFastest">(fastest)</small></label>
<label><input type="radio" name="beam_size" value="3"> 3</label>
<label><input type="radio" name="beam_size" value="5" checked> 5 <small class="control-hint">(best)</small></label>
<label><input type="radio" name="beam_size" value="5" checked> 5 <small class="control-hint" data-i18n="beamBest">(best)</small></label>
</div>
<div class="control-row">
<span class="control-label">VAD-filter</span>
<label><input type="checkbox" name="vad_filter" id="vadFilterCheck" value="1" checked> Fjern stillhet</label>
<span class="control-label" data-i18n="vadFilter">VAD filter</span>
<label><input type="checkbox" name="vad_filter" id="vadFilterCheck" value="1" checked> <span data-i18n="vadFilterLabel">Remove silence</span></label>
</div>
</div>
</details>
@@ -120,14 +129,14 @@ require_once __DIR__ . '/includes/layout.php';
<div class="form-footer">
<p id="toolStatus" class="form-status" role="status" aria-live="polite"></p>
<button id="runButton" type="submit">Kj&oslash;r</button>
<button id="runButton" type="submit" data-i18n="run">Run</button>
</div>
</form>
<section id="results" class="results" aria-live="polite">
<div class="empty-state">
<h3>Klar</h3>
<p>Velg et verkt&oslash;y, kj&oslash;r en foresp&oslash;rsel, og svaret vises her.</p>
<h3 data-i18n="readyTitle">Ready</h3>
<p data-i18n="readyDesc">Select a tool, run a request, and the result appears here.</p>
</div>
</section>
<?php require_once __DIR__ . '/includes/layout_footer.php'; ?>