-
Notifications
You must be signed in to change notification settings - Fork 1
Add advanced diagnostics: feedback risk detection, noise profiling, gain calibration wizard, and impulse response viewer #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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"; | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| 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
|
||
|
|
||
| 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", () => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
|
|
||
| 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) => { | ||
|
|
||
There was a problem hiding this comment.
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.