Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions src/styles/audio_lines.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
Expand All @@ -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):
Expand All @@ -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))
Expand Down Expand Up @@ -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
if is_active:
self.time += 0.04 # Velocidad del flujo
153 changes: 87 additions & 66 deletions src/styles/oscilloscope.py
Original file line number Diff line number Diff line change
@@ -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)
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
18 changes: 9 additions & 9 deletions src/styles/spectrum_bars.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/styles/waveform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
43 changes: 37 additions & 6 deletions src/ui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Comment on lines +336 to +337

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore settings dialog after window is unminimized

When the main window is minimized, changeEvent forcibly calls self.settings_dialog.hide(), but unlike the control panel there is no state tracking to restore it; because _show_settings opens this dialog with exec(), minimizing while settings are open can terminate that modal session and drop the user's in-progress edits after restore. This affects users who open Settings, adjust values, then minimize the app before applying.

Useful? React with 👍 / 👎.

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
Expand Down