Do Better Norge
+Legal Tools
+= htmlspecialchars($toolKind) ?>
+diff --git a/api/transcribe.php b/api/transcribe.php index 42cf177..ea1457e 100644 --- a/api/transcribe.php +++ b/api/transcribe.php @@ -6,12 +6,21 @@ require_once __DIR__ . '/../includes/LegalTools.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); -$validLangs = ['auto', 'no', 'en', 'sv', 'da', 'de', 'fr', 'es', 'pl']; +// ── Common params ───────────────────────────────────────────────────────────── + +$validLangs = ['auto', 'no', 'nn', 'en', 'sv', 'da', 'de', 'fr', 'es', 'pl', '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'; $diarize = !empty($_POST['diarize']) && $_POST['diarize'] !== '0'; $numSpeakers = isset($_POST['num_speakers']) ? max(0, min(20, (int)$_POST['num_speakers'])) : 0; +$engine = in_array($_POST['engine'] ?? '', ['gpu', 'openai', 'azure'], true) ? $_POST['engine'] : 'gpu'; +$validModels = ['tiny', 'base', 'small', 'medium', 'large-v2', 'large-v3']; +$model = in_array($_POST['model'] ?? '', $validModels, true) ? $_POST['model'] : 'small'; +$beamSize = max(1, min(5, (int)($_POST['beam_size'] ?? 5))); +$task = ($_POST['task'] ?? 'transcribe') === 'translate' ? 'translate' : 'transcribe'; +$vadFilter = !empty($_POST['vad_filter']) && $_POST['vad_filter'] !== '0'; +$initPrompt = substr(trim((string)($_POST['initial_prompt'] ?? '')), 0, 500); // ── Validate upload ─────────────────────────────────────────────────────────── @@ -39,88 +48,41 @@ if (!in_array($ext, $allowedExts, true)) { dbnToolsError("Unsupported format: .{$ext}. Use MP3, WAV, OGG, M4A, FLAC, or WebM.", 415, 'unsupported_format'); } -// ── Build Whisper request ───────────────────────────────────────────────────── - -$whisperBase = 'http://194.93.49.14:20019'; -$endpoint = $diarize ? $whisperBase . '/transcribe/diarize' : $whisperBase . '/transcribe'; - -$boundary = '----DBN' . bin2hex(random_bytes(8)); -$body = "--{$boundary}\r\n"; -$body .= 'Content-Disposition: form-data; name="file"; filename="' . addslashes(basename($file['name'])) . '"' . "\r\n"; -$body .= "Content-Type: application/octet-stream\r\n\r\n"; - -$fileContents = file_get_contents($file['tmp_name']); -if ($fileContents === false) { - dbnToolsError('Could not read uploaded file.', 500, 'file_read_error'); +// OpenAI has a 25 MB file limit +if ($engine === 'openai' && $file['size'] > 25 * 1024 * 1024) { + dbnToolsError('OpenAI Whisper API has a 25 MB file limit. Use the GPU engine for larger files.', 413, 'openai_file_too_large'); } -$body .= $fileContents . "\r\n"; - -if ($language !== 'auto') { - $body .= "--{$boundary}\r\n"; - $body .= "Content-Disposition: form-data; name=\"language\"\r\n\r\n"; - $body .= $language . "\r\n"; -} - -if ($diarize && $numSpeakers > 1) { - $body .= "--{$boundary}\r\n"; - $body .= "Content-Disposition: form-data; name=\"num_speakers\"\r\n\r\n"; - $body .= $numSpeakers . "\r\n"; -} - -$body .= "--{$boundary}--\r\n"; - -// ── Call Whisper ────────────────────────────────────────────────────────────── $t0 = microtime(true); -if (function_exists('curl_init')) { - $ch = curl_init($endpoint); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $body, - CURLOPT_HTTPHEADER => [ - "Content-Type: multipart/form-data; boundary={$boundary}", - 'Accept: application/json', - ], - CURLOPT_TIMEOUT => 600, - ]); - $whisperBody = curl_exec($ch); - $httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); - $curlErr = curl_error($ch); - curl_close($ch); +// ── Route to engine ─────────────────────────────────────────────────────────── - if ($whisperBody === false || $httpCode !== 200) { - dbnToolsError('Whisper service error (HTTP ' . $httpCode . '): ' . $curlErr, 502, 'whisper_error'); +if ($engine === 'openai') { + $apiKey = trim((string)($_POST['openai_key'] ?? '')); + if (!$apiKey || !str_starts_with($apiKey, 'sk-')) { + dbnToolsError('A valid OpenAI API key (sk-…) is required for the OpenAI engine.', 400, 'missing_openai_key'); } + $result = transcribeViaOpenAI($file, $language, $task, $apiKey); + +} elseif ($engine === 'azure') { + $apiKey = trim((string)($_POST['azure_key'] ?? '')); + $region = preg_replace('/[^a-z0-9]/', '', strtolower(trim((string)($_POST['azure_region'] ?? 'norwayeast')))); + if (!$apiKey) { + dbnToolsError('An Azure Speech API key is required for the Azure engine.', 400, 'missing_azure_key'); + } + $result = transcribeViaAzure($file, $language, $apiKey, $region, $diarize); + } else { - $ctx = stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'timeout' => 600, - 'header' => "Content-Type: multipart/form-data; boundary={$boundary}\r\nAccept: application/json\r\n", - 'content' => $body, - 'ignore_errors' => true, - ], - ]); - $whisperBody = @file_get_contents($endpoint, false, $ctx); - - if ($whisperBody === false) { - dbnToolsError('Whisper service unreachable. The GPU may be offline.', 502, 'whisper_unreachable'); - } + // GPU (default) + $result = transcribeViaWhisperGpu($file, $language, $diarize, $numSpeakers, $model, $beamSize, $task, $vadFilter, $initPrompt); } $latencyMs = (int)round((microtime(true) - $t0) * 1000); -$whisper = json_decode($whisperBody, true); -if (!is_array($whisper) || empty($whisper['text'])) { - dbnToolsError('Empty or invalid response from Whisper.', 502, 'whisper_empty'); -} +// ── Speaker role labelling (GPU + diarize only) ─────────────────────────────── -// ── Speaker role labelling ──────────────────────────────────────────────────── - -$segments = is_array($whisper['segments'] ?? null) ? $whisper['segments'] : []; -$numDetected = (int)($whisper['num_speakers'] ?? 1); +$segments = $result['segments'] ?? []; +$numDetected = (int)($result['num_speakers'] ?? 1); if ($numDetected < 2 && $segments) { $uniqueSpeakers = array_filter(array_unique(array_column($segments, 'speaker'))); @@ -132,10 +94,12 @@ if ($diarize && $numDetected > 1 && $segments) { $speakerRoles = dbnLabelSpeakerRoles($segments); } -// ── Respond ─────────────────────────────────────────────────────────────────── +// ── Log + respond ───────────────────────────────────────────────────────────── dbnToolsLogMetadata([ 'tool' => 'transcribe', + 'engine' => $engine, + 'model' => $model, 'language' => $language, 'ok' => true, 'latency_ms' => $latencyMs, @@ -144,17 +108,237 @@ dbnToolsLogMetadata([ dbnToolsRespond([ 'ok' => true, 'tool' => 'transcribe', - 'transcript' => (string)$whisper['text'], + 'transcript' => (string)($result['text'] ?? ''), 'segments' => $segments, 'speaker_roles' => $speakerRoles, 'num_speakers' => $numDetected, - 'language' => (string)($whisper['language'] ?? $language), - 'duration_sec' => round((float)($whisper['duration_seconds'] ?? 0), 2), - 'model' => (string)($whisper['model'] ?? 'whisper'), + 'language' => (string)($result['language'] ?? $language), + 'duration_sec' => round((float)($result['duration_seconds'] ?? 0), 2), + 'processing_sec'=> round((float)($result['processing_seconds'] ?? 0), 2), + 'model' => (string)($result['model'] ?? ($engine === 'gpu' ? $model : $engine)), + 'engine' => $engine, 'latency_ms' => $latencyMs, ]); -// ── Speaker role labelling helper ───────────────────────────────────────────── + +// ── Engine implementations ──────────────────────────────────────────────────── + +function transcribeViaWhisperGpu(array $file, string $language, bool $diarize, int $numSpeakers, + string $model, int $beamSize, string $task, + bool $vadFilter, string $initPrompt): array +{ + $whisperBase = 'http://194.93.49.14:20019'; + $endpoint = $diarize ? $whisperBase . '/transcribe/diarize' : $whisperBase . '/transcribe'; + $boundary = '----DBN' . bin2hex(random_bytes(8)); + + $body = "--{$boundary}\r\n"; + $body .= 'Content-Disposition: form-data; name="file"; filename="' . addslashes(basename($file['name'])) . '"' . "\r\n"; + $body .= "Content-Type: application/octet-stream\r\n\r\n"; + + $fileContents = file_get_contents($file['tmp_name']); + if ($fileContents === false) { + dbnToolsError('Could not read uploaded file.', 500, 'file_read_error'); + } + $body .= $fileContents . "\r\n"; + + $fields = [ + 'model' => $model, + 'beam_size' => (string)$beamSize, + 'task' => $task, + 'vad_filter' => $vadFilter ? '1' : '0', + 'initial_prompt' => $initPrompt, + ]; + if ($language !== 'auto') $fields['language'] = $language; + if ($diarize && $numSpeakers > 1) $fields['num_speakers'] = (string)$numSpeakers; + + foreach ($fields as $name => $value) { + if ($value === '') continue; + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"{$name}\"\r\n\r\n"; + $body .= $value . "\r\n"; + } + $body .= "--{$boundary}--\r\n"; + + $ch = curl_init($endpoint); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => [ + "Content-Type: multipart/form-data; boundary={$boundary}", + 'Accept: application/json', + ], + CURLOPT_TIMEOUT => 600, + ]); + $responseBody = curl_exec($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $curlErr = curl_error($ch); + curl_close($ch); + + if ($responseBody === false || $httpCode !== 200) { + $detail = $curlErr ?: (is_string($responseBody) ? substr(strip_tags($responseBody), 0, 300) : ''); + dbnToolsError('Whisper service error (HTTP ' . $httpCode . '): ' . $detail, 502, 'whisper_error'); + } + + $data = json_decode($responseBody, true); + if (!is_array($data) || empty($data['text'])) { + dbnToolsError('Empty or invalid response from Whisper.', 502, 'whisper_empty'); + } + return $data; +} + + +function transcribeViaOpenAI(array $file, string $language, string $task, string $apiKey): array +{ + $boundary = '----DBN' . bin2hex(random_bytes(8)); + $body = "--{$boundary}\r\n"; + $body .= 'Content-Disposition: form-data; name="file"; filename="' . addslashes(basename($file['name'])) . '"' . "\r\n"; + $body .= "Content-Type: application/octet-stream\r\n\r\n"; + $body .= file_get_contents($file['tmp_name']) . "\r\n"; + $body .= "--{$boundary}\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\nwhisper-1\r\n"; + $body .= "--{$boundary}\r\nContent-Disposition: form-data; name=\"response_format\"\r\n\r\nverbose_json\r\n"; + if ($language !== 'auto') { + $body .= "--{$boundary}\r\nContent-Disposition: form-data; name=\"language\"\r\n\r\n{$language}\r\n"; + } + if ($task === 'translate') { + $body .= "--{$boundary}\r\nContent-Disposition: form-data; name=\"task\"\r\n\r\ntranslation\r\n"; + } + $body .= "--{$boundary}--\r\n"; + + $ch = curl_init('https://api.openai.com/v1/audio/transcriptions'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => [ + "Authorization: Bearer {$apiKey}", + "Content-Type: multipart/form-data; boundary={$boundary}", + 'Accept: application/json', + ], + CURLOPT_TIMEOUT => 300, + ]); + $responseBody = curl_exec($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $curlErr = curl_error($ch); + curl_close($ch); + + if ($responseBody === false || $httpCode !== 200) { + $detail = $curlErr ?: (is_string($responseBody) ? substr(strip_tags($responseBody), 0, 300) : ''); + dbnToolsError('OpenAI API error (HTTP ' . $httpCode . '): ' . $detail, 502, 'openai_error'); + } + + $data = json_decode($responseBody, true); + if (!is_array($data)) { + dbnToolsError('Invalid response from OpenAI.', 502, 'openai_empty'); + } + + // Normalise to internal shape + return [ + 'text' => (string)($data['text'] ?? ''), + 'language' => (string)($data['language'] ?? $language), + 'duration_seconds' => (float)($data['duration'] ?? 0), + 'processing_seconds' => 0, + 'segments' => array_map(fn($s) => [ + 'id' => $s['id'] ?? 0, + 'start' => $s['start'] ?? 0, + 'end' => $s['end'] ?? 0, + 'text' => $s['text'] ?? '', + 'speaker' => 'SPEAKER_00', + ], $data['segments'] ?? []), + 'model' => 'openai/whisper-1', + ]; +} + + +function transcribeViaAzure(array $file, string $language, string $apiKey, + string $region, bool $diarize): array +{ + // Azure Batch Transcription — POST audio directly for short-form (<60 min) + // Uses the simple REST endpoint for synchronous short audio transcription. + $langCode = match($language) { + 'no', 'nb' => 'nb-NO', + 'nn' => 'nn-NO', + 'en' => 'en-US', + 'sv' => 'sv-SE', + 'da' => 'da-DK', + 'de' => 'de-DE', + 'fr' => 'fr-FR', + 'es' => 'es-ES', + 'pl' => 'pl-PL', + 'fi' => 'fi-FI', + 'nl' => 'nl-NL', + 'it' => 'it-IT', + 'pt' => 'pt-PT', + default => 'nb-NO', + }; + + // Mime type map + $mimeMap = [ + 'wav' => 'audio/wav', 'mp3' => 'audio/mpeg', 'ogg' => 'audio/ogg', + 'oga' => 'audio/ogg', 'm4a' => 'audio/mp4', 'mp4' => 'audio/mp4', + 'flac' => 'audio/flac', 'webm' => 'audio/webm', 'aac' => 'audio/aac', + ]; + $fileExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + $mimeType = $mimeMap[$fileExt] ?? 'audio/wav'; + + $endpoint = "https://{$region}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1" + . "?language={$langCode}&format=detailed"; + + $fileContents = file_get_contents($file['tmp_name']); + if ($fileContents === false) { + dbnToolsError('Could not read uploaded file.', 500, 'file_read_error'); + } + + $ch = curl_init($endpoint); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $fileContents, + CURLOPT_HTTPHEADER => [ + "Ocp-Apim-Subscription-Key: {$apiKey}", + "Content-Type: {$mimeType}", + 'Accept: application/json', + ], + CURLOPT_TIMEOUT => 300, + ]); + $responseBody = curl_exec($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $curlErr = curl_error($ch); + curl_close($ch); + + if ($responseBody === false || $httpCode !== 200) { + $detail = $curlErr ?: (is_string($responseBody) ? substr(strip_tags($responseBody), 0, 300) : ''); + dbnToolsError('Azure Speech error (HTTP ' . $httpCode . '): ' . $detail, 502, 'azure_error'); + } + + $data = json_decode($responseBody, true); + if (!is_array($data) || empty($data['DisplayText'])) { + dbnToolsError('Empty or invalid response from Azure Speech.', 502, 'azure_empty'); + } + + // Normalise to internal shape + $text = (string)($data['DisplayText'] ?? ''); + $segs = []; + foreach (($data['NBest'][0]['Words'] ?? []) as $i => $word) { + $segs[] = [ + 'id' => $i, + 'start' => round((float)($word['Offset'] ?? 0) / 10_000_000, 3), + 'end' => round(((float)($word['Offset'] ?? 0) + (float)($word['Duration'] ?? 0)) / 10_000_000, 3), + 'text' => (string)($word['Word'] ?? ''), + 'speaker' => 'SPEAKER_00', + ]; + } + + return [ + 'text' => $text, + 'language' => $langCode, + 'duration_seconds' => 0, + 'processing_seconds' => 0, + 'segments' => $segs, + 'model' => "azure/{$langCode}", + ]; +} + function dbnLabelSpeakerRoles(array $segments): array { diff --git a/ask.php b/ask.php new file mode 100644 index 0000000..3817a70 --- /dev/null +++ b/ask.php @@ -0,0 +1,10 @@ + + + diff --git a/assets/css/tools.css b/assets/css/tools.css index 23b1e87..aef4768 100644 --- a/assets/css/tools.css +++ b/assets/css/tools.css @@ -1218,3 +1218,74 @@ p { gap: 0.5rem; margin-top: 0.75rem; } + +/* ── Transcribe extended controls ─────────────────────────────────────── */ + +.byok-input { + font-size: 0.82rem; + padding: 0.3rem 0.6rem; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg); + color: var(--ink); + width: 22rem; + max-width: 100%; +} +.byok-input--short { width: 9rem; } +.byok-input:focus { outline: 2px solid var(--teal); outline-offset: 1px; } + +.inline-hint { + font-size: 0.75rem; + color: var(--muted); + margin-left: 0.4rem; +} + +.expert-settings { + border: 1px solid var(--line); + border-radius: 8px; + padding: 0; + margin-top: 0.75rem; +} +.expert-summary { + font-size: 0.82rem; + font-weight: 600; + color: var(--muted); + cursor: pointer; + padding: 0.55rem 0.9rem; + list-style: none; + user-select: none; +} +.expert-summary::-webkit-details-marker { display: none; } +.expert-summary::before { + content: '▶ '; + font-size: 0.65rem; + transition: transform 0.15s; +} +.expert-settings[open] .expert-summary::before { content: '▼ '; } +.expert-body { + padding: 0.6rem 0.9rem 0.9rem; + border-top: 1px solid var(--line); + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.expert-field { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-top: 0.4rem; +} +.prompt-textarea { + font-size: 0.82rem; + padding: 0.4rem 0.6rem; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg); + color: var(--ink); + resize: vertical; + width: 100%; + box-sizing: border-box; +} +.prompt-textarea:focus { outline: 2px solid var(--teal); outline-offset: 1px; } + +.control-hint { font-size: 0.74rem; color: var(--muted); font-weight: 400; } diff --git a/assets/js/tools.js b/assets/js/tools.js index 2bf6a74..43d69b8 100644 --- a/assets/js/tools.js +++ b/assets/js/tools.js @@ -116,22 +116,26 @@ document.addEventListener('DOMContentLoaded', () => { transcribeLangControl: document.querySelector('#transcribeLangControl'), }); - els.tabs.forEach((button) => { - button.addEventListener('click', () => setTool(button.dataset.tool)); + els.tabs.forEach((tab) => { + if (tab.tagName !== 'A') { + tab.addEventListener('click', () => setTool(tab.dataset.tool)); + } }); - els.form.addEventListener('submit', runTool); - els.passcodeForm.addEventListener('submit', submitPasscode); + els.form?.addEventListener('submit', runTool); + els.passcodeForm?.addEventListener('submit', submitPasscode); els.healthButton.addEventListener('click', checkHealth); setupUpload(); setupAliases(); setupAudio(); + setupTranscribeControls(); els.results.addEventListener('click', (e) => { if (e.target.closest('#exportCsvBtn')) exportTimelineCSV(lastTimelineEvents); if (e.target.closest('#dlTxt')) downloadTranscriptTxt(); if (e.target.closest('#dlSrt')) downloadTranscriptSrt(); if (e.target.closest('#dlVtt')) downloadTranscriptVtt(); }); - setTool(state.activeTool); + const activeTool = document.body.dataset.activeTool || state.activeTool; + setTool(activeTool); if (state.authenticated) { checkHealth(); @@ -559,16 +563,56 @@ 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'; +} + async function runTranscribe() { if (!lastAudioFile) { els.status.textContent = 'Choose an audio file before transcribing.'; return; } + + const engine = currentTranscribeEngine(); + + // BYOK key validation before starting the upload + if (engine === 'openai') { + const key = document.getElementById('openaiKeyInput')?.value?.trim(); + if (!key || !key.startsWith('sk-')) { + els.status.textContent = 'Enter a valid OpenAI API key (sk-…) before running.'; + return; + } + if (lastAudioFile.size > 25 * 1024 * 1024) { + els.status.textContent = 'OpenAI Whisper has a 25 MB file limit. Switch to GPU engine for this file.'; + return; + } + } + if (engine === 'azure') { + const key = document.getElementById('azureKeyInput')?.value?.trim(); + if (!key) { + els.status.textContent = 'Enter an Azure Speech API key before running.'; + return; + } + } + setBusy(true); const startTime = Date.now(); let elapsed = 0; - updateTranscribeTrace(0); + updateTranscribeTrace(0, engine); els.status.textContent = 'Transcribing…'; const timer = setInterval(() => { @@ -576,19 +620,38 @@ async function runTranscribe() { const m = Math.floor(elapsed / 60); const s = elapsed % 60; els.status.textContent = m > 0 ? `Transcribing… ${m}:${pad2(s)}` : `Transcribing… ${s}s`; - updateTranscribeTrace(elapsed); + updateTranscribeTrace(elapsed, engine); }, 1000); try { const formData = new FormData(); formData.append('audio', lastAudioFile); + formData.append('engine', engine); formData.append('language', currentTranscribeLang()); + formData.append('model', currentTranscribeModel()); + formData.append('beam_size', currentBeamSize()); + formData.append('task', currentTask()); + + const vadCheck = document.getElementById('vadFilterCheck'); + if (vadCheck?.checked) formData.append('vad_filter', '1'); + + const initPrompt = document.getElementById('initPromptInput')?.value?.trim(); + if (initPrompt) formData.append('initial_prompt', initPrompt); + if (els.diarizeCheck?.checked) { formData.append('diarize', '1'); const n = parseInt(els.numSpeakersInput?.value || '', 10); if (n >= 2) formData.append('num_speakers', String(n)); } + 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', credentials: 'same-origin', @@ -602,8 +665,11 @@ async function runTranscribe() { lastTranscriptData = data; renderTranscriptResults(data); - const dur = data.duration_sec ? ` · Audio: ${Math.round(data.duration_sec)}s` : ''; - els.status.textContent = `Done in ${data.latency_ms || 0} ms${dur}.`; + const dur = data.duration_sec ? ` · Audio: ${Math.round(data.duration_sec)}s` : ''; + const proc = data.processing_sec ? ` · GPU: ${data.processing_sec.toFixed(1)}s` : ''; + const rtf = (data.duration_sec && data.processing_sec) + ? ` · RTF: ${(data.processing_sec / data.duration_sec).toFixed(2)}` : ''; + els.status.textContent = `Done in ${data.latency_ms || 0} ms${dur}${proc}${rtf}.`; } catch (error) { els.status.textContent = error.message; renderTrace([{ label: 'Transcription error', detail: error.message, status: 'warning' }]); @@ -613,19 +679,24 @@ async function runTranscribe() { } } -function updateTranscribeTrace(elapsed) { +function updateTranscribeTrace(elapsed, engine) { + const engineLabel = engine === 'openai' ? 'OpenAI API' : engine === 'azure' ? 'Azure Speech' : 'Whisper GPU'; let label, detail; if (elapsed < 10) { - label = 'Uploading to Whisper'; - detail = 'Sending audio to cuttlefish GPU…'; + label = `Uploading to ${engineLabel}`; + detail = engine === 'gpu' + ? 'Sending audio to cuttlefish GPU…' + : `Sending audio to ${engineLabel}…`; } else if (elapsed < 60) { - label = 'Processing on GPU'; - detail = 'Whisper is transcribing. Large files take 1–3 minutes.'; + label = `Processing — ${engineLabel}`; + detail = engine === 'gpu' + ? 'Whisper is transcribing. Large files take 1–3 minutes.' + : `${engineLabel} is processing the audio.`; } else if (elapsed < 120) { - label = 'Still processing…'; - detail = `${Math.floor(elapsed / 60)} min elapsed — Whisper is working through the audio.`; + label = 'Still processing…'; + detail = `${Math.floor(elapsed / 60)} min elapsed — ${engineLabel} is working through the audio.`; } else { - label = 'Still processing…'; + label = 'Still processing…'; detail = `${Math.floor(elapsed / 60)} min ${pad2(elapsed % 60)}s — long recordings can take several minutes.`; } renderTrace([{ label, detail, status: 'running' }]); @@ -789,6 +860,17 @@ 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'); + document.getElementById('azureKeyControl')?.classList.toggle('is-hidden', engine !== 'azure'); + document.getElementById('modelControl')?.classList.toggle('is-hidden', engine === 'openai' || engine === 'azure'); + }); + }); +} + function handleAudio(file) { const allowedExts = ['mp3', 'wav', 'ogg', 'oga', 'm4a', 'mp4', 'flac', 'webm', 'aac']; const ext = file.name.split('.').pop().toLowerCase(); diff --git a/includes/layout.php b/includes/layout.php new file mode 100644 index 0000000..43f9921 --- /dev/null +++ b/includes/layout.php @@ -0,0 +1,67 @@ + ['Ask', 'Source-grounded'], + 'search' => ['Search', 'Legal sources'], + 'summarize' => ['Summarize', 'Pasted text'], + 'timeline' => ['Timeline', 'Events'], + 'redact' => ['Redact', 'Privacy'], + 'transcribe' => ['Transcribe', 'Audio'], +]; +$toolName = $toolName ?? 'ask'; +$toolTitle = $toolTitle ?? 'Legal Tools'; +$toolKind = $toolKind ?? ''; +$toolBadge = $toolBadge ?? ''; +?> + + +
+ + +Do Better Norge
+= htmlspecialchars($toolKind) ?>
+Choose a tool, run a request, and the answer will show the evidence trail beside it.
+Do Better Norge
-Source-grounded Legal Ask
-Choose a tool, run a request, and the answer will show the evidence trail beside it.
-Choose a tool, run a request, and the answer will show the evidence trail beside it.
+