428 lines
23 KiB
JavaScript
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);
|