diff --git a/README.md b/README.md index 5a586f2..9a99d97 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ AeroSpec is a web-based, real-time, high-precision audio analysis tool that requ - **Test Tone Generator**: Generate Sine, Square, Sawtooth, or Triangle waves. Real-time control of frequency, panning, and gain. - **Level Meter**: Independent Left/Right peak level monitoring. +- **Auto Feedback Detector**: Live feedback-loop risk scoring with high-risk warning. +- **Noise Floor Profiler**: Tracks baseline system/room noise and trend over time. +- **Calibration Wizard**: 3-step interface/mic gain staging guidance. +- **Impulse Response Viewer**: One-click transient capture with quick RT60 estimate. - **Performance Benchmark**: Measure your device's rendering performance (FPS and average processing time in ms) to ensure real-time accuracy. ### ⚙️ Advanced Settings diff --git a/index.html b/index.html index dfd9ee1..f07ea49 100644 --- a/index.html +++ b/index.html @@ -404,7 +404,7 @@

Analyzer Settings

Level Meter font-weight: bold; animation: blink 1s infinite; " - >HOWLING!FEEDBACK RISK HIGH

@@ -428,6 +428,32 @@

+
+ +
+
+
+
+
@@ -492,6 +518,99 @@

+
+

Noise Floor Profiler

+
+ +
+

+ Status: Stopped +

+

+ Current Floor: -- +

+

+ Baseline: -- +

+

+ Trend: -- +

+
+ +
+

Calibration Wizard (Gain Staging)

+
+ + + +
+

+ Status: Idle +

+

+ Press Start to begin 3-step calibration. +

+

+ -- +

+
+ +
+

Impulse Response Viewer

+
+ +
+
+ +
+

+ Capture: Waiting +

+

+ Estimated RT60: -- +

+
+

