Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 121 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ <h2>Analyzer Settings</h2>
<h2 style="display: flex; justify-content: space-between">
Level Meter
<span
id="howling-warning"
id="feedback-warning"
style="
display: none;
color: white;
Expand All @@ -415,7 +415,7 @@ <h2 style="display: flex; justify-content: space-between">
font-weight: bold;
animation: blink 1s infinite;
"
>HOWLING!</span
>FEEDBACK RISK HIGH</span
>
</h2>

Expand All @@ -428,6 +428,32 @@ <h2 style="display: flex; justify-content: space-between">
</select>
</div>

<div class="setting-group" style="margin-bottom: 0.75rem">
<label style="margin-bottom: 0.3rem"
>Auto Feedback Detector:
<strong id="feedback-risk-text">Low</strong></label
>
<div
style="
height: 8px;
background: #111827;
border: 1px solid var(--border);
border-radius: 999px;
overflow: hidden;
"
>
<div
id="feedback-risk-fill"
style="
height: 100%;
width: 0%;
background: #10b981;
transition: width 120ms linear;
"
></div>
</div>
</div>

<div class="meter-wrapper">
<div class="meter-track">
<div class="meter-fill" id="peak-fill"></div>
Expand Down Expand Up @@ -492,6 +518,99 @@ <h2 style="display: flex; justify-content: space-between">
</div>
</div>

<div class="panel">
<h2>Noise Floor Profiler</h2>
<div class="setting-group">
<button id="btn-noise-profile" class="btn" style="width: 100%">
Start Profiling
</button>
</div>
<p style="font-size: 0.85rem; color: var(--text-muted)">
Status: <span id="noise-profile-status">Stopped</span>
</p>
<p style="font-size: 0.85rem; color: var(--text-muted)">
Current Floor: <span id="noise-profile-current">--</span>
</p>
<p style="font-size: 0.85rem; color: var(--text-muted)">
Baseline: <span id="noise-profile-baseline">--</span>
</p>
<p style="font-size: 0.85rem; color: var(--text-muted)">
Trend: <span id="noise-profile-trend">--</span>
</p>
</div>

<div class="panel">
<h2>Calibration Wizard (Gain Staging)</h2>
<div
class="setting-group"
style="display: flex; gap: 0.4rem; flex-wrap: wrap"
>
<button
id="btn-calibration-start"
class="btn"
style="flex: 1; min-width: 90px"
>
Start
</button>
<button
id="btn-calibration-next"
class="btn"
style="flex: 1; min-width: 90px"
>
Next
</button>
<button
id="btn-calibration-reset"
class="btn"
style="flex: 1; min-width: 90px"
>
Reset
</button>
</div>
<p style="font-size: 0.85rem; color: var(--text-muted)">
Status: <span id="calibration-status">Idle</span>
</p>
<p
id="calibration-instruction"
style="font-size: 0.85rem; color: var(--text-muted)"
>
Press Start to begin 3-step calibration.
</p>
<p
id="calibration-result"
style="font-size: 0.82rem; color: var(--text-main)"
>
--
</p>
</div>

<div class="panel">
<h2>Impulse Response Viewer</h2>
<div class="setting-group">
<button id="btn-impulse-capture" class="btn" style="width: 100%">
Capture Impulse
</button>
</div>
<div
style="
width: 100%;
height: 140px;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
margin-bottom: 0.5rem;
"
>
<canvas id="canvas-impulse" style="width: 100%; height: 100%"></canvas>
</div>
<p style="font-size: 0.85rem; color: var(--text-muted)">
Capture: <span id="impulse-status">Waiting</span>
</p>
<p style="font-size: 0.85rem; color: var(--text-muted)">
Estimated RT60: <span id="impulse-rt60">--</span>
</p>
</div>

<div class="panel">
<h2
style="
Expand Down
130 changes: 130 additions & 0 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { drawTextToAudioBuffer } from "./spectrogramDraw.js";
export function initApp() {
const dom = getDomRefs();
const state = createInitialState();
const TARGET_NOMINAL_PEAK_DB = -18;
const LOW_HEADROOM_THRESHOLD_DB = -6;

function syncConfig() {
if (!state.config) return;
Expand Down Expand Up @@ -166,6 +168,134 @@ export function initApp() {
stopAudio: audio.stopAudio,
});

function getPercentile(samples, p) {
if (!samples || samples.length === 0) return null;
const arr = samples.filter(Number.isFinite).slice().sort((a, b) => 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";
}
});
Comment on lines +184 to +198

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noise profiling can be started while audio is offline; in that case the render loop isn’t producing new spectrum frames, so the UI will show “Running” but no samples are collected until audio starts (and startTime becomes stale). Consider blocking the toggle when !state.isRunning / !state.audioCtx (or disabling the button while offline) and/or auto-stopping/resetting the profiler on stopAudio.

Copilot uses AI. Check for mistakes.
}

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 = [];
}
});
}
Comment on lines +201 to +273

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calibration can be started and advanced (“Next”) while audio is offline, which leads to empty sample sets (percentile returns null) and potentially confusing results/state transitions. Consider requiring state.isRunning/state.audioCtx before starting/advancing steps (or disabling the controls until audio is running) and showing a clear message when calibration can’t proceed.

Copilot uses AI. Check for mistakes.

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", () => {
Expand Down
20 changes: 20 additions & 0 deletions js/audio.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines 304 to +317

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stopAudio() resets feedback/impulse state but leaves noiseProfile/calibration potentially active with stale startTime/samples; after restarting audio, profiling/calibration can resume with an incorrect time base and mixed old/new samples. Consider resetting noiseProfile and calibration state here (and updating related UI text/button labels) similarly to how impulse/feedback are reset.

Copilot uses AI. Check for mistakes.

if (state.animationId) {
cancelAnimationFrame(state.animationId);
Expand Down Expand Up @@ -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) => {
Expand Down
22 changes: 21 additions & 1 deletion js/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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
Expand Down Expand Up @@ -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,

Expand Down
10 changes: 10 additions & 0 deletions js/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
Loading
Loading