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 @@
+
+
+
+
+
+
+
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,
+ },
};
}