infinicode 2.8.13 → 2.8.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/kernel/federation/dashboard-html.d.ts +1 -1
  2. package/dist/kernel/federation/dashboard-html.js +32 -1
  3. package/dist/robopark/auto-start.d.ts +1 -1
  4. package/dist/robopark/preview-agent-launcher.d.ts +3 -0
  5. package/dist/robopark/preview-agent-launcher.js +8 -0
  6. package/dist/robopark/python-env.d.ts +10 -4
  7. package/dist/robopark/python-env.js +28 -16
  8. package/dist/robopark/setup.d.ts +1 -0
  9. package/dist/robopark/setup.js +22 -0
  10. package/dist/robopark/vision-agent-launcher.d.ts +7 -0
  11. package/dist/robopark/vision-agent-launcher.js +55 -0
  12. package/dist/robopark-cli.js +16 -0
  13. package/package.json +3 -1
  14. package/packages/robopark/pi-client/README.md +17 -0
  15. package/packages/robopark/pi-client/_install_steps.sh +29 -0
  16. package/packages/robopark/pi-client/client.py +305 -0
  17. package/packages/robopark/pi-client/install.sh +41 -0
  18. package/packages/robopark/pi-client/join_convo.sh +55 -0
  19. package/packages/robopark/pi-client/livekit_bridge.py +289 -0
  20. package/packages/robopark/pi-client/motor_bridge.py +82 -0
  21. package/packages/robopark/pi-client/pi_ui.py +375 -0
  22. package/packages/robopark/pi-client/requirements.txt +10 -0
  23. package/packages/robopark/pi-client/robopark-pi-client.service +26 -0
  24. package/packages/robopark/pi-client/robopark-pi-ui.service +24 -0
  25. package/packages/robopark/pi-client/smoke_bridge.py +15 -0
  26. package/packages/robopark/pi-client/stub_motor_server.py +46 -0
  27. package/packages/robopark/scheduler/main.py +45 -9
  28. package/packages/robopark/scheduler/preview_agent.py +132 -3
  29. package/packages/robopark/vision/.env.example +7 -0
  30. package/packages/robopark/vision/README.md +66 -0
  31. package/packages/robopark/vision/app_pi_clean.py +326 -0
  32. package/packages/robopark/vision/audio_server_pi.py +751 -0
  33. package/packages/robopark/vision/install.sh +34 -0
  34. package/packages/robopark/vision/motor_server.py +304 -0
  35. package/packages/robopark/vision/requirements-demo.txt +12 -0
  36. package/packages/robopark/vision/requirements_pi_unified.txt +101 -0
  37. package/packages/robopark/vision/run.sh +244 -0
  38. package/packages/robopark/vision/services/services.sh +12 -0
