Spaces:
Running
Running
Commit ·
bf04727
0
Parent(s):
Initial commit: InnerVoice MVP
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +29 -0
- README.md +64 -0
- backend/.env.example +5 -0
- backend/main.py +72 -0
- backend/models/__init__.py +0 -0
- backend/models/database.py +108 -0
- backend/requirements.txt +17 -0
- backend/routes/__init__.py +0 -0
- backend/routes/alerts.py +41 -0
- backend/routes/analyze.py +159 -0
- backend/routes/chat.py +93 -0
- backend/routes/entries.py +43 -0
- backend/routes/trends.py +96 -0
- backend/seed_data.py +230 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_processor.py +80 -0
- backend/services/emotion_analyzer.py +75 -0
- backend/services/emotion_fusion.py +85 -0
- backend/services/gpt_service.py +153 -0
- backend/services/mood_calculator.py +59 -0
- backend/services/text_emotion.py +57 -0
- backend/services/transcriber.py +32 -0
- backend/services/trend_detector.py +93 -0
- frontend/.env.local.example +12 -0
- frontend/.eslintrc.json +3 -0
- frontend/.gitignore +36 -0
- frontend/README.md +36 -0
- frontend/app/chat/page.tsx +169 -0
- frontend/app/dashboard/page.tsx +171 -0
- frontend/app/favicon.ico +0 -0
- frontend/app/fonts/GeistMonoVF.woff +0 -0
- frontend/app/fonts/GeistVF.woff +0 -0
- frontend/app/globals.css +88 -0
- frontend/app/layout.tsx +33 -0
- frontend/app/page.tsx +77 -0
- frontend/app/record/page.tsx +183 -0
- frontend/app/settings/page.tsx +116 -0
- frontend/app/timeline/page.tsx +134 -0
- frontend/components/AlertCard.tsx +71 -0
- frontend/components/CalendarHeatmap.tsx +101 -0
- frontend/components/CircularProgress.tsx +66 -0
- frontend/components/DemoModeBanner.tsx +24 -0
- frontend/components/EmotionBadge.tsx +47 -0
- frontend/components/MoodTimeline.tsx +103 -0
- frontend/components/Navbar.tsx +100 -0
- frontend/components/WaveformVisualizer.tsx +87 -0
- frontend/hooks/useAudioRecorder.ts +120 -0
- frontend/hooks/useDemoMode.ts +33 -0
- frontend/lib/api.ts +111 -0
- 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 };
|