feat: add mic test page

This commit is contained in:
Tomáš Mládek 2025-09-27 12:55:09 +02:00
parent 26be01e058
commit 26cc8a8587
10 changed files with 911 additions and 10 deletions

View file

@ -102,5 +102,40 @@
"signalGen_stop": "Stop", "signalGen_stop": "Stop",
"signalGen_scope": "Osciloskop", "signalGen_scope": "Osciloskop",
"signalGen_spectrum": "Spektrum", "signalGen_spectrum": "Spektrum",
"signalGen_loop": "Smyčka" "signalGen_loop": "Smyčka",
"mic_title": "Test mikrofonu",
"mic_startMicrophone": "Spustit mikrofon",
"mic_stop": "Zastavit",
"mic_monitoringOn": "Monitorování: ZAP",
"mic_monitoringOff": "Monitorování: VYP",
"mic_gain": "Zesílení",
"mic_monitorDelay": "Zpoždění monitoru",
"mic_sampleRate": "Vzorkovací frekvence",
"mic_inputDevice": "Vstupní zařízení",
"mic_volume": "Hlasitost",
"mic_recording": "Nahrávání",
"mic_startRecording": "Spustit nahrávání",
"mic_stopRecording": "Zastavit nahrávání",
"mic_downloadRecording": "Stáhnout nahrávku",
"mic_device": "Zařízení",
"mic_noMicFound": "Nenalezen žádný mikrofon",
"mic_refresh": "Obnovit",
"mic_clipping": "Ořezávání",
"mic_constraints": "Omezení",
"mic_echoCancellation": "Potlačení ozvěny",
"mic_noiseSuppression": "Potlačení šumu",
"mic_agc": "Automatické řízení zisku",
"mic_applyConstraints": "Použít",
"mic_channels": "Kanály",
"mic_stereo": "Stereo",
"mic_requested": "Požadováno",
"mic_obtained": "Získáno",
"mic_peakNow": "Špička",
"mic_peakHold": "Držení špičky",
"mic_resetPeaks": "Resetovat špičky",
"mic_advanced": "Pokročilé",
"mic_default": "Výchozí",
"mic_on": "Zapnuto",
"mic_off": "Vypnuto",
"mic_mono": "Mono"
} }

View file

@ -102,5 +102,40 @@
"signalGen_stop": "Stopp", "signalGen_stop": "Stopp",
"signalGen_scope": "Oszilloskop", "signalGen_scope": "Oszilloskop",
"signalGen_spectrum": "Spektrum", "signalGen_spectrum": "Spektrum",
"signalGen_loop": "Schleife" "signalGen_loop": "Schleife",
"mic_title": "Mikrofontest",
"mic_startMicrophone": "Mikrofon starten",
"mic_stop": "Stopp",
"mic_monitoringOn": "Monitoring: EIN",
"mic_monitoringOff": "Monitoring: AUS",
"mic_gain": "Verstärkung",
"mic_monitorDelay": "Monitor-Verzögerung",
"mic_sampleRate": "Abtastrate",
"mic_inputDevice": "Eingabegerät",
"mic_volume": "Lautstärke",
"mic_recording": "Aufnahme",
"mic_startRecording": "Aufnahme starten",
"mic_stopRecording": "Aufnahme stoppen",
"mic_downloadRecording": "Aufnahme herunterladen",
"mic_device": "Gerät",
"mic_noMicFound": "Kein Mikrofon gefunden",
"mic_refresh": "Aktualisieren",
"mic_clipping": "Übersteuerung",
"mic_constraints": "Einschränkungen",
"mic_echoCancellation": "Echounterdrückung",
"mic_noiseSuppression": "Rauschunterdrückung",
"mic_agc": "Automatische Verstärkungsregelung",
"mic_applyConstraints": "Anwenden",
"mic_channels": "Kanäle",
"mic_stereo": "Stereo",
"mic_requested": "Angefordert",
"mic_obtained": "Erhalten",
"mic_peakNow": "Spitze",
"mic_peakHold": "Spitze halten",
"mic_resetPeaks": "Spitzen zurücksetzen",
"mic_advanced": "Erweitert",
"mic_default": "Standard",
"mic_on": "Ein",
"mic_off": "Aus",
"mic_mono": "Mono"
} }

