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/progress.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Structured progress events shared by CLI workers and the GUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ProgressEvent:
|
|
12
|
+
kind: str
|
|
13
|
+
message: str | None = None
|
|
14
|
+
phase: str | None = None
|
|
15
|
+
preset_id: int | None = None
|
|
16
|
+
device_patch: str | None = None
|
|
17
|
+
preset_index: int | None = None
|
|
18
|
+
preset_total: int | None = None
|
|
19
|
+
snapshot: int | None = None
|
|
20
|
+
snapshot_total: int | None = None
|
|
21
|
+
reference_lufs: float | None = None
|
|
22
|
+
lufs: float | None = None
|
|
23
|
+
crest_factor_db: float | None = None
|
|
24
|
+
path: str | None = None
|
|
25
|
+
|
|
26
|
+
def to_json(self) -> str:
|
|
27
|
+
return json.dumps(asdict(self), separators=(",", ":"))
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_json(cls, value: str) -> ProgressEvent:
|
|
31
|
+
payload: Any = json.loads(value)
|
|
32
|
+
|
|
33
|
+
if not isinstance(payload, dict):
|
|
34
|
+
raise ValueError("Progress event must be a JSON object")
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
return cls(**payload)
|
|
38
|
+
except TypeError as exc:
|
|
39
|
+
raise ValueError(f"Invalid progress event: {exc}") from exc
|
matchpatch/workflow.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Reusable preset-normalization workflow shared by CLI and GUI front ends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from dataclasses import replace as dataclass_replace
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from matchpatch.analysis import AnalysisOptions
|
|
14
|
+
from matchpatch.custom_adjustments import load_custom_adjustments_file
|
|
15
|
+
from matchpatch.devices import get_device_profile
|
|
16
|
+
from matchpatch.devices.base import (
|
|
17
|
+
DeviceProfile,
|
|
18
|
+
NormalizationPolicy,
|
|
19
|
+
PatchFileAdjustments,
|
|
20
|
+
validate_snapshot_count,
|
|
21
|
+
)
|
|
22
|
+
from matchpatch.progress import ProgressEvent
|
|
23
|
+
|
|
24
|
+
PROJECT_DIR = Path(__file__).resolve().parents[2]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ImportRequest:
|
|
29
|
+
kind: str
|
|
30
|
+
device_display_name: str
|
|
31
|
+
path: Path
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def message(self) -> str:
|
|
35
|
+
description = "measurement" if self.kind == "measurement" else "adjusted"
|
|
36
|
+
return (
|
|
37
|
+
f"Please import this {description} file into {self.device_display_name}:\n{self.path}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class NormalizationRequest:
|
|
43
|
+
device: str
|
|
44
|
+
input_path: Path
|
|
45
|
+
backend: str
|
|
46
|
+
windows_python: str
|
|
47
|
+
reference_di: Path
|
|
48
|
+
custom_adjustments_path: Path | None = None
|
|
49
|
+
output_path: Path | None = None
|
|
50
|
+
diff_input_path: Path | None = None
|
|
51
|
+
automation: bool = True
|
|
52
|
+
defer_export: bool = False
|
|
53
|
+
preset_set: str | None = None
|
|
54
|
+
limit: int | None = None
|
|
55
|
+
keep_temp: bool = False
|
|
56
|
+
ignore_bad_lufs: bool = True
|
|
57
|
+
target_lufs: float = -16.0
|
|
58
|
+
timeout: float | None = None
|
|
59
|
+
audio_device: str | int | None = None
|
|
60
|
+
sample_rate: int | None = None
|
|
61
|
+
input_mapping: str | None = None
|
|
62
|
+
output_mapping: str | None = None
|
|
63
|
+
blocksize: int | None = None
|
|
64
|
+
steering_output: str | None = None
|
|
65
|
+
steering_channel: int | None = None
|
|
66
|
+
preset_wait: float | None = None
|
|
67
|
+
snapshot_wait: float | None = None
|
|
68
|
+
measurement_wait: float | None = None
|
|
69
|
+
pre_roll: float | None = None
|
|
70
|
+
post_roll: float | None = None
|
|
71
|
+
round_trip_latency: float | None = None
|
|
72
|
+
simulate_fail_presets: str | None = None
|
|
73
|
+
play_recorded_output: bool = False
|
|
74
|
+
record_device_output: bool = False
|
|
75
|
+
playback_toggle_path: Path | None = None
|
|
76
|
+
recorded_output_dir: Path | None = None
|
|
77
|
+
snapshot_plan: tuple[tuple[str, tuple[int, ...]], ...] = ()
|
|
78
|
+
policy: NormalizationPolicy = NormalizationPolicy()
|
|
79
|
+
analysis_options: AnalysisOptions = AnalysisOptions()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class NormalizationResult:
|
|
84
|
+
output_path: Path | None
|
|
85
|
+
temp_dir: Path | None
|
|
86
|
+
retained_csv_path: Path | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
ProgressCallback = Callable[[ProgressEvent], None]
|
|
90
|
+
ConfirmationCallback = Callable[[ImportRequest], bool]
|
|
91
|
+
AnalysisRunner = Callable[[NormalizationRequest, list[int], Path, ProgressCallback | None], None]
|
|
92
|
+
ProfileProvider = Callable[[str], DeviceProfile]
|
|
93
|
+
TempDirFactory = Callable[[], Path]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def normalize_presets(
|
|
97
|
+
request: NormalizationRequest,
|
|
98
|
+
*,
|
|
99
|
+
run_analysis: AnalysisRunner,
|
|
100
|
+
on_progress: ProgressCallback | None = None,
|
|
101
|
+
confirm_import: ConfirmationCallback | None = None,
|
|
102
|
+
get_profile: ProfileProvider = get_device_profile,
|
|
103
|
+
make_temp_dir: TempDirFactory | None = None,
|
|
104
|
+
) -> NormalizationResult:
|
|
105
|
+
profile = get_profile(request.device)
|
|
106
|
+
validate_snapshot_count(profile, request.policy.snapshot_count)
|
|
107
|
+
handler = profile.create_patch_file_handler(PROJECT_DIR)
|
|
108
|
+
log_setter = getattr(handler, "set_log_callback", None)
|
|
109
|
+
if log_setter is not None:
|
|
110
|
+
log_setter(lambda message: _emit(on_progress, ProgressEvent("log", message=message)))
|
|
111
|
+
input_path = request.input_path.resolve()
|
|
112
|
+
handler.validate_input(input_path)
|
|
113
|
+
|
|
114
|
+
if not request.reference_di.is_file():
|
|
115
|
+
raise ValueError(f"Reference DI WAV does not exist: {request.reference_di}")
|
|
116
|
+
_validate_custom_adjustments(request)
|
|
117
|
+
|
|
118
|
+
if request.automation:
|
|
119
|
+
if request.output_path is not None:
|
|
120
|
+
raise ValueError("--output must not be specified with --automation")
|
|
121
|
+
|
|
122
|
+
measurement_path = handler.automation_output_path(input_path, "_measurement")
|
|
123
|
+
output_path = (
|
|
124
|
+
None
|
|
125
|
+
if request.defer_export
|
|
126
|
+
else handler.automation_output_path(input_path, "_adjusted")
|
|
127
|
+
)
|
|
128
|
+
_emit(on_progress, ProgressEvent("phase", phase="preparing_measurement"))
|
|
129
|
+
handler.create_measurement_file(input_path, measurement_path)
|
|
130
|
+
_emit(on_progress, ProgressEvent("phase", phase="waiting_for_measurement_import"))
|
|
131
|
+
_confirm(
|
|
132
|
+
confirm_import,
|
|
133
|
+
ImportRequest("measurement", profile.display_name, measurement_path),
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
if request.output_path is None:
|
|
137
|
+
raise ValueError("--output is required unless --automation is used")
|
|
138
|
+
|
|
139
|
+
output_path = request.output_path.resolve()
|
|
140
|
+
handler.validate_output(input_path, output_path)
|
|
141
|
+
|
|
142
|
+
requested_ids = (
|
|
143
|
+
handler.parse_patch_set(request.preset_set) if request.preset_set is not None else None
|
|
144
|
+
)
|
|
145
|
+
assignments = handler.list_assignments(input_path)
|
|
146
|
+
preset_ids = handler.select_preset_ids(input_path, assignments, requested_ids)
|
|
147
|
+
|
|
148
|
+
snapshot_plan = request.snapshot_plan
|
|
149
|
+
if request.diff_input_path is not None:
|
|
150
|
+
previous_input_path = request.diff_input_path.resolve()
|
|
151
|
+
if previous_input_path.suffix.lower() != input_path.suffix.lower():
|
|
152
|
+
raise ValueError("--diff-input must use the same file type as --input")
|
|
153
|
+
diff_snapshot_ids = getattr(handler, "diff_snapshot_ids", None)
|
|
154
|
+
if diff_snapshot_ids is None:
|
|
155
|
+
diff_snapshots = {
|
|
156
|
+
preset_id: tuple(range(1, request.policy.snapshot_count + 1))
|
|
157
|
+
for preset_id in handler.diff_preset_ids(input_path, previous_input_path)
|
|
158
|
+
}
|
|
159
|
+
else:
|
|
160
|
+
diff_snapshots = diff_snapshot_ids(
|
|
161
|
+
input_path,
|
|
162
|
+
previous_input_path,
|
|
163
|
+
request.policy.snapshot_count,
|
|
164
|
+
)
|
|
165
|
+
diff_ids = set(diff_snapshots)
|
|
166
|
+
preset_ids = [preset_id for preset_id in preset_ids if preset_id in diff_ids]
|
|
167
|
+
snapshot_plan = _intersect_snapshot_plans(
|
|
168
|
+
snapshot_plan,
|
|
169
|
+
tuple(
|
|
170
|
+
(handler.format_patch_id(preset_id), diff_snapshots[preset_id])
|
|
171
|
+
for preset_id in preset_ids
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if request.limit is not None:
|
|
176
|
+
if request.limit < 1:
|
|
177
|
+
raise ValueError("--limit must be at least 1")
|
|
178
|
+
|
|
179
|
+
preset_ids = preset_ids[: request.limit]
|
|
180
|
+
|
|
181
|
+
if not preset_ids:
|
|
182
|
+
raise ValueError("Patch file contains no measurable presets")
|
|
183
|
+
|
|
184
|
+
temp_dir = (
|
|
185
|
+
make_temp_dir()
|
|
186
|
+
if make_temp_dir is not None
|
|
187
|
+
else Path(tempfile.mkdtemp(prefix="matchpatch_normalization_", dir=PROJECT_DIR))
|
|
188
|
+
)
|
|
189
|
+
success = False
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
csv_path = temp_dir / "lufs_analysis.csv"
|
|
193
|
+
analysis_request = (
|
|
194
|
+
request
|
|
195
|
+
if not request.record_device_output or request.recorded_output_dir is not None
|
|
196
|
+
else dataclass_replace(request, recorded_output_dir=temp_dir / "recordings")
|
|
197
|
+
)
|
|
198
|
+
if snapshot_plan != analysis_request.snapshot_plan:
|
|
199
|
+
analysis_request = dataclass_replace(analysis_request, snapshot_plan=snapshot_plan)
|
|
200
|
+
_emit(
|
|
201
|
+
on_progress,
|
|
202
|
+
ProgressEvent(
|
|
203
|
+
"phase",
|
|
204
|
+
phase="measuring",
|
|
205
|
+
message="Starting measurement worker...",
|
|
206
|
+
preset_total=len(preset_ids),
|
|
207
|
+
snapshot_total=request.policy.snapshot_count,
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
run_analysis(analysis_request, preset_ids, csv_path, on_progress)
|
|
211
|
+
|
|
212
|
+
measured_rows = _count_csv_rows(csv_path)
|
|
213
|
+
|
|
214
|
+
if measured_rows != len(preset_ids):
|
|
215
|
+
raise RuntimeError(
|
|
216
|
+
f"Windows analysis wrote {measured_rows} rows for {len(preset_ids)} presets"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if output_path is not None:
|
|
220
|
+
_emit(
|
|
221
|
+
on_progress,
|
|
222
|
+
ProgressEvent("phase", phase="applying", message="Applying adjustments"),
|
|
223
|
+
)
|
|
224
|
+
handler.apply_analysis_csv(
|
|
225
|
+
input_path,
|
|
226
|
+
output_path,
|
|
227
|
+
csv_path,
|
|
228
|
+
request.ignore_bad_lufs,
|
|
229
|
+
request.target_lufs,
|
|
230
|
+
request.policy,
|
|
231
|
+
request.custom_adjustments_path,
|
|
232
|
+
)
|
|
233
|
+
elif request.defer_export:
|
|
234
|
+
preview_path = temp_dir / f"{input_path.stem}_preview{input_path.suffix}"
|
|
235
|
+
_emit(
|
|
236
|
+
on_progress,
|
|
237
|
+
ProgressEvent("phase", phase="applying", message="Calculating adjustments"),
|
|
238
|
+
)
|
|
239
|
+
try:
|
|
240
|
+
handler.apply_analysis_csv(
|
|
241
|
+
input_path,
|
|
242
|
+
preview_path,
|
|
243
|
+
csv_path,
|
|
244
|
+
request.ignore_bad_lufs,
|
|
245
|
+
request.target_lufs,
|
|
246
|
+
request.policy,
|
|
247
|
+
request.custom_adjustments_path,
|
|
248
|
+
)
|
|
249
|
+
finally:
|
|
250
|
+
preview_path.unlink(missing_ok=True)
|
|
251
|
+
success = True
|
|
252
|
+
finally:
|
|
253
|
+
if not request.keep_temp and success and not request.defer_export:
|
|
254
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
255
|
+
elif not request.defer_export:
|
|
256
|
+
_emit(
|
|
257
|
+
on_progress,
|
|
258
|
+
ProgressEvent(
|
|
259
|
+
"temp_retained",
|
|
260
|
+
message=f"Kept temporary CSV: {csv_path}",
|
|
261
|
+
path=str(csv_path),
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
_emit(
|
|
266
|
+
on_progress,
|
|
267
|
+
ProgressEvent(
|
|
268
|
+
"phase",
|
|
269
|
+
phase="completed",
|
|
270
|
+
message=(
|
|
271
|
+
"Measurement completed; ready to export"
|
|
272
|
+
if request.defer_export
|
|
273
|
+
else "Gain-adjusted patch file written"
|
|
274
|
+
),
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if request.automation and output_path is not None:
|
|
279
|
+
_emit(on_progress, ProgressEvent("phase", phase="waiting_for_adjusted_import"))
|
|
280
|
+
_confirm(
|
|
281
|
+
confirm_import,
|
|
282
|
+
ImportRequest("adjusted", profile.display_name, output_path),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
return NormalizationResult(
|
|
286
|
+
output_path,
|
|
287
|
+
temp_dir if request.keep_temp or not success or request.defer_export else None,
|
|
288
|
+
csv_path if request.keep_temp or not success or request.defer_export else None,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def export_adjusted_file(
|
|
293
|
+
request: NormalizationRequest,
|
|
294
|
+
csv_path: Path,
|
|
295
|
+
output_path: Path,
|
|
296
|
+
*,
|
|
297
|
+
adjustments: PatchFileAdjustments | None = None,
|
|
298
|
+
on_progress: ProgressCallback | None = None,
|
|
299
|
+
get_profile: ProfileProvider = get_device_profile,
|
|
300
|
+
) -> None:
|
|
301
|
+
profile = get_profile(request.device)
|
|
302
|
+
handler = profile.create_patch_file_handler(PROJECT_DIR)
|
|
303
|
+
log_setter = getattr(handler, "set_log_callback", None)
|
|
304
|
+
if log_setter is not None:
|
|
305
|
+
log_setter(lambda message: _emit(on_progress, ProgressEvent("log", message=message)))
|
|
306
|
+
input_path = request.input_path.resolve()
|
|
307
|
+
output_path = output_path.resolve()
|
|
308
|
+
handler.validate_input(input_path)
|
|
309
|
+
handler.validate_output(input_path, output_path)
|
|
310
|
+
_validate_custom_adjustments(request)
|
|
311
|
+
handler.apply_analysis_csv(
|
|
312
|
+
input_path,
|
|
313
|
+
output_path,
|
|
314
|
+
csv_path,
|
|
315
|
+
request.ignore_bad_lufs,
|
|
316
|
+
request.target_lufs,
|
|
317
|
+
request.policy,
|
|
318
|
+
request.custom_adjustments_path,
|
|
319
|
+
adjustments,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _validate_custom_adjustments(request: NormalizationRequest) -> None:
|
|
324
|
+
if request.custom_adjustments_path is None:
|
|
325
|
+
return
|
|
326
|
+
if not request.custom_adjustments_path.is_file():
|
|
327
|
+
raise ValueError(
|
|
328
|
+
f"Custom adjustments CSV does not exist: {request.custom_adjustments_path}"
|
|
329
|
+
)
|
|
330
|
+
load_custom_adjustments_file(request.custom_adjustments_path, request.policy.snapshot_count)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _intersect_snapshot_plans(
|
|
334
|
+
first: tuple[tuple[str, tuple[int, ...]], ...],
|
|
335
|
+
second: tuple[tuple[str, tuple[int, ...]], ...],
|
|
336
|
+
) -> tuple[tuple[str, tuple[int, ...]], ...]:
|
|
337
|
+
if not first:
|
|
338
|
+
return second
|
|
339
|
+
first_by_patch = {patch.upper(): tuple(snapshots) for patch, snapshots in first}
|
|
340
|
+
result = []
|
|
341
|
+
for patch, snapshots in second:
|
|
342
|
+
first_snapshots = first_by_patch.get(patch.upper(), ())
|
|
343
|
+
selected = tuple(snapshot for snapshot in snapshots if snapshot in first_snapshots)
|
|
344
|
+
if selected:
|
|
345
|
+
result.append((patch, selected))
|
|
346
|
+
return tuple(result)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _emit(callback: ProgressCallback | None, event: ProgressEvent) -> None:
|
|
350
|
+
if callback is not None:
|
|
351
|
+
callback(event)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _confirm(callback: ConfirmationCallback | None, request: ImportRequest) -> None:
|
|
355
|
+
if callback is not None and not callback(request):
|
|
356
|
+
raise RuntimeError("Normalization cancelled by user")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _count_csv_rows(csv_path: Path) -> int:
|
|
360
|
+
with csv_path.open("r", encoding="utf-8-sig", newline="") as csv_file:
|
|
361
|
+
return sum(1 for _ in csv.DictReader(csv_file))
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: matchpatch
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Audio processor preset gain normalization and measurement tools
|
|
5
|
+
Project-URL: Homepage, https://github.com/noseglasses/MatchPatch
|
|
6
|
+
Project-URL: Issues, https://github.com/noseglasses/MatchPatch/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/noseglasses/MatchPatch.git
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: <3.15,>=3.12
|
|
10
|
+
Provides-Extra: gui
|
|
11
|
+
Requires-Dist: pyside6>=6.8; extra == 'gui'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# MatchPatch
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<img src="docs/assets/matchmatch-logo.png" alt="MatchPatch: Normalize presets. Match volume." width="260">
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
[](https://github.com/noseglasses/MatchPatch/actions/workflows/quality.yml)
|
|
21
|
+
[](https://github.com/noseglasses/MatchPatch/actions/workflows/release.yml)
|
|
22
|
+
[](https://pypi.org/project/matchpatch/)
|
|
23
|
+
[](https://pypi.org/project/matchpatch/)
|
|
24
|
+
|
|
25
|
+
<p align="center">
|
|
26
|
+
<strong><a href="https://youtu.be/Dw1Kez0AnCk">Watch the demo video</a></strong>
|
|
27
|
+
·
|
|
28
|
+
<strong><a href="https://noseglasses.github.io/MatchPatch/">Read the documentation</a></strong>
|
|
29
|
+
·
|
|
30
|
+
<strong><a href="https://github.com/noseglasses/MatchPatch/releases/latest">Download MatchPatch</a></strong>
|
|
31
|
+
</p>
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
**No more unexpected volume jumps when switching sounds.**
|
|
36
|
+
|
|
37
|
+
MatchPatch automatically equalizes the loudness of presets and snapshots in
|
|
38
|
+
guitar processors such as the Line 6 Helix.
|
|
39
|
+
|
|
40
|
+
## Why MatchPatch?
|
|
41
|
+
|
|
42
|
+
You create a great clean sound. You create a great lead sound. Then you switch
|
|
43
|
+
between them and one is much louder than the other.
|
|
44
|
+
|
|
45
|
+
MatchPatch measures your presets and calculates the gain adjustments needed to
|
|
46
|
+
make them consistent, so your setlist feels balanced before rehearsal or stage
|
|
47
|
+
use.
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
- Measure preset loudness automatically.
|
|
52
|
+
- Analyze snapshots.
|
|
53
|
+
- Calculate required gain corrections.
|
|
54
|
+
- Modify Helix setlists and presets.
|
|
55
|
+
- Test the workflow without hardware.
|
|
56
|
+
- Configure normal runs from the GUI.
|
|
57
|
+
- Use CLI and worker commands for advanced scripting.
|
|
58
|
+
- Open source.
|
|
59
|
+
|
|
60
|
+
## Current Support
|
|
61
|
+
|
|
62
|
+
Current normal workflows support:
|
|
63
|
+
|
|
64
|
+
- Line 6 Helix
|
|
65
|
+
- `.hls` Helix setlists
|
|
66
|
+
- `.hlx` Helix presets
|
|
67
|
+
- GUI-first workflows
|
|
68
|
+
|
|
69
|
+
Loopback and simulated modes are available for no-hardware tests. Hardware mode
|
|
70
|
+
is for real Helix measurement.
|
|
71
|
+
|
|
72
|
+
## Documentation
|
|
73
|
+
|
|
74
|
+
- Online manual: [noseglasses.github.io/MatchPatch](https://noseglasses.github.io/MatchPatch/)
|
|
75
|
+
- Start here: [docs/index.md](docs/index.md)
|
|
76
|
+
- 10-minute guide: [docs/quick-start.md](docs/quick-start.md)
|
|
77
|
+
- Main manual: [docs/musician-guide.md](docs/musician-guide.md)
|
|
78
|
+
- Test without hardware: [docs/workflows/test-without-hardware.md](docs/workflows/test-without-hardware.md)
|
|
79
|
+
- Hardware measurement: [docs/workflows/hardware-measurement.md](docs/workflows/hardware-measurement.md)
|
|
80
|
+
- Reference DI: [docs/concepts/reference-di.md](docs/concepts/reference-di.md)
|
|
81
|
+
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
|
|
82
|
+
- FAQ: [docs/faq.md](docs/faq.md)
|
|
83
|
+
- Glossary: [docs/glossary.md](docs/glossary.md)
|
|
84
|
+
|
|
85
|
+
## Safety Notes
|
|
86
|
+
|
|
87
|
+
> Warning:
|
|
88
|
+
> Keep backups of your original Helix files.
|
|
89
|
+
|
|
90
|
+
> Warning:
|
|
91
|
+
> Measurement files are for measuring, not for live playing.
|
|
92
|
+
|
|
93
|
+
## Install And Launch
|
|
94
|
+
|
|
95
|
+
On Windows, download the latest installer from
|
|
96
|
+
[GitHub Releases](https://github.com/noseglasses/MatchPatch/releases/latest),
|
|
97
|
+
run `MatchPatch-Setup-<version>.exe`, then launch MatchPatch from the Start
|
|
98
|
+
Menu. The installed app bundles offline Help, available from the GUI.
|
|
99
|
+
|
|
100
|
+
For source checkouts, use the verified local setup commands below and see
|
|
101
|
+
[Developer Notes](docs/developer-notes.md) and
|
|
102
|
+
[developer commands](docs/dev/commands.md) for fuller setup details.
|
|
103
|
+
|
|
104
|
+
Install the optional GUI support and launch MatchPatch:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# Linux or WSL
|
|
108
|
+
scripts/sync-wsl.sh --extra gui
|
|
109
|
+
matchpatch-gui
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```powershell
|
|
113
|
+
# Windows PowerShell
|
|
114
|
+
cd C:\src\MatchPatch-windows
|
|
115
|
+
.\scripts\sync-windows.cmd --extra gui
|
|
116
|
+
.\.venv-windows\Scripts\matchpatch-gui.exe
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Hardware measurement from WSL needs a synced native Windows runtime. See
|
|
120
|
+
[developer commands](docs/dev/commands.md) before using real Helix hardware from
|
|
121
|
+
WSL.
|
|
122
|
+
|
|
123
|
+
## Advanced And Developer Information
|
|
124
|
+
|
|
125
|
+
Technical details live in the developer docs:
|
|
126
|
+
|
|
127
|
+
- [Developer Notes](docs/developer-notes.md)
|
|
128
|
+
- [Architecture](docs/dev/architecture.md)
|
|
129
|
+
- [Commands](docs/dev/commands.md)
|
|
130
|
+
- [File Formats](docs/dev/file-formats.md)
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MatchPatch is open source software released under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
matchpatch/__init__.py,sha256=F9nWOCr_UM22K_V3SBR-iOBKKFs1K1eD5sGbxY38heI,610
|
|
2
|
+
matchpatch/analysis.py,sha256=5965dl0UKoZXCfp1o-LQ_EHIglUynZGe0Sfqx9BYrzY,2776
|
|
3
|
+
matchpatch/app.py,sha256=_hqmVrXQ5-CnG3NPQCib8AkMfZJlHJWurMarWnl9Fzw,1950
|
|
4
|
+
matchpatch/audio.py,sha256=anP23VmmlP5V6AJzSVCbGHdD48Evtmf0ZgYFC-Pq_yA,4885
|
|
5
|
+
matchpatch/cli.py,sha256=UIfgN581QHLoV4pIlIa2aP5Ihzp2Vg0LpmD5qwGY4S4,2043
|
|
6
|
+
matchpatch/config.py,sha256=YjflYuoUfTXD0EpR6tYzHkD61JWQk6FPdA0WWdStMJ4,5992
|
|
7
|
+
matchpatch/custom_adjustments.py,sha256=9vrPBCK8zbAP9vSAudhYCQwkttIg9urUtkA_OasqMDc,2312
|
|
8
|
+
matchpatch/measure.py,sha256=c3gJFFBoiMqR6eO-BktYMnEUCAgcqkgX1p6qhMZNBSk,44255
|
|
9
|
+
matchpatch/measurement_optimizer.py,sha256=3CgDKCWEKRbnsP6lsdxlKaRTPV0bUDh29-w3mIBYSvo,22785
|
|
10
|
+
matchpatch/normalize.py,sha256=ZxoEYGHwlG4g4pVyEJ5HqeonTj397huRUkyMqC1x4wI,30139
|
|
11
|
+
matchpatch/progress.py,sha256=uMxTHpE5BBKP_jFdJU3-dsnDxnfMDu21GV0NKlVtsTE,1124
|
|
12
|
+
matchpatch/workflow.py,sha256=u1uQqTNtp68pvimbZWMqIe3PQPPsVvyqjO9UTMfh8_w,12928
|
|
13
|
+
matchpatch/devices/__init__.py,sha256=nC4WfuzMOQmFPrSgrsIF-hIRBTVDpHl1a5nzFld1-gw,196
|
|
14
|
+
matchpatch/devices/base.py,sha256=FPY-k4l3lW968ULBArlUgGqXxiSyqzqTAemALO03O_w,6677
|
|
15
|
+
matchpatch/devices/helix.py,sha256=ZcXtfU0j2inSGVIBlt7LN6wFxrO7fEmAebLFz35buKk,15763
|
|
16
|
+
matchpatch/devices/registry.py,sha256=SgfqsVHmvT-mItbhiBP8I5NHwNIS05fVd5rBih3DDUc,642
|
|
17
|
+
matchpatch/gui/__init__.py,sha256=PD5RmVUlqzMUz3rQRzvC8YN58R9Yb0805O08wp9OlnM,48
|
|
18
|
+
matchpatch/gui/app.py,sha256=iB1WMgU82yJtXy6eeX_3hH170o0DYEHQgl8iZpW-LH4,6485
|
|
19
|
+
matchpatch/gui/device_panels.py,sha256=bxptD85eDKxVwKnNC6y0izxn5IpQTIrV7Jt60rCucS8,5328
|
|
20
|
+
matchpatch/gui/dialogs.py,sha256=ab7PDn__gzRCZOw-VWkGsCX048rIYD0a6OAaCf7RcY4,5810
|
|
21
|
+
matchpatch/gui/help.py,sha256=g2km4N0--0D_wXwLqtcgGevXs2dIUmnslClpt6qe11E,7464
|
|
22
|
+
matchpatch/gui/main_window.py,sha256=25yBPKgjcP8NPWczY3CaGL9S4I41dvoODRMU8hotKhs,322861
|
|
23
|
+
matchpatch/gui/snapshot_header.py,sha256=ZuZdXkZHZTOlI6kuax7lj_SvAIcNnCsF2bk8N8pZLO8,1927
|
|
24
|
+
matchpatch/gui/worker.py,sha256=CzMBTvMFgQ4wSTtsO8nTb0WkTzLmboUezuD2wVA62P0,4239
|
|
25
|
+
matchpatch-0.4.0.dist-info/METADATA,sha256=tczsa4V3FLx2h9-RHBKyEb2LSQ7GQ47BX6BolboboOY,4799
|
|
26
|
+
matchpatch-0.4.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
27
|
+
matchpatch-0.4.0.dist-info/entry_points.txt,sha256=kjAMZz7z0FH-8lNWGE6ECXq664vPId54K6bRvc6eSH8,92
|
|
28
|
+
matchpatch-0.4.0.dist-info/licenses/LICENSE,sha256=8pi7wtiIu5UFxxFQwLfTi7DudedhoPhOldqL5Z00LTo,1080
|
|
29
|
+
matchpatch-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MatchPatch contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|