Files

428 lines
23 KiB
JavaScript

'use strict';
const streamId = crypto.randomUUID();
const $ = id => document.getElementById(id);
const captureAssetBase = new URL('.', document.currentScript.src);
if ($('relayAddress') && window.CaptionRelay) {
if (window.CaptionRelay.custom()) $('relayAddress').value = window.CaptionRelay.url();
const relayParams = new URLSearchParams(location.search);
if (relayParams.has('room')) $('room').value = relayParams.get('room');
if (relayParams.has('output')) $('reviewRoom').value = relayParams.get('output');
if (captureAssetBase.pathname !== '/static/') $('captionSite').value = new URL('.', location.href).href;
}
function serviceFetch(path, options = {}) {
if (window.captionLocalConnection) return window.captionLocalConnection.fetch(path, options);
return fetch(path, {...options, credentials: 'same-origin'});
}
let stream, context, node, source, publisher, wakeLock;
let running = false, starting = false, stopping = false, processing = false, failed = false;
let ready = false, serviceReady = false, supportedModes = [], multilingual = false, buffer = new CaptureBuffer();
let pending = null, transcript = [], stopPromise = null, stopAck = null;
let counter = Date.now(), activeLanguage = 'en', activeMode = 'transcribe', activeRelayOutput = 'transcript';
let currentRequestStarted = 0, availableLanguages = "", relayConfigLoaded = false, relayConfigData = null, roomRequired = false;
async function relayConfig() {
if (!$('relayAddress')) return false;
if (relayConfigLoaded) return true;
let response;
try {
response = await serviceFetch('/relay-config', {signal: AbortSignal.timeout(5000)});
} catch (_) { return false; }
if (!response.ok) return false;
let config;
try { config = await response.json(); } catch (_) { return false; }
// Only auto-configure a relay the service actually supplied; there is no built-in default.
try { if (!config.address) return false; window.CaptionRelay.validate(config.address); } catch (_) { return false; }
relayConfigLoaded = true; relayConfigData = config;
const params = new URLSearchParams(location.search);
if (!params.has('relay')) $('relayAddress').value = config.address;
if (config.site && $('captionSite') && !$('captionSite').value.trim()) $('captionSite').value = config.site;
if (!params.has('room')) $('room').value = config.room;
if (!params.has('output') && $('reviewRoom')) $('reviewRoom').value = config.outputRoom;
if ($('relayToken') && !$('relayToken').value) $('relayToken').value = config.writeToken;
if (config.publicId) {
const site = new URL(config.site || location.origin, location.href);
window.CaptionRoomMenu?.set({
roomId: config.publicId,
audienceUrl: new URL('watch/' + encodeURIComponent(config.publicId), site).href
});
if ($('share')) { $('share').checked = true; configureRelay(); }
return true;
}
// Legacy ready links carry tokens in their fragment, so only build them for the
// caption site the operator configured, never a hardcoded fallback.
const siteValue = $('captionSite')?.value.trim();
if (!siteValue) { if ($('share') && !$('share').checked) { $('share').checked = true; configureRelay(); } return true; }
const site = new URL(siteValue);
const readyLink = (page, room, fragment, output = '') => {
const link = new URL(page, site);
link.searchParams.set('room', room);
if (output) link.searchParams.set('output', output);
link.searchParams.set('relay', config.address);
link.hash = fragment;
return link.href;
};
const sourceRead = 'relayReadToken=' + encodeURIComponent(config.readToken);
if ($('overlayLink')) {
$('overlayLink').href = readyLink('overlay.html', config.room, sourceRead);
$('overlayReady').hidden = false;
}
// A private relay is configured by the operator: turn sharing on.
if ($('share')) { $('share').checked = true; configureRelay(); }
return true;
}
function setDot(id, state, label) { window.CaptionStatus?.set(id, state, label); }
function setText(id, text) { const element = $(id); if (element) element.textContent = text; }
function updateEngineDiagnostics(info) {
setText('diagModel', info.model || '—');
setText('diagDevice', info.device ? String(info.device).toUpperCase() : '—');
setText('diagPrecision', info.compute_type || '—');
setText('diagRunning', String(info.running ?? 0));
setText('diagPending', String(info.pending ?? 0));
setText('diagVersion', info.version ? 'v' + info.version : '—');
}
function fail(message) { $('error').textContent = message; }
function controls() {
const busy = running || starting || stopping || processing || failed;
window.captionLocalConnection?.setBusy(busy);
if ($('relayTarget')) $('relayTarget').disabled = busy;
for (const id of ['relayAddress', 'captionSite', 'relayToken', 'reviewRoom']) if ($(id)) $(id).disabled = busy || $('share').checked;
for (const id of ['language', 'microphone', 'room', 'mode', 'relayOutput', 'sensitivity', 'captionInterval']) $(id).disabled = busy;
// Operators may always turn sharing off. Turning it back on requires a stopped session
// and a relay address: there is no built-in relay to fall back to.
const noRelay = !$('relayAddress')?.value.trim();
$('share').disabled = !$('share').checked && (busy || noRelay);
if (!$('share').checked) {
const text = noRelay ? 'Sharing off: no relay configured' : 'Sharing off';
$('relay').textContent = text;
setDot('relayDot', 'idle', text);
}
const captureState = failed ? 'danger' : (starting || processing || stopping) ? 'warn' : running ? 'good' : ready ? 'idle' : 'danger';
setDot('captureDot', captureState, $('status').textContent);
$('start').disabled = busy || !ready;
$('stop').disabled = !running || stopping;
$('retry').hidden = !failed;
$('discard').hidden = !failed;
$('retry').disabled = processing || stopping;
$('discard').disabled = processing || stopping;
}
async function devices() {
const selected = $('microphone').value;
const list = await navigator.mediaDevices.enumerateDevices();
$('microphone').replaceChildren(new Option('System default', ''));
list.filter(d => d.kind === 'audioinput').forEach((d, i) => $('microphone').add(new Option(d.label || `Microphone ${i+1}`, d.deviceId)));
if ([...$('microphone').options].some(o => o.value === selected)) $('microphone').value = selected;
}
function configureRelay() {
publisher?.disconnect(); publisher = null;
if ($('overlayReady')) $('overlayReady').hidden = true;
if (!$('share').checked) { controls(); return; }
const room = $('room').value.trim();
if (!/^[A-Za-z0-9_-]{1,128}$/.test(room)) {
$('share').checked = false; fail('Enter the editor source room before enabling sharing.'); return;
}
const direct = $('relayTarget')?.value === 'overlay';
let destination = null;
try {
const address = $('relayAddress')?.value.trim();
if (!address || !window.CaptionRelay) throw new Error('No relay configured. Enter a relay address under "Use a private relay".');
window.CaptionRelay.configure(address);
const siteValue = $('captionSite')?.value.trim();
if (direct && siteValue) {
const site = new URL(siteValue);
if (!['http:', 'https:'].includes(site.protocol) || site.username || site.password || site.search || site.hash ||
(site.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(site.hostname)))
throw new Error('Use an HTTPS caption website address, or HTTP localhost for testing.');
if (!site.pathname.endsWith('/')) site.pathname += '/';
destination = new URL('overlay.html', site);
destination.searchParams.set('room', room);
destination = window.CaptionRelay.link(destination);
if (relayConfigData && relayConfigData.readToken)
destination += '#relayReadToken=' + encodeURIComponent(relayConfigData.readToken);
}
publisher = window.CaptionRelay.createPublisher({room, maxQueue: 100, relayToken: $('relayToken')?.value.trim(),
onError: message => fail('Caption relay: ' + message),
onStats: stats => {
const text = `Relay: ${stats.state}; queued ${stats.queueLength}; dropped ${stats.droppedCount}`;
$('relay').textContent = text;
const state = stats.state === 'connected' ? 'good'
: ['denied', 'closed'].includes(stats.state) ? 'danger'
: ['connecting', 'authenticating', 'reconnecting', 'connected-with-gap'].includes(stats.state) ? 'warn'
: 'idle';
setDot('relayDot', state, text);
}
});
} catch (error) {
$('share').checked = false; setDot('relayDot', 'danger', error.message);
fail(error.message); controls(); return;
}
publisher.connect();
if (destination && $('overlayLink') && $('overlayReady')) {
$('overlayLink').href = destination;
$('overlayReady').hidden = false;
}
controls();
}
$('share').onchange = configureRelay;
if ($('relayAddress')) $('relayAddress').oninput = controls;
$('room').onchange = () => { if ($('share').checked) configureRelay(); };
if ($('relayTarget')) $('relayTarget').onchange = () => { if ($('share').checked) configureRelay(); };
function frame(audio) {
if (!running && !stopping) return;
$('meter').value = buffer.append(audio);
if (buffer.length > 16000 * 30 && !stopping) {
fail('Capture stopped because inference is falling behind. Buffered speech will finish. Use a faster model or transcription-only mode.');
stop();
}
drain();
}
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
async function send(snapshot) {
const params = new URLSearchParams({language: activeLanguage, mode: activeMode,
window:'1', final: snapshot.final ? '1' : '0', context_seconds:String(snapshot.context)});
let error;
for (let attempt = 0; attempt < 4; attempt++) {
try {
const response = await serviceFetch(`/transcribe?${params}`, {
method:'POST', headers:{'Content-Type':'application/octet-stream', 'X-Caption-Local':'1', 'X-Request-ID':snapshot.id, 'X-Stream-ID':streamId},
body:snapshot.audio.buffer, signal:AbortSignal.timeout(30000)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
error = new Error(data.detail || `Service returned HTTP ${response.status}`);
if (![408, 429, 502, 503, 504].includes(response.status)) { error.permanent = true; throw error; }
} else return await response.json();
} catch (caught) { error = caught; if (error.permanent) throw error; }
if (attempt < 3) {
$('status').textContent = `Connection interrupted; retrying buffered speech (${attempt+1}/3)…`;
await delay(500 * 2**attempt);
}
}
throw error;
}
function renderResult(result) {
setText('diagRequestedLanguage', activeLanguage === 'auto' ? 'Automatic' : activeLanguage);
setText('diagDetectedLanguage', result.language || '—');
setText('diagAudienceOutput', activeRelayOutput === 'translation' ? 'English translation' : 'Original transcription');
if (!result.transcript && !result.translation) return;
const display = activeMode === 'both' ? `Original: ${result.transcript}\nEnglish: ${result.translation}` : result.text;
transcript.push(display);
const line = document.createElement('div'); line.textContent = display;
$('captions').append(line);
while ($('captions').children.length > 6) $('captions').firstChild.remove();
const outgoing = activeRelayOutput === 'translation' ? result.translation : result.transcript;
if (outgoing && publisher && $('share').checked) publisher.publish({msg:true, final:outgoing,
id:++counter, ln:activeRelayOutput === 'translation' ? 'en' : result.language});
}
async function drain() {
if (processing || failed || stopping || starting) return;
processing = true; controls();
try {
while (!failed && !stopping) {
if (!pending) {
pending = buffer.snapshot(!running);
if (!pending) break;
pending.id = crypto.randomUUID();
}
$('status').textContent = `Transcribing · ${(buffer.length/16000).toFixed(1)}s buffered`;
currentRequestStarted = performance.now();
const result = await send(pending);
if (window.captionLocalConnection) window.captionLocalConnection.lastInference = {
inference_seconds: result.inference_seconds, queue_seconds: result.queue_seconds || 0
};
buffer.commit(pending, result.committed_seconds);
pending = null;
renderResult(result);
$('status').textContent = `${running ? 'Listening' : 'Stopped'} · ${result.inference_seconds}s processing + ${result.queue_seconds || 0}s queued / ${result.audio_seconds.toFixed(1)}s audio`;
}
} catch (error) {
failed = true;
$('status').textContent = 'Stopped · pending audio retained';
fail(`${error.message}. Capture stopped. Audio is retained: use Retry pending audio or Discard pending audio.`);
await stop();
} finally {
if (!running && !stopping && !failed && !pending) {
await serviceFetch(`/streams/${streamId}`, {method:'DELETE', headers:{'X-Caption-Local':'1'},
signal:AbortSignal.timeout(3000)}).catch(() => {});
}
processing = false; currentRequestStarted = 0; controls();
}
}
async function stop() {
if (stopPromise) return stopPromise;
stopping = true; running = false; controls();
stopPromise = (async () => {
try {
if (node && context?.state === 'running') {
await new Promise(resolve => {
const timer = setTimeout(() => {
fail('The audio device did not finish cleanly; its final frame may be missing.');
stopAck = null; resolve();
}, 2000);
stopAck = () => { clearTimeout(timer); stopAck = null; resolve(); };
node.port.postMessage('stop');
});
}
} finally {
stream?.getTracks().forEach(t => { t.onended = null; t.stop(); });
source?.disconnect(); node?.disconnect();
if (context && context.state !== 'closed') await context.close().catch(() => {});
await wakeLock?.release().catch(() => {}); wakeLock = null;
stream = context = source = node = null; $('meter').value = 0;
stopping = false; controls();
}
})();
await stopPromise; stopPromise = null;
if (!processing && !failed) await drain();
if (!failed && !processing) $('status').textContent = 'Stopped';
}
$('stop').onclick = stop;
$('retry').onclick = async () => { failed = false; fail(''); await drain(); };
$('discard').onclick = async () => {
buffer.reset(); pending = null; failed = false; fail('');
// The empty drain closes our idle server session, just like a successful Stop.
await drain();
if (!failed) $('status').textContent = 'Stopped · pending audio discarded';
};
$('start').onclick = async () => {
if (starting || running || processing || failed) return;
starting = true; controls(); fail('');
activeLanguage = $('language').value.trim().toLowerCase() || 'en';
activeMode = $('mode').value; activeRelayOutput = $('relayOutput').value;
setText('diagRequestedLanguage', activeLanguage === 'auto' ? 'Automatic' : activeLanguage);
setText('diagAudienceOutput', activeRelayOutput === 'translation' ? 'English translation' : 'Original transcription');
buffer = new CaptureBuffer(Number($('sensitivity').value), Number($('captionInterval').value)); pending = null;
try {
if (!supportedModes.includes(activeMode)) throw new Error('The loaded model does not support that output mode.');
if (!multilingual && !['en','auto'].includes(activeLanguage)) throw new Error('This model supports English only.');
// Prefer a 16 kHz context; fall back to the hardware rate and resample in
// the worklet when the browser cannot run at 16 kHz (for example Firefox).
try { context = new AudioContext({sampleRate:16000}); }
catch (_) { context = new AudioContext(); }
await context.resume(); // Keep the user activation before asynchronous permission prompts.
stream = await navigator.mediaDevices.getUserMedia({audio:{
deviceId:$('microphone').value ? {exact:$('microphone').value} : undefined,
channelCount:1, echoCancellation:false, noiseSuppression:false, autoGainControl:false
}});
await devices();
try { source = context.createMediaStreamSource(stream); }
catch (error) {
// Firefox accepts a 16 kHz context but refuses to connect a microphone
// running at another rate. Retry at the hardware rate; the worklet resamples.
if (context.sampleRate !== 16000 || error.name !== 'NotSupportedError') throw error;
await context.close();
context = new AudioContext();
await context.resume();
source = context.createMediaStreamSource(stream);
}
await context.audioWorklet.addModule(new URL('pcm-worklet.js', captureAssetBase));
node = new AudioWorkletNode(context, 'pcm', {processorOptions:{inputRate:context.sampleRate, outputRate:16000}});
node.port.onmessage = event => { if (event.data === 'stopped') stopAck?.(); else frame(event.data); };
source.connect(node); node.connect(context.destination);
running = true; starting = false; controls(); $('status').textContent = 'Listening';
stream.getTracks().forEach(track => { track.onended = () => { fail('Microphone disconnected. Finishing captured speech.'); stop(); }; });
context.onstatechange = () => {
if (running && context?.state === 'suspended') { fail('Audio capture was suspended by the browser. Finishing captured speech.'); stop(); }
};
if (navigator.wakeLock) wakeLock = await navigator.wakeLock.request('screen').catch(() => null);
} catch (error) { fail(error.message); starting = false; await stop(); controls(); }
};
$('download').onclick = () => {
const url = URL.createObjectURL(new Blob([transcript.join('\n')], {type:'text/plain;charset=utf-8'}));
const a = document.createElement('a'); a.href = url; a.download = `captions-${new Date().toISOString().slice(0,19).replaceAll(':','-')}.txt`; a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
window.addEventListener('beforeunload', event => {
if (running || processing || failed || (publisher?.getSnapshot().queueLength || 0)) {
event.preventDefault(); event.returnValue = '';
}
});
window.addEventListener('pagehide', () => { stream?.getTracks().forEach(t => t.stop()); publisher?.disconnect(); });
function updateOutputLabels() {
const translation = $('relayOutput').value === 'translation';
const label = translation ? 'English translation' : 'Original transcription';
setText('diagAudienceOutput', label);
const language = $('language').value;
const hint = $('outputHint');
if (hint) {
if ($('mode').value !== 'transcribe' && language === 'en') {
hint.textContent = 'English is selected as the spoken language. If speakers use another language, select it explicitly for reliable translation.';
} else if (language === 'auto') {
hint.textContent = 'Automatic language detection is enabled. Explicitly selecting the spoken language is more reliable for translation.';
} else {
hint.textContent = 'Start with six-second captions. English translation means speech translated into English.';
}
}
setText('diagRequestedLanguage', language === 'auto' ? 'Automatic' : language);
}
function updateOutputChoice(preferTranslation = false) {
const mode = $('mode').value;
for (const option of $('relayOutput').options) option.disabled =
(mode === 'translate' && option.value === 'transcript') || (mode === 'transcribe' && option.value === 'translation');
if (mode === 'translate') $('relayOutput').value = 'translation';
else if (mode === 'transcribe') $('relayOutput').value = 'transcript';
else if (preferTranslation) $('relayOutput').value = 'translation';
updateOutputLabels();
}
$('mode').onchange = () => { updateOutputChoice(true); };
$('language').onchange = () => { updateOutputChoice(); };
$('relayOutput').onchange = updateOutputLabels;
updateOutputChoice(); controls();
async function health() {
if (window.captionLocalConnection && !window.captionLocalConnection.connected) return;
const revision = window.captionLocalConnection?.revision;
try {
const response = await serviceFetch('/health', {signal:AbortSignal.timeout(5000)});
if (revision !== window.captionLocalConnection?.revision) return;
if (response.status === 401) {
ready = false; serviceReady = false; roomRequired = true;
$('capabilities').textContent = 'Open this page from its room link to publish captions.';
setDot('serviceDot', 'danger', $('capabilities').textContent);
controls(); return;
}
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const relayReady = await relayConfig();
const info = await response.json();
if (revision !== window.captionLocalConnection?.revision) return;
roomRequired = !!info.rooms_enabled && !relayReady;
serviceReady = !!info.ready;
ready = serviceReady && !!navigator.mediaDevices && !roomRequired;
updateEngineDiagnostics(info);
supportedModes = info.modes; multilingual = info.multilingual;
const languages = info.languages || ['en'];
if (availableLanguages !== languages.join(',')) {
const previous = $('language').value;
const names = new Intl.DisplayNames([navigator.language || 'en'], {type:'language'});
const label = code => { try { return names.of(code); } catch (_) { return code; } };
const options = languages.map(code => new Option(label(code), code)).sort((a,b) => a.text.localeCompare(b.text));
$('language').replaceChildren(new Option('Detect automatically', 'auto'), ...options);
$('language').value = previous === 'auto' && info.multilingual ? 'auto'
: languages.includes(previous) ? previous : 'en';
availableLanguages = languages.join(',');
updateOutputChoice();
}
for (const option of $('mode').options) option.disabled = !supportedModes.includes(option.value);
if (roomRequired) {
$('capabilities').textContent = 'Open this page from its room link to publish captions.';
setDot('serviceDot', 'warn', $('capabilities').textContent);
} else if (!info.ready) {
$('capabilities').textContent = 'Speech service is starting…';
setDot('serviceDot', 'warn', $('capabilities').textContent);
} else {
$('capabilities').textContent = info.multilingual
? 'Ready for multilingual captions and English translation'
: 'Ready for English captions';
setDot('serviceDot', 'good', $('capabilities').textContent);
}
} catch (_) {
if (revision !== window.captionLocalConnection?.revision) return;
ready = false; serviceReady = false; roomRequired = false;
$('capabilities').textContent = 'Speech service unavailable';
setDot('serviceDot', 'danger', $('capabilities').textContent);
}
controls();
}
if (navigator.mediaDevices) {
devices().catch(error => fail(error.message));
navigator.mediaDevices.addEventListener('devicechange', () => { if (!running) devices().catch(() => {}); });
} else fail('Microphone capture requires localhost or HTTPS. Use the SSH tunnel described in the deployment guide.');
health(); setInterval(health, 10000);