audio_player.py 7.3 KB

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