audio_recorder.py 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. """
  2. Audio Recorder — Capture audio from sound card input.
  3. """
  4. import sounddevice as sd
  5. import numpy as np
  6. import soundfile as sf
  7. from typing import Optional, Callable
  8. import threading
  9. from .audio_device import DeviceManager
  10. class AudioRecorder:
  11. """
  12. Captures audio from the selected input device.
  13. Supports continuous ring-buffering for UI updates and saving to file.
  14. """
  15. def __init__(self, device_manager: DeviceManager):
  16. self.device_manager = device_manager
  17. self.stream: Optional[sd.InputStream] = None
  18. self.is_recording: bool = False
  19. # Buffer for capturing the full recording
  20. self.recorded_data: list[np.ndarray] = []
  21. # Callbacks
  22. self.on_audio_data: Optional[Callable[[np.ndarray], None]] = None
  23. self.vu_levels = (-100.0, -100.0)
  24. def start(self):
  25. """Start capturing audio."""
  26. if self.is_recording:
  27. return
  28. self.recorded_data = []
  29. kwargs = self.device_manager.get_sd_input_kwargs()
  30. self.stream = sd.InputStream(
  31. callback=self._audio_callback,
  32. **kwargs
  33. )
  34. self.is_recording = True
  35. self.stream.start()
  36. def stop(self) -> np.ndarray:
  37. """Stop capturing and return the recorded data."""
  38. if self.is_recording and self.stream:
  39. self.stream.stop()
  40. self.stream.close()
  41. self.stream = None
  42. self.is_recording = False
  43. if not self.recorded_data:
  44. channels = self.device_manager.channels
  45. return np.zeros((0, channels), dtype=np.float32)
  46. # Concatenate all blocks
  47. full_data = np.concatenate(self.recorded_data, axis=0)
  48. return full_data
  49. def save_to_wav(self, file_path: str):
  50. """Save the recorded data to a WAV file."""
  51. data = self.stop()
  52. if len(data) > 0:
  53. sf.write(
  54. file_path,
  55. data,
  56. self.device_manager.sample_rate,
  57. subtype='FLOAT'
  58. )
  59. return True
  60. return False
  61. def _audio_callback(self, indata: np.ndarray, frames: int, time, status: sd.CallbackFlags):
  62. """sounddevice callback for incoming audio."""
  63. if status:
  64. print(f"Record status: {status}")
  65. # Make a copy of the input data
  66. chunk = indata.copy()
  67. # Store for full recording
  68. self.recorded_data.append(chunk)
  69. # Update VU
  70. self._update_vu_levels(chunk)
  71. # Notify UI/analyzers (e.g. for real-time spectrum)
  72. if self.on_audio_data:
  73. self.on_audio_data(chunk)
  74. def _update_vu_levels(self, chunk: np.ndarray):
  75. """Calculate RMS levels for VU meters."""
  76. if chunk.size == 0:
  77. return
  78. rms = np.sqrt(np.mean(chunk**2, axis=0))
  79. db = 20 * np.log10(np.maximum(rms, 1e-5))
  80. if chunk.shape[1] >= 2:
  81. self.vu_levels = (float(db[0]), float(db[1]))
  82. else:
  83. self.vu_levels = (float(db[0]), float(db[0]))