Spaces:
Sleeping
Sleeping
CardioScreen AI commited on
Commit ·
2c59c0c
0
Parent(s):
Initial commit: CardioScreen AI v1.0 - Canine cardiac screening tool
Browse files- .gitignore +33 -0
- api.py +32 -0
- download_hf_model.py +21 -0
- inference.py +252 -0
- model_params.json +37 -0
- render.yaml +21 -0
- requirements.txt +7 -0
- src/train.py +218 -0
- webapp/.env.production +4 -0
- webapp/.gitignore +24 -0
- webapp/README.md +16 -0
- webapp/eslint.config.js +29 -0
- webapp/index.html +17 -0
- webapp/package-lock.json +0 -0
- webapp/package.json +31 -0
- webapp/public/_redirects +1 -0
- webapp/public/vite.svg +1 -0
- webapp/src/App.css +184 -0
- webapp/src/App.jsx +1130 -0
- webapp/src/assets/react.svg +1 -0
- webapp/src/index.css +251 -0
- webapp/src/main.jsx +10 -0
- webapp/vite.config.js +16 -0
.gitignore
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── Python ──────────────────────────────────────────────────────────────────
|
| 2 |
+
venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
*.pyo
|
| 6 |
+
*.egg-info/
|
| 7 |
+
dist/
|
| 8 |
+
*.pkl
|
| 9 |
+
*.h5
|
| 10 |
+
|
| 11 |
+
# ── Audio / data files (too large for git) ───────────────────────────────────
|
| 12 |
+
*.mp3
|
| 13 |
+
*.wav
|
| 14 |
+
*.flac
|
| 15 |
+
*.ogg
|
| 16 |
+
dataset_raw/
|
| 17 |
+
Ettinger_Tracks/
|
| 18 |
+
Ettinger canine heart sound/
|
| 19 |
+
weights/
|
| 20 |
+
local_hf_model/
|
| 21 |
+
metrics/
|
| 22 |
+
|
| 23 |
+
# ── Node / Frontend ──────────────────────────────────────────────────────────
|
| 24 |
+
webapp/node_modules/
|
| 25 |
+
webapp/dist/
|
| 26 |
+
webapp/.env.local
|
| 27 |
+
|
| 28 |
+
# ── OS / Editor ──────────────────────────────────────────────────────────────
|
| 29 |
+
.DS_Store
|
| 30 |
+
Thumbs.db
|
| 31 |
+
.vscode/
|
| 32 |
+
*.log
|
| 33 |
+
get-pip.py
|
api.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CardioScreen AI — FastAPI Backend
|
| 3 |
+
Serves the local AI inference engine for canine cardiac screening.
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
from fastapi import FastAPI, UploadFile, File
|
| 8 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
+
import uvicorn
|
| 10 |
+
from inference import predict_audio
|
| 11 |
+
|
| 12 |
+
app = FastAPI(title="CardioScreen AI — Canine Cardiac Screening")
|
| 13 |
+
|
| 14 |
+
app.add_middleware(
|
| 15 |
+
CORSMiddleware,
|
| 16 |
+
allow_origins=["*"],
|
| 17 |
+
allow_credentials=True,
|
| 18 |
+
allow_methods=["*"],
|
| 19 |
+
allow_headers=["*"],
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
@app.post("/analyze")
|
| 23 |
+
async def analyze_audio(file: UploadFile = File(...)):
|
| 24 |
+
"""Receives audio from the React frontend and returns screening results."""
|
| 25 |
+
audio_bytes = await file.read()
|
| 26 |
+
print(f"Received: {file.filename}, {len(audio_bytes)} bytes", flush=True)
|
| 27 |
+
return predict_audio(audio_bytes)
|
| 28 |
+
|
| 29 |
+
if __name__ == "__main__":
|
| 30 |
+
port = int(os.environ.get("PORT", 8000))
|
| 31 |
+
print(f"Starting CardioScreen AI server on http://0.0.0.0:{port}")
|
| 32 |
+
uvicorn.run(app, host="0.0.0.0", port=port)
|
download_hf_model.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from transformers import pipeline
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
MODEL_NAME = "cogniveon/eeem069_heart_murmur_classification"
|
| 5 |
+
SAVE_PATH = "./local_hf_model"
|
| 6 |
+
|
| 7 |
+
if __name__ == "__main__":
|
| 8 |
+
print(f"Downloading pre-trained High-Accuracy model: {MODEL_NAME}...")
|
| 9 |
+
# This automatically downloads the weights and config if they aren't cached
|
| 10 |
+
# and saves them explicitly to our local folder so we don't rely on the cloud API
|
| 11 |
+
|
| 12 |
+
os.makedirs(SAVE_PATH, exist_ok=True)
|
| 13 |
+
|
| 14 |
+
# We use pipeline to ensure the feature extractor and model are both fetched
|
| 15 |
+
classifier = pipeline("audio-classification", model=MODEL_NAME)
|
| 16 |
+
|
| 17 |
+
print(f"Saving model to {SAVE_PATH}...")
|
| 18 |
+
classifier.model.save_pretrained(SAVE_PATH)
|
| 19 |
+
classifier.feature_extractor.save_pretrained(SAVE_PATH)
|
| 20 |
+
|
| 21 |
+
print("Download and save successful!")
|
inference.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CardioScreen AI — Lightweight Inference Engine
|
| 3 |
+
No PyTorch. No transformers. Just signal processing.
|
| 4 |
+
|
| 5 |
+
Detects murmurs using spectral analysis of heart sounds:
|
| 6 |
+
- Heart rate via Hilbert envelope peak detection
|
| 7 |
+
- Murmur screening via frequency analysis between S1/S2 beats
|
| 8 |
+
(murmurs produce abnormal energy in 100–600 Hz between heartbeats)
|
| 9 |
+
"""
|
| 10 |
+
import io
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
# Numpy 2.0 compatibility
|
| 14 |
+
if not hasattr(np, 'trapz'):
|
| 15 |
+
np.trapz = np.trapezoid
|
| 16 |
+
if not hasattr(np, 'in1d'):
|
| 17 |
+
np.in1d = np.isin
|
| 18 |
+
|
| 19 |
+
import librosa
|
| 20 |
+
import scipy.signal
|
| 21 |
+
|
| 22 |
+
TARGET_SR = 16000
|
| 23 |
+
|
| 24 |
+
print("CardioScreen AI engine loaded (lightweight mode)", flush=True)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def load_audio(audio_bytes: bytes):
|
| 28 |
+
"""Decode audio bytes → mono 16kHz numpy array with cardiac bandpass filter."""
|
| 29 |
+
import soundfile as sf
|
| 30 |
+
y, sr = sf.read(io.BytesIO(audio_bytes))
|
| 31 |
+
|
| 32 |
+
if len(y.shape) > 1:
|
| 33 |
+
y = np.mean(y, axis=1)
|
| 34 |
+
|
| 35 |
+
if sr != TARGET_SR:
|
| 36 |
+
y = librosa.resample(y, orig_sr=sr, target_sr=TARGET_SR)
|
| 37 |
+
|
| 38 |
+
# Cardiac bandpass filter (25–600 Hz) — covers both normal sounds AND murmurs
|
| 39 |
+
nyq = 0.5 * TARGET_SR
|
| 40 |
+
b, a = scipy.signal.butter(4, [25.0 / nyq, 600.0 / nyq], btype='band')
|
| 41 |
+
y_filtered = scipy.signal.filtfilt(b, a, y)
|
| 42 |
+
|
| 43 |
+
return librosa.util.normalize(y_filtered)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def calculate_bpm(y, sr):
|
| 47 |
+
"""Extract BPM and heartbeat count via Hilbert envelope peak detection."""
|
| 48 |
+
try:
|
| 49 |
+
envelope = np.abs(scipy.signal.hilbert(y))
|
| 50 |
+
win_len = int(0.1 * sr)
|
| 51 |
+
if win_len % 2 == 0:
|
| 52 |
+
win_len += 1
|
| 53 |
+
smooth_env = scipy.signal.savgol_filter(envelope, window_length=win_len, polyorder=3)
|
| 54 |
+
|
| 55 |
+
v_90 = np.percentile(smooth_env, 90)
|
| 56 |
+
height = v_90 * 0.5
|
| 57 |
+
min_dist = int(0.3 * sr) # min 300ms between beats
|
| 58 |
+
|
| 59 |
+
peaks, properties = scipy.signal.find_peaks(smooth_env, distance=min_dist, height=height)
|
| 60 |
+
|
| 61 |
+
if len(peaks) < 3:
|
| 62 |
+
height = v_90 * 0.2
|
| 63 |
+
peaks, properties = scipy.signal.find_peaks(smooth_env, distance=min_dist, height=height)
|
| 64 |
+
if len(peaks) < 2:
|
| 65 |
+
return 0, 0, peaks
|
| 66 |
+
|
| 67 |
+
intervals = np.diff(peaks)
|
| 68 |
+
bpm = (60.0 * sr) / np.mean(intervals)
|
| 69 |
+
return int(max(40, min(220, bpm))), len(peaks), peaks
|
| 70 |
+
except Exception as e:
|
| 71 |
+
print(f"BPM Error: {e}", flush=True)
|
| 72 |
+
return 0, 0, np.array([])
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def detect_murmur(y, sr, peaks):
|
| 76 |
+
"""
|
| 77 |
+
Murmur detection via spectral analysis of inter-beat intervals.
|
| 78 |
+
|
| 79 |
+
Normal heart sounds (S1, S2) are brief, low-frequency thuds.
|
| 80 |
+
Murmurs are prolonged, higher-frequency sounds BETWEEN the beats.
|
| 81 |
+
|
| 82 |
+
We analyze the spectral content between detected heartbeats:
|
| 83 |
+
- High energy ratio in 100-600Hz between beats → murmur likely
|
| 84 |
+
- Low spectral entropy → normal clean silence between beats
|
| 85 |
+
- High spectral entropy → turbulent flow (murmur indicator)
|
| 86 |
+
"""
|
| 87 |
+
if len(peaks) < 3:
|
| 88 |
+
return {
|
| 89 |
+
"label": "Insufficient Data",
|
| 90 |
+
"confidence": 0.0,
|
| 91 |
+
"is_disease": False,
|
| 92 |
+
"details": "Need at least 3 heartbeats for analysis",
|
| 93 |
+
"all_classes": [
|
| 94 |
+
{"label": "Insufficient Data", "probability": 1.0},
|
| 95 |
+
]
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
# Analyze the intervals BETWEEN heartbeats
|
| 99 |
+
inter_beat_energies = []
|
| 100 |
+
inter_beat_entropies = []
|
| 101 |
+
beat_energies = []
|
| 102 |
+
|
| 103 |
+
for i in range(len(peaks) - 1):
|
| 104 |
+
# Region around the beat itself (±50ms)
|
| 105 |
+
beat_start = max(0, peaks[i] - int(0.05 * sr))
|
| 106 |
+
beat_end = min(len(y), peaks[i] + int(0.05 * sr))
|
| 107 |
+
beat_segment = y[beat_start:beat_end]
|
| 108 |
+
|
| 109 |
+
# Region between beats (the "gap" where murmurs live)
|
| 110 |
+
gap_start = peaks[i] + int(0.08 * sr) # skip 80ms after beat
|
| 111 |
+
gap_end = peaks[i + 1] - int(0.08 * sr) # stop 80ms before next beat
|
| 112 |
+
|
| 113 |
+
if gap_end <= gap_start:
|
| 114 |
+
continue
|
| 115 |
+
|
| 116 |
+
gap_segment = y[gap_start:gap_end]
|
| 117 |
+
|
| 118 |
+
# RMS energy of the beat vs the gap
|
| 119 |
+
beat_rms = np.sqrt(np.mean(beat_segment ** 2)) if len(beat_segment) > 0 else 0
|
| 120 |
+
gap_rms = np.sqrt(np.mean(gap_segment ** 2)) if len(gap_segment) > 0 else 0
|
| 121 |
+
|
| 122 |
+
beat_energies.append(beat_rms)
|
| 123 |
+
inter_beat_energies.append(gap_rms)
|
| 124 |
+
|
| 125 |
+
# Spectral entropy of the gap (high entropy = turbulent flow = murmur)
|
| 126 |
+
if len(gap_segment) > 256:
|
| 127 |
+
freqs = np.abs(np.fft.rfft(gap_segment))
|
| 128 |
+
freqs = freqs / (np.sum(freqs) + 1e-12)
|
| 129 |
+
entropy = -np.sum(freqs * np.log2(freqs + 1e-12))
|
| 130 |
+
inter_beat_entropies.append(entropy)
|
| 131 |
+
|
| 132 |
+
if not inter_beat_energies:
|
| 133 |
+
return {
|
| 134 |
+
"label": "Insufficient Data",
|
| 135 |
+
"confidence": 0.0,
|
| 136 |
+
"is_disease": False,
|
| 137 |
+
"details": "Could not isolate inter-beat intervals",
|
| 138 |
+
"all_classes": [
|
| 139 |
+
{"label": "Insufficient Data", "probability": 1.0},
|
| 140 |
+
]
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
# Key metrics
|
| 144 |
+
avg_beat_energy = np.mean(beat_energies)
|
| 145 |
+
avg_gap_energy = np.mean(inter_beat_energies)
|
| 146 |
+
energy_ratio = avg_gap_energy / (avg_beat_energy + 1e-12)
|
| 147 |
+
avg_entropy = np.mean(inter_beat_entropies) if inter_beat_entropies else 0
|
| 148 |
+
|
| 149 |
+
# Inter-beat energy consistency (murmurs are consistent; noise is random)
|
| 150 |
+
gap_energy_std = np.std(inter_beat_energies) / (avg_gap_energy + 1e-12)
|
| 151 |
+
consistency = 1.0 - min(1.0, gap_energy_std) # High = consistent inter-beat energy
|
| 152 |
+
|
| 153 |
+
# High-frequency energy ratio (murmurs have more energy in 200-600Hz band)
|
| 154 |
+
# Analyze frequency distribution in the gaps
|
| 155 |
+
hf_ratios = []
|
| 156 |
+
for i in range(len(peaks) - 1):
|
| 157 |
+
gap_start = peaks[i] + int(0.08 * sr)
|
| 158 |
+
gap_end = peaks[i + 1] - int(0.08 * sr)
|
| 159 |
+
if gap_end <= gap_start:
|
| 160 |
+
continue
|
| 161 |
+
gap_segment = y[gap_start:gap_end]
|
| 162 |
+
if len(gap_segment) > 512:
|
| 163 |
+
fft_mag = np.abs(np.fft.rfft(gap_segment))
|
| 164 |
+
freqs_hz = np.fft.rfftfreq(len(gap_segment), 1.0 / sr)
|
| 165 |
+
# Energy in murmur band (150-500Hz) vs total
|
| 166 |
+
murmur_band = np.sum(fft_mag[(freqs_hz >= 150) & (freqs_hz <= 500)])
|
| 167 |
+
total = np.sum(fft_mag) + 1e-12
|
| 168 |
+
hf_ratios.append(murmur_band / total)
|
| 169 |
+
|
| 170 |
+
hf_ratio = np.mean(hf_ratios) if hf_ratios else 0.0
|
| 171 |
+
|
| 172 |
+
# Also extract MFCCs for overall spectral characterization
|
| 173 |
+
mfccs = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
|
| 174 |
+
mfcc_var = np.mean(np.var(mfccs, axis=1))
|
| 175 |
+
|
| 176 |
+
# ─── Trained classifier (logistic regression on 21 canine recordings) ───
|
| 177 |
+
# Features: [energy_ratio, consistency, hf_ratio, entropy, mfcc_var]
|
| 178 |
+
# Trained on VetCPD (5) + Hannover Examples (9) + Hannover Grading (7)
|
| 179 |
+
# Results: 95% accuracy, 94% sensitivity, 100% specificity
|
| 180 |
+
#
|
| 181 |
+
# Model weights (scikit-learn logistic regression):
|
| 182 |
+
SCALER_MEAN = [0.4315, 0.7709, 0.2588, 7.1566, 220.9825]
|
| 183 |
+
SCALER_STD = [0.1573, 0.0888, 0.1294, 0.7728, 124.5063]
|
| 184 |
+
WEIGHTS = [1.2507, 0.3728, -0.4740, -0.3317, 1.1285]
|
| 185 |
+
INTERCEPT = 0.8248
|
| 186 |
+
SCREENING_THRESHOLD = 0.40 # Optimized for screening sensitivity
|
| 187 |
+
|
| 188 |
+
# Scale features
|
| 189 |
+
raw_features = [energy_ratio, consistency, hf_ratio, avg_entropy, mfcc_var]
|
| 190 |
+
scaled = [(f - m) / (s + 1e-12) for f, m, s in zip(raw_features, SCALER_MEAN, SCALER_STD)]
|
| 191 |
+
|
| 192 |
+
# Logistic regression: P(murmur) = sigmoid(w·x + b)
|
| 193 |
+
logit = sum(w * x for w, x in zip(WEIGHTS, scaled)) + INTERCEPT
|
| 194 |
+
murmur_prob = float(1.0 / (1.0 + np.exp(-logit)))
|
| 195 |
+
normal_prob = float(1.0 - murmur_prob)
|
| 196 |
+
|
| 197 |
+
is_murmur = bool(murmur_prob >= SCREENING_THRESHOLD)
|
| 198 |
+
|
| 199 |
+
return {
|
| 200 |
+
"label": "Murmur" if is_murmur else "Normal",
|
| 201 |
+
"confidence": round(murmur_prob if is_murmur else normal_prob, 4),
|
| 202 |
+
"is_disease": is_murmur,
|
| 203 |
+
"details": f"Energy ratio: {energy_ratio:.3f}, HF ratio: {hf_ratio:.3f}, Consistency: {consistency:.3f}, Entropy: {avg_entropy:.1f}, MFCC var: {mfcc_var:.1f}",
|
| 204 |
+
"all_classes": [
|
| 205 |
+
{"label": "Normal", "probability": round(normal_prob, 4)},
|
| 206 |
+
{"label": "Murmur", "probability": round(murmur_prob, 4)},
|
| 207 |
+
]
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def predict_audio(audio_bytes: bytes):
|
| 212 |
+
"""Main inference function called by api.py."""
|
| 213 |
+
try:
|
| 214 |
+
waveform = load_audio(audio_bytes)
|
| 215 |
+
duration = len(waveform) / TARGET_SR
|
| 216 |
+
print(f"Audio: {len(waveform)} samples, {duration:.1f}s", flush=True)
|
| 217 |
+
|
| 218 |
+
bpm, heartbeat_count, peaks = calculate_bpm(waveform, TARGET_SR)
|
| 219 |
+
print(f"BPM: {bpm}, Beats: {heartbeat_count}", flush=True)
|
| 220 |
+
|
| 221 |
+
classification = detect_murmur(waveform, TARGET_SR, peaks)
|
| 222 |
+
print(f"Classification: {classification['label']} ({classification['confidence']:.1%})", flush=True)
|
| 223 |
+
print(f"Details: {classification['details']}", flush=True)
|
| 224 |
+
|
| 225 |
+
summary = "Warning: Heart Murmur Detected" if classification["is_disease"] else "Normal Heart Sound"
|
| 226 |
+
|
| 227 |
+
# Downsample waveform for frontend (~800 points)
|
| 228 |
+
num_points = 800
|
| 229 |
+
step = max(1, len(waveform) // num_points)
|
| 230 |
+
vis_waveform = waveform[::step].tolist()
|
| 231 |
+
vis_duration = len(vis_waveform) # number of rendered points
|
| 232 |
+
|
| 233 |
+
# Convert peak sample indices → seconds, then → waveform-point index
|
| 234 |
+
peak_times_sec = [round(float(p) / TARGET_SR, 3) for p in peaks]
|
| 235 |
+
# Map peak sample position to the downsampled index space
|
| 236 |
+
peak_vis_indices = [int(p // step) for p in peaks if int(p // step) < vis_duration]
|
| 237 |
+
|
| 238 |
+
return {
|
| 239 |
+
"bpm": bpm,
|
| 240 |
+
"heartbeat_count": heartbeat_count,
|
| 241 |
+
"duration_seconds": round(duration, 1),
|
| 242 |
+
"clinical_summary": summary,
|
| 243 |
+
"ai_classification": classification,
|
| 244 |
+
"waveform": vis_waveform,
|
| 245 |
+
"peak_times_seconds": peak_times_sec,
|
| 246 |
+
"peak_vis_indices": peak_vis_indices,
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
except Exception as e:
|
| 250 |
+
import traceback
|
| 251 |
+
print(f"Error:\n{traceback.format_exc()}", flush=True)
|
| 252 |
+
return {"error": str(e)}
|
model_params.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"scaler_mean": [
|
| 3 |
+
0.4315492464945563,
|
| 4 |
+
0.7709345691357302,
|
| 5 |
+
0.25880673479401617,
|
| 6 |
+
7.15659643281259,
|
| 7 |
+
220.9824894463221
|
| 8 |
+
],
|
| 9 |
+
"scaler_scale": [
|
| 10 |
+
0.1572568658351758,
|
| 11 |
+
0.08882097430976825,
|
| 12 |
+
0.12937050957583243,
|
| 13 |
+
0.7727553020891176,
|
| 14 |
+
124.5062970996893
|
| 15 |
+
],
|
| 16 |
+
"weights": [
|
| 17 |
+
1.2507186240143076,
|
| 18 |
+
0.3727735273449431,
|
| 19 |
+
-0.4739513225273016,
|
| 20 |
+
-0.3316725756172594,
|
| 21 |
+
1.1284599560429354
|
| 22 |
+
],
|
| 23 |
+
"intercept": 0.8248089475357907,
|
| 24 |
+
"threshold": 0.4,
|
| 25 |
+
"feature_names": [
|
| 26 |
+
"Energy Ratio",
|
| 27 |
+
"Consistency",
|
| 28 |
+
"HF Ratio",
|
| 29 |
+
"Entropy",
|
| 30 |
+
"MFCC Var"
|
| 31 |
+
],
|
| 32 |
+
"training_samples": 21,
|
| 33 |
+
"n_normal": 3,
|
| 34 |
+
"n_murmur": 18,
|
| 35 |
+
"sensitivity": 0.9444,
|
| 36 |
+
"specificity": 1.0
|
| 37 |
+
}
|
render.yaml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
# ── Backend: FastAPI inference server ─────────────────────────────────────
|
| 3 |
+
- type: web
|
| 4 |
+
name: cardioscreen-api
|
| 5 |
+
runtime: python
|
| 6 |
+
region: frankfurt
|
| 7 |
+
plan: free
|
| 8 |
+
buildCommand: pip install -r requirements.txt
|
| 9 |
+
startCommand: uvicorn api:app --host 0.0.0.0 --port $PORT
|
| 10 |
+
|
| 11 |
+
# ── Frontend: Vite static build ────────────────────────────────────────────
|
| 12 |
+
- type: web
|
| 13 |
+
name: cardioscreen-ui
|
| 14 |
+
runtime: static
|
| 15 |
+
region: frankfurt
|
| 16 |
+
buildCommand: cd webapp && npm ci && npm run build
|
| 17 |
+
staticPublishPath: webapp/dist
|
| 18 |
+
routes:
|
| 19 |
+
- type: rewrite
|
| 20 |
+
source: /*
|
| 21 |
+
destination: /index.html
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.0
|
| 2 |
+
uvicorn>=0.30.0
|
| 3 |
+
numpy>=1.26.0
|
| 4 |
+
scipy>=1.13.0
|
| 5 |
+
librosa>=0.10.0
|
| 6 |
+
soundfile>=0.12.1
|
| 7 |
+
python-multipart>=0.0.9
|
src/train.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import glob
|
| 3 |
+
import librosa
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import scipy.signal
|
| 7 |
+
import matplotlib.pyplot as plt
|
| 8 |
+
import seaborn as sns
|
| 9 |
+
from sklearn.model_selection import train_test_split
|
| 10 |
+
from sklearn.preprocessing import StandardScaler
|
| 11 |
+
from sklearn.linear_model import LogisticRegression
|
| 12 |
+
from sklearn.ensemble import RandomForestClassifier
|
| 13 |
+
from sklearn.svm import SVC
|
| 14 |
+
from sklearn.metrics import accuracy_score, confusion_matrix, roc_curve, auc, cohen_kappa_score
|
| 15 |
+
import joblib
|
| 16 |
+
|
| 17 |
+
# Numpy 2.0 compatibility for librosa
|
| 18 |
+
if not hasattr(np, 'trapz'):
|
| 19 |
+
np.trapz = np.trapezoid
|
| 20 |
+
if not hasattr(np, 'in1d'):
|
| 21 |
+
def in1d_patch(ar1, ar2, assume_unique=False, invert=False):
|
| 22 |
+
return np.isin(ar1, ar2, assume_unique=assume_unique, invert=invert)
|
| 23 |
+
np.in1d = in1d_patch
|
| 24 |
+
|
| 25 |
+
# Config
|
| 26 |
+
DATASET_DIR = "dataset"
|
| 27 |
+
TARGET_SR = 16000
|
| 28 |
+
AUDIO_LENGTH_SEC = 5
|
| 29 |
+
os.makedirs("weights", exist_ok=True)
|
| 30 |
+
os.makedirs("metrics", exist_ok=True)
|
| 31 |
+
|
| 32 |
+
def apply_clinical_bandpass(y, sr):
|
| 33 |
+
nyq = 0.5 * sr
|
| 34 |
+
low = 25.0 / nyq
|
| 35 |
+
high = 400.0 / nyq
|
| 36 |
+
b, a = scipy.signal.butter(4, [low, high], btype='band')
|
| 37 |
+
return scipy.signal.filtfilt(b, a, y)
|
| 38 |
+
|
| 39 |
+
def extract_statistical_features(y, sr):
|
| 40 |
+
"""Extracts 1D interpretable statistical biomarkers."""
|
| 41 |
+
features = {}
|
| 42 |
+
|
| 43 |
+
mfccs = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
|
| 44 |
+
for i in range(13):
|
| 45 |
+
features[f'mfcc_{i}_mean'] = np.mean(mfccs[i])
|
| 46 |
+
features[f'mfcc_{i}_std'] = np.std(mfccs[i])
|
| 47 |
+
|
| 48 |
+
features['centroid_mean'] = np.mean(librosa.feature.spectral_centroid(y=y, sr=sr))
|
| 49 |
+
features['zcr_mean'] = np.mean(librosa.feature.zero_crossing_rate(y))
|
| 50 |
+
features['rms_mean'] = np.mean(librosa.feature.rms(y=y))
|
| 51 |
+
|
| 52 |
+
prob = np.square(np.abs(librosa.stft(y)))
|
| 53 |
+
prob = prob / np.sum(prob)
|
| 54 |
+
features['entropy'] = -np.sum(prob * np.log2(prob + 1e-10))
|
| 55 |
+
|
| 56 |
+
return features
|
| 57 |
+
|
| 58 |
+
def load_dataset():
|
| 59 |
+
print("Scanning dataset directory...")
|
| 60 |
+
files = glob.glob(os.path.join(DATASET_DIR, "*.wav"))
|
| 61 |
+
|
| 62 |
+
if not files:
|
| 63 |
+
print("ERROR: No .wav files found in dataset/")
|
| 64 |
+
return None, None
|
| 65 |
+
|
| 66 |
+
X_features = []
|
| 67 |
+
y_labels = []
|
| 68 |
+
|
| 69 |
+
for f in files:
|
| 70 |
+
try:
|
| 71 |
+
basename = os.path.basename(f).lower()
|
| 72 |
+
label = 1 if 'murmur' in basename or 'abnormal' in basename else 0
|
| 73 |
+
|
| 74 |
+
y, sr = librosa.load(f, sr=TARGET_SR, mono=True)
|
| 75 |
+
y = librosa.util.normalize(y)
|
| 76 |
+
y_clean = apply_clinical_bandpass(y, sr)
|
| 77 |
+
|
| 78 |
+
target_length = TARGET_SR * AUDIO_LENGTH_SEC
|
| 79 |
+
if len(y_clean) > target_length:
|
| 80 |
+
y_clean = y_clean[:target_length]
|
| 81 |
+
else:
|
| 82 |
+
y_clean = np.pad(y_clean, (0, target_length - len(y_clean)))
|
| 83 |
+
|
| 84 |
+
feats = extract_statistical_features(y_clean, sr)
|
| 85 |
+
X_features.append(feats)
|
| 86 |
+
y_labels.append(label)
|
| 87 |
+
except Exception as e:
|
| 88 |
+
print(f"Error processing {f}: {e}")
|
| 89 |
+
|
| 90 |
+
df = pd.DataFrame(X_features)
|
| 91 |
+
labels = np.array(y_labels)
|
| 92 |
+
|
| 93 |
+
print(f"Successfully processed {len(df)} canine recordings.")
|
| 94 |
+
return df, labels
|
| 95 |
+
|
| 96 |
+
def evaluate_model(y_true, y_pred):
|
| 97 |
+
acc = accuracy_score(y_true, y_pred)
|
| 98 |
+
cm = confusion_matrix(y_true, y_pred, labels=[0, 1])
|
| 99 |
+
|
| 100 |
+
if cm.shape == (2, 2):
|
| 101 |
+
tn, fp, fn, tp = cm.ravel()
|
| 102 |
+
sensitivity = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
| 103 |
+
specificity = tn / (tn + fp) if (tn + fp) > 0 else 0.0
|
| 104 |
+
else:
|
| 105 |
+
# Handle all one class cases for tiny datasets
|
| 106 |
+
sensitivity = 0.0
|
| 107 |
+
specificity = 0.0
|
| 108 |
+
|
| 109 |
+
return acc, sensitivity, specificity, cm
|
| 110 |
+
|
| 111 |
+
def train_and_evaluate():
|
| 112 |
+
X, y = load_dataset()
|
| 113 |
+
if X is None: return
|
| 114 |
+
|
| 115 |
+
# Feature Scaling is critical for SVM and Logistic Regression
|
| 116 |
+
scaler = StandardScaler()
|
| 117 |
+
feature_names = X.columns
|
| 118 |
+
X_scaled = scaler.fit_transform(X)
|
| 119 |
+
X_scaled = pd.DataFrame(X_scaled, columns=feature_names)
|
| 120 |
+
joblib.dump(scaler, "weights/scaler.pkl")
|
| 121 |
+
joblib.dump(list(feature_names), "weights/feature_columns.pkl")
|
| 122 |
+
|
| 123 |
+
# Strictly 70/30 split
|
| 124 |
+
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.3, random_state=42)
|
| 125 |
+
|
| 126 |
+
print(f"\n--- Training on {len(X_train)} samples, Testing on {len(X_test)} samples (70/30 Split) ---")
|
| 127 |
+
|
| 128 |
+
models = {
|
| 129 |
+
"Logistic Regression": LogisticRegression(max_iter=1000, random_state=42),
|
| 130 |
+
"Random Forest": RandomForestClassifier(n_estimators=100, max_depth=10, random_state=42),
|
| 131 |
+
"SVM (RBF)": SVC(kernel='rbf', probability=True, random_state=42)
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
results = {}
|
| 135 |
+
y_preds_all = {}
|
| 136 |
+
y_proba_all = {}
|
| 137 |
+
|
| 138 |
+
for name, model in models.items():
|
| 139 |
+
print(f"\nTraining {name}...")
|
| 140 |
+
model.fit(X_train, y_train)
|
| 141 |
+
|
| 142 |
+
y_pred = model.predict(X_test)
|
| 143 |
+
y_proba = model.predict_proba(X_test)[:, 1]
|
| 144 |
+
|
| 145 |
+
y_preds_all[name] = y_pred
|
| 146 |
+
y_proba_all[name] = y_proba
|
| 147 |
+
|
| 148 |
+
acc, sens, spec, cm = evaluate_model(y_test, y_pred)
|
| 149 |
+
results[name] = {
|
| 150 |
+
"Accuracy": acc,
|
| 151 |
+
"Sensitivity": sens,
|
| 152 |
+
"Specificity": spec,
|
| 153 |
+
"CM": cm
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
print(f"Accuracy: {acc*100:.1f}%")
|
| 157 |
+
print(f"Sensitivity: {sens*100:.1f}%")
|
| 158 |
+
print(f"Specificity: {spec*100:.1f}%")
|
| 159 |
+
|
| 160 |
+
filename = name.lower().replace(" ", "_").replace("(", "").replace(")", "")
|
| 161 |
+
joblib.dump(model, f"weights/canine_{filename}.pkl")
|
| 162 |
+
|
| 163 |
+
# 1. Output ROC Curve Plot
|
| 164 |
+
plt.figure(figsize=(8, 6))
|
| 165 |
+
for name, y_proba in y_proba_all.items():
|
| 166 |
+
fpr, tpr, _ = roc_curve(y_test, y_proba)
|
| 167 |
+
roc_auc = auc(fpr, tpr)
|
| 168 |
+
plt.plot(fpr, tpr, lw=2, label=f'{name} (AUC = {roc_auc:.2f})')
|
| 169 |
+
|
| 170 |
+
plt.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--')
|
| 171 |
+
plt.xlim([0.0, 1.0])
|
| 172 |
+
plt.ylim([0.0, 1.05])
|
| 173 |
+
plt.xlabel('False Positive Rate (1 - Specificity)')
|
| 174 |
+
plt.ylabel('True Positive Rate (Sensitivity)')
|
| 175 |
+
plt.title('Receiver Operating Characteristic (ROC) Comparison')
|
| 176 |
+
plt.legend(loc="lower right")
|
| 177 |
+
plt.grid(True, alpha=0.3)
|
| 178 |
+
plt.savefig('metrics/roc_curve.png')
|
| 179 |
+
plt.close()
|
| 180 |
+
|
| 181 |
+
# 2. Confusion Matrices Plot
|
| 182 |
+
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
|
| 183 |
+
for ax, (name, res) in zip(axes, results.items()):
|
| 184 |
+
sns.heatmap(res["CM"], annot=True, fmt='d', cmap='Blues', ax=ax, cbar=False)
|
| 185 |
+
ax.set_title(f'{name}\nAcc: {res["Accuracy"]:.2f}')
|
| 186 |
+
ax.set_xlabel('Predicted Label')
|
| 187 |
+
ax.set_ylabel('True Label')
|
| 188 |
+
ax.set_xticklabels(['Normal (0)', 'Murmur (1)'])
|
| 189 |
+
ax.set_yticklabels(['Normal (0)', 'Murmur (1)'])
|
| 190 |
+
plt.tight_layout()
|
| 191 |
+
plt.savefig('metrics/confusion_matrix.png')
|
| 192 |
+
plt.close()
|
| 193 |
+
|
| 194 |
+
# 3. Random Forest Feature Importance Plot
|
| 195 |
+
rf_model = models["Random Forest"]
|
| 196 |
+
importances = rf_model.feature_importances_
|
| 197 |
+
indices = np.argsort(importances)[::-1][:15] # Top 15 features
|
| 198 |
+
|
| 199 |
+
plt.figure(figsize=(10, 6))
|
| 200 |
+
plt.title("Top 15 Feature Importances (Random Forest)")
|
| 201 |
+
plt.bar(range(15), importances[indices], align="center", color='skyblue', edgecolor='black')
|
| 202 |
+
plt.xticks(range(15), [feature_names[i] for i in indices], rotation=45, ha='right')
|
| 203 |
+
plt.xlim([-1, 15])
|
| 204 |
+
plt.tight_layout()
|
| 205 |
+
plt.savefig('metrics/feature_importance.png')
|
| 206 |
+
plt.close()
|
| 207 |
+
|
| 208 |
+
# 4. Model Agreement (Kappa between RF and SVM)
|
| 209 |
+
kappa = cohen_kappa_score(y_preds_all["Random Forest"], y_preds_all["SVM (RBF)"])
|
| 210 |
+
print(f"\n--- Model Agreement ---")
|
| 211 |
+
print(f"Cohen's Kappa (Random Forest vs SVM): {kappa:.3f}")
|
| 212 |
+
|
| 213 |
+
print("\nTraining Pipeline Complete.")
|
| 214 |
+
print("Interpretable Models saved to weights/")
|
| 215 |
+
print("Clinical visual metrics saved to metrics/")
|
| 216 |
+
|
| 217 |
+
if __name__ == "__main__":
|
| 218 |
+
train_and_evaluate()
|
webapp/.env.production
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This URL will be filled in AFTER you create the Render backend service.
|
| 2 |
+
# Replace the placeholder with your actual Render backend URL, then redeploy the frontend.
|
| 3 |
+
# Example: VITE_API_URL=https://cardioscreen-api.onrender.com/analyze
|
| 4 |
+
VITE_API_URL=https://REPLACE_WITH_YOUR_RENDER_BACKEND_URL/analyze
|
webapp/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
webapp/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
webapp/eslint.config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 6 |
+
|
| 7 |
+
export default defineConfig([
|
| 8 |
+
globalIgnores(['dist']),
|
| 9 |
+
{
|
| 10 |
+
files: ['**/*.{js,jsx}'],
|
| 11 |
+
extends: [
|
| 12 |
+
js.configs.recommended,
|
| 13 |
+
reactHooks.configs.flat.recommended,
|
| 14 |
+
reactRefresh.configs.vite,
|
| 15 |
+
],
|
| 16 |
+
languageOptions: {
|
| 17 |
+
ecmaVersion: 2020,
|
| 18 |
+
globals: globals.browser,
|
| 19 |
+
parserOptions: {
|
| 20 |
+
ecmaVersion: 'latest',
|
| 21 |
+
ecmaFeatures: { jsx: true },
|
| 22 |
+
sourceType: 'module',
|
| 23 |
+
},
|
| 24 |
+
},
|
| 25 |
+
rules: {
|
| 26 |
+
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
])
|
webapp/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>CardioScreen AI</title>
|
| 8 |
+
<!-- Google Fonts: Outfit and Inter -->
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 11 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<div id="root"></div>
|
| 15 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 16 |
+
</body>
|
| 17 |
+
</html>
|
webapp/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
webapp/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "webapp",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"html2canvas": "^1.4.1",
|
| 14 |
+
"jspdf": "^4.2.0",
|
| 15 |
+
"lucide-react": "^0.575.0",
|
| 16 |
+
"react": "^19.2.0",
|
| 17 |
+
"react-dom": "^19.2.0",
|
| 18 |
+
"recharts": "^3.7.0"
|
| 19 |
+
},
|
| 20 |
+
"devDependencies": {
|
| 21 |
+
"@eslint/js": "^9.39.1",
|
| 22 |
+
"@types/react": "^19.2.7",
|
| 23 |
+
"@types/react-dom": "^19.2.3",
|
| 24 |
+
"@vitejs/plugin-react": "^5.1.1",
|
| 25 |
+
"eslint": "^9.39.1",
|
| 26 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 27 |
+
"eslint-plugin-react-refresh": "^0.4.24",
|
| 28 |
+
"globals": "^16.5.0",
|
| 29 |
+
"vite": "^7.3.1"
|
| 30 |
+
}
|
| 31 |
+
}
|
webapp/public/_redirects
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/* /index.html 200
|
webapp/public/vite.svg
ADDED
|
|
webapp/src/App.css
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* App specific styles, relying on index.css for global utilities */
|
| 2 |
+
|
| 3 |
+
.dashboard-header {
|
| 4 |
+
display: flex;
|
| 5 |
+
justify-content: space-between;
|
| 6 |
+
align-items: center;
|
| 7 |
+
margin-bottom: 32px;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
.header-title {
|
| 11 |
+
margin-bottom: 4px;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
.header-subtitle {
|
| 15 |
+
color: var(--text-secondary);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
.dashboard-grid {
|
| 19 |
+
display: grid;
|
| 20 |
+
grid-template-columns: 1fr 1fr 1fr;
|
| 21 |
+
gap: 24px;
|
| 22 |
+
margin-bottom: 24px;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.col-span-2 {
|
| 26 |
+
grid-column: span 2;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.col-span-3 {
|
| 30 |
+
grid-column: span 3;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
@media (max-width: 1024px) {
|
| 34 |
+
.dashboard-grid {
|
| 35 |
+
grid-template-columns: 1fr 1fr;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.col-span-3 {
|
| 39 |
+
grid-column: span 2;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
@media (max-width: 768px) {
|
| 44 |
+
.dashboard-grid {
|
| 45 |
+
grid-template-columns: 1fr;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.col-span-2,
|
| 49 |
+
.col-span-3 {
|
| 50 |
+
grid-column: span 1;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.app-container {
|
| 54 |
+
flex-direction: column;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.sidebar {
|
| 58 |
+
width: 100%;
|
| 59 |
+
border-right: none;
|
| 60 |
+
border-bottom: 1px solid var(--border-color);
|
| 61 |
+
padding: 16px;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.main-content {
|
| 65 |
+
padding: 16px;
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/* Upload Area */
|
| 70 |
+
.upload-zone {
|
| 71 |
+
border: 2px dashed rgba(6, 182, 212, 0.3);
|
| 72 |
+
border-radius: var(--card-radius);
|
| 73 |
+
padding: 48px 24px;
|
| 74 |
+
text-align: center;
|
| 75 |
+
cursor: pointer;
|
| 76 |
+
transition: all var(--transition-smooth);
|
| 77 |
+
background: rgba(6, 182, 212, 0.02);
|
| 78 |
+
display: flex;
|
| 79 |
+
flex-direction: column;
|
| 80 |
+
align-items: center;
|
| 81 |
+
justify-content: center;
|
| 82 |
+
gap: 16px;
|
| 83 |
+
min-height: 300px;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.upload-zone:hover {
|
| 87 |
+
border-color: var(--accent-cyan);
|
| 88 |
+
background: rgba(6, 182, 212, 0.05);
|
| 89 |
+
transform: scale(1.01);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.upload-icon {
|
| 93 |
+
color: var(--accent-cyan);
|
| 94 |
+
background: rgba(6, 182, 212, 0.1);
|
| 95 |
+
padding: 16px;
|
| 96 |
+
border-radius: 50%;
|
| 97 |
+
box-shadow: 0 0 20px rgba(6, 182, 212, 0.2);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/* Feature Value Displays */
|
| 101 |
+
.feature-metric {
|
| 102 |
+
display: flex;
|
| 103 |
+
justify-content: space-between;
|
| 104 |
+
align-items: center;
|
| 105 |
+
padding: 12px 0;
|
| 106 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.feature-metric:last-child {
|
| 110 |
+
border-bottom: none;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.feature-name {
|
| 114 |
+
color: var(--text-secondary);
|
| 115 |
+
font-size: 0.9rem;
|
| 116 |
+
display: flex;
|
| 117 |
+
align-items: center;
|
| 118 |
+
gap: 8px;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.feature-value {
|
| 122 |
+
font-weight: 600;
|
| 123 |
+
font-family: monospace;
|
| 124 |
+
font-size: 1.1rem;
|
| 125 |
+
color: var(--accent-cyan);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/* Result Card */
|
| 129 |
+
.result-normal {
|
| 130 |
+
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1), rgba(16, 185, 129, 0.02));
|
| 131 |
+
border-color: rgba(16, 185, 129, 0.3);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.result-disease {
|
| 135 |
+
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1), rgba(239, 68, 68, 0.02));
|
| 136 |
+
border-color: rgba(239, 68, 68, 0.3);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.confidence-bar-bg {
|
| 140 |
+
height: 8px;
|
| 141 |
+
background: rgba(255, 255, 255, 0.1);
|
| 142 |
+
border-radius: 4px;
|
| 143 |
+
overflow: hidden;
|
| 144 |
+
margin-top: 12px;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.confidence-bar-fill {
|
| 148 |
+
height: 100%;
|
| 149 |
+
border-radius: 4px;
|
| 150 |
+
transition: width 1s cubic-bezier(0.16, 1, 0.3, 1);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.result-normal .confidence-bar-fill {
|
| 154 |
+
background: var(--success);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.result-disease .confidence-bar-fill {
|
| 158 |
+
background: var(--danger);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
/* Sidebar Navigation */
|
| 162 |
+
.nav-item {
|
| 163 |
+
display: flex;
|
| 164 |
+
align-items: center;
|
| 165 |
+
gap: 12px;
|
| 166 |
+
padding: 12px 16px;
|
| 167 |
+
border-radius: 8px;
|
| 168 |
+
color: var(--text-secondary);
|
| 169 |
+
cursor: pointer;
|
| 170 |
+
transition: all var(--transition-fast);
|
| 171 |
+
margin-bottom: 8px;
|
| 172 |
+
font-weight: 500;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.nav-item:hover {
|
| 176 |
+
background: rgba(255, 255, 255, 0.05);
|
| 177 |
+
color: var(--text-primary);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.nav-item.active {
|
| 181 |
+
background: rgba(6, 182, 212, 0.1);
|
| 182 |
+
color: var(--accent-cyan);
|
| 183 |
+
border-left: 3px solid var(--accent-cyan);
|
| 184 |
+
}
|
webapp/src/App.jsx
ADDED
|
@@ -0,0 +1,1130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
Upload, Activity, Heart, FileAudio,
|
| 4 |
+
CheckCircle, AlertTriangle, RefreshCw,
|
| 5 |
+
Mic, Square, Download, Cpu
|
| 6 |
+
} from 'lucide-react';
|
| 7 |
+
import jsPDF from 'jspdf';
|
| 8 |
+
import './App.css';
|
| 9 |
+
|
| 10 |
+
// ─── Waveform Canvas with Time Axis + Heartbeat Markers ──────────────────────
|
| 11 |
+
// Waveform Canvas: static bottom layer + live animated overlay
|
| 12 |
+
function WaveformCanvas({ waveform, peakVisIndices, peakTimesSec, duration, isDisease, canvasRefOut, audioRef }) {
|
| 13 |
+
const staticRef = useRef(null);
|
| 14 |
+
const overlayRef = useRef(null);
|
| 15 |
+
const rafRef = useRef(null);
|
| 16 |
+
const accentColor = isDisease ? '#ef4444' : '#06b6d4';
|
| 17 |
+
|
| 18 |
+
const PAD = { L: 42, R: 12, T: 12, B: 36 };
|
| 19 |
+
|
| 20 |
+
// Static draw: runs once when waveform data changes
|
| 21 |
+
useEffect(() => {
|
| 22 |
+
const canvas = staticRef.current;
|
| 23 |
+
if (!canvas || !waveform || waveform.length === 0) return;
|
| 24 |
+
if (canvasRefOut) canvasRefOut.current = canvas;
|
| 25 |
+
const dpr = window.devicePixelRatio || 1;
|
| 26 |
+
const W = canvas.offsetWidth, H = canvas.offsetHeight;
|
| 27 |
+
canvas.width = W * dpr; canvas.height = H * dpr;
|
| 28 |
+
const ctx = canvas.getContext('2d');
|
| 29 |
+
ctx.scale(dpr, dpr);
|
| 30 |
+
|
| 31 |
+
const { L, R, T, B } = PAD;
|
| 32 |
+
const cW = W - L - R, cH = H - B - T;
|
| 33 |
+
const n = waveform.length;
|
| 34 |
+
const xOf = (i) => L + (i / (n - 1)) * cW;
|
| 35 |
+
const yOf = (v) => T + cH / 2 - v * cH * 0.42;
|
| 36 |
+
|
| 37 |
+
ctx.fillStyle = 'rgba(5,8,18,1)'; ctx.fillRect(0, 0, W, H);
|
| 38 |
+
ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1;
|
| 39 |
+
for (let i = 0; i <= 4; i++) {
|
| 40 |
+
const y = T + (cH * i) / 4;
|
| 41 |
+
ctx.beginPath(); ctx.moveTo(L, y); ctx.lineTo(L + cW, y); ctx.stroke();
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const grad = ctx.createLinearGradient(0, T, 0, T + cH);
|
| 45 |
+
grad.addColorStop(0, isDisease ? 'rgba(239,68,68,0.5)' : 'rgba(6,182,212,0.5)');
|
| 46 |
+
grad.addColorStop(0.5, isDisease ? 'rgba(239,68,68,0.08)' : 'rgba(6,182,212,0.08)');
|
| 47 |
+
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
| 48 |
+
ctx.beginPath();
|
| 49 |
+
ctx.moveTo(xOf(0), yOf(waveform[0]));
|
| 50 |
+
for (let i = 1; i < n; i++) ctx.lineTo(xOf(i), yOf(waveform[i]));
|
| 51 |
+
ctx.lineTo(xOf(n - 1), T + cH / 2); ctx.lineTo(xOf(0), T + cH / 2);
|
| 52 |
+
ctx.closePath(); ctx.fillStyle = grad; ctx.fill();
|
| 53 |
+
|
| 54 |
+
ctx.beginPath();
|
| 55 |
+
ctx.moveTo(xOf(0), yOf(waveform[0]));
|
| 56 |
+
for (let i = 1; i < n; i++) ctx.lineTo(xOf(i), yOf(waveform[i]));
|
| 57 |
+
ctx.strokeStyle = accentColor; ctx.lineWidth = 1.8;
|
| 58 |
+
ctx.shadowBlur = 8; ctx.shadowColor = isDisease ? 'rgba(239,68,68,0.4)' : 'rgba(6,182,212,0.4)';
|
| 59 |
+
ctx.stroke(); ctx.shadowBlur = 0;
|
| 60 |
+
|
| 61 |
+
if (peakVisIndices && peakVisIndices.length > 0) {
|
| 62 |
+
peakVisIndices.forEach((pidx, i) => {
|
| 63 |
+
const x = xOf(pidx);
|
| 64 |
+
ctx.setLineDash([3, 4]); ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1;
|
| 65 |
+
ctx.beginPath(); ctx.moveTo(x, T + 4); ctx.lineTo(x, T + cH); ctx.stroke();
|
| 66 |
+
ctx.setLineDash([]);
|
| 67 |
+
ctx.save(); ctx.translate(x, T + 6); ctx.rotate(Math.PI / 4);
|
| 68 |
+
ctx.fillStyle = accentColor; ctx.fillRect(-4, -4, 8, 8); ctx.restore();
|
| 69 |
+
if (peakTimesSec && peakTimesSec[i] !== undefined && (peakVisIndices.length < 30 || i % 2 === 0)) {
|
| 70 |
+
ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = '9px monospace';
|
| 71 |
+
ctx.textAlign = 'center'; ctx.fillText(peakTimesSec[i].toFixed(1) + 's', x, T + 22);
|
| 72 |
+
}
|
| 73 |
+
});
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
const axisY = T + cH + 6;
|
| 77 |
+
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
|
| 78 |
+
ctx.beginPath(); ctx.moveTo(L, axisY); ctx.lineTo(L + cW, axisY); ctx.stroke();
|
| 79 |
+
const numTicks = Math.min(12, Math.floor(duration));
|
| 80 |
+
for (let t = 0; t <= numTicks; t++) {
|
| 81 |
+
const ts = (t / numTicks) * duration;
|
| 82 |
+
const x = L + (ts / duration) * cW;
|
| 83 |
+
ctx.beginPath(); ctx.moveTo(x, axisY); ctx.lineTo(x, axisY + 5); ctx.stroke();
|
| 84 |
+
ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = '10px monospace';
|
| 85 |
+
ctx.textAlign = 'center'; ctx.fillText(ts.toFixed(0) + 's', x, axisY + 16);
|
| 86 |
+
}
|
| 87 |
+
ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = '10px sans-serif';
|
| 88 |
+
ctx.textAlign = 'left'; ctx.fillText('Time (seconds)', L, axisY + 30);
|
| 89 |
+
ctx.save(); ctx.translate(12, T + cH / 2); ctx.rotate(-Math.PI / 2);
|
| 90 |
+
ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = '10px sans-serif';
|
| 91 |
+
ctx.textAlign = 'center'; ctx.fillText('Amplitude', 0, 0); ctx.restore();
|
| 92 |
+
|
| 93 |
+
}, [waveform, peakVisIndices, peakTimesSec, duration, isDisease]);
|
| 94 |
+
|
| 95 |
+
// Animation loop: playhead + beat pulse (runs while audio plays)
|
| 96 |
+
useEffect(() => {
|
| 97 |
+
const audio = audioRef?.current;
|
| 98 |
+
const overlay = overlayRef.current;
|
| 99 |
+
if (!overlay || !audio || !duration) return;
|
| 100 |
+
|
| 101 |
+
const { L, R, T, B } = PAD;
|
| 102 |
+
const BEAT_WIN = 0.35; // seconds a beat glows after being crossed
|
| 103 |
+
|
| 104 |
+
const drawOverlay = () => {
|
| 105 |
+
const dpr = window.devicePixelRatio || 1;
|
| 106 |
+
const W = overlay.offsetWidth, H = overlay.offsetHeight;
|
| 107 |
+
if (overlay.width !== Math.round(W * dpr)) overlay.width = Math.round(W * dpr);
|
| 108 |
+
if (overlay.height !== Math.round(H * dpr)) overlay.height = Math.round(H * dpr);
|
| 109 |
+
const ctx = overlay.getContext('2d');
|
| 110 |
+
ctx.save();
|
| 111 |
+
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
| 112 |
+
ctx.scale(dpr, dpr);
|
| 113 |
+
|
| 114 |
+
const cW = W - L - R, cH = H - B - T;
|
| 115 |
+
const n = waveform ? waveform.length : 1;
|
| 116 |
+
const t = audio.currentTime;
|
| 117 |
+
|
| 118 |
+
if (t > 0 && cW > 0) {
|
| 119 |
+
const px = L + (t / duration) * cW;
|
| 120 |
+
|
| 121 |
+
// Scanned region overlay
|
| 122 |
+
ctx.fillStyle = 'rgba(255,255,255,0.025)';
|
| 123 |
+
ctx.fillRect(L, T, px - L, cH);
|
| 124 |
+
|
| 125 |
+
// White glowing playhead
|
| 126 |
+
ctx.save();
|
| 127 |
+
ctx.strokeStyle = 'rgba(255,255,255,0.92)'; ctx.lineWidth = 1.5;
|
| 128 |
+
ctx.shadowBlur = 10; ctx.shadowColor = 'rgba(255,255,255,0.8)';
|
| 129 |
+
ctx.beginPath(); ctx.moveTo(px, T); ctx.lineTo(px, T + cH); ctx.stroke();
|
| 130 |
+
ctx.restore();
|
| 131 |
+
|
| 132 |
+
// Beat pulse glows
|
| 133 |
+
if (peakTimesSec && peakVisIndices) {
|
| 134 |
+
peakTimesSec.forEach((beatT, i) => {
|
| 135 |
+
const diff = t - beatT;
|
| 136 |
+
if (diff < 0 || diff > BEAT_WIN) return;
|
| 137 |
+
const alpha = 1 - diff / BEAT_WIN;
|
| 138 |
+
const bx = L + (peakVisIndices[i] / (n - 1)) * cW;
|
| 139 |
+
|
| 140 |
+
// Radial halo
|
| 141 |
+
ctx.save();
|
| 142 |
+
const rg = ctx.createRadialGradient(bx, T + cH / 2, 0, bx, T + cH / 2, 40);
|
| 143 |
+
rg.addColorStop(0, isDisease ? `rgba(239,68,68,${alpha * 0.55})` : `rgba(6,182,212,${alpha * 0.55})`);
|
| 144 |
+
rg.addColorStop(1, 'rgba(0,0,0,0)');
|
| 145 |
+
ctx.fillStyle = rg;
|
| 146 |
+
ctx.fillRect(bx - 42, T, 84, cH);
|
| 147 |
+
ctx.restore();
|
| 148 |
+
|
| 149 |
+
// Pulsing diamond
|
| 150 |
+
const size = 5 + alpha * 10;
|
| 151 |
+
ctx.save();
|
| 152 |
+
ctx.translate(bx, T + cH / 2);
|
| 153 |
+
ctx.rotate(Math.PI / 4);
|
| 154 |
+
ctx.globalAlpha = alpha;
|
| 155 |
+
ctx.fillStyle = accentColor;
|
| 156 |
+
ctx.shadowBlur = 22 * alpha; ctx.shadowColor = accentColor;
|
| 157 |
+
ctx.fillRect(-size / 2, -size / 2, size, size);
|
| 158 |
+
ctx.restore();
|
| 159 |
+
|
| 160 |
+
// Beat number label
|
| 161 |
+
ctx.save();
|
| 162 |
+
ctx.globalAlpha = alpha * 0.85;
|
| 163 |
+
ctx.fillStyle = 'white'; ctx.font = 'bold 11px monospace';
|
| 164 |
+
ctx.textAlign = 'center';
|
| 165 |
+
ctx.fillText('\u2665 ' + (i + 1), bx, T + cH / 2 + 22);
|
| 166 |
+
ctx.restore();
|
| 167 |
+
});
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
ctx.restore();
|
| 172 |
+
rafRef.current = requestAnimationFrame(drawOverlay);
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
+
const startLoop = () => {
|
| 176 |
+
if (!rafRef.current) rafRef.current = requestAnimationFrame(drawOverlay);
|
| 177 |
+
};
|
| 178 |
+
const stopLoop = () => {
|
| 179 |
+
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }
|
| 180 |
+
const ctx = overlay.getContext('2d');
|
| 181 |
+
if (ctx) ctx.clearRect(0, 0, overlay.width, overlay.height);
|
| 182 |
+
};
|
| 183 |
+
|
| 184 |
+
audio.addEventListener('play', startLoop);
|
| 185 |
+
audio.addEventListener('pause', stopLoop);
|
| 186 |
+
audio.addEventListener('ended', stopLoop);
|
| 187 |
+
audio.addEventListener('seeked', () => { if (!audio.paused) startLoop(); });
|
| 188 |
+
|
| 189 |
+
return () => {
|
| 190 |
+
stopLoop();
|
| 191 |
+
audio.removeEventListener('play', startLoop);
|
| 192 |
+
audio.removeEventListener('pause', stopLoop);
|
| 193 |
+
audio.removeEventListener('ended', stopLoop);
|
| 194 |
+
};
|
| 195 |
+
}, [waveform, peakVisIndices, peakTimesSec, duration, isDisease, audioRef]);
|
| 196 |
+
|
| 197 |
+
return (
|
| 198 |
+
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
| 199 |
+
<canvas ref={staticRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }} />
|
| 200 |
+
<canvas ref={overlayRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block', pointerEvents: 'none' }} />
|
| 201 |
+
</div>
|
| 202 |
+
);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
// Module-level WAV encoder (used by both recording and trimmer)
|
| 207 |
+
function bufferToWave(abuffer, startSample, numSamples) {
|
| 208 |
+
const numChan = abuffer.numberOfChannels;
|
| 209 |
+
const sr = abuffer.sampleRate;
|
| 210 |
+
const byteLen = numSamples * numChan * 2 + 44;
|
| 211 |
+
const buf = new ArrayBuffer(byteLen);
|
| 212 |
+
const view = new DataView(buf);
|
| 213 |
+
let pos = 0;
|
| 214 |
+
const w16 = (v) => { view.setUint16(pos, v, true); pos += 2; };
|
| 215 |
+
const w32 = (v) => { view.setUint32(pos, v, true); pos += 4; };
|
| 216 |
+
w32(0x46464952); w32(byteLen - 8); w32(0x45564157);
|
| 217 |
+
w32(0x20746d66); w32(16); w16(1); w16(numChan);
|
| 218 |
+
w32(sr); w32(sr * 2 * numChan); w16(numChan * 2); w16(16);
|
| 219 |
+
w32(0x61746164); w32(byteLen - pos - 4);
|
| 220 |
+
const channels = [];
|
| 221 |
+
for (let c = 0; c < numChan; c++) channels.push(abuffer.getChannelData(c));
|
| 222 |
+
for (let i = 0; i < numSamples; i++) {
|
| 223 |
+
for (let c = 0; c < numChan; c++) {
|
| 224 |
+
let s = Math.max(-1, Math.min(1, channels[c][startSample + i]));
|
| 225 |
+
s = (0.5 + s < 0 ? s * 32768 : s * 32767) | 0;
|
| 226 |
+
view.setInt16(pos, s, true); pos += 2;
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
return new Blob([buf], { type: 'audio/wav' });
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
// Decode any audio blob and return { waveform (Float32Array), duration, buffer }
|
| 233 |
+
async function decodeAudioBlob(blob) {
|
| 234 |
+
const arrayBuffer = await blob.arrayBuffer();
|
| 235 |
+
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
| 236 |
+
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
|
| 237 |
+
await ctx.close();
|
| 238 |
+
return audioBuffer;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
// Downsample a Float32Array to ~800 points for display
|
| 242 |
+
function downsample(data, points = 800) {
|
| 243 |
+
const step = Math.max(1, Math.floor(data.length / points));
|
| 244 |
+
const out = [];
|
| 245 |
+
for (let i = 0; i < data.length; i += step) out.push(data[i]);
|
| 246 |
+
return out;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// ─── Audio Trimmer ─────────────────────────────────────────────────────────────
|
| 250 |
+
function AudioTrimmer({ waveform, duration, onAnalyze, onSkip }) {
|
| 251 |
+
const canvasRef = useRef(null);
|
| 252 |
+
const [startFrac, setStartFrac] = useState(0);
|
| 253 |
+
const [endFrac, setEndFrac] = useState(1);
|
| 254 |
+
const dragging = useRef(null); // 'start' | 'end' | null
|
| 255 |
+
const stateRef = useRef({ startFrac: 0, endFrac: 1 });
|
| 256 |
+
|
| 257 |
+
const PAD = { L: 16, R: 16, T: 24, B: 28 };
|
| 258 |
+
|
| 259 |
+
// Keep stateRef in sync for RAF access inside mouse handlers
|
| 260 |
+
useEffect(() => { stateRef.current = { startFrac, endFrac }; }, [startFrac, endFrac]);
|
| 261 |
+
|
| 262 |
+
// Draw whenever state or waveform changes
|
| 263 |
+
useEffect(() => {
|
| 264 |
+
const canvas = canvasRef.current;
|
| 265 |
+
if (!canvas || !waveform || waveform.length === 0) return;
|
| 266 |
+
const dpr = window.devicePixelRatio || 1;
|
| 267 |
+
const W = canvas.offsetWidth, H = canvas.offsetHeight;
|
| 268 |
+
canvas.width = W * dpr; canvas.height = H * dpr;
|
| 269 |
+
const ctx = canvas.getContext('2d');
|
| 270 |
+
ctx.scale(dpr, dpr);
|
| 271 |
+
const { L, R, T, B } = PAD;
|
| 272 |
+
const cW = W - L - R, cH = H - T - B;
|
| 273 |
+
const n = waveform.length;
|
| 274 |
+
const xOf = (i) => L + (i / (n - 1)) * cW;
|
| 275 |
+
const yOf = (v) => T + cH / 2 - v * cH * 0.42;
|
| 276 |
+
const sX = L + startFrac * cW;
|
| 277 |
+
const eX = L + endFrac * cW;
|
| 278 |
+
|
| 279 |
+
// Background
|
| 280 |
+
ctx.fillStyle = 'rgba(5,8,18,1)'; ctx.fillRect(0, 0, W, H);
|
| 281 |
+
|
| 282 |
+
// Waveform (dimmed outside selection)
|
| 283 |
+
for (let i = 1; i < n; i++) {
|
| 284 |
+
const x0 = xOf(i - 1), x1 = xOf(i);
|
| 285 |
+
const inside = x0 >= sX && x1 <= eX;
|
| 286 |
+
ctx.strokeStyle = inside ? '#06b6d4' : 'rgba(255,255,255,0.12)';
|
| 287 |
+
ctx.lineWidth = inside ? 1.8 : 1;
|
| 288 |
+
ctx.shadowBlur = inside ? 6 : 0;
|
| 289 |
+
ctx.shadowColor = '#06b6d4';
|
| 290 |
+
ctx.beginPath(); ctx.moveTo(x0, yOf(waveform[i - 1])); ctx.lineTo(x1, yOf(waveform[i])); ctx.stroke();
|
| 291 |
+
}
|
| 292 |
+
ctx.shadowBlur = 0;
|
| 293 |
+
|
| 294 |
+
// Dim excluded zones
|
| 295 |
+
ctx.fillStyle = 'rgba(5,8,18,0.6)';
|
| 296 |
+
ctx.fillRect(L, T, sX - L, cH);
|
| 297 |
+
ctx.fillRect(eX, T, L + cW - eX, cH);
|
| 298 |
+
|
| 299 |
+
// Selection highlight box
|
| 300 |
+
ctx.strokeStyle = 'rgba(6,182,212,0.4)'; ctx.lineWidth = 1;
|
| 301 |
+
ctx.strokeRect(sX, T, eX - sX, cH);
|
| 302 |
+
ctx.fillStyle = 'rgba(6,182,212,0.05)'; ctx.fillRect(sX, T, eX - sX, cH);
|
| 303 |
+
|
| 304 |
+
// Draw handles (vertical bar with grip arrows)
|
| 305 |
+
[[sX, 'start'], [eX, 'end']].forEach(([x, side]) => {
|
| 306 |
+
ctx.fillStyle = '#06b6d4';
|
| 307 |
+
ctx.fillRect(x - 2, T, 4, cH);
|
| 308 |
+
// Arrow triangle on handle
|
| 309 |
+
ctx.fillStyle = 'white';
|
| 310 |
+
const dir = side === 'start' ? 1 : -1;
|
| 311 |
+
ctx.beginPath();
|
| 312 |
+
ctx.moveTo(x + dir * 2, T + cH / 2);
|
| 313 |
+
ctx.lineTo(x + dir * 10, T + cH / 2 - 7);
|
| 314 |
+
ctx.lineTo(x + dir * 10, T + cH / 2 + 7);
|
| 315 |
+
ctx.closePath(); ctx.fill();
|
| 316 |
+
});
|
| 317 |
+
|
| 318 |
+
// Time labels on handles
|
| 319 |
+
ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.font = 'bold 11px monospace';
|
| 320 |
+
ctx.textAlign = 'center';
|
| 321 |
+
ctx.fillText((startFrac * duration).toFixed(2) + 's', sX, T - 6);
|
| 322 |
+
ctx.fillText((endFrac * duration).toFixed(2) + 's', eX, T - 6);
|
| 323 |
+
|
| 324 |
+
// Bottom axis
|
| 325 |
+
const axisY = T + cH + 6;
|
| 326 |
+
ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1;
|
| 327 |
+
ctx.beginPath(); ctx.moveTo(L, axisY); ctx.lineTo(L + cW, axisY); ctx.stroke();
|
| 328 |
+
const numTicks = Math.min(10, Math.floor(duration));
|
| 329 |
+
for (let t = 0; t <= numTicks; t++) {
|
| 330 |
+
const x = L + (t / numTicks) * cW;
|
| 331 |
+
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.font = '9px monospace';
|
| 332 |
+
ctx.textAlign = 'center'; ctx.fillText(((t / numTicks) * duration).toFixed(0) + 's', x, axisY + 14);
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
// Selection duration label in center
|
| 336 |
+
const selSec = ((endFrac - startFrac) * duration).toFixed(1);
|
| 337 |
+
ctx.fillStyle = 'rgba(6,182,212,0.9)'; ctx.font = 'bold 12px sans-serif';
|
| 338 |
+
ctx.textAlign = 'center';
|
| 339 |
+
ctx.fillText(`Selection: ${selSec}s`, L + cW / 2, T + cH / 2 - 14);
|
| 340 |
+
|
| 341 |
+
}, [waveform, duration, startFrac, endFrac]);
|
| 342 |
+
|
| 343 |
+
// Mouse interaction
|
| 344 |
+
const fracFromX = (canvas, clientX) => {
|
| 345 |
+
const rect = canvas.getBoundingClientRect();
|
| 346 |
+
const { L, R } = PAD;
|
| 347 |
+
const cW = rect.width - L - R;
|
| 348 |
+
return Math.max(0, Math.min(1, (clientX - rect.left - L) / cW));
|
| 349 |
+
};
|
| 350 |
+
|
| 351 |
+
const onMouseDown = (e) => {
|
| 352 |
+
const canvas = canvasRef.current;
|
| 353 |
+
const f = fracFromX(canvas, e.clientX);
|
| 354 |
+
const { startFrac: s, endFrac: en } = stateRef.current;
|
| 355 |
+
const dS = Math.abs(f - s), dE = Math.abs(f - en);
|
| 356 |
+
dragging.current = dS < dE ? 'start' : 'end';
|
| 357 |
+
};
|
| 358 |
+
|
| 359 |
+
const onMouseMove = (e) => {
|
| 360 |
+
if (!dragging.current) return;
|
| 361 |
+
const f = fracFromX(canvasRef.current, e.clientX);
|
| 362 |
+
const { startFrac: s, endFrac: en } = stateRef.current;
|
| 363 |
+
if (dragging.current === 'start') setStartFrac(Math.min(f, en - 0.01));
|
| 364 |
+
else setEndFrac(Math.max(f, s + 0.01));
|
| 365 |
+
};
|
| 366 |
+
|
| 367 |
+
const onMouseUp = () => { dragging.current = null; };
|
| 368 |
+
|
| 369 |
+
// Touch support
|
| 370 |
+
const onTouchStart = (e) => onMouseDown(e.touches[0]);
|
| 371 |
+
const onTouchMove = (e) => { e.preventDefault(); onMouseMove(e.touches[0]); };
|
| 372 |
+
const onTouchEnd = () => onMouseUp();
|
| 373 |
+
|
| 374 |
+
return (
|
| 375 |
+
<div>
|
| 376 |
+
<canvas ref={canvasRef}
|
| 377 |
+
style={{ width: '100%', height: '200px', display: 'block', cursor: 'col-resize', borderRadius: '8px', overflow: 'hidden' }}
|
| 378 |
+
onMouseDown={onMouseDown} onMouseMove={onMouseMove} onMouseUp={onMouseUp} onMouseLeave={onMouseUp}
|
| 379 |
+
onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd}
|
| 380 |
+
/>
|
| 381 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '16px', flexWrap: 'wrap', gap: '12px' }}>
|
| 382 |
+
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
| 383 |
+
<span style={{ color: '#06b6d4', fontWeight: 600 }}>✂ Drag the handles</span> to select the clean cardiac section
|
| 384 |
+
</div>
|
| 385 |
+
<div style={{ display: 'flex', gap: '12px' }}>
|
| 386 |
+
<button className="btn-secondary" onClick={onSkip} style={{ fontSize: '0.9rem' }}>
|
| 387 |
+
Use Full Recording
|
| 388 |
+
</button>
|
| 389 |
+
<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' }}
|
| 390 |
+
onClick={() => onAnalyze(startFrac, endFrac)}>
|
| 391 |
+
✂ Analyze Selection
|
| 392 |
+
</button>
|
| 393 |
+
</div>
|
| 394 |
+
</div>
|
| 395 |
+
</div>
|
| 396 |
+
);
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
const API_URL = (import.meta.env.VITE_API_URL) ?? "http://127.0.0.1:8000/analyze";
|
| 400 |
+
|
| 401 |
+
function App() {
|
| 402 |
+
const [appState, setAppState] = useState('upload');
|
| 403 |
+
const [patientData, setPatientData] = useState({ dogId: '', breed: '', age: '' });
|
| 404 |
+
const [analysisResult, setAnalysisResult] = useState(null);
|
| 405 |
+
const [isRecording, setIsRecording] = useState(false);
|
| 406 |
+
const [recordingTime, setRecordingTime] = useState(0);
|
| 407 |
+
const [audioBlob, setAudioBlob] = useState(null);
|
| 408 |
+
const [audioUrl, setAudioUrl] = useState(null);
|
| 409 |
+
const [trimWaveform, setTrimWaveform] = useState(null);
|
| 410 |
+
const [trimDuration, setTrimDuration] = useState(0);
|
| 411 |
+
const rawAudioBuffer = useRef(null); // holds decoded AudioBuffer for trimming
|
| 412 |
+
|
| 413 |
+
const audioContextRef = useRef(null);
|
| 414 |
+
const analyserRef = useRef(null);
|
| 415 |
+
const mediaStreamRef = useRef(null);
|
| 416 |
+
const sourceRef = useRef(null);
|
| 417 |
+
const animationFrameRef = useRef(null);
|
| 418 |
+
const canvasRef = useRef(null);
|
| 419 |
+
const timerRef = useRef(null);
|
| 420 |
+
const mediaRecorderRef = useRef(null);
|
| 421 |
+
const audioChunksRef = useRef([]);
|
| 422 |
+
const waveformCanvasRef = useRef(null);
|
| 423 |
+
const audioRef = useRef(null);
|
| 424 |
+
|
| 425 |
+
const stopRecording = () => {
|
| 426 |
+
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
| 427 |
+
mediaRecorderRef.current.stop();
|
| 428 |
+
}
|
| 429 |
+
if (mediaStreamRef.current) {
|
| 430 |
+
mediaStreamRef.current.getTracks().forEach(track => track.stop());
|
| 431 |
+
mediaStreamRef.current = null;
|
| 432 |
+
}
|
| 433 |
+
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
|
| 434 |
+
audioContextRef.current.close().catch(console.error);
|
| 435 |
+
audioContextRef.current = null;
|
| 436 |
+
}
|
| 437 |
+
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
| 438 |
+
if (timerRef.current) clearInterval(timerRef.current);
|
| 439 |
+
setIsRecording(false);
|
| 440 |
+
};
|
| 441 |
+
|
| 442 |
+
const startRecording = async () => {
|
| 443 |
+
if (!patientData.dogId) {
|
| 444 |
+
alert("Please enter a Dog ID first.");
|
| 445 |
+
return;
|
| 446 |
+
}
|
| 447 |
+
try {
|
| 448 |
+
setAppState('recording');
|
| 449 |
+
setIsRecording(true);
|
| 450 |
+
setRecordingTime(0);
|
| 451 |
+
audioChunksRef.current = [];
|
| 452 |
+
|
| 453 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 454 |
+
mediaStreamRef.current = stream;
|
| 455 |
+
|
| 456 |
+
mediaRecorderRef.current = new MediaRecorder(stream);
|
| 457 |
+
mediaRecorderRef.current.ondataavailable = (event) => {
|
| 458 |
+
if (event.data.size > 0) audioChunksRef.current.push(event.data);
|
| 459 |
+
};
|
| 460 |
+
|
| 461 |
+
mediaRecorderRef.current.onstop = async () => {
|
| 462 |
+
const rawBlob = new Blob(audioChunksRef.current);
|
| 463 |
+
const audioBuffer = await decodeAudioBlob(rawBlob);
|
| 464 |
+
rawAudioBuffer.current = audioBuffer;
|
| 465 |
+
const wavBlob = bufferToWave(audioBuffer, 0, audioBuffer.length);
|
| 466 |
+
setAudioBlob(wavBlob);
|
| 467 |
+
setAudioUrl(URL.createObjectURL(wavBlob));
|
| 468 |
+
const wf = downsample(audioBuffer.getChannelData(0));
|
| 469 |
+
setTrimWaveform(wf);
|
| 470 |
+
setTrimDuration(audioBuffer.duration);
|
| 471 |
+
setAppState('trimming');
|
| 472 |
+
};
|
| 473 |
+
|
| 474 |
+
mediaRecorderRef.current.start();
|
| 475 |
+
|
| 476 |
+
audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)();
|
| 477 |
+
analyserRef.current = audioContextRef.current.createAnalyser();
|
| 478 |
+
analyserRef.current.fftSize = 2048;
|
| 479 |
+
sourceRef.current = audioContextRef.current.createMediaStreamSource(stream);
|
| 480 |
+
sourceRef.current.connect(analyserRef.current);
|
| 481 |
+
drawWaveform();
|
| 482 |
+
|
| 483 |
+
timerRef.current = setInterval(() => setRecordingTime((prev) => prev + 1), 1000);
|
| 484 |
+
|
| 485 |
+
} catch (err) {
|
| 486 |
+
console.error("Microphone access error:", err);
|
| 487 |
+
alert("Microphone access denied or unavailable.");
|
| 488 |
+
setAppState('upload');
|
| 489 |
+
setIsRecording(false);
|
| 490 |
+
}
|
| 491 |
+
};
|
| 492 |
+
|
| 493 |
+
const drawWaveform = () => {
|
| 494 |
+
if (!canvasRef.current || !analyserRef.current) {
|
| 495 |
+
animationFrameRef.current = requestAnimationFrame(drawWaveform);
|
| 496 |
+
return;
|
| 497 |
+
}
|
| 498 |
+
const canvas = canvasRef.current;
|
| 499 |
+
const ctx = canvas.getContext('2d');
|
| 500 |
+
const analyser = analyserRef.current;
|
| 501 |
+
const bufferLength = analyser.frequencyBinCount;
|
| 502 |
+
const dataArray = new Uint8Array(bufferLength);
|
| 503 |
+
analyser.getByteTimeDomainData(dataArray);
|
| 504 |
+
|
| 505 |
+
ctx.fillStyle = 'rgba(11, 15, 25, 1)';
|
| 506 |
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
| 507 |
+
ctx.lineWidth = 3;
|
| 508 |
+
ctx.strokeStyle = '#06b6d4';
|
| 509 |
+
ctx.shadowBlur = 10;
|
| 510 |
+
ctx.shadowColor = '#06b6d4';
|
| 511 |
+
ctx.beginPath();
|
| 512 |
+
|
| 513 |
+
const sliceWidth = canvas.width / bufferLength;
|
| 514 |
+
let x = 0;
|
| 515 |
+
for (let i = 0; i < bufferLength; i++) {
|
| 516 |
+
const v = dataArray[i] / 128.0;
|
| 517 |
+
const y = v * canvas.height / 2;
|
| 518 |
+
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
| 519 |
+
x += sliceWidth;
|
| 520 |
+
}
|
| 521 |
+
ctx.lineTo(canvas.width, canvas.height / 2);
|
| 522 |
+
ctx.stroke();
|
| 523 |
+
animationFrameRef.current = requestAnimationFrame(drawWaveform);
|
| 524 |
+
};
|
| 525 |
+
|
| 526 |
+
const handleFinishRecording = () => {
|
| 527 |
+
setAppState('analyzing'); // will be overridden to 'trimming' once audio decoded in onstop
|
| 528 |
+
stopRecording();
|
| 529 |
+
};
|
| 530 |
+
|
| 531 |
+
const handleManualUpload = () => {
|
| 532 |
+
const input = document.createElement('input');
|
| 533 |
+
input.type = 'file';
|
| 534 |
+
input.accept = 'audio/*';
|
| 535 |
+
input.onchange = async (e) => {
|
| 536 |
+
const file = e.target.files[0];
|
| 537 |
+
if (!file) return;
|
| 538 |
+
if (!patientData.dogId) { alert('Please enter a Dog ID first.'); return; }
|
| 539 |
+
const audioBuffer = await decodeAudioBlob(file);
|
| 540 |
+
rawAudioBuffer.current = audioBuffer;
|
| 541 |
+
const wavBlob = bufferToWave(audioBuffer, 0, audioBuffer.length);
|
| 542 |
+
setAudioBlob(wavBlob);
|
| 543 |
+
setAudioUrl(URL.createObjectURL(wavBlob));
|
| 544 |
+
const wf = downsample(audioBuffer.getChannelData(0));
|
| 545 |
+
setTrimWaveform(wf);
|
| 546 |
+
setTrimDuration(audioBuffer.duration);
|
| 547 |
+
setAppState('trimming');
|
| 548 |
+
};
|
| 549 |
+
input.click();
|
| 550 |
+
};
|
| 551 |
+
|
| 552 |
+
const handleAnalyzeTrimmed = (startFrac, endFrac) => {
|
| 553 |
+
const ab = rawAudioBuffer.current;
|
| 554 |
+
if (!ab) return;
|
| 555 |
+
const startSample = Math.floor(startFrac * ab.length);
|
| 556 |
+
const numSamples = Math.floor((endFrac - startFrac) * ab.length);
|
| 557 |
+
const trimmedBlob = bufferToWave(ab, startSample, numSamples);
|
| 558 |
+
// Update the stored audio blob/url to the trimmed version
|
| 559 |
+
if (audioUrl) URL.revokeObjectURL(audioUrl);
|
| 560 |
+
const newUrl = URL.createObjectURL(trimmedBlob);
|
| 561 |
+
setAudioBlob(trimmedBlob);
|
| 562 |
+
setAudioUrl(newUrl);
|
| 563 |
+
setAppState('analyzing');
|
| 564 |
+
sendToBackend(trimmedBlob);
|
| 565 |
+
};
|
| 566 |
+
|
| 567 |
+
const handleSkipTrim = () => {
|
| 568 |
+
setAppState('analyzing');
|
| 569 |
+
sendToBackend(audioBlob);
|
| 570 |
+
};
|
| 571 |
+
|
| 572 |
+
const sendToBackend = async (audioBlob) => {
|
| 573 |
+
try {
|
| 574 |
+
const formData = new FormData();
|
| 575 |
+
formData.append('file', audioBlob, 'recording.wav');
|
| 576 |
+
|
| 577 |
+
const response = await fetch(API_URL, { method: "POST", body: formData });
|
| 578 |
+
const result = await response.json();
|
| 579 |
+
console.log("Backend response:", result);
|
| 580 |
+
|
| 581 |
+
if (result.error) throw new Error(result.error);
|
| 582 |
+
|
| 583 |
+
let bpmStatus = "Normal";
|
| 584 |
+
let bpmColor = "var(--success)";
|
| 585 |
+
if (result.bpm > 140) { bpmStatus = "High (Tachycardia?)"; bpmColor = "var(--danger)"; }
|
| 586 |
+
else if (result.bpm < 60 && result.bpm > 0) { bpmStatus = "Low (Bradycardia?)"; bpmColor = "var(--warning)"; }
|
| 587 |
+
else if (result.bpm === 0) { bpmStatus = "Undetected"; bpmColor = "var(--text-secondary)"; }
|
| 588 |
+
|
| 589 |
+
const waveformData = result.waveform.map((amp, idx) => ({ time: idx, amplitude: amp }));
|
| 590 |
+
|
| 591 |
+
setAnalysisResult({
|
| 592 |
+
...result,
|
| 593 |
+
bpmStatus,
|
| 594 |
+
bpmColor,
|
| 595 |
+
waveformData,
|
| 596 |
+
});
|
| 597 |
+
setAppState('dashboard');
|
| 598 |
+
|
| 599 |
+
} catch (error) {
|
| 600 |
+
console.error("Analysis error:", error);
|
| 601 |
+
alert(`Analysis failed: ${error.message}\n\nCheck if api.py is running.`);
|
| 602 |
+
resetApp();
|
| 603 |
+
}
|
| 604 |
+
};
|
| 605 |
+
|
| 606 |
+
const downloadAudio = () => {
|
| 607 |
+
if (!audioBlob) return;
|
| 608 |
+
const url = URL.createObjectURL(audioBlob);
|
| 609 |
+
const a = document.createElement('a');
|
| 610 |
+
a.href = url;
|
| 611 |
+
a.download = `heart_sound_${patientData.dogId}_${Date.now()}.wav`;
|
| 612 |
+
a.click();
|
| 613 |
+
URL.revokeObjectURL(url);
|
| 614 |
+
};
|
| 615 |
+
|
| 616 |
+
const downloadReport = async () => {
|
| 617 |
+
if (!analysisResult) return;
|
| 618 |
+
const r = analysisResult;
|
| 619 |
+
const ai = r.ai_classification;
|
| 620 |
+
const now = new Date().toLocaleString();
|
| 621 |
+
const isMurmur = ai.is_disease;
|
| 622 |
+
const pdf = new jsPDF('p', 'mm', 'a4');
|
| 623 |
+
const W = 210, H = 297;
|
| 624 |
+
const marginL = 18, marginR = 18;
|
| 625 |
+
const contentW = W - marginL - marginR;
|
| 626 |
+
let y = 0;
|
| 627 |
+
|
| 628 |
+
// === HEADER BAND ===
|
| 629 |
+
pdf.setFillColor(15, 23, 42);
|
| 630 |
+
pdf.rect(0, 0, W, 38, 'F');
|
| 631 |
+
// Accent line
|
| 632 |
+
pdf.setFillColor(isMurmur ? 239 : 6, isMurmur ? 68 : 182, isMurmur ? 68 : 212);
|
| 633 |
+
pdf.rect(0, 38, W, 2, 'F');
|
| 634 |
+
// Title
|
| 635 |
+
pdf.setTextColor(255, 255, 255);
|
| 636 |
+
pdf.setFont('helvetica', 'bold'); pdf.setFontSize(20);
|
| 637 |
+
pdf.text('CardioScreen AI', marginL, 18);
|
| 638 |
+
pdf.setFont('helvetica', 'normal'); pdf.setFontSize(10);
|
| 639 |
+
pdf.setTextColor(148, 163, 184);
|
| 640 |
+
pdf.text('Canine Cardiac Screening Report', marginL, 26);
|
| 641 |
+
// Date right-aligned
|
| 642 |
+
pdf.setFontSize(9); pdf.setTextColor(148, 163, 184);
|
| 643 |
+
pdf.text(now, W - marginR, 18, { align: 'right' });
|
| 644 |
+
pdf.text(`Patient: ${patientData.dogId}`, W - marginR, 26, { align: 'right' });
|
| 645 |
+
y = 48;
|
| 646 |
+
|
| 647 |
+
// === RESULT BANNER ===
|
| 648 |
+
pdf.setFillColor(isMurmur ? 254 : 240, isMurmur ? 242 : 253, isMurmur ? 242 : 250);
|
| 649 |
+
pdf.roundedRect(marginL, y, contentW, 28, 3, 3, 'F');
|
| 650 |
+
pdf.setDrawColor(isMurmur ? 239 : 34, isMurmur ? 68 : 197, isMurmur ? 68 : 94);
|
| 651 |
+
pdf.roundedRect(marginL, y, contentW, 28, 3, 3, 'S');
|
| 652 |
+
pdf.setFontSize(16); pdf.setFont('helvetica', 'bold');
|
| 653 |
+
pdf.setTextColor(isMurmur ? 185 : 21, isMurmur ? 28 : 128, isMurmur ? 28 : 61);
|
| 654 |
+
pdf.text(isMurmur ? '⚠ MURMUR DETECTED' : '✓ NORMAL HEART SOUND', marginL + 8, y + 12);
|
| 655 |
+
pdf.setFontSize(10); pdf.setFont('helvetica', 'normal');
|
| 656 |
+
pdf.setTextColor(100, 100, 100);
|
| 657 |
+
pdf.text(`Confidence: ${(ai.confidence * 100).toFixed(1)}%`, marginL + 8, y + 22);
|
| 658 |
+
// BPM right side
|
| 659 |
+
pdf.setFontSize(22); pdf.setFont('helvetica', 'bold');
|
| 660 |
+
pdf.setTextColor(isMurmur ? 185 : 21, isMurmur ? 28 : 128, isMurmur ? 28 : 61);
|
| 661 |
+
pdf.text(`${r.bpm}`, W - marginR - 30, y + 14, { align: 'right' });
|
| 662 |
+
pdf.setFontSize(9); pdf.setFont('helvetica', 'normal');
|
| 663 |
+
pdf.setTextColor(100, 100, 100);
|
| 664 |
+
pdf.text('BPM', W - marginR - 8, y + 14, { align: 'right' });
|
| 665 |
+
pdf.text(`${r.bpmStatus}`, W - marginR - 8, y + 22, { align: 'right' });
|
| 666 |
+
y += 36;
|
| 667 |
+
|
| 668 |
+
// === PATIENT INFO TABLE ===
|
| 669 |
+
pdf.setFontSize(11); pdf.setFont('helvetica', 'bold');
|
| 670 |
+
pdf.setTextColor(30, 41, 59);
|
| 671 |
+
pdf.text('Patient Information', marginL, y);
|
| 672 |
+
y += 6;
|
| 673 |
+
pdf.setFillColor(248, 250, 252);
|
| 674 |
+
pdf.rect(marginL, y, contentW, 22, 'F');
|
| 675 |
+
pdf.setDrawColor(226, 232, 240);
|
| 676 |
+
pdf.rect(marginL, y, contentW, 22, 'S');
|
| 677 |
+
pdf.setFontSize(9); pdf.setFont('helvetica', 'normal');
|
| 678 |
+
pdf.setTextColor(71, 85, 105);
|
| 679 |
+
const col1 = marginL + 5, col2 = marginL + 50, col3 = marginL + 95, col4 = marginL + 130;
|
| 680 |
+
pdf.text('Dog ID:', col1, y + 8); pdf.setFont('helvetica', 'bold'); pdf.setTextColor(30, 41, 59); pdf.text(patientData.dogId, col1, y + 15);
|
| 681 |
+
pdf.setFont('helvetica', 'normal'); pdf.setTextColor(71, 85, 105);
|
| 682 |
+
pdf.text('Breed:', col2, y + 8); pdf.setFont('helvetica', 'bold'); pdf.setTextColor(30, 41, 59); pdf.text(patientData.breed || 'N/A', col2, y + 15);
|
| 683 |
+
pdf.setFont('helvetica', 'normal'); pdf.setTextColor(71, 85, 105);
|
| 684 |
+
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);
|
| 685 |
+
pdf.setFont('helvetica', 'normal'); pdf.setTextColor(71, 85, 105);
|
| 686 |
+
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);
|
| 687 |
+
y += 30;
|
| 688 |
+
|
| 689 |
+
// === WAVEFORM IMAGE ===
|
| 690 |
+
pdf.setFontSize(11); pdf.setFont('helvetica', 'bold');
|
| 691 |
+
pdf.setTextColor(30, 41, 59);
|
| 692 |
+
pdf.text('Phonocardiogram', marginL, y);
|
| 693 |
+
y += 4;
|
| 694 |
+
if (waveformCanvasRef.current) {
|
| 695 |
+
try {
|
| 696 |
+
const imgData = waveformCanvasRef.current.toDataURL('image/png');
|
| 697 |
+
const imgW = contentW;
|
| 698 |
+
const imgH = imgW * 0.35;
|
| 699 |
+
pdf.addImage(imgData, 'PNG', marginL, y, imgW, imgH);
|
| 700 |
+
y += imgH + 4;
|
| 701 |
+
} catch (e) { console.warn('Could not capture waveform:', e); y += 4; }
|
| 702 |
+
} else { y += 4; }
|
| 703 |
+
|
| 704 |
+
// === PROBABILITY BREAKDOWN ===
|
| 705 |
+
pdf.setFontSize(11); pdf.setFont('helvetica', 'bold');
|
| 706 |
+
pdf.setTextColor(30, 41, 59);
|
| 707 |
+
pdf.text('AI Classification', marginL, y);
|
| 708 |
+
y += 6;
|
| 709 |
+
ai.all_classes.forEach(cls => {
|
| 710 |
+
const pct = (cls.probability * 100).toFixed(1);
|
| 711 |
+
const isM = cls.label.toLowerCase().includes('murmur');
|
| 712 |
+
pdf.setFontSize(9); pdf.setFont('helvetica', 'normal');
|
| 713 |
+
pdf.setTextColor(71, 85, 105);
|
| 714 |
+
pdf.text(`${cls.label}:`, marginL + 5, y + 4);
|
| 715 |
+
pdf.setFont('helvetica', 'bold');
|
| 716 |
+
pdf.text(`${pct}%`, marginL + 35, y + 4);
|
| 717 |
+
// Bar background
|
| 718 |
+
pdf.setFillColor(226, 232, 240);
|
| 719 |
+
pdf.roundedRect(marginL + 52, y, contentW - 60, 6, 2, 2, 'F');
|
| 720 |
+
// Bar fill
|
| 721 |
+
const barW = Math.max(2, (cls.probability) * (contentW - 60));
|
| 722 |
+
pdf.setFillColor(isM ? 239 : 34, isM ? 68 : 197, isM ? 68 : 94);
|
| 723 |
+
pdf.roundedRect(marginL + 52, y, barW, 6, 2, 2, 'F');
|
| 724 |
+
y += 12;
|
| 725 |
+
});
|
| 726 |
+
y += 4;
|
| 727 |
+
|
| 728 |
+
// === FEATURE DETAILS ===
|
| 729 |
+
pdf.setFontSize(11); pdf.setFont('helvetica', 'bold');
|
| 730 |
+
pdf.setTextColor(30, 41, 59);
|
| 731 |
+
pdf.text('Signal Analysis Features', marginL, y);
|
| 732 |
+
y += 5;
|
| 733 |
+
pdf.setFillColor(248, 250, 252);
|
| 734 |
+
pdf.rect(marginL, y, contentW, 8, 'F');
|
| 735 |
+
pdf.setDrawColor(226, 232, 240);
|
| 736 |
+
pdf.rect(marginL, y, contentW, 8, 'S');
|
| 737 |
+
pdf.setFontSize(8); pdf.setFont('helvetica', 'normal');
|
| 738 |
+
pdf.setTextColor(71, 85, 105);
|
| 739 |
+
pdf.text(ai.details || '', marginL + 4, y + 5.5);
|
| 740 |
+
y += 16;
|
| 741 |
+
|
| 742 |
+
// === DISCLAIMER ===
|
| 743 |
+
pdf.setFillColor(255, 251, 235);
|
| 744 |
+
pdf.roundedRect(marginL, y, contentW, 22, 2, 2, 'F');
|
| 745 |
+
pdf.setDrawColor(251, 191, 36);
|
| 746 |
+
pdf.roundedRect(marginL, y, contentW, 22, 2, 2, 'S');
|
| 747 |
+
pdf.setFontSize(8); pdf.setFont('helvetica', 'bold');
|
| 748 |
+
pdf.setTextColor(146, 64, 14);
|
| 749 |
+
pdf.text('IMPORTANT NOTICE', marginL + 5, y + 6);
|
| 750 |
+
pdf.setFont('helvetica', 'normal'); pdf.setFontSize(7.5);
|
| 751 |
+
pdf.setTextColor(120, 53, 15);
|
| 752 |
+
pdf.text('This is an AI-assisted screening tool for preliminary cardiac assessment. Results are NOT diagnostic.', marginL + 5, y + 12);
|
| 753 |
+
pdf.text('All findings should be confirmed by a veterinary cardiologist via echocardiography.', marginL + 5, y + 17);
|
| 754 |
+
y += 28;
|
| 755 |
+
|
| 756 |
+
// === FOOTER ===
|
| 757 |
+
pdf.setFontSize(7); pdf.setTextColor(148, 163, 184);
|
| 758 |
+
pdf.text('Model: CardioScreen Logistic Regression Classifier · Trained on: VetCPD + Hannover Vet School (21 canine recordings)', marginL, H - 12);
|
| 759 |
+
pdf.text(`Generated: ${now}`, W - marginR, H - 12, { align: 'right' });
|
| 760 |
+
|
| 761 |
+
pdf.save(`screening_${patientData.dogId}_${Date.now()}.pdf`);
|
| 762 |
+
};
|
| 763 |
+
|
| 764 |
+
const resetApp = () => {
|
| 765 |
+
stopRecording();
|
| 766 |
+
if (audioUrl) URL.revokeObjectURL(audioUrl);
|
| 767 |
+
setAppState('upload');
|
| 768 |
+
setPatientData({ dogId: '', breed: '', age: '' });
|
| 769 |
+
setAnalysisResult(null);
|
| 770 |
+
setAudioBlob(null);
|
| 771 |
+
setAudioUrl(null);
|
| 772 |
+
setTrimWaveform(null);
|
| 773 |
+
setTrimDuration(0);
|
| 774 |
+
rawAudioBuffer.current = null;
|
| 775 |
+
};
|
| 776 |
+
|
| 777 |
+
return (
|
| 778 |
+
<div className="app-container">
|
| 779 |
+
{/* Sidebar */}
|
| 780 |
+
<aside className="sidebar">
|
| 781 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '40px' }}>
|
| 782 |
+
<div style={{ background: 'var(--accent-cyan)', padding: '8px', borderRadius: '8px' }}>
|
| 783 |
+
<Heart color="white" size={24} />
|
| 784 |
+
</div>
|
| 785 |
+
<h2 style={{ fontSize: '1.2rem', margin: 0 }}>CardioScreen <span className="text-gradient">AI</span></h2>
|
| 786 |
+
</div>
|
| 787 |
+
|
| 788 |
+
<nav>
|
| 789 |
+
<div className={`nav-item ${(appState === 'upload' || appState === 'recording') ? 'active' : ''}`} onClick={(appState !== 'analyzing') ? resetApp : undefined}>
|
| 790 |
+
<Upload size={18} />
|
| 791 |
+
<span>New Scan</span>
|
| 792 |
+
</div>
|
| 793 |
+
<div className={`nav-item ${appState === 'dashboard' ? 'active' : ''}`}>
|
| 794 |
+
<Activity size={18} />
|
| 795 |
+
<span>Analysis Result</span>
|
| 796 |
+
</div>
|
| 797 |
+
</nav>
|
| 798 |
+
|
| 799 |
+
<div style={{ marginTop: 'auto', padding: '16px', background: 'rgba(255,255,255,0.02)', borderRadius: '8px', border: '1px solid var(--border-color)' }}>
|
| 800 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
| 801 |
+
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: 'var(--success)', boxShadow: '0 0 10px var(--success)' }}></div>
|
| 802 |
+
<span style={{ fontSize: '0.9rem', color: 'var(--text-secondary)' }}>Local AI Engine</span>
|
| 803 |
+
</div>
|
| 804 |
+
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', opacity: 0.7 }}>v1.0 Thesis Edition</div>
|
| 805 |
+
</div>
|
| 806 |
+
</aside>
|
| 807 |
+
|
| 808 |
+
{/* Main Content */}
|
| 809 |
+
<main className="main-content">
|
| 810 |
+
|
| 811 |
+
{/* VIEW: UPLOAD / RECORD */}
|
| 812 |
+
{(appState === 'upload' || appState === 'recording') && (
|
| 813 |
+
<div className="animate-fade-in" style={{ maxWidth: '800px', margin: '0 auto' }}>
|
| 814 |
+
<div className="dashboard-header">
|
| 815 |
+
<div>
|
| 816 |
+
<h1 className="header-title">New Patient Scan</h1>
|
| 817 |
+
<p className="header-subtitle">Capture phonocardiogram via stethoscope + microphone</p>
|
| 818 |
+
</div>
|
| 819 |
+
</div>
|
| 820 |
+
|
| 821 |
+
<div className="glass-card" style={{ marginBottom: '24px' }}>
|
| 822 |
+
<h3 style={{ marginBottom: '16px' }}>Patient Details</h3>
|
| 823 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '16px' }}>
|
| 824 |
+
<div>
|
| 825 |
+
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9rem', color: 'var(--text-secondary)' }}>Dog ID *</label>
|
| 826 |
+
<input type="text" value={patientData.dogId} onChange={e => setPatientData({ ...patientData, dogId: e.target.value })} placeholder="e.g. DOG-001" disabled={appState === 'recording'}
|
| 827 |
+
style={{ width: '100%', background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border-color)', padding: '12px', borderRadius: '8px', color: 'white' }} />
|
| 828 |
+
</div>
|
| 829 |
+
<div>
|
| 830 |
+
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9rem', color: 'var(--text-secondary)' }}>Breed</label>
|
| 831 |
+
<input type="text" value={patientData.breed} onChange={e => setPatientData({ ...patientData, breed: e.target.value })} placeholder="e.g. German Shepherd" disabled={appState === 'recording'}
|
| 832 |
+
style={{ width: '100%', background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border-color)', padding: '12px', borderRadius: '8px', color: 'white' }} />
|
| 833 |
+
</div>
|
| 834 |
+
<div>
|
| 835 |
+
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9rem', color: 'var(--text-secondary)' }}>Age (Years)</label>
|
| 836 |
+
<input type="number" value={patientData.age} onChange={e => setPatientData({ ...patientData, age: e.target.value })} placeholder="e.g. 7" disabled={appState === 'recording'}
|
| 837 |
+
style={{ width: '100%', background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border-color)', padding: '12px', borderRadius: '8px', color: 'white' }} />
|
| 838 |
+
</div>
|
| 839 |
+
</div>
|
| 840 |
+
</div>
|
| 841 |
+
|
| 842 |
+
{appState === 'upload' ? (
|
| 843 |
+
<div style={{ display: 'flex', gap: '24px' }}>
|
| 844 |
+
<div className="upload-zone" style={{ flex: 1 }} onClick={startRecording}>
|
| 845 |
+
<div style={{ background: 'rgba(59, 130, 246, 0.1)', padding: '24px', borderRadius: '50%', boxShadow: '0 0 20px rgba(59, 130, 246, 0.3)' }}>
|
| 846 |
+
<Mic size={48} color="var(--accent-blue)" />
|
| 847 |
+
</div>
|
| 848 |
+
<h3 style={{ fontSize: '1.2rem', marginTop: '8px' }}>Start Live Recording</h3>
|
| 849 |
+
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Record from stethoscope microphone</p>
|
| 850 |
+
</div>
|
| 851 |
+
<div className="upload-zone" style={{ flex: 1 }} onClick={handleManualUpload}>
|
| 852 |
+
<div style={{ background: 'rgba(6, 182, 212, 0.1)', padding: '24px', borderRadius: '50%', boxShadow: '0 0 20px rgba(6, 182, 212, 0.2)' }}>
|
| 853 |
+
<FileAudio size={48} color="var(--accent-cyan)" />
|
| 854 |
+
</div>
|
| 855 |
+
<h3 style={{ fontSize: '1.2rem', marginTop: '8px' }}>Upload .WAV File</h3>
|
| 856 |
+
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Use an existing recording</p>
|
| 857 |
+
</div>
|
| 858 |
+
</div>
|
| 859 |
+
) : (
|
| 860 |
+
<div className="glass-card" style={{ border: '2px solid var(--danger)', textAlign: 'center' }}>
|
| 861 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
| 862 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
| 863 |
+
<div style={{ width: '12px', height: '12px', borderRadius: '50%', background: 'var(--danger)', animation: 'pulse-danger 1s infinite' }}></div>
|
| 864 |
+
<span style={{ color: 'var(--danger)', fontWeight: 600 }}>RECORDING</span>
|
| 865 |
+
</div>
|
| 866 |
+
<div style={{ fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: 600 }}>
|
| 867 |
+
00:{recordingTime.toString().padStart(2, '0')}
|
| 868 |
+
</div>
|
| 869 |
+
</div>
|
| 870 |
+
<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)' }}>
|
| 871 |
+
<canvas ref={canvasRef} width="800" height="180" style={{ width: '100%', height: '100%' }}></canvas>
|
| 872 |
+
</div>
|
| 873 |
+
<div style={{ display: 'flex', gap: '16px', justifyContent: 'center' }}>
|
| 874 |
+
<button className="btn-secondary" onClick={stopRecording}>
|
| 875 |
+
<AlertTriangle size={18} /> Cancel
|
| 876 |
+
</button>
|
| 877 |
+
<button className="btn-danger" onClick={handleFinishRecording}>
|
| 878 |
+
<Square size={18} fill="white" /> Stop & Analyze
|
| 879 |
+
</button>
|
| 880 |
+
</div>
|
| 881 |
+
</div>
|
| 882 |
+
)}
|
| 883 |
+
</div>
|
| 884 |
+
)}
|
| 885 |
+
|
| 886 |
+
{/* VIEW: TRIMMING */}
|
| 887 |
+
{appState === 'trimming' && trimWaveform && (
|
| 888 |
+
<div className="animate-fade-in" style={{ maxWidth: '900px', margin: '0 auto' }}>
|
| 889 |
+
<div className="dashboard-header">
|
| 890 |
+
<div>
|
| 891 |
+
<h1 className="header-title">Trim Recording</h1>
|
| 892 |
+
<p className="header-subtitle">Remove noise before the stethoscope was placed correctly</p>
|
| 893 |
+
</div>
|
| 894 |
+
<button className="btn-secondary" onClick={resetApp}>
|
| 895 |
+
<RefreshCw size={16} /> Cancel
|
| 896 |
+
</button>
|
| 897 |
+
</div>
|
| 898 |
+
<div className="glass-card">
|
| 899 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
|
| 900 |
+
<div style={{ background: 'rgba(6,182,212,0.15)', padding: '8px', borderRadius: '8px' }}>
|
| 901 |
+
<Activity size={20} color="var(--accent-cyan)" />
|
| 902 |
+
</div>
|
| 903 |
+
<div>
|
| 904 |
+
<div style={{ fontWeight: 600, fontSize: '0.95rem' }}>Select Clean Section</div>
|
| 905 |
+
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
| 906 |
+
Full recording: {trimDuration.toFixed(1)}s · Drag handles to select just the heart sounds
|
| 907 |
+
</div>
|
| 908 |
+
</div>
|
| 909 |
+
</div>
|
| 910 |
+
<AudioTrimmer
|
| 911 |
+
waveform={trimWaveform}
|
| 912 |
+
duration={trimDuration}
|
| 913 |
+
onAnalyze={handleAnalyzeTrimmed}
|
| 914 |
+
onSkip={handleSkipTrim}
|
| 915 |
+
/>
|
| 916 |
+
</div>
|
| 917 |
+
</div>
|
| 918 |
+
)}
|
| 919 |
+
|
| 920 |
+
{/* VIEW: ANALYZING */}
|
| 921 |
+
{appState === 'analyzing' && (
|
| 922 |
+
<div className="animate-fade-in" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
| 923 |
+
<RefreshCw size={48} color="var(--accent-cyan)" style={{ animation: 'spin 2s linear infinite', marginBottom: '24px' }} />
|
| 924 |
+
<style>{`@keyframes spin { 100% { transform: rotate(360deg); } }`}</style>
|
| 925 |
+
<h2>Analyzing Heart Sound...</h2>
|
| 926 |
+
<p style={{ color: 'var(--text-secondary)', marginTop: '8px', maxWidth: '400px', textAlign: 'center' }}>
|
| 927 |
+
Running cardiac screening via pre-trained AI model on your local machine.
|
| 928 |
+
</p>
|
| 929 |
+
</div>
|
| 930 |
+
)}
|
| 931 |
+
|
| 932 |
+
{/* VIEW: DASHBOARD */}
|
| 933 |
+
{appState === 'dashboard' && analysisResult && (
|
| 934 |
+
<div className="animate-fade-in delay-100">
|
| 935 |
+
<div className="dashboard-header">
|
| 936 |
+
<div>
|
| 937 |
+
<h1 className="header-title">Screening Result</h1>
|
| 938 |
+
<p className="header-subtitle">Patient: {patientData.dogId} | Breed: {patientData.breed || 'N/A'} | Age: {patientData.age || 'N/A'}</p>
|
| 939 |
+
</div>
|
| 940 |
+
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
| 941 |
+
<button className="btn-secondary" onClick={downloadReport}>
|
| 942 |
+
<Download size={16} /> PDF Report
|
| 943 |
+
</button>
|
| 944 |
+
{audioBlob && (
|
| 945 |
+
<button className="btn-secondary" onClick={downloadAudio}>
|
| 946 |
+
<FileAudio size={16} /> Save Audio
|
| 947 |
+
</button>
|
| 948 |
+
)}
|
| 949 |
+
<button className="btn-secondary" onClick={resetApp}>
|
| 950 |
+
<RefreshCw size={16} /> New Scan
|
| 951 |
+
</button>
|
| 952 |
+
</div>
|
| 953 |
+
</div>
|
| 954 |
+
|
| 955 |
+
<div className="dashboard-grid">
|
| 956 |
+
|
| 957 |
+
{/* Main Result Card */}
|
| 958 |
+
<div className={`glass-card col-span-3 ${analysisResult.ai_classification.is_disease ? 'result-disease' : 'result-normal'}`}>
|
| 959 |
+
|
| 960 |
+
{/* Header: Diagnosis + BPM */}
|
| 961 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '24px' }}>
|
| 962 |
+
<div>
|
| 963 |
+
<h3 style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
| 964 |
+
AI Cardiac Screening
|
| 965 |
+
</h3>
|
| 966 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
| 967 |
+
{analysisResult.ai_classification.is_disease ? (
|
| 968 |
+
<AlertTriangle size={36} color="var(--danger)" />
|
| 969 |
+
) : (
|
| 970 |
+
<CheckCircle size={36} color="var(--success)" />
|
| 971 |
+
)}
|
| 972 |
+
<h2 style={{ fontSize: '2.2rem', margin: 0, color: analysisResult.ai_classification.is_disease ? 'var(--danger)' : 'var(--success)' }}>
|
| 973 |
+
{analysisResult.clinical_summary}
|
| 974 |
+
</h2>
|
| 975 |
+
</div>
|
| 976 |
+
</div>
|
| 977 |
+
|
| 978 |
+
{/* BPM + Heartbeat Count */}
|
| 979 |
+
<div style={{ display: 'flex', gap: '16px' }}>
|
| 980 |
+
<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)' }}>
|
| 981 |
+
<div style={{ fontSize: '3rem', fontWeight: 800, color: analysisResult.bpmColor, lineHeight: 1 }}>
|
| 982 |
+
{analysisResult.bpm}
|
| 983 |
+
</div>
|
| 984 |
+
<div style={{ fontSize: '0.9rem', color: 'var(--text-secondary)', marginTop: '4px' }}>BPM</div>
|
| 985 |
+
<div style={{ color: analysisResult.bpmColor, fontSize: '0.8rem', fontWeight: 600, marginTop: '4px', display: 'flex', alignItems: 'center', gap: '4px', justifyContent: 'center' }}>
|
| 986 |
+
<Activity size={12} /> {analysisResult.bpmStatus}
|
| 987 |
+
</div>
|
| 988 |
+
</div>
|
| 989 |
+
<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)' }}>
|
| 990 |
+
<div style={{ fontSize: '3rem', fontWeight: 800, color: 'var(--accent-cyan)', lineHeight: 1 }}>
|
| 991 |
+
{analysisResult.heartbeat_count}
|
| 992 |
+
</div>
|
| 993 |
+
<div style={{ fontSize: '0.9rem', color: 'var(--text-secondary)', marginTop: '4px' }}>Beats</div>
|
| 994 |
+
<div style={{ color: 'var(--text-secondary)', fontSize: '0.8rem', marginTop: '4px' }}>
|
| 995 |
+
in {analysisResult.duration_seconds}s
|
| 996 |
+
</div>
|
| 997 |
+
</div>
|
| 998 |
+
</div>
|
| 999 |
+
</div>
|
| 1000 |
+
|
| 1001 |
+
{/* Audio Player */}
|
| 1002 |
+
{audioUrl && (
|
| 1003 |
+
<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' }}>
|
| 1004 |
+
<FileAudio size={20} color="var(--accent-cyan)" style={{ flexShrink: 0 }} />
|
| 1005 |
+
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', flexShrink: 0 }}>Playback</span>
|
| 1006 |
+
<audio controls src={audioUrl} ref={audioRef} style={{ flex: 1, height: '36px', borderRadius: '8px' }} />
|
| 1007 |
+
</div>
|
| 1008 |
+
)}
|
| 1009 |
+
|
| 1010 |
+
{/* Waveform with time axis + beat markers */}
|
| 1011 |
+
<div style={{ marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '16px' }}>
|
| 1012 |
+
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
| 1013 |
+
<span style={{ display: 'inline-block', width: 10, height: 10, background: analysisResult.ai_classification.is_disease ? 'var(--danger)' : 'var(--accent-cyan)', transform: 'rotate(45deg)' }}></span>
|
| 1014 |
+
Heartbeat peaks ({analysisResult.peak_times_seconds?.length ?? 0} detected)
|
| 1015 |
+
</span>
|
| 1016 |
+
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Duration: {analysisResult.duration_seconds}s</span>
|
| 1017 |
+
</div>
|
| 1018 |
+
<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)' }}>
|
| 1019 |
+
<WaveformCanvas
|
| 1020 |
+
waveform={analysisResult.waveform}
|
| 1021 |
+
peakVisIndices={analysisResult.peak_vis_indices}
|
| 1022 |
+
peakTimesSec={analysisResult.peak_times_seconds}
|
| 1023 |
+
duration={analysisResult.duration_seconds}
|
| 1024 |
+
isDisease={analysisResult.ai_classification.is_disease}
|
| 1025 |
+
canvasRefOut={waveformCanvasRef}
|
| 1026 |
+
audioRef={audioRef}
|
| 1027 |
+
/>
|
| 1028 |
+
</div>
|
| 1029 |
+
|
| 1030 |
+
{/* AI Probability Breakdown */}
|
| 1031 |
+
<div style={{ marginTop: '24px', background: 'rgba(0,0,0,0.2)', borderRadius: '12px', padding: '24px', border: '1px solid rgba(255,255,255,0.05)' }}>
|
| 1032 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
| 1033 |
+
<div style={{ color: 'var(--text-secondary)', fontSize: '1rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
| 1034 |
+
<Cpu size={18} color="var(--accent-cyan)" /> AI Probability Breakdown
|
| 1035 |
+
</div>
|
| 1036 |
+
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', opacity: 0.7 }}>
|
| 1037 |
+
CardioScreen · Logistic Regression · 21 recordings
|
| 1038 |
+
</div>
|
| 1039 |
+
</div>
|
| 1040 |
+
|
| 1041 |
+
{analysisResult.ai_classification.all_classes.map((cls, idx) => {
|
| 1042 |
+
const pct = (cls.probability * 100).toFixed(1);
|
| 1043 |
+
const isTop = cls.label === analysisResult.ai_classification.label;
|
| 1044 |
+
const isMurmur = cls.label.toLowerCase().includes('murmur') || cls.label.toLowerCase().includes('abnormal');
|
| 1045 |
+
const barColor = isMurmur ? 'var(--danger)' : 'var(--success)';
|
| 1046 |
+
|
| 1047 |
+
return (
|
| 1048 |
+
<div key={idx} style={{ marginBottom: '16px' }}>
|
| 1049 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '6px' }}>
|
| 1050 |
+
<span style={{ color: isTop ? 'white' : 'var(--text-secondary)', fontWeight: isTop ? 700 : 400, fontSize: '0.95rem' }}>
|
| 1051 |
+
{cls.label} {isTop && '★'}
|
| 1052 |
+
</span>
|
| 1053 |
+
<span style={{ color: isTop ? 'white' : 'var(--text-secondary)', fontWeight: 600 }}>
|
| 1054 |
+
{pct}%
|
| 1055 |
+
</span>
|
| 1056 |
+
</div>
|
| 1057 |
+
<div className="confidence-bar-bg" style={{ height: '10px', borderRadius: '5px' }}>
|
| 1058 |
+
<div className="confidence-bar-fill" style={{
|
| 1059 |
+
borderRadius: '5px',
|
| 1060 |
+
width: `${Math.max(2, pct)}%`,
|
| 1061 |
+
background: isTop ? barColor : 'rgba(255,255,255,0.15)',
|
| 1062 |
+
transition: 'width 1s ease'
|
| 1063 |
+
}}></div>
|
| 1064 |
+
</div>
|
| 1065 |
+
</div>
|
| 1066 |
+
);
|
| 1067 |
+
})}
|
| 1068 |
+
</div>
|
| 1069 |
+
</div>
|
| 1070 |
+
|
| 1071 |
+
{/* Canine Reference Table */}
|
| 1072 |
+
<div className="glass-card col-span-3" style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
| 1073 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '32px' }}>
|
| 1074 |
+
<div>
|
| 1075 |
+
<h3 style={{ marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--accent-cyan)' }}>
|
| 1076 |
+
<Activity size={18} /> Normal Heart Rate (Canine)
|
| 1077 |
+
</h3>
|
| 1078 |
+
<table style={{ width: '100%', fontSize: '0.85rem', borderCollapse: 'collapse', color: 'var(--text-secondary)' }}>
|
| 1079 |
+
<thead>
|
| 1080 |
+
<tr style={{ textAlign: 'left', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
| 1081 |
+
<th style={{ padding: '8px 0' }}>Dog Size</th>
|
| 1082 |
+
<th style={{ padding: '8px 0' }}>Normal Range</th>
|
| 1083 |
+
</tr>
|
| 1084 |
+
</thead>
|
| 1085 |
+
<tbody>
|
| 1086 |
+
<tr><td style={{ padding: '8px 0' }}>Large (60lb+)</td><td style={{ color: 'white' }}>60 – 100 BPM</td></tr>
|
| 1087 |
+
<tr><td style={{ padding: '8px 0' }}>Medium (20–60lb)</td><td style={{ color: 'white' }}>80 – 120 BPM</td></tr>
|
| 1088 |
+
<tr><td style={{ padding: '8px 0' }}>Small (<20lb)</td><td style={{ color: 'white' }}>100 – 160 BPM</td></tr>
|
| 1089 |
+
<tr><td style={{ padding: '8px 0' }}>Puppies</td><td style={{ color: 'white' }}>Up to 220 BPM</td></tr>
|
| 1090 |
+
</tbody>
|
| 1091 |
+
</table>
|
| 1092 |
+
</div>
|
| 1093 |
+
<div>
|
| 1094 |
+
<h3 style={{ marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--accent-cyan)' }}>
|
| 1095 |
+
<Heart size={18} /> Murmur Grading (Levine Scale)
|
| 1096 |
+
</h3>
|
| 1097 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', fontSize: '0.8rem' }}>
|
| 1098 |
+
<div style={{ background: 'rgba(255,255,255,0.02)', padding: '8px', borderRadius: '4px' }}>
|
| 1099 |
+
<b style={{ color: 'white' }}>Grade I:</b> Very faint
|
| 1100 |
+
</div>
|
| 1101 |
+
<div style={{ background: 'rgba(255,255,255,0.02)', padding: '8px', borderRadius: '4px' }}>
|
| 1102 |
+
<b style={{ color: 'white' }}>Grade II:</b> Soft, easily heard
|
| 1103 |
+
</div>
|
| 1104 |
+
<div style={{ background: 'rgba(255,255,255,0.02)', padding: '8px', borderRadius: '4px' }}>
|
| 1105 |
+
<b style={{ color: 'white' }}>Grade III:</b> Intermediate
|
| 1106 |
+
</div>
|
| 1107 |
+
<div style={{ background: 'rgba(255,255,255,0.02)', padding: '8px', borderRadius: '4px' }}>
|
| 1108 |
+
<b style={{ color: 'white' }}>Grade IV:</b> Loud, no thrill
|
| 1109 |
+
</div>
|
| 1110 |
+
<div style={{ background: 'rgba(255,255,255,0.02)', padding: '8px', borderRadius: '4px' }}>
|
| 1111 |
+
<b style={{ color: 'white' }}>Grade V:</b> With palpable thrill
|
| 1112 |
+
</div>
|
| 1113 |
+
<div style={{ background: 'rgba(255,255,255,0.02)', padding: '8px', borderRadius: '4px' }}>
|
| 1114 |
+
<b style={{ color: 'white' }}>Grade VI:</b> Heard without stethoscope
|
| 1115 |
+
</div>
|
| 1116 |
+
</div>
|
| 1117 |
+
</div>
|
| 1118 |
+
</div>
|
| 1119 |
+
</div>
|
| 1120 |
+
|
| 1121 |
+
</div>
|
| 1122 |
+
</div>
|
| 1123 |
+
)}
|
| 1124 |
+
|
| 1125 |
+
</main>
|
| 1126 |
+
</div>
|
| 1127 |
+
);
|
| 1128 |
+
}
|
| 1129 |
+
|
| 1130 |
+
export default App;
|
webapp/src/assets/react.svg
ADDED
|
|
webapp/src/index.css
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg-color: #0b0f19;
|
| 3 |
+
--bg-card: rgba(20, 26, 40, 0.6);
|
| 4 |
+
--bg-card-hover: rgba(30, 38, 56, 0.8);
|
| 5 |
+
--text-primary: #f8fafc;
|
| 6 |
+
--text-secondary: #94a3b8;
|
| 7 |
+
--accent-cyan: #06b6d4;
|
| 8 |
+
--accent-cyan-glow: rgba(6, 182, 212, 0.4);
|
| 9 |
+
--accent-blue: #3b82f6;
|
| 10 |
+
--accent-blue-glow: rgba(59, 130, 246, 0.4);
|
| 11 |
+
--danger: #ef4444;
|
| 12 |
+
--danger-glow: rgba(239, 68, 68, 0.4);
|
| 13 |
+
--success: #10b981;
|
| 14 |
+
--border-color: rgba(255, 255, 255, 0.08);
|
| 15 |
+
--card-radius: 16px;
|
| 16 |
+
--transition-fast: 0.2s ease;
|
| 17 |
+
--transition-smooth: 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
* {
|
| 21 |
+
box-sizing: border-box;
|
| 22 |
+
margin: 0;
|
| 23 |
+
padding: 0;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
body {
|
| 27 |
+
font-family: 'Outfit', 'Inter', system-ui, -apple-system, sans-serif;
|
| 28 |
+
background-color: var(--bg-color);
|
| 29 |
+
color: var(--text-primary);
|
| 30 |
+
line-height: 1.5;
|
| 31 |
+
overflow-x: hidden;
|
| 32 |
+
background-image:
|
| 33 |
+
radial-gradient(circle at 15% 50%, rgba(6, 182, 212, 0.1), transparent 25%),
|
| 34 |
+
radial-gradient(circle at 85% 30%, rgba(59, 130, 246, 0.1), transparent 25%);
|
| 35 |
+
background-attachment: fixed;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* Glass Card */
|
| 39 |
+
.glass-card {
|
| 40 |
+
background: var(--bg-card);
|
| 41 |
+
backdrop-filter: blur(12px);
|
| 42 |
+
-webkit-backdrop-filter: blur(12px);
|
| 43 |
+
border: 1px solid var(--border-color);
|
| 44 |
+
border-radius: var(--card-radius);
|
| 45 |
+
padding: 24px;
|
| 46 |
+
transition: all var(--transition-smooth);
|
| 47 |
+
position: relative;
|
| 48 |
+
overflow: hidden;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.glass-card::before {
|
| 52 |
+
content: '';
|
| 53 |
+
position: absolute;
|
| 54 |
+
top: 0;
|
| 55 |
+
left: 0;
|
| 56 |
+
right: 0;
|
| 57 |
+
height: 1px;
|
| 58 |
+
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
| 59 |
+
opacity: 0;
|
| 60 |
+
transition: opacity var(--transition-smooth);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.glass-card:hover {
|
| 64 |
+
background: var(--bg-card-hover);
|
| 65 |
+
transform: translateY(-2px);
|
| 66 |
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.glass-card:hover::before {
|
| 70 |
+
opacity: 1;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/* Typography */
|
| 74 |
+
h1,
|
| 75 |
+
h2,
|
| 76 |
+
h3,
|
| 77 |
+
h4,
|
| 78 |
+
h5,
|
| 79 |
+
h6 {
|
| 80 |
+
font-weight: 600;
|
| 81 |
+
letter-spacing: -0.02em;
|
| 82 |
+
color: var(--text-primary);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.text-gradient {
|
| 86 |
+
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
|
| 87 |
+
-webkit-background-clip: text;
|
| 88 |
+
-webkit-text-fill-color: transparent;
|
| 89 |
+
background-clip: text;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.text-sm {
|
| 93 |
+
font-size: 0.875rem;
|
| 94 |
+
color: var(--text-secondary);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.text-lg {
|
| 98 |
+
font-size: 1.125rem;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.text-xl {
|
| 102 |
+
font-size: 1.25rem;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.text-2xl {
|
| 106 |
+
font-size: 1.5rem;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.text-3xl {
|
| 110 |
+
font-size: 1.875rem;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/* Buttons */
|
| 114 |
+
.btn-primary {
|
| 115 |
+
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
|
| 116 |
+
color: white;
|
| 117 |
+
border: none;
|
| 118 |
+
padding: 12px 24px;
|
| 119 |
+
border-radius: 8px;
|
| 120 |
+
font-weight: 600;
|
| 121 |
+
font-family: 'Outfit', sans-serif;
|
| 122 |
+
cursor: pointer;
|
| 123 |
+
transition: all var(--transition-fast);
|
| 124 |
+
box-shadow: 0 4px 15px var(--accent-blue-glow);
|
| 125 |
+
display: flex;
|
| 126 |
+
align-items: center;
|
| 127 |
+
justify-content: center;
|
| 128 |
+
gap: 8px;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.btn-primary:hover {
|
| 132 |
+
transform: translateY(-2px);
|
| 133 |
+
box-shadow: 0 6px 20px var(--accent-cyan-glow);
|
| 134 |
+
filter: brightness(1.1);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.btn-primary:active {
|
| 138 |
+
transform: translateY(0);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
.btn-secondary {
|
| 142 |
+
background: rgba(255, 255, 255, 0.05);
|
| 143 |
+
color: var(--text-primary);
|
| 144 |
+
border: 1px solid var(--border-color);
|
| 145 |
+
padding: 12px 24px;
|
| 146 |
+
border-radius: 8px;
|
| 147 |
+
font-weight: 500;
|
| 148 |
+
cursor: pointer;
|
| 149 |
+
transition: all var(--transition-fast);
|
| 150 |
+
display: flex;
|
| 151 |
+
align-items: center;
|
| 152 |
+
justify-content: center;
|
| 153 |
+
gap: 8px;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.btn-secondary:hover {
|
| 157 |
+
background: rgba(255, 255, 255, 0.1);
|
| 158 |
+
border-color: rgba(255, 255, 255, 0.2);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.btn-danger {
|
| 162 |
+
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
| 163 |
+
color: white;
|
| 164 |
+
border: none;
|
| 165 |
+
padding: 12px 24px;
|
| 166 |
+
border-radius: 8px;
|
| 167 |
+
font-weight: 600;
|
| 168 |
+
font-family: 'Outfit', sans-serif;
|
| 169 |
+
cursor: pointer;
|
| 170 |
+
transition: all var(--transition-fast);
|
| 171 |
+
box-shadow: 0 4px 15px var(--danger-glow);
|
| 172 |
+
display: flex;
|
| 173 |
+
align-items: center;
|
| 174 |
+
justify-content: center;
|
| 175 |
+
gap: 8px;
|
| 176 |
+
animation: pulse-danger 2s infinite;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.btn-danger:hover {
|
| 180 |
+
transform: translateY(-2px);
|
| 181 |
+
filter: brightness(1.2);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
@keyframes pulse-danger {
|
| 185 |
+
0% {
|
| 186 |
+
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
70% {
|
| 190 |
+
box-shadow: 0 0 0 15px rgba(239, 68, 68, 0);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
100% {
|
| 194 |
+
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
/* Layout */
|
| 199 |
+
.app-container {
|
| 200 |
+
display: flex;
|
| 201 |
+
min-height: 100vh;
|
| 202 |
+
width: 100vw;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.sidebar {
|
| 206 |
+
width: 280px;
|
| 207 |
+
background: rgba(11, 15, 25, 0.8);
|
| 208 |
+
backdrop-filter: blur(20px);
|
| 209 |
+
border-right: 1px solid var(--border-color);
|
| 210 |
+
display: flex;
|
| 211 |
+
flex-direction: column;
|
| 212 |
+
padding: 24px;
|
| 213 |
+
position: relative;
|
| 214 |
+
z-index: 10;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.main-content {
|
| 218 |
+
flex: 1;
|
| 219 |
+
padding: 32px 40px;
|
| 220 |
+
overflow-y: auto;
|
| 221 |
+
position: relative;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/* Base Animations */
|
| 225 |
+
@keyframes fadeIn {
|
| 226 |
+
from {
|
| 227 |
+
opacity: 0;
|
| 228 |
+
transform: translateY(10px);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
to {
|
| 232 |
+
opacity: 1;
|
| 233 |
+
transform: translateY(0);
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.animate-fade-in {
|
| 238 |
+
animation: fadeIn 0.6s var(--transition-smooth) forwards;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.delay-100 {
|
| 242 |
+
animation-delay: 0.1s;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.delay-200 {
|
| 246 |
+
animation-delay: 0.2s;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.delay-300 {
|
| 250 |
+
animation-delay: 0.3s;
|
| 251 |
+
}
|
webapp/src/main.jsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
+
import App from './App.jsx'
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
)
|
webapp/vite.config.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
|
| 4 |
+
// https://vite.dev/config/
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [react()],
|
| 7 |
+
server: {
|
| 8 |
+
proxy: {
|
| 9 |
+
'/hf-api': {
|
| 10 |
+
target: 'https://api-inference.huggingface.co',
|
| 11 |
+
changeOrigin: true,
|
| 12 |
+
rewrite: (path) => path.replace(/^\/hf-api/, ''),
|
| 13 |
+
},
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
})
|