microphone.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. # -*- coding: utf-8 -*-
  2. """
  3. Mikrofon-Aufnahme via sounddevice.
  4. Erfasst Audio vom Mikrofon in einem PortAudio-Thread
  5. und leitet es per Callback an den Consumer weiter.
  6. 16kHz, 16-bit, mono, 80ms Frames (1280 Samples).
  7. """
  8. from typing import Callable
  9. import numpy as np
  10. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  11. try:
  12. import sounddevice as sd
  13. HAS_SOUNDDEVICE = True
  14. except ImportError:
  15. sd = None
  16. HAS_SOUNDDEVICE = False
  17. class MicrophoneCapture:
  18. """
  19. Mikrofon-Aufnahme via sounddevice.InputStream.
  20. Callback läuft in PortAudio-Thread (eigener Thread).
  21. 16kHz, 16-bit, mono, 80ms Frames (1280 Samples).
  22. """
  23. def __init__(
  24. self,
  25. sample_rate: int = 16000,
  26. channels: int = 1,
  27. frame_ms: int = 80,
  28. device: str | int | None = None,
  29. ) -> None:
  30. """
  31. Initialisiert MicrophoneCapture.
  32. Args:
  33. sample_rate: Abtastrate in Hz
  34. channels: Anzahl Kanäle (1=Mono)
  35. frame_ms: Frame-Größe in Millisekunden
  36. device: Audio-Eingabegerät (None=Standard)
  37. """
  38. if not HAS_SOUNDDEVICE:
  39. raise ImportError("sounddevice ist nicht installiert: pip install sounddevice")
  40. self._sample_rate = sample_rate
  41. self._channels = channels
  42. self._frame_ms = frame_ms
  43. self._frame_size = int(sample_rate * frame_ms / 1000) # 1280 bei 16kHz/80ms
  44. self._device = device
  45. self._stream: sd.InputStream | None = None
  46. self._callback: Callable[[bytes], None] | None = None
  47. def set_callback(self, callback: Callable[[bytes], None]) -> None:
  48. """
  49. Setzt den Audio-Callback.
  50. Args:
  51. callback: Funktion die Audio-Bytes empfängt (16-bit PCM)
  52. """
  53. self._callback = callback
  54. def start(self) -> None:
  55. """Erstellt und startet InputStream mit _audio_callback."""
  56. if self._stream is not None:
  57. pwarn("Mikrofon läuft bereits")
  58. return
  59. if not self._callback:
  60. raise RuntimeError("Kein Callback gesetzt - set_callback() aufrufen")
  61. # Gerät auflösen (Name → Index)
  62. device = self._resolve_device(self._device)
  63. self._stream = sd.InputStream(
  64. samplerate=self._sample_rate,
  65. channels=self._channels,
  66. dtype="int16",
  67. blocksize=self._frame_size,
  68. device=device,
  69. callback=self._audio_callback,
  70. )
  71. self._stream.start()
  72. pinfo(f"Mikrofonaufnahme gestartet ({self._sample_rate}Hz, {self._channels}ch, {self._frame_ms}ms Frames)")
  73. def stop(self) -> None:
  74. """Stoppt und schließt Stream."""
  75. if self._stream is not None:
  76. try:
  77. self._stream.stop()
  78. self._stream.close()
  79. except Exception as e:
  80. pdebug(f"Mikrofon-Stop-Fehler: {e}")
  81. finally:
  82. self._stream = None
  83. pdebug("Mikrofonaufnahme gestoppt")
  84. def _audio_callback(self, indata: np.ndarray, frames: int, time_info, status) -> None:
  85. """
  86. sounddevice-Callback: numpy → bytes → self._callback.
  87. Läuft in PortAudio-Thread, darf nicht blockieren.
  88. """
  89. if status:
  90. pdebug(f"Mikrofon-Status: {status}")
  91. if self._callback:
  92. # numpy int16 Array → bytes (Little-Endian 16-bit PCM)
  93. audio_bytes = indata.tobytes()
  94. try:
  95. self._callback(audio_bytes)
  96. except Exception:
  97. # Callback-Fehler nicht in PortAudio-Thread propagieren
  98. pass
  99. @property
  100. def is_running(self) -> bool:
  101. """Prüft ob Aufnahme läuft."""
  102. return self._stream is not None and self._stream.active
  103. @property
  104. def sample_rate(self) -> int:
  105. """Abtastrate."""
  106. return self._sample_rate
  107. @property
  108. def channels(self) -> int:
  109. """Anzahl Kanäle."""
  110. return self._channels
  111. @property
  112. def frame_size(self) -> int:
  113. """Frame-Größe in Samples."""
  114. return self._frame_size
  115. @staticmethod
  116. def _resolve_device(device: str | int | None) -> int | None:
  117. """
  118. Löst Gerätenamen zu Index auf.
  119. Args:
  120. device: Gerätename, Index oder None
  121. Returns:
  122. Geräte-Index oder None (Standard)
  123. """
  124. if device is None or device == "":
  125. return None
  126. if isinstance(device, int):
  127. return device
  128. # Name → Index suchen
  129. try:
  130. devices = sd.query_devices()
  131. for i, dev in enumerate(devices):
  132. if device.lower() in dev["name"].lower() and dev["max_input_channels"] > 0:
  133. pdebug(f"Mikrofon-Gerät gefunden: [{i}] {dev['name']}")
  134. return i
  135. pwarn(f"Mikrofon-Gerät '{device}' nicht gefunden, verwende Standard")
  136. except Exception as e:
  137. pwarn(f"Gerätesuche fehlgeschlagen: {e}")
  138. return None
  139. @staticmethod
  140. def list_devices() -> list[dict]:
  141. """
  142. Listet verfügbare Eingabegeräte auf.
  143. Returns:
  144. Liste von Geräte-Informationen
  145. """
  146. if not HAS_SOUNDDEVICE:
  147. return []
  148. result = []
  149. try:
  150. devices = sd.query_devices()
  151. for i, dev in enumerate(devices):
  152. if dev["max_input_channels"] > 0:
  153. result.append({
  154. "index": i,
  155. "name": dev["name"],
  156. "channels": dev["max_input_channels"],
  157. "sample_rate": dev["default_samplerate"],
  158. })
  159. except Exception:
  160. pass
  161. return result