forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
610 lines (494 loc) · 21.4 KB
/
Copy pathmain.py
File metadata and controls
610 lines (494 loc) · 21.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
"""
Omi Audio Emotion Analysis - FastAPI Server
Main file with API endpoints only. Helper functions are in app.py
"""
import os
import tempfile
from datetime import datetime
from typing import Optional
from pathlib import Path
# Load environment variables
from dotenv import load_dotenv
load_dotenv()
# FastAPI imports
from fastapi import FastAPI, Request, Query, HTTPException
from fastapi.responses import JSONResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
import uvicorn
# Import all helper functions from app.py
from app import (
# Config and globals
audio_stats,
EMOTION_CONFIG,
load_emotion_config,
# Rizz functions
update_rizz_score,
get_rizz_status_text,
get_rizz_notification_message,
can_send_notification,
update_notification_time,
NOTIFICATION_COOLDOWN_SECONDS,
# Audio processing
create_wav_header,
cleanup_old_audio_files,
# Omi integration
send_omi_notification,
create_omi_memory,
save_emotion_memory,
emotion_memory_background_task,
generate_emotion_summary,
# Emotion detection
check_emotion_triggers,
# Hume AI
analyze_text_with_hume,
analyze_audio_with_hume,
)
# Initialize FastAPI app
app = FastAPI(title="Omi Audio Streaming Service with Hume AI")
# Initialize Jinja2 templates
templates = Jinja2Templates(directory="templates")
# ============================================================================
# STARTUP EVENT
# ============================================================================
@app.on_event("startup")
async def startup_event():
"""Initialize background tasks on server startup"""
import asyncio
print("🚀 Starting emotion memory background task (runs every 1 hour)...")
asyncio.create_task(emotion_memory_background_task())
print("🗑️ Starting audio file cleanup task (runs every 1 minute)...")
asyncio.create_task(cleanup_old_audio_files())
# ============================================================================
# API ENDPOINTS
# ============================================================================
@app.post("/audio")
async def handle_audio_stream(
request: Request,
sample_rate: int = Query(..., description="Audio sample rate in Hz"),
uid: str = Query(..., description="User ID"),
analyze_emotion: bool = Query(True, description="Whether to analyze emotions with Hume AI"),
send_notification: Optional[bool] = Query(None, description="Override notification setting (uses config default if not specified)"),
emotion_filters: Optional[str] = Query(None, description="Override emotion filters (uses config default if not specified)")
):
"""
Endpoint to receive audio bytes from Omi device and analyze with Hume AI.
Query Parameters:
- sample_rate: Audio sample rate (e.g., 8000 or 16000)
- uid: User unique ID
- analyze_emotion: Whether to analyze emotions with Hume AI (default: True)
- send_notification: Whether to send Omi notification (uses config default if not specified)
- emotion_filters: JSON string of emotion:threshold pairs
Examples:
- '{"Anger":0.7}' - notify only if Anger >= 0.7
- '{"Anger":0.7,"Sadness":0.6}' - notify if Anger >= 0.7 OR Sadness >= 0.6
- null - notify for all detected emotions
Body:
- Binary audio data (application/octet-stream)
Examples:
# Basic emotion analysis
POST /audio?sample_rate=16000&uid=user123
# With notification for any emotion
POST /audio?sample_rate=16000&uid=user123&send_notification=true
# With notification only for high anger or sadness
POST /audio?sample_rate=16000&uid=user123&send_notification=true&emotion_filters={"Anger":0.7,"Sadness":0.6}
"""
try:
# Update stats
audio_stats["total_requests"] += 1
audio_stats["last_request_time"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
audio_stats["last_uid"] = uid
# Read audio bytes from request body
audio_data = await request.body()
if not audio_data:
raise HTTPException(status_code=400, detail="No audio data received")
print(f"Received {len(audio_data)} bytes of audio from user {uid} at {sample_rate}Hz")
# Create temporary WAV file
audio_dir = Path("audio_files")
audio_dir.mkdir(exist_ok=True)
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S_%f")
filename = f"{uid}_{timestamp}.wav"
local_file_path = audio_dir / filename
# Write WAV file with header
wav_header = create_wav_header(sample_rate, len(audio_data))
with open(local_file_path, 'wb') as f:
f.write(wav_header)
f.write(audio_data)
print(f"Saved audio file: {local_file_path}")
# Analyze with Hume AI if requested
hume_results = None
if analyze_emotion:
hume_api_key = os.getenv('HUME_API_KEY')
if not hume_api_key:
print("Warning: HUME_API_KEY not set, skipping emotion analysis")
else:
print(f"Analyzing audio with Hume AI...")
hume_results = await analyze_audio_with_hume(str(local_file_path))
if hume_results.get("success"):
audio_stats["successful_analyses"] += 1
predictions = hume_results.get("predictions", [])
all_top_emotions = []
for pred in predictions:
top_3 = pred.get("top_3_emotions", [])
all_top_emotions.extend(top_3)
for emotion in top_3:
emotion_name = emotion.get("name")
if emotion_name:
audio_stats["emotion_counts"][emotion_name] = \
audio_stats["emotion_counts"].get(emotion_name, 0) + 1
if all_top_emotions:
audio_stats["recent_emotions"] = all_top_emotions[:10]
update_rizz_score(all_top_emotions)
# Check if should send notification
should_notify = send_notification if send_notification is not None else EMOTION_CONFIG.get("notification_enabled", True)
has_predictions = len(predictions) > 0
print(f"🔔 Notification check: should_notify={should_notify}, has_predictions={has_predictions}")
if should_notify and has_predictions:
import json
# Use custom filters if provided, otherwise use config
if emotion_filters:
try:
custom_filters = json.loads(emotion_filters)
print(f"Using custom emotion filters: {custom_filters}")
except json.JSONDecodeError:
print(f"Warning: Invalid emotion_filters JSON, using config default")
custom_filters = EMOTION_CONFIG.get("emotion_thresholds", {})
else:
custom_filters = EMOTION_CONFIG.get("emotion_thresholds", {})
print(f"Using config emotion filters: {custom_filters}")
# Check trigger conditions
trigger_result = check_emotion_triggers(predictions, custom_filters)
print(f"📊 Trigger check result: triggered={trigger_result['triggered']}, count={trigger_result['count']}")
if trigger_result["triggered"]:
triggered_emotions = trigger_result["emotions"]
emotion_names = [e["name"] for e in triggered_emotions[:3]]
# Check cooldown before sending notification
if can_send_notification():
message = get_rizz_notification_message(audio_stats["rizz_score"], triggered_emotions)
notification_result = await send_omi_notification(uid, message)
if notification_result.get("success"):
update_notification_time()
audio_stats["recent_notifications"].append({
"uid": uid,
"emotions": emotion_names,
"timestamp": datetime.utcnow().isoformat()
})
audio_stats["recent_notifications"] = audio_stats["recent_notifications"][-10:]
else:
print(f"⏳ Notification cooldown active. Skipping notification.")
else:
audio_stats["failed_analyses"] += 1
response_data = {
"message": "Audio processed successfully",
"filename": filename,
"uid": uid,
"sample_rate": sample_rate,
"data_size_bytes": len(audio_data),
"timestamp": timestamp,
"local_file_path": str(local_file_path.absolute())
}
# Add Hume results if available
if hume_results:
response_data["hume_analysis"] = hume_results
return JSONResponse(
status_code=200,
content=response_data
)
except Exception as e:
print(f"Error processing audio: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@app.get("/", response_class=HTMLResponse)
async def root(request: Request):
"""Root endpoint with web interface"""
hume_configured = bool(os.getenv('HUME_API_KEY'))
omi_configured = bool(os.getenv('OMI_APP_ID') and os.getenv('OMI_API_KEY'))
# Build emotion statistics data
emotion_stats = []
if audio_stats['emotion_counts']:
sorted_emotions = sorted(
audio_stats['emotion_counts'].items(),
key=lambda x: x[1],
reverse=True
)[:10]
total_count = sum(audio_stats['emotion_counts'].values())
for emotion, count in sorted_emotions:
percentage = (count / total_count) * 100
bar_width = int(percentage * 2)
bar = '█' * bar_width
emotion_stats.append((emotion, count, percentage, bar))
# Rizz meter display
rizz_score = audio_stats.get("rizz_score", 75)
rizz_status = get_rizz_status_text(rizz_score)
rizz_color = "#4CAF50" if rizz_score >= 60 else "#ff9800" if rizz_score >= 40 else "#f44336"
rizz_bg_color = "#2ecc71" if rizz_score >= 60 else "#f39c12" if rizz_score >= 40 else "#e74c3c"
return templates.TemplateResponse("dashboard.html", {
"request": request,
"hume_configured": hume_configured,
"omi_configured": omi_configured,
"stats": audio_stats,
"emotion_stats": emotion_stats,
"rizz_score": rizz_score,
"rizz_status": rizz_status,
"rizz_color": rizz_color,
"rizz_bg_color": rizz_bg_color,
"ngrok_url": os.getenv('NGROK_URL', 'https://your-ngrok-url.ngrok-free.app')
})
@app.get("/status")
async def get_status():
"""Get current server status and stats"""
hume_configured = bool(os.getenv('HUME_API_KEY'))
omi_configured = bool(os.getenv('OMI_APP_ID') and os.getenv('OMI_API_KEY'))
return JSONResponse({
"status": "online",
"service": "omi-audio-streaming",
"configuration": {
"hume_ai": hume_configured,
"omi_integration": omi_configured
},
"stats": audio_stats
})
@app.post("/analyze-text")
async def analyze_text_emotion(
request: Request,
uid: Optional[str] = Query(None, description="User ID (optional)")
):
"""
Analyze emotion from text using Hume AI Language model.
⚠️ NOTE: This endpoint has NOT been tested yet.
It's available for future use if you want to combine audio + text emotion analysis.
Currently, the /audio endpoint only processes audio (speech prosody).
Body (JSON):
{
"text": "Your text here...",
"metadata": {} // optional
}
Example curl:
curl -X POST "https://your-url/analyze-text?uid=user123" \
-H "Content-Type: application/json" \
-d '{"text": "I am feeling really happy and excited today!"}'
"""
try:
# Parse JSON body
body = await request.json()
text = body.get('text')
metadata = body.get('metadata', {})
if not text:
raise HTTPException(status_code=400, detail="Missing 'text' field in request body")
# Check text length (API limit is 10,000 characters)
if len(text) > 10000:
raise HTTPException(
status_code=400,
detail=f"Text too long ({len(text)} characters). Maximum is 10,000 characters."
)
print(f"Analyzing text emotion for user: {uid or 'anonymous'}")
print(f"Text length: {len(text)} characters")
print(f"Text preview: {text[:100]}...")
# Analyze text with Hume AI
hume_results = await analyze_text_with_hume(text)
response_data = {
"message": "Text emotion analysis complete",
"text_length": len(text),
"uid": uid,
"hume_analysis": hume_results,
"metadata": metadata
}
return JSONResponse(
status_code=200,
content=response_data
)
except HTTPException:
raise
except Exception as e:
print(f"Error analyzing text: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@app.get("/emotion-config")
async def get_emotion_config():
"""Get current emotion notification configuration"""
return JSONResponse({
"current_config": EMOTION_CONFIG
})
@app.post("/emotion-config")
async def update_emotion_config(request: Request):
"""
Update emotion notification configuration (LOCAL DEVELOPMENT ONLY)
WARNING: This endpoint only works in single-worker deployments.
In production with multiple workers, configuration changes will only
affect the worker that receives this request, causing inconsistent behavior.
For production deployments, update the EMOTION_NOTIFICATION_CONFIG
environment variable instead and redeploy.
Body (JSON):
{
"notification_enabled": true,
"emotion_thresholds": {
"Anger": 0.7,
"Sadness": 0.6
}
}
"""
# Check if running in single-worker mode
if os.getenv('RENDER') or os.getenv('RAILWAY') or os.getenv('FLY_APP_NAME'):
return JSONResponse(
status_code=400,
content={
"error": "Runtime configuration updates are disabled in cloud deployments",
"message": "Update the EMOTION_NOTIFICATION_CONFIG environment variable and redeploy instead",
"documentation": "See README for configuration instructions"
}
)
global EMOTION_CONFIG
try:
new_config = await request.json()
# Validate config
if "notification_enabled" in new_config:
if not isinstance(new_config["notification_enabled"], bool):
raise HTTPException(status_code=400, detail="notification_enabled must be boolean")
if "emotion_thresholds" in new_config:
if not isinstance(new_config["emotion_thresholds"], dict):
raise HTTPException(status_code=400, detail="emotion_thresholds must be a dict")
# Validate thresholds are numbers between 0 and 1
for emotion, threshold in new_config["emotion_thresholds"].items():
if not isinstance(threshold, (int, float)) or threshold < 0 or threshold > 1:
raise HTTPException(
status_code=400,
detail=f"Threshold for {emotion} must be between 0 and 1"
)
# Update configuration
EMOTION_CONFIG.update(new_config)
# Save to file (local development only)
import json
config_file = Path("emotion_config.json")
with open(config_file, 'w') as f:
json.dump(EMOTION_CONFIG, f, indent=2)
print(f"✓ Updated emotion config (local): {EMOTION_CONFIG}")
return {
"message": "Configuration updated successfully (local development only)",
"new_config": EMOTION_CONFIG,
"warning": "Changes will be lost on restart. Use environment variables for persistent config."
}
except HTTPException:
raise
except Exception as e:
print(f"Error updating config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/reset-stats")
async def reset_stats(request: Request):
"""Reset all statistics (requires confirmation)"""
try:
body = await request.json()
if not body.get("confirm"):
raise HTTPException(status_code=400, detail="Confirmation required")
# Reset stats
audio_stats["total_requests"] = 0
audio_stats["successful_analyses"] = 0
audio_stats["failed_analyses"] = 0
audio_stats["last_request_time"] = None
audio_stats["last_uid"] = None
audio_stats["recent_emotions"] = []
audio_stats["emotion_counts"] = {}
audio_stats["rizz_score"] = 75
audio_stats["recent_notifications"] = []
print("✓ Statistics reset")
return {"message": "Statistics reset successfully"}
except HTTPException:
raise
except Exception as e:
print(f"Error resetting stats: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/save-emotion-memory")
async def manual_save_emotion_memory(uid: Optional[str] = Query(None, description="User ID (optional)")):
"""
Manually save current emotion statistics to Omi memories.
Query Parameters:
- uid: User ID (optional, uses last active user if not provided)
"""
result = await save_emotion_memory(uid)
if result.get("success"):
return JSONResponse(
status_code=200,
content={
"message": "Emotion memory saved successfully",
"result": result
}
)
else:
return JSONResponse(
status_code=400,
content={
"message": "Failed to save emotion memory",
"error": result.get("error")
}
)
@app.post("/force-send-notification")
async def force_send_notification_endpoint(uid: Optional[str] = Query(None, description="User ID (optional)")):
"""
Force send a notification immediately, bypassing cooldown.
Query Parameters:
- uid: User ID (optional, uses last active user if not provided)
"""
target_uid = uid or audio_stats.get("last_uid")
if not target_uid:
return JSONResponse(
status_code=400,
content={
"message": "No user ID available. Please provide a UID or speak into your Omi device first.",
"success": False
}
)
# Get recent emotions for the notification
recent_emotions = audio_stats.get("recent_emotions", [])[:3]
if not recent_emotions:
return JSONResponse(
status_code=400,
content={
"message": "No recent emotions detected. Please speak into your Omi device first.",
"success": False
}
)
# Generate message with current rizz score
message = get_rizz_notification_message(audio_stats["rizz_score"], recent_emotions)
# Send notification (force, ignore cooldown)
notification_result = await send_omi_notification(target_uid, message)
if notification_result.get("success"):
# Update notification time
update_notification_time()
# Log notification
emotion_names = [e.get("name") for e in recent_emotions]
audio_stats["recent_notifications"].append({
"uid": target_uid,
"emotions": emotion_names,
"timestamp": datetime.utcnow().isoformat(),
"forced": True
})
audio_stats["recent_notifications"] = audio_stats["recent_notifications"][-10:]
return JSONResponse(
status_code=200,
content={
"message": "Notification sent successfully",
"success": True,
"uid": target_uid,
"notification_message": message,
"emotions": emotion_names
}
)
else:
return JSONResponse(
status_code=500,
content={
"message": "Failed to send notification",
"success": False,
"error": notification_result.get("error")
}
)
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "service": "omi-audio-streaming"}
# ============================================================================
# SERVER STARTUP
# ============================================================================
if __name__ == "__main__":
# Run the server
uvicorn.run(app, host="0.0.0.0", port=8080)