From 8b5ec24f5cf3889b655f6891e3e0a6a5d93646e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCipe?= Date: Wed, 18 Feb 2026 09:31:48 -0400 Subject: [PATCH 01/13] perf: cache background rendering and expose runtime timing metrics --- docs/RECOMENDACIONES_MEJORA.md | 157 +++++++++++++++++++++++++++++++++ src/audio_processor.py | 26 ++++-- src/ui/overlay.py | 12 ++- src/visualizer.py | 34 ++++++- 4 files changed, 214 insertions(+), 15 deletions(-) create mode 100644 docs/RECOMENDACIONES_MEJORA.md diff --git a/docs/RECOMENDACIONES_MEJORA.md b/docs/RECOMENDACIONES_MEJORA.md new file mode 100644 index 0000000..8a9bc7b --- /dev/null +++ b/docs/RECOMENDACIONES_MEJORA.md @@ -0,0 +1,157 @@ +# Recomendaciones de mejora para EchoPy (UX, fluidez, diseño y refactorización) + +## Resumen ejecutivo + +EchoPy ya tiene una base sólida: arquitectura por capas, sistema de estilos extensible con `BaseVisualizer` + `VisualizerFactory`, y separación aceptable entre captura de audio y renderizado. Las mejoras con mayor impacto están en tres frentes: + +1. **Fluidez perceptual**: reducir jitter y coste por frame en el render. +2. **UI moderna y coherente**: mejorar consistencia visual, ergonomía y microinteracciones. +3. **Refactor estructural**: reducir responsabilidades en `MainWindow`/`VisualizerWidget` y aislar configuración/estado de sesión. + +--- + +## 1) Fluidez de visualización (prioridad alta) + +### 1.1 Cachear operaciones de pintura costosas +- El fondo se escala en cada `paintEvent` (`QPixmap.scaled`), lo cual es caro cuando hay resize o FPS altos. +- **Mejora**: cachear una versión escalada del fondo y regenerarla solo en `resizeEvent` o cuando cambie imagen/opacidad. +- **Impacto**: menor uso de CPU/GPU y menos stutter en estilos complejos. + +### 1.2 Reducir trabajo por frame en visualizadores con `QPainterPath` +- Algunos estilos construyen paths largos cada frame (p. ej. `Waveform`). +- **Mejora**: + - Precalcular coordenadas X (vector fijo por ancho de widget). + - Downsampling adaptativo (según FPS real, no fijo). + - Evitar recrear gradientes/brushes si no cambian tema ni tamaño. +- **Impacto**: frame-time más estable bajo audio intenso. + +### 1.3 Métricas de performance en runtime +- Ya existe `DebugOverlay`; se puede explotar para telemetría de render. +- **Mejora**: medir `frame_time_ms`, `audio_callback_time_ms`, `dropped_frames` y mostrar semáforo de salud. +- **Impacto**: permite tuning de parámetros y profiling en producción. + +### 1.4 Control de VSync/FPS más robusto +- El timer usa intervalos discretos (`1000 // fps`) y puede introducir jitter. +- **Mejora**: usar `QElapsedTimer` para delta-time estable y compensación del loop; mantener `QTimer` solo como trigger base. +- **Impacto**: animaciones más suaves, especialmente en 120Hz/144Hz. + +--- + +## 2) UI/UX más moderna y atractiva (prioridad alta) + +### 2.1 Sistema de diseño unificado (tokens) +- Hoy hay estilos embebidos en QSS y CSS inline en widgets. +- **Mejora**: definir Design Tokens centralizados (radio, spacing, elevación, colores semánticos, tipografía) y generar QSS desde esos tokens. +- **Impacto**: coherencia visual, mantenimiento más simple y theming más premium. + +### 2.2 Panel de controles contextual y no intrusivo +- El `ControlPanel` es flotante tipo tool window, útil pero algo rígido. +- **Mejora**: + - Modo dockable + modo overlay compacto. + - Auto-hide inteligente en fullscreen. + - Mini panel quick-actions (tema, estilo, sensibilidad). +- **Impacto**: mejor experiencia para uso continuo en modo visualización. + +### 2.3 Microinteracciones visuales +- **Mejora**: + - Transiciones cortas (150–250 ms) en hover, focus, toggle. + - Estados vacíos/onboarding para “sin señal” o “sin dispositivo”. + - Indicadores de nivel de entrada en tiempo real dentro de settings. +- **Impacto**: percepción de app más moderna, viva y profesional. + +### 2.4 Accesibilidad y legibilidad +- **Mejora**: + - Contraste WCAG AA para textos secundarios. + - Escala tipográfica consistente (12/14/16/20). + - Navegación por teclado más explícita (focus ring visible). +- **Impacto**: mayor usabilidad en diferentes condiciones de pantalla. + +--- + +## 3) Patrones de diseño y refactorización (prioridad media/alta) + +### 3.1 Reducir responsabilidades de `MainWindow` +- Actualmente coordina UI, audio, configuración, estado y acciones. +- **Refactor recomendado**: + - `AppController` (orquestación global) + - `SettingsService` (persistencia y validación) + - `AudioSessionService` (arranque/parada/cambio de dispositivo) +- **Impacto**: menor acoplamiento y mayor testabilidad. + +### 3.2 Separar “estado de sesión” de widgets +- El estado operativo vive disperso (config + widget + audio). +- **Mejora**: introducir `AppState` (dataclass inmutable por snapshots + events), con suscripciones desde UI. +- **Impacto**: facilita depuración, undo básico de ajustes y future-proof para plugins. + +### 3.3 Estrategia de render por estilo +- Ya existe base abstracta de visualizadores (muy bien). +- **Mejora**: interfaz opcional `prepare_frame(context)` + `render_frame(painter)` para desacoplar cálculo y dibujo. +- **Impacto**: posibilita paralelizar/precalcular en estilos pesados. + +### 3.4 Registro de estilos por plugin discovery +- La factory actual usa registro estático. +- **Mejora**: carga dinámica de estilos por entry points (o carpeta plugins), con metadata (coste estimado, tags, preview). +- **Impacto**: extensibilidad real sin tocar core. + +--- + +## 4) Calidad técnica y robustez (prioridad media) + +### 4.1 Manejo de excepciones más específico +- Hay varios `except:` genéricos. +- **Mejora**: capturar excepciones concretas y normalizar errores de audio con códigos/causas. +- **Impacto**: mejor diagnóstico y menos silencios peligrosos. + +### 4.2 Imports y empaquetado +- Se usa `sys.path.insert` en `main.py`. +- **Mejora**: ejecutar como paquete (`python -m src.main`) y migrar a imports absolutos de paquete. +- **Impacto**: despliegue más limpio y menos fragilidad por rutas. + +### 4.3 Test de rendimiento y regresión visual +- **Mejora**: + - Benchmarks de callback de audio (tiempo medio/p95). + - Snapshot tests de estilos clave con entradas sintéticas. + - Smoke tests de cambio de estilo/tema/dispositivo. +- **Impacto**: evita degradaciones al iterar en efectos visuales. + +--- + +## 5) Plan propuesto por fases + +### Fase 1 (1–2 semanas, quick wins) +1. Cache de fondo escalado + cache de gradientes por tamaño/tema. +2. Telemetría de frame-time/audio-time en overlay. +3. Limpieza de `except:` genéricos en módulos críticos. +4. Tokenizar QSS base (colores, radios, spacing). + +### Fase 2 (2–4 semanas) +1. Extraer `AppController` y `SettingsService`. +2. Introducir `AppState` y evento de cambios. +3. Mejorar panel de control (dockable + quick-actions). +4. Implementar pruebas smoke + benchmark base. + +### Fase 3 (4+ semanas) +1. Plugin discovery de visualizadores. +2. Pipeline `prepare_frame/render_frame` para estilos avanzados. +3. Galería de presets (tema + estilo + sensibilidad + FPS). + +--- + +## 6) Top 10 recomendaciones accionables (ordenadas) + +1. Cachear fondo escalado y recursos de dibujo por tamaño. +2. Introducir métricas de frame-time y callback-time en overlay. +3. Separar `MainWindow` en controller + servicios. +4. Unificar estilos con design tokens en QSS. +5. Implementar downsampling adaptativo por carga real. +6. Reemplazar imports frágiles por estructura de paquete formal. +7. Mejorar estados de UI (no signal/no device) con UX explícita. +8. Añadir tests de regresión visual y smoke tests de flujo. +9. Preparar arquitectura para plugins externos de visualizadores. +10. Crear presets y quick-actions para personalización rápida. + +--- + +## Cierre + +EchoPy ya transmite una base técnica muy buena para su categoría. Si priorizas **fluidez + coherencia visual + separación de responsabilidades**, el salto de calidad será muy visible sin reescribir toda la app. diff --git a/src/audio_processor.py b/src/audio_processor.py index de50b64..cc6de2d 100644 --- a/src/audio_processor.py +++ b/src/audio_processor.py @@ -1,5 +1,5 @@ import numpy as np -import time +import time as time_module import sounddevice as sd try: @@ -19,8 +19,8 @@ class AudioProcessor(QObject): # Signal emitted when new audio data is available audio_data_ready = Signal( - object, object, float - ) # (waveform, fft_data, activity_level) + object, object, float, float + ) # (waveform, fft_data, activity_level, callback_time_ms) def __init__( self, @@ -53,6 +53,7 @@ def __init__( # AGC State self.running_peak = 0.01 + self.last_callback_ms = 0.0 def start(self, device_index: Optional[int] = None, use_loopback: bool = True): """ @@ -281,7 +282,7 @@ def _find_mme_version(self, target_name: str) -> Optional[int]: ): logger.info(f"Found MME fallback device: {d['name']} (index {i})") return i - except: + except Exception: pass return None @@ -296,14 +297,14 @@ def stop(self): self.stream.stop() if hasattr(self.stream, "close"): self.stream.close() - except: + except Exception: pass self.stream = None if self.pa_instance: try: self.pa_instance.terminate() - except: + except Exception: pass self.pa_instance = None @@ -312,6 +313,7 @@ def stop(self): def _audio_callback(self, indata, frames, time, status): """Optimized audio stream callback.""" + callback_start = time_module.perf_counter() try: if status: # Log status but don't stop processing unless fatal @@ -385,7 +387,13 @@ def _audio_callback(self, indata, frames, time, status): # 4. Smoothing and Delivery self.fft_data = self.smoother.update(fft_magnitude) - self.audio_data_ready.emit(self.audio_buffer, self.fft_data, activity_level) + self.last_callback_ms = (time_module.perf_counter() - callback_start) * 1000.0 + self.audio_data_ready.emit( + self.audio_buffer, + self.fft_data, + activity_level, + self.last_callback_ms, + ) except Exception as e: logger.error(f"CALLBACK FATAL ERROR: {e}") @@ -533,7 +541,7 @@ def set_device(self, device_index: Optional[int]): if was_running: self.stop() # Allow driver resources to release (critical for WASAPI Loopback) - time.sleep(0.2) + time_module.sleep(0.2) # Try to restart with new device if was_running: @@ -542,5 +550,5 @@ def set_device(self, device_index: Optional[int]): # Robustness check: If failed, try fallback to auto-detection if not self.is_running: logger.warning("Device switch failed, attempting fallback restart...") - time.sleep(0.2) + time_module.sleep(0.2) self.start() # Try default/auto strategy diff --git a/src/ui/overlay.py b/src/ui/overlay.py index 24bf5da..efb44e7 100644 --- a/src/ui/overlay.py +++ b/src/ui/overlay.py @@ -37,7 +37,9 @@ def increment_frame(self): def render(self, painter: QPainter, width: int, height: int, visualizer: Optional['BaseVisualizer'], - audio_peak: float, is_silent: bool = False): + audio_peak: float, is_silent: bool = False, + frame_time_ms: float = 0.0, + callback_time_ms: float = 0.0): """Render the debug overlay.""" if not self.visible: return @@ -47,7 +49,7 @@ def render(self, painter: QPainter, width: int, height: int, painter.setPen(QPen(QColor(255, 255, 255, 180))) # Semi-transparent white # Draw background for text to ensure readability - bg_height = 80 if visualizer else 60 + bg_height = 120 if visualizer else 100 painter.fillRect(5, 5, 200, bg_height, QColor(0, 0, 0, 100)) y_offset = 25 @@ -60,5 +62,11 @@ def render(self, painter: QPainter, width: int, height: int, y_offset += 20 status = "SILENCE" if is_silent else "ACTIVE" painter.drawText(15, y_offset, f"Peak: {audio_peak:.4f} [{status}]") + + y_offset += 20 + painter.drawText(15, y_offset, f"Frame: {frame_time_ms:.2f} ms") + + y_offset += 20 + painter.drawText(15, y_offset, f"Audio CB: {callback_time_ms:.2f} ms") painter.restore() diff --git a/src/visualizer.py b/src/visualizer.py index 5a9194e..4dc3a6e 100644 --- a/src/visualizer.py +++ b/src/visualizer.py @@ -1,5 +1,6 @@ from __future__ import annotations import numpy as np +import time from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: @@ -86,6 +87,12 @@ def __init__(self, parent=None): # Background image self.background_image: Optional[QPixmap] = None self.background_opacity = 0.3 + self._cached_background: Optional[QPixmap] = None + self._cached_bg_size = None + + # Performance diagnostics + self.last_frame_time_ms = 0.0 + self.last_audio_callback_ms = 0.0 # Debug / Overlay self.debug_overlay = DebugOverlay(self) @@ -111,10 +118,15 @@ def set_theme(self, theme: ColorTheme): def set_background_image(self, pixmap: Optional[QPixmap]): self.background_image = pixmap + self._invalidate_background_cache() def set_background_opacity(self, opacity: float): self.background_opacity = max(0.0, min(1.0, opacity)) + def _invalidate_background_cache(self): + self._cached_background = None + self._cached_bg_size = None + def set_show_fps(self, show: bool): self.debug_overlay.visible = show self.update() @@ -129,13 +141,18 @@ def set_sensitivity(self, threshold_on: float, threshold_off: float, timeout: in ) def update_audio_data( - self, waveform: np.ndarray, fft_data: np.ndarray, activity_signal: float + self, + waveform: np.ndarray, + fft_data: np.ndarray, + activity_signal: float, + callback_time_ms: float = 0.0, ): """ Update audio data with RMS Temporal Hysteresis. activity_signal is the RMS of the 'cleaned' audio. """ self._update_activity_metrics(activity_signal, waveform) + self.last_audio_callback_ms = max(0.0, callback_time_ms) self._update_state_machine() self._update_internal_buffers(waveform, fft_data) self._handle_debug_logging(waveform) @@ -208,6 +225,7 @@ def _handle_debug_logging(self, waveform: np.ndarray): def paintEvent(self, event): """Paint event handler.""" + frame_start = time.perf_counter() painter = QPainter(self) try: # Disable Antialiasing for performance (Waveform line is too complex) @@ -233,12 +251,15 @@ def paintEvent(self, event): self.visualizer, self.current_max_peak, self.is_silent, + frame_time_ms=self.last_frame_time_ms, + callback_time_ms=self.last_audio_callback_ms, ) # Update frame count in overlay self.debug_overlay.increment_frame() finally: painter.end() + self.last_frame_time_ms = (time.perf_counter() - frame_start) * 1000.0 def _draw_background(self, painter: QPainter): """Draw widget background and image.""" @@ -248,9 +269,13 @@ def _draw_background(self, painter: QPainter): painter.save() painter.setOpacity(self.background_opacity) - scaled = self.background_image.scaled( - self.size(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation - ) + if self._cached_background is None or self._cached_bg_size != self.size(): + self._cached_background = self.background_image.scaled( + self.size(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation + ) + self._cached_bg_size = self.size() + + scaled = self._cached_background x = (self.width() - scaled.width()) // 2 y = (self.height() - scaled.height()) // 2 @@ -265,6 +290,7 @@ def _draw_no_visualizer_message(self, painter: QPainter): def resizeEvent(self, event): """Handle resize events.""" super().resizeEvent(event) + self._invalidate_background_cache() if self.visualizer: self.visualizer.set_size(self.width(), self.height()) From c37c64d69b0a7ea60ee494680164b80ccd0c965d Mon Sep 17 00:00:00 2001 From: userlg Date: Fri, 20 Feb 2026 09:45:00 -0400 Subject: [PATCH 02/13] style(visualizer): potenciar escala y fluidez de Sound Wave 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expandido el ancho al 98% de la pantalla. - Elevada drásticamente la potencia (x1.5) y sensibilidad (.65) de la gráfica. - Cambiado el suavizado de ataque a casi instantáneo y caída 3x más lenta para sensación líquida. --- resources/modern.qss | 46 ++++++------ src/styles/circular.py | 55 +++++++++----- src/styles/sound_wave_2.py | 146 ++++++++++++++++++++++++++++++++++++ src/styles/spectrum_bars.py | 122 +++++++++++++++++++++--------- src/themes.py | 73 +++++++++--------- src/ui/controls.py | 5 +- src/utils.py | 141 ++++++++++++++++++---------------- src/visualizer_factory.py | 2 + 8 files changed, 413 insertions(+), 177 deletions(-) create mode 100644 src/styles/sound_wave_2.py diff --git a/resources/modern.qss b/resources/modern.qss index 552d301..f1686ec 100644 --- a/resources/modern.qss +++ b/resources/modern.qss @@ -6,9 +6,9 @@ QMainWindow { /* Glassmorphism effect for panels and dialogs */ QWidget#ControlPanel, QDialog { - background-color: #121212; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 16px; + background-color: rgba(18, 18, 18, 0.85); /* Mayor transparencia */ + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 20px; /* Bordes más redondeados */ } QLabel { @@ -29,8 +29,8 @@ QPushButton { QPushButton:hover { background-color: #2A2A2A; - border-color: #6C5CE7; /* Indigo Accent */ - color: #FFFFFF; + border-color: #7A5FFF; /* Indigo Accent */ + color: #00E5FF; /* Cyan resaltado */ } QPushButton:pressed { @@ -38,22 +38,24 @@ QPushButton:pressed { } QPushButton:checked { - background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #6C5CE7, stop:1 #00CEC9); - color: #FFFFFF; + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #7A5FFF, stop:1 #00E5FF); + color: #000000; border: none; + font-weight: bold; } QComboBox, QSpinBox { - background-color: #1E1E1E; - border: 1px solid #333; - border-radius: 6px; + background-color: rgba(30, 30, 30, 0.7); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; padding: 6px 12px; color: #E0E0E0; - selection-background-color: #6C5CE7; + selection-background-color: #7A5FFF; } QComboBox:hover, QSpinBox:hover { - border-color: #00CEC9; + border-color: #00E5FF; + background-color: rgba(40, 40, 40, 0.9); } QComboBox::drop-down { @@ -79,12 +81,12 @@ QSlider::groove:horizontal { } QSlider::handle:horizontal { - background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #6C5CE7, stop:1 #00CEC9); - border: 1px solid rgba(255,255,255,0.2); - width: 16px; - height: 16px; - margin: -6px 0; - border-radius: 8px; + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #7A5FFF, stop:1 #00E5FF); + border: 1px solid rgba(255,255,255,0.4); + width: 18px; + height: 18px; + margin: -7px 0; + border-radius: 9px; } QSlider::handle:horizontal:hover { @@ -93,12 +95,12 @@ QSlider::handle:horizontal:hover { } QGroupBox { - border: 1px solid rgba(255, 255, 255, 0.05); - border-radius: 12px; - margin-top: 20px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + margin-top: 22px; padding-top: 15px; font-weight: bold; - color: #00CEC9; /* Cyan Accent Title */ + color: #00E5FF; /* Cyan Accent Title */ } QGroupBox::title { diff --git a/src/styles/circular.py b/src/styles/circular.py index 81c86fb..e53afd5 100644 --- a/src/styles/circular.py +++ b/src/styles/circular.py @@ -16,18 +16,18 @@ def __init__(self): self.min_radius = 80 self.bar_width = 3 self.smoothed_bass = 0.0 - # Estado para la fluidez (Smoothing) self.prev_bar_lengths = np.zeros(self.num_bands) - self.smoothing_factor = 0.2 - + self.smoothing_factor = 0.12 # Más bajo = movimiento más líquido y conectado + # --- NUEVO: Estado para la animación de reposo de las barras superiores --- self.idle_phase = 0.0 # Definimos cuántas barras cerca de la cima (índice 0) se verán afectadas - self.top_bars_count = 10 - + self.top_bars_count = 10 + # Effects State self.shockwaves = [] + self.rotation_angle = 0.0 # Ligera rotación ambiental def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): """Renderizado con fluidez de acero y rotación eliminada.""" @@ -39,6 +39,7 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): # --- NUEVO: Actualizar fase de animación de reposo --- # Esto crea un movimiento ondulatorio lento y constante self.idle_phase += 0.04 + self.rotation_angle += 0.05 # Rotación ambiental muy lenta # Layout center_x = self.width / 2 @@ -69,13 +70,15 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): boost = np.ones(self.num_bands, dtype=np.float64) # Aumentamos ligeramente el boost inicial de los graves - boost[:bass_end] = 1.8 + boost[:bass_end] = 1.8 boost[bass_end:mids_end] = 3.5 boost[mids_end:] = 5.5 magnitudes = magnitudes * boost # Usamos una curva de potencia ligeramente más agresiva para los graves - magnitudes[:bass_end] = np.power(np.clip(magnitudes[:bass_end], 0.0, None), 0.45) + magnitudes[:bass_end] = np.power( + np.clip(magnitudes[:bass_end], 0.0, None), 0.45 + ) magnitudes[bass_end:] = np.power(np.clip(magnitudes[bass_end:], 0.0, None), 0.5) # ──────────────── NUEVO: Micro-Dinámica para Barras Superiores ──────────────── @@ -84,10 +87,10 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): # 1. Crear una oscilación suave basada en el tiempo y el índice (para que no se muevan igual) # El seno genera un valor entre -1 y 1, lo ajustamos a 0.0 - 1.0 oscillation = (math.sin(self.idle_phase + i * 0.5) * 0.5) + 0.5 - + # 2. Definir la "magnitud de reposo" que queremos añadir (aprox. 15% del tamaño máximo) idle_magnitude = oscillation * 0.15 - + # 3. Mezcla inteligente: Si la magnitud real es baja (< 0.25), aplicamos la oscilación. # Si el audio real sube, la oscilación desaparece para no ensuciar la señal real. threshold = 0.25 @@ -99,9 +102,11 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): # ──────────────── Suavizado de Barras (Fluidity) ──────────────── target_lengths = magnitudes * bar_zone * 45.0 # El suavizado se aplica DESPUÉS de la micro-dinámica para que el movimiento sea elegante - current_bar_lengths = (target_lengths * self.smoothing_factor) + (self.prev_bar_lengths * (1.0 - self.smoothing_factor)) + current_bar_lengths = (target_lengths * self.smoothing_factor) + ( + self.prev_bar_lengths * (1.0 - self.smoothing_factor) + ) self.prev_bar_lengths = current_bar_lengths - + bar_lengths = np.clip(current_bar_lengths, 3.0, bar_zone) # ──────────────── Energía de Impacto (Shockwaves) ──────────────── @@ -133,7 +138,7 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): new_shockwaves.append([r, opacity]) self.shockwaves = new_shockwaves - # ──────────────── Renderizado de Barras (Estáticas) ──────────────── + # ──────────────── Renderizado de Barras (Dinámicas) ──────────────── half_sweep = 180.0 angle_step = half_sweep / self.num_bands @@ -148,7 +153,9 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): angle_left = 360.0 - angle_right for angle_deg in (angle_right, angle_left): - angle_rad = math.radians(angle_deg - 90) # 0° es la parte superior + angle_rad = math.radians( + angle_deg - 90 + self.rotation_angle + ) # Aplicando rotación suave cos_a = math.cos(angle_rad) sin_a = math.sin(angle_rad) @@ -160,22 +167,32 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): start_pt = QPointF(sx, sy) end_pt = QPointF(ex, ey) - # Glow halo + # Glow halo interno e intenso glow_color = QColor(color) - glow_color.setAlpha(50) + glow_intensity = min(255, max(30, int(mag * 2.5))) + glow_color.setAlpha(glow_intensity) glow_pen = QPen(glow_color) - glow_pen.setWidthF(w * 3.0) + glow_pen.setWidthF(w * 4.0) glow_pen.setCapStyle(Qt.RoundCap) painter.setPen(glow_pen) painter.drawLine(start_pt, end_pt) - # Core bar - pen = QPen(color) + # Core bar brillante + core_color = color.lighter(130) if mag > 15 else color + pen = QPen(core_color) pen.setWidthF(w) pen.setCapStyle(Qt.RoundCap) painter.setPen(pen) painter.drawLine(start_pt, end_pt) + # Punta iluminada (Dot) al final de la barra + if mag > 10: + dot_color = color.lighter(200) + dot_color.setAlpha(200) + painter.setPen(Qt.NoPen) + painter.setBrush(QBrush(dot_color)) + painter.drawEllipse(end_pt, w * 0.8, w * 0.8) + # ──────────────── Centro Reactivo ──────────────── center_r = current_inner_r - 8 core_color = self.theme.get_color(0) @@ -205,4 +222,4 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): painter.setBrush(Qt.NoBrush) painter.drawEllipse( QPointF(center_x, center_y), current_inner_r, current_inner_r - ) \ No newline at end of file + ) diff --git a/src/styles/sound_wave_2.py b/src/styles/sound_wave_2.py new file mode 100644 index 0000000..329d3f5 --- /dev/null +++ b/src/styles/sound_wave_2.py @@ -0,0 +1,146 @@ +from __future__ import annotations +import numpy as np +from PySide6.QtGui import QPainter, QBrush, QColor, QLinearGradient +from PySide6.QtCore import Qt, QRectF +from visualizer import BaseVisualizer + + +class SoundWave2(BaseVisualizer): + """ + Un visualizador moderno y estilizado basado en barras simétricas sólidas, + con puntas redondeadas y un rebaje suave del centro a los bordes. + Idóneo para fondos minimalistas y amplios. + """ + + def __init__(self): + super().__init__("Sound Wave 2") + self.bar_count = 120 # Número total de barras en la onda + self.bar_gap_ratio = ( + 0.4 # Proporción del ancho que toma el espacio respecto a la barra + ) + self.smoothed_magnitudes = np.zeros(self.bar_count // 2, dtype=np.float64) + + def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): + if self.theme is None or fft_data is None: + return + + painter.setRenderHint(QPainter.Antialiasing, True) + + center_x = self.width / 2 + center_y = self.height / 2 + + # Expansión al 98% de la pantalla para inmersión total + usable_width = self.width * 0.98 + + # Calcular ancho individual con margen interno + total_bar_space = usable_width / self.bar_count + bar_width = total_bar_space * (1 - self.bar_gap_ratio) + bar_width = max(3.0, bar_width) # Ancho mínimo más grueso para mayor modernidad + + # Rango útil del FFT: ignorar las frecuencias de agudos inaudibles (reducimos al 30%) + n_fft = len(fft_data) + effective_fft = int(n_fft * 0.3) + + half_bars = self.bar_count // 2 + + # Mantenemos el array suavizado en sincronía con el tamaño si eventualmente cambia + if len(self.smoothed_magnitudes) != half_bars: + self.smoothed_magnitudes = np.zeros(half_bars, dtype=np.float64) + + # Creamos posiciones de lectura logarítmicas para extraer graves al principio y medios-agudos al final + log_indices = np.logspace( + np.log10(1), np.log10(max(1, effective_fft - 1)), half_bars + ).astype(int) + + raw_magnitudes = np.zeros(half_bars, dtype=np.float64) + for i in range(half_bars): + lo = log_indices[i] + hi = max(lo + 1, min(effective_fft, log_indices[i] + 2)) + raw_magnitudes[i] = np.mean(fft_data[lo:hi]) if lo < effective_fft else 0.0 + + # Suavizado asimétrico por bin (Sube rápido, baja líquido y lento) + for i in range(half_bars): + target = raw_magnitudes[i] + current = self.smoothed_magnitudes[i] + if target > current: + self.smoothed_magnitudes[i] += ( + target - current + ) * 0.85 # Ataque casi instantáneo + else: + self.smoothed_magnitudes[i] += ( + target - current + ) * 0.08 # Caída muy sedosa y líquida + + # Multiplicador de escala global masivo para dominar la pantalla + scale_factor = self.height * 1.5 + + # Atenuación gaussiana forzosa desde el centro hacia los bordes + x_lin = np.linspace(0, 1, half_bars) + # Forma de campana muy ancha, cayendo solo levemente en las puntas extremas + envelope = np.exp(-1.2 * (x_lin) ** 2) + + # Aplicar el multiplicador global + magnitudes = self.smoothed_magnitudes * scale_factor * envelope + + # Super boost a las magnitudes, elevación a la potencia de 0.65 hace que las frecuencias + # débiles resalten mucho más, dándole armonía constante al espectro vacío. + magnitudes = np.clip(np.power(magnitudes, 0.65) * 45.0, 8.0, self.height * 0.95) + + # Construcción de la onda espejo completa + # Lado izquierdo (frecuencias altas cayendo en el borde -> graves al medio) + left_side = magnitudes[::-1] + # Lado derecho (graves al medio -> frecuencias altas borde derecho) + right_side = magnitudes + + full_wave = np.concatenate((left_side, right_side)) + + # Posicionamiento inicial en X para centrar + start_x = center_x - (usable_width / 2) + + # Determinamos el color predominante + base_color = self.theme.get_color(1) + + # Gradiente base hiper-brillante característico + brush_gradient = QLinearGradient( + 0, center_y - (self.height / 2.5), 0, center_y + (self.height / 2.5) + ) + fill_c = QColor(255, 255, 255, 255) + border_c = QColor(255, 255, 255, 120) + + brush_gradient.setColorAt(0.0, border_c) + brush_gradient.setColorAt(0.2, fill_c) + brush_gradient.setColorAt(0.5, QColor(255, 255, 255, 255)) + brush_gradient.setColorAt(0.8, fill_c) + brush_gradient.setColorAt(1.0, border_c) + + painter.setPen(Qt.NoPen) + painter.setBrush(QBrush(brush_gradient)) + + for i in range(self.bar_count): + h = full_wave[i] + + # Dibujo de la barra + # Se centra horizontalmente según el iterador y verticalmente (`center_y - h/2`) + bx = ( + start_x + + (i * total_bar_space) + + (total_bar_space * self.bar_gap_ratio / 2) + ) + by = center_y - (h / 2) + + # Puntas ultra redondeadas calculadas en base al ancho + radius = bar_width / 2 + + painter.drawRoundedRect(QRectF(bx, by, bar_width, h), radius, radius) + + # Sutil Glow exterior blanco detrás de las frecuencias potentes (centro) + if h > 30: + glow_color = QColor(base_color) + glow_intensity = min(60, int((h / self.height) * 150)) + glow_color.setAlpha(glow_intensity) + painter.setBrush(QBrush(glow_color)) + painter.drawRoundedRect( + QRectF(bx - 2, by - 2, bar_width + 4, h + 4), radius + 2, radius + 2 + ) + # Restaurar brush normal para el núcleo + painter.setBrush(QBrush(brush_gradient)) diff --git a/src/styles/spectrum_bars.py b/src/styles/spectrum_bars.py index 56dc8ed..ef2fb03 100644 --- a/src/styles/spectrum_bars.py +++ b/src/styles/spectrum_bars.py @@ -1,9 +1,10 @@ from __future__ import annotations import numpy as np -from PySide6.QtGui import QPainter, QPen, QBrush, QColor, QLinearGradient +from PySide6.QtGui import QPainter, QBrush, QColor, QLinearGradient from PySide6.QtCore import Qt, QPointF, QRectF from visualizer import BaseVisualizer + class SpectrumBars(BaseVisualizer): """Spectrum analyzer with centered-out harmonious bars for high-impact visuals.""" @@ -12,15 +13,15 @@ def __init__(self): self.num_bars = 64 self.bar_spacing = 3 self.corner_radius = 4 # Estética moderna: bordes redondeados - + # Estado para suavizado (Smoothing) self.prev_magnitudes = np.zeros(self.num_bars) - self.smoothing_factor = 0.25 # Menos es más fluido, más es más reactivo + self.smoothing_factor = 0.18 # Menos valor = más fluido, menos "jitter" # Peak hold state self.peaks = np.zeros(self.num_bars, dtype=np.float64) - self.peak_decay = 0.92 - self.peak_gravity = 0.005 + self.peak_decay = 0.94 # Caída más suave y controlada + self.peak_gravity = 0.003 def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): if self.theme is None: @@ -37,7 +38,7 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): # ──────────── Frequency Binning (Vocal Focus) ──────────── # Nos enfocamos en el rango de 20Hz a ~5000Hz para máxima respuesta - useful_bins = int(n_fft * 0.20) + useful_bins = int(n_fft * 0.20) log_indices = np.logspace( np.log10(2), np.log10(useful_bins), self.num_bars + 1 ).astype(int) @@ -46,13 +47,15 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): for i in range(self.num_bars): lo = log_indices[i] hi = max(lo + 1, log_indices[i + 1]) - raw_magnitudes[i] = np.mean(fft_data[lo : min(hi, n_fft)]) if lo < n_fft else 0.0 + raw_magnitudes[i] = ( + np.mean(fft_data[lo : min(hi, n_fft)]) if lo < n_fft else 0.0 + ) # ──────────── Distribución Simétrica (Center-Out) ──────────── # Mapeamos los graves al centro y los agudos a los extremos magnitudes = np.empty(self.num_bars) half = self.num_bars // 2 - + for i in range(self.num_bars): # Calcula la distancia al centro para traer los graves al medio dist_from_center = abs(i - half) @@ -63,17 +66,21 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): # Boost dinámico: Agudos (ahora en los bordes) necesitan más ganancia visual edge_boost = np.linspace(2.5, 1.0, half) boost = np.concatenate([edge_boost, edge_boost[::-1]]) - + magnitudes *= boost # Curva de potencia agresiva para que "floten" magnitudes = np.power(np.clip(magnitudes, 0.0, None), 0.5) - + # Suavizado temporal (Interpolación) - magnitudes = (magnitudes * self.smoothing_factor) + (self.prev_magnitudes * (1.0 - self.smoothing_factor)) + magnitudes = (magnitudes * self.smoothing_factor) + ( + self.prev_magnitudes * (1.0 - self.smoothing_factor) + ) self.prev_magnitudes = magnitudes # Actualizar Picos - self.peaks = np.maximum(self.peaks * self.peak_decay - self.peak_gravity, magnitudes) + self.peaks = np.maximum( + self.peaks * self.peak_decay - self.peak_gravity, magnitudes + ) # ──────────── Renderizado de Barras ──────────── for i in range(self.num_bars): @@ -83,7 +90,7 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): # 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) @@ -92,40 +99,85 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): y_peak = baseline_y - peak_height # Color dinámico basado en la posición (Armonía visual) - color_pos = abs(i - half) / half # 0 en el centro, 1 en los bordes + color_pos = abs(i - half) / half # 0 en el centro, 1 en los bordes color = self.theme.get_gradient_color(color_pos) - # 1. Reflexión Elegante - reflection_h = bar_height * 0.35 - ref_grad = QLinearGradient(QPointF(x, baseline_y), QPointF(x, baseline_y + reflection_h)) + # 1. Glow Base Trasero (Luminiscencia suave) + if mag > 0.05: + base_glow = QColor(color) + base_glow.setAlpha(35) + painter.setBrush(QBrush(base_glow)) + painter.drawRoundedRect( + QRectF(x - 2, y_bar - 2, bar_width + 4, bar_height + 4), + self.corner_radius + 2, + self.corner_radius + 2, + ) + + # 2. Reflexión Elegante + reflection_h = bar_height * 0.4 + ref_grad = QLinearGradient( + QPointF(x, baseline_y), QPointF(x, baseline_y + reflection_h) + ) ref_col = QColor(color) - ref_col.setAlpha(60) + ref_col.setAlpha(80) # Reflexión más notoria en la base ref_grad.setColorAt(0.0, ref_col) ref_grad.setColorAt(1.0, Qt.transparent) - + painter.setPen(Qt.NoPen) painter.setBrush(QBrush(ref_grad)) - painter.drawRoundedRect(QRectF(x, baseline_y + 2, bar_width, reflection_h), self.corner_radius, self.corner_radius) + painter.drawRoundedRect( + QRectF(x, baseline_y + 2, bar_width, reflection_h), + self.corner_radius, + self.corner_radius, + ) - # 2. Barra Principal con Degradado de Potencia + # 3. Barra Principal con Degradado de Potencia bar_grad = QLinearGradient(QPointF(x, baseline_y), QPointF(x, y_bar)) - bar_grad.setColorAt(0.0, color.darker(150)) - bar_grad.setColorAt(0.4, color) - bar_grad.setColorAt(1.0, color.lighter(140)) + # Oscura en la base, brillante en la cima + base_color = color.darker(180) + base_color.setAlpha(200) + bar_grad.setColorAt(0.0, base_color) + bar_grad.setColorAt(0.5, color) + bar_grad.setColorAt(1.0, color.lighter(130)) painter.setBrush(QBrush(bar_grad)) - painter.drawRoundedRect(QRectF(x, y_bar, bar_width, bar_height), self.corner_radius, self.corner_radius) - - # 3. Glow Cap (Brillo en la punta) - if mag > 0.1: + painter.drawRoundedRect( + QRectF(x, y_bar, bar_width, bar_height), + self.corner_radius, + self.corner_radius, + ) + + # 4. Glow Cap (Brillo Intenso en la punta) + if mag > 0.05: + cap_intensity = int(100 + (mag * 155)) glow_col = QColor(color.lighter(160)) - glow_col.setAlpha(120) + glow_col.setAlpha(min(255, cap_intensity)) painter.setBrush(QBrush(glow_col)) - painter.drawRoundedRect(QRectF(x, y_bar, bar_width, 6), self.corner_radius, self.corner_radius) - - # 4. Peak Dot (Puntos flotantes) + # Hacemos que la "tapa" ocluya sutilmente la barra final para un highlight vibrante + painter.drawRoundedRect( + QRectF(x, y_bar - 1, bar_width, 6 + (mag * 4)), + self.corner_radius, + self.corner_radius, + ) + + # 5. Peak Dot (Puntos flotantes estilo chispa) if peak_height > bar_height + 4: - p_col = QColor(color.lighter(180)) - p_col.setAlpha(int(255 * (peak_height / self.height))) + # Color del pico más incandescente + p_col = ( + QColor(255, 255, 255) if peak > 0.85 else QColor(color.lighter(200)) + ) + alpha_val = int( + min(255, max(50, 255 * (peak_height / self.height * 1.5))) + ) + p_col.setAlpha(alpha_val) + painter.setBrush(QBrush(p_col)) + # Dibujar un pequeño "halo" al pico simulando que emite luz + halo_col = QColor(color) + halo_col.setAlpha(int(alpha_val * 0.4)) + painter.drawRoundedRect( + QRectF(x - 1, y_peak - 4, bar_width + 2, 5), 2, 2 + ) + + # Pico central puro painter.setBrush(QBrush(p_col)) - painter.drawRoundedRect(QRectF(x, y_peak - 3, bar_width, 3), 1, 1) \ No newline at end of file + painter.drawRoundedRect(QRectF(x, y_peak - 3, bar_width, 3), 1, 1) diff --git a/src/themes.py b/src/themes.py index 1cd79c2..df0b2b6 100644 --- a/src/themes.py +++ b/src/themes.py @@ -57,8 +57,9 @@ def get_gradient_color(self, value: float) -> QColor: r = int(c1.red() + (c2.red() - c1.red()) * t) g = int(c1.green() + (c2.green() - c1.green()) * t) b = int(c1.blue() + (c2.blue() - c1.blue()) * t) + a = int(c1.alpha() + (c2.alpha() - c1.alpha()) * t) - return QColor(r, g, b) + return QColor(r, g, b, a) def create_gradient(self, start: QPointF, end: QPointF) -> QLinearGradient: """Create a QLinearGradient from this theme.""" @@ -72,97 +73,97 @@ def create_gradient(self, start: QPointF, end: QPointF) -> QLinearGradient: return gradient -# Define 10 predefined themes +# Define 10 predefined themes (Modernized & Vibrantly adjusted) THEMES: Dict[str, ColorTheme] = { "modern": ColorTheme( name="Modern", - colors=["#6C5CE7", "#A29BFE", "#00CEC9", "#81ECEC"], # Indigo to Cyan - bg_color="#050505", - text_color="#FFFFFF" + colors=["#7A5FFF", "#00E5FF", "#00FFC2"], # Deep Purple to Bright Neon Cyan/Mint + bg_color="#05050A", + text_color="#F0F8FF" ), "cyberpunk": ColorTheme( name="Cyberpunk", - colors=["#F72585", "#7209B7", "#3A0CA3", "#4361EE", "#4CC9F0"], # Neon Palette - bg_color="#0D0221", - text_color="#4CC9F0" + colors=["#FF007F", "#B900FF", "#00F0FF", "#00FF66"], # Vibrant Pink, Violet, Bright Cyan, Neon Green + bg_color="#0A0214", + text_color="#00F0FF" ), "aurora": ColorTheme( name="Aurora", - colors=["#00ff87", "#60efff", "#0061ff", "#60efff"], # Green/Blue/Purple - bg_color="#000428", - text_color="#60efff" + colors=["#00FF87", "#00FFFF", "#0055FF", "#7B2CBF"], # Sharp Green to Deep Royal Blue-Purple + bg_color="#020815", + text_color="#00FFFF" ), "aesthetic": ColorTheme( name="Aesthetic", - colors=["#FFB3D9", "#C9A0DC", "#B19CD9", "#A8E6CF"], - bg_color="#FFF5F7", - text_color="#5A5A5A" + colors=["#FF99C8", "#D9A8FF", "#A9C1FF", "#9EEBCB"], # Richer pastels + bg_color="#FAF5F8", + text_color="#333333" ), "classic": ColorTheme( name="Classic", - colors=["#00FF00", "#00DD00", "#00BB00", "#009900"], + colors=["#1EFF00", "#18CC00", "#109900"], bg_color="#000000", - text_color="#00FF00" + text_color="#1EFF00" ), "fire": ColorTheme( name="Fire", - colors=["#FF0000", "#FF4400", "#FF8800", "#FFAA00", "#FFFF00"], - bg_color="#1A0000", - text_color="#FFAA00" + colors=["#FF1100", "#FF4500", "#FF8C00", "#FFD700", "#FFFF33"], # More dynamic fire curve + bg_color="#0F0000", + text_color="#FF8C00" ), "ocean": ColorTheme( name="Ocean", - colors=["#003366", "#005588", "#0099CC", "#00BBEE", "#00FFCC"], - bg_color="#001122", - text_color="#00FFCC" + colors=["#001F54", "#034078", "#0A1128", "#1282A2", "#00E5FF"], # Deeper abyss to bright surface + bg_color="#010A15", + text_color="#00E5FF" ), "sunset": ColorTheme( name="Sunset", - colors=["#FF6B35", "#FF8C42", "#F4A261", "#FF006E", "#8338EC"], - bg_color="#1A0A14", - text_color="#FFB4A2" + colors=["#FF3366", "#FF6B35", "#F4A261", "#E9C46A", "#9B5DE5"], # Warmer midtones mixing with purple night + bg_color="#12050C", + text_color="#F4A261" ), "neon": ColorTheme( name="Neon", - colors=["#FF00FF", "#FF0080", "#FF0000", "#FF8000", "#FFFF00", "#00FF00", "#00FFFF"], + colors=["#FF00FF", "#FF0055", "#FF3300", "#FFFF00", "#33FF00", "#00FFFF"], bg_color="#000000", - text_color="#FFFFFF" + text_color="#FAFAFA" ), "monochrome": ColorTheme( name="Monochrome", - colors=["#FFFFFF", "#CCCCCC", "#999999", "#666666"], - bg_color="#000000", + colors=["#FFFFFF", "#D4D4D4", "#A3A3A3", "#737373"], + bg_color="#050505", text_color="#FFFFFF" ), "rainbow": ColorTheme( name="Rainbow", - colors=["#FF0000", "#FF7F00", "#FFFF00", "#00FF00", "#0000FF", "#4B0082", "#9400D3"], + colors=["#FF0040", "#FF8000", "#FFEE00", "#00FF00", "#0040FF", "#8A2BE2"], bg_color="#000000", text_color="#FFFFFF" ), "deep_space": ColorTheme( name="Deep Space", - colors=["#000033", "#000066", "#330099", "#6600CC", "#9900FF"], - bg_color="#000011", - text_color="#99CCFF" + colors=["#05001A", "#1A004D", "#4B0099", "#8C1AFF", "#D966FF"], # Vibrant nebula purples + bg_color="#02000A", + text_color="#D966FF" ), "lava": ColorTheme( name="Lava", - colors=["#330000", "#660000", "#990000", "#CC3300", "#FF6600"], - bg_color="#110000", - text_color="#FFCC33" + colors=["#2A0000", "#5E0000", "#A30000", "#E63900", "#FF7F00"], + bg_color="#0A0000", + text_color="#FFB366" ) } diff --git a/src/ui/controls.py b/src/ui/controls.py index 48b4e7e..55445f1 100644 --- a/src/ui/controls.py +++ b/src/ui/controls.py @@ -158,6 +158,7 @@ def _setup_ui(self): "Frequency Rings", "Audio Lines", "Sound Wave", + "Sound Wave 2", ] ) self.style_combo.currentTextChanged.connect(self._on_style_changed) @@ -294,6 +295,7 @@ def _on_style_changed(self, style_name: str): "Frequency Rings": "frequency_rings", "Audio Lines": "audio_lines", "Sound Wave": "sound_wave", + "Sound Wave 2": "sound_wave_2", } internal_name = style_map.get(style_name, "spectrum_bars") @@ -339,6 +341,7 @@ def set_current_style(self, style_name: str): "frequency_rings": "Frequency Rings", "audio_lines": "Audio Lines", "sound_wave": "Sound Wave", + "sound_wave_2": "Sound Wave 2", } display_name = style_map.get(style_name) if display_name: @@ -349,11 +352,9 @@ def set_current_style(self, style_name: str): def set_current_theme_name(self, theme_name: str): """Set the current theme button programmatically.""" self.blockSignals(True) - found = False for btn in self.theme_buttons: if btn.text().lower() == theme_name.lower(): btn.setChecked(True) - found = True else: btn.setChecked(False) self.blockSignals(False) diff --git a/src/utils.py b/src/utils.py index ec78674..56ba367 100644 --- a/src/utils.py +++ b/src/utils.py @@ -19,18 +19,18 @@ def get_resource_path(relative_path: str) -> str: # Not running in a bundle, use project root # This assumes utils is in src/ base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - + return os.path.join(base_path, relative_path) def get_user_data_path() -> str: """Get absolute path to user data directory for logs and config.""" app_name = "EchoPy" - if os.name == 'nt': - base_path = os.getenv('LOCALAPPDATA', os.path.expanduser('~')) + if os.name == "nt": + base_path = os.getenv("LOCALAPPDATA", os.path.expanduser("~")) else: - base_path = os.path.join(os.path.expanduser('~'), '.local', 'share') - + base_path = os.path.join(os.path.expanduser("~"), ".local", "share") + data_path = os.path.join(base_path, app_name) os.makedirs(data_path, exist_ok=True) return data_path @@ -41,11 +41,13 @@ def setup_logging(level=logging.INFO): """Setup application logging.""" logging.basicConfig( level=level, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=[ logging.StreamHandler(), - logging.FileHandler(os.path.join(get_user_data_path(), "echopy.log"), encoding='utf-8') - ] + logging.FileHandler( + os.path.join(get_user_data_path(), "echopy.log"), encoding="utf-8" + ), + ], ) @@ -54,7 +56,7 @@ def setup_logging(level=logging.INFO): class Config: """Configuration manager for persistence.""" - + def __init__(self, config_file: str = "config.json"): """Initialize configuration manager.""" # Use user data path if just a filename is provided @@ -62,12 +64,12 @@ def __init__(self, config_file: str = "config.json"): self.config_file = os.path.join(get_user_data_path(), config_file) else: self.config_file = config_file - + if not os.path.exists(self.config_file): - self._ensure_writable() - + self._ensure_writable() + self.config: Dict[str, Any] = self._load_config() - + def _load_config(self) -> Dict[str, Any]: """Load configuration from file.""" defaults = { @@ -85,41 +87,42 @@ def _load_config(self) -> Dict[str, Any]: "sensitivity": { "rms_threshold_on": 0.0002, "rms_threshold_off": 0.0001, - "silence_timeout": 60 - } + "silence_timeout": 60, + }, } if os.path.exists(self.config_file): try: - with open(self.config_file, 'r') as f: + with open(self.config_file, "r") as f: saved_config = json.load(f) # Merge saved config with defaults defaults.update(saved_config) return defaults except Exception as e: logger.error(f"Error loading config: {e}") - + # Default configuration return defaults - + def save_config(self): """Save configuration to file.""" try: # Ensure file is not hidden/read-only before writing self._ensure_writable() - + # Write JSON - with open(self.config_file, 'w') as f: + with open(self.config_file, "w") as f: json.dump(self.config, f, indent=2) - + except Exception as e: logger.error(f"Error saving config: {e}") - + def _ensure_writable(self): """Ensure file is writable by removing Hidden attribute on Windows.""" - if os.name == 'nt' and os.path.exists(self.config_file): + if os.name == "nt" and os.path.exists(self.config_file): try: import ctypes + # FILE_ATTRIBUTE_NORMAL = 0x80 # Set to normal to remove Hidden (0x02) or Read-only (0x01) ctypes.windll.kernel32.SetFileAttributesW(self.config_file, 0x80) @@ -129,7 +132,7 @@ def _ensure_writable(self): def get(self, key: str, default: Any = None) -> Any: """Get configuration value.""" return self.config.get(key, default) - + def set(self, key: str, value: Any): """Set configuration value and save.""" self.config[key] = value @@ -138,11 +141,11 @@ def set(self, key: str, value: Any): class SmoothingBuffer: """Exponential moving average smoother for audio data (Vectorized).""" - + def __init__(self, size: int, smoothing: float = 0.8): """ Initialize smoothing buffer. - + Args: size: Buffer size smoothing: Smoothing factor (0.0 to 1.0, higher = smoother) @@ -150,14 +153,14 @@ def __init__(self, size: int, smoothing: float = 0.8): self.size = size self.smoothing = max(0.0, min(1.0, smoothing)) self.buffer = np.zeros(size, dtype=np.float32) - + def update(self, values: Any) -> np.ndarray: """ Update buffer with new values and return smoothed values. - + Args: values: New values to smooth (list or np.ndarray) - + Returns: Smoothed values as np.ndarray """ @@ -166,27 +169,27 @@ def update(self, values: Any) -> np.ndarray: new_values = values.astype(np.float32) else: new_values = np.array(values, dtype=np.float32) - + if len(new_values) != self.size: # Resize buffer if needed (resetting history) self.size = len(new_values) self.buffer = np.zeros(self.size, dtype=np.float32) - + # Vectorized calculation: # buffer = buffer * smoothing + new * (1 - smoothing) self.buffer = self.buffer * self.smoothing + new_values * (1.0 - self.smoothing) - + return self.buffer.copy() - + def set_smoothing(self, smoothing: float): """Set smoothing factor.""" # Ensure input is float try: - s = float(smoothing) + s = float(smoothing) except: - s = 0.5 + s = 0.5 self.smoothing = max(0.0, min(1.0, s)) - + def reset(self): """Reset buffer to zeros.""" self.buffer = np.zeros(self.size, dtype=np.float32) @@ -197,11 +200,13 @@ class CavaFilter: Advanced filter inspired by CAVA (Integral Filter + Fall-off). Provides smoother and more 'liquid' transitions than simple EMA. """ - - def __init__(self, size: int, integral_weight: float = 0.7, gravity: float = 0.03): + + def __init__( + self, size: int, integral_weight: float = 0.75, gravity: float = 0.015 + ): """ Initialize CavaFilter. - + Args: size: Buffer size integral_weight: Weight of previous values in the integral (0.0 to 1.0) @@ -212,7 +217,7 @@ def __init__(self, size: int, integral_weight: float = 0.7, gravity: float = 0.0 self.gravity = gravity self.prev_values = np.zeros(size, dtype=np.float32) self.integral_buffer = np.zeros(size, dtype=np.float32) - + def update(self, values: np.ndarray) -> np.ndarray: """ Apply CAVA-style filtering. @@ -224,69 +229,77 @@ def update(self, values: np.ndarray) -> np.ndarray: # 1. Integral filter (Smooths the 'ascent') # integral = (current * (1-weight)) + (prev_integral * weight) - self.integral_buffer = (values * (1.0 - self.integral_weight)) + (self.integral_buffer * self.integral_weight) - - # 2. Fall-off filter (Smooths the 'descent') - # If new value is lower than old, apply gravity + self.integral_buffer = (values * (1.0 - self.integral_weight)) + ( + self.integral_buffer * self.integral_weight + ) + + # 2. Fall-off filter (Smooths the 'descent' with dynamic gravity) + # La gravedad se acelera sutilmente mientras más cae, pero empieza muy lenta (movimiento elástico) mask = self.integral_buffer < (self.prev_values - self.gravity) - output = np.where(mask, self.prev_values - self.gravity, self.integral_buffer) - + output = np.where( + mask, self.prev_values - self.gravity * 0.8, self.integral_buffer + ) + # Clip to ensure no negative values output = np.maximum(0.0, output) - + self.prev_values = output.copy() return output def set_smoothing(self, smoothing: float): """Map generic smoothing (0-1) to integral weight.""" self.integral_weight = clamp(smoothing, 0.1, 0.95) - + def set_gravity(self, gravity: float): """Set gravity factor.""" self.gravity = gravity -def load_image(path: str, width: Optional[int] = None, height: Optional[int] = None) -> Optional[QPixmap]: +def load_image( + path: str, width: Optional[int] = None, height: Optional[int] = None +) -> Optional[QPixmap]: """ Load and optionally scale an image. - + Args: path: Path to image file width: Target width (None to keep aspect ratio) height: Target height (None to keep aspect ratio) - + Returns: QPixmap or None if loading failed """ if not os.path.exists(path): return None - + image = QImage(path) if image.isNull(): return None - + pixmap = QPixmap.fromImage(image) - + if width or height: if width and height: - pixmap = pixmap.scaled(width, height, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) + pixmap = pixmap.scaled( + width, height, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation + ) elif width: pixmap = pixmap.scaledToWidth(width, Qt.SmoothTransformation) elif height: pixmap = pixmap.scaledToHeight(height, Qt.SmoothTransformation) - + return pixmap def frequency_to_bin(frequency: float, sample_rate: int, fft_size: int) -> int: """ Convert frequency in Hz to FFT bin index. - + Args: frequency: Frequency in Hz sample_rate: Sample rate in Hz fft_size: FFT size - + Returns: Bin index """ @@ -296,29 +309,31 @@ def frequency_to_bin(frequency: float, sample_rate: int, fft_size: int) -> int: def bin_to_frequency(bin_index: int, sample_rate: int, fft_size: int) -> float: """ Convert FFT bin index to frequency in Hz. - + Args: bin_index: Bin index sample_rate: Sample rate in Hz fft_size: FFT size - + Returns: Frequency in Hz """ return bin_index * sample_rate / fft_size -def map_range(value: float, in_min: float, in_max: float, out_min: float, out_max: float) -> float: +def map_range( + value: float, in_min: float, in_max: float, out_min: float, out_max: float +) -> float: """ Map a value from one range to another. - + Args: value: Input value in_min: Input range minimum in_max: Input range maximum out_min: Output range minimum out_max: Output range maximum - + Returns: Mapped value """ diff --git a/src/visualizer_factory.py b/src/visualizer_factory.py index 8d19879..e50178b 100644 --- a/src/visualizer_factory.py +++ b/src/visualizer_factory.py @@ -13,6 +13,7 @@ from styles.frequency_rings import FrequencyRings from styles.audio_lines import AudioLines from styles.sound_wave import SoundWave +from styles.sound_wave_2 import SoundWave2 from utils import logger @@ -31,6 +32,7 @@ class VisualizerFactory: "frequency_rings": FrequencyRings, "audio_lines": AudioLines, "sound_wave": SoundWave, + "sound_wave_2": SoundWave2, } _instances: Dict[str, BaseVisualizer] = {} From 26ff021dfbe0974432d0140889adf5d8b1721504 Mon Sep 17 00:00:00 2001 From: userlg Date: Fri, 20 Feb 2026 09:51:54 -0400 Subject: [PATCH 03/13] fix(styles): allow visualizers to respond to global UI settings - Removed hardcoded internal smoothing loops from SoundWave2, SpectrumBars, CircularSpectrum, and AudioLines. - Removed excessive exponentiation math in SoundWave2 that forced clipping, hiding Gain changes. - Visualizers now properly trust and display the ft_data output that contains UI-controlled Gain and CavaFilter Smoothing. --- src/styles/audio_lines.py | 45 +++++++++++++++++++------------------ src/styles/circular.py | 13 ++++------- src/styles/sound_wave_2.py | 32 ++++++++------------------ src/styles/spectrum_bars.py | 11 ++------- 4 files changed, 38 insertions(+), 63 deletions(-) diff --git a/src/styles/audio_lines.py b/src/styles/audio_lines.py index 09ab0c4..9aa726d 100644 --- a/src/styles/audio_lines.py +++ b/src/styles/audio_lines.py @@ -8,61 +8,57 @@ class AudioLines(BaseVisualizer): """Energy ribbons with high-impact flow and vocal-driven turbulence.""" - + def __init__(self): super().__init__("Audio Lines") self.num_layers = 8 # Más capas para mayor profundidad visual self.line_width = 2 self.time = 0.0 - self.smoothing = 0.2 # Suavizado para una fluidez cinematográfica self.prev_magnitudes = None - + def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): if self.theme is None or fft_data is None: return - + painter.setRenderHint(QPainter.Antialiasing, True) - + # 1. Análisis de Poder Vocal (Foco en la autoridad de la voz) n_fft = len(fft_data) num_points = 12 # Puntos de control para la curva samples_per_point = max(1, int(n_fft * 0.25) // num_points) - + current_mags = np.zeros(num_points) for i in range(num_points): start = i * samples_per_point end = start + samples_per_point current_mags[i] = np.mean(fft_data[start:end]) if start < n_fft else 0 - # Suavizado temporal para evitar saltos nerviosos - if self.prev_magnitudes is None: - self.prev_magnitudes = current_mags - else: - self.prev_magnitudes = (current_mags * self.smoothing) + (self.prev_magnitudes * (1.0 - self.smoothing)) + # El FFT ya viene suavizado globalmente, por lo tanto reflejamos el dato crudo para la gráfica + self.prev_magnitudes = current_mags # 2. Renderizado de Capas de Energía (Ribbons) for layer in range(self.num_layers): path = QPainterPath() - + # Color y degradado de la capa color_pos = layer / self.num_layers base_color = self.theme.get_gradient_color(color_pos) - + # 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 - + points = [] for i in range(num_points): x = (i / (num_points - 1)) * self.width - + # Dinámica de movimiento: # - 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) - + y = center_y + layer_offset + wave + audio_react points.append(QPointF(x, y)) @@ -70,7 +66,7 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): path.moveTo(points[0]) for i in range(len(points) - 1): p1 = points[i] - p2 = points[i+1] + p2 = points[i + 1] # Puntos de control para suavizado control_x = (p1.x() + p2.x()) / 2 path.cubicTo(QPointF(control_x, p1.y()), QPointF(control_x, p2.y()), p2) @@ -81,14 +77,14 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): fill_path.lineTo(self.width, center_y) fill_path.lineTo(0, center_y) fill_path.closeSubpath() - + fill_grad = QLinearGradient(0, center_y - 100, 0, center_y + 100) c_fill = QColor(base_color) c_fill.setAlpha(30) fill_grad.setColorAt(0, Qt.transparent) fill_grad.setColorAt(0.5, c_fill) fill_grad.setColorAt(1, Qt.transparent) - + painter.setPen(Qt.NoPen) painter.setBrush(QBrush(fill_grad)) painter.drawPath(fill_path) @@ -96,13 +92,18 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): # Dibujo de la línea principal con resplandor glow_color = QColor(base_color) glow_color.setAlpha(60) - painter.setPen(QPen(glow_color, self.line_width + 6, Qt.SolidLine, Qt.RoundCap)) + painter.setPen( + QPen(glow_color, self.line_width + 6, Qt.SolidLine, Qt.RoundCap) + ) painter.setBrush(Qt.NoBrush) painter.drawPath(path) core_pen = QPen(base_color, self.line_width) - if layer % 2 == 0: core_pen.setColor(base_color.lighter(150)) # Brillo extra en capas alternas + if layer % 2 == 0: + core_pen.setColor( + base_color.lighter(150) + ) # Brillo extra en capas alternas painter.setPen(core_pen) painter.drawPath(path) - self.time += 0.04 # Velocidad del flujo \ No newline at end of file + self.time += 0.04 # Velocidad del flujo diff --git a/src/styles/circular.py b/src/styles/circular.py index e53afd5..5244b82 100644 --- a/src/styles/circular.py +++ b/src/styles/circular.py @@ -16,9 +16,7 @@ def __init__(self): self.min_radius = 80 self.bar_width = 3 self.smoothed_bass = 0.0 - # Estado para la fluidez (Smoothing) - self.prev_bar_lengths = np.zeros(self.num_bands) - self.smoothing_factor = 0.12 # Más bajo = movimiento más líquido y conectado + # La interpolación la controla CavaFilter, quitamos smoothing_factor manual. # --- NUEVO: Estado para la animación de reposo de las barras superiores --- self.idle_phase = 0.0 @@ -99,13 +97,10 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): blend_factor = 1.0 - (magnitudes[i] / threshold) magnitudes[i] += idle_magnitude * blend_factor - # ──────────────── Suavizado de Barras (Fluidity) ──────────────── + # ──────────────── Suavizado Delegado a AudioProcessor ──────────────── target_lengths = magnitudes * bar_zone * 45.0 - # El suavizado se aplica DESPUÉS de la micro-dinámica para que el movimiento sea elegante - current_bar_lengths = (target_lengths * self.smoothing_factor) + ( - self.prev_bar_lengths * (1.0 - self.smoothing_factor) - ) - self.prev_bar_lengths = current_bar_lengths + # El suavizado se aplica desde el AudioProcessor globalmente. + current_bar_lengths = target_lengths bar_lengths = np.clip(current_bar_lengths, 3.0, bar_zone) diff --git a/src/styles/sound_wave_2.py b/src/styles/sound_wave_2.py index 329d3f5..2a4fce0 100644 --- a/src/styles/sound_wave_2.py +++ b/src/styles/sound_wave_2.py @@ -18,7 +18,6 @@ def __init__(self): self.bar_gap_ratio = ( 0.4 # Proporción del ancho que toma el espacio respecto a la barra ) - self.smoothed_magnitudes = np.zeros(self.bar_count // 2, dtype=np.float64) def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): if self.theme is None or fft_data is None: @@ -43,10 +42,6 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): half_bars = self.bar_count // 2 - # Mantenemos el array suavizado en sincronía con el tamaño si eventualmente cambia - if len(self.smoothed_magnitudes) != half_bars: - self.smoothed_magnitudes = np.zeros(half_bars, dtype=np.float64) - # Creamos posiciones de lectura logarítmicas para extraer graves al principio y medios-agudos al final log_indices = np.logspace( np.log10(1), np.log10(max(1, effective_fft - 1)), half_bars @@ -58,33 +53,24 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): hi = max(lo + 1, min(effective_fft, log_indices[i] + 2)) raw_magnitudes[i] = np.mean(fft_data[lo:hi]) if lo < effective_fft else 0.0 - # Suavizado asimétrico por bin (Sube rápido, baja líquido y lento) - for i in range(half_bars): - target = raw_magnitudes[i] - current = self.smoothed_magnitudes[i] - if target > current: - self.smoothed_magnitudes[i] += ( - target - current - ) * 0.85 # Ataque casi instantáneo - else: - self.smoothed_magnitudes[i] += ( - target - current - ) * 0.08 # Caída muy sedosa y líquida + # El FFT ya viene suavizado desde el AudioProcessor según la configuración del usuario. # Multiplicador de escala global masivo para dominar la pantalla scale_factor = self.height * 1.5 # Atenuación gaussiana forzosa desde el centro hacia los bordes x_lin = np.linspace(0, 1, half_bars) - # Forma de campana muy ancha, cayendo solo levemente en las puntas extremas + # Forma de campana muy ancha, cayendo levemente en las puntas extremas envelope = np.exp(-1.2 * (x_lin) ** 2) - # Aplicar el multiplicador global - magnitudes = self.smoothed_magnitudes * scale_factor * envelope + # Aplicar el multiplicador global con una potencia mucho más contenida. + # Esto permite que los ajustes de "Gain" en la UI sí reflejen cambios visuales grandes. + magnitudes = raw_magnitudes * envelope - # Super boost a las magnitudes, elevación a la potencia de 0.65 hace que las frecuencias - # débiles resalten mucho más, dándole armonía constante al espectro vacío. - magnitudes = np.clip(np.power(magnitudes, 0.65) * 45.0, 8.0, self.height * 0.95) + # Subir los bajos sutilmente sin aplastar la ganancia (potencia = 0.85 en lugar de 0.65) + magnitudes = np.clip( + np.power(magnitudes, 0.85) * scale_factor, 8.0, self.height * 0.95 + ) # Construcción de la onda espejo completa # Lado izquierdo (frecuencias altas cayendo en el borde -> graves al medio) diff --git a/src/styles/spectrum_bars.py b/src/styles/spectrum_bars.py index ef2fb03..b63b633 100644 --- a/src/styles/spectrum_bars.py +++ b/src/styles/spectrum_bars.py @@ -14,10 +14,6 @@ def __init__(self): self.bar_spacing = 3 self.corner_radius = 4 # Estética moderna: bordes redondeados - # Estado para suavizado (Smoothing) - self.prev_magnitudes = np.zeros(self.num_bars) - self.smoothing_factor = 0.18 # Menos valor = más fluido, menos "jitter" - # Peak hold state self.peaks = np.zeros(self.num_bars, dtype=np.float64) self.peak_decay = 0.94 # Caída más suave y controlada @@ -71,11 +67,8 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): # Curva de potencia agresiva para que "floten" magnitudes = np.power(np.clip(magnitudes, 0.0, None), 0.5) - # Suavizado temporal (Interpolación) - magnitudes = (magnitudes * self.smoothing_factor) + ( - self.prev_magnitudes * (1.0 - self.smoothing_factor) - ) - self.prev_magnitudes = magnitudes + # El FFT ya viene suavizado desde el AudioProcessor en función de la UI. + # Quitamos el suavizado interno para evitar latencia artificial y pérdida de control. # Actualizar Picos self.peaks = np.maximum( From ecc703eadb6f3ac6b92c7b66e7c3857b05a8d686 Mon Sep 17 00:00:00 2001 From: userlg Date: Fri, 20 Feb 2026 10:01:18 -0400 Subject: [PATCH 04/13] fix(audio): boost base fft multiplier for better gain scaling - Enhanced the base scalar coefficient (x3.5) during FFT generation so that values pushed to Visualizers carry satisfying height out-of-the-box. - This effectively solves the issue where 200% Gain in the UI showed underwhelming graphics due to raw FFT values being exceptionally small prior to clipping. --- src/audio_processor.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/audio_processor.py b/src/audio_processor.py index cc6de2d..763a121 100644 --- a/src/audio_processor.py +++ b/src/audio_processor.py @@ -379,15 +379,18 @@ def _audio_callback(self, indata, frames, time, status): ) fft_magnitude[:2] = 0.0 # Remove DC - # Log-perception scaling - fft_magnitude = np.log1p(fft_magnitude) * 0.33 + # Scale raw fft slightly before log perception to grab sub-harmonics + fft_magnitude = np.log1p(fft_magnitude * 15.0) * 0.35 - # Apply User Gain and Clip - fft_magnitude = np.clip(fft_magnitude * (self.gain / 60.0), 0.0, 1.0) + # Apply User Gain with a solid base multiplier (x3.5) and Clip + # This ensures that even at 100% gain, average audio takes a good chunk of the screen + fft_magnitude = np.clip(fft_magnitude * (self.gain / 60.0) * 3.5, 0.0, 1.0) # 4. Smoothing and Delivery self.fft_data = self.smoother.update(fft_magnitude) - self.last_callback_ms = (time_module.perf_counter() - callback_start) * 1000.0 + self.last_callback_ms = ( + time_module.perf_counter() - callback_start + ) * 1000.0 self.audio_data_ready.emit( self.audio_buffer, self.fft_data, From 7dd549371ae84af6d79d25f49fe60debe569042c Mon Sep 17 00:00:00 2001 From: userlg Date: Fri, 20 Feb 2026 10:03:42 -0400 Subject: [PATCH 05/13] refactor(ui): store and log gain control as intuitive percentage - Shifted the gain representation so that the internal variable matches the user-facing UI value (100.0 instead of 60.0). - Users will now safely see 'Gain set to: 157.0' instead of arbitrary internal mathematical multipliers like '94.2' in the logs. --- src/audio_processor.py | 6 +++--- src/ui/main_window.py | 11 ++--------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/audio_processor.py b/src/audio_processor.py index 763a121..3a9196c 100644 --- a/src/audio_processor.py +++ b/src/audio_processor.py @@ -41,7 +41,7 @@ def __init__( self.fft_data = np.zeros(fft_size // 2, dtype=np.float32) # Signal processing state - self.gain = 60.0 + self.gain = 100.0 self.smoother = smoother or CavaFilter(fft_size // 2, 0.7, 0.03) # Audio stream state @@ -362,7 +362,7 @@ def _audio_callback(self, indata, frames, time, status): # Apply Gain for visualizer (using the already cleaned data) # Much more aggressive gain multiplier for better visualization - user_gain_factor = self.gain / 60.0 + user_gain_factor = self.gain / 100.0 GAIN_MULTIPLIER = 15000.0 * user_gain_factor self.audio_buffer = np.clip(audio_data * GAIN_MULTIPLIER, -1.0, 1.0) @@ -384,7 +384,7 @@ def _audio_callback(self, indata, frames, time, status): # Apply User Gain with a solid base multiplier (x3.5) and Clip # This ensures that even at 100% gain, average audio takes a good chunk of the screen - fft_magnitude = np.clip(fft_magnitude * (self.gain / 60.0) * 3.5, 0.0, 1.0) + fft_magnitude = np.clip(fft_magnitude * (self.gain / 100.0) * 3.5, 0.0, 1.0) # 4. Smoothing and Delivery self.fft_data = self.smoother.update(fft_magnitude) diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 65d4389..c72c32f 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -57,9 +57,7 @@ def __init__(self): # Restore saved gain saved_gain = self.config.get("gain", 100.0) - # Convert % to multiplier - initial_gain_mult = 60.0 * (saved_gain / 100.0) - self.audio_processor.set_gain(initial_gain_mult) + self.audio_processor.set_gain(saved_gain) # Start audio processing with saved device saved_device = self.config.get("audio_device") @@ -260,12 +258,7 @@ def _change_smoothing(self, smoothing: float): def _change_gain(self, gain: float): """Change gain multiplier.""" - # Convert percentage (e.g. 100.0) to multiplier (e.g. 60.0 default base) - # Base gain is 60.0, so 100% = 60.0 - base_gain = 60.0 - multiplier = gain / 100.0 - new_gain = base_gain * multiplier - self.audio_processor.set_gain(new_gain) + self.audio_processor.set_gain(gain) self.config.set("gain", gain) def _change_sample_rate(self, rate: int): From 68254eac6c62a0f2511cc04d9322a7a7276975af Mon Sep 17 00:00:00 2001 From: userlg Date: Fri, 20 Feb 2026 10:05:34 -0400 Subject: [PATCH 06/13] fix(styles): resolve TypeError in CircularSpectrum - Replaced get_color() with get_gradient_color() when passing a float value (0.2). - Prevented the 'TypeError: list indices must be integers or slices, not float' crash. --- src/styles/circular.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/circular.py b/src/styles/circular.py index 5244b82..63ea4c5 100644 --- a/src/styles/circular.py +++ b/src/styles/circular.py @@ -123,7 +123,7 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): opacity -= 0.015 if opacity > 0.0 and r < max_radius: - color = self.theme.get_color(0.2) + color = self.theme.get_gradient_color(0.2) wave_color = QColor(color) wave_color.setAlphaF(opacity * 0.4) pen = QPen(wave_color) From d3378993d713ee7eec2dec0d40dcdc6847b412c5 Mon Sep 17 00:00:00 2001 From: userlg Date: Fri, 20 Feb 2026 10:07:50 -0400 Subject: [PATCH 07/13] feat(styles): revolutionize waveform visualization aesthetics - Replaced basic jagged line-drawing with high-performance cubicTo Bezier curves for a liquid-smooth appearance. - Implemented soft-clipping ( p.tanh) to tame explosive volume drops instead of hard math cutoffs. - Added multi-layered glowing strokes (wide nebulous, middle intense, core laser) to generate a premium cinematic look. --- src/styles/waveform.py | 159 +++++++++++++++++++++++++++-------------- 1 file changed, 104 insertions(+), 55 deletions(-) diff --git a/src/styles/waveform.py b/src/styles/waveform.py index 59df695..4d261de 100644 --- a/src/styles/waveform.py +++ b/src/styles/waveform.py @@ -4,9 +4,10 @@ from PySide6.QtCore import Qt, QPointF from visualizer import BaseVisualizer + class Waveform(BaseVisualizer): """Enhanced cinematic waveform with energy aura and mirrored pulse.""" - + def __init__(self): super().__init__("Waveform") self.line_width = 3 @@ -16,88 +17,136 @@ def __init__(self): def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): if self.theme is None or len(waveform) == 0: return - + painter.setRenderHint(QPainter.Antialiasing, True) - - # 1. Suavizado Temporal (Inter-frame smoothing) + + # 1. Preparación de la Onda Real y Limpieza + # Removido el if/else de prev_waveform agresivo. El AudioProcessor global + # ya administra muy bien la señal amortiguada si se desea suavizado. + + # Ocasionalmente el waveform literal es un poco nervioso visualmente, + # optaremos por un smoothing muy sutil sólo para no dañar las curvas ricas. + # Pero nos aseguraremos que siempre dibuje. + if self.prev_waveform is None or len(self.prev_waveform) != len(waveform): - self.prev_waveform = waveform + self.prev_waveform = np.array(waveform) else: - waveform = (waveform * self.smoothing) + (self.prev_waveform * (1.0 - self.smoothing)) - self.prev_waveform = waveform + # Smoothing hiper bajo enfocado a mantener el diseño líquido (0.1 frente al viejo 0.3) + # Esto ayuda a que el trazado Bezier no se rompa visualmente. + smoothing = 0.05 + self.prev_waveform = (waveform * (1.0 - smoothing)) + ( + self.prev_waveform * smoothing + ) + + # 2. Submuestreo para Cubic Bezier (Curvas suaves reales) + # Menos puntos = ondas más redondeadas y líquidas + num_points = 80 + step = max(1, len(self.prev_waveform) // num_points) - # 2. Procesamiento de Puntos (Downsampling Inteligente) - 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 - - # Creamos los paths para la parte superior e inferior (Efecto Espejo) + amplitude = self.height * 0.45 # Alto impacto + path_top = QPainterPath() path_bottom = QPainterPath() - - first = True - 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) - - x = (i / len(waveform)) * self.width + + # Puntos de control para la interpolación + points = [] + for i in range(0, len(self.prev_waveform), step): + sample = self.prev_waveform[i] + # La waveform puede explotar, limitamos suavemente + val = np.tanh(sample * 1.5) # Soft clipping elegante + x = (i / len(self.prev_waveform)) * self.width y_offset = val * amplitude - - if first: - path_top.moveTo(x, center_y - y_offset) - path_bottom.moveTo(x, center_y + y_offset) - first = False - else: - path_top.lineTo(x, center_y - y_offset) - path_bottom.lineTo(x, center_y + y_offset) - - # 3. Renderizado de "Aura" (Relleno con gradiente) - # Cerramos los paths para poder rellenarlos + points.append((x, y_offset)) + + # Aseguramos inicio y fin en el horizonte + if not points: + return + points[0] = (0, 0) + points[-1] = (self.width, 0) + + # 3. Trazado de Curva Cúbica (Bezieres en lugar de Líneas Rectas) + path_top.moveTo(points[0][0], center_y - points[0][1]) + path_bottom.moveTo(points[0][0], center_y + points[0][1]) + + for i in range(len(points) - 1): + x1, y1 = points[i] + x2, y2 = points[i + 1] + + # Puntos de control en X (mitad del camino entre los puntos) para curvatura perfecta + cp_x = (x1 + x2) / 2 + + # Curva Superior + path_top.cubicTo( + QPointF(cp_x, center_y - y1), + QPointF(cp_x, center_y - y2), + QPointF(x2, center_y - y2), + ) + # Curva Inferior (Espejo) + path_bottom.cubicTo( + QPointF(cp_x, center_y + y1), + QPointF(cp_x, center_y + y2), + QPointF(x2, center_y + y2), + ) + + # 4. Renderizado del Aura Eléctrica (Glow central a bordes) fill_path = QPainterPath(path_top) - # Conectamos con el path inferior de forma invertida para cerrar la forma fill_path.connectPath(path_bottom.toReversed()) fill_path.closeSubpath() - # Gradiente de poder (del centro hacia los bordes) - grad = QLinearGradient(0, center_y - amplitude, 0, center_y + amplitude) main_color = self.theme.get_color(0) - - c_glow = QColor(main_color) - c_glow.setAlpha(120) - c_fade = QColor(main_color) + alt_color = self.theme.get_color(1) + + grad_fill = QLinearGradient(0, center_y - amplitude, 0, center_y + amplitude) + c_core = QColor(main_color) + c_core.setAlpha(160) + c_fade = QColor(alt_color) c_fade.setAlpha(0) - grad.setColorAt(0.0, c_fade) - grad.setColorAt(0.5, c_glow) - grad.setColorAt(1.0, c_fade) + grad_fill.setColorAt(0.0, c_fade) + grad_fill.setColorAt(0.5, c_core) + grad_fill.setColorAt(1.0, c_fade) painter.setPen(Qt.NoPen) - painter.setBrush(QBrush(grad)) + painter.setBrush(QBrush(grad_fill)) painter.drawPath(fill_path) - # 4. Líneas de Contorno (Los "nervios" de la onda) - # Brillo exterior - glow_pen = QPen(QColor(main_color.red(), main_color.green(), main_color.blue(), 60)) - glow_pen.setWidth(8) - painter.setPen(glow_pen) + # 5. Capas de Líneas Estilizadas (Nebulosa exterior a Láser interior) + # Capa Glow Ancha + glow_pen_wide = QPen( + QColor(main_color.red(), main_color.green(), main_color.blue(), 30) + ) + glow_pen_wide.setWidth(12) + glow_pen_wide.setCapStyle(Qt.RoundCap) + painter.setPen(glow_pen_wide) + painter.setBrush(Qt.NoBrush) painter.drawPath(path_top) painter.drawPath(path_bottom) - # Línea principal sólida - core_pen = QPen(main_color) + # Capa Glow Media + glow_pen_mid = QPen( + QColor(alt_color.red(), alt_color.green(), alt_color.blue(), 80) + ) + glow_pen_mid.setWidth(5) + painter.setPen(glow_pen_mid) + painter.drawPath(path_top) + painter.drawPath(path_bottom) + + # Capa Láser Central (Pura y Brillante) + core_pen = QPen(main_color.lighter(150)) core_pen.setWidth(2) painter.setPen(core_pen) painter.drawPath(path_top) painter.drawPath(path_bottom) - # 5. Núcleo Central (Línea de horizonte) - # En lugar de una línea punteada simple, una línea con gradiente de opacidad + # 6. Eje de Gravedad (Línea central pulsante) + horizon_intensity = max(100, int(np.mean(np.abs(self.prev_waveform)) * 1500)) + horizon_intensity = min(255, horizon_intensity) + center_grad = QLinearGradient(0, 0, self.width, 0) center_grad.setColorAt(0.0, Qt.transparent) - center_grad.setColorAt(0.5, QColor(255, 255, 255, 100)) + center_grad.setColorAt(0.5, QColor(255, 255, 255, horizon_intensity)) center_grad.setColorAt(1.0, Qt.transparent) - + painter.setPen(QPen(QBrush(center_grad), 1)) - painter.drawLine(0, int(center_y), self.width, int(center_y)) \ No newline at end of file + painter.drawLine(0, int(center_y), self.width, int(center_y)) From 12ee8d06ed11015697c6beaaf846dc302beb3fbe Mon Sep 17 00:00:00 2001 From: userlg Date: Fri, 20 Feb 2026 10:11:32 -0400 Subject: [PATCH 08/13] fix(audio): eliminate extreme clipping in raw audio buffer - Changed an old, erroneous multiplier of 15000.0 to a sensible 2.0 on the raw udio_buffer generation. - The massive 15000x multiplier was forcing all audio input straight to its limits (-1.0 or 1.0), artificially turning all audio samples into hard, unnatural square waves which ruined 'Waveform' and 'Oscilloscope' visualizations. --- src/audio_processor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/audio_processor.py b/src/audio_processor.py index 3a9196c..0addd9b 100644 --- a/src/audio_processor.py +++ b/src/audio_processor.py @@ -360,10 +360,10 @@ def _audio_callback(self, indata, frames, time, status): ) self._audio_debug_logged = True - # Apply Gain for visualizer (using the already cleaned data) - # Much more aggressive gain multiplier for better visualization + # Apply Gain for visualizer user_gain_factor = self.gain / 100.0 - GAIN_MULTIPLIER = 15000.0 * user_gain_factor + # Reducimos de 15000.0 a un valor natural (2.0) dado que audio_data viene en float32 [-1, 1] + GAIN_MULTIPLIER = 2.0 * user_gain_factor self.audio_buffer = np.clip(audio_data * GAIN_MULTIPLIER, -1.0, 1.0) # Also apply gain to activity_level so thresholds make sense From 57a98f5ca6c3994183d1058d0b9fa65af066519e Mon Sep 17 00:00:00 2001 From: userlg Date: Fri, 20 Feb 2026 10:18:52 -0400 Subject: [PATCH 09/13] fix(styles): balance high-gain acoustic responses - Relaxed Math: Downscaled excessive p.power() bounds and hardcoded height multipliers inside SpectrumBars, CircularSpectrum, SoundWave2 and AudioLines. - Issue Fixed: Prevent visualizers from immediately clipping to the absolute screen border on low-gain setups, thus maintaining elegant breathing room for acoustic responses. --- src/styles/audio_lines.py | 5 +++-- src/styles/circular.py | 2 +- src/styles/sound_wave_2.py | 12 +++++------- src/styles/spectrum_bars.py | 12 ++++++------ 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/styles/audio_lines.py b/src/styles/audio_lines.py index 9aa726d..6300046 100644 --- a/src/styles/audio_lines.py +++ b/src/styles/audio_lines.py @@ -54,10 +54,11 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): # Dinámica de movimiento: # - Sinusoidal constante para el "flow" - # - Reacción al audio multiplicada por el peso de la capa + # Reacción al audio 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) + # Multiplicador interno rebajado ya que el FFT base es fuerte + audio_react = self.prev_magnitudes[i] * (80 + layer * 20) y = center_y + layer_offset + wave + audio_react points.append(QPointF(x, y)) diff --git a/src/styles/circular.py b/src/styles/circular.py index 63ea4c5..3990549 100644 --- a/src/styles/circular.py +++ b/src/styles/circular.py @@ -98,7 +98,7 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): magnitudes[i] += idle_magnitude * blend_factor # ──────────────── Suavizado Delegado a AudioProcessor ──────────────── - target_lengths = magnitudes * bar_zone * 45.0 + target_lengths = magnitudes * bar_zone * 25.0 # El suavizado se aplica desde el AudioProcessor globalmente. current_bar_lengths = target_lengths diff --git a/src/styles/sound_wave_2.py b/src/styles/sound_wave_2.py index 2a4fce0..e2a52d5 100644 --- a/src/styles/sound_wave_2.py +++ b/src/styles/sound_wave_2.py @@ -55,21 +55,19 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): # El FFT ya viene suavizado desde el AudioProcessor según la configuración del usuario. - # Multiplicador de escala global masivo para dominar la pantalla - scale_factor = self.height * 1.5 + # Multiplicador de escala global equilibrado + scale_factor = self.height * 0.9 # Atenuación gaussiana forzosa desde el centro hacia los bordes x_lin = np.linspace(0, 1, half_bars) - # Forma de campana muy ancha, cayendo levemente en las puntas extremas envelope = np.exp(-1.2 * (x_lin) ** 2) - # Aplicar el multiplicador global con una potencia mucho más contenida. - # Esto permite que los ajustes de "Gain" en la UI sí reflejen cambios visuales grandes. + # Aplicar el multiplicador global magnitudes = raw_magnitudes * envelope - # Subir los bajos sutilmente sin aplastar la ganancia (potencia = 0.85 en lugar de 0.65) + # Curva de atenuación menos limitante magnitudes = np.clip( - np.power(magnitudes, 0.85) * scale_factor, 8.0, self.height * 0.95 + np.power(magnitudes, 0.8) * scale_factor, 8.0, self.height * 0.95 ) # Construcción de la onda espejo completa diff --git a/src/styles/spectrum_bars.py b/src/styles/spectrum_bars.py index b63b633..a2f5248 100644 --- a/src/styles/spectrum_bars.py +++ b/src/styles/spectrum_bars.py @@ -59,13 +59,13 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): magnitudes[i] = raw_magnitudes[dist_from_center] # ──────────── Procesamiento Estético ──────────── - # Boost dinámico: Agudos (ahora en los bordes) necesitan más ganancia visual - edge_boost = np.linspace(2.5, 1.0, half) + # Boost dinámico: Agudos (ahora en los bordes) necesitan un empuje suave + edge_boost = np.linspace(1.5, 1.0, half) boost = np.concatenate([edge_boost, edge_boost[::-1]]) magnitudes *= boost - # Curva de potencia agresiva para que "floten" - magnitudes = np.power(np.clip(magnitudes, 0.0, None), 0.5) + # Curva de potencia (0.7 en lugar de 0.5) para dar más rango dinámico a la música + magnitudes = np.power(np.clip(magnitudes, 0.0, None), 0.7) # El FFT ya viene suavizado desde el AudioProcessor en función de la UI. # Quitamos el suavizado interno para evitar latencia artificial y pérdida de control. @@ -80,8 +80,8 @@ 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) + # Altura con piso estético (mínimo 5px) y tope holgado + bar_height = max(5, mag * self.height * 0.70) bar_height = min(bar_height, self.height * 0.75) peak_height = max(bar_height, peak * self.height * 0.9) From 7216afdf2d07a0e920d0873b447689616c1efac7 Mon Sep 17 00:00:00 2001 From: userlg Date: Tue, 24 Feb 2026 10:26:41 -0400 Subject: [PATCH 10/13] feat(ui): implement frameless translucent window, style shortcuts, spectre theme, and circular visualizer smoothing optimizations --- src/styles/circular.py | 20 +++-- src/themes.py | 149 ++++++++++++++++++++++------------- src/ui/controls.py | 2 +- src/ui/main_window.py | 161 +++++++++++++++++++++++++++++++++++++- src/ui/settings_dialog.py | 5 ++ src/visualizer.py | 22 ++++-- 6 files changed, 294 insertions(+), 65 deletions(-) diff --git a/src/styles/circular.py b/src/styles/circular.py index 3990549..2516887 100644 --- a/src/styles/circular.py +++ b/src/styles/circular.py @@ -16,7 +16,11 @@ def __init__(self): self.min_radius = 80 self.bar_width = 3 self.smoothed_bass = 0.0 - # La interpolación la controla CavaFilter, quitamos smoothing_factor manual. + # Fluididad local para compensar los saltos rápidos del audio + self.smoothed_bars = np.zeros(self.num_bands, dtype=np.float64) + self.smoothing_factor = ( + 0.75 # Interpolación suave (75% frame anterior, 25% nuevo) + ) # --- NUEVO: Estado para la animación de reposo de las barras superiores --- self.idle_phase = 0.0 @@ -97,10 +101,16 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): blend_factor = 1.0 - (magnitudes[i] / threshold) magnitudes[i] += idle_magnitude * blend_factor - # ──────────────── Suavizado Delegado a AudioProcessor ──────────────── - target_lengths = magnitudes * bar_zone * 25.0 - # El suavizado se aplica desde el AudioProcessor globalmente. - current_bar_lengths = target_lengths + # ──────────────── Suavizado y Escalado Final ──────────────── + # El multiplicador original 25.0 era extremo y saturaba el visualizador. + # Ajustamos a 1.8 para un mayor rango dinámico sensible al gain global. + target_lengths = magnitudes * bar_zone * 1.8 + + # Suavizado local para lograr el movimiento "fluido" deseado + self.smoothed_bars = (self.smoothed_bars * self.smoothing_factor) + ( + target_lengths * (1.0 - self.smoothing_factor) + ) + current_bar_lengths = self.smoothed_bars bar_lengths = np.clip(current_bar_lengths, 3.0, bar_zone) diff --git a/src/themes.py b/src/themes.py index df0b2b6..155b010 100644 --- a/src/themes.py +++ b/src/themes.py @@ -7,11 +7,17 @@ class ColorTheme: """Represents a color theme for visualization.""" - - def __init__(self, name: str, colors: List[str], bg_color: str = "#000000", text_color: str = "#FFFFFF"): + + def __init__( + self, + name: str, + colors: List[str], + bg_color: str = "#000000", + text_color: str = "#FFFFFF", + ): """ Initialize a color theme. - + Args: name: Theme name colors: List of hex color strings for gradients @@ -22,54 +28,54 @@ def __init__(self, name: str, colors: List[str], bg_color: str = "#000000", text self.colors = [QColor(c) for c in colors] self.bg_color = QColor(bg_color) self.text_color = QColor(text_color) - + def get_color(self, index: int) -> QColor: """Get a color from the theme by index (wraps around).""" return self.colors[index % len(self.colors)] - + def get_gradient_color(self, value: float) -> QColor: """ Get a color from the gradient based on value (0.0 to 1.0). - + Args: value: Position in gradient (0.0 to 1.0) - + Returns: Interpolated QColor """ value = max(0.0, min(1.0, value)) - + if len(self.colors) == 1: return self.colors[0] - + # Calculate which two colors to interpolate between segment = value * (len(self.colors) - 1) index = int(segment) - + if index >= len(self.colors) - 1: return self.colors[-1] - + # Interpolate between the two colors t = segment - index c1 = self.colors[index] c2 = self.colors[index + 1] - + r = int(c1.red() + (c2.red() - c1.red()) * t) g = int(c1.green() + (c2.green() - c1.green()) * t) b = int(c1.blue() + (c2.blue() - c1.blue()) * t) a = int(c1.alpha() + (c2.alpha() - c1.alpha()) * t) - + return QColor(r, g, b, a) - + def create_gradient(self, start: QPointF, end: QPointF) -> QLinearGradient: """Create a QLinearGradient from this theme.""" gradient = QLinearGradient(start, end) - + num_colors = len(self.colors) for i, color in enumerate(self.colors): position = i / (num_colors - 1) if num_colors > 1 else 0 gradient.setColorAt(position, color) - + return gradient @@ -77,104 +83,141 @@ def create_gradient(self, start: QPointF, end: QPointF) -> QLinearGradient: THEMES: Dict[str, ColorTheme] = { "modern": ColorTheme( name="Modern", - colors=["#7A5FFF", "#00E5FF", "#00FFC2"], # Deep Purple to Bright Neon Cyan/Mint + colors=[ + "#7A5FFF", + "#00E5FF", + "#00FFC2", + ], # Deep Purple to Bright Neon Cyan/Mint bg_color="#05050A", - text_color="#F0F8FF" + text_color="#F0F8FF", ), - "cyberpunk": ColorTheme( name="Cyberpunk", - colors=["#FF007F", "#B900FF", "#00F0FF", "#00FF66"], # Vibrant Pink, Violet, Bright Cyan, Neon Green + colors=[ + "#FF007F", + "#B900FF", + "#00F0FF", + "#00FF66", + ], # Vibrant Pink, Violet, Bright Cyan, Neon Green bg_color="#0A0214", - text_color="#00F0FF" + text_color="#00F0FF", ), - "aurora": ColorTheme( name="Aurora", - colors=["#00FF87", "#00FFFF", "#0055FF", "#7B2CBF"], # Sharp Green to Deep Royal Blue-Purple + colors=[ + "#00FF87", + "#00FFFF", + "#0055FF", + "#7B2CBF", + ], # Sharp Green to Deep Royal Blue-Purple bg_color="#020815", - text_color="#00FFFF" + text_color="#00FFFF", ), - "aesthetic": ColorTheme( name="Aesthetic", - colors=["#FF99C8", "#D9A8FF", "#A9C1FF", "#9EEBCB"], # Richer pastels + colors=["#FF99C8", "#D9A8FF", "#A9C1FF", "#9EEBCB"], # Richer pastels bg_color="#FAF5F8", - text_color="#333333" + text_color="#333333", ), - "classic": ColorTheme( name="Classic", - colors=["#1EFF00", "#18CC00", "#109900"], + colors=["#1EFF00", "#18CC00", "#109900"], bg_color="#000000", - text_color="#1EFF00" + text_color="#1EFF00", ), - "fire": ColorTheme( name="Fire", - colors=["#FF1100", "#FF4500", "#FF8C00", "#FFD700", "#FFFF33"], # More dynamic fire curve + colors=[ + "#FF1100", + "#FF4500", + "#FF8C00", + "#FFD700", + "#FFFF33", + ], # More dynamic fire curve bg_color="#0F0000", - text_color="#FF8C00" + text_color="#FF8C00", ), - "ocean": ColorTheme( name="Ocean", - colors=["#001F54", "#034078", "#0A1128", "#1282A2", "#00E5FF"], # Deeper abyss to bright surface + colors=[ + "#001F54", + "#034078", + "#0A1128", + "#1282A2", + "#00E5FF", + ], # Deeper abyss to bright surface bg_color="#010A15", - text_color="#00E5FF" + text_color="#00E5FF", ), - "sunset": ColorTheme( name="Sunset", - colors=["#FF3366", "#FF6B35", "#F4A261", "#E9C46A", "#9B5DE5"], # Warmer midtones mixing with purple night + colors=[ + "#FF3366", + "#FF6B35", + "#F4A261", + "#E9C46A", + "#9B5DE5", + ], # Warmer midtones mixing with purple night bg_color="#12050C", - text_color="#F4A261" + text_color="#F4A261", ), - "neon": ColorTheme( name="Neon", colors=["#FF00FF", "#FF0055", "#FF3300", "#FFFF00", "#33FF00", "#00FFFF"], bg_color="#000000", - text_color="#FAFAFA" + text_color="#FAFAFA", ), - "monochrome": ColorTheme( name="Monochrome", colors=["#FFFFFF", "#D4D4D4", "#A3A3A3", "#737373"], bg_color="#050505", - text_color="#FFFFFF" + text_color="#FFFFFF", ), - "rainbow": ColorTheme( name="Rainbow", colors=["#FF0040", "#FF8000", "#FFEE00", "#00FF00", "#0040FF", "#8A2BE2"], bg_color="#000000", - text_color="#FFFFFF" + text_color="#FFFFFF", ), - "deep_space": ColorTheme( name="Deep Space", - colors=["#05001A", "#1A004D", "#4B0099", "#8C1AFF", "#D966FF"], # Vibrant nebula purples + colors=[ + "#05001A", + "#1A004D", + "#4B0099", + "#8C1AFF", + "#D966FF", + ], # Vibrant nebula purples bg_color="#02000A", - text_color="#D966FF" + text_color="#D966FF", ), - "lava": ColorTheme( name="Lava", colors=["#2A0000", "#5E0000", "#A30000", "#E63900", "#FF7F00"], bg_color="#0A0000", - text_color="#FFB366" - ) + text_color="#FFB366", + ), + "spectre": ColorTheme( + name="Spectre", + colors=[ + "#4A00E0", + "#8E2DE2", + "#FF00FF", + "#00FFFF", + ], # Purple to Neon Pink and Cyan + bg_color="#0D001A", + text_color="#00FFFF", + ), } def get_theme(name: str) -> ColorTheme: """ Get a theme by name. - + Args: name: Theme name (lowercase) - + Returns: ColorTheme instance """ diff --git a/src/ui/controls.py b/src/ui/controls.py index 55445f1..7dfa178 100644 --- a/src/ui/controls.py +++ b/src/ui/controls.py @@ -33,7 +33,7 @@ def __init__(self, parent=None): """Initialize control panel.""" super().__init__(parent) - self.setWindowFlags(Qt.Tool | Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint) + self.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint) self.setAttribute(Qt.WA_TranslucentBackground) self.setObjectName("ControlPanel") diff --git a/src/ui/main_window.py b/src/ui/main_window.py index c72c32f..6b6ac89 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 @@ -23,6 +23,13 @@ def __init__(self): """Initialize main window.""" super().__init__() + # Drag tracking + self._drag_pos = None + + # Resizing tracking + self._resize_mode = None + self._resize_margin = 8 # px distance from edge to resize + # Configuration self.config = Config() @@ -75,8 +82,17 @@ def _setup_ui(self): self.setMinimumSize(800, 600) self.resize(1200, 800) + # Frameless and translucent + self.setWindowFlags(self.windowFlags() | Qt.FramelessWindowHint) + self.setAttribute(Qt.WA_TranslucentBackground, True) + + # Essential to receive mouse events for resize cursor updates + self.setMouseTracking(True) + # Set central widget self.setCentralWidget(self.visualizer_widget) + # Visualizer also needs mouse tracking to pass through to main window + self.visualizer_widget.setMouseTracking(True) # Load and apply modern stylesheet self._apply_stylesheet() @@ -128,6 +144,18 @@ def _setup_shortcuts(self): set_act.triggered.connect(self._show_settings) self.addAction(set_act) + # Cycle Style (Ctrl+V) + cycle_style_act = QAction(self) + cycle_style_act.setShortcut(QKeySequence("Ctrl+V")) + cycle_style_act.triggered.connect(self._cycle_style) + self.addAction(cycle_style_act) + + # Cycle Theme (Ctrl+T) + cycle_theme_act = QAction(self) + cycle_theme_act.setShortcut(QKeySequence("Ctrl+T")) + cycle_theme_act.triggered.connect(self._cycle_theme) + self.addAction(cycle_theme_act) + # Screenshot (S) sc_act = QAction(self) sc_act.setShortcut(QKeySequence("S")) @@ -230,6 +258,47 @@ def _change_theme(self, theme_name: str): self.visualizer_widget.set_theme(theme) self.config.set("theme", theme_name) + def _cycle_style(self): + """Cycle to the next visualization style.""" + from visualizer_factory import VisualizerFactory + + styles = VisualizerFactory.get_available_styles() + if not styles: + return + + current_style = self.config.get("style", "spectrum_bars") + try: + current_index = styles.index(current_style) + next_index = (current_index + 1) % len(styles) + except ValueError: + next_index = 0 + + next_style = styles[next_index] + self._change_style(next_style) + self.control_panel.set_current_style(next_style) + + def _cycle_theme(self): + """Cycle to the next color theme.""" + from themes import get_theme_names + + theme_names = get_theme_names() + if not theme_names: + return + + current_theme = self.config.get("theme", "modern") + + current_index = 0 + for i, name in enumerate(theme_names): + if name.lower() == current_theme.lower(): + current_index = i + break + + next_index = (current_index + 1) % len(theme_names) + next_theme = theme_names[next_index].lower() + + self._change_theme(next_theme) + self.control_panel.set_current_theme_name(next_theme) + def _load_background(self, file_path: str, save: bool = True): """Load background image.""" pixmap = load_image(file_path) @@ -253,6 +322,7 @@ def _change_device(self, device_index: int): def _change_smoothing(self, smoothing: float): """Change smoothing factor.""" + logger.info(f"Smoothing set to: {smoothing:.2f}") self.audio_processor.set_smoothing(smoothing) self.config.set("smoothing", smoothing) @@ -276,6 +346,7 @@ def _change_sample_rate(self, rate: int): def _change_opacity(self, opacity: float): """Change background opacity.""" + logger.info(f"Background opacity set to: {opacity:.2f}") self.visualizer_widget.set_background_opacity(opacity) self.visualizer_widget.update() # Force redraw self.config.set("opacity", opacity) @@ -393,6 +464,94 @@ def keyPressEvent(self, event): else: super().keyPressEvent(event) + def _edges_at(self, pos): + """Determine which edge(s) the mouse is over.""" + edges = Qt.Edge(0) + rect = self.rect() + x, y = pos.x(), pos.y() + + if x < self._resize_margin: + edges |= Qt.LeftEdge + elif x > rect.width() - self._resize_margin: + edges |= Qt.RightEdge + + if y < self._resize_margin: + edges |= Qt.TopEdge + elif y > rect.height() - self._resize_margin: + edges |= Qt.BottomEdge + + return edges + + def _update_cursor(self, edges): + """Update cursor based on mouse position near edges.""" + if edges == (Qt.LeftEdge | Qt.TopEdge) or edges == ( + Qt.RightEdge | Qt.BottomEdge + ): + self.setCursor(Qt.SizeFDiagCursor) + elif edges == (Qt.RightEdge | Qt.TopEdge) or edges == ( + Qt.LeftEdge | Qt.BottomEdge + ): + self.setCursor(Qt.SizeBDiagCursor) + elif edges & Qt.LeftEdge or edges & Qt.RightEdge: + self.setCursor( + Qt.SizeVerCursor + if edges & Qt.TopEdge or edges & Qt.BottomEdge + else Qt.SizeHorCursor + ) + elif edges & Qt.TopEdge or edges & Qt.BottomEdge: + self.setCursor(Qt.SizeVerCursor) + else: + self.setCursor(Qt.ArrowCursor) + + def mousePressEvent(self, event): + """Handle mouse click for dragging and resizing.""" + if event.button() == Qt.LeftButton: + edges = self._edges_at(event.position().toPoint()) + if edges and not self.isFullScreen(): + self._resize_mode = edges + # Native startSystemResize provides native OS window resizing instead of manual calculation + self.windowHandle().startSystemResize(edges) + else: + self._drag_pos = ( + event.globalPosition().toPoint() - self.frameGeometry().topLeft() + ) + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + """Handle mouse move for dragging window.""" + edges = self._edges_at(event.position().toPoint()) + self._update_cursor(edges) + + if ( + event.buttons() == Qt.LeftButton + and self._drag_pos is not None + and not self._resize_mode + ): + # If not resizing, we are dragging + if not self.isFullScreen(): + self.windowHandle().startSystemMove() + self._drag_pos = None # Reset so it doesn't conflict + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + """Handle mouse release.""" + self._drag_pos = None + self._resize_mode = None + super().mouseReleaseEvent(event) + + def changeEvent(self, event): + """Handle window state changes.""" + if event.type() == QEvent.WindowStateChange: + if self.isMinimized(): + if self.control_panel.isVisible(): + self._control_panel_was_visible = True + self.control_panel.hide() + else: + self._control_panel_was_visible = False + elif getattr(self, "_control_panel_was_visible", False): + self.control_panel.show() + super().changeEvent(event) + def closeEvent(self, event): """Handle window close event.""" # Stop audio processing diff --git a/src/ui/settings_dialog.py b/src/ui/settings_dialog.py index 5c9fa47..355dd5c 100644 --- a/src/ui/settings_dialog.py +++ b/src/ui/settings_dialog.py @@ -232,10 +232,15 @@ def get_sample_rate(self) -> int: def _on_smoothing_changed(self, value: int): """Handle smoothing slider change.""" self.smoothing_label.setText(f"{value / 100:.2f}") + new_smoothing = value / 100.0 + self.smoothing_changed.emit(new_smoothing) + self.current_state["smoothing"] = new_smoothing def _on_gain_changed(self, value: int): """Handle gain slider change.""" self.gain_label.setText(f"{value}%") + self.gain_changed.emit(float(value)) + self.current_state["gain"] = float(value) def _on_opacity_changed(self, value: int): """Handle opacity slider change.""" diff --git a/src/visualizer.py b/src/visualizer.py index 4dc3a6e..0a2c137 100644 --- a/src/visualizer.py +++ b/src/visualizer.py @@ -7,9 +7,8 @@ from numpy import ndarray from abc import ABC, abstractmethod from PySide6.QtWidgets import QWidget -from PySide6.QtGui import QPainter, QPixmap, QPen, QBrush, QColor -from PySide6.QtCore import Qt, QTimer, QPointF -from typing import Optional +from PySide6.QtGui import QPainter, QPixmap, QPen, QColor +from PySide6.QtCore import Qt, QTimer from themes import ColorTheme, get_theme from utils import logger from ui.overlay import DebugOverlay @@ -66,7 +65,8 @@ def __init__(self, parent=None): # Set widget properties self.setMinimumSize(800, 600) - self.setAttribute(Qt.WA_OpaquePaintEvent) + self.setAttribute(Qt.WA_OpaquePaintEvent, False) + self.setMouseTracking(True) # Current visualizer self.visualizer: Optional[BaseVisualizer] = None @@ -263,7 +263,9 @@ def paintEvent(self, event): def _draw_background(self, painter: QPainter): """Draw widget background and image.""" - painter.fillRect(self.rect(), self.current_theme.bg_color) + bg_color = QColor(self.current_theme.bg_color) + bg_color.setAlphaF(self.background_opacity) + painter.fillRect(self.rect(), bg_color) if self.background_image: painter.save() @@ -287,6 +289,16 @@ def _draw_no_visualizer_message(self, painter: QPainter): painter.setPen(QPen(QColor(255, 255, 255))) painter.drawText(self.rect(), Qt.AlignCenter, "No visualizer set") + def mouseMoveEvent(self, event): + """Pass mouse move events to parent for resize cursor tracking.""" + event.ignore() + super().mouseMoveEvent(event) + + def mousePressEvent(self, event): + """Pass mouse press events to parent for dragging/resizing.""" + event.ignore() + super().mousePressEvent(event) + def resizeEvent(self, event): """Handle resize events.""" super().resizeEvent(event) From 38a3928c37ece0df4ecafdd453746d020e869547 Mon Sep 17 00:00:00 2001 From: userlg Date: Tue, 24 Feb 2026 10:48:05 -0400 Subject: [PATCH 11/13] docs: update readme with frameless window features, spectre theme, and new keyboard/mouse controls --- README.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5077b2d..00198a0 100644 --- a/README.md +++ b/README.md @@ -26,19 +26,21 @@ 1. **Modern** - Indigo to Cyan gradients (Premium default) 2. **Cyberpunk** - High-contrast Neon Pink & Blue 3. **Aurora** - Northern Lights inspired (Green/Blue) [NEW] -4. **Aesthetic** - Soft pastel colors -5. **Classic** - Retro green monochrome -6. **Fire** - intense Red to yellow flame colors -7. **Ocean** - Deep blue to cyan waves -8. **Sunset** - Warm Orange and Purple hues -9. **Neon** - Bright multi-color spectrum -10. **Rainbow** - Full ROYGBIV spectrum +4. **Spectre** - Ultra-violet to Cyan neon [NEW] +5. **Aesthetic** - Soft pastel colors +6. **Classic** - Retro green monochrome +7. **Fire** - intense Red to yellow flame colors +8. **Ocean** - Deep blue to cyan waves +9. **Sunset** - Warm Orange and Purple hues +10. **Neon** - Bright multi-color spectrum +11. **Rainbow** - Full ROYGBIV spectrum ### 🎛️ Advanced Features +- 🪟 **Frameless & Translucent** - True "Glass" transparent backgrounds overlapping your desktop - 🖼️ **Custom backgrounds** - Persistent background loading - 🎚️ **Audio device selection** - Choose input source -- ⚙️ **Configurable settings** - Adjust smoothing, sample rate, FFT size +- ⚙️ **Configurable settings** - Adjust smoothing, sample rate, FFT size, and Gain in real-time - 🖥️ **Fullscreen mode** - Immersive experience (F11) - 💾 **Settings persistence** - Robust saving to AppData - 📊 **Real-time performance** - 60 FPS smooth rendering From f3bbb1a5e5d96a0249c897e7f413bef23b9f39e1 Mon Sep 17 00:00:00 2001 From: userlg Date: Tue, 24 Feb 2026 10:55:07 -0400 Subject: [PATCH 12/13] feat(ui): add ctrl+m to minimize, change quit to ctrl+x, update docs --- README.md | 12 +++++++++--- src/ui/main_window.py | 10 ++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 00198a0..3f18f01 100644 --- a/README.md +++ b/README.md @@ -113,13 +113,19 @@ python src/main.py - `F11` - Toggle fullscreen - `Ctrl+H` - Show/hide control panel +- `Ctrl+V` - Cycle Visualization Style +- `Ctrl+T` - Cycle Color Theme - `Ctrl+,` - Open settings -- `Ctrl+Q` - Quit application +- `S` - Take Screenshot +- `Ctrl+M` - Minimize application +- `Ctrl+X` - Quit application - `ESC` - Exit fullscreen -### �️ Mouse Controls +### 🖱️ Mouse Controls -- **Right-Click** anywhere to open the **Main Menu** (Settings, Toggle Controls, Fullscreen, Exit). +- **Left-Click & Drag** anywhere on the visualizer to move the frameless window freely. +- **Hover Edges** to dynamically resize the application window. +- *(Note: Right-click context menu is intentionally disabled in this version. Please rely on the keyboard shortcuts above to access settings and controls).* ## 📦 Building Executables diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 6b6ac89..4ea7ee2 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -162,9 +162,15 @@ def _setup_shortcuts(self): sc_act.triggered.connect(self._take_screenshot) self.addAction(sc_act) - # Exit (Ctrl+Q) + # Minimize (Ctrl+M) + min_act = QAction(self) + min_act.setShortcut(QKeySequence("Ctrl+M")) + min_act.triggered.connect(self.showMinimized) + self.addAction(min_act) + + # Exit (Ctrl+X) exit_act = QAction(self) - exit_act.setShortcut(QKeySequence("Ctrl+Q")) + exit_act.setShortcut(QKeySequence("Ctrl+X")) exit_act.triggered.connect(self.close) self.addAction(exit_act) From 6f42ccb1a5330b13a95ef77a00aee9fe41a46e39 Mon Sep 17 00:00:00 2001 From: userlg Date: Tue, 24 Feb 2026 10:57:53 -0400 Subject: [PATCH 13/13] feat: modify styles --- PROJECT_CONTEXT.md | 5 +- src/styles/oscilloscope.py | 166 +++++++++++++++++++------------------ src/styles/radial_bars.py | 14 ++-- 3 files changed, 94 insertions(+), 91 deletions(-) diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 425f1b7..30b9dbf 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -24,13 +24,14 @@ EchoPy is a real-time music visualizer built with Python, PySide6, and NumPy. It - **NumPy 2.0 Compatibility**: The system uses NumPy 2.4.1. - **Optimization**: `AudioProcessor` callback has been refactored for vectorized Boolean masking to avoid performance bottlenecks (Input Overflow). -## Current State (Updated 2026-01-28) +## Current State (Updated 2026-02-20) - **Logic**: Fully optimized for NumPy 2.0. - **Audio Capture**: IMPROVED. - Integrated **Weighted Multichannel Downmixing** (inspired by CAVA) to preserve surround audio fidelity on NVIDIA SyncMaster/HDMI drivers. - Enhanced **WASAPI Loopback Discovery** with name-matching and "SyncMaster" prioritization. -- **Visuals**: Superior 'liquid' movement achieved via **CavaFilter** (Integral + Fall-off filters), replacing simple EMA smoothing. +- **Visuals**: Superior 'liquid' movement achieved via **CavaFilter** (Integral + Fall-off filters) tuned for elasticity. + - Glassmorphism UI enhanced with deeper transparencies and vibrant glow effects (Spectrum/Circular). - **Calibration**: `NOISE_FLOOR = 0.00020` and `GAIN = 15000` maintained for clean response. ## Advanced Deployment (Frozen App Strategy) diff --git a/src/styles/oscilloscope.py b/src/styles/oscilloscope.py index 025d194..1a098ee 100644 --- a/src/styles/oscilloscope.py +++ b/src/styles/oscilloscope.py @@ -1,108 +1,110 @@ from __future__ import annotations 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.smooth_scale = 1.0 - self.smooth_flicker = 0.0 - self.interpolation_factor = 0.15 # Determina la inercia del movimiento + self.interpolation_factor = 0.25 # Más rápido para mayor respuesta + self.scan_y = 0.0 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 - self.smooth_flicker += (target_flicker - self.smooth_flicker) * self.interpolation_factor - + + # 1. Procesamiento de Energía + avg_energy = float(np.mean(np.abs(waveform))) + center_x = self.width / 2 center_y = self.height / 2 - # 2. Retícula de Enfoque + # 2. Retícula del Osciloscopio (Grid) grid_color = QColor(self.theme.get_color(0)) - grid_color.setAlpha(40) + grid_color.setAlpha(25) painter.setPen(QPen(grid_color, 1)) - + + # Dibujar cuadrícula + grid_spacing = 50 + for x in range(int(center_x) % grid_spacing, int(self.width), grid_spacing): + painter.drawLine(x, 0, x, int(self.height)) + for y in range(int(center_y) % grid_spacing, int(self.height), grid_spacing): + painter.drawLine(0, y, int(self.width), y) + + # Regla central + grid_color.setAlpha(60) + painter.setPen(QPen(grid_color, 2)) + painter.drawLine(0, int(center_y), int(self.width), int(center_y)) + painter.drawLine(int(center_x), 0, int(center_x), int(self.height)) + + # 3. Construcción del Lissajous ultra fluido + num_points = min(1024, len(waveform)) + + # Escala dinámica suave base_r = min(self.width, self.height) - for r_factor in [0.2, 0.4, 0.6]: - 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) - 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" + target_scale = (base_r * 0.45) * (1.0 + avg_energy * 2.0) + self.smooth_scale += ( + target_scale - self.smooth_scale + ) * self.interpolation_factor + + # Mapeo de Lissajous (X = señal, Y = señal desfasada) + shift = num_points // 4 # Desfase para formar curvas cerradas + x_vals = waveform[:num_points] + y_vals = np.roll(waveform, shift)[:num_points] + + px = center_x + x_vals * self.smooth_scale + py = center_y + y_vals * self.smooth_scale + + # Combinar en un QPolygonF para dibujado Vectorizado (Ultra Rápido) + points = [QPointF(float(x), float(y)) for x, y in zip(px, py)] + poly = QPolygonF(points) + + # 4. Renderizado con efecto CRT Glow Múltiple main_color = self.theme.get_color(0) - - # Glow - 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) - - # Núcleo - core_color = QColor(main_color) - if self.smooth_flicker > 0.6: - core_color = core_color.lighter(140) - - 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.drawLine(0, int(scanline_y), self.width, int(scanline_y)) - + + # Glow exterior ancho + glow1 = QColor(main_color) + glow1.setAlpha(15) + painter.setPen(QPen(glow1, 18, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) + painter.drawPolyline(poly) + + # Glow interior intenso + glow2 = QColor(main_color) + glow2.setAlpha(60) + painter.setPen(QPen(glow2, 6, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) + painter.drawPolyline(poly) + + # Núcleo brillante + core = QColor(main_color.lighter(150)) + core.setAlpha(200) + painter.setPen(QPen(core, 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) + painter.drawPolyline(poly) + + # Filamento central super blanco + white_core = QColor(255, 255, 255, 255) + painter.setPen(QPen(white_core, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) + painter.drawPolyline(poly) + + # 5. Efecto Scanline constante (ciclo continuo, sin tirones) + self.scan_y += 1.5 + if self.scan_y > self.height: + self.scan_y = 0.0 + + scan_color = QColor(255, 255, 255, 12) + painter.setPen(QPen(scan_color, 4)) + painter.drawLine(0, int(self.scan_y), int(self.width), int(self.scan_y)) + + # Viñeta (bordes oscuros) vignette = QRadialGradient(QPointF(center_x, center_y), self.width * 0.7) vignette.setColorAt(0, Qt.transparent) - vignette.setColorAt(1, QColor(0, 0, 0, 160)) + vignette.setColorAt(1, QColor(0, 0, 0, 180)) 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, int(self.width), int(self.height)) diff --git a/src/styles/radial_bars.py b/src/styles/radial_bars.py index 3b87784..c9a8a29 100644 --- a/src/styles/radial_bars.py +++ b/src/styles/radial_bars.py @@ -1,9 +1,7 @@ from __future__ import annotations import numpy as np -import math from PySide6.QtGui import ( QPainter, - QPen, QBrush, QColor, QRadialGradient, @@ -19,7 +17,6 @@ class RadialBars(BaseVisualizer): def __init__(self): super().__init__("Radial Bars") self.num_rays = 120 - self.min_radius = 60 # Núcleo más sólido self.smoothed_bass = 0.0 def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): @@ -30,8 +27,11 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): center_x = self.width / 2 center_y = self.height / 2 + + # Tamaño dinámico basado en la pantalla (mucho más grande) + min_radius = min(self.width, self.height) * 0.18 max_radius = min(self.width, self.height) / 2 - 20 - bar_len_max = max_radius - self.min_radius + bar_len_max = max_radius - min_radius # ──────────── Frequency Binning (Vocal focus: 25%) ──────────── n_fft = len(fft_data) @@ -66,7 +66,7 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): # Pulso del núcleo central bass_energy = float(np.mean(magnitudes[: max(1, bass_end)])) self.smoothed_bass += (bass_energy - self.smoothed_bass) * 0.2 - pulse_r = self.min_radius + (self.smoothed_bass * 40.0) + pulse_r = min_radius + (self.smoothed_bass * 70.0) # Pulso más pronunciado # ──────────── Renderizado Estático (Sin Giro) ──────────── # Guardamos el estado central @@ -79,8 +79,8 @@ def render(self, painter: QPainter, waveform: np.ndarray, fft_data: np.ndarray): mag = magnitudes[i] # Longitud de barra con multiplicador de impacto - bar_len = mag * bar_len_max * 1.8 - bar_len = np.clip(bar_len, 4.0, bar_len_max) + bar_len = mag * bar_len_max * 2.5 + bar_len = np.clip(bar_len, 6.0, bar_len_max) # Color según posición radial (Armonía) color_pos = i / self.num_rays