Spaces:
Runtime error
Runtime error
Upload 4 files
Browse files- app.py +504 -0
- audio_utils.py +341 -0
- requirements.txt +3 -0
- start_server.py +75 -0
app.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Real-time Microphone Streaming Server with Gradio Interface
|
| 3 |
+
Hosted on Hugging Face Spaces with WebSocket support for Android devices
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
import asyncio
|
| 8 |
+
import websockets
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
import threading
|
| 12 |
+
import time
|
| 13 |
+
import base64
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
from typing import Dict, List, Optional
|
| 16 |
+
import queue
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
from audio_utils import AudioStreamManager, AudioProcessor
|
| 20 |
+
|
| 21 |
+
# Configure logging
|
| 22 |
+
logging.basicConfig(level=logging.INFO)
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
# Global variables
|
| 26 |
+
stream_manager = AudioStreamManager()
|
| 27 |
+
websocket_server = None
|
| 28 |
+
connected_clients = {}
|
| 29 |
+
client_lock = threading.Lock()
|
| 30 |
+
|
| 31 |
+
# Message queues for real-time updates
|
| 32 |
+
device_update_queue = queue.Queue()
|
| 33 |
+
audio_data_queue = queue.Queue()
|
| 34 |
+
|
| 35 |
+
class GradioWebSocketHandler:
|
| 36 |
+
"""Handles WebSocket connections from Android devices"""
|
| 37 |
+
|
| 38 |
+
def __init__(self):
|
| 39 |
+
self.running = False
|
| 40 |
+
|
| 41 |
+
async def handle_client(self, websocket, path):
|
| 42 |
+
"""Handle individual WebSocket client connection"""
|
| 43 |
+
client_id = f"{websocket.remote_address[0]}:{websocket.remote_address[1]}"
|
| 44 |
+
client_type = None
|
| 45 |
+
device_id = None
|
| 46 |
+
|
| 47 |
+
logger.info(f"New WebSocket connection from {client_id}")
|
| 48 |
+
|
| 49 |
+
with client_lock:
|
| 50 |
+
connected_clients[client_id] = {
|
| 51 |
+
'websocket': websocket,
|
| 52 |
+
'connected_at': time.time(),
|
| 53 |
+
'type': 'unknown'
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
# Send welcome message
|
| 58 |
+
await websocket.send(json.dumps({
|
| 59 |
+
'type': 'welcome',
|
| 60 |
+
'message': 'Connected to Real-time Audio Streaming Server (Gradio)',
|
| 61 |
+
'timestamp': datetime.now().isoformat(),
|
| 62 |
+
'server_info': {
|
| 63 |
+
'platform': 'Gradio/HuggingFace',
|
| 64 |
+
'version': '1.0.0'
|
| 65 |
+
}
|
| 66 |
+
}))
|
| 67 |
+
|
| 68 |
+
async for message in websocket:
|
| 69 |
+
try:
|
| 70 |
+
data = json.loads(message)
|
| 71 |
+
message_type = data.get('type', '')
|
| 72 |
+
|
| 73 |
+
if message_type == 'register_device':
|
| 74 |
+
# Android device registration
|
| 75 |
+
client_type = 'device'
|
| 76 |
+
device_id = data.get('device_id', '')
|
| 77 |
+
device_name = data.get('device_name', 'Unknown Device')
|
| 78 |
+
device_model = data.get('device_model', 'Unknown')
|
| 79 |
+
|
| 80 |
+
# Register device with stream manager
|
| 81 |
+
device_info = {
|
| 82 |
+
'name': device_name,
|
| 83 |
+
'model': device_model,
|
| 84 |
+
'client_id': client_id,
|
| 85 |
+
'platform': 'Android'
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
stream_manager.device_manager.register_device(device_id, device_info)
|
| 89 |
+
|
| 90 |
+
with client_lock:
|
| 91 |
+
connected_clients[client_id]['type'] = 'device'
|
| 92 |
+
connected_clients[client_id]['device_id'] = device_id
|
| 93 |
+
|
| 94 |
+
# Send registration confirmation
|
| 95 |
+
await websocket.send(json.dumps({
|
| 96 |
+
'type': 'registration_success',
|
| 97 |
+
'device_id': device_id,
|
| 98 |
+
'message': 'Device registered successfully',
|
| 99 |
+
'timestamp': datetime.now().isoformat()
|
| 100 |
+
}))
|
| 101 |
+
|
| 102 |
+
# Notify Gradio interface
|
| 103 |
+
device_update_queue.put({
|
| 104 |
+
'type': 'device_connected',
|
| 105 |
+
'device_id': device_id,
|
| 106 |
+
'device_info': device_info
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
logger.info(f"Device registered: {device_id} ({device_name})")
|
| 110 |
+
|
| 111 |
+
elif message_type == 'audio_chunk':
|
| 112 |
+
# Audio data from Android device
|
| 113 |
+
if client_type == 'device' and device_id:
|
| 114 |
+
audio_data = data.get('data', {})
|
| 115 |
+
|
| 116 |
+
# Decode base64 audio
|
| 117 |
+
base64_audio = audio_data.get('audio_data', '')
|
| 118 |
+
if base64_audio:
|
| 119 |
+
audio_bytes = AudioProcessor.decode_base64_audio(base64_audio)
|
| 120 |
+
if audio_bytes:
|
| 121 |
+
timestamp = data.get('timestamp', time.time())
|
| 122 |
+
|
| 123 |
+
# Add to stream manager
|
| 124 |
+
stream_manager.device_manager.add_audio_chunk(
|
| 125 |
+
device_id, audio_bytes, timestamp
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# Queue for Gradio display
|
| 129 |
+
audio_data_queue.put({
|
| 130 |
+
'device_id': device_id,
|
| 131 |
+
'timestamp': timestamp,
|
| 132 |
+
'size_bytes': len(audio_bytes),
|
| 133 |
+
'sample_rate': audio_data.get('sample_rate', 16000)
|
| 134 |
+
})
|
| 135 |
+
|
| 136 |
+
elif message_type == 'start_streaming':
|
| 137 |
+
if client_type == 'device' and device_id:
|
| 138 |
+
stream_manager.start_stream(device_id)
|
| 139 |
+
await websocket.send(json.dumps({
|
| 140 |
+
'type': 'streaming_started',
|
| 141 |
+
'device_id': device_id,
|
| 142 |
+
'timestamp': datetime.now().isoformat()
|
| 143 |
+
}))
|
| 144 |
+
|
| 145 |
+
elif message_type == 'stop_streaming':
|
| 146 |
+
if client_type == 'device' and device_id:
|
| 147 |
+
stream_manager.stop_stream(device_id)
|
| 148 |
+
await websocket.send(json.dumps({
|
| 149 |
+
'type': 'streaming_stopped',
|
| 150 |
+
'device_id': device_id,
|
| 151 |
+
'timestamp': datetime.now().isoformat()
|
| 152 |
+
}))
|
| 153 |
+
|
| 154 |
+
elif message_type == 'ping':
|
| 155 |
+
# Heartbeat response
|
| 156 |
+
if device_id:
|
| 157 |
+
stream_manager.device_manager.update_device_heartbeat(device_id)
|
| 158 |
+
|
| 159 |
+
await websocket.send(json.dumps({
|
| 160 |
+
'type': 'pong',
|
| 161 |
+
'timestamp': datetime.now().isoformat()
|
| 162 |
+
}))
|
| 163 |
+
|
| 164 |
+
else:
|
| 165 |
+
logger.warning(f"Unknown message type: {message_type}")
|
| 166 |
+
|
| 167 |
+
except json.JSONDecodeError as e:
|
| 168 |
+
logger.error(f"JSON decode error: {e}")
|
| 169 |
+
except Exception as e:
|
| 170 |
+
logger.error(f"Error processing message: {e}")
|
| 171 |
+
|
| 172 |
+
except websockets.exceptions.ConnectionClosed:
|
| 173 |
+
logger.info(f"WebSocket connection closed: {client_id}")
|
| 174 |
+
except Exception as e:
|
| 175 |
+
logger.error(f"WebSocket error: {e}")
|
| 176 |
+
finally:
|
| 177 |
+
# Cleanup on disconnect
|
| 178 |
+
with client_lock:
|
| 179 |
+
if client_id in connected_clients:
|
| 180 |
+
client_info = connected_clients[client_id]
|
| 181 |
+
if client_info.get('type') == 'device' and 'device_id' in client_info:
|
| 182 |
+
device_id = client_info['device_id']
|
| 183 |
+
stream_manager.device_manager.unregister_device(device_id)
|
| 184 |
+
|
| 185 |
+
# Notify Gradio interface
|
| 186 |
+
device_update_queue.put({
|
| 187 |
+
'type': 'device_disconnected',
|
| 188 |
+
'device_id': device_id
|
| 189 |
+
})
|
| 190 |
+
|
| 191 |
+
logger.info(f"Device disconnected: {device_id}")
|
| 192 |
+
|
| 193 |
+
del connected_clients[client_id]
|
| 194 |
+
|
| 195 |
+
async def send_command_to_device(self, device_id: str, command: str) -> bool:
|
| 196 |
+
"""Send command to specific device"""
|
| 197 |
+
with client_lock:
|
| 198 |
+
for client_id, client_info in connected_clients.items():
|
| 199 |
+
if (client_info.get('type') == 'device' and
|
| 200 |
+
client_info.get('device_id') == device_id):
|
| 201 |
+
try:
|
| 202 |
+
await client_info['websocket'].send(json.dumps({
|
| 203 |
+
'type': command,
|
| 204 |
+
'timestamp': datetime.now().isoformat()
|
| 205 |
+
}))
|
| 206 |
+
return True
|
| 207 |
+
except Exception as e:
|
| 208 |
+
logger.error(f"Error sending command to device {device_id}: {e}")
|
| 209 |
+
return False
|
| 210 |
+
return False
|
| 211 |
+
|
| 212 |
+
# WebSocket handler instance
|
| 213 |
+
ws_handler = GradioWebSocketHandler()
|
| 214 |
+
|
| 215 |
+
def start_websocket_server():
|
| 216 |
+
"""Start WebSocket server in background thread"""
|
| 217 |
+
global websocket_server
|
| 218 |
+
|
| 219 |
+
async def run_server():
|
| 220 |
+
global websocket_server
|
| 221 |
+
# Use port 7861 for WebSocket (Gradio uses 7860)
|
| 222 |
+
websocket_server = await websockets.serve(
|
| 223 |
+
ws_handler.handle_client,
|
| 224 |
+
"0.0.0.0",
|
| 225 |
+
7861,
|
| 226 |
+
ping_interval=30,
|
| 227 |
+
ping_timeout=60
|
| 228 |
+
)
|
| 229 |
+
logger.info("WebSocket server started on port 7861")
|
| 230 |
+
await websocket_server.wait_closed()
|
| 231 |
+
|
| 232 |
+
def run_in_thread():
|
| 233 |
+
loop = asyncio.new_event_loop()
|
| 234 |
+
asyncio.set_event_loop(loop)
|
| 235 |
+
loop.run_until_complete(run_server())
|
| 236 |
+
|
| 237 |
+
server_thread = threading.Thread(target=run_in_thread, daemon=True)
|
| 238 |
+
server_thread.start()
|
| 239 |
+
|
| 240 |
+
def get_connected_devices() -> List[Dict]:
|
| 241 |
+
"""Get list of connected devices for Gradio display"""
|
| 242 |
+
devices = stream_manager.device_manager.get_all_devices_info()
|
| 243 |
+
device_list = []
|
| 244 |
+
|
| 245 |
+
for device_id, device_info in devices.items():
|
| 246 |
+
if device_info.get('status') == 'connected':
|
| 247 |
+
device_data = {
|
| 248 |
+
'device_id': device_id,
|
| 249 |
+
'name': device_info.get('info', {}).get('name', 'Unknown'),
|
| 250 |
+
'model': device_info.get('info', {}).get('model', 'Unknown'),
|
| 251 |
+
'connected_at': datetime.fromtimestamp(device_info.get('connected_at', 0)).strftime('%H:%M:%S'),
|
| 252 |
+
'streaming': device_info.get('streaming', False),
|
| 253 |
+
'total_chunks': device_info.get('stats', {}).get('total_chunks', 0),
|
| 254 |
+
'bytes_received': device_info.get('stats', {}).get('bytes_received', 0),
|
| 255 |
+
'last_seen': 'Now' if time.time() - device_info.get('last_seen', 0) < 10 else f"{int(time.time() - device_info.get('last_seen', 0))}s ago"
|
| 256 |
+
}
|
| 257 |
+
device_list.append(device_data)
|
| 258 |
+
|
| 259 |
+
return device_list
|
| 260 |
+
|
| 261 |
+
def start_streaming(device_id: str) -> str:
|
| 262 |
+
"""Start streaming from a device"""
|
| 263 |
+
if stream_manager.start_stream(device_id):
|
| 264 |
+
# Send command to device
|
| 265 |
+
asyncio.run(ws_handler.send_command_to_device(device_id, 'start_streaming'))
|
| 266 |
+
return f"β
Started streaming from device: {device_id}"
|
| 267 |
+
else:
|
| 268 |
+
return f"β Failed to start streaming from device: {device_id}"
|
| 269 |
+
|
| 270 |
+
def stop_streaming(device_id: str) -> str:
|
| 271 |
+
"""Stop streaming from a device"""
|
| 272 |
+
if stream_manager.stop_stream(device_id):
|
| 273 |
+
# Send command to device
|
| 274 |
+
asyncio.run(ws_handler.send_command_to_device(device_id, 'stop_streaming'))
|
| 275 |
+
return f"π Stopped streaming from device: {device_id}"
|
| 276 |
+
else:
|
| 277 |
+
return f"β Failed to stop streaming from device: {device_id}"
|
| 278 |
+
|
| 279 |
+
def get_server_stats() -> Dict:
|
| 280 |
+
"""Get server statistics"""
|
| 281 |
+
devices = stream_manager.device_manager.get_all_devices_info()
|
| 282 |
+
connected_count = len([d for d in devices.values() if d.get('status') == 'connected'])
|
| 283 |
+
streaming_count = len(stream_manager.get_streaming_devices())
|
| 284 |
+
|
| 285 |
+
total_bytes = sum([
|
| 286 |
+
device.get('stats', {}).get('bytes_received', 0)
|
| 287 |
+
for device in devices.values()
|
| 288 |
+
])
|
| 289 |
+
|
| 290 |
+
with client_lock:
|
| 291 |
+
total_connections = len(connected_clients)
|
| 292 |
+
|
| 293 |
+
return {
|
| 294 |
+
'connected_devices': connected_count,
|
| 295 |
+
'streaming_devices': streaming_count,
|
| 296 |
+
'total_connections': total_connections,
|
| 297 |
+
'total_data_mb': round(total_bytes / (1024 * 1024), 2),
|
| 298 |
+
'server_uptime': time.strftime('%H:%M:%S', time.gmtime(time.time() - server_start_time))
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
def update_device_list():
|
| 302 |
+
"""Update device list for Gradio"""
|
| 303 |
+
devices = get_connected_devices()
|
| 304 |
+
if not devices:
|
| 305 |
+
return "No devices connected. Install the Android app and connect to start streaming."
|
| 306 |
+
|
| 307 |
+
# Create HTML table for devices
|
| 308 |
+
html = """
|
| 309 |
+
<div style="font-family: Arial, sans-serif;">
|
| 310 |
+
<h3>π± Connected Devices</h3>
|
| 311 |
+
<table style="width: 100%; border-collapse: collapse; margin-top: 10px;">
|
| 312 |
+
<thead>
|
| 313 |
+
<tr style="background-color: #f0f0f0;">
|
| 314 |
+
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Device</th>
|
| 315 |
+
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Status</th>
|
| 316 |
+
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Data</th>
|
| 317 |
+
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Last Seen</th>
|
| 318 |
+
</tr>
|
| 319 |
+
</thead>
|
| 320 |
+
<tbody>
|
| 321 |
+
"""
|
| 322 |
+
|
| 323 |
+
for device in devices:
|
| 324 |
+
status_icon = "ποΈ Streaming" if device['streaming'] else "β
Connected"
|
| 325 |
+
status_color = "#28a745" if device['streaming'] else "#007bff"
|
| 326 |
+
|
| 327 |
+
html += f"""
|
| 328 |
+
<tr>
|
| 329 |
+
<td style="border: 1px solid #ddd; padding: 8px;">
|
| 330 |
+
<strong>{device['name']}</strong><br>
|
| 331 |
+
<small style="color: #666;">{device['device_id'][:12]}...</small>
|
| 332 |
+
</td>
|
| 333 |
+
<td style="border: 1px solid #ddd; padding: 8px;">
|
| 334 |
+
<span style="color: {status_color}; font-weight: bold;">{status_icon}</span><br>
|
| 335 |
+
<small>Since: {device['connected_at']}</small>
|
| 336 |
+
</td>
|
| 337 |
+
<td style="border: 1px solid #ddd; padding: 8px;">
|
| 338 |
+
<strong>{device['total_chunks']}</strong> chunks<br>
|
| 339 |
+
<small>{round(device['bytes_received']/1024, 1)} KB</small>
|
| 340 |
+
</td>
|
| 341 |
+
<td style="border: 1px solid #ddd; padding: 8px;">
|
| 342 |
+
{device['last_seen']}
|
| 343 |
+
</td>
|
| 344 |
+
</tr>
|
| 345 |
+
"""
|
| 346 |
+
|
| 347 |
+
html += """
|
| 348 |
+
</tbody>
|
| 349 |
+
</table>
|
| 350 |
+
</div>
|
| 351 |
+
"""
|
| 352 |
+
|
| 353 |
+
return html
|
| 354 |
+
|
| 355 |
+
def create_gradio_interface():
|
| 356 |
+
"""Create the main Gradio interface"""
|
| 357 |
+
|
| 358 |
+
with gr.Blocks(title="π€ Real-time Mic Streaming Server", theme=gr.themes.Soft()) as interface:
|
| 359 |
+
|
| 360 |
+
# Header
|
| 361 |
+
gr.HTML("""
|
| 362 |
+
<div style="text-align: center; padding: 20px; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); color: white; border-radius: 10px; margin-bottom: 20px;">
|
| 363 |
+
<h1 style="margin: 0; font-size: 2.5em;">π€ Real-time Microphone Streaming</h1>
|
| 364 |
+
<p style="margin: 10px 0 0 0; font-size: 1.2em;">Android Device Audio Streaming Server</p>
|
| 365 |
+
</div>
|
| 366 |
+
""")
|
| 367 |
+
|
| 368 |
+
# Connection info
|
| 369 |
+
with gr.Row():
|
| 370 |
+
gr.HTML("""
|
| 371 |
+
<div style="background: #e8f4f8; padding: 15px; border-radius: 8px; border-left: 5px solid #007bff;">
|
| 372 |
+
<h3 style="margin: 0 0 10px 0;">π‘ Connection Information</h3>
|
| 373 |
+
<p style="margin: 5px 0;"><strong>WebSocket URL:</strong> <code>wss://your-space-name.hf.space:7861</code></p>
|
| 374 |
+
<p style="margin: 5px 0;"><strong>Protocol:</strong> WebSocket with JSON messages</p>
|
| 375 |
+
<p style="margin: 5px 0;">Configure your Android app to connect to this WebSocket URL.</p>
|
| 376 |
+
</div>
|
| 377 |
+
""")
|
| 378 |
+
|
| 379 |
+
# Server Statistics
|
| 380 |
+
with gr.Row():
|
| 381 |
+
with gr.Column(scale=1):
|
| 382 |
+
connected_devices_display = gr.Number(label="π± Connected Devices", value=0, interactive=False)
|
| 383 |
+
with gr.Column(scale=1):
|
| 384 |
+
streaming_devices_display = gr.Number(label="ποΈ Streaming Devices", value=0, interactive=False)
|
| 385 |
+
with gr.Column(scale=1):
|
| 386 |
+
total_data_display = gr.Number(label="π Total Data (MB)", value=0.0, interactive=False)
|
| 387 |
+
with gr.Column(scale=1):
|
| 388 |
+
uptime_display = gr.Textbox(label="β±οΈ Server Uptime", value="00:00:00", interactive=False)
|
| 389 |
+
|
| 390 |
+
# Device List
|
| 391 |
+
device_list_display = gr.HTML(
|
| 392 |
+
value="No devices connected. Install the Android app and connect to start streaming.",
|
| 393 |
+
label="Connected Devices"
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
# Control Buttons
|
| 397 |
+
with gr.Row():
|
| 398 |
+
with gr.Column(scale=1):
|
| 399 |
+
device_id_input = gr.Textbox(
|
| 400 |
+
label="Device ID",
|
| 401 |
+
placeholder="Enter device ID to control",
|
| 402 |
+
info="Copy device ID from the table above"
|
| 403 |
+
)
|
| 404 |
+
with gr.Column(scale=1):
|
| 405 |
+
start_btn = gr.Button("π€ Start Streaming", variant="primary")
|
| 406 |
+
stop_btn = gr.Button("π Stop Streaming", variant="secondary")
|
| 407 |
+
|
| 408 |
+
# Control feedback
|
| 409 |
+
control_output = gr.Textbox(label="Control Status", interactive=False)
|
| 410 |
+
|
| 411 |
+
# Audio Data Monitor (recent activity)
|
| 412 |
+
audio_monitor = gr.HTML(
|
| 413 |
+
value="<p>No recent audio data...</p>",
|
| 414 |
+
label="Recent Audio Activity"
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
# Event handlers
|
| 418 |
+
start_btn.click(
|
| 419 |
+
fn=start_streaming,
|
| 420 |
+
inputs=[device_id_input],
|
| 421 |
+
outputs=[control_output]
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
stop_btn.click(
|
| 425 |
+
fn=stop_streaming,
|
| 426 |
+
inputs=[device_id_input],
|
| 427 |
+
outputs=[control_output]
|
| 428 |
+
)
|
| 429 |
+
|
| 430 |
+
# Auto-refresh components
|
| 431 |
+
def update_all():
|
| 432 |
+
stats = get_server_stats()
|
| 433 |
+
devices_html = update_device_list()
|
| 434 |
+
|
| 435 |
+
# Get recent audio activity
|
| 436 |
+
recent_audio = []
|
| 437 |
+
while not audio_data_queue.empty():
|
| 438 |
+
try:
|
| 439 |
+
audio_info = audio_data_queue.get_nowait()
|
| 440 |
+
recent_audio.append(audio_info)
|
| 441 |
+
except queue.Empty:
|
| 442 |
+
break
|
| 443 |
+
|
| 444 |
+
if recent_audio:
|
| 445 |
+
audio_html = "<div style='font-family: monospace; font-size: 12px;'>"
|
| 446 |
+
audio_html += "<h4>Recent Audio Chunks:</h4>"
|
| 447 |
+
for audio in recent_audio[-10:]: # Show last 10 chunks
|
| 448 |
+
timestamp = datetime.fromtimestamp(audio['timestamp']).strftime('%H:%M:%S')
|
| 449 |
+
audio_html += f"<p>{timestamp} - Device: {audio['device_id'][:12]}... | Size: {audio['size_bytes']} bytes | Rate: {audio['sample_rate']} Hz</p>"
|
| 450 |
+
audio_html += "</div>"
|
| 451 |
+
else:
|
| 452 |
+
audio_html = "<p>No recent audio data...</p>"
|
| 453 |
+
|
| 454 |
+
return (
|
| 455 |
+
stats['connected_devices'],
|
| 456 |
+
stats['streaming_devices'],
|
| 457 |
+
stats['total_data_mb'],
|
| 458 |
+
stats['server_uptime'],
|
| 459 |
+
devices_html,
|
| 460 |
+
audio_html
|
| 461 |
+
)
|
| 462 |
+
|
| 463 |
+
# Set up auto-refresh every 2 seconds
|
| 464 |
+
interface.load(
|
| 465 |
+
fn=update_all,
|
| 466 |
+
outputs=[
|
| 467 |
+
connected_devices_display,
|
| 468 |
+
streaming_devices_display,
|
| 469 |
+
total_data_display,
|
| 470 |
+
uptime_display,
|
| 471 |
+
device_list_display,
|
| 472 |
+
audio_monitor
|
| 473 |
+
],
|
| 474 |
+
every=2
|
| 475 |
+
)
|
| 476 |
+
|
| 477 |
+
return interface
|
| 478 |
+
|
| 479 |
+
# Global server start time
|
| 480 |
+
server_start_time = time.time()
|
| 481 |
+
|
| 482 |
+
def main():
|
| 483 |
+
"""Main application entry point"""
|
| 484 |
+
logger.info("Starting Real-time Microphone Streaming Server...")
|
| 485 |
+
|
| 486 |
+
# Start WebSocket server
|
| 487 |
+
start_websocket_server()
|
| 488 |
+
|
| 489 |
+
# Wait a moment for WebSocket server to start
|
| 490 |
+
time.sleep(2)
|
| 491 |
+
|
| 492 |
+
# Create and launch Gradio interface
|
| 493 |
+
interface = create_gradio_interface()
|
| 494 |
+
|
| 495 |
+
# Launch with public access for Hugging Face
|
| 496 |
+
interface.launch(
|
| 497 |
+
server_name="0.0.0.0",
|
| 498 |
+
server_port=7860,
|
| 499 |
+
share=False, # Set to True for public tunnels during development
|
| 500 |
+
debug=False
|
| 501 |
+
)
|
| 502 |
+
|
| 503 |
+
if __name__ == "__main__":
|
| 504 |
+
main()
|
audio_utils.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Audio Processing Utilities for Real-time Microphone Streaming
|
| 3 |
+
Handles audio data encoding, decoding, and processing for Gradio backend.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import base64
|
| 7 |
+
import numpy as np
|
| 8 |
+
import threading
|
| 9 |
+
import queue
|
| 10 |
+
import time
|
| 11 |
+
import logging
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from typing import Optional, Dict, List, Tuple
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
class AudioBuffer:
|
| 18 |
+
"""Thread-safe audio buffer for storing audio chunks"""
|
| 19 |
+
|
| 20 |
+
def __init__(self, max_size: int = 1000):
|
| 21 |
+
self.max_size = max_size
|
| 22 |
+
self.buffer = queue.Queue(maxsize=max_size)
|
| 23 |
+
self.lock = threading.Lock()
|
| 24 |
+
self._stats = {
|
| 25 |
+
'total_chunks': 0,
|
| 26 |
+
'dropped_chunks': 0,
|
| 27 |
+
'buffer_size': 0
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
def add_chunk(self, audio_data: bytes, device_id: str, timestamp: float) -> bool:
|
| 31 |
+
"""Add audio chunk to buffer. Returns True if successful, False if buffer full"""
|
| 32 |
+
try:
|
| 33 |
+
chunk = {
|
| 34 |
+
'audio_data': audio_data,
|
| 35 |
+
'device_id': device_id,
|
| 36 |
+
'timestamp': timestamp,
|
| 37 |
+
'server_received': time.time()
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
self.buffer.put_nowait(chunk)
|
| 41 |
+
with self.lock:
|
| 42 |
+
self._stats['total_chunks'] += 1
|
| 43 |
+
self._stats['buffer_size'] = self.buffer.qsize()
|
| 44 |
+
return True
|
| 45 |
+
|
| 46 |
+
except queue.Full:
|
| 47 |
+
with self.lock:
|
| 48 |
+
self._stats['dropped_chunks'] += 1
|
| 49 |
+
logger.warning(f"Audio buffer full, dropped chunk from device {device_id}")
|
| 50 |
+
return False
|
| 51 |
+
|
| 52 |
+
def get_chunk(self, timeout: float = 0.1) -> Optional[Dict]:
|
| 53 |
+
"""Get next audio chunk from buffer"""
|
| 54 |
+
try:
|
| 55 |
+
chunk = self.buffer.get(timeout=timeout)
|
| 56 |
+
with self.lock:
|
| 57 |
+
self._stats['buffer_size'] = self.buffer.qsize()
|
| 58 |
+
return chunk
|
| 59 |
+
except queue.Empty:
|
| 60 |
+
return None
|
| 61 |
+
|
| 62 |
+
def get_stats(self) -> Dict:
|
| 63 |
+
"""Get buffer statistics"""
|
| 64 |
+
with self.lock:
|
| 65 |
+
return self._stats.copy()
|
| 66 |
+
|
| 67 |
+
def clear(self):
|
| 68 |
+
"""Clear the buffer"""
|
| 69 |
+
with self.lock:
|
| 70 |
+
while not self.buffer.empty():
|
| 71 |
+
try:
|
| 72 |
+
self.buffer.get_nowait()
|
| 73 |
+
except queue.Empty:
|
| 74 |
+
break
|
| 75 |
+
self._stats['buffer_size'] = 0
|
| 76 |
+
|
| 77 |
+
class AudioProcessor:
|
| 78 |
+
"""Audio processing utilities"""
|
| 79 |
+
|
| 80 |
+
@staticmethod
|
| 81 |
+
def decode_base64_audio(base64_data: str) -> Optional[bytes]:
|
| 82 |
+
"""Decode base64 audio data to bytes"""
|
| 83 |
+
try:
|
| 84 |
+
# Clean the base64 string
|
| 85 |
+
clean_base64 = base64_data.replace('\n', '').replace('\r', '').strip()
|
| 86 |
+
|
| 87 |
+
# Add padding if necessary
|
| 88 |
+
missing_padding = len(clean_base64) % 4
|
| 89 |
+
if missing_padding:
|
| 90 |
+
clean_base64 += '=' * (4 - missing_padding)
|
| 91 |
+
|
| 92 |
+
# Decode base64 to bytes
|
| 93 |
+
audio_bytes = base64.b64decode(clean_base64)
|
| 94 |
+
return audio_bytes
|
| 95 |
+
|
| 96 |
+
except Exception as e:
|
| 97 |
+
logger.error(f"Error decoding base64 audio: {e}")
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
@staticmethod
|
| 101 |
+
def encode_audio_to_base64(audio_bytes: bytes) -> str:
|
| 102 |
+
"""Encode audio bytes to base64 string"""
|
| 103 |
+
return base64.b64encode(audio_bytes).decode('utf-8')
|
| 104 |
+
|
| 105 |
+
@staticmethod
|
| 106 |
+
def pcm_to_numpy(audio_bytes: bytes, sample_rate: int = 16000, channels: int = 1) -> Optional[np.ndarray]:
|
| 107 |
+
"""Convert PCM bytes to numpy array"""
|
| 108 |
+
try:
|
| 109 |
+
# Convert bytes to 16-bit signed integers
|
| 110 |
+
audio_array = np.frombuffer(audio_bytes, dtype=np.int16)
|
| 111 |
+
|
| 112 |
+
# Normalize to float32 range [-1.0, 1.0]
|
| 113 |
+
audio_float = audio_array.astype(np.float32) / 32768.0
|
| 114 |
+
|
| 115 |
+
# Handle multi-channel audio
|
| 116 |
+
if channels > 1:
|
| 117 |
+
audio_float = audio_float.reshape(-1, channels)
|
| 118 |
+
|
| 119 |
+
return audio_float
|
| 120 |
+
|
| 121 |
+
except Exception as e:
|
| 122 |
+
logger.error(f"Error converting PCM to numpy: {e}")
|
| 123 |
+
return None
|
| 124 |
+
|
| 125 |
+
@staticmethod
|
| 126 |
+
def numpy_to_pcm(audio_array: np.ndarray) -> bytes:
|
| 127 |
+
"""Convert numpy array to PCM bytes"""
|
| 128 |
+
try:
|
| 129 |
+
# Ensure array is float32
|
| 130 |
+
if audio_array.dtype != np.float32:
|
| 131 |
+
audio_array = audio_array.astype(np.float32)
|
| 132 |
+
|
| 133 |
+
# Clamp values to [-1.0, 1.0]
|
| 134 |
+
audio_array = np.clip(audio_array, -1.0, 1.0)
|
| 135 |
+
|
| 136 |
+
# Convert to 16-bit signed integers
|
| 137 |
+
audio_int16 = (audio_array * 32767).astype(np.int16)
|
| 138 |
+
|
| 139 |
+
# Convert to bytes
|
| 140 |
+
return audio_int16.tobytes()
|
| 141 |
+
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger.error(f"Error converting numpy to PCM: {e}")
|
| 144 |
+
return b''
|
| 145 |
+
|
| 146 |
+
@staticmethod
|
| 147 |
+
def calculate_audio_stats(audio_bytes: bytes) -> Dict:
|
| 148 |
+
"""Calculate basic audio statistics"""
|
| 149 |
+
try:
|
| 150 |
+
audio_array = np.frombuffer(audio_bytes, dtype=np.int16)
|
| 151 |
+
audio_float = audio_array.astype(np.float32) / 32768.0
|
| 152 |
+
|
| 153 |
+
stats = {
|
| 154 |
+
'length_samples': len(audio_array),
|
| 155 |
+
'length_bytes': len(audio_bytes),
|
| 156 |
+
'rms': float(np.sqrt(np.mean(audio_float**2))),
|
| 157 |
+
'peak': float(np.max(np.abs(audio_float))),
|
| 158 |
+
'mean': float(np.mean(audio_float)),
|
| 159 |
+
'std': float(np.std(audio_float))
|
| 160 |
+
}
|
| 161 |
+
return stats
|
| 162 |
+
|
| 163 |
+
except Exception as e:
|
| 164 |
+
logger.error(f"Error calculating audio stats: {e}")
|
| 165 |
+
return {}
|
| 166 |
+
|
| 167 |
+
class DeviceManager:
|
| 168 |
+
"""Manages connected audio streaming devices"""
|
| 169 |
+
|
| 170 |
+
def __init__(self):
|
| 171 |
+
self.devices = {}
|
| 172 |
+
self.lock = threading.Lock()
|
| 173 |
+
self.device_stats = {}
|
| 174 |
+
|
| 175 |
+
def register_device(self, device_id: str, device_info: Dict) -> bool:
|
| 176 |
+
"""Register a new device"""
|
| 177 |
+
with self.lock:
|
| 178 |
+
self.devices[device_id] = {
|
| 179 |
+
'info': device_info,
|
| 180 |
+
'connected_at': time.time(),
|
| 181 |
+
'last_seen': time.time(),
|
| 182 |
+
'status': 'connected',
|
| 183 |
+
'streaming': False,
|
| 184 |
+
'audio_buffer': AudioBuffer()
|
| 185 |
+
}
|
| 186 |
+
self.device_stats[device_id] = {
|
| 187 |
+
'total_chunks': 0,
|
| 188 |
+
'bytes_received': 0,
|
| 189 |
+
'connection_time': time.time(),
|
| 190 |
+
'last_chunk_time': None
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
logger.info(f"Device registered: {device_id} ({device_info.get('name', 'Unknown')})")
|
| 194 |
+
return True
|
| 195 |
+
|
| 196 |
+
def unregister_device(self, device_id: str) -> bool:
|
| 197 |
+
"""Unregister a device"""
|
| 198 |
+
with self.lock:
|
| 199 |
+
if device_id in self.devices:
|
| 200 |
+
self.devices[device_id]['status'] = 'disconnected'
|
| 201 |
+
self.devices[device_id]['disconnected_at'] = time.time()
|
| 202 |
+
# Clear the audio buffer
|
| 203 |
+
self.devices[device_id]['audio_buffer'].clear()
|
| 204 |
+
|
| 205 |
+
logger.info(f"Device unregistered: {device_id}")
|
| 206 |
+
return True
|
| 207 |
+
|
| 208 |
+
def update_device_heartbeat(self, device_id: str):
|
| 209 |
+
"""Update device last seen timestamp"""
|
| 210 |
+
with self.lock:
|
| 211 |
+
if device_id in self.devices:
|
| 212 |
+
self.devices[device_id]['last_seen'] = time.time()
|
| 213 |
+
|
| 214 |
+
def add_audio_chunk(self, device_id: str, audio_data: bytes, timestamp: float) -> bool:
|
| 215 |
+
"""Add audio chunk for a device"""
|
| 216 |
+
with self.lock:
|
| 217 |
+
if device_id not in self.devices:
|
| 218 |
+
return False
|
| 219 |
+
|
| 220 |
+
device = self.devices[device_id]
|
| 221 |
+
if device['status'] != 'connected':
|
| 222 |
+
return False
|
| 223 |
+
|
| 224 |
+
# Update device streaming status
|
| 225 |
+
device['streaming'] = True
|
| 226 |
+
device['last_seen'] = time.time()
|
| 227 |
+
|
| 228 |
+
# Update stats
|
| 229 |
+
stats = self.device_stats[device_id]
|
| 230 |
+
stats['total_chunks'] += 1
|
| 231 |
+
stats['bytes_received'] += len(audio_data)
|
| 232 |
+
stats['last_chunk_time'] = time.time()
|
| 233 |
+
|
| 234 |
+
# Add to buffer
|
| 235 |
+
return device['audio_buffer'].add_chunk(audio_data, device_id, timestamp)
|
| 236 |
+
|
| 237 |
+
def get_audio_chunk(self, device_id: str) -> Optional[Dict]:
|
| 238 |
+
"""Get next audio chunk from device"""
|
| 239 |
+
with self.lock:
|
| 240 |
+
if device_id not in self.devices:
|
| 241 |
+
return None
|
| 242 |
+
return self.devices[device_id]['audio_buffer'].get_chunk()
|
| 243 |
+
|
| 244 |
+
def get_connected_devices(self) -> List[str]:
|
| 245 |
+
"""Get list of connected device IDs"""
|
| 246 |
+
with self.lock:
|
| 247 |
+
return [
|
| 248 |
+
device_id for device_id, device in self.devices.items()
|
| 249 |
+
if device['status'] == 'connected'
|
| 250 |
+
]
|
| 251 |
+
|
| 252 |
+
def get_device_info(self, device_id: str) -> Optional[Dict]:
|
| 253 |
+
"""Get device information"""
|
| 254 |
+
with self.lock:
|
| 255 |
+
if device_id in self.devices:
|
| 256 |
+
device = self.devices[device_id].copy()
|
| 257 |
+
device['stats'] = self.device_stats[device_id].copy()
|
| 258 |
+
return device
|
| 259 |
+
return None
|
| 260 |
+
|
| 261 |
+
def get_all_devices_info(self) -> Dict:
|
| 262 |
+
"""Get information for all devices"""
|
| 263 |
+
with self.lock:
|
| 264 |
+
devices_info = {}
|
| 265 |
+
for device_id, device in self.devices.items():
|
| 266 |
+
device_copy = device.copy()
|
| 267 |
+
device_copy['stats'] = self.device_stats[device_id].copy()
|
| 268 |
+
devices_info[device_id] = device_copy
|
| 269 |
+
return devices_info
|
| 270 |
+
|
| 271 |
+
def cleanup_inactive_devices(self, timeout_seconds: int = 300):
|
| 272 |
+
"""Remove devices that haven't been seen for a while"""
|
| 273 |
+
current_time = time.time()
|
| 274 |
+
with self.lock:
|
| 275 |
+
inactive_devices = []
|
| 276 |
+
for device_id, device in self.devices.items():
|
| 277 |
+
if device['status'] == 'connected':
|
| 278 |
+
if current_time - device['last_seen'] > timeout_seconds:
|
| 279 |
+
inactive_devices.append(device_id)
|
| 280 |
+
|
| 281 |
+
for device_id in inactive_devices:
|
| 282 |
+
logger.info(f"Cleaning up inactive device: {device_id}")
|
| 283 |
+
self.unregister_device(device_id)
|
| 284 |
+
|
| 285 |
+
class AudioStreamManager:
|
| 286 |
+
"""Manages real-time audio streaming between devices and clients"""
|
| 287 |
+
|
| 288 |
+
def __init__(self):
|
| 289 |
+
self.device_manager = DeviceManager()
|
| 290 |
+
self.active_streams = {}
|
| 291 |
+
self.stream_lock = threading.Lock()
|
| 292 |
+
self.running = False
|
| 293 |
+
|
| 294 |
+
# Start cleanup thread
|
| 295 |
+
self.cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True)
|
| 296 |
+
self.cleanup_thread.start()
|
| 297 |
+
|
| 298 |
+
def start_stream(self, device_id: str) -> bool:
|
| 299 |
+
"""Start streaming from a device"""
|
| 300 |
+
with self.stream_lock:
|
| 301 |
+
if device_id in self.device_manager.get_connected_devices():
|
| 302 |
+
self.active_streams[device_id] = {
|
| 303 |
+
'started_at': time.time(),
|
| 304 |
+
'active': True
|
| 305 |
+
}
|
| 306 |
+
logger.info(f"Started streaming from device: {device_id}")
|
| 307 |
+
return True
|
| 308 |
+
return False
|
| 309 |
+
|
| 310 |
+
def stop_stream(self, device_id: str) -> bool:
|
| 311 |
+
"""Stop streaming from a device"""
|
| 312 |
+
with self.stream_lock:
|
| 313 |
+
if device_id in self.active_streams:
|
| 314 |
+
self.active_streams[device_id]['active'] = False
|
| 315 |
+
del self.active_streams[device_id]
|
| 316 |
+
logger.info(f"Stopped streaming from device: {device_id}")
|
| 317 |
+
return True
|
| 318 |
+
return False
|
| 319 |
+
|
| 320 |
+
def is_streaming(self, device_id: str) -> bool:
|
| 321 |
+
"""Check if device is currently streaming"""
|
| 322 |
+
with self.stream_lock:
|
| 323 |
+
return device_id in self.active_streams and self.active_streams[device_id]['active']
|
| 324 |
+
|
| 325 |
+
def get_streaming_devices(self) -> List[str]:
|
| 326 |
+
"""Get list of currently streaming devices"""
|
| 327 |
+
with self.stream_lock:
|
| 328 |
+
return [
|
| 329 |
+
device_id for device_id, stream in self.active_streams.items()
|
| 330 |
+
if stream['active']
|
| 331 |
+
]
|
| 332 |
+
|
| 333 |
+
def _cleanup_loop(self):
|
| 334 |
+
"""Background cleanup loop"""
|
| 335 |
+
while True:
|
| 336 |
+
try:
|
| 337 |
+
self.device_manager.cleanup_inactive_devices()
|
| 338 |
+
time.sleep(30) # Run cleanup every 30 seconds
|
| 339 |
+
except Exception as e:
|
| 340 |
+
logger.error(f"Error in cleanup loop: {e}")
|
| 341 |
+
time.sleep(5)
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.0.0
|
| 2 |
+
websockets>=12.0
|
| 3 |
+
numpy>=1.21.0
|
start_server.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Local startup script for the Real-time Microphone Streaming Server
|
| 4 |
+
Use this for local development and testing before deploying to Hugging Face
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
import subprocess
|
| 10 |
+
import time
|
| 11 |
+
|
| 12 |
+
def check_dependencies():
|
| 13 |
+
"""Check if required packages are installed"""
|
| 14 |
+
required_packages = ['gradio', 'websockets', 'numpy']
|
| 15 |
+
missing_packages = []
|
| 16 |
+
|
| 17 |
+
for package in required_packages:
|
| 18 |
+
try:
|
| 19 |
+
__import__(package)
|
| 20 |
+
except ImportError:
|
| 21 |
+
missing_packages.append(package)
|
| 22 |
+
|
| 23 |
+
if missing_packages:
|
| 24 |
+
print("β Missing required packages:")
|
| 25 |
+
for package in missing_packages:
|
| 26 |
+
print(f" - {package}")
|
| 27 |
+
print("\nπ¦ Installing missing packages...")
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
subprocess.check_call([sys.executable, '-m', 'pip', 'install'] + missing_packages)
|
| 31 |
+
print("β
Packages installed successfully!")
|
| 32 |
+
except subprocess.CalledProcessError as e:
|
| 33 |
+
print(f"β Failed to install packages: {e}")
|
| 34 |
+
return False
|
| 35 |
+
|
| 36 |
+
return True
|
| 37 |
+
|
| 38 |
+
def main():
|
| 39 |
+
"""Main startup function"""
|
| 40 |
+
print("π€ Real-time Microphone Streaming Server")
|
| 41 |
+
print("=" * 50)
|
| 42 |
+
|
| 43 |
+
# Check dependencies
|
| 44 |
+
print("π Checking dependencies...")
|
| 45 |
+
if not check_dependencies():
|
| 46 |
+
print("β Dependency check failed. Exiting.")
|
| 47 |
+
sys.exit(1)
|
| 48 |
+
|
| 49 |
+
print("β
Dependencies OK")
|
| 50 |
+
|
| 51 |
+
# Set environment variables for local development
|
| 52 |
+
os.environ['GRADIO_SERVER_NAME'] = '0.0.0.0'
|
| 53 |
+
os.environ['GRADIO_SERVER_PORT'] = '7860'
|
| 54 |
+
|
| 55 |
+
print("\nπ Starting server...")
|
| 56 |
+
print("π‘ Gradio interface will be available at: http://localhost:7860")
|
| 57 |
+
print("π WebSocket server will be available at: ws://localhost:7861")
|
| 58 |
+
print("\nπ± Update your Android app to use: ws://YOUR_LOCAL_IP:7861")
|
| 59 |
+
print(" (Replace YOUR_LOCAL_IP with your computer's IP address)")
|
| 60 |
+
print("\nβΉοΈ Press Ctrl+C to stop the server")
|
| 61 |
+
print("=" * 50)
|
| 62 |
+
|
| 63 |
+
# Import and run the main app
|
| 64 |
+
try:
|
| 65 |
+
from app import main as run_app
|
| 66 |
+
run_app()
|
| 67 |
+
except KeyboardInterrupt:
|
| 68 |
+
print("\n\nπ Server stopped by user")
|
| 69 |
+
except Exception as e:
|
| 70 |
+
print(f"\nβ Server error: {e}")
|
| 71 |
+
import traceback
|
| 72 |
+
traceback.print_exc()
|
| 73 |
+
|
| 74 |
+
if __name__ == "__main__":
|
| 75 |
+
main()
|