feat: auto-select STT engine (Azure → Google Cloud → Whisper) and show provider in results

Removes user-facing engine/model/key/beam controls. The server now picks
the best available engine automatically:
1. Microsoft Azure Speech — short clips (≤1MB, no diarization, audio/*)
2. Google Cloud Speech v2 — long audio, diarization, all languages
3. OpenAI Whisper GPU — local fallback

Results display which provider was used (e.g. "Transcribed with Google
Cloud Speech") via transcript-engine-badge and traceMeta.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 13:22:24 +02:00
parent c6a9cc9199
commit 08d1e3cee3
14 changed files with 2937 additions and 416 deletions
+25 -182
View File
@@ -402,17 +402,6 @@ function syncOutputLanguage(lang) {
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',
@@ -433,26 +422,12 @@ const TRANSCRIBE_I18N = {
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)',
beamSizeHint: 'Controls search breadth — higher values improve accuracy but take longer. 5 is recommended for legal recordings.',
vadFilter: 'VAD filter',
vadFilterLabel: 'Remove silence',
vadFilterHint: 'Voice Activity Detection — skips silent passages before transcribing. Speeds up processing and prevents the model hallucinating on 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}`,
@@ -460,25 +435,14 @@ const TRANSCRIBE_I18N = {
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.`,
traceUploadLabel: (clip) => `${clip} — uploading`,
traceUploadDetail: () => 'Sending to transcription service…',
traceProcessingLabel: (clip) => `${clip} — transcribing`,
traceProcessingDetail: () => 'Processing audio. Large files may take 13 minutes.',
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',
@@ -499,26 +463,12 @@ const TRANSCRIBE_I18N = {
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)',
beamSizeHint: 'Styrer søkebredde — høyere verdier gir bedre nøyaktighet men tar lengre tid. 5 anbefales for juridiske opptak.',
vadFilter: 'VAD-filter',
vadFilterLabel: 'Fjern stillhet',
vadFilterHint: 'Taleaktivitetsdeteksjon — hopper over stille partier før transkripsjon. Raskere behandling og forhindrer hallusinasjon på 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}`,
@@ -526,25 +476,13 @@ const TRANSCRIBE_I18N = {
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…`,
traceUploadLabel: (clip) => `${clip} — laster opp`,
traceUploadDetail: () => 'Sender til transkripsjonsleverandør…',
traceProcessingLabel: (clip) => `${clip} — transkriberer`,
traceProcessingDetail: () => 'Behandler lyden. Store filer tar 13 minutter.', 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: 'Мовці',
@@ -565,26 +503,12 @@ const TRANSCRIBE_I18N = {
uploadHint: 'макс 200 МБ на файл',
uploadAddFiles: '+ Додати файли',
uploadClearQueue: '× Очистити чергу',
expertSettings: 'Розширені налаштування',
task: 'Завдання',
taskTranscribe: 'Транскрибувати',
taskTranslate: 'Перекласти англійською',
beamSize: 'Розмір пучка',
beamFastest: '(найшвидший)',
beamBest: '(найкращий)',
beamSizeHint: 'Ширина пошуку — більше значення підвищує точність, але займає більше часу. 5 рекомендовано для юридичних записів.',
vadFilter: 'VAD-фільтр',
vadFilterLabel: 'Видалити тишу',
vadFilterHint: 'Виявлення мовної активності — пропускає тихі ділянки перед транскрипцією. Прискорює обробку та запобігає галюцинаціям на тиші.',
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}`,
@@ -592,25 +516,13 @@ const TRANSCRIBE_I18N = {
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 транскрибує. Великі файли займають 1–3 хвилини.' : `${eng} обробляє аудіо.`,
traceStillLabel: (clip) => `${clip} — ще обробляється…`,
traceUploadLabel: (clip) => `${clip} — завантаження`,
traceUploadDetail: () => 'Відправка до сервісу транскрипції…',
traceProcessingLabel: (clip) => `${clip} — транскрибування`,
traceProcessingDetail: () => 'Обробка аудіо. Великі файли займають 1–3 хвилини.', 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',
@@ -631,26 +543,12 @@ const TRANSCRIBE_I18N = {
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)',
beamSizeHint: 'Kontroluje szerokość wyszukiwania — wyższe wartości poprawiają dokładność, ale wydłużają czas. 5 zalecane dla nagrań prawnych.',
vadFilter: 'Filtr VAD',
vadFilterLabel: 'Usuń ciszę',
vadFilterHint: 'Wykrywanie aktywności głosowej — pomija ciche fragmenty przed transkrypcją. Przyspiesza przetwarzanie i zapobiega halucynacjom na ciszy.',
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}`,
@@ -658,11 +556,10 @@ const TRANSCRIBE_I18N = {
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…`,
traceUploadLabel: (clip) => `${clip} — przesyłanie`,
traceUploadDetail: () => 'Wysyłanie do serwisu transkrypcji…',
traceProcessingLabel: (clip) => `${clip} — transkrybowanie`,
traceProcessingDetail: () => 'Przetwarzanie audio. Duże pliki zajmują 13 minuty.', 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.`; },
},
};
@@ -1556,18 +1453,6 @@ function exportTimelineCSV(events) {
URL.revokeObjectURL(url);
}
function currentTranscribeEngine() {
const el = document.querySelector('input[name="engine"]:checked');
return el ? el.value : 'gpu';
}
function currentTranscribeModel() {
const el = document.querySelector('input[name="model"]:checked');
return el ? el.value : 'small';
}
function currentBeamSize() {
const el = document.querySelector('input[name="beam_size"]:checked');
return el ? el.value : '5';
}
function currentTask() {
const el = document.querySelector('input[name="task"]:checked');
return el ? el.value : 'transcribe';
@@ -1579,28 +1464,6 @@ async function runTranscribe() {
return;
}
const engine = currentTranscribeEngine();
if (engine === 'openai') {
const key = document.getElementById('openaiKeyInput')?.value?.trim();
if (!key || !key.startsWith('sk-')) {
els.status.textContent = currentUiT('missingOpenaiKey');
return;
}
const oversized = audioQueue.find((item) => item.file.size > 25 * 1024 * 1024);
if (oversized) {
els.status.textContent = currentUiT('openaiFileTooLarge', oversized.file.name);
return;
}
}
if (engine === 'azure' && !window.DBN_AZURE_SPEECH_CONFIGURED) {
const key = document.getElementById('azureKeyInput')?.value?.trim();
if (!key) {
els.status.textContent = currentUiT('missingAzureKey');
return;
}
}
setBusy(true);
const initPrompt = els.initPromptInput?.value?.trim() || '';
@@ -1635,16 +1498,13 @@ async function runTranscribe() {
const s = elapsed % 60;
const t = m > 0 ? `${m}:${pad2(s)}` : `${s}s`;
els.status.textContent = `${clipLabel}${t}`;
updateTranscribeTrace(elapsed, engine, clipLabel);
updateTranscribeTrace(elapsed, clipLabel);
}, 1000);
try {
const formData = new FormData();
formData.append('audio', item.file);
formData.append('engine', engine);
formData.append('language', currentTranscribeLang());
formData.append('model', currentTranscribeModel());
formData.append('beam_size', currentBeamSize());
formData.append('task', currentTask());
formData.append('time_offset', String(cumulativeOffset));
if (vadFilter) formData.append('vad_filter', '1');
@@ -1653,13 +1513,6 @@ async function runTranscribe() {
formData.append('diarize', '1');
if (numSpeakers >= 2) formData.append('num_speakers', String(numSpeakers));
}
if (engine === 'openai') {
formData.append('openai_key', document.getElementById('openaiKeyInput')?.value?.trim());
}
if (engine === 'azure') {
formData.append('azure_key', document.getElementById('azureKeyInput')?.value?.trim());
formData.append('azure_region', document.getElementById('azureRegionInput')?.value?.trim() || 'norwayeast');
}
const resp = await fetch('api/transcribe.php', {
method: 'POST',
@@ -1721,23 +1574,21 @@ async function runTranscribe() {
setBusy(false);
}
function updateTranscribeTrace(elapsed, engine, clipLabel) {
function updateTranscribeTrace(elapsed, 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 = currentUiT('traceUploadLabel', clipLabel, engineLabel);
detail = currentUiT('traceUploadDetail', engine);
label = currentUiT('traceUploadLabel', clipLabel);
detail = currentUiT('traceUploadDetail');
} else if (elapsed < 60) {
label = currentUiT('traceProcessingLabel', clipLabel, engineLabel);
detail = currentUiT('traceProcessingDetail', engine);
label = currentUiT('traceProcessingLabel', clipLabel);
detail = currentUiT('traceProcessingDetail');
} else {
label = currentUiT('traceStillLabel', clipLabel);
detail = currentUiT('traceStillDetail', elapsed);
}
renderTrace([{ label, detail, status: 'running' }]);
}
function renderTranscriptResults(data) {
const speakerRoles = data.speaker_roles || {};
const segments = data.segments || [];
@@ -1776,6 +1627,7 @@ function renderTranscriptResults(data) {
els.results.innerHTML = `
<section class="result-section">
<h3>Transcript</h3>
${data.model ? `<p class="transcript-engine-badge">Transcribed with <strong>${escapeHtml(data.model)}</strong></p>` : ''}
${rolesHtml}
<div class="transcript-box"><pre class="transcript-text">${escapeHtml(data.transcript)}</pre></div>
${segmentsHtml}
@@ -1791,7 +1643,7 @@ function renderTranscriptResults(data) {
if (data.duration_sec) traceMeta.push({ label: `Duration: ${Math.round(data.duration_sec)}s`, detail: '', status: 'complete' });
if (data.language) traceMeta.push({ label: `Language: ${data.language}`, detail: '', status: 'complete' });
if (data.num_speakers > 1) traceMeta.push({ label: `Speakers detected: ${data.num_speakers}`, detail: Object.entries(speakerRoles).map(([id, r]) => `${id}: ${r}`).join(', ') || '', status: 'complete' });
if (data.model) traceMeta.push({ label: `Model: ${data.model}`, detail: '', status: 'complete' });
if (data.model) traceMeta.push({ label: data.model, detail: '', status: 'complete' });
renderTrace(traceMeta.length ? traceMeta : [{ label: 'Transcribed', detail: '', status: 'complete' }]);
}
@@ -1935,16 +1787,7 @@ function setupAudio() {
}
function setupTranscribeControls() {
document.querySelectorAll('input[name="engine"]').forEach((radio) => {
radio.addEventListener('change', () => {
const engine = currentTranscribeEngine();
document.getElementById('openaiKeyControl')?.classList.toggle('is-hidden', engine !== 'openai');
// Hide azure key row if server has a pre-configured key
const azureNeedsKey = engine === 'azure' && !window.DBN_AZURE_SPEECH_CONFIGURED;
document.getElementById('azureKeyControl')?.classList.toggle('is-hidden', !azureNeedsKey);
document.getElementById('modelControl')?.classList.toggle('is-hidden', engine === 'openai' || engine === 'azure');
});
});
// engine auto-selected server-side
}
function setupVocabPresets() {