View file

@ -102,5 +102,40 @@
"signalGen_stop": "Stop", "signalGen_stop": "Stop",
"signalGen_scope": "Oscilloscope", "signalGen_scope": "Oscilloscope",
"signalGen_spectrum": "Spectrum", "signalGen_spectrum": "Spectrum",
"signalGen_loop": "Loop" "signalGen_loop": "Loop",
"mic_title": "Microphone test",
"mic_startMicrophone": "Start microphone",
"mic_stop": "Stop",
"mic_monitoringOn": "Monitoring: ON",
"mic_monitoringOff": "Monitoring: OFF",
"mic_gain": "Gain",
"mic_monitorDelay": "Monitor delay",
"mic_sampleRate": "Sample rate",
"mic_inputDevice": "Input",
"mic_volume": "Volume",
"mic_recording": "Recording",
"mic_startRecording": "Start recording",
"mic_stopRecording": "Stop recording",
"mic_downloadRecording": "Download",
"mic_device": "Device",
"mic_noMicFound": "No microphone found",
"mic_refresh": "Refresh",
"mic_clipping": "Clipping",
"mic_constraints": "Constraints",
"mic_echoCancellation": "Echo cancellation",
"mic_noiseSuppression": "Noise suppression",
"mic_agc": "Auto gain control",
"mic_applyConstraints": "Apply",
"mic_channels": "Channels",
"mic_stereo": "Stereo",
"mic_requested": "Requested",
"mic_obtained": "Obtained",
"mic_peakNow": "Peak",
"mic_peakHold": "Peak hold",
"mic_resetPeaks": "Reset peaks",
"mic_advanced": "Advanced",
"mic_default": "Default",
"mic_on": "On",
"mic_off": "Off",
"mic_mono": "Mono"
} }

View file

@ -102,5 +102,40 @@
"signalGen_stop": "Detener", "signalGen_stop": "Detener",
"signalGen_scope": "Osciloscopio", "signalGen_scope": "Osciloscopio",
"signalGen_spectrum": "Espectro", "signalGen_spectrum": "Espectro",
"signalGen_loop": "Bucle" "signalGen_loop": "Bucle",
"mic_title": "Prueba de micrófono",
"mic_startMicrophone": "Iniciar micrófono",
"mic_stop": "Detener",
"mic_monitoringOn": "Monitorización: ON",
"mic_monitoringOff": "Monitorización: OFF",
"mic_gain": "Ganancia",
"mic_monitorDelay": "Retraso del monitor",
"mic_sampleRate": "Tasa de muestreo",
"mic_inputDevice": "Dispositivo de entrada",
"mic_volume": "Volumen",
"mic_recording": "Grabación",
"mic_startRecording": "Iniciar grabación",
"mic_stopRecording": "Detener grabación",
"mic_downloadRecording": "Descargar grabación",
"mic_device": "Dispositivo",
"mic_noMicFound": "No se encontró ningún micrófono",
"mic_refresh": "Actualizar",
"mic_clipping": "Recorte",
"mic_constraints": "Restricciones",
"mic_echoCancellation": "Cancelación de eco",
"mic_noiseSuppression": "Supresión de ruido",
"mic_agc": "Control automático de ganancia",
"mic_applyConstraints": "Aplicar",
"mic_channels": "Canales",
"mic_stereo": "Estéreo",
"mic_requested": "Solicitado",
"mic_obtained": "Obtenido",
"mic_peakNow": "Pico",
"mic_peakHold": "Mantener pico",
"mic_resetPeaks": "Restablecer picos",
"mic_advanced": "Avanzado",
"mic_default": "Predeterminado",
"mic_on": "Encendido",
"mic_off": "Apagado",
"mic_mono": "Mono"
} }

View file

