E5K7 commited on
Commit
bf04727
·
0 Parent(s):

Initial commit: InnerVoice MVP

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +29 -0
  2. README.md +64 -0
  3. backend/.env.example +5 -0
  4. backend/main.py +72 -0
  5. backend/models/__init__.py +0 -0
  6. backend/models/database.py +108 -0
  7. backend/requirements.txt +17 -0
  8. backend/routes/__init__.py +0 -0
  9. backend/routes/alerts.py +41 -0
  10. backend/routes/analyze.py +159 -0
  11. backend/routes/chat.py +93 -0
  12. backend/routes/entries.py +43 -0
  13. backend/routes/trends.py +96 -0
  14. backend/seed_data.py +230 -0
  15. backend/services/__init__.py +0 -0
  16. backend/services/audio_processor.py +80 -0
  17. backend/services/emotion_analyzer.py +75 -0
  18. backend/services/emotion_fusion.py +85 -0
  19. backend/services/gpt_service.py +153 -0
  20. backend/services/mood_calculator.py +59 -0
  21. backend/services/text_emotion.py +57 -0
  22. backend/services/transcriber.py +32 -0
  23. backend/services/trend_detector.py +93 -0
  24. frontend/.env.local.example +12 -0
  25. frontend/.eslintrc.json +3 -0
  26. frontend/.gitignore +36 -0
  27. frontend/README.md +36 -0
  28. frontend/app/chat/page.tsx +169 -0
  29. frontend/app/dashboard/page.tsx +171 -0
  30. frontend/app/favicon.ico +0 -0
  31. frontend/app/fonts/GeistMonoVF.woff +0 -0
  32. frontend/app/fonts/GeistVF.woff +0 -0
  33. frontend/app/globals.css +88 -0
  34. frontend/app/layout.tsx +33 -0
  35. frontend/app/page.tsx +77 -0
  36. frontend/app/record/page.tsx +183 -0
  37. frontend/app/settings/page.tsx +116 -0
  38. frontend/app/timeline/page.tsx +134 -0
  39. frontend/components/AlertCard.tsx +71 -0
  40. frontend/components/CalendarHeatmap.tsx +101 -0
  41. frontend/components/CircularProgress.tsx +66 -0
  42. frontend/components/DemoModeBanner.tsx +24 -0
  43. frontend/components/EmotionBadge.tsx +47 -0
  44. frontend/components/MoodTimeline.tsx +103 -0
  45. frontend/components/Navbar.tsx +100 -0
  46. frontend/components/WaveformVisualizer.tsx +87 -0
  47. frontend/hooks/useAudioRecorder.ts +120 -0
  48. frontend/hooks/useDemoMode.ts +33 -0
  49. frontend/lib/api.ts +111 -0
  50. frontend/lib/demo-data.ts +125 -0
