diff --git a/src/styles/audio_lines.py b/src/styles/audio_lines.py index 09ab0c4..ab1c36b 100644 --- a/src/styles/audio_lines.py +++ b/src/styles/audio_lines.py @@ -16,6 +16,7 @@ def __init__(self): self.time = 0.0 self.smoothing = 0.2 # Suavizado para una fluidez cinematográfica self.prev_magnitudes = None + self.idle_energy_floor = 0.002 def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): if self.theme is None or fft_data is None: @@ -40,6 +41,9 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): else: self.prev_magnitudes = (current_mags * self.smoothing) + (self.prev_magnitudes * (1.0 - self.smoothing)) + signal_energy = float(np.mean(np.abs(waveform))) if len(waveform) else 0.0 + is_active = signal_energy > self.idle_energy_floor + # 2. Renderizado de Capas de Energía (Ribbons) for layer in range(self.num_layers): path = QPainterPath() @@ -50,7 +54,7 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): # El listón se sitúa en el centro con un ligero offset por capa center_y = self.height * 0.5 - layer_offset = (layer - (self.num_layers / 2)) * 15 + layer_offset = (layer - (self.num_layers / 2)) * 19 points = [] for i in range(num_points): @@ -60,8 +64,9 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): # - Sinusoidal constante para el "flow" # - Reacción al audio multiplicada por el peso de la capa phase = i * 0.8 + layer * 0.5 + self.time - wave = math.sin(phase) * (20 + layer * 5) - audio_react = self.prev_magnitudes[i] * (200 + layer * 50) + wave_strength = (20 + layer * 5) if is_active else 0.0 + wave = math.sin(phase) * wave_strength + audio_react = self.prev_magnitudes[i] * (260 + layer * 60) y = center_y + layer_offset + wave + audio_react points.append(QPointF(x, y)) @@ -105,4 +110,5 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): painter.setPen(core_pen) painter.drawPath(path) - self.time += 0.04 # Velocidad del flujo \ No newline at end of file + if is_active: + self.time += 0.04 # Velocidad del flujo diff --git a/src/styles/oscilloscope.py b/src/styles/oscilloscope.py index 025d194..03c38c0 100644 --- a/src/styles/oscilloscope.py +++ b/src/styles/oscilloscope.py @@ -1,108 +1,129 @@ from __future__ import annotations +import math import numpy as np -from PySide6.QtGui import QPainter, QPen, QPainterPath, QColor, QRadialGradient, QBrush +from PySide6.QtGui import ( + QPainter, + QPen, + QColor, + QRadialGradient, + QBrush, + QPolygonF, +) from PySide6.QtCore import Qt, QPointF from visualizer import BaseVisualizer + class Oscilloscope(BaseVisualizer): """Oscilloscope optimizado para máxima fluidez y rendimiento cinemático.""" - + def __init__(self): super().__init__("Oscilloscope") self.line_width = 3 self.flicker_intensity = 0.0 - self.glitch_timer = 0 - - # Variables de suavizado (Smoothing) + self.glitch_timer = 0.0 + + # Variables de suavizado self.smooth_scale = 1.0 self.smooth_flicker = 0.0 - self.interpolation_factor = 0.15 # Determina la inercia del movimiento + self.interpolation_factor = 0.18 + + # Estado para estabilidad de frame-time + self.phase = 0.0 + self.max_points = 360 + self.min_points = 180 + + def _build_polyline(self, waveform: np.ndarray, center_x: float, center_y: float) -> QPolygonF: + """Construye la polilínea principal minimizando costo por frame.""" + wf = np.nan_to_num(waveform, nan=0.0, posinf=0.0, neginf=0.0) + + # Puntos adaptativos según tamaño de waveform para evitar sobrecarga + num_points = int(np.clip(len(wf) // 6, self.min_points, self.max_points)) + indices = np.linspace(0, len(wf) - 1, num_points, dtype=np.int32) + + x_vals = wf[indices] + y_indices = (indices + len(wf) // 3) % len(wf) + y_vals = wf[y_indices] + + # Jitter determinista (sin np.random por frame -> más estable) + jitter_amount = 2.5 * self.smooth_flicker + t = self.phase + np.linspace(0.0, 6.0, num_points) + jitter_x = np.sin(t * 1.7) * jitter_amount + jitter_y = np.cos(t * 2.1) * jitter_amount + + px = center_x + x_vals * self.smooth_scale + jitter_x + py = center_y + y_vals * self.smooth_scale + jitter_y + + poly = QPolygonF() + poly.reserve(num_points) + for x, y in zip(px, py): + poly.append(QPointF(float(x), float(y))) + + return poly def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): if self.theme is None or len(waveform) < 2: return - + painter.setRenderHint(QPainter.Antialiasing, True) - - # 1. Procesamiento de Energía con Suavizado - avg_energy = np.mean(np.abs(waveform)) - - # Suavizado de la intensidad del parpadeo (flicker) - target_flicker = min(1.0, self.flicker_intensity + 0.2) if avg_energy > 0.4 else self.flicker_intensity * 0.8 + + avg_energy = float(np.mean(np.abs(waveform))) + + # Flicker suave y acotado + target_flicker = min(1.0, avg_energy * 2.4) self.smooth_flicker += (target_flicker - self.smooth_flicker) * self.interpolation_factor - + center_x = self.width / 2 center_y = self.height / 2 - # 2. Retícula de Enfoque + # Retícula ligera grid_color = QColor(self.theme.get_color(0)) - grid_color.setAlpha(40) + grid_color.setAlpha(34) painter.setPen(QPen(grid_color, 1)) - + base_r = min(self.width, self.height) - for r_factor in [0.2, 0.4, 0.6]: + for r_factor in (0.22, 0.44, 0.66): r = base_r * r_factor painter.drawEllipse(QPointF(center_x, center_y), r, r) painter.drawLine(0, int(center_y), self.width, int(center_y)) painter.drawLine(int(center_x), 0, int(center_x), self.height) - # 3. Construcción del Núcleo (Optimización NumPy) - num_points = 500 # Un poco menos de puntos para mayor fluidez - - # Suavizado de la escala dinámica para evitar saltos - target_scale = (base_r * 0.4) * (1.0 + avg_energy * 2.0) + # Escala dinámica con inercia + target_scale = (base_r * 0.34) * (1.0 + avg_energy * 1.8) self.smooth_scale += (target_scale - self.smooth_scale) * self.interpolation_factor - # Vectorización: Calculamos todos los índices y posiciones de una vez con NumPy - indices = np.linspace(0, len(waveform) - 1, num_points).astype(int) - x_vals = waveform[indices] - - y_indices = (indices + len(waveform) // 3) % len(waveform) - y_vals = waveform[y_indices] - - # Generamos el Jitter (temblor) de forma masiva - jitter_amount = 5 * self.smooth_flicker - jitters = np.random.uniform(-jitter_amount, jitter_amount, (num_points, 2)) if jitter_amount > 0.1 else np.zeros((num_points, 2)) - - # Coordenadas finales calculadas por NumPy (mucho más rápido que un bucle for) - px = center_x + x_vals * self.smooth_scale + jitters[:, 0] - py = center_y + y_vals * self.smooth_scale + jitters[:, 1] - - # Creación del Path (Aún requiere un bucle, pero sin cálculos matemáticos dentro) - path = QPainterPath() - path.moveTo(px[0], py[0]) - for i in range(1, num_points): - path.lineTo(px[i], py[i]) - - # 4. Renderizado de "Rastro de Gloria" - main_color = self.theme.get_color(0) - + polyline = self._build_polyline(waveform, center_x, center_y) + # Glow + main_color = self.theme.get_color(0) glow_color = QColor(main_color) - glow_color.setAlpha(int(60 * self.smooth_flicker + 20)) - painter.setPen(QPen(glow_color, self.line_width + 10, Qt.SolidLine, Qt.RoundCap)) - painter.drawPath(path) - + glow_color.setAlpha(int(24 + 56 * self.smooth_flicker)) + painter.setPen(QPen(glow_color, self.line_width + 8, Qt.SolidLine, Qt.RoundCap)) + painter.drawPolyline(polyline) + # Núcleo core_color = QColor(main_color) - if self.smooth_flicker > 0.6: - core_color = core_color.lighter(140) - + if self.smooth_flicker > 0.65: + core_color = core_color.lighter(135) + painter.setPen(QPen(core_color, self.line_width, Qt.SolidLine, Qt.RoundCap)) - painter.drawPath(path) - - # 5. Efectos Finales (Scanline y Viñeta) - self.glitch_timer += 2 - scanline_y = (self.glitch_timer % 100) / 100.0 * self.height - - scan_color = QColor(255, 255, 255, 25) - painter.setPen(QPen(scan_color, 2)) + painter.drawPolyline(polyline) + + # Scanline más barata + self.glitch_timer += 1.8 + scanline_y = (self.glitch_timer % 100.0) / 100.0 * self.height + painter.setPen(QPen(QColor(255, 255, 255, 20), 2)) painter.drawLine(0, int(scanline_y), self.width, int(scanline_y)) - - vignette = QRadialGradient(QPointF(center_x, center_y), self.width * 0.7) + + # Viñeta + vignette = QRadialGradient(QPointF(center_x, center_y), self.width * 0.72) vignette.setColorAt(0, Qt.transparent) - vignette.setColorAt(1, QColor(0, 0, 0, 160)) + vignette.setColorAt(1, QColor(0, 0, 0, 145)) painter.setBrush(QBrush(vignette)) painter.setPen(Qt.NoPen) - painter.drawRect(0, 0, self.width, self.height) \ No newline at end of file + painter.drawRect(0, 0, self.width, self.height) + + # Fase temporal para jitter determinista + self.phase += 0.025 + (0.03 * self.smooth_flicker) + if self.phase > math.tau: + self.phase -= math.tau diff --git a/src/styles/spectrum_bars.py b/src/styles/spectrum_bars.py index 56dc8ed..3c5a5a2 100644 --- a/src/styles/spectrum_bars.py +++ b/src/styles/spectrum_bars.py @@ -9,8 +9,8 @@ class SpectrumBars(BaseVisualizer): def __init__(self): super().__init__("Spectrum Bars") - self.num_bars = 64 - self.bar_spacing = 3 + self.num_bars = 72 + self.bar_spacing = 2 self.corner_radius = 4 # Estética moderna: bordes redondeados # Estado para suavizado (Smoothing) @@ -31,7 +31,7 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): # Layout total_width = self.width - (self.num_bars * self.bar_spacing) bar_width = total_width / self.num_bars - baseline_y = self.height * 0.82 # Elevamos un poco para la reflexión + baseline_y = self.height * 0.9 # Más presencia vertical para barras grandes n_fft = len(fft_data) @@ -80,12 +80,12 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): mag = magnitudes[i] peak = self.peaks[i] - # Altura con piso estético (mínimo 5px) - bar_height = max(5, mag * self.height * 0.9) - bar_height = min(bar_height, self.height * 0.75) - - peak_height = max(bar_height, peak * self.height * 0.9) - peak_height = min(peak_height, self.height * 0.75) + # Altura más amplia para ocupar mayor parte del lienzo + bar_height = max(8, mag * self.height * 1.1) + bar_height = min(bar_height, self.height * 0.86) + + peak_height = max(bar_height, peak * self.height * 1.15) + peak_height = min(peak_height, self.height * 0.9) x = i * (bar_width + self.bar_spacing) y_bar = baseline_y - bar_height diff --git a/src/styles/waveform.py b/src/styles/waveform.py index 59df695..12d730a 100644 --- a/src/styles/waveform.py +++ b/src/styles/waveform.py @@ -30,7 +30,7 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): num_points = 250 # Suficiente para que se vea fluido pero no pesado step = max(1, len(waveform) // num_points) center_y = self.height / 2 - amplitude = self.height * 0.4 # Usar el 40% de la altura para cada lado + amplitude = self.height * 0.46 # Mayor ocupación vertical # Creamos los paths para la parte superior e inferior (Efecto Espejo) path_top = QPainterPath() @@ -40,7 +40,7 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): for i in range(0, len(waveform), step): sample = waveform[i] # Limitador y ganancia dinámica - val = np.clip(sample * 0.4, -1.0, 1.0) + val = np.clip(sample * 0.55, -1.0, 1.0) x = (i / len(waveform)) * self.width y_offset = val * amplitude diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 65d4389..1d87e4d 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -4,7 +4,7 @@ QMainWindow, QMessageBox, ) -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QEvent from PySide6.QtGui import QAction, QKeySequence from visualizer import VisualizerWidget from audio_processor import AudioProcessor @@ -42,7 +42,8 @@ def __init__(self): self.visualizer_widget = VisualizerWidget() # Create control panel - self.control_panel = ControlPanel() + self.control_panel = ControlPanel(self) + self._restore_controls_on_normalize = False # Create settings dialog self.settings_dialog = SettingsDialog(self) @@ -315,13 +316,43 @@ def _toggle_controls(self): def _position_control_panel(self): """Position control panel on screen.""" - # Position in top-right corner - screen_geom = self.screen().geometry() - x = screen_geom.width() - self.control_panel.width() - 20 - y = 20 + # Position in top-right corner of the main window so it tracks minimize/restore + window_geo = self.frameGeometry() + x = window_geo.right() - self.control_panel.width() - 16 + y = window_geo.top() + 40 self.control_panel.move(x, y) + def changeEvent(self, event): + """Track minimize/restore state to keep auxiliary windows in sync.""" + if event.type() == QEvent.WindowStateChange: + if self.isMinimized(): + # If controls were visible, hide them while app is minimized. + self._restore_controls_on_normalize = self.control_panel.isVisible() + if self.control_panel.isVisible(): + self.control_panel.hide() + + # Keep settings dialog from lingering on top when minimizing. + if self.settings_dialog.isVisible(): + self.settings_dialog.hide() + elif self._restore_controls_on_normalize: + self._restore_controls_on_normalize = False + self._show_controls() + + super().changeEvent(event) + + def moveEvent(self, event): + """Keep control panel aligned with the main window when moving.""" + super().moveEvent(event) + if self.control_panel.isVisible(): + self._position_control_panel() + + def resizeEvent(self, event): + """Keep control panel anchored while resizing the main window.""" + super().resizeEvent(event) + if self.control_panel.isVisible(): + self._position_control_panel() + def _show_settings(self): """Show settings dialog.""" # Inject current state to prevent redundant updates