viewers: smoother mic dBL chart + restore binary/TXT download links

Two issues spotted in the modal:

1. Mic dBL chart looked spikey/discontinuous — isolated bars at 80-95
   with gaps in between.  Cause: _psiToDbl() returns null for zero or
   negative samples, and most mic samples on a quiet event sit at the
   digitization noise floor where they're effectively zero.  Result:
   the chart only renders the moments when instantaneous SPL exceeded
   the Y-axis bottom — looks like a sound trigger gate.

   Fix: new _psiToDblForChart() rectifies the AC waveform (abs), then
   converts to dBL, then floors at MIC_DBL_FLOOR=60 dBL.  Chart now
   has a continuous 60 dBL baseline with peaks above it — matches how
   acoustic engineers expect SPL-vs-time.  Y-axis bottom pinned to
   MIC_DBL_FLOOR, top to peak + 5 dB headroom.  Peak label still uses
   the unrectified _psiToDbl so the displayed peak value is exact.

2. Filename in Source/Files block was unlinked.  Endpoint exists
   (/db/events/{id}/blastware_file) — just wasn't wired to the modal.
   Made it a clickable download link.  Same treatment for the
   preserved .TXT — added "(download .TXT)" link next to source kind
   when source.txt_filename is populated (events ingested after the
   .TXT preservation feature landed; older events show no link).

