Spaces:
Running
Running
| 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 Β· 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 (<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; | |