mirror of
https://github.com/SrIzan10/vdo.ninja.git
synced 2026-05-01 11:05:24 +00:00
411 lines
12 KiB
HTML
411 lines
12 KiB
HTML
<!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>
|