infinicode 2.8.13 ā 2.8.15
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.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +32 -1
- package/dist/robopark/preview-agent-launcher.d.ts +3 -0
- package/dist/robopark/preview-agent-launcher.js +8 -0
- package/dist/robopark-cli.js +3 -0
- package/package.json +3 -1
- package/packages/robopark/pi-client/README.md +17 -0
- package/packages/robopark/pi-client/_install_steps.sh +29 -0
- package/packages/robopark/pi-client/client.py +305 -0
- package/packages/robopark/pi-client/install.sh +41 -0
- package/packages/robopark/pi-client/join_convo.sh +55 -0
- package/packages/robopark/pi-client/livekit_bridge.py +289 -0
- package/packages/robopark/pi-client/motor_bridge.py +82 -0
- package/packages/robopark/pi-client/pi_ui.py +375 -0
- package/packages/robopark/pi-client/requirements.txt +10 -0
- package/packages/robopark/pi-client/robopark-pi-client.service +26 -0
- package/packages/robopark/pi-client/robopark-pi-ui.service +24 -0
- package/packages/robopark/pi-client/smoke_bridge.py +15 -0
- package/packages/robopark/pi-client/stub_motor_server.py +46 -0
- package/packages/robopark/scheduler/main.py +45 -9
- package/packages/robopark/scheduler/preview_agent.py +132 -3
- package/packages/robopark/vision/.env.example +7 -0
- package/packages/robopark/vision/README.md +63 -0
- package/packages/robopark/vision/app_pi_clean.py +307 -0
- package/packages/robopark/vision/audio_server_pi.py +751 -0
- package/packages/robopark/vision/install.sh +34 -0
- package/packages/robopark/vision/motor_server.py +304 -0
- package/packages/robopark/vision/requirements_pi_unified.txt +101 -0
- package/packages/robopark/vision/run.sh +244 -0
- 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
|
+
)
|