Spaces:
Running
Running
| """ | |
| Speech Analysis API โ FastAPI backend for Hugging Face Spaces deployment. | |
| Exposes: | |
| - POST /evaluate โ score a single pronunciation recording | |
| - POST /diagnose_profile โ generate a clinical diagnostic report via GPT-4o | |
| """ | |
| import json | |
| import os | |
| import io | |
| import sys | |
| import uuid | |
| import time | |
| import tempfile | |
| import threading | |
| from contextlib import asynccontextmanager | |
| from fastapi import FastAPI, File, Form, UploadFile, HTTPException, Header | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel, Field | |
| from pydub import AudioSegment | |
| from openai import OpenAI, APITimeoutError, APIConnectionError | |
| from score_engine import score_pronunciation | |
| from phoneme_extractor import warmup | |
| from data_store import ( | |
| save_evaluation, | |
| extract_user_id, | |
| get_user_dashboard, | |
| get_user_progress, | |
| save_user_progress, | |
| _get_client as _init_supabase_client, | |
| ) | |
| # ============================================================================= | |
| # GPT-4o Structured Output โ Clinical Report Schema | |
| # ============================================================================= | |
| class ClinicalReport(BaseModel): | |
| """Structured clinical diagnostic report for a child's speech profile.""" | |
| clinical_hypothesis: str = Field( | |
| description="A deeply empathetic, professional explanation in Hebrew " | |
| "of the root issue behind the child's pronunciation pattern." | |
| ) | |
| requires_clinic_intervention: bool = Field( | |
| description="Whether the child needs in-person therapy with a " | |
| "speech-language pathologist, or can improve with home practice." | |
| ) | |
| intervention_reason: str = Field( | |
| description="Clear explanation in Hebrew of why the child does or " | |
| "does not need external clinical therapy." | |
| ) | |
| estimated_duration_weeks: int = Field( | |
| description="Estimated number of weeks to reach age-appropriate " | |
| "pronunciation based on standard SLP progress curves." | |
| ) | |
| offline_exercises: list[str] = Field( | |
| description="Exactly 3 specific, highly actionable offline games or " | |
| "exercises the parents can do at home with the child." | |
| ) | |
| app_roadmap_summary: str = Field( | |
| description="How the app will adapt its difficulty levels and word " | |
| "selection to guide this specific child's improvement." | |
| ) | |
| VALID_WORDS = { | |
| # ืฉ (Shin) words | |
| "shalom", "shemesh", "shir", "shuk", "geshem", "shshshsh", "shaon", "shulchan", | |
| "shin", "shofar", "mishkafayim", "shatiach", "sharsheret", "galshan", "deshe", "yanshuf", "hipushit", | |
| "mashrokit", "karish", "marshmelo", "machshev", "dvash", "shtayim", "nachash", "sheva", "mivreshet_shinayim", | |
| # ืง (Kuf) words โ competitive alignment (K vs T substitution) | |
| "kof", "kir", "kubiya", "kalmar", "kova", "kadur", "kelev", "mishkafayim_kuf", | |
| "akavish", "sakin", "mashrokit_kuf", | |
| # CV syllables (sh-X / k-X) โ bridge between isolated sounds and full words | |
| "sh_syllable_sha", "sh_syllable_she", "sh_syllable_shi", "sh_syllable_shu", | |
| "k_syllable_ka", "k_syllable_ke", "k_syllable_ki", "k_syllable_ku", | |
| # Isolated sounds โ for children who struggle with full words | |
| "k_sound", "t_sound", "sh_sound", | |
| } | |
| # How often the background thread re-runs a tiny LOCAL inference to keep the | |
| # Wav2Vec2 model hot. The server is always-on, but after a day idle the model | |
| # path goes cold (memory paged out / CPU throttled) and the first real request | |
| # paid ~30s. /warmup never runs the model, so it can't prevent this. 240s keeps | |
| # the model warm with negligible cost. Override via env if needed. | |
| SELF_WARM_INTERVAL_SEC = int(os.environ.get("SELF_WARM_INTERVAL_SEC", "240")) | |
| async def lifespan(app: FastAPI): | |
| """Pre-load Wav2Vec2 model AND Supabase client before accepting requests. | |
| Without the eager Supabase init, the first /evaluate paid 5-15s on the | |
| service-role client's import + TLS handshake (logged as | |
| "[DATA] Supabase client initialized" on the first request only). | |
| """ | |
| warmup() | |
| # Eagerly initialise the Supabase service-role client so the first | |
| # /evaluate doesn't pay the supabase-py import + TLS handshake cost. | |
| try: | |
| _init_supabase_client() | |
| except Exception as e: | |
| # Don't block server startup โ if Supabase is misconfigured the | |
| # endpoints will still surface the error per-request as before. | |
| print(f"[WARMUP] Supabase eager init skipped: {e}") | |
| # Force a dummy /evaluate-style scoring pass so any lazy librosa / | |
| # numpy / DTW code paths inside score_pronunciation are materialised | |
| # before the first real child recording arrives. | |
| try: | |
| import numpy as np | |
| import soundfile as sf | |
| dummy_wav = os.path.join(tempfile.gettempdir(), "warmup_dummy.wav") | |
| # 0.5s of low-amplitude pink-ish noise โ silent enough to be cheap, | |
| # loud enough that the engine doesn't abort on an empty-signal path. | |
| rng = np.random.default_rng(0) | |
| signal = (rng.standard_normal(8000) * 0.01).astype("float32") | |
| sf.write(dummy_wav, signal, 16000) | |
| try: | |
| score_pronunciation(dummy_wav, "k_sound") | |
| print("[WARMUP] score_pronunciation dummy pass OK") | |
| except Exception as e: | |
| print(f"[WARMUP] dummy scoring pass raised (non-fatal): {e}") | |
| finally: | |
| if os.path.exists(dummy_wav): | |
| os.remove(dummy_wav) | |
| except Exception as e: | |
| print(f"[WARMUP] dummy scoring setup failed: {e}") | |
| # Background self-warm: keep the local Wav2Vec2 model HOT 24/7. The server is | |
| # always-on (paid, never-sleep), but after a day idle the model path goes cold | |
| # and the first real request paid ~30s. This daemon thread runs a tiny LOCAL | |
| # inference every few minutes โ Wav2Vec2 forward pass only, NO STT API calls | |
| # (those cost money) โ so the model never goes cold, independent of the app. | |
| def _self_warm_loop(): | |
| import time as _time | |
| import numpy as np | |
| import soundfile as sf | |
| warm_wav = os.path.join(tempfile.gettempdir(), "selfwarm.wav") | |
| try: | |
| rng = np.random.default_rng(1) | |
| sf.write(warm_wav, (rng.standard_normal(8000) * 0.01).astype("float32"), 16000) | |
| except Exception as e: | |
| print(f"[SELF-WARM] setup failed, warmer not started: {e}") | |
| return | |
| from phoneme_extractor import shin_vs_samekh | |
| while True: | |
| _time.sleep(SELF_WARM_INTERVAL_SEC) | |
| try: | |
| shin_vs_samekh(warm_wav) # local Wav2Vec2 forward pass, no network | |
| print("[SELF-WARM] model kept hot") | |
| except Exception as e: | |
| print(f"[SELF-WARM] tick failed: {e}") | |
| threading.Thread(target=_self_warm_loop, daemon=True).start() | |
| print(f"[SELF-WARM] background warmer started (every {SELF_WARM_INTERVAL_SEC}s)") | |
| yield | |
| app = FastAPI(title="Speech Analysis API", version="1.0.0", lifespan=lifespan) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ============================================================================= | |
| # Warm-up endpoints โ used by the Unity client to keep the TLS connection | |
| # alive and prove the Space is ready, without paying the cost of /evaluate. | |
| # ============================================================================= | |
| async def root(): | |
| """Root endpoint โ replaces the 404 the warming pings used to hit.""" | |
| return {"ok": True, "service": "speechkid-api"} | |
| async def warmup_endpoint(): | |
| """ | |
| Cheap POST that establishes / refreshes the TLS connection on the same | |
| code path as /evaluate (POST + JSON body), without running scoring. | |
| The Unity client pings this at boot and then every ~30s so the first | |
| real /evaluate doesn't pay the TLS handshake cost. | |
| """ | |
| return {"ok": True, "warm": True} | |
| def convert_to_wav(input_path: str, output_path: str) -> None: | |
| """Convert any audio format (m4a, aac, caf, webm, etc.) to 16kHz mono WAV.""" | |
| audio = AudioSegment.from_file(input_path) | |
| audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2) | |
| audio.export(output_path, format="wav") | |
| async def evaluate( | |
| file: UploadFile = File(...), | |
| word: str = Form(...), | |
| x_supabase_token: str = Header(default=None), | |
| ): | |
| """ | |
| Evaluate a child's pronunciation of a Hebrew word. | |
| Supports two phoneme types: | |
| - **ืฉ (Shin) words**: shalom, shemesh, shir, shuk, geshem, shshshsh, shaon, shulchan | |
| - **ืง (Kuf) words**: kof, kir, kubiya, kalmar (detects T-for-K substitution) | |
| - **file**: Audio recording (WAV, M4A, AAC, CAF, WebM, etc.). | |
| - **word**: Target word key. | |
| Returns the full scoring result including diagnosis and feedback. | |
| """ | |
| if word not in VALID_WORDS: | |
| raise HTTPException( | |
| status_code=422, | |
| detail=f"Unknown word '{word}'. Must be one of: {', '.join(sorted(VALID_WORDS))}", | |
| ) | |
| # Verify Supabase auth token and extract user_id | |
| user_id = extract_user_id(x_supabase_token) | |
| evaluation_id = str(uuid.uuid4()) | |
| uid = evaluation_id.replace("-", "") | |
| suffix = os.path.splitext(file.filename or "recording.bin")[1] or ".bin" | |
| raw_path = os.path.join(tempfile.gettempdir(), f"{uid}_raw{suffix}") | |
| wav_path = os.path.join(tempfile.gettempdir(), f"{uid}.wav") | |
| try: | |
| # Save the uploaded file with its original extension | |
| contents = await file.read() | |
| with open(raw_path, "wb") as f: | |
| f.write(contents) | |
| # Convert to 16kHz mono WAV (handles m4a, aac, caf, webm, ogg, etc.) | |
| convert_to_wav(raw_path, wav_path) | |
| # Capture server logs during scoring | |
| log_buffer = io.StringIO() | |
| old_stdout = sys.stdout | |
| sys.stdout = log_buffer | |
| start_time = time.time() | |
| result = score_pronunciation(wav_path, word) | |
| processing_ms = int((time.time() - start_time) * 1000) | |
| sys.stdout = old_stdout | |
| server_logs = log_buffer.getvalue() | |
| # Print captured logs to actual stdout so they appear in HF Spaces logs | |
| print(server_logs, end="") | |
| # Read WAV into memory before files get deleted in finally block | |
| with open(wav_path, "rb") as f: | |
| wav_data = f.read() | |
| # Save recording + result to Supabase (background thread, non-blocking) | |
| save_evaluation(evaluation_id, word, result, wav_data, server_logs, processing_ms, user_id) | |
| return result | |
| except Exception: | |
| # Restore stdout if scoring crashed mid-capture | |
| sys.stdout = sys.__stdout__ | |
| raise | |
| finally: | |
| for p in (raw_path, wav_path): | |
| if os.path.exists(p): | |
| os.remove(p) | |
| # ============================================================================= | |
| # Dashboard โ User Stats & Recent Activity | |
| # ============================================================================= | |
| async def dashboard(x_supabase_token: str = Header(default=None)): | |
| """ | |
| Return the logged-in user's practice stats and recent evaluations. | |
| Authenticates via X-Supabase-Token header. Queries are scoped to the | |
| user via RLS (uses anon key + user token, not service_role). | |
| """ | |
| if not x_supabase_token: | |
| raise HTTPException(status_code=401, detail="Missing X-Supabase-Token header") | |
| # Verify the token is valid | |
| user_id = extract_user_id(x_supabase_token) | |
| if not user_id: | |
| raise HTTPException(status_code=401, detail="Invalid or expired token") | |
| # Query user's data (RLS-scoped via user token + anon key) | |
| result = get_user_dashboard(x_supabase_token) | |
| if result is None: | |
| raise HTTPException(status_code=500, detail="Dashboard query failed โ SUPABASE_ANON_KEY may not be configured") | |
| return result | |
| # ============================================================================= | |
| # Progress โ Level Completion & Unlocks | |
| # ============================================================================= | |
| class ProgressPayload(BaseModel): | |
| level: int = Field(ge=1) | |
| stars: int = Field(ge=0) | |
| passed: bool | |
| word_count: int = Field(ge=0) | |
| async def get_progress(x_supabase_token: str = Header(default=None)): | |
| """Return the user's unlocked level and per-level star breakdown.""" | |
| if not x_supabase_token: | |
| raise HTTPException(status_code=401, detail="Missing X-Supabase-Token header") | |
| user_id = extract_user_id(x_supabase_token) | |
| if not user_id: | |
| raise HTTPException(status_code=401, detail="Invalid or expired token") | |
| result = get_user_progress(x_supabase_token) | |
| if result is None: | |
| raise HTTPException( | |
| status_code=500, | |
| detail="Progress query failed โ SUPABASE_ANON_KEY may not be configured", | |
| ) | |
| return result | |
| async def post_progress( | |
| payload: ProgressPayload, | |
| x_supabase_token: str = Header(default=None), | |
| ): | |
| """Upsert level completion with merge semantics (max stars, sticky passed).""" | |
| if not x_supabase_token: | |
| raise HTTPException(status_code=401, detail="Missing X-Supabase-Token header") | |
| user_id = extract_user_id(x_supabase_token) | |
| if not user_id: | |
| raise HTTPException(status_code=401, detail="Invalid or expired token") | |
| ok = save_user_progress( | |
| x_supabase_token, | |
| level=payload.level, | |
| stars=payload.stars, | |
| passed=payload.passed, | |
| word_count=payload.word_count, | |
| ) | |
| if not ok: | |
| raise HTTPException( | |
| status_code=500, | |
| detail="Progress save failed โ SUPABASE_ANON_KEY may not be configured", | |
| ) | |
| return {"ok": True} | |
| # ============================================================================= | |
| # Diagnostic Profile โ GPT-4o Clinical Reasoning | |
| # ============================================================================= | |
| SYSTEM_PROMPT = """\ | |
| You are a Top-Tier Israeli Pediatric Speech-Language Pathologist (ืงืืื ืืืช ืชืงืฉืืจืช ืืืืืืช). \ | |
| Your task is to analyze an intake form for a child and provide a highly accurate, empathetic, \ | |
| and professional diagnostic report in fluent Hebrew. | |
| CRITICAL CLINICAL REASONING INSTRUCTIONS: | |
| Before generating the structured JSON output, you MUST internally follow these logical steps (Chain of Thought): | |
| 1. Analyze Age vs. Norms: Compare the child's age to Hebrew acquisition norms. \ | |
| - Basic sounds (B, P, M, N, T, D, K, G, Ch, R, L) should be mastered by age 4. \ | |
| - Hissing sounds (S, Z, Ts, Sh) are typically mastered by age 6. \ | |
| - Note: If a 4-year-old struggles with 'SH', it is developmental. If a 7-year-old does, it requires intervention. | |
| 2. Differentiate Articulation vs. Phonology: \ | |
| - Articulation (Phonetic): Distorting a specific sound (e.g., lateral lisp on 'S' or 'SH', guttural 'R'). \ | |
| - Phonological: Changing word structures. E.g., Fronting (K->T), Cluster Reduction ("Psanter" -> "Santer"), \ | |
| Dropping ending consonants. Phonological errors past age 4-5 are highly significant. | |
| 3. Check Red Flags: If any medical/organic red flags (cleft palate, hearing loss, regression, drooling) \ | |
| are marked TRUE, you MUST recommend immediate medical consultation. | |
| 4. Assess Intelligibility & Frustration: If intelligibility is low or frustration is high, the tone must be \ | |
| highly empathetic, and the app plan must start with high "forgiveness" (lenient grading) and auditory \ | |
| discrimination before verbal production. | |
| Generate the exact JSON structure required by the Pydantic schema based on this reasoning. \ | |
| The tone must be professional, warm, reassuring, and clearly state that this is an AI-assisted \ | |
| roadmap and not a replacement for a formal clinical evaluation.""" | |
| class IntakeForm(BaseModel): | |
| """Intake questionnaire submitted by the parent.""" | |
| child_age_years: int = Field(description="Child's age in years") | |
| child_age_months: int = Field(default=0, description="Additional months") | |
| target_sounds: list[str] = Field( | |
| description="Sounds the child struggles with, e.g. ['ืฉ', 'ืจ']" | |
| ) | |
| questionnaire: dict = Field( | |
| description="Free-form questionnaire answers from the parent" | |
| ) | |
| async def diagnose_profile(form: IntakeForm): | |
| """ | |
| Generate a clinical diagnostic report based on a parent's intake form. | |
| Uses GPT-4o with Structured Outputs to guarantee valid JSON matching | |
| the ClinicalReport schema. All output is in Hebrew. | |
| - **child_age_years**: Child's age in years. | |
| - **child_age_months**: Additional months (optional, default 0). | |
| - **target_sounds**: List of sounds the child struggles with. | |
| - **questionnaire**: Dict of parent-provided intake answers. | |
| Returns a ClinicalReport with hypothesis, exercises, and recommendations. | |
| """ | |
| api_key = os.environ.get("OPENAI_API_KEY") | |
| if not api_key: | |
| raise HTTPException( | |
| status_code=500, | |
| detail="OPENAI_API_KEY environment variable is not set.", | |
| ) | |
| age_display = f"{form.child_age_years} ืฉื ืื" | |
| if form.child_age_months: | |
| age_display += f" ื-{form.child_age_months} ืืืืฉืื" | |
| user_prompt = ( | |
| f"ื ืชืื ื ืืืื/ื:\n" | |
| f"ืืื: {age_display}\n" | |
| f"ืฆืืืืื ืืขืืืชืืื: {', '.join(form.target_sounds)}\n\n" | |
| f"ืชืฉืืืืช ืฉืืืื ืืืืจืื:\n" | |
| f"{json.dumps(form.questionnaire, ensure_ascii=False, indent=2)}" | |
| ) | |
| client = OpenAI(api_key=api_key, timeout=60.0) | |
| try: | |
| completion = client.beta.chat.completions.parse( | |
| model="gpt-4o", | |
| messages=[ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_prompt}, | |
| ], | |
| response_format=ClinicalReport, | |
| ) | |
| except (APITimeoutError, APIConnectionError) as e: | |
| raise HTTPException( | |
| status_code=504, | |
| detail=f"OpenAI API timeout or connection error: {e}", | |
| ) | |
| except Exception as e: | |
| raise HTTPException( | |
| status_code=502, | |
| detail=f"OpenAI API error: {e}", | |
| ) | |
| report = completion.choices[0].message.parsed | |
| if report is None: | |
| raise HTTPException( | |
| status_code=502, | |
| detail="OpenAI returned a response but structured parsing failed.", | |
| ) | |
| return report | |