alecomma/Logos
0
1#!/usr/bin/env python32# -*- coding: utf-8 -*-3 4import os5import json6import tempfile7 8import numpy as np9import parselmouth10from parselmouth.praat import call11import librosa12 13from flask import Flask, request, render_template_string14 15app = Flask(__name__)16 17# ==========================18# FUNZIONE FEATURE19# ==========================20 21def estrai_feature(path_audio: str) -> dict:22 """23 Estrae un set di feature acustiche da un file audio.24 Richiede: praat-parselmouth, librosa, numpy25 """26 27 if not os.path.exists(path_audio):28 raise FileNotFoundError(f"File audio non trovato: {path_audio}")29 30 # Prova a caricare con Praat31 try:32 snd = parselmouth.Sound(path_audio)33 except Exception as e:34 msg = (35 f"Il file esiste ma Praat/parselmouth non riesce a leggerlo. "36 f"Probabilmente NON è un WAV PCM (magari un m4a/mp3 rinominato). "37 f"Esporta come WAV 16-bit PCM mono e riprova. "38 f"Dettagli tecnici: {e}"39 )40 raise RuntimeError(msg)41 42 duration = snd.duration # durata in secondi43 44 # Caricamento per librosa (MFCC, spettro, pause)45 y, sr = librosa.load(path_audio, sr=None, mono=True)46 47 features = {}48 49 # ==========================50 # 1) MFCC (librosa)51 # ==========================52 mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)53 mfcc_mean = mfcc.mean(axis=1) # media per coefficiente54 55 for i, val in enumerate(mfcc_mean):56 features[f"mfcc_{i+1}_mean"] = float(val)57 58 # ==========================59 # 2) PITCH (F0)60 # ==========================61 pitch = snd.to_pitch()62 f0_mean = call(pitch, "Get mean", 0, 0, "Hertz")63 f0_stdev = call(pitch, "Get standard deviation", 0, 0, "Hertz")64 f0_min = call(pitch, "Get minimum", 0, 0, "Hertz", "Parabolic")65 f0_max = call(pitch, "Get maximum", 0, 0, "Hertz", "Parabolic")66 67 features["f0_mean_Hz"] = float(f0_mean)68 features["f0_stdev_Hz"] = float(f0_stdev)69 features["f0_min_Hz"] = float(f0_min)70 features["f0_max_Hz"] = float(f0_max)71 features["f0_range_Hz"] = float(f0_max - f0_min)72 73 # ==========================74 # 3) INTENSITÀ75 # ==========================76 intensity = snd.to_intensity()77 int_mean = call(intensity, "Get mean", 0, 0)78 int_stdev = call(intensity, "Get standard deviation", 0, 0)79 80 features["intensity_mean_dB"] = float(int_mean)81 features["intensity_stdev_dB"] = float(int_stdev)82 83 # ==========================84 # 4) JITTER & SHIMMER85 # ==========================86 try:87 point_process = call(snd, "To PointProcess (periodic, cc)", 75, 500)88 89 jitter_local = call(90 point_process,91 "Get jitter (local)",92 0.0, # tmin93 0.0, # tmax (0 = fino alla fine)94 0.0001, # periodo minimo95 0.02, # periodo massimo96 1.3 # max period factor97 )98 99 shimmer_local = call(100 [snd, point_process],101 "Get shimmer (local)",102 0.0,103 0.0,104 0.0001,105 0.02,106 1.3,107 1.6108 )109 110 features["jitter_local"] = float(jitter_local)111 features["shimmer_local"] = float(shimmer_local)112 113 except Exception as e:114 features["jitter_local"] = float("nan")115 features["shimmer_local"] = float("nan")116 features["jitter_shimmer_error"] = str(e)117 118 # ==========================119 # 5) HNR120 # ==========================121 harmonicity = call(snd, "To Harmonicity (cc)", 0.01, 75, 0.1, 1.0)122 hnr_mean = call(harmonicity, "Get mean", 0, 0)123 features["hnr_mean_dB"] = float(hnr_mean)124 125 # ==========================126 # 6) FEATURE SPETTRALI127 # ==========================128 spectral_centroid = librosa.feature.spectral_centroid(y=y, sr=sr).mean()129 spectral_rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr, roll_percent=0.85).mean()130 spectral_flatness = librosa.feature.spectral_flatness(y=y).mean()131 132 features["spectral_centroid_Hz"] = float(spectral_centroid)133 features["spectral_rolloff_Hz"] = float(spectral_rolloff)134 features["spectral_flatness"] = float(spectral_flatness)135 136 # ==========================137 # 7) PAUSE / SILENZI138 # ==========================139 rms = librosa.feature.rms(y=y)[0] # energia per frame140 frame_duration = 512 / sr # hop_length default = 512141 142 thr = 0.03 * float(rms.max()) if rms.max() > 0 else 0.0143 silent_flags = rms < thr144 145 MIN_PAUSE_DUR = 0.2 # secondi146 147 total_silence = 0.0148 num_pauses = 0149 current_silence_frames = 0150 151 for s in silent_flags:152 if s:153 current_silence_frames += 1154 else:155 if current_silence_frames > 0:156 dur = current_silence_frames * frame_duration157 if dur >= MIN_PAUSE_DUR:158 num_pauses += 1159 total_silence += dur160 current_silence_frames = 0161 162 if current_silence_frames > 0:163 dur = current_silence_frames * frame_duration164 if dur >= MIN_PAUSE_DUR:165 num_pauses += 1166 total_silence += dur167 168 silence_ratio = total_silence / duration if duration > 0 else float("nan")169 mean_pause_dur = total_silence / num_pauses if num_pauses > 0 else 0.0170 171 features["num_pauses"] = int(num_pauses)172 features["total_silence_s"] = float(total_silence)173 features["silence_ratio"] = float(silence_ratio)174 175 speaking_time = max(duration - total_silence, 0.0)176 speaking_ratio = speaking_time / duration if duration > 0 else float("nan")177 178 features["mean_pause_duration_s"] = float(mean_pause_dur)179 features["speaking_time_s"] = float(speaking_time)180 features["speaking_ratio"] = float(speaking_ratio)181 182 # ==========================183 # 8) INDICI COMPOSITI184 # ==========================185 prosody_range_index = (features["f0_range_Hz"] + features["intensity_stdev_dB"]) / 2.0186 features["prosody_range_index"] = float(prosody_range_index)187 188 flattening_index = 1.0 / (1.0 + features["f0_stdev_Hz"] + abs(features["intensity_stdev_dB"]))189 features["flattening_index"] = float(flattening_index)190 191 # ==========================192 # INFO DI BASE193 # ==========================194 features["duration_s"] = float(duration)195 features["sr_Hz"] = int(sr)196 197 return features198 199 200# ==========================201# HTML TEMPLATE CANVA-STYLE + "by AC"202# ==========================203 204TEMPLATE = """205<!doctype html>206<html lang="it">207<head>208 <meta charset="utf-8">209 <title>Analisi vocale psichiatria · by AC</title>210 <link rel="preconnect" href="https://fonts.googleapis.com">211 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>212 <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;600;700&display=swap" rel="stylesheet">213 <style>214 * {215 box-sizing: border-box;216 }217 218 body {219 margin: 0;220 min-height: 100vh;221 font-family: 'Nunito', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;222 background: radial-gradient(circle at 0% 0%, #fdf2ff 0, #e0f2fe 35%, #fef3c7 70%, #f5f3ff 100%);223 display: flex;224 align-items: center;225 justify-content: center;226 padding: 24px;227 color: #111827;228 }229 230 .shell {231 width: 100%;232 max-width: 1150px;233 background: rgba(255, 255, 255, 0.9);234 border-radius: 28px;235 box-shadow:236 0 18px 45px rgba(148, 163, 184, 0.4),237 0 0 0 1px rgba(255, 255, 255, 0.9);238 padding: 26px 26px 18px;239 position: relative;240 overflow: hidden;241 backdrop-filter: blur(14px);242 }243 244 .shell::before,245 .shell::after {246 content: "";247 position: absolute;248 width: 260px;249 height: 260px;250 border-radius: 999px;251 filter: blur(40px);252 opacity: 0.6;253 pointer-events: none;254 z-index: 0;255 }256 257 .shell::before {258 background: linear-gradient(135deg, #a855f7, #f97316);259 top: -90px;260 right: -50px;261 }262 263 .shell::after {264 background: linear-gradient(135deg, #22c55e, #3b82f6);265 bottom: -110px;266 left: -40px;267 }268 269 .content {270 position: relative;271 z-index: 1;272 }273 274 .header {275 display: flex;276 justify-content: space-between;277 gap: 16px;278 margin-bottom: 18px;279 align-items: center;280 }281 282 .title-block h1 {283 margin: 6px 0 0;284 font-size: 1.9rem;285 letter-spacing: 0.03em;286 display: flex;287 align-items: center;288 gap: 8px;289 flex-wrap: wrap;290 }291 292 .logo-circle {293 width: 30px;294 height: 30px;295 border-radius: 999px;296 background: conic-gradient(from 180deg, #6366f1, #f97316, #ec4899, #22c55e, #6366f1);297 display: flex;298 align-items: center;299 justify-content: center;300 color: white;301 font-size: 0.9rem;302 box-shadow: 0 6px 14px rgba(79, 70, 229, 0.7);303 }304 305 .title-pill {306 font-size: 0.75rem;307 text-transform: uppercase;308 letter-spacing: 0.16em;309 background: rgba(236, 72, 153, 0.07);310 color: #db2777;311 padding: 4px 11px;312 border-radius: 999px;313 border: 1px solid rgba(236, 72, 153, 0.4);314 display: inline-flex;315 align-items: center;316 gap: 6px;317 }318 319 .title-pill span.dot {320 width: 6px;321 height: 6px;322 border-radius: 999px;323 background: #ec4899;324 }325 326 .subtitle {327 margin-top: 6px;328 font-size: 0.9rem;329 color: #4b5563;330 }331 332 .badge-group {333 display: flex;334 flex-direction: column;335 align-items: flex-end;336 gap: 6px;337 font-size: 0.78rem;338 color: #6b7280;339 }340 341 .pill {342 padding: 4px 10px;343 border-radius: 999px;344 background: linear-gradient(135deg, rgba(129, 140, 248, 0.16), rgba(56, 189, 248, 0.16));345 color: #1d4ed8;346 border: 1px solid rgba(129, 140, 248, 0.7);347 display: inline-flex;348 align-items: center;349 gap: 6px;350 }351 352 .status-dot {353 width: 7px;354 height: 7px;355 border-radius: 999px;356 background: #22c55e;357 box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.25);358 }359 360 .status-text {361 display: inline-flex;362 align-items: center;363 gap: 6px;364 padding: 3px 9px;365 border-radius: 999px;366 background: rgba(15, 23, 42, 0.03);367 }368 369 .signature {370 font-size: 0.95rem;371 color: #6366f1;372 font-weight: 700;373 margin-left: 6px;374 display: inline-flex;375 align-items: center;376 gap: 4px;377 }378 379 .signature span.dot {380 width: 6px;381 height: 6px;382 border-radius: 999px;383 background: #6366f1;384 }385 386 .layout {387 display: grid;388 grid-template-columns: minmax(0, 1.05fr) minmax(0, 1.15fr);389 gap: 18px;390 }391 392 @media (max-width: 880px) {393 .layout {394 grid-template-columns: 1fr;395 }396 .badge-group {397 align-items: flex-start;398 }399 }400 401 .card {402 border-radius: 22px;403 border: 1px solid rgba(148, 163, 184, 0.5);404 padding: 16px 16px 14px;405 background: rgba(255, 255, 255, 0.9);406 position: relative;407 overflow: hidden;408 }409 410 .card::before {411 content: "";412 position: absolute;413 inset: 0;414 background: radial-gradient(circle at top left, rgba(129, 140, 248, 0.14), transparent 55%);415 opacity: 1;416 pointer-events: none;417 }418 419 .card-inner {420 position: relative;421 z-index: 1;422 }423 424 .card h2 {425 font-size: 1.02rem;426 margin: 0 0 6px;427 display: flex;428 align-items: center;429 gap: 8px;430 }431 432 .card h2 span.icon {433 font-size: 1.3rem;434 }435 436 .chip {437 font-size: 0.72rem;438 padding: 3px 8px;439 border-radius: 999px;440 background: rgba(248, 250, 252, 0.9);441 border: 1px solid rgba(148, 163, 184, 0.7);442 margin-left: auto;443 }444 445 .card-header-row {446 display: flex;447 align-items: center;448 gap: 8px;449 }450 451 .card-desc {452 margin: 3px 0 12px;453 font-size: 0.84rem;454 color: #6b7280;455 }456 457 label {458 font-size: 0.85rem;459 font-weight: 600;460 color: #374151;461 }462 463 input[type="file"] {464 margin-top: 8px;465 font-size: 0.84rem;466 }467 468 .btn {469 margin-top: 14px;470 padding: 9px 18px;471 border-radius: 999px;472 border: none;473 background: linear-gradient(135deg, #6366f1, #ec4899);474 color: white;475 cursor: pointer;476 font-size: 0.9rem;477 font-weight: 600;478 display: inline-flex;479 align-items: center;480 gap: 8px;481 box-shadow:482 0 10px 25px rgba(79, 70, 229, 0.55),483 0 0 0 1px rgba(255, 255, 255, 0.6);484 transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease;485 }486 487 .btn span.icon {488 font-size: 1rem;489 }490 491 .btn:hover {492 filter: brightness(1.05);493 transform: translateY(-1px);494 box-shadow:495 0 12px 28px rgba(79, 70, 229, 0.7),496 0 0 0 1px rgba(255, 255, 255, 0.9);497 }498 499 .btn:active {500 transform: translateY(0);501 box-shadow:502 0 8px 18px rgba(79, 70, 229, 0.8),503 0 0 0 1px rgba(255, 255, 255, 0.9);504 }505 506 .error {507 margin-top: 10px;508 font-size: 0.8rem;509 color: #b91c1c;510 background: #fef2f2;511 border-radius: 12px;512 padding: 8px 10px;513 border: 1px solid #fecaca;514 }515 516 table {517 border-collapse: collapse;518 width: 100%;519 font-size: 0.78rem;520 background: #ffffff;521 border-radius: 14px;522 overflow: hidden;523 border: 1px solid rgba(209, 213, 219, 0.9);524 }525 526 th, td {527 padding: 6px 8px;528 border-bottom: 1px solid #e5e7eb;529 }530 531 th {532 background: linear-gradient(135deg, #eef2ff, #e0f2fe);533 text-align: left;534 font-weight: 700;535 color: #374151;536 }537 538 tr:nth-child(even) td {539 background: #f9fafb;540 }541 542 tr:last-child td {543 border-bottom: none;544 }545 546 .feature-key {547 white-space: nowrap;548 color: #4b5563;549 font-weight: 600;550 }551 552 .feature-val {553 font-family: "SF Mono", ui-monospace, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;554 color: #111827;555 }556 557 pre {558 margin: 0;559 background: #020617;560 color: #e5e7eb;561 padding: 10px 12px;562 border-radius: 14px;563 font-size: 0.74rem;564 max-height: 360px;565 overflow: auto;566 border: 1px solid rgba(15, 23, 42, 0.9);567 }568 569 .json-card {570 margin-top: 16px;571 border-radius: 22px;572 border: 1px solid rgba(15, 23, 42, 0.9);573 background: radial-gradient(circle at 0% 0%, #0f172a 0, #020617 45%, #111827 100%);574 padding: 14px 16px;575 color: #e5e7eb;576 }577 578 .json-title-row {579 display: flex;580 align-items: center;581 justify-content: space-between;582 margin-bottom: 8px;583 font-size: 0.8rem;584 color: #9ca3af;585 }586 587 .json-pill {588 padding: 3px 9px;589 border-radius: 999px;590 border: 1px solid rgba(148, 163, 184, 0.9);591 background: rgba(15, 23, 42, 0.3);592 font-size: 0.7rem;593 }594 595 .footer {596 margin-top: 10px;597 text-align: right;598 font-size: 0.8rem;599 color: #9ca3af;600 }601 602 .footer span.brand {603 font-weight: 700;604 color: #6366f1;605 }606 607 /* Watermark by AC stile Canva */608 .watermark {609 position: fixed;610 bottom: 14px;611 left: 14px;612 font-size: 0.8rem;613 opacity: 0.55;614 color: #6366f1;615 font-weight: 700;616 display: inline-flex;617 align-items: center;618 gap: 4px;619 }620 621 .watermark span.dot {622 width: 6px;623 height: 6px;624 border-radius: 999px;625 background: #ec4899;626 }627 </style>628</head>629<body>630 <div class="shell">631 <div class="content">632 <div class="header">633 <div class="title-block">634 <div class="title-pill">635 <span class="dot"></span>636 VOCE e PSICHIATRIA637 </div>638 <h1>639 <div class="logo-circle">V</div>640 LOGOS: analisi vocale clinica641 642 643 <span class="signature">644 <span class="dot"></span>645 by AC646 </span>647 </h1>648 <p class="subtitle">649 Carica un file <strong>WAV (16-bit PCM, mono)</strong> per estrarre feature acustiche650 su prosodia, pause, jitter/shimmer e parametri spettrali, pronte per l'analisi statistica.651 </p>652 </div>653 <div class="badge-group">654 <div class="pill">655 <span>🎓 Tesi sperimentale</span>656 <span>·</span>657 <span>by AC</span>658 </div>659 <div class="status-text">660 <span class="status-dot"></span>661 <span>662 {{ 'Risultati caricati · pronto per interpretazione' if features else 'In attesa di un file audio' }}663 </span>664 </div>665 </div>666 </div>667 668 <div class="layout">669 <div class="card">670 <div class="card-inner">671 <div class="card-header-row">672 <h2><span class="icon">🎙️</span>Carica audio</h2>673 <div class="chip">Input .wav</div>674 </div>675 <p class="card-desc">676 Seleziona il file del colloquio / lettura. Ideale: voce chiara, senza rumori677 di fondo importanti. I parametri vengono calcolati localmente sul tuo computer.678 </p>679 <form method="post" enctype="multipart/form-data">680 <label for="audio_file">File audio (.wav)</label><br>681 <input type="file" id="audio_file" name="audio_file" accept=".wav" required>682 <br>683 <button type="submit" class="btn">684 <span class="icon">✨</span>685 Analizza file686 </button>687 </form>688 {% if error %}689 <p class="error">{{ error }}</p>690 {% endif %}691 </div>692 </div>693 694 <div class="card">695 <div class="card-inner">696 <div class="card-header-row">697 <h2><span class="icon">📊</span>Risultati sintetici</h2>698 <div class="chip">Feature numeriche</div>699 </div>700 <p class="card-desc">701 Valori numerici pronti per essere copiati in un foglio dati.702 Ogni riga corrisponde a una feature acustica del file caricato.703 </p>704 705 {% if features %}706 <table>707 <tr><th>Feature</th><th>Valore</th></tr>708 {% for k, v in features.items() %}709 <tr>710 <td class="feature-key">{{ k }}</td>711 <td class="feature-val">{{ v }}</td>712 </tr>713 {% endfor %}714 </table>715 {% else %}716 <p style="font-size:0.8rem; color:#6b7280; margin-top:4px;">717 Nessun risultato ancora. Carica un file sulla sinistra per iniziare.718 </p>719 {% endif %}720 </div>721 </div>722 </div>723 724 {% if features %}725 <div class="json-card">726 <div class="json-title-row">727 <span>JSON completo delle feature estratte</span>728 <span class="json-pill">Copia-incolla diretto in script · Python / R</span>729 </div>730 <pre>{{ features_json }}</pre>731 </div>732 {% endif %}733 734 <div class="footer">735 by <span class="brand">AC</span>736 </div>737 </div>738 </div>739 740 <div class="watermark">741 <span class="dot"></span>742 by AC743 </div>744</body>745</html>746"""747 748# ==========================749# ROUTE WEB750# ==========================751 752@app.route("/", methods=["GET", "POST"])753def index():754 features = None755 features_json = None756 error = None757 758 if request.method == "POST":759 if "audio_file" not in request.files:760 error = "Nessun file ricevuto."761 else:762 file = request.files["audio_file"]763 if file.filename == "":764 error = "Nessun file selezionato."765 else:766 # Salva il file in un temporaneo767 with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:768 file.save(tmp.name)769 tmp_path = tmp.name770 771 try:772 feat = estrai_feature(tmp_path)773 # ordina le feature per chiave774 features = dict(sorted(feat.items(), key=lambda x: x[0]))775 features_json = json.dumps(features, indent=2, ensure_ascii=False)776 except Exception as e:777 error = str(e)778 finally:779 # ripulisci il file temporaneo780 if os.path.exists(tmp_path):781 os.remove(tmp_path)782 783 return render_template_string(784 TEMPLATE,785 features=features,786 features_json=features_json,787 error=error,788 )789 790 791if __name__ == "__main__":792 app.run(debug=True)793 