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.
@@ -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}")