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/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)