audio_player.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. """
  2. Audio Player — Play audio files through the EQ engine to the sound card output.
  3. """
  4. import sounddevice as sd
  5. import numpy as np
  6. from typing import Optional, Callable
  7. import threading
  8. from .file_loader import load_audio, AudioData, normalize_audio
  9. from .eq_engine import EQEngine
  10. from .tape_processor import TapeProcessor
  11. from .compressor import Compressor
  12. from .audio_device import DeviceManager
  13. class AudioPlayer:
  14. """
  15. Handles playback of audio files through a TapeProcessor and EQEngine.
  16. """
  17. def __init__(self, eq_engine: EQEngine, tape_processor: TapeProcessor, device_manager: DeviceManager):
  18. self.eq_engine = eq_engine
  19. self.tape_processor = tape_processor
  20. self.compressor = Compressor()
  21. self.device_manager = device_manager
  22. self.audio_data: Optional[AudioData] = None
  23. self.stream: Optional[sd.OutputStream] = None
  24. self.current_frame: float = 0.0
  25. self.is_playing: bool = False
  26. self.is_looping: bool = False
  27. # Callbacks for UI updates
  28. self.on_position_update: Optional[Callable[[float], None]] = None
  29. self.on_playback_stopped: Optional[Callable[[], None]] = None
  30. # VU meter levels (left, right RMS in dB)
  31. self.vu_levels = (-100.0, -100.0)
  32. self.playback_speed: float = 1.0
  33. self.output_volume: float = 1.0
  34. self._is_restarting: bool = False
  35. self._last_raw_chunk: Optional[np.ndarray] = None
  36. self._last_processed_chunk: Optional[np.ndarray] = None
  37. def load_file(self, file_path: str, normalize: bool = False):
  38. """Load an audio file and prepare for playback."""
  39. self.stop()
  40. audio = load_audio(file_path)
  41. if normalize:
  42. audio = normalize_audio(audio)
  43. # Convert mono to stereo for output if needed
  44. if audio.channels == 1 and self.device_manager.channels == 2:
  45. audio.samples = np.repeat(audio.samples, 2, axis=1)
  46. audio.channels = 2
  47. self.audio_data = audio
  48. self.current_frame = 0.0
  49. def play(self):
  50. """Start or resume playback."""
  51. if self.audio_data is None:
  52. return
  53. if self.is_playing:
  54. return
  55. out_sr = self.device_manager.sample_rate
  56. self.eq_engine.reset_state()
  57. self.eq_engine.set_sample_rate(out_sr)
  58. self.tape_processor.reset_state()
  59. self.tape_processor.set_sample_rate(out_sr)
  60. kwargs = self.device_manager.get_sd_output_kwargs()
  61. self.stream = sd.OutputStream(
  62. callback=self._audio_callback,
  63. finished_callback=self._on_finished,
  64. **kwargs
  65. )
  66. self.is_playing = True
  67. self.stream.start()
  68. def pause(self):
  69. """Pause playback."""
  70. if self.stream:
  71. try:
  72. self.stream.stop()
  73. self.stream.close()
  74. except Exception as e:
  75. print(f"Error closing stream: {e}")
  76. self.stream = None
  77. self.is_playing = False
  78. def stop(self):
  79. """Stop playback and reset position."""
  80. self.pause()
  81. self.current_frame = 0.0
  82. self.vu_levels = (-100.0, -100.0)
  83. if self.on_position_update:
  84. self.on_position_update(0.0)
  85. def set_speed(self, speed: float):
  86. """Set playback speed. Stream adjusts seamlessly via interpolation."""
  87. self.playback_speed = max(0.1, speed)
  88. def seek(self, position_sec: float):
  89. """Seek to a specific position in seconds."""
  90. if self.audio_data is None:
  91. return
  92. frame = position_sec * self.audio_data.sample_rate
  93. self.current_frame = max(0.0, min(float(frame), float(self.audio_data.num_samples - 1)))
  94. if self.is_playing:
  95. self.eq_engine.reset_state()
  96. self.tape_processor.reset_state()
  97. def _audio_callback(self, outdata: np.ndarray, frames: int, time, status: sd.CallbackFlags):
  98. """sounddevice callback for generating output audio."""
  99. if status:
  100. print(f"Playback status: {status}")
  101. if self.audio_data is None or not self.is_playing:
  102. outdata.fill(0)
  103. return
  104. # Ratio of input samples to advance per output sample
  105. ratio = (self.audio_data.sample_rate / self.device_manager.sample_rate) * self.playback_speed
  106. # We need to produce `frames` samples
  107. out_indices = np.arange(frames)
  108. in_indices = self.current_frame + out_indices * ratio
  109. # Find which indices are valid
  110. valid_mask = in_indices < (self.audio_data.num_samples - 1)
  111. valid_count = int(np.sum(valid_mask))
  112. if valid_count == 0:
  113. if self.is_looping:
  114. self.current_frame = 0.0
  115. self.eq_engine.reset_state()
  116. self.tape_processor.reset_state()
  117. in_indices = self.current_frame + out_indices * ratio
  118. valid_mask = in_indices < (self.audio_data.num_samples - 1)
  119. valid_count = int(np.sum(valid_mask))
  120. else:
  121. outdata.fill(0)
  122. raise sd.CallbackStop()
  123. # Get integer part and fractional part
  124. in_idx_int = in_indices[:valid_count].astype(np.int32)
  125. in_idx_frac = in_indices[:valid_count] - in_idx_int
  126. # Broadcast for channels (e.g. 2 channels)
  127. in_idx_frac = in_idx_frac[:, np.newaxis].astype(np.float32)
  128. c0 = self.audio_data.samples[in_idx_int]
  129. c1 = self.audio_data.samples[in_idx_int + 1]
  130. raw_chunk = c0 + (c1 - c0) * in_idx_frac
  131. # Tape Processing & EQ Chain
  132. chunk = self.tape_processor.process_pre_eq(raw_chunk)
  133. chunk = self.eq_engine.process(chunk)
  134. chunk = self.tape_processor.process_post_eq(chunk)
  135. # Dynamic Range Compression
  136. chunk = self.compressor.process(chunk)
  137. # Apply output volume
  138. if self.output_volume != 1.0:
  139. chunk = chunk * self.output_volume
  140. # Write to output buffer
  141. outdata[:valid_count] = chunk
  142. self._last_raw_chunk = np.copy(raw_chunk)
  143. self._last_processed_chunk = np.copy(chunk)
  144. # Pad with zeros if we reached the end
  145. if valid_count < frames:
  146. outdata[valid_count:] = 0
  147. # Update VU meters
  148. self._update_vu_levels(chunk)
  149. self.current_frame = float(in_indices[valid_count - 1] + ratio)
  150. # Notify UI (safely, could be high rate, UI should throttle)
  151. if self.on_position_update:
  152. pos_sec = self.current_frame / self.audio_data.sample_rate
  153. self.on_position_update(pos_sec)
  154. if valid_count < frames and not self.is_looping:
  155. raise sd.CallbackStop()
  156. def _update_vu_levels(self, chunk: np.ndarray):
  157. """Calculate RMS levels for VU meters."""
  158. if chunk.size == 0:
  159. return
  160. # Calculate RMS per channel
  161. rms = np.sqrt(np.mean(chunk**2, axis=0))
  162. # Convert to dB, avoid log(0)
  163. db = 20 * np.log10(np.maximum(rms, 1e-5))
  164. if chunk.shape[1] >= 2:
  165. self.vu_levels = (float(db[0]), float(db[1]))
  166. else:
  167. self.vu_levels = (float(db[0]), float(db[0]))
  168. def _on_finished(self):
  169. """Called when stream stops."""
  170. if self._is_restarting:
  171. return
  172. self.is_playing = False
  173. if self.on_playback_stopped:
  174. self.on_playback_stopped()