From eb83b16b549138e85880946fdbf330a86b7c8f30 Mon Sep 17 00:00:00 2001 From: Andrea Date: Sat, 26 Sep 2026 17:32:54 +0200 Subject: [PATCH] Add audio level meter functionality and update UI elements --- frontend/app.js | 59 +++++++++++++++++++++++++++++++++++++++++++- frontend/favicon.svg | 2 +- frontend/index.html | 3 +++ frontend/styles.css | 3 +++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index ae59053..1764a4d 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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); diff --git a/frontend/favicon.svg b/frontend/favicon.svg index 6b3bd07..38364cb 100644 --- a/frontend/favicon.svg +++ b/frontend/favicon.svg @@ -1,4 +1,4 @@ - V + KB \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 04c9860..3c71a94 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -26,6 +26,9 @@
Ready Tap to record a voice note +
diff --git a/frontend/styles.css b/frontend/styles.css index a0ca459..aa1ab77 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -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; }