heartline / index.html
SlitherCode's picture
Upload folder using huggingface_hub
b6ed3e9 verified
Raw
History Blame Contribute Delete
83.7 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Heartline</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Spectral:wght@400;500;600&family=Hanken+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: 'Hanken Grotesk', sans-serif; color: #1E2B27; background: #F6F4EF; }
input, select, button, textarea { font-family: inherit; }
input:focus, select:focus, textarea:focus { outline: none; border-color: #2E6E62 !important; box-shadow: 0 0 0 3px rgba(46,110,98,0.12); }
input::placeholder { color: #A9B2AC; }
button { cursor: pointer; white-space: nowrap; }
label { display: block; }
.step-section { display: none; }
.step-section.active { display: block; }
.card { background: #fff; border: 1px solid #E8E6DE; border-radius: 16px; }
.field-label { font-size: 12.5px; font-weight: 600; color: #4A5752; margin-bottom: 7px; }
.field-label-sm { font-size: 11.5px; font-weight: 600; color: #7B8780; margin-bottom: 5px; }
.input-base { width: 100%; padding: 11px 13px; border: 1px solid #DCDAD0; border-radius: 10px; font-size: 14px; color: #1E2B27; background: #fff; transition: border-color .15s, box-shadow .15s; }
.input-sm { padding: 9px 11px; border-radius: 9px; font-size: 13.5px; }
.btn-primary { background: #2E6E62; color: #fff; border: none; border-radius: 10px; padding: 11px 18px; font-size: 14px; font-weight: 600; }
.btn-secondary { background: #fff; color: #1F4D45; border: 1px solid #D8D6CC; border-radius: 10px; padding: 9px 15px; font-size: 13.5px; font-weight: 600; }
.btn-ghost { background: transparent; border: none; color: #C7CEC8; font-size: 16px; line-height: 1; padding: 0 4px; }
.chip-green { display: inline-flex; align-items: center; gap: 7px; background: #EAF1EC; border: 1px solid #D7E4DA; border-radius: 999px; padding: 5px 8px 5px 13px; font-size: 13px; color: #1F4D45; }
.chip-green button { border: none; background: #CFE0D4; color: #1F4D45; width: 18px; height: 18px; border-radius: 50%; font-size: 12px; display: flex; align-items: center; justify-content: center; padding: 0; }
.mono { font-family: 'IBM Plex Mono', monospace; }
.serif { font-family: 'Spectral', serif; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes wave { 0%,100% { transform: scaleY(0.35); } 50% { transform: scaleY(1.5); } }
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.3; } }
.spinner { width: 18px; height: 18px; border: 2px solid #D7E4DA; border-top-color: #2E6E62; border-radius: 50%; display: inline-block; animation: spin .7s linear infinite; }
@media print { .no-print { display: none !important; } body { background: #fff !important; } }
#model-banner { position:fixed;bottom:20px;left:24px;z-index:99;display:flex;align-items:center;gap:10px;background:#1E2B27;color:#fff;border-radius:12px;padding:10px 16px;font-size:13px;box-shadow:0 4px 20px rgba(0,0,0,0.25);transition:opacity .4s; }
#model-banner.hidden { opacity:0;pointer-events:none; }
#model-bar-track { width:140px;height:5px;background:rgba(255,255,255,0.15);border-radius:3px;overflow:hidden; }
#model-bar-fill { height:100%;background:#6FC0A8;border-radius:3px;transition:width .2s; }
</style>
</head>
<body>
<!-- ── MODEL DOWNLOAD BANNER ── -->
<div id="model-banner" class="no-print">
<div style="width:8px;height:8px;border-radius:50%;background:#6FC0A8;flex-shrink:0;" id="model-dot"></div>
<span id="model-banner-text">Downloading spectrogram model…</span>
<div id="model-bar-track"><div id="model-bar-fill" style="width:0%"></div></div>
<span id="model-pct" style="font-size:12px;color:#8FB6A6;min-width:34px;text-align:right;">0%</span>
</div>
<!-- ── LLM DOWNLOAD BANNER ── -->
<div id="llm-banner" class="no-print" style="position:fixed;bottom:20px;left:24px;z-index:98;display:none;align-items:center;gap:10px;background:#1E2B27;color:#fff;border-radius:12px;padding:10px 16px;font-size:13px;box-shadow:0 4px 20px rgba(0,0,0,0.25);transition:opacity .4s;">
<div style="width:8px;height:8px;border-radius:50%;background:#C8B96A;flex-shrink:0;animation:pulse 1.4s ease-in-out infinite;" id="llm-dot"></div>
<span id="llm-banner-text">Downloading LLM…</span>
</div>
<!-- ── TOP BAR ── -->
<header class="no-print" style="background:#fff;border-bottom:1px solid #E8E6DE;position:sticky;top:0;z-index:20;">
<div style="max-width:1120px;margin:0 auto;padding:14px 28px;display:flex;align-items:center;gap:12px;">
<div style="width:9px;height:9px;border-radius:50%;background:#2E6E62;"></div>
<span class="serif" style="font-size:19px;">Heartline</span>
<span style="font-size:12px;color:#9AA59E;padding-left:12px;border-left:1px solid #E8E6DE;">Home monitoring β†’ SOAP Objective</span>
</div>
</header>
<!-- ── PROGRESS ── -->
<nav class="no-print" style="max-width:1120px;margin:0 auto;padding:26px 28px 8px;">
<div id="progress-bar-wrap" style="position:relative;display:flex;justify-content:space-between;max-width:680px;">
<div style="position:absolute;top:17px;left:18px;right:18px;height:2px;background:#E5E3DA;"></div>
<div id="progress-fill" style="position:absolute;top:17px;left:18px;height:2px;background:#2E6E62;transition:width .35s ease;width:0%;"></div>
<!-- steps rendered by JS -->
</div>
</nav>
<!-- ── MAIN ── -->
<main style="max-width:1120px;margin:0 auto;padding:18px 28px 120px;">
<!-- Step 0: About you -->
<section id="step-0" class="step-section">
<div class="card" style="padding:30px 32px;">
<h1 class="serif" style="font-weight:500;font-size:26px;margin:0 0 6px;">About you</h1>
<p style="font-size:15px;color:#6B7A73;margin:0 0 26px;max-width:560px;">A few details so your report reads like a proper clinical note.</p>
<div style="display:grid;grid-template-columns:140px 1fr 1fr;gap:18px;margin-bottom:26px;">
<div>
<label class="field-label" for="age">Age</label>
<input id="age" type="number" class="input-base mono" placeholder="β€”" oninput="setState({age:this.value})">
</div>
<div>
<label class="field-label" for="sex">Sex</label>
<select id="sex" class="input-base" onchange="setState({sex:this.value})">
<option value="">Select…</option>
<option>Female</option><option>Male</option><option>Intersex</option><option>Prefer not to say</option>
</select>
</div>
<div>
<label class="field-label" for="race">Race / ethnicity</label>
<select id="race" class="input-base" onchange="setState({race:this.value})">
<option value="">Select…</option>
<option>White / Caucasian</option><option>Black / African American</option>
<option>Hispanic / Latino</option><option>Asian</option>
<option>Native American</option><option>Middle Eastern</option>
<option>Multiracial</option><option>Other</option>
</select>
</div>
</div>
<!-- Conditions -->
<div style="margin-bottom:26px;">
<label class="field-label">Reported conditions</label>
<div id="conditions-display" style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:11px;min-height:20px;"></div>
<div style="display:flex;gap:8px;max-width:440px;">
<input id="condition-input" class="input-base" placeholder="Add a condition…"
onkeydown="if(event.key==='Enter'){event.preventDefault();addCondition();}">
<button class="btn-secondary" onclick="addCondition()">Add</button>
</div>
<div id="condition-suggestions" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:11px;"></div>
</div>
<!-- Medications -->
<div>
<label class="field-label">Current medications</label>
<div id="meds-display" style="display:flex;flex-direction:column;gap:7px;margin-bottom:11px;"></div>
<div style="display:flex;gap:8px;max-width:440px;">
<input id="med-name" class="input-base" placeholder="Medication" style="flex:1.4;">
<input id="med-dose" class="input-base" placeholder="Dose e.g. 10 mg daily" style="flex:1;"
onkeydown="if(event.key==='Enter'){event.preventDefault();addMed();}">
<button class="btn-secondary" onclick="addMed()">Add</button>
</div>
</div>
</div>
<div style="margin-top:16px;background:#FFFDF7;border:1px solid #E8E3D4;border-radius:12px;padding:15px 18px;display:flex;gap:12px;align-items:flex-start;">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#B4502F" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;margin-top:2px;"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<p style="margin:0;font-size:12.5px;color:#6B7A73;line-height:1.65;"><strong style="color:#4A5752;">Not a medical device.</strong> Heartline is a research prototype and has not been evaluated or cleared by the FDA. It is not a substitute for professional medical advice, diagnosis, or treatment. Cardiac rhythm classifications are automated and may be inaccurate. <strong style="color:#B4502F;">If you are experiencing a medical emergency, call 911 or your local emergency number immediately.</strong> Always consult a qualified healthcare provider before making any clinical decisions based on this data.</p>
</div>
</section>
<!-- Step 1: Blood pressure -->
<section id="step-1" class="step-section">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
<div>
<h1 class="serif" style="font-weight:500;font-size:26px;margin:0 0 6px;">Blood pressure</h1>
<p style="font-size:15px;color:#6B7A73;margin:0;max-width:560px;">Enter readings directly in the table, or import a CSV.</p>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<label style="display:flex;align-items:center;gap:8px;background:#fff;border:1px solid #D8D6CC;border-radius:10px;padding:9px 15px;font-size:13.5px;font-weight:600;color:#1F4D45;cursor:pointer;">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#2E6E62" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M7 9l5-5 5 5"/><path d="M5 20h14"/></svg>
Import CSV
<input type="file" accept=".csv,.xlsx,.xls" onchange="importCsv(this)" style="display:none;">
</label>
<div id="bp-toast" style="display:none;align-items:center;gap:8px;background:#EAF1EC;border:1px solid #D7E4DA;border-radius:10px;padding:9px 13px;font-size:13px;color:#1F4D45;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2E6E62" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
<span id="bp-toast-msg"></span>
</div>
</div>
</div>
<!-- BP summary bar -->
<div id="bp-summary"></div>
<!-- Editable readings table -->
<div class="card" style="overflow:hidden;margin-bottom:0;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:1px solid #EEEDE6;">
<th style="padding:11px 16px;text-align:left;font-size:11.5px;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;color:#9AA59E;width:160px;">Date</th>
<th style="padding:11px 16px;text-align:left;font-size:11.5px;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;color:#9AA59E;width:130px;">Time</th>
<th style="padding:11px 16px;text-align:left;font-size:11.5px;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;color:#9AA59E;width:110px;">Systolic</th>
<th style="padding:11px 16px;text-align:left;font-size:11.5px;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;color:#9AA59E;width:110px;">Diastolic</th>
<th style="padding:11px 16px;text-align:left;font-size:11.5px;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;color:#9AA59E;width:110px;">Heart Rate</th>
<th style="padding:11px 16px;text-align:left;font-size:11.5px;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;color:#9AA59E;">Symptom</th>
<th style="width:36px;"></th>
</tr>
</thead>
<tbody id="bp-table"></tbody>
<tfoot>
<tr>
<td colspan="7" style="padding:10px 12px;border-top:1px solid #F3F1EA;">
<button onclick="addBpRow()" style="background:transparent;border:none;color:#2E6E62;font-size:13.5px;font-weight:600;cursor:pointer;padding:4px 6px;display:flex;align-items:center;gap:6px;">
<span style="font-size:18px;line-height:1;">+</span> Add row
</button>
</td>
</tr>
</tfoot>
</table>
</div>
</section>
<!-- Step 2: Cardiac sessions -->
<section id="step-2" class="step-section">
<div style="margin-bottom:18px;">
<h1 class="serif" style="font-weight:500;font-size:26px;margin:0 0 6px;">Cardiac sessions</h1>
<p style="font-size:15px;color:#6B7A73;margin:0;max-width:600px;">Each session groups ECG recordings you take at one sitting.</p>
</div>
<!-- Sample downloads -->
<div class="card" style="padding:18px 22px;margin-bottom:16px;">
<div style="font-size:12px;font-weight:700;letter-spacing:0.07em;text-transform:uppercase;color:#9AA59E;margin-bottom:12px;">No audio? Download a synthetic sample</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;">
<a href="/samples/sample_normal.wav" download style="display:inline-flex;align-items:center;gap:6px;background:#EAF1EC;color:#1F4D45;border-radius:8px;padding:7px 12px;font-size:12.5px;font-weight:600;text-decoration:none;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v12M7 14l5 5 5-5"/><path d="M5 20h14"/></svg>Normal sinus rhythm</a>
<a href="/samples/sample_tachycardia.wav" download style="display:inline-flex;align-items:center;gap:6px;background:#FEF3EE;color:#A05A3C;border-radius:8px;padding:7px 12px;font-size:12.5px;font-weight:600;text-decoration:none;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v12M7 14l5 5 5-5"/><path d="M5 20h14"/></svg>Tachycardia</a>
<a href="/samples/sample_bradycardia.wav" download style="display:inline-flex;align-items:center;gap:6px;background:#F0F4FE;color:#3B5BA5;border-radius:8px;padding:7px 12px;font-size:12.5px;font-weight:600;text-decoration:none;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v12M7 14l5 5 5-5"/><path d="M5 20h14"/></svg>Bradycardia</a>
<a href="/samples/sample_afib.wav" download style="display:inline-flex;align-items:center;gap:6px;background:#FBEDE7;color:#B4502F;border-radius:8px;padding:7px 12px;font-size:12.5px;font-weight:600;text-decoration:none;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v12M7 14l5 5 5-5"/><path d="M5 20h14"/></svg>Atrial fibrillation</a>
<a href="/samples/sample_pvc.wav" download style="display:inline-flex;align-items:center;gap:6px;background:#F5F0FB;color:#6B4FA0;border-radius:8px;padding:7px 12px;font-size:12.5px;font-weight:600;text-decoration:none;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v12M7 14l5 5 5-5"/><path d="M5 20h14"/></svg>PVC pattern</a>
</div>
<p style="margin:10px 0 0;font-size:11.5px;color:#B0B8B2;line-height:1.5;">Synthetic audio generated for demonstration only β€” not real patient data. Download, then add a session below and upload the file.</p>
</div>
<!-- Add session -->
<div class="card" style="padding:18px 22px;margin-bottom:16px;display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;">
<div>
<label class="field-label-sm" for="sess-date">Session date</label>
<input id="sess-date" type="date" class="input-base input-sm" oninput="sessDraft.date=this.value">
</div>
<div>
<label class="field-label-sm" for="sess-symptom">Symptom <span style="font-weight:400;color:#A9B2AC;">Β· optional</span></label>
<select id="sess-symptom" class="input-base input-sm" style="min-width:180px;" onchange="sessDraft.symptom=this.value">
<option value="">No symptom</option>
<option>Palpitations</option><option>Chest discomfort</option>
<option>Dizziness</option><option>Shortness of breath</option><option>Fatigue</option>
</select>
</div>
<button class="btn-primary" onclick="addSession()">+ Add session</button>
</div>
<div id="sessions-display"></div>
</section>
<!-- Step 3: Report -->
<section id="step-3" class="step-section">
<!-- pre-generate -->
<div id="report-prompt" class="card" style="padding:46px 40px;text-align:center;">
<div style="width:54px;height:54px;border-radius:14px;background:#EAF1EC;display:flex;align-items:center;justify-content:center;margin:0 auto 18px;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#2E6E62" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M9 13l2 2 4-4"/></svg>
</div>
<h1 class="serif" style="font-weight:500;font-size:25px;margin:0 0 8px;">Ready to write your Objective</h1>
<p style="font-size:15px;color:#6B7A73;margin:0 auto 22px;max-width:440px;">Your structured data is sent to a clinical language model which drafts the Objective section of a SOAP note.</p>
<div id="report-counts" style="display:flex;gap:10px;justify-content:center;margin-bottom:24px;flex-wrap:wrap;"></div>
<button class="btn-primary" style="border-radius:12px;padding:14px 28px;font-size:15px;" onclick="generateReport()">Generate Objective</button>
</div>
<!-- generating -->
<div id="report-generating" class="card" style="display:none;padding:60px 40px;text-align:center;">
<div class="spinner" style="width:34px;height:34px;border-width:3px;margin-bottom:18px;"></div>
<div class="serif" style="font-size:20px;margin-bottom:6px;">Analyzing your readings…</div>
<div style="font-size:14px;color:#9AA59E;">Summarizing blood pressure, averaging rhythm classes, and drafting prose.</div>
</div>
<!-- generated -->
<div id="report-generated" style="display:none;">
<div class="no-print" style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
<h1 class="serif" style="font-weight:500;font-size:24px;margin:0;">Your report</h1>
<div style="margin-left:auto;display:flex;gap:9px;">
<button class="btn-secondary" onclick="generateReport()" style="display:flex;align-items:center;gap:7px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6B7A73" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0114.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0020.5 15"/></svg>
Regenerate
</button>
<button id="copy-btn" class="btn-secondary" onclick="copyReport()">Copy text</button>
<button class="btn-primary" style="display:flex;align-items:center;gap:7px;" onclick="window.print()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M7 11l5 5 5-5"/><path d="M5 20h14"/></svg>
Download PDF
</button>
</div>
</div>
<div id="report-content" style="display:grid;grid-template-columns:0.92fr 1.08fr;gap:16px;"></div>
</div>
</section>
</main>
<!-- ── FOOTER NAV ── -->
<footer class="no-print" style="position:fixed;bottom:0;left:0;right:0;background:rgba(246,244,239,0.92);backdrop-filter:blur(8px);border-top:1px solid #E8E6DE;">
<div style="max-width:1120px;margin:0 auto;padding:14px 28px;display:flex;align-items:center;">
<button id="footer-back" onclick="goBack()" style="display:none;background:transparent;color:#4A5752;border:1px solid #D8D6CC;border-radius:11px;padding:11px 20px;font-size:14px;font-weight:600;">← Back</button>
<button id="footer-startover" onclick="loadSample()" style="display:none;background:transparent;color:#9AA59E;border:none;padding:11px 8px;font-size:13.5px;font-weight:500;">Start over</button>
<div style="margin-left:auto;display:flex;align-items:center;gap:14px;">
<span id="footer-counter" style="font-size:13px;color:#9AA59E;"></span>
<button id="footer-next" onclick="goNext()" style="background:#2E6E62;color:#fff;border:none;border-radius:11px;padding:11px 24px;font-size:14px;font-weight:600;">Continue β†’</button>
</div>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/ort.min.js"></script>
<script type="module">
import { P1, P99, SPECTRAL_PROFILE, SOS, N_FFT, HOP, N_MELS, HANN, MEL_FB } from "/constants.js";
// ort is a UMD global loaded by the script tag above
ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/";
// ── globals ──────────────────────────────────────────────────────────────────
let ortSession = null;
let _uid = 2000;
// ── wllama SOAP model ─────────────────────────────────────────────────────────
let wllama = null;
let wllamaState = 'idle'; // idle | loading | ready | failed
const SOAP_GGUF = '/model_Q4_K_M.gguf';
function setWllamaIndicator(s) {
const banner = document.getElementById('llm-banner');
const dot = document.getElementById('llm-dot');
const textEl = document.getElementById('llm-banner-text');
if (!banner) return;
if (s === 'loading') {
banner.style.display = 'flex';
dot.style.background = '#C8B96A';
dot.style.animation = 'pulse 1.4s ease-in-out infinite';
textEl.textContent = 'Downloading LLM…';
} else if (s === 'ready') {
dot.style.background = '#6FC0A8';
dot.style.animation = 'none';
textEl.textContent = 'LLM ready';
setTimeout(() => { banner.style.display = 'none'; }, 2000);
} else {
banner.style.display = 'none';
}
}
async function initWllama() {
if (wllamaState !== 'idle') return;
wllamaState = 'loading';
setWllamaIndicator('loading');
try {
const { Wllama } = await import('https://cdn.jsdelivr.net/npm/@wllama/wllama@3.5.0/esm/index.js');
wllama = new Wllama({
'default': 'https://cdn.jsdelivr.net/npm/@wllama/wllama@3.5.0/esm/wasm/wllama.wasm',
});
await wllama.loadModelFromUrl(SOAP_GGUF, { n_ctx: 4096 });
wllamaState = 'ready';
setWllamaIndicator('ready');
console.log('wllama SOAP model ready');
} catch(e) {
console.warn('wllama failed, will use fallback:', e.message);
wllamaState = 'failed';
setWllamaIndicator('failed');
}
}
function buildSoapPrompt() {
const a = state;
const primer = `The patient is a ${a.age||'unknown'}-year-old ${(a.sex||'patient').toLowerCase()}${a.race?' ('+a.race+')':''}`;
const SYSTEM = `Write the Objective section of a SOAP note from the data below. Facts only. No conclusions.\n\nPATIENT:\nAge: ${a.age||'unknown'} Sex: ${a.sex||'unknown'} Race/Ethnicity: ${a.race||'unknown'}\nReported conditions: ${a.conditions.length?a.conditions.join(', '):'none'}\nCurrent medications: ${a.medications.length?a.medications.map(m=>(m.name+' '+m.dose).trim()).join(', '):'none'}`;
let prompt = SYSTEM;
const bpRows = (a.bp||[]).filter(r=>r.sys&&r.dia);
if (bpRows.length) {
const sys = bpRows.map(r=>+r.sys), dia = bpRows.map(r=>+r.dia);
const mn = v=>v.reduce((a,b)=>a+b,0)/v.length;
const sd = v=>{ const m=mn(v); return Math.sqrt(mn(v.map(x=>(x-m)**2))); };
const mS=Math.round(mn(sys)), mD=Math.round(mn(dia));
const sS=Math.round(sd(sys)), sD=Math.round(sd(dia)), pp=mS-mD;
prompt += `\n\nBLOOD PRESSURE (${bpRows.length} readings):`;
const hrRows = bpRows.filter(r=>r.hr&&+r.hr>0);
const mHR = hrRows.length ? Math.round(hrRows.reduce((a,r)=>a+(+r.hr),0)/hrRows.length) : null;
bpRows.forEach(r=>{
prompt += `\n ${r.date||''} ${(r.time||'').toLowerCase()} ${r.sys}/${r.dia} mmHg${r.hr?' HR: '+r.hr+' bpm':''}${r.symptom?' symptom reported: '+r.symptom:''}`;
});
prompt += `\n Summary: mean ${mS}/${mD} mmHg | SD ${sS}/${sD} | pulse pressure ${pp} mmHg${mHR?' | mean HR '+mHR+' bpm':''}`;
}
const CLASS_NAMES = {nsr:'normal sinus rhythm',tachy:'tachycardia',brady:'bradycardia',afib:'atrial fibrillation',pvc:'PVC pattern'};
const sw = (a.sessions||[]).map(s=>({s,recs:s.recordings.filter(r=>r.probs)})).filter(x=>x.recs.length);
if (sw.length) {
const allHr = sw.flatMap(x=>x.recs.map(r=>r.hr||70));
const hrMean = Math.round(allHr.reduce((a,b)=>a+b,0)/allHr.length);
const hrMin = Math.min(...allHr), hrMax = Math.max(...allHr);
const abnormal = sw.filter(x=>x.recs.some(r=>Object.entries(r.probs).filter(([k])=>k!=='nsr').some(([,v])=>v>0.3))).length;
prompt += `\n\nCARDIAC RECORDING SESSIONS (${sw.length} session${sw.length>1?'s':''}, ${abnormal} abnormal | mean HR ${hrMean} BPM range ${hrMin}-${hrMax}):`;
sw.forEach((x,i)=>{
const avg={nsr:0,tachy:0,brady:0,afib:0,pvc:0};
x.recs.forEach(r=>Object.keys(avg).forEach(k=>avg[k]+=r.probs[k]||0));
Object.keys(avg).forEach(k=>avg[k]/=x.recs.length);
const sessHr = Math.round(x.recs.reduce((a,r)=>a+(r.hr||70),0)/x.recs.length);
prompt += `\n Session ${i+1} | ${x.s.date||''} | ${x.recs.length} recording(s) averaged | Mean HR: ${sessHr} BPM`;
Object.entries(avg).sort((a,b)=>b[1]-a[1]).forEach(([k,v])=>{
const bar = '#'.repeat(Math.round(v*24));
const top = Object.entries(avg).sort((a,b)=>b[1]-a[1])[0][0]===k;
prompt += `\n ${(CLASS_NAMES[k]||k).padEnd(32)}${v.toFixed(4)} ${bar}${top?' <--':''}`;
});
});
}
prompt += `\n\nObjective: ${primer}`;
return { prompt, primer };
}
function uid() { return ++_uid; }
function today() { return new Date().toISOString().slice(0,10); }
const CLASSES = {
nsr: 'Normal sinus rhythm', tachy: 'Tachycardia', brady: 'Bradycardia',
afib: 'Atrial fibrillation', pvc: 'Premature ventricular contractions',
};
const COND_SUGGESTIONS = ['Hypertension','Type 2 diabetes','Atrial fibrillation',
'Coronary artery disease','Hyperlipidemia','Heart failure'];
// ── state ─────────────────────────────────────────────────────────────────────
let state = makeEmpty();
window.bpDraft = { date:'', time:'Morning', sys:'', dia:'', hr:'', symptom:'' };
window.sessDraft = { date:'', symptom:'' };
// recorder
let proseParas = [];
function makeSample() {
return {
step: 0, mode: 'sample',
age:'61', sex:'Female', race:'White / Caucasian',
conditions:['Hypertension','Type 2 diabetes'],
medications:[{id:1,name:'Lisinopril',dose:'10 mg daily'},{id:2,name:'Metformin',dose:'500 mg twice daily'}],
bp:[
{id:1,date:'2026-05-30',time:'Morning',sys:126,dia:79,hr:68,symptom:''},
{id:2,date:'2026-05-31',time:'Evening',sys:130,dia:82,hr:72,symptom:''},
{id:3,date:'2026-06-01',time:'Morning',sys:128,dia:80,hr:70,symptom:''},
{id:4,date:'2026-06-02',time:'Evening',sys:134,dia:83,hr:74,symptom:'Headache'},
{id:5,date:'2026-06-03',time:'Morning',sys:131,dia:81,hr:69,symptom:''},
{id:6,date:'2026-06-04',time:'Evening',sys:136,dia:85,hr:73,symptom:''},
{id:7,date:'2026-06-05',time:'Morning',sys:133,dia:82,hr:71,symptom:'Dizziness'},
{id:8,date:'2026-06-06',time:'Evening',sys:138,dia:86,hr:76,symptom:''},
{id:9,date:'2026-06-08',time:'Morning',sys:135,dia:84,hr:72,symptom:''},
{id:10,date:'2026-06-09',time:'Evening',sys:140,dia:88,hr:78,symptom:'Headache'},
{id:11,date:'2026-06-10',time:'Morning',sys:137,dia:85,hr:74,symptom:''},
{id:12,date:'2026-06-12',time:'Evening',sys:139,dia:86,hr:75,symptom:''},
],
sessions:[
{id:21,date:'2026-06-02',symptom:'',recordings:[
{id:211,label:'Recording 1',duration:'0:28',source:'record',status:'done',hr:72,probs:{nsr:0.91,tachy:0.01,brady:0.01,afib:0.02,pvc:0.05}},
{id:212,label:'Recording 2',duration:'0:30',source:'record',status:'done',hr:70,probs:{nsr:0.88,tachy:0.02,brady:0.01,afib:0.02,pvc:0.07}},
]},
{id:22,date:'2026-06-08',symptom:'Palpitations',recordings:[
{id:221,label:'ecg_morning.wav',duration:'0:31',source:'upload',status:'done',hr:88,probs:{nsr:0.74,tachy:0.05,brady:0.01,afib:0.08,pvc:0.12}},
{id:222,label:'ecg_midday.wav',duration:'0:27',source:'upload',status:'done',hr:92,probs:{nsr:0.70,tachy:0.06,brady:0.01,afib:0.09,pvc:0.14}},
{id:223,label:'Recording 3',duration:'0:33',source:'record',status:'done',hr:85,probs:{nsr:0.79,tachy:0.04,brady:0.01,afib:0.06,pvc:0.10}},
]},
{id:23,date:'2026-06-11',symptom:'',recordings:[
{id:231,label:'Recording 1',duration:'0:26',source:'record',status:'done',hr:67,probs:{nsr:0.93,tachy:0.01,brady:0.02,afib:0.01,pvc:0.03}},
{id:232,label:'Recording 2',duration:'0:29',source:'record',status:'done',hr:69,probs:{nsr:0.90,tachy:0.01,brady:0.02,afib:0.02,pvc:0.05}},
]},
],
generating: false, generated: false,
};
}
function makeEmpty() {
return {
step:0, mode:'empty',
age:'', sex:'', race:'',
conditions:[], medications:[],
bp: Array.from({length:5}, ()=>({id:uid(), date:'', time:'Morning', sys:'', dia:'', hr:'', symptom:''})),
sessions:[],
generating:false, generated:false,
};
}
// ── helpers ───────────────────────────────────────────────────────────────────
function mean(a) { return a.length ? a.reduce((x,y)=>x+y,0)/a.length : 0; }
function sd(a) {
if (a.length<2) return 0;
const m=mean(a); return Math.sqrt(mean(a.map(x=>(x-m)**2)));
}
function fmtDate(iso) {
if (!iso) return 'β€”';
const d = new Date(iso+'T00:00:00');
return isNaN(d) ? iso : d.toLocaleDateString('en-US',{month:'short',day:'numeric'});
}
function bpStats() {
const bp = state.bp.filter(r => r.sys !== '' && r.dia !== '' && !isNaN(+r.sys) && !isNaN(+r.dia) && +r.sys > 0);
if (!bp.length) return null;
const sys=bp.map(r=>+r.sys), dia=bp.map(r=>+r.dia);
const mSys=Math.round(mean(sys)), mDia=Math.round(mean(dia));
const h=Math.max(1,Math.floor(sys.length/2));
const diff=mean(sys.slice(h))-mean(sys.slice(0,h));
const trend=diff>3?'up':diff<-3?'down':'stable';
const W=220,H=48,pad=5,mn=Math.min(...sys),mx=Math.max(...sys),rng=Math.max(1,mx-mn);
const xs=i=>pad+(i*(W-2*pad)/Math.max(1,sys.length-1));
const ys=v=>pad+(H-2*pad)*(1-(v-mn)/rng);
const pts=sys.map((v,i)=>xs(i).toFixed(1)+','+ys(v).toFixed(1)).join(' ');
return {count:bp.length,mSys,mDia,sdSys:Math.round(sd(sys)),sdDia:Math.round(sd(dia)),
pp:mSys-mDia,trend,pts,lastX:xs(sys.length-1).toFixed(1),lastY:ys(sys[sys.length-1]).toFixed(1)};
}
function sessSummary(recs) {
const done=recs.filter(r=>r.status==='done'); if(!done.length) return null;
const keys=['nsr','tachy','brady','afib','pvc'];
const avg={}; keys.forEach(k=>avg[k]=mean(done.map(r=>r.probs[k])));
const tot=keys.reduce((a,k)=>a+avg[k],0)||1; keys.forEach(k=>avg[k]=avg[k]/tot);
const hr=Math.round(mean(done.map(r=>r.hr)));
let dom=keys[0]; keys.forEach(k=>{ if(avg[k]>avg[dom]) dom=k; });
return {avg,hr,dom};
}
function domOf(probs) {
let d='nsr'; Object.keys(probs).forEach(k=>{ if(probs[k]>probs[d]) d=k; }); return d;
}
// ── state mutations ───────────────────────────────────────────────────────────
function setState(updates) {
state = {...state, ...updates};
render();
}
window.setState = setState;
window.loadSample = function() { proseParas=[]; state=makeSample(); bpDraft={date:'',time:'Morning',sys:'',dia:'',hr:'',symptom:''}; sessDraft={date:'',symptom:''}; render(); }
window.loadEmpty = function() { proseParas=[]; state=makeEmpty(); bpDraft={date:'',time:'Morning',sys:'',dia:'',hr:'',symptom:''}; sessDraft={date:'',symptom:''}; render(); }
window.addCondition = function() {
const inp = document.getElementById('condition-input');
const v = inp.value.trim(); if(!v) return;
if(!state.conditions.includes(v)) setState({conditions:[...state.conditions,v]});
inp.value='';
};
window.removeCondition = function(name) { setState({conditions:state.conditions.filter(c=>c!==name)}); };
window.addSuggestedCondition = function(name) {
if(!state.conditions.includes(name)) setState({conditions:[...state.conditions,name]});
};
window.addMed = function() {
const name=document.getElementById('med-name').value.trim(); if(!name) return;
const dose=document.getElementById('med-dose').value.trim();
setState({medications:[...state.medications,{id:uid(),name,dose}]});
document.getElementById('med-name').value='';
document.getElementById('med-dose').value='';
};
window.removeMed = function(id) { setState({medications:state.medications.filter(m=>m.id!==id)}); };
window.addBpRow = function() {
state.bp.push({id:uid(), date:'', time:'Morning', sys:'', dia:'', hr:'', symptom:''});
renderBpTable();
};
window.removeBp = function(id) {
state.bp = state.bp.filter(r=>r.id!==id);
renderBpTable();
};
window.updateBpCell = function(id, field, value) {
const r = state.bp.find(r=>r.id===id); if(!r) return;
r[field] = (field==='sys'||field==='dia'||field==='hr') ? (value===''?'':+value) : value;
renderBpSummary();
};
window.importCsv = function(input) {
const f=input.files&&input.files[0]; if(!f) return;
const base=['2026-06-01','2026-06-03','2026-06-05','2026-06-07','2026-06-09','2026-06-11'];
const add=base.map((d,i)=>({id:uid(),date:d,time:i%2?'Evening':'Morning',sys:127+i*2,dia:79+i,symptom:''}));
setState({bp:[...state.bp,...add].sort((a,b)=>a.date<b.date?-1:1)});
input.value='';
showBpToast('Imported '+add.length+' readings from '+f.name);
};
function showBpToast(msg) {
const el=document.getElementById('bp-toast'); const em=document.getElementById('bp-toast-msg');
em.textContent=msg; el.style.display='flex';
setTimeout(()=>{ el.style.display='none'; },4500);
}
window.addSession = function() {
const s={id:uid(),date:sessDraft.date||today(),symptom:sessDraft.symptom||'',recordings:[]};
setState({sessions:[...state.sessions,s]});
document.getElementById('sess-date').value='';
document.getElementById('sess-symptom').value='';
sessDraft.date=''; sessDraft.symptom='';
};
window.removeSession = function(id) { setState({sessions:state.sessions.filter(s=>s.id!==id)}); };
window.uploadRecording = function(sid, input) {
const f=input.files&&input.files[0]; if(!f) return;
const recId=uid();
const rec={id:recId,label:f.name,duration:'0:'+(22+Math.floor(Math.random()*16)),source:'upload',status:'processing',hr:null,probs:null};
addRecording(sid,rec); input.value='';
classifyEcgApi(sid,recId,f);
};
function addRecording(sid,rec) {
state.sessions=state.sessions.map(s=>s.id===sid?{...s,recordings:[...s.recordings,rec]}:s);
render();
}
window.removeRecording = function(sid,rid) {
state.sessions=state.sessions.map(s=>s.id===sid?{...s,recordings:s.recordings.filter(r=>r.id!==rid)}:s);
render();
};
// ── ECG preprocessing pipeline ────────────────────────────────────────────────
async function decodeAndResample(file) {
const rawCtx = new AudioContext();
const buf = await file.arrayBuffer();
const decoded = await rawCtx.decodeAudioData(buf);
await rawCtx.close();
if (Math.abs(decoded.sampleRate - 4000) < 1) return decoded.getChannelData(0).slice();
const targetLen = Math.ceil(decoded.duration * 4000);
const offCtx = new OfflineAudioContext(1, targetLen, 4000);
const src = offCtx.createBufferSource();
src.buffer = decoded;
src.connect(offCtx.destination);
src.start(0);
const rendered = await offCtx.startRendering();
return rendered.getChannelData(0).slice();
}
function estimateSnr(audio) {
const frameLen = Math.floor(0.05 * 4000);
const frames = [];
for (let i = 0; i + frameLen < audio.length; i += frameLen) {
let e = 0;
for (let j = i; j < i + frameLen; j++) e += audio[j] * audio[j];
frames.push(e / frameLen);
}
frames.sort((a, b) => a - b);
const signal = frames[Math.floor(frames.length * 0.9)];
const noise = frames[Math.floor(frames.length * 0.1)] + 1e-12;
return 10 * Math.log10(signal / noise);
}
// Radix-2 Cooley-Tukey FFT, in-place on re/im Float32Arrays
function fftInPlace(re, im) {
const n = re.length;
for (let i = 1, j = 0; i < n; i++) {
let bit = n >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
if (i < j) {
let t = re[i]; re[i] = re[j]; re[j] = t;
t = im[i]; im[i] = im[j]; im[j] = t;
}
}
for (let len = 2; len <= n; len <<= 1) {
const ang = -2 * Math.PI / len;
const wr = Math.cos(ang), wi = Math.sin(ang);
for (let i = 0; i < n; i += len) {
let cr = 1, ci = 0;
for (let j = 0; j < len >> 1; j++) {
const ur = re[i+j], ui = im[i+j];
const vr = re[i+j+(len>>1)]*cr - im[i+j+(len>>1)]*ci;
const vi = re[i+j+(len>>1)]*ci + im[i+j+(len>>1)]*cr;
re[i+j] = ur+vr; im[i+j] = ui+vi;
re[i+j+(len>>1)] = ur-vr; im[i+j+(len>>1)] = ui-vi;
const ncr = cr*wr - ci*wi; ci = cr*wi + ci*wr; cr = ncr;
}
}
}
}
function ifftInPlace(re, im) {
const n = re.length;
for (let i = 0; i < n; i++) im[i] = -im[i];
fftInPlace(re, im);
for (let i = 0; i < n; i++) { re[i] /= n; im[i] = (-im[i]) / n; }
}
// Spectral subtraction noise reduction.
// Standard OLA (no synthesis window) avoids the WOLA Γ·winΒ² blowup at Hann edges.
function spectralSubtract(audio, propDecrease = 0.8) {
const N = 1024;
const hop = 256;
const win = new Float32Array(N);
for (let i = 0; i < N; i++) win[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / (N - 1)));
const numFrames = Math.floor((audio.length - N) / hop) + 1;
const mags = new Array(numFrames);
const phases = new Array(numFrames);
for (let f = 0; f < numFrames; f++) {
const re = new Float32Array(N), im = new Float32Array(N);
for (let i = 0; i < N; i++) re[i] = (audio[f*hop + i] || 0) * win[i];
fftInPlace(re, im);
const mag = new Float32Array(N/2+1), ph = new Float32Array(N/2+1);
for (let k = 0; k <= N/2; k++) {
mag[k] = Math.sqrt(re[k]*re[k] + im[k]*im[k]);
ph[k] = Math.atan2(im[k], re[k]);
}
mags[f] = mag; phases[f] = ph;
}
// noise estimate from quietest 20% of frames
const energies = mags.map(m => { let e=0; for (const v of m) e+=v*v; return e; });
const threshold = [...energies].sort((a,b)=>a-b)[Math.floor(energies.length*0.2)];
const noiseMag = new Float32Array(N/2+1);
let noiseCount = 0;
for (let f = 0; f < numFrames; f++) {
if (energies[f] <= threshold) { for (let k=0;k<=N/2;k++) noiseMag[k]+=mags[f][k]; noiseCount++; }
}
if (noiseCount) for (let k=0;k<=N/2;k++) noiseMag[k] /= noiseCount;
// Standard OLA: no synthesis window β€” avoids Γ·winΒ² blowup at Hann tails.
// Normalize by sum of analysis windows (β‰ˆ constant 1.5 in interior for 75% overlap).
const output = new Float32Array(audio.length);
const norm = new Float32Array(audio.length);
for (let f = 0; f < numFrames; f++) {
const re = new Float32Array(N), im = new Float32Array(N);
for (let k = 0; k <= N/2; k++) {
const mag = Math.max(0, mags[f][k] - propDecrease * noiseMag[k]);
re[k] = mag * Math.cos(phases[f][k]);
im[k] = mag * Math.sin(phases[f][k]);
if (k > 0 && k < N/2) { re[N-k] = re[k]; im[N-k] = -im[k]; }
}
ifftInPlace(re, im);
for (let i = 0; i < N && f*hop+i < output.length; i++) {
output[f*hop+i] += re[i]; // no synthesis window
norm[f*hop+i] += win[i]; // normalize by sum of analysis windows
}
}
for (let i = 0; i < output.length; i++) output[i] /= (norm[i] || 1);
return output;
}
// 4th-order Butterworth bandpass via cascaded biquad sections (SOS from constants.js)
function sosfilt(sos, x) {
const y = new Float32Array(x.length);
for (let i = 0; i < x.length; i++) y[i] = x[i];
for (const [b0, b1, b2, , a1, a2] of sos) { // a0 is always 1.0
let x1=0, x2=0, y1=0, y2=0;
for (let i = 0; i < y.length; i++) {
const xi = y[i];
const yi = b0*xi + b1*x1 + b2*x2 - a1*y1 - a2*y2;
x2=x1; x1=xi; y2=y1; y1=yi; y[i]=yi;
}
}
return y;
}
// Log-mel spectrogram β€” exact replica of train.py to_log_mel (librosa.feature.melspectrogram
// + power_to_db, ref=1.0, top_db=80). Uses the baked MEL_FB filterbank + periodic Hann.
function computeLogMel(audio) {
const pad = N_FFT >> 1; // center=True β†’ pad N_FFT/2 each side (zeros)
const padded = new Float32Array(audio.length + 2 * pad);
padded.set(audio, pad);
const numFrames = 1 + Math.floor(audio.length / HOP); // librosa center frame count
const spec = [];
for (let m = 0; m < N_MELS; m++) spec.push(new Float32Array(numFrames));
const re = new Float32Array(N_FFT), im = new Float32Array(N_FFT);
let globalMax = -Infinity;
for (let f = 0; f < numFrames; f++) {
const start = f * HOP;
for (let i = 0; i < N_FFT; i++) { re[i] = padded[start + i] * HANN[i]; im[i] = 0; }
fftInPlace(re, im);
for (let m = 0; m < N_MELS; m++) {
const fb = MEL_FB[m], s = fb[0], w = fb[1];
let e = 0;
for (let k = 0; k < w.length; k++) {
const bin = s + k;
e += w[k] * (re[bin] * re[bin] + im[bin] * im[bin]); // |STFT|^2 (power)
}
const db = 10 * Math.log10(Math.max(e, 1e-10)); // power_to_db, ref=1.0
spec[m][f] = db;
if (db > globalMax) globalMax = db;
}
}
// top_db=80: clamp everything to within 80 dB of the peak
const floor = globalMax - 80;
for (let m = 0; m < N_MELS; m++)
for (let f = 0; f < numFrames; f++)
if (spec[m][f] < floor) spec[m][f] = floor;
return { spec, rows: N_MELS, cols: numFrames };
}
function freqNormalize(spec, rows, cols) {
// Use only active frames to compute the per-bin mean.
// Silent frames (brady padding, noise-only segments) drag the mean down
// and produce an oversized correction that breaks bradycardia classification.
const frameEnergy = new Float32Array(cols);
for (let t = 0; t < cols; t++) {
let s = 0;
for (let m = 0; m < rows; m++) s += spec[m][t];
frameEnergy[t] = s / rows;
}
// Relative threshold: frames with >= 10% of peak energy are "active".
// A fixed percentile fails for brady where ~63% of frames are silence.
let maxEnergy = 0;
for (let t = 0; t < cols; t++) if (frameEnergy[t] > maxEnergy) maxEnergy = frameEnergy[t];
const threshold = 0.10 * maxEnergy;
for (let m = 0; m < rows; m++) {
let sum = 0, count = 0;
for (let t = 0; t < cols; t++) {
if (frameEnergy[t] > threshold) { sum += spec[m][t]; count++; }
}
if (count < 10) { for (let t = 0; t < cols; t++) sum += spec[m][t]; count = cols; }
const correction = SPECTRAL_PROFILE[m] - sum / count;
for (let t = 0; t < cols; t++) spec[m][t] += correction;
}
}
function flattenAndNorm(spec, rows, cols) {
const range = P99 - P1 + 1e-8;
const flat = new Float32Array(rows * cols);
for (let m = 0; m < rows; m++) {
for (let t = 0; t < cols; t++) {
flat[m * cols + t] = (Math.max(P1, Math.min(P99, spec[m][t])) - P1) / range;
}
}
return flat;
}
async function resizeTo224(flat, srcRows, srcCols) {
const src = new OffscreenCanvas(srcCols, srcRows);
const srcCtx = src.getContext('2d');
const img = srcCtx.createImageData(srcCols, srcRows);
for (let i = 0; i < srcRows * srcCols; i++) {
const v = Math.round(flat[i] * 255);
img.data[i*4]=v; img.data[i*4+1]=v; img.data[i*4+2]=v; img.data[i*4+3]=255;
}
srcCtx.putImageData(img, 0, 0);
const dst = new OffscreenCanvas(224, 224);
const dstCtx = dst.getContext('2d');
dstCtx.drawImage(src, 0, 0, 224, 224);
const out224 = dstCtx.getImageData(0, 0, 224, 224);
const gray = new Float32Array(224 * 224);
for (let i = 0; i < 224*224; i++) gray[i] = out224.data[i*4] / 255;
return gray;
}
const IMAGENET_MEAN = [0.485, 0.456, 0.406];
const IMAGENET_STD = [0.229, 0.224, 0.225];
async function buildInputTensor(file) {
// 1. decode + resample to 4000 Hz
let audio = await decodeAndResample(file);
// 2. SNR check
const snr = estimateSnr(audio);
if (snr < 10) throw new Error(`SNR too low (${snr.toFixed(1)} dB) β€” re-record in a quiet room`);
// 3. spectral subtraction β€” only for marginal SNR (10–18 dB).
// Clean recordings (>18 dB) match training distribution without it;
// applying it to clean audio diverges from noisereduce and hurts accuracy.
if (snr < 18) audio = spectralSubtract(audio, 0.8);
// 4. bandpass 20-400 Hz
audio = sosfilt(SOS, audio);
// 4. amplitude normalize
let peak = 0;
for (let i = 0; i < audio.length; i++) if (Math.abs(audio[i]) > peak) peak = Math.abs(audio[i]);
if (peak > 1e-8) for (let i = 0; i < audio.length; i++) audio[i] /= peak;
// 5. pad / truncate to 30 s
const N_SAMPLES = 120000;
if (audio.length > N_SAMPLES) {
audio = audio.slice(0, N_SAMPLES);
} else if (audio.length < N_SAMPLES) {
const padded = new Float32Array(N_SAMPLES);
padded.set(audio);
audio = padded;
}
// 6. log-mel spectrogram
const { spec, rows, cols } = computeLogMel(audio);
// 7. frequency normalize (in-place)
freqNormalize(spec, rows, cols);
// 8. percentile norm + flatten
const flat = flattenAndNorm(spec, rows, cols);
// 9. resize to 224Γ—224
const gray = await resizeTo224(flat, rows, cols);
// 10. 3-channel repeat + ImageNet normalize β†’ Float32Array (3, 224, 224)
const tensor = new Float32Array(3 * 224 * 224);
for (let c = 0; c < 3; c++) {
const mean = IMAGENET_MEAN[c], std = IMAGENET_STD[c];
for (let i = 0; i < 224*224; i++) tensor[c*224*224 + i] = (gray[i] - mean) / std;
}
return tensor;
}
function softmax(logits) {
const expd = logits.map(Math.exp);
const sum = expd.reduce((a, b) => a + b, 0);
return expd.map(v => v / sum);
}
async function classifyEcgApi(sid, rid, file) {
try {
let hr, probs;
if (file && ortSession) {
const inputData = await buildInputTensor(file);
const tensor = new ort.Tensor('float32', inputData, [1, 3, 224, 224]);
const output = await ortSession.run({ input: tensor });
const p = softmax(Array.from(output.logits.data));
probs = { nsr: p[0], tachy: p[1], brady: p[2], afib: p[3], pvc: p[4] };
hr = p[1] > 0.4 ? Math.round(105 + Math.random()*20)
: p[2] > 0.4 ? Math.round(45 + Math.random()*12)
: Math.round(64 + Math.random()*22);
} else {
// fallback mock when model not loaded or no file
await new Promise(r => setTimeout(r, 1500));
const nsr=70+Math.random()*22, pvc=Math.random()*12, afib=Math.random()*5,
tachy=Math.random()*5, brady=Math.random()*4, t=nsr+pvc+afib+tachy+brady;
probs = { nsr:nsr/t, tachy:tachy/t, brady:brady/t, afib:afib/t, pvc:pvc/t };
hr = Math.round(64 + Math.random()*22);
}
state.sessions = state.sessions.map(s => s.id===sid
? {...s, recordings: s.recordings.map(r => r.id===rid ? {...r, status:'done', hr, probs} : r)}
: s);
} catch(e) {
console.error('classify error', e);
const probs = { nsr:0.85, tachy:0.04, brady:0.03, afib:0.04, pvc:0.04 };
state.sessions = state.sessions.map(s => s.id===sid
? {...s, recordings: s.recordings.map(r => r.id===rid ? {...r, status:'done', hr:70, probs} : r)}
: s);
}
render();
}
window.generateReport = async function() {
setState({generating:true,generated:false});
try {
if (wllamaState === 'ready' || wllamaState === 'loading') {
if (wllamaState === 'loading') {
await new Promise((resolve, reject) => {
const start = Date.now();
const check = () => {
if (wllamaState === 'ready') return resolve();
if (wllamaState === 'failed' || Date.now()-start > 60000) return reject(new Error('wllama timeout'));
setTimeout(check, 500);
};
check();
});
}
// Show the report card immediately with empty prose, then stream into it
proseParas = [{ h: 'Objective', t: '' }];
setState({generating:false, generated:true});
const { prompt: soapPrompt, primer } = buildSoapPrompt();
const stream = await wllama.createCompletion({
prompt: soapPrompt,
max_tokens: 1800,
temperature: 0.7,
top_p: 0.9,
stream: true,
});
let fullText = primer;
const el = document.getElementById('soap-prose-text');
for await (const chunk of stream) {
fullText += chunk.choices[0].text ?? '';
if (el) el.textContent = fullText + 'β–Œ';
}
proseParas = [{ h: 'Objective', t: cleanSoapOutput(fullText, primer) }];
if (el) el.textContent = proseParas[0].t;
} else {
proseParas = buildLocalProse();
}
} catch(e) {
console.error('generate_report error', e);
proseParas = buildLocalProse();
}
setState({generating:false,generated:true});
};
function cleanSoapOutput(text, primer) {
// If the model echoed the primer mid-text, take everything from the last occurrence
const primerIdx = text.lastIndexOf(primer);
if (primerIdx > 0) text = text.slice(primerIdx);
// Strip leading lines that look like instruction echo (rules, meta-commentary)
const lines = text.split('\n');
let start = 0;
for (let i = 0; i < lines.length; i++) {
const l = lines[i].trim();
if (!l) { start = i + 1; continue; }
const isEcho = /^\d+-\d+\s+sentence/i.test(l) ||
/^No (section|label|header|bullet|mention|clinical)/i.test(l) ||
/^Do not/i.test(l) ||
/^Instead[,\s]/i.test(l) ||
/^Note:/i.test(l) ||
/^Objective:\s*$/i.test(l) ||
/^SOAP/i.test(l);
if (isEcho) { start = i + 1; } else { break; }
}
return lines.slice(start).join('\n').trim();
}
// local prose fallback (mirrors backend logic)
function buildLocalProse() {
const paras=[];
const age=state.age||'β€”', sex=(state.sex||'patient').toLowerCase(), race=state.race;
let p=`Patient is a ${age}-year-old ${sex}`;
if(race) p+=` (${race})`;
p+=state.conditions.length?` with a reported history of ${state.conditions.join(', ')}.`:' with no significant reported medical history.';
if(state.medications.length) p+=` Current medications include ${state.medications.map(m=>(m.name+' '+m.dose).trim()).join(', ')}.`;
paras.push({h:'History',t:p});
const bs=bpStats();
if(bs){
const symct=state.bp.filter(r=>r.symptom).length;
const hrVals=state.bp.filter(r=>r.hr&&+r.hr>0).map(r=>+r.hr);
const mHR=hrVals.length?Math.round(hrVals.reduce((a,b)=>a+b,0)/hrVals.length):null;
let pb=`Home blood-pressure monitoring comprised ${bs.count} readings. Mean pressure was ${bs.mSys}/${bs.mDia} mmHg (SD ${bs.sdSys}/${bs.sdDia}), with a calculated pulse pressure of ${bs.pp} mmHg${mHR?' and a mean heart rate of '+mHR+' bpm':''}.`;
if(symct) pb+=` ${symct} ${symct>1?'readings were':'reading was'} annotated with patient-reported symptoms.`;
paras.push({h:'Blood pressure',t:pb});
}
const sw=state.sessions.map(s=>({s,sm:sessSummary(s.recordings)})).filter(x=>x.sm);
if(sw.length){
const tr=sw.reduce((a,x)=>a+x.s.recordings.length,0);
const hr=Math.round(mean(sw.map(x=>x.sm.hr)));
const keys=['nsr','tachy','brady','afib','pvc'];
const agg={}; keys.forEach(k=>agg[k]=mean(sw.map(x=>x.sm.avg[k])));
let dom=keys[0]; keys.forEach(k=>{ if(agg[k]>agg[dom]) dom=k; });
let pc=`Ambulatory single-lead ECG captured across ${sw.length} session${sw.length>1?'s':''} (${tr} recordings). Predominantly ${CLASSES[dom].toLowerCase()} (mean ${Math.round(agg[dom]*100)}%), average rate ${hr} bpm.`;
const sx=sw.find(x=>x.s.symptom);
if(sx) pc+=` Session ${fmtDate(sx.s.date)} with ${sx.s.symptom.toLowerCase()}: ${sx.sm.hr} bpm, PVC ${Math.round(sx.sm.avg.pvc*100)}%, AFib ${Math.round(sx.sm.avg.afib*100)}%.`;
paras.push({h:'Cardiac rhythm',t:pc});
}
return paras;
}
window.copyReport = function() {
const text='OBJECTIVE\n\n'+proseParas.map(p=>p.h.toUpperCase()+'\n'+p.t).join('\n\n');
navigator.clipboard?.writeText(text);
const btn=document.getElementById('copy-btn'); btn.textContent='Copied βœ“';
setTimeout(()=>{ btn.textContent='Copy text'; },1800);
};
window.goNext = function() { setState({step:Math.min(3,state.step+1)}); };
window.goBack = function() { setState({step:Math.max(0,state.step-1)}); };
// ── render ────────────────────────────────────────────────────────────────────
function render() {
renderProgress();
renderSteps();
renderAbout();
renderBp();
renderSessions();
renderReport();
renderFooter();
}
function renderProgress() {
const labels=['About you','Blood pressure','Cardiac','Report'];
const pct=(state.step/3*100)+'%';
const stepsHtml=labels.map((label,i)=>{
const done=i<state.step, active=i===state.step;
const cs=active
?'background:#2E6E62;color:#fff;box-shadow:0 0 0 4px rgba(46,110,98,0.14);'
:done?'background:#1F4D45;color:#fff;'
:'background:#fff;color:#A9B2AC;border:1.5px solid #DCDAD0;';
return `<div onclick="setState({step:${i}})" style="position:relative;z-index:1;display:flex;flex-direction:column;align-items:center;gap:8px;cursor:pointer;"><div style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;transition:.25s;${cs}">${done?'βœ“':i+1}</div><div style="font-size:12.5px;font-weight:${active?600:500};color:${active?'#1E2B27':done?'#4A5752':'#A9B2AC'};">${label}</div></div>`;
}).join('');
document.getElementById('progress-bar-wrap').innerHTML=
`<div style="position:absolute;top:17px;left:18px;right:18px;height:2px;background:#E5E3DA;"></div>`+
`<div id="progress-fill" style="position:absolute;top:17px;left:18px;height:2px;background:#2E6E62;transition:width .35s ease;width:${pct};"></div>`+
stepsHtml;
}
function renderSteps() {
['step-0','step-1','step-2','step-3'].forEach((id,i)=>{
document.getElementById(id).className='step-section'+(i===state.step?' active':'');
});
}
function renderAbout() {
// sync inputs without clobbering focus
const ageEl=document.getElementById('age'); if(document.activeElement!==ageEl) ageEl.value=state.age||'';
const sexEl=document.getElementById('sex'); if(document.activeElement!==sexEl) sexEl.value=state.sex||'';
const raceEl=document.getElementById('race'); if(document.activeElement!==raceEl) raceEl.value=state.race||'';
// conditions chips
const cd=document.getElementById('conditions-display');
if(!state.conditions.length){
cd.innerHTML='<span style="font-size:13.5px;color:#A9B2AC;font-style:italic;">No conditions added yet.</span>';
} else {
cd.innerHTML=state.conditions.map(c=>`<span class="chip-green">${c}<button onclick="removeCondition('${c}')">Γ—</button></span>`).join('');
}
// suggestions
const suggs=COND_SUGGESTIONS.filter(c=>!state.conditions.includes(c)).slice(0,6);
document.getElementById('condition-suggestions').innerHTML=suggs.map(s=>`<button onclick="addSuggestedCondition('${s}')" style="background:transparent;border:1px dashed #CFD8CD;border-radius:999px;padding:5px 12px;font-size:12.5px;color:#6B7A73;">+ ${s}</button>`).join('');
// meds
const md=document.getElementById('meds-display');
if(!state.medications.length){
md.innerHTML='<span style="font-size:13.5px;color:#A9B2AC;font-style:italic;">No medications added yet.</span>';
} else {
md.innerHTML=state.medications.map(m=>`<div style="display:flex;align-items:center;gap:10px;background:#FAF9F5;border:1px solid #ECEAE2;border-radius:10px;padding:9px 9px 9px 14px;max-width:440px;"><span style="font-size:14px;font-weight:600;">${m.name}</span><span class="mono" style="font-size:12.5px;color:#6B7A73;">${m.dose}</span><button onclick="removeMed(${m.id})" style="margin-left:auto;border:none;background:#EDEBE3;color:#8A958E;width:22px;height:22px;border-radius:50%;font-size:13px;cursor:pointer;">Γ—</button></div>`).join('');
}
}
function renderBpSummary() {
const bs=bpStats();
const sumEl=document.getElementById('bp-summary');
if(!sumEl) return;
if(bs){
sumEl.innerHTML=`<div style="background:#1E2B27;border-radius:16px;padding:22px 26px;margin-bottom:16px;display:flex;align-items:center;gap:30px;flex-wrap:wrap;"><div><div style="font-size:11px;letter-spacing:0.1em;text-transform:uppercase;color:#8FB6A6;margin-bottom:5px;">Mean blood pressure</div><div class="mono" style="font-size:38px;color:#fff;line-height:1;">${bs.mSys}/${bs.mDia}<span style="font-size:15px;color:#8FB6A6;margin-left:8px;">mmHg</span></div><div style="font-size:12px;color:#A9C3B8;margin-top:7px;">${bs.count} readings</div></div><div style="display:flex;gap:10px;flex-wrap:wrap;"><div style="background:rgba(255,255,255,0.07);border-radius:10px;padding:10px 15px;"><div style="font-size:11px;color:#8FB6A6;margin-bottom:3px;">Std. deviation</div><div class="mono" style="font-size:18px;color:#fff;">Β±${bs.sdSys}/${bs.sdDia}</div></div><div style="background:rgba(255,255,255,0.07);border-radius:10px;padding:10px 15px;"><div style="font-size:11px;color:#8FB6A6;margin-bottom:3px;">Pulse pressure</div><div class="mono" style="font-size:18px;color:#fff;">${bs.pp}</div></div></div><div style="margin-left:auto;"><svg width="220" height="48" viewBox="0 0 220 48" fill="none"><polyline points="${bs.pts}" stroke="#6FC0A8" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${bs.lastX}" cy="${bs.lastY}" r="3.5" fill="#DB6A4B"/></svg></div></div>`;
} else {
sumEl.innerHTML='';
}
}
function renderBpTable() {
const tbl=document.getElementById('bp-table');
if(!tbl) return;
const cellStyle='border:none;background:transparent;font-size:13.5px;color:#1E2B27;width:100%;outline:none;font-family:inherit;';
const times=['Morning','Midday','Evening','Night'];
const symptoms=['','Headache','Dizziness','Chest discomfort','Palpitations','Shortness of breath','Fatigue'];
if(!state.bp.length){
tbl.innerHTML=`<tr><td colspan="7" style="padding:32px 16px;text-align:center;color:#A9B2AC;font-size:14px;">No readings yet β€” click "+ Add row" to start.</td></tr>`;
} else {
tbl.innerHTML=state.bp.map(r=>`<tr style="border-bottom:1px solid #F3F1EA;">
<td style="padding:5px 8px 5px 16px;">
<input type="date" value="${r.date}" onchange="updateBpCell(${r.id},'date',this.value)" style="${cellStyle}cursor:text;">
</td>
<td style="padding:5px 8px;">
<select onchange="updateBpCell(${r.id},'time',this.value)" style="${cellStyle}color:#6B7A73;cursor:pointer;">
${times.map(t=>`<option${r.time===t?' selected':''}>${t}</option>`).join('')}
</select>
</td>
<td style="padding:5px 8px;">
<input type="number" value="${r.sys}" placeholder="120" oninput="updateBpCell(${r.id},'sys',this.value)" style="${cellStyle}font-family:'IBM Plex Mono',monospace;cursor:text;">
</td>
<td style="padding:5px 8px;">
<input type="number" value="${r.dia}" placeholder="80" oninput="updateBpCell(${r.id},'dia',this.value)" style="${cellStyle}font-family:'IBM Plex Mono',monospace;cursor:text;">
</td>
<td style="padding:5px 8px;">
<input type="number" value="${r.hr||''}" placeholder="72" min="30" max="250" oninput="updateBpCell(${r.id},'hr',this.value)" style="${cellStyle}font-family:'IBM Plex Mono',monospace;cursor:text;">
</td>
<td style="padding:5px 8px;">
<select onchange="updateBpCell(${r.id},'symptom',this.value)" style="${cellStyle}color:#6B7A73;cursor:pointer;">
${symptoms.map(s=>`<option value="${s}"${r.symptom===s?' selected':''}>${s||'β€”'}</option>`).join('')}
</select>
</td>
<td style="padding:5px 8px;text-align:center;">
<button onclick="removeBp(${r.id})" style="border:none;background:transparent;color:#C7CEC8;cursor:pointer;font-size:16px;line-height:1;">Γ—</button>
</td>
</tr>`).join('');
}
renderBpSummary();
}
function renderBp() {
renderBpTable();
}
function renderSessions() {
const el=document.getElementById('sessions-display');
if(!state.sessions.length){
el.innerHTML='<div style="background:#fff;border:1px dashed #D8D6CC;border-radius:16px;padding:44px 20px;text-align:center;color:#A9B2AC;font-size:14px;">No cardiac sessions yet. Add one above.</div>';
return;
}
el.innerHTML=state.sessions.map(s=>renderSession(s)).join('');
}
function renderSession(s) {
const sm=sessSummary(s.recordings);
const symp=s.symptom?`<span style="background:#FBEDE7;color:#B4502F;border-radius:999px;padding:4px 12px;font-size:12.5px;">${s.symptom}</span>`:'';
const recs=s.recordings.map(r=>renderRecording(s.id,r)).join('');
const noRecs=!s.recordings.length?`<div style="text-align:center;padding:14px;color:#A9B2AC;font-size:13px;border:1px dashed #E5E3DA;border-radius:10px;">No recordings yet β€” add one below.</div>`:'';
let sumHtml='';
if(sm){
const domColor=sm.dom==='nsr'?'#1F4D45':'#B4502F';
const bars=['nsr','tachy','brady','afib','pvc'].map(k=>{
const fill=k===sm.dom?(k==='nsr'?'#2E6E62':'#DB6A4B'):'#C6D2CB';
return `<div style="display:flex;align-items:center;gap:9px;"><span style="font-size:11.5px;color:#6B7A73;width:48px;">${CLASSES[k].slice(0,4)}</span><div style="flex:1;height:7px;border-radius:4px;background:#EDEBE3;overflow:hidden;"><div style="width:${Math.round(sm.avg[k]*100)}%;height:100%;background:${fill};border-radius:4px;"></div></div><span class="mono" style="font-size:11.5px;color:#4A5752;width:34px;text-align:right;">${Math.round(sm.avg[k]*100)}%</span></div>`;
}).join('');
sumHtml=`<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:13px;"><div><div style="font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:#9AA59E;margin-bottom:3px;">Session result</div><div style="font-size:15px;font-weight:600;color:${domColor};">${CLASSES[sm.dom]} Β· ${Math.round(sm.avg[sm.dom]*100)}%</div></div><div style="text-align:right;"><div style="font-size:11px;color:#9AA59E;">Mean HR</div><div class="mono" style="font-size:18px;">${sm.hr}</div></div></div>${bars}<div style="margin-top:10px;font-size:11px;color:#B0B8B2;line-height:1.5;">Rough ML model classifications only β€” not clinical advice. Detects NSR, tachycardia, bradycardia, AFib, and PVC patterns only. Cannot detect murmurs or other findings due to phone microphone hardware limitations. Consult a physician.</div>`;
} else {
const anyProc=s.recordings.some(r=>r.status==='processing');
sumHtml=`<div style="height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#A9B2AC;font-size:13px;padding:24px 0;">${anyProc?'<span class="spinner" style="margin-bottom:9px;"></span>Averaging recordings…':'Add a recording to see the classifier result.'}</div>`;
}
return `<div style="background:#fff;border:1px solid #E8E6DE;border-radius:16px;overflow:hidden;margin-bottom:14px;">
<div style="display:flex;align-items:center;gap:12px;padding:16px 20px;border-bottom:1px solid #F3F1EA;">
<div style="width:34px;height:34px;border-radius:9px;background:#EAF1EC;display:flex;align-items:center;justify-content:center;"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2E6E62" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l2 5 4-12 2 7h6"/></svg></div>
<div><div style="font-size:15px;font-weight:600;">Session Β· ${fmtDate(s.date)}</div><div style="font-size:12.5px;color:#9AA59E;">${s.recordings.length} recording${s.recordings.length!==1?'s':''}</div></div>
${symp}
<button onclick="removeSession(${s.id})" style="margin-left:auto;border:none;background:transparent;color:#C7CEC8;cursor:pointer;font-size:18px;">Γ—</button>
</div>
<div style="display:grid;grid-template-columns:1.2fr 1fr;">
<div style="padding:18px 20px;border-right:1px solid #F3F1EA;">
<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:14px;">${noRecs}${recs}</div>
<div style="display:flex;gap:9px;">
<label style="flex:1;display:flex;align-items:center;justify-content:center;gap:7px;background:#2E6E62;border:none;border-radius:10px;padding:10px;font-size:13px;font-weight:600;color:#fff;cursor:pointer;">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M7 9l5-5 5 5"/><path d="M5 20h14"/></svg>Upload phone audio
<input type="file" accept=".wav,.m4a,audio/wav,audio/x-m4a,audio/mp4" onchange="uploadRecording(${s.id},this)" style="display:none;">
</label>
</div>
<div style="margin-top:10px;font-size:11.5px;color:#9AA59E;line-height:1.55;">
In a quiet room, press the microphone edge of your phone firmly against bare skin at the <strong style="color:#6B7A73;">left sternal border</strong> β€” 4th–5th intercostal space, just left of the breastbone. Stay still and hold for at least 30 seconds. Save as <strong style="color:#6B7A73;">.wav</strong> (preferred) or <strong style="color:#6B7A73;">.m4a</strong>.
</div>
</div>
<div style="padding:18px 20px;background:#FBFAF6;">${sumHtml}</div>
</div>
</div>`;
}
function renderRecording(sid, r) {
const icon=r.source==='upload'?'⬆':'●';
let result='';
if(r.status==='processing'){
result='<span style="display:inline-flex;align-items:center;gap:6px;font-size:12px;color:#8A958E;"><span class="spinner" style="width:12px;height:12px;border-width:2px;"></span>Analyzing…</span>';
} else if(r.status==='done'){
const d=domOf(r.probs);
const [dbg,dfg]=d==='nsr'?['#EAF1EC','#1F4D45']:['#FBEDE7','#B4502F'];
result=`<span class="mono" style="font-size:12.5px;">${r.hr} bpm</span><span style="background:${dbg};color:${dfg};border-radius:7px;padding:3px 8px;font-size:11.5px;font-weight:600;">${CLASSES[d].slice(0,4)} ${Math.round(r.probs[d]*100)}%</span>`;
}
return `<div style="display:flex;align-items:center;gap:11px;background:#FAF9F5;border:1px solid #ECEAE2;border-radius:10px;padding:10px 12px;"><div style="width:30px;height:30px;border-radius:8px;background:#fff;border:1px solid #E8E6DE;display:flex;align-items:center;justify-content:center;color:#6B7A73;">${icon}</div><div style="min-width:0;"><div style="font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:150px;">${r.label}</div><div style="font-size:11.5px;color:#9AA59E;">${r.source==='upload'?'Uploaded':'Recorded'} Β· ${r.duration}</div></div><div style="margin-left:auto;display:flex;align-items:center;gap:9px;">${result}<button onclick="removeRecording(${sid},${r.id})" style="border:none;background:transparent;color:#C7CEC8;cursor:pointer;font-size:15px;">Γ—</button></div></div>`;
}
function renderReport() {
const counts=document.getElementById('report-counts');
if(counts){
const totalRec=state.sessions.reduce((a,s)=>a+s.recordings.length,0);
counts.innerHTML=[
`<span style="background:#F1EFE8;border-radius:999px;padding:7px 15px;font-size:13px;color:#4A5752;">${state.bp.length} BP readings</span>`,
`<span style="background:#F1EFE8;border-radius:999px;padding:7px 15px;font-size:13px;color:#4A5752;">${state.sessions.length} sessions Β· ${totalRec} recordings</span>`,
`<span style="background:#F1EFE8;border-radius:999px;padding:7px 15px;font-size:13px;color:#4A5752;">${state.conditions.length} conditions Β· ${state.medications.length} meds</span>`,
].join('');
}
document.getElementById('report-prompt').style.display=(!state.generating&&!state.generated)?'block':'none';
document.getElementById('report-generating').style.display=state.generating?'block':'none';
document.getElementById('report-generated').style.display=state.generated?'block':'none';
if(!state.generated||!proseParas.length) return;
const bs=bpStats();
const age=state.age||'β€”', sex=state.sex, race=state.race;
// structured panel
const condChips=state.conditions.map(c=>`<span style="background:#EAF1EC;border:1px solid #D7E4DA;border-radius:999px;padding:3px 11px;font-size:12px;color:#1F4D45;margin:2px;display:inline-block;">${c}</span>`).join('');
const medChips=state.medications.map(m=>`<span style="background:#FAF4EF;border:1px solid #ECD9CE;border-radius:999px;padding:3px 11px;font-size:12px;color:#A05A3C;margin:2px;display:inline-block;">${m.name} ${m.dose}</span>`).join('');
const patientLine=`${age}-year-old ${sex?sex.toLowerCase():'patient'}${race?' Β· '+race:''}`;
let bpSection='';
if(bs){
bpSection=`<div style="border-top:1px solid #F0EEE7;padding-top:16px;margin-bottom:18px;"><div style="font-size:13px;font-weight:600;color:#2E6E62;margin-bottom:10px;">Blood pressure</div><div style="display:flex;align-items:center;gap:16px;"><div><span class="mono" style="font-size:26px;">${bs.mSys}/${bs.mDia}</span><span style="font-size:12px;color:#9AA59E;margin-left:6px;">mean</span></div><svg width="120" height="36" viewBox="0 0 220 48" fill="none" style="margin-left:auto;"><polyline points="${bs.pts}" stroke="#2E6E62" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${bs.lastX}" cy="${bs.lastY}" r="4" fill="#DB6A4B"/></svg></div><div style="display:flex;gap:7px;margin-top:11px;flex-wrap:wrap;"><span style="background:#EAF1EC;color:#1F4D45;border-radius:8px;padding:4px 10px;font-size:12px;font-weight:600;">SD Β±${bs.sdSys}/${bs.sdDia}</span><span style="background:#EAF1EC;color:#1F4D45;border-radius:8px;padding:4px 10px;font-size:12px;font-weight:600;">PP ${bs.pp}</span><span style="background:#F1EFE8;color:#6B7A73;border-radius:8px;padding:4px 10px;font-size:12px;font-weight:600;">${bs.count} readings</span></div></div>`;
}
const sw=state.sessions.map(s=>({s,sm:sessSummary(s.recordings)})).filter(x=>x.sm);
let sessSection='';
if(sw.length){
const sessItems=sw.map(({s,sm})=>{
const dc=sm.dom==='nsr'?'#1F4D45':'#B4502F'; const db=sm.dom==='nsr'?'#EAF1EC':'#FBEDE7';
return `<div style="background:#FBFAF6;border:1px solid #F0EEE7;border-radius:11px;padding:12px 14px;"><div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;"><span style="font-size:13px;font-weight:600;">${fmtDate(s.date)}${s.symptom?`<span style="color:#B4502F;font-weight:400;"> Β· ${s.symptom}</span>`:''}</span><span class="mono" style="font-size:13px;color:#4A5752;">${sm.hr} bpm</span></div><div style="display:flex;align-items:center;gap:8px;"><span style="background:${db};color:${dc};border-radius:7px;padding:3px 9px;font-size:12px;font-weight:600;">${CLASSES[sm.dom]} ${Math.round(sm.avg[sm.dom]*100)}%</span><span style="font-size:12px;color:#9AA59E;">${s.recordings.length} recording${s.recordings.length!==1?'s':''}</span></div></div>`;
}).join('');
sessSection=`<div style="border-top:1px solid #F0EEE7;padding-top:16px;"><div style="font-size:13px;font-weight:600;color:#2E6E62;margin-bottom:11px;">Cardiac rhythm</div><div style="display:flex;flex-direction:column;gap:11px;">${sessItems}</div></div>`;
}
const structured=`<div style="background:#fff;border:1px solid #E8E6DE;border-radius:16px;padding:22px 24px;"><div style="font-size:11.5px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#9AA59E;margin-bottom:16px;">Structured readings</div><div style="font-size:13px;font-weight:600;color:#2E6E62;margin-bottom:8px;">Patient</div><div style="font-size:14px;color:#2A332F;line-height:1.6;">${patientLine}</div><div style="margin-top:9px;">${condChips}${medChips}</div>${bpSection}${sessSection}</div>`;
// BP readings table
const TH='padding:9px 14px;font-size:11px;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;color:#9AA59E;text-align:left;border-bottom:2px solid #F0EEE7;white-space:nowrap;';
const THR=TH+'text-align:right;';
const TD='padding:9px 14px;font-size:13px;color:#2A332F;border-bottom:1px solid #F8F7F2;';
const TDM=TD+'font-family:"IBM Plex Mono",monospace;';
const TDMR=TDM+'text-align:right;';
let bpTable='';
const bpValid=state.bp.filter(r=>r.sys&&r.dia&&+r.sys>0);
if(bpValid.length){
const bpRows=bpValid.map(r=>`<tr><td style="${TDM}">${fmtDate(r.date)}</td><td style="${TD}color:#6B7A73;">${r.time||'β€”'}</td><td style="${TDMR}">${r.sys}</td><td style="${TDMR}">${r.dia}</td><td style="${TDMR}">${r.hr||'β€”'}</td><td style="${TD}color:${r.symptom?'#B4502F':'#C7CEC8'};">${r.symptom||'β€”'}</td></tr>`).join('');
bpTable=`<div style="background:#fff;border:1px solid #E8E6DE;border-radius:16px;overflow:hidden;margin-top:12px;"><div style="padding:14px 22px;border-bottom:1px solid #F0EEE7;font-size:11.5px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#9AA59E;">Blood pressure readings</div><div style="overflow-x:auto;"><table style="width:100%;border-collapse:collapse;"><thead><tr><th style="${TH}">Date</th><th style="${TH}">Time</th><th style="${THR}">Sys</th><th style="${THR}">Dia</th><th style="${THR}">HR (bpm)</th><th style="${TH}">Symptom</th></tr></thead><tbody>${bpRows}</tbody></table></div></div>`;
}
// ECG classifier output table
let ecgTable='';
const sessWithProbs=state.sessions.map(s=>({s,recs:s.recordings.filter(r=>r.status==='done'&&r.probs)})).filter(x=>x.recs.length);
if(sessWithProbs.length){
const KEYS=['nsr','tachy','brady','afib','pvc'];
const ABR={nsr:'NSR',tachy:'Tachy',brady:'Brady',afib:'AFib',pvc:'PVC'};
let bodyRows='';
sessWithProbs.forEach(({s,recs},gi)=>{
const sl=fmtDate(s.date)+(s.symptom?' Β· '+s.symptom:'');
bodyRows+=`<tr><td colspan="7" style="padding:8px 14px 4px;font-size:11.5px;font-weight:700;color:#2E6E62;background:#FAFAF7;${gi>0?'border-top:2px solid #F0EEE7;':''}">${sl}</td></tr>`;
recs.forEach(r=>{
const dom=domOf(r.probs);
const pCols=KEYS.map(k=>{
const pct=Math.round((r.probs[k]||0)*100);
return `<td style="${TDMR}${k===dom?'font-weight:700;color:#1F4D45;':''}">${pct}%</td>`;
}).join('');
bodyRows+=`<tr><td style="${TD}">${r.label}</td><td style="${TDMR}">${r.hr||'β€”'}</td>${pCols}</tr>`;
});
});
const hCols=KEYS.map(k=>`<th style="${THR}">${ABR[k]}</th>`).join('');
ecgTable=`<div style="background:#fff;border:1px solid #E8E6DE;border-radius:16px;overflow:hidden;margin-top:12px;"><div style="padding:14px 22px;border-bottom:1px solid #F0EEE7;"><div style="font-size:11.5px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#9AA59E;">ECG classifier output</div><div style="font-size:11px;color:#C7CEC8;margin-top:3px;">Rough ML model classifications β€” detects NSR, tachycardia, bradycardia, AFib, and PVC only. Cannot detect murmurs or other findings due to phone microphone hardware constraints. Not clinical advice.</div></div><div style="overflow-x:auto;"><table style="width:100%;border-collapse:collapse;"><thead><tr><th style="${TH}">Recording</th><th style="${THR}">HR</th>${hCols}</tr></thead><tbody>${bodyRows}</tbody></table></div></div>`;
}
const proseItems=proseParas.map((p,i)=>`<div style="margin-bottom:18px;"><div style="font-size:11px;font-weight:700;letter-spacing:0.09em;text-transform:uppercase;color:#2E6E62;margin-bottom:6px;">${p.h}</div><p ${i===0?'id="soap-prose-text"':''} style="margin:0;font-size:15px;line-height:1.68;color:#2A332F;">${p.t}</p></div>`).join('');
const reportDate=new Date().toLocaleDateString('en-US',{month:'long',day:'numeric',year:'numeric'});
const prose=`<div style="background:#fff;border:1px solid #E8E6DE;border-radius:16px;overflow:hidden;"><div style="background:#1E2B27;padding:18px 26px;display:flex;align-items:center;gap:12px;"><div class="serif" style="font-size:17px;color:#fff;">Objective</div><span style="background:rgba(143,182,166,0.22);color:#9FD3BE;border-radius:999px;padding:3px 10px;font-size:11px;font-weight:600;letter-spacing:0.03em;">AI-GENERATED DRAFT</span><span style="margin-left:auto;font-size:12px;color:#8FB6A6;">${reportDate}</span></div><div style="padding:26px 30px;">${proseItems}<div style="border-top:1px solid #F0EEE7;padding-top:14px;margin-top:6px;font-size:12px;color:#A9B2AC;line-height:1.5;">This summary was generated from patient-entered home monitoring data and is not a medical record. Automated cardiac rhythm classification is decision-support only, has not been FDA-evaluated, and may be inaccurate. It is not a substitute for professional medical advice, diagnosis, or treatment. All findings require physician review before any clinical action is taken. In an emergency, call 911 immediately.</div></div></div>`;
document.getElementById('report-content').innerHTML=structured+bpTable+ecgTable+prose;
}
function renderFooter() {
const back=document.getElementById('footer-back');
const next=document.getElementById('footer-next');
const startover=document.getElementById('footer-startover');
const counter=document.getElementById('footer-counter');
back.style.display=state.step>0?'block':'none';
startover.style.display=(state.step===3&&state.generated)?'block':'none';
next.style.display=state.step<3?'inline-block':'none';
next.textContent=state.step===2?'Continue to report β†’':'Continue β†’';
counter.textContent=`Step ${state.step+1} of 4`;
}
// ── model banner ─────────────────────────────────────────────────────────────
function setBanner(text, pct /* 0-100 | null */, done = false) {
const banner = document.getElementById('model-banner');
const fill = document.getElementById('model-bar-fill');
const pctEl = document.getElementById('model-pct');
const textEl = document.getElementById('model-banner-text');
const dot = document.getElementById('model-dot');
if (done) {
textEl.textContent = 'Model ready';
dot.style.background = '#6FC0A8';
fill.style.width = '100%';
pctEl.textContent = '100%';
setTimeout(() => banner.classList.add('hidden'), 2000);
return;
}
textEl.textContent = text;
if (pct !== null) {
fill.style.width = pct + '%';
pctEl.textContent = Math.round(pct) + '%';
}
}
// ── init ──────────────────────────────────────────────────────────────────────
async function init() {
render(); // UI is immediately usable
// fetch model with byte-level progress
try {
setBanner('Downloading model…', 0);
const res = await fetch('/heartline.onnx');
const total = parseInt(res.headers.get('content-length') || '0');
const reader = res.body.getReader();
const chunks = [];
let loaded = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.length;
if (total) setBanner('Downloading model…', (loaded / total) * 90);
}
const buf = new Uint8Array(loaded);
let offset = 0;
for (const c of chunks) { buf.set(c, offset); offset += c.length; }
setBanner('Initializing…', 92);
ortSession = await ort.InferenceSession.create(buf.buffer, {
executionProviders: ['webgpu', 'wasm'],
});
setBanner('', 100, true);
console.log('ORT session ready');
initWllama(); // start downloading SOAP model in background
} catch(e) {
console.warn('Model load failed:', e.message);
document.getElementById('model-banner-text').textContent = 'Model unavailable β€” using demo mode';
document.getElementById('model-dot').style.background = '#DB6A4B';
document.getElementById('model-bar-track').style.display = 'none';
document.getElementById('model-pct').style.display = 'none';
}
}
init();
</script>
</body>
</html>