diff --git a/sfm/event_browser.html b/sfm/event_browser.html
index 30542af..9be5431 100644
--- a/sfm/event_browser.html
+++ b/sfm/event_browser.html
@@ -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, {
diff --git a/sfm/sfm_webapp.html b/sfm/sfm_webapp.html
index 856c8f5..19a3e05 100644
--- a/sfm/sfm_webapp.html
+++ b/sfm/sfm_webapp.html
@@ -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.