player_panel.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. """
  2. Player Panel — UI for playing files with EQ applied.
  3. """
  4. import os
  5. from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout,
  6. QPushButton, QLabel, QFileDialog, QProgressBar,
  7. QListWidget, QSplitter, QMessageBox, QCheckBox, QSlider)
  8. from PyQt6.QtCore import Qt, QTimer, QSettings
  9. from core.file_loader import get_audio_duration
  10. from core.audio_player import AudioPlayer
  11. from ui.waveform_widget import WaveformWidget
  12. from ui.spectrum_widget import SpectrumWidget
  13. class VUMeter(QProgressBar):
  14. """Custom progress bar for VU metering."""
  15. def __init__(self):
  16. super().__init__()
  17. self.setOrientation(Qt.Orientation.Vertical)
  18. self.setRange(-60, 0) # dB range
  19. self.setValue(-60)
  20. self.setTextVisible(False)
  21. self.setFixedWidth(15)
  22. # Styling handled in global QSS, but we can dynamic color here if needed
  23. self.setStyleSheet("""
  24. QProgressBar {
  25. border: 1px solid #30304a;
  26. background-color: #161625;
  27. }
  28. QProgressBar::chunk {
  29. background-color: qlineargradient(x1: 0, y1: 1, x2: 0, y2: 0,
  30. stop: 0 #4CAF50, stop: 0.7 #FFEB3B, stop: 1 #F44336);
  31. }
  32. """)
  33. class PlayerPanel(QWidget):
  34. def __init__(self, player: AudioPlayer):
  35. super().__init__()
  36. self.player = player
  37. self.playlist = []
  38. self.current_track_index = -1
  39. self.settings = QSettings("TapeRecorder", "Player")
  40. self._init_ui()
  41. # Init volume
  42. self.player.output_volume = self.settings.value("volume", 100, type=int) / 100.0
  43. # Connect player callbacks
  44. # Note: These are called from the audio thread.
  45. # For thread safety, we use a QTimer to poll state instead of direct signals.
  46. self.poll_timer = QTimer()
  47. self.poll_timer.timeout.connect(self._poll_player_state)
  48. self.poll_timer.start(50) # 20Hz update
  49. def _init_ui(self):
  50. # --- Left Side: Player Controls ---
  51. left_widget = QWidget()
  52. left_layout = QVBoxLayout(left_widget)
  53. # --- Top: File Info & Load ---
  54. top_row = QHBoxLayout()
  55. info_layout = QVBoxLayout()
  56. self.file_lbl = QLabel("No file loaded")
  57. self.file_lbl.setStyleSheet("font-weight: bold; font-size: 12pt;")
  58. self.format_lbl = QLabel("")
  59. self.format_lbl.setStyleSheet("color: #808090; font-size: 10pt;")
  60. info_layout.addWidget(self.file_lbl)
  61. info_layout.addWidget(self.format_lbl)
  62. load_btn = QPushButton("Load Files...")
  63. load_btn.clicked.connect(self.load_files)
  64. load_folder_btn = QPushButton("Load Folder...")
  65. load_folder_btn.clicked.connect(self.load_folder)
  66. self.refresh_btn = QPushButton("Refresh Folder")
  67. self.refresh_btn.clicked.connect(self.refresh_folder)
  68. top_row.addLayout(info_layout, 1)
  69. top_row.addWidget(load_btn)
  70. top_row.addWidget(load_folder_btn)
  71. top_row.addWidget(self.refresh_btn)
  72. left_layout.addLayout(top_row)
  73. # --- Middle: Waveform ---
  74. self.waveform = WaveformWidget()
  75. self.waveform.setFixedHeight(120)
  76. self.waveform.seek_requested.connect(self.player.seek)
  77. left_layout.addWidget(self.waveform)
  78. # --- Time display ---
  79. self.time_lbl = QLabel("00:00.0 / 00:00.0")
  80. self.time_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
  81. self.time_lbl.setStyleSheet("font-family: monospace; font-size: 14pt; color: #ff7b00;")
  82. left_layout.addWidget(self.time_lbl)
  83. # --- Controls & VU ---
  84. control_row = QHBoxLayout()
  85. # Buttons
  86. btn_layout = QHBoxLayout()
  87. self.play_btn = QPushButton("▶ Play")
  88. self.play_btn.clicked.connect(self.toggle_play)
  89. self.stop_btn = QPushButton("■ Stop")
  90. self.stop_btn.clicked.connect(self._on_stop_clicked)
  91. self.norm_cb = QCheckBox("Normalize")
  92. self.norm_cb.setChecked(self.settings.value("normalize_volume", True, type=bool))
  93. self.norm_cb.clicked.connect(self._on_norm_changed)
  94. self.speed_slider = QSlider(Qt.Orientation.Horizontal)
  95. self.speed_slider.setRange(90, 110)
  96. self.speed_slider.setValue(100)
  97. self.speed_slider.setFixedWidth(100)
  98. self.speed_slider.valueChanged.connect(self._on_speed_changed)
  99. self.speed_lbl = QLabel("Speed: 1.00x")
  100. self.speed_lbl.setFixedWidth(80)
  101. btn_layout.addWidget(self.play_btn)
  102. btn_layout.addWidget(self.stop_btn)
  103. btn_layout.addWidget(self.norm_cb)
  104. btn_layout.addWidget(QLabel(" | "))
  105. self.vol_slider = QSlider(Qt.Orientation.Horizontal)
  106. self.vol_slider.setRange(0, 200)
  107. self.vol_slider.setValue(self.settings.value("volume", 100, type=int))
  108. self.vol_slider.setFixedWidth(100)
  109. self.vol_slider.valueChanged.connect(self._on_vol_changed)
  110. self.vol_lbl = QLabel(f"Vol: {self.vol_slider.value()}%")
  111. self.vol_lbl.setFixedWidth(70)
  112. btn_layout.addWidget(self.vol_lbl)
  113. btn_layout.addWidget(self.vol_slider)
  114. btn_layout.addWidget(QLabel(" | "))
  115. btn_layout.addWidget(self.speed_lbl)
  116. btn_layout.addWidget(self.speed_slider)
  117. btn_layout.addStretch()
  118. control_row.addLayout(btn_layout, 1)
  119. # VU Meters
  120. vu_layout = QHBoxLayout()
  121. self.vu_l = VUMeter()
  122. self.vu_r = VUMeter()
  123. vu_layout.addWidget(QLabel("L"))
  124. vu_layout.addWidget(self.vu_l)
  125. vu_layout.addWidget(self.vu_r)
  126. vu_layout.addWidget(QLabel("R"))
  127. control_row.addLayout(vu_layout)
  128. left_layout.addLayout(control_row)
  129. # --- Bottom: Spectrum Analyzer ---
  130. self.spectrum = SpectrumWidget()
  131. self.spectrum.setFixedHeight(200)
  132. left_layout.addWidget(self.spectrum)
  133. # --- Right Side: Playlist ---
  134. right_widget = QWidget()
  135. right_layout = QVBoxLayout(right_widget)
  136. self.playlist_lbl = QLabel("Playlist")
  137. self.playlist_lbl.setStyleSheet("font-weight: bold;")
  138. right_layout.addWidget(self.playlist_lbl)
  139. self.playlist_widget = QListWidget()
  140. self.playlist_widget.itemDoubleClicked.connect(self._on_playlist_double_click)
  141. right_layout.addWidget(self.playlist_widget)
  142. # Splitter
  143. splitter = QSplitter(Qt.Orientation.Horizontal)
  144. splitter.addWidget(left_widget)
  145. splitter.addWidget(right_widget)
  146. splitter.setSizes([700, 300]) # Default proportions
  147. layout = QVBoxLayout(self)
  148. layout.addWidget(splitter)
  149. def load_files(self):
  150. files, _ = QFileDialog.getOpenFileNames(
  151. self, "Open Audio Files", "", "Audio Files (*.wav *.mp3 *.opus *.flac *.ogg)"
  152. )
  153. if files:
  154. self.playlist = files
  155. self.current_track_index = 0
  156. self._update_playlist_ui()
  157. self._load_track(self.playlist[0])
  158. def load_folder(self):
  159. last_folder = self.settings.value("last_folder", "")
  160. folder_path = QFileDialog.getExistingDirectory(self, "Select Folder", last_folder)
  161. if folder_path:
  162. self.settings.setValue("last_folder", folder_path)
  163. supported = ('.wav', '.mp3', '.opus', '.flac', '.ogg', '.aiff')
  164. files = []
  165. for f in os.listdir(folder_path):
  166. if f.lower().endswith(supported):
  167. files.append(os.path.join(folder_path, f))
  168. if not files:
  169. QMessageBox.information(self, "Info", "No supported audio files found in folder.")
  170. return
  171. self.playlist = sorted(files)
  172. self.current_track_index = 0
  173. self._update_playlist_ui()
  174. self._load_track(self.playlist[self.current_track_index], auto_play=True)
  175. def refresh_folder(self):
  176. folder_path = self.settings.value("last_folder", "")
  177. if not folder_path or not os.path.isdir(folder_path):
  178. QMessageBox.information(self, "Info", "No folder currently loaded or folder no longer exists.")
  179. return
  180. supported = ('.wav', '.mp3', '.opus', '.flac', '.ogg', '.aiff')
  181. files = []
  182. for f in os.listdir(folder_path):
  183. if f.lower().endswith(supported):
  184. files.append(os.path.join(folder_path, f))
  185. if not files:
  186. QMessageBox.information(self, "Info", "No supported audio files found in folder.")
  187. return
  188. new_playlist = sorted(files)
  189. current_file = None
  190. if self.playlist and 0 <= self.current_track_index < len(self.playlist):
  191. current_file = self.playlist[self.current_track_index]
  192. self.playlist = new_playlist
  193. if current_file in self.playlist:
  194. self.current_track_index = self.playlist.index(current_file)
  195. else:
  196. self.current_track_index = -1
  197. self._update_playlist_ui()
  198. def _update_playlist_ui(self):
  199. self.playlist_widget.clear()
  200. total_duration = 0.0
  201. for f in self.playlist:
  202. self.playlist_widget.addItem(os.path.basename(f))
  203. total_duration += get_audio_duration(f)
  204. total_duration /= self.player.playback_speed
  205. dur_h = int(total_duration // 3600)
  206. dur_m = int((total_duration % 3600) // 60)
  207. dur_s = int(total_duration % 60)
  208. if dur_h > 0:
  209. time_str = f"{dur_h:02d}:{dur_m:02d}:{dur_s:02d}"
  210. else:
  211. time_str = f"{dur_m:02d}:{dur_s:02d}"
  212. self.playlist_lbl.setText(f"Playlist - Total Time: {time_str}")
  213. self.playlist_lbl.setStyleSheet("font-weight: bold; color: #4CAF50; font-size: 11pt;")
  214. if self.playlist and 0 <= self.current_track_index < len(self.playlist):
  215. self.playlist_widget.setCurrentRow(self.current_track_index)
  216. def _on_playlist_double_click(self, item):
  217. idx = self.playlist_widget.row(item)
  218. if 0 <= idx < len(self.playlist):
  219. self.current_track_index = idx
  220. self._load_track(self.playlist[self.current_track_index], auto_play=True)
  221. def _on_speed_changed(self, value):
  222. speed = value / 100.0
  223. self.speed_lbl.setText(f"Speed: {speed:.2f}x")
  224. self.player.set_speed(speed)
  225. self._update_playlist_ui()
  226. if self.player.audio_data:
  227. pos_sec = self.player.current_frame / self.player.audio_data.sample_rate
  228. self._update_time_display(pos_sec)
  229. def _on_norm_changed(self):
  230. self.settings.setValue("normalize_volume", self.norm_cb.isChecked())
  231. def _load_track(self, file_path, auto_play=False):
  232. try:
  233. should_normalize = self.norm_cb.isChecked()
  234. self.player.load_file(file_path, normalize=should_normalize)
  235. filename = os.path.basename(file_path)
  236. prefix = f"[{self.current_track_index + 1}/{len(self.playlist)}] " if self.playlist else ""
  237. self.file_lbl.setText(f"Loaded: {prefix}{filename}")
  238. audio = self.player.audio_data
  239. if audio:
  240. ch_str = "Stereo" if audio.channels == 2 else "Mono"
  241. fmt = audio.format_name or "Audio"
  242. self.format_lbl.setText(f"{audio.sample_rate} Hz | {ch_str} | {fmt}")
  243. self.waveform.load_audio(self.player.audio_data)
  244. self._update_time_display(0)
  245. self.spectrum.set_sample_rate(self.player.device_manager.sample_rate)
  246. if auto_play:
  247. try:
  248. self.player.play()
  249. self.play_btn.setText("⏸ Pause")
  250. except Exception as e:
  251. QMessageBox.warning(self, "Playback Error", f"Could not start playback. Is your output device set?\n\n{e}")
  252. except Exception as e:
  253. self.file_lbl.setText(f"Error loading file: {e}")
  254. def _on_vol_changed(self, value):
  255. self.vol_lbl.setText(f"Vol: {value}%")
  256. self.settings.setValue("volume", value)
  257. self.player.output_volume = value / 100.0
  258. def play_next_track(self):
  259. if self.playlist and self.current_track_index < len(self.playlist) - 1:
  260. self.current_track_index += 1
  261. self.playlist_widget.setCurrentRow(self.current_track_index)
  262. self._load_track(self.playlist[self.current_track_index], auto_play=True)
  263. else:
  264. self.play_btn.setText("▶ Play")
  265. def _on_stop_clicked(self):
  266. self.play_btn.setText("▶ Play")
  267. self.player.stop()
  268. def toggle_play(self):
  269. if self.player.is_playing:
  270. self.player.pause()
  271. self.play_btn.setText("▶ Play")
  272. else:
  273. if self.player.audio_data is not None:
  274. try:
  275. self.player.play()
  276. self.play_btn.setText("⏸ Pause")
  277. except Exception as e:
  278. QMessageBox.warning(self, "Playback Error", f"Could not start playback. Is your output device set?\n\n{e}")
  279. def _poll_player_state(self):
  280. """Poll the player state to update UI (thread-safe)."""
  281. # Update play button state if stopped automatically (reached end of file)
  282. if not self.player.is_playing and self.play_btn.text() == "⏸ Pause":
  283. self.play_next_track()
  284. # Update playhead
  285. if self.player.audio_data:
  286. pos_sec = self.player.current_frame / self.player.audio_data.sample_rate
  287. self.waveform.set_position(pos_sec)
  288. self._update_time_display(pos_sec)
  289. # If playing, pull the latest chunks for the spectrum.
  290. if self.player.is_playing:
  291. raw = self.player._last_raw_chunk
  292. processed = self.player._last_processed_chunk
  293. if raw is not None and processed is not None:
  294. self.spectrum.add_audio_data(raw, processed)
  295. # Consume to avoid adding the same chunk multiple times
  296. self.player._last_raw_chunk = None
  297. self.player._last_processed_chunk = None
  298. # Update VU meters
  299. l_db, r_db = self.player.vu_levels
  300. # Map dB to progress bar (-60 to 0)
  301. self.vu_l.setValue(int(max(-60, l_db)))
  302. self.vu_r.setValue(int(max(-60, r_db)))
  303. # Decay VU meters when stopped
  304. if not self.player.is_playing:
  305. self.player.vu_levels = (max(-100, l_db - 2), max(-100, r_db - 2))
  306. def _update_time_display(self, pos_sec: float):
  307. if not self.player.audio_data:
  308. return
  309. speed = self.player.playback_speed
  310. pos_sec_actual = pos_sec / speed
  311. dur_sec_actual = self.player.audio_data.duration / speed
  312. pos_m = int(pos_sec_actual // 60)
  313. pos_s = pos_sec_actual % 60
  314. dur_m = int(dur_sec_actual // 60)
  315. dur_s = dur_sec_actual % 60
  316. self.time_lbl.setText(f"{pos_m:02d}:{pos_s:04.1f} / {dur_m:02d}:{dur_s:04.1f}")