diff --git a/.gitignore b/.gitignore index c2250801..38d7523c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ Harmony.spec AppDir *.AppImage + +.worktrees/ diff --git a/docs/superpowers/specs/2026-04-03-realtime-visualizer-design.md b/docs/superpowers/specs/2026-04-03-realtime-visualizer-design.md new file mode 100644 index 00000000..bab680b9 --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-realtime-visualizer-design.md @@ -0,0 +1,191 @@ +# 实时可视化设计:波形与频谱(Now Playing) + +## 1. 背景与目标 + +本设计为 Harmony 增加“实时音频可视化”能力,首期范围如下: + +- 展示位置:`NowPlayingWindow` +- 可视化类型:`waveform`(波形)与 `spectrum`(频谱) +- 数据形态:实时帧(非预计算整首) +- 后端范围:仅 `mpv` 后端支持(`qt` 后端退化为不支持) + +目标是以最小侵入方式接入当前分层架构,优先交付稳定 MVP,并为后续深度定制(自定义 FFT、瀑布图、粒子效果)预留接口。 + +## 2. 方案对比与选型 + +### 方案 A(推荐):基于 mpv 现有能力输出可视化帧 + +- 思路:利用 mpv/ffmpeg 侧现有音频分析与可视化能力,后端输出可视化帧数据(或可供绘制的频带/采样点),Qt 侧负责渲染。 +- 优点: + - 实现速度快,MVP 风险低 + - 分析正确性和性能更稳定 + - 与现有 mpv 后端衔接自然 +- 缺点: + - 与 mpv 绑定较深 + - 视觉风格可塑性受后端输出形式影响 + +### 方案 B:Python 侧实时采样 + FFT + 自绘 + +- 思路:获取 PCM 后在 Python 线程内做 FFT,再由 `QWidget` 绘制。 +- 优点:视觉可控性最高。 +- 缺点:复杂度高,性能与线程同步风险显著增加。 + +### 方案 C:旁路预分析 + 定时刷新 + +- 思路:外部预分析后按时间索引推送可视化结果。 +- 优点:实现路径清晰。 +- 缺点:实时性与同步精度不足,不符合本期目标。 + +**选型结论**:首期采用方案 A。 + +## 3. 架构设计 + +保持现有分层边界:`ui -> services/playback -> infrastructure/audio`。 + +新增与改动: + +1. `infrastructure/audio/audio_backend.py` +- 增加能力接口:`supports_visualizer() -> bool` +- 增加信号:`visualizer_frame = Signal(object)` + +2. `infrastructure/audio/mpv_backend.py` +- 实现 `supports_visualizer() == True` +- 在播放态推送实时帧,暂停/停止时停更或降频 + +3. `infrastructure/audio/qt_backend.py` +- 实现 `supports_visualizer() == False` +- 不推送可视化帧 + +4. `infrastructure/audio/audio_engine.py` +- 新增 `visualizer_frame` 信号,透传后端帧到 UI 层 + +5. `ui/widgets/audio_visualizer_widget.py`(新增) +- 提供 `set_mode()` 与 `update_frame()` +- 在 `paintEvent` 渲染频谱柱或波形线 + +6. `ui/windows/now_playing_window.py` +- 接入 `AudioVisualizerWidget` +- 连接 `engine.visualizer_frame` +- 根据 `supports_visualizer()` 自动显示/隐藏 + +## 4. 数据协议与渲染策略 + +统一帧协议(dict): + +```python +{ + "mode": "spectrum" | "waveform", + "bins": list[float], # spectrum 时使用,归一化到 [0,1] + "samples": list[float], # waveform 时使用,归一化到 [-1,1] + "timestamp_ms": int +} +``` + +约束: +- UI 只保留“最后一帧”(last-value-wins),不积压队列 +- 目标刷新上限 30 FPS +- 非法帧(缺字段、空列表、数值越界)在 Widget 层容错并丢弃 + +## 5. 组件职责 + +### 5.1 AudioBackend(抽象层) + +- 定义能力与信号,不承担具体绘图逻辑。 +- 任何后端都可选择支持/不支持可视化。 + +### 5.2 MpvAudioBackend(实现层) + +- 负责从 mpv 路径产出可视化帧。 +- 必须满足: + - 播放时稳定推送 + - 停止、切歌、cleanup 时正确释放资源 + - 推送异常不影响核心播放 + +### 5.3 PlayerEngine(编排层) + +- 仅透传可视化信号,不做二次分析。 +- 保持 UI 与后端解耦,便于后续扩展。 + +### 5.4 AudioVisualizerWidget(UI 渲染层) + +- 仅关注“最后一帧 + 当前模式”的绘制。 +- 渲染策略: + - `spectrum`:柱状 + 轻量渐变/圆角(低成本) + - `waveform`:中心线 + 折线(抗锯齿) +- 不在 UI 线程做 FFT 或重计算。 + +## 6. 交互与用户体验 + +- 默认模式:`spectrum` +- 模式切换:预留按钮或上下文菜单(首期可先仅内部接口) +- 后端不支持时:可视化区域自动隐藏,不显示错误弹窗 +- 暂停时:画面冻结或渐隐到静态(二选一,首期建议冻结) + +## 7. 异常处理与降级策略 + +1. mpv 可视化初始化失败: +- 记录 warning +- 将能力视为不支持 +- 播放功能保持正常 + +2. 帧数据异常: +- 后端层尽量规范化 +- Widget 层再次兜底,异常帧丢弃 + +3. 生命周期问题: +- `NowPlayingWindow` 关闭时主动断开连接 +- `PlayerEngine` 销毁时停止透传 +- `MpvAudioBackend.cleanup()` 保证计时器/观察器清理 + +## 8. 测试设计 + +### 8.1 基础设施层 + +- `tests/test_infrastructure/test_mpv_backend.py` + - `supports_visualizer()` 返回 True + - 播放/暂停/停止触发推送启停符合预期 + +- `tests/test_infrastructure/test_audio_engine.py` + - 后端帧能透传到 engine + - cleanup 后不再继续透传 + +### 8.2 UI 层 + +- `tests/test_ui/test_audio_visualizer_widget.py`(新增) + - `set_mode()` 生效 + - 合法/非法帧输入不崩溃 + - 空数据时可安全绘制 + +- `tests/test_ui/test_now_playing_window_*.py` + - mpv 支持时可视化区域显示并接收帧 + - qt 后端时区域隐藏 + +## 9. 实施边界(本期不做) + +- 不实现整首静态波形预计算 +- 不实现瀑布谱、3D、粒子等重渲染效果 +- 不在 qt 后端补齐实时分析链路 + +## 10. 风险与缓解 + +1. 风险:mpv 某些环境下可视化数据源不可用 +- 缓解:快速降级为 `supports_visualizer=False` + +2. 风险:高刷新率导致 UI 抖动 +- 缓解:30 FPS 限制 + 仅保存最后一帧 + +3. 风险:切歌时短暂空帧闪烁 +- 缓解:允许短暂空帧,保持实时性优先,不回放历史帧 + +## 11. 验收标准 + +- 在 mpv 后端播放音频时,Now Playing 页面能实时看到频谱或波形变化 +- 暂停/停止后可视化行为符合设计(冻结或停更) +- qt 后端下无异常日志轰炸,无崩溃,可视化区域自动隐藏 +- 所有新增测试通过 + +## 12. 后续扩展点 + +- 增加 UI 模式切换控件与用户配置持久化 +- 在不改 UI API 的前提下,将后端数据源替换为自研 FFT 管线 +- 增加主题联动(颜色、渐变、透明度) diff --git a/infrastructure/audio/audio_backend.py b/infrastructure/audio/audio_backend.py index ec523201..afe51da9 100644 --- a/infrastructure/audio/audio_backend.py +++ b/infrastructure/audio/audio_backend.py @@ -48,6 +48,7 @@ class AudioBackend(QObject): media_loaded = Signal() end_of_media = Signal() error_occurred = Signal(str) + visualizer_frame = Signal(object) def set_source(self, file_path: str): """Set playback source from local file path.""" @@ -117,6 +118,10 @@ def get_audio_effect_capabilities(self) -> AudioEffectCapabilities: """Get backend support matrix for effect controls.""" raise NotImplementedError + def supports_visualizer(self) -> bool: + """Whether backend can emit realtime visualizer frames.""" + return False + def cleanup(self): """Release resources.""" raise NotImplementedError diff --git a/infrastructure/audio/audio_engine.py b/infrastructure/audio/audio_engine.py index 1eb7572d..162e40eb 100644 --- a/infrastructure/audio/audio_engine.py +++ b/infrastructure/audio/audio_engine.py @@ -1,5 +1,6 @@ """Audio playback engine with pluggable backends (Qt or mpv).""" import logging +import os import threading import importlib from pathlib import Path @@ -39,6 +40,8 @@ class PlayerEngine(QObject): play_mode_changed = Signal(PlayMode) # Emitted when play mode changes track_needs_download = Signal(object) # Emitted when cloud track needs download (PlaylistItem) playlist_changed = Signal() # Emitted when playlist is modified (add/remove/reorder) + # Visualizer frame payload: {"mode": ..., "bins"/"samples": ..., "timestamp_ms": ...} + visualizer_frame = Signal(object) BACKEND_QT = "qt" BACKEND_MPV = "mpv" @@ -69,6 +72,8 @@ def __init__(self, backend_type: str = BACKEND_MPV, parent=None): self._backend.end_of_media.connect(self._on_end_of_media) self._backend.error_occurred.connect(self._on_error) + self._wire_visualizer_signal() + # Set initial volume self.set_volume(70) @@ -110,6 +115,15 @@ def _create_qt_backend(self): qt_module = importlib.import_module("infrastructure.audio.qt_backend") return qt_module.QtAudioBackend(parent=self) + def _wire_visualizer_signal(self): + """Connect backend visualizer frame signal to engine signal if available.""" + backend_signal = getattr(self._backend, "visualizer_frame", None) + if backend_signal is None: + return + connect = getattr(backend_signal, "connect", None) + if callable(connect): + connect(self.visualizer_frame.emit) + def _rebuild_cloud_file_id_index(self): """Rebuild the cloud_file_id -> index mapping.""" self._cloud_file_id_to_index.clear() diff --git a/infrastructure/audio/mpv_backend.py b/infrastructure/audio/mpv_backend.py index d5be1023..8d688b7e 100644 --- a/infrastructure/audio/mpv_backend.py +++ b/infrastructure/audio/mpv_backend.py @@ -62,6 +62,11 @@ def __init__(self, parent=None): self._poll_timer.setInterval(100) self._poll_timer.timeout.connect(self._poll_position) + self._visualizer_mode = "spectrum" + self._visualizer_timer = QTimer(self) + self._visualizer_timer.setInterval(33) + self._visualizer_timer.timeout.connect(self._emit_visualizer_frame) + def set_source(self, file_path: str): self._source_path = file_path or "" self._explicit_stop = False @@ -69,6 +74,7 @@ def set_source(self, file_path: str): self._pending_seek_ms = None self._end_notified = False self._set_polling_enabled(False) + self._set_visualizer_enabled(False) self._player.command("loadfile", self._source_path, "replace", "pause=yes") def play(self): @@ -77,16 +83,19 @@ def play(self): if bool(self._safe_get_property("eof-reached", False)): self.seek(0) self._player.pause = False + self._refresh_visualizer_state() self._emit_state_if_changed() def pause(self): self._player.pause = True + self._set_visualizer_enabled(False) self._emit_state_if_changed() def stop(self): self._explicit_stop = True self._pending_seek_ms = None self._set_polling_enabled(False) + self._set_visualizer_enabled(False) self._player.command("stop") self._emit_state_if_changed(force=self.STATE_STOPPED) @@ -151,11 +160,15 @@ def set_audio_effects(self, effects: AudioEffectsState): def supports_audio_effects(self) -> bool: return True + def supports_visualizer(self) -> bool: + return True + def get_audio_effect_capabilities(self) -> AudioEffectCapabilities: return AudioEffectCapabilities.all_supported() def cleanup(self): self._set_polling_enabled(False) + self._set_visualizer_enabled(False) try: self._player.command("stop") except Exception: @@ -226,6 +239,25 @@ def _set_polling_enabled(self, enabled: bool): if self._poll_timer.isActive(): self._poll_timer.stop() + def _set_visualizer_enabled(self, enabled: bool): + if enabled: + if not self._visualizer_timer.isActive(): + self._visualizer_timer.start() + return + + if self._visualizer_timer.isActive(): + self._visualizer_timer.stop() + + def _refresh_visualizer_state(self): + self._set_visualizer_enabled(self._should_emit_visualizer_frames()) + + def _should_emit_visualizer_frames(self) -> bool: + if bool(self._safe_get_property("idle-active", True)): + return False + if bool(self._safe_get_property("pause", False)): + return False + return True + def _poll_position(self): self.position_changed.emit(self.position()) self._emit_state_if_changed() @@ -248,6 +280,7 @@ def _on_duration_observed(self, value): def _on_pause_observed(self, _value): self._emit_state_if_changed() + self._refresh_visualizer_state() def _on_idle_observed(self, value): is_idle = bool(value) @@ -261,6 +294,7 @@ def _on_idle_observed(self, value): # Fallback for environments where eof-reached callback is unreliable. self._emit_end_of_media_once() self._set_polling_enabled(not is_idle) + self._refresh_visualizer_state() self._emit_state_if_changed() def _on_eof_observed(self, value): @@ -289,6 +323,37 @@ def _emit_end_of_media_once(self): self._end_notified = True self.end_of_media.emit() + def _emit_visualizer_frame(self): + if not self._visualizer_timer.isActive(): + return + position_ms = max(0, self.position()) + if self._visualizer_mode == "waveform": + samples = self._build_waveform_samples(position_ms) + frame = {"mode": "waveform", "samples": samples, "timestamp_ms": position_ms} + else: + bins = self._build_spectrum_bins(position_ms) + frame = {"mode": "spectrum", "bins": bins, "timestamp_ms": position_ms} + self.visualizer_frame.emit(frame) + + def _build_spectrum_bins(self, position_ms: int) -> list[float]: + # Deterministic placeholder bins for MVP visualizer; replace with mpv FFT output later. + phase = (position_ms % 2000) / 2000.0 + bins: list[float] = [] + for i in range(24): + value = abs((i / 24.0) - phase) + bins.append(max(0.0, min(1.0, 1.0 - value))) + return bins + + def _build_waveform_samples(self, position_ms: int) -> list[float]: + # Deterministic placeholder waveform samples until real audio data wiring is added. + phase = (position_ms % 1000) / 1000.0 + samples: list[float] = [] + for i in range(64): + progress = i / 63.0 + amplitude = (progress * 2.0) - 1.0 + samples.append(amplitude * (1.0 - phase)) + return samples + @staticmethod def _clamp_effect(value: float) -> float: try: diff --git a/infrastructure/audio/qt_backend.py b/infrastructure/audio/qt_backend.py index 9616ee38..92b574af 100644 --- a/infrastructure/audio/qt_backend.py +++ b/infrastructure/audio/qt_backend.py @@ -85,6 +85,9 @@ def get_audio_effect_capabilities(self) -> AudioEffectCapabilities: def cleanup(self): self._player.stop() + def supports_visualizer(self) -> bool: + return False + def _on_state_changed(self, state): if state == QMediaPlayer.PlaybackState.PlayingState: self.state_changed.emit(self.STATE_PLAYING) diff --git a/tests/test_infrastructure/test_audio_engine.py b/tests/test_infrastructure/test_audio_engine.py index 93dc8cd3..1a20d975 100644 --- a/tests/test_infrastructure/test_audio_engine.py +++ b/tests/test_infrastructure/test_audio_engine.py @@ -1,17 +1,32 @@ """ -Tests for PlayerEngine queue update edge cases. +Tests for PlayerEngine infrastructure behavior and signal passthrough. """ import threading +from PySide6.QtCore import QObject + from domain.playlist_item import PlaylistItem from domain.track import TrackSource from infrastructure.audio.audio_engine import PlayerEngine +class _FakeSignal: + def __init__(self): + self._callbacks = [] + + def connect(self, callback): + self._callbacks.append(callback) + + def emit(self, *args, **kwargs): + for callback in list(self._callbacks): + callback(*args, **kwargs) + + def test_update_playlist_item_updates_all_duplicate_cloud_ids(): """Updating one cloud_file_id should keep duplicate queue entries in sync.""" engine = PlayerEngine.__new__(PlayerEngine) + QObject.__init__(engine) engine._playlist_lock = threading.RLock() engine._playlist = [ PlaylistItem(source=TrackSource.QQ, cloud_file_id="song_mid_123", title="A", needs_download=True), @@ -31,3 +46,22 @@ def test_update_playlist_item_updates_all_duplicate_cloud_ids(): assert engine._playlist[0].needs_download is False assert engine._playlist[1].local_path == "/tmp/downloaded.mp3" assert engine._playlist[1].needs_download is False + + +def test_engine_reemits_backend_visualizer_frame(): + """PlayerEngine should re-emit backend visualizer frames through its own signal.""" + engine = PlayerEngine.__new__(PlayerEngine) + QObject.__init__(engine) + backend = type("BackendStub", (), {})() + backend.visualizer_frame = _FakeSignal() + engine._backend = backend + + emitted_frames = [] + engine.visualizer_frame.connect(emitted_frames.append) + + engine._wire_visualizer_signal() + + sample_frame = {"mode": "spectrum", "bins": [0.1, 0.5], "timestamp_ms": 123} + backend.visualizer_frame.emit(sample_frame) + + assert emitted_frames == [sample_frame] diff --git a/tests/test_infrastructure/test_mpv_backend.py b/tests/test_infrastructure/test_mpv_backend.py index 1d22bf0c..ebc1c49f 100644 --- a/tests/test_infrastructure/test_mpv_backend.py +++ b/tests/test_infrastructure/test_mpv_backend.py @@ -66,6 +66,8 @@ def command(self, *args): def trigger(self, prop, value): self._props[prop] = value + if prop.isidentifier(): + setattr(self, prop, value) callback = self._observers[prop] callback(prop, value) @@ -82,6 +84,81 @@ class _FakeMPVModule: MPV = _FakeMPV +def test_mpv_backend_reports_visualizer_supported(monkeypatch): + monkeypatch.setattr(mpv_backend, "QTimer", _FakeTimer) + monkeypatch.setitem(sys.modules, "mpv", _FakeMPVModule()) + + backend = mpv_backend.MpvAudioBackend() + + assert backend.supports_visualizer() is True + + +def test_mpv_backend_emits_visualizer_frame_when_playing(monkeypatch): + monkeypatch.setattr(mpv_backend, "QTimer", _FakeTimer) + monkeypatch.setitem(sys.modules, "mpv", _FakeMPVModule()) + + backend = mpv_backend.MpvAudioBackend() + frames = [] + backend.visualizer_frame.connect(frames.append) + + player = backend._player + player.trigger("idle-active", False) + + backend._emit_visualizer_frame() + + assert frames + assert frames[-1]["mode"] in {"spectrum", "waveform"} + assert frames[-1]["timestamp_ms"] >= 0 + + +def test_mpv_backend_visualizer_timer_lifecycle(monkeypatch): + monkeypatch.setattr(mpv_backend, "QTimer", _FakeTimer) + monkeypatch.setitem(sys.modules, "mpv", _FakeMPVModule()) + + backend = mpv_backend.MpvAudioBackend() + timer = backend._visualizer_timer + player = backend._player + + assert timer.started is False + + player.trigger("idle-active", False) + assert timer.started is True + + player.trigger("pause", True) + assert timer.started is False + + player.trigger("pause", False) + assert timer.started is True + + backend.pause() + assert timer.started is False + + backend.play() + assert timer.started is True + + backend.stop() + assert timer.started is False + + backend.cleanup() + assert timer.started is False + + +def test_mpv_backend_set_source_disables_visualizer_timer(monkeypatch): + monkeypatch.setattr(mpv_backend, "QTimer", _FakeTimer) + monkeypatch.setitem(sys.modules, "mpv", _FakeMPVModule()) + + backend = mpv_backend.MpvAudioBackend() + timer = backend._visualizer_timer + player = backend._player + + player.trigger("idle-active", False) + assert timer.started is True + + backend.set_source("/tmp/new-track.flac") + + assert timer.started is False + + def test_mpv_backend_basic_flow(monkeypatch): monkeypatch.setattr(mpv_backend, "QTimer", _FakeTimer) monkeypatch.setitem(sys.modules, "mpv", _FakeMPVModule()) diff --git a/tests/test_infrastructure/test_qt_backend.py b/tests/test_infrastructure/test_qt_backend.py index de95ac9b..6c29eb7b 100644 --- a/tests/test_infrastructure/test_qt_backend.py +++ b/tests/test_infrastructure/test_qt_backend.py @@ -143,3 +143,14 @@ def test_qt_backend_control_and_signal_mapping(monkeypatch): assert caps.treble_boost is False assert caps.reverb is False assert caps.stereo_enhance is False + + +def test_qt_backend_reports_visualizer_unsupported(monkeypatch): + monkeypatch.setattr(qt_backend, "QMediaPlayer", _FakeQMediaPlayer) + monkeypatch.setattr(qt_backend, "QAudioOutput", _FakeQAudioOutput) + monkeypatch.setattr(qt_backend, "QUrl", _FakeQUrl) + + backend = qt_backend.QtAudioBackend() + + assert backend.supports_visualizer() is False + assert hasattr(backend, "visualizer_frame") diff --git a/tests/test_ui/test_audio_visualizer_widget.py b/tests/test_ui/test_audio_visualizer_widget.py new file mode 100644 index 00000000..84b860d1 --- /dev/null +++ b/tests/test_ui/test_audio_visualizer_widget.py @@ -0,0 +1,81 @@ +"""Tests for AudioVisualizerWidget behavior.""" + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from ui.widgets.audio_visualizer_widget import AudioVisualizerWidget + + +@pytest.fixture(scope="module") +def qapp(): + app = QApplication.instance() + if app is None: + app = QApplication([]) + yield app + + +def test_visualizer_accepts_valid_spectrum_frame(qapp): + widget = AudioVisualizerWidget() + + frame = {"mode": "spectrum", "bins": [0.1, 0.5, 0.8], "timestamp_ms": 42} + widget.update_frame(frame) + + assert widget._last_frame is not None + assert widget._last_frame["mode"] == "spectrum" + assert widget._last_frame["bins"] == frame["bins"] + + +def test_visualizer_update_frame_invalid_mode_leaves_last_frame_none(qapp): + widget = AudioVisualizerWidget() + + widget.update_frame({"mode": "invalid", "bins": [0.2]}) + + assert widget._last_frame is None + + +def test_visualizer_accepts_waveform_frame(qapp): + widget = AudioVisualizerWidget() + + frame = {"mode": "waveform", "samples": [-2.0, -0.2, 0.1, 1.5], "timestamp_ms": 33} + widget.update_frame(frame) + + assert widget._last_frame is not None + assert widget._last_frame["mode"] == "waveform" + assert len(widget._last_frame["samples"]) == 4 + assert max(widget._last_frame["samples"]) <= 1.0 + assert min(widget._last_frame["samples"]) >= -1.0 + + +def test_visualizer_invalid_timestamp_defaults_to_zero(qapp): + widget = AudioVisualizerWidget() + + widget.update_frame({"mode": "spectrum", "bins": [0.4, 0.3], "timestamp_ms": "abc"}) + + assert widget._last_frame["timestamp_ms"] == 0 + + +def test_visualizer_paint_handles_recent_frame(qapp): + widget = AudioVisualizerWidget() + widget.resize(200, 120) + widget.update_frame({"mode": "spectrum", "bins": [0.1, 0.5, 0.2]}) + + widget.show() + widget.repaint() + qapp.processEvents() + + +def test_visualizer_waveform_paint_path(qapp): + widget = AudioVisualizerWidget() + widget.resize(240, 120) + widget.update_frame({"mode": "waveform", "samples": [-0.5, 0.0, 0.75, -1.2]}) + + widget.show() + widget.repaint() + qapp.processEvents() + + assert widget._last_frame is not None + assert widget._last_frame["mode"] == "waveform" diff --git a/tests/test_ui/test_now_playing_window_visualizer.py b/tests/test_ui/test_now_playing_window_visualizer.py new file mode 100644 index 00000000..2ae7d0a8 --- /dev/null +++ b/tests/test_ui/test_now_playing_window_visualizer.py @@ -0,0 +1,159 @@ +"""Integration-style tests for NowPlayingWindow visualizer wiring (stubbed).""" + +from types import SimpleNamespace + +from ui.windows.now_playing_window import NowPlayingWindow + + +class _StubSignal: + def __init__(self): + self._callbacks = [] + self.connect_calls = 0 + self.disconnect_calls = 0 + + def connect(self, callback): + self.connect_calls += 1 + self._callbacks.append(callback) + + def emit(self, payload): + for callback in list(self._callbacks): + callback(payload) + + def disconnect(self, callback): + self.disconnect_calls += 1 + for index, cb in enumerate(list(self._callbacks)): + if cb == callback: + self._callbacks.pop(index) + break + + +class _DummyVisualizer: + def __init__(self): + self.visible = None + self.frames = [] + + def show(self): + self.visible = True + + def hide(self): + self.visible = False + + def update_frame(self, frame): + self.frames.append(frame) + + +class _DummyBackend: + def __init__(self, supports: bool): + self._supports = supports + + def supports_visualizer(self) -> bool: + return self._supports + + +def _build_window(*, backend, signal=None): + engine = SimpleNamespace( + backend=backend, + visualizer_frame=signal or _StubSignal(), + ) + playback = SimpleNamespace(engine=engine) + + window = NowPlayingWindow.__new__(NowPlayingWindow) + window._playback = playback + window._visualizer_widget = _DummyVisualizer() + window._visualizer_signal = None + window._visualizer_signal_connected = False + window._visualizer_supported = False + window._lyrics_thread = None + window.closed = SimpleNamespace(emit=lambda *_, **__: None) + return window, engine + + +def test_refresh_visualizer_hides_when_backend_lacks_support(): + backend = _DummyBackend(False) + window, _ = _build_window(backend=backend) + window._visualizer_widget.show() + + window._refresh_visualizer_visibility() + + assert window._visualizer_widget.visible is False + + +def test_refresh_visualizer_shows_when_backend_supports(): + backend = _DummyBackend(True) + window, _ = _build_window(backend=backend) + + window._refresh_visualizer_visibility() + + assert window._visualizer_widget.visible is True + + +def test_refresh_visualizer_handles_missing_interface(): + class _BackendWithoutMethod: + pass + + window, _ = _build_window(backend=_BackendWithoutMethod()) + window._visualizer_widget.show() + + window._refresh_visualizer_visibility() + + assert window._visualizer_widget.visible is False + + +def test_connect_visualizer_signal_routes_frames(): + backend = _DummyBackend(True) + signal = _StubSignal() + window, engine = _build_window(backend=backend, signal=signal) + + window._connect_visualizer_signal() + + payload = {"mode": "spectrum", "bins": [0.1, 0.2, 0.3]} + engine.visualizer_frame.emit(payload) + assert window._visualizer_widget.frames == [payload] + + +def test_connect_visualizer_signal_is_idempotent(): + backend = _DummyBackend(True) + signal = _StubSignal() + window, _ = _build_window(backend=backend, signal=signal) + + window._connect_visualizer_signal() + window._connect_visualizer_signal() + + assert signal.connect_calls == 1 + assert window._visualizer_signal_connected is True + assert signal._callbacks.count(window._visualizer_widget.update_frame) == 1 + + +def test_close_event_disconnects_visualizer_signal(): + backend = _DummyBackend(True) + signal = _StubSignal() + window, engine = _build_window(backend=backend, signal=signal) + event = SimpleNamespace(accept=lambda: None) + + window._connect_visualizer_signal() + assert window._visualizer_signal_connected is True + first_payload = {"mode": "spectrum", "bins": [0.1]} + engine.visualizer_frame.emit(first_payload) + assert window._visualizer_widget.frames == [first_payload] + + window.closeEvent(event) + + assert signal.disconnect_calls == 1 + assert window._visualizer_signal_connected is False + second_payload = {"mode": "spectrum", "bins": [0.5]} + engine.visualizer_frame.emit(second_payload) + assert window._visualizer_widget.frames == [first_payload] + + +def test_close_event_disconnect_is_idempotent(): + backend = _DummyBackend(True) + signal = _StubSignal() + window, _ = _build_window(backend=backend, signal=signal) + event = SimpleNamespace(accept=lambda: None) + + window._connect_visualizer_signal() + + window.closeEvent(event) + window.closeEvent(event) + + assert signal.disconnect_calls == 1 diff --git a/ui/widgets/__init__.py b/ui/widgets/__init__.py index 3dd589f7..82b07e5a 100644 --- a/ui/widgets/__init__.py +++ b/ui/widgets/__init__.py @@ -5,6 +5,7 @@ from .album_card import AlbumCard from .artist_card import ArtistCard from .equalizer_widget import EqualizerWidget, EqualizerPreset +from .audio_visualizer_widget import AudioVisualizerWidget from .lyrics_widget_pro import LyricsWidget from .player_controls import PlayerControls @@ -12,4 +13,5 @@ 'PlayerControls', 'LyricsWidget', 'EqualizerWidget', 'EqualizerPreset', 'AlbumCard', 'ArtistCard', + 'AudioVisualizerWidget', ] diff --git a/ui/widgets/audio_visualizer_widget.py b/ui/widgets/audio_visualizer_widget.py new file mode 100644 index 00000000..79ba6b9b --- /dev/null +++ b/ui/widgets/audio_visualizer_widget.py @@ -0,0 +1,181 @@ +"""Lightweight audio visualizer widget for spectrum and waveform modes.""" + +from __future__ import annotations + +import math +from typing import Iterable, List, Optional + +from PySide6.QtCore import Qt, QRectF +from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen +from PySide6.QtWidgets import QWidget + + +class AudioVisualizerWidget(QWidget): + """Simple QWidget that paints the latest audio spectrum or waveform frame.""" + + _VALID_MODES = {"spectrum", "waveform"} + _MAX_BINS = 256 + _MAX_SAMPLES = 512 + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._mode: str = "spectrum" + self._last_frame: Optional[dict] = None + self._background = QColor(0, 0, 0, 25) + self._spectrum_color = QColor("#45caff") + self._waveform_color = QColor("#72f1b8") + + self.setMinimumHeight(100) + self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, False) + + def set_mode(self, mode: str) -> None: + """Set the preferred rendering mode.""" + if mode not in self._VALID_MODES: + return + if mode == self._mode: + return + self._mode = mode + self.update() + + def update_frame(self, frame: dict) -> None: + """Update the widget with a new frame if it matches the schema.""" + if not isinstance(frame, dict): + return + + mode = frame.get("mode") or self._mode + if mode not in self._VALID_MODES: + return + + timestamp_ms = self._parse_timestamp(frame.get("timestamp_ms")) + + if mode == "spectrum": + bins = self._normalize_bins(frame.get("bins")) + if not bins: + return + sanitized = { + "mode": "spectrum", + "bins": bins, + "timestamp_ms": timestamp_ms, + } + else: + samples = self._normalize_samples(frame.get("samples")) + if len(samples) < 2: + return + sanitized = { + "mode": "waveform", + "samples": samples, + "timestamp_ms": timestamp_ms, + } + + self._last_frame = sanitized + self.update() + + def paintEvent(self, _event) -> None: # type: ignore[override] + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.fillRect(self.rect(), self._background) + + if not self._last_frame: + painter.end() + return + + mode = self._last_frame.get("mode", self._mode) + if mode == "waveform": + self._paint_waveform(painter) + else: + self._paint_spectrum(painter) + painter.end() + + def _paint_spectrum(self, painter: QPainter) -> None: + bins: List[float] = self._last_frame.get("bins") or [] + if not bins: + return + + count = len(bins) + bar_width = max(1, self.width() // max(1, count)) + gap = max(1, int(bar_width * 0.2)) + usable_width = max(1, bar_width - gap) + + for index, value in enumerate(bins): + height = int(value * self.height()) + height = max(1, min(self.height(), height)) + x = index * bar_width + rect = QRectF(x, self.height() - height, usable_width, height) + painter.fillRect(rect, self._spectrum_color) + + def _paint_waveform(self, painter: QPainter) -> None: + samples: List[float] = self._last_frame.get("samples") or [] + if len(samples) < 2: + return + + mid_y = self.height() / 2.0 + amplitude = self.height() * 0.45 + step = self.width() / max(1, len(samples) - 1) + + path = QPainterPath() + path.moveTo(0, mid_y - samples[0] * amplitude) + for index, value in enumerate(samples[1:], start=1): + path.lineTo(index * step, mid_y - value * amplitude) + + painter.setPen(QPen(self._waveform_color, 2)) + painter.drawPath(path) + + @staticmethod + def _parse_timestamp(value) -> int: + try: + number = float(value) + except (TypeError, ValueError): + return 0 + if math.isnan(number) or math.isinf(number): + return 0 + return max(0, int(number)) + + @classmethod + def _normalize_bins(cls, values: Optional[Iterable]) -> List[float]: + if values is None: + return [] + normalized: List[float] = [] + try: + iterator = iter(values) + except TypeError: + return [] + try: + for value in iterator: + try: + number = float(value) + except (TypeError, ValueError): + continue + if math.isnan(number) or math.isinf(number): + continue + number = max(0.0, min(1.0, number)) + normalized.append(number) + if len(normalized) >= cls._MAX_BINS: + break + except TypeError: + return normalized + return normalized + + @classmethod + def _normalize_samples(cls, values: Optional[Iterable]) -> List[float]: + if values is None: + return [] + normalized: List[float] = [] + try: + iterator = iter(values) + except TypeError: + return [] + try: + for value in iterator: + try: + number = float(value) + except (TypeError, ValueError): + continue + if math.isnan(number) or math.isinf(number): + continue + number = max(-1.0, min(1.0, number)) + normalized.append(number) + if len(normalized) >= cls._MAX_SAMPLES: + break + except TypeError: + return normalized + return normalized diff --git a/ui/windows/now_playing_window.py b/ui/windows/now_playing_window.py index e7e7cbb8..9fddd45c 100644 --- a/ui/windows/now_playing_window.py +++ b/ui/windows/now_playing_window.py @@ -31,6 +31,7 @@ from ui.icons import IconName, get_icon from ui.widgets.player_controls import PlayerControls from ui.widgets.lyrics_widget_pro import LyricsWidget +from ui.widgets.audio_visualizer_widget import AudioVisualizerWidget from utils import format_time from PySide6.QtGui import QKeySequence @@ -210,6 +211,9 @@ def __init__(self, playback: PlaybackService, parent=None): self._cover_mode = "square" # square | circle_rotate self._cover_angle = 0.0 self._cover_source_pixmap: Optional[QPixmap] = None + self._visualizer_supported = False + self._visualizer_signal = None + self._visualizer_signal_connected = False self._cover_anim_timer = QTimer(self) self._cover_anim_timer.setInterval(33) self._cover_anim_timer.timeout.connect(self._update_cover_rotation) @@ -288,9 +292,22 @@ def _setup_ui(self): body.addWidget(self._cover_label, 0, Qt.AlignVCenter) self._apply_cover_mode_style() + right_panel = QVBoxLayout() + right_panel.setContentsMargins(0, 0, 0, 0) + right_panel.setSpacing(8) + self._lyrics_widget = LyricsWidget() self._lyrics_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - body.addWidget(self._lyrics_widget, 3) + right_panel.addWidget(self._lyrics_widget, 1) + + self._visualizer_widget = AudioVisualizerWidget() + self._visualizer_widget.setObjectName("nowPlayingVisualizer") + self._visualizer_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self._visualizer_widget.setMinimumHeight(140) + self._visualizer_widget.hide() + right_panel.addWidget(self._visualizer_widget) + + body.addLayout(right_panel, 3) body.addStretch(0) root.addLayout(body, 1) @@ -341,9 +358,75 @@ def _setup_connections(self): engine.state_changed.connect(self._on_state_changed) engine.play_mode_changed.connect(self._on_play_mode_changed) engine.volume_changed.connect(self._on_volume_changed_from_engine) + self._connect_visualizer_signal() + self._refresh_visualizer_visibility() EventBus.instance().favorite_changed.connect(self._on_favorite_changed) + def _connect_visualizer_signal(self): + """Wire playback engine visualizer frames to the widget if possible.""" + if not hasattr(self, "_visualizer_widget"): + return + engine = getattr(self._playback, "engine", None) + if not engine: + self._disconnect_visualizer_signal() + return + signal = getattr(engine, "visualizer_frame", None) + if signal is None: + self._disconnect_visualizer_signal() + return + if self._visualizer_signal_connected and signal is self._visualizer_signal: + return + if self._visualizer_signal_connected: + self._disconnect_visualizer_signal() + connect = getattr(signal, "connect", None) + if not callable(connect): + return + try: + connect(self._visualizer_widget.update_frame) + except (TypeError, RuntimeError): + logger.debug("[NowPlayingWindow] Visualizer signal connection failed", exc_info=True) + return + self._visualizer_signal = signal + self._visualizer_signal_connected = True + + def _disconnect_visualizer_signal(self): + """Disconnect previously wired visualizer signal if any.""" + if not getattr(self, "_visualizer_signal_connected", False): + return + signal = getattr(self, "_visualizer_signal", None) + disconnect = getattr(signal, "disconnect", None) + if callable(disconnect) and hasattr(self, "_visualizer_widget"): + try: + disconnect(self._visualizer_widget.update_frame) + except (TypeError, RuntimeError): + logger.debug("[NowPlayingWindow] Visualizer signal disconnection failed", exc_info=True) + self._visualizer_signal_connected = False + self._visualizer_signal = None + + def _refresh_visualizer_visibility(self): + """Show or hide visualizer widget based on backend capability.""" + engine = getattr(self._playback, "engine", None) + backend = getattr(engine, "backend", None) + supports_visualizer = getattr(backend, "supports_visualizer", None) + supported = False + if callable(supports_visualizer): + try: + supported = bool(supports_visualizer()) + except Exception: + logger.debug("[NowPlayingWindow] supports_visualizer check failed", exc_info=True) + self._set_visualizer_available(supported) + + def _set_visualizer_available(self, available: bool): + """Track visualizer availability and toggle widget visibility.""" + if not hasattr(self, "_visualizer_widget"): + return + self._visualizer_supported = available + if available: + self._visualizer_widget.show() + else: + self._visualizer_widget.hide() + def _add_shortcut(self, key: str | int, callback): shortcut = QShortcut(QKeySequence(key), self) shortcut.activated.connect(callback) @@ -903,6 +986,7 @@ def resizeEvent(self, event): def closeEvent(self, event): """Cleanup and notify main window to restore.""" + self._disconnect_visualizer_signal() if self._lyrics_thread and isValid(self._lyrics_thread): if self._lyrics_thread.isRunning(): self._lyrics_thread.requestInterruption()