audio_device.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. """
  2. Audio Device Manager — Enumerate and manage sound card I/O devices.
  3. Provides a singleton DeviceManager that all components use to access
  4. the currently selected input/output devices and sample rate.
  5. """
  6. import sounddevice as sd
  7. import numpy as np
  8. import json
  9. import os
  10. from dataclasses import dataclass, field
  11. from typing import Optional
  12. @dataclass
  13. class AudioDeviceInfo:
  14. """Information about an audio device."""
  15. index: int
  16. name: str
  17. max_input_channels: int
  18. max_output_channels: int
  19. default_samplerate: float
  20. hostapi_name: str
  21. @property
  22. def is_input(self) -> bool:
  23. return self.max_input_channels > 0
  24. @property
  25. def is_output(self) -> bool:
  26. return self.max_output_channels > 0
  27. def __str__(self) -> str:
  28. direction = []
  29. if self.is_input:
  30. direction.append(f"IN:{self.max_input_channels}ch")
  31. if self.is_output:
  32. direction.append(f"OUT:{self.max_output_channels}ch")
  33. return f"[{self.index}] {self.name} ({', '.join(direction)}) [{self.hostapi_name}]"
  34. class DeviceManager:
  35. """
  36. Manages audio device selection and configuration.
  37. Singleton pattern — use DeviceManager.instance() to get the shared instance.
  38. """
  39. _instance: Optional["DeviceManager"] = None
  40. def __init__(self):
  41. self._input_device_id: Optional[int] = None
  42. self._output_device_id: Optional[int] = None
  43. self._sample_rate: int = 44100
  44. self._channels: int = 2 # Stereo
  45. self._block_size: int = 1024
  46. self._devices: list[AudioDeviceInfo] = []
  47. self.refresh_devices()
  48. # Load saved preferences if available
  49. self._load_config()
  50. def _load_config(self):
  51. """Load settings from config.json."""
  52. if not os.path.exists("config.json"):
  53. return
  54. try:
  55. with open("config.json", "r") as f:
  56. config = json.load(f)
  57. if "sample_rate" in config:
  58. self.sample_rate = config["sample_rate"]
  59. # Try to match saved device names to current indices
  60. if "input_device_name" in config:
  61. for dev in self.get_input_devices():
  62. if dev.name == config["input_device_name"]:
  63. self._input_device_id = dev.index
  64. break
  65. if "output_device_name" in config:
  66. for dev in self.get_output_devices():
  67. if dev.name == config["output_device_name"]:
  68. self._output_device_id = dev.index
  69. break
  70. except Exception as e:
  71. print(f"Failed to load config: {e}")
  72. def _save_config(self):
  73. """Save settings to config.json."""
  74. config = {
  75. "sample_rate": self.sample_rate
  76. }
  77. if self._input_device_id is not None:
  78. dev = self.get_device_by_index(self._input_device_id)
  79. if dev: config["input_device_name"] = dev.name
  80. if self._output_device_id is not None:
  81. dev = self.get_device_by_index(self._output_device_id)
  82. if dev: config["output_device_name"] = dev.name
  83. try:
  84. with open("config.json", "w") as f:
  85. json.dump(config, f, indent=4)
  86. except Exception as e:
  87. print(f"Failed to save config: {e}")
  88. @classmethod
  89. def instance(cls) -> "DeviceManager":
  90. """Get the singleton DeviceManager instance."""
  91. if cls._instance is None:
  92. cls._instance = DeviceManager()
  93. return cls._instance
  94. @classmethod
  95. def reset_instance(cls):
  96. """Reset the singleton (useful for testing)."""
  97. cls._instance = None
  98. def refresh_devices(self):
  99. """Re-scan available audio devices."""
  100. self._devices = []
  101. devices = sd.query_devices()
  102. hostapis = sd.query_hostapis()
  103. for i, dev in enumerate(devices):
  104. hostapi_name = hostapis[dev["hostapi"]]["name"] if dev["hostapi"] < len(hostapis) else "Unknown"
  105. self._devices.append(AudioDeviceInfo(
  106. index=i,
  107. name=dev["name"],
  108. max_input_channels=dev["max_input_channels"],
  109. max_output_channels=dev["max_output_channels"],
  110. default_samplerate=dev["default_samplerate"],
  111. hostapi_name=hostapi_name,
  112. ))
  113. def get_all_devices(self) -> list[AudioDeviceInfo]:
  114. """Return all available audio devices."""
  115. return list(self._devices)
  116. def get_input_devices(self) -> list[AudioDeviceInfo]:
  117. """Return devices that support audio input."""
  118. return [d for d in self._devices if d.is_input]
  119. def get_output_devices(self) -> list[AudioDeviceInfo]:
  120. """Return devices that support audio output."""
  121. return [d for d in self._devices if d.is_output]
  122. def get_device_by_index(self, index: int) -> Optional[AudioDeviceInfo]:
  123. """Get device info by its index."""
  124. for d in self._devices:
  125. if d.index == index:
  126. return d
  127. return None
  128. @property
  129. def input_device_id(self) -> Optional[int]:
  130. return self._input_device_id
  131. @input_device_id.setter
  132. def input_device_id(self, device_id: int):
  133. dev = self.get_device_by_index(device_id)
  134. if dev is None:
  135. raise ValueError(f"Device index {device_id} not found")
  136. if not dev.is_input:
  137. raise ValueError(f"Device '{dev.name}' has no input channels")
  138. self._input_device_id = device_id
  139. self._save_config()
  140. @property
  141. def output_device_id(self) -> Optional[int]:
  142. return self._output_device_id
  143. @output_device_id.setter
  144. def output_device_id(self, device_id: int):
  145. dev = self.get_device_by_index(device_id)
  146. if dev is None:
  147. raise ValueError(f"Device index {device_id} not found")
  148. if not dev.is_output:
  149. raise ValueError(f"Device '{dev.name}' has no output channels")
  150. self._output_device_id = device_id
  151. # Automatically update sample rate to device's default to prevent speed mismatch
  152. if dev.default_samplerate > 0:
  153. self._sample_rate = int(dev.default_samplerate)
  154. self._save_config()
  155. @property
  156. def sample_rate(self) -> int:
  157. return self._sample_rate
  158. @sample_rate.setter
  159. def sample_rate(self, rate: int):
  160. if rate not in (22050, 44100, 48000, 88200, 96000):
  161. raise ValueError(f"Unsupported sample rate: {rate}")
  162. self._sample_rate = rate
  163. self._save_config()
  164. @property
  165. def channels(self) -> int:
  166. return self._channels
  167. @channels.setter
  168. def channels(self, ch: int):
  169. if ch not in (1, 2):
  170. raise ValueError(f"Unsupported channel count: {ch}")
  171. self._channels = ch
  172. @property
  173. def block_size(self) -> int:
  174. return self._block_size
  175. @block_size.setter
  176. def block_size(self, size: int):
  177. if size < 64 or size > 8192:
  178. raise ValueError(f"Block size must be 64-8192, got {size}")
  179. self._block_size = size
  180. def get_sd_input_kwargs(self) -> dict:
  181. """Get sounddevice kwargs for input stream."""
  182. kwargs = {
  183. "samplerate": self._sample_rate,
  184. "channels": self._channels,
  185. "blocksize": self._block_size,
  186. "dtype": "float32",
  187. }
  188. if self._input_device_id is not None:
  189. kwargs["device"] = self._input_device_id
  190. return kwargs
  191. def get_sd_output_kwargs(self) -> dict:
  192. """Get sounddevice kwargs for output stream."""
  193. kwargs = {
  194. "samplerate": self._sample_rate,
  195. "channels": self._channels,
  196. "blocksize": self._block_size,
  197. "dtype": "float32",
  198. }
  199. if self._output_device_id is not None:
  200. kwargs["device"] = self._output_device_id
  201. return kwargs
  202. def auto_select_defaults(self):
  203. """Auto-select the system default input and output devices."""
  204. defaults = sd.query_devices()
  205. default_input = sd.default.device[0]
  206. default_output = sd.default.device[1]
  207. if default_input is not None and isinstance(default_input, int):
  208. try:
  209. self.input_device_id = default_input
  210. except ValueError:
  211. pass
  212. if default_output is not None and isinstance(default_output, int):
  213. try:
  214. self.output_device_id = default_output
  215. except ValueError:
  216. pass