a - b); + if (arr.length === 0) return null; + const rawPos = (arr.length - 1) * p; + const pos = Math.max(0, Math.min(arr.length - 1, rawPos)); + const lo = Math.floor(pos); + const hi = Math.ceil(pos); + if (lo === hi) return arr[lo]; + const t = pos - lo; + return arr[lo] * (1 - t) + arr[hi] * t; + } + + if (dom.btnNoiseProfile) { + dom.btnNoiseProfile.addEventListener("click", () => { + state.noiseProfile.active = !state.noiseProfile.active; + if (state.noiseProfile.active) { + state.noiseProfile.startTime = Date.now(); + state.noiseProfile.samples = []; + state.noiseProfile.baselineDb = null; + state.noiseProfile.trendDbPerMin = 0; + dom.btnNoiseProfile.textContent = "Stop Profiling"; + if (dom.noiseProfileStatus) dom.noiseProfileStatus.textContent = "Running"; + } else { + dom.btnNoiseProfile.textContent = "Start Profiling"; + if (dom.noiseProfileStatus) dom.noiseProfileStatus.textContent = "Stopped"; + } + }); + } + + if (dom.btnCalibrationStart) { + dom.btnCalibrationStart.addEventListener("click", () => { + state.calibration.active = true; + state.calibration.step = 1; + state.calibration.samples = []; + state.calibration.noiseFloorDb = null; + state.calibration.nominalPeakDb = null; + state.calibration.loudPeakDb = null; + state.calibration.recommendedGainDeltaDb = null; + if (dom.calibrationStatus) dom.calibrationStatus.textContent = "Running step 1"; + if (dom.calibrationInstruction) { + dom.calibrationInstruction.textContent = + "Step 1/3: Stay quiet for room noise capture, then press Next."; + } + if (dom.calibrationResult) dom.calibrationResult.textContent = "--"; + }); + } + + if (dom.btnCalibrationNext) { + dom.btnCalibrationNext.addEventListener("click", () => { + if (!state.calibration.active) return; + if (state.calibration.step === 1) { + state.calibration.noiseFloorDb = getPercentile(state.calibration.samples, 0.5); + state.calibration.step = 2; + state.calibration.samples = []; + if (dom.calibrationInstruction) { + dom.calibrationInstruction.textContent = + "Step 2/3: Speak/play at normal working level, then press Next."; + } + } else if (state.calibration.step === 2) { + state.calibration.nominalPeakDb = getPercentile(state.calibration.samples, 0.9); + if (state.calibration.nominalPeakDb != null) { + state.calibration.recommendedGainDeltaDb = + TARGET_NOMINAL_PEAK_DB - state.calibration.nominalPeakDb; + } + state.calibration.step = 3; + state.calibration.samples = []; + if (dom.calibrationInstruction) { + dom.calibrationInstruction.textContent = + "Step 3/3: Produce the loudest expected level, then press Next."; + } + } else { + state.calibration.loudPeakDb = getPercentile(state.calibration.samples, 0.98); + state.calibration.active = false; + const rec = state.calibration.recommendedGainDeltaDb; + const loud = state.calibration.loudPeakDb; + const safety = + loud != null && loud > LOW_HEADROOM_THRESHOLD_DB + ? "Headroom low; reduce gain." + : "Headroom looks safe."; + if (dom.calibrationInstruction) { + dom.calibrationInstruction.textContent = "Calibration complete."; + } + if (dom.calibrationStatus) dom.calibrationStatus.textContent = "Completed"; + if (dom.calibrationResult) { + const recText = + rec == null ? "--" : `${rec >= 0 ? "+" : ""}${rec.toFixed(1)} dB`; + const noiseText = + state.calibration.noiseFloorDb == null + ? "--" + : `${state.calibration.noiseFloorDb.toFixed(1)} dB`; + const nominalText = + state.calibration.nominalPeakDb == null + ? "--" + : `${state.calibration.nominalPeakDb.toFixed(1)} dB`; + const loudText = loud == null ? "--" : `${loud.toFixed(1)} dB`; + dom.calibrationResult.textContent = `Noise: ${noiseText} | Nominal peak: ${nominalText} | Loud peak: ${loudText} | Gain adjust: ${recText} | ${safety}`; + } + state.calibration.step = 0; + state.calibration.samples = []; + } + }); + } + + if (dom.btnCalibrationReset) { + dom.btnCalibrationReset.addEventListener("click", () => { + state.calibration.active = false; + state.calibration.step = 0; + state.calibration.samples = []; + state.calibration.noiseFloorDb = null; + state.calibration.nominalPeakDb = null; + state.calibration.loudPeakDb = null; + state.calibration.recommendedGainDeltaDb = null; + if (dom.calibrationStatus) dom.calibrationStatus.textContent = "Idle"; + if (dom.calibrationInstruction) { + dom.calibrationInstruction.textContent = "Press Start to begin 3-step calibration."; + } + if (dom.calibrationResult) dom.calibrationResult.textContent = "--"; + }); + } + + if (dom.btnImpulseCapture) { + dom.btnImpulseCapture.addEventListener("click", () => { + state.impulse.captureRequested = true; + if (dom.impulseStatus) dom.impulseStatus.textContent = "Capturing..."; + }); + } + // FSK Modem Handlers if (dom.btnModemTx) { dom.btnModemTx.addEventListener("click", () => { diff --git a/js/audio.js b/js/audio.js index 7d60d5c..5c261ed 100644 --- a/js/audio.js +++ b/js/audio.js @@ -304,6 +304,17 @@ export function createAudioController({ state, dom, resizeCanvases, draw }) { dom.channelsText.textContent = "--"; dom.deviceNameText.textContent = "--"; state.isRunning = false; + state.feedbackRisk = 0; + state.feedbackIsHigh = false; + state.feedbackStableFrames = 0; + state.feedbackLastFreq = 0; + if (state.impulse) { + state.impulse.buffer = null; + state.impulse.captureRequested = false; + state.impulse.capturedAt = null; + state.impulse.rt60Ms = null; + state.impulse.peak = 0; + } if (state.animationId) { cancelAnimationFrame(state.animationId); @@ -355,6 +366,15 @@ export function createAudioController({ state, dom, resizeCanvases, draw }) { dom.peakFill.style.width = "0%"; dom.peakValue.textContent = "-\u221E dB"; dom.peakValue.style.color = "var(--text-muted)"; + + if (dom.feedbackWarning) dom.feedbackWarning.style.display = "none"; + if (dom.feedbackRiskText) dom.feedbackRiskText.textContent = "Low"; + if (dom.feedbackRiskFill) { + dom.feedbackRiskFill.style.width = "0%"; + dom.feedbackRiskFill.style.backgroundColor = "#10b981"; + } + if (dom.impulseStatus) dom.impulseStatus.textContent = "Waiting"; + if (dom.impulseRt60) dom.impulseRt60.textContent = "--"; } state.updateToneGenerator = (s, d) => { diff --git a/js/dom.js b/js/dom.js index 32b86fd..a463a68 100644 --- a/js/dom.js +++ b/js/dom.js @@ -65,7 +65,9 @@ export function getDomRefs() { freqMaxInput: document.getElementById("freq-max"), peakCountInput: document.getElementById("peak-count"), peakCountVal: document.getElementById("peak-count-val"), - howlingWarning: document.getElementById("howling-warning"), + feedbackWarning: document.getElementById("feedback-warning"), + feedbackRiskText: document.getElementById("feedback-risk-text"), + feedbackRiskFill: document.getElementById("feedback-risk-fill"), meteringStandard: document.getElementById("metering-standard"), peakFill: document.getElementById("peak-fill"), @@ -76,6 +78,21 @@ export function getDomRefs() { peakFreqValue: document.getElementById("peak-freq"), correlationValue: document.getElementById("correlation-value"), correlationFill: document.getElementById("correlation-fill"), + btnNoiseProfile: document.getElementById("btn-noise-profile"), + noiseProfileStatus: document.getElementById("noise-profile-status"), + noiseProfileCurrent: document.getElementById("noise-profile-current"), + noiseProfileBaseline: document.getElementById("noise-profile-baseline"), + noiseProfileTrend: document.getElementById("noise-profile-trend"), + btnCalibrationStart: document.getElementById("btn-calibration-start"), + btnCalibrationNext: document.getElementById("btn-calibration-next"), + btnCalibrationReset: document.getElementById("btn-calibration-reset"), + calibrationStatus: document.getElementById("calibration-status"), + calibrationInstruction: document.getElementById("calibration-instruction"), + calibrationResult: document.getElementById("calibration-result"), + btnImpulseCapture: document.getElementById("btn-impulse-capture"), + impulseStatus: document.getElementById("impulse-status"), + impulseRt60: document.getElementById("impulse-rt60"), + canvasImpulse: document.getElementById("canvas-impulse"), canvasSpectrum, ctxSpectrum: canvasSpectrum @@ -109,6 +126,9 @@ export function getDomRefs() { }) || canvasVectorscope.getContext("webgl", { preserveDrawingBuffer: false }) : null, + ctxImpulse: document.getElementById("canvas-impulse") + ? document.getElementById("canvas-impulse").getContext("2d") + : null, canvasVectorscopeOverlay: null, // If needed in future ctxVectorscopeOverlay: null, diff --git a/js/layout.js b/js/layout.js index cb1c1b7..d419d88 100644 --- a/js/layout.js +++ b/js/layout.js @@ -86,4 +86,14 @@ export function resizeCanvases(dom) { dom.canvasVectorscope.style.width = vectorscopeRect.width + "px"; dom.canvasVectorscope.style.height = vectorscopeRect.height + "px"; } + + if (dom.canvasImpulse && dom.ctxImpulse) { + const impulseRect = dom.canvasImpulse.parentElement.getBoundingClientRect(); + dom.canvasImpulse.width = impulseRect.width * dpr; + dom.canvasImpulse.height = impulseRect.height * dpr; + dom.ctxImpulse.setTransform(1, 0, 0, 1, 0, 0); + dom.ctxImpulse.scale(dpr, dpr); + dom.canvasImpulse.style.width = impulseRect.width + "px"; + dom.canvasImpulse.style.height = impulseRect.height + "px"; + } } diff --git a/js/render.js b/js/render.js index 3482a5f..57fc185 100644 --- a/js/render.js +++ b/js/render.js @@ -2,6 +2,7 @@ import { drawSpectrum } from "./render/spectrum.js"; import { drawWaveformAndMeter } from "./render/waveformMeter.js"; import { drawSpectrogram } from "./render/spectrogram.js"; import { drawVectorscope } from "./render/vectorscope.js"; +import { drawImpulseViewer } from "./render/impulse.js"; import { demodulateFrame } from "./modem.js"; function buildFrameData({ state, dom }) { @@ -112,6 +113,7 @@ export function createRenderer({ state, dom }) { drawWaveformAndMeter({ state, dom, frame }); drawSpectrogram({ dom, frame, state }); drawVectorscope({ state, dom }); + drawImpulseViewer({ state, dom, frame }); if (state.modemActive && state.modemAnalyser) { demodulateFrame(state, dom, timestamp); diff --git a/js/render/impulse.js b/js/render/impulse.js new file mode 100644 index 0000000..3a17f16 --- /dev/null +++ b/js/render/impulse.js @@ -0,0 +1,102 @@ +function drawGrid(ctx, w, h) { + ctx.strokeStyle = "rgba(255,255,255,0.08)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, h / 2); + ctx.lineTo(w, h / 2); + ctx.stroke(); +} + +const RT60_THRESHOLD = Math.pow(10, -60 / 20); +const MIN_TRANSIENT_PEAK = 0.06; +const IMPULSE_PRE_SAMPLES = 256; +const IMPULSE_POST_SAMPLES = 2048; + +function estimateRt60Ms(buffer, sampleRate, peakIndex, peakValue) { + if (!buffer || !sampleRate || peakValue <= 0) return null; + const threshold = peakValue * RT60_THRESHOLD; + for (let i = peakIndex; i < buffer.length; i++) { + if (Math.abs(buffer[i]) <= threshold) { + return ((i - peakIndex) / sampleRate) * 1000; + } + } + return null; +} + +export function drawImpulseViewer({ state, dom, frame }) { + const { ctxImpulse, canvasImpulse } = dom; + if (!ctxImpulse || !canvasImpulse) return; + + const w = canvasImpulse.width / (window.devicePixelRatio || 1); + const h = canvasImpulse.height / (window.devicePixelRatio || 1); + if (w <= 0 || h <= 0) return; + + const impulse = state.impulse; + + if (impulse.captureRequested && frame.timeData && state.audioCtx) { + impulse.captureRequested = false; + impulse.capturedAt = Date.now(); + const src = frame.timeData; + let peak = 0; + let peakIndex = 0; + for (let i = 0; i < src.length; i++) { + const v = Math.abs(src[i]); + if (v > peak) { + peak = v; + peakIndex = i; + } + } + + if (peak > MIN_TRANSIENT_PEAK) { + const pre = Math.min(IMPULSE_PRE_SAMPLES, peakIndex); + const post = Math.min(src.length - peakIndex, IMPULSE_POST_SAMPLES); + const start = Math.max(0, peakIndex - pre); + const end = Math.min(src.length, peakIndex + post); + impulse.buffer = src.slice(start, end); + impulse.peak = peak; + impulse.rt60Ms = estimateRt60Ms( + impulse.buffer, + state.audioCtx.sampleRate, + pre, + peak, + ); + } else { + impulse.buffer = null; + impulse.peak = 0; + impulse.rt60Ms = null; + } + } + + ctxImpulse.clearRect(0, 0, w, h); + ctxImpulse.fillStyle = "rgb(10, 15, 20)"; + ctxImpulse.fillRect(0, 0, w, h); + drawGrid(ctxImpulse, w, h); + + const buffer = impulse.buffer || frame.timeData; + if (!buffer || buffer.length < 2) return; + + ctxImpulse.strokeStyle = "#e2e8f0"; + ctxImpulse.lineWidth = 1.2; + ctxImpulse.beginPath(); + for (let i = 0; i < buffer.length; i++) { + const x = (i / (buffer.length - 1)) * w; + const y = h / 2 - buffer[i] * (h * 0.45); + if (i === 0) ctxImpulse.moveTo(x, y); + else ctxImpulse.lineTo(x, y); + } + ctxImpulse.stroke(); + + if (dom.impulseStatus) { + if (impulse.buffer) { + dom.impulseStatus.textContent = `Captured (${(impulse.peak * 100).toFixed(1)}% peak)`; + } else if (impulse.capturedAt) { + dom.impulseStatus.textContent = "No strong transient detected"; + } else { + dom.impulseStatus.textContent = "Waiting"; + } + } + if (dom.impulseRt60) { + dom.impulseRt60.textContent = + impulse.rt60Ms != null ? `${impulse.rt60Ms.toFixed(1)} ms` : "--"; + } +} diff --git a/js/render/spectrum.js b/js/render/spectrum.js index 37aaca6..e9615d4 100644 --- a/js/render/spectrum.js +++ b/js/render/spectrum.js @@ -83,6 +83,27 @@ function initWebGL(gl) { let webglStateSpec = null; let currentU8DataSpec = null; +const NOISE_FLOOR_SAMPLE_STRIDE = 8; +const NOISE_FLOOR_PERCENTILE = 0.2; +const MAX_NOISE_PROFILE_SAMPLES = 3600; +const MIN_TREND_CALC_MINUTES = 1 / 60; +const MAX_FEEDBACK_STABLE_FRAMES = 300; +const FEEDBACK_HIGH_RISK_THRESHOLD = 0.78; + +function formatDb(v) { + return Number.isFinite(v) ? `${v.toFixed(1)} dB` : "--"; +} + +function percentileInterpolated(sortedValues, p) { + if (!sortedValues || sortedValues.length === 0) return null; + const clampedP = Math.max(0, Math.min(1, p)); + const rawPos = (sortedValues.length - 1) * clampedP; + const lo = Math.floor(rawPos); + const hi = Math.ceil(rawPos); + if (lo === hi) return sortedValues[lo]; + const t = rawPos - lo; + return sortedValues[lo] * (1 - t) + sortedValues[hi] * t; +} function setupWebGLSpec(gl) { const result = initWebGL(gl); @@ -228,7 +249,7 @@ export function drawSpectrum({ state, dom, frame }) { ); ctxOvl.restore(); - const howlingEnabled = dom.howlingWarning !== null; + const howlingEnabled = dom.feedbackWarning !== null; let peaks = []; let howlingDetected = false; const { peakCount } = state.config; @@ -242,6 +263,48 @@ export function drawSpectrum({ state, dom, frame }) { const avgDb = 10 * Math.log10(sumPower / sampleCount); const mOffset = Math.max(2, Math.round(150 / hzPerBin)); + const floorSamples = []; + for (let i = 2; i < bufferLength - 2; i += NOISE_FLOOR_SAMPLE_STRIDE) { + const f = i * hzPerBin; + if (f < minFreqLog || f > maxFreqLog) continue; + floorSamples.push(freqData[i]); + } + floorSamples.sort((a, b) => a - b); + const noiseFloorDb = percentileInterpolated(floorSamples, NOISE_FLOOR_PERCENTILE) ?? avgDb; + + if (state.noiseProfile) { + state.noiseProfile.currentDb = noiseFloorDb; + if (state.noiseProfile.active) { + const tSec = (Date.now() - state.noiseProfile.startTime) / 1000; + state.noiseProfile.samples.push({ tSec, db: noiseFloorDb }); + if (state.noiseProfile.samples.length > MAX_NOISE_PROFILE_SAMPLES) { + state.noiseProfile.samples.shift(); + } + const sum = state.noiseProfile.samples.reduce((acc, s) => acc + s.db, 0); + state.noiseProfile.baselineDb = sum / state.noiseProfile.samples.length; + if (state.noiseProfile.samples.length > 5) { + const first = state.noiseProfile.samples[0]; + const last = state.noiseProfile.samples[state.noiseProfile.samples.length - 1]; + const dtMin = (last.tSec - first.tSec) / 60; + if (dtMin >= MIN_TREND_CALC_MINUTES) { + state.noiseProfile.trendDbPerMin = (last.db - first.db) / dtMin; + } + } + } + if (dom.noiseProfileCurrent) { + dom.noiseProfileCurrent.textContent = formatDb(state.noiseProfile.currentDb); + } + if (dom.noiseProfileBaseline) { + dom.noiseProfileBaseline.textContent = formatDb(state.noiseProfile.baselineDb); + } + if (dom.noiseProfileTrend) { + const trend = state.noiseProfile.trendDbPerMin; + dom.noiseProfileTrend.textContent = Number.isFinite(trend) + ? `${trend >= 0 ? "+" : ""}${trend.toFixed(2)} dB/min` + : "--"; + } + } + if (howlingEnabled || peakCount > 0) { for (let i = 2; i < bufferLength - 2; i++) { const val = freqData[i]; @@ -293,7 +356,8 @@ export function drawSpectrum({ state, dom, frame }) { if (dom.clipLogContainer) { dom.clipLogContainer.innerHTML = state.eventLogs .map((log) => { - const color = log.includes("Howling") + const color = + log.includes("Howling") || log.includes("Feedback") ? "#fbbf24" : "#ef4444"; return `
${log}
`; @@ -307,10 +371,83 @@ export function drawSpectrum({ state, dom, frame }) { } } - if (dom.howlingWarning) { - const disp = howlingDetected ? "inline-block" : "none"; - if (dom.howlingWarning.style.display !== disp) { - dom.howlingWarning.style.display = disp; + const dominantFreq = maxFreqIndex * hzPerBin; + const dominantDb = maxFreqVal; + const leftVal = freqData[Math.max(0, maxFreqIndex - 1)]; + const rightVal = freqData[Math.min(bufferLength - 1, maxFreqIndex + 1)]; + const narrownessDb = dominantDb - Math.max(leftVal, rightVal); + + if (Math.abs(dominantFreq - (state.feedbackLastFreq || 0)) < 25) { + state.feedbackStableFrames = Math.min( + MAX_FEEDBACK_STABLE_FRAMES, + (state.feedbackStableFrames || 0) + 1, + ); + } else { + state.feedbackStableFrames = Math.max(0, (state.feedbackStableFrames || 0) - 2); + } + state.feedbackLastFreq = dominantFreq; + + let instantRisk = 0; + if (dominantFreq > 150 && dominantDb > -45) { + const levelScore = Math.max(0, Math.min(1, (dominantDb + 45) / 20)); + const contrastScore = Math.max(0, Math.min(1, (dominantDb - noiseFloorDb - 12) / 18)); + const narrowScore = Math.max(0, Math.min(1, (narrownessDb - 2) / 10)); + const stableScore = Math.max( + 0, + Math.min(1, (state.feedbackStableFrames || 0) / 24), + ); + instantRisk = + levelScore * 0.25 + + contrastScore * 0.35 + + narrowScore * 0.2 + + stableScore * 0.2; + } + state.feedbackRisk = (state.feedbackRisk || 0) * 0.9 + instantRisk * 0.1; + const feedbackHigh = state.feedbackRisk >= FEEDBACK_HIGH_RISK_THRESHOLD; + + if (dom.feedbackRiskText) { + dom.feedbackRiskText.textContent = feedbackHigh + ? "High" + : state.feedbackRisk >= 0.5 + ? "Medium" + : "Low"; + } + if (dom.feedbackRiskFill) { + const p = Math.max(0, Math.min(100, state.feedbackRisk * 100)); + dom.feedbackRiskFill.style.width = `${p.toFixed(0)}%`; + dom.feedbackRiskFill.style.backgroundColor = feedbackHigh + ? "#ef4444" + : p >= 50 + ? "#f59e0b" + : "#10b981"; + } + + if (feedbackHigh && !state.feedbackIsHigh) { + const now = new Date(); + const timeStr = now.toLocaleTimeString(); + const fStr = dominantFreq > 1000 + ? `${(dominantFreq / 1000).toFixed(2)}k` + : `${Math.round(dominantFreq)}`; + state.eventLogs.unshift(`[${timeStr}] Feedback Risk HIGH: ${fStr}Hz`); + if (state.eventLogs.length > 50) state.eventLogs.pop(); + if (dom.clipLogContainer) { + dom.clipLogContainer.innerHTML = state.eventLogs + .map((log) => { + const color = + log.includes("Howling") || log.includes("Feedback") + ? "#fbbf24" + : "#ef4444"; + return `
${log}
`; + }) + .join(""); + } + } + state.feedbackIsHigh = feedbackHigh; + + if (dom.feedbackWarning) { + const disp = howlingDetected || feedbackHigh ? "inline-block" : "none"; + if (dom.feedbackWarning.style.display !== disp) { + dom.feedbackWarning.style.display = disp; } } diff --git a/js/render/waveformMeter.js b/js/render/waveformMeter.js index 8c7ce2e..155ffc2 100644 --- a/js/render/waveformMeter.js +++ b/js/render/waveformMeter.js @@ -101,6 +101,8 @@ export function drawWaveformAndMeter({ state, dom, frame }) { let currentPeakDb = -Infinity; const standard = state.config.meteringStandard || "peak"; + const rawRms = Math.sqrt(sumSquares / state.analyser.fftSize); + const rawRmsDb = rawRms > 0 ? 20 * Math.log10(rawRms) : -Infinity; if (maxAbs > 0) { if (standard === "rms") { @@ -119,6 +121,20 @@ export function drawWaveformAndMeter({ state, dom, frame }) { truePeakDb = 20 * Math.log10(maxAbs); } + if (state.calibration && state.calibration.active) { + if (state.calibration.step === 1 && Number.isFinite(rawRmsDb)) { + state.calibration.samples.push(rawRmsDb); + } else if ( + (state.calibration.step === 2 || state.calibration.step === 3) && + Number.isFinite(truePeakDb) + ) { + state.calibration.samples.push(truePeakDb); + } + if (dom.calibrationStatus) { + dom.calibrationStatus.textContent = `Running step ${state.calibration.step} (${state.calibration.samples.length} samples)`; + } + } + if (currentPeakDb > state.prevPeakValue) { state.prevPeakValue = currentPeakDb; } else { @@ -137,7 +153,7 @@ export function drawWaveformAndMeter({ state, dom, frame }) { dom.clipLogContainer.innerHTML = state.eventLogs .map( (log) => - `
${log}
`, + `
${log}
`, ) .join(""); } diff --git a/js/state.js b/js/state.js index 235600d..abd8523 100644 --- a/js/state.js +++ b/js/state.js @@ -36,6 +36,11 @@ export function createInitialState() { prevPeakValue: -Infinity, eventLogs: [], + lastHowlingTime: null, + feedbackRisk: 0, + feedbackStableFrames: 0, + feedbackLastFreq: 0, + feedbackIsHigh: false, lastDrawTime: 0, fpsFrameCount: 0, lastFpsTime: 0, @@ -63,5 +68,32 @@ export function createInitialState() { wVec: 0, hVec: 0, }, + + noiseProfile: { + active: false, + startTime: 0, + samples: [], + currentDb: null, + baselineDb: null, + trendDbPerMin: 0, + }, + + calibration: { + active: false, + step: 0, + samples: [], + noiseFloorDb: null, + nominalPeakDb: null, + loudPeakDb: null, + recommendedGainDeltaDb: null, + }, + + impulse: { + captureRequested: false, + buffer: null, + peak: 0, + rt60Ms: null, + capturedAt: null, + }, }; }