Update app.py
Browse files
app.py
CHANGED
|
@@ -32,14 +32,16 @@ N_FFT = 2048
|
|
| 32 |
HOP_LENGTH = 512
|
| 33 |
N_MELS = 128
|
| 34 |
FMAX = 8000
|
| 35 |
-
DURATION = 10
|
| 36 |
|
| 37 |
# --- LOAD YOUR FINE-TUNED MODEL ---
|
| 38 |
FINETUNED_MODEL_PATH = "snoring_detection_model fine tuned.h5"
|
| 39 |
finetuned_snore_model = None
|
| 40 |
|
| 41 |
def build_finetuned_model_architecture():
|
| 42 |
-
"""
|
|
|
|
|
|
|
| 43 |
inputs = layers.Input(shape=(128, 431, 1))
|
| 44 |
|
| 45 |
# Block 1
|
|
@@ -123,38 +125,57 @@ except Exception as e:
|
|
| 123 |
finetuned_snore_model = None
|
| 124 |
|
| 125 |
# ========================================
|
| 126 |
-
#
|
| 127 |
# ========================================
|
| 128 |
|
| 129 |
def create_mel_spectrogram(y, sr):
|
| 130 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 131 |
mel_spec = librosa.feature.melspectrogram(
|
| 132 |
y=y, sr=sr, n_fft=N_FFT, hop_length=HOP_LENGTH,
|
| 133 |
n_mels=N_MELS, fmax=FMAX, power=1.0
|
| 134 |
)
|
| 135 |
mel_spec_db = librosa.power_to_db(mel_spec, ref=np.max)
|
|
|
|
| 136 |
mel_spec_db = (mel_spec_db - mel_spec_db.min()) / (mel_spec_db.max() - mel_spec_db.min() + 1e-10)
|
| 137 |
return mel_spec_db
|
| 138 |
|
| 139 |
def predict_snoring_segment(y_segment, sr, model):
|
| 140 |
-
"""
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
if len(y_segment) < target_length:
|
|
|
|
| 144 |
padding = target_length - len(y_segment)
|
| 145 |
y_segment = np.pad(y_segment, (0, padding), mode='constant')
|
| 146 |
elif len(y_segment) > target_length:
|
|
|
|
| 147 |
y_segment = y_segment[:target_length]
|
| 148 |
|
|
|
|
| 149 |
y_segment = y_segment / (np.max(np.abs(y_segment)) + 1e-10)
|
|
|
|
|
|
|
| 150 |
mel_spec = create_mel_spectrogram(y_segment, sr)
|
|
|
|
|
|
|
| 151 |
mel_spec_input = mel_spec[np.newaxis, ..., np.newaxis]
|
|
|
|
|
|
|
| 152 |
probability = model.predict(mel_spec_input, verbose=0)[0][0]
|
| 153 |
|
| 154 |
return float(probability)
|
| 155 |
|
| 156 |
def analyze_audio_segments(y, sr, model, segment_duration=10):
|
| 157 |
-
"""
|
|
|
|
|
|
|
| 158 |
segment_length = int(segment_duration * sr)
|
| 159 |
total_duration = len(y) / sr
|
| 160 |
num_segments = int(np.ceil(total_duration / segment_duration))
|
|
@@ -167,15 +188,19 @@ def analyze_audio_segments(y, sr, model, segment_duration=10):
|
|
| 167 |
start_sample = i * segment_length
|
| 168 |
end_sample = min(start_sample + segment_length, len(y))
|
| 169 |
|
|
|
|
| 170 |
segment = y[start_sample:end_sample]
|
| 171 |
|
|
|
|
| 172 |
if len(segment) < segment_length:
|
| 173 |
padding = segment_length - len(segment)
|
| 174 |
segment = np.pad(segment, (0, padding), mode='constant')
|
| 175 |
|
|
|
|
| 176 |
if sr != SAMPLE_RATE:
|
| 177 |
segment = librosa.resample(segment, orig_sr=sr, target_sr=SAMPLE_RATE)
|
| 178 |
|
|
|
|
| 179 |
probability = predict_snoring_segment(segment, SAMPLE_RATE, model)
|
| 180 |
is_snoring = probability > 0.5
|
| 181 |
|
|
@@ -190,82 +215,121 @@ def analyze_audio_segments(y, sr, model, segment_duration=10):
|
|
| 190 |
|
| 191 |
segment_results.append(segment_result)
|
| 192 |
|
|
|
|
| 193 |
if is_snoring:
|
| 194 |
print(f" โ
Segment {i+1}/{num_segments}: SNORE (prob: {probability:.4f})")
|
|
|
|
|
|
|
| 195 |
|
| 196 |
return segment_results
|
| 197 |
|
| 198 |
# ========================================
|
| 199 |
-
#
|
| 200 |
# ========================================
|
| 201 |
|
| 202 |
-
def
|
| 203 |
-
"""
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
duration = len(y) / sr
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
rms_energy = np.sqrt(np.mean(y**2))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
-
|
| 226 |
-
if rms_energy > 0.7:
|
| 227 |
-
return False, "REJECT: Audio too loud (likely music/TV/party, not sleep recording)"
|
| 228 |
|
| 229 |
-
# Spectral analysis
|
| 230 |
stft = np.abs(librosa.stft(y))
|
| 231 |
freq_bins = librosa.fft_frequencies(sr=sr)
|
| 232 |
|
| 233 |
-
# Frequency distribution
|
| 234 |
low_freq_energy = np.sum(stft[freq_bins < 2000, :])
|
| 235 |
high_freq_energy = np.sum(stft[freq_bins > 4000, :])
|
| 236 |
-
very_high_freq = np.sum(stft[freq_bins > 6000, :])
|
| 237 |
total_energy = np.sum(stft) + 1e-10
|
| 238 |
|
| 239 |
low_freq_ratio = low_freq_energy / total_energy
|
| 240 |
high_freq_ratio = high_freq_energy / total_energy
|
| 241 |
-
very_high_ratio = very_high_freq / total_energy
|
| 242 |
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
return False, "REJECT: Too much very high-frequency content (likely music or TV)"
|
| 246 |
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
return False, "REJECT: Frequency pattern matches speech/conversation, not sleep audio"
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
if duration < 10:
|
| 255 |
-
warnings.append("โ ๏ธ Very short audio (< 10s) - limited analysis")
|
| 256 |
-
|
| 257 |
-
if rms_energy < 0.003:
|
| 258 |
-
warnings.append("โ ๏ธ Very quiet audio - may be mostly silence")
|
| 259 |
-
|
| 260 |
-
if high_freq_ratio > 0.35:
|
| 261 |
-
warnings.append("โ ๏ธ Moderate high-frequency content detected")
|
| 262 |
-
|
| 263 |
-
# ACCEPT: Let the model decide
|
| 264 |
-
if warnings:
|
| 265 |
-
warning_msg = " | ".join(warnings)
|
| 266 |
-
return True, f"SOFT WARNING: {warning_msg}"
|
| 267 |
else:
|
| 268 |
-
return
|
| 269 |
|
| 270 |
# --- API ENDPOINTS ---
|
| 271 |
|
|
@@ -277,13 +341,15 @@ async def root():
|
|
| 277 |
"model_loaded": finetuned_snore_model is not None,
|
| 278 |
"model_type": "Fine-tuned CNN with Kaggle preprocessing",
|
| 279 |
"model_accuracy": "95.33%",
|
| 280 |
-
"
|
|
|
|
| 281 |
}
|
| 282 |
|
| 283 |
@app.post("/analyze")
|
| 284 |
async def analyze_audio(file: UploadFile = File(...)):
|
| 285 |
-
"""Analyze audio using
|
| 286 |
|
|
|
|
| 287 |
if finetuned_snore_model is None:
|
| 288 |
raise HTTPException(
|
| 289 |
status_code=503,
|
|
@@ -292,43 +358,40 @@ async def analyze_audio(file: UploadFile = File(...)):
|
|
| 292 |
|
| 293 |
temp_path = f"temp_{os.getpid()}_{file.filename}"
|
| 294 |
|
|
|
|
| 295 |
with open(temp_path, "wb") as buffer:
|
| 296 |
shutil.copyfileobj(file.file, buffer)
|
| 297 |
|
| 298 |
try:
|
| 299 |
-
# 1. LOAD
|
| 300 |
print(f"\n๐ Loading audio: {file.filename}")
|
| 301 |
y, sr = librosa.load(temp_path, sr=None, dtype=np.float32)
|
| 302 |
-
print(f"
|
|
|
|
| 303 |
|
| 304 |
-
# 2.
|
| 305 |
-
print("\n๐
|
| 306 |
-
|
| 307 |
|
| 308 |
-
if
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
raise HTTPException(
|
| 312 |
-
status_code=400,
|
| 313 |
-
detail=validation_message
|
| 314 |
-
)
|
| 315 |
|
| 316 |
-
|
| 317 |
-
print(f" โ
{validation_message}")
|
| 318 |
|
| 319 |
-
# 3.
|
| 320 |
-
print("๐งน Applying noise reduction...")
|
| 321 |
y_clean = nr.reduce_noise(y=y, sr=sr, stationary=True)
|
| 322 |
|
| 323 |
-
# 4.
|
| 324 |
-
print(f"๐ฏ Analyzing with fine-tuned model (Kaggle method)...")
|
| 325 |
segment_results = analyze_audio_segments(y_clean, sr, finetuned_snore_model, segment_duration=10)
|
| 326 |
|
| 327 |
-
# 5. COUNT
|
| 328 |
snore_count = sum(1 for seg in segment_results if seg['is_snoring'])
|
| 329 |
total_segments = len(segment_results)
|
| 330 |
|
| 331 |
-
print(f"\n๐
|
| 332 |
print(f" Total segments: {total_segments}")
|
| 333 |
print(f" Snoring segments: {snore_count}")
|
| 334 |
print(f" Snoring percentage: {(snore_count/total_segments)*100:.1f}%")
|
|
@@ -337,7 +400,7 @@ async def analyze_audio(file: UploadFile = File(...)):
|
|
| 337 |
annotations = []
|
| 338 |
apnea_count = 0
|
| 339 |
|
| 340 |
-
#
|
| 341 |
for seg in segment_results:
|
| 342 |
if seg['is_snoring']:
|
| 343 |
annotations.append({
|
|
@@ -349,45 +412,48 @@ async def analyze_audio(file: UploadFile = File(...)):
|
|
| 349 |
"confidence": round(seg['confidence'], 4)
|
| 350 |
})
|
| 351 |
|
| 352 |
-
#
|
| 353 |
for i in range(len(segment_results) - 1):
|
| 354 |
current = segment_results[i]
|
| 355 |
next_seg = segment_results[i + 1]
|
| 356 |
|
|
|
|
| 357 |
if current['is_snoring'] and not next_seg['is_snoring']:
|
|
|
|
|
|
|
| 358 |
for j in range(i + 1, len(segment_results)):
|
| 359 |
if segment_results[j]['is_snoring']:
|
| 360 |
-
|
|
|
|
| 361 |
|
|
|
|
| 362 |
if 10.0 <= gap_duration <= 120.0:
|
| 363 |
apnea_count += 1
|
| 364 |
risk = "LOW" if gap_duration < 15.0 else ("MEDIUM" if gap_duration < 20.0 else "HIGH")
|
| 365 |
annotations.append({
|
| 366 |
"label": "APNEA",
|
| 367 |
-
"start_sec": round(
|
| 368 |
-
"end_sec": round(
|
| 369 |
"duration": round(gap_duration, 2),
|
| 370 |
"risk_level": risk
|
| 371 |
})
|
| 372 |
-
print(f" โ ๏ธ Apnea: {gap_duration:.1f}s gap")
|
| 373 |
break
|
| 374 |
|
| 375 |
-
# 7.
|
| 376 |
total_duration = segment_results[-1]['end_time']
|
| 377 |
duration_hours = total_duration / 3600
|
| 378 |
ahi = apnea_count / duration_hours if duration_hours > 0 else 0
|
| 379 |
|
| 380 |
snoring_percentage = (snore_count / total_segments) * 100
|
| 381 |
|
| 382 |
-
|
| 383 |
-
if snoring_percentage >
|
| 384 |
risk_summary = "HIGH"
|
| 385 |
-
elif snoring_percentage >
|
| 386 |
risk_summary = "MEDIUM"
|
| 387 |
-
elif snoring_percentage >
|
| 388 |
risk_summary = "LOW"
|
| 389 |
-
else:
|
| 390 |
-
risk_summary = "NORMAL"
|
| 391 |
|
| 392 |
# Cleanup
|
| 393 |
del y, y_clean
|
|
@@ -395,7 +461,8 @@ async def analyze_audio(file: UploadFile = File(...)):
|
|
| 395 |
|
| 396 |
return {
|
| 397 |
"valid_recording": True,
|
| 398 |
-
"
|
|
|
|
| 399 |
"snore_count": snore_count,
|
| 400 |
"total_segments": total_segments,
|
| 401 |
"snoring_percentage": round(snoring_percentage, 2),
|
|
@@ -403,11 +470,10 @@ async def analyze_audio(file: UploadFile = File(...)):
|
|
| 403 |
"overall_risk": risk_summary,
|
| 404 |
"ahi_score": round(ahi, 1),
|
| 405 |
"model_used": "Fine-tuned CNN (Kaggle method)",
|
|
|
|
| 406 |
"events": annotations
|
| 407 |
}
|
| 408 |
|
| 409 |
-
except HTTPException:
|
| 410 |
-
raise
|
| 411 |
except Exception as e:
|
| 412 |
print(f"๐ฅ ERROR: {str(e)}")
|
| 413 |
import traceback
|
|
|
|
| 32 |
HOP_LENGTH = 512
|
| 33 |
N_MELS = 128
|
| 34 |
FMAX = 8000
|
| 35 |
+
DURATION = 10 # 10 seconds per segment
|
| 36 |
|
| 37 |
# --- LOAD YOUR FINE-TUNED MODEL ---
|
| 38 |
FINETUNED_MODEL_PATH = "snoring_detection_model fine tuned.h5"
|
| 39 |
finetuned_snore_model = None
|
| 40 |
|
| 41 |
def build_finetuned_model_architecture():
|
| 42 |
+
"""
|
| 43 |
+
Reconstruct the EXACT fine-tuned model architecture from training.
|
| 44 |
+
"""
|
| 45 |
inputs = layers.Input(shape=(128, 431, 1))
|
| 46 |
|
| 47 |
# Block 1
|
|
|
|
| 125 |
finetuned_snore_model = None
|
| 126 |
|
| 127 |
# ========================================
|
| 128 |
+
# SNORING DETECTION - EXACT FROM KAGGLE CLEANING PIPELINE
|
| 129 |
# ========================================
|
| 130 |
|
| 131 |
def create_mel_spectrogram(y, sr):
|
| 132 |
+
"""
|
| 133 |
+
Create mel-spectrogram EXACTLY as in Kaggle cleaning pipeline
|
| 134 |
+
This is the EXACT function from your working code
|
| 135 |
+
"""
|
| 136 |
mel_spec = librosa.feature.melspectrogram(
|
| 137 |
y=y, sr=sr, n_fft=N_FFT, hop_length=HOP_LENGTH,
|
| 138 |
n_mels=N_MELS, fmax=FMAX, power=1.0
|
| 139 |
)
|
| 140 |
mel_spec_db = librosa.power_to_db(mel_spec, ref=np.max)
|
| 141 |
+
# Normalize to 0-1 range
|
| 142 |
mel_spec_db = (mel_spec_db - mel_spec_db.min()) / (mel_spec_db.max() - mel_spec_db.min() + 1e-10)
|
| 143 |
return mel_spec_db
|
| 144 |
|
| 145 |
def predict_snoring_segment(y_segment, sr, model):
|
| 146 |
+
"""
|
| 147 |
+
Predict snoring for a segment - EXACT from Kaggle cleaning pipeline
|
| 148 |
+
This is the EXACT function from your working code
|
| 149 |
+
"""
|
| 150 |
+
# Ensure segment is exactly 10 seconds
|
| 151 |
+
target_length = SAMPLE_RATE * DURATION # 22050 * 10 = 220500 samples
|
| 152 |
|
| 153 |
if len(y_segment) < target_length:
|
| 154 |
+
# Pad with zeros
|
| 155 |
padding = target_length - len(y_segment)
|
| 156 |
y_segment = np.pad(y_segment, (0, padding), mode='constant')
|
| 157 |
elif len(y_segment) > target_length:
|
| 158 |
+
# Truncate
|
| 159 |
y_segment = y_segment[:target_length]
|
| 160 |
|
| 161 |
+
# Normalize
|
| 162 |
y_segment = y_segment / (np.max(np.abs(y_segment)) + 1e-10)
|
| 163 |
+
|
| 164 |
+
# Create mel-spectrogram
|
| 165 |
mel_spec = create_mel_spectrogram(y_segment, sr)
|
| 166 |
+
|
| 167 |
+
# Add batch and channel dimensions
|
| 168 |
mel_spec_input = mel_spec[np.newaxis, ..., np.newaxis]
|
| 169 |
+
|
| 170 |
+
# Predict
|
| 171 |
probability = model.predict(mel_spec_input, verbose=0)[0][0]
|
| 172 |
|
| 173 |
return float(probability)
|
| 174 |
|
| 175 |
def analyze_audio_segments(y, sr, model, segment_duration=10):
|
| 176 |
+
"""
|
| 177 |
+
Analyze audio in segments - EXACT from Kaggle cleaning pipeline
|
| 178 |
+
"""
|
| 179 |
segment_length = int(segment_duration * sr)
|
| 180 |
total_duration = len(y) / sr
|
| 181 |
num_segments = int(np.ceil(total_duration / segment_duration))
|
|
|
|
| 188 |
start_sample = i * segment_length
|
| 189 |
end_sample = min(start_sample + segment_length, len(y))
|
| 190 |
|
| 191 |
+
# Extract segment
|
| 192 |
segment = y[start_sample:end_sample]
|
| 193 |
|
| 194 |
+
# Pad if too short
|
| 195 |
if len(segment) < segment_length:
|
| 196 |
padding = segment_length - len(segment)
|
| 197 |
segment = np.pad(segment, (0, padding), mode='constant')
|
| 198 |
|
| 199 |
+
# Resample to 22050 Hz (training sample rate)
|
| 200 |
if sr != SAMPLE_RATE:
|
| 201 |
segment = librosa.resample(segment, orig_sr=sr, target_sr=SAMPLE_RATE)
|
| 202 |
|
| 203 |
+
# Predict using EXACT Kaggle method
|
| 204 |
probability = predict_snoring_segment(segment, SAMPLE_RATE, model)
|
| 205 |
is_snoring = probability > 0.5
|
| 206 |
|
|
|
|
| 215 |
|
| 216 |
segment_results.append(segment_result)
|
| 217 |
|
| 218 |
+
# Progress and logging
|
| 219 |
if is_snoring:
|
| 220 |
print(f" โ
Segment {i+1}/{num_segments}: SNORE (prob: {probability:.4f})")
|
| 221 |
+
else:
|
| 222 |
+
print(f" โฌ Segment {i+1}/{num_segments}: No snore (prob: {probability:.4f})")
|
| 223 |
|
| 224 |
return segment_results
|
| 225 |
|
| 226 |
# ========================================
|
| 227 |
+
# BREATHING & SLEEP RECORDING VALIDATION
|
| 228 |
# ========================================
|
| 229 |
|
| 230 |
+
def has_speech_in_segment(y_segment, sr):
|
| 231 |
+
"""Check if segment contains human speech"""
|
| 232 |
+
if len(y_segment) < sr * 0.2:
|
| 233 |
+
return False
|
| 234 |
+
|
| 235 |
+
zcr = np.mean(librosa.feature.zero_crossing_rate(y_segment)[0])
|
| 236 |
+
if zcr > 0.30:
|
| 237 |
+
return True
|
| 238 |
+
|
| 239 |
+
centroid = np.mean(librosa.feature.spectral_centroid(y=y_segment, sr=sr)[0])
|
| 240 |
+
if centroid > 3000:
|
| 241 |
+
return True
|
| 242 |
+
|
| 243 |
+
stft = np.abs(librosa.stft(y_segment))
|
| 244 |
+
freq_bins = librosa.fft_frequencies(sr=sr)
|
| 245 |
+
speech_range = (freq_bins >= 2000) & (freq_bins <= 8000)
|
| 246 |
+
speech_energy = np.sum(stft[speech_range, :])
|
| 247 |
+
total_energy = np.sum(stft) + 1e-10
|
| 248 |
+
speech_ratio = speech_energy / total_energy
|
| 249 |
+
|
| 250 |
+
if speech_ratio > 0.4:
|
| 251 |
+
return True
|
| 252 |
+
|
| 253 |
+
return False
|
| 254 |
+
|
| 255 |
+
def is_human_breathing_sound(y_segment, sr):
|
| 256 |
+
"""Detect if segment contains breathing sounds"""
|
| 257 |
+
if len(y_segment) < sr * 0.05:
|
| 258 |
+
return False
|
| 259 |
+
|
| 260 |
+
rms_energy = np.sqrt(np.mean(y_segment**2))
|
| 261 |
+
|
| 262 |
+
if rms_energy < 0.002 or rms_energy > 0.8:
|
| 263 |
+
return False
|
| 264 |
+
|
| 265 |
+
stft = np.abs(librosa.stft(y_segment))
|
| 266 |
+
freq_bins = librosa.fft_frequencies(sr=sr)
|
| 267 |
+
|
| 268 |
+
breathing_range = (freq_bins >= 200) & (freq_bins <= 2000)
|
| 269 |
+
breathing_energy = np.sum(stft[breathing_range, :])
|
| 270 |
+
total_energy = np.sum(stft) + 1e-10
|
| 271 |
+
breathing_ratio = breathing_energy / total_energy
|
| 272 |
+
|
| 273 |
+
if breathing_ratio < 0.2:
|
| 274 |
+
return False
|
| 275 |
+
|
| 276 |
+
zcr = librosa.feature.zero_crossing_rate(y_segment)[0]
|
| 277 |
+
mean_zcr = np.mean(zcr)
|
| 278 |
+
|
| 279 |
+
if mean_zcr > 0.25:
|
| 280 |
+
return False
|
| 281 |
+
|
| 282 |
+
return True
|
| 283 |
+
|
| 284 |
+
def validate_sleep_recording(y, sr):
|
| 285 |
+
"""Validate that this is a sleep recording"""
|
| 286 |
duration = len(y) / sr
|
| 287 |
+
|
| 288 |
+
if duration < 20:
|
| 289 |
+
return False, 0.0, "Audio too short (< 20 seconds)"
|
| 290 |
+
|
| 291 |
rms_energy = np.sqrt(np.mean(y**2))
|
| 292 |
+
if rms_energy < 0.001:
|
| 293 |
+
return False, 0.0, "Audio is blank/silent"
|
| 294 |
+
|
| 295 |
+
if rms_energy > 0.6:
|
| 296 |
+
return False, 0.0, "Audio too loud (not a sleep recording)"
|
| 297 |
|
| 298 |
+
num_samples = min(10, int(duration / 5))
|
| 299 |
+
breathing_segments = 0
|
| 300 |
+
|
| 301 |
+
for i in range(num_samples):
|
| 302 |
+
start = int((duration / num_samples) * i * sr)
|
| 303 |
+
end = int(start + 3 * sr)
|
| 304 |
+
if end > len(y):
|
| 305 |
+
end = len(y)
|
| 306 |
+
segment = y[start:end]
|
| 307 |
+
|
| 308 |
+
if is_human_breathing_sound(segment, sr):
|
| 309 |
+
breathing_segments += 1
|
| 310 |
|
| 311 |
+
breathing_confidence = breathing_segments / num_samples
|
|
|
|
|
|
|
| 312 |
|
|
|
|
| 313 |
stft = np.abs(librosa.stft(y))
|
| 314 |
freq_bins = librosa.fft_frequencies(sr=sr)
|
| 315 |
|
|
|
|
| 316 |
low_freq_energy = np.sum(stft[freq_bins < 2000, :])
|
| 317 |
high_freq_energy = np.sum(stft[freq_bins > 4000, :])
|
|
|
|
| 318 |
total_energy = np.sum(stft) + 1e-10
|
| 319 |
|
| 320 |
low_freq_ratio = low_freq_energy / total_energy
|
| 321 |
high_freq_ratio = high_freq_energy / total_energy
|
|
|
|
| 322 |
|
| 323 |
+
if low_freq_ratio < 0.4:
|
| 324 |
+
return False, breathing_confidence, "Frequency distribution doesn't match sleep audio"
|
|
|
|
| 325 |
|
| 326 |
+
if high_freq_ratio > 0.3:
|
| 327 |
+
return False, breathing_confidence, "Too much high-frequency content (speech/TV detected)"
|
|
|
|
| 328 |
|
| 329 |
+
if breathing_confidence >= 0.3:
|
| 330 |
+
return True, breathing_confidence, "Valid sleep recording detected"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
else:
|
| 332 |
+
return False, breathing_confidence, "Insufficient breathing patterns detected"
|
| 333 |
|
| 334 |
# --- API ENDPOINTS ---
|
| 335 |
|
|
|
|
| 341 |
"model_loaded": finetuned_snore_model is not None,
|
| 342 |
"model_type": "Fine-tuned CNN with Kaggle preprocessing",
|
| 343 |
"model_accuracy": "95.33%",
|
| 344 |
+
"model_auc": "99.43%",
|
| 345 |
+
"preprocessing": "Exact match to Kaggle cleaning pipeline"
|
| 346 |
}
|
| 347 |
|
| 348 |
@app.post("/analyze")
|
| 349 |
async def analyze_audio(file: UploadFile = File(...)):
|
| 350 |
+
"""Analyze audio using Kaggle preprocessing method"""
|
| 351 |
|
| 352 |
+
# Check if model is loaded
|
| 353 |
if finetuned_snore_model is None:
|
| 354 |
raise HTTPException(
|
| 355 |
status_code=503,
|
|
|
|
| 358 |
|
| 359 |
temp_path = f"temp_{os.getpid()}_{file.filename}"
|
| 360 |
|
| 361 |
+
# Save uploaded file
|
| 362 |
with open(temp_path, "wb") as buffer:
|
| 363 |
shutil.copyfileobj(file.file, buffer)
|
| 364 |
|
| 365 |
try:
|
| 366 |
+
# 1. LOAD: Use original sample rate, will resample per segment
|
| 367 |
print(f"\n๐ Loading audio: {file.filename}")
|
| 368 |
y, sr = librosa.load(temp_path, sr=None, dtype=np.float32)
|
| 369 |
+
print(f" Original sample rate: {sr} Hz")
|
| 370 |
+
print(f" Duration: {len(y)/sr:.1f}s")
|
| 371 |
|
| 372 |
+
# 2. VALIDATE: Check if valid sleep recording
|
| 373 |
+
print("\n๐ Validating sleep recording...")
|
| 374 |
+
is_valid_recording, breathing_confidence, validation_reason = validate_sleep_recording(y, sr)
|
| 375 |
|
| 376 |
+
print(f" Validation: {'โ
PASS' if is_valid_recording else 'โ ๏ธ WARNING'}")
|
| 377 |
+
print(f" Breathing confidence: {breathing_confidence*100:.1f}%")
|
| 378 |
+
print(f" Reason: {validation_reason}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
|
| 380 |
+
validation_warning = None if is_valid_recording else validation_reason
|
|
|
|
| 381 |
|
| 382 |
+
# 3. CLEAN: Apply noise reduction
|
| 383 |
+
print("\n๐งน Applying noise reduction...")
|
| 384 |
y_clean = nr.reduce_noise(y=y, sr=sr, stationary=True)
|
| 385 |
|
| 386 |
+
# 4. ANALYZE: Use EXACT Kaggle method
|
| 387 |
+
print(f"\n๐ฏ Analyzing with fine-tuned model (Kaggle method)...")
|
| 388 |
segment_results = analyze_audio_segments(y_clean, sr, finetuned_snore_model, segment_duration=10)
|
| 389 |
|
| 390 |
+
# 5. COUNT SNORING
|
| 391 |
snore_count = sum(1 for seg in segment_results if seg['is_snoring'])
|
| 392 |
total_segments = len(segment_results)
|
| 393 |
|
| 394 |
+
print(f"\n๐ Results:")
|
| 395 |
print(f" Total segments: {total_segments}")
|
| 396 |
print(f" Snoring segments: {snore_count}")
|
| 397 |
print(f" Snoring percentage: {(snore_count/total_segments)*100:.1f}%")
|
|
|
|
| 400 |
annotations = []
|
| 401 |
apnea_count = 0
|
| 402 |
|
| 403 |
+
# Add snoring annotations
|
| 404 |
for seg in segment_results:
|
| 405 |
if seg['is_snoring']:
|
| 406 |
annotations.append({
|
|
|
|
| 412 |
"confidence": round(seg['confidence'], 4)
|
| 413 |
})
|
| 414 |
|
| 415 |
+
# Detect apnea (gaps between snoring)
|
| 416 |
for i in range(len(segment_results) - 1):
|
| 417 |
current = segment_results[i]
|
| 418 |
next_seg = segment_results[i + 1]
|
| 419 |
|
| 420 |
+
# If current is snoring and next is not, check gap
|
| 421 |
if current['is_snoring'] and not next_seg['is_snoring']:
|
| 422 |
+
gap_start = current['end_time']
|
| 423 |
+
# Find next snoring segment
|
| 424 |
for j in range(i + 1, len(segment_results)):
|
| 425 |
if segment_results[j]['is_snoring']:
|
| 426 |
+
gap_end = segment_results[j]['start_time']
|
| 427 |
+
gap_duration = gap_end - gap_start
|
| 428 |
|
| 429 |
+
# Apnea = 10-120 second gaps
|
| 430 |
if 10.0 <= gap_duration <= 120.0:
|
| 431 |
apnea_count += 1
|
| 432 |
risk = "LOW" if gap_duration < 15.0 else ("MEDIUM" if gap_duration < 20.0 else "HIGH")
|
| 433 |
annotations.append({
|
| 434 |
"label": "APNEA",
|
| 435 |
+
"start_sec": round(gap_start, 2),
|
| 436 |
+
"end_sec": round(gap_end, 2),
|
| 437 |
"duration": round(gap_duration, 2),
|
| 438 |
"risk_level": risk
|
| 439 |
})
|
| 440 |
+
print(f" โ ๏ธ Apnea detected: {gap_duration:.1f}s gap")
|
| 441 |
break
|
| 442 |
|
| 443 |
+
# 7. Calculate statistics
|
| 444 |
total_duration = segment_results[-1]['end_time']
|
| 445 |
duration_hours = total_duration / 3600
|
| 446 |
ahi = apnea_count / duration_hours if duration_hours > 0 else 0
|
| 447 |
|
| 448 |
snoring_percentage = (snore_count / total_segments) * 100
|
| 449 |
|
| 450 |
+
risk_summary = "NORMAL"
|
| 451 |
+
if snoring_percentage > 50:
|
| 452 |
risk_summary = "HIGH"
|
| 453 |
+
elif snoring_percentage > 30:
|
| 454 |
risk_summary = "MEDIUM"
|
| 455 |
+
elif snoring_percentage > 15:
|
| 456 |
risk_summary = "LOW"
|
|
|
|
|
|
|
| 457 |
|
| 458 |
# Cleanup
|
| 459 |
del y, y_clean
|
|
|
|
| 461 |
|
| 462 |
return {
|
| 463 |
"valid_recording": True,
|
| 464 |
+
"validation_warning": validation_warning,
|
| 465 |
+
"breathing_confidence": round(breathing_confidence, 4),
|
| 466 |
"snore_count": snore_count,
|
| 467 |
"total_segments": total_segments,
|
| 468 |
"snoring_percentage": round(snoring_percentage, 2),
|
|
|
|
| 470 |
"overall_risk": risk_summary,
|
| 471 |
"ahi_score": round(ahi, 1),
|
| 472 |
"model_used": "Fine-tuned CNN (Kaggle method)",
|
| 473 |
+
"model_accuracy": "95.33%",
|
| 474 |
"events": annotations
|
| 475 |
}
|
| 476 |
|
|
|
|
|
|
|
| 477 |
except Exception as e:
|
| 478 |
print(f"๐ฅ ERROR: {str(e)}")
|
| 479 |
import traceback
|