CardioScreen AI commited on
Commit
2c59c0c
·
0 Parent(s):

Initial commit: CardioScreen AI v1.0 - Canine cardiac screening tool

Browse files
.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 &nbsp;·&nbsp; 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 (&lt;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
+ })