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
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"""Timing-parameter stability optimization for measurement runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from typing import Any, Protocol
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from matchpatch.analysis import AnalysisOptions, analyze_audio
|
|
13
|
+
from matchpatch.devices.base import DeviceProfile
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MeasurementBackend(Protocol):
|
|
17
|
+
def activate_preset(self, preset_id: int) -> None: ...
|
|
18
|
+
|
|
19
|
+
def reapply_snapshot(self, snapshot: int) -> None: ...
|
|
20
|
+
|
|
21
|
+
def record(self, reference_audio: np.ndarray) -> np.ndarray: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class TimingParameter:
|
|
26
|
+
name: str
|
|
27
|
+
label: str
|
|
28
|
+
table: tuple[str, ...]
|
|
29
|
+
key: str
|
|
30
|
+
duration_multiplier: float = 1.0
|
|
31
|
+
lower_bound: Callable[[dict[str, float]], float] = lambda values: 0.0
|
|
32
|
+
stable_start: Callable[[dict[str, float]], float] = lambda values: 0.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class StabilityStatistics:
|
|
37
|
+
snapshot1_lufs_mean: float
|
|
38
|
+
snapshot1_lufs_std: float
|
|
39
|
+
snapshot1_crest_mean: float
|
|
40
|
+
snapshot1_crest_std: float
|
|
41
|
+
snapshot2_lufs_mean: float
|
|
42
|
+
snapshot2_lufs_std: float
|
|
43
|
+
snapshot2_crest_mean: float
|
|
44
|
+
snapshot2_crest_std: float
|
|
45
|
+
tolerance_percent: float = 2.0
|
|
46
|
+
snapshot1_lufs_tolerance: float = 0.0
|
|
47
|
+
snapshot1_lufs_max_deviation: float = 0.0
|
|
48
|
+
snapshot1_crest_tolerance: float = 0.0
|
|
49
|
+
snapshot1_crest_max_deviation: float = 0.0
|
|
50
|
+
snapshot2_lufs_tolerance: float = 0.0
|
|
51
|
+
snapshot2_lufs_max_deviation: float = 0.0
|
|
52
|
+
snapshot2_crest_tolerance: float = 0.0
|
|
53
|
+
snapshot2_crest_max_deviation: float = 0.0
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
return {
|
|
57
|
+
"snapshot1_lufs_mean": self.snapshot1_lufs_mean,
|
|
58
|
+
"snapshot1_lufs_std": self.snapshot1_lufs_std,
|
|
59
|
+
"snapshot1_crest_mean": self.snapshot1_crest_mean,
|
|
60
|
+
"snapshot1_crest_std": self.snapshot1_crest_std,
|
|
61
|
+
"snapshot2_lufs_mean": self.snapshot2_lufs_mean,
|
|
62
|
+
"snapshot2_lufs_std": self.snapshot2_lufs_std,
|
|
63
|
+
"snapshot2_crest_mean": self.snapshot2_crest_mean,
|
|
64
|
+
"snapshot2_crest_std": self.snapshot2_crest_std,
|
|
65
|
+
"tolerance_percent": self.tolerance_percent,
|
|
66
|
+
"snapshot1_lufs_tolerance": self.snapshot1_lufs_tolerance,
|
|
67
|
+
"snapshot1_lufs_max_deviation": self.snapshot1_lufs_max_deviation,
|
|
68
|
+
"snapshot1_crest_tolerance": self.snapshot1_crest_tolerance,
|
|
69
|
+
"snapshot1_crest_max_deviation": self.snapshot1_crest_max_deviation,
|
|
70
|
+
"snapshot2_lufs_tolerance": self.snapshot2_lufs_tolerance,
|
|
71
|
+
"snapshot2_lufs_max_deviation": self.snapshot2_lufs_max_deviation,
|
|
72
|
+
"snapshot2_crest_tolerance": self.snapshot2_crest_tolerance,
|
|
73
|
+
"snapshot2_crest_max_deviation": self.snapshot2_crest_max_deviation,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_dict(cls, value: dict[str, Any]) -> StabilityStatistics:
|
|
78
|
+
return cls(
|
|
79
|
+
snapshot1_lufs_mean=float(value["snapshot1_lufs_mean"]),
|
|
80
|
+
snapshot1_lufs_std=float(value["snapshot1_lufs_std"]),
|
|
81
|
+
snapshot1_crest_mean=float(value["snapshot1_crest_mean"]),
|
|
82
|
+
snapshot1_crest_std=float(value["snapshot1_crest_std"]),
|
|
83
|
+
snapshot2_lufs_mean=float(value["snapshot2_lufs_mean"]),
|
|
84
|
+
snapshot2_lufs_std=float(value["snapshot2_lufs_std"]),
|
|
85
|
+
snapshot2_crest_mean=float(value["snapshot2_crest_mean"]),
|
|
86
|
+
snapshot2_crest_std=float(value["snapshot2_crest_std"]),
|
|
87
|
+
tolerance_percent=float(value.get("tolerance_percent", 2.0)),
|
|
88
|
+
snapshot1_lufs_tolerance=float(value.get("snapshot1_lufs_tolerance", 0.0)),
|
|
89
|
+
snapshot1_lufs_max_deviation=float(value.get("snapshot1_lufs_max_deviation", 0.0)),
|
|
90
|
+
snapshot1_crest_tolerance=float(value.get("snapshot1_crest_tolerance", 0.0)),
|
|
91
|
+
snapshot1_crest_max_deviation=float(value.get("snapshot1_crest_max_deviation", 0.0)),
|
|
92
|
+
snapshot2_lufs_tolerance=float(value.get("snapshot2_lufs_tolerance", 0.0)),
|
|
93
|
+
snapshot2_lufs_max_deviation=float(value.get("snapshot2_lufs_max_deviation", 0.0)),
|
|
94
|
+
snapshot2_crest_tolerance=float(value.get("snapshot2_crest_tolerance", 0.0)),
|
|
95
|
+
snapshot2_crest_max_deviation=float(value.get("snapshot2_crest_max_deviation", 0.0)),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class ParameterOptimizationResult:
|
|
101
|
+
parameter: TimingParameter
|
|
102
|
+
value: float
|
|
103
|
+
stable: bool
|
|
104
|
+
iterations: int
|
|
105
|
+
statistics: StabilityStatistics | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class OptimizationProgress:
|
|
110
|
+
kind: str
|
|
111
|
+
message: str
|
|
112
|
+
parameter: str | None = None
|
|
113
|
+
candidate: float | None = None
|
|
114
|
+
stable: bool | None = None
|
|
115
|
+
low: float | None = None
|
|
116
|
+
high: float | None = None
|
|
117
|
+
best: float | None = None
|
|
118
|
+
iteration: int | None = None
|
|
119
|
+
statistics: StabilityStatistics | None = None
|
|
120
|
+
result_toml: str | None = None
|
|
121
|
+
results: tuple[ParameterOptimizationResult, ...] = ()
|
|
122
|
+
|
|
123
|
+
def to_json(self) -> str:
|
|
124
|
+
payload = {
|
|
125
|
+
"kind": self.kind,
|
|
126
|
+
"message": self.message,
|
|
127
|
+
"parameter": self.parameter,
|
|
128
|
+
"candidate": self.candidate,
|
|
129
|
+
"stable": self.stable,
|
|
130
|
+
"low": self.low,
|
|
131
|
+
"high": self.high,
|
|
132
|
+
"best": self.best,
|
|
133
|
+
"iteration": self.iteration,
|
|
134
|
+
"statistics": (self.statistics.to_dict() if self.statistics is not None else None),
|
|
135
|
+
"result_toml": self.result_toml,
|
|
136
|
+
"results": [
|
|
137
|
+
{
|
|
138
|
+
"parameter": {
|
|
139
|
+
"name": result.parameter.name,
|
|
140
|
+
"label": result.parameter.label,
|
|
141
|
+
"table": result.parameter.table,
|
|
142
|
+
"key": result.parameter.key,
|
|
143
|
+
},
|
|
144
|
+
"value": result.value,
|
|
145
|
+
"stable": result.stable,
|
|
146
|
+
"iterations": result.iterations,
|
|
147
|
+
"statistics": (
|
|
148
|
+
result.statistics.to_dict() if result.statistics is not None else None
|
|
149
|
+
),
|
|
150
|
+
}
|
|
151
|
+
for result in self.results
|
|
152
|
+
],
|
|
153
|
+
}
|
|
154
|
+
return json.dumps(payload, separators=(",", ":"))
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def from_json(cls, value: str) -> OptimizationProgress:
|
|
158
|
+
payload: Any = json.loads(value)
|
|
159
|
+
if not isinstance(payload, dict):
|
|
160
|
+
raise ValueError("Optimization progress must be a JSON object")
|
|
161
|
+
payload["results"] = tuple(
|
|
162
|
+
ParameterOptimizationResult(
|
|
163
|
+
TimingParameter(
|
|
164
|
+
item["parameter"]["name"],
|
|
165
|
+
item["parameter"]["label"],
|
|
166
|
+
tuple(item["parameter"]["table"]),
|
|
167
|
+
item["parameter"]["key"],
|
|
168
|
+
),
|
|
169
|
+
item["value"],
|
|
170
|
+
item["stable"],
|
|
171
|
+
item["iterations"],
|
|
172
|
+
StabilityStatistics.from_dict(item["statistics"])
|
|
173
|
+
if item.get("statistics") is not None
|
|
174
|
+
else None,
|
|
175
|
+
)
|
|
176
|
+
for item in payload.get("results", ())
|
|
177
|
+
)
|
|
178
|
+
if payload.get("statistics") is not None:
|
|
179
|
+
payload["statistics"] = StabilityStatistics.from_dict(payload["statistics"])
|
|
180
|
+
return cls(**payload)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class BackendFactory(Protocol):
|
|
184
|
+
def __call__(self, values: dict[str, float]) -> MeasurementBackend: ...
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
ProgressCallback = Callable[[OptimizationProgress], None]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class MeasurementAnalysisError(ValueError):
|
|
191
|
+
"""Raised when a timing candidate produces audio that cannot be analyzed."""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
TIMING_PARAMETERS: tuple[TimingParameter, ...] = (
|
|
195
|
+
TimingParameter(
|
|
196
|
+
"pre_roll",
|
|
197
|
+
"Pre-roll",
|
|
198
|
+
("analysis",),
|
|
199
|
+
"pre_roll_seconds",
|
|
200
|
+
2.0,
|
|
201
|
+
stable_start=lambda values: 1.0,
|
|
202
|
+
),
|
|
203
|
+
TimingParameter(
|
|
204
|
+
"post_roll",
|
|
205
|
+
"Post-roll",
|
|
206
|
+
("analysis",),
|
|
207
|
+
"post_roll_seconds",
|
|
208
|
+
2.0,
|
|
209
|
+
lower_bound=lambda values: values["round_trip_latency"],
|
|
210
|
+
stable_start=lambda values: 1.0,
|
|
211
|
+
),
|
|
212
|
+
TimingParameter(
|
|
213
|
+
"round_trip_latency",
|
|
214
|
+
"Round-trip latency",
|
|
215
|
+
("analysis",),
|
|
216
|
+
"round_trip_latency_seconds",
|
|
217
|
+
2.0,
|
|
218
|
+
stable_start=lambda values: 0.05,
|
|
219
|
+
),
|
|
220
|
+
TimingParameter(
|
|
221
|
+
"preset_wait",
|
|
222
|
+
"Preset wait",
|
|
223
|
+
("devices", "{device}", "steering"),
|
|
224
|
+
"preset_wait_seconds",
|
|
225
|
+
1.0,
|
|
226
|
+
stable_start=lambda values: 2.0,
|
|
227
|
+
),
|
|
228
|
+
TimingParameter(
|
|
229
|
+
"snapshot_wait",
|
|
230
|
+
"Snapshot wait",
|
|
231
|
+
("devices", "{device}", "steering"),
|
|
232
|
+
"snapshot_wait_seconds",
|
|
233
|
+
2.0,
|
|
234
|
+
stable_start=lambda values: 1.0,
|
|
235
|
+
),
|
|
236
|
+
TimingParameter(
|
|
237
|
+
"measurement_wait",
|
|
238
|
+
"Measurement wait",
|
|
239
|
+
("devices", "{device}", "steering"),
|
|
240
|
+
"measurement_wait_seconds",
|
|
241
|
+
2.0,
|
|
242
|
+
stable_start=lambda values: 1.0,
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _optimization_start_values(
|
|
248
|
+
initial_values: dict[str, float], parameters: tuple[TimingParameter, ...]
|
|
249
|
+
) -> dict[str, float]:
|
|
250
|
+
values = dict(initial_values)
|
|
251
|
+
for parameter in parameters:
|
|
252
|
+
lower = parameter.lower_bound(values)
|
|
253
|
+
stable_start = parameter.stable_start(values)
|
|
254
|
+
values[parameter.name] = max(values[parameter.name], lower, stable_start)
|
|
255
|
+
return values
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _parameters_by_duration_impact(
|
|
259
|
+
values: dict[str, float], parameters: tuple[TimingParameter, ...]
|
|
260
|
+
) -> tuple[TimingParameter, ...]:
|
|
261
|
+
return tuple(
|
|
262
|
+
sorted(
|
|
263
|
+
parameters,
|
|
264
|
+
key=lambda parameter: (
|
|
265
|
+
values[parameter.name] * parameter.duration_multiplier,
|
|
266
|
+
parameter.duration_multiplier,
|
|
267
|
+
),
|
|
268
|
+
reverse=True,
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def optimize_timing_parameters(
|
|
274
|
+
profile: DeviceProfile,
|
|
275
|
+
preset_id: int,
|
|
276
|
+
alternate_preset_id: int,
|
|
277
|
+
reference: np.ndarray,
|
|
278
|
+
sample_rate: int,
|
|
279
|
+
backend_factory: BackendFactory,
|
|
280
|
+
initial_values: dict[str, float],
|
|
281
|
+
analysis_options: AnalysisOptions,
|
|
282
|
+
*,
|
|
283
|
+
stability_runs: int = 3,
|
|
284
|
+
termination_tolerance_percent: float = 10.0,
|
|
285
|
+
stability_tolerance_percent: float = 2.0,
|
|
286
|
+
on_progress: ProgressCallback | None = None,
|
|
287
|
+
parameters: tuple[TimingParameter, ...] = TIMING_PARAMETERS,
|
|
288
|
+
) -> tuple[ParameterOptimizationResult, ...]:
|
|
289
|
+
if stability_runs < 2:
|
|
290
|
+
raise ValueError("Stability runs must be at least 2")
|
|
291
|
+
if termination_tolerance_percent <= 0:
|
|
292
|
+
raise ValueError("Termination tolerance must be greater than zero")
|
|
293
|
+
if stability_tolerance_percent < 0:
|
|
294
|
+
raise ValueError("Stability tolerance must be zero or greater")
|
|
295
|
+
|
|
296
|
+
results: list[ParameterOptimizationResult] = []
|
|
297
|
+
values = _optimization_start_values(initial_values, parameters)
|
|
298
|
+
ordered_parameters = _parameters_by_duration_impact(values, parameters)
|
|
299
|
+
preset_label = profile.format_patch_id(preset_id)
|
|
300
|
+
reference_stable, reference_statistics = _is_stable(
|
|
301
|
+
profile,
|
|
302
|
+
preset_id,
|
|
303
|
+
alternate_preset_id,
|
|
304
|
+
reference,
|
|
305
|
+
sample_rate,
|
|
306
|
+
backend_factory,
|
|
307
|
+
values,
|
|
308
|
+
analysis_options,
|
|
309
|
+
stability_runs,
|
|
310
|
+
stability_tolerance_percent,
|
|
311
|
+
)
|
|
312
|
+
proven_stable_statistics = reference_statistics if reference_stable else None
|
|
313
|
+
|
|
314
|
+
for parameter in ordered_parameters:
|
|
315
|
+
start = values[parameter.name]
|
|
316
|
+
low = parameter.lower_bound(values)
|
|
317
|
+
if start < low:
|
|
318
|
+
raise ValueError(f"{parameter.label} must be at least {low:g} s for optimization")
|
|
319
|
+
high = start
|
|
320
|
+
iterations = 0
|
|
321
|
+
best = high
|
|
322
|
+
best_statistics = proven_stable_statistics
|
|
323
|
+
tolerance = abs(start) * termination_tolerance_percent / 100.0
|
|
324
|
+
if tolerance == 0:
|
|
325
|
+
tolerance = termination_tolerance_percent / 1000.0
|
|
326
|
+
|
|
327
|
+
_emit(
|
|
328
|
+
on_progress,
|
|
329
|
+
OptimizationProgress(
|
|
330
|
+
"parameter_started",
|
|
331
|
+
(
|
|
332
|
+
f"Investigating {parameter.label} from {start:.6g} s "
|
|
333
|
+
f"on preset {preset_label} using snapshots 1 and 2 "
|
|
334
|
+
f"({stability_runs} stability runs)"
|
|
335
|
+
),
|
|
336
|
+
parameter=parameter.name,
|
|
337
|
+
low=low,
|
|
338
|
+
high=high,
|
|
339
|
+
best=best,
|
|
340
|
+
iteration=iterations,
|
|
341
|
+
results=tuple(results),
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
if proven_stable_statistics is not None:
|
|
346
|
+
stable = True
|
|
347
|
+
statistics = proven_stable_statistics
|
|
348
|
+
else:
|
|
349
|
+
stable = False
|
|
350
|
+
statistics = reference_statistics
|
|
351
|
+
|
|
352
|
+
latest_statistics = statistics
|
|
353
|
+
if not stable:
|
|
354
|
+
result = ParameterOptimizationResult(
|
|
355
|
+
parameter, high, False, iterations, latest_statistics
|
|
356
|
+
)
|
|
357
|
+
results.append(result)
|
|
358
|
+
_emit(
|
|
359
|
+
on_progress,
|
|
360
|
+
OptimizationProgress(
|
|
361
|
+
"parameter_completed",
|
|
362
|
+
f"{parameter.label} is unstable at the optimization start value",
|
|
363
|
+
parameter=parameter.name,
|
|
364
|
+
candidate=high,
|
|
365
|
+
stable=False,
|
|
366
|
+
low=low,
|
|
367
|
+
high=high,
|
|
368
|
+
best=best,
|
|
369
|
+
iteration=iterations,
|
|
370
|
+
statistics=latest_statistics,
|
|
371
|
+
results=tuple(results),
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
while high - low > tolerance:
|
|
377
|
+
candidate = (low + high) / 2.0
|
|
378
|
+
candidate_values = {**values, parameter.name: candidate}
|
|
379
|
+
stable, statistics = _is_stable(
|
|
380
|
+
profile,
|
|
381
|
+
preset_id,
|
|
382
|
+
alternate_preset_id,
|
|
383
|
+
reference,
|
|
384
|
+
sample_rate,
|
|
385
|
+
backend_factory,
|
|
386
|
+
candidate_values,
|
|
387
|
+
analysis_options,
|
|
388
|
+
stability_runs,
|
|
389
|
+
stability_tolerance_percent,
|
|
390
|
+
reference_statistics,
|
|
391
|
+
)
|
|
392
|
+
latest_statistics = statistics
|
|
393
|
+
iterations += 1
|
|
394
|
+
if stable:
|
|
395
|
+
best = candidate
|
|
396
|
+
high = candidate
|
|
397
|
+
best_statistics = statistics
|
|
398
|
+
else:
|
|
399
|
+
low = candidate
|
|
400
|
+
|
|
401
|
+
_emit(
|
|
402
|
+
on_progress,
|
|
403
|
+
OptimizationProgress(
|
|
404
|
+
"candidate_completed",
|
|
405
|
+
(
|
|
406
|
+
f"{parameter.label}: {candidate:.6g} s "
|
|
407
|
+
f"{'stable' if stable else 'unstable'} after "
|
|
408
|
+
f"{stability_runs} runs on preset {preset_label}, snapshots 1 and 2"
|
|
409
|
+
),
|
|
410
|
+
parameter=parameter.name,
|
|
411
|
+
candidate=candidate,
|
|
412
|
+
stable=stable,
|
|
413
|
+
low=low,
|
|
414
|
+
high=high,
|
|
415
|
+
best=best,
|
|
416
|
+
iteration=iterations,
|
|
417
|
+
statistics=latest_statistics,
|
|
418
|
+
results=tuple(results),
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
values[parameter.name] = best
|
|
423
|
+
proven_stable_statistics = best_statistics
|
|
424
|
+
result = ParameterOptimizationResult(parameter, best, True, iterations, best_statistics)
|
|
425
|
+
results.append(result)
|
|
426
|
+
_emit(
|
|
427
|
+
on_progress,
|
|
428
|
+
OptimizationProgress(
|
|
429
|
+
"parameter_completed",
|
|
430
|
+
(
|
|
431
|
+
f"{parameter.label}: {best:.6g} s; applying this value "
|
|
432
|
+
"to the remaining parameter checks"
|
|
433
|
+
),
|
|
434
|
+
parameter=parameter.name,
|
|
435
|
+
candidate=best,
|
|
436
|
+
stable=True,
|
|
437
|
+
low=low,
|
|
438
|
+
high=high,
|
|
439
|
+
best=best,
|
|
440
|
+
iteration=iterations,
|
|
441
|
+
statistics=latest_statistics,
|
|
442
|
+
results=tuple(results),
|
|
443
|
+
),
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
if any(not result.stable for result in results):
|
|
447
|
+
return tuple(results)
|
|
448
|
+
|
|
449
|
+
_emit(
|
|
450
|
+
on_progress,
|
|
451
|
+
OptimizationProgress(
|
|
452
|
+
"final_stability_started",
|
|
453
|
+
(
|
|
454
|
+
f"Verifying final stability proof on preset {preset_label}, "
|
|
455
|
+
"snapshots 1 and 2, with all optimized values"
|
|
456
|
+
),
|
|
457
|
+
results=tuple(results),
|
|
458
|
+
),
|
|
459
|
+
)
|
|
460
|
+
final_statistics = proven_stable_statistics
|
|
461
|
+
final_stable = final_statistics is not None
|
|
462
|
+
_emit(
|
|
463
|
+
on_progress,
|
|
464
|
+
OptimizationProgress(
|
|
465
|
+
"final_stability_completed",
|
|
466
|
+
(
|
|
467
|
+
"Final stability check passed with optimized timing values"
|
|
468
|
+
if final_stable
|
|
469
|
+
else "Final stability check failed"
|
|
470
|
+
),
|
|
471
|
+
stable=final_stable,
|
|
472
|
+
statistics=final_statistics,
|
|
473
|
+
results=tuple(results),
|
|
474
|
+
),
|
|
475
|
+
)
|
|
476
|
+
if not final_stable:
|
|
477
|
+
raise RuntimeError("Final stability check failed with optimized timing values")
|
|
478
|
+
|
|
479
|
+
return tuple(results)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def optimization_results_toml(device: str, results: tuple[ParameterOptimizationResult, ...]) -> str:
|
|
483
|
+
grouped: dict[tuple[str, ...], list[tuple[str, float]]] = {}
|
|
484
|
+
for result in results:
|
|
485
|
+
table = tuple(device if part == "{device}" else part for part in result.parameter.table)
|
|
486
|
+
grouped.setdefault(table, []).append((result.parameter.key, result.value))
|
|
487
|
+
|
|
488
|
+
lines: list[str] = []
|
|
489
|
+
for table, items in grouped.items():
|
|
490
|
+
if lines:
|
|
491
|
+
lines.append("")
|
|
492
|
+
lines.append(f"[{'.'.join(table)}]")
|
|
493
|
+
for key, value in items:
|
|
494
|
+
lines.append(f"{key} = {_format_float(value)}")
|
|
495
|
+
return "\n".join(lines)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def alternate_preset_id(preset_id: int) -> int:
|
|
499
|
+
return preset_id + 1 if preset_id < 128 else preset_id - 1
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _is_stable(
|
|
503
|
+
profile: DeviceProfile,
|
|
504
|
+
preset_id: int,
|
|
505
|
+
alternate_preset_id: int,
|
|
506
|
+
reference: np.ndarray,
|
|
507
|
+
sample_rate: int,
|
|
508
|
+
backend_factory: BackendFactory,
|
|
509
|
+
values: dict[str, float],
|
|
510
|
+
analysis_options: AnalysisOptions,
|
|
511
|
+
stability_runs: int,
|
|
512
|
+
stability_tolerance_percent: float,
|
|
513
|
+
reference_statistics: StabilityStatistics | None = None,
|
|
514
|
+
) -> tuple[bool, StabilityStatistics | None]:
|
|
515
|
+
measurements = []
|
|
516
|
+
for _ in range(stability_runs):
|
|
517
|
+
try:
|
|
518
|
+
measurements.append(
|
|
519
|
+
_measure_two_snapshots(
|
|
520
|
+
profile,
|
|
521
|
+
preset_id,
|
|
522
|
+
alternate_preset_id,
|
|
523
|
+
reference,
|
|
524
|
+
sample_rate,
|
|
525
|
+
backend_factory(values),
|
|
526
|
+
_analysis_options(values, analysis_options),
|
|
527
|
+
)
|
|
528
|
+
)
|
|
529
|
+
except MeasurementAnalysisError:
|
|
530
|
+
return False, None
|
|
531
|
+
statistics = _stability_statistics(
|
|
532
|
+
measurements,
|
|
533
|
+
stability_tolerance_percent,
|
|
534
|
+
reference_statistics,
|
|
535
|
+
)
|
|
536
|
+
stable = all(
|
|
537
|
+
deviation <= tolerance
|
|
538
|
+
for deviation, tolerance in (
|
|
539
|
+
(statistics.snapshot1_lufs_max_deviation, statistics.snapshot1_lufs_tolerance),
|
|
540
|
+
(
|
|
541
|
+
statistics.snapshot1_crest_max_deviation,
|
|
542
|
+
statistics.snapshot1_crest_tolerance,
|
|
543
|
+
),
|
|
544
|
+
(statistics.snapshot2_lufs_max_deviation, statistics.snapshot2_lufs_tolerance),
|
|
545
|
+
(
|
|
546
|
+
statistics.snapshot2_crest_max_deviation,
|
|
547
|
+
statistics.snapshot2_crest_tolerance,
|
|
548
|
+
),
|
|
549
|
+
)
|
|
550
|
+
)
|
|
551
|
+
return stable, statistics
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _measure_two_snapshots(
|
|
555
|
+
profile: DeviceProfile,
|
|
556
|
+
preset_id: int,
|
|
557
|
+
alternate_preset_id: int,
|
|
558
|
+
reference: np.ndarray,
|
|
559
|
+
sample_rate: int,
|
|
560
|
+
backend: MeasurementBackend,
|
|
561
|
+
analysis_options: AnalysisOptions,
|
|
562
|
+
) -> tuple[tuple[float, float], tuple[float, float]]:
|
|
563
|
+
if getattr(profile, "max_snapshot_count", None) == 1:
|
|
564
|
+
raise ValueError(f"{profile.display_name} must support at least two snapshots")
|
|
565
|
+
|
|
566
|
+
backend.activate_preset(alternate_preset_id)
|
|
567
|
+
backend.activate_preset(preset_id)
|
|
568
|
+
results = []
|
|
569
|
+
for snapshot in (1, 2):
|
|
570
|
+
backend.reapply_snapshot(snapshot)
|
|
571
|
+
try:
|
|
572
|
+
values = analyze_audio(backend.record(reference), sample_rate, analysis_options)
|
|
573
|
+
except ValueError as exc:
|
|
574
|
+
raise MeasurementAnalysisError(str(exc)) from exc
|
|
575
|
+
results.append((values.short_term_lufs, values.crest_factor_db))
|
|
576
|
+
return results[0], results[1]
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _stability_statistics(
|
|
580
|
+
measurements: list[tuple[tuple[float, float], tuple[float, float]]],
|
|
581
|
+
tolerance_percent: float,
|
|
582
|
+
reference_statistics: StabilityStatistics | None = None,
|
|
583
|
+
) -> StabilityStatistics:
|
|
584
|
+
values = np.asarray(measurements, dtype=np.float64)
|
|
585
|
+
means = values.mean(axis=0)
|
|
586
|
+
stds = values.std(axis=0)
|
|
587
|
+
reference_means = (
|
|
588
|
+
means
|
|
589
|
+
if reference_statistics is None
|
|
590
|
+
else np.asarray(
|
|
591
|
+
[
|
|
592
|
+
[
|
|
593
|
+
reference_statistics.snapshot1_lufs_mean,
|
|
594
|
+
reference_statistics.snapshot1_crest_mean,
|
|
595
|
+
],
|
|
596
|
+
[
|
|
597
|
+
reference_statistics.snapshot2_lufs_mean,
|
|
598
|
+
reference_statistics.snapshot2_crest_mean,
|
|
599
|
+
],
|
|
600
|
+
],
|
|
601
|
+
dtype=np.float64,
|
|
602
|
+
)
|
|
603
|
+
)
|
|
604
|
+
max_deviations = (
|
|
605
|
+
np.max(np.abs(values - means), axis=0)
|
|
606
|
+
if reference_statistics is None
|
|
607
|
+
else np.abs(means - reference_means)
|
|
608
|
+
)
|
|
609
|
+
tolerances = np.maximum(np.abs(reference_means), 1.0) * tolerance_percent / 100.0
|
|
610
|
+
return StabilityStatistics(
|
|
611
|
+
snapshot1_lufs_mean=float(means[0, 0]),
|
|
612
|
+
snapshot1_lufs_std=float(stds[0, 0]),
|
|
613
|
+
snapshot1_crest_mean=float(means[0, 1]),
|
|
614
|
+
snapshot1_crest_std=float(stds[0, 1]),
|
|
615
|
+
snapshot2_lufs_mean=float(means[1, 0]),
|
|
616
|
+
snapshot2_lufs_std=float(stds[1, 0]),
|
|
617
|
+
snapshot2_crest_mean=float(means[1, 1]),
|
|
618
|
+
snapshot2_crest_std=float(stds[1, 1]),
|
|
619
|
+
tolerance_percent=tolerance_percent,
|
|
620
|
+
snapshot1_lufs_tolerance=float(tolerances[0, 0]),
|
|
621
|
+
snapshot1_lufs_max_deviation=float(max_deviations[0, 0]),
|
|
622
|
+
snapshot1_crest_tolerance=float(tolerances[0, 1]),
|
|
623
|
+
snapshot1_crest_max_deviation=float(max_deviations[0, 1]),
|
|
624
|
+
snapshot2_lufs_tolerance=float(tolerances[1, 0]),
|
|
625
|
+
snapshot2_lufs_max_deviation=float(max_deviations[1, 0]),
|
|
626
|
+
snapshot2_crest_tolerance=float(tolerances[1, 1]),
|
|
627
|
+
snapshot2_crest_max_deviation=float(max_deviations[1, 1]),
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _analysis_options(values: dict[str, float], options: AnalysisOptions) -> AnalysisOptions:
|
|
632
|
+
return replace(
|
|
633
|
+
options,
|
|
634
|
+
window_seconds=values["analysis_window"],
|
|
635
|
+
interval_seconds=values["analysis_interval"],
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _format_float(value: float) -> str:
|
|
640
|
+
text = f"{value:.6f}".rstrip("0").rstrip(".")
|
|
641
|
+
return text if text else "0"
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _emit(callback: ProgressCallback | None, progress: OptimizationProgress) -> None:
|
|
645
|
+
if callback is not None:
|
|
646
|
+
callback(progress)
|