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_scope": "Osciloskop",
|
||||
"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_scope": "Oszilloskop",
|
||||
"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_scope": "Oscilloscope",
|
||||
"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_scope": "Osciloscopio",
|
||||
"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_scope": "Oscilloscope",
|
||||
"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_scope": "オシロスコープ",
|
||||
"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_scope": "Осцилограф",
|
||||
"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_scope": "示波器",
|
||||
"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',
|
||||
icon: 'ti-microphone',
|
||||
categories: ['inputs', 'audio'],
|
||||
disabled: true
|
||||
categories: ['inputs', 'audio']
|
||||
},
|
||||
{
|
||||
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