audio_player.py 7.5 KB

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