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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ Harmony.spec

AppDir
*.AppImage

.worktrees/
191 changes: 191 additions & 0 deletions docs/superpowers/specs/2026-04-03-realtime-visualizer-design.md
Original file line number Diff line number Diff line change
@@ -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 管线
- 增加主题联动(颜色、渐变、透明度)
5 changes: 5 additions & 0 deletions infrastructure/audio/audio_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
14 changes: 14 additions & 0 deletions infrastructure/audio/audio_engine.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand Down
65 changes: 65 additions & 0 deletions infrastructure/audio/mpv_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,19 @@ 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
self._media_ready = False
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):
Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading