Prechádzať zdrojové kódy

Update audio player, spectrum widget, and waveform UI components; cleanup pycache trackings

Your Name 1 mesiac pred
rodič
commit
5059c8b462

+ 8 - 0
.gitignore

@@ -0,0 +1,8 @@
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+.pytest_cache/
+build/
+dist/
+*.spec

+ 2 - 2
config.json

@@ -1,5 +1,5 @@
 {
-    "sample_rate": 44100,
+    "sample_rate": 48000,
     "input_device_name": "Microphone (HD Audio Mixed capture)",
-    "output_device_name": "Remote Audio"
+    "output_device_name": "Speakers (HD Audio Speaker)"
 }

BIN
core/__pycache__/__init__.cpython-312.pyc


BIN
core/__pycache__/analyzer.cpython-312.pyc


BIN
core/__pycache__/audio_device.cpython-312.pyc


BIN
core/__pycache__/audio_player.cpython-312.pyc


BIN
core/__pycache__/audio_recorder.cpython-312.pyc


BIN
core/__pycache__/calibrator.cpython-312.pyc


BIN
core/__pycache__/eq_engine.cpython-312.pyc


BIN
core/__pycache__/file_loader.cpython-312.pyc


BIN
core/__pycache__/signal_generator.cpython-312.pyc


BIN
core/__pycache__/tape_processor.cpython-312.pyc


+ 65 - 30
core/audio_player.py

@@ -24,7 +24,7 @@ class AudioPlayer:
         self.audio_data: Optional[AudioData] = None
         self.stream: Optional[sd.OutputStream] = None
         
-        self.current_frame: int = 0
+        self.current_frame: float = 0.0
         self.is_playing: bool = False
         self.is_looping: bool = False
         
@@ -34,6 +34,12 @@ class AudioPlayer:
         
         # VU meter levels (left, right RMS in dB)
         self.vu_levels = (-100.0, -100.0)
+        
+        self.playback_speed: float = 1.0
+        self._is_restarting: bool = False
+        
+        self._last_raw_chunk: Optional[np.ndarray] = None
+        self._last_processed_chunk: Optional[np.ndarray] = None
 
     def load_file(self, file_path: str, normalize: bool = False):
         """Load an audio file and prepare for playback."""
@@ -51,7 +57,7 @@ class AudioPlayer:
             audio.channels = 2
             
         self.audio_data = audio
-        self.current_frame = 0
+        self.current_frame = 0.0
 
     def play(self):
         """Start or resume playback."""
@@ -61,14 +67,15 @@ class AudioPlayer:
         if self.is_playing:
             return
             
+        out_sr = self.device_manager.sample_rate
+        
         self.eq_engine.reset_state()
-        self.eq_engine.set_sample_rate(self.audio_data.sample_rate)
+        self.eq_engine.set_sample_rate(out_sr)
         
         self.tape_processor.reset_state()
-        self.tape_processor.set_sample_rate(self.audio_data.sample_rate)
+        self.tape_processor.set_sample_rate(out_sr)
         
         kwargs = self.device_manager.get_sd_output_kwargs()
-        kwargs["samplerate"] = self.audio_data.sample_rate
         
         self.stream = sd.OutputStream(
             callback=self._audio_callback,
@@ -81,27 +88,34 @@ class AudioPlayer:
 
     def pause(self):
         """Pause playback."""
-        if self.is_playing and self.stream:
-            self.stream.stop()
-            self.stream.close()
+        if self.stream:
+            try:
+                self.stream.stop()
+                self.stream.close()
+            except Exception as e:
+                print(f"Error closing stream: {e}")
             self.stream = None
-            self.is_playing = False
+        self.is_playing = False
 
     def stop(self):
         """Stop playback and reset position."""
         self.pause()
-        self.current_frame = 0
+        self.current_frame = 0.0
         self.vu_levels = (-100.0, -100.0)
         if self.on_position_update:
             self.on_position_update(0.0)
 
+    def set_speed(self, speed: float):
+        """Set playback speed. Stream adjusts seamlessly via interpolation."""
+        self.playback_speed = max(0.1, speed)
+
     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))
+        frame = position_sec * self.audio_data.sample_rate
+        self.current_frame = max(0.0, min(float(frame), float(self.audio_data.num_samples - 1)))
         
         if self.is_playing:
             self.eq_engine.reset_state()
