feat: add mic test page
This commit is contained in:
parent
26be01e058
commit
26cc8a8587
10 changed files with 911 additions and 10 deletions
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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": "モノラル"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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": "Моно"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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": "单声道"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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>
|
Loading…
Add table
Reference in a new issue