Health & Human Optimization Hackathon. Pitch: 8:30 PM.
DONE. The capture pipeline no longer assumes getUserMedia is going to work. Before, if the laptop didn't have a mic detectable by PipeWire, the whole flow aborted with NotFoundError. Now each audio source (mic, loopback) is opened in its own try/catch, and the app continues with whatever it managed to get. It only aborts if literally nothing could be opened — and even then the error screen has a "Use demo audio" button so the user is never stranded.
Real incident during a live demo: the laptop didn't have a physical microphone exposed by PipeWire. The first click on "Speak for 30 seconds" threw NotFoundError: Requested device not found and the app stayed dead until refresh and discovering the shy link to demo mode. Unacceptable for a tool whose pitch is "30 seconds without hardware".
| micStream | sysStream | activeCapture | UI |
|---|---|---|---|
| ✓ | ✓ | mic-system |
Cyan badge Mic + System, no warning |
| ✓ | ✗ | mic-only |
Amber badge Mic only + soft warning |
| ✗ | ✓ | system-only |
Amber badge System only + soft warning |
| ✗ | ✗ | (not applicable) | Error screen with "Use demo audio" button |
Pure mic mode: if it opens, cyan badge Mic only. If not, error screen with the demo escape hatch. demo mode: zinc badge Demo audio.
handleStart(mode: "mic" | "system")no longerthrows at each step; it wraps eachgetUserMediaand device enumeration in its own try/catch that logs aconsole.warnand leaves the variable atnull.LOOPBACK_HINTSlifted to the module top level (was inside the body ofhandleStart).- New state
activeCapture: "mic-only" | "system-only" | "mic-system" | "demo"reflects what was actually opened, NOT what the user asked for. That's what the badge uses. - New state
captureWarning: string | nullfor the soft message shown at the top during recording when there was degradation. - New
CaptureBadgecomponent maps(activeCapture, captureMode)to(label, tone). Cyan tone for complete cases, amber for degraded cases, zinc for demo. RecordingViewnow receivesactiveCapture+captureWarning; the badge goes up to the right next to the "Capturing audio" label, and the warning appears as a soft amber pill below.ResultViewreceivesactiveCapture; the badge sits next to "REPORT READY" in the header, so the judge sees what was captured when they read the report.mic-errorscreen: primary button is now Use demo audio (handleStartDemoAudio) and the secondary is Back (handleReset). Before, it only had "Try again" which sent you to idle without a clue.MediaRecorderties to the stream that's actually playing: the destination of the merger when there are two sources, or the raw stream (mic or sys) when there's only one. Whisper always receives the blob from the real source, never an empty stream.
lib/meydaCapture.ts,lib/audioFeatures.ts,lib/speechRecognition.ts, all/api/*routes,ResultCards,EvolutionChart,CrossInsightsCard,LiveFeaturesStrip,Waveformremained intact.- The loopback auto-detect (
LOOPBACK_HINTS) is exactly the same; we only removed thethrowwhen there's no match. demomode withpublic/demo-audio.mp3is identical — it only addssetActiveCapture("demo")so the badge renders correctly.- Comparison screen, save profile, individual result: no regressions.
cd /home/junrod/Desktop/Echo
npm run dev
# → http://localhost:3000- No mic detected (
micmode): physically disconnect the mic orpactl unload-module module-alsa-source(Linux). Click "Speak for 30 seconds" → should show the "We couldn't record" screen with the Use demo audio primary button and Back secondary. Used to crash. - No mic + no loopback (
systemmode): same setup as (1), click "Capture mic + system audio" → idem, useful error with the demo escape. - Mic, no loopback (
systemmode): on a machine without Stereo Mix/Monitor, click "Capture mic + system audio" → records with mic only, amber badge Mic only at the top and amber warning "No system audio detected, capturing only your mic.". - No mic, with loopback (
systemmode): hard to reproduce in practice because without a mic the device labels come back empty and the loopback matcher doesn't work — the app treats this case as "nothing available" and goes to the error with the demo escape. It's a browser limitation, not ours. Documented. - Demo mode: click "Mic trouble? Use demo audio" → plays
public/demo-audio.mp3just like before, zinc badge Demo audio. - Result + comparison: the captured mode badge is also visible at the top of the report (next to "REPORT READY") and the comparison screen keeps working without changes.
If the doctor asks what happens when "something doesn't work": the app degrades instead of failing. It tells you what it captured, warns you if it's partial, and offers demo audio as a safety net. Resilience ≠ silencing errors: each fallback has a visible badge that the user (and the judge) can read.
DONE. The "profile comparison" screen goes from a cramped 6-column grid (two <ResultCards /> side by side inside an xl:grid-cols-2) to an executive three-tier report: hero scoreboard with deltas, detail rows per dimension, and collapsible cross insights + transcripts.
<ResultCards />already renders 3 cards inlg:grid-cols-3. Rendering it twice insidexl:grid-cols-2produced 6 cramped columns on normal screens.- Scores (
text-3xl) overflowed the card border when shrunk. - Asymmetric heights: the "Mental wellbeing" card has pills + actions, the other two only summary — no visual parity between rows.
- Text wrapped word by word in such narrow columns.
- No "comparison" narrative: just two identical blocks glued together, no deltas, no visible numeric differentiation.
- New component:
components/ComparisonReport.tsx. Encapsulates the entire comparison report. Receivescurrent,saved,onClose,onClearSaved,onReset. ResultCardsNOT touched: the individual result screen remains identical. The comparison was duplicating that component; now it doesn't use it at all, it builds its own layout.ComparisonViewinapp/page.tsxstays as a thin wrapper passing props toComparisonReport. All previous inline JSX deleted.
- Header: cyan subtitle
PROFILE COMPARISON, titleTwo voices, two profiles, zinc-400 subtitle{saved label} ({hh:mm}) vs {current label}witharia-label. - Hero scoreboard: one row of 3 cards (
lg:grid-cols-3, stack on mobile), one per dimension. Each card: icon + title, twotext-5xlscores side by side with vertical divider, short label truncated (max 12 chars with tooltip), delta pill (+8 pts ↑emerald /-5 pts ↓rose /No changezinc). Borderborder-cyan-500/25to set them apart as "score". - Detail rows per dimension: 3 full-width cards in vertical stack, each with a dimension header and two columns (
md:grid-cols-2,items-start). Each column: header with label + large score withscoreColor, divider, dimension-specific content:- Mental: pills (Stress/Fatigue/Mood) + summary + recommended actions list with check icons.
- Respiratory and Cognitive: summary only (no invented extra content).
- Cross insights: two columns. If both have
cross_insights, two<CrossInsightsCard />side by side. If only one has them, that column + dashed placeholder. If neither has them, the section is omitted. - Transcripts: collapsible section (closed by default). Ghost
View transcriptstoggle button. When open, two columns with each transcript in a sober card. If a transcript is empty, that column is omitted. - Actions:
Back to single report/Delete saved profile/New session— same handlers as before (onClose,onClearSaved,onReset).
scoreColor(score): identical mapping toResultCards(≥80 emerald, ≥60 cyan, ≥40 amber, else rose).delta(current, saved):{ value, sign, color, label }with signup/down/flatand Tailwind classes for the pill.truncateLabel(label, max): cuts with…and returns{ display, full }fortitle=.indicatorTone/indicatorLabel: copied fromResultCardsfor the mental pills.
cd /home/junrod/Desktop/Echo
npm run dev
# → http://localhost:3000- Click
Speak for 30 seconds→ record 30s → result →Save as reference→ labelA. Start over→ record another 30s → result →Compare with A.- See: header with
A (HH:MM) vs Current session, row of 3 hero cards with deltas, 3 balanced detail rows, cross-insights section if applicable,View transcriptsbutton closed by default. - Resize to mobile (~360px): everything should stack to 1 column without overflow.
- Back to single report → the individual screen looks identical (smoke check for no regressions on
ResultCards).
- If an older saved profile doesn't have
cross_insights(pre-PHASE 3.5), the entire section is cleanly omitted (nullwhen both are empty, dashed placeholder for the one that's missing). - Collapsed transcripts by default reduce scroll and give hierarchy to the scoreboard.
card-inwithanimationDelay0/120/240ms applied to hero cards AND to dimension rows — the stagger appears doubled (first the hero, then the detail rows while the user reads). Deliberate.
DONE. The shared mode (which relied on getDisplayMedia and forced the user to pick a specific tab with a "Share tab audio" tick) was replaced by a system mode that captures mic + system audio with a single button and no tab-picker dialog.
CaptureMode = "mic" | "system" | "demo"(was:"mic" | "shared" | "demo").- Deleted the
getDisplayMedia({ audio: true, video: true })path anddisplayStreamRef. Replaced by two simultaneousgetUserMediacalls:- Real mic (default) — opens the mic permission once.
- System loopback / Monitor — opened with
getUserMedia({ audio: { deviceId: { exact: <loopback.deviceId> } } }).
- Mix identical to before: both
MediaStreamSource→GainNode(merger) →MediaStreamAudioDestinationNodeforMediaRecorder. Meyda reads from the merger. Whisper receives the mixed blob. - Web Speech does NOT start in system mode (same as before in shared/demo). The transcript comes from Whisper.
- Downstream pipeline unchanged: Meyda → MediaRecorder → /api/transcribe → /api/analyze → cards.
- Request mic permission once (
getUserMedia({audio:true})). This unlocks reallabelvalues inenumerateDevices(); without that permission, labels come back empty and the matcher doesn't work. - Filter
audioinputand look for a device whoselabel.toLowerCase()contains any of these substrings:monitor(Linux PulseAudio/PipeWire — "Monitor of Built-in Audio")loopbackstereo mix(Windows)blackhole(Mac)soundflower(Mac legacy)vb-audio,vb-cable(Windows virtual cables)wave out,what u hear(legacy Windows)
- No match → throw with an OS-specific message and clean abort (the permission tracks get stopped).
- Match → use
permStreamas the mic stream (it's the default mic) and open a new stream specific to the loopback.
- Linux (my setup): PulseAudio/PipeWire expose them by default. Verify with
pactl list sources short— at least one source with.monitorin the name should appear. If not, check the PulseAudio config or installpavucontrol. - Mac: install BlackHole (free, open source). Then, in Audio MIDI Setup, create a Multi-Output Device so audio goes out to both speakers AND BlackHole at the same time.
- Windows: enable Stereo Mix in Sound Control Panel → Recording → right click → "Show Disabled Devices" → enable Stereo Mix. If it doesn't exist (modern laptops often don't have it), install VB-CABLE and route system output through the virtual cable.
- Single click: no "choose a tab" dialog, no manual "Share audio" tick. Click → mic permission → record.
- Captures EVERYTHING playing: doesn't matter if the doctor is on Meet in a tab, on the Zoom native app, or on a WhatsApp call in another window. If it plays through speakers/headphones, Echo captures it.
- Pitch flow now: have Meet (or whatever) open in any window → click "Capture mic + system audio" → grant mic once → speak → 30s later the report is there.
If the judge's laptop doesn't have loopback configured, the mode falls back to the OS-specific error message. mic mode and demo mode keep working as independent fallbacks.
DONE. 5 additive visual features layered on the existing flow. Nothing broke — the mic / shared / comparison flows keep working as before.
- Live indicators during recording (
components/LiveFeaturesStrip.tsx): 3 horizontal mini-cards below the waveform showing Energy, Pitch and Clarity moving in real time with each Meyda callback. Bars withtransition-all duration-100.startMeydaCapturenow accepts an optionalonFrame(doesn't break prior callers). The frame is stored in a ref and flushed to React state every 80 ms — so Meyda runs at its own pace (~43 fps) and the render doesn't overload. - Mini evolution chart (
components/EvolutionChart.tsx): Recharts LineChart with 5 points (4 mocked historical + the real current one) and 3 lines (Mental cyan, Respiratory emerald, Cognitive amber). Dark-styled tooltip, legend, dots with activeDot. Appears below the 3 score cards. - Cross Insights card (
components/CrossInsightsCard.tsx): cyan→emerald gradient border, Sparkles icon, list of 1-3 observations that cross two dimensions. The OpenAI schema now includescross_insights: string[]as required (strict mode). The system prompt asks for observations like "the pause pattern combined with vocal monotony suggests attention to your cognitive load". Validated with curl: the model returns 2-3 strings coherent with the features sent. - Analysis meta header (
ResultMetaStripinline in page.tsx): shows analysis date + time (new Date()captured when the result arrives), real recorded duration (features.duration_seconds), and the model used ("Processed by GPT-4o + Whisper + Meyda"). Discreet style, under the subtitle. - "Use demo audio" button (sober link on idle screen): pitch life-saver.
captureMode = "demo"runs the full flow with apublic/demo-audio.mp3file. Meyda analyzes viaMediaElementAudioSourceNode, Whisper transcribes the fetched blob, OpenAI generates the report. The audio plays through the speakers while the analysis runs (source.connect(audioCtx.destination)).
- Upload a
demo-audio.mp3file to thepublic/folder of the project:/home/junrod/Desktop/Echo/public/demo-audio.mp3 - Any browser-decodable format works (mp3, ogg, wav, m4a). The path in code is
/demo-audio.mp3— to use a different name, change theDEMO_AUDIO_PATHconstant inapp/page.tsx. - Recommended: 30 seconds of human English voice so Whisper transcribes something coherent and the scores vary.
- If the file does NOT exist → clicking takes the flow to "mic-error" with a clear message: "Demo audio not found at /demo-audio.mp3. Upload the file to /public."
cross_insights added to ECHO_SCHEMA under required (strict mode requires it). The system prompt adds the specific rule for that key. The frontend reads result.cross_insights ?? [] with graceful fallback for older saved profiles.
GET /→ 200, 19 KB of HTML (vs 16 KB before — extra UI)POST /api/analyze→ response has keys[mental_wellbeing, respiratory_voice, cognitive_performance, cross_insights].cross_insights.length === 2, first insight: "The high coherence and lexical richness suggest you're in an optimal mental state..."POST /api/transcribeand the other endpoints with no regressions (only features were added, nothing was modified destructively).
- localStorage of older saved profiles: if Junior saved a profile BEFORE this update, it has no
cross_insights. The card simply does not render for that profile (returns null on empty array). Side-by-side comparison keeps working. - Live indicators with a silent stream: if the shared tab isn't playing anything, the 3 bars stay at 0. Not a bug, it's a signal — it tells the judge "no audio coming through, try again".
- Recharts is ~110 KB minified. Acceptable for a demo.
MediaElementAudioSourceNode: if you connect the sameaudioelement to destination AND to Meyda, it plays through the speakers while Meyda reads it. Dramatic bonus for the live demo.
DONE. System audio capture (shared tab) + mic, Whisper transcription, and side-by-side comparison of saved profiles.
/api/transcriberoute with OpenAI Whisper (whisper-1,language: "en"). Receives FormData withaudiofield, returns{ transcript: string }. Tested with curl: 200 with real audio, 400 without audio, 500 without key.- Demo mode on idle screen: two buttons —
- Primary: "Speak for 30 seconds" (mic only, Web Speech API → instant)
- Secondary: "Capture shared audio + mic" (
getDisplayMedia+ mix + Whisper)
- Tab audio capture: uses
navigator.mediaDevices.getDisplayMedia({ audio: true, video: true }). Chrome shows the "share screen" dialog, the user ticks "Share tab audio" and picks the tab (Meet, Zoom Web, YouTube, etc). The video track is discarded immediately — we only care about audio. - Stream mixing: tab audio + mic mixed in a Web Audio
GainNode. Meyda reads the mix. AMediaStreamAudioDestinationNodeexports the mix as a newMediaStreamrecorded byMediaRecorderfor Whisper. - Whisper instead of Web Speech: in demo mode, Web Speech does NOT start (it would only listen to your mic). The full MediaRecorder blob is sent to
/api/transcribeafter the 30s. - Extended "Analyzing..." steps in demo mode: adds "Transcribing with Whisper..." between audio processing and extraction.
- Recompute features with real transcript: in demo mode, features are computed AFTER having the Whisper transcript, so
speech_rateandlexical_richnesscome out accurate. - Profile saved in localStorage: "Save as reference" button on the result screen. Prompts for a label (default "Doctor" in demo mode, "My profile" in mic). Persists
{ label, result, features, transcript, savedAt }. - Side-by-side comparison: if there's a saved profile, a "Compare with [label]" button appears. Renders two sets of 3 cards in a 2-column grid (xl:grid-cols-2), each with its label on top.
- Saved profile indicator on idle: cyan card with the label + trash button to delete it.
- Before the pitch (5 min before): record 30s with "Speak for 30 seconds" → "Save as reference" → label "Junior".
- The pitch starts on Meet, you share your screen.
- You show Echo, open the flow, click "Capture shared audio + mic".
- Chrome opens the dialog. CRITICAL: tick "Share tab audio", pick the Meet tab, click "Share".
- Ask the doctor-judge to talk 30s about anything in her day.
- Echo captures her voice via the Meet tab (codec quality, not acoustic).
- Whisper transcribes (3-5s), Meyda extracts features, OpenAI generates the report.
- 3 cards appear with her scores and the transcript of what she said.
- Click "Compare with Junior" → 6 cards on screen, two distinct profiles.
- "Echo distinguishes two voices in 30 seconds each. This is what would come to an EPS or to HR."
- Tick "Share tab audio". If you forget, no system audio comes through and we get the error "You didn't tick 'Share audio'... try again and check the option." (specific message). Practice the sequence 2 times before the pitch.
- Share the right tab: the tab you pick in the dialog is the one that provides the audio. If your Meet is in one tab and you share another, her voice doesn't come through. Share the Meet tab.
- getDisplayMedia requires a user gesture: already handled, the button triggers it.
- Whisper adds ~5s of latency. The "Analyzing..." screen with 4 steps covers it visually.
- macOS / Linux with Wayland: in some cases the system denies
getDisplayMedia. If it happens, mic-only mode keeps working. - If Whisper fails (rate limit, key, etc):
analyze-errorscreen with "Retry analysis". Features are already saved, no need to re-record.
app/api/transcribe/route.ts # NEW — Whisper proxy
app/page.tsx # rewrite: 2 modes, save/compare
page.tsx now has CaptureMode = "mic" | "shared". Core functions (handleStart, finishRecording, runAnalyze) branch on mode.
npm run dev # http://localhost:3000Test mic mode (fast):
- Click "Speak for 30 seconds"
- Grant mic
- Speak for 30s
- Result in ~3-5s
Test demo mode:
- Open YouTube in another tab, queue up a video with English voice without hitting play yet
- Click "Capture shared audio + mic"
- Chrome shows the dialog → "Tab" → pick YouTube → tick "Share tab audio" → "Share"
- Immediately play the YouTube video
- Echo captures + Whisper transcribes + reports
- The transcript should show what the video said, not what you said
Test comparison:
- After the first report, "Save as reference"
- "Start over"
- Another report
- "Compare with [label]"
GET /→ 200, page renders with both buttonsPOST /api/transcribewith no audio → 400 with clear errorPOST /api/transcribewith a real WebM (440 Hz tone, 2s) → 200 with transcript fieldPOST /api/analyzewith complete features → 200 with valid structured JSON
DONE. Acoustic features extracted with real Meyda. Manual computation remains as fallback.
- Meyda 5.x installed. Replaces the manual RMS sampling with
Meyda.createMeydaAnalyzerand bufferSize 1024. - Features extracted frame by frame:
rms,zcr,spectralCentroid,spectralFlatness,spectralRolloff,perceptualSpread,perceptualSharpness,loudness,energy. AcousticFeaturesextended with new spectral and perceptual fields (seelib/types.ts). The payload sent to OpenAI now carries:spectral_centroid_mean,spectral_centroid_std,spectral_flatness_mean,spectral_rolloff_mean,perceptual_spread_mean,perceptual_sharpness_mean,zcr_mean,lexical_richness, in addition to the previous ones.pitch_variability_aproxis now real:std(spectralCentroid) / mean(spectralCentroid)— relative spectral variability, much better proxy for prosody than theenergy_std × 4of the fallback.shimmer_aproxis now real:std(rms) / mean(rms)computed over Meyda frames (bufferSize 1024 at ~44.1 kHz = ~23 ms per frame, academic granularity).lexical_richness=unique_words / total_wordscomputed on the client from the transcript.- Provenance: each
AcousticFeaturescarriessource: "meyda" | "manual". Popovers show aMeydaorfallbackbadge so the judge sees where the numbers come from. - Automatic fallback: if
startMeydaCapturefails (browser without ScriptProcessor, import error, etc.), it automatically falls back to manual sampling withAnalyserNode + getByteTimeDomainDataevery 100ms. Doesn't break the flow. - Category-specific popovers:
- Mental: F0 variability, pause ratio, shimmer, speed
- Respiratory: spectral flatness, spectral rolloff, energy mean, duration
- Cognitive: speed, lexical richness, pause ratio, spectral centroid
- System prompt updated with rules that mention the real names of the Meyda features. Validated with curl: high features under stress → indicators "high", low pitch_variability → depression_risk "moderate" with a referral suggestion.
- Open DevTools → Console before recording.
- If Meyda fails, you'll see
console.warn("Meyda failed to initialize, falling back: ..."). - After getting a result, open the Popover on any card. Upper right there's a badge:
Meyda(cyan) → Meyda ranfallback(amber) → fell back to manual
- If you're in fallback, the spectral fields (
spectral_flatness,spectral_rolloff) will come back as0— visible in the "Respiratory health" popover.
Before (manual fallback), pitch_variability_aprox was approximated as energy_std × 4 — it only captured volume instability, not real pitch. Now it comes from the spectral centroid variance, which DOES move with real pitch (singing a flat note vs speaking with intonation → centroid changes a lot).
Expected result: speaking very flat and monotone (robotic reading) will pull pitch_variability_aprox below 0.1 → activates the "flat prosody → low mood marker" rule → depression_risk rises to mild or moderate. Speaking with normal intonation keeps it minimal.
DONE. End-to-end flow with real LLM, real browser features, and transcription.
- Provider: OpenAI (not Anthropic). Model
gpt-4o-2024-08-06withresponse_format: { type: "json_schema", strict: true }. The response is JSON guaranteed by the model — no defensive parsing or retries. - Real features from
AudioContext + AnalyserNode(fftSize 2048). RMS sampling every 100 ms during recording. - Real Web Speech API (
webkitSpeechRecognition) inen-US, continuous + interim. We only accumulateresult.isFinal. - Clickable tooltips on each card with
@radix-ui/react-popover: show the 5 real user features + clinical text hardcoded by category. - Visible transcript on the result screen, in a sober card below the 3 scores. If the browser doesn't support Web Speech, an amber-500 warning is shown.
- Retry without re-recording: on
analyze-error, the state keepsfeaturesandtranscript, so "Retry analysis" reuses the previous capture. - Analyzing steps: 3 progressive steps change every 700 ms on the waiting screen.
In lib/audioFeatures.ts:
| Feature | How it's computed |
|---|---|
energy_mean |
average RMS per frame |
energy_std |
standard deviation of RMS per frame |
pause_ratio |
% frames with RMS < (energy_mean × 0.3) |
duration_seconds |
real performance.now() at start/stop |
shimmer_aprox |
clamp(energy_std / max(energy_mean, 0.001), 0.01, 0.15) |
pitch_variability_aprox |
clamp(energy_std × 4, 0.05, 0.5) |
speech_rate |
transcript_words / duration_seconds |
The first 4 are measured. The last 3 are derived from the real ones — not random. If Junior speaks very softly or very loudly, the derived values also change.
app/api/analyze/route.ts:
- POST receives
{ features, transcript } - Calls OpenAI with SYSTEM_PROMPT + strict ECHO_SCHEMA
- Returns parsed JSON or
{ error }with the right status - Handling: 401 (invalid key), 429 (rate limit), 400 (bad body), 500 (no key or generic error)
Tested with real curl: POST /api/analyze with a test payload returns 200 in ~3.8 s and JSON conforming to the schema.
- Capture logic inline in
page.tsxinstead of a custom hook: only one component consumes it — the indirection doesn't pay off under time pressure. - MediaRecorder kept even though we don't use the blob: it gives us Chrome's "recording state" (mic LED) and forward compatibility to send audio to the backend if we wanted to.
performance.now()for real duration: key if the user hits "Stop now" before 30 s.continuous: trueon Web Speech: in long sessions Chrome sometimes cuts it and firesonend. We keep what was accumulated up to that point.- Separate
mic-errorvsanalyze-errorstatuses: different UX. The first requires re-recording, the second allows retry with already-captured data. low/moderate/highstates mapped to colors: emerald (low/minimal), cyan (mild), amber (moderate), rose (high/elevated). Same mapping applied to pills and to the large score color.
cd /home/junrod/Desktop/Echo
npm run dev
# → http://localhost:3000# Endpoint smoke test without recording:
curl -X POST http://localhost:3000/api/analyze \
-H "Content-Type: application/json" \
-d '{"features":{"duration_seconds":25,"energy_mean":0.05,"energy_std":0.02,"pause_ratio":0.18,"shimmer_aprox":0.04,"pitch_variability_aprox":0.15,"speech_rate":2.4},"transcript":"today I had a long meeting, I felt fine although a bit tired"}'# .env.local
OPENAI_API_KEY=sk-...Without this key, the endpoint returns 500 with a clear message. The frontend shows it in analyze-error with a "Retry analysis" button.
npm run dev, openhttp://localhost:3000in Chrome.- Click "Speak for 30 seconds" → grant mic permission.
- Speak while the countdown runs. Spoken words appear in gray below the waveform in real time (Web Speech).
- Wait 30 s or hit "Stop now".
- "Analyzing" screen walks through 3 steps.
- 3 cards with real model scores + indicators (Stress/Fatigue/Mood) + 3 recommended actions.
- Click the Info icon on any card → Popover with the real acoustic features + clinical rationale.
- "What you said" block below with the full transcript.
- Low, slow, monotone voice, negative phrases ("I'm tired today, I don't feel good, everything feels heavy, nothing motivates me..."): should lower mental_wellbeing and push indicators.depression_risk to
mildormoderate. Highpause_ratio, lowpitch_variability_aprox. - Energetic, fast voice, positive phrases ("I'm excited for the pitch, things are going well, the team is aligned"): should raise scores overall, low indicators.
- Very fast voice with no pauses and choppy speech: can raise stress to
moderate/high. - 5-10 s audio: the model returns scores near 50 with a summary noting "limited duration" — correct behavior per the system prompt.
Echo/
├── app/
│ ├── layout.tsx
│ ├── page.tsx # state machine + AudioContext + Speech + fetch /api/analyze
│ ├── globals.css
│ └── api/
│ └── analyze/
│ └── route.ts # OpenAI structured outputs
├── components/
│ ├── ui/
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── progress.tsx
│ │ └── popover.tsx # Radix Popover wrapper
│ ├── Waveform.tsx
│ └── ResultCards.tsx # now takes `features` + has the 3 Popovers
├── lib/
│ ├── utils.ts
│ ├── types.ts
│ ├── audioFeatures.ts # computeRmsFromTimeDomain, computeFeatures
│ ├── speechRecognition.ts # webkitSpeechRecognition wrapper
│ └── mockResult.ts # legacy from Phase 1, no longer used
└── package.json
mockResult.ts is orphaned. Left as reference until Phase 4 in case Vercel deploy fails and we need to re-enable the fallback.
- Mini evolution chart with Recharts (4 mock points) below the cards
- Secondary "Use demo audio" button (pitch backup in case the venue's mic fails)
- Mobile polish (the 3 cards should stack well on a narrow screen — already there with
lg:grid-cols-3, validate visually) - Polish header / hero copy
- Deploy to Vercel
-
OPENAI_API_KEYin Vercel env vars (Production) - Smoke test on the prod URL
- Test on at least 2 browsers (Chrome and Safari for the judge just in case — Safari doesn't have Web Speech, validate the fallback)
- Web Speech API doesn't work in Firefox nor in some Safari builds. The amber warning is shown and the scores are computed from acoustic features alone. If the judge uses Chrome/Edge, OK.
- Mic permissions require https or localhost. Vercel provides https, OK.
- OpenAI structured outputs: the model RESPECTS the strict schema, no risk of malformed JSON. The only possible errors are 401/429 — handled with retry without re-recording.
- Latency: the OpenAI call takes ~3-5 s in the smoke test. The "Analyzing" screen with 3 steps covers that visually.
- Meyda uses ScriptProcessorNode (deprecated but supported in Chrome). If it breaks in modern Safari, the manual fallback kicks in automatically — the app doesn't crash.
- If the doctor asks how we really measure shimmer/jitter:
shimmer_aproxandpitch_variability_aproxare now computed over Meyda frames (bufferSize 1024 ≈ 23 ms at 44.1 kHz). It's still a proxy, not Praat's canonical shimmer. The honest answer: "Meyda gives us spectrum and RMS frame by frame; we compute relative variance as a proxy. For real clinical use we'd move to Praat or to algorithms like YIN/RAPT for pure F0. The detection of pauses, speech rate and lexical richness are direct measures."