| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182 |
- """
- 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()
|