Add audio level meter functionality and update UI elements

This commit is contained in:
2026-09-26 17:32:54 +02:00
parent 3f4fccdba2
commit eb83b16b54
4 changed files with 65 additions and 2 deletions
+58 -1
View File
@@ -2,7 +2,7 @@ const DB_NAME = 'faerro-kb-local';
const STORE_NAME = 'recordings';
const MAX_RECORDING_MS = 30 * 60 * 1000;
const elements = Object.fromEntries([
'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'capture-message',
'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'audio-level', 'audio-level-fill', 'capture-message',
'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty',
'network-state', 'storage-status',
].map((id) => [id, document.getElementById(id)]));
@@ -10,6 +10,8 @@ const elements = Object.fromEntries([
let databasePromise;
let mediaRecorder;
let activeStream;
let audioContext;
let audioLevelFrame;
let recordingChunks = [];
let recordingStartedAt = 0;
let recordingTimer;
@@ -64,12 +66,62 @@ function preferredMimeType() {
.find((type) => window.MediaRecorder?.isTypeSupported(type));
}
function prepareAudioLevelMeter() {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return;
try {
audioContext = new AudioContextClass();
audioContext.resume().catch(() => {});
} catch {
audioContext = undefined;
}
}
function startAudioLevelMeter(stream) {
if (!audioContext) return;
try {
const analyser = audioContext.createAnalyser();
analyser.fftSize = 512;
audioContext.createMediaStreamSource(stream).connect(analyser);
const samples = new Uint8Array(analyser.fftSize);
elements['audio-level'].hidden = false;
const updateLevel = () => {
analyser.getByteTimeDomainData(samples);
const sumSquares = samples.reduce((sum, sample) => sum + (sample - 128) ** 2, 0);
const level = Math.min(100, Math.round(Math.sqrt(sumSquares / samples.length) / 32 * 100));
elements['audio-level-fill'].style.width = `${level}%`;
elements['audio-level'].setAttribute('aria-valuenow', level);
audioLevelFrame = requestAnimationFrame(updateLevel);
};
audioLevelFrame = requestAnimationFrame(updateLevel);
} catch {
audioContext?.close().catch(() => {});
audioContext = undefined;
}
}
function stopAudioLevelMeter() {
cancelAnimationFrame(audioLevelFrame);
audioLevelFrame = undefined;
if (audioContext) {
audioContext.close().catch(() => {});
audioContext = undefined;
}
elements['audio-level'].hidden = true;
elements['audio-level-fill'].style.width = '0';
elements['audio-level'].setAttribute('aria-valuenow', '0');
}
async function startRecording() {
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
setMessage('This browser cannot record audio. Open the app in current iOS Safari.', true);
return;
}
try {
prepareAudioLevelMeter();
activeStream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = preferredMimeType();
mediaRecorder = new MediaRecorder(activeStream, mimeType ? { mimeType } : undefined);
@@ -77,6 +129,7 @@ async function startRecording() {
mediaRecorder.ondataavailable = (event) => { if (event.data.size) recordingChunks.push(event.data); };
mediaRecorder.onstop = saveRecording;
mediaRecorder.start(1000);
startAudioLevelMeter(activeStream);
recordingStartedAt = Date.now();
elements['record-button'].classList.add('recording');
elements['record-button'].setAttribute('aria-label', 'Stop recording');
@@ -90,12 +143,16 @@ async function startRecording() {
if (elapsed >= MAX_RECORDING_MS) stopRecording();
}, 250);
} catch (error) {
stopAudioLevelMeter();
activeStream?.getTracks().forEach((track) => track.stop());
activeStream = undefined;
setMessage(error.name === 'NotAllowedError' ? 'Microphone access was denied.' : 'Could not start microphone recording.', true);
}
}
function stopRecording() {
if (mediaRecorder?.state === 'recording') mediaRecorder.stop();
stopAudioLevelMeter();
activeStream?.getTracks().forEach((track) => track.stop());
activeStream = undefined;
clearInterval(recordingTimer);
+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="32" fill="#355a43" />
<text x="32" y="45" fill="#fffefa" font-family="Georgia, serif" font-size="40" text-anchor="middle">V</text>
<text x="32" y="45" fill="#fffefa" font-family="Georgia, serif" font-size="28" text-anchor="middle">KB</text>
</svg>

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 230 B

+3
View File
@@ -26,6 +26,9 @@
<div class="record-copy">
<strong id="record-title">Ready</strong>
<span id="record-help">Tap to record a voice note</span>
<div id="audio-level" class="audio-level" role="meter" aria-label="Audio level" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" hidden>
<span id="audio-level-fill"></span>
</div>
</div>
<time id="record-time">00:00</time>
</div>
+3
View File
@@ -37,6 +37,9 @@ h1 em { color: var(--moss); font-weight: 500; }
.record-copy { display: grid; gap: 4px; flex: 1; }
.record-copy strong { font-size: 14px; font-weight: 600; }
.record-copy span { color: var(--muted); font-size: 12px; }
.audio-level { width: min(120px, 100%); height: 3px; overflow: hidden; background: #e4e3dc; }
.audio-level[hidden] { display: none; }
.audio-level span { display: block; width: 0; height: 100%; background: var(--moss); transition: width .08s linear; }
#record-time { color: var(--muted); font: 12px var(--mono); font-variant-numeric: tabular-nums; }
.record-meter { height: 2px; max-width: 590px; margin-inline: auto; background: #e4e3dc; }
.record-meter span { display: block; height: 100%; width: 0; background: var(--coral); transition: width .25s linear; }