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/normalize.py
ADDED
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
"""Generic MatchPatch gain-normalization orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import csv
|
|
7
|
+
import os
|
|
8
|
+
import queue
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, cast
|
|
18
|
+
|
|
19
|
+
from matchpatch.analysis import AnalysisOptions
|
|
20
|
+
from matchpatch.config import Config, config_value, load_config, parse_channel_mapping, prefer
|
|
21
|
+
from matchpatch.devices import get_device_profile
|
|
22
|
+
from matchpatch.devices.base import (
|
|
23
|
+
NormalizationPolicy,
|
|
24
|
+
normalize_regex_pattern,
|
|
25
|
+
validate_snapshot_count,
|
|
26
|
+
)
|
|
27
|
+
from matchpatch.measurement_optimizer import OptimizationProgress
|
|
28
|
+
from matchpatch.progress import ProgressEvent
|
|
29
|
+
from matchpatch.workflow import ImportRequest, NormalizationRequest, normalize_presets
|
|
30
|
+
|
|
31
|
+
PROJECT_DIR = Path(__file__).resolve().parents[2]
|
|
32
|
+
DEFAULT_WINDOWS_PYTHON = PROJECT_DIR / ".venv-windows" / "Scripts" / "python.exe"
|
|
33
|
+
PROCESS_REAP_TIMEOUT_SECONDS = 1.0
|
|
34
|
+
DEFAULT_REFERENCE_DI = (
|
|
35
|
+
PROJECT_DIR / "audio" / "reference-di" / "DI_Strandberg_Boden_Fusion_Bridge_Humbucker.wav"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _mapping_argument(value: object | None) -> str | None:
|
|
40
|
+
if value is None:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
return ",".join(str(channel) for channel in parse_channel_mapping(value))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _normalization_policy(config: Config, args: argparse.Namespace) -> NormalizationPolicy:
|
|
47
|
+
profile = get_device_profile(args.device)
|
|
48
|
+
policy = NormalizationPolicy(
|
|
49
|
+
snapshot_count=cast(
|
|
50
|
+
int,
|
|
51
|
+
prefer(
|
|
52
|
+
args.snapshot_count,
|
|
53
|
+
config,
|
|
54
|
+
"policy",
|
|
55
|
+
"measured_snapshots",
|
|
56
|
+
default=getattr(profile, "snapshot_count", 4),
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
solo_regex=normalize_regex_pattern(
|
|
60
|
+
cast(
|
|
61
|
+
str,
|
|
62
|
+
prefer(
|
|
63
|
+
args.solo_regex,
|
|
64
|
+
config,
|
|
65
|
+
"policy",
|
|
66
|
+
"solo_regex",
|
|
67
|
+
default=config_value(
|
|
68
|
+
config,
|
|
69
|
+
"policy",
|
|
70
|
+
"solo_marker",
|
|
71
|
+
default=NormalizationPolicy().solo_regex,
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
),
|
|
76
|
+
ignore_snapshot_regex=normalize_regex_pattern(
|
|
77
|
+
cast(
|
|
78
|
+
str,
|
|
79
|
+
prefer(
|
|
80
|
+
args.ignore_snapshot_regex,
|
|
81
|
+
config,
|
|
82
|
+
"policy",
|
|
83
|
+
"ignore_snapshot_regex",
|
|
84
|
+
default=NormalizationPolicy().ignore_snapshot_regex,
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
),
|
|
88
|
+
solo_gain_bump_db=cast(
|
|
89
|
+
float,
|
|
90
|
+
prefer(args.solo_gain_bump_db, config, "policy", "solo_gain_bump_db", default=3.0),
|
|
91
|
+
),
|
|
92
|
+
crest_factor_reference_db=config_value(
|
|
93
|
+
config, "policy", "crest_factor_reference_db", default=12.0
|
|
94
|
+
),
|
|
95
|
+
crest_factor_correction_ratio=config_value(
|
|
96
|
+
config, "policy", "crest_factor_correction_ratio", default=0.4
|
|
97
|
+
),
|
|
98
|
+
max_crest_factor_correction_db=config_value(
|
|
99
|
+
config, "policy", "max_crest_factor_correction_db", default=3.0
|
|
100
|
+
),
|
|
101
|
+
gain_deadband_db=config_value(config, "policy", "gain_deadband_db", default=0.25),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
validate_snapshot_count(profile, policy.snapshot_count)
|
|
105
|
+
try:
|
|
106
|
+
re.compile(policy.solo_regex)
|
|
107
|
+
except re.error as exc:
|
|
108
|
+
raise ValueError(f"Invalid solo snapshot regex: {exc}") from exc
|
|
109
|
+
try:
|
|
110
|
+
re.compile(policy.ignore_snapshot_regex)
|
|
111
|
+
except re.error as exc:
|
|
112
|
+
raise ValueError(f"Invalid ignore snapshot regex: {exc}") from exc
|
|
113
|
+
|
|
114
|
+
return policy
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _analysis_options(config: Config, args: argparse.Namespace) -> AnalysisOptions:
|
|
118
|
+
def float_prefer(arg_value: object | None, section: str, key: str, default: float) -> float:
|
|
119
|
+
value = prefer(arg_value, config, section, key, default=default)
|
|
120
|
+
if value is None:
|
|
121
|
+
return default
|
|
122
|
+
return float(cast(Any, value))
|
|
123
|
+
|
|
124
|
+
return AnalysisOptions(
|
|
125
|
+
window_seconds=float_prefer(
|
|
126
|
+
args.analysis_window,
|
|
127
|
+
"analysis",
|
|
128
|
+
"window_seconds",
|
|
129
|
+
default=3.0,
|
|
130
|
+
),
|
|
131
|
+
interval_seconds=float_prefer(
|
|
132
|
+
args.analysis_interval,
|
|
133
|
+
"analysis",
|
|
134
|
+
"interval_seconds",
|
|
135
|
+
default=0.1,
|
|
136
|
+
),
|
|
137
|
+
minimum_valid_lufs=float_prefer(
|
|
138
|
+
args.minimum_valid_lufs,
|
|
139
|
+
"analysis",
|
|
140
|
+
"minimum_valid_lufs",
|
|
141
|
+
default=-100.0,
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def apply_config(args: argparse.Namespace) -> argparse.Namespace:
|
|
147
|
+
config = load_config(args.config)
|
|
148
|
+
profile = get_device_profile(args.device)
|
|
149
|
+
default_audio = (
|
|
150
|
+
profile.default_audio_routing()
|
|
151
|
+
if hasattr(profile, "default_audio_routing")
|
|
152
|
+
else argparse.Namespace(
|
|
153
|
+
device=None,
|
|
154
|
+
sample_rate=None,
|
|
155
|
+
input_mapping=None,
|
|
156
|
+
output_mapping=None,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
default_steering = (
|
|
160
|
+
profile.default_steering_options()
|
|
161
|
+
if hasattr(profile, "default_steering_options")
|
|
162
|
+
else argparse.Namespace(
|
|
163
|
+
output=None,
|
|
164
|
+
channel=None,
|
|
165
|
+
preset_wait_seconds=None,
|
|
166
|
+
snapshot_wait_seconds=None,
|
|
167
|
+
measurement_wait_seconds=None,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
device_audio = ("devices", args.device, "audio")
|
|
171
|
+
device_steering = ("devices", args.device, "steering")
|
|
172
|
+
args.backend = (
|
|
173
|
+
args.backend
|
|
174
|
+
or os.getenv("MATCHPATCH_BACKEND")
|
|
175
|
+
or config_value(config, "normalize", "backend", default="hardware")
|
|
176
|
+
)
|
|
177
|
+
args.windows_python = (
|
|
178
|
+
args.windows_python
|
|
179
|
+
or os.getenv("MATCHPATCH_WINDOWS_PYTHON")
|
|
180
|
+
or config_value(
|
|
181
|
+
config,
|
|
182
|
+
"normalize",
|
|
183
|
+
"windows_python",
|
|
184
|
+
default=str(DEFAULT_WINDOWS_PYTHON),
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
args.reference_di = (
|
|
188
|
+
args.reference_di
|
|
189
|
+
or os.getenv("MATCHPATCH_REFERENCE_DI")
|
|
190
|
+
or config_value(config, "normalize", "reference_di", default=str(DEFAULT_REFERENCE_DI))
|
|
191
|
+
)
|
|
192
|
+
args.custom_adjustments_file = prefer(
|
|
193
|
+
args.custom_adjustments_file,
|
|
194
|
+
config,
|
|
195
|
+
"normalize",
|
|
196
|
+
"custom_adjustments_file",
|
|
197
|
+
default=config_value(config, "normalize", "custom_adjustments"),
|
|
198
|
+
)
|
|
199
|
+
args.target_lufs = prefer(args.target_lufs, config, "normalize", "target_lufs", default=-16.0)
|
|
200
|
+
args.timeout = prefer(args.timeout, config, "normalize", "timeout_seconds")
|
|
201
|
+
args.ignore_bad_lufs = True
|
|
202
|
+
args.audio_device = prefer(
|
|
203
|
+
args.audio_device,
|
|
204
|
+
config,
|
|
205
|
+
*device_audio,
|
|
206
|
+
"device",
|
|
207
|
+
default=default_audio.device,
|
|
208
|
+
)
|
|
209
|
+
args.sample_rate = prefer(
|
|
210
|
+
args.sample_rate,
|
|
211
|
+
config,
|
|
212
|
+
*device_audio,
|
|
213
|
+
"sample_rate",
|
|
214
|
+
default=default_audio.sample_rate,
|
|
215
|
+
)
|
|
216
|
+
args.input_mapping = _mapping_argument(
|
|
217
|
+
prefer(
|
|
218
|
+
args.input_mapping,
|
|
219
|
+
config,
|
|
220
|
+
*device_audio,
|
|
221
|
+
"input_mapping",
|
|
222
|
+
default=default_audio.input_mapping,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
args.output_mapping = _mapping_argument(
|
|
226
|
+
prefer(
|
|
227
|
+
args.output_mapping,
|
|
228
|
+
config,
|
|
229
|
+
*device_audio,
|
|
230
|
+
"output_mapping",
|
|
231
|
+
default=default_audio.output_mapping,
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
args.blocksize = prefer(args.blocksize, config, *device_audio, "blocksize", default=0)
|
|
235
|
+
args.steering_output = prefer(
|
|
236
|
+
args.steering_output,
|
|
237
|
+
config,
|
|
238
|
+
*device_steering,
|
|
239
|
+
"output",
|
|
240
|
+
default=default_steering.output,
|
|
241
|
+
)
|
|
242
|
+
args.steering_channel = prefer(
|
|
243
|
+
args.steering_channel,
|
|
244
|
+
config,
|
|
245
|
+
*device_steering,
|
|
246
|
+
"channel",
|
|
247
|
+
default=default_steering.channel,
|
|
248
|
+
)
|
|
249
|
+
args.preset_wait = prefer(
|
|
250
|
+
args.preset_wait,
|
|
251
|
+
config,
|
|
252
|
+
*device_steering,
|
|
253
|
+
"preset_wait_seconds",
|
|
254
|
+
default=default_steering.preset_wait_seconds,
|
|
255
|
+
)
|
|
256
|
+
args.snapshot_wait = prefer(
|
|
257
|
+
args.snapshot_wait,
|
|
258
|
+
config,
|
|
259
|
+
*device_steering,
|
|
260
|
+
"snapshot_wait_seconds",
|
|
261
|
+
default=default_steering.snapshot_wait_seconds,
|
|
262
|
+
)
|
|
263
|
+
args.measurement_wait = prefer(
|
|
264
|
+
args.measurement_wait,
|
|
265
|
+
config,
|
|
266
|
+
*device_steering,
|
|
267
|
+
"measurement_wait_seconds",
|
|
268
|
+
default=default_steering.measurement_wait_seconds,
|
|
269
|
+
)
|
|
270
|
+
args.pre_roll = prefer(args.pre_roll, config, "analysis", "pre_roll_seconds", default=0.2)
|
|
271
|
+
args.post_roll = prefer(args.post_roll, config, "analysis", "post_roll_seconds", default=0.1)
|
|
272
|
+
args.round_trip_latency = prefer(
|
|
273
|
+
args.round_trip_latency,
|
|
274
|
+
config,
|
|
275
|
+
"analysis",
|
|
276
|
+
"round_trip_latency_seconds",
|
|
277
|
+
default=0.02,
|
|
278
|
+
)
|
|
279
|
+
args.policy = _normalization_policy(config, args)
|
|
280
|
+
args.analysis_options = _analysis_options(config, args)
|
|
281
|
+
return args
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def run_command(args: list[object], timeout: float | None = None) -> None:
|
|
285
|
+
subprocess.run(
|
|
286
|
+
[str(arg) for arg in args],
|
|
287
|
+
check=True,
|
|
288
|
+
text=True,
|
|
289
|
+
timeout=timeout,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _is_windows() -> bool:
|
|
294
|
+
return os.name == "nt"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def wsl_path_to_windows(path: Path) -> str:
|
|
298
|
+
text = str(path)
|
|
299
|
+
if _is_windows() or re.match(r"^[A-Za-z]:[\\/]", text) or text.startswith("\\\\"):
|
|
300
|
+
return text
|
|
301
|
+
|
|
302
|
+
completed = subprocess.run(
|
|
303
|
+
["wslpath", "-w", str(path.resolve())],
|
|
304
|
+
check=True,
|
|
305
|
+
text=True,
|
|
306
|
+
stdout=subprocess.PIPE,
|
|
307
|
+
)
|
|
308
|
+
return completed.stdout.strip()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _missing_windows_environment_message() -> str:
|
|
312
|
+
if _is_windows():
|
|
313
|
+
return (
|
|
314
|
+
"Native Windows MatchPatch environment is missing. Run scripts\\sync-windows.cmd first."
|
|
315
|
+
)
|
|
316
|
+
return (
|
|
317
|
+
"Native Windows MatchPatch environment is missing. "
|
|
318
|
+
"Run scripts/sync-windows-from-wsl.sh first."
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def count_csv_rows(csv_path: Path) -> int:
|
|
323
|
+
with csv_path.open("r", encoding="utf-8-sig", newline="") as csv_file:
|
|
324
|
+
return sum(1 for _ in csv.DictReader(csv_file))
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def wait_for_user_confirmation(message: str) -> None:
|
|
328
|
+
print()
|
|
329
|
+
print(message)
|
|
330
|
+
input("Press Enter to continue...")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def run_windows_analysis(
|
|
334
|
+
args: argparse.Namespace | NormalizationRequest,
|
|
335
|
+
preset_ids: list[int],
|
|
336
|
+
csv_path: Path,
|
|
337
|
+
on_progress: Callable[[ProgressEvent], None] | None = None,
|
|
338
|
+
cancel_requested: Callable[[], bool] | None = None,
|
|
339
|
+
) -> None:
|
|
340
|
+
windows_python = Path(args.windows_python).resolve()
|
|
341
|
+
|
|
342
|
+
if not windows_python.exists():
|
|
343
|
+
raise RuntimeError(_missing_windows_environment_message())
|
|
344
|
+
|
|
345
|
+
command: list[object] = [
|
|
346
|
+
windows_python,
|
|
347
|
+
"-m",
|
|
348
|
+
"matchpatch.measure",
|
|
349
|
+
"measure",
|
|
350
|
+
"--device",
|
|
351
|
+
args.device,
|
|
352
|
+
"--backend",
|
|
353
|
+
args.backend,
|
|
354
|
+
"--preset-ids",
|
|
355
|
+
",".join(str(preset_id) for preset_id in preset_ids),
|
|
356
|
+
"--csv",
|
|
357
|
+
wsl_path_to_windows(csv_path),
|
|
358
|
+
"--reference-di",
|
|
359
|
+
wsl_path_to_windows(Path(args.reference_di)),
|
|
360
|
+
]
|
|
361
|
+
|
|
362
|
+
optional_values = {
|
|
363
|
+
"--audio-device": args.audio_device,
|
|
364
|
+
"--steering-output": args.steering_output,
|
|
365
|
+
"--steering-channel": args.steering_channel,
|
|
366
|
+
"--sample-rate": args.sample_rate,
|
|
367
|
+
"--input-mapping": args.input_mapping,
|
|
368
|
+
"--output-mapping": args.output_mapping,
|
|
369
|
+
"--simulate-fail-presets": getattr(args, "simulate_fail_presets", None),
|
|
370
|
+
"--blocksize": getattr(args, "blocksize", None),
|
|
371
|
+
"--preset-wait": getattr(args, "preset_wait", None),
|
|
372
|
+
"--snapshot-wait": getattr(args, "snapshot_wait", None),
|
|
373
|
+
"--measurement-wait": getattr(args, "measurement_wait", None),
|
|
374
|
+
"--pre-roll": getattr(args, "pre_roll", None),
|
|
375
|
+
"--post-roll": getattr(args, "post_roll", None),
|
|
376
|
+
"--round-trip-latency": getattr(args, "round_trip_latency", None),
|
|
377
|
+
"--snapshot-count": getattr(args, "policy", NormalizationPolicy()).snapshot_count,
|
|
378
|
+
"--analysis-window": getattr(args, "analysis_options", AnalysisOptions()).window_seconds,
|
|
379
|
+
"--analysis-interval": getattr(
|
|
380
|
+
args, "analysis_options", AnalysisOptions()
|
|
381
|
+
).interval_seconds,
|
|
382
|
+
"--minimum-valid-lufs": getattr(
|
|
383
|
+
args, "analysis_options", AnalysisOptions()
|
|
384
|
+
).minimum_valid_lufs,
|
|
385
|
+
}
|
|
386
|
+
path_values = {
|
|
387
|
+
"--playback-toggle-file": getattr(args, "playback_toggle_path", None),
|
|
388
|
+
"--recordings-dir": getattr(args, "recorded_output_dir", None),
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
for option, value in optional_values.items():
|
|
392
|
+
if value is not None:
|
|
393
|
+
command.extend([option, value])
|
|
394
|
+
for option, value in path_values.items():
|
|
395
|
+
if value is not None:
|
|
396
|
+
command.extend([option, wsl_path_to_windows(Path(value))])
|
|
397
|
+
snapshot_plan = getattr(args, "snapshot_plan", ())
|
|
398
|
+
if snapshot_plan:
|
|
399
|
+
command.extend(["--snapshot-plan", _format_snapshot_plan(snapshot_plan)])
|
|
400
|
+
if getattr(args, "play_recorded_output", False):
|
|
401
|
+
command.append("--play-recorded-output")
|
|
402
|
+
|
|
403
|
+
if on_progress is None:
|
|
404
|
+
try:
|
|
405
|
+
run_command(command, timeout=args.timeout)
|
|
406
|
+
except subprocess.TimeoutExpired as exc:
|
|
407
|
+
raise TimeoutError("Timed out waiting for native Windows analysis") from exc
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
command.append("--progress-jsonl")
|
|
411
|
+
_run_progress_command(command, args.timeout, on_progress, cancel_requested)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _format_snapshot_plan(snapshot_plan: tuple[tuple[str, tuple[int, ...]], ...]) -> str:
|
|
415
|
+
return ";".join(
|
|
416
|
+
f"{patch}={','.join(str(snapshot) for snapshot in snapshots)}"
|
|
417
|
+
for patch, snapshots in snapshot_plan
|
|
418
|
+
if snapshots
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def run_windows_optimization(
|
|
423
|
+
args: argparse.Namespace | NormalizationRequest,
|
|
424
|
+
preset_id: int,
|
|
425
|
+
*,
|
|
426
|
+
stability_runs: int,
|
|
427
|
+
termination_tolerance: float,
|
|
428
|
+
stability_tolerance: float,
|
|
429
|
+
pinned_parameters: tuple[str, ...] = (),
|
|
430
|
+
on_progress: Callable[[OptimizationProgress], None] | None = None,
|
|
431
|
+
cancel_requested: Callable[[], bool] | None = None,
|
|
432
|
+
) -> str:
|
|
433
|
+
windows_python = Path(args.windows_python).resolve()
|
|
434
|
+
|
|
435
|
+
if not windows_python.exists():
|
|
436
|
+
raise RuntimeError(_missing_windows_environment_message())
|
|
437
|
+
|
|
438
|
+
command: list[object] = [
|
|
439
|
+
windows_python,
|
|
440
|
+
"-m",
|
|
441
|
+
"matchpatch.measure",
|
|
442
|
+
"optimize",
|
|
443
|
+
"--device",
|
|
444
|
+
args.device,
|
|
445
|
+
"--backend",
|
|
446
|
+
args.backend,
|
|
447
|
+
"--preset-id",
|
|
448
|
+
preset_id,
|
|
449
|
+
"--reference-di",
|
|
450
|
+
wsl_path_to_windows(Path(args.reference_di)),
|
|
451
|
+
"--stability-runs",
|
|
452
|
+
stability_runs,
|
|
453
|
+
"--termination-tolerance",
|
|
454
|
+
termination_tolerance,
|
|
455
|
+
"--stability-tolerance",
|
|
456
|
+
stability_tolerance,
|
|
457
|
+
]
|
|
458
|
+
for parameter in pinned_parameters:
|
|
459
|
+
command.extend(["--pinned-parameter", parameter])
|
|
460
|
+
|
|
461
|
+
optional_values = {
|
|
462
|
+
"--audio-device": args.audio_device,
|
|
463
|
+
"--steering-output": args.steering_output,
|
|
464
|
+
"--steering-channel": args.steering_channel,
|
|
465
|
+
"--sample-rate": args.sample_rate,
|
|
466
|
+
"--input-mapping": args.input_mapping,
|
|
467
|
+
"--output-mapping": args.output_mapping,
|
|
468
|
+
"--simulate-fail-presets": getattr(args, "simulate_fail_presets", None),
|
|
469
|
+
"--blocksize": getattr(args, "blocksize", None),
|
|
470
|
+
"--preset-wait": getattr(args, "preset_wait", None),
|
|
471
|
+
"--snapshot-wait": getattr(args, "snapshot_wait", None),
|
|
472
|
+
"--measurement-wait": getattr(args, "measurement_wait", None),
|
|
473
|
+
"--pre-roll": getattr(args, "pre_roll", None),
|
|
474
|
+
"--post-roll": getattr(args, "post_roll", None),
|
|
475
|
+
"--round-trip-latency": getattr(args, "round_trip_latency", None),
|
|
476
|
+
"--analysis-window": getattr(args, "analysis_options", AnalysisOptions()).window_seconds,
|
|
477
|
+
"--analysis-interval": getattr(
|
|
478
|
+
args, "analysis_options", AnalysisOptions()
|
|
479
|
+
).interval_seconds,
|
|
480
|
+
"--minimum-valid-lufs": getattr(
|
|
481
|
+
args, "analysis_options", AnalysisOptions()
|
|
482
|
+
).minimum_valid_lufs,
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
for option, value in optional_values.items():
|
|
486
|
+
if value is not None:
|
|
487
|
+
command.extend([option, value])
|
|
488
|
+
playback_toggle_path = getattr(args, "playback_toggle_path", None)
|
|
489
|
+
if playback_toggle_path is not None:
|
|
490
|
+
command.extend(["--playback-toggle-file", wsl_path_to_windows(Path(playback_toggle_path))])
|
|
491
|
+
if getattr(args, "play_recorded_output", False):
|
|
492
|
+
command.append("--play-recorded-output")
|
|
493
|
+
|
|
494
|
+
if on_progress is None:
|
|
495
|
+
completed = subprocess.run(
|
|
496
|
+
[str(arg) for arg in command],
|
|
497
|
+
check=True,
|
|
498
|
+
text=True,
|
|
499
|
+
stdout=subprocess.PIPE,
|
|
500
|
+
timeout=args.timeout,
|
|
501
|
+
)
|
|
502
|
+
return completed.stdout.strip()
|
|
503
|
+
|
|
504
|
+
command.append("--progress-jsonl")
|
|
505
|
+
return _run_optimization_progress_command(
|
|
506
|
+
command,
|
|
507
|
+
args.timeout,
|
|
508
|
+
on_progress,
|
|
509
|
+
cancel_requested,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def check_windows_hardware(args: argparse.Namespace | NormalizationRequest) -> None:
|
|
514
|
+
windows_python = Path(args.windows_python).resolve()
|
|
515
|
+
|
|
516
|
+
if not windows_python.exists():
|
|
517
|
+
raise RuntimeError(_missing_windows_environment_message())
|
|
518
|
+
|
|
519
|
+
command: list[object] = [
|
|
520
|
+
windows_python,
|
|
521
|
+
"-m",
|
|
522
|
+
"matchpatch.measure",
|
|
523
|
+
"check-hardware",
|
|
524
|
+
"--device",
|
|
525
|
+
args.device,
|
|
526
|
+
]
|
|
527
|
+
|
|
528
|
+
optional_values = {
|
|
529
|
+
"--audio-device": args.audio_device,
|
|
530
|
+
"--steering-output": args.steering_output,
|
|
531
|
+
"--steering-channel": args.steering_channel,
|
|
532
|
+
"--sample-rate": args.sample_rate,
|
|
533
|
+
"--input-mapping": args.input_mapping,
|
|
534
|
+
"--output-mapping": args.output_mapping,
|
|
535
|
+
"--blocksize": getattr(args, "blocksize", None),
|
|
536
|
+
"--preset-wait": getattr(args, "preset_wait", None),
|
|
537
|
+
"--snapshot-wait": getattr(args, "snapshot_wait", None),
|
|
538
|
+
"--measurement-wait": getattr(args, "measurement_wait", None),
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
for option, value in optional_values.items():
|
|
542
|
+
if value is not None:
|
|
543
|
+
command.extend([option, value])
|
|
544
|
+
|
|
545
|
+
try:
|
|
546
|
+
subprocess.run(
|
|
547
|
+
[str(arg) for arg in command],
|
|
548
|
+
check=True,
|
|
549
|
+
text=True,
|
|
550
|
+
stdout=subprocess.PIPE,
|
|
551
|
+
stderr=subprocess.PIPE,
|
|
552
|
+
timeout=args.timeout,
|
|
553
|
+
)
|
|
554
|
+
except subprocess.TimeoutExpired as exc:
|
|
555
|
+
raise TimeoutError("Timed out checking native Windows hardware") from exc
|
|
556
|
+
except subprocess.CalledProcessError as exc:
|
|
557
|
+
message = (exc.stderr or exc.stdout or "").strip()
|
|
558
|
+
raise RuntimeError(message or "Native Windows hardware check failed") from exc
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _run_progress_command(
|
|
562
|
+
command: list[object],
|
|
563
|
+
timeout: float | None,
|
|
564
|
+
on_progress: Callable[[ProgressEvent], None],
|
|
565
|
+
cancel_requested: Callable[[], bool] | None = None,
|
|
566
|
+
) -> None:
|
|
567
|
+
process = subprocess.Popen( # noqa: S603
|
|
568
|
+
[str(arg) for arg in command],
|
|
569
|
+
text=True,
|
|
570
|
+
stdout=subprocess.PIPE,
|
|
571
|
+
stderr=subprocess.PIPE,
|
|
572
|
+
bufsize=1,
|
|
573
|
+
)
|
|
574
|
+
lines: queue.Queue[tuple[str, str] | None] = queue.Queue()
|
|
575
|
+
|
|
576
|
+
def read_stream(name: str) -> None:
|
|
577
|
+
stream = getattr(process, name)
|
|
578
|
+
assert stream is not None
|
|
579
|
+
|
|
580
|
+
for line in stream:
|
|
581
|
+
lines.put((name, line))
|
|
582
|
+
|
|
583
|
+
lines.put(None)
|
|
584
|
+
|
|
585
|
+
threading.Thread(target=read_stream, args=("stdout",), daemon=True).start()
|
|
586
|
+
threading.Thread(target=read_stream, args=("stderr",), daemon=True).start()
|
|
587
|
+
deadline = time.monotonic() + timeout if timeout is not None else None
|
|
588
|
+
open_streams = 2
|
|
589
|
+
|
|
590
|
+
try:
|
|
591
|
+
while open_streams or process.poll() is None:
|
|
592
|
+
if cancel_requested is not None and cancel_requested():
|
|
593
|
+
raise RuntimeError("Normalization cancelled by user")
|
|
594
|
+
|
|
595
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
596
|
+
raise TimeoutError("Timed out waiting for native Windows analysis")
|
|
597
|
+
|
|
598
|
+
try:
|
|
599
|
+
line = lines.get(timeout=0.1)
|
|
600
|
+
except queue.Empty:
|
|
601
|
+
continue
|
|
602
|
+
|
|
603
|
+
if line is None:
|
|
604
|
+
open_streams -= 1
|
|
605
|
+
continue
|
|
606
|
+
|
|
607
|
+
stream_name, text = line
|
|
608
|
+
if stream_name == "stderr":
|
|
609
|
+
on_progress(ProgressEvent("error_log", message=text.rstrip()))
|
|
610
|
+
continue
|
|
611
|
+
|
|
612
|
+
try:
|
|
613
|
+
on_progress(ProgressEvent.from_json(text))
|
|
614
|
+
except ValueError as exc:
|
|
615
|
+
raise RuntimeError(
|
|
616
|
+
f"Invalid progress output from native Windows analysis: {text}"
|
|
617
|
+
) from exc
|
|
618
|
+
|
|
619
|
+
return_code = process.wait()
|
|
620
|
+
|
|
621
|
+
if return_code:
|
|
622
|
+
raise subprocess.CalledProcessError(return_code, [str(arg) for arg in command])
|
|
623
|
+
finally:
|
|
624
|
+
if process.poll() is None:
|
|
625
|
+
cleanup = threading.Thread(target=_kill_process, args=(process,), daemon=True)
|
|
626
|
+
cleanup.start()
|
|
627
|
+
cleanup.join(PROCESS_REAP_TIMEOUT_SECONDS)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _run_optimization_progress_command(
|
|
631
|
+
command: list[object],
|
|
632
|
+
timeout: float | None,
|
|
633
|
+
on_progress: Callable[[OptimizationProgress], None],
|
|
634
|
+
cancel_requested: Callable[[], bool] | None = None,
|
|
635
|
+
) -> str:
|
|
636
|
+
process = subprocess.Popen( # noqa: S603
|
|
637
|
+
[str(arg) for arg in command],
|
|
638
|
+
text=True,
|
|
639
|
+
stdout=subprocess.PIPE,
|
|
640
|
+
stderr=subprocess.PIPE,
|
|
641
|
+
bufsize=1,
|
|
642
|
+
)
|
|
643
|
+
lines: queue.Queue[tuple[str, str] | None] = queue.Queue()
|
|
644
|
+
result_toml = ""
|
|
645
|
+
|
|
646
|
+
def read_stream(name: str) -> None:
|
|
647
|
+
stream = getattr(process, name)
|
|
648
|
+
assert stream is not None
|
|
649
|
+
|
|
650
|
+
for line in stream:
|
|
651
|
+
lines.put((name, line))
|
|
652
|
+
|
|
653
|
+
lines.put(None)
|
|
654
|
+
|
|
655
|
+
threading.Thread(target=read_stream, args=("stdout",), daemon=True).start()
|
|
656
|
+
threading.Thread(target=read_stream, args=("stderr",), daemon=True).start()
|
|
657
|
+
deadline = time.monotonic() + timeout if timeout is not None else None
|
|
658
|
+
open_streams = 2
|
|
659
|
+
error_lines: list[str] = []
|
|
660
|
+
|
|
661
|
+
try:
|
|
662
|
+
while open_streams or process.poll() is None:
|
|
663
|
+
if cancel_requested is not None and cancel_requested():
|
|
664
|
+
raise RuntimeError("Measurement optimization cancelled by user")
|
|
665
|
+
|
|
666
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
667
|
+
raise TimeoutError("Timed out waiting for native Windows optimization")
|
|
668
|
+
|
|
669
|
+
try:
|
|
670
|
+
line = lines.get(timeout=0.1)
|
|
671
|
+
except queue.Empty:
|
|
672
|
+
continue
|
|
673
|
+
|
|
674
|
+
if line is None:
|
|
675
|
+
open_streams -= 1
|
|
676
|
+
continue
|
|
677
|
+
|
|
678
|
+
stream_name, text = line
|
|
679
|
+
if stream_name == "stderr":
|
|
680
|
+
stripped = text.rstrip()
|
|
681
|
+
if stripped:
|
|
682
|
+
error_lines.append(stripped)
|
|
683
|
+
continue
|
|
684
|
+
|
|
685
|
+
try:
|
|
686
|
+
event = OptimizationProgress.from_json(text)
|
|
687
|
+
except ValueError as exc:
|
|
688
|
+
raise RuntimeError(
|
|
689
|
+
f"Invalid progress output from native Windows optimization: {text}"
|
|
690
|
+
) from exc
|
|
691
|
+
if event.result_toml is not None:
|
|
692
|
+
result_toml = event.result_toml
|
|
693
|
+
on_progress(event)
|
|
694
|
+
|
|
695
|
+
return_code = process.wait()
|
|
696
|
+
|
|
697
|
+
if return_code:
|
|
698
|
+
detail = "\n".join(error_lines).strip()
|
|
699
|
+
if detail:
|
|
700
|
+
raise RuntimeError(detail)
|
|
701
|
+
raise RuntimeError(f"Native Windows optimization failed with exit status {return_code}")
|
|
702
|
+
finally:
|
|
703
|
+
if process.poll() is None:
|
|
704
|
+
cleanup = threading.Thread(target=_kill_process, args=(process,), daemon=True)
|
|
705
|
+
cleanup.start()
|
|
706
|
+
cleanup.join(PROCESS_REAP_TIMEOUT_SECONDS)
|
|
707
|
+
|
|
708
|
+
return result_toml
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _kill_process(process: subprocess.Popen[str]) -> None:
|
|
712
|
+
try:
|
|
713
|
+
process.kill()
|
|
714
|
+
except OSError:
|
|
715
|
+
return
|
|
716
|
+
try:
|
|
717
|
+
process.wait(timeout=PROCESS_REAP_TIMEOUT_SECONDS)
|
|
718
|
+
except subprocess.TimeoutExpired:
|
|
719
|
+
pass
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
723
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
724
|
+
parser.add_argument("--config", help="TOML configuration file")
|
|
725
|
+
parser.add_argument("--device", required=True, help="Audio processor profile")
|
|
726
|
+
parser.add_argument("-i", "--input", required=True)
|
|
727
|
+
parser.add_argument("-o", "--output")
|
|
728
|
+
parser.add_argument(
|
|
729
|
+
"--diff-input",
|
|
730
|
+
help="Previous version of the input file; only changed presets are normalized",
|
|
731
|
+
)
|
|
732
|
+
parser.add_argument("-a", "--automation", action="store_true")
|
|
733
|
+
parser.add_argument("-S", "--preset-set")
|
|
734
|
+
parser.add_argument("-n", "--limit", type=int)
|
|
735
|
+
parser.add_argument("--keep-temp", action="store_true")
|
|
736
|
+
parser.add_argument("--target-lufs", type=float)
|
|
737
|
+
parser.add_argument("--solo-regex")
|
|
738
|
+
parser.add_argument("--ignore-snapshot-regex")
|
|
739
|
+
parser.add_argument("--solo-gain-bump-db", type=float)
|
|
740
|
+
parser.add_argument("--snapshot-count", type=int)
|
|
741
|
+
parser.add_argument(
|
|
742
|
+
"--backend",
|
|
743
|
+
choices=["hardware", "loopback", "simulated"],
|
|
744
|
+
)
|
|
745
|
+
parser.add_argument(
|
|
746
|
+
"--windows-python",
|
|
747
|
+
)
|
|
748
|
+
parser.add_argument(
|
|
749
|
+
"--reference-di",
|
|
750
|
+
)
|
|
751
|
+
parser.add_argument("--custom-adjustments-file")
|
|
752
|
+
parser.add_argument("--audio-device")
|
|
753
|
+
parser.add_argument("--steering-output", "--midi-output")
|
|
754
|
+
parser.add_argument("--steering-channel", "--midi-channel", type=int)
|
|
755
|
+
parser.add_argument("--sample-rate", type=int)
|
|
756
|
+
parser.add_argument("--input-mapping")
|
|
757
|
+
parser.add_argument("--output-mapping")
|
|
758
|
+
parser.add_argument("--simulate-fail-presets")
|
|
759
|
+
parser.add_argument("--blocksize", type=int)
|
|
760
|
+
parser.add_argument("--preset-wait", type=float)
|
|
761
|
+
parser.add_argument("--snapshot-wait", type=float)
|
|
762
|
+
parser.add_argument("--measurement-wait", type=float)
|
|
763
|
+
parser.add_argument("--pre-roll", type=float)
|
|
764
|
+
parser.add_argument("--post-roll", type=float)
|
|
765
|
+
parser.add_argument("--round-trip-latency", type=float)
|
|
766
|
+
parser.add_argument("--analysis-window", type=float)
|
|
767
|
+
parser.add_argument("--analysis-interval", type=float)
|
|
768
|
+
parser.add_argument("--minimum-valid-lufs", type=float)
|
|
769
|
+
parser.add_argument("--play-recorded-output", action="store_true")
|
|
770
|
+
parser.add_argument("--record-device-output", action="store_true")
|
|
771
|
+
parser.add_argument("--playback-toggle-path")
|
|
772
|
+
parser.add_argument("--recorded-output-dir")
|
|
773
|
+
parser.add_argument("--timeout", type=float)
|
|
774
|
+
return parser.parse_args(argv)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def request_from_args(args: argparse.Namespace) -> NormalizationRequest:
|
|
778
|
+
return NormalizationRequest(
|
|
779
|
+
device=args.device,
|
|
780
|
+
input_path=Path(args.input),
|
|
781
|
+
output_path=Path(args.output) if args.output else None,
|
|
782
|
+
diff_input_path=Path(args.diff_input) if args.diff_input else None,
|
|
783
|
+
automation=args.automation,
|
|
784
|
+
preset_set=args.preset_set,
|
|
785
|
+
limit=args.limit,
|
|
786
|
+
keep_temp=args.keep_temp,
|
|
787
|
+
ignore_bad_lufs=args.ignore_bad_lufs,
|
|
788
|
+
target_lufs=args.target_lufs,
|
|
789
|
+
backend=args.backend,
|
|
790
|
+
windows_python=args.windows_python,
|
|
791
|
+
reference_di=Path(args.reference_di),
|
|
792
|
+
custom_adjustments_path=(
|
|
793
|
+
Path(args.custom_adjustments_file) if args.custom_adjustments_file else None
|
|
794
|
+
),
|
|
795
|
+
audio_device=args.audio_device,
|
|
796
|
+
sample_rate=args.sample_rate,
|
|
797
|
+
input_mapping=args.input_mapping,
|
|
798
|
+
output_mapping=args.output_mapping,
|
|
799
|
+
blocksize=args.blocksize,
|
|
800
|
+
steering_output=args.steering_output,
|
|
801
|
+
steering_channel=args.steering_channel,
|
|
802
|
+
preset_wait=args.preset_wait,
|
|
803
|
+
snapshot_wait=args.snapshot_wait,
|
|
804
|
+
measurement_wait=args.measurement_wait,
|
|
805
|
+
pre_roll=args.pre_roll,
|
|
806
|
+
post_roll=args.post_roll,
|
|
807
|
+
round_trip_latency=args.round_trip_latency,
|
|
808
|
+
simulate_fail_presets=args.simulate_fail_presets,
|
|
809
|
+
play_recorded_output=getattr(args, "play_recorded_output", False),
|
|
810
|
+
record_device_output=getattr(args, "record_device_output", False),
|
|
811
|
+
playback_toggle_path=(
|
|
812
|
+
Path(args.playback_toggle_path) if getattr(args, "playback_toggle_path", None) else None
|
|
813
|
+
),
|
|
814
|
+
recorded_output_dir=(
|
|
815
|
+
Path(args.recorded_output_dir) if getattr(args, "recorded_output_dir", None) else None
|
|
816
|
+
),
|
|
817
|
+
timeout=args.timeout,
|
|
818
|
+
policy=args.policy,
|
|
819
|
+
analysis_options=args.analysis_options,
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _cli_confirm_import(request: ImportRequest) -> bool:
|
|
824
|
+
wait_for_user_confirmation(request.message)
|
|
825
|
+
return True
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _cli_progress(event: ProgressEvent) -> None:
|
|
829
|
+
if event.phase == "preparing_measurement":
|
|
830
|
+
print("Creating measurement file")
|
|
831
|
+
elif event.phase == "applying":
|
|
832
|
+
print("Applying gain adjustments")
|
|
833
|
+
elif event.kind == "temp_retained":
|
|
834
|
+
print(event.message)
|
|
835
|
+
elif event.kind == "log":
|
|
836
|
+
print(event.message)
|
|
837
|
+
elif event.kind == "error_log":
|
|
838
|
+
print(event.message, file=sys.stderr)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def main(argv: list[str] | None = None) -> None:
|
|
842
|
+
args = apply_config(parse_args(argv))
|
|
843
|
+
request = request_from_args(args)
|
|
844
|
+
|
|
845
|
+
def run_analysis(
|
|
846
|
+
workflow_request: NormalizationRequest,
|
|
847
|
+
preset_ids: list[int],
|
|
848
|
+
csv_path: Path,
|
|
849
|
+
on_progress: Callable[[ProgressEvent], None] | None,
|
|
850
|
+
) -> None:
|
|
851
|
+
profile = get_device_profile(workflow_request.device)
|
|
852
|
+
handler = profile.create_patch_file_handler(PROJECT_DIR)
|
|
853
|
+
print(f"Device : {profile.name}")
|
|
854
|
+
print(f"Preset set : {','.join(str(preset_id) for preset_id in preset_ids)}")
|
|
855
|
+
print(
|
|
856
|
+
"Device IDs : "
|
|
857
|
+
+ ",".join(handler.format_patch_id(preset_id) for preset_id in preset_ids)
|
|
858
|
+
)
|
|
859
|
+
print(f"Temp CSV : {csv_path}")
|
|
860
|
+
run_windows_analysis(args, preset_ids, csv_path)
|
|
861
|
+
|
|
862
|
+
result = normalize_presets(
|
|
863
|
+
request,
|
|
864
|
+
run_analysis=run_analysis,
|
|
865
|
+
on_progress=_cli_progress,
|
|
866
|
+
confirm_import=_cli_confirm_import if request.automation else None,
|
|
867
|
+
get_profile=get_device_profile,
|
|
868
|
+
make_temp_dir=lambda: Path(
|
|
869
|
+
tempfile.mkdtemp(prefix="matchpatch_normalization_", dir=PROJECT_DIR)
|
|
870
|
+
),
|
|
871
|
+
)
|
|
872
|
+
print()
|
|
873
|
+
print("[OK] Gain-adjusted patch file written")
|
|
874
|
+
print(f"Output: {result.output_path}")
|