matchpatch 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- matchpatch/__init__.py +20 -0
- matchpatch/analysis.py +97 -0
- matchpatch/app.py +71 -0
- matchpatch/audio.py +148 -0
- matchpatch/cli.py +70 -0
- matchpatch/config.py +181 -0
- matchpatch/custom_adjustments.py +60 -0
- matchpatch/devices/__init__.py +5 -0
- matchpatch/devices/base.py +204 -0
- matchpatch/devices/helix.py +447 -0
- matchpatch/devices/registry.py +22 -0
- matchpatch/gui/__init__.py +1 -0
- matchpatch/gui/app.py +193 -0
- matchpatch/gui/device_panels.py +142 -0
- matchpatch/gui/dialogs.py +150 -0
- matchpatch/gui/help.py +213 -0
- matchpatch/gui/main_window.py +7745 -0
- matchpatch/gui/snapshot_header.py +48 -0
- matchpatch/gui/worker.py +135 -0
- matchpatch/measure.py +1191 -0
- matchpatch/measurement_optimizer.py +646 -0
- matchpatch/normalize.py +874 -0
- matchpatch/progress.py +39 -0
- matchpatch/workflow.py +361 -0
- matchpatch-0.4.0.dist-info/METADATA +134 -0
- matchpatch-0.4.0.dist-info/RECORD +29 -0
- matchpatch-0.4.0.dist-info/WHEEL +4 -0
- matchpatch-0.4.0.dist-info/entry_points.txt +3 -0
- matchpatch-0.4.0.dist-info/licenses/LICENSE +21 -0
matchpatch/gui/app.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Application entry point for the MatchPatch PySide6 GUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from PySide6.QtCore import QMessageLogContext, Qt, QTimer, QtMsgType, qInstallMessageHandler
|
|
11
|
+
from PySide6.QtGui import QColor, QFont, QGuiApplication, QIcon, QImage, QPainter
|
|
12
|
+
from PySide6.QtWidgets import QApplication
|
|
13
|
+
|
|
14
|
+
from matchpatch.gui.main_window import MainWindow
|
|
15
|
+
|
|
16
|
+
IGNORED_QT_MESSAGES = {"This plugin supports grabbing the mouse only for popup windows"}
|
|
17
|
+
SOURCE_ROOT = Path(__file__).resolve().parents[3]
|
|
18
|
+
DESKTOP_FILE_ID = "matchpatch-gui"
|
|
19
|
+
DESKTOP_ICON_SIZE = 512
|
|
20
|
+
DEFAULT_XDG_DATA_DIRS = "/usr/local/share:/usr/share"
|
|
21
|
+
GUI_STYLE = "Fusion"
|
|
22
|
+
GUI_FONT_FAMILY = "DejaVu Sans"
|
|
23
|
+
GUI_FONT_POINT_SIZE = 10
|
|
24
|
+
GUI_SMOKE_ENV = "MATCHPATCH_GUI_SMOKE"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resource_path(*parts: str) -> Path:
|
|
28
|
+
"""Resolve bundled resources in frozen and source-tree execution."""
|
|
29
|
+
relative_path = Path(*parts)
|
|
30
|
+
candidates: list[Path] = []
|
|
31
|
+
meipass = getattr(sys, "_MEIPASS", None)
|
|
32
|
+
if getattr(sys, "frozen", False):
|
|
33
|
+
if meipass:
|
|
34
|
+
candidates.append(Path(meipass) / relative_path)
|
|
35
|
+
candidates.append(Path(sys.executable).resolve().parent / relative_path)
|
|
36
|
+
candidates.append(SOURCE_ROOT / relative_path)
|
|
37
|
+
|
|
38
|
+
for candidate in candidates:
|
|
39
|
+
if candidate.exists():
|
|
40
|
+
return candidate
|
|
41
|
+
return candidates[0]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def assets_dir() -> Path:
|
|
45
|
+
return resource_path("docs", "assets")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def qt_message_handler(
|
|
49
|
+
message_type: QtMsgType,
|
|
50
|
+
context: QMessageLogContext,
|
|
51
|
+
message: str,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Suppress known harmless platform noise while preserving other Qt messages."""
|
|
54
|
+
del message_type, context
|
|
55
|
+
if message not in IGNORED_QT_MESSAGES:
|
|
56
|
+
print(message, file=sys.stderr)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def configure_wslg_runtime() -> None:
|
|
60
|
+
"""Point Qt at WSLg when systemd provides a runtime dir without its socket."""
|
|
61
|
+
wayland_display = os.getenv("WAYLAND_DISPLAY")
|
|
62
|
+
if not wayland_display:
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
runtime_dir = Path(os.getenv("XDG_RUNTIME_DIR", ""))
|
|
66
|
+
if runtime_dir.joinpath(wayland_display).exists():
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
wslg_runtime = Path("/mnt/wslg/runtime-dir")
|
|
70
|
+
if wslg_runtime.joinpath(wayland_display).exists():
|
|
71
|
+
os.environ["XDG_RUNTIME_DIR"] = str(wslg_runtime)
|
|
72
|
+
os.environ.setdefault("QT_QPA_PLATFORM", "wayland")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def configure_high_dpi_scaling() -> None:
|
|
76
|
+
"""Keep Qt's per-screen scale factors stable across platforms."""
|
|
77
|
+
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
|
|
78
|
+
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def configure_gui_appearance(app: QApplication) -> None:
|
|
83
|
+
"""Apply the WSLg visual baseline consistently on every Qt platform."""
|
|
84
|
+
app.setStyle(GUI_STYLE)
|
|
85
|
+
app.setFont(QFont(GUI_FONT_FAMILY, GUI_FONT_POINT_SIZE))
|
|
86
|
+
app.setPalette(app.style().standardPalette())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _write_square_desktop_icon(source: Path, target: Path) -> None:
|
|
90
|
+
image = QImage(str(source))
|
|
91
|
+
if image.isNull():
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
icon = QImage(DESKTOP_ICON_SIZE, DESKTOP_ICON_SIZE, QImage.Format.Format_ARGB32)
|
|
95
|
+
icon.fill(QColor(0, 0, 0, 0))
|
|
96
|
+
scaled = image.scaled(
|
|
97
|
+
DESKTOP_ICON_SIZE,
|
|
98
|
+
DESKTOP_ICON_SIZE,
|
|
99
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
100
|
+
Qt.TransformationMode.SmoothTransformation,
|
|
101
|
+
)
|
|
102
|
+
x = (DESKTOP_ICON_SIZE - scaled.width()) // 2
|
|
103
|
+
y = (DESKTOP_ICON_SIZE - scaled.height()) // 2
|
|
104
|
+
painter = QPainter(icon)
|
|
105
|
+
painter.drawImage(x, y, scaled)
|
|
106
|
+
painter.end()
|
|
107
|
+
icon.save(str(target))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _desktop_entry_data_dirs() -> list[Path]:
|
|
111
|
+
data_home = Path(os.getenv("XDG_DATA_HOME", str(Path.home() / ".local" / "share")))
|
|
112
|
+
data_dirs = [
|
|
113
|
+
Path(path)
|
|
114
|
+
for path in os.getenv("XDG_DATA_DIRS", DEFAULT_XDG_DATA_DIRS).split(os.pathsep)
|
|
115
|
+
if path
|
|
116
|
+
]
|
|
117
|
+
return [*data_dirs, data_home]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _desktop_entry(icon: Path) -> str:
|
|
121
|
+
return (
|
|
122
|
+
"[Desktop Entry]\n"
|
|
123
|
+
"Type=Application\n"
|
|
124
|
+
"Name=MatchPatch\n"
|
|
125
|
+
"Comment=Normalize audio processor presets\n"
|
|
126
|
+
"Exec=matchpatch-gui\n"
|
|
127
|
+
f"Icon={icon}\n"
|
|
128
|
+
"Terminal=false\n"
|
|
129
|
+
"Categories=AudioVideo;Audio;\n"
|
|
130
|
+
f"StartupWMClass={DESKTOP_FILE_ID}\n"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def register_desktop_entry() -> None:
|
|
135
|
+
"""Give Wayland/WSLg an application ID with a project icon."""
|
|
136
|
+
for data_dir in _desktop_entry_data_dirs():
|
|
137
|
+
applications = data_dir / "applications"
|
|
138
|
+
icons = data_dir / "icons" / "hicolor" / "512x512" / "apps"
|
|
139
|
+
desktop_file = applications / f"{DESKTOP_FILE_ID}.desktop"
|
|
140
|
+
installed_icon = icons / f"{DESKTOP_FILE_ID}.png"
|
|
141
|
+
entry = _desktop_entry(installed_icon)
|
|
142
|
+
try:
|
|
143
|
+
applications.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
icons.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
_write_square_desktop_icon(assets_dir() / "matchmatch-icon-512.png", installed_icon)
|
|
146
|
+
if not desktop_file.exists() or desktop_file.read_text(encoding="utf-8") != entry:
|
|
147
|
+
desktop_file.write_text(entry, encoding="utf-8")
|
|
148
|
+
except OSError:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def install_terminal_interrupt_handler(app: QApplication, window: MainWindow) -> QTimer:
|
|
153
|
+
"""Route terminal cancellation through the window's normal close handling."""
|
|
154
|
+
|
|
155
|
+
def close_window(_signum: int, _frame: object) -> None:
|
|
156
|
+
QTimer.singleShot(0, window.close)
|
|
157
|
+
|
|
158
|
+
signal.signal(signal.SIGINT, close_window)
|
|
159
|
+
timer = QTimer(app)
|
|
160
|
+
timer.timeout.connect(lambda: None)
|
|
161
|
+
timer.start(100)
|
|
162
|
+
return timer
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def gui_smoke_enabled() -> bool:
|
|
166
|
+
return os.getenv(GUI_SMOKE_ENV, "").strip().lower() in {"1", "true", "yes", "on"}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main(argv: list[str] | None = None) -> None:
|
|
170
|
+
configure_wslg_runtime()
|
|
171
|
+
configure_high_dpi_scaling()
|
|
172
|
+
register_desktop_entry()
|
|
173
|
+
qInstallMessageHandler(qt_message_handler)
|
|
174
|
+
app = QApplication([sys.argv[0], *(argv or [])])
|
|
175
|
+
configure_gui_appearance(app)
|
|
176
|
+
app.setApplicationName(DESKTOP_FILE_ID)
|
|
177
|
+
app.setApplicationDisplayName("MatchPatch")
|
|
178
|
+
app.setDesktopFileName(DESKTOP_FILE_ID)
|
|
179
|
+
icon = assets_dir() / "matchmatch-icon.png"
|
|
180
|
+
app.setWindowIcon(QIcon(str(icon)))
|
|
181
|
+
window = MainWindow()
|
|
182
|
+
if gui_smoke_enabled():
|
|
183
|
+
window.show()
|
|
184
|
+
app.processEvents()
|
|
185
|
+
window.close()
|
|
186
|
+
raise SystemExit(0)
|
|
187
|
+
window.showMaximized()
|
|
188
|
+
_interrupt_timer = install_terminal_interrupt_handler(app, window)
|
|
189
|
+
raise SystemExit(app.exec())
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__": # pragma: no cover - module entry point
|
|
193
|
+
main()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Device-specific settings panels for the MatchPatch GUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from PySide6.QtWidgets import (
|
|
8
|
+
QFormLayout,
|
|
9
|
+
QGroupBox,
|
|
10
|
+
QLabel,
|
|
11
|
+
QLineEdit,
|
|
12
|
+
QSpinBox,
|
|
13
|
+
QVBoxLayout,
|
|
14
|
+
QWidget,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HelixSettingsPanel(QWidget):
|
|
19
|
+
def __init__(self, backend_selector: QWidget | None = None) -> None:
|
|
20
|
+
super().__init__()
|
|
21
|
+
layout = QVBoxLayout(self)
|
|
22
|
+
self.audio_group = QGroupBox("Audio routing")
|
|
23
|
+
self.steering_group = QGroupBox("MIDI steering")
|
|
24
|
+
layout.addWidget(self.audio_group)
|
|
25
|
+
layout.addWidget(self.steering_group)
|
|
26
|
+
if backend_selector is not None:
|
|
27
|
+
backend = QFormLayout()
|
|
28
|
+
backend.addRow(
|
|
29
|
+
_label(
|
|
30
|
+
"Backend", "Select loopback for testing or hardware for a connected device."
|
|
31
|
+
),
|
|
32
|
+
backend_selector,
|
|
33
|
+
)
|
|
34
|
+
layout.addLayout(backend)
|
|
35
|
+
layout.addStretch()
|
|
36
|
+
|
|
37
|
+
audio = QFormLayout(self.audio_group)
|
|
38
|
+
self.audio_device = QLineEdit()
|
|
39
|
+
self.sample_rate = QSpinBox()
|
|
40
|
+
self.sample_rate.setRange(1, 384000)
|
|
41
|
+
self.input_mapping = QLineEdit()
|
|
42
|
+
self.output_mapping = QLineEdit()
|
|
43
|
+
self.blocksize = QSpinBox()
|
|
44
|
+
self.blocksize.setRange(0, 65536)
|
|
45
|
+
audio.addRow(
|
|
46
|
+
_label("Audio device", "Windows audio-interface name used for Helix I/O."),
|
|
47
|
+
self.audio_device,
|
|
48
|
+
)
|
|
49
|
+
audio.addRow(
|
|
50
|
+
_label(
|
|
51
|
+
"Sample rate",
|
|
52
|
+
"Audio sample rate used while replaying and recording the reference DI.",
|
|
53
|
+
),
|
|
54
|
+
self.sample_rate,
|
|
55
|
+
)
|
|
56
|
+
audio.addRow(
|
|
57
|
+
_label(
|
|
58
|
+
"Recording channels", "Input channels carrying the Helix-processed stereo signal."
|
|
59
|
+
),
|
|
60
|
+
self.input_mapping,
|
|
61
|
+
)
|
|
62
|
+
audio.addRow(
|
|
63
|
+
_label("Playback channels", "Output channels sending the reference DI to the Helix."),
|
|
64
|
+
self.output_mapping,
|
|
65
|
+
)
|
|
66
|
+
audio.addRow(
|
|
67
|
+
_label("Block size", "Audio buffer size. Use 0 to let the audio backend decide."),
|
|
68
|
+
self.blocksize,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
steering = QFormLayout(self.steering_group)
|
|
72
|
+
self.steering_output = QLineEdit()
|
|
73
|
+
self.steering_channel = QSpinBox()
|
|
74
|
+
self.steering_channel.setRange(0, 15)
|
|
75
|
+
self.preset_wait = QLineEdit()
|
|
76
|
+
self.snapshot_wait = QLineEdit()
|
|
77
|
+
self.measurement_wait = QLineEdit()
|
|
78
|
+
steering.addRow(
|
|
79
|
+
_label("MIDI output", "MIDI port substring used to find the connected Helix."),
|
|
80
|
+
self.steering_output,
|
|
81
|
+
)
|
|
82
|
+
steering.addRow(
|
|
83
|
+
_label("MIDI channel", "Zero-based MIDI channel used for preset and snapshot changes."),
|
|
84
|
+
self.steering_channel,
|
|
85
|
+
)
|
|
86
|
+
steering.addRow(
|
|
87
|
+
_label("Preset wait (s)", "Pause after switching presets before continuing."),
|
|
88
|
+
self.preset_wait,
|
|
89
|
+
)
|
|
90
|
+
steering.addRow(
|
|
91
|
+
_label("Snapshot wait (s)", "Pause after switching snapshots before continuing."),
|
|
92
|
+
self.snapshot_wait,
|
|
93
|
+
)
|
|
94
|
+
steering.addRow(
|
|
95
|
+
_label(
|
|
96
|
+
"Measurement wait (s)", "Pause before capturing loudness after a snapshot change."
|
|
97
|
+
),
|
|
98
|
+
self.measurement_wait,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def populate(self, args: argparse.Namespace) -> None:
|
|
102
|
+
self.audio_device.setText(_text(args.audio_device or "Helix"))
|
|
103
|
+
self.sample_rate.setValue(args.sample_rate or 48000)
|
|
104
|
+
self.input_mapping.setText(args.input_mapping or "1,2")
|
|
105
|
+
self.output_mapping.setText(args.output_mapping or "3,4")
|
|
106
|
+
self.blocksize.setValue(args.blocksize or 0)
|
|
107
|
+
self.steering_output.setText(_text(args.steering_output or "Helix"))
|
|
108
|
+
self.steering_channel.setValue(args.steering_channel or 0)
|
|
109
|
+
self.preset_wait.setText(_text(args.preset_wait if args.preset_wait is not None else 0.5))
|
|
110
|
+
self.snapshot_wait.setText(
|
|
111
|
+
_text(args.snapshot_wait if args.snapshot_wait is not None else 0.2)
|
|
112
|
+
)
|
|
113
|
+
self.measurement_wait.setText(
|
|
114
|
+
_text(args.measurement_wait if args.measurement_wait is not None else 0.1)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def append_arguments(self, argv: list[str]) -> None:
|
|
118
|
+
_append(argv, "--audio-device", self.audio_device.text())
|
|
119
|
+
_append(argv, "--sample-rate", self.sample_rate.value())
|
|
120
|
+
_append(argv, "--input-mapping", self.input_mapping.text())
|
|
121
|
+
_append(argv, "--output-mapping", self.output_mapping.text())
|
|
122
|
+
_append(argv, "--blocksize", self.blocksize.value())
|
|
123
|
+
_append(argv, "--steering-output", self.steering_output.text())
|
|
124
|
+
_append(argv, "--steering-channel", self.steering_channel.value())
|
|
125
|
+
_append(argv, "--preset-wait", self.preset_wait.text())
|
|
126
|
+
_append(argv, "--snapshot-wait", self.snapshot_wait.text())
|
|
127
|
+
_append(argv, "--measurement-wait", self.measurement_wait.text())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _append(argv: list[str], name: str, value: object) -> None:
|
|
131
|
+
if str(value).strip():
|
|
132
|
+
argv.extend([name, str(value)])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _text(value: object | None) -> str:
|
|
136
|
+
return "" if value is None else str(value)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _label(text: str, tooltip: str) -> QLabel:
|
|
140
|
+
label = QLabel(text)
|
|
141
|
+
label.setToolTip(tooltip)
|
|
142
|
+
return label
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Help and About dialogs for the MatchPatch GUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import Qt
|
|
8
|
+
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap
|
|
9
|
+
from PySide6.QtWidgets import (
|
|
10
|
+
QApplication,
|
|
11
|
+
QDialog,
|
|
12
|
+
QDialogButtonBox,
|
|
13
|
+
QLabel,
|
|
14
|
+
QStyle,
|
|
15
|
+
QTextBrowser,
|
|
16
|
+
QVBoxLayout,
|
|
17
|
+
QWidget,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from matchpatch import __version__
|
|
21
|
+
from matchpatch.gui.help import HelpId, resolve_help_url
|
|
22
|
+
|
|
23
|
+
PROJECT_URL = "https://github.com/noseglasses/MatchPatch"
|
|
24
|
+
ASSETS_DIR = Path(__file__).resolve().parents[3] / "docs" / "assets"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _about_icon_blue(size: int) -> QColor:
|
|
28
|
+
pixmap = (
|
|
29
|
+
QApplication.style()
|
|
30
|
+
.standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation)
|
|
31
|
+
.pixmap(size, size)
|
|
32
|
+
)
|
|
33
|
+
image = pixmap.toImage()
|
|
34
|
+
red_total = 0
|
|
35
|
+
green_total = 0
|
|
36
|
+
blue_total = 0
|
|
37
|
+
count = 0
|
|
38
|
+
for y in range(image.height()):
|
|
39
|
+
for x in range(image.width()):
|
|
40
|
+
color = image.pixelColor(x, y)
|
|
41
|
+
if color.alpha() <= 0:
|
|
42
|
+
continue
|
|
43
|
+
if 180 <= color.hue() <= 250 and color.saturation() >= 60 and color.value() >= 80:
|
|
44
|
+
red_total += color.red()
|
|
45
|
+
green_total += color.green()
|
|
46
|
+
blue_total += color.blue()
|
|
47
|
+
count += 1
|
|
48
|
+
if count == 0:
|
|
49
|
+
return QColor("#308cc6")
|
|
50
|
+
return QColor(red_total // count, green_total // count, blue_total // count)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _question_mark_icon() -> QIcon:
|
|
54
|
+
pixmap = QPixmap(64, 64)
|
|
55
|
+
pixmap.fill(Qt.GlobalColor.transparent)
|
|
56
|
+
|
|
57
|
+
painter = QPainter(pixmap)
|
|
58
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
59
|
+
painter.setPen(Qt.PenStyle.NoPen)
|
|
60
|
+
painter.setBrush(_about_icon_blue(64))
|
|
61
|
+
painter.drawEllipse(4, 4, 56, 56)
|
|
62
|
+
painter.setPen(QColor("#ffffff"))
|
|
63
|
+
font = QFont()
|
|
64
|
+
font.setBold(True)
|
|
65
|
+
font.setPointSize(40)
|
|
66
|
+
painter.setFont(font)
|
|
67
|
+
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "?")
|
|
68
|
+
painter.end()
|
|
69
|
+
return QIcon(pixmap)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AboutDialog(QDialog):
|
|
73
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
74
|
+
super().__init__(parent)
|
|
75
|
+
self.setWindowTitle("About MatchPatch")
|
|
76
|
+
self.setProperty("help_id", HelpId.DOCS_INDEX)
|
|
77
|
+
self.setWindowIcon(
|
|
78
|
+
QApplication.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation)
|
|
79
|
+
)
|
|
80
|
+
self.setMinimumWidth(560)
|
|
81
|
+
layout = QVBoxLayout(self)
|
|
82
|
+
logo = QLabel()
|
|
83
|
+
pixmap = QPixmap(str(ASSETS_DIR / "matchmatch-logo.png"))
|
|
84
|
+
logo.setPixmap(
|
|
85
|
+
pixmap.scaled(
|
|
86
|
+
520,
|
|
87
|
+
320,
|
|
88
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
89
|
+
Qt.TransformationMode.SmoothTransformation,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
logo.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
93
|
+
layout.addWidget(logo)
|
|
94
|
+
details = QLabel(
|
|
95
|
+
"<h2>MatchPatch</h2>"
|
|
96
|
+
f"<p>Version {__version__}</p>"
|
|
97
|
+
"<p>Automatic loudness alignment for audio-processor presets and snapshots.</p>"
|
|
98
|
+
f'<p><a href="{resolve_help_url(HelpId.DOCS_INDEX).toString()}">'
|
|
99
|
+
"Documentation</a></p>"
|
|
100
|
+
f'<p><a href="{PROJECT_URL}">{PROJECT_URL}</a></p>'
|
|
101
|
+
"<p>Copyright © 2026 MatchPatch contributors.</p>"
|
|
102
|
+
"<p>Open source software released under the MIT License.</p>"
|
|
103
|
+
"<p>Keep backups of original processor files. Generated measurement files are "
|
|
104
|
+
"intended for measurement workflows only.</p>"
|
|
105
|
+
)
|
|
106
|
+
details.setOpenExternalLinks(True)
|
|
107
|
+
details.setWordWrap(True)
|
|
108
|
+
layout.addWidget(details)
|
|
109
|
+
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
|
110
|
+
buttons.rejected.connect(self.reject)
|
|
111
|
+
layout.addWidget(buttons)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class HelpDialog(QDialog):
|
|
115
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
116
|
+
super().__init__(parent)
|
|
117
|
+
self.setWindowTitle("MatchPatch Help")
|
|
118
|
+
self.setWindowIcon(_question_mark_icon())
|
|
119
|
+
self.resize(680, 520)
|
|
120
|
+
layout = QVBoxLayout(self)
|
|
121
|
+
help_text = QTextBrowser()
|
|
122
|
+
help_text.setOpenExternalLinks(True)
|
|
123
|
+
help_text.setHtml(
|
|
124
|
+
"<h2>Guided normalization</h2>"
|
|
125
|
+
"<ol>"
|
|
126
|
+
"<li>Open a Helix <code>.hls</code> setlist or <code>.hlx</code> preset from "
|
|
127
|
+
"the toolbar.</li>"
|
|
128
|
+
"<li>The default <b>hardware</b> backend steers and measures the connected Helix. "
|
|
129
|
+
"Use <b>loopback</b> only for testing without hardware.</li>"
|
|
130
|
+
"<li>Use the <b>Presets</b> panel to change preset selection. Expand "
|
|
131
|
+
"<b>Advanced</b> to change device settings, miscellaneous policy values, "
|
|
132
|
+
"or inspect the log.</li>"
|
|
133
|
+
"<li>Start normalization and follow the import dialog for the generated measurement "
|
|
134
|
+
"file.</li>"
|
|
135
|
+
"<li>Use toolbar save or save-as to write the adjusted setlist or preset.</li>"
|
|
136
|
+
"</ol>"
|
|
137
|
+
"<h3>Progress</h3>"
|
|
138
|
+
"<p>A pulsing green dot in the window footer means MatchPatch is working. "
|
|
139
|
+
"The dot is grey while MatchPatch is idle and red after a measurement was "
|
|
140
|
+
"cancelled. While presets are measured, a determinate progress pane shows "
|
|
141
|
+
"the current preset and snapshot.</p>"
|
|
142
|
+
"<h3>Temporary files</h3>"
|
|
143
|
+
"<p>Enable <b>Keep temporary files</b> to retain the measurement CSV. "
|
|
144
|
+
"Its exact path appears below the processing controls.</p>"
|
|
145
|
+
f'<p>More documentation: <a href="{PROJECT_URL}">{PROJECT_URL}</a></p>'
|
|
146
|
+
)
|
|
147
|
+
layout.addWidget(help_text)
|
|
148
|
+
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
|
149
|
+
buttons.rejected.connect(self.reject)
|
|
150
|
+
layout.addWidget(buttons)
|
matchpatch/gui/help.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Offline help topic IDs and URL resolution for the MatchPatch GUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import QUrl
|
|
12
|
+
from PySide6.QtGui import QDesktopServices
|
|
13
|
+
|
|
14
|
+
GITHUB_DOCS_URL = "https://github.com/noseglasses/MatchPatch/tree/main/docs"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HelpId:
|
|
18
|
+
DOCS_INDEX = "docs_index"
|
|
19
|
+
QUICK_START = "quick_start"
|
|
20
|
+
OPEN_FILES = "open_files"
|
|
21
|
+
SAVE_IMPORT = "save_import"
|
|
22
|
+
MEASUREMENT_FILE = "measurement_file"
|
|
23
|
+
NORMALIZE_SETLIST = "normalize_setlist"
|
|
24
|
+
NORMALIZE_SINGLE_PRESET = "normalize_single_preset"
|
|
25
|
+
PROGRESS_CANCEL = "progress_cancel"
|
|
26
|
+
RECORDED_OUTPUT = "recorded_output"
|
|
27
|
+
ADVANCED_SETTINGS = "advanced_settings"
|
|
28
|
+
SELECT_PRESETS = "select_presets"
|
|
29
|
+
SELECT_CHANGED = "select_changed"
|
|
30
|
+
MANUAL_EDITING = "manual_editing"
|
|
31
|
+
MANUAL_CSV = "manual_csv"
|
|
32
|
+
SNAPSHOTS_SOLOS_IGNORED = "snapshots_solos_ignored"
|
|
33
|
+
READING_RESULTS = "reading_results"
|
|
34
|
+
BACKENDS = "backends"
|
|
35
|
+
ROUTING_LEVELS = "routing_levels"
|
|
36
|
+
HARDWARE_MEASUREMENT = "hardware_measurement"
|
|
37
|
+
FILES_TAB = "files_tab"
|
|
38
|
+
REFERENCE_DI = "reference_di"
|
|
39
|
+
CUSTOM_ADJUSTMENTS = "custom_adjustments"
|
|
40
|
+
TIMING = "timing"
|
|
41
|
+
OPTIMIZE_TIMING = "optimize_timing"
|
|
42
|
+
LUFS_LOUDNESS = "lufs_loudness"
|
|
43
|
+
SNAPSHOT_COUNT = "snapshot_count"
|
|
44
|
+
METADATA = "metadata"
|
|
45
|
+
TROUBLESHOOTING = "troubleshooting"
|
|
46
|
+
HARDWARE_TROUBLESHOOTING = "hardware_troubleshooting"
|
|
47
|
+
OPTIMIZE_TIMING_RESULTS = "optimize_timing_results"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class HelpTopic:
|
|
52
|
+
page: str
|
|
53
|
+
anchor: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
HELP_TOPICS: dict[str, HelpTopic] = {
|
|
57
|
+
HelpId.DOCS_INDEX: HelpTopic("index.html"),
|
|
58
|
+
HelpId.QUICK_START: HelpTopic("quick-start.html"),
|
|
59
|
+
HelpId.OPEN_FILES: HelpTopic("musician-guide.html", "help-opening-files"),
|
|
60
|
+
HelpId.SAVE_IMPORT: HelpTopic("workflows/save-and-import.html"),
|
|
61
|
+
HelpId.MEASUREMENT_FILE: HelpTopic(
|
|
62
|
+
"concepts/measurement-and-adjusted-files.html",
|
|
63
|
+
"help-measurement-file",
|
|
64
|
+
),
|
|
65
|
+
HelpId.NORMALIZE_SETLIST: HelpTopic("workflows/normalize-setlist.html"),
|
|
66
|
+
HelpId.NORMALIZE_SINGLE_PRESET: HelpTopic("workflows/normalize-single-preset.html"),
|
|
67
|
+
HelpId.PROGRESS_CANCEL: HelpTopic("concepts/reading-results.html", "help-progress-and-cancel"),
|
|
68
|
+
HelpId.RECORDED_OUTPUT: HelpTopic(
|
|
69
|
+
"workflows/hardware-measurement.html",
|
|
70
|
+
"help-recorded-output-playback",
|
|
71
|
+
),
|
|
72
|
+
HelpId.ADVANCED_SETTINGS: HelpTopic("musician-guide.html", "help-advanced-settings"),
|
|
73
|
+
HelpId.SELECT_PRESETS: HelpTopic("workflows/normalize-setlist.html", "help-select-presets"),
|
|
74
|
+
HelpId.SELECT_CHANGED: HelpTopic("workflows/select-changed-presets.html"),
|
|
75
|
+
HelpId.MANUAL_EDITING: HelpTopic(
|
|
76
|
+
"workflows/manual-editing-and-csv.html",
|
|
77
|
+
"help-manual-editing",
|
|
78
|
+
),
|
|
79
|
+
HelpId.MANUAL_CSV: HelpTopic("workflows/manual-editing-and-csv.html", "help-csv"),
|
|
80
|
+
HelpId.SNAPSHOTS_SOLOS_IGNORED: HelpTopic("concepts/snapshots-solos-and-ignored.html"),
|
|
81
|
+
HelpId.READING_RESULTS: HelpTopic("concepts/reading-results.html"),
|
|
82
|
+
HelpId.BACKENDS: HelpTopic("concepts/backends.html"),
|
|
83
|
+
HelpId.ROUTING_LEVELS: HelpTopic("concepts/routing-and-levels.html", "help-audio-routing"),
|
|
84
|
+
HelpId.HARDWARE_MEASUREMENT: HelpTopic("workflows/hardware-measurement.html"),
|
|
85
|
+
HelpId.FILES_TAB: HelpTopic("concepts/measurement-and-adjusted-files.html"),
|
|
86
|
+
HelpId.REFERENCE_DI: HelpTopic("concepts/reference-di.html", "help-reference-di"),
|
|
87
|
+
HelpId.CUSTOM_ADJUSTMENTS: HelpTopic("workflows/custom-adjustments.html"),
|
|
88
|
+
HelpId.TIMING: HelpTopic("concepts/timing.html"),
|
|
89
|
+
HelpId.OPTIMIZE_TIMING: HelpTopic("workflows/optimize-timing.html"),
|
|
90
|
+
HelpId.LUFS_LOUDNESS: HelpTopic("concepts/lufs-and-loudness.html"),
|
|
91
|
+
HelpId.SNAPSHOT_COUNT: HelpTopic(
|
|
92
|
+
"concepts/snapshots-solos-and-ignored.html",
|
|
93
|
+
"help-snapshot-count",
|
|
94
|
+
),
|
|
95
|
+
HelpId.METADATA: HelpTopic("musician-guide.html", "help-metadata"),
|
|
96
|
+
HelpId.TROUBLESHOOTING: HelpTopic("troubleshooting.html"),
|
|
97
|
+
HelpId.HARDWARE_TROUBLESHOOTING: HelpTopic("troubleshooting.html", "help-hardware-not-found"),
|
|
98
|
+
HelpId.OPTIMIZE_TIMING_RESULTS: HelpTopic(
|
|
99
|
+
"workflows/optimize-timing.html",
|
|
100
|
+
"help-apply-optimized-timing",
|
|
101
|
+
),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def repo_root() -> Path:
|
|
106
|
+
return Path(__file__).resolve().parents[3]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _docs_root_if_available(path: Path) -> Path | None:
|
|
110
|
+
if (path / "index.html").is_file():
|
|
111
|
+
return path
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def local_docs_root() -> Path | None:
|
|
116
|
+
if getattr(sys, "frozen", False):
|
|
117
|
+
executable = getattr(sys, "executable", "")
|
|
118
|
+
if executable:
|
|
119
|
+
if packaged_docs := _docs_root_if_available(
|
|
120
|
+
Path(executable).resolve().parent / "docs_html"
|
|
121
|
+
):
|
|
122
|
+
return packaged_docs
|
|
123
|
+
|
|
124
|
+
if checkout_docs := _docs_root_if_available(repo_root() / "docs_html"):
|
|
125
|
+
return checkout_docs
|
|
126
|
+
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def resolve_help_url(help_id: str, *, docs_root: Path | None = None) -> QUrl:
|
|
131
|
+
topic = HELP_TOPICS.get(help_id, HELP_TOPICS[HelpId.DOCS_INDEX])
|
|
132
|
+
if docs_root is None:
|
|
133
|
+
docs_root = local_docs_root()
|
|
134
|
+
|
|
135
|
+
if docs_root is not None:
|
|
136
|
+
target = docs_root / topic.page
|
|
137
|
+
if target.is_file():
|
|
138
|
+
url = QUrl.fromLocalFile(str(target))
|
|
139
|
+
if topic.anchor:
|
|
140
|
+
url.setFragment(topic.anchor)
|
|
141
|
+
return url
|
|
142
|
+
|
|
143
|
+
source_page = topic.page.removesuffix(".html") + ".md"
|
|
144
|
+
url = QUrl(f"{GITHUB_DOCS_URL}/{source_page}")
|
|
145
|
+
if topic.anchor:
|
|
146
|
+
url.setFragment(topic.anchor)
|
|
147
|
+
return url
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _running_under_wsl() -> bool:
|
|
151
|
+
if sys.platform != "linux":
|
|
152
|
+
return False
|
|
153
|
+
try:
|
|
154
|
+
version = Path("/proc/version").read_text(encoding="utf-8").casefold()
|
|
155
|
+
except OSError:
|
|
156
|
+
return False
|
|
157
|
+
return "microsoft" in version or "wsl" in version
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _wslpath_to_windows(path: Path) -> str | None:
|
|
161
|
+
try:
|
|
162
|
+
result = subprocess.run(
|
|
163
|
+
["wslpath", "-w", str(path)],
|
|
164
|
+
check=False,
|
|
165
|
+
stdout=subprocess.PIPE,
|
|
166
|
+
stderr=subprocess.DEVNULL,
|
|
167
|
+
text=True,
|
|
168
|
+
)
|
|
169
|
+
except OSError:
|
|
170
|
+
return None
|
|
171
|
+
if result.returncode != 0:
|
|
172
|
+
return None
|
|
173
|
+
return result.stdout.strip() or None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _url_for_wsl_launcher(url: QUrl) -> str:
|
|
177
|
+
if not url.isLocalFile():
|
|
178
|
+
return url.toString()
|
|
179
|
+
|
|
180
|
+
launcher_url = _wslpath_to_windows(Path(url.toLocalFile())) or url.toLocalFile()
|
|
181
|
+
if fragment := url.fragment():
|
|
182
|
+
launcher_url = f"{launcher_url}#{fragment}"
|
|
183
|
+
return launcher_url
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _open_url_with_wsl_launcher(url: QUrl) -> bool:
|
|
187
|
+
launcher_url = _url_for_wsl_launcher(url)
|
|
188
|
+
commands = []
|
|
189
|
+
if wslview := shutil.which("wslview"):
|
|
190
|
+
commands.append([wslview, launcher_url])
|
|
191
|
+
if shutil.which("cmd.exe"):
|
|
192
|
+
commands.append(["cmd.exe", "/d", "/c", "start", "", launcher_url])
|
|
193
|
+
|
|
194
|
+
for command in commands:
|
|
195
|
+
try:
|
|
196
|
+
result = subprocess.run(
|
|
197
|
+
command,
|
|
198
|
+
check=False,
|
|
199
|
+
stdout=subprocess.DEVNULL,
|
|
200
|
+
stderr=subprocess.DEVNULL,
|
|
201
|
+
)
|
|
202
|
+
except OSError:
|
|
203
|
+
continue
|
|
204
|
+
if result.returncode == 0:
|
|
205
|
+
return True
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def open_help(help_id: str = HelpId.DOCS_INDEX) -> bool:
|
|
210
|
+
url = resolve_help_url(help_id)
|
|
211
|
+
if _running_under_wsl() and _open_url_with_wsl_launcher(url):
|
|
212
|
+
return True
|
|
213
|
+
return QDesktopServices.openUrl(url)
|