@ -102,5 +102,40 @@
"signalGen_stop": "Arrêter", "signalGen_stop": "Arrêter",
"signalGen_scope": "Oscilloscope", "signalGen_scope": "Oscilloscope",
"signalGen_spectrum": "Spectre", "signalGen_spectrum": "Spectre",
"signalGen_loop": "Boucle" "signalGen_loop": "Boucle",
"mic_title": "Test du microphone",
"mic_startMicrophone": "Démarrer le microphone",
"mic_stop": "Arrêter",
"mic_monitoringOn": "Monitoring : ON",
"mic_monitoringOff": "Monitoring : OFF",
"mic_gain": "Gain",
"mic_monitorDelay": "Délai du moniteur",
"mic_sampleRate": "Taux d'échantillonnage",
"mic_inputDevice": "Périphérique d'entrée",
"mic_volume": "Volume",
"mic_recording": "Enregistrement",
"mic_startRecording": "Démarrer l'enregistrement",
"mic_stopRecording": "Arrêter l'enregistrement",
"mic_downloadRecording": "Télécharger l'enregistrement",
"mic_device": "Appareil",
"mic_noMicFound": "Aucun microphone trouvé",
"mic_refresh": "Actualiser",
"mic_clipping": "Écrêtage",
"mic_constraints": "Contraintes",
"mic_echoCancellation": "Annulation de l'écho",
"mic_noiseSuppression": "Suppression du bruit",
"mic_agc": "Contrôle automatique du gain",
"mic_applyConstraints": "Appliquer",
"mic_channels": "Canaux",
"mic_stereo": "Stéréo",
"mic_requested": "Demandé",
"mic_obtained": "Obtenu",
"mic_peakNow": "Pic",
"mic_peakHold": "Maintien du pic",
"mic_resetPeaks": "Réinitialiser les pics",
"mic_advanced": "Avancé",
"mic_default": "Défaut",
"mic_on": "Activé",
"mic_off": "Désactivé",
"mic_mono": "Mono"
} }

View file

@ -102,5 +102,40 @@
"signalGen_stop": "停止", "signalGen_stop": "停止",
"signalGen_scope": "オシロスコープ", "signalGen_scope": "オシロスコープ",
"signalGen_spectrum": "スペクトラム", "signalGen_spectrum": "スペクトラム",
"signalGen_loop": "ループ" "signalGen_loop": "ループ",
"mic_title": "マイクテスト",
"mic_startMicrophone": "マイクを開始",
"mic_stop": "停止",
"mic_monitoringOn": "モニタリング:オン",
"mic_monitoringOff": "モニタリング:オフ",
"mic_gain": "ゲイン",
"mic_monitorDelay": "モニター遅延",
"mic_sampleRate": "サンプルレート",
"mic_inputDevice": "入力デバイス",
"mic_volume": "音量",
"mic_recording": "録音",
"mic_startRecording": "録音を開始",
"mic_stopRecording": "録音を停止",
"mic_downloadRecording": "録音をダウンロード",
"mic_device": "デバイス",
"mic_noMicFound": "マイクが見つかりません",
"mic_refresh": "更新",
"mic_clipping": "クリッピング",
"mic_constraints": "制約",
"mic_echoCancellation": "エコーキャンセル",
"mic_noiseSuppression": "ノイズ抑制",
"mic_agc": "自動ゲインコントロール",
"mic_applyConstraints": "適用",
"mic_channels": "チャンネル",
"mic_stereo": "ステレオ",
"mic_requested": "要求",
"mic_obtained": "取得",
"mic_peakNow": "ピーク",
"mic_peakHold": "ピークホールド",
"mic_resetPeaks": "ピークをリセット",
"mic_advanced": "詳細",
"mic_default": "デフォルト",
"mic_on": "オン",
"mic_off": "オフ",
"mic_mono": "モノラル"
} }

View file

