import pyaudio import threading from .config import ( AUDIO_INPUT_DEVICE_INDEX, AUDIO_INPUT_DEVICE_NAME, AUDIO_OUTPUT_DEVICE_INDEX, AUDIO_OUTPUT_DEVICE_NAME, ) class AudioManager: _instance = None _lock = threading.Lock() def __new__(cls): with cls._lock: if cls._instance is None: cls._instance = super(AudioManager, cls).__new__(cls) cls._instance.pa = pyaudio.PyAudio() cls._instance._input_device_index = None cls._instance._output_device_index = None cls._instance._input_device_resolved = False cls._instance._output_device_resolved = False print("🔊 AudioManager: PyAudio initialized (Global)") return cls._instance def get_pyaudio(self): return self.pa def get_input_device_index(self): """ Returns PortAudio input device index or None (let PortAudio pick default). Raises a RuntimeError with a helpful message if no input devices exist. """ if self._input_device_resolved: return self._input_device_index self._input_device_index = self._resolve_input_device_index() self._input_device_resolved = True return self._input_device_index def get_output_device_index(self): """ Returns PortAudio output device index or None (let PortAudio pick default). Raises a RuntimeError with a helpful message if no output devices exist. """ if self._output_device_resolved: return self._output_device_index self._output_device_index = self._resolve_output_device_index() self._output_device_resolved = True return self._output_device_index def _get_device_count(self) -> int: if self.pa is None: return 0 return int(self.pa.get_device_count() or 0) def _is_input_device(self, idx: int) -> bool: try: info = self.pa.get_device_info_by_index(idx) except Exception: return False return int(info.get("maxInputChannels") or 0) > 0 def _is_output_device(self, idx: int) -> bool: try: info = self.pa.get_device_info_by_index(idx) except Exception: return False return int(info.get("maxOutputChannels") or 0) > 0 def _find_device_by_name(self, needle: str, input_kind: bool): if not needle: return None lowered = needle.lower() count = self._get_device_count() for idx in range(count): if input_kind and not self._is_input_device(idx): continue if not input_kind and not self._is_output_device(idx): continue try: name = str(self.pa.get_device_info_by_index(idx).get("name") or "") except Exception: continue if lowered in name.lower(): return idx return None def _get_default_input_index(self): try: info = self.pa.get_default_input_device_info() idx = int(info.get("index")) if self._is_input_device(idx): return idx except Exception: pass return None def _get_default_output_index(self): try: info = self.pa.get_default_output_device_info() idx = int(info.get("index")) if self._is_output_device(idx): return idx except Exception: pass return None def _resolve_input_device_index(self): if self.pa is None: return None device_count = self._get_device_count() if AUDIO_INPUT_DEVICE_INDEX is not None: idx = int(AUDIO_INPUT_DEVICE_INDEX) if 0 <= idx < device_count and self._is_input_device(idx): return idx raise RuntimeError( "Audio input initialization failed: invalid AUDIO_INPUT_DEVICE_INDEX=" f"{AUDIO_INPUT_DEVICE_INDEX}. Available input devices:\n" + self.describe_input_devices() ) if AUDIO_INPUT_DEVICE_NAME: match_idx = self._find_device_by_name(AUDIO_INPUT_DEVICE_NAME, input_kind=True) if match_idx is not None: return match_idx raise RuntimeError( "Audio input initialization failed: could not find an input device " f"matching AUDIO_INPUT_DEVICE_NAME={AUDIO_INPUT_DEVICE_NAME!r}. " "Available input devices:\n" + self.describe_input_devices() ) # Default input device (if PortAudio has one). default_idx = self._get_default_input_index() if default_idx is not None: return default_idx # Fallback: first input device. for idx in range(device_count): if self._is_input_device(idx): return idx raise RuntimeError( "Audio input initialization failed: no input devices found. " "Check microphone connection and PipeWire/PulseAudio. " "PortAudio devices:\n" + self.describe_input_devices() ) def _resolve_output_device_index(self): if self.pa is None: return None device_count = self._get_device_count() if AUDIO_OUTPUT_DEVICE_INDEX is not None: idx = int(AUDIO_OUTPUT_DEVICE_INDEX) if 0 <= idx < device_count and self._is_output_device(idx): return idx raise RuntimeError( "Audio output initialization failed: invalid AUDIO_OUTPUT_DEVICE_INDEX=" f"{AUDIO_OUTPUT_DEVICE_INDEX}. Available output devices:\n" + self.describe_output_devices() ) if AUDIO_OUTPUT_DEVICE_NAME: match_idx = self._find_device_by_name( AUDIO_OUTPUT_DEVICE_NAME, input_kind=False ) if match_idx is not None: return match_idx raise RuntimeError( "Audio output initialization failed: could not find an output device " f"matching AUDIO_OUTPUT_DEVICE_NAME={AUDIO_OUTPUT_DEVICE_NAME!r}. " "Available output devices:\n" + self.describe_output_devices() ) default_idx = self._get_default_output_index() if default_idx is not None: return default_idx for idx in range(device_count): if self._is_output_device(idx): return idx raise RuntimeError( "Audio output initialization failed: no output devices found. " "Check speaker connection and PipeWire/PulseAudio. " "PortAudio devices:\n" + self.describe_output_devices() ) def _ordered_input_candidates(self, preferred_index=None): candidates = [] def add(idx): if idx not in candidates: candidates.append(idx) if preferred_index is not None: add(preferred_index) else: try: add(self.get_input_device_index()) except Exception: pass add(self._get_default_input_index()) add(None) # Let PortAudio decide default path. for idx in range(self._get_device_count()): if self._is_input_device(idx): add(idx) return [idx for idx in candidates if idx is None or self._is_input_device(idx)] def _ordered_output_candidates(self, preferred_index=None): candidates = [] def add(idx): if idx not in candidates: candidates.append(idx) if preferred_index is not None: add(preferred_index) else: try: add(self.get_output_device_index()) except Exception: pass add(self._get_default_output_index()) add(None) # Let PortAudio decide default path. for idx in range(self._get_device_count()): if self._is_output_device(idx): add(idx) return [idx for idx in candidates if idx is None or self._is_output_device(idx)] def open_input_stream( self, *, rate: int, channels: int, format, frames_per_buffer: int, preferred_index=None, fallback_rates=None, ): if self.pa is None: raise RuntimeError("PyAudio is not initialized") fallback_rates = fallback_rates or [] rates = [int(rate)] + [int(r) for r in fallback_rates if int(r) > 0 and int(r) != int(rate)] errors = [] for device_idx in self._ordered_input_candidates(preferred_index): for attempt_rate in rates: fb = max( 64, int(round(frames_per_buffer * attempt_rate / max(1, int(rate)))) ) kwargs = { "rate": attempt_rate, "channels": channels, "format": format, "input": True, "frames_per_buffer": fb, } if device_idx is not None: kwargs["input_device_index"] = device_idx try: stream = self.pa.open(**kwargs) return stream, device_idx, attempt_rate except Exception as exc: errors.append( f"device={device_idx!r}, rate={attempt_rate}: {exc}" ) joined_errors = "\n".join(errors[:12]) raise RuntimeError( "Audio input initialization failed. Tried multiple devices/rates.\n" f"{joined_errors}\nAvailable input devices:\n{self.describe_input_devices()}" ) def open_output_stream( self, *, rate: int, channels: int, format, preferred_index=None, fallback_rates=None, ): if self.pa is None: raise RuntimeError("PyAudio is not initialized") fallback_rates = fallback_rates or [] rates = [int(rate)] + [int(r) for r in fallback_rates if int(r) > 0 and int(r) != int(rate)] errors = [] for device_idx in self._ordered_output_candidates(preferred_index): for attempt_rate in rates: kwargs = { "rate": attempt_rate, "channels": channels, "format": format, "output": True, } if device_idx is not None: kwargs["output_device_index"] = device_idx try: stream = self.pa.open(**kwargs) return stream, device_idx, attempt_rate except Exception as exc: errors.append( f"device={device_idx!r}, rate={attempt_rate}: {exc}" ) joined_errors = "\n".join(errors[:12]) raise RuntimeError( "Audio output initialization failed. Tried multiple devices/rates.\n" f"{joined_errors}\nAvailable output devices:\n{self.describe_output_devices()}" ) def describe_input_devices(self, limit: int = 20) -> str: if self.pa is None: return "" items = [] count = self._get_device_count() for idx in range(count): try: info = self.pa.get_device_info_by_index(idx) except Exception: continue max_in = int(info.get("maxInputChannels") or 0) if max_in <= 0: continue name = str(info.get("name") or "").strip() items.append(f"[{idx}] {name} (in={max_in})") if len(items) >= limit: break return "\n".join(items) if items else "" def describe_output_devices(self, limit: int = 20) -> str: if self.pa is None: return "" items = [] count = self._get_device_count() for idx in range(count): try: info = self.pa.get_device_info_by_index(idx) except Exception: continue max_out = int(info.get("maxOutputChannels") or 0) if max_out <= 0: continue name = str(info.get("name") or "").strip() items.append(f"[{idx}] {name} (out={max_out})") if len(items) >= limit: break return "\n".join(items) if items else "" def cleanup(self): if self.pa: self.pa.terminate() self.pa = None def get_audio_manager(): return AudioManager()