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