mirror of
https://github.com/SrIzan10/vdo.ninja.git
synced 2026-05-01 11:05:24 +00:00
v29.0
This commit is contained in:
@@ -1,410 +1,410 @@
|
||||
<!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>
|
||||
<!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>
|
||||
|
||||
Reference in New Issue
Block a user