@ -102,5 +102,40 @@
"signalGen_stop": "Стоп", "signalGen_stop": "Стоп",
"signalGen_scope": "Осцилограф", "signalGen_scope": "Осцилограф",
"signalGen_spectrum": "Спектр", "signalGen_spectrum": "Спектр",
"signalGen_loop": "Цикл" "signalGen_loop": "Цикл",
"mic_title": "Тест мікрофона",
"mic_startMicrophone": "Почати мікрофон",
"mic_stop": "Зупинити",
"mic_monitoringOn": "Моніторинг: УВІМК",
"mic_monitoringOff": "Моніторинг: ВИМК",
"mic_gain": "Посилення",
"mic_monitorDelay": "Затримка монітора",
"mic_sampleRate": "Частота дискретизації",
"mic_inputDevice": "Пристрій введення",
"mic_volume": "Гучність",
"mic_recording": "Запис",
"mic_startRecording": "Почати запис",
"mic_stopRecording": "Зупинити запис",
"mic_downloadRecording": "Завантажити запис",
"mic_device": "Пристрій",
"mic_noMicFound": "Мікрофон не знайдено",
"mic_refresh": "Оновити",
"mic_clipping": "Відсікання",
"mic_constraints": "Обмеження",
"mic_echoCancellation": "Скасування відлуння",
"mic_noiseSuppression": "Придушення шуму",
"mic_agc": "Автоматичне регулювання посилення",
"mic_applyConstraints": "Застосувати",
"mic_channels": "Канали",
"mic_stereo": "Стерео",
"mic_requested": "Запитано",
"mic_obtained": "Отримано",
"mic_peakNow": "Пік",
"mic_peakHold": "Утримання піку",
"mic_resetPeaks": "Скинути піки",
"mic_advanced": "Розширений",
"mic_default": "За замовчуванням",
"mic_on": "Увімкнено",
"mic_off": "Вимкнено",
"mic_mono": "Моно"
} }

View file

@ -102,5 +102,40 @@
"signalGen_stop": "停止", "signalGen_stop": "停止",
"signalGen_scope": "示波器", "signalGen_scope": "示波器",
"signalGen_spectrum": "频谱图", "signalGen_spectrum": "频谱图",
"signalGen_loop": "循环" "signalGen_loop": "循环",
"mic_title": "麦克风测试",
"mic_startMicrophone": "开始麦克风",
"mic_stop": "停止",
"mic_monitoringOn": "监听:开",
"mic_monitoringOff": "监听:关",
"mic_gain": "增益",
"mic_monitorDelay": "监听延迟",
"mic_sampleRate": "采样率",
"mic_inputDevice": "输入设备",
"mic_volume": "音量",
"mic_recording": "录音",
"mic_startRecording": "开始录音",
"mic_stopRecording": "停止录音",
"mic_downloadRecording": "下载录音",
"mic_device": "设备",
"mic_noMicFound": "未找到麦克风",
"mic_refresh": "刷新",
"mic_clipping": "削波",
"mic_constraints": "约束",
"mic_echoCancellation": "回声消除",
"mic_noiseSuppression": "噪声抑制",
"mic_agc": "自动增益控制",
"mic_applyConstraints": "应用",
"mic_channels": "声道",
"mic_stereo": "立体声",
"mic_requested": "已请求",
"mic_obtained": "已获取",
"mic_peakNow": "峰值",
"mic_peakHold": "峰值保持",
"mic_resetPeaks": "重置峰值",
"mic_advanced": "高级",
"mic_default": "默认",
"mic_on": "开",
"mic_off": "关",
"mic_mono": "单声道"
} }

View file

