diff --git a/backend/main.py b/backend/main.py index 1982e9a..5115b9c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -863,6 +863,29 @@ async def caller_audio_stream(websocket: WebSocket): ) +# --- Host Audio Broadcast --- + +async def _broadcast_host_audio(pcm_bytes: bytes): + """Send host mic audio to all active real callers""" + for caller_id in list(caller_service.active_calls.keys()): + try: + await caller_service.send_audio_to_caller(caller_id, pcm_bytes, 16000) + except Exception: + pass + + +def _host_audio_sync_callback(pcm_bytes: bytes): + """Sync wrapper to schedule async broadcast from audio thread""" + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + loop.call_soon_threadsafe( + asyncio.ensure_future, _broadcast_host_audio(pcm_bytes) + ) + except Exception: + pass + + # --- Queue Endpoints --- @app.get("/api/queue") @@ -888,6 +911,10 @@ async def take_call_from_queue(caller_id: str): # Notify caller they're on air via WebSocket await caller_service.notify_caller(caller_id, {"status": "on_air", "channel": call_info["channel"]}) + # Start host mic streaming if this is the first real caller + if len(caller_service.active_calls) == 1: + audio_service.start_host_stream(_host_audio_sync_callback) + return { "status": "on_air", "caller": call_info, @@ -1009,6 +1036,10 @@ async def hangup_real_caller(): caller_service.hangup(caller_id) await caller_service.disconnect_caller(caller_id) + # Stop host streaming if no more active callers + if len(caller_service.active_calls) == 0: + audio_service.stop_host_stream() + session.active_real_caller = None # Play hangup sound diff --git a/backend/services/audio.py b/backend/services/audio.py index 3763d32..0cd8fa0 100644 --- a/backend/services/audio.py +++ b/backend/services/audio.py @@ -48,6 +48,10 @@ class AudioService: self._caller_stop_event = threading.Event() self._caller_thread: Optional[threading.Thread] = None + # Host mic streaming state + self._host_stream: Optional[sd.InputStream] = None + self._host_send_callback: Optional[Callable] = None + # Sample rates self.input_sample_rate = 16000 # For Whisper self.output_sample_rate = 24000 # For TTS @@ -345,6 +349,55 @@ class AudioService: except Exception as e: print(f"Real caller audio routing error: {e}") + # --- Host Mic Streaming --- + + def start_host_stream(self, send_callback: Callable): + """Start continuous host mic capture for streaming to real callers""" + if self.input_device is None: + print("[Audio] No input device configured for host streaming") + return + + self._host_send_callback = send_callback + + device_info = sd.query_devices(self.input_device) + max_channels = device_info['max_input_channels'] + device_sr = int(device_info['default_samplerate']) + record_channel = min(self.input_channel, max_channels) - 1 + + import librosa + + def callback(indata, frames, time_info, status): + if not self._host_send_callback: + return + # Extract the configured input channel + mono = indata[:, record_channel].copy() + # Resample to 16kHz if needed + if device_sr != 16000: + mono = librosa.resample(mono, orig_sr=device_sr, target_sr=16000) + # Convert float32 to int16 PCM + pcm = (mono * 32767).astype(np.int16).tobytes() + self._host_send_callback(pcm) + + self._host_stream = sd.InputStream( + device=self.input_device, + channels=max_channels, + samplerate=device_sr, + dtype=np.float32, + blocksize=4096, + callback=callback, + ) + self._host_stream.start() + print(f"[Audio] Host mic streaming started (device {self.input_device} ch {self.input_channel} @ {device_sr}Hz)") + + def stop_host_stream(self): + """Stop host mic streaming""" + if self._host_stream: + self._host_stream.stop() + self._host_stream.close() + self._host_stream = None + self._host_send_callback = None + print("[Audio] Host mic streaming stopped") + # --- Music Playback --- def load_music(self, file_path: str) -> bool: