40376-vm/static/js/audio-capture.js
Flatlogic Bot 2447ac66a4 gg
2026-07-03 20:03:39 +00:00

331 lines
11 KiB
JavaScript

(() => {
const state = {
audioContext: null,
destination: null,
micStream: null,
systemStream: null,
mixedStream: null,
recorder: null,
chunks: [],
animationFrame: null,
analyzers: {},
downloadUrl: null,
};
const els = {
connectMic: document.getElementById('connect-mic'),
connectSystem: document.getElementById('connect-system'),
start: document.getElementById('start-recording'),
stop: document.getElementById('stop-recording'),
reset: document.getElementById('reset-audio'),
micStatus: document.getElementById('mic-status'),
systemStatus: document.getElementById('system-status'),
mixStatus: document.getElementById('mix-status'),
micMeter: document.getElementById('mic-meter'),
systemMeter: document.getElementById('system-meter'),
mixMeter: document.getElementById('mix-meter'),
log: document.getElementById('audio-log'),
result: document.getElementById('recording-result'),
preview: document.getElementById('recording-preview'),
download: document.getElementById('download-recording'),
sharedVideo: document.getElementById('shared-video-preview'),
};
if (!navigator.mediaDevices || !window.MediaRecorder) {
log('This browser does not support the required MediaDevices/MediaRecorder APIs. Try current Chrome or Edge over HTTPS.');
[els.connectMic, els.connectSystem, els.start].forEach((button) => {
if (button) button.disabled = true;
});
return;
}
els.connectMic?.addEventListener('click', connectMicrophone);
els.connectSystem?.addEventListener('click', connectSystemAudio);
els.start?.addEventListener('click', startRecording);
els.stop?.addEventListener('click', stopRecording);
els.reset?.addEventListener('click', resetAudio);
window.addEventListener('beforeunload', cleanupTracks);
async function ensureAudioGraph() {
if (state.audioContext) return;
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
state.audioContext = new AudioContextClass();
state.destination = state.audioContext.createMediaStreamDestination();
state.mixedStream = state.destination.stream;
state.analyzers.mix = createAnalyser();
const mixSource = state.audioContext.createMediaStreamSource(state.mixedStream);
mixSource.connect(state.analyzers.mix);
updateStatus(els.mixStatus, 'Waiting', 'muted');
startMeters();
}
async function connectMicrophone() {
try {
await ensureAudioGraph();
await state.audioContext.resume();
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
replaceStream('micStream', stream);
connectStream(stream, 'mic');
updateStatus(els.micStatus, 'Connected', 'active');
log('Microphone connected.');
refreshButtons();
} catch (error) {
handleCaptureError('microphone', error);
}
}
async function connectSystemAudio() {
try {
await ensureAudioGraph();
await state.audioContext.resume();
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
});
if (!stream.getAudioTracks().length) {
stopStream(stream);
throw new Error('No audio track was shared. Reopen sharing and enable tab/system audio.');
}
replaceStream('systemStream', stream);
connectStream(stream, 'system');
els.sharedVideo.srcObject = stream;
els.sharedVideo.play().catch(() => {});
updateStatus(els.systemStatus, 'Connected', 'active');
log('Computer/tab audio connected. Keep the shared source open while recording.');
stream.getTracks().forEach((track) => {
track.addEventListener('ended', () => {
if (state.systemStream === stream) {
updateStatus(els.systemStatus, 'Stopped', 'muted');
log('Shared computer/tab audio stopped by the browser.');
state.systemStream = null;
refreshButtons();
}
});
});
refreshButtons();
} catch (error) {
handleCaptureError('computer/tab audio', error);
}
}
function connectStream(stream, sourceName) {
const audioTracks = stream.getAudioTracks();
if (!audioTracks.length) return;
const audioOnlyStream = new MediaStream(audioTracks);
const source = state.audioContext.createMediaStreamSource(audioOnlyStream);
const analyser = createAnalyser();
state.analyzers[sourceName] = analyser;
source.connect(analyser);
source.connect(state.destination);
updateStatus(els.mixStatus, 'Mix ready', 'active');
}
function createAnalyser() {
const analyser = state.audioContext.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.78;
return analyser;
}
function startMeters() {
if (state.animationFrame) return;
const data = new Uint8Array(128);
const draw = () => {
drawMeter(state.analyzers.mic, els.micMeter, data);
drawMeter(state.analyzers.system, els.systemMeter, data);
drawMeter(state.analyzers.mix, els.mixMeter, data);
state.animationFrame = requestAnimationFrame(draw);
};
draw();
}
function drawMeter(analyser, element, data) {
if (!element) return;
if (!analyser) {
element.style.width = '0%';
return;
}
analyser.getByteTimeDomainData(data);
let sum = 0;
for (const value of data) {
const centered = value - 128;
sum += centered * centered;
}
const rms = Math.sqrt(sum / data.length);
const width = Math.min(100, Math.max(3, rms * 4.6));
element.style.width = `${width}%`;
}
function startRecording() {
if (!state.mixedStream || !state.mixedStream.getAudioTracks().length) {
log('Connect at least one audio source before recording.');
return;
}
cleanupRecordingUrl();
state.chunks = [];
const options = pickRecorderOptions();
state.recorder = new MediaRecorder(state.mixedStream, options);
state.recorder.addEventListener('dataavailable', (event) => {
if (event.data && event.data.size > 0) state.chunks.push(event.data);
});
state.recorder.addEventListener('stop', () => {
const mimeType = state.recorder?.mimeType || 'audio/webm';
const blob = new Blob(state.chunks, { type: mimeType });
state.downloadUrl = URL.createObjectURL(blob);
els.preview.src = state.downloadUrl;
els.download.href = state.downloadUrl;
els.download.download = `reliefsignal-audio-test-${new Date().toISOString().replace(/[:.]/g, '-')}.webm`;
els.result.hidden = false;
log(`Recording ready (${formatBytes(blob.size)}).`);
refreshButtons();
});
state.recorder.start(1000);
updateStatus(els.mixStatus, 'Recording', 'recording');
log('Local recording started.');
refreshButtons();
}
function stopRecording() {
if (state.recorder && state.recorder.state !== 'inactive') {
state.recorder.stop();
updateStatus(els.mixStatus, 'Mix ready', hasAnySource() ? 'active' : 'muted');
log('Recording stopped.');
refreshButtons();
}
}
function resetAudio() {
if (state.recorder && state.recorder.state !== 'inactive') {
state.recorder.stop();
}
cleanupTracks();
cleanupRecordingUrl();
state.micStream = null;
state.systemStream = null;
state.mixedStream = null;
state.destination = null;
state.analyzers = {};
state.audioContext?.close().catch(() => {});
state.audioContext = null;
state.recorder = null;
state.chunks = [];
if (state.animationFrame) cancelAnimationFrame(state.animationFrame);
state.animationFrame = null;
els.preview.removeAttribute('src');
els.download.removeAttribute('href');
els.result.hidden = true;
els.sharedVideo.srcObject = null;
updateStatus(els.micStatus, 'Not connected', 'muted');
updateStatus(els.systemStatus, 'Not connected', 'muted');
updateStatus(els.mixStatus, 'Waiting', 'muted');
[els.micMeter, els.systemMeter, els.mixMeter].forEach((meter) => {
if (meter) meter.style.width = '0%';
});
log('Audio capture reset.');
refreshButtons();
}
function refreshButtons() {
const recording = state.recorder && state.recorder.state === 'recording';
const hasSource = hasAnySource();
els.start.disabled = !hasSource || recording;
els.stop.disabled = !recording;
els.reset.disabled = !hasSource && !state.downloadUrl;
els.connectMic.disabled = Boolean(state.micStream) || recording;
els.connectSystem.disabled = Boolean(state.systemStream) || recording;
}
function hasAnySource() {
return Boolean(
state.micStream?.getAudioTracks().some((track) => track.readyState === 'live') ||
state.systemStream?.getAudioTracks().some((track) => track.readyState === 'live')
);
}
function pickRecorderOptions() {
const candidates = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
];
const mimeType = candidates.find((type) => MediaRecorder.isTypeSupported(type));
return mimeType ? { mimeType } : {};
}
function replaceStream(key, stream) {
if (state[key]) stopStream(state[key]);
state[key] = stream;
}
function stopStream(stream) {
stream?.getTracks().forEach((track) => track.stop());
}
function cleanupTracks() {
stopStream(state.micStream);
stopStream(state.systemStream);
}
function cleanupRecordingUrl() {
if (state.downloadUrl) {
URL.revokeObjectURL(state.downloadUrl);
state.downloadUrl = null;
}
}
function updateStatus(element, label, mode) {
if (!element) return;
element.textContent = label;
element.classList.remove('active', 'muted', 'recording');
element.classList.add(mode);
}
function log(message) {
if (!els.log) return;
const item = document.createElement('li');
item.textContent = `${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} — ${message}`;
els.log.prepend(item);
}
function handleCaptureError(label, error) {
const message = error?.name === 'NotAllowedError'
? `Permission was not granted for ${label}.`
: error?.message || `Could not connect ${label}.`;
log(message);
}
function formatBytes(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / Math.pow(1024, index)).toFixed(index ? 1 : 0)} ${units[index]}`;
}
})();