| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407 |
- """
- Player Panel — UI for playing files with EQ applied.
- """
- import os
- from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout,
- QPushButton, QLabel, QFileDialog, QProgressBar,
- QListWidget, QSplitter, QMessageBox, QCheckBox, QSlider)
- from PyQt6.QtCore import Qt, QTimer, QSettings
- from core.file_loader import get_audio_duration
- from core.audio_player import AudioPlayer
- from ui.waveform_widget import WaveformWidget
- from ui.spectrum_widget import SpectrumWidget
- class VUMeter(QProgressBar):
- """Custom progress bar for VU metering."""
- def __init__(self):
- super().__init__()
- self.setOrientation(Qt.Orientation.Vertical)
- self.setRange(-60, 0) # dB range
- self.setValue(-60)
- self.setTextVisible(False)
- self.setFixedWidth(15)
- # Styling handled in global QSS, but we can dynamic color here if needed
- self.setStyleSheet("""
- QProgressBar {
- border: 1px solid #30304a;
- background-color: #161625;
- }
- QProgressBar::chunk {
- background-color: qlineargradient(x1: 0, y1: 1, x2: 0, y2: 0,
- stop: 0 #4CAF50, stop: 0.7 #FFEB3B, stop: 1 #F44336);
- }
- """)
- class PlayerPanel(QWidget):
- def __init__(self, player: AudioPlayer):
- super().__init__()
- self.player = player
- self.playlist = []
- self.current_track_index = -1
- self.settings = QSettings("TapeRecorder", "Player")
- self._init_ui()
-
- # Init volume and compressor
- self.player.output_volume = self.settings.value("volume", 100, type=int) / 100.0
- self.player.compressor.enabled = self.settings.value("compressor", False, type=bool)
-
- # Connect player callbacks
- # Note: These are called from the audio thread.
- # For thread safety, we use a QTimer to poll state instead of direct signals.
- self.poll_timer = QTimer()
- self.poll_timer.timeout.connect(self._poll_player_state)
- self.poll_timer.start(50) # 20Hz update
- def _init_ui(self):
- # --- Left Side: Player Controls ---
- left_widget = QWidget()
- left_layout = QVBoxLayout(left_widget)
-
- # --- Top: File Info & Load ---
- top_row = QHBoxLayout()
-
- info_layout = QVBoxLayout()
- self.file_lbl = QLabel("No file loaded")
- self.file_lbl.setStyleSheet("font-weight: bold; font-size: 12pt;")
- self.format_lbl = QLabel("")
- self.format_lbl.setStyleSheet("color: #808090; font-size: 10pt;")
- info_layout.addWidget(self.file_lbl)
- info_layout.addWidget(self.format_lbl)
-
- load_btn = QPushButton("Load Files...")
- load_btn.clicked.connect(self.load_files)
-
- load_folder_btn = QPushButton("Load Folder...")
- load_folder_btn.clicked.connect(self.load_folder)
-
- self.refresh_btn = QPushButton("Refresh Folder")
- self.refresh_btn.clicked.connect(self.refresh_folder)
-
- top_row.addLayout(info_layout, 1)
- top_row.addWidget(load_btn)
- top_row.addWidget(load_folder_btn)
- top_row.addWidget(self.refresh_btn)
- left_layout.addLayout(top_row)
-
- # --- Middle: Waveform ---
- self.waveform = WaveformWidget()
- self.waveform.setFixedHeight(120)
- self.waveform.seek_requested.connect(self.player.seek)
- left_layout.addWidget(self.waveform)
-
- # --- Time display ---
- self.time_lbl = QLabel("00:00.0 / 00:00.0")
- self.time_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self.time_lbl.setStyleSheet("font-family: monospace; font-size: 14pt; color: #ff7b00;")
- left_layout.addWidget(self.time_lbl)
-
- # --- Controls & VU ---
- control_row = QHBoxLayout()
-
- # Buttons
- btn_layout = QHBoxLayout()
- self.play_btn = QPushButton("▶ Play")
- self.play_btn.clicked.connect(self.toggle_play)
-
- self.stop_btn = QPushButton("■ Stop")
- self.stop_btn.clicked.connect(self._on_stop_clicked)
-
- self.norm_cb = QCheckBox("Normalize")
- self.norm_cb.setChecked(self.settings.value("normalize_volume", True, type=bool))
- self.norm_cb.clicked.connect(self._on_norm_changed)
-
- self.speed_slider = QSlider(Qt.Orientation.Horizontal)
- self.speed_slider.setRange(90, 110)
- self.speed_slider.setValue(100)
- self.speed_slider.setFixedWidth(100)
- self.speed_slider.valueChanged.connect(self._on_speed_changed)
-
- self.speed_lbl = QLabel("Speed: 1.00x")
- self.speed_lbl.setFixedWidth(80)
-
- btn_layout.addWidget(self.play_btn)
- btn_layout.addWidget(self.stop_btn)
- btn_layout.addWidget(self.norm_cb)
-
- self.comp_cb = QCheckBox("Movie Mode (Comp)")
- self.comp_cb.setChecked(self.settings.value("compressor", False, type=bool))
- self.comp_cb.clicked.connect(self._on_comp_changed)
- btn_layout.addWidget(self.comp_cb)
-
- btn_layout.addWidget(QLabel(" | "))
-
- self.vol_slider = QSlider(Qt.Orientation.Horizontal)
- self.vol_slider.setRange(0, 200)
- self.vol_slider.setValue(self.settings.value("volume", 100, type=int))
- self.vol_slider.setFixedWidth(100)
- self.vol_slider.valueChanged.connect(self._on_vol_changed)
-
- self.vol_lbl = QLabel(f"Vol: {self.vol_slider.value()}%")
- self.vol_lbl.setFixedWidth(70)
-
- btn_layout.addWidget(self.vol_lbl)
- btn_layout.addWidget(self.vol_slider)
- btn_layout.addWidget(QLabel(" | "))
-
- btn_layout.addWidget(self.speed_lbl)
- btn_layout.addWidget(self.speed_slider)
- btn_layout.addStretch()
-
- control_row.addLayout(btn_layout, 1)
-
- # VU Meters
- vu_layout = QHBoxLayout()
- self.vu_l = VUMeter()
- self.vu_r = VUMeter()
- vu_layout.addWidget(QLabel("L"))
- vu_layout.addWidget(self.vu_l)
- vu_layout.addWidget(self.vu_r)
- vu_layout.addWidget(QLabel("R"))
-
- control_row.addLayout(vu_layout)
- left_layout.addLayout(control_row)
-
- # --- Bottom: Spectrum Analyzer ---
- self.spectrum = SpectrumWidget()
- self.spectrum.setFixedHeight(200)
- left_layout.addWidget(self.spectrum)
-
- # --- Right Side: Playlist ---
- right_widget = QWidget()
- right_layout = QVBoxLayout(right_widget)
-
- self.playlist_lbl = QLabel("Playlist")
- self.playlist_lbl.setStyleSheet("font-weight: bold;")
- right_layout.addWidget(self.playlist_lbl)
-
- self.playlist_widget = QListWidget()
- self.playlist_widget.itemDoubleClicked.connect(self._on_playlist_double_click)
- right_layout.addWidget(self.playlist_widget)
-
- # Splitter
- splitter = QSplitter(Qt.Orientation.Horizontal)
- splitter.addWidget(left_widget)
- splitter.addWidget(right_widget)
- splitter.setSizes([700, 300]) # Default proportions
-
- layout = QVBoxLayout(self)
- layout.addWidget(splitter)
- def load_files(self):
- files, _ = QFileDialog.getOpenFileNames(
- self, "Open Audio Files", "", "Audio Files (*.wav *.mp3 *.opus *.flac *.ogg)"
- )
- if files:
- self.playlist = files
- self.current_track_index = 0
- self._update_playlist_ui()
- self._load_track(self.playlist[0])
- def load_folder(self):
- last_folder = self.settings.value("last_folder", "")
- folder_path = QFileDialog.getExistingDirectory(self, "Select Folder", last_folder)
- if folder_path:
- self.settings.setValue("last_folder", folder_path)
- supported = ('.wav', '.mp3', '.opus', '.flac', '.ogg', '.aiff')
- files = []
- for f in os.listdir(folder_path):
- if f.lower().endswith(supported):
- files.append(os.path.join(folder_path, f))
-
- if not files:
- QMessageBox.information(self, "Info", "No supported audio files found in folder.")
- return
-
- self.playlist = sorted(files)
- self.current_track_index = 0
- self._update_playlist_ui()
- self._load_track(self.playlist[self.current_track_index], auto_play=True)
- def refresh_folder(self):
- folder_path = self.settings.value("last_folder", "")
- if not folder_path or not os.path.isdir(folder_path):
- QMessageBox.information(self, "Info", "No folder currently loaded or folder no longer exists.")
- return
-
- supported = ('.wav', '.mp3', '.opus', '.flac', '.ogg', '.aiff')
- files = []
- for f in os.listdir(folder_path):
- if f.lower().endswith(supported):
- files.append(os.path.join(folder_path, f))
-
- if not files:
- QMessageBox.information(self, "Info", "No supported audio files found in folder.")
- return
-
- new_playlist = sorted(files)
-
- current_file = None
- if self.playlist and 0 <= self.current_track_index < len(self.playlist):
- current_file = self.playlist[self.current_track_index]
-
- self.playlist = new_playlist
-
- if current_file in self.playlist:
- self.current_track_index = self.playlist.index(current_file)
- else:
- self.current_track_index = -1
-
- self._update_playlist_ui()
- def _update_playlist_ui(self):
- self.playlist_widget.clear()
- total_duration = 0.0
-
- for f in self.playlist:
- self.playlist_widget.addItem(os.path.basename(f))
- total_duration += get_audio_duration(f)
-
- total_duration /= self.player.playback_speed
-
- dur_h = int(total_duration // 3600)
- dur_m = int((total_duration % 3600) // 60)
- dur_s = int(total_duration % 60)
-
- if dur_h > 0:
- time_str = f"{dur_h:02d}:{dur_m:02d}:{dur_s:02d}"
- else:
- time_str = f"{dur_m:02d}:{dur_s:02d}"
-
- self.playlist_lbl.setText(f"Playlist - Total Time: {time_str}")
- self.playlist_lbl.setStyleSheet("font-weight: bold; color: #4CAF50; font-size: 11pt;")
-
- if self.playlist and 0 <= self.current_track_index < len(self.playlist):
- self.playlist_widget.setCurrentRow(self.current_track_index)
-
- def _on_playlist_double_click(self, item):
- idx = self.playlist_widget.row(item)
- if 0 <= idx < len(self.playlist):
- self.current_track_index = idx
- self._load_track(self.playlist[self.current_track_index], auto_play=True)
- def _on_speed_changed(self, value):
- speed = value / 100.0
- self.speed_lbl.setText(f"Speed: {speed:.2f}x")
- self.player.set_speed(speed)
- self._update_playlist_ui()
- if self.player.audio_data:
- pos_sec = self.player.current_frame / self.player.audio_data.sample_rate
- self._update_time_display(pos_sec)
- def _on_norm_changed(self):
- self.settings.setValue("normalize_volume", self.norm_cb.isChecked())
- # Reload track if playing to apply normalization immediately?
- # Actually normalize is done at load time, so we just inform the user.
-
- def _on_comp_changed(self):
- enabled = self.comp_cb.isChecked()
- self.settings.setValue("compressor", enabled)
- self.player.compressor.enabled = enabled
- def _load_track(self, file_path, auto_play=False):
- try:
- should_normalize = self.norm_cb.isChecked()
- self.player.load_file(file_path, normalize=should_normalize)
- filename = os.path.basename(file_path)
-
- prefix = f"[{self.current_track_index + 1}/{len(self.playlist)}] " if self.playlist else ""
- self.file_lbl.setText(f"Loaded: {prefix}{filename}")
-
- audio = self.player.audio_data
- if audio:
- ch_str = "Stereo" if audio.channels == 2 else "Mono"
- fmt = audio.format_name or "Audio"
- self.format_lbl.setText(f"{audio.sample_rate} Hz | {ch_str} | {fmt}")
-
- self.waveform.load_audio(self.player.audio_data)
- self._update_time_display(0)
- self.spectrum.set_sample_rate(self.player.device_manager.sample_rate)
-
- if auto_play:
- try:
- self.player.play()
- self.play_btn.setText("⏸ Pause")
- except Exception as e:
- QMessageBox.warning(self, "Playback Error", f"Could not start playback. Is your output device set?\n\n{e}")
-
- except Exception as e:
- self.file_lbl.setText(f"Error loading file: {e}")
- def _on_vol_changed(self, value):
- self.vol_lbl.setText(f"Vol: {value}%")
- self.settings.setValue("volume", value)
- self.player.output_volume = value / 100.0
- def play_next_track(self):
- if self.playlist and self.current_track_index < len(self.playlist) - 1:
- self.current_track_index += 1
- self.playlist_widget.setCurrentRow(self.current_track_index)
- self._load_track(self.playlist[self.current_track_index], auto_play=True)
- else:
- self.play_btn.setText("▶ Play")
-
- def _on_stop_clicked(self):
- self.play_btn.setText("▶ Play")
- self.player.stop()
- def toggle_play(self):
- if self.player.is_playing:
- self.player.pause()
- self.play_btn.setText("▶ Play")
- else:
- if self.player.audio_data is not None:
- try:
- self.player.play()
- self.play_btn.setText("⏸ Pause")
- except Exception as e:
- QMessageBox.warning(self, "Playback Error", f"Could not start playback. Is your output device set?\n\n{e}")
- def _poll_player_state(self):
- """Poll the player state to update UI (thread-safe)."""
- # Update play button state if stopped automatically (reached end of file)
- if not self.player.is_playing and self.play_btn.text() == "⏸ Pause":
- self.play_next_track()
-
- # Update playhead
- if self.player.audio_data:
- pos_sec = self.player.current_frame / self.player.audio_data.sample_rate
- self.waveform.set_position(pos_sec)
- self._update_time_display(pos_sec)
-
- # If playing, pull the latest chunks for the spectrum.
- if self.player.is_playing:
- raw = self.player._last_raw_chunk
- processed = self.player._last_processed_chunk
- if raw is not None and processed is not None:
- self.spectrum.add_audio_data(raw, processed)
- # Consume to avoid adding the same chunk multiple times
- self.player._last_raw_chunk = None
- self.player._last_processed_chunk = None
-
- # Update VU meters
- l_db, r_db = self.player.vu_levels
- # Map dB to progress bar (-60 to 0)
- self.vu_l.setValue(int(max(-60, l_db)))
- self.vu_r.setValue(int(max(-60, r_db)))
-
- # Decay VU meters when stopped
- if not self.player.is_playing:
- self.player.vu_levels = (max(-100, l_db - 2), max(-100, r_db - 2))
- def _update_time_display(self, pos_sec: float):
- if not self.player.audio_data:
- return
-
- speed = self.player.playback_speed
- pos_sec_actual = pos_sec / speed
- dur_sec_actual = self.player.audio_data.duration / speed
-
- pos_m = int(pos_sec_actual // 60)
- pos_s = pos_sec_actual % 60
-
- dur_m = int(dur_sec_actual // 60)
- dur_s = dur_sec_actual % 60
-
- self.time_lbl.setText(f"{pos_m:02d}:{pos_s:04.1f} / {dur_m:02d}:{dur_s:04.1f}")
|