331 lines
11 KiB
JavaScript
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]}`;
|
|
}
|
|
})();
|