.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environments
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+ !.env.local.example
6
+
7
+ # Node
8
+ frontend/node_modules/
9
+ frontend/.next/
10
+ frontend/out/
11
+
12
+ # Python
13
+ backend/venv/
14
+ backend/__pycache__/
15
+ backend/*.pyc
16
+ backend/.pytest_cache/
17
+
18
+ # Databases
19
+ *.db
20
+ *.sqlite
21
+ *.sqlite3
22
+
23
+ # Models / ML
24
+ *.pt
25
+ *.bin
26
+ *.h5
27
+
28
+ # OS
29
+ .DS_Store
README.md ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # InnerVoice — AI Emotional Wellness Tracker
2
+
3
+ InnerVoice is a full-stack web application that allows users to record daily 60-second voice check-ins. The AI analyzes their voice to detect emotional signatures using acoustic features, tracking mental wellness trends over time.
4
+
5
+ ## Tech Stack
6
+ - **Frontend**: Next.js 14, TailwindCSS, Recharts, Framer Motion
7
+ - **Backend**: FastAPI, SQLAlchemy (SQLite/PostgreSQL)
8
+ - **AI/ML**: `wav2vec2` (Emotion Classification), `librosa` (Acoustic Features), `OpenAI Whisper` (Transcription), `GPT-3.5` (Insights/Chat)
9
+
10
+ ## Demo Mode
11
+ The UI is fully connected and defaults to **Demo Mode**, meaning it uses 30 days of pre-generated realistic mock data. You can explore the Dashboard, Timeline, and Chat without running the backend or providing an API key.
12
+ To disable Demo Mode, click the "Demo Mode ON" button in the sidebar or edit `.env.local`.
13
+
14
+ ---
15
+
16
+ ## 🚀 Quick Start (Production/Live Mode)
17
+
18
+ If you want to run the real AI pipeline locally:
19
+
20
+ ### 1. Backend Setup (FastAPI + AI Services)
21
+ The backend requires Python 3.10+ and downloads ~1.5GB of ML models on first run.
22
+ ```bash
23
+ cd backend
24
+ python3 -m venv venv
25
+ source venv/bin/activate
26
+ pip install -r requirements.txt
27
+
28
+ # Start the server (runs on http://localhost:8000)
29
+ uvicorn main:app --reload
30
+ ```
31
+
32
+ *Note: For the AI Companion and Insights to work, rename `backend/.env.example` to `backend/.env` and add your `OPENAI_API_KEY`.*
33
+
34
+ ### 2. Frontend Setup (Next.js)
35
+ ```bash
36
+ cd frontend
37
+ npm install
38
+
39
+ # Start the dev server (runs on http://localhost:3000)
40
+ npm run dev
41
+ ```
42
+
43
+ Visit `http://localhost:3000` to start recording.
44
+
45
+ ### 3. Database Seeding (Optional)
46
+ If you want to test the live backend but don't want to record 30 days of real audio first, you can seed the SQLite database with 30 days of realistic mock entries:
47
+ ```bash
48
+ cd backend
49
+ source venv/bin/activate
50
+ python seed_data.py
51
+ ```
52
+ This generates a user ID. Add that ID to `frontend/.env.local` as `NEXT_PUBLIC_DEMO_USER_ID`.
53
+
54
+ ---
55
+
56
+ ## Technical Features Implemented
57
+ - **Web Audio API**: Client-side recording with real-time waveform visualization.
58
+ - **Acoustic Extraction**: `librosa` extracts pitch, energy (RMS), tempo, pause duration, and zero-crossing rate (filler words).
59
+ - **Emotion Inference**: HuggingFace `wav2vec2-lg-xlsr-en-speech-emotion-recognition` model lazy-loaded on the server.
60
+ - **Trend Detection**: Week-over-week deviation analysis triggers alerts (e.g., "Mood declined 24%").
61
+ - **State Injection Chat**: Custom GPT context window is injected with the last 30 days of voice metrics before the user asks a question, enabling personalized insights without fine-tuning.
62
+
63
+ ## Privacy Note
64
+ Audio files are converted in-memory and deleted immediately after the feature-extraction pipeline completes. We only store acoustic metrics (floats), the transcription, and the detected emotion.
backend/.env.example ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ OPENROUTER_API_KEY=your_openrouter_api_key_here
2
+ OPENROUTER_MODEL=meta-llama/llama-3-8b-instruct
3
+ DATABASE_URL=sqlite:///./innervoice.db
4
+ SECRET_KEY=change_me_to_a_random_secret_key_32chars
5
+ CORS_ORIGINS=http://localhost:3000
backend/main.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI application entry point for InnerVoice backend.
3
+ """
4
+ import os
5
+ from fastapi import FastAPI
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from dotenv import load_dotenv
8
+
9
+ load_dotenv()
10
+
11
+ from models.database import init_db
12
+ from routes.analyze import router as analyze_router
13
+ from routes.entries import router as entries_router
14
+ from routes.trends import router as trends_router
15
+ from routes.alerts import router as alerts_router
16
+ from routes.chat import router as chat_router
17
+
18
+ app = FastAPI(
19
+ title="InnerVoice API",
20
+ description="AI-powered emotional wellness tracker backend",
21
+ version="1.0.0",
22
+ )
23
+
24
+ # CORS
25
+ origins = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",")
26
+ app.add_middleware(
27
+ CORSMiddleware,
28
+ allow_origins=origins,
29
+ allow_credentials=True,
30
+ allow_methods=["*"],
31
+ allow_headers=["*"],
32
+ )
33
+
34
+ # Init DB on startup
35
+ @app.on_event("startup")
36
+ def startup():
37
+ init_db()
38
+ print("[InnerVoice] Database initialized.")
39
+
40
+ # Register routers
41
+ app.include_router(analyze_router, prefix="/api")
42
+ app.include_router(entries_router, prefix="/api")
43
+ app.include_router(trends_router, prefix="/api")
44
+ app.include_router(alerts_router, prefix="/api")
45
+ app.include_router(chat_router, prefix="/api")
46
+
47
+ # User creation helper endpoint
48
+ from fastapi import Depends
49
+ from sqlalchemy.orm import Session
50
+ from models.database import get_db, User
51
+ from pydantic import BaseModel
52
+ import uuid
53
+
54
+ class UserCreate(BaseModel):
55
+ email: str
56
+ name: str
57
+
58
+ @app.post("/api/users")
59
+ def create_or_get_user(body: UserCreate, db: Session = Depends(get_db)):
60
+ user = db.query(User).filter(User.email == body.email).first()
61
+ if user:
62
+ return {"id": user.id, "email": user.email, "name": user.name, "existed": True}
63
+ user = User(id=str(uuid.uuid4()), email=body.email, name=body.name)
64
+ db.add(user)
65
+ db.commit()
66
+ db.refresh(user)
67
+ return {"id": user.id, "email": user.email, "name": user.name, "existed": False}
68
+
69
+
70
+ @app.get("/api/health")
71
+ def health():
72
+ return {"status": "ok", "service": "InnerVoice API"}
backend/models/__init__.py ADDED
File without changes
backend/models/database.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import datetime
3
+ from typing import Optional, List
4
+ from sqlalchemy import (
5
+ create_engine, Column, String, Float, Integer,
6
+ Boolean, Text, ForeignKey, DateTime, JSON
7
+ )
8
+ from sqlalchemy.ext.declarative import declarative_base
9
+ from sqlalchemy.orm import sessionmaker, relationship
10
+ import os
11
+ from dotenv import load_dotenv
12
+
13
+ load_dotenv()
14
+
15
+ DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./innervoice.db")
16
+
17
+ engine = create_engine(
18
+ DATABASE_URL,
19
+ connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
20
+ )
21
+
22
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
23
+ Base = declarative_base()
24
+
25
+
26
+ def get_db():
27
+ db = SessionLocal()
28
+ try:
29
+ yield db
30
+ finally:
31
+ db.close()
32
+
33
+
34
+ class User(Base):
35
+ __tablename__ = "users"
36
+
37
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
38
+ email = Column(String, unique=True, nullable=False, index=True)
39
+ name = Column(String, nullable=False)
40
+ created_at = Column(DateTime, default=datetime.utcnow)
41
+ baseline_pitch = Column(Float, nullable=True)
42
+ baseline_energy = Column(Float, nullable=True)
43
+ baseline_speech_rate = Column(Float, nullable=True)
44
+
45
+ voice_entries = relationship("VoiceEntry", back_populates="user", cascade="all, delete-orphan")
46
+ mood_alerts = relationship("MoodAlert", back_populates="user", cascade="all, delete-orphan")
47
+ chat_messages = relationship("ChatMessage", back_populates="user", cascade="all, delete-orphan")
48
+
49
+
50
+ class VoiceEntry(Base):
51
+ __tablename__ = "voice_entries"
52
+
53
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
54
+ user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
55
+ created_at = Column(DateTime, default=datetime.utcnow)
56
+ audio_url = Column(String, nullable=True)
57
+ duration_seconds = Column(Float, nullable=True)
58
+ transcription = Column(Text, nullable=True)
59
+ primary_emotion = Column(String, nullable=True)
60
+ emotion_confidence = Column(Float, nullable=True)
61
+ energy_score = Column(Integer, nullable=True)
62
+ calmness_score = Column(Integer, nullable=True)
63
+ mood_score = Column(Integer, nullable=True)
64
+ clarity_score = Column(Integer, nullable=True)
65
+ pitch_mean = Column(Float, nullable=True)
66
+ pitch_std = Column(Float, nullable=True)
67
+ energy_raw = Column(Float, nullable=True)
68
+ speech_rate = Column(Float, nullable=True)
69
+ pause_count = Column(Integer, nullable=True)
70
+ avg_pause_duration = Column(Float, nullable=True)
71
+ filler_rate = Column(Float, nullable=True)
72
+ mfcc_features = Column(JSON, nullable=True)
73
+
74
+ user = relationship("User", back_populates="voice_entries")
75
+ chat_messages = relationship("ChatMessage", back_populates="voice_entry")
76
+
77
+
78
+ class MoodAlert(Base):
79
+ __tablename__ = "mood_alerts"
80
+
81
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
82
+ user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
83
+ created_at = Column(DateTime, default=datetime.utcnow)
84
+ alert_type = Column(String, nullable=False)
85
+ severity = Column(String, nullable=False)
86
+ message = Column(Text, nullable=False)
87
+ is_read = Column(Boolean, default=False)
88
+ suggested_action = Column(Text, nullable=True)
89
+
90
+ user = relationship("User", back_populates="mood_alerts")
91
+
92
+
93
+ class ChatMessage(Base):
94
+ __tablename__ = "chat_messages"
95
+
96
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
97
+ user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
98
+ created_at = Column(DateTime, default=datetime.utcnow)
99
+ role = Column(String, nullable=False) # user / assistant
100
+ content = Column(Text, nullable=False)
101
+ voice_entry_id = Column(String, ForeignKey("voice_entries.id"), nullable=True)
102
+
103
+ user = relationship("User", back_populates="chat_messages")
104
+ voice_entry = relationship("VoiceEntry", back_populates="chat_messages")
105
+
106
+
107
+ def init_db():
108
+ Base.metadata.create_all(bind=engine)
backend/requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn[standard]==0.30.1
3
+ sqlalchemy==2.0.30
4
+ python-multipart==0.0.9
5
+ python-dotenv==1.0.1
6
+ pydub==0.25.1
7
+ librosa==0.10.2
8
+ numpy==1.26.4
9
+ scipy==1.13.0
10
+ openai==1.30.1
11
+ httpx==0.27.0
12
+ aiofiles==23.2.1
13
+ # ML models — comment out if you want faster install without AI analysis
14
+ transformers==4.41.2
15
+ torch==2.3.0
16
+ torchaudio==2.3.0
17
+ openai-whisper==20231117
backend/routes/__init__.py ADDED
File without changes
backend/routes/alerts.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GET /api/alerts — fetch unread alerts for a user
3
+ PUT /api/alerts/{id}/read — mark alert as read
4
+ """
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+ from sqlalchemy.orm import Session
7
+ from models.database import get_db, MoodAlert
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ @router.get("/alerts")
13
+ def get_alerts(user_id: str, db: Session = Depends(get_db)):
14
+ alerts = (
15
+ db.query(MoodAlert)
16
+ .filter(MoodAlert.user_id == user_id, MoodAlert.is_read == False)
17
+ .order_by(MoodAlert.created_at.desc())
18
+ .all()
19
+ )
20
+ return [
21
+ {
22
+ "id": a.id,
23
+ "created_at": a.created_at.isoformat(),
24
+ "alert_type": a.alert_type,
25
+ "severity": a.severity,
26
+ "message": a.message,
27
+ "suggested_action": a.suggested_action,
28
+ "is_read": a.is_read,
29
+ }
30
+ for a in alerts
31
+ ]
32
+
33
+
34
+ @router.put("/alerts/{alert_id}/read")
35
+ def mark_alert_read(alert_id: str, db: Session = Depends(get_db)):
36
+ alert = db.query(MoodAlert).filter(MoodAlert.id == alert_id).first()
37
+ if not alert:
38
+ raise HTTPException(status_code=404, detail="Alert not found")
39
+ alert.is_read = True
40
+ db.commit()
41
+ return {"success": True}
backend/routes/analyze.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ POST /api/analyze — Full voice analysis pipeline
3
+ """
4
+ import os
5
+ import tempfile
6
+ from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException
7
+ from sqlalchemy.orm import Session
8
+ from models.database import get_db, VoiceEntry, MoodAlert, User
9
+ from services.audio_processor import convert_to_wav, extract_features
10
+ from services.emotion_analyzer import classify_emotion
11
+ from services.text_emotion import classify_text_emotion
12
+ from services.emotion_fusion import fuse_emotions
13
+ from services.transcriber import transcribe
14
+ from services.mood_calculator import calculate_mood_scores
15
+ from services.trend_detector import check_trends
16
+ from services.gpt_service import generate_insight
17
+ import uuid
18
+
19
+ router = APIRouter()
20
+
21
+
22
+ @router.post("/analyze")
23
+ async def analyze_audio(
24
+ audio: UploadFile = File(...),
25
+ user_id: str = Form(...),
26
+ db: Session = Depends(get_db),
27
+ ):
28
+ # 1. Save uploaded audio to temp file
29
+ suffix = ".webm" if audio.content_type == "audio/webm" else ".wav"
30
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=suffix)
31
+ wav_path = None
32
+ try:
33
+ content = await audio.read()
34
+ with os.fdopen(tmp_fd, "wb") as f:
35
+ f.write(content)
36
+
37
+ # 2. Convert to WAV 16kHz mono
38
+ wav_path = convert_to_wav(tmp_path)
39
+
40
+ # 3. Run emotion classification (acoustic)
41
+ audio_emotion_result = classify_emotion(wav_path)
42
+
43
+ # 4. Extract acoustic features
44
+ features = extract_features(wav_path)
45
+
46
+ # 5. Transcribe (speech to text)
47
+ transcription = transcribe(wav_path)
48
+
49
+ # 5b. Run emotion classification (text)
50
+ text_emotion_result = classify_text_emotion(transcription)
51
+
52
+ # 5c. Fuse acoustic and text emotion predictions
53
+ emotion_result = fuse_emotions(audio_emotion_result, text_emotion_result)
54
+ emotion = emotion_result["emotion"]
55
+ confidence = emotion_result["confidence"]
56
+
57
+ # 6. Calculate mood scores
58
+ mood_scores = calculate_mood_scores(
59
+ emotion=emotion,
60
+ confidence=confidence,
61
+ pitch_mean=features["pitch_mean"],
62
+ pitch_std=features["pitch_std"],
63
+ energy=features["energy_raw"],
64
+ tempo=features["speech_rate"],
65
+ avg_pause=features["avg_pause_duration"],
66
+ filler_rate=features["filler_rate"],
67
+ )
68
+
69
+ # 7. Generate AI insight
70
+ insight = generate_insight(
71
+ emotion=emotion,
72
+ confidence=confidence,
73
+ mood_scores=mood_scores,
74
+ transcription=transcription,
75
+ features=features,
76
+ )
77
+
78
+ # 8. Save voice entry to DB
79
+ entry = VoiceEntry(
80
+ id=str(uuid.uuid4()),
81
+ user_id=user_id,
82
+ transcription=transcription,
83
+ primary_emotion=emotion,
84
+ emotion_confidence=confidence,
85
+ energy_score=mood_scores["energy"],
86
+ calmness_score=mood_scores["calmness"],
87
+ mood_score=mood_scores["mood"],
88
+ clarity_score=mood_scores["clarity"],
89
+ pitch_mean=features["pitch_mean"],
90
+ pitch_std=features["pitch_std"],
91
+ energy_raw=features["energy_raw"],
92
+ speech_rate=features["speech_rate"],
93
+ pause_count=features["pause_count"],
94
+ avg_pause_duration=features["avg_pause_duration"],
95
+ filler_rate=features["filler_rate"],
96
+ mfcc_features=features["mfcc_features"],
97
+ duration_seconds=features["duration_seconds"],
98
+ )
99
+ db.add(entry)
100
+ db.commit()
101
+ db.refresh(entry)
102
+
103
+ # 9. Check for trend alerts
104
+ all_entries = (
105
+ db.query(VoiceEntry)
106
+ .filter(VoiceEntry.user_id == user_id)
107
+ .order_by(VoiceEntry.created_at)
108
+ .all()
109
+ )
110
+ new_alerts = check_trends(all_entries)
111
+ saved_alerts = []
112
+ for alert_data in new_alerts:
113
+ # Avoid duplicate alerts of same type in last 7 days
114
+ from datetime import datetime, timedelta
115
+ cutoff = datetime.utcnow() - timedelta(days=7)
116
+ existing = (
117
+ db.query(MoodAlert)
118
+ .filter(
119
+ MoodAlert.user_id == user_id,
120
+ MoodAlert.alert_type == alert_data["type"],
121
+ MoodAlert.created_at >= cutoff,
122
+ )
123
+ .first()
124
+ )
125
+ if not existing:
126
+ alert = MoodAlert(
127
+ user_id=user_id,
128
+ alert_type=alert_data["type"],
129
+ severity=alert_data["severity"],
130
+ message=alert_data["message"],
131
+ suggested_action=alert_data.get("suggested_action"),
132
+ )
133
+ db.add(alert)
134
+ db.commit()
135
+ saved_alerts.append({
136
+ "id": alert.id,
137
+ "type": alert.alert_type,
138
+ "severity": alert.severity,
139
+ "message": alert.message,
140
+ "suggested_action": alert.suggested_action,
141
+ })
142
+
143
+ return {
144
+ "entry_id": entry.id,
145
+ "emotion": emotion,
146
+ "confidence": confidence,
147
+ "mood_scores": mood_scores,
148
+ "transcription": transcription,
149
+ "features": features,
150
+ "insight": insight,
151
+ "new_alerts": saved_alerts,
152
+ }
153
+
154
+ finally:
155
+ # Always delete temp files (audio is never stored permanently)
156
+ if os.path.exists(tmp_path):
157
+ os.remove(tmp_path)
158
+ if wav_path and os.path.exists(wav_path):
159
+ os.remove(wav_path)
backend/routes/chat.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ POST /api/chat — AI companion chat with full user context
3
+ """
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+ from pydantic import BaseModel
6
+ from sqlalchemy.orm import Session
7
+ from models.database import get_db, ChatMessage, VoiceEntry
8
+ from services.gpt_service import chat_response
9
+ import uuid
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ class ChatRequest(BaseModel):
15
+ user_id: str
16
+ message: str
17
+ voice_entry_id: str | None = None
18
+
19
+
20
+ @router.post("/chat")
21
+ def chat(req: ChatRequest, db: Session = Depends(get_db)):
22
+ # Fetch recent voice entries for context
23
+ recent_entries = (
24
+ db.query(VoiceEntry)
25
+ .filter(VoiceEntry.user_id == req.user_id)
26
+ .order_by(VoiceEntry.created_at.desc())
27
+ .limit(30)
28
+ .all()
29
+ )
30
+ recent_entries = list(reversed(recent_entries))
31
+
32
+ # Fetch conversation history
33
+ history = (
34
+ db.query(ChatMessage)
35
+ .filter(ChatMessage.user_id == req.user_id)
36
+ .order_by(ChatMessage.created_at.asc())
37
+ .limit(40)
38
+ .all()
39
+ )
40
+ history_dicts = [{"role": m.role, "content": m.content} for m in history]
41
+
42
+ # Get AI response
43
+ response_text = chat_response(
44
+ user_message=req.message,
45
+ recent_entries=recent_entries,
46
+ conversation_history=history_dicts,
47
+ )
48
+
49
+ # Save user message
50
+ user_msg = ChatMessage(
51
+ id=str(uuid.uuid4()),
52
+ user_id=req.user_id,
53
+ role="user",
54
+ content=req.message,
55
+ voice_entry_id=req.voice_entry_id,
56
+ )
57
+ db.add(user_msg)
58
+
59
+ # Save assistant response
60
+ ai_msg = ChatMessage(
61
+ id=str(uuid.uuid4()),
62
+ user_id=req.user_id,
63
+ role="assistant",
64
+ content=response_text,
65
+ voice_entry_id=req.voice_entry_id,
66
+ )
67
+ db.add(ai_msg)
68
+ db.commit()
69
+
70
+ return {
71
+ "response": response_text,
72
+ "message_id": ai_msg.id,
73
+ }
74
+
75
+
76
+ @router.get("/chat/history")
77
+ def get_chat_history(user_id: str, db: Session = Depends(get_db)):
78
+ messages = (
79
+ db.query(ChatMessage)
80
+ .filter(ChatMessage.user_id == user_id)
81
+ .order_by(ChatMessage.created_at.asc())
82
+ .limit(100)
83
+ .all()
84
+ )
85
+ return [
86
+ {
87
+ "id": m.id,
88
+ "role": m.role,
89
+ "content": m.content,
90
+ "created_at": m.created_at.isoformat(),
91
+ }
92
+ for m in messages
93
+ ]
backend/routes/entries.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GET /api/entries — fetch voice entries for a user
3
+ """
4
+ from fastapi import APIRouter, Depends, Query
5
+ from sqlalchemy.orm import Session
6
+ from models.database import get_db, VoiceEntry
7
+ from datetime import datetime, timedelta
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ @router.get("/entries")
13
+ def get_entries(
14
+ user_id: str,
15
+ days: int = Query(default=30, ge=1, le=365),
16
+ db: Session = Depends(get_db),
17
+ ):
18
+ cutoff = datetime.utcnow() - timedelta(days=days)
19
+ entries = (
20
+ db.query(VoiceEntry)
21
+ .filter(VoiceEntry.user_id == user_id, VoiceEntry.created_at >= cutoff)
22
+ .order_by(VoiceEntry.created_at)
23
+ .all()
24
+ )
25
+ return [
26
+ {
27
+ "id": e.id,
28
+ "created_at": e.created_at.isoformat(),
29
+ "primary_emotion": e.primary_emotion,
30
+ "emotion_confidence": e.emotion_confidence,
31
+ "energy_score": e.energy_score,
32
+ "calmness_score": e.calmness_score,
33
+ "mood_score": e.mood_score,
34
+ "clarity_score": e.clarity_score,
35
+ "transcription": e.transcription,
36
+ "duration_seconds": e.duration_seconds,
37
+ "pitch_mean": e.pitch_mean,
38
+ "speech_rate": e.speech_rate,
39
+ "pause_count": e.pause_count,
40
+ "filler_rate": e.filler_rate,
41
+ }
42
+ for e in entries
43
+ ]
backend/routes/trends.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GET /api/trends — week-over-week comparisons and pattern insights
3
+ """
4
+ from fastapi import APIRouter, Depends, Query
5
+ from sqlalchemy.orm import Session
6
+ from models.database import get_db, VoiceEntry
7
+ from datetime import datetime, timedelta
8
+ from statistics import mean
9
+ from collections import Counter
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.get("/trends")
15
+ def get_trends(user_id: str, db: Session = Depends(get_db)):
16
+ entries = (
17
+ db.query(VoiceEntry)
18
+ .filter(VoiceEntry.user_id == user_id)
19
+ .order_by(VoiceEntry.created_at)
20
+ .all()
21
+ )
22
+
23
+ if not entries:
24
+ return {"entries_count": 0, "trends": {}, "patterns": [], "insights": []}
25
+
26
+ now = datetime.utcnow()
27
+
28
+ # ── Week over week ────────────────────────────────────────────────────────
29
+ this_week_cutoff = now - timedelta(days=7)
30
+ last_week_cutoff = now - timedelta(days=14)
31
+ this_week = [e for e in entries if e.created_at >= this_week_cutoff]
32
+ last_week = [e for e in entries if last_week_cutoff <= e.created_at < this_week_cutoff]
33
+
34
+ def avg_score(es, field):
35
+ vals = [getattr(e, field) for e in es if getattr(e, field) is not None]
36
+ return round(mean(vals), 1) if vals else None
37
+
38
+ this_week_avg = {
39
+ "mood": avg_score(this_week, "mood_score"),
40
+ "energy": avg_score(this_week, "energy_score"),
41
+ "calmness": avg_score(this_week, "calmness_score"),
42
+ "clarity": avg_score(this_week, "clarity_score"),
43
+ }
44
+ last_week_avg = {
45
+ "mood": avg_score(last_week, "mood_score"),
46
+ "energy": avg_score(last_week, "energy_score"),
47
+ "calmness": avg_score(last_week, "calmness_score"),
48
+ "clarity": avg_score(last_week, "clarity_score"),
49
+ }
50
+
51
+ # ── Streak ────────────────────────────────────────────────────────────────
52
+ streak = 0
53
+ check_date = now.date()
54
+ entry_dates = {e.created_at.date() for e in entries}
55
+ while check_date in entry_dates:
56
+ streak += 1
57
+ check_date -= timedelta(days=1)
58
+
59
+ # ── Most common emotion this month ────────────────────────────────────────
60
+ month_cutoff = now - timedelta(days=30)
61
+ month_entries = [e for e in entries if e.created_at >= month_cutoff]
62
+ emotion_counts = Counter(
63
+ e.primary_emotion for e in month_entries if e.primary_emotion
64
+ )
65
+ most_common_emotion = emotion_counts.most_common(1)[0][0] if emotion_counts else None
66
+
67
+ # ── Pattern insights ──────────────────────────────────────────────────────
68
+ insights = []
69
+ if len(entries) >= 7:
70
+ # Day-of-week patterns
71
+ from collections import defaultdict
72
+ day_moods = defaultdict(list)
73
+ for e in entries[-60:]:
74
+ if e.mood_score is not None:
75
+ day_name = e.created_at.strftime("%A")
76
+ day_moods[day_name].append(e.mood_score)
77
+ if day_moods:
78
+ best_day = max(day_moods, key=lambda d: mean(day_moods[d]))
79
+ worst_day = min(day_moods, key=lambda d: mean(day_moods[d]))
80
+ insights.append(f"Your best days tend to be {best_day}s")
81
+ if worst_day != best_day:
82
+ insights.append(f"Your mood tends to dip on {worst_day}s")
83
+
84
+ # Improvement streak
85
+ recent_5 = [e.mood_score for e in entries[-5:] if e.mood_score]
86
+ if len(recent_5) >= 3 and all(recent_5[i] <= recent_5[i+1] for i in range(len(recent_5)-1)):
87
+ insights.append(f"You've been improving steadily for {len(recent_5)} days 🌱")
88
+
89
+ return {
90
+ "entries_count": len(entries),
91
+ "streak": streak,
92
+ "most_common_emotion": most_common_emotion,
93
+ "this_week": this_week_avg,
94
+ "last_week": last_week_avg,
95
+ "insights": insights,
96
+ }
backend/seed_data.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Seed the database with 30 days of realistic mock data for demo mode.
3
+ Run: python seed_data.py
4
+ """
5
+ import sys
6
+ import os
7
+ import uuid
8
+ import random
9
+ from datetime import datetime, timedelta
10
+ from dotenv import load_dotenv
11
+
12
+ load_dotenv()
13
+ sys.path.insert(0, os.path.dirname(__file__))
14
+
15
+ from models.database import init_db, SessionLocal, User, VoiceEntry, MoodAlert, ChatMessage
16
+
17
+ DEMO_USER_EMAIL = "demo@innervoice.app"
18
+ DEMO_USER_NAME = "Alex (Demo)"
19
+
20
+ EMOTIONS = ["happy", "neutral", "sad", "anxious", "fearful", "angry"]
21
+ EMOTION_WEIGHTS = [0.30, 0.28, 0.18, 0.12, 0.07, 0.05]
22
+
23
+ TRANSCRIPTIONS = {
24
+ "happy": [
25
+ "Today was actually really good. I feel like things are finally clicking into place and I'm excited about what's ahead.",
26
+ "I had a great time with friends this evening. Laughed a lot. Really needed that connection.",
27
+ "Finished a project I've been working on for weeks. Feels so satisfying to see it done.",
28
+ "Went for a long walk this morning and the sunshine just lifted everything. Feeling grateful.",
29
+ ],
30
+ "neutral": [
31
+ "It's been a regular day. Nothing spectacular, nothing bad. Just steady.",
32
+ "Work was fine. Meetings ran long but got through it. Feeling okay overall.",
33
+ "Pretty normal Tuesday. Had lunch outside which was nice. Mostly just going through the motions.",
34
+ "No major feelings today. I'm present, just... existing. That's okay too.",
35
+ ],
36
+ "sad": [
37
+ "I don't really know how to explain it. Just feel a heaviness today that I can't shake.",
38
+ "Missing some people today. Feeling a bit disconnected from everything.",
39
+ "Things feel harder than they should. I'm trying but motivation is low.",
40
+ "Cried a little this evening and I'm not even sure why. Just needed to let it out.",
41
+ ],
42
+ "anxious": [
43
+ "My mind keeps racing about things I can't control. Hard to focus on the present.",
44
+ "Feeling that familiar chest tightness again. Too many things on my plate.",
45
+ "The week ahead feels overwhelming. I'm trying to take it one thing at a time.",
46
+ "Lots of uncertainty right now and my nervous system is definitely feeling it.",
47
+ ],
48
+ "fearful": [
49
+ "Something doesn't feel right but I can't name it. Just uneasy.",
50
+ "Woke up with a sense of dread this morning. Trying to ground myself.",
51
+ "Scared about some changes happening in my life. Trying to trust the process.",
52
+ ],
53
+ "angry": [
54
+ "Really frustrated today. Feel like I'm not being heard or seen.",
55
+ "Had a tense situation at work. Hard not to carry that home.",
56
+ "Some things happened today that felt unfair and I'm still processing the anger.",
57
+ ],
58
+ }
59
+
60
+
61
+ def generate_entry(user_id: str, days_ago: int) -> VoiceEntry:
62
+ # Generate a realistic mood arc (dip in middle, recovery)
63
+ progress = days_ago / 30.0 # 0 = most recent, 1 = oldest
64
+ base_mood_shift = int(20 * (0.5 - abs(progress - 0.6))) # dip 18 days ago
65
+
66
+ emotion = random.choices(EMOTIONS, weights=EMOTION_WEIGHTS)[0]
67
+ confidence = round(random.uniform(0.60, 0.95), 3)
68
+
69
+ from services.mood_calculator import calculate_mood_scores
70
+ pitch_mean = random.uniform(100, 200)
71
+ pitch_std = random.uniform(10, 60)
72
+ energy = random.uniform(0.005, 0.04)
73
+ tempo = random.uniform(60, 120)
74
+ avg_pause = random.uniform(0.1, 0.8)
75
+ filler_rate = random.uniform(0.01, 0.08)
76
+
77
+ scores = calculate_mood_scores(
78
+ emotion=emotion,
79
+ confidence=confidence,
80
+ pitch_mean=pitch_mean,
81
+ pitch_std=pitch_std,
82
+ energy=energy,
83
+ tempo=tempo,
84
+ avg_pause=avg_pause,
85
+ filler_rate=filler_rate,
86
+ )
87
+
88
+ # Apply arc shift
89
+ for key in scores:
90
+ scores[key] = max(0, min(100, scores[key] + base_mood_shift + random.randint(-5, 5)))
91
+
92
+ transcription_options = TRANSCRIPTIONS.get(emotion, TRANSCRIPTIONS["neutral"])
93
+ transcription = random.choice(transcription_options)
94
+
95
+ created_at = datetime.utcnow() - timedelta(days=days_ago, hours=random.randint(6, 22))
96
+
97
+ return VoiceEntry(
98
+ id=str(uuid.uuid4()),
99
+ user_id=user_id,
100
+ created_at=created_at,
101
+ audio_url=None,
102
+ duration_seconds=round(random.uniform(30, 65), 1),
103
+ transcription=transcription,
104
+ primary_emotion=emotion,
105
+ emotion_confidence=confidence,
106
+ energy_score=scores["energy"],
107
+ calmness_score=scores["calmness"],
108
+ mood_score=scores["mood"],
109
+ clarity_score=scores["clarity"],
110
+ pitch_mean=round(pitch_mean, 2),
111
+ pitch_std=round(pitch_std, 2),
112
+ energy_raw=round(energy, 6),
113
+ speech_rate=round(tempo, 2),
114
+ pause_count=random.randint(2, 15),
115
+ avg_pause_duration=round(avg_pause, 3),
116
+ filler_rate=round(filler_rate, 4),
117
+ mfcc_features=[round(random.uniform(-50, 50), 2) for _ in range(13)],
118
+ )
119
+
120
+
121
+ def seed():
122
+ init_db()
123
+ db = SessionLocal()
124
+ try:
125
+ # Create or get demo user
126
+ user = db.query(User).filter(User.email == DEMO_USER_EMAIL).first()
127
+ if not user:
128
+ user = User(
129
+ id=str(uuid.uuid4()),
130
+ email=DEMO_USER_EMAIL,
131
+ name=DEMO_USER_NAME,
132
+ )
133
+ db.add(user)
134
+ db.commit()
135
+ db.refresh(user)
136
+ print(f"[Seed] Created demo user: {user.id}")
137
+ else:
138
+ print(f"[Seed] Demo user exists: {user.id}")
139
+ # Clear old entries
140
+ db.query(VoiceEntry).filter(VoiceEntry.user_id == user.id).delete()
141
+ db.query(MoodAlert).filter(MoodAlert.user_id == user.id).delete()
142
+ db.query(ChatMessage).filter(ChatMessage.user_id == user.id).delete()
143
+ db.commit()
144
+
145
+ # Generate 30 days of entries (skip ~20% of days for realism)
146
+ entries = []
147
+ for days_ago in range(30, 0, -1):
148
+ if random.random() < 0.80: # 80% check-in rate
149
+ entry = generate_entry(user.id, days_ago)
150
+ db.add(entry)
151
+ entries.append(entry)
152
+
153
+ db.commit()
154
+ print(f"[Seed] Created {len(entries)} voice entries.")
155
+
156
+ # Create sample alerts
157
+ alerts = [
158
+ MoodAlert(
159
+ id=str(uuid.uuid4()),
160
+ user_id=user.id,
161
+ created_at=datetime.utcnow() - timedelta(days=2),
162
+ alert_type="mood_decline",
163
+ severity="warning",
164
+ message="Your mood has declined 24% this week compared to last week.",
165
+ suggested_action="Try scheduling something enjoyable today, even something small.",
166
+ is_read=False,
167
+ ),
168
+ MoodAlert(
169
+ id=str(uuid.uuid4()),
170
+ user_id=user.id,
171
+ created_at=datetime.utcnow() - timedelta(days=1),
172
+ alert_type="low_energy",
173
+ severity="concern",
174
+ message="Your energy has been consistently low for the past 5 days.",
175
+ suggested_action="Even a 10-minute walk can shift your energy. Try it today.",
176
+ is_read=False,
177
+ ),
178
+ ]
179
+ for alert in alerts:
180
+ db.add(alert)
181
+
182
+ # Create sample chat messages
183
+ chats = [
184
+ ChatMessage(
185
+ id=str(uuid.uuid4()),
186
+ user_id=user.id,
187
+ created_at=datetime.utcnow() - timedelta(hours=3),
188
+ role="user",
189
+ content="I'm fine, just tired.",
190
+ ),
191
+ ChatMessage(
192
+ id=str(uuid.uuid4()),
193
+ user_id=user.id,
194
+ created_at=datetime.utcnow() - timedelta(hours=3, seconds=-5),
195
+ role="assistant",
196
+ content="I hear you — and 'tired' can mean so many things. Your voice this week has been a little lower in energy than usual. Is there something specific weighing on you, or is it more of a general fatigue?",
197
+ ),
198
+ ChatMessage(
199
+ id=str(uuid.uuid4()),
200
+ user_id=user.id,
201
+ created_at=datetime.utcnow() - timedelta(hours=2),
202
+ role="user",
203
+ content="Why have I been feeling off lately?",
204
+ ),
205
+ ChatMessage(
206
+ id=str(uuid.uuid4()),
207
+ user_id=user.id,
208
+ created_at=datetime.utcnow() - timedelta(hours=2, seconds=-5),
209
+ role="assistant",
210
+ content="Looking at your last two weeks, your mood started dipping around 18 days ago and your energy has been below your baseline since then. That sometimes happens during seasonal changes or when routines get disrupted. Does anything stand out from around that time?",
211
+ ),
212
+ ]
213
+ for chat in chats:
214
+ db.add(chat)
215
+
216
+ db.commit()
217
+ print(f"[Seed] Created {len(alerts)} alerts and {len(chats)} chat messages.")
218
+ print(f"[Seed] Demo user ID: {user.id}")
219
+ print(f"[Seed] Seeding complete ✓")
220
+
221
+ return user.id
222
+
223
+ finally:
224
+ db.close()
225
+
226
+
227
+ if __name__ == "__main__":
228
+ user_id = seed()
229
+ print(f"\nDemo user ID: {user_id}")
230
+ print("Use this ID in Demo Mode or set NEXT_PUBLIC_DEMO_USER_ID in frontend/.env.local")
backend/services/__init__.py ADDED
File without changes
backend/services/audio_processor.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Audio processing: convert to WAV 16kHz mono and extract librosa features.
3
+ Temp files are deleted immediately after feature extraction.
4
+ """
5
+ import os
6
+ import tempfile
7
+ import numpy as np
8
+ from typing import Optional
9
+
10
+
11
+ def convert_to_wav(input_path: str) -> str:
12
+ """Convert any audio format to WAV 16kHz mono. Returns path to WAV file."""
13
+ try:
14
+ from pydub import AudioSegment
15
+ audio = AudioSegment.from_file(input_path)
16
+ audio = audio.set_frame_rate(16000).set_channels(1)
17
+ wav_fd, wav_path = tempfile.mkstemp(suffix=".wav")
18
+ os.close(wav_fd)
19
+ audio.export(wav_path, format="wav")
20
+ return wav_path
21
+ except Exception as e:
22
+ raise RuntimeError(f"Audio conversion failed: {e}")
23
+
24
+
25
+ def extract_features(wav_path: str) -> dict:
26
+ """
27
+ Extract acoustic features from a 16kHz mono WAV file.
28
+ Returns a dict with pitch, energy, speech_rate, pauses, MFCCs, filler_rate.
29
+ """
30
+ import librosa
31
+
32
+ y, sr = librosa.load(wav_path, sr=16000, mono=True)
33
+ duration = librosa.get_duration(y=y, sr=sr)
34
+
35
+ # ── Pitch ────────────────────────────────────────────────────────────────
36
+ f0, voiced_flag, _ = librosa.pyin(
37
+ y, fmin=librosa.note_to_hz("C2"), fmax=librosa.note_to_hz("C7"), sr=sr
38
+ )
39
+ f0_clean = f0[voiced_flag == 1] if voiced_flag is not None else np.array([])
40
+ pitch_mean = float(np.mean(f0_clean)) if len(f0_clean) > 0 else 0.0
41
+ pitch_std = float(np.std(f0_clean)) if len(f0_clean) > 0 else 0.0
42
+
43
+ # ── Energy / RMS ─────────────────────────────────────────────────────────
44
+ rms = librosa.feature.rms(y=y)[0]
45
+ energy_raw = float(np.sqrt(np.mean(rms ** 2)))
46
+
47
+ # ── Tempo (speech rate proxy) ─────────────────────────────────────────────
48
+ tempo_arr, _ = librosa.beat.beat_track(y=y, sr=sr)
49
+ tempo = float(tempo_arr) if np.isscalar(tempo_arr) else float(tempo_arr[0])
50
+
51
+ # ── Pauses (silence detection) ────────────────────────────────────────────
52
+ intervals = librosa.effects.split(y, top_db=30)
53
+ pauses = []
54
+ for i in range(1, len(intervals)):
55
+ gap = (intervals[i][0] - intervals[i - 1][1]) / sr
56
+ if gap > 0.2:
57
+ pauses.append(gap)
58
+ pause_count = len(pauses)
59
+ avg_pause_duration = float(np.mean(pauses)) if pauses else 0.0
60
+
61
+ # ── MFCCs ────────────────────────────────────────────────────────────────
62
+ mfccs = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
63
+ mfcc_means = [round(float(np.mean(mfccs[i])), 4) for i in range(13)]
64
+
65
+ # ── Filler Rate (estimate via zero crossing rate) ─────────────────────────
66
+ # High ZCR in quiet segments indicates filler sounds (um, uh, er)
67
+ zcr = librosa.feature.zero_crossing_rate(y)[0]
68
+ filler_rate = float(np.mean(zcr)) * 2 # scale to ~0–1 range
69
+
70
+ return {
71
+ "pitch_mean": round(pitch_mean, 2),
72
+ "pitch_std": round(pitch_std, 2),
73
+ "energy_raw": round(energy_raw, 6),
74
+ "speech_rate": round(tempo, 2),
75
+ "pause_count": pause_count,
76
+ "avg_pause_duration": round(avg_pause_duration, 3),
77
+ "filler_rate": round(filler_rate, 4),
78
+ "mfcc_features": mfcc_means,
79
+ "duration_seconds": round(duration, 2),
80
+ }
backend/services/emotion_analyzer.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Emotion analysis using wav2vec2.
3
+ Model: ehcalabres/wav2vec2-lg-xlsr-en-speech-emotion-recognition
4
+
5
+ Lazy-loaded on first call to avoid slow startup.
6
+ Falls back to a heuristic if the model is unavailable.
7
+ """
8
+ import numpy as np
9
+ from typing import Optional
10
+
11
+ _pipeline = None
12
+
13
+
14
+ def _load_model():
15
+ global _pipeline
16
+ if _pipeline is None:
17
+ try:
18
+ from transformers import pipeline as hf_pipeline
19
+ _pipeline = hf_pipeline(
20
+ "audio-classification",
21
+ model="ehcalabres/wav2vec2-lg-xlsr-en-speech-emotion-recognition",
22
+ )
23
+ print("[EmotionAnalyzer] wav2vec2 model loaded.")
24
+ except Exception as e:
25
+ print(f"[EmotionAnalyzer] WARNING: Could not load model: {e}")
26
+ _pipeline = "fallback"
27
+ return _pipeline
28
+
29
+
30
+ def classify_emotion(audio_path: str) -> dict:
31
+ """
32
+ Returns {"emotion": str, "confidence": float}
33
+ emotion is one of: happy, sad, angry, fearful, disgust, neutral, surprised
34
+ """
35
+ model = _load_model()
36
+
37
+ if model == "fallback" or model is None:
38
+ return _heuristic_emotion(audio_path)
39
+
40
+ try:
41
+ results = model(audio_path, top_k=5)
42
+ # results is a list of {"label": str, "score": float}
43
+ top = results[0]
44
+ label = top["label"].lower()
45
+ # Normalise labels from the model
46
+ label_map = {
47
+ "hap": "happy", "happy": "happy",
48
+ "sad": "sad",
49
+ "ang": "angry", "angry": "angry",
50
+ "fea": "fearful", "fearful": "fearful",
51
+ "dis": "disgust", "disgust": "disgust",
52
+ "neu": "neutral", "neutral": "neutral",
53
+ "sur": "surprised", "surprised": "surprised",
54
+ }
55
+ emotion = label_map.get(label, label)
56
+ return {"emotion": emotion, "confidence": round(float(top["score"]), 4)}
57
+ except Exception as e:
58
+ print(f"[EmotionAnalyzer] Inference error: {e}")
59
+ return _heuristic_emotion(audio_path)
60
+
61
+
62
+ def _heuristic_emotion(audio_path: str) -> dict:
63
+ """Simple energy-based fallback when the model isn't available."""
64
+ try:
65
+ import librosa
66
+ y, sr = librosa.load(audio_path, sr=16000, mono=True)
67
+ rms = float(np.sqrt(np.mean(y ** 2)))
68
+ if rms > 0.05:
69
+ return {"emotion": "happy", "confidence": 0.65}
70
+ elif rms > 0.02:
71
+ return {"emotion": "neutral", "confidence": 0.70}
72
+ else:
73
+ return {"emotion": "sad", "confidence": 0.60}
74
+ except Exception:
75
+ return {"emotion": "neutral", "confidence": 0.50}
backend/services/emotion_fusion.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fuses acoustic emotion with text-based emotion to produce a more accurate final prediction.
3
+ """
4
+ from typing import Optional
5
+
6
+ # Affective mapping of emotions to a 2D valence-arousal space (rough approximation)
7
+ # Valence [-1 to 1] (Negative to Positive)
8
+ # Arousal [-1 to 1] (Calm to Excited)
9
+ EMOTION_SPACE = {
10
+ "happy": ( 0.8, 0.6),
11
+ "sad": (-0.8, -0.5),
12
+ "angry": (-0.8, 0.8),
13
+ "fearful": (-0.6, 0.6),
14
+ "anxious": (-0.5, 0.7),
15
+ "disgust": (-0.7, 0.4),
16
+ "surprised": ( 0.4, 0.8),
17
+ "neutral": ( 0.0, 0.0),
18
+ }
19
+
20
+ def fuse_emotions(audio_emotion: dict, text_emotion: Optional[dict]) -> dict:
21
+ """
22
+ Blends audio and text emotions.
23
+ If text_emotion is None or missing, falls back to audio.
24
+ """
25
+ if not text_emotion:
26
+ return audio_emotion
27
+
28
+ a_label = audio_emotion["emotion"]
29
+ a_conf = audio_emotion["confidence"]
30
+
31
+ t_label = text_emotion["emotion"]
32
+ t_conf = text_emotion["confidence"]
33
+
34
+ # If they agree, boost confidence
35
+ if a_label == t_label:
36
+ return {
37
+ "emotion": a_label,
38
+ "confidence": min(0.99, a_conf + (t_conf * 0.2)) # Boost confidence slightly
39
+ }
40
+
41
+ # If they disagree, we do a weighted average in the Valence-Arousal space
42
+ if a_label not in EMOTION_SPACE or t_label not in EMOTION_SPACE:
43
+ # Fallback if unknown labels
44
+ if a_conf > t_conf:
45
+ return audio_emotion
46
+ else:
47
+ return text_emotion
48
+
49
+ a_v, a_a = EMOTION_SPACE[a_label]
50
+ t_v, t_a = EMOTION_SPACE[t_label]
51
+
52
+ # Weights: give slightly more weight to audio for raw "emotion",
53
+ # but adjust based on raw confidence levels
54
+ a_weight = a_conf * 1.2
55
+ t_weight = t_conf * 0.8
56
+ total_weight = a_weight + t_weight
57
+
58
+ if total_weight == 0:
59
+ return audio_emotion
60
+
61
+ fused_v = ((a_v * a_weight) + (t_v * t_weight)) / total_weight
62
+ fused_a = ((a_a * a_weight) + (t_a * t_weight)) / total_weight
63
+
64
+ # Find the closest emotion in the space to the fused point
65
+ closest_emotion = "neutral"
66
+ min_dist = float('inf')
67
+
68
+ for label, (v, a) in EMOTION_SPACE.items():
69
+ dist = ((v - fused_v)**2 + (a - fused_a)**2)**0.5
70
+ if dist < min_dist:
71
+ min_dist = dist
72
+ closest_emotion = label
73
+
74
+ # The new confidence is a blend of the two previous confidences
75
+ fused_confidence = (a_conf + t_conf) / 2.0
76
+
77
+ # If the distance is very large, it means the audio and text are diametrically opposed
78
+ # (e.g. happy words, sad voice - sarcasm/masking). We might default back to audio as the "truer" signal
79
+ if min_dist > 1.0:
80
+ return {"emotion": a_label, "confidence": a_conf * 0.8} # Lower confidence due to conflict
81
+
82
+ return {
83
+ "emotion": closest_emotion,
84
+ "confidence": round(fused_confidence, 4)
85
+ }
backend/services/gpt_service.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenAI GPT service for:
3
+ 1. generate_insight() — single-turn analysis insight after a recording
4
+ 2. chat_response() — multi-turn AI companion chat with user history context
5
+ """
6
+ import os
7
+ from typing import Optional, List
8
+ from dotenv import load_dotenv
9
+
10
+ load_dotenv()
11
+
12
+ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
13
+ OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "meta-llama/llama-3-8b-instruct")
14
+
15
+ SYSTEM_PROMPT = """You are InnerVoice, a warm and gentle emotional wellness companion. You are NOT a therapist or medical professional — always make this clear when appropriate.
16
+
17
+ You have access to the user's voice analysis history including their daily emotions, mood scores (energy, calmness, mood, clarity each 0-100), and transcriptions.
18
+
19
+ Your personality:
20
+ - Warm, empathetic, non-judgmental
21
+ - You notice patterns others might miss
22
+ - You gently challenge 'I'm fine' when the voice data suggests otherwise
23
+ - You reference specific data points: 'Your energy dropped 15% since Tuesday'
24
+ - You remind users what helped them before: 'Last time you felt this way, calling your sister seemed to help'
25
+ - You never diagnose, prescribe, or replace professional help
26
+ - If someone seems in crisis, you gently ask and provide crisis resources
27
+ - Keep responses concise — 2-3 sentences usually, longer only when needed
28
+
29
+ Current user context will be provided with each message."""
30
+
31
+
32
+ def _get_client():
33
+ if not OPENROUTER_API_KEY or OPENROUTER_API_KEY == "your_openrouter_api_key_here":
34
+ return None
35
+ try:
36
+ from openai import OpenAI
37
+ return OpenAI(
38
+ base_url="https://openrouter.ai/api/v1",
39
+ api_key=OPENROUTER_API_KEY,
40
+ )
41
+ except Exception:
42
+ return None
43
+
44
+
45
+ def generate_insight(
46
+ emotion: str,
47
+ confidence: float,
48
+ mood_scores: dict,
49
+ transcription: str,
50
+ features: dict,
51
+ ) -> str:
52
+ """Generate a warm, personalised insight after a voice check-in."""
53
+ client = _get_client()
54
+ if client is None:
55
+ return _fallback_insight(emotion, mood_scores)
56
+
57
+ prompt = f"""The user just completed a voice check-in. Here is the analysis:
58
+
59
+ Detected emotion: {emotion} (confidence: {confidence:.0%})
60
+ Mood scores: Energy={mood_scores['energy']}/100, Calmness={mood_scores['calmness']}/100, Mood={mood_scores['mood']}/100, Clarity={mood_scores['clarity']}/100
61
+ What they said: "{transcription or '(no transcription)'}"
62
+ Acoustic metrics: pitch_mean={features.get('pitch_mean', 0):.1f}Hz, speech_rate={features.get('speech_rate', 0):.0f} BPM, pause_count={features.get('pause_count', 0)}
63
+
64
+ Write a warm 2-3 sentence insight about their emotional state right now. Be empathetic and specific. Do NOT start with "I". Do NOT use clinical language."""
65
+
66
+ try:
67
+ response = client.chat.completions.create(
68
+ model=OPENROUTER_MODEL,
69
+ messages=[
70
+ {"role": "system", "content": SYSTEM_PROMPT},
71
+ {"role": "user", "content": prompt},
72
+ ],
73
+ max_tokens=150,
74
+ temperature=0.7,
75
+ )
76
+ return response.choices[0].message.content.strip()
77
+ except Exception as e:
78
+ print(f"[GPTService] generate_insight error: {e}")
79
+ return _fallback_insight(emotion, mood_scores)
80
+
81
+
82
+ def chat_response(
83
+ user_message: str,
84
+ recent_entries: list,
85
+ conversation_history: List[dict],
86
+ ) -> str:
87
+ """Generate an AI companion chat response with full user context."""
88
+ client = _get_client()
89
+
90
+ # Build context from recent entries
91
+ context_lines = []
92
+ for entry in recent_entries[-7:]:
93
+ date_str = entry.created_at.strftime("%b %d") if hasattr(entry.created_at, "strftime") else str(entry.created_at)
94
+ context_lines.append(
95
+ f" {date_str}: {entry.primary_emotion} (energy={entry.energy_score}, mood={entry.mood_score})"
96
+ )
97
+
98
+ context = "Recent voice check-ins:\n" + "\n".join(context_lines) if context_lines else "No recent check-ins."
99
+
100
+ if client is None:
101
+ return _fallback_chat(user_message, recent_entries)
102
+
103
+ messages = [
104
+ {"role": "system", "content": f"{SYSTEM_PROMPT}\n\n{context}"},
105
+ ]
106
+ # Add conversation history (last 10 exchanges)
107
+ for msg in conversation_history[-20:]:
108
+ messages.append({"role": msg["role"], "content": msg["content"]})
109
+ messages.append({"role": "user", "content": user_message})
110
+
111
+ try:
112
+ response = client.chat.completions.create(
113
+ model=OPENROUTER_MODEL,
114
+ messages=messages,
115
+ max_tokens=300,
116
+ temperature=0.8,
117
+ )
118
+ return response.choices[0].message.content.strip()
119
+ except Exception as e:
120
+ print(f"[GPTService] chat_response error: {e}")
121
+ return _fallback_chat(user_message, recent_entries)
122
+
123
+
124
+ def _fallback_insight(emotion: str, mood_scores: dict) -> str:
125
+ """Template-based insight when OpenAI is unavailable."""
126
+ templates = {
127
+ "happy": f"Your voice radiates warmth and positivity today — your mood score of {mood_scores.get('mood', 0)} reflects that brightness. Keep nurturing what's making you feel this way.",
128
+ "sad": f"Your voice carries some heaviness today, and that's completely okay. Your clarity score of {mood_scores.get('clarity', 0)} suggests you're still expressing yourself — that takes courage.",
129
+ "angry": f"There's some tension in your voice today. Your calmness score of {mood_scores.get('calmness', 0)} reflects that. Whatever is frustrating you deserves acknowledgment.",
130
+ "fearful": f"Your voice shows some anxiety today. Take a breath — your energy score of {mood_scores.get('energy', 0)} means you have reserves to draw on.",
131
+ "neutral": f"Your voice sounds measured and steady today. A mood score of {mood_scores.get('mood', 0)} suggests a balanced state — sometimes that's exactly right.",
132
+ }
133
+ return templates.get(emotion, f"Thank you for checking in today. Your mood score is {mood_scores.get('mood', 50)} — keep showing up for yourself.")
134
+
135
+
136
+ def _fallback_chat(user_message: str, recent_entries: list) -> str:
137
+ """Template chat response when OpenAI is unavailable."""
138
+ lower = user_message.lower()
139
+ if any(w in lower for w in ["fine", "okay", "ok", "good"]):
140
+ if recent_entries:
141
+ last = recent_entries[-1]
142
+ if last.mood_score and last.mood_score < 55:
143
+ return f"I hear you saying you're fine — and I trust that. Your voice yesterday showed a mood score of {last.mood_score}, which is a little lower than usual. Want to explore what's been going on?"
144
+ return "I'm glad you're okay. Your check-ins are helping build a picture of your emotional patterns over time."
145
+ if any(w in lower for w in ["help", "support", "crisis", "bad", "awful"]):
146
+ return "I'm here with you. If you're in crisis, please reach out to a crisis line: 988 (Suicide & Crisis Lifeline) or text HOME to 741741. You don't have to face this alone."
147
+ if "trend" in lower or "pattern" in lower or "week" in lower:
148
+ if recent_entries and len(recent_entries) >= 3:
149
+ moods = [e.mood_score for e in recent_entries[-7:] if e.mood_score]
150
+ if moods:
151
+ avg = sum(moods) / len(moods)
152
+ return f"Looking at your recent check-ins, your average mood score is {avg:.0f}/100. {'You seem to be in a positive stretch — keep it going!' if avg > 60 else 'There are some fluctuations. What feels different lately?'}"
153
+ return "Thank you for sharing that with me. I'm here to listen and help you notice patterns over time. Would you like to do a voice check-in today?"
backend/services/mood_calculator.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mood score calculation logic as specified in the InnerVoice requirements.
3
+ """
4
+
5
+
6
+ def calculate_mood_scores(
7
+ emotion: str,
8
+ confidence: float,
9
+ pitch_mean: float,
10
+ pitch_std: float,
11
+ energy: float,
12
+ tempo: float,
13
+ avg_pause: float,
14
+ filler_rate: float,
15
+ ) -> dict:
16
+ """Calculate Energy, Calmness, Mood, and Clarity scores (0-100)."""
17
+
18
+ # ── Energy Score ──────────────────────────────────────────────────────────
19
+ energy_score = min(100, int(energy * 5000))
20
+ if emotion == "happy":
21
+ energy_score = min(100, energy_score + 15)
22
+ if emotion == "sad":
23
+ energy_score = max(0, energy_score - 20)
24
+
25
+ # ── Calmness Score ────────────────────────────────────────────────────────
26
+ calmness_score = 100
27
+ calmness_score -= min(40, int(filler_rate * 500)) # fillers reduce calmness
28
+ calmness_score -= min(30, int(pitch_std / 5)) # pitch instability
29
+ if emotion == "angry":
30
+ calmness_score -= 25
31
+ if emotion == "fearful":
32
+ calmness_score -= 30
33
+ calmness_score = max(0, calmness_score)
34
+
35
+ # ── Mood Score ────────────────────────────────────────────────────────────
36
+ emotion_mood_map = {
37
+ "happy": 85,
38
+ "neutral": 60,
39
+ "sad": 30,
40
+ "angry": 40,
41
+ "fearful": 35,
42
+ "disgust": 35,
43
+ "surprised": 65,
44
+ }
45
+ base_mood = emotion_mood_map.get(emotion, 50)
46
+ mood_score = int(base_mood * confidence + 50 * (1 - confidence))
47
+
48
+ # ── Clarity Score ─────────────────────────────────────────────────────────
49
+ clarity_score = 100
50
+ clarity_score -= min(30, int(avg_pause * 50)) # long pauses reduce clarity
51
+ clarity_score -= min(30, int(filler_rate * 300)) # fillers reduce clarity
52
+ clarity_score = max(0, clarity_score)
53
+
54
+ return {
55
+ "energy": energy_score,
56
+ "calmness": calmness_score,
57
+ "mood": mood_score,
58
+ "clarity": clarity_score,
59
+ }
backend/services/text_emotion.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Emotion analysis using transcript text.
3
+ Model: SamLowe/roberta-base-go_emotions (lazy-loaded).
4
+ """
5
+ from typing import Optional
6
+
7
+ _pipeline = None
8
+
9
+ def _load_model():
10
+ global _pipeline
11
+ if _pipeline is None:
12
+ try:
13
+ from transformers import pipeline as hf_pipeline
14
+ _pipeline = hf_pipeline(
15
+ "text-classification",
16
+ model="SamLowe/roberta-base-go_emotions",
17
+ )
18
+ print("[TextEmotionAnalyzer] roberta-base-go_emotions model loaded.")
19
+ except Exception as e:
20
+ print(f"[TextEmotionAnalyzer] WARNING: Could not load model: {e}")
21
+ _pipeline = "fallback"
22
+ return _pipeline
23
+
24
+ def classify_text_emotion(text: str) -> Optional[dict]:
25
+ """
26
+ Returns {"emotion": str, "confidence": float}
27
+ """
28
+ if not text or not text.strip():
29
+ return None
30
+
31
+ model = _load_model()
32
+ if model == "fallback" or model is None:
33
+ return None
34
+
35
+ try:
36
+ results = model(text, top_k=5)
37
+ top = results[0]
38
+ label = top["label"].lower()
39
+
40
+ # Map go_emotions labels to our 7 core categories
41
+ label_map = {
42
+ "joy": "happy", "amusement": "happy", "approval": "happy", "excitement": "happy", "optimism": "happy", "pride": "happy", "relief": "happy",
43
+ "sadness": "sad", "disappointment": "sad", "grief": "sad", "remorse": "sad",
44
+ "anger": "angry", "annoyance": "angry", "disapproval": "angry",
45
+ "fear": "fearful", "nervousness": "anxious",
46
+ "disgust": "disgust",
47
+ "surprise": "surprised", "realization": "surprised",
48
+ "neutral": "neutral"
49
+ }
50
+
51
+ # If it doesn't map directly, see if we can find a close match, else neutral
52
+ emotion = label_map.get(label, "neutral")
53
+
54
+ return {"emotion": emotion, "confidence": round(float(top["score"]), 4)}
55
+ except Exception as e:
56
+ print(f"[TextEmotionAnalyzer] Inference error: {e}")
57
+ return None
backend/services/transcriber.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Whisper transcription service.
3
+ Lazy-loaded on first call. Falls back to empty string if unavailable.
4
+ """
5
+
6
+ _model = None
7
+
8
+
9
+ def _load_model():
10
+ global _model
11
+ if _model is None:
12
+ try:
13
+ import whisper
14
+ _model = whisper.load_model("base")
15
+ print("[Transcriber] Whisper base model loaded.")
16
+ except Exception as e:
17
+ print(f"[Transcriber] WARNING: Could not load Whisper: {e}")
18
+ _model = "unavailable"
19
+ return _model
20
+
21
+
22
+ def transcribe(wav_path: str) -> str:
23
+ """Transcribe a WAV file and return the text. Returns '' on failure."""
24
+ model = _load_model()
25
+ if model == "unavailable" or model is None:
26
+ return ""
27
+ try:
28
+ result = model.transcribe(wav_path, language="en")
29
+ return result.get("text", "").strip()
30
+ except Exception as e:
31
+ print(f"[Transcriber] Transcription error: {e}")
32
+ return ""
backend/services/trend_detector.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Trend detection and alert generation — checks user history for concerning patterns.
3
+ """
4
+ from statistics import mean
5
+ from typing import List
6
+
7
+
8
+ def check_trends(user_history: list) -> List[dict]:
9
+ """
10
+ Analyse a user's voice entry history and return a list of alert dicts.
11
+ Each entry in user_history must expose:
12
+ .mood_score (int), .energy_score (int), .primary_emotion (str)
13
+ """
14
+ alerts = []
15
+
16
+ if len(user_history) < 7:
17
+ return alerts
18
+
19
+ recent_7 = user_history[-7:]
20
+ recent_mood = mean([e.mood_score for e in recent_7])
21
+ recent_energy = [e.energy_score for e in recent_7]
22
+
23
+ # ── Week-over-week mood decline ───────────────────────────────────────────
24
+ if len(user_history) >= 14:
25
+ prev_7 = user_history[-14:-7]
26
+ prev_mood = mean([e.mood_score for e in prev_7])
27
+ if prev_mood > 0:
28
+ decline = ((prev_mood - recent_mood) / prev_mood) * 100
29
+ if decline > 20:
30
+ alerts.append(
31
+ {
32
+ "type": "mood_decline",
33
+ "severity": "warning",
34
+ "message": (
35
+ f"Your mood has declined {decline:.0f}% this week "
36
+ f"compared to last week."
37
+ ),
38
+ "suggested_action": (
39
+ "Try scheduling something enjoyable today, even something small."
40
+ ),
41
+ }
42
+ )
43
+
44
+ # ── Sustained low energy ──────────────────────────────────────────────────
45
+ if all(e < 40 for e in recent_energy):
46
+ alerts.append(
47
+ {
48
+ "type": "low_energy",
49
+ "severity": "concern",
50
+ "message": "Your energy has been consistently low for 7 days.",
51
+ "suggested_action": (
52
+ "Consider your sleep, hydration, and movement. "
53
+ "Even a 10-minute walk can help."
54
+ ),
55
+ }
56
+ )
57
+
58
+ # ── Persistent sadness ────────────────────────────────────────────────────
59
+ sad_days = sum(1 for e in recent_7 if e.primary_emotion == "sad")
60
+ if sad_days >= 5:
61
+ alerts.append(
62
+ {
63
+ "type": "persistent_sadness",
64
+ "severity": "concern",
65
+ "message": (
66
+ "You have sounded sad for 5 out of the last 7 days. "
67
+ "Consider reaching out to someone you trust."
68
+ ),
69
+ "suggested_action": (
70
+ "Talk to a friend, family member, or professional. You don't have to carry this alone."
71
+ ),
72
+ }
73
+ )
74
+
75
+ # ── High anxiety ──────────────────────────────────────────────────────────
76
+ anxious_days = sum(
77
+ 1 for e in recent_7 if e.primary_emotion in ("fearful", "angry")
78
+ )
79
+ anxious_calms = [e.calmness_score for e in recent_7 if hasattr(e, "calmness_score")]
80
+ avg_calmness = mean(anxious_calms) if anxious_calms else 100
81
+ if anxious_days >= 4 or avg_calmness < 35:
82
+ alerts.append(
83
+ {
84
+ "type": "high_anxiety",
85
+ "severity": "warning",
86
+ "message": "Signs of elevated anxiety detected across your recent check-ins.",
87
+ "suggested_action": (
88
+ "Try a 5-minute breathing exercise: inhale 4s, hold 4s, exhale 6s."
89
+ ),
90
+ }
91
+ )
92
+
93
+ return alerts
frontend/.env.local.example ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ NEXTAUTH_URL=http://localhost:3000
2
+ NEXTAUTH_SECRET=change_me_to_a_random_secret_32chars
3
+
4
+ GOOGLE_CLIENT_ID=your_google_client_id
5
+ GOOGLE_CLIENT_SECRET=your_google_client_secret
6
+
7
+ GITHUB_CLIENT_ID=your_github_client_id
8
+ GITHUB_CLIENT_SECRET=your_github_client_secret
9
+
10
+ NEXT_PUBLIC_API_URL=http://localhost:8000
11
+ NEXT_PUBLIC_DEMO_USER_ID=demo-user-id-from-seed-script
12
+ NEXT_PUBLIC_DEMO_MODE=true
frontend/.eslintrc.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {
2
+ "extends": ["next/core-web-vitals", "next/typescript"]
3
+ }
frontend/.gitignore ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.js
7
+ .yarn/install-state.gz
8
+
9
+ # testing
10
+ /coverage
11
+
12
+ # next.js
13
+ /.next/
14
+ /out/
15
+
16
+ # production
17
+ /build
18
+
19
+ # misc
20
+ .DS_Store
21
+ *.pem
22
+
23
+ # debug
24
+ npm-debug.log*
25
+ yarn-debug.log*
26
+ yarn-error.log*
27
+
28
+ # local env files
29
+ .env*.local
30
+
31
+ # vercel
32
+ .vercel
33
+
34
+ # typescript
35
+ *.tsbuildinfo
36
+ next-env.d.ts
frontend/README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2
+
3
+ ## Getting Started
4
+
5
+ First, run the development server:
6
+
7
+ ```bash
8
+ npm run dev
9
+ # or
10
+ yarn dev
11
+ # or
12
+ pnpm dev
13
+ # or
14
+ bun dev
15
+ ```
16
+
17
+ Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18
+
19
+ You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20
+
21
+ This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22
+
23
+ ## Learn More
24
+
25
+ To learn more about Next.js, take a look at the following resources:
26
+
27
+ - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28
+ - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29
+
30
+ You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31
+
32
+ ## Deploy on Vercel
33
+
34
+ The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35
+
36
+ Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
frontend/app/chat/page.tsx ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState, useEffect, useRef } from "react";
3
+ import { useDemoMode } from "@/hooks/useDemoMode";
4
+ import { api } from "@/lib/api";
5
+ import { DEMO_CHAT_HISTORY } from "@/lib/demo-data";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ interface Message {
9
+ id: string;
10
+ role: "user" | "assistant";
11
+ content: string;
12
+ }
13
+
14
+ const SUGGESTED_ACTIONS = [
15
+ "How am I doing lately?",
16
+ "What helped me before?",
17
+ "I'm feeling overwhelmed",
18
+ ];
19
+
20
+ export default function ChatPage() {
21
+ const { isDemoMode, userId } = useDemoMode();
22
+ const [messages, setMessages] = useState<Message[]>([]);
23
+ const [input, setInput] = useState("");
24
+ const [loading, setLoading] = useState(false);
25
+ const [initialLoad, setInitialLoad] = useState(true);
26
+ const bottomRef = useRef<HTMLDivElement>(null);
27
+
28
+ useEffect(() => {
29
+ async function load() {
30
+ if (!userId) return;
31
+ try {
32
+ if (isDemoMode) {
33
+ setMessages(DEMO_CHAT_HISTORY as Message[]);
34
+ } else {
35
+ const hist = await api.getChatHistory(userId);
36
+ setMessages(hist as Message[]);
37
+ }
38
+ } catch (e) {
39
+ console.error(e);
40
+ } finally {
41
+ setInitialLoad(false);
42
+ }
43
+ }
44
+ load();
45
+ }, [userId, isDemoMode]);
46
+
47
+ useEffect(() => {
48
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
49
+ }, [messages, loading]);
50
+
51
+ const handleSend = async (text: string) => {
52
+ if (!text.trim() || !userId) return;
53
+
54
+ const userMsg: Message = { id: Date.now().toString(), role: "user", content: text };
55
+ setMessages((prev) => [...prev, userMsg]);
56
+ setInput("");
57
+ setLoading(true);
58
+
59
+ try {
60
+ if (isDemoMode) {
61
+ await new Promise(r => setTimeout(r, 1500));
62
+ import("@/lib/demo-data").then(({ DEMO_LATEST_ENTRY }) => {
63
+ let reply = "I'm here for you. Since we're in Demo Mode without a live API key, my responses are limited. But your voice check-ins show you're doing okay.";
64
+ if (text.toLowerCase().includes("overwhelmed")) {
65
+ reply = "It's completely understandable to feel overwhelmed. When you felt this way two weeks ago, going for a short walk seemed to help shift your energy. Would you be open to trying that today?";
66
+ }
67
+ setMessages(prev => [...prev, { id: Date.now().toString(), role: "assistant", content: reply }]);
68
+ setLoading(false);
69
+ });
70
+ } else {
71
+ const res = await api.chat(userId, text);
72
+ setMessages(prev => [...prev, { id: Date.now().toString(), role: "assistant", content: res.response }]);
73
+ }
74
+ } catch (e) {
75
+ console.error(e);
76
+ setMessages(prev => [...prev, { id: Date.now().toString(), role: "assistant", content: "Sorry, I couldn't reach the server right now. But I am still here tracking your trends." }]);
77
+ } finally {
78
+ setLoading(false);
79
+ }
80
+ };
81
+
82
+ if (initialLoad) return <div className="p-10 flex justify-center"><div className="w-8 h-8 rounded-full border-2 border-purple-500 border-t-transparent animate-spin"/></div>;
83
+
84
+ return (
85
+ <div className="max-w-4xl mx-auto h-[calc(100vh-theme(spacing.16))] md:h-screen flex flex-col pt-6 pb-6 px-4 md:px-6">
86
+
87
+ <div className="flex-none pb-4 border-b border-white/10 mb-4">
88
+ <h1 className="text-2xl font-bold flex items-center gap-3">
89
+ InnerVoice Companion <span className="text-xs px-2 py-1 bg-white/5 rounded-md text-white/50 font-normal">AI</span>
90
+ </h1>
91
+ <p className="text-sm text-white/50 mt-1">A safe space to explore your patterns. (Not a therapist)</p>
92
+ </div>
93
+
94
+ <div className="flex-1 overflow-y-auto space-y-6 pr-2 custom-scrollbar">
95
+ {messages.length === 0 && (
96
+ <div className="h-full flex flex-col items-center justify-center text-center space-y-4 opacity-50">
97
+ <div className="text-4xl">👋</div>
98
+ <p className="text-sm">I'm here to listen and help you notice patterns in your emotional history.</p>
99
+ </div>
100
+ )}
101
+
102
+ {messages.map((m) => (
103
+ <div key={m.id} className={cn("flex w-full", m.role === "user" ? "justify-end" : "justify-start")}>
104
+ <div
105
+ className={cn(
106
+ "max-w-[85%] md:max-w-[75%] rounded-2xl p-4 text-sm leading-relaxed shadow-lg",
107
+ m.role === "user"
108
+ ? "bg-purple-600/80 text-white rounded-tr-sm border border-purple-500/50"
109
+ : "bg-white/5 text-white/90 rounded-tl-sm border border-white/10"
110
+ )}
111
+ >
112
+ {m.content}
113
+ </div>
114
+ </div>
115
+ ))}
116
+
117
+ {loading && (
118
+ <div className="flex justify-start">
119
+ <div className="bg-white/5 border border-white/10 rounded-2xl rounded-tl-sm p-4 w-20 flex justify-center gap-1">
120
+ <div className="w-2 h-2 bg-white/40 rounded-full animate-bounce [animation-delay:-0.3s]" />
121
+ <div className="w-2 h-2 bg-white/40 rounded-full animate-bounce [animation-delay:-0.15s]" />
122
+ <div className="w-2 h-2 bg-white/40 rounded-full animate-bounce" />
123
+ </div>
124
+ </div>
125
+ )}
126
+ <div ref={bottomRef} />
127
+ </div>
128
+
129
+ <div className="flex-none pt-4">
130
+ {/* Quick action chips */}
131
+ <div className="flex gap-2 overflow-x-auto pb-4 scrollbar-hide">
132
+ {SUGGESTED_ACTIONS.map(a => (
133
+ <button
134
+ key={a}
135
+ onClick={() => handleSend(a)}
136
+ disabled={loading}
137
+ className="whitespace-nowrap px-4 py-2 rounded-full border border-white/10 bg-white/5 text-xs text-white/70 hover:bg-white/10 hover:text-white transition-colors"
138
+ >
139
+ {a}
140
+ </button>
141
+ ))}
142
+ </div>
143
+
144
+ {/* Input bar */}
145
+ <form
146
+ onSubmit={(e) => { e.preventDefault(); handleSend(input); }}
147
+ className="flex gap-2 relative shadow-glow-purple bg-black/20 rounded-2xl p-2 border border-white/10"
148
+ >
149
+ <input
150
+ type="text"
151
+ value={input}
152
+ onChange={(e) => setInput(e.target.value)}
153
+ disabled={loading}
154
+ placeholder="Ask about your emotional patterns..."
155
+ className="flex-1 bg-transparent border-none outline-none px-4 text-sm placeholder:text-white/30 text-white"
156
+ />
157
+ <button
158
+ type="submit"
159
+ disabled={!input.trim() || loading}
160
+ className="w-10 h-10 rounded-xl bg-purple-600 disabled:opacity-50 disabled:bg-white/10 flex items-center justify-center transition-colors hover:bg-purple-500"
161
+ >
162
+
163
+ </button>
164
+ </form>
165
+ </div>
166
+
167
+ </div>
168
+ );
169
+ }
frontend/app/dashboard/page.tsx ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import Link from "next/link";
4
+ import { api, VoiceEntry, MoodAlert, TrendsData } from "@/lib/api";
5
+ import { DEMO_ENTRIES, DEMO_ALERTS, DEMO_TRENDS, DEMO_LATEST_ENTRY } from "@/lib/demo-data";
6
+ import { useDemoMode } from "@/hooks/useDemoMode";
7
+ import CircularProgress from "@/components/CircularProgress";
8
+ import MoodTimeline from "@/components/MoodTimeline";
9
+ import AlertCard from "@/components/AlertCard";
10
+ import EmotionBadge from "@/components/EmotionBadge";
11
+ import { getMoodEmoji, capitalise } from "@/lib/utils";
12
+
13
+ export default function DashboardPage() {
14
+ const { isDemoMode, userId } = useDemoMode();
15
+ const [entries, setEntries] = useState<VoiceEntry[]>([]);
16
+ const [alerts, setAlerts] = useState<MoodAlert[]>([]);
17
+ const [trends, setTrends] = useState<TrendsData | null>(null);
18
+ const [loading, setLoading] = useState(true);
19
+
20
+ useEffect(() => {
21
+ async function loadData() {
22
+ if (!userId) return;
23
+ try {
24
+ if (isDemoMode) {
25
+ setEntries([DEMO_LATEST_ENTRY, ...DEMO_ENTRIES].slice(0, 15));
26
+ setAlerts(DEMO_ALERTS);
27
+ setTrends(DEMO_TRENDS);
28
+ } else {
29
+ const [eRes, aRes, tRes] = await Promise.all([
30
+ api.getEntries(userId, 14),
31
+ api.getAlerts(userId),
32
+ api.getTrends(userId),
33
+ ]);
34
+ setEntries(eRes);
35
+ setAlerts(aRes);
36
+ setTrends(tRes);
37
+ }
38
+ } catch (err) {
39
+ console.error("Failed to load dashboard data", err);
40
+ } finally {
41
+ setLoading(false);
42
+ }
43
+ }
44
+ loadData();
45
+ }, [userId, isDemoMode]);
46
+
47
+ const handleDismissAlert = async (id: string) => {
48
+ setAlerts((prev) => prev.filter((a) => a.id !== id));
49
+ if (!isDemoMode) {
50
+ try {
51
+ await api.markAlertRead(id);
52
+ } catch (e) {
53
+ console.error("Failed to dismiss", e);
54
+ }
55
+ }
56
+ };
57
+
58
+ if (loading) {
59
+ return <div className="p-8 flex items-center justify-center min-h-[50vh]"><div className="w-8 h-8 rounded-full border-2 border-purple-500 border-t-transparent animate-spin" /></div>;
60
+ }
61
+
62
+ const today = entries.length > 0 ? entries[0] : null;
63
+
64
+ return (
65
+ <div className="p-6 md:p-10 max-w-7xl mx-auto space-y-8 animate-fade-in relative z-10 min-h-screen">
66
+ <div className="flex items-center justify-between">
67
+ <h1 className="text-3xl font-bold">Dashboard</h1>
68
+ <Link
69
+ href="/record"
70
+ className="px-5 py-2.5 bg-gradient-to-r from-purple-600 to-teal-500 hover:from-purple-500 hover:to-teal-400 rounded-xl font-medium text-white shadow-glow-purple transition-all hover:scale-105 active:scale-95 flex items-center gap-2"
71
+ >
72
+ <span>🎙️</span>
73
+ <span className="hidden sm:inline">New Check-in</span>
74
+ </Link>
75
+ </div>
76
+
77
+ <div className="grid md:grid-cols-3 gap-6">
78
+ {/* Main Weather Card */}
79
+ <div className="md:col-span-2 space-y-6">
80
+ <div className="glass-card p-6 md:p-8">
81
+ <h2 className="text-xl font-semibold mb-6 flex items-center gap-2">
82
+ <span>Today's Weather</span>
83
+ <span className="text-xs font-normal text-white/50 bg-white/5 px-2 py-0.5 rounded-full">
84
+ {new Date().toLocaleDateString()}
85
+ </span>
86
+ </h2>
87
+
88
+ {today ? (
89
+ <div className="flex flex-col md:flex-row gap-8 items-center bg-black/20 rounded-2xl p-6 border border-white/5">
90
+ <div className="flex flex-col items-center">
91
+ <div className="text-8xl w-32 h-32 flex items-center justify-center bg-white/5 rounded-full shadow-[inset_0_0_20px_rgba(255,255,255,0.05)] border border-white/10">
92
+ {getMoodEmoji(today.mood_score)}
93
+ </div>
94
+ <div className="mt-4">
95
+ <EmotionBadge emotion={today.primary_emotion} confidence={today.emotion_confidence} />
96
+ </div>
97
+ </div>
98
+
99
+ <div className="flex-1 w-full relative">
100
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
101
+ <CircularProgress score={today.energy_score} label="Energy" size={80} />
102
+ <CircularProgress score={today.calmness_score} label="Calmness" size={80} />
103
+ <CircularProgress score={today.mood_score} label="Mood" size={80} />
104
+ <CircularProgress score={today.clarity_score} label="Clarity" size={80} />
105
+ </div>
106
+
107
+ <div className="mt-8 pt-6 border-t border-white/10">
108
+ <div className="text-xs uppercase tracking-wider text-white/40 mb-2 font-semibold">You said:</div>
109
+ <p className="text-white/80 italic leading-relaxed text-sm">
110
+ "{today.transcription}"
111
+ </p>
112
+ </div>
113
+ </div>
114
+ </div>
115
+ ) : (
116
+ <div className="text-center py-12 flex flex-col items-center">
117
+ <div className="text-5xl mb-4 opacity-50">😶</div>
118
+ <p className="text-white/50 mb-4">You haven't checked in today.</p>
119
+ <Link href="/record" className="text-purple-400 hover:text-purple-300 font-medium">Record now →</Link>
120
+ </div>
121
+ )}
122
+ </div>
123
+
124
+ <div className="glass-card p-6">
125
+ <div className="flex justify-between items-end mb-6">
126
+ <h2 className="text-xl font-semibold">Recent Trend</h2>
127
+ <Link href="/timeline" className="text-sm text-purple-400 hover:text-purple-300 transition-colors">Full Timeline →</Link>
128
+ </div>
129
+ {entries.length > 1 ? (
130
+ <MoodTimeline entries={[...entries].reverse()} height={250} />
131
+ ) : (
132
+ <div className="h-[250px] flex items-center justify-center text-white/30 text-sm">Need more data</div>
133
+ )}
134
+ </div>
135
+ </div>
136
+
137
+ {/* Sidebar */}
138
+ <div className="space-y-6">
139
+ <div className="glass-card p-6 flex items-center justify-between">
140
+ <div>
141
+ <div className="text-xs font-semibold uppercase tracking-wider text-white/50 mb-1">Check-in Streak</div>
142
+ <div className="text-3xl font-bold flex items-baseline gap-2">
143
+ {trends?.streak ?? 0} <span className="text-base font-normal text-white/40">days</span>
144
+ </div>
145
+ </div>
146
+ <div className="text-4xl">🔥</div>
147
+ </div>
148
+
149
+ {alerts.length > 0 && (
150
+ <div className="space-y-3">
151
+ <h3 className="text-sm font-semibold uppercase tracking-wider text-white/50 ml-1">Active Alerts</h3>
152
+ {alerts.map((alert) => (
153
+ <AlertCard key={alert.id} {...alert} onDismiss={handleDismissAlert} />
154
+ ))}
155
+ </div>
156
+ )}
157
+
158
+ {alerts.length === 0 && (
159
+ <div className="glass-card p-5 border-green-500/20 bg-green-500/5 flex items-start gap-4">
160
+ <div className="text-2xl mt-0.5">🌱</div>
161
+ <div>
162
+ <h4 className="font-medium text-green-400 mb-1">No Active Alerts</h4>
163
+ <p className="text-sm text-white/60 leading-relaxed">Your emotional patterns are stable. Keep up the daily check-ins.</p>
164
+ </div>
165
+ </div>
166
+ )}
167
+ </div>
168
+ </div>
169
+ </div>
170
+ );
171
+ }
frontend/app/favicon.ico ADDED
frontend/app/fonts/GeistMonoVF.woff ADDED
Binary file (67.9 kB). View file
 
