""" Audio Player — Play audio files through the EQ engine to the sound card output. """ import sounddevice as sd import numpy as np from typing import Optional, Callable import threading from .file_loader import load_audio, AudioData, normalize_audio from .eq_engine import EQEngine from .tape_processor import TapeProcessor from .audio_device import DeviceManager class AudioPlayer: """ Handles playback of audio files through a TapeProcessor and EQEngine. """ def __init__(self, eq_engine: EQEngine, tape_processor: TapeProcessor, device_manager: DeviceManager): self.eq_engine = eq_engine self.tape_processor = tape_processor self.device_manager = device_manager self.audio_data: Optional[AudioData] = None self.stream: Optional[sd.OutputStream] = None self.current_frame: int = 0 self.is_playing: bool = False self.is_looping: bool = False # Callbacks for UI updates self.on_position_update: Optional[Callable[[float], None]] = None self.on_playback_stopped: Optional[Callable[[], None]] = None # VU meter levels (left, right RMS in dB) self.vu_levels = (-100.0, -100.0) def load_file(self, file_path: str, normalize: bool = False): """Load an audio file and prepare for playback.""" self.stop() audio = load_audio(file_path) if normalize: audio = normalize_audio(audio) # Convert mono to stereo for output if needed if audio.channels == 1 and self.device_manager.channels == 2: audio.samples = np.repeat(audio.samples, 2, axis=1) audio.channels = 2 self.audio_data = audio self.current_frame = 0 def play(self): """Start or resume playback.""" if self.audio_data is None: return if self.is_playing: return self.eq_engine.reset_state() self.eq_engine.set_sample_rate(self.audio_data.sample_rate) self.tape_processor.reset_state() self.tape_processor.set_sample_rate(self.audio_data.sample_rate) kwargs = self.device_manager.get_sd_output_kwargs() kwargs["samplerate"] = self.audio_data.sample_rate self.stream = sd.OutputStream( callback=self._audio_callback, finished_callback=self._on_finished, **kwargs ) self.is_playing = True self.stream.start() def pause(self): """Pause playback.""" if self.is_playing and self.stream: self.stream.stop() self.stream.close() self.stream = None self.is_playing = False def stop(self): """Stop playback and reset position.""" self.pause() self.current_frame = 0 self.vu_levels = (-100.0, -100.0) if self.on_position_update: self.on_position_update(0.0) def seek(self, position_sec: float): """Seek to a specific position in seconds.""" if self.audio_data is None: return frame = int(position_sec * self.audio_data.sample_rate) self.current_frame = max(0, min(frame, self.audio_data.num_samples - 1)) if self.is_playing: self.eq_engine.reset_state() self.tape_processor.reset_state() def _audio_callback(self, outdata: np.ndarray, frames: int, time, status: sd.CallbackFlags): """sounddevice callback for generating output audio.""" if status: print(f"Playback status: {status}") if self.audio_data is None or not self.is_playing: outdata.fill(0) return chunk_size = frames remaining = self.audio_data.num_samples - self.current_frame if remaining <= 0: if self.is_looping: self.current_frame = 0 self.eq_engine.reset_state() self.tape_processor.reset_state() remaining = self.audio_data.num_samples else: outdata.fill(0) raise sd.CallbackStop() valid_frames = min(chunk_size, remaining) # Read from audio data chunk = self.audio_data.samples[self.current_frame:self.current_frame + valid_frames] # Tape Processing & EQ Chain chunk = self.tape_processor.process_pre_eq(chunk) chunk = self.eq_engine.process(chunk) chunk = self.tape_processor.process_post_eq(chunk) processed_chunk = chunk # Write to output buffer outdata[:valid_frames] = processed_chunk # Pad with zeros if we reached the end if valid_frames < chunk_size: outdata[valid_frames:] = 0 if not self.is_looping: raise sd.CallbackStop() # Update VU meters self._update_vu_levels(processed_chunk) self.current_frame += valid_frames # Notify UI (safely, could be high rate, UI should throttle) if self.on_position_update: pos_sec = self.current_frame / self.audio_data.sample_rate self.on_position_update(pos_sec) def _update_vu_levels(self, chunk: np.ndarray): """Calculate RMS levels for VU meters.""" if chunk.size == 0: return # Calculate RMS per channel rms = np.sqrt(np.mean(chunk**2, axis=0)) # Convert to dB, avoid log(0) db = 20 * np.log10(np.maximum(rms, 1e-5)) if chunk.shape[1] >= 2: self.vu_levels = (float(db[0]), float(db[1])) else: self.vu_levels = (float(db[0]), float(db[0])) def _on_finished(self): """Called when stream stops.""" self.is_playing = False if self.on_playback_stopped: self.on_playback_stopped()