Skip to content

Instantly share code, notes, and snippets.

@kellyja1
Created May 31, 2025 18:28
Show Gist options
  • Save kellyja1/43f8635c7f146574dcd3f20fb6e3e4ea to your computer and use it in GitHub Desktop.
Save kellyja1/43f8635c7f146574dcd3f20fb6e3e4ea to your computer and use it in GitHub Desktop.
Sound meter
<div class="flex flex-col items-center p-8 bg-white rounded-2xl shadow-xl w-full" style="max-width: 336px;">
<h1 id="voiceOMeterTitle" class="voice-o-meter-title inactive">
<svg class="microphone-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 4c0-1.66-1.34-3-3-3S9 2.34 9 4v7c0 1.66 1.34 3 3 3zm5.2-3c0 3.53-2.93 6.43-6.2 6.43S4.8 14.53 4.8 11H3c0 4.56 3.68 8.37 8 8.94V23h2v-3.06c4.32-.57 8-4.38 8-8.94h-1.8z"/>
</svg>
Voice-O-Meter
</h1>
<div id="statusBox" class="status-box bg-gray-300">
</div>
<!-- Calibration Controls -->
<div class="calibration-controls">
<div class="calibration-status">
<span id="calibrationStatus">Ready to calibrate</span>
</div>
<div class="calibration-progress">
<div id="calibrationProgressBar" class="calibration-progress-bar"></div>
</div>
<div>
<button id="calibrateButton" class="calibration-button">Calibrate Quiet</button>
<button id="resetCalibrationButton" class="calibration-button">Reset</button>
</div>
</div>
<p class="noise-zone-title">Noise Zone</p>
<div class="relative flex flex-col items-center mb-4">
<div class="meter-and-zones-container">
<div class="noise-bar-container">
<div id="currentLevelMarker" class="current-level-marker"></div>
</div>
<div class="zones-container">
<div class="zone-label mca-quiet-zone" style="height: 21.05%; bottom: 0%;">
<span>MCA QUIET</span>
<span class="range-text">(0-20 dB)</span>
</div>
<div class="zone-label solo-zone" style="height: 21.05%; bottom: 21.05%;">
<span>SOLO</span>
<span class="range-text">(20-40 dB)</span>
</div>
<div class="zone-label group-zone" style="height: 31.58%; bottom: 42.10%;">
<span>GROUP</span>
<span class="range-text">(40-70 dB)</span>
</div>
</div>
</div>
<p id="decibelValue" class="text-4xl font-extrabold text-gray-900 mt-4">0 dB</p>
<p class="text-sm text-gray-500 mt-2">Current Noise Level</p>
<p id="loudestDecibelValue" class="text-xl font-semibold text-gray-700 mt-4">Loudest: 0 dB</p>
<p id="averageDecibelValue" class="text-xl font-semibold text-gray-700 mt-2">Average: 0 dB</p>
</div>
<div class="flex space-x-4 mb-8">
<button id="btnMcaQuiet" class="zone-button active" data-zone="MCA_QUIET">MCA QUIET</button>
<button id="btnSolo" class="zone-button" data-zone="SOLO">SOLO</button>
<button id="btnGroup" class="zone-button" data-zone="GROUP">GROUP</button>
</div>
<div class="w-full max-w-xs mb-8">
<label for="sensitivitySlider" class="block text-lg font-medium text-gray-700 mb-3 text-center">Sensitivity</label>
<input type="range" id="sensitivitySlider" min="0.1" max="1.0" value="0.5" step="0.01" class="w-full">
<div class="flex justify-between text-sm text-gray-500 mt-2">
<span>Less Sensitive</span>
<span>More Sensitive</span>
</div>
</div>
<div id="statusMessage" class="text-red-600 text-center font-medium hidden"></div>
</div>
<div id="messageBox" class="message-box">
<p id="messageText" class="text-lg text-gray-800 mb-4"></p>
<button id="messageButton" class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition duration-200 ease-in-out shadow-md">OK</button>
</div>
// In-memory data persistence
const memoryStorage = {
sensitivity: 0.5,
activeZone: 'MCA_QUIET',
calibrationOffset: 11,
isCalibrated: false
};
// Global variables
let audioContext;
let analyser;
let microphone;
let dataArray;
let animationFrameId;
let microphoneAccessGranted = false;
// DOM elements
const currentLevelMarker = document.getElementById('currentLevelMarker');
const decibelValueDisplay = document.getElementById('decibelValue');
const loudestDecibelValueDisplay = document.getElementById('loudestDecibelValue');
const averageDecibelValueDisplay = document.getElementById('averageDecibelValue');
const sensitivitySlider = document.getElementById('sensitivitySlider');
const statusMessage = document.getElementById('statusMessage');
const messageBox = document.getElementById('messageBox');
const messageButton = document.getElementById('messageButton');
const statusBox = document.getElementById('statusBox');
const btnMcaQuiet = document.getElementById('btnMcaQuiet');
const btnSolo = document.getElementById('btnSolo');
const btnGroup = document.getElementById('btnGroup');
const voiceOMeterTitle = document.getElementById('voiceOMeterTitle');
const calibrateButton = document.getElementById('calibrateButton');
const resetCalibrationButton = document.getElementById('resetCalibrationButton');
const calibrationStatus = document.getElementById('calibrationStatus');
const calibrationProgressBar = document.getElementById('calibrationProgressBar');
// Settings
let sensitivity = 0.5;
const HISTORY_SIZE = 3; // Shortened moving average
let decibelHistory = [];
let loudestDecibel = 0;
const minDisplayDb = 0;
const maxDisplayDb = 95;
const displayRange = maxDisplayDb - minDisplayDb;
let CALIBRATION_OFFSET_DB = 11;
let isCalibrated = false;
// Calibration variables
let isCalibrating = false;
let calibrationSamples = [];
const CALIBRATION_DURATION = 3000;
const CALIBRATION_SAMPLE_RATE = 100;
const noiseZones = {
MCA_QUIET: { min: 0, max: 20 },
SOLO: { min: 20, max: 40 },
GROUP: { min: 40, max: 70 }
};
let activeZone = 'MCA_QUIET';
// Functions
function saveSettings() {
memoryStorage.sensitivity = sensitivity;
memoryStorage.activeZone = activeZone;
memoryStorage.calibrationOffset = CALIBRATION_OFFSET_DB;
memoryStorage.isCalibrated = isCalibrated;
console.log('Settings saved to memory:', memoryStorage);
}
function loadSettings() {
sensitivity = memoryStorage.sensitivity;
activeZone = memoryStorage.activeZone;
CALIBRATION_OFFSET_DB = memoryStorage.calibrationOffset;
isCalibrated = memoryStorage.isCalibrated;
if (sensitivitySlider) sensitivitySlider.value = sensitivity;
// Update active zone button
if (btnMcaQuiet) btnMcaQuiet.classList.remove('active');
if (btnSolo) btnSolo.classList.remove('active');
if (btnGroup) btnGroup.classList.remove('active');
if (activeZone === 'MCA_QUIET' && btnMcaQuiet) btnMcaQuiet.classList.add('active');
else if (activeZone === 'SOLO' && btnSolo) btnSolo.classList.add('active');
else if (activeZone === 'GROUP' && btnGroup) btnGroup.classList.add('active');
updateCalibrationStatus();
console.log('Settings loaded from memory:', memoryStorage);
}
function showMessageBox(message, buttonText = "OK", onButtonClick = null) {
const messageText = document.getElementById('messageText');
if (messageText) messageText.textContent = message;
if (messageButton) messageButton.textContent = buttonText;
if (messageBox) messageBox.style.display = 'block';
if (messageButton) {
messageButton.onclick = () => {
if (messageBox) messageBox.style.display = 'none';
if (onButtonClick) onButtonClick();
};
}
}
function calculateRMS(data) {
let sumSquares = 0;
for (let i = 0; i < data.length; i++) {
const normalizedValue = (data[i] / 128) - 1;
sumSquares += normalizedValue * normalizedValue;
}
return Math.sqrt(sumSquares / data.length);
}
function updateCalibrationStatus() {
if (!calibrationStatus) return;
if (isCalibrated) {
calibrationStatus.textContent = `Calibrated (offset: ${CALIBRATION_OFFSET_DB.toFixed(1)} dB)`;
if (calibrateButton) calibrateButton.textContent = 'Re-calibrate';
} else {
calibrationStatus.textContent = 'Not calibrated - may be inaccurate';
if (calibrateButton) calibrateButton.textContent = 'Calibrate Quiet';
}
}
function startCalibration() {
if (!microphoneAccessGranted) {
showMessageBox('Please start the microphone first by clicking the Voice-O-Meter title.');
return;
}
isCalibrating = true;
calibrationSamples = [];
if (calibrateButton) calibrateButton.disabled = true;
if (calibrationStatus) calibrationStatus.textContent = 'Calibrating... Stay quiet!';
if (calibrationProgressBar) calibrationProgressBar.style.width = '0%';
const startTime = Date.now();
const calibrationInterval = setInterval(() => {
const elapsed = Date.now() - startTime;
const progress = Math.min(100, (elapsed / CALIBRATION_DURATION) * 100);
if (calibrationProgressBar) calibrationProgressBar.style.width = `${progress}%`;
if (elapsed >= CALIBRATION_DURATION) {
clearInterval(calibrationInterval);
finishCalibration();
} else {
if (analyser && dataArray) {
analyser.getByteFrequencyData(dataArray);
const rms = calculateRMS(dataArray);
const scaledRms = Math.min(1, rms * sensitivity);
const logRms = Math.log10(scaledRms + 0.001);
let rawDb = minDisplayDb + ((logRms - (-3)) / (0 - (-3))) * displayRange;
calibrationSamples.push(rawDb);
}
}
}, CALIBRATION_SAMPLE_RATE);
}
function finishCalibration() {
isCalibrating = false;
if (calibrateButton) calibrateButton.disabled = false;
if (calibrationSamples.length > 0) {
const averageQuietLevel = calibrationSamples.reduce((sum, val) => sum + val, 0) / calibrationSamples.length;
CALIBRATION_OFFSET_DB = averageQuietLevel - 2;
isCalibrated = true;
if (calibrationStatus) {
calibrationStatus.textContent = `Calibrated! Quiet level: ${(averageQuietLevel - CALIBRATION_OFFSET_DB).toFixed(1)} dB`;
}
showMessageBox(`Calibration complete! Your quiet room baseline is now set to ${(averageQuietLevel - CALIBRATION_OFFSET_DB).toFixed(1)} dB.`);
saveSettings();
} else {
if (calibrationStatus) calibrationStatus.textContent = 'Calibration failed - try again';
showMessageBox('Calibration failed. Please ensure the microphone is working and try again.');
}
updateCalibrationStatus();
if (calibrationProgressBar) calibrationProgressBar.style.width = '0%';
}
function resetCalibration() {
CALIBRATION_OFFSET_DB = 11;
isCalibrated = false;
updateCalibrationStatus();
saveSettings();
showMessageBox('Calibration reset to default values.');
}
function updateNoiseLevel() {
if (!microphoneAccessGranted || !analyser || !dataArray) {
return;
}
analyser.getByteFrequencyData(dataArray);
const rms = calculateRMS(dataArray);
const scaledRms = Math.min(1, rms * sensitivity);
const logRms = Math.log10(scaledRms + 0.001);
let currentDb = minDisplayDb + ((logRms - (-3)) / (0 - (-3))) * displayRange;
currentDb -= CALIBRATION_OFFSET_DB;
currentDb = Math.max(minDisplayDb, Math.min(maxDisplayDb, currentDb));
decibelHistory.push(currentDb);
if (decibelHistory.length > HISTORY_SIZE) {
decibelHistory.shift();
}
const averagedDecibels = decibelHistory.reduce((sum, val) => sum + val, 0) / decibelHistory.length;
if (decibelValueDisplay) decibelValueDisplay.textContent = `${Math.round(averagedDecibels)} dB`;
const markerPosition = ((averagedDecibels - minDisplayDb) / displayRange) * 100;
if (currentLevelMarker) {
currentLevelMarker.style.bottom = `${Math.min(100, Math.max(0, markerPosition))}%`;
}
loudestDecibel = Math.max(loudestDecibel, averagedDecibels);
if (loudestDecibelValueDisplay) {
loudestDecibelValueDisplay.textContent = `Loudest: ${Math.round(loudestDecibel)} dB`;
}
if (averageDecibelValueDisplay) {
averageDecibelValueDisplay.textContent = `Average: ${Math.round(averagedDecibels)} dB`;
}
const zone = noiseZones[activeZone];
let statusColor = '';
let emoji = '';
let shouldBlink = false;
const levelRelativeToZoneStart = averagedDecibels - zone.min;
const zoneRange = zone.max - zone.min;
let percentageOfZone = (zoneRange > 0) ? (levelRelativeToZoneStart / zoneRange) * 100 : 0;
if (averagedDecibels > zone.max) {
statusColor = 'red';
emoji = '😞';
shouldBlink = true;
} else if (percentageOfZone >= 90) {
statusColor = 'yellow';
emoji = '😮';
} else {
statusColor = 'green';
emoji = '😊';
}
if (statusBox) {
statusBox.style.backgroundColor = statusColor;
statusBox.innerHTML = emoji;
if (shouldBlink) {
statusBox.classList.add('blink-red');
} else {
statusBox.classList.remove('blink-red');
}
}
animationFrameId = requestAnimationFrame(updateNoiseLevel);
}
async function startMicrophone() {
console.log('startMicrophone function called.');
try {
console.log('Attempting to get user media...');
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('Microphone access granted!');
if (statusMessage) statusMessage.classList.add('hidden');
microphoneAccessGranted = true;
if (voiceOMeterTitle) {
voiceOMeterTitle.classList.remove('inactive');
voiceOMeterTitle.style.cursor = 'default';
}
const microphoneIcon = voiceOMeterTitle ? voiceOMeterTitle.querySelector('.microphone-icon') : null;
if (microphoneIcon) {
microphoneIcon.style.fill = 'black';
}
audioContext = new (window.AudioContext || window.webkitAudioContext)();
microphone = audioContext.createMediaStreamSource(stream);
analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
dataArray = new Uint8Array(analyser.frequencyBinCount);
microphone.connect(analyser);
updateNoiseLevel();
} catch (err) {
console.error('Error accessing microphone:', err);
if (statusMessage) statusMessage.classList.remove('hidden');
let errorMessage = 'Microphone access denied or not available.';
if (err.name === 'NotAllowedError') {
errorMessage = 'Microphone access was denied. Please allow microphone access and try again.';
} else if (err.name === 'NotFoundError') {
errorMessage = 'No microphone found. Please ensure a microphone is connected and enabled.';
} else if (err.name === 'NotReadableError') {
errorMessage = 'Could not access the microphone due to hardware or system issues.';
} else if (err.name === 'SecurityError') {
errorMessage = 'Microphone access could not be granted due to security policies.';
} else if (err.name === 'AbortError') {
errorMessage = 'Microphone access was aborted.';
} else {
errorMessage = `An unknown error occurred: ${err.message}`;
}
if (statusMessage) statusMessage.textContent = errorMessage;
showMessageBox(errorMessage, "OK");
if (voiceOMeterTitle) {
voiceOMeterTitle.classList.add('inactive');
voiceOMeterTitle.style.cursor = 'pointer';
}
const microphoneIcon = voiceOMeterTitle ? voiceOMeterTitle.querySelector('.microphone-icon') : null;
if (microphoneIcon) {
microphoneIcon.style.fill = 'gray';
}
}
}
function handleZoneButtonClick(event) {
if (btnMcaQuiet) btnMcaQuiet.classList.remove('active');
if (btnSolo) btnSolo.classList.remove('active');
if (btnGroup) btnGroup.classList.remove('active');
event.target.classList.add('active');
activeZone = event.target.dataset.zone;
saveSettings();
}
// Event listeners
document.addEventListener('DOMContentLoaded', () => {
loadSettings();
if (voiceOMeterTitle) voiceOMeterTitle.style.cursor = 'pointer';
if (sensitivitySlider) {
sensitivitySlider.addEventListener('input', (event) => {
sensitivity = parseFloat(event.target.value);
saveSettings();
});
}
if (btnMcaQuiet) btnMcaQuiet.addEventListener('click', handleZoneButtonClick);
if (btnSolo) btnSolo.addEventListener('click', handleZoneButtonClick);
if (btnGroup) btnGroup.addEventListener('click', handleZoneButtonClick);
if (calibrateButton) calibrateButton.addEventListener('click', startCalibration);
if (resetCalibrationButton) resetCalibrationButton.addEventListener('click', resetCalibration);
if (voiceOMeterTitle) {
voiceOMeterTitle.addEventListener('click', () => {
console.log('Voice-O-Meter title clicked.');
if (!microphoneAccessGranted) {
startMicrophone();
}
});
}
});
// Cleanup
window.addEventListener('beforeunload', () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close();
}
if (microphone) {
microphone.disconnect();
}
});
@import url('https://fonts.googleapis.com/css2?family=Bangers&display=swap');
/* Custom styles for the noise bar and overall layout */
body {
font-family: 'Inter', sans-serif;
background-color: #f0f4f8;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
overflow: hidden;
}
.meter-and-zones-container {
display: flex;
align-items: flex-end;
gap: 10px;
}
.noise-bar-container {
width: 60px;
height: 300px;
background: linear-gradient(to top, #4ade80, #facc15, #ef4444);
border-radius: 15px;
overflow: hidden;
position: relative;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.06);
border: 4px solid black;
}
.current-level-marker {
width: 100%;
height: 8px;
background-color: #ffffff;
position: absolute;
left: 0;
bottom: 0;
transition: bottom 0.1s ease-out;
z-index: 10;
box-shadow: 0 0 12px rgba(0, 0, 0, 0.7);
}
.zones-container {
width: 100px;
height: 300px;
position: relative;
display: flex;
flex-direction: column-reverse;
justify-content: flex-start;
pointer-events: none;
}
.zone-label {
position: absolute;
left: 0;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-weight: 900;
color: white;
text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000;
border-radius: 5px;
padding: 5px 0;
box-sizing: border-box;
transition: all 0.1s ease-out;
font-size: 16px;
border: 4px solid black;
}
.zone-label span.range-text {
font-size: 14.4px;
font-weight: normal;
margin-top: 2px;
text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000;
}
.mca-quiet-zone {
background-color: rgba(67, 160, 71, 0.7);
height: 21.05%;
bottom: 0%;
}
.solo-zone {
background-color: rgba(0, 0, 255, 0.7);
height: 21.05%;
bottom: 21.05%;
}
.group-zone {
background-color: rgba(128, 0, 128, 0.7);
height: 31.58%;
bottom: 42.10%;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 8px;
background: #cbd5e1;
outline: none;
opacity: 0.7;
transition: opacity .15s ease-in-out;
border-radius: 5px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
background: #3b82f6;
cursor: pointer;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
input[type="range"]::-moz-range-thumb {
width: 24px;
height: 24px;
background: #3b82f6;
cursor: pointer;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
border: none;
}
.message-box {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #fff;
padding: 24px;
border-radius: 12px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
z-index: 1000;
text-align: center;
display: none;
font-size: 16px;
}
.status-box {
width: 307.2px;
height: 80px;
border: 4px solid black;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
font-size: 48px;
margin-bottom: 16px;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.blink-red {
animation: blink 1s linear infinite;
}
.zone-button {
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 0.2s ease-in-out;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
background-color: #e2e8f0;
color: #4a5568;
border: 2px solid #cbd5e1;
font-size: 12.8px;
cursor: pointer;
}
.zone-button.active {
background-color: #3b82f6;
color: white;
border-color: #2563eb;
box-shadow: 0 6px 8px rgba(0, 0, 0, 0.2);
transform: translateY(-2px);
}
.zone-button:hover:not(.active) {
background-color: #cbd5e1;
}
.noise-zone-title {
font-size: 24px;
font-weight: bold;
color: black;
text-align: center;
margin-bottom: 8px;
}
#statusMessage {
font-size: 16px;
text-align: center;
margin-top: 8px;
}
.voice-o-meter-title {
font-family: 'Bangers', cursive;
font-size: 64px;
color: red;
text-shadow: -6px -6px 0 #000, 6px -6px 0 #000, -6px 6px 0 #000, 6px 6px 0 #000;
margin-bottom: 16px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.microphone-icon {
width: 96px;
height: 96px;
fill: black;
stroke: none;
}
.voice-o-meter-title.inactive {
color: gray;
cursor: pointer;
}
.voice-o-meter-title.inactive .microphone-icon {
fill: gray;
}
.calibration-button {
padding: 8px 16px;
border-radius: 6px;
font-weight: 500;
transition: all 0.2s ease-in-out;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
background-color: #f59e0b;
color: white;
border: none;
font-size: 12px;
cursor: pointer;
margin: 4px;
}
.calibration-button:hover {
background-color: #d97706;
transform: translateY(-1px);
}
.calibration-button:disabled {
background-color: #9ca3af;
cursor: not-allowed;
transform: none;
}
.calibration-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 12px;
border: 2px solid #e5e7eb;
border-radius: 8px;
background-color: #f9fafb;
}
.calibration-status {
font-size: 14px;
font-weight: medium;
color: #374151;
text-align: center;
}
.calibration-progress {
width: 100%;
height: 6px;
background-color: #e5e7eb;
border-radius: 3px;
overflow: hidden;
}
.calibration-progress-bar {
height: 100%;
background-color: #3b82f6;
width: 0%;
transition: width 0.1s ease-out;
}
.hidden {
display: none;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment