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/measure.py ADDED
@@ -0,0 +1,1191 @@
1
+ """Native Windows measurement worker for MatchPatch audio processors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import re
8
+ import sys
9
+ import time
10
+ from collections.abc import Callable
11
+ from dataclasses import replace
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any, Protocol, cast
14
+
15
+ import numpy as np
16
+ import soundfile as sf
17
+
18
+ from matchpatch.analysis import AnalysisOptions, analyze_audio
19
+ from matchpatch.config import (
20
+ config_value,
21
+ load_config,
22
+ )
23
+ from matchpatch.config import (
24
+ parse_channel_mapping as parse_config_mapping,
25
+ )
26
+ from matchpatch.devices import get_device_profile, list_device_profiles
27
+ from matchpatch.devices.base import (
28
+ AudioRouting,
29
+ DeviceController,
30
+ DeviceProfile,
31
+ SteeringOptions,
32
+ validate_snapshot_count,
33
+ )
34
+ from matchpatch.measurement_optimizer import (
35
+ TIMING_PARAMETERS,
36
+ OptimizationProgress,
37
+ ParameterOptimizationResult,
38
+ alternate_preset_id,
39
+ optimization_results_toml,
40
+ optimize_timing_parameters,
41
+ )
42
+ from matchpatch.progress import ProgressEvent
43
+
44
+ if TYPE_CHECKING:
45
+ from matchpatch.audio import AudioConfig
46
+
47
+ SnapshotPlan = dict[str, tuple[int, ...]]
48
+ SnapshotResult = tuple[float, float] | None
49
+ SNAPSHOT_SKIP_SENTINEL = "SKIP"
50
+
51
+
52
+ class MeasurementBackend(Protocol):
53
+ def activate_preset(self, preset_id: int) -> None: ...
54
+
55
+ def reapply_snapshot(self, snapshot: int) -> None: ...
56
+
57
+ def record(self, reference_audio: np.ndarray) -> np.ndarray: ...
58
+
59
+
60
+ PlaybackEnabled = Callable[[], bool]
61
+
62
+
63
+ class HardwareBackend:
64
+ def __init__(
65
+ self,
66
+ audio_config: AudioConfig,
67
+ controller: DeviceController,
68
+ measurement_wait_seconds: float,
69
+ ) -> None:
70
+ self.audio_config = audio_config
71
+ self.controller = controller
72
+ self.measurement_wait_seconds = measurement_wait_seconds
73
+
74
+ def activate_preset(self, preset_id: int) -> None:
75
+ self.controller.activate_preset(preset_id)
76
+
77
+ def reapply_snapshot(self, snapshot: int) -> None:
78
+ self.controller.reapply_snapshot(snapshot)
79
+ time.sleep(self.measurement_wait_seconds)
80
+
81
+ def record(self, reference_audio: np.ndarray) -> np.ndarray:
82
+ from matchpatch.audio import record_processed_audio
83
+
84
+ return record_processed_audio(reference_audio, self.audio_config)
85
+
86
+
87
+ class LoopbackBackend:
88
+ """Simulate an empty processor patch without steering or USB access."""
89
+
90
+ def activate_preset(self, preset_id: int) -> None:
91
+ return None
92
+
93
+ def reapply_snapshot(self, snapshot: int) -> None:
94
+ return None
95
+
96
+ def record(self, reference_audio: np.ndarray) -> np.ndarray:
97
+ return reference_audio.copy()
98
+
99
+
100
+ class SimulatedHardwareBackend:
101
+ """Stateful processor simulation for portable integration tests."""
102
+
103
+ def __init__(
104
+ self,
105
+ routing: AudioRouting,
106
+ snapshot_count: int,
107
+ input_mapping: tuple[int, int] | None = None,
108
+ output_mapping: tuple[int, int] | None = None,
109
+ failing_preset_ids: frozenset[int] = frozenset(),
110
+ ) -> None:
111
+ self.routing = routing
112
+ self.snapshot_count = snapshot_count
113
+ self.input_mapping = input_mapping or routing.input_mapping
114
+ self.output_mapping = output_mapping or routing.output_mapping
115
+ self.failing_preset_ids = failing_preset_ids
116
+ self.active_preset_id: int | None = None
117
+ self.active_snapshot: int | None = None
118
+ self.steering_events: list[tuple[str, int]] = []
119
+ self._validate_routing()
120
+
121
+ def _validate_routing(self) -> None:
122
+ if self.input_mapping != self.routing.input_mapping:
123
+ raise ValueError(
124
+ f"Simulated processor input mapping must be {self.routing.input_mapping}, "
125
+ f"got {self.input_mapping}"
126
+ )
127
+
128
+ if self.output_mapping != self.routing.output_mapping:
129
+ raise ValueError(
130
+ f"Simulated processor output mapping must be {self.routing.output_mapping}, "
131
+ f"got {self.output_mapping}"
132
+ )
133
+
134
+ def activate_preset(self, preset_id: int) -> None:
135
+ if preset_id < 1:
136
+ raise ValueError(f"Invalid simulated preset ID: {preset_id}")
137
+
138
+ if preset_id in self.failing_preset_ids:
139
+ raise RuntimeError(f"Simulated processor failure for preset {preset_id}")
140
+
141
+ self.active_preset_id = preset_id
142
+ self.active_snapshot = None
143
+ self.steering_events.append(("preset", preset_id))
144
+
145
+ def reapply_snapshot(self, snapshot: int) -> None:
146
+ if self.active_preset_id is None:
147
+ raise RuntimeError("Simulated processor preset is not active")
148
+
149
+ if snapshot < 1 or snapshot > self.snapshot_count:
150
+ raise ValueError(f"Invalid simulated snapshot: {snapshot}")
151
+
152
+ self.steering_events.append(("snapshot", snapshot))
153
+ self.active_snapshot = snapshot
154
+
155
+ def record(self, reference_audio: np.ndarray) -> np.ndarray:
156
+ if self.active_preset_id is None or self.active_snapshot is None:
157
+ raise RuntimeError("Simulated processor preset and snapshot must be active")
158
+
159
+ gain_db = self._gain_db(self.active_preset_id, self.active_snapshot)
160
+ processed = reference_audio.astype(np.float64, copy=True) * 10.0 ** (gain_db / 20.0)
161
+
162
+ if self.active_snapshot % 2 == 0:
163
+ processed = np.tanh(processed * 2.0) / 2.0
164
+
165
+ return processed
166
+
167
+ @staticmethod
168
+ def _gain_db(preset_id: int, snapshot: int) -> float:
169
+ return float(((preset_id - 1) % 5 - 2) * 2 + (snapshot - 1))
170
+
171
+
172
+ def parse_int_list(value: str) -> list[int]:
173
+ return [int(item.strip()) for item in value.split(",") if item.strip()]
174
+
175
+
176
+ def parse_channel_mapping(value: str) -> tuple[int, int]:
177
+ channels = tuple(parse_int_list(value))
178
+
179
+ if len(channels) != 2 or any(channel < 1 for channel in channels):
180
+ raise argparse.ArgumentTypeError("Channel mapping must contain two positive IDs")
181
+
182
+ return channels[0], channels[1]
183
+
184
+
185
+ def load_reference_audio(path: Path, sample_rate: int) -> np.ndarray:
186
+ audio, actual_rate = sf.read(path, dtype="float32", always_2d=True)
187
+
188
+ if actual_rate != sample_rate:
189
+ raise ValueError(f"Reference DI sample rate is {actual_rate}, expected {sample_rate}")
190
+
191
+ if audio.shape[1] < 2:
192
+ audio = np.repeat(audio, 2, axis=1)
193
+ elif audio.shape[1] > 2:
194
+ audio = audio[:, :2]
195
+
196
+ return audio
197
+
198
+
199
+ def csv_fields(snapshot_count: int) -> list[str]:
200
+ return [
201
+ "Preset",
202
+ "DevicePatch",
203
+ *(f"LUFS{snapshot}" for snapshot in range(1, snapshot_count + 1)),
204
+ *(f"CrestFactor{snapshot}" for snapshot in range(1, snapshot_count + 1)),
205
+ ]
206
+
207
+
208
+ def append_result_row(
209
+ writer: csv.DictWriter,
210
+ preset_id: int,
211
+ device_patch: str,
212
+ snapshot_count: int,
213
+ results: list[SnapshotResult] | dict[int, SnapshotResult] | None,
214
+ ) -> None:
215
+ row: dict[str, str | int | float] = {
216
+ "Preset": preset_id,
217
+ "DevicePatch": device_patch,
218
+ }
219
+
220
+ for snapshot in range(1, snapshot_count + 1):
221
+ if results is None:
222
+ row[f"LUFS{snapshot}"] = "ERROR"
223
+ row[f"CrestFactor{snapshot}"] = "ERROR"
224
+ elif isinstance(results, dict) and snapshot not in results:
225
+ row[f"LUFS{snapshot}"] = SNAPSHOT_SKIP_SENTINEL
226
+ row[f"CrestFactor{snapshot}"] = SNAPSHOT_SKIP_SENTINEL
227
+ else:
228
+ result = results[snapshot] if isinstance(results, dict) else results[snapshot - 1]
229
+ if result is None:
230
+ row[f"LUFS{snapshot}"] = "ERROR"
231
+ row[f"CrestFactor{snapshot}"] = "ERROR"
232
+ else:
233
+ lufs, crest = result
234
+ row[f"LUFS{snapshot}"] = lufs
235
+ row[f"CrestFactor{snapshot}"] = crest
236
+
237
+ writer.writerow(row)
238
+
239
+
240
+ def measure_presets(
241
+ profile: DeviceProfile,
242
+ preset_ids: list[int],
243
+ csv_path: Path,
244
+ reference: np.ndarray,
245
+ sample_rate: int,
246
+ backend: MeasurementBackend,
247
+ *,
248
+ snapshot_count: int | None = None,
249
+ analysis_options: AnalysisOptions = AnalysisOptions(),
250
+ on_progress: Callable[[ProgressEvent], None] | None = None,
251
+ log_output: bool = True,
252
+ play_recorded_output: bool | PlaybackEnabled = False,
253
+ recorded_output_dir: Path | None = None,
254
+ snapshot_plan: SnapshotPlan | None = None,
255
+ ) -> None:
256
+ measured_snapshots = (
257
+ snapshot_count if snapshot_count is not None else getattr(profile, "snapshot_count", 4)
258
+ )
259
+ validate_snapshot_count(profile, measured_snapshots)
260
+ csv_path.parent.mkdir(parents=True, exist_ok=True)
261
+ handler = profile.create_patch_file_handler(Path.cwd())
262
+ _emit_progress(
263
+ on_progress,
264
+ ProgressEvent("measurement_preparation", message="Analyzing reference DI loudness..."),
265
+ )
266
+ reference_lufs = analyze_audio(reference, sample_rate, analysis_options).short_term_lufs
267
+ _emit_progress(
268
+ on_progress,
269
+ ProgressEvent("reference_loudness", reference_lufs=reference_lufs),
270
+ )
271
+
272
+ with csv_path.open("w", encoding="utf-8", newline="") as csv_file:
273
+ writer = csv.DictWriter(csv_file, fieldnames=csv_fields(measured_snapshots))
274
+ writer.writeheader()
275
+
276
+ for preset_index, preset_id in enumerate(preset_ids, start=1):
277
+ device_patch = handler.format_patch_id(preset_id)
278
+ _emit_progress(
279
+ on_progress,
280
+ ProgressEvent(
281
+ "preset_started",
282
+ preset_id=preset_id,
283
+ device_patch=device_patch,
284
+ preset_index=preset_index,
285
+ preset_total=len(preset_ids),
286
+ snapshot_total=measured_snapshots,
287
+ ),
288
+ )
289
+
290
+ if log_output:
291
+ print(f"[MEASURE] {profile.name}:{device_patch}", flush=True)
292
+
293
+ try:
294
+ backend.activate_preset(preset_id)
295
+ snapshots_to_measure = _snapshots_to_measure(
296
+ snapshot_plan,
297
+ device_patch,
298
+ measured_snapshots,
299
+ )
300
+ results: dict[int, SnapshotResult] = {}
301
+
302
+ for snapshot in snapshots_to_measure:
303
+ _emit_progress(
304
+ on_progress,
305
+ ProgressEvent(
306
+ "snapshot_started",
307
+ preset_id=preset_id,
308
+ device_patch=device_patch,
309
+ preset_index=preset_index,
310
+ preset_total=len(preset_ids),
311
+ snapshot=snapshot,
312
+ snapshot_total=measured_snapshots,
313
+ ),
314
+ )
315
+ try:
316
+ backend.reapply_snapshot(snapshot)
317
+ recorded = backend.record(reference)
318
+ recorded_path = _recorded_output_path(
319
+ recorded_output_dir,
320
+ device_patch,
321
+ snapshot,
322
+ )
323
+ if recorded_path is not None:
324
+ recorded_path.parent.mkdir(parents=True, exist_ok=True)
325
+ sf.write(recorded_path, recorded, sample_rate)
326
+ _emit_progress(
327
+ on_progress,
328
+ ProgressEvent(
329
+ "snapshot_recorded",
330
+ preset_id=preset_id,
331
+ device_patch=device_patch,
332
+ preset_index=preset_index,
333
+ preset_total=len(preset_ids),
334
+ snapshot=snapshot,
335
+ snapshot_total=measured_snapshots,
336
+ path=str(recorded_path),
337
+ ),
338
+ )
339
+ if _playback_enabled(play_recorded_output):
340
+ _play_audio(recorded, sample_rate)
341
+ values = analyze_audio(recorded, sample_rate, analysis_options)
342
+ except Exception as exc: # noqa: BLE001
343
+ results[snapshot] = None
344
+ _emit_progress(
345
+ on_progress,
346
+ ProgressEvent(
347
+ "snapshot_failed",
348
+ message=str(exc),
349
+ preset_id=preset_id,
350
+ device_patch=device_patch,
351
+ preset_index=preset_index,
352
+ preset_total=len(preset_ids),
353
+ snapshot=snapshot,
354
+ snapshot_total=measured_snapshots,
355
+ ),
356
+ )
357
+ if log_output:
358
+ print(
359
+ f"[ERROR] {profile.name}:{device_patch} snapshot {snapshot}: {exc}",
360
+ file=sys.stderr,
361
+ flush=True,
362
+ )
363
+ continue
364
+
365
+ results[snapshot] = (values.short_term_lufs, values.crest_factor_db)
366
+ _emit_progress(
367
+ on_progress,
368
+ ProgressEvent(
369
+ "snapshot_completed",
370
+ preset_id=preset_id,
371
+ device_patch=device_patch,
372
+ preset_index=preset_index,
373
+ preset_total=len(preset_ids),
374
+ snapshot=snapshot,
375
+ snapshot_total=measured_snapshots,
376
+ reference_lufs=reference_lufs,
377
+ lufs=values.short_term_lufs,
378
+ crest_factor_db=values.crest_factor_db,
379
+ ),
380
+ )
381
+
382
+ if log_output:
383
+ print(
384
+ f" snapshot {snapshot}: "
385
+ f"{values.short_term_lufs:.3f} LUFS, "
386
+ f"{values.crest_factor_db:.3f} dB crest",
387
+ flush=True,
388
+ )
389
+
390
+ append_result_row(
391
+ writer,
392
+ preset_id,
393
+ device_patch,
394
+ measured_snapshots,
395
+ results,
396
+ )
397
+
398
+ except Exception as exc:
399
+ _emit_progress(
400
+ on_progress,
401
+ ProgressEvent(
402
+ "preset_failed",
403
+ message=str(exc),
404
+ preset_id=preset_id,
405
+ device_patch=device_patch,
406
+ preset_index=preset_index,
407
+ preset_total=len(preset_ids),
408
+ snapshot_total=measured_snapshots,
409
+ ),
410
+ )
411
+
412
+ if log_output:
413
+ print(
414
+ f"[ERROR] {profile.name}:{device_patch}: {exc}",
415
+ file=sys.stderr,
416
+ flush=True,
417
+ )
418
+ append_result_row(
419
+ writer,
420
+ preset_id,
421
+ device_patch,
422
+ measured_snapshots,
423
+ None,
424
+ )
425
+
426
+ csv_file.flush()
427
+ _emit_progress(
428
+ on_progress,
429
+ ProgressEvent(
430
+ "preset_completed",
431
+ preset_id=preset_id,
432
+ device_patch=device_patch,
433
+ preset_index=preset_index,
434
+ preset_total=len(preset_ids),
435
+ snapshot_total=measured_snapshots,
436
+ ),
437
+ )
438
+
439
+ _emit_progress(on_progress, ProgressEvent("measurement_completed"))
440
+
441
+
442
+ def _snapshots_to_measure(
443
+ snapshot_plan: SnapshotPlan | None,
444
+ device_patch: str,
445
+ snapshot_count: int,
446
+ ) -> tuple[int, ...]:
447
+ if snapshot_plan is None:
448
+ return tuple(range(1, snapshot_count + 1))
449
+
450
+ snapshots = snapshot_plan.get(device_patch.upper(), ())
451
+ return tuple(snapshot for snapshot in snapshots if 1 <= snapshot <= snapshot_count)
452
+
453
+
454
+ def parse_snapshot_plan(value: str | None) -> SnapshotPlan | None:
455
+ if not value:
456
+ return None
457
+
458
+ plan: SnapshotPlan = {}
459
+ for chunk in value.split(";"):
460
+ chunk = chunk.strip()
461
+ if not chunk:
462
+ continue
463
+ if "=" not in chunk:
464
+ raise argparse.ArgumentTypeError("Snapshot plan entries must be PATCH=1,2")
465
+ patch, snapshots_text = chunk.split("=", 1)
466
+ patch = patch.strip().upper()
467
+ if not patch:
468
+ raise argparse.ArgumentTypeError("Snapshot plan patch IDs must not be empty")
469
+ try:
470
+ snapshots = tuple(parse_int_list(snapshots_text))
471
+ except ValueError as exc:
472
+ raise argparse.ArgumentTypeError("Snapshot plan snapshots must be integers") from exc
473
+ if not snapshots or any(snapshot < 1 for snapshot in snapshots):
474
+ raise argparse.ArgumentTypeError("Snapshot plan snapshots must be positive integers")
475
+ plan[patch] = snapshots
476
+ return plan or None
477
+
478
+
479
+ def _recorded_output_path(
480
+ recorded_output_dir: Path | None,
481
+ device_patch: str,
482
+ snapshot: int,
483
+ ) -> Path | None:
484
+ if recorded_output_dir is None:
485
+ return None
486
+ safe_patch = re.sub(r"[^A-Za-z0-9_-]+", "_", device_patch).strip("_") or "preset"
487
+ return recorded_output_dir / f"{safe_patch}_snapshot_{snapshot}.wav"
488
+
489
+
490
+ def _playback_enabled(value: bool | PlaybackEnabled) -> bool:
491
+ return value() if callable(value) else bool(value)
492
+
493
+
494
+ def _play_audio(audio: np.ndarray, sample_rate: int) -> None:
495
+ from matchpatch.audio import play_audio
496
+
497
+ play_audio(audio, sample_rate)
498
+
499
+
500
+ def _playback_toggle(path: str | None, fallback: bool = False) -> PlaybackEnabled:
501
+ if not path:
502
+ return lambda: fallback
503
+
504
+ toggle_path = Path(path)
505
+
506
+ def enabled() -> bool:
507
+ try:
508
+ return toggle_path.read_text(encoding="utf-8").strip() in {"1", "true", "yes", "on"}
509
+ except OSError:
510
+ return fallback
511
+
512
+ return enabled
513
+
514
+
515
+ class PlaybackBackend:
516
+ def __init__(
517
+ self,
518
+ backend: MeasurementBackend,
519
+ sample_rate: int,
520
+ play_recorded_output: bool | PlaybackEnabled,
521
+ ) -> None:
522
+ self.backend = backend
523
+ self.sample_rate = sample_rate
524
+ self.play_recorded_output = play_recorded_output
525
+
526
+ def activate_preset(self, preset_id: int) -> None:
527
+ self.backend.activate_preset(preset_id)
528
+
529
+ def reapply_snapshot(self, snapshot: int) -> None:
530
+ self.backend.reapply_snapshot(snapshot)
531
+
532
+ def record(self, reference_audio: np.ndarray) -> np.ndarray:
533
+ recorded = self.backend.record(reference_audio)
534
+ if _playback_enabled(self.play_recorded_output):
535
+ _play_audio(recorded, self.sample_rate)
536
+ return recorded
537
+
538
+
539
+ def _emit_progress(
540
+ callback: Callable[[ProgressEvent], None] | None,
541
+ event: ProgressEvent,
542
+ ) -> None:
543
+ if callback is not None:
544
+ callback(event)
545
+
546
+
547
+ def resolve_audio_config(args: argparse.Namespace, profile: DeviceProfile) -> AudioConfig:
548
+ from matchpatch.audio import AudioConfig
549
+
550
+ defaults = profile.default_audio_routing()
551
+ config = AudioConfig(
552
+ device=args.audio_device if args.audio_device is not None else defaults.device,
553
+ sample_rate=args.sample_rate if args.sample_rate is not None else defaults.sample_rate,
554
+ input_mapping=(
555
+ args.input_mapping if args.input_mapping is not None else defaults.input_mapping
556
+ ),
557
+ output_mapping=(
558
+ args.output_mapping if args.output_mapping is not None else defaults.output_mapping
559
+ ),
560
+ blocksize=args.blocksize,
561
+ pre_roll_seconds=getattr(args, "pre_roll", 0.2),
562
+ post_roll_seconds=getattr(args, "post_roll", 0.1),
563
+ round_trip_latency_seconds=getattr(args, "round_trip_latency", 0.02),
564
+ )
565
+
566
+ if (
567
+ min(
568
+ config.pre_roll_seconds,
569
+ config.post_roll_seconds,
570
+ config.round_trip_latency_seconds,
571
+ )
572
+ < 0
573
+ ):
574
+ raise ValueError("Audio pre-roll, post-roll, and round-trip latency must not be negative")
575
+
576
+ if config.round_trip_latency_seconds > config.post_roll_seconds:
577
+ raise ValueError("Audio post-roll must be at least as long as round-trip latency")
578
+
579
+ return config
580
+
581
+
582
+ def resolve_steering_options(
583
+ args: argparse.Namespace,
584
+ profile: DeviceProfile,
585
+ ) -> SteeringOptions:
586
+ defaults = profile.default_steering_options()
587
+ return SteeringOptions(
588
+ output=(args.steering_output if args.steering_output is not None else defaults.output),
589
+ channel=args.steering_channel if args.steering_channel is not None else defaults.channel,
590
+ preset_wait_seconds=(
591
+ args.preset_wait if args.preset_wait is not None else defaults.preset_wait_seconds
592
+ ),
593
+ snapshot_wait_seconds=(
594
+ args.snapshot_wait if args.snapshot_wait is not None else defaults.snapshot_wait_seconds
595
+ ),
596
+ measurement_wait_seconds=(
597
+ args.measurement_wait
598
+ if args.measurement_wait is not None
599
+ else defaults.measurement_wait_seconds
600
+ ),
601
+ )
602
+
603
+
604
+ def measure(args: argparse.Namespace) -> None:
605
+ profile = get_device_profile(args.device)
606
+ defaults = profile.default_audio_routing()
607
+ sample_rate = args.sample_rate if args.sample_rate is not None else defaults.sample_rate
608
+ on_progress = getattr(args, "on_progress", None)
609
+ _emit_progress(
610
+ on_progress,
611
+ ProgressEvent("measurement_preparation", message="Loading reference DI audio..."),
612
+ )
613
+ reference = load_reference_audio(Path(args.reference_di), sample_rate)
614
+ requested_snapshot_count = getattr(args, "snapshot_count", None)
615
+ snapshot_count = (
616
+ requested_snapshot_count
617
+ if requested_snapshot_count is not None
618
+ else getattr(profile, "snapshot_count", 4)
619
+ )
620
+ analysis_options = getattr(args, "analysis_options", AnalysisOptions())
621
+ log_output = not getattr(args, "progress_jsonl", False)
622
+ play_recorded_output = _playback_toggle(
623
+ getattr(args, "playback_toggle_file", None),
624
+ getattr(args, "play_recorded_output", False),
625
+ )
626
+ recorded_output_dir = (
627
+ Path(args.recordings_dir) if getattr(args, "recordings_dir", None) else None
628
+ )
629
+ snapshot_plan = getattr(args, "snapshot_plan", None)
630
+
631
+ if args.backend == "loopback":
632
+ measure_presets(
633
+ profile,
634
+ args.preset_ids,
635
+ Path(args.csv),
636
+ reference,
637
+ sample_rate,
638
+ LoopbackBackend(),
639
+ snapshot_count=snapshot_count,
640
+ analysis_options=analysis_options,
641
+ on_progress=on_progress,
642
+ log_output=log_output,
643
+ play_recorded_output=play_recorded_output,
644
+ recorded_output_dir=recorded_output_dir,
645
+ snapshot_plan=snapshot_plan,
646
+ )
647
+ return
648
+
649
+ if args.backend == "simulated":
650
+ measure_presets(
651
+ profile,
652
+ args.preset_ids,
653
+ Path(args.csv),
654
+ reference,
655
+ sample_rate,
656
+ SimulatedHardwareBackend(
657
+ defaults,
658
+ snapshot_count,
659
+ args.input_mapping,
660
+ args.output_mapping,
661
+ frozenset(args.simulate_fail_presets),
662
+ ),
663
+ snapshot_count=snapshot_count,
664
+ analysis_options=analysis_options,
665
+ on_progress=on_progress,
666
+ log_output=log_output,
667
+ play_recorded_output=play_recorded_output,
668
+ recorded_output_dir=recorded_output_dir,
669
+ snapshot_plan=snapshot_plan,
670
+ )
671
+ return
672
+
673
+ from matchpatch.audio import prepare_audio_config
674
+
675
+ _emit_progress(
676
+ on_progress,
677
+ ProgressEvent(
678
+ "measurement_preparation", message="Resolving and validating audio device..."
679
+ ),
680
+ )
681
+ audio_config = prepare_audio_config(resolve_audio_config(args, profile))
682
+ steering_options = resolve_steering_options(args, profile)
683
+
684
+ _emit_progress(
685
+ on_progress,
686
+ ProgressEvent("measurement_preparation", message="Opening processor MIDI output..."),
687
+ )
688
+ with profile.create_controller(steering_options) as controller:
689
+ measure_presets(
690
+ profile,
691
+ args.preset_ids,
692
+ Path(args.csv),
693
+ reference,
694
+ sample_rate,
695
+ HardwareBackend(
696
+ audio_config,
697
+ controller,
698
+ steering_options.measurement_wait_seconds,
699
+ ),
700
+ snapshot_count=snapshot_count,
701
+ analysis_options=analysis_options,
702
+ on_progress=on_progress,
703
+ log_output=log_output,
704
+ play_recorded_output=play_recorded_output,
705
+ recorded_output_dir=recorded_output_dir,
706
+ snapshot_plan=snapshot_plan,
707
+ )
708
+
709
+
710
+ def optimize_measurement_timing(args: argparse.Namespace) -> None:
711
+ profile = get_device_profile(args.device)
712
+ defaults = profile.default_audio_routing()
713
+ sample_rate = args.sample_rate if args.sample_rate is not None else defaults.sample_rate
714
+ reference = load_reference_audio(Path(args.reference_di), sample_rate)
715
+ initial_values = _timing_values(args)
716
+ valid_parameter_names = {parameter.name for parameter in TIMING_PARAMETERS}
717
+ pinned_names = tuple(dict.fromkeys(getattr(args, "pinned_parameter", ())))
718
+ invalid_pins = sorted(set(pinned_names) - valid_parameter_names)
719
+ if invalid_pins:
720
+ raise ValueError(f"Unknown pinned timing parameter: {', '.join(invalid_pins)}")
721
+ pinned_parameters = tuple(
722
+ parameter for parameter in TIMING_PARAMETERS if parameter.name in pinned_names
723
+ )
724
+ optimization_parameters = tuple(
725
+ parameter for parameter in TIMING_PARAMETERS if parameter.name not in pinned_names
726
+ )
727
+ pinned_results = tuple(
728
+ ParameterOptimizationResult(parameter, initial_values[parameter.name], True, 0)
729
+ for parameter in pinned_parameters
730
+ )
731
+ alternate_id = (
732
+ args.alternate_preset_id
733
+ if args.alternate_preset_id is not None
734
+ else alternate_preset_id(args.preset_id)
735
+ )
736
+
737
+ on_progress = getattr(args, "on_optimization_progress", None)
738
+ analysis_options = getattr(args, "analysis_options", AnalysisOptions())
739
+ play_recorded_output = _playback_toggle(
740
+ getattr(args, "playback_toggle_file", None),
741
+ getattr(args, "play_recorded_output", False),
742
+ )
743
+
744
+ if args.backend == "loopback":
745
+ results = optimize_timing_parameters(
746
+ profile,
747
+ args.preset_id,
748
+ alternate_id,
749
+ reference,
750
+ sample_rate,
751
+ lambda values: PlaybackBackend(LoopbackBackend(), sample_rate, play_recorded_output),
752
+ initial_values,
753
+ analysis_options,
754
+ stability_runs=args.stability_runs,
755
+ termination_tolerance_percent=args.termination_tolerance,
756
+ stability_tolerance_percent=args.stability_tolerance,
757
+ on_progress=on_progress,
758
+ parameters=optimization_parameters,
759
+ )
760
+ elif args.backend == "simulated":
761
+ results = optimize_timing_parameters(
762
+ profile,
763
+ args.preset_id,
764
+ alternate_id,
765
+ reference,
766
+ sample_rate,
767
+ lambda values: PlaybackBackend(
768
+ SimulatedHardwareBackend(
769
+ defaults,
770
+ max(2, getattr(profile, "snapshot_count", 4)),
771
+ args.input_mapping,
772
+ args.output_mapping,
773
+ frozenset(args.simulate_fail_presets),
774
+ ),
775
+ sample_rate,
776
+ play_recorded_output,
777
+ ),
778
+ initial_values,
779
+ analysis_options,
780
+ stability_runs=args.stability_runs,
781
+ termination_tolerance_percent=args.termination_tolerance,
782
+ stability_tolerance_percent=args.stability_tolerance,
783
+ on_progress=on_progress,
784
+ parameters=optimization_parameters,
785
+ )
786
+ else:
787
+ from matchpatch.audio import prepare_audio_config
788
+
789
+ audio_config = prepare_audio_config(resolve_audio_config(args, profile))
790
+ steering_options = resolve_steering_options(args, profile)
791
+
792
+ with profile.create_controller(steering_options) as controller:
793
+
794
+ def hardware_backend(values: dict[str, float]) -> PlaybackBackend:
795
+ if hasattr(controller, "options"):
796
+ controller_any: Any = controller
797
+ controller_any.options = replace(
798
+ steering_options,
799
+ preset_wait_seconds=values["preset_wait"],
800
+ snapshot_wait_seconds=values["snapshot_wait"],
801
+ )
802
+ return PlaybackBackend(
803
+ HardwareBackend(
804
+ replace(
805
+ audio_config,
806
+ pre_roll_seconds=values["pre_roll"],
807
+ post_roll_seconds=values["post_roll"],
808
+ round_trip_latency_seconds=values["round_trip_latency"],
809
+ ),
810
+ controller,
811
+ values["measurement_wait"],
812
+ ),
813
+ sample_rate,
814
+ play_recorded_output,
815
+ )
816
+
817
+ results = optimize_timing_parameters(
818
+ profile,
819
+ args.preset_id,
820
+ alternate_id,
821
+ reference,
822
+ sample_rate,
823
+ hardware_backend,
824
+ initial_values,
825
+ analysis_options,
826
+ stability_runs=args.stability_runs,
827
+ termination_tolerance_percent=args.termination_tolerance,
828
+ stability_tolerance_percent=args.stability_tolerance,
829
+ on_progress=on_progress,
830
+ parameters=optimization_parameters,
831
+ )
832
+
833
+ result_by_name = {result.parameter.name: result for result in (*pinned_results, *results)}
834
+ results = tuple(
835
+ result_by_name[parameter.name]
836
+ for parameter in TIMING_PARAMETERS
837
+ if parameter.name in result_by_name
838
+ )
839
+ toml_text = optimization_results_toml(args.device, results)
840
+ if on_progress is not None:
841
+ on_progress(
842
+ OptimizationProgress(
843
+ "completed",
844
+ "Timing optimization completed",
845
+ result_toml=toml_text,
846
+ results=results,
847
+ )
848
+ )
849
+ else:
850
+ print(toml_text, flush=True)
851
+
852
+
853
+ def _timing_values(args: argparse.Namespace) -> dict[str, float]:
854
+ return {
855
+ "analysis_window": args.analysis_options.window_seconds,
856
+ "analysis_interval": args.analysis_options.interval_seconds,
857
+ "pre_roll": args.pre_roll,
858
+ "post_roll": args.post_roll,
859
+ "round_trip_latency": args.round_trip_latency,
860
+ "preset_wait": args.preset_wait,
861
+ "snapshot_wait": args.snapshot_wait,
862
+ "measurement_wait": args.measurement_wait,
863
+ }
864
+
865
+
866
+ def check_hardware(args: argparse.Namespace) -> None:
867
+ """Validate that configured processor audio and steering endpoints are present."""
868
+ profile = get_device_profile(args.device)
869
+
870
+ from matchpatch.audio import validate_audio_device_available
871
+
872
+ validate_audio_device_available(resolve_audio_config(args, profile))
873
+ steering_options = resolve_steering_options(args, profile)
874
+ _validate_steering_output_available(steering_options)
875
+
876
+
877
+ def _validate_steering_output_available(steering_options: SteeringOptions) -> None:
878
+ import mido
879
+
880
+ names = mido.get_output_names()
881
+ query = steering_options.output
882
+ matches = (
883
+ names if query is None else [name for name in names if query.casefold() in name.casefold()]
884
+ )
885
+
886
+ if len(matches) != 1:
887
+ raise ValueError(
888
+ f"MIDI output query {query!r} matched {len(matches)} ports; "
889
+ "configure a unique steering output"
890
+ )
891
+
892
+
893
+ def list_devices() -> None:
894
+ from matchpatch.audio import sd
895
+
896
+ print("MatchPatch processor profiles:")
897
+
898
+ for profile in list_device_profiles():
899
+ print(f" {profile.name}: {profile.display_name}")
900
+
901
+ print("\nAudio host APIs:")
902
+
903
+ for index, api in enumerate(sd.query_hostapis()):
904
+ print(f" [{index}] {api['name']}")
905
+
906
+ print("\nAudio devices:")
907
+
908
+ for index, device in enumerate(sd.query_devices()):
909
+ api = sd.query_hostapis(device["hostapi"])["name"]
910
+ print(
911
+ f" [{index}] {device['name']} | {api} | "
912
+ f"in={device['max_input_channels']} "
913
+ f"out={device['max_output_channels']}"
914
+ )
915
+
916
+ print("\nMIDI outputs:")
917
+
918
+ try:
919
+ import mido
920
+
921
+ for name in mido.get_output_names():
922
+ print(f" {name}")
923
+ except ImportError:
924
+ print(" unavailable: mido is not installed")
925
+
926
+
927
+ def add_hardware_arguments(parser: argparse.ArgumentParser) -> None:
928
+ parser.add_argument("--audio-device")
929
+ parser.add_argument("--steering-output", "--midi-output")
930
+ parser.add_argument("--steering-channel", "--midi-channel", type=int)
931
+ parser.add_argument("--sample-rate", type=int)
932
+ parser.add_argument("--input-mapping", type=parse_channel_mapping)
933
+ parser.add_argument("--output-mapping", type=parse_channel_mapping)
934
+ parser.add_argument("--blocksize", type=int)
935
+ parser.add_argument("--preset-wait", type=float)
936
+ parser.add_argument("--snapshot-wait", type=float)
937
+ parser.add_argument("--measurement-wait", type=float)
938
+
939
+
940
+ def apply_config(args: argparse.Namespace) -> argparse.Namespace:
941
+ config = load_config(args.config)
942
+ profile = get_device_profile(args.device)
943
+ default_audio = profile.default_audio_routing()
944
+ default_steering = profile.default_steering_options()
945
+ device_audio = ("devices", args.device, "audio")
946
+ device_steering = ("devices", args.device, "steering")
947
+
948
+ def float_config_value(value: object | None, default: float) -> float:
949
+ if value is None:
950
+ return default
951
+ return float(cast(Any, value))
952
+
953
+ args.backend = getattr(args, "backend", None) or config_value(
954
+ config, "normalize", "backend", default="hardware"
955
+ )
956
+ args.audio_device = (
957
+ args.audio_device
958
+ if args.audio_device is not None
959
+ else config_value(config, *device_audio, "device", default=default_audio.device)
960
+ )
961
+ args.sample_rate = (
962
+ args.sample_rate
963
+ if args.sample_rate is not None
964
+ else config_value(config, *device_audio, "sample_rate", default=default_audio.sample_rate)
965
+ )
966
+
967
+ for name in ("input_mapping", "output_mapping"):
968
+ value = getattr(args, name)
969
+
970
+ if value is None:
971
+ value = config_value(config, *device_audio, name, default=getattr(default_audio, name))
972
+
973
+ if value is not None:
974
+ setattr(args, name, parse_config_mapping(value))
975
+
976
+ args.blocksize = (
977
+ args.blocksize
978
+ if args.blocksize is not None
979
+ else config_value(config, *device_audio, "blocksize", default=0)
980
+ )
981
+ args.steering_output = (
982
+ args.steering_output
983
+ if args.steering_output is not None
984
+ else config_value(config, *device_steering, "output", default=default_steering.output)
985
+ )
986
+ args.steering_channel = (
987
+ args.steering_channel
988
+ if args.steering_channel is not None
989
+ else config_value(config, *device_steering, "channel", default=default_steering.channel)
990
+ )
991
+ args.preset_wait = (
992
+ args.preset_wait
993
+ if args.preset_wait is not None
994
+ else config_value(
995
+ config,
996
+ *device_steering,
997
+ "preset_wait_seconds",
998
+ default=default_steering.preset_wait_seconds,
999
+ )
1000
+ )
1001
+ args.snapshot_wait = (
1002
+ args.snapshot_wait
1003
+ if args.snapshot_wait is not None
1004
+ else config_value(
1005
+ config,
1006
+ *device_steering,
1007
+ "snapshot_wait_seconds",
1008
+ default=default_steering.snapshot_wait_seconds,
1009
+ )
1010
+ )
1011
+ args.measurement_wait = (
1012
+ args.measurement_wait
1013
+ if args.measurement_wait is not None
1014
+ else config_value(
1015
+ config,
1016
+ *device_steering,
1017
+ "measurement_wait_seconds",
1018
+ default=default_steering.measurement_wait_seconds,
1019
+ )
1020
+ )
1021
+ args.pre_roll = (
1022
+ getattr(args, "pre_roll", None)
1023
+ if getattr(args, "pre_roll", None) is not None
1024
+ else config_value(config, "analysis", "pre_roll_seconds", default=0.2)
1025
+ )
1026
+ args.post_roll = (
1027
+ getattr(args, "post_roll", None)
1028
+ if getattr(args, "post_roll", None) is not None
1029
+ else config_value(config, "analysis", "post_roll_seconds", default=0.1)
1030
+ )
1031
+ args.round_trip_latency = (
1032
+ getattr(args, "round_trip_latency", None)
1033
+ if getattr(args, "round_trip_latency", None) is not None
1034
+ else config_value(config, "analysis", "round_trip_latency_seconds", default=0.02)
1035
+ )
1036
+ args.snapshot_count = (
1037
+ getattr(args, "snapshot_count", None)
1038
+ if getattr(args, "snapshot_count", None) is not None
1039
+ else config_value(config, "policy", "measured_snapshots")
1040
+ )
1041
+ args.stability_tolerance = (
1042
+ getattr(args, "stability_tolerance", None)
1043
+ if getattr(args, "stability_tolerance", None) is not None
1044
+ else config_value(config, "measurement", "stability_tolerance_percent", default=2.0)
1045
+ )
1046
+
1047
+ if args.snapshot_count is not None:
1048
+ validate_snapshot_count(profile, args.snapshot_count)
1049
+
1050
+ args.analysis_options = AnalysisOptions(
1051
+ window_seconds=float_config_value(
1052
+ getattr(args, "analysis_window", None)
1053
+ if getattr(args, "analysis_window", None) is not None
1054
+ else config_value(config, "analysis", "window_seconds", default=3.0),
1055
+ 3.0,
1056
+ ),
1057
+ interval_seconds=float_config_value(
1058
+ getattr(args, "analysis_interval", None)
1059
+ if getattr(args, "analysis_interval", None) is not None
1060
+ else config_value(config, "analysis", "interval_seconds", default=0.1),
1061
+ 0.1,
1062
+ ),
1063
+ minimum_valid_lufs=float_config_value(
1064
+ getattr(args, "minimum_valid_lufs", None)
1065
+ if getattr(args, "minimum_valid_lufs", None) is not None
1066
+ else config_value(config, "analysis", "minimum_valid_lufs", default=-100.0),
1067
+ -100.0,
1068
+ ),
1069
+ )
1070
+ return args
1071
+
1072
+
1073
+ def parse_args() -> argparse.Namespace:
1074
+ parser = argparse.ArgumentParser(description=__doc__)
1075
+ subparsers = parser.add_subparsers(dest="command", required=True)
1076
+ subparsers.add_parser("devices", help="List profiles, audio devices, and MIDI outputs")
1077
+
1078
+ check_parser = subparsers.add_parser(
1079
+ "check-hardware",
1080
+ help="Validate configured processor audio and MIDI endpoints",
1081
+ )
1082
+ check_parser.add_argument("--device", required=True)
1083
+ check_parser.add_argument("--config", help="TOML configuration file")
1084
+ add_hardware_arguments(check_parser)
1085
+
1086
+ measure_parser = subparsers.add_parser(
1087
+ "measure",
1088
+ help="Measure processor snapshots for each preset",
1089
+ )
1090
+ measure_parser.add_argument("--device", required=True)
1091
+ measure_parser.add_argument("--config", help="TOML configuration file")
1092
+ measure_parser.add_argument("--preset-ids", type=parse_int_list, required=True)
1093
+ measure_parser.add_argument("--csv", required=True)
1094
+ measure_parser.add_argument("--reference-di", required=True)
1095
+ measure_parser.add_argument(
1096
+ "--backend",
1097
+ choices=["hardware", "loopback", "simulated", "helix"],
1098
+ help="Use hardware, empty-patch loopback, or a stateful processor simulation",
1099
+ )
1100
+ measure_parser.add_argument(
1101
+ "--simulate-fail-presets",
1102
+ type=parse_int_list,
1103
+ default=[],
1104
+ help="Comma-separated numeric preset IDs that fail in simulated mode",
1105
+ )
1106
+ measure_parser.add_argument("--snapshot-count", type=int)
1107
+ measure_parser.add_argument("--snapshot-plan", type=parse_snapshot_plan)
1108
+ measure_parser.add_argument("--analysis-window", type=float)
1109
+ measure_parser.add_argument("--analysis-interval", type=float)
1110
+ measure_parser.add_argument("--minimum-valid-lufs", type=float)
1111
+ measure_parser.add_argument("--pre-roll", type=float)
1112
+ measure_parser.add_argument("--post-roll", type=float)
1113
+ measure_parser.add_argument("--round-trip-latency", type=float)
1114
+ measure_parser.add_argument("--play-recorded-output", action="store_true")
1115
+ measure_parser.add_argument("--playback-toggle-file")
1116
+ measure_parser.add_argument("--recordings-dir")
1117
+ measure_parser.add_argument("--progress-jsonl", action="store_true")
1118
+ add_hardware_arguments(measure_parser)
1119
+
1120
+ optimize_parser = subparsers.add_parser(
1121
+ "optimize",
1122
+ help="Determine stable lower bounds for measurement timing parameters",
1123
+ )
1124
+ optimize_parser.add_argument("--device", required=True)
1125
+ optimize_parser.add_argument("--config", help="TOML configuration file")
1126
+ optimize_parser.add_argument("--preset-id", type=int, required=True)
1127
+ optimize_parser.add_argument("--alternate-preset-id", type=int)
1128
+ optimize_parser.add_argument("--reference-di", required=True)
1129
+ optimize_parser.add_argument(
1130
+ "--backend",
1131
+ choices=["hardware", "loopback", "simulated", "helix"],
1132
+ )
1133
+ optimize_parser.add_argument("--stability-runs", type=int, default=3)
1134
+ optimize_parser.add_argument("--termination-tolerance", type=float, default=10.0)
1135
+ optimize_parser.add_argument("--stability-tolerance", type=float)
1136
+ optimize_parser.add_argument(
1137
+ "--pinned-parameter",
1138
+ action="append",
1139
+ default=[],
1140
+ help="Timing parameter to keep fixed at its configured value during optimization",
1141
+ )
1142
+ optimize_parser.add_argument(
1143
+ "--simulate-fail-presets",
1144
+ type=parse_int_list,
1145
+ default=[],
1146
+ help="Comma-separated numeric preset IDs that fail in simulated mode",
1147
+ )
1148
+ optimize_parser.add_argument("--analysis-window", type=float)
1149
+ optimize_parser.add_argument("--analysis-interval", type=float)
1150
+ optimize_parser.add_argument("--minimum-valid-lufs", type=float)
1151
+ optimize_parser.add_argument("--pre-roll", type=float)
1152
+ optimize_parser.add_argument("--post-roll", type=float)
1153
+ optimize_parser.add_argument("--round-trip-latency", type=float)
1154
+ optimize_parser.add_argument("--play-recorded-output", action="store_true")
1155
+ optimize_parser.add_argument("--playback-toggle-file")
1156
+ optimize_parser.add_argument("--progress-jsonl", action="store_true")
1157
+ add_hardware_arguments(optimize_parser)
1158
+
1159
+ args = parser.parse_args()
1160
+ return apply_config(args) if args.command in {"check-hardware", "measure", "optimize"} else args
1161
+
1162
+
1163
+ def main() -> None:
1164
+ args = parse_args()
1165
+
1166
+ if args.command == "devices":
1167
+ list_devices()
1168
+ elif args.command == "check-hardware":
1169
+ try:
1170
+ check_hardware(args)
1171
+ except Exception as exc: # noqa: BLE001
1172
+ print(str(exc), file=sys.stderr)
1173
+ raise SystemExit(1) from None
1174
+ else:
1175
+ print("Hardware available")
1176
+ elif args.command == "measure":
1177
+ if args.backend == "helix":
1178
+ args.backend = "hardware"
1179
+ if getattr(args, "progress_jsonl", False):
1180
+ args.on_progress = lambda event: print(event.to_json(), flush=True)
1181
+ measure(args)
1182
+ else:
1183
+ if args.backend == "helix":
1184
+ args.backend = "hardware"
1185
+ if getattr(args, "progress_jsonl", False):
1186
+ args.on_optimization_progress = lambda event: print(event.to_json(), flush=True)
1187
+ optimize_measurement_timing(args)
1188
+
1189
+
1190
+ if __name__ == "__main__": # pragma: no cover - module entry point
1191
+ main()