@@ -116,50 +130,69 @@ class AudioPlayer:
             outdata.fill(0)
             return
 
-        chunk_size = frames
-        remaining = self.audio_data.num_samples - self.current_frame
+        # Ratio of input samples to advance per output sample
+        ratio = (self.audio_data.sample_rate / self.device_manager.sample_rate) * self.playback_speed
+        
+        # We need to produce `frames` samples
+        out_indices = np.arange(frames)
+        in_indices = self.current_frame + out_indices * ratio
+        
+        # Find which indices are valid
+        valid_mask = in_indices < (self.audio_data.num_samples - 1)
+        valid_count = int(np.sum(valid_mask))
         
-        if remaining <= 0:
+        if valid_count == 0:
             if self.is_looping:
-                self.current_frame = 0
+                self.current_frame = 0.0
                 self.eq_engine.reset_state()
                 self.tape_processor.reset_state()
-                remaining = self.audio_data.num_samples
+                in_indices = self.current_frame + out_indices * ratio
+                valid_mask = in_indices < (self.audio_data.num_samples - 1)
+                valid_count = int(np.sum(valid_mask))
             else:
                 outdata.fill(0)
                 raise sd.CallbackStop()
 
-        valid_frames = min(chunk_size, remaining)
+        # Get integer part and fractional part
+        in_idx_int = in_indices[:valid_count].astype(np.int32)
+        in_idx_frac = in_indices[:valid_count] - in_idx_int
         
-        # Read from audio data
-        chunk = self.audio_data.samples[self.current_frame:self.current_frame + valid_frames]
+        # Broadcast for channels (e.g. 2 channels)
+        in_idx_frac = in_idx_frac[:, np.newaxis].astype(np.float32)
+        
+        c0 = self.audio_data.samples[in_idx_int]
+        c1 = self.audio_data.samples[in_idx_int + 1]
+        
+        raw_chunk = c0 + (c1 - c0) * in_idx_frac
         
         # Tape Processing & EQ Chain
-        chunk = self.tape_processor.process_pre_eq(chunk)
+        chunk = self.tape_processor.process_pre_eq(raw_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
+        outdata[:valid_count] = chunk
+        
+        self._last_raw_chunk = np.copy(raw_chunk)
+        self._last_processed_chunk = np.copy(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()
+        if valid_count < frames:
+            outdata[valid_count:] = 0
             
         # Update VU meters
-        self._update_vu_levels(processed_chunk)
+        self._update_vu_levels(chunk)
             
-        self.current_frame += valid_frames
+        self.current_frame = float(in_indices[valid_count - 1] + ratio)
         
         # 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)
 
+        if valid_count < frames and not self.is_looping:
+            raise sd.CallbackStop()
+
     def _update_vu_levels(self, chunk: np.ndarray):
         """Calculate RMS levels for VU meters."""
         if chunk.size == 0:
@@ -177,6 +210,8 @@ class AudioPlayer:
 
     def _on_finished(self):
         """Called when stream stops."""
+        if self._is_restarting:
+            return
         self.is_playing = False
         if self.on_playback_stopped:
             self.on_playback_stopped()

BIN
ui/__pycache__/__init__.cpython-312.pyc


BIN
ui/__pycache__/calibration_panel.cpython-312.pyc


BIN
ui/__pycache__/device_panel.cpython-312.pyc


BIN
ui/__pycache__/eq_panel.cpython-312.pyc


BIN
ui/__pycache__/main_window.cpython-312.pyc


BIN
ui/__pycache__/player_panel.cpython-312.pyc


BIN
ui/__pycache__/spectrum_widget.cpython-312.pyc


BIN
ui/__pycache__/waveform_widget.cpython-312.pyc


+ 100 - 17
ui/player_panel.py

@@ -5,7 +5,7 @@ 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)
+                             QListWidget, QSplitter, QMessageBox, QCheckBox, QSlider)
 from PyQt6.QtCore import Qt, QTimer, QSettings
 from core.file_loader import get_audio_duration
 
@@ -57,8 +57,14 @@ class PlayerPanel(QWidget):
         
         # --- 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)
@@ -66,9 +72,13 @@ class PlayerPanel(QWidget):
         load_folder_btn = QPushButton("Load Folder...")
         load_folder_btn.clicked.connect(self.load_folder)
         
