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:
+23
-1
@@ -356,6 +356,18 @@ function _psiToDbl(psi) {
|
|||||||
return 20 * Math.log10(psi / DBL_REF);
|
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
|
// Format an ISO timestamp in the browser's local timezone — UTC values
|
||||||
// (with 'Z' suffix) convert; naive values are interpreted as local clock.
|
// (with 'Z' suffix) convert; naive values are interpreted as local clock.
|
||||||
// Returns '—' for null/empty/unparseable.
|
// Returns '—' for null/empty/unparseable.
|
||||||
@@ -638,7 +650,10 @@ function renderWaveform(data) {
|
|||||||
let peak = chData.peak;
|
let peak = chData.peak;
|
||||||
const peakT = chData.peak_t_ms;
|
const peakT = chData.peak_t_ms;
|
||||||
if (ch === 'MicL' && unit === 'psi' && micUnit === 'dBL') {
|
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);
|
peak = _psiToDbl(peak);
|
||||||
unit = 'dB(L)';
|
unit = 'dB(L)';
|
||||||
}
|
}
|
||||||
@@ -711,6 +726,13 @@ function renderWaveform(data) {
|
|||||||
}
|
}
|
||||||
const padded = (absMax || 1) * 1.10;
|
const padded = (absMax || 1) * 1.10;
|
||||||
yBounds = { min: -padded, max: padded };
|
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, {
|
const chart = new Chart(canvas, {
|
||||||
|
|||||||
+65
-5
@@ -2589,6 +2589,23 @@ function _psiToDbl(psi) {
|
|||||||
return 20 * Math.log10(psi / DBL_REF);
|
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
|
// Adaptive decimal formatter — scientific notation is reserved for truly
|
||||||
// extreme values (10000+ or sub-0.0001). Normal-range values (most peaks
|
// extreme values (10000+ or sub-0.0001). Normal-range values (most peaks
|
||||||
// fall here) render as decimals with sensible precision. Replaces the
|
// fall here) render as decimals with sensible precision. Replaces the
|
||||||
@@ -2649,10 +2666,14 @@ function _renderScWaveform(data) {
|
|||||||
let chPeak = chData.peak;
|
let chPeak = chData.peak;
|
||||||
|
|
||||||
// Mic channel: convert from raw psi to dB(L) when user prefers dBL
|
// Mic channel: convert from raw psi to dB(L) when user prefers dBL
|
||||||
// (default). Mic samples that are zero/negative become null (Chart.js
|
// (default). Per-sample values use _psiToDblForChart which rectifies
|
||||||
// renders them as gaps in line mode, zero-height bars in histogram mode).
|
// (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') {
|
if (ch === 'MicL' && chUnit === 'psi' && micUnit === 'dBL') {
|
||||||
values = values.map(_psiToDbl);
|
values = values.map(_psiToDblForChart);
|
||||||
chPeak = _psiToDbl(chPeak);
|
chPeak = _psiToDbl(chPeak);
|
||||||
chUnit = 'dB(L)';
|
chUnit = 'dB(L)';
|
||||||
}
|
}
|
||||||
@@ -2736,6 +2757,13 @@ function _renderScWaveform(data) {
|
|||||||
}
|
}
|
||||||
const padded = (absMax || 1) * 1.10;
|
const padded = (absMax || 1) * 1.10;
|
||||||
yBounds = { min: -padded, max: padded };
|
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, {
|
_scCharts[ch] = new Chart(canvas, {
|
||||||
@@ -2882,10 +2910,42 @@ function _renderSidecar(data) {
|
|||||||
document.getElementById('sc-f-operator').textContent = pi.operator || '—';
|
document.getElementById('sc-f-operator').textContent = pi.operator || '—';
|
||||||
document.getElementById('sc-f-loc').textContent = pi.sensor_location || '—';
|
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-bwsize').textContent = bw.filesize != null ? `${bw.filesize} bytes` : '—';
|
||||||
document.getElementById('sc-f-sha').textContent = bw.sha256 || '—';
|
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
|
// 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"
|
// — matches the BW-reported recorded-at, no more "21:59:57 vs it's 6 PM"
|
||||||
// confusion from operators reading the raw UTC value.
|
// confusion from operators reading the raw UTC value.
|
||||||
|
|||||||
Reference in New Issue
Block a user