Applied to both the inline modal in sfm_webapp.html and the
standalone /events page in event_browser.html.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 23:08:21 +00:00
parent ace542cba5
commit 87aec3f4d1
2 changed files with 88 additions and 6 deletions
+23 -1
View File
@@ -356,6 +356,18 @@ function _psiToDbl(psi) {
return 20 * Math.log10(psi / DBL_REF);
}
// Per-sample mic chart conversion — rectify the AC waveform, dBL,
// floor below the noise-floor minimum. Gives a continuous baseline
// instead of the spikey/discontinuous look you get from raw _psiToDbl.
const MIC_DBL_FLOOR = 60;
function _psiToDblForChart(psi) {
if (psi == null) return MIC_DBL_FLOOR;
const a = Math.abs(psi);
if (a === 0) return MIC_DBL_FLOOR;
const dbl = 20 * Math.log10(a / DBL_REF);
return dbl > MIC_DBL_FLOOR ? dbl : MIC_DBL_FLOOR;
}
// Format an ISO timestamp in the browser's local timezone — UTC values
// (with 'Z' suffix) convert; naive values are interpreted as local clock.
// Returns '—' for null/empty/unparseable.
@@ -638,7 +650,10 @@ function renderWaveform(data) {
let peak = chData.peak;
const peakT = chData.peak_t_ms;
if (ch === 'MicL' && unit === 'psi' && micUnit === 'dBL') {
values = values.map(_psiToDbl);
// Per-sample chart uses rectified-and-floored conversion so the
// baseline is continuous; the peak label uses the unrectified
// converter to preserve the true measurement.
values = values.map(_psiToDblForChart);
peak = _psiToDbl(peak);
unit = 'dB(L)';
}
@@ -711,6 +726,13 @@ function renderWaveform(data) {
}
const padded = (absMax || 1) * 1.10;
yBounds = { min: -padded, max: padded };
} else if (ch === 'MicL' && micUnit === 'dBL') {
// Baseline at noise-floor minimum (matches what we floored
// null/quiet samples to), top at peak + 5 dB headroom.
const peakDbl = (typeof peak === 'number' && isFinite(peak))
? peak + 5
: 100;
yBounds = { min: MIC_DBL_FLOOR, max: Math.max(peakDbl, MIC_DBL_FLOOR + 20) };
}
const chart = new Chart(canvas, {
+65 -5
View File
@@ -2589,6 +2589,23 @@ function _psiToDbl(psi) {
return 20 * Math.log10(psi / DBL_REF);
}
// Per-sample mic display floor. Sound pressure AC samples spend most
// of their time at the digitization noise floor (1-2 ADC counts ≈ ~20-40
// dBL). Rendering each one as null/-inf produces a spikey discontinuous
// chart of "moments when sound briefly exceeded 80 dBL" — confusing.
// Instead we rectify (abs the AC waveform), convert to dBL, and floor
// anything below MIC_DBL_FLOOR so the chart has a continuous baseline
// with peaks rising above it. Matches how acoustic engineers expect to
// see SPL-vs-time.
const MIC_DBL_FLOOR = 60;
function _psiToDblForChart(psi) {
if (psi == null) return MIC_DBL_FLOOR;
const a = Math.abs(psi);
if (a === 0) return MIC_DBL_FLOOR;
const dbl = 20 * Math.log10(a / DBL_REF);
return dbl > MIC_DBL_FLOOR ? dbl : MIC_DBL_FLOOR;
}
// Adaptive decimal formatter — scientific notation is reserved for truly
// extreme values (10000+ or sub-0.0001). Normal-range values (most peaks
// fall here) render as decimals with sensible precision. Replaces the
@@ -2649,10 +2666,14 @@ function _renderScWaveform(data) {
let chPeak = chData.peak;
// Mic channel: convert from raw psi to dB(L) when user prefers dBL
// (default). Mic samples that are zero/negative become null (Chart.js
// renders them as gaps in line mode, zero-height bars in histogram mode).
// (default). Per-sample values use _psiToDblForChart which rectifies
// (abs) the AC waveform and floors at MIC_DBL_FLOOR so the chart is
// continuous with a baseline + peaks above it, instead of a sparse
// pattern of isolated spikes for "moments when sound briefly exceeded
// the Y-axis bottom". The peak label uses _psiToDbl with the
// unrectified peak (preserves the true measurement).
if (ch === 'MicL' && chUnit === 'psi' && micUnit === 'dBL') {
values = values.map(_psiToDbl);
values = values.map(_psiToDblForChart);
chPeak = _psiToDbl(chPeak);
chUnit = 'dB(L)';
}
@@ -2736,6 +2757,13 @@ function _renderScWaveform(data) {
}
const padded = (absMax || 1) * 1.10;
yBounds = { min: -padded, max: padded };
} else if (ch === 'MicL' && micUnit === 'dBL') {
// Pin baseline at the chart floor (which matches what we flooded
// null/quiet samples to), top at the actual peak + a few dB headroom.
const peakDbl = (typeof chPeak === 'number' && isFinite(chPeak))
? chPeak + 5
: 100;
yBounds = { min: MIC_DBL_FLOOR, max: Math.max(peakDbl, MIC_DBL_FLOOR + 20) };
}
_scCharts[ch] = new Chart(canvas, {
@@ -2882,10 +2910,42 @@ function _renderSidecar(data) {
document.getElementById('sc-f-operator').textContent = pi.operator || '—';
document.getElementById('sc-f-loc').textContent = pi.sensor_location || '—';
document.getElementById('sc-f-bw').textContent = bw.filename || '—';
// Filename rendered as a clickable download link for the original BW
// binary. Same endpoint the live-device viewer uses for stored events
// (/db/events/{id}/blastware_file).
const bwCell = document.getElementById('sc-f-bw');
bwCell.innerHTML = '';
if (bw.filename && _scCurrentEventId) {
const a = document.createElement('a');
a.href = `${api()}/db/events/${_scCurrentEventId}/blastware_file`;
a.textContent = bw.filename;
a.download = bw.filename;
a.title = 'Download original BW event binary';
a.style.color = 'var(--accent, #58a6ff)';
a.style.textDecoration = 'underline';
bwCell.appendChild(a);
} else {
bwCell.textContent = '—';
}
document.getElementById('sc-f-bwsize').textContent = bw.filesize != null ? `${bw.filesize} bytes` : '—';
document.getElementById('sc-f-sha').textContent = bw.sha256 || '—';
document.getElementById('sc-f-src').textContent = src.kind || '—';
// Source kind + a download link for the preserved BW ASCII report
// (.TXT), when available. Only events ingested after 2026-05-27
// have the .TXT preserved; older events show "—".
const srcCell = document.getElementById('sc-f-src');
srcCell.innerHTML = '';
srcCell.appendChild(document.createTextNode(src.kind || '—'));
if (src.txt_filename && _scCurrentEventId) {
const a = document.createElement('a');
a.href = `${api()}/db/events/${_scCurrentEventId}/ascii_report.txt`;
a.textContent = ' (download .TXT)';
a.download = src.txt_filename;
a.title = 'Download preserved BW ASCII report';
a.style.color = 'var(--accent, #58a6ff)';
a.style.marginLeft = '8px';
a.style.fontSize = '11px';
srcCell.appendChild(a);
}
// captured_at has a "Z" suffix (UTC); _fmtTs converts to browser local
// — matches the BW-reported recorded-at, no more "21:59:57 vs it's 6 PM"
// confusion from operators reading the raw UTC value.