-        top_row.addWidget(self.file_lbl, 1)
+        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 ---
@@ -94,13 +104,25 @@ class PlayerPanel(QWidget):
         self.stop_btn = QPushButton("■ Stop")
         self.stop_btn.clicked.connect(self._on_stop_clicked)
         
-        self.norm_cb = QCheckBox("Normalize Volume")
+        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)
+        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)
@@ -173,6 +195,37 @@ class PlayerPanel(QWidget):
             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
@@ -181,11 +234,21 @@ class PlayerPanel(QWidget):
             self.playlist_widget.addItem(os.path.basename(f))
             total_duration += get_audio_duration(f)
             
-        dur_m = int(total_duration // 60)
+        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)
-        self.playlist_lbl.setText(f"Playlist (Total: {dur_m:02d}:{dur_s:02d})")
         
-        if self.playlist and self.current_track_index >= 0:
+        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):
@@ -194,6 +257,15 @@ class PlayerPanel(QWidget):
             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())
 
@@ -206,6 +278,12 @@ class PlayerPanel(QWidget):
             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)
@@ -256,12 +334,15 @@ class PlayerPanel(QWidget):
             self.waveform.set_position(pos_sec)
             self._update_time_display(pos_sec)
             
-            # If playing, we can pull the latest chunk from the player for the spectrum.
-            # In a real app we'd pass this via queue, but this is a simple approximation.
-            if self.player.is_playing and self.player.current_frame > 1024:
-                idx = self.player.current_frame
-                chunk = self.player.audio_data.samples[max(0, idx-1024):idx]
-                self.spectrum.add_audio_data(chunk)
+            # 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
@@ -277,12 +358,14 @@ class PlayerPanel(QWidget):
         if not self.player.audio_data:
             return
         
