Add audio level meter functionality and update UI elements
This commit is contained in:
+58
-1
@@ -2,7 +2,7 @@ const DB_NAME = 'faerro-kb-local';
|
|||||||
const STORE_NAME = 'recordings';
|
const STORE_NAME = 'recordings';
|
||||||
const MAX_RECORDING_MS = 30 * 60 * 1000;
|
const MAX_RECORDING_MS = 30 * 60 * 1000;
|
||||||
const elements = Object.fromEntries([
|
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',
|
'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty',
|
||||||
'network-state', 'storage-status',
|
'network-state', 'storage-status',
|
||||||
].map((id) => [id, document.getElementById(id)]));
|
].map((id) => [id, document.getElementById(id)]));
|
||||||
@@ -10,6 +10,8 @@ const elements = Object.fromEntries([
|
|||||||
let databasePromise;
|
let databasePromise;
|
||||||
let mediaRecorder;
|
let mediaRecorder;
|
||||||
let activeStream;
|
let activeStream;
|
||||||
|
let audioContext;
|
||||||
|
let audioLevelFrame;
|
||||||
let recordingChunks = [];
|
let recordingChunks = [];
|
||||||
let recordingStartedAt = 0;
|
let recordingStartedAt = 0;
|
||||||
let recordingTimer;
|
let recordingTimer;
|
||||||
@@ -64,12 +66,62 @@ function preferredMimeType() {
|
|||||||
.find((type) => window.MediaRecorder?.isTypeSupported(type));
|
.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() {
|
async function startRecording() {
|
||||||
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
|
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
|
||||||
setMessage('This browser cannot record audio. Open the app in current iOS Safari.', true);
|
setMessage('This browser cannot record audio. Open the app in current iOS Safari.', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
prepareAudioLevelMeter();
|
||||||
activeStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
activeStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
const mimeType = preferredMimeType();
|
const mimeType = preferredMimeType();
|
||||||
mediaRecorder = new MediaRecorder(activeStream, mimeType ? { mimeType } : undefined);
|
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.ondataavailable = (event) => { if (event.data.size) recordingChunks.push(event.data); };
|
||||||
mediaRecorder.onstop = saveRecording;
|
mediaRecorder.onstop = saveRecording;
|
||||||
mediaRecorder.start(1000);
|
mediaRecorder.start(1000);
|
||||||
|
startAudioLevelMeter(activeStream);
|
||||||
recordingStartedAt = Date.now();
|
recordingStartedAt = Date.now();
|
||||||
elements['record-button'].classList.add('recording');
|
elements['record-button'].classList.add('recording');
|
||||||
elements['record-button'].setAttribute('aria-label', 'Stop recording');
|
elements['record-button'].setAttribute('aria-label', 'Stop recording');
|
||||||
@@ -90,12 +143,16 @@ async function startRecording() {
|
|||||||
if (elapsed >= MAX_RECORDING_MS) stopRecording();
|
if (elapsed >= MAX_RECORDING_MS) stopRecording();
|
||||||
}, 250);
|
}, 250);
|
||||||
} catch (error) {
|
} 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);
|
setMessage(error.name === 'NotAllowedError' ? 'Microphone access was denied.' : 'Could not start microphone recording.', true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopRecording() {
|
function stopRecording() {
|
||||||
if (mediaRecorder?.state === 'recording') mediaRecorder.stop();
|
if (mediaRecorder?.state === 'recording') mediaRecorder.stop();
|
||||||
|
stopAudioLevelMeter();
|
||||||
activeStream?.getTracks().forEach((track) => track.stop());
|
activeStream?.getTracks().forEach((track) => track.stop());
|
||||||
activeStream = undefined;
|
activeStream = undefined;
|
||||||
clearInterval(recordingTimer);
|
clearInterval(recordingTimer);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
<circle cx="32" cy="32" r="32" fill="#355a43" />
|
<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>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 229 B After Width: | Height: | Size: 230 B |
@@ -26,6 +26,9 @@
|
|||||||
<div class="record-copy">
|
<div class="record-copy">
|
||||||
<strong id="record-title">Ready</strong>
|
<strong id="record-title">Ready</strong>
|
||||||
<span id="record-help">Tap to record a voice note</span>
|
<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>
|
</div>
|
||||||
<time id="record-time">00:00</time>
|
<time id="record-time">00:00</time>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ h1 em { color: var(--moss); font-weight: 500; }
|
|||||||
.record-copy { display: grid; gap: 4px; flex: 1; }
|
.record-copy { display: grid; gap: 4px; flex: 1; }
|
||||||
.record-copy strong { font-size: 14px; font-weight: 600; }
|
.record-copy strong { font-size: 14px; font-weight: 600; }
|
||||||
.record-copy span { color: var(--muted); font-size: 12px; }
|
.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-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 { 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; }
|
.record-meter span { display: block; height: 100%; width: 0; background: var(--coral); transition: width .25s linear; }
|
||||||
|
|||||||
Reference in New Issue
Block a user