@ -97,8 +97,7 @@
{ {
id: 'microphone', id: 'microphone',
icon: 'ti-microphone', icon: 'ti-microphone',
categories: ['inputs', 'audio'], categories: ['inputs', 'audio']
disabled: true
}, },
{ {
id: 'sensors', id: 'sensors',

View file

@ -0,0 +1,622 @@
<script lang="ts">
import { onMount } from 'svelte';
import Spectrum from '$lib/audio/Spectrum.svelte';
import Oscilloscope from '$lib/audio/Oscilloscope.svelte';
import { m } from '$lib/paraglide/messages';
// Mic / audio graph state
let audioCtx: AudioContext | null = null;
let mediaStream: MediaStream | null = null;
let source: MediaStreamAudioSourceNode | null = null;
let analyserTime: AnalyserNode | null = $state(null);
let analyserFreq: AnalyserNode | null = $state(null);
let monitorGain: GainNode | null = null;
let delayNode: DelayNode | null = null;
// UI state (Svelte 5 runes-style)
let micActive = $state(false);
let monitoring = $state(false);
let gain = $state(1.0);
let delayMs = $state(0);
let sampleRate = $state<number | null>(null);
let deviceLabel = $state<string>('');
// Volume meter (RMS 0..1)
let volume = $state(0);
let rafId: number | null = null;
// Peak metrics
let peak = $state(0); // instantaneous peak 0..1
let peakHold = $state(0); // hold 0..1
let peakHoldDecayPerSec = 0.5; // how fast the hold marker decays
// Recording
let recorder: MediaRecorder | null = null;
let isRecording = $state(false);
let recordedChunks: BlobPart[] = [];
let recordingUrl = $state<string | null>(null);
// Devices
let devices: MediaDeviceInfo[] = $state([]);
let selectedDeviceId: string | null = $state(null);
// Clipping indicator
let clipping = $state(false);
let lastClipTs = 0;
// Track settings and constraints
let channels = $state<number | null>(null);
let obtainedEchoCancellation = $state<boolean | null>(null);
let obtainedNoiseSuppression = $state<boolean | null>(null);
let obtainedAGC = $state<boolean | null>(null);
// Requested constraints (best-effort, tri-state: null=default, true, false)
let reqEchoCancellation = $state<boolean | null>(null);
let reqNoiseSuppression = $state<boolean | null>(null);
let reqAGC = $state<boolean | null>(null);
async function startMic() {
if (micActive) return;
try {
const audioConstraints: MediaTrackConstraints = selectedDeviceId
? { deviceId: { exact: selectedDeviceId } }
: {};
if (reqEchoCancellation !== null) audioConstraints.echoCancellation = reqEchoCancellation;
if (reqNoiseSuppression !== null) audioConstraints.noiseSuppression = reqNoiseSuppression;
if (reqAGC !== null) audioConstraints.autoGainControl = reqAGC;
const constraints: MediaStreamConstraints = {
audio: Object.keys(audioConstraints).length ? audioConstraints : true,
video: false
};
mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
deviceLabel = mediaStream.getAudioTracks()[0]?.label ?? '';
audioCtx = new AudioContext();
sampleRate = audioCtx.sampleRate;
source = audioCtx.createMediaStreamSource(mediaStream);
analyserTime = audioCtx.createAnalyser();
analyserFreq = audioCtx.createAnalyser();
monitorGain = audioCtx.createGain();
delayNode = audioCtx.createDelay(2.0); // up to 2 seconds
// Default params
analyserTime.fftSize = 2048;
analyserTime.smoothingTimeConstant = 0.2;
analyserFreq.fftSize = 2048;
analyserFreq.smoothingTimeConstant = 0.8;
monitorGain.gain.value = gain;
delayNode.delayTime.value = delayMs / 1000;
// Fan-out: source -> (analysers)
source.connect(analyserTime);
source.connect(analyserFreq);
// Monitoring path (initially disconnected; toggleMonitoring() handles it)
updateMonitoringGraph();
// Start volume meter loop
startVolumeLoop();
micActive = true;
// Update device list after permission to get labels
refreshDevices();
// Read obtained settings
const track = mediaStream.getAudioTracks()[0];
const s = track.getSettings?.() as MediaTrackSettings | undefined;
channels = s?.channelCount ?? null;
obtainedEchoCancellation = (s?.echoCancellation as boolean | undefined) ?? null;
obtainedNoiseSuppression = (s?.noiseSuppression as boolean | undefined) ?? null;
obtainedAGC = (s?.autoGainControl as boolean | undefined) ?? null;
} catch (err) {
console.error('Failed to start microphone', err);
stopMic();
}
}
async function refreshDevices() {
try {
const list = await navigator.mediaDevices.enumerateDevices();
devices = list.filter((d) => d.kind === 'audioinput');
if (devices.length > 0 && !selectedDeviceId) {
selectedDeviceId = devices[0].deviceId;
}
} catch (e) {
console.error('enumerateDevices failed', e);
}
}
function onDeviceChange(e: Event) {
selectedDeviceId = (e.target as HTMLSelectElement).value || null;
if (micActive) {
// Restart mic with the new device
const wasMonitoring = monitoring;
stopMic();
monitoring = wasMonitoring; // preserve desired state; will reconnect on start
startMic();
}
}
function updateMonitoringGraph() {
if (!audioCtx || !source || !monitorGain || !delayNode) return;
try {
// Always disconnect monitoring path before reconnecting to avoid duplicate connections
monitorGain.disconnect();
delayNode.disconnect();
} catch {}
if (monitoring) {
// source -> monitorGain -> delayNode -> destination
monitorGain.gain.value = gain;
delayNode.delayTime.value = delayMs / 1000;
source.connect(monitorGain);
monitorGain.connect(delayNode);
delayNode.connect(audioCtx.destination);
}
}
function startVolumeLoop() {
cancelVolumeLoop();
const buf = new Uint8Array(analyserTime?.fftSize ?? 2048);
const loop = () => {
if (analyserTime) {
analyserTime.getByteTimeDomainData(buf);
// Compute RMS
let sum = 0;
let pk = 0;
for (let i = 0; i < buf.length; i++) {
const v = (buf[i] - 128) / 128; // -1..1
sum += v * v;
const a = Math.abs(v);
if (a > pk) pk = a;
}
const rms = Math.sqrt(sum / buf.length);
volume = rms; // 0..1
peak = pk;
// Update hold
const dt = 1 / 60; // approx
peakHold = Math.max(pk, Math.max(0, peakHold - peakHoldDecayPerSec * dt));
// Clipping detection (very near full-scale)
const now = performance.now();
if (pk >= 0.985) {
clipping = true;
lastClipTs = now;
} else if (clipping && now - lastClipTs > 500) {
// Auto clear after 500ms without clipping
clipping = false;
}
}
rafId = requestAnimationFrame(loop);
};
rafId = requestAnimationFrame(loop);
}
function cancelVolumeLoop() {
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
}
function stopMic() {
cancelVolumeLoop();
micActive = false;
monitoring = false;
try {
recorder?.state !== 'inactive' && recorder?.stop();
} catch {}
recorder = null;
isRecording = false;
try {
source?.disconnect();
} catch {}
try {
monitorGain?.disconnect();
} catch {}
try {
delayNode?.disconnect();
} catch {}
analyserTime = null;
analyserFreq = null;
monitorGain = null;
delayNode = null;
source = null;
if (audioCtx) {
audioCtx.close().catch(() => {});
audioCtx = null;
}
if (mediaStream) {
for (const t of mediaStream.getTracks()) t.stop();
mediaStream = null;
}
peak = 0;
peakHold = 0;
channels = null;
obtainedEchoCancellation = obtainedNoiseSuppression = obtainedAGC = null;
}
function toggleMonitoring() {
monitoring = !monitoring;
updateMonitoringGraph();
}
function onGainChange(e: Event) {
gain = Number((e.target as HTMLInputElement).value);
if (monitorGain) monitorGain.gain.value = gain;
}
function onDelayChange(e: Event) {
delayMs = Number((e.target as HTMLInputElement).value);
if (delayNode) delayNode.delayTime.value = delayMs / 1000;
}
// Recording via MediaRecorder on raw MediaStream
function startRecording() {
if (!mediaStream || isRecording) return;
try {
// If there's an old blob URL, release it
if (recordingUrl) {
URL.revokeObjectURL(recordingUrl);
recordingUrl = null;
}
recordedChunks = [];
recorder = new MediaRecorder(mediaStream);
recorder.ondataavailable = (ev) => {
if (ev.data && ev.data.size > 0) recordedChunks.push(ev.data);
};
recorder.onstop = () => {
const blob = new Blob(recordedChunks, { type: recorder?.mimeType || 'audio/webm' });
recordingUrl = URL.createObjectURL(blob);
};
recorder.start();
isRecording = true;
} catch (e) {
console.error('Unable to start recording', e);
}
}
function stopRecording() {
if (!recorder || recorder.state === 'inactive') return;
recorder.stop();
isRecording = false;
}
onMount(() => {
// Try to prefetch devices (may require prior permission for labels)
refreshDevices();
return () => {
stopMic();
if (recordingUrl) URL.revokeObjectURL(recordingUrl);
};
});
</script>
<article>
<h3>{m.mic_title()}</h3>
<section class="controls">
<div class="row">
<button class="button" onclick={startMic} disabled={micActive}
>{m.mic_startMicrophone()}</button
>
<button class="button stop" onclick={stopMic} disabled={!micActive}>{m.mic_stop()}</button>
<button
class="button"
onclick={toggleMonitoring}
disabled={!micActive}
aria-pressed={monitoring}
>
{monitoring ? m.mic_monitoringOn() : m.mic_monitoringOff()}
</button>
</div>
<div class="row inputs">
<label>
{m.mic_device()}
<select onchange={onDeviceChange} disabled={devices.length === 0}>
{#if devices.length === 0}
<option value="">{m.mic_noMicFound()}</option>
{:else}
{#each devices as d}
<option value={d.deviceId} selected={d.deviceId === selectedDeviceId}
>{d.label || m.mic_device()}</option
>
{/each}
{/if}
</select>
</label>
<button class="button" onclick={refreshDevices}>{m.mic_refresh()}</button>
</div>
<div class="row inputs">
<label>
{m.mic_gain()}
<input
type="range"
min="0"
max="2"
step="0.01"
value={gain}
oninput={onGainChange}
disabled={!micActive}
/>
<span class="value">{gain.toFixed(2)}x</span>
</label>
<label>
{m.mic_monitorDelay()}
<input
type="range"
min="0"
max="1000"
step="1"
value={delayMs}
oninput={onDelayChange}
disabled={!micActive}
/>
<span class="value">{delayMs} ms</span>
</label>
</div>
<details>
<summary>{m.mic_advanced()}</summary>
<div class="row inputs">
<div class="label">{m.mic_constraints()}</div>
<label>
{m.mic_echoCancellation()}
<select
onchange={(e) => {
const v = (e.target as HTMLSelectElement).value;
reqEchoCancellation = v === 'default' ? null : v === 'on';
}}
>
<option value="default" selected>{m.mic_default()}</option>
<option value="on">{m.mic_on()}</option>
<option value="off">{m.mic_off()}</option>
</select>
</label>
<label>
{m.mic_noiseSuppression()}
<select
onchange={(e) => {
const v = (e.target as HTMLSelectElement).value;
reqNoiseSuppression = v === 'default' ? null : v === 'on';
}}
>
<option value="default" selected>{m.mic_default()}</option>
<option value="on">{m.mic_on()}</option>
<option value="off">{m.mic_off()}</option>
</select>
</label>
<label>
{m.mic_agc()}
<select
onchange={(e) => {
const v = (e.target as HTMLSelectElement).value;
reqAGC = v === 'default' ? null : v === 'on';
}}
>
<option value="default" selected>{m.mic_default()}</option>
<option value="on">{m.mic_on()}</option>
<option value="off">{m.mic_off()}</option>
</select>
</label>
<button
class="button"
onclick={() => {
if (micActive) {
const wasMonitoring = monitoring;
stopMic();
monitoring = wasMonitoring;
}
startMic();
}}>{m.mic_applyConstraints()}</button
>
</div>
<div class="obtained">
<div>
<strong>{m.mic_requested()}:</strong> EC={reqEchoCancellation === null
? ''
: reqEchoCancellation
? 'on'
: 'off'}, NS={reqNoiseSuppression === null
? ''
: reqNoiseSuppression
? 'on'
: 'off'}, AGC={reqAGC === null ? '' : reqAGC ? 'on' : 'off'}
</div>
<div>
<strong>{m.mic_obtained()}:</strong> EC={(obtainedEchoCancellation ??
'') as unknown as string}, NS={(obtainedNoiseSuppression ?? '') as unknown as string},
AGC={(obtainedAGC ?? '') as unknown as string}
</div>
</div>
</details>
</section>
<section class="info">
<div><strong>{m.mic_sampleRate()}:</strong> {sampleRate ?? ''} Hz</div>
{#if deviceLabel}
<div><strong>{m.mic_inputDevice()}:</strong> {deviceLabel}</div>
{/if}
<div>
<strong>{m.mic_channels()}:</strong>
{#if channels === null}
{:else if channels === 1}
1 ({m.mic_mono()})
{:else if channels === 2}
2 ({m.mic_stereo()})
{:else}
{channels}
{/if}
</div>
</section>
<section class="meter">
<div class="label">{m.mic_volume()}</div>
<div class="bar">
<div class="fill" style={`transform: scaleX(${Math.min(1, volume).toFixed(3)})`}></div>
{#if peakHold > 0}
<div class="peak-hold" style={`left: ${Math.min(100, peakHold * 100)}%`}></div>
{/if}
</div>
<div class="volval">
{m.mic_peakNow()}: {(20 * Math.log10(Math.max(1e-5, peak))).toFixed(1)} dBFS ·
{m.mic_peakHold()}: {(20 * Math.log10(Math.max(1e-5, peakHold))).toFixed(1)} dBFS · RMS: {(
20 * Math.log10(Math.max(1e-5, volume))
).toFixed(1)} dBFS
<button class="button small" onclick={() => (peakHold = 0)}>{m.mic_resetPeaks()}</button>
</div>
{#if clipping}
<div class="clip">{m.mic_clipping()}</div>
{/if}
</section>
<section class="graphs">
{#if analyserTime && analyserFreq}
<Oscilloscope analyser={analyserTime} title={m.signalGen_scope()} />
<Spectrum analyser={analyserFreq} title={m.signalGen_spectrum()} />
{/if}
</section>
<section class="recording">
<h4>{m.mic_recording()}</h4>
<div class="row">
<button class="button" onclick={startRecording} disabled={!micActive || isRecording}
>{m.mic_startRecording()}</button
>
<button class="button stop" onclick={stopRecording} disabled={!isRecording}
>{m.mic_stopRecording()}</button
>
</div>
{#if recordingUrl}
<audio src={recordingUrl} controls></audio>
<a class="button" href={recordingUrl} download="mic-test.webm">{m.mic_downloadRecording()}</a>
{/if}
</section>
</article>
<style>
h3 {
margin-top: 0;
}
article {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.controls .row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.controls .inputs {
gap: 1.25rem;
}
.controls label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
.controls input[type='range'] {
width: min(40vw, 280px);
}
.value {
opacity: 0.8;
min-width: 4ch;
text-align: right;
display: inline-block;
}
.info {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
}
.meter {
display: grid;
gap: 0.25rem;
}
.bar {
position: relative;
height: 14px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 999px;
overflow: hidden;
}
.bar .fill {
position: absolute;
inset: 0;
background: linear-gradient(90deg, #5cb85c, #f0ad4e 70%, #d9534f);
transform-origin: left center;
}
/* Peak hold marker */
.bar .peak-hold {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: #fff;
opacity: 0.9;
transform: translateX(-1px);
}
.volval {
opacity: 0.8;
font-variant-numeric: tabular-nums;
}
.button.small {
padding: 0.1rem 0.4rem;
font-size: 0.85em;
margin-left: 0.5rem;
}
.graphs {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.recording .row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
}
.obtained {
display: grid;
gap: 0.25rem;
}
.button.stop:not(:disabled) {
background: darkred;
}
@media (min-width: 900px) {
.graphs {
grid-template-columns: 1fr 1fr;
}
}
</style>