-        dur_sec = self.player.audio_data.duration
+        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 // 60)
-        pos_s = pos_sec % 60
+        pos_m = int(pos_sec_actual // 60)
+        pos_s = pos_sec_actual % 60
         
-        dur_m = int(dur_sec // 60)
-        dur_s = dur_sec % 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}")

+ 54 - 51
ui/spectrum_widget.py

@@ -16,10 +16,12 @@ class SpectrumWidget(QWidget):
         self.sample_rate = sample_rate
         self.update_interval_ms = update_interval_ms
         
-        # Audio buffer for FFT
+        # Audio buffers for FFT
         self.fft_size = 4096
-        self.buffer = np.zeros(self.fft_size, dtype=np.float32)
-        self.buffer_idx = 0
+        self.buffer_raw = np.zeros(self.fft_size, dtype=np.float32)
+        self.buffer_processed = np.zeros(self.fft_size, dtype=np.float32)
+        self.buffer_idx_raw = 0
+        self.buffer_idx_processed = 0
         
         # Window function for FFT (Hanning)
         self.window = np.hanning(self.fft_size)
@@ -49,8 +51,12 @@ class SpectrumWidget(QWidget):
         self.plot_widget.setXRange(np.log10(20), np.log10(20000))
         self.plot_widget.showGrid(x=True, y=True, alpha=0.3)
         
-        # Plot curve (Amber color for analog feel)
-        self.curve = self.plot_widget.plot(pen=pg.mkPen('#ff7b00', width=2))
+        from PyQt6.QtCore import Qt
+        
+        # Plot curves
+        self.curve_raw = self.plot_widget.plot(pen=pg.mkPen('#606070', width=1, style=Qt.PenStyle.DashLine), name="Actual")
+        self.curve_processed = self.plot_widget.plot(pen=pg.mkPen('#ff7b00', width=2), name="Corrected")
+        self.plot_widget.addLegend()
         
         # Prepare frequency bins
         self.freqs = np.fft.rfftfreq(self.fft_size, d=1.0/self.sample_rate)
@@ -65,62 +71,59 @@ class SpectrumWidget(QWidget):
             self.freqs = np.fft.rfftfreq(self.fft_size, d=1.0/self.sample_rate)
             self.freqs[0] = 1e-10
 
-    def add_audio_data(self, data: np.ndarray):
+    def add_audio_data(self, raw_data: np.ndarray, processed_data: np.ndarray):
         """
-        Add new audio data to the buffer.
-        Called from the audio callback thread.
+        Add new audio data to the buffers.
+        Called from the GUI thread on poll.
         """
-        if data.size == 0:
-            return
-            
-        # Convert to mono if stereo
-        if data.ndim > 1:
-            chunk = np.mean(data, axis=1)
-        else:
-            chunk = data
-            
-        n = len(chunk)
-        if n >= self.fft_size:
-            # Take the latest fft_size samples
-            self.buffer[:] = chunk[-self.fft_size:]
-            self.buffer_idx = self.fft_size
-        else:
-            # Shift buffer left and append new data
-            self.buffer[:-n] = self.buffer[n:]
-            self.buffer[-n:] = chunk
-            self.buffer_idx = min(self.buffer_idx + n, self.fft_size)
-            
+        def _add(data, buffer, idx):
+            if data.size == 0:
+                return idx
+            if data.ndim > 1:
+                chunk = np.mean(data, axis=1)
+            else:
+                chunk = data
+            n = len(chunk)
+            if n >= self.fft_size:
+                buffer[:] = chunk[-self.fft_size:]
+                return self.fft_size
+            else:
+                buffer[:-n] = buffer[n:]
+                buffer[-n:] = chunk
+                return min(idx + n, self.fft_size)
+                
+        self.buffer_idx_raw = _add(raw_data, self.buffer_raw, self.buffer_idx_raw)
+        self.buffer_idx_processed = _add(processed_data, self.buffer_processed, self.buffer_idx_processed)
         self._new_data = True
 
     def _update_plot(self):
-        """Calculate FFT and update the plot on the GUI thread."""
-        if not self._new_data or self.buffer_idx < self.fft_size // 4:
-            # Decay the plot if no new data
+        """Calculate FFT and update the plots on the GUI thread."""
+        if not self._new_data or self.buffer_idx_processed < self.fft_size // 4:
+            # Decay the plots if no new data
             if not self._new_data:
-                 current_data = self.curve.getData()[1]
-                 if current_data is not None:
-                     decayed = current_data - 2.0 # -2dB per frame decay
-                     self.curve.setData(self.freqs, decayed)
+                for curve in (self.curve_raw, self.curve_processed):
+                    current_data = curve.getData()[1]
+                    if current_data is not None:
+                        decayed = current_data - 2.0 # -2dB per frame decay
+                        curve.setData(self.freqs, decayed)
             return
             
         self._new_data = False
         
-        # Apply window and calculate FFT
-        windowed = self.buffer * self.window
-        mag = np.abs(np.fft.rfft(windowed))
-        
-        # Normalize and convert to dB
-        # Reference is amplitude 1.0 (0 dBFS)
-        mag_db = 20 * np.log10(mag / (self.fft_size / 2) + 1e-10)
-        
-        # Smooth slightly in time domain (optional, but looks better)
-        current_data = self.curve.getData()[1]
-        if current_data is not None:
-             alpha = 0.6
-             mag_db = alpha * mag_db + (1 - alpha) * current_data
-        
-        # Update curve
-        self.curve.setData(self.freqs, mag_db)
+        def _calc_fft(buffer, curve):
+            windowed = buffer * self.window
+            mag = np.abs(np.fft.rfft(windowed))
+            mag_db = 20 * np.log10(mag / (self.fft_size / 2) + 1e-10)
+            
+            current_data = curve.getData()[1]
+            if current_data is not None:
+                alpha = 0.6
+                mag_db = alpha * mag_db + (1 - alpha) * current_data
+                
+            curve.setData(self.freqs, mag_db)
+
+        _calc_fft(self.buffer_raw, self.curve_raw)
+        _calc_fft(self.buffer_processed, self.curve_processed)
 
     def closeEvent(self, event):
         self.update_timer.stop()

+ 2 - 1
ui/waveform_widget.py

@@ -97,7 +97,8 @@ class WaveformWidget(QWidget):
 
     def _on_mouse_clicked(self, event):
         """Handle click to seek."""
-        if event.button() == 1: # Left click
+        from PyQt6.QtCore import Qt
+        if event.button() == Qt.MouseButton.LeftButton:
             # Map scene coordinates to plot coordinates
             pos = self.plot_widget.plotItem.vb.mapSceneToView(event.scenePos())
             x_sec = pos.x()