CardioScreen AI
fix: React hooks order violation (useState before useEffect)
8f78b71
import React, { useState, useRef, useEffect } from 'react';
import {
Upload, Activity, Heart, FileAudio,
CheckCircle, AlertTriangle, RefreshCw,
Mic, Square, Download, Cpu, Sun, Moon, Menu, X
} from 'lucide-react';
import jsPDF from 'jspdf';
import './App.css';
// ─── Waveform Canvas with Time Axis + Heartbeat Markers ──────────────────────
// Waveform Canvas: static bottom layer + live animated overlay
function WaveformCanvas({ waveform, peakVisIndices, peakTimesSec, duration, isDisease, canvasRefOut, audioRef }) {
const staticRef = useRef(null);
const overlayRef = useRef(null);
const rafRef = useRef(null);
const accentColor = isDisease ? '#ef4444' : '#06b6d4';
const PAD = { L: 42, R: 12, T: 12, B: 36 };
// Static draw: runs once when waveform data changes
useEffect(() => {
const canvas = staticRef.current;
if (!canvas || !waveform || waveform.length === 0) return;
if (canvasRefOut) canvasRefOut.current = canvas;
const dpr = window.devicePixelRatio || 1;
const W = canvas.offsetWidth, H = canvas.offsetHeight;
canvas.width = W * dpr; canvas.height = H * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const { L, R, T, B } = PAD;
const cW = W - L - R, cH = H - B - T;
const n = waveform.length;
const xOf = (i) => L + (i / (n - 1)) * cW;
const yOf = (v) => T + cH / 2 - v * cH * 0.42;
ctx.fillStyle = 'rgba(5,8,18,1)'; ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = T + (cH * i) / 4;
ctx.beginPath(); ctx.moveTo(L, y); ctx.lineTo(L + cW, y); ctx.stroke();
}
const grad = ctx.createLinearGradient(0, T, 0, T + cH);
grad.addColorStop(0, isDisease ? 'rgba(239,68,68,0.5)' : 'rgba(6,182,212,0.5)');
grad.addColorStop(0.5, isDisease ? 'rgba(239,68,68,0.08)' : 'rgba(6,182,212,0.08)');
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.beginPath();
ctx.moveTo(xOf(0), yOf(waveform[0]));
for (let i = 1; i < n; i++) ctx.lineTo(xOf(i), yOf(waveform[i]));
ctx.lineTo(xOf(n - 1), T + cH / 2); ctx.lineTo(xOf(0), T + cH / 2);
ctx.closePath(); ctx.fillStyle = grad; ctx.fill();
ctx.beginPath();
ctx.moveTo(xOf(0), yOf(waveform[0]));
for (let i = 1; i < n; i++) ctx.lineTo(xOf(i), yOf(waveform[i]));
ctx.strokeStyle = accentColor; ctx.lineWidth = 1.8;
ctx.shadowBlur = 8; ctx.shadowColor = isDisease ? 'rgba(239,68,68,0.4)' : 'rgba(6,182,212,0.4)';
ctx.stroke(); ctx.shadowBlur = 0;
if (peakVisIndices && peakVisIndices.length > 0) {
peakVisIndices.forEach((pidx, i) => {
const x = xOf(pidx);
ctx.setLineDash([3, 4]); ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x, T + 4); ctx.lineTo(x, T + cH); ctx.stroke();
ctx.setLineDash([]);
ctx.save(); ctx.translate(x, T + 6); ctx.rotate(Math.PI / 4);
ctx.fillStyle = accentColor; ctx.fillRect(-4, -4, 8, 8); ctx.restore();
if (peakTimesSec && peakTimesSec[i] !== undefined && (peakVisIndices.length < 30 || i % 2 === 0)) {
ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = '9px monospace';
ctx.textAlign = 'center'; ctx.fillText(peakTimesSec[i].toFixed(1) + 's', x, T + 22);
}
});
}
const axisY = T + cH + 6;
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(L, axisY); ctx.lineTo(L + cW, axisY); ctx.stroke();
const numTicks = Math.min(12, Math.floor(duration));
for (let t = 0; t <= numTicks; t++) {
const ts = (t / numTicks) * duration;
const x = L + (ts / duration) * cW;
ctx.beginPath(); ctx.moveTo(x, axisY); ctx.lineTo(x, axisY + 5); ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = '10px monospace';
ctx.textAlign = 'center'; ctx.fillText(ts.toFixed(0) + 's', x, axisY + 16);
}
ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = '10px sans-serif';
ctx.textAlign = 'left'; ctx.fillText('Time (seconds)', L, axisY + 30);
ctx.save(); ctx.translate(12, T + cH / 2); ctx.rotate(-Math.PI / 2);
ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = '10px sans-serif';
ctx.textAlign = 'center'; ctx.fillText('Amplitude', 0, 0); ctx.restore();
}, [waveform, peakVisIndices, peakTimesSec, duration, isDisease]);
// Animation loop: playhead + beat pulse (runs while audio plays)
useEffect(() => {
const audio = audioRef?.current;
const overlay = overlayRef.current;
if (!overlay || !audio || !duration) return;
const { L, R, T, B } = PAD;
const BEAT_WIN = 0.35; // seconds a beat glows after being crossed
const drawOverlay = () => {
const dpr = window.devicePixelRatio || 1;
const W = overlay.offsetWidth, H = overlay.offsetHeight;
if (overlay.width !== Math.round(W * dpr)) overlay.width = Math.round(W * dpr);
if (overlay.height !== Math.round(H * dpr)) overlay.height = Math.round(H * dpr);
const ctx = overlay.getContext('2d');
ctx.save();
ctx.clearRect(0, 0, overlay.width, overlay.height);
ctx.scale(dpr, dpr);
const cW = W - L - R, cH = H - B - T;
const n = waveform ? waveform.length : 1;
const t = audio.currentTime;
if (t > 0 && cW > 0) {
const px = L + (t / duration) * cW;
// Scanned region overlay
ctx.fillStyle = 'rgba(255,255,255,0.025)';
ctx.fillRect(L, T, px - L, cH);
// White glowing playhead
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.92)'; ctx.lineWidth = 1.5;
ctx.shadowBlur = 10; ctx.shadowColor = 'rgba(255,255,255,0.8)';
ctx.beginPath(); ctx.moveTo(px, T); ctx.lineTo(px, T + cH); ctx.stroke();
ctx.restore();
// Beat pulse glows
if (peakTimesSec && peakVisIndices) {
peakTimesSec.forEach((beatT, i) => {
const diff = t - beatT;
if (diff < 0 || diff > BEAT_WIN) return;
const alpha = 1 - diff / BEAT_WIN;
const bx = L + (peakVisIndices[i] / (n - 1)) * cW;
// Radial halo
ctx.save();
const rg = ctx.createRadialGradient(bx, T + cH / 2, 0, bx, T + cH / 2, 40);
rg.addColorStop(0, isDisease ? `rgba(239,68,68,${alpha * 0.55})` : `rgba(6,182,212,${alpha * 0.55})`);
rg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = rg;
ctx.fillRect(bx - 42, T, 84, cH);
ctx.restore();
// Pulsing diamond
const size = 5 + alpha * 10;
ctx.save();
ctx.translate(bx, T + cH / 2);
ctx.rotate(Math.PI / 4);
ctx.globalAlpha = alpha;
ctx.fillStyle = accentColor;
ctx.shadowBlur = 22 * alpha; ctx.shadowColor = accentColor;
ctx.fillRect(-size / 2, -size / 2, size, size);
ctx.restore();
// Beat number label
ctx.save();
ctx.globalAlpha = alpha * 0.85;
ctx.fillStyle = 'white'; ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center';
ctx.fillText('\u2665 ' + (i + 1), bx, T + cH / 2 + 22);
ctx.restore();
});
}
}
ctx.restore();
rafRef.current = requestAnimationFrame(drawOverlay);
};
const startLoop = () => {
if (!rafRef.current) rafRef.current = requestAnimationFrame(drawOverlay);
};
const stopLoop = () => {
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }
const ctx = overlay.getContext('2d');
if (ctx) ctx.clearRect(0, 0, overlay.width, overlay.height);
};
audio.addEventListener('play', startLoop);
audio.addEventListener('pause', stopLoop);
audio.addEventListener('ended', stopLoop);
audio.addEventListener('seeked', () => { if (!audio.paused) startLoop(); });
return () => {
stopLoop();
audio.removeEventListener('play', startLoop);
audio.removeEventListener('pause', stopLoop);
audio.removeEventListener('ended', stopLoop);
};
}, [waveform, peakVisIndices, peakTimesSec, duration, isDisease, audioRef]);
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<canvas ref={staticRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }} />
<canvas ref={overlayRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block', pointerEvents: 'none' }} />
</div>
);
}
// Module-level WAV encoder (used by both recording and trimmer)
function bufferToWave(abuffer, startSample, numSamples) {
const numChan = abuffer.numberOfChannels;
const sr = abuffer.sampleRate;
const byteLen = numSamples * numChan * 2 + 44;
const buf = new ArrayBuffer(byteLen);
const view = new DataView(buf);
let pos = 0;
const w16 = (v) => { view.setUint16(pos, v, true); pos += 2; };
const w32 = (v) => { view.setUint32(pos, v, true); pos += 4; };
w32(0x46464952); w32(byteLen - 8); w32(0x45564157);
w32(0x20746d66); w32(16); w16(1); w16(numChan);
w32(sr); w32(sr * 2 * numChan); w16(numChan * 2); w16(16);
w32(0x61746164); w32(byteLen - pos - 4);
const channels = [];
for (let c = 0; c < numChan; c++) channels.push(abuffer.getChannelData(c));
for (let i = 0; i < numSamples; i++) {
for (let c = 0; c < numChan; c++) {
let s = Math.max(-1, Math.min(1, channels[c][startSample + i]));
s = (0.5 + s < 0 ? s * 32768 : s * 32767) | 0;
view.setInt16(pos, s, true); pos += 2;
}
}
return new Blob([buf], { type: 'audio/wav' });
}
// Decode any audio blob and return { waveform (Float32Array), duration, buffer }
async function decodeAudioBlob(blob) {
const arrayBuffer = await blob.arrayBuffer();
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
await ctx.close();
return audioBuffer;
}
// Downsample a Float32Array to ~800 points for display
function downsample(data, points = 800) {
const step = Math.max(1, Math.floor(data.length / points));
const out = [];
for (let i = 0; i < data.length; i += step) out.push(data[i]);
return out;
}
// ─── Audio Trimmer ─────────────────────────────────────────────────────────────
function AudioTrimmer({ waveform, duration, onAnalyze, onSkip }) {
const canvasRef = useRef(null);
const [startFrac, setStartFrac] = useState(0);
const [endFrac, setEndFrac] = useState(1);
const dragging = useRef(null); // 'start' | 'end' | null
const stateRef = useRef({ startFrac: 0, endFrac: 1 });
const PAD = { L: 16, R: 16, T: 24, B: 28 };
// Keep stateRef in sync for RAF access inside mouse handlers
useEffect(() => { stateRef.current = { startFrac, endFrac }; }, [startFrac, endFrac]);
// Draw whenever state or waveform changes
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !waveform || waveform.length === 0) return;
const dpr = window.devicePixelRatio || 1;
const W = canvas.offsetWidth, H = canvas.offsetHeight;
canvas.width = W * dpr; canvas.height = H * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const { L, R, T, B } = PAD;
const cW = W - L - R, cH = H - T - B;
const n = waveform.length;
const xOf = (i) => L + (i / (n - 1)) * cW;
const yOf = (v) => T + cH / 2 - v * cH * 0.42;
const sX = L + startFrac * cW;
const eX = L + endFrac * cW;
// Background
ctx.fillStyle = 'rgba(5,8,18,1)'; ctx.fillRect(0, 0, W, H);
// Waveform (dimmed outside selection)
for (let i = 1; i < n; i++) {
const x0 = xOf(i - 1), x1 = xOf(i);
const inside = x0 >= sX && x1 <= eX;
ctx.strokeStyle = inside ? '#06b6d4' : 'rgba(255,255,255,0.12)';
ctx.lineWidth = inside ? 1.8 : 1;
ctx.shadowBlur = inside ? 6 : 0;
ctx.shadowColor = '#06b6d4';
ctx.beginPath(); ctx.moveTo(x0, yOf(waveform[i - 1])); ctx.lineTo(x1, yOf(waveform[i])); ctx.stroke();
}
ctx.shadowBlur = 0;
// Dim excluded zones
ctx.fillStyle = 'rgba(5,8,18,0.6)';
ctx.fillRect(L, T, sX - L, cH);
ctx.fillRect(eX, T, L + cW - eX, cH);
// Selection highlight box
ctx.strokeStyle = 'rgba(6,182,212,0.4)'; ctx.lineWidth = 1;
ctx.strokeRect(sX, T, eX - sX, cH);
ctx.fillStyle = 'rgba(6,182,212,0.05)'; ctx.fillRect(sX, T, eX - sX, cH);
// Draw handles (vertical bar with grip arrows)
[[sX, 'start'], [eX, 'end']].forEach(([x, side]) => {
ctx.fillStyle = '#06b6d4';
ctx.fillRect(x - 2, T, 4, cH);
// Arrow triangle on handle
ctx.fillStyle = 'white';
const dir = side === 'start' ? 1 : -1;
ctx.beginPath();
ctx.moveTo(x + dir * 2, T + cH / 2);
ctx.lineTo(x + dir * 10, T + cH / 2 - 7);
ctx.lineTo(x + dir * 10, T + cH / 2 + 7);
ctx.closePath(); ctx.fill();
});
// Time labels on handles
ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center';
ctx.fillText((startFrac * duration).toFixed(2) + 's', sX, T - 6);
ctx.fillText((endFrac * duration).toFixed(2) + 's', eX, T - 6);
// Bottom axis
const axisY = T + cH + 6;
ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(L, axisY); ctx.lineTo(L + cW, axisY); ctx.stroke();
const numTicks = Math.min(10, Math.floor(duration));
for (let t = 0; t <= numTicks; t++) {
const x = L + (t / numTicks) * cW;
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.font = '9px monospace';
ctx.textAlign = 'center'; ctx.fillText(((t / numTicks) * duration).toFixed(0) + 's', x, axisY + 14);
}
// Selection duration label in center
const selSec = ((endFrac - startFrac) * duration).toFixed(1);
ctx.fillStyle = 'rgba(6,182,212,0.9)'; ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`Selection: ${selSec}s`, L + cW / 2, T + cH / 2 - 14);
}, [waveform, duration, startFrac, endFrac]);
// Mouse interaction
const fracFromX = (canvas, clientX) => {
const rect = canvas.getBoundingClientRect();
const { L, R } = PAD;
const cW = rect.width - L - R;
return Math.max(0, Math.min(1, (clientX - rect.left - L) / cW));
};
const onMouseDown = (e) => {
const canvas = canvasRef.current;
const f = fracFromX(canvas, e.clientX);
const { startFrac: s, endFrac: en } = stateRef.current;
const dS = Math.abs(f - s), dE = Math.abs(f - en);
dragging.current = dS < dE ? 'start' : 'end';
};
const onMouseMove = (e) => {
if (!dragging.current) return;
const f = fracFromX(canvasRef.current, e.clientX);
const { startFrac: s, endFrac: en } = stateRef.current;
if (dragging.current === 'start') setStartFrac(Math.min(f, en - 0.01));
else setEndFrac(Math.max(f, s + 0.01));
};
const onMouseUp = () => { dragging.current = null; };
// Touch support
const onTouchStart = (e) => onMouseDown(e.touches[0]);
const onTouchMove = (e) => { e.preventDefault(); onMouseMove(e.touches[0]); };
const onTouchEnd = () => onMouseUp();
return (
<div>
<canvas ref={canvasRef}
style={{ width: '100%', height: '200px', display: 'block', cursor: 'col-resize', borderRadius: '8px', overflow: 'hidden' }}
onMouseDown={onMouseDown} onMouseMove={onMouseMove} onMouseUp={onMouseUp} onMouseLeave={onMouseUp}
onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '16px', flexWrap: 'wrap', gap: '12px' }}>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
<span style={{ color: '#06b6d4', fontWeight: 600 }}>βœ‚ Drag the handles</span> to select the clean cardiac section
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button className="btn-secondary" onClick={onSkip} style={{ fontSize: '0.9rem' }}>
Use Full Recording
</button>
<button className="btn-primary" style={{ background: 'var(--accent-cyan)', color: '#000', padding: '10px 20px', borderRadius: '8px', fontWeight: 700, border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.9rem' }}
onClick={() => onAnalyze(startFrac, endFrac)}>
βœ‚ Analyze Selection
</button>
</div>
</div>
</div>
);
}
const API_URL = (import.meta.env.VITE_API_URL) ?? "http://127.0.0.1:8000/analyze";
function App() {
// ── All useState hooks first (React rules of hooks) ──────
const [appState, setAppState] = useState('upload');
const [patientData, setPatientData] = useState({ dogId: '', breed: '', age: '' });
const [analysisResult, setAnalysisResult] = useState(null);
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [audioBlob, setAudioBlob] = useState(null);
const [audioUrl, setAudioUrl] = useState(null);
const [trimWaveform, setTrimWaveform] = useState(null);
const [trimDuration, setTrimDuration] = useState(0);
const [theme, setTheme] = useState('dark');
const [mobileNavOpen, setMobileNavOpen] = useState(false);
// ── All useRef hooks next ─────────────────────────────────
const rawAudioBuffer = useRef(null);
const audioContextRef = useRef(null);
const analyserRef = useRef(null);
const mediaStreamRef = useRef(null);
const sourceRef = useRef(null);
const animationFrameRef = useRef(null);
const canvasRef = useRef(null);
const timerRef = useRef(null);
const mediaRecorderRef = useRef(null);
const audioChunksRef = useRef([]);
const waveformCanvasRef = useRef(null);
const audioRef = useRef(null);
// Apply theme attribute to <html> element
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
const closeMobileNav = () => setMobileNavOpen(false);
const stopRecording = () => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach(track => track.stop());
mediaStreamRef.current = null;
}
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
audioContextRef.current.close().catch(console.error);
audioContextRef.current = null;
}
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
if (timerRef.current) clearInterval(timerRef.current);
setIsRecording(false);
};
const startRecording = async () => {
if (!patientData.dogId) {
alert("Please enter a Dog ID first.");
return;
}
try {
setAppState('recording');
setIsRecording(true);
setRecordingTime(0);
audioChunksRef.current = [];
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaStreamRef.current = stream;
mediaRecorderRef.current = new MediaRecorder(stream);
mediaRecorderRef.current.ondataavailable = (event) => {
if (event.data.size > 0) audioChunksRef.current.push(event.data);
};
mediaRecorderRef.current.onstop = async () => {
const rawBlob = new Blob(audioChunksRef.current);
const audioBuffer = await decodeAudioBlob(rawBlob);
rawAudioBuffer.current = audioBuffer;
const wavBlob = bufferToWave(audioBuffer, 0, audioBuffer.length);
setAudioBlob(wavBlob);
setAudioUrl(URL.createObjectURL(wavBlob));
const wf = downsample(audioBuffer.getChannelData(0));
setTrimWaveform(wf);
setTrimDuration(audioBuffer.duration);
setAppState('trimming');
};
mediaRecorderRef.current.start();
audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)();
analyserRef.current = audioContextRef.current.createAnalyser();
analyserRef.current.fftSize = 2048;
sourceRef.current = audioContextRef.current.createMediaStreamSource(stream);
sourceRef.current.connect(analyserRef.current);
drawWaveform();
timerRef.current = setInterval(() => setRecordingTime((prev) => prev + 1), 1000);
} catch (err) {
console.error("Microphone access error:", err);
alert("Microphone access denied or unavailable.");
setAppState('upload');
setIsRecording(false);
}
};
const drawWaveform = () => {
if (!canvasRef.current || !analyserRef.current) {
animationFrameRef.current = requestAnimationFrame(drawWaveform);
return;
}
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const analyser = analyserRef.current;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyser.getByteTimeDomainData(dataArray);
ctx.fillStyle = 'rgba(11, 15, 25, 1)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.lineWidth = 3;
ctx.strokeStyle = '#06b6d4';
ctx.shadowBlur = 10;
ctx.shadowColor = '#06b6d4';
ctx.beginPath();
const sliceWidth = canvas.width / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = v * canvas.height / 2;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
x += sliceWidth;
}
ctx.lineTo(canvas.width, canvas.height / 2);
ctx.stroke();
animationFrameRef.current = requestAnimationFrame(drawWaveform);
};
const handleFinishRecording = () => {
setAppState('analyzing'); // will be overridden to 'trimming' once audio decoded in onstop
stopRecording();
};
const handleManualUpload = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'audio/*';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!patientData.dogId) { alert('Please enter a Dog ID first.'); return; }
const audioBuffer = await decodeAudioBlob(file);
rawAudioBuffer.current = audioBuffer;
const wavBlob = bufferToWave(audioBuffer, 0, audioBuffer.length);
setAudioBlob(wavBlob);
setAudioUrl(URL.createObjectURL(wavBlob));
const wf = downsample(audioBuffer.getChannelData(0));
setTrimWaveform(wf);
setTrimDuration(audioBuffer.duration);
setAppState('trimming');
};
input.click();
};
const handleAnalyzeTrimmed = (startFrac, endFrac) => {
const ab = rawAudioBuffer.current;
if (!ab) return;
const startSample = Math.floor(startFrac * ab.length);
const numSamples = Math.floor((endFrac - startFrac) * ab.length);
const trimmedBlob = bufferToWave(ab, startSample, numSamples);
// Update the stored audio blob/url to the trimmed version
if (audioUrl) URL.revokeObjectURL(audioUrl);
const newUrl = URL.createObjectURL(trimmedBlob);
setAudioBlob(trimmedBlob);
setAudioUrl(newUrl);
setAppState('analyzing');
sendToBackend(trimmedBlob);
};
const handleSkipTrim = () => {
setAppState('analyzing');
sendToBackend(audioBlob);
};
const sendToBackend = async (audioBlob) => {
try {
const formData = new FormData();
formData.append('file', audioBlob, 'recording.wav');
const response = await fetch(API_URL, { method: "POST", body: formData });
const result = await response.json();
console.log("Backend response:", result);
if (result.error) throw new Error(result.error);
let bpmStatus = "Normal";
let bpmColor = "var(--success)";
if (result.bpm > 140) { bpmStatus = "High (Tachycardia?)"; bpmColor = "var(--danger)"; }
else if (result.bpm < 60 && result.bpm > 0) { bpmStatus = "Low (Bradycardia?)"; bpmColor = "var(--warning)"; }
else if (result.bpm === 0) { bpmStatus = "Undetected"; bpmColor = "var(--text-secondary)"; }
const waveformData = result.waveform.map((amp, idx) => ({ time: idx, amplitude: amp }));
setAnalysisResult({
...result,
bpmStatus,
bpmColor,
waveformData,
});
setAppState('dashboard');
} catch (error) {
console.error("Analysis error:", error);
alert(`Analysis failed: ${error.message}\n\nCheck if api.py is running.`);
resetApp();
}
};
const downloadAudio = () => {
if (!audioBlob) return;
const url = URL.createObjectURL(audioBlob);
const a = document.createElement('a');
a.href = url;
a.download = `heart_sound_${patientData.dogId}_${Date.now()}.wav`;
a.click();
URL.revokeObjectURL(url);
};
const downloadReport = async () => {
if (!analysisResult) return;
const r = analysisResult;
const ai = r.ai_classification;
const now = new Date().toLocaleString();
const isMurmur = ai.is_disease;
const pdf = new jsPDF('p', 'mm', 'a4');
const W = 210, H = 297;
const marginL = 18, marginR = 18;
const contentW = W - marginL - marginR;
let y = 0;
// === HEADER BAND ===
pdf.setFillColor(15, 23, 42);
pdf.rect(0, 0, W, 38, 'F');
// Accent line
pdf.setFillColor(isMurmur ? 239 : 6, isMurmur ? 68 : 182, isMurmur ? 68 : 212);
pdf.rect(0, 38, W, 2, 'F');
// Title
pdf.setTextColor(255, 255, 255);
pdf.setFont('helvetica', 'bold'); pdf.setFontSize(20);
pdf.text('CardioScreen AI', marginL, 18);
pdf.setFont('helvetica', 'normal'); pdf.setFontSize(10);
pdf.setTextColor(148, 163, 184);
pdf.text('Canine Cardiac Screening Report', marginL, 26);
// Date right-aligned
pdf.setFontSize(9); pdf.setTextColor(148, 163, 184);
pdf.text(now, W - marginR, 18, { align: 'right' });
pdf.text(`Patient: ${patientData.dogId}`, W - marginR, 26, { align: 'right' });
y = 48;
// === RESULT BANNER ===
pdf.setFillColor(isMurmur ? 254 : 240, isMurmur ? 242 : 253, isMurmur ? 242 : 250);
pdf.roundedRect(marginL, y, contentW, 28, 3, 3, 'F');
pdf.setDrawColor(isMurmur ? 239 : 34, isMurmur ? 68 : 197, isMurmur ? 68 : 94);
pdf.roundedRect(marginL, y, contentW, 28, 3, 3, 'S');
pdf.setFontSize(16); pdf.setFont('helvetica', 'bold');
pdf.setTextColor(isMurmur ? 185 : 21, isMurmur ? 28 : 128, isMurmur ? 28 : 61);
pdf.text(isMurmur ? '⚠ MURMUR DETECTED' : 'βœ“ NORMAL HEART SOUND', marginL + 8, y + 12);
pdf.setFontSize(10); pdf.setFont('helvetica', 'normal');
pdf.setTextColor(100, 100, 100);
pdf.text(`Confidence: ${(ai.confidence * 100).toFixed(1)}%`, marginL + 8, y + 22);
// BPM right side
pdf.setFontSize(22); pdf.setFont('helvetica', 'bold');
pdf.setTextColor(isMurmur ? 185 : 21, isMurmur ? 28 : 128, isMurmur ? 28 : 61);
pdf.text(`${r.bpm}`, W - marginR - 30, y + 14, { align: 'right' });
pdf.setFontSize(9); pdf.setFont('helvetica', 'normal');
pdf.setTextColor(100, 100, 100);
pdf.text('BPM', W - marginR - 8, y + 14, { align: 'right' });
pdf.text(`${r.bpmStatus}`, W - marginR - 8, y + 22, { align: 'right' });
y += 36;
// === PATIENT INFO TABLE ===
pdf.setFontSize(11); pdf.setFont('helvetica', 'bold');
pdf.setTextColor(30, 41, 59);
pdf.text('Patient Information', marginL, y);
y += 6;
pdf.setFillColor(248, 250, 252);
pdf.rect(marginL, y, contentW, 22, 'F');
pdf.setDrawColor(226, 232, 240);
pdf.rect(marginL, y, contentW, 22, 'S');
pdf.setFontSize(9); pdf.setFont('helvetica', 'normal');
pdf.setTextColor(71, 85, 105);
const col1 = marginL + 5, col2 = marginL + 50, col3 = marginL + 95, col4 = marginL + 130;
pdf.text('Dog ID:', col1, y + 8); pdf.setFont('helvetica', 'bold'); pdf.setTextColor(30, 41, 59); pdf.text(patientData.dogId, col1, y + 15);
pdf.setFont('helvetica', 'normal'); pdf.setTextColor(71, 85, 105);
pdf.text('Breed:', col2, y + 8); pdf.setFont('helvetica', 'bold'); pdf.setTextColor(30, 41, 59); pdf.text(patientData.breed || 'N/A', col2, y + 15);
pdf.setFont('helvetica', 'normal'); pdf.setTextColor(71, 85, 105);
pdf.text('Age:', col3, y + 8); pdf.setFont('helvetica', 'bold'); pdf.setTextColor(30, 41, 59); pdf.text(patientData.age ? `${patientData.age} years` : 'N/A', col3, y + 15);
pdf.setFont('helvetica', 'normal'); pdf.setTextColor(71, 85, 105);
pdf.text('Duration:', col4, y + 8); pdf.setFont('helvetica', 'bold'); pdf.setTextColor(30, 41, 59); pdf.text(`${r.duration_seconds}s (${r.heartbeat_count} beats)`, col4, y + 15);
y += 30;
// === WAVEFORM IMAGE ===
pdf.setFontSize(11); pdf.setFont('helvetica', 'bold');
pdf.setTextColor(30, 41, 59);
pdf.text('Phonocardiogram', marginL, y);
y += 4;
if (waveformCanvasRef.current) {
try {
const imgData = waveformCanvasRef.current.toDataURL('image/png');
const imgW = contentW;
const imgH = imgW * 0.35;
pdf.addImage(imgData, 'PNG', marginL, y, imgW, imgH);
y += imgH + 4;
} catch (e) { console.warn('Could not capture waveform:', e); y += 4; }
} else { y += 4; }
// === PROBABILITY BREAKDOWN ===
pdf.setFontSize(11); pdf.setFont('helvetica', 'bold');
pdf.setTextColor(30, 41, 59);
pdf.text('AI Classification', marginL, y);
y += 6;
ai.all_classes.forEach(cls => {
const pct = (cls.probability * 100).toFixed(1);
const isM = cls.label.toLowerCase().includes('murmur');
pdf.setFontSize(9); pdf.setFont('helvetica', 'normal');
pdf.setTextColor(71, 85, 105);
pdf.text(`${cls.label}:`, marginL + 5, y + 4);
pdf.setFont('helvetica', 'bold');
pdf.text(`${pct}%`, marginL + 35, y + 4);
// Bar background
pdf.setFillColor(226, 232, 240);
pdf.roundedRect(marginL + 52, y, contentW - 60, 6, 2, 2, 'F');
// Bar fill
const barW = Math.max(2, (cls.probability) * (contentW - 60));
pdf.setFillColor(isM ? 239 : 34, isM ? 68 : 197, isM ? 68 : 94);
pdf.roundedRect(marginL + 52, y, barW, 6, 2, 2, 'F');
y += 12;
});
y += 4;
// === FEATURE DETAILS ===
pdf.setFontSize(11); pdf.setFont('helvetica', 'bold');
pdf.setTextColor(30, 41, 59);
pdf.text('Signal Analysis Features', marginL, y);
y += 5;
pdf.setFillColor(248, 250, 252);
pdf.rect(marginL, y, contentW, 8, 'F');
pdf.setDrawColor(226, 232, 240);
pdf.rect(marginL, y, contentW, 8, 'S');
pdf.setFontSize(8); pdf.setFont('helvetica', 'normal');
pdf.setTextColor(71, 85, 105);
pdf.text(ai.details || '', marginL + 4, y + 5.5);
y += 16;
// === DISCLAIMER ===
pdf.setFillColor(255, 251, 235);
pdf.roundedRect(marginL, y, contentW, 22, 2, 2, 'F');
pdf.setDrawColor(251, 191, 36);
pdf.roundedRect(marginL, y, contentW, 22, 2, 2, 'S');
pdf.setFontSize(8); pdf.setFont('helvetica', 'bold');
pdf.setTextColor(146, 64, 14);
pdf.text('IMPORTANT NOTICE', marginL + 5, y + 6);
pdf.setFont('helvetica', 'normal'); pdf.setFontSize(7.5);
pdf.setTextColor(120, 53, 15);
pdf.text('This is an AI-assisted screening tool for preliminary cardiac assessment. Results are NOT diagnostic.', marginL + 5, y + 12);
pdf.text('All findings should be confirmed by a veterinary cardiologist via echocardiography.', marginL + 5, y + 17);
y += 28;
// === FOOTER ===
pdf.setFontSize(7); pdf.setTextColor(148, 163, 184);
pdf.text('Model: CardioScreen Logistic Regression Classifier Β· Trained on: VetCPD + Hannover Vet School (21 canine recordings)', marginL, H - 12);
pdf.text(`Generated: ${now}`, W - marginR, H - 12, { align: 'right' });
pdf.save(`screening_${patientData.dogId}_${Date.now()}.pdf`);
};
const resetApp = () => {
stopRecording();
if (audioUrl) URL.revokeObjectURL(audioUrl);
setAppState('upload');
setPatientData({ dogId: '', breed: '', age: '' });
setAnalysisResult(null);
setAudioBlob(null);
setAudioUrl(null);
setTrimWaveform(null);
setTrimDuration(0);
rawAudioBuffer.current = null;
};
return (
<div className="app-container">
{/* Sidebar */}
{/* Mobile Header */}
<header className="mobile-header">
<div className="mobile-logo">
<div style={{ background: 'var(--accent-cyan)', padding: '6px', borderRadius: '7px' }}><Heart color="white" size={18} /></div>
CardioScreen <span className="text-gradient" style={{ marginLeft: 4 }}>AI</span>
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button className="btn-icon" onClick={toggleTheme} title={theme === 'dark' ? 'Light mode' : 'Dark mode'}>
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button>
<button className="btn-icon" onClick={() => setMobileNavOpen(o => !o)} title="Menu">
{mobileNavOpen ? <X size={18} /> : <Menu size={18} />}
</button>
</div>
</header>
{/* Mobile nav overlay */}
<div className={`mobile-nav-overlay ${mobileNavOpen ? 'open' : ''}`} onClick={closeMobileNav} />
<aside className={`sidebar ${mobileNavOpen ? 'mobile-open' : ''}`}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '32px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div style={{ background: 'var(--accent-cyan)', padding: '8px', borderRadius: '8px' }}><Heart color="white" size={22} /></div>
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>CardioScreen <span className="text-gradient">AI</span></h2>
</div>
{/* Theme toggle (desktop) */}
<button className="btn-icon" onClick={toggleTheme} title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'} style={{ display: 'flex' }}>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
</div>
<nav>
<div className={`nav-item ${(appState === 'upload' || appState === 'recording') ? 'active' : ''}`}
onClick={() => { if (appState !== 'analyzing') { resetApp(); closeMobileNav(); } }}>
<Upload size={17} /><span>New Scan</span>
</div>
<div className={`nav-item ${appState === 'dashboard' ? 'active' : ''}`} onClick={closeMobileNav}>
<Activity size={17} /><span>Analysis Result</span>
</div>
</nav>
<div style={{ marginTop: 'auto', padding: '14px', background: 'var(--bg-input)', borderRadius: '10px', border: '1px solid var(--border-color)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: 'var(--success)', boxShadow: '0 0 8px var(--success)' }}></div>
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>AI Engine Active</span>
</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>v1.0 Β· Thesis Edition</div>
</div>
</aside>
{/* Main Content */}
<main className="main-content">
{/* VIEW: UPLOAD / RECORD */}
{(appState === 'upload' || appState === 'recording') && (
<div className="animate-fade-in" style={{ maxWidth: '800px', margin: '0 auto' }}>
<div className="dashboard-header">
<div>
<h1 className="header-title">New Patient Scan</h1>
<p className="header-subtitle">Capture phonocardiogram via stethoscope + microphone</p>
</div>
</div>
<div className="glass-card" style={{ marginBottom: '20px' }}>
<h3 style={{ marginBottom: '14px', fontSize: '1rem' }}>Patient Details</h3>
<div className="patient-row" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '14px' }}>
<div>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Dog ID *</label>
<input type="text" value={patientData.dogId} onChange={e => setPatientData({ ...patientData, dogId: e.target.value })} placeholder="e.g. DOG-001" disabled={appState === 'recording'}
style={{ width: '100%', background: 'var(--bg-input)', border: '1px solid var(--border-color)', padding: '10px 12px', borderRadius: '8px', color: 'var(--text-primary)', fontSize: '0.9rem', outline: 'none' }} />
</div>
<div>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Breed</label>
<input type="text" value={patientData.breed} onChange={e => setPatientData({ ...patientData, breed: e.target.value })} placeholder="e.g. German Shepherd" disabled={appState === 'recording'}
style={{ width: '100%', background: 'var(--bg-input)', border: '1px solid var(--border-color)', padding: '10px 12px', borderRadius: '8px', color: 'var(--text-primary)', fontSize: '0.9rem', outline: 'none' }} />
</div>
<div>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Age (Years)</label>
<input type="number" value={patientData.age} onChange={e => setPatientData({ ...patientData, age: e.target.value })} placeholder="e.g. 7" disabled={appState === 'recording'}
style={{ width: '100%', background: 'var(--bg-input)', border: '1px solid var(--border-color)', padding: '10px 12px', borderRadius: '8px', color: 'var(--text-primary)', fontSize: '0.9rem', outline: 'none' }} />
</div>
</div>
</div>
{appState === 'upload' ? (
<div className="upload-zones-row" style={{ display: 'flex', gap: '20px' }}>
<div className="upload-zone" style={{ flex: 1 }} onClick={startRecording}>
<div style={{ background: 'rgba(59,130,246,0.12)', padding: '20px', borderRadius: '50%', boxShadow: '0 0 24px rgba(59,130,246,0.3)' }}>
<Mic size={40} color="var(--accent-blue)" />
</div>
<h3 style={{ fontSize: '1.1rem' }}>Start Live Recording</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.88rem' }}>Record from stethoscope microphone</p>
</div>
<div className="upload-zone" style={{ flex: 1 }} onClick={handleManualUpload}>
<div style={{ background: 'rgba(6,182,212,0.12)', padding: '20px', borderRadius: '50%', boxShadow: '0 0 24px rgba(6,182,212,0.25)' }}>
<FileAudio size={40} color="var(--accent-cyan)" />
</div>
<h3 style={{ fontSize: '1.1rem' }}>Upload Audio File</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.88rem' }}>WAV, MP3 or any audio format</p>
</div>
</div>
) : (
<div className="glass-card" style={{ border: '2px solid var(--danger)', textAlign: 'center' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '12px', height: '12px', borderRadius: '50%', background: 'var(--danger)', animation: 'pulse-danger 1s infinite' }}></div>
<span style={{ color: 'var(--danger)', fontWeight: 600 }}>RECORDING</span>
</div>
<div style={{ fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: 600 }}>
00:{recordingTime.toString().padStart(2, '0')}
</div>
</div>
<div style={{ width: '100%', height: '180px', background: 'rgba(0,0,0,0.5)', borderRadius: '8px', overflow: 'hidden', marginBottom: '24px', border: '1px solid rgba(255,255,255,0.05)' }}>
<canvas ref={canvasRef} width="800" height="180" style={{ width: '100%', height: '100%' }}></canvas>
</div>
<div style={{ display: 'flex', gap: '16px', justifyContent: 'center' }}>
<button className="btn-secondary" onClick={stopRecording}>
<AlertTriangle size={18} /> Cancel
</button>
<button className="btn-danger" onClick={handleFinishRecording}>
<Square size={18} fill="white" /> Stop & Analyze
</button>
</div>
</div>
)}
</div>
)}
{/* VIEW: TRIMMING */}
{appState === 'trimming' && trimWaveform && (
<div className="animate-fade-in" style={{ maxWidth: '900px', margin: '0 auto' }}>
<div className="dashboard-header">
<div>
<h1 className="header-title">Trim Recording</h1>
<p className="header-subtitle">Remove noise before the stethoscope was placed correctly</p>
</div>
<button className="btn-secondary" onClick={resetApp}>
<RefreshCw size={16} /> Cancel
</button>
</div>
<div className="glass-card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
<div style={{ background: 'rgba(6,182,212,0.15)', padding: '8px', borderRadius: '8px' }}>
<Activity size={20} color="var(--accent-cyan)" />
</div>
<div>
<div style={{ fontWeight: 600, fontSize: '0.95rem' }}>Select Clean Section</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
Full recording: {trimDuration.toFixed(1)}s &nbsp;Β·&nbsp; Drag handles to select just the heart sounds
</div>
</div>
</div>
<AudioTrimmer
waveform={trimWaveform}
duration={trimDuration}
onAnalyze={handleAnalyzeTrimmed}
onSkip={handleSkipTrim}
/>
</div>
</div>
)}
{/* VIEW: ANALYZING */}
{appState === 'analyzing' && (
<div className="animate-fade-in" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<RefreshCw size={48} color="var(--accent-cyan)" style={{ animation: 'spin 2s linear infinite', marginBottom: '24px' }} />
<style>{`@keyframes spin { 100% { transform: rotate(360deg); } }`}</style>
<h2>Analyzing Heart Sound...</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: '8px', maxWidth: '400px', textAlign: 'center' }}>
Running cardiac screening via pre-trained AI model on your local machine.
</p>
</div>
)}
{/* VIEW: DASHBOARD */}
{appState === 'dashboard' && analysisResult && (
<div className="animate-fade-in delay-100">
<div className="dashboard-header">
<div>
<h1 className="header-title">Screening Result</h1>
<p className="header-subtitle">Patient: {patientData.dogId} | Breed: {patientData.breed || 'N/A'} | Age: {patientData.age || 'N/A'}</p>
</div>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<button className="btn-secondary" onClick={downloadReport}>
<Download size={16} /> PDF Report
</button>
{audioBlob && (
<button className="btn-secondary" onClick={downloadAudio}>
<FileAudio size={16} /> Save Audio
</button>
)}
<button className="btn-secondary" onClick={resetApp}>
<RefreshCw size={16} /> New Scan
</button>
</div>
</div>
<div className="dashboard-grid">
{/* Main Result Card */}
<div className={`glass-card col-span-3 ${analysisResult.ai_classification.is_disease ? 'result-disease' : 'result-normal'}`}>
{/* Header: Diagnosis + BPM */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '24px' }}>
<div>
<h3 style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '1px' }}>
AI Cardiac Screening
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{analysisResult.ai_classification.is_disease ? (
<AlertTriangle size={36} color="var(--danger)" />
) : (
<CheckCircle size={36} color="var(--success)" />
)}
<h2 style={{ fontSize: '2.2rem', margin: 0, color: analysisResult.ai_classification.is_disease ? 'var(--danger)' : 'var(--success)' }}>
{analysisResult.clinical_summary}
</h2>
</div>
</div>
{/* BPM + Heartbeat Count */}
<div style={{ display: 'flex', gap: '16px' }}>
<div style={{ textAlign: 'center', background: 'rgba(0,0,0,0.2)', padding: '16px 24px', borderRadius: '12px', border: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ fontSize: '3rem', fontWeight: 800, color: analysisResult.bpmColor, lineHeight: 1 }}>
{analysisResult.bpm}
</div>
<div style={{ fontSize: '0.9rem', color: 'var(--text-secondary)', marginTop: '4px' }}>BPM</div>
<div style={{ color: analysisResult.bpmColor, fontSize: '0.8rem', fontWeight: 600, marginTop: '4px', display: 'flex', alignItems: 'center', gap: '4px', justifyContent: 'center' }}>
<Activity size={12} /> {analysisResult.bpmStatus}
</div>
</div>
<div style={{ textAlign: 'center', background: 'rgba(0,0,0,0.2)', padding: '16px 24px', borderRadius: '12px', border: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ fontSize: '3rem', fontWeight: 800, color: 'var(--accent-cyan)', lineHeight: 1 }}>
{analysisResult.heartbeat_count}
</div>
<div style={{ fontSize: '0.9rem', color: 'var(--text-secondary)', marginTop: '4px' }}>Beats</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '0.8rem', marginTop: '4px' }}>
in {analysisResult.duration_seconds}s
</div>
</div>
</div>
</div>
{/* Audio Player */}
{audioUrl && (
<div style={{ marginBottom: '16px', background: 'rgba(0,0,0,0.25)', borderRadius: '12px', padding: '14px 18px', border: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '14px' }}>
<FileAudio size={20} color="var(--accent-cyan)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', flexShrink: 0 }}>Playback</span>
<audio controls src={audioUrl} ref={audioRef} style={{ flex: 1, height: '36px', borderRadius: '8px' }} />
</div>
)}
{/* Waveform with time axis + beat markers */}
<div style={{ marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '16px' }}>
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ display: 'inline-block', width: 10, height: 10, background: analysisResult.ai_classification.is_disease ? 'var(--danger)' : 'var(--accent-cyan)', transform: 'rotate(45deg)' }}></span>
Heartbeat peaks ({analysisResult.peak_times_seconds?.length ?? 0} detected)
</span>
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Duration: {analysisResult.duration_seconds}s</span>
</div>
<div style={{ width: '100%', height: '280px', background: 'rgba(0,0,0,0.35)', borderRadius: '12px', overflow: 'hidden', border: '1px solid rgba(255,255,255,0.05)' }}>
<WaveformCanvas
waveform={analysisResult.waveform}
peakVisIndices={analysisResult.peak_vis_indices}
peakTimesSec={analysisResult.peak_times_seconds}
duration={analysisResult.duration_seconds}
isDisease={analysisResult.ai_classification.is_disease}
canvasRefOut={waveformCanvasRef}
audioRef={audioRef}
/>
</div>
{/* AI Probability Breakdown */}
<div style={{ marginTop: '24px', background: 'rgba(0,0,0,0.2)', borderRadius: '12px', padding: '24px', border: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<div style={{ color: 'var(--text-secondary)', fontSize: '1rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Cpu size={18} color="var(--accent-cyan)" /> AI Probability Breakdown
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', opacity: 0.7 }}>
CardioScreen Β· Logistic Regression Β· 21 recordings
</div>
</div>
{analysisResult.ai_classification.all_classes.map((cls, idx) => {
const pct = (cls.probability * 100).toFixed(1);
const isTop = cls.label === analysisResult.ai_classification.label;
const isMurmur = cls.label.toLowerCase().includes('murmur') || cls.label.toLowerCase().includes('abnormal');
const barColor = isMurmur ? 'var(--danger)' : 'var(--success)';
return (
<div key={idx} style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '6px' }}>
<span style={{ color: isTop ? 'white' : 'var(--text-secondary)', fontWeight: isTop ? 700 : 400, fontSize: '0.95rem' }}>
{cls.label} {isTop && 'β˜…'}
</span>
<span style={{ color: isTop ? 'white' : 'var(--text-secondary)', fontWeight: 600 }}>
{pct}%
</span>
</div>
<div className="confidence-bar-bg" style={{ height: '10px', borderRadius: '5px' }}>
<div className="confidence-bar-fill" style={{
borderRadius: '5px',
width: `${Math.max(2, pct)}%`,
background: isTop ? barColor : 'rgba(255,255,255,0.15)',
transition: 'width 1s ease'
}}></div>
</div>
</div>
);
})}
</div>
</div>
{/* Canine Reference Table */}
<div className="glass-card col-span-3" style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '32px' }}>
<div>
<h3 style={{ marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--accent-cyan)' }}>
<Activity size={18} /> Normal Heart Rate (Canine)
</h3>
<table style={{ width: '100%', fontSize: '0.85rem', borderCollapse: 'collapse', color: 'var(--text-secondary)' }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<th style={{ padding: '8px 0' }}>Dog Size</th>
<th style={{ padding: '8px 0' }}>Normal Range</th>
</tr>
</thead>
<tbody>
<tr><td style={{ padding: '8px 0' }}>Large (60lb+)</td><td style={{ color: 'white' }}>60 – 100 BPM</td></tr>
<tr><td style={{ padding: '8px 0' }}>Medium (20–60lb)</td><td style={{ color: 'white' }}>80 – 120 BPM</td></tr>
<tr><td style={{ padding: '8px 0' }}>Small (&lt;20lb)</td><td style={{ color: 'white' }}>100 – 160 BPM</td></tr>
<tr><td style={{ padding: '8px 0' }}>Puppies</td><td style={{ color: 'white' }}>Up to 220 BPM</td></tr>
</tbody>
</table>
</div>
<div>
<h3 style={{ marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--accent-cyan)' }}>
<Heart size={18} /> Murmur Grading (Levine Scale)
</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', fontSize: '0.8rem' }}>
<div style={{ background: 'rgba(255,255,255,0.02)', padding: '8px', borderRadius: '4px' }}>
<b style={{ color: 'white' }}>Grade I:</b> Very faint
</div>
<div style={{ background: 'rgba(255,255,255,0.02)', padding: '8px', borderRadius: '4px' }}>
<b style={{ color: 'white' }}>Grade II:</b> Soft, easily heard
</div>
<div style={{ background: 'rgba(255,255,255,0.02)', padding: '8px', borderRadius: '4px' }}>
<b style={{ color: 'white' }}>Grade III:</b> Intermediate
</div>
<div style={{ background: 'rgba(255,255,255,0.02)', padding: '8px', borderRadius: '4px' }}>
<b style={{ color: 'white' }}>Grade IV:</b> Loud, no thrill
</div>
<div style={{ background: 'rgba(255,255,255,0.02)', padding: '8px', borderRadius: '4px' }}>
<b style={{ color: 'white' }}>Grade V:</b> With palpable thrill
</div>
<div style={{ background: 'rgba(255,255,255,0.02)', padding: '8px', borderRadius: '4px' }}>
<b style={{ color: 'white' }}>Grade VI:</b> Heard without stethoscope
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</main>
</div>
);
}
export default App;