@@ -0,0 +1,751 @@
1
+ """
2
+ Raspberry Pi Audio Server with Bluetooth Support
3
+ Handles audio playback and microphone recording via Bluetooth devices
4
+ """
5
+ from fastapi import FastAPI, File, UploadFile, HTTPException, Form
6
+ from fastapi.responses import JSONResponse
7
+ from contextlib import asynccontextmanager
8
+ import uvicorn
9
+ import sounddevice as sd
10
+ import soundfile as sf
11
+ import numpy as np
12
+ import requests
13
+ import threading
14
+ import time
15
+ import tempfile
16
+ import os
17
+ from datetime import datetime
18
+ import logging
19
+ import subprocess
20
+ import base64
21
+ from groq import Groq
22
+
23
+ # Configure logging
24
+ logging.basicConfig(level=logging.INFO, format='%(message)s')
25
+ logger = logging.getLogger(__name__)
26
+
27
+ @asynccontextmanager
28
+ async def lifespan(app: FastAPI):
29
+ """Lifespan event handler for startup and shutdown"""
30
+ global recording_active, groq_client
31
+
32
+ # Startup
33
+ logger.info("\n" + "="*60)
34
+ logger.info("šŸš€ Raspberry Pi Audio Server Starting...")
35
+ logger.info("="*60)
36
+ logger.info("šŸŽ§ Bluetooth audio support enabled")
37
+ logger.info("🌐 Server URL: http://0.0.0.0:8000")
38
+ logger.info("šŸ“‹ API Documentation: http://0.0.0.0:8000/docs")
39
+ logger.info("="*60 + "\n")
40
+
41
+ # Initialize Groq client
42
+ if TRANSCRIPTION_ENABLED:
43
+ if GROQ_API_KEY:
44
+ try:
45
+ logger.info("šŸ¤– Initializing Groq Whisper API...")
46
+ logger.info(f" Model: {WHISPER_MODEL}")
47
+ groq_client = Groq(api_key=GROQ_API_KEY)
48
+ logger.info("āœ“ Groq API ready\n")
49
+ except Exception as e:
50
+ logger.error(f"āœ— Failed to initialize Groq: {e}")
51
+ logger.warning("⚠ Transcription will be disabled\n")
52
+ groq_client = None
53
+ else:
54
+ logger.warning("⚠ GROQ_API_KEY not set")
55
+ logger.warning("⚠ Get free API key from: https://console.groq.com")
56
+ logger.warning("⚠ Transcription will be disabled\n")
57
+ groq_client = None
58
+
59
+ # List available audio devices
60
+ try:
61
+ logger.info("šŸ”Š Available audio devices:")
62
+ devices = sd.query_devices()
63
+ for i, device in enumerate(devices):
64
+ logger.info(f" [{i}] {device['name']} - In: {device['max_input_channels']}, Out: {device['max_output_channels']}")
65
+ logger.info("")
66
+ except Exception as e:
67
+ logger.warning(f"⚠ Could not list audio devices: {e}")
68
+
69
+ yield # Server runs here
70
+
71
+ # Shutdown
72
+ recording_active = False
73
+ logger.info("\nāœ“ Server shutting down")
74
+
75
+ app = FastAPI(
76
+ title="Raspberry Pi Audio Server",
77
+ description="Audio playback and recording server with Bluetooth support",
78
+ lifespan=lifespan
79
+ )
80
+
81
+ # Configuration
82
+ WEBHOOK_AUDIO_DETECTED = os.getenv("WEBHOOK_AUDIO_DETECTED", "")
83
+ WEBHOOK_NO_AUDIO = os.getenv("WEBHOOK_NO_AUDIO", "")
84
+
85
+ # Audio recording settings
86
+ CHANNELS = 1
87
+ RATE = 44100
88
+ RECORD_SECONDS = 10
89
+ SILENCE_THRESHOLD = 0.00005 # Ultra-sensitive threshold
90
+ SILENCE_DURATION = 2.0 # Seconds of silence before stopping
91
+ DIAGNOSTIC_MODE = True # Show audio levels for debugging
92
+ MIC_BOOST = 10.0 # Amplification factor for quiet microphones
93
+
94
+ # Bluetooth audio device settings
95
+ BLUETOOTH_DEVICE_NAME = os.getenv("BLUETOOTH_DEVICE_NAME", None) # e.g., "My Bluetooth Headset"
96
+ BLUETOOTH_INPUT_DEVICE = None
97
+ BLUETOOTH_OUTPUT_DEVICE = None
98
+
99
+ # Global state
100
+ recording_active = False
101
+ audio_workflow_active = False # Flag to pause motion detection during audio workflow
102
+ groq_client = None
103
+
104
+ # Transcription settings
105
+ TRANSCRIPTION_ENABLED = os.getenv("TRANSCRIPTION_ENABLED", "true").lower() == "true"
106
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
107
+ WHISPER_MODEL = os.getenv("WHISPER_MODEL", "whisper-large-v3") # whisper-large-v3 or whisper-large-v3-turbo
108
+ AUTO_DETECT_LANGUAGE = os.getenv("AUTO_DETECT_LANGUAGE", "true").lower() == "true"
109
+
110
+ def find_bluetooth_devices():
111
+ """Find Bluetooth audio devices and list all available devices"""
112
+ global BLUETOOTH_INPUT_DEVICE, BLUETOOTH_OUTPUT_DEVICE
113
+
114
+ try:
115
+ devices = sd.query_devices()
116
+
117
+ logger.info("\n" + "="*60)
118
+ logger.info("šŸŽ§ AVAILABLE AUDIO DEVICES:")
119
+ logger.info("="*60)
120
+
121
+ for i, device in enumerate(devices):
122
+ device_name = device['name']
123
+ device_name_lower = device_name.lower()
124
+
125
+ # Detect device type
126
+ is_bluetooth = any(keyword in device_name_lower for keyword in ['bluetooth', 'bt', 'wireless', 'bluez'])
127
+ is_usb = any(keyword in device_name_lower for keyword in ['usb', 'webcam', 'camera'])
128
+
129
+ device_type = ""
130
+ if is_bluetooth:
131
+ device_type = "šŸŽ§ BLUETOOTH"
132
+ elif is_usb:
133
+ device_type = "šŸ”Œ USB"
134
+ else:
135
+ device_type = "šŸŽµ SYSTEM"
136
+
137
+ # Show input/output capabilities
138
+ capabilities = []
139
+ if device['max_input_channels'] > 0:
140
+ capabilities.append(f"IN:{device['max_input_channels']}")
141
+ if device['max_output_channels'] > 0:
142
+ capabilities.append(f"OUT:{device['max_output_channels']}")
143
+
144
+ caps_str = " | ".join(capabilities) if capabilities else "NO I/O"
145
+
146
+ logger.info(f" [{i:2d}] {device_type:15s} | {caps_str:12s} | {device_name}")
147
+
148
+ # Auto-select Bluetooth devices
149
+ if BLUETOOTH_DEVICE_NAME and BLUETOOTH_DEVICE_NAME.lower() in device_name_lower:
150
+ is_bluetooth = True
151
+
152
+ if is_bluetooth:
153
+ if device['max_input_channels'] > 0 and BLUETOOTH_INPUT_DEVICE is None:
154
+ BLUETOOTH_INPUT_DEVICE = i
155
+ logger.info(f" āœ“ Selected as Bluetooth INPUT")
156
+
157
+ if device['max_output_channels'] > 0 and BLUETOOTH_OUTPUT_DEVICE is None:
158
+ BLUETOOTH_OUTPUT_DEVICE = i
159
+ logger.info(f" āœ“ Selected as Bluetooth OUTPUT")
160
+
161
+ logger.info("="*60)
162
+
163
+ if BLUETOOTH_INPUT_DEVICE is None:
164
+ logger.warning("⚠ No Bluetooth input device found, will use default")
165
+ if BLUETOOTH_OUTPUT_DEVICE is None:
166
+ logger.warning("⚠ No Bluetooth output device found, will use default")
167
+
168
+ logger.info("")
169
+
170
+ except Exception as e:
171
+ logger.error(f"āœ— Error finding Bluetooth devices: {e}")
172
+
173
+ # Find Bluetooth devices on startup
174
+ find_bluetooth_devices()
175
+
176
+ def play_audio_file(file_path: str):
177
+ """Play audio file using sounddevice (supports Bluetooth)"""
178
+ try:
179
+ logger.info(f"šŸ”Š Playing audio: {file_path}")
180
+
181
+ # Read audio file
182
+ data, samplerate = sf.read(file_path)
183
+
184
+ # Play audio on Bluetooth device if available
185
+ device = BLUETOOTH_OUTPUT_DEVICE if BLUETOOTH_OUTPUT_DEVICE is not None else None
186
+
187
+ if device is not None:
188
+ logger.info(f"šŸŽ§ Using Bluetooth output device: {device}")
189
+
190
+ sd.play(data, samplerate, device=device)
191
+ sd.wait() # Wait until playback is finished
192
+
193
+ logger.info(f"āœ“ Audio playback completed")
194
+ return True
195
+ except Exception as e:
196
+ logger.error(f"āœ— Error playing audio: {e}")
197
+
198
+ # Fallback to system command
199
+ try:
200
+ logger.info(" Trying aplay fallback...")
201
+ subprocess.run(['aplay', file_path], check=True)
202
+ logger.info(f"āœ“ Audio playback completed (aplay)")
203
+ return True
204
+ except Exception as e2:
205
+ logger.error(f"āœ— Fallback playback also failed: {e2}")
206
+ return False
207
+
208
+ def detect_audio_level(audio_data, apply_boost=False):
209
+ """Detect if audio level is above threshold"""
210
+ if apply_boost:
211
+ audio_data = audio_data * MIC_BOOST
212
+
213
+ volume = np.abs(audio_data).mean()
214
+ max_volume = np.abs(audio_data).max()
215
+
216
+ detected = volume > SILENCE_THRESHOLD or max_volume > (SILENCE_THRESHOLD * 3)
217
+
218
+ if DIAGNOSTIC_MODE:
219
+ status = "šŸ”“ DETECTED" if detected else "⚪ silence"
220
+ boost_info = f" [BOOSTED x{MIC_BOOST}]" if apply_boost else ""
221
+ logger.info(f"šŸ“Š Mean: {volume:.6f}, Max: {max_volume:.6f} | Threshold: {SILENCE_THRESHOLD} | {status}{boost_info}")
222
+
223
+ return detected, audio_data if apply_boost else audio_data
224
+
225
+ def get_available_input_devices():
226
+ """Get list of all available input devices"""
227
+ devices = sd.query_devices()
228
+ input_devices = []
229
+ for i, device in enumerate(devices):
230
+ if device['max_input_channels'] > 0:
231
+ input_devices.append(i)
232
+ return input_devices
233
+
234
+ def record_audio_with_vad():
235
+ """
236
+ Record audio from Bluetooth microphone with Voice Activity Detection
237
+ Rotates through available devices if no audio detected
238
+ Returns: (audio_detected: bool, file_path: str or None)
239
+ """
240
+ global recording_active, BLUETOOTH_INPUT_DEVICE
241
+
242
+ # Get all available input devices
243
+ available_devices = get_available_input_devices()
244
+
245
+ # Try Bluetooth first, then rotate through all devices
246
+ devices_to_try = []
247
+ if BLUETOOTH_INPUT_DEVICE is not None and BLUETOOTH_INPUT_DEVICE in available_devices:
248
+ devices_to_try.append(BLUETOOTH_INPUT_DEVICE)
249
+
250
+ # Add other devices
251
+ for dev in available_devices:
252
+ if dev not in devices_to_try:
253
+ devices_to_try.append(dev)
254
+
255
+ # If no devices found, try default (None)
256
+ if not devices_to_try:
257
+ devices_to_try = [None]
258
+
259
+ for attempt, device in enumerate(devices_to_try):
260
+ frames = []
261
+ audio_detected = False
262
+ silence_start = None
263
+ recording_started = False
264
+ stream = None
265
+
266
+ try:
267
+ device_info = sd.query_devices(device) if device is not None else sd.query_devices(kind='input')
268
+ device_name = device_info['name'] if device is not None else "Default"
269
+
270
+ logger.info(f"\n{'='*60}")
271
+ logger.info(f"šŸŽ¤ ATTEMPT {attempt + 1}/{len(devices_to_try)}")
272
+ logger.info(f"{'='*60}")
273
+
274
+ if device is not None:
275
+ logger.info(f"šŸŽ¤ Using device [{device}]: {device_name}")
276
+ else:
277
+ logger.info(f"šŸŽ¤ Using default input device: {device_name}")
278
+
279
+ logger.info(f"Sample rate: {RATE} Hz, Channels: {CHANNELS}")
280
+ logger.info(f"Detection threshold: {SILENCE_THRESHOLD}\n")
281
+ logger.info("šŸŽ¤ Listening for audio...")
282
+
283
+ start_time = time.time()
284
+ chunk_duration = 0.1 # 100ms chunks
285
+ test_duration = 3.0 if attempt > 0 else RECORD_SECONDS # Quick test for non-primary devices
286
+
287
+ while recording_active and (time.time() - start_time) < test_duration:
288
+ # Record a chunk
289
+ chunk = sd.rec(
290
+ int(chunk_duration * RATE),
291
+ samplerate=RATE,
292
+ channels=CHANNELS,
293
+ dtype='float32',
294
+ device=device
295
+ )
296
+ sd.wait()
297
+
298
+ # Check audio level with boost
299
+ has_audio, boosted_chunk = detect_audio_level(chunk, apply_boost=True)
300
+
301
+ if has_audio:
302
+ if not recording_started:
303
+ logger.info("šŸ”“ Audio detected! Recording...")
304
+ recording_started = True
305
+ audio_detected = True
306
+ frames.append(boosted_chunk)
307
+ silence_start = None
308
+ elif recording_started:
309
+ frames.append(boosted_chunk)
310
+ if silence_start is None:
311
+ silence_start = time.time()
312
+ elif time.time() - silence_start > SILENCE_DURATION:
313
+ logger.info("āø Silence detected, stopping recording")
314
+ break
315
+
316
+ # Save recorded audio if any was detected
317
+ if audio_detected and frames:
318
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
319
+ temp_dir = tempfile.gettempdir()
320
+ output_path = os.path.join(temp_dir, f"recorded_audio_{timestamp}.wav")
321
+
322
+ # Concatenate all frames
323
+ audio_data = np.concatenate(frames, axis=0)
324
+
325
+ # Save using soundfile
326
+ sf.write(output_path, audio_data, RATE)
327
+
328
+ logger.info(f"āœ“ Audio saved: {output_path}")
329
+ logger.info(f"āœ“ Device [{device}] successfully captured audio!")
330
+ return True, output_path
331
+ else:
332
+ logger.warning(f"⚠ No audio detected on device [{device}]: {device_name}")
333
+ # Continue to next device
334
+
335
+ except Exception as e:
336
+ logger.error(f"āœ— Error with device [{device}]: {e}")
337
+ # Continue to next device
338
+ finally:
339
+ # Critical: Stop all audio streams and cleanup before trying next device
340
+ try:
341
+ sd.stop()
342
+ time.sleep(0.2) # Give PortAudio time to cleanup
343
+ except:
344
+ pass
345
+
346
+ # If we get here, no device captured audio
347
+ logger.error("āœ— No audio detected on any available device")
348
+ recording_active = False
349
+ return False, None
350
+
351
+ def transcribe_audio(audio_file_path: str):
352
+ """Transcribe audio file using Groq Whisper API"""
353
+ global groq_client
354
+
355
+ if not groq_client:
356
+ logger.warning("⚠ Groq client not initialized, skipping transcription")
357
+ return None, None
358
+
359
+ try:
360
+ logger.info("\n" + "="*60)
361
+ logger.info("šŸŽ™ļø Transcribing audio with Groq Whisper API...")
362
+ logger.info("="*60)
363
+
364
+ # Open audio file
365
+ with open(audio_file_path, "rb") as audio_file:
366
+ # Call Groq Whisper API
367
+ transcription = groq_client.audio.transcriptions.create(
368
+ file=(os.path.basename(audio_file_path), audio_file.read()),
369
+ model=WHISPER_MODEL,
370
+ response_format="verbose_json",
371
+ language=None if AUTO_DETECT_LANGUAGE else "en"
372
+ )
373
+
374
+ transcription_text = transcription.text.strip()
375
+ detected_language = getattr(transcription, 'language', 'unknown')
376
+
377
+ if not transcription_text:
378
+ logger.warning("⚠ No speech detected in audio")
379
+ return None, None
380
+
381
+ logger.info(f"āœ“ Detected language: {detected_language}")
382
+ logger.info(f"āœ“ Transcription: {transcription_text}")
383
+ logger.info("="*60 + "\n")
384
+
385
+ return transcription_text, detected_language
386
+
387
+ except Exception as e:
388
+ logger.error(f"āœ— Transcription error: {e}")
389
+ return None, None
390
+
391
+ def send_transcription_to_webhook(transcription: str, language: str, audio_file_path: str = None):
392
+ """Send transcription text to webhook"""
393
+ if not WEBHOOK_AUDIO_DETECTED:
394
+ logger.warning("⚠ No webhook URL configured for audio detection")
395
+ return False
396
+
397
+ try:
398
+ payload = {
399
+ "transcription": transcription,
400
+ "language": language,
401
+ "timestamp": datetime.now().isoformat(),
402
+ "audio_duration": None
403
+ }
404
+
405
+ # Optionally get audio duration
406
+ if audio_file_path and os.path.exists(audio_file_path):
407
+ try:
408
+ data, samplerate = sf.read(audio_file_path)
409
+ duration = len(data) / samplerate
410
+ payload["audio_duration"] = round(duration, 2)
411
+ except:
412
+ pass
413
+
414
+ logger.info("\n" + "="*60)
415
+ logger.info("šŸ“¤ Sending transcription to webhook")
416
+ logger.info("="*60)
417
+ logger.info(f"Text: {transcription}")
418
+ logger.info(f"Language: {language}")
419
+
420
+ response = requests.post(
421
+ WEBHOOK_AUDIO_DETECTED,
422
+ json=payload,
423
+ headers={'Content-Type': 'application/json'},
424
+ timeout=30
425
+ )
426
+
427
+ if response.status_code == 200:
428
+ logger.info(f"āœ“ Transcription sent to webhook successfully")
429
+ logger.info("="*60 + "\n")
430
+ return True
431
+ else:
432
+ logger.warning(f"āœ— Webhook returned status {response.status_code}")
433
+ logger.info("="*60 + "\n")
434
+ return False
435
+ except Exception as e:
436
+ logger.error(f"āœ— Error sending transcription to webhook: {e}")
437
+ logger.info("="*60 + "\n")
438
+ return False
439
+
440
+ def send_stop_to_webhook():
441
+ """Send stop message to webhook"""
442
+ if not WEBHOOK_NO_AUDIO:
443
+ logger.warning("⚠ No webhook URL configured for no audio")
444
+ return False
445
+
446
+ try:
447
+ payload = {
448
+ "status": "stopped",
449
+ "message": "No audio detected",
450
+ "timestamp": datetime.now().isoformat()
451
+ }
452
+ response = requests.post(
453
+ WEBHOOK_NO_AUDIO,
454
+ json=payload,
455
+ headers={'Content-Type': 'application/json'},
456
+ timeout=10
457
+ )
458
+
459
+ if response.status_code == 200:
460
+ logger.info(f"āœ“ Stop message sent to webhook successfully")
461
+ return True
462
+ else:
463
+ logger.warning(f"āœ— Webhook returned status {response.status_code}")
464
+ return False
465
+ except Exception as e:
466
+ logger.error(f"āœ— Error sending stop to webhook: {e}")
467
+ return False
468
+
469
+ def audio_workflow(audio_file_path: str):
470
+ """
471
+ Complete audio workflow:
472
+ 1. Play audio file via Bluetooth
473
+ 2. Start Bluetooth microphone listening
474
+ 3. Transcribe recorded audio
475
+ 4. Send transcription to webhook
476
+ """
477
+ global recording_active, audio_workflow_active
478
+
479
+ # Set flag to pause motion detection
480
+ audio_workflow_active = True
481
+
482
+ logger.info(f"\n{'='*60}")
483
+ logger.info(f"šŸ”Š Starting audio workflow")
484
+ logger.info(f"{'='*60}")
485
+
486
+ # Step 1: Play the audio file
487
+ success = play_audio_file(audio_file_path)
488
+ if not success:
489
+ logger.warning("⚠ Audio playback had issues, but continuing to recording...")
490
+
491
+ time.sleep(0.5)
492
+
493
+ # Step 2: Start recording
494
+ logger.info(f"\n{'='*60}")
495
+ logger.info(f"šŸŽ¤ Starting Bluetooth microphone recording (10s timeout)")
496
+ logger.info(f"{'='*60}")
497
+
498
+ recording_active = True
499
+ audio_detected, recorded_file = record_audio_with_vad()
500
+
501
+ # Step 3: Transcribe and send to webhook
502
+ if audio_detected and recorded_file:
503
+ logger.info(f"āœ“ Audio detected")
504
+
505
+ # Transcribe the audio
506
+ if TRANSCRIPTION_ENABLED and groq_client:
507
+ transcription, language = transcribe_audio(recorded_file)
508
+
509
+ if transcription:
510
+ # Send transcription to webhook
511
+ send_transcription_to_webhook(transcription, language, recorded_file)
512
+ else:
513
+ logger.warning("⚠ Transcription failed - no text to send")
514
+ send_stop_to_webhook()
515
+ else:
516
+ logger.warning("⚠ Transcription disabled - no text to send")
517
+ send_stop_to_webhook()
518
+
519
+ # Cleanup temporary file
520
+ try:
521
+ os.remove(recorded_file)
522
+ logger.info(f"šŸ—‘ Cleaned up temporary file")
523
+ except:
524
+ pass
525
+ else:
526
+ logger.info(f"⚠ No audio detected - sending stop message")
527
+ send_stop_to_webhook()
528
+
529
+ # Cleanup original file
530
+ try:
531
+ os.remove(audio_file_path)
532
+ except:
533
+ pass
534
+
535
+ # Clear flag to resume motion detection
536
+ audio_workflow_active = False
537
+
538
+ logger.info(f"\n{'='*60}")
539
+ logger.info(f"āœ“ Workflow completed")
540
+ logger.info(f"{'='*60}\n")
541
+
542
+ @app.get("/")
543
+ async def root():
544
+ """Health check endpoint"""
545
+ return {
546
+ "status": "online",
547
+ "service": "Raspberry Pi Audio Server",
548
+ "bluetooth_input": BLUETOOTH_INPUT_DEVICE,
549
+ "bluetooth_output": BLUETOOTH_OUTPUT_DEVICE,
550
+ "audio_workflow_active": audio_workflow_active,
551
+ "endpoints": {
552
+ "play_audio": "/play-audio (POST)",
553
+ "play_audio_base64": "/play-audio-base64 (POST)",
554
+ "list_devices": "/devices (GET)",
555
+ "set_device": "/set-device (POST)",
556
+ "workflow_status": "/workflow-status (GET)",
557
+ "health": "/ (GET)"
558
+ }
559
+ }
560
+
561
+ @app.get("/workflow-status")
562
+ async def workflow_status():
563
+ """Get current audio workflow status"""
564
+ return {
565
+ "audio_workflow_active": audio_workflow_active,
566
+ "recording_active": recording_active
567
+ }
568
+
569
+ @app.get("/devices")
570
+ async def list_devices():
571
+ """List all available audio devices"""
572
+ try:
573
+ devices = sd.query_devices()
574
+ device_list = []
575
+
576
+ for i, device in enumerate(devices):
577
+ device_list.append({
578
+ "index": i,
579
+ "name": device['name'],
580
+ "max_input_channels": device['max_input_channels'],
581
+ "max_output_channels": device['max_output_channels'],
582
+ "default_samplerate": device['default_samplerate']
583
+ })
584
+
585
+ return {
586
+ "devices": device_list,
587
+ "bluetooth_input": BLUETOOTH_INPUT_DEVICE,
588
+ "bluetooth_output": BLUETOOTH_OUTPUT_DEVICE
589
+ }
590
+ except Exception as e:
591
+ raise HTTPException(status_code=500, detail=str(e))
592
+
593
+ @app.post("/set-device")
594
+ async def set_device(request: dict):
595
+ """Set Bluetooth input/output devices manually"""
596
+ global BLUETOOTH_INPUT_DEVICE, BLUETOOTH_OUTPUT_DEVICE
597
+
598
+ try:
599
+ if "input" in request:
600
+ BLUETOOTH_INPUT_DEVICE = request["input"]
601
+ logger.info(f"āœ“ Bluetooth input device set to: {BLUETOOTH_INPUT_DEVICE}")
602
+
603
+ if "output" in request:
604
+ BLUETOOTH_OUTPUT_DEVICE = request["output"]
605
+ logger.info(f"āœ“ Bluetooth output device set to: {BLUETOOTH_OUTPUT_DEVICE}")
606
+
607
+ return {
608
+ "status": "success",
609
+ "bluetooth_input": BLUETOOTH_INPUT_DEVICE,
610
+ "bluetooth_output": BLUETOOTH_OUTPUT_DEVICE
611
+ }
612
+ except Exception as e:
613
+ raise HTTPException(status_code=500, detail=str(e))
614
+
615
+ @app.post("/play-audio-base64")
616
+ async def play_audio_base64(request: dict):
617
+ """
618
+ Receive base64 encoded audio, play it via Bluetooth, then start Bluetooth mic recording
619
+
620
+ Request body (JSON):
621
+ {
622
+ "audio": "base64_encoded_audio_data",
623
+ "format": "mp3" (optional, default: "wav")
624
+ }
625
+ """
626
+ try:
627
+ audio_base64 = request.get("audio") or request.get("data")
628
+ audio_format = request.get("format", "wav")
629
+
630
+ if not audio_base64:
631
+ raise HTTPException(
632
+ status_code=400,
633
+ detail="Missing 'audio' or 'data' field with base64 encoded audio"
634
+ )
635
+
636
+ logger.info(f"\nāœ“ Decoding base64 audio data...")
637
+ audio_bytes = base64.b64decode(audio_base64)
638
+
639
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
640
+ temp_dir = tempfile.gettempdir()
641
+ temp_path = os.path.join(temp_dir, f"incoming_audio_{timestamp}.{audio_format}")
642
+
643
+ with open(temp_path, "wb") as f:
644
+ f.write(audio_bytes)
645
+
646
+ logger.info(f"āœ“ Decoded audio: {len(audio_bytes)} bytes, format: {audio_format}")
647
+
648
+ # Start workflow in background thread
649
+ thread = threading.Thread(
650
+ target=audio_workflow,
651
+ args=(temp_path,),
652
+ daemon=True
653
+ )
654
+ thread.start()
655
+
656
+ return JSONResponse(
657
+ status_code=200,
658
+ content={
659
+ "status": "success",
660
+ "message": "Audio workflow started (Bluetooth)",
661
+ "size_bytes": len(audio_bytes),
662
+ "format": audio_format
663
+ }
664
+ )
665
+
666
+ except base64.binascii.Error:
667
+ raise HTTPException(status_code=400, detail="Invalid base64 encoding")
668
+ except HTTPException:
669
+ raise
670
+ except Exception as e:
671
+ logger.error(f"āœ— Error processing base64 audio: {e}")
672
+ raise HTTPException(status_code=500, detail=str(e))
673
+
674
+ @app.post("/play-audio")
675
+ async def play_audio(
676
+ audio: UploadFile = File(None),
677
+ url: str = Form(None)
678
+ ):
679
+ """
680
+ Receive audio file or URL, play it via Bluetooth, then start Bluetooth mic recording
681
+
682
+ Parameters:
683
+ - audio: Audio file upload (optional if url provided)
684
+ - url: URL to audio file (optional if audio provided)
685
+ """
686
+ try:
687
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
688
+ temp_dir = tempfile.gettempdir()
689
+ temp_path = os.path.join(temp_dir, f"incoming_audio_{timestamp}.wav")
690
+
691
+ if url:
692
+ logger.info(f"\nāœ“ Downloading audio from URL: {url}")
693
+ response = requests.get(url, timeout=30)
694
+ if response.status_code != 200:
695
+ raise HTTPException(status_code=400, detail=f"Failed to download audio from URL: {response.status_code}")
696
+
697
+ with open(temp_path, "wb") as f:
698
+ f.write(response.content)
699
+
700
+ filename = url.split('/')[-1] or "audio_from_url"
701
+ size = len(response.content)
702
+ logger.info(f"āœ“ Downloaded: {filename} ({size} bytes)")
703
+
704
+ elif audio:
705
+ logger.info(f"\nāœ“ Receiving uploaded audio file...")
706
+ content = await audio.read()
707
+
708
+ with open(temp_path, "wb") as f:
709
+ f.write(content)
710
+
711
+ filename = audio.filename
712
+ size = len(content)
713
+ logger.info(f"āœ“ Received: {filename} ({size} bytes)")
714
+
715
+ else:
716
+ raise HTTPException(
717
+ status_code=400,
718
+ detail="Either 'audio' file or 'url' parameter is required"
719
+ )
720
+
721
+ # Start workflow in background thread
722
+ thread = threading.Thread(
723
+ target=audio_workflow,
724
+ args=(temp_path,),
725
+ daemon=True
726
+ )
727
+ thread.start()
728
+
729
+ return JSONResponse(
730
+ status_code=200,
731
+ content={
732
+ "status": "success",
733
+ "message": "Audio workflow started (Bluetooth)",
734
+ "filename": filename,
735
+ "size_bytes": size
736
+ }
737
+ )
738
+
739
+ except HTTPException:
740
+ raise
741
+ except Exception as e:
742
+ logger.error(f"āœ— Error processing audio: {e}")
743
+ raise HTTPException(status_code=500, detail=str(e))
744
+
745
+ if __name__ == "__main__":
746
+ uvicorn.run(
747
+ app,
748
+ host="0.0.0.0",
749
+ port=8000,
750
+ log_level="info"
751
+ )