frontend/app/fonts/GeistVF.woff ADDED
Binary file (66.3 kB). View file
 
frontend/app/globals.css ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ --background: #0f0a1e;
7
+ --foreground: #f8f8ff;
8
+ }
9
+
10
+ * {
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ color: var(--foreground);
16
+ background: var(--background);
17
+ font-family: "Inter", system-ui, sans-serif;
18
+ -webkit-font-smoothing: antialiased;
19
+ }
20
+
21
+ /* Custom scrollbar */
22
+ ::-webkit-scrollbar {
23
+ width: 6px;
24
+ height: 6px;
25
+ }
26
+ ::-webkit-scrollbar-track {
27
+ background: rgba(255, 255, 255, 0.03);
28
+ }
29
+ ::-webkit-scrollbar-thumb {
30
+ background: rgba(139, 92, 246, 0.4);
31
+ border-radius: 3px;
32
+ }
33
+ ::-webkit-scrollbar-thumb:hover {
34
+ background: rgba(139, 92, 246, 0.7);
35
+ }
36
+
37
+ /* Gradient text utility */
38
+ .gradient-text {
39
+ background: linear-gradient(135deg, #8B5CF6 0%, #14b8a6 100%);
40
+ -webkit-background-clip: text;
41
+ -webkit-text-fill-color: transparent;
42
+ background-clip: text;
43
+ }
44
+
45
+ /* Glass card */
46
+ .glass-card {
47
+ background: rgba(255, 255, 255, 0.04);
48
+ backdrop-filter: blur(12px);
49
+ border: 1px solid rgba(255, 255, 255, 0.08);
50
+ border-radius: 16px;
51
+ }
52
+
53
+ /* Pulse ring animation for record button */
54
+ @keyframes pulseRing {
55
+ 0% { transform: scale(1); opacity: 0.8; }
56
+ 70% { transform: scale(1.6); opacity: 0; }
57
+ 100% { transform: scale(1.6); opacity: 0; }
58
+ }
59
+
60
+ .pulse-ring::before {
61
+ content: "";
62
+ position: absolute;
63
+ inset: -8px;
64
+ border-radius: 50%;
65
+ border: 2px solid #ef4444;
66
+ animation: pulseRing 1.5s ease-out infinite;
67
+ }
68
+
69
+ .pulse-ring::after {
70
+ content: "";
71
+ position: absolute;
72
+ inset: -16px;
73
+ border-radius: 50%;
74
+ border: 2px solid #ef444460;
75
+ animation: pulseRing 1.5s ease-out 0.4s infinite;
76
+ }
77
+
78
+ @layer utilities {
79
+ .text-balance {
80
+ text-wrap: balance;
81
+ }
82
+ }
83
+
84
+ /* Animated background dots */
85
+ .bg-dots {
86
+ background-image: radial-gradient(rgba(139, 92, 246, 0.15) 1px, transparent 1px);
87
+ background-size: 24px 24px;
88
+ }
frontend/app/layout.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Inter } from "next/font/google";
3
+ import "./globals.css";
4
+ import Navbar from "@/components/Navbar";
5
+ import DemoModeBanner from "@/components/DemoModeBanner";
6
+
7
+ const inter = Inter({ subsets: ["latin"] });
8
+
9
+ export const metadata: Metadata = {
10
+ title: "InnerVoice — AI Emotional Wellness Tracker",
11
+ description:
12
+ "Record 60 seconds of your voice each day. AI analyzes your emotions and tracks your mental wellness over time.",
13
+ };
14
+
15
+ export default function RootLayout({
16
+ children,
17
+ }: {
18
+ children: React.ReactNode;
19
+ }) {
20
+ return (
21
+ <html lang="en" className="dark">
22
+ <head>
23
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
24
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
25
+ </head>
26
+ <body className={`${inter.className} bg-[#0f0a1e] text-white min-h-screen`}>
27
+ <DemoModeBanner />
28
+ <Navbar />
29
+ <main className="md:ml-64 min-h-screen pt-10 md:pt-0">{children}</main>
30
+ </body>
31
+ </html>
32
+ );
33
+ }
frontend/app/page.tsx ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import Link from "next/link";
3
+ import { useEffect, useState } from "react";
4
+ import WaveformVisualizer from "@/components/WaveformVisualizer";
5
+
6
+ export default function LandingPage() {
7
+ const [mounted, setMounted] = useState(false);
8
+
9
+ useEffect(() => {
10
+ setMounted(true);
11
+ }, []);
12
+
13
+ return (
14
+ <div className="min-h-screen bg-dark-base relative overflow-hidden flex flex-col pt-16 md:pt-0 pb-16">
15
+ {/* Background ambient visuals */}
16
+ <div className="absolute top-0 left-0 w-full h-full overflow-hidden z-0 pointer-events-none">
17
+ <div className="absolute top-[-20%] left-[-10%] w-[50%] h-[50%] bg-purple-600/20 rounded-full blur-[120px]" />
18
+ <div className="absolute bottom-[-20%] right-[-10%] w-[50%] h-[50%] bg-teal-500/20 rounded-full blur-[120px]" />
19
+ <div className="absolute top-[40%] left-[50%] translate-x-[-50%] w-[80%] h-[30%] bg-coral-500/10 rounded-full blur-[150px]" />
20
+ </div>
21
+
22
+ <div className="container mx-auto px-6 relative z-10 flex-1 flex flex-col justify-center max-w-5xl">
23
+ <div className="text-center space-y-8 animate-slide-up">
24
+ <div className="inline-block px-4 py-1.5 rounded-full bg-white/5 border border-white/10 text-sm font-medium text-purple-300 mb-4 shadow-glow-purple">
25
+ ✨ Your AI Emotional Wellness Tracker
26
+ </div>
27
+
28
+ <h1 className="text-5xl md:text-7xl font-extrabold tracking-tight text-balance mx-auto">
29
+ Your Voice Knows <br className="hidden md:block" />
30
+ <span className="gradient-text">Before You Do</span>
31
+ </h1>
32
+
33
+ <p className="text-lg md:text-xl text-white/60 max-w-2xl mx-auto leading-relaxed">
34
+ 60 seconds of speaking. Zero effort. InnerVoice analyzes acoustic features to detect emotions and track your mental health trends over time.
35
+ </p>
36
+
37
+ <div className="pt-8">
38
+ <Link
39
+ href="/dashboard"
40
+ className="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-2xl bg-white text-purple-950 hover:bg-purple-50 transition-all hover:scale-105 shadow-glow-purple"
41
+ >
42
+ Start Your First Check-in
43
+ </Link>
44
+ </div>
45
+ </div>
46
+
47
+ {/* Fake waveform animation for landing */}
48
+ <div className="mt-20 max-w-3xl mx-auto w-full glass-card p-6 md:p-8 animate-fade-in opacity-80" style={{ animationDelay: "0.2s" }}>
49
+ {mounted && <WaveformVisualizer analyserNode={null} isActive={false} height={120} />}
50
+ </div>
51
+
52
+ {/* How it works */}
53
+ <div className="mt-32 grid md:grid-cols-3 gap-8">
54
+ {[
55
+ { icon: "🎙️", title: "Record", desc: "Speak freely for 60 seconds about your day." },
56
+ { icon: "🧠", title: "Analyze", desc: "AI extracts emotional signals from your vocal tone." },
57
+ { icon: "📈", title: "Track", desc: "View personalized trends and get gentle nudges." }
58
+ ].map((step, i) => (
59
+ <div key={i} className="glass-card p-8 flex flex-col items-center text-center space-y-4 hover:-translate-y-2 transition-transform">
60
+ <div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center text-3xl">
61
+ {step.icon}
62
+ </div>
63
+ <h3 className="text-xl font-bold">{step.title}</h3>
64
+ <p className="text-white/50 leading-relaxed">{step.desc}</p>
65
+ </div>
66
+ ))}
67
+ </div>
68
+ </div>
69
+
70
+ <footer className="mt-auto pt-24 pb-8 text-center relative z-10 px-6">
71
+ <p className="text-sm text-white/40">
72
+ 🔒 <strong className="text-white/60">Privacy First:</strong> Your voice is processed and deleted immediately. We only store insights, never recordings.
73
+ </p>
74
+ </footer>
75
+ </div>
76
+ );
77
+ }
frontend/app/record/page.tsx ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState, useEffect } from "react";
3
+ import { useRouter } from "next/navigation";
4
+ import { useAudioRecorder } from "@/hooks/useAudioRecorder";
5
+ import { useDemoMode } from "@/hooks/useDemoMode";
6
+ import { api, AnalyzeResult } from "@/lib/api";
7
+ import { getGreeting, getMoodEmoji } from "@/lib/utils";
8
+ import WaveformVisualizer from "@/components/WaveformVisualizer";
9
+ import CircularProgress from "@/components/CircularProgress";
10
+ import EmotionBadge from "@/components/EmotionBadge";
11
+
12
+ const PROMPTS = [
13
+ "How are you feeling right now?",
14
+ "What was the best part of your day?",
15
+ "Is anything on your mind?",
16
+ "Describe your energy level today."
17
+ ];
18
+
19
+ export default function RecordPage() {
20
+ const router = useRouter();
21
+ const { isDemoMode, userId } = useDemoMode();
22
+ const { state, seconds, audioBlob, analyserNode, startRecording, stopRecording, reset, error } = useAudioRecorder(60);
23
+
24
+ const [analyzing, setAnalyzing] = useState(false);
25
+ const [result, setResult] = useState<AnalyzeResult | null>(null);
26
+ const [greeting, setGreeting] = useState("Hello");
27
+
28
+ useEffect(() => {
29
+ setGreeting(getGreeting());
30
+ }, []);
31
+
32
+ const handleStop = async () => {
33
+ stopRecording();
34
+ };
35
+
36
+ useEffect(() => {
37
+ async function process() {
38
+ if (state === "stopped" && audioBlob && !result && !analyzing) {
39
+ setAnalyzing(true);
40
+ try {
41
+ if (isDemoMode) {
42
+ // Fake analysis delay for demo
43
+ await new Promise((r) => setTimeout(r, 2500));
44
+ import("@/lib/demo-data").then(({ DEMO_LATEST_ENTRY }) => {
45
+ setResult({
46
+ entry_id: "demo-new",
47
+ emotion: "calm",
48
+ confidence: 0.88,
49
+ mood_scores: { energy: 65, calmness: 80, mood: 72, clarity: 75 },
50
+ transcription: "Just testing the demo mode recording.",
51
+ features: {},
52
+ insight: "Your voice sounds measured and steady today. Keep showing up for yourself.",
53
+ new_alerts: [],
54
+ });
55
+ setAnalyzing(false);
56
+ });
57
+ } else {
58
+ const res = await api.analyzeAudio(audioBlob, userId);
59
+ setResult(res);
60
+ setAnalyzing(false);
61
+ }
62
+ } catch (e) {
63
+ console.error(e);
64
+ setAnalyzing(false);
65
+ // Handle error state gracefully here in real app
66
+ }
67
+ }
68
+ }
69
+ process();
70
+ }, [state, audioBlob, analyzing, result, isDemoMode, userId]);
71
+
72
+ return (
73
+ <div className="min-h-screen flex flex-col items-center justify-center p-6 relative z-10">
74
+ <div className="w-full max-w-2xl text-center space-y-12">
75
+
76
+ {/* Header / Prompts */}
77
+ {state === "idle" && !result && (
78
+ <div className="animate-fade-in space-y-6">
79
+ <h1 className="text-4xl font-bold">{greeting}.</h1>
80
+ <p className="text-xl text-white/50">Take 60 seconds to check in with yourself.</p>
81
+ <div className="flex flex-wrap justify-center gap-2 pt-4">
82
+ {PROMPTS.map((p) => (
83
+ <div key={p} className="text-sm px-4 py-2 rounded-full border border-white/10 bg-white/5 text-white/70">
84
+ {p}
85
+ </div>
86
+ ))}
87
+ </div>
88
+ </div>
89
+ )}
90
+
91
+ {/* Recording Interface */}
92
+ {!result && !analyzing && (
93
+ <div className="space-y-12 animate-slide-up">
94
+ <div className="h-[120px] flex items-center justify-center">
95
+ {state === "recording" ? (
96
+ <WaveformVisualizer analyserNode={analyserNode} isActive={true} height={120} color="#ef4444" />
97
+ ) : (
98
+ <div className="text-white/20 text-sm">Waiting for audio...</div>
99
+ )}
100
+ </div>
101
+
102
+ <div className="flex flex-col items-center gap-4">
103
+ <button
104
+ onClick={state === "recording" ? handleStop : startRecording}
105
+ className={`w-24 h-24 rounded-full flex items-center justify-center transition-all ${
106
+ state === "recording" ? "bg-red-500/20 text-red-500 pulse-ring shadow-glow-coral" : "bg-white text-purple-950 hover:scale-105"
107
+ }`}
108
+ >
109
+ {state === "recording" ? (
110
+ <div className="w-8 h-8 bg-red-500 rounded-sm" /> // Stop square
111
+ ) : (
112
+ <svg className="w-10 h-10 ml-1" fill="currentColor" viewBox="0 0 24 24"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5-3c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg> // Mic icon
113
+ )}
114
+ </button>
115
+
116
+ {state === "recording" && (
117
+ <div className="text-2xl font-variant-numeric w-16 text-center text-red-400 font-medium">
118
+ 0:{seconds.toString().padStart(2, "0")}
119
+ </div>
120
+ )}
121
+ {error && <div className="text-red-400 text-sm">{error}</div>}
122
+ </div>
123
+ </div>
124
+ )}
125
+
126
+ {/* Analyzing State */}
127
+ {analyzing && (
128
+ <div className="animate-fade-in flex flex-col items-center gap-6 py-12">
129
+ <div className="w-16 h-16 rounded-full border-4 border-purple-500/30 border-t-purple-500 animate-spin" />
130
+ <h2 className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-teal-400 animate-pulse">
131
+ Analyzing your voice...
132
+ </h2>
133
+ <p className="text-white/50 text-sm text-center max-w-sm">
134
+ Extracting acoustic features and looking for emotional signatures.
135
+ </p>
136
+ </div>
137
+ )}
138
+
139
+ {/* Results */}
140
+ {result && (
141
+ <div className="glass-card p-8 space-y-10 animate-slide-up text-left max-w-xl mx-auto w-full">
142
+ <div className="text-center">
143
+ <h2 className="text-2xl font-bold mb-6">Your Details</h2>
144
+ <div className="flex flex-wrap justify-center gap-3">
145
+ <EmotionBadge emotion={result.emotion} confidence={result.confidence} lg />
146
+ </div>
147
+ </div>
148
+
149
+ <div className="grid grid-cols-4 gap-2">
150
+ <CircularProgress score={result.mood_scores.energy} label="Energy" size={70} strokeWidth={6} />
151
+ <CircularProgress score={result.mood_scores.calmness} label="Calm" size={70} strokeWidth={6} />
152
+ <CircularProgress score={result.mood_scores.mood} label="Mood" size={70} strokeWidth={6} />
153
+ <CircularProgress score={result.mood_scores.clarity} label="Clear" size={70} strokeWidth={6} />
154
+ </div>
155
+
156
+ <div className="bg-white/5 rounded-xl p-5 border border-white/10">
157
+ <p className="text-purple-300 italic text-lg leading-relaxed text-center mb-1">
158
+ "{result.insight}"
159
+ </p>
160
+ </div>
161
+
162
+ {result.transcription && (
163
+ <div className="text-center px-4">
164
+ <p className="text-white/40 text-xs uppercase tracking-wider mb-2">You said:</p>
165
+ <p className="text-white/60 text-sm italic">"{result.transcription}"</p>
166
+ </div>
167
+ )}
168
+
169
+ <div className="flex justify-center pt-4">
170
+ <button
171
+ onClick={() => router.push("/dashboard")}
172
+ className="w-full py-4 bg-gradient-to-r from-purple-600 to-teal-500 font-semibold rounded-xl hover:opacity-90 transition-opacity text-white"
173
+ >
174
+ Save & Continue
175
+ </button>
176
+ </div>
177
+ </div>
178
+ )}
179
+
180
+ </div>
181
+ </div>
182
+ );
183
+ }
frontend/app/settings/page.tsx ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ export default function SettingsPage() {
4
+ return (
5
+ <div className="p-6 md:p-10 max-w-4xl mx-auto space-y-8 pb-20">
6
+ <h1 className="text-3xl font-bold">Settings</h1>
7
+
8
+ <div className="space-y-6 animate-slide-up">
9
+
10
+ {/* Profile */}
11
+ <section className="glass-card p-6 border-white/5">
12
+ <h2 className="text-xl font-semibold mb-4 border-b border-white/10 pb-2">Profile</h2>
13
+ <div className="flex gap-4 items-center mb-4">
14
+ <div className="w-16 h-16 rounded-full bg-gradient-to-br from-purple-500 to-teal-400 flex items-center justify-center text-2xl shadow-glow-purple">
15
+ 👤
16
+ </div>
17
+ <div>
18
+ <div className="font-medium">Demo User</div>
19
+ <div className="text-sm text-white/50">demo@innervoice.app</div>
20
+ </div>
21
+ </div>
22
+ <button className="px-4 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-sm text-white/80 transition-colors">
23
+ Edit Profile
24
+ </button>
25
+ </section>
26
+
27
+ {/* Notifications */}
28
+ <section className="glass-card p-6 border-white/5">
29
+ <h2 className="text-xl font-semibold mb-4 border-b border-white/10 pb-2">Notifications</h2>
30
+ <div className="space-y-4">
31
+ <div className="flex items-center justify-between">
32
+ <div>
33
+ <div className="font-medium">Daily Reminder</div>
34
+ <div className="text-sm text-white/50">Get a gentle nudge to record your 60-second check-in.</div>
35
+ </div>
36
+ <input type="time" defaultValue="20:00" className="bg-black/20 border border-white/10 rounded-lg p-2 text-sm text-white" />
37
+ </div>
38
+ <div className="flex items-center justify-between pt-4 border-t border-white/5">
39
+ <div>
40
+ <div className="font-medium">Trend Alerts</div>
41
+ <div className="text-sm text-white/50">Receive emails when AI detects concerning mood patterns.</div>
42
+ </div>
43
+ <div className="w-10 h-6 bg-purple-500 rounded-full relative cursor-pointer">
44
+ <div className="absolute right-1 top-1 w-4 h-4 bg-white rounded-full" />
45
+ </div>
46
+ </div>
47
+ </div>
48
+ </section>
49
+
50
+ {/* Trusted Circle */}
51
+ <section className="glass-card p-6 border-white/5">
52
+ <h2 className="text-xl font-semibold mb-4 border-b border-white/10 pb-2 flex justify-between">
53
+ <span>Trusted Circle</span>
54
+ <span className="text-xs bg-purple-500/20 text-purple-300 px-2 py-1 rounded-md">Coming Soon</span>
55
+ </h2>
56
+ <p className="text-sm text-white/60 leading-relaxed mb-4">
57
+ Setup automated, read-only weekly reports that send to a therapist, partner, or close friend so they can check in on you.
58
+ </p>
59
+ <div className="flex gap-2">
60
+ <input disabled placeholder="partner@email.com" className="flex-1 bg-black/20 border border-white/10 rounded-lg p-2 text-sm text-white/30" />
61
+ <button disabled className="px-4 py-2 bg-white/5 rounded-lg text-sm text-white/30 cursor-not-allowed">
62
+ Invite
63
+ </button>
64
+ </div>
65
+ </section>
66
+
67
+ {/* Crisis Resources (Always Visible) */}
68
+ <section className="glass-card p-6 border-coral-500/30 bg-coral-500/5 shadow-glow-coral">
69
+ <h2 className="text-xl font-semibold mb-4 border-b border-coral-500/20 pb-2 text-coral-400">Crisis Resources</h2>
70
+ <p className="text-sm text-white/80 leading-relaxed mb-4">
71
+ InnerVoice is not a medical tool. If you are experiencing a crisis, please reach out for immediate support. You are not alone.
72
+ </p>
73
+ <div className="flex flex-col gap-2">
74
+ <div className="bg-black/20 p-3 rounded-lg border border-coral-500/20 flex justify-between items-center">
75
+ <div>
76
+ <div className="font-bold">Suicide & Crisis Lifeline (US)</div>
77
+ <div className="text-xs text-white/60">Available 24/7. Free and confidential.</div>
78
+ </div>
79
+ <a href="tel:988" className="bg-coral-500 hover:bg-coral-400 text-white font-bold py-2 px-4 rounded-lg transition-colors">
80
+ Call 988
81
+ </a>
82
+ </div>
83
+ <div className="bg-black/20 p-3 rounded-lg border border-coral-500/20 flex justify-between items-center">
84
+ <div>
85
+ <div className="font-bold">Crisis Text Line (US)</div>
86
+ <div className="text-xs text-white/60">Text HOME to connect with a Crisis Counselor.</div>
87
+ </div>
88
+ <a href="sms:741741" className="bg-white/10 hover:bg-white/20 text-white font-bold py-2 px-4 rounded-lg transition-colors border border-white/10">
89
+ Text 741741
90
+ </a>
91
+ </div>
92
+ </div>
93
+ </section>
94
+
95
+ {/* Privacy & Data */}
96
+ <section className="glass-card p-6 border-white/5">
97
+ <h2 className="text-xl font-semibold mb-4 border-b border-red-500/20 pb-2 text-red-400">Privacy & Data Control</h2>
98
+ <div className="space-y-4">
99
+ <p className="text-sm text-white/60">
100
+ Your voice recordings are processed in memory and deleted immediately after analysis. They are never stored permanently.
101
+ </p>
102
+ <div className="flex gap-4">
103
+ <button className="px-4 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-sm text-white/80 transition-colors flex-1">
104
+ Export My Data
105
+ </button>
106
+ <button className="px-4 py-2 border border-red-500/30 text-red-400 hover:bg-red-500/10 rounded-lg text-sm transition-colors flex-1">
107
+ Delete Account
108
+ </button>
109
+ </div>
110
+ </div>
111
+ </section>
112
+
113
+ </div>
114
+ </div>
115
+ );
116
+ }
frontend/app/timeline/page.tsx ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import { useDemoMode } from "@/hooks/useDemoMode";
4
+ import { api, VoiceEntry, TrendsData } from "@/lib/api";
5
+ import { DEMO_ENTRIES, DEMO_TRENDS } from "@/lib/demo-data";
6
+ import MoodTimeline from "@/components/MoodTimeline";
7
+ import CalendarHeatmap from "@/components/CalendarHeatmap";
8
+ import EmotionBadge from "@/components/EmotionBadge";
9
+ import { getMoodEmoji } from "@/lib/utils";
10
+
11
+ export default function TimelinePage() {
12
+ const { isDemoMode, userId } = useDemoMode();
13
+ const [entries, setEntries] = useState<VoiceEntry[]>([]);
14
+ const [trends, setTrends] = useState<TrendsData | null>(null);
15
+ const [loading, setLoading] = useState(true);
16
+ const [selectedEntry, setSelectedEntry] = useState<VoiceEntry | null>(null);
17
+ const [selectedDate, setSelectedDate] = useState<Date | null>(null);
18
+
19
+ useEffect(() => {
20
+ async function load() {
21
+ if (!userId) return;
22
+ try {
23
+ if (isDemoMode) {
24
+ setEntries([...DEMO_ENTRIES].reverse());
25
+ setTrends(DEMO_TRENDS);
26
+ } else {
27
+ const [eRes, tRes] = await Promise.all([
28
+ api.getEntries(userId, 30),
29
+ api.getTrends(userId),
30
+ ]);
31
+ setEntries(eRes);
32
+ setTrends(tRes);
33
+ }
34
+ } catch (e) {
35
+ console.error(e);
36
+ } finally {
37
+ setLoading(false);
38
+ }
39
+ }
40
+ load();
41
+ }, [userId, isDemoMode]);
42
+
43
+ const handleDayClick = (entry: VoiceEntry | null, date: Date) => {
44
+ setSelectedEntry(entry);
45
+ setSelectedDate(date);
46
+ };
47
+
48
+ if (loading) return <div className="p-10 flex justify-center"><div className="w-8 h-8 rounded-full border-2 border-purple-500 border-t-transparent animate-spin"/></div>;
49
+
50
+ return (
51
+ <div className="p-6 md:p-10 max-w-7xl mx-auto space-y-8 min-h-[90vh]">
52
+ <h1 className="text-3xl font-bold">Timeline Patterns</h1>
53
+
54
+ <div className="grid lg:grid-cols-3 gap-6">
55
+
56
+ <div className="lg:col-span-2 space-y-6">
57
+ <div className="glass-card p-6">
58
+ <h2 className="text-lg font-semibold mb-6 flex items-center justify-between">
59
+ <span>30-Day Trajectory</span>
60
+ <span className="text-xs font-normal px-2 py-1 bg-white/5 rounded-md text-white/50">All Scores</span>
61
+ </h2>
62
+ <MoodTimeline entries={entries} showAllScores height={300} onPointClick={setSelectedEntry} />
63
+ </div>
64
+
65
+ <div className="glass-card p-6 overflow-x-auto">
66
+ <h2 className="text-lg font-semibold mb-6">Check-in Activity</h2>
67
+ <CalendarHeatmap entries={entries} onDayClick={handleDayClick} />
68
+ </div>
69
+ </div>
70
+
71
+ <div className="space-y-6">
72
+ {selectedEntry ? (
73
+ <div className="glass-card p-6 sticky top-6 animate-fade-in border-purple-500/30 shadow-glow-purple">
74
+ <div className="flex justify-between items-start mb-4">
75
+ <h3 className="font-semibold text-lg">
76
+ {new Date(selectedEntry.created_at).toLocaleDateString("en-US", { weekday: 'short', month: 'short', day: 'numeric'})}
77
+ </h3>
78
+ <button onClick={() => setSelectedEntry(null)} className="text-white/40 hover:text-white">✕</button>
79
+ </div>
80
+
81
+ <div className="flex items-center gap-4 mb-6">
82
+ <div className="text-4xl">{getMoodEmoji(selectedEntry.mood_score)}</div>
83
+ <EmotionBadge emotion={selectedEntry.primary_emotion} confidence={selectedEntry.emotion_confidence} />
84
+ </div>
85
+
86
+ <div className="space-y-3 mb-6 bg-white/5 p-4 rounded-xl">
87
+ <div className="flex justify-between text-sm">
88
+ <span className="text-white/60">Mood</span>
89
+ <span className="font-medium text-purple-300">{selectedEntry.mood_score}</span>
90
+ </div>
91
+ <div className="flex justify-between text-sm">
92
+ <span className="text-white/60">Energy</span>
93
+ <span className="font-medium text-teal-300">{selectedEntry.energy_score}</span>
94
+ </div>
95
+ <div className="flex justify-between text-sm">
96
+ <span className="text-white/60">Calmness</span>
97
+ <span className="font-medium text-blue-300">{selectedEntry.calmness_score}</span>
98
+ </div>
99
+ </div>
100
+
101
+ {selectedEntry.transcription && (
102
+ <div>
103
+ <div className="text-xs uppercase text-white/40 mb-2 font-semibold">Transcription</div>
104
+ <p className="text-sm text-white/80 italic">"{selectedEntry.transcription}"</p>
105
+ </div>
106
+ )}
107
+ </div>
108
+ ) : (
109
+ <div className="glass-card p-6 text-white/40 text-sm text-center italic border-dashed border-white/10 sticky top-6">
110
+ Click any point on the chart or calendar to view details for that day.
111
+ </div>
112
+ )}
113
+
114
+ <div className="glass-card p-6">
115
+ <h2 className="text-lg font-semibold mb-4">Pattern Insights</h2>
116
+ {trends?.insights && trends.insights.length > 0 ? (
117
+ <ul className="space-y-4">
118
+ {trends.insights.map((insight, i) => (
119
+ <li key={i} className="flex gap-3 text-sm text-white/80 items-start">
120
+ <span className="text-purple-400 mt-0.5">✦</span>
121
+ <span>{insight}</span>
122
+ </li>
123
+ ))}
124
+ </ul>
125
+ ) : (
126
+ <p className="text-sm text-white/50 italic">More data needed to generate pattern insights (min 7 days).</p>
127
+ )}
128
+ </div>
129
+ </div>
130
+
131
+ </div>
132
+ </div>
133
+ );
134
+ }
frontend/components/AlertCard.tsx ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { getSeverityColor, getSeverityIcon } from "@/lib/utils";
3
+
4
+ interface AlertCardProps {
5
+ id: string;
6
+ alert_type: string;
7
+ severity: string;
8
+ message: string;
9
+ suggested_action?: string | null;
10
+ created_at: string;
11
+ onDismiss?: (id: string) => void;
12
+ }
13
+
14
+ export default function AlertCard({
15
+ id,
16
+ alert_type,
17
+ severity,
18
+ message,
19
+ suggested_action,
20
+ created_at,
21
+ onDismiss,
22
+ }: AlertCardProps) {
23
+ const severityClass = getSeverityColor(severity);
24
+ const icon = getSeverityIcon(severity);
25
+
26
+ const typeLabels: Record<string, string> = {
27
+ mood_decline: "Mood Decline",
28
+ low_energy: "Low Energy",
29
+ high_anxiety: "Elevated Anxiety",
30
+ burnout_risk: "Burnout Risk",
31
+ crisis: "Needs Attention",
32
+ persistent_sadness: "Persistent Sadness",
33
+ };
34
+
35
+ return (
36
+ <div
37
+ className={`rounded-xl border p-4 ${severityClass} animate-fade-in transition-all`}
38
+ >
39
+ <div className="flex items-start justify-between gap-3">
40
+ <div className="flex items-start gap-3 flex-1">
41
+ <span className="text-xl mt-0.5 shrink-0">{icon}</span>
42
+ <div className="flex-1 min-w-0">
43
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
44
+ <span className="text-xs font-semibold uppercase tracking-wide text-white/50">
45
+ {typeLabels[alert_type] ?? alert_type}
46
+ </span>
47
+ <span className="text-xs text-white/30">
48
+ {new Date(created_at).toLocaleDateString()}
49
+ </span>
50
+ </div>
51
+ <p className="text-sm text-white/90 leading-relaxed">{message}</p>
52
+ {suggested_action && (
53
+ <p className="mt-2 text-xs text-white/60 italic">
54
+ 💡 {suggested_action}
55
+ </p>
56
+ )}
57
+ </div>
58
+ </div>
59
+ {onDismiss && (
60
+ <button
61
+ onClick={() => onDismiss(id)}
62
+ className="text-white/30 hover:text-white/70 transition-colors shrink-0"
63
+ title="Dismiss"
64
+ >
65
+
66
+ </button>
67
+ )}
68
+ </div>
69
+ </div>
70
+ );
71
+ }
frontend/components/CalendarHeatmap.tsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { VoiceEntry } from "@/lib/api";
3
+ import { getScoreColor } from "@/lib/utils";
4
+
5
+ interface CalendarHeatmapProps {
6
+ entries: VoiceEntry[];
7
+ onDayClick?: (entry: VoiceEntry | null, date: Date) => void;
8
+ }
9
+
10
+ function getDateKey(date: Date): string {
11
+ return date.toISOString().split("T")[0];
12
+ }
13
+
14
+ export default function CalendarHeatmap({ entries, onDayClick }: CalendarHeatmapProps) {
15
+ // Build a map of date → entry
16
+ const entryMap: Record<string, VoiceEntry> = {};
17
+ entries.forEach((e) => {
18
+ const key = e.created_at.split("T")[0];
19
+ entryMap[key] = e;
20
+ });
21
+
22
+ // Generate last 30 days grid
23
+ const days: Date[] = [];
24
+ for (let i = 29; i >= 0; i--) {
25
+ const d = new Date();
26
+ d.setDate(d.getDate() - i);
27
+ days.push(d);
28
+ }
29
+
30
+ // Group into weeks
31
+ const weeks: Date[][] = [];
32
+ let week: Date[] = [];
33
+ days.forEach((d, i) => {
34
+ week.push(d);
35
+ if (week.length === 7 || i === days.length - 1) {
36
+ weeks.push(week);
37
+ week = [];
38
+ }
39
+ });
40
+
41
+ return (
42
+ <div>
43
+ <div className="text-xs text-white/40 mb-3">Last 30 days</div>
44
+ <div className="flex gap-1.5">
45
+ {weeks.map((week, wi) => (
46
+ <div key={wi} className="flex flex-col gap-1.5">
47
+ {week.map((day) => {
48
+ const key = getDateKey(day);
49
+ const entry = entryMap[key];
50
+ const isToday = key === getDateKey(new Date());
51
+
52
+ let bg = "bg-white/5";
53
+ let title = day.toLocaleDateString();
54
+
55
+ if (entry) {
56
+ const color = getScoreColor(entry.mood_score);
57
+ const opacity = Math.max(0.2, entry.mood_score / 100);
58
+ title = `${day.toLocaleDateString()} — Mood: ${entry.mood_score} (${entry.primary_emotion})`;
59
+ return (
60
+ <button
61
+ key={key}
62
+ title={title}
63
+ onClick={() => onDayClick?.(entry, day)}
64
+ className={`w-8 h-8 rounded-md transition-all hover:scale-110 hover:brightness-125 ${isToday ? "ring-2 ring-purple-400" : ""}`}
65
+ style={{
66
+ backgroundColor: color,
67
+ opacity,
68
+ }}
69
+ />
70
+ );
71
+ }
72
+
73
+ return (
74
+ <button
75
+ key={key}
76
+ title={title}
77
+ onClick={() => onDayClick?.(null, day)}
78
+ className={`w-8 h-8 rounded-md ${bg} ${isToday ? "ring-2 ring-purple-400/50" : ""} cursor-default`}
79
+ />
80
+ );
81
+ })}
82
+ </div>
83
+ ))}
84
+ </div>
85
+ {/* Legend */}
86
+ <div className="flex items-center gap-3 mt-3">
87
+ <span className="text-xs text-white/30">Less</span>
88
+ <div className="flex gap-1">
89
+ {[15, 30, 50, 70, 90].map((v) => (
90
+ <div
91
+ key={v}
92
+ className="w-4 h-4 rounded-sm"
93
+ style={{ backgroundColor: getScoreColor(v), opacity: Math.max(0.2, v / 100) }}
94
+ />
95
+ ))}
96
+ </div>
97
+ <span className="text-xs text-white/30">More</span>
98
+ </div>
99
+ </div>
100
+ );
101
+ }
frontend/components/CircularProgress.tsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { getScoreColor } from "@/lib/utils";
3
+
4
+ interface CircularProgressProps {
5
+ score: number;
6
+ label: string;
7
+ size?: number;
8
+ strokeWidth?: number;
9
+ }
10
+
11
+ export default function CircularProgress({
12
+ score,
13
+ label,
14
+ size = 96,
15
+ strokeWidth = 8,
16
+ }: CircularProgressProps) {
17
+ const radius = (size - strokeWidth * 2) / 2;
18
+ const circumference = 2 * Math.PI * radius;
19
+ const progress = (score / 100) * circumference;
20
+ const color = getScoreColor(score);
21
+ const cx = size / 2;
22
+ const cy = size / 2;
23
+
24
+ return (
25
+ <div className="flex flex-col items-center gap-2">
26
+ <div className="relative" style={{ width: size, height: size }}>
27
+ <svg
28
+ width={size}
29
+ height={size}
30
+ className="-rotate-90"
31
+ style={{ transform: "rotate(-90deg)" }}
32
+ >
33
+ {/* Background track */}
34
+ <circle
35
+ cx={cx}
36
+ cy={cy}
37
+ r={radius}
38
+ fill="none"
39
+ stroke="rgba(255,255,255,0.08)"
40
+ strokeWidth={strokeWidth}
41
+ />
42
+ {/* Progress arc */}
43
+ <circle
44
+ cx={cx}
45
+ cy={cy}
46
+ r={radius}
47
+ fill="none"
48
+ stroke={color}
49
+ strokeWidth={strokeWidth}
50
+ strokeDasharray={`${progress} ${circumference}`}
51
+ strokeLinecap="round"
52
+ style={{
53
+ filter: `drop-shadow(0 0 6px ${color}80)`,
54
+ transition: "stroke-dasharray 0.8s ease",
55
+ }}
56
+ />
57
+ </svg>
58
+ {/* Center score */}
59
+ <div className="absolute inset-0 flex flex-col items-center justify-center">
60
+ <span className="text-xl font-bold text-white leading-none">{score}</span>
61
+ </div>
62
+ </div>
63
+ <span className="text-xs font-medium text-white/60 tracking-wide uppercase">{label}</span>
64
+ </div>
65
+ );
66
+ }
frontend/components/DemoModeBanner.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useDemoMode } from "@/hooks/useDemoMode";
3
+
4
+ export default function DemoModeBanner() {
5
+ const { isDemoMode, toggleDemoMode } = useDemoMode();
6
+ if (!isDemoMode) return null;
7
+
8
+ return (
9
+ <div className="fixed top-0 left-0 right-0 z-50 bg-gradient-to-r from-teal-600/80 to-purple-600/80 backdrop-blur-md border-b border-white/10 py-2 px-4 flex items-center justify-between gap-4 md:left-64">
10
+ <div className="flex items-center gap-2 text-sm text-white/90">
11
+ <span>📊</span>
12
+ <span>
13
+ <strong>Demo Mode</strong> — Showing 30 days of sample data. No account needed.
14
+ </span>
15
+ </div>
16
+ <button
17
+ onClick={toggleDemoMode}
18
+ className="text-xs text-white/60 hover:text-white underline shrink-0"
19
+ >
20
+ Exit Demo
21
+ </button>
22
+ </div>
23
+ );
24
+ }
frontend/components/EmotionBadge.tsx ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { capitalise, getEmotionBg, getEmotionColor } from "@/lib/utils";
3
+
4
+ interface EmotionBadgeProps {
5
+ emotion: string;
6
+ confidence?: number;
7
+ size?: "sm" | "md" | "lg";
8
+ }
9
+
10
+ const EMOTION_EMOJI: Record<string, string> = {
11
+ happy: "😊",
12
+ sad: "😢",
13
+ angry: "😠",
14
+ fearful: "😨",
15
+ neutral: "😐",
16
+ anxious: "😰",
17
+ disgust: "😒",
18
+ surprised: "😲",
19
+ };
20
+
21
+ export default function EmotionBadge({
22
+ emotion,
23
+ confidence,
24
+ size = "md",
25
+ }: EmotionBadgeProps) {
26
+ const emoji = EMOTION_EMOJI[emotion] ?? "🎭";
27
+ const bgClass = getEmotionBg(emotion);
28
+ const textClass = getEmotionColor(emotion);
29
+
30
+ const sizeClasses = {
31
+ sm: "text-xs px-2 py-1 gap-1",
32
+ md: "text-sm px-3 py-1.5 gap-1.5",
33
+ lg: "text-base px-4 py-2 gap-2",
34
+ };
35
+
36
+ return (
37
+ <span
38
+ className={`inline-flex items-center rounded-full border font-medium ${bgClass} ${textClass} ${sizeClasses[size]}`}
39
+ >
40
+ <span>{emoji}</span>
41
+ <span>{capitalise(emotion)}</span>
42
+ {confidence !== undefined && (
43
+ <span className="opacity-60">· {Math.round(confidence * 100)}%</span>
44
+ )}
45
+ </span>
46
+ );
47
+ }
frontend/components/MoodTimeline.tsx ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import {
3
+ AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
4
+ ResponsiveContainer, ReferenceLine
5
+ } from "recharts";
6
+ import { VoiceEntry } from "@/lib/api";
7
+ import { formatDate, getMoodEmoji } from "@/lib/utils";
8
+
9
+ interface MoodTimelineProps {
10
+ entries: VoiceEntry[];
11
+ onPointClick?: (entry: VoiceEntry) => void;
12
+ showAllScores?: boolean;
13
+ height?: number;
14
+ }
15
+
16
+ function CustomTooltip({ active, payload, label }: any) {
17
+ if (!active || !payload?.length) return null;
18
+ const d = payload[0]?.payload;
19
+ return (
20
+ <div className="bg-[#1a0f2e]/95 border border-white/10 rounded-xl p-3 shadow-xl text-sm">
21
+ <div className="text-white/50 mb-2">{label}</div>
22
+ <div className="flex items-center gap-2 mb-1">
23
+ <span className="text-lg">{getMoodEmoji(d.mood_score)}</span>
24
+ <span className="text-white font-medium">Mood: {d.mood_score}</span>
25
+ </div>
26
+ {d.energy_score && <div className="text-white/60">Energy: {d.energy_score}</div>}
27
+ {d.calmness_score && <div className="text-white/60">Calmness: {d.calmness_score}</div>}
28
+ {d.clarity_score && <div className="text-white/60">Clarity: {d.clarity_score}</div>}
29
+ <div className="mt-2 text-xs text-purple-300 capitalize">{d.primary_emotion}</div>
30
+ </div>
31
+ );
32
+ }
33
+
34
+ export default function MoodTimeline({
35
+ entries,
36
+ onPointClick,
37
+ showAllScores = false,
38
+ height = 220,
39
+ }: MoodTimelineProps) {
40
+ const data = entries.map((e) => ({
41
+ ...e,
42
+ date: formatDate(e.created_at),
43
+ }));
44
+
45
+ return (
46
+ <ResponsiveContainer width="100%" height={height}>
47
+ <AreaChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
48
+ onClick={(e: any) => {
49
+ if (e?.activePayload?.[0] && onPointClick) {
50
+ onPointClick(e.activePayload[0].payload);
51
+ }
52
+ }}
53
+ >
54
+ <defs>
55
+ <linearGradient id="moodGradient" x1="0" y1="0" x2="0" y2="1">
56
+ <stop offset="5%" stopColor="#8B5CF6" stopOpacity={0.4} />
57
+ <stop offset="95%" stopColor="#8B5CF6" stopOpacity={0.0} />
58
+ </linearGradient>
59
+ <linearGradient id="energyGradient" x1="0" y1="0" x2="0" y2="1">
60
+ <stop offset="5%" stopColor="#14b8a6" stopOpacity={0.3} />
61
+ <stop offset="95%" stopColor="#14b8a6" stopOpacity={0.0} />
62
+ </linearGradient>
63
+ <linearGradient id="calmnessGradient" x1="0" y1="0" x2="0" y2="1">
64
+ <stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
65
+ <stop offset="95%" stopColor="#3b82f6" stopOpacity={0.0} />
66
+ </linearGradient>
67
+ </defs>
68
+ <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
69
+ <XAxis
70
+ dataKey="date"
71
+ tick={{ fill: "rgba(255,255,255,0.4)", fontSize: 11 }}
72
+ tickLine={false}
73
+ axisLine={false}
74
+ interval="preserveStartEnd"
75
+ />
76
+ <YAxis
77
+ domain={[0, 100]}
78
+ tick={{ fill: "rgba(255,255,255,0.4)", fontSize: 11 }}
79
+ tickLine={false}
80
+ axisLine={false}
81
+ />
82
+ <Tooltip content={<CustomTooltip />} />
83
+ <ReferenceLine y={50} stroke="rgba(255,255,255,0.1)" strokeDasharray="4 4" />
84
+ <Area
85
+ type="monotone"
86
+ dataKey="mood_score"
87
+ stroke="#8B5CF6"
88
+ strokeWidth={2.5}
89
+ fill="url(#moodGradient)"
90
+ dot={{ fill: "#8B5CF6", r: 3, strokeWidth: 0 }}
91
+ activeDot={{ r: 6, fill: "#8B5CF6", strokeWidth: 0 }}
92
+ name="Mood"
93
+ />
94
+ {showAllScores && (
95
+ <>
96
+ <Area type="monotone" dataKey="energy_score" stroke="#14b8a6" strokeWidth={1.5} fill="url(#energyGradient)" dot={false} name="Energy" />
97
+ <Area type="monotone" dataKey="calmness_score" stroke="#3b82f6" strokeWidth={1.5} fill="url(#calmnessGradient)" dot={false} name="Calmness" />
98
+ </>
99
+ )}
100
+ </AreaChart>
101
+ </ResponsiveContainer>
102
+ );
103
+ }
frontend/components/Navbar.tsx ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import Link from "next/link";
3
+ import { usePathname } from "next/navigation";
4
+ import { useDemoMode } from "@/hooks/useDemoMode";
5
+
6
+ const navItems = [
7
+ { href: "/dashboard", label: "Dashboard", icon: "🏠" },
8
+ { href: "/record", label: "Check-in", icon: "🎙️" },
9
+ { href: "/timeline", label: "Timeline", icon: "📈" },
10
+ { href: "/chat", label: "Companion", icon: "💬" },
11
+ { href: "/settings", label: "Settings", icon: "⚙️" },
12
+ ];
13
+
14
+ export default function Navbar() {
15
+ const pathname = usePathname();
16
+ const { isDemoMode, toggleDemoMode } = useDemoMode();
17
+
18
+ return (
19
+ <>
20
+ {/* Desktop sidebar */}
21
+ <nav className="hidden md:flex fixed left-0 top-0 h-full w-64 flex-col bg-[#130d24]/90 backdrop-blur-xl border-r border-white/5 z-40">
22
+ {/* Logo */}
23
+ <div className="p-6 border-b border-white/5">
24
+ <Link href="/" className="flex items-center gap-3 group">
25
+ <div className="w-9 h-9 rounded-xl bg-gradient-to-br from-purple-600 to-teal-500 flex items-center justify-center text-lg shadow-glow-purple">
26
+ 🎵
27
+ </div>
28
+ <div>
29
+ <div className="font-bold text-white text-lg leading-none">InnerVoice</div>
30
+ <div className="text-xs text-white/40 mt-0.5">Wellness Tracker</div>
31
+ </div>
32
+ </Link>
33
+ </div>
34
+
35
+ {/* Nav links */}
36
+ <div className="flex-1 p-4 space-y-1">
37
+ {navItems.map(({ href, label, icon }) => {
38
+ const active = pathname === href || pathname.startsWith(href + "/");
39
+ return (
40
+ <Link
41
+ key={href}
42
+ href={href}
43
+ className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${
44
+ active
45
+ ? "bg-purple-600/20 text-purple-300 border border-purple-600/30"
46
+ : "text-white/50 hover:text-white/80 hover:bg-white/5"
47
+ }`}
48
+ >
49
+ <span className="text-lg">{icon}</span>
50
+ <span className="font-medium">{label}</span>
51
+ {active && (
52
+ <div className="ml-auto w-1.5 h-1.5 rounded-full bg-purple-400" />
53
+ )}
54
+ </Link>
55
+ );
56
+ })}
57
+ </div>
58
+
59
+ {/* Demo mode toggle */}
60
+ <div className="p-4 border-t border-white/5">
61
+ <button
62
+ onClick={toggleDemoMode}
63
+ className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all text-sm ${
64
+ isDemoMode
65
+ ? "bg-teal-500/20 text-teal-300 border border-teal-500/30"
66
+ : "text-white/40 hover:text-white/60 hover:bg-white/5"
67
+ }`}
68
+ >
69
+ <span>{isDemoMode ? "📊" : "🔒"}</span>
70
+ <span className="font-medium">{isDemoMode ? "Demo Mode ON" : "Demo Mode OFF"}</span>
71
+ </button>
72
+ <p className="text-[10px] text-white/20 text-center mt-2 px-2">
73
+ InnerVoice is not a medical tool. If in crisis, call 988.
74
+ </p>
75
+ </div>
76
+ </nav>
77
+
78
+ {/* Mobile bottom bar */}
79
+ <nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 bg-[#130d24]/95 backdrop-blur-xl border-t border-white/5">
80
+ <div className="flex justify-around px-2 py-2">
81
+ {navItems.map(({ href, label, icon }) => {
82
+ const active = pathname === href;
83
+ return (
84
+ <Link
85
+ key={href}
86
+ href={href}
87
+ className={`flex flex-col items-center gap-1 px-3 py-2 rounded-xl transition-all ${
88
+ active ? "text-purple-400" : "text-white/40"
89
+ }`}
90
+ >
91
+ <span className="text-xl">{icon}</span>
92
+ <span className="text-[10px] font-medium">{label}</span>
93
+ </Link>
94
+ );
95
+ })}
96
+ </div>
97
+ </nav>
98
+ </>
99
+ );
100
+ }
frontend/components/WaveformVisualizer.tsx ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useRef } from "react";
3
+
4
+ interface WaveformVisualizerProps {
5
+ analyserNode: AnalyserNode | null;
6
+ isActive: boolean;
7
+ height?: number;
8
+ color?: string;
9
+ }
10
+
11
+ export default function WaveformVisualizer({
12
+ analyserNode,
13
+ isActive,
14
+ height = 80,
15
+ color = "#8B5CF6",
16
+ }: WaveformVisualizerProps) {
17
+ const canvasRef = useRef<HTMLCanvasElement>(null);
18
+ const animFrameRef = useRef<number>(0);
19
+
20
+ useEffect(() => {
21
+ const canvas = canvasRef.current;
22
+ if (!canvas) return;
23
+ const ctx = canvas.getContext("2d");
24
+ if (!ctx) return;
25
+
26
+ const draw = () => {
27
+ animFrameRef.current = requestAnimationFrame(draw);
28
+ const W = canvas.width;
29
+ const H = canvas.height;
30
+ ctx.clearRect(0, 0, W, H);
31
+
32
+ if (!analyserNode || !isActive) {
33
+ // Idle animation — gentle sine wave
34
+ ctx.beginPath();
35
+ const t = Date.now() / 800;
36
+ for (let x = 0; x < W; x++) {
37
+ const y = H / 2 + Math.sin(x * 0.03 + t) * 8 * Math.sin(x * 0.008 + t * 0.5);
38
+ x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
39
+ }
40
+ ctx.strokeStyle = color + "60";
41
+ ctx.lineWidth = 2;
42
+ ctx.stroke();
43
+ return;
44
+ }
45
+
46
+ const bufLen = analyserNode.frequencyBinCount;
47
+ const dataArray = new Uint8Array(bufLen);
48
+ analyserNode.getByteTimeDomainData(dataArray);
49
+
50
+ // Draw gradient waveform
51
+ const gradient = ctx.createLinearGradient(0, 0, W, 0);
52
+ gradient.addColorStop(0, "#8B5CF6");
53
+ gradient.addColorStop(0.5, "#14b8a6");
54
+ gradient.addColorStop(1, "#8B5CF6");
55
+
56
+ ctx.beginPath();
57
+ const sliceW = W / bufLen;
58
+ let x = 0;
59
+ for (let i = 0; i < bufLen; i++) {
60
+ const v = dataArray[i] / 128.0;
61
+ const y = (v * H) / 2;
62
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
63
+ x += sliceW;
64
+ }
65
+ ctx.lineTo(W, H / 2);
66
+ ctx.strokeStyle = gradient;
67
+ ctx.lineWidth = 2.5;
68
+ ctx.shadowBlur = 12;
69
+ ctx.shadowColor = color;
70
+ ctx.stroke();
71
+ ctx.shadowBlur = 0;
72
+ };
73
+
74
+ draw();
75
+ return () => cancelAnimationFrame(animFrameRef.current);
76
+ }, [analyserNode, isActive, color]);
77
+
78
+ return (
79
+ <canvas
80
+ ref={canvasRef}
81
+ width={600}
82
+ height={height}
83
+ className="w-full rounded-xl"
84
+ style={{ height }}
85
+ />
86
+ );
87
+ }
frontend/hooks/useAudioRecorder.ts ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState, useRef, useCallback, useEffect } from "react";
3
+
4
+ export type RecorderState = "idle" | "requesting" | "recording" | "stopped" | "error";
5
+
6
+ export interface UseAudioRecorderReturn {
7
+ state: RecorderState;
8
+ seconds: number;
9
+ audioBlob: Blob | null;
10
+ analyserNode: AnalyserNode | null;
11
+ startRecording: () => Promise<void>;
12
+ stopRecording: () => void;
13
+ reset: () => void;
14
+ error: string | null;
15
+ }
16
+
17
+ export function useAudioRecorder(maxSeconds = 60): UseAudioRecorderReturn {
18
+ const [state, setState] = useState<RecorderState>("idle");
19
+ const [seconds, setSeconds] = useState(0);
20
+ const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
21
+ const [analyserNode, setAnalyserNode] = useState<AnalyserNode | null>(null);
22
+ const [error, setError] = useState<string | null>(null);
23
+
24
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null);
25
+ const chunksRef = useRef<Blob[]>([]);
26
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
27
+ const audioContextRef = useRef<AudioContext | null>(null);
28
+ const streamRef = useRef<MediaStream | null>(null);
29
+
30
+ const cleanup = useCallback(() => {
31
+ if (timerRef.current) clearInterval(timerRef.current);
32
+ if (audioContextRef.current && audioContextRef.current.state !== "closed") {
33
+ audioContextRef.current.close();
34
+ audioContextRef.current = null;
35
+ }
36
+ if (streamRef.current) {
37
+ streamRef.current.getTracks().forEach((t) => t.stop());
38
+ streamRef.current = null;
39
+ }
40
+ setAnalyserNode(null);
41
+ }, []);
42
+
43
+ const startRecording = useCallback(async () => {
44
+ setState("requesting");
45
+ setError(null);
46
+ chunksRef.current = [];
47
+ setAudioBlob(null);
48
+
49
+ try {
50
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
51
+ streamRef.current = stream;
52
+
53
+ // Set up Web Audio API for visualisation
54
+ const audioContext = new AudioContext();
55
+ audioContextRef.current = audioContext;
56
+ const source = audioContext.createMediaStreamSource(stream);
57
+ const analyser = audioContext.createAnalyser();
58
+ analyser.fftSize = 256;
59
+ source.connect(analyser);
60
+ setAnalyserNode(analyser);
61
+
62
+ const mediaRecorder = new MediaRecorder(stream, {
63
+ mimeType: MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
64
+ ? "audio/webm;codecs=opus"
65
+ : "audio/webm",
66
+ });
67
+ mediaRecorderRef.current = mediaRecorder;
68
+
69
+ mediaRecorder.ondataavailable = (e) => {
70
+ if (e.data.size > 0) chunksRef.current.push(e.data);
71
+ };
72
+
73
+ mediaRecorder.onstop = () => {
74
+ const blob = new Blob(chunksRef.current, { type: "audio/webm" });
75
+ setAudioBlob(blob);
76
+ cleanup();
77
+ };
78
+
79
+ mediaRecorder.start(250);
80
+ setState("recording");
81
+ setSeconds(0);
82
+
83
+ timerRef.current = setInterval(() => {
84
+ setSeconds((prev) => {
85
+ if (prev + 1 >= maxSeconds) {
86
+ mediaRecorderRef.current?.stop();
87
+ if (timerRef.current) clearInterval(timerRef.current);
88
+ setState("stopped");
89
+ return maxSeconds;
90
+ }
91
+ return prev + 1;
92
+ });
93
+ }, 1000);
94
+ } catch (err) {
95
+ setError("Microphone access denied. Please allow microphone access and try again.");
96
+ setState("error");
97
+ }
98
+ }, [cleanup, maxSeconds]);
99
+
100
+ const stopRecording = useCallback(() => {
101
+ if (mediaRecorderRef.current && state === "recording") {
102
+ mediaRecorderRef.current.stop();
103
+ if (timerRef.current) clearInterval(timerRef.current);
104
+ setState("stopped");
105
+ }
106
+ }, [state]);
107
+
108
+ const reset = useCallback(() => {
109
+ cleanup();
110
+ setState("idle");
111
+ setSeconds(0);
112
+ setAudioBlob(null);
113
+ setError(null);
114
+ chunksRef.current = [];
115
+ }, [cleanup]);
116
+
117
+ useEffect(() => () => cleanup(), [cleanup]);
118
+
119
+ return { state, seconds, audioBlob, analyserNode, startRecording, stopRecording, reset, error };
120
+ }
frontend/hooks/useDemoMode.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState, useEffect } from "react";
3
+
4
+ const DEMO_KEY = "innervoice_demo_mode";
5
+
6
+ export function useDemoMode() {
7
+ const [isDemoMode, setIsDemoMode] = useState(true);
8
+
9
+ useEffect(() => {
10
+ const stored = localStorage.getItem(DEMO_KEY);
11
+ if (stored !== null) {
12
+ setIsDemoMode(stored === "true");
13
+ } else {
14
+ // Default to demo mode on first visit
15
+ const envDefault = process.env.NEXT_PUBLIC_DEMO_MODE === "true";
16
+ setIsDemoMode(envDefault);
17
+ }
18
+ }, []);
19
+
20
+ const toggleDemoMode = () => {
21
+ setIsDemoMode((prev) => {
22
+ const next = !prev;
23
+ localStorage.setItem(DEMO_KEY, String(next));
24
+ return next;
25
+ });
26
+ };
27
+
28
+ const userId = isDemoMode
29
+ ? "demo-user-local"
30
+ : (typeof window !== "undefined" && localStorage.getItem("innervoice_user_id")) || "";
31
+
32
+ return { isDemoMode, toggleDemoMode, userId };
33
+ }
frontend/lib/api.ts ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // API client for InnerVoice backend
2
+
3
+ const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
4
+
5
+ export async function apiGet<T>(path: string): Promise<T> {
6
+ const res = await fetch(`${API_URL}${path}`);
7
+ if (!res.ok) throw new Error(`API Error ${res.status}: ${await res.text()}`);
8
+ return res.json();
9
+ }
10
+
11
+ export async function apiPost<T>(path: string, body: unknown): Promise<T> {
12
+ const res = await fetch(`${API_URL}${path}`, {
13
+ method: "POST",
14
+ headers: { "Content-Type": "application/json" },
15
+ body: JSON.stringify(body),
16
+ });
17
+ if (!res.ok) throw new Error(`API Error ${res.status}: ${await res.text()}`);
18
+ return res.json();
19
+ }
20
+
21
+ export async function apiPut<T>(path: string): Promise<T> {
22
+ const res = await fetch(`${API_URL}${path}`, { method: "PUT" });
23
+ if (!res.ok) throw new Error(`API Error ${res.status}: ${await res.text()}`);
24
+ return res.json();
25
+ }
26
+
27
+ // ── Typed API functions ───────────────────────────────────────────────────────
28
+
29
+ export interface VoiceEntry {
30
+ id: string;
31
+ created_at: string;
32
+ primary_emotion: string;
33
+ emotion_confidence: number;
34
+ energy_score: number;
35
+ calmness_score: number;
36
+ mood_score: number;
37
+ clarity_score: number;
38
+ transcription: string;
39
+ duration_seconds: number;
40
+ pitch_mean: number;
41
+ speech_rate: number;
42
+ pause_count: number;
43
+ filler_rate: number;
44
+ }
45
+
46
+ export interface MoodAlert {
47
+ id: string;
48
+ created_at: string;
49
+ alert_type: string;
50
+ severity: string;
51
+ message: string;
52
+ suggested_action: string | null;
53
+ is_read: boolean;
54
+ }
55
+
56
+ export interface TrendsData {
57
+ entries_count: number;
58
+ streak: number;
59
+ most_common_emotion: string | null;
60
+ this_week: Record<string, number | null>;
61
+ last_week: Record<string, number | null>;
62
+ insights: string[];
63
+ }
64
+
65
+ export interface AnalyzeResult {
66
+ entry_id: string;
67
+ emotion: string;
68
+ confidence: number;
69
+ mood_scores: { energy: number; calmness: number; mood: number; clarity: number };
70
+ transcription: string;
71
+ features: Record<string, unknown>;
72
+ insight: string;
73
+ new_alerts: MoodAlert[];
74
+ }
75
+
76
+ export const api = {
77
+ getEntries: (userId: string, days = 30) =>
78
+ apiGet<VoiceEntry[]>(`/api/entries?user_id=${userId}&days=${days}`),
79
+
80
+ getTrends: (userId: string) =>
81
+ apiGet<TrendsData>(`/api/trends?user_id=${userId}`),
82
+
83
+ getAlerts: (userId: string) =>
84
+ apiGet<MoodAlert[]>(`/api/alerts?user_id=${userId}`),
85
+
86
+ markAlertRead: (alertId: string) =>
87
+ apiPut<{ success: boolean }>(`/api/alerts/${alertId}/read`),
88
+
89
+ chat: (userId: string, message: string) =>
90
+ apiPost<{ response: string }>("/api/chat", { user_id: userId, message }),
91
+
92
+ getChatHistory: (userId: string) =>
93
+ apiGet<Array<{ id: string; role: string; content: string; created_at: string }>>(
94
+ `/api/chat/history?user_id=${userId}`
95
+ ),
96
+
97
+ createUser: (email: string, name: string) =>
98
+ apiPost<{ id: string; email: string; name: string }>("/api/users", { email, name }),
99
+
100
+ analyzeAudio: async (audioBlob: Blob, userId: string): Promise<AnalyzeResult> => {
101
+ const formData = new FormData();
102
+ formData.append("audio", audioBlob, "recording.webm");
103
+ formData.append("user_id", userId);
104
+ const res = await fetch(`${API_URL}/api/analyze`, {
105
+ method: "POST",
106
+ body: formData,
107
+ });
108
+ if (!res.ok) throw new Error(`Analyze error ${res.status}`);
109
+ return res.json();
110
+ },
111
+ };
frontend/lib/demo-data.ts ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 30-day pre-generated mock data for Demo Mode
2
+ import { VoiceEntry, MoodAlert, TrendsData } from "./api";
3
+
4
+ const DEMO_USER_ID = "demo-user-local";
5
+
6
+ function daysAgo(n: number): string {
7
+ const d = new Date();
8
+ d.setDate(d.getDate() - n);
9
+ d.setHours(20, 15, 0, 0);
10
+ return d.toISOString();
11
+ }
12
+
13
+ const emotions = ["happy", "neutral", "sad", "happy", "fearful", "neutral", "happy", "sad", "neutral", "anxious", "happy", "neutral", "sad", "angry", "neutral", "happy", "neutral", "sad", "sad", "fearful", "neutral", "neutral", "happy", "neutral", "happy", "happy", "neutral", "happy", "sad", "happy"];
14
+
15
+ const transcriptions: Record<string, string[]> = {
16
+ happy: ["Today was genuinely great. I feel energised and hopeful about what's ahead.", "Spent time with people I love. Laughing feels like medicine.", "Something shifted today — I feel lighter than I have in weeks."],
17
+ neutral: ["Just a regular day. Nothing dramatic, but steady feels okay.", "Work was fine. Meetings ran long. Surviving.", "Feeling present. Not high, not low. Just here."],
18
+ sad: ["There's a heaviness I can't quite shake. Just going through the motions.", "Missing someone today. Feeling the distance.", "Hard to explain. Just off."],
19
+ anxious: ["Mind racing. Too many open tabs. Need to slow down.", "That chest-tight feeling again. Trying to breathe through it."],
20
+ fearful: ["Something feels uncertain and I can't name it.", "Woke up with dread. Working through it slowly."],
21
+ angry: ["Really frustrated with a situation. Trying not to carry it home."],
22
+ };
23
+
24
+ function pickTranscription(emotion: string): string {
25
+ const list = transcriptions[emotion] ?? transcriptions.neutral;
26
+ return list[Math.floor(Math.random() * list.length)];
27
+ }
28
+
29
+ function moodArc(i: number): number {
30
+ // Simulate a dip ~2 weeks ago with recovery
31
+ const x = (i - 15) / 15;
32
+ return Math.round(50 + 25 * Math.cos(x * Math.PI * 0.8));
33
+ }
34
+
35
+ export const DEMO_ENTRIES: VoiceEntry[] = Array.from({ length: 30 }, (_, i) => {
36
+ const daysAgoN = 30 - i;
37
+ const skip = [5, 11, 18, 25].includes(daysAgoN);
38
+ if (skip) return null as unknown as VoiceEntry;
39
+
40
+ const emotion = emotions[i % emotions.length];
41
+ const base = moodArc(i);
42
+ const jitter = () => Math.floor(Math.random() * 10 - 5);
43
+
44
+ const mood_score = Math.max(10, Math.min(95, base + jitter()));
45
+ const energy_score = Math.max(10, Math.min(95, base - 5 + jitter()));
46
+ const calmness_score = Math.max(10, Math.min(95, base + 5 + jitter()));
47
+ const clarity_score = Math.max(10, Math.min(95, base - 3 + jitter()));
48
+
49
+ return {
50
+ id: `demo-entry-${i}`,
51
+ created_at: daysAgo(daysAgoN),
52
+ primary_emotion: emotion,
53
+ emotion_confidence: parseFloat((0.65 + Math.random() * 0.3).toFixed(3)),
54
+ energy_score,
55
+ calmness_score,
56
+ mood_score,
57
+ clarity_score,
58
+ transcription: pickTranscription(emotion),
59
+ duration_seconds: parseFloat((35 + Math.random() * 25).toFixed(1)),
60
+ pitch_mean: parseFloat((130 + Math.random() * 60).toFixed(2)),
61
+ speech_rate: parseFloat((80 + Math.random() * 40).toFixed(2)),
62
+ pause_count: Math.floor(2 + Math.random() * 10),
63
+ filler_rate: parseFloat((0.01 + Math.random() * 0.06).toFixed(4)),
64
+ };
65
+ }).filter(Boolean);
66
+
67
+ export const DEMO_LATEST_ENTRY: VoiceEntry = {
68
+ id: "demo-today",
69
+ created_at: new Date().toISOString(),
70
+ primary_emotion: "happy",
71
+ emotion_confidence: 0.82,
72
+ energy_score: 74,
73
+ calmness_score: 68,
74
+ mood_score: 76,
75
+ clarity_score: 71,
76
+ transcription: "Honestly, today felt like a turning point. I feel more settled than I have in a while and I'm trying to hold onto that feeling.",
77
+ duration_seconds: 48.2,
78
+ pitch_mean: 162.4,
79
+ speech_rate: 98.5,
80
+ pause_count: 4,
81
+ filler_rate: 0.023,
82
+ };
83
+
84
+ export const DEMO_ALERTS: MoodAlert[] = [
85
+ {
86
+ id: "demo-alert-1",
87
+ created_at: daysAgo(2),
88
+ alert_type: "mood_decline",
89
+ severity: "warning",
90
+ message: "Your mood declined 24% this week compared to last week.",
91
+ suggested_action: "Try scheduling something enjoyable today, even something small.",
92
+ is_read: false,
93
+ },
94
+ {
95
+ id: "demo-alert-2",
96
+ created_at: daysAgo(1),
97
+ alert_type: "low_energy",
98
+ severity: "concern",
99
+ message: "Your energy has been consistently low for 5 days.",
100
+ suggested_action: "Even a 10-minute walk can shift your energy. Try it today.",
101
+ is_read: false,
102
+ },
103
+ ];
104
+
105
+ export const DEMO_TRENDS: TrendsData = {
106
+ entries_count: 26,
107
+ streak: 5,
108
+ most_common_emotion: "neutral",
109
+ this_week: { mood: 68, energy: 62, calmness: 71, clarity: 66 },
110
+ last_week: { mood: 54, energy: 49, calmness: 58, clarity: 61 },
111
+ insights: [
112
+ "Your best days tend to be Wednesdays 🌟",
113
+ "Your mood tends to dip on Sundays",
114
+ "You've been improving steadily for 5 days 🌱",
115
+ ],
116
+ };
117
+
118
+ export const DEMO_CHAT_HISTORY = [
119
+ { id: "dm1", role: "user", content: "I'm fine, just a bit tired.", created_at: daysAgo(0) },
120
+ { id: "dm2", role: "assistant", content: "I hear you — and 'tired' can mean so many different things. Your voice this week has been a little lower in energy than usual. Is there something specific weighing on you, or is it more of a general fatigue?", created_at: daysAgo(0) },
121
+ { id: "dm3", role: "user", content: "Why have I been feeling off lately?", created_at: daysAgo(0) },
122
+ { id: "dm4", role: "assistant", content: "Looking at your last two weeks, your mood started dipping around 14 days ago and your energy has been below your baseline since then. That sometimes happens when routines are disrupted or there's background stress building up. Does anything stand out from around that time?", created_at: daysAgo(0) },
123
+ ];
124
+
125
+ export { DEMO_USER_ID };