Files
archived-vdo.ninja/recorder/monitor.html
steveseguin a631bc074c v29.0
2026-01-18 03:27:00 -05:00

411 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Self Monitor Audio | Listen to Your Input Devices</title>
<meta name="description" content="Pick a microphone, choose where to listen, and safely monitor your audio input with gain control and live waveform feedback.">
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon">
<link id="favicon1" rel="icon" type="image/png" sizes="32x32" href="../media/favicon-32x32.png">
<link id="favicon2" rel="icon" type="image/png" sizes="16x16" href="../media/favicon-16x16.png">
<link id="favicon3" rel="icon" href="../media/favicon.ico">
<link id="thumbnailUrl" itemprop="thumbnailUrl" href="../media/vdoNinja_logo_full.png">
<style>
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background-color: #1f1f1f;
font-family: "Helvetica Neue", Arial, sans-serif;
color: #f0f0f0;
}
#container {
width: 95%;
max-width: 900px;
padding: 24px;
background: #2b2b2b;
border-radius: 14px;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.45);
}
h1 {
margin: 0 0 16px 0;
font-size: 26px;
color: #ffffff;
}
p.note {
margin: 0 0 20px 0;
color: #cfcfcf;
font-size: 14px;
line-height: 1.4;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
}
.controls label {
display: flex;
flex-direction: column;
font-size: 13px;
color: #b0b0b0;
min-width: 200px;
gap: 6px;
}
select, button, input[type="range"] {
padding: 10px 12px;
border-radius: 6px;
border: 1px solid #444;
background: #3a3a3a;
color: #f0f0f0;
font-size: 14px;
cursor: pointer;
}
select:focus, button:focus, input[type="range"]:focus {
outline: none;
border-color: #0099ff;
}
button.primary {
background: #0099ff;
border: none;
font-weight: 600;
min-width: 160px;
}
button.primary.muted {
background: #444;
color: #f0f0f0;
}
.flex-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.flex-row span.value {
min-width: 48px;
text-align: right;
font-variant-numeric: tabular-nums;
}
canvas {
width: 100%;
height: 160px;
background: #151515;
border-radius: 8px;
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.35);
}
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #d0d0d0;
}
.checkbox-group input {
cursor: pointer;
}
.small {
font-size: 12px;
color: #888;
}
@media (max-width: 640px) {
.controls label {
min-width: 100%;
}
button.primary {
width: 100%;
}
}
</style>
</head>
<body>
<div id="container">
<h1>Self Monitor Audio</h1>
<p class="note">
Select a microphone and click <strong>Unmute Monitor</strong> when you're ready to listen.
Browsers require a user interaction before audio can play, so the monitor starts muted.
</p>
<div class="controls">
<label>
Input device
<select id="inputSelect"></select>
</label>
<label>
Output device
<select id="outputSelect" disabled title="Change your speaker/earpiece if supported"></select>
<span class="small">Chrome/Edge over HTTPS or localhost support custom outputs.</span>
</label>
<label>
Monitor gain
<div class="flex-row">
<input id="gainControl" type="range" min="0" max="300" step="5" value="100">
<span class="value" id="gainValue">1.0×</span>
</div>
</label>
</div>
<div class="controls">
<button id="monitorToggle" class="primary muted">Unmute Monitor</button>
<label class="checkbox-group">
<input type="checkbox" id="noiseToggle" checked>
<span>Noise reduction</span>
</label>
<label class="checkbox-group">
<input type="checkbox" id="agcToggle" checked>
<span>Auto gain control</span>
</label>
</div>
<canvas id="waveform" width="900" height="160"></canvas>
</div>
<audio id="monitorAudio" autoplay></audio>
<script>
const inputSelect = document.getElementById("inputSelect");
const outputSelect = document.getElementById("outputSelect");
const monitorToggle = document.getElementById("monitorToggle");
const noiseToggle = document.getElementById("noiseToggle");
const agcToggle = document.getElementById("agcToggle");
const gainControl = document.getElementById("gainControl");
const gainValue = document.getElementById("gainValue");
const waveformCanvas = document.getElementById("waveform");
const monitorAudioEl = document.getElementById("monitorAudio");
const canvasCtx = waveformCanvas.getContext("2d");
let audioCtx = null;
let analyser = null;
let gainNode = null;
let monitorStreamDestination = null;
let mediaStream = null;
let isMonitoring = false;
let animationFrame = null;
let deviceLabelsReady = false;
let sourceNode = null;
function formatGainLabel(value) {
return `${(value / 100).toFixed(1)}×`;
}
function updateGainFromControl() {
const gain = gainControl.value / 100;
gainValue.textContent = formatGainLabel(gainControl.value);
if (gainNode && audioCtx) {
gainNode.gain.setTargetAtTime(isMonitoring ? gain : 0, audioCtx.currentTime, 0.01);
}
}
function drawWaveform() {
if (!analyser) {
return;
}
const bufferLength = analyser.fftSize;
const dataArray = new Uint8Array(bufferLength);
analyser.getByteTimeDomainData(dataArray);
canvasCtx.fillStyle = "#151515";
canvasCtx.fillRect(0, 0, waveformCanvas.width, waveformCanvas.height);
canvasCtx.lineWidth = 2;
canvasCtx.strokeStyle = isMonitoring ? "#00d4ff" : "#666";
canvasCtx.beginPath();
const sliceWidth = (waveformCanvas.width * 1.0) / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = (v * waveformCanvas.height) / 2;
i === 0 ? canvasCtx.moveTo(x, y) : canvasCtx.lineTo(x, y);
x += sliceWidth;
}
canvasCtx.lineTo(waveformCanvas.width, waveformCanvas.height / 2);
canvasCtx.stroke();
animationFrame = requestAnimationFrame(drawWaveform);
}
async function ensureAudioContext() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
gainNode = audioCtx.createGain();
gainNode.gain.value = 0;
monitorStreamDestination = audioCtx.createMediaStreamDestination();
gainNode.connect(monitorStreamDestination);
monitorAudioEl.srcObject = monitorStreamDestination.stream;
}
if (audioCtx.state === "suspended") {
await audioCtx.resume();
}
}
function stopStream() {
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
mediaStream = null;
}
if (sourceNode) {
try {
sourceNode.disconnect();
} catch (error) {
console.warn("Source disconnect warning:", error);
}
sourceNode = null;
}
}
async function startStream() {
try {
stopStream();
await ensureAudioContext();
const constraints = {
audio: {
deviceId: inputSelect.value ? { exact: inputSelect.value } : undefined,
echoCancellation: false,
noiseSuppression: noiseToggle.checked,
autoGainControl: agcToggle.checked
}
};
mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
const source = audioCtx.createMediaStreamSource(mediaStream);
if (sourceNode) {
try {
sourceNode.disconnect();
} catch (error) {
console.warn("Previous source disconnect warning:", error);
}
}
sourceNode = source;
sourceNode.connect(analyser);
sourceNode.connect(gainNode);
if (!deviceLabelsReady) {
await populateDeviceLists();
}
updateGainFromControl();
if (!animationFrame) {
drawWaveform();
}
} catch (error) {
console.error("Failed to start stream:", error);
alert("Unable to access the selected audio device. Please check permissions and device availability.");
}
}
async function populateDeviceLists() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devices.filter(device => device.kind === "audioinput");
const audioOutputs = devices.filter(device => device.kind === "audiooutput");
const previousInput = inputSelect.value;
inputSelect.innerHTML = audioInputs.map(device => `<option value="${device.deviceId}">${device.label || "Microphone"}</option>`).join("");
if (previousInput) {
const match = Array.from(inputSelect.options).find(option => option.value === previousInput);
if (match) {
inputSelect.value = previousInput;
}
}
if (typeof monitorAudioEl.setSinkId === "function" && audioOutputs.length) {
const previousOutput = outputSelect.value;
outputSelect.innerHTML = audioOutputs.map(device => `<option value="${device.deviceId}">${device.label || "Speaker"}</option>`).join("");
outputSelect.disabled = false;
if (previousOutput) {
const matchOut = Array.from(outputSelect.options).find(option => option.value === previousOutput);
if (matchOut) {
outputSelect.value = previousOutput;
}
}
} else {
outputSelect.innerHTML = `<option value="">Default system output</option>`;
outputSelect.disabled = true;
}
deviceLabelsReady = devices.some(device => device.label);
} catch (error) {
console.warn("Unable to refresh device list:", error);
}
}
async function requestPermissions() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(track => track.stop());
await populateDeviceLists();
} catch (error) {
console.warn("Microphone permission denied:", error);
}
}
monitorToggle.addEventListener("click", async () => {
await ensureAudioContext();
if (!mediaStream) {
await startStream();
}
const shouldMonitor = !isMonitoring;
isMonitoring = shouldMonitor;
monitorToggle.textContent = shouldMonitor ? "Mute Monitor" : "Unmute Monitor";
monitorToggle.classList.toggle("muted", !shouldMonitor);
updateGainFromControl();
try {
if (shouldMonitor) {
await monitorAudioEl.play();
} else {
monitorAudioEl.pause();
}
} catch (error) {
console.warn("Playback blocked until user interacts:", error);
}
});
gainControl.addEventListener("input", () => {
updateGainFromControl();
});
inputSelect.addEventListener("change", () => {
startStream();
});
noiseToggle.addEventListener("change", () => {
startStream();
});
agcToggle.addEventListener("change", () => {
startStream();
});
outputSelect.addEventListener("change", async () => {
if (typeof monitorAudioEl.setSinkId !== "function") {
return;
}
try {
await monitorAudioEl.setSinkId(outputSelect.value || "");
} catch (error) {
console.warn("Unable to set output device:", error);
alert("Failed to switch output device. Your browser may require HTTPS or does not support audio output switching.");
outputSelect.value = "";
}
});
window.addEventListener("beforeunload", () => {
stopStream();
if (audioCtx) {
audioCtx.close();
}
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
});
(async () => {
gainValue.textContent = formatGainLabel(gainControl.value);
canvasCtx.fillStyle = "#151515";
canvasCtx.fillRect(0, 0, waveformCanvas.width, waveformCanvas.height);
await requestPermissions();
await startStream();
})();
</script>
</body>
</html>