audio_player.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  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: int = 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. def load_file(self, file_path: str, normalize: bool = False):
  31. """Load an audio file and prepare for playback."""
  32. self.stop()
  33. audio = load_audio(file_path)
  34. if normalize:
  35. audio = normalize_audio(audio)
  36. # Convert mono to stereo for output if needed
  37. if audio.channels == 1 and self.device_manager.channels == 2:
  38. audio.samples = np.repeat(audio.samples, 2, axis=1)
  39. audio.channels = 2
  40. self.audio_data = audio
  41. self.current_frame = 0
  42. def play(self):
  43. """Start or resume playback."""
  44. if self.audio_data is None:
  45. return
  46. if self.is_playing:
  47. return
  48. self.eq_engine.reset_state()
  49. self.eq_engine.set_sample_rate(self.audio_data.sample_rate)
  50. self.tape_processor.reset_state()
  51. self.tape_processor.set_sample_rate(self.audio_data.sample_rate)
  52. kwargs = self.device_manager.get_sd_output_kwargs()
  53. kwargs["samplerate"] = self.audio_data.sample_rate
  54. self.stream = sd.OutputStream(
  55. callback=self._audio_callback,
  56. finished_callback=self._on_finished,
  57. **kwargs
  58. )
  59. self.is_playing = True
  60. self.stream.start()
  61. def pause(self):
  62. """Pause playback."""
  63. if self.is_playing and self.stream:
  64. self.stream.stop()
  65. self.stream.close()
  66. self.stream = None
  67. self.is_playing = False
  68. def stop(self):
  69. """Stop playback and reset position."""
  70. self.pause()
  71. self.current_frame = 0
  72. self.vu_levels = (-100.0, -100.0)
  73. if self.on_position_update:
  74. self.on_position_update(0.0)
  75. def seek(self, position_sec: float):
  76. """Seek to a specific position in seconds."""
  77. if self.audio_data is None:
  78. return
  79. frame = int(position_sec * self.audio_data.sample_rate)
  80. self.current_frame = max(0, min(frame, self.audio_data.num_samples - 1))
  81. if self.is_playing:
  82. self.eq_engine.reset_state()
  83. self.tape_processor.reset_state()
  84. def _audio_callback(self, outdata: np.ndarray, frames: int, time, status: sd.CallbackFlags):
  85. """sounddevice callback for generating output audio."""
  86. if status:
  87. print(f"Playback status: {status}")
  88. if self.audio_data is None or not self.is_playing:
  89. outdata.fill(0)
  90. return
  91. chunk_size = frames
  92. remaining = self.audio_data.num_samples - self.current_frame
  93. if remaining <= 0:
  94. if self.is_looping:
  95. self.current_frame = 0
  96. self.eq_engine.reset_state()
  97. self.tape_processor.reset_state()
  98. remaining = self.audio_data.num_samples
  99. else:
  100. outdata.fill(0)
  101. raise sd.CallbackStop()
  102. valid_frames = min(chunk_size, remaining)
  103. # Read from audio data
  104. chunk = self.audio_data.samples[self.current_frame:self.current_frame + valid_frames]
  105. # Tape Processing & EQ Chain
  106. chunk = self.tape_processor.process_pre_eq(chunk)
  107. chunk = self.eq_engine.process(chunk)
  108. chunk = self.tape_processor.process_post_eq(chunk)
  109. processed_chunk = chunk
  110. # Write to output buffer
  111. outdata[:valid_frames] = processed_chunk
  112. # Pad with zeros if we reached the end
  113. if valid_frames < chunk_size:
  114. outdata[valid_frames:] = 0
  115. if not self.is_looping:
  116. raise sd.CallbackStop()
  117. # Update VU meters
  118. self._update_vu_levels(processed_chunk)
  119. self.current_frame += valid_frames
  120. # Notify UI (safely, could be high rate, UI should throttle)
  121. if self.on_position_update:
  122. pos_sec = self.current_frame / self.audio_data.sample_rate
  123. self.on_position_update(pos_sec)
  124. def _update_vu_levels(self, chunk: np.ndarray):
  125. """Calculate RMS levels for VU meters."""
  126. if chunk.size == 0:
  127. return
  128. # Calculate RMS per channel
  129. rms = np.sqrt(np.mean(chunk**2, axis=0))
  130. # Convert to dB, avoid log(0)
  131. db = 20 * np.log10(np.maximum(rms, 1e-5))
  132. if chunk.shape[1] >= 2:
  133. self.vu_levels = (float(db[0]), float(db[1]))
  134. else:
  135. self.vu_levels = (float(db[0]), float(db[0]))
  136. def _on_finished(self):
  137. """Called when stream stops."""
  138. self.is_playing = False
  139. if self.on_playback_stopped:
  140. self.on_playback_stopped()