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/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""MatchPatch package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _version_from_pyproject() -> str:
|
|
11
|
+
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
|
12
|
+
with pyproject_path.open("rb") as pyproject_file:
|
|
13
|
+
pyproject = tomllib.load(pyproject_file)
|
|
14
|
+
return str(pyproject["project"]["version"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
__version__ = version("matchpatch")
|
|
19
|
+
except PackageNotFoundError: # pragma: no cover - fallback for direct source execution
|
|
20
|
+
__version__ = _version_from_pyproject()
|
matchpatch/analysis.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Audio measurements compatible with the historical MatchPatch CSV format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pyloudnorm as pyln
|
|
9
|
+
|
|
10
|
+
MINIMUM_LUFS_WINDOW_SECONDS = 0.4
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class AudioMeasurements:
|
|
15
|
+
short_term_lufs: float
|
|
16
|
+
crest_factor_db: float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class AnalysisOptions:
|
|
21
|
+
window_seconds: float = 3.0
|
|
22
|
+
interval_seconds: float = 0.1
|
|
23
|
+
minimum_valid_lufs: float = -100.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _as_float_audio(audio: np.ndarray) -> np.ndarray:
|
|
27
|
+
result = np.asarray(audio, dtype=np.float64)
|
|
28
|
+
|
|
29
|
+
if result.ndim == 1:
|
|
30
|
+
result = result[:, np.newaxis]
|
|
31
|
+
|
|
32
|
+
if result.ndim != 2 or result.shape[0] == 0:
|
|
33
|
+
raise ValueError("Audio must contain frames and one or more channels")
|
|
34
|
+
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def calculate_average_short_term_lufs(
|
|
39
|
+
audio: np.ndarray,
|
|
40
|
+
sample_rate: int,
|
|
41
|
+
window_seconds: float = 3.0,
|
|
42
|
+
interval_seconds: float = 0.1,
|
|
43
|
+
minimum_valid_lufs: float = -100.0,
|
|
44
|
+
) -> float:
|
|
45
|
+
"""Average LUFS values from sliding three-second analysis windows."""
|
|
46
|
+
|
|
47
|
+
samples = _as_float_audio(audio)
|
|
48
|
+
window_frames = round(window_seconds * sample_rate)
|
|
49
|
+
interval_frames = round(interval_seconds * sample_rate)
|
|
50
|
+
|
|
51
|
+
if window_seconds < MINIMUM_LUFS_WINDOW_SECONDS:
|
|
52
|
+
raise ValueError(f"LUFS window must be at least {MINIMUM_LUFS_WINDOW_SECONDS:g} s")
|
|
53
|
+
|
|
54
|
+
if samples.shape[0] < window_frames:
|
|
55
|
+
raise ValueError(f"Audio is shorter than the {window_seconds:g} s LUFS window")
|
|
56
|
+
|
|
57
|
+
meter = pyln.Meter(sample_rate)
|
|
58
|
+
values = []
|
|
59
|
+
|
|
60
|
+
for end in range(window_frames, samples.shape[0] + 1, interval_frames):
|
|
61
|
+
value = meter.integrated_loudness(samples[end - window_frames : end])
|
|
62
|
+
|
|
63
|
+
if np.isfinite(value) and value > minimum_valid_lufs:
|
|
64
|
+
values.append(float(value))
|
|
65
|
+
|
|
66
|
+
if not values:
|
|
67
|
+
raise ValueError("Could not collect valid short-term LUFS values")
|
|
68
|
+
|
|
69
|
+
return float(np.mean(values))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def calculate_crest_factor_db(audio: np.ndarray) -> float:
|
|
73
|
+
samples = _as_float_audio(audio)
|
|
74
|
+
peak = float(np.max(np.abs(samples)))
|
|
75
|
+
rms = float(np.sqrt(np.mean(np.square(samples))))
|
|
76
|
+
|
|
77
|
+
if peak <= 0.0 or rms <= 0.0:
|
|
78
|
+
raise ValueError("Could not calculate crest factor from silent audio")
|
|
79
|
+
|
|
80
|
+
return float(20.0 * np.log10(peak / rms))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def analyze_audio(
|
|
84
|
+
audio: np.ndarray,
|
|
85
|
+
sample_rate: int,
|
|
86
|
+
options: AnalysisOptions = AnalysisOptions(),
|
|
87
|
+
) -> AudioMeasurements:
|
|
88
|
+
return AudioMeasurements(
|
|
89
|
+
short_term_lufs=calculate_average_short_term_lufs(
|
|
90
|
+
audio,
|
|
91
|
+
sample_rate,
|
|
92
|
+
options.window_seconds,
|
|
93
|
+
options.interval_seconds,
|
|
94
|
+
options.minimum_valid_lufs,
|
|
95
|
+
),
|
|
96
|
+
crest_factor_db=calculate_crest_factor_db(audio),
|
|
97
|
+
)
|
matchpatch/app.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Unified MatchPatch application entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
CLI_SWITCHES = {"--cli", "/cli"}
|
|
8
|
+
ATTACH_PARENT_PROCESS = 0xFFFFFFFF
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _stream_is_usable(stream: object) -> bool:
|
|
12
|
+
write = getattr(stream, "write", None)
|
|
13
|
+
flush = getattr(stream, "flush", None)
|
|
14
|
+
if write is None or flush is None:
|
|
15
|
+
return False
|
|
16
|
+
try:
|
|
17
|
+
write("")
|
|
18
|
+
flush()
|
|
19
|
+
except (AttributeError, OSError, ValueError):
|
|
20
|
+
return False
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _attach_parent_console() -> bool:
|
|
25
|
+
if sys.platform != "win32":
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
import ctypes
|
|
30
|
+
|
|
31
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
32
|
+
attach_console = kernel32.AttachConsole
|
|
33
|
+
attach_console.argtypes = [ctypes.c_uint32]
|
|
34
|
+
attach_console.restype = ctypes.c_int
|
|
35
|
+
attached = attach_console(ATTACH_PARENT_PROCESS) != 0
|
|
36
|
+
already_attached = ctypes.get_last_error() == 5
|
|
37
|
+
if not attached and not already_attached:
|
|
38
|
+
return False
|
|
39
|
+
sys.stdout = open("CONOUT$", "w", encoding="utf-8", buffering=1)
|
|
40
|
+
sys.stderr = open("CONOUT$", "w", encoding="utf-8", buffering=1)
|
|
41
|
+
except (AttributeError, OSError, ValueError):
|
|
42
|
+
return False
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _prepare_cli_stdout() -> bool:
|
|
47
|
+
if _stream_is_usable(sys.stdout):
|
|
48
|
+
return True
|
|
49
|
+
if _attach_parent_console():
|
|
50
|
+
return _stream_is_usable(sys.stdout)
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main(argv: list[str] | None = None) -> None:
|
|
55
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
56
|
+
if args and args[0].lower() in CLI_SWITCHES:
|
|
57
|
+
if args[1:] == ["--version"] and not _prepare_cli_stdout():
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
from matchpatch.cli import main as cli_main
|
|
61
|
+
|
|
62
|
+
cli_main(args[1:])
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
from matchpatch.gui.app import main as gui_main
|
|
66
|
+
|
|
67
|
+
gui_main(args)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__": # pragma: no cover - frozen executable entry point
|
|
71
|
+
main()
|
matchpatch/audio.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Native Windows duplex audio support shared by hardware profiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
os.environ.setdefault("SD_ENABLE_ASIO", "1")
|
|
12
|
+
|
|
13
|
+
import sounddevice as sd # noqa: E402
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class AudioConfig:
|
|
18
|
+
device: str | int | None
|
|
19
|
+
sample_rate: int
|
|
20
|
+
input_mapping: tuple[int, int]
|
|
21
|
+
output_mapping: tuple[int, int]
|
|
22
|
+
blocksize: int = 0
|
|
23
|
+
pre_roll_seconds: float = 0.2
|
|
24
|
+
post_roll_seconds: float = 0.1
|
|
25
|
+
round_trip_latency_seconds: float = 0.02
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _matches_device(device: dict[str, Any], query: str) -> bool:
|
|
29
|
+
return query.casefold() in str(device["name"]).casefold()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_audio_device(query: str | int | None) -> int | None:
|
|
33
|
+
if query is None:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
if isinstance(query, int) or str(query).isdigit():
|
|
37
|
+
return int(query)
|
|
38
|
+
|
|
39
|
+
devices = sd.query_devices()
|
|
40
|
+
matches = [index for index, device in enumerate(devices) if _matches_device(device, str(query))]
|
|
41
|
+
|
|
42
|
+
if not matches:
|
|
43
|
+
raise ValueError(f"No audio device matched {query!r}")
|
|
44
|
+
|
|
45
|
+
asio_matches = [
|
|
46
|
+
index
|
|
47
|
+
for index in matches
|
|
48
|
+
if "asio" in sd.query_hostapis(devices[index]["hostapi"])["name"].lower()
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
if len(asio_matches) == 1:
|
|
52
|
+
return asio_matches[0]
|
|
53
|
+
|
|
54
|
+
if len(matches) == 1:
|
|
55
|
+
return matches[0]
|
|
56
|
+
|
|
57
|
+
raise ValueError(f"Audio device query {query!r} is ambiguous; use a numeric device ID")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def prepare_audio_config(config: AudioConfig) -> AudioConfig:
|
|
61
|
+
"""Resolve and validate audio settings once before recording starts."""
|
|
62
|
+
device = resolve_audio_device(config.device)
|
|
63
|
+
sd.check_input_settings(
|
|
64
|
+
device=device,
|
|
65
|
+
channels=len(config.input_mapping),
|
|
66
|
+
dtype="float32",
|
|
67
|
+
samplerate=config.sample_rate,
|
|
68
|
+
)
|
|
69
|
+
sd.check_output_settings(
|
|
70
|
+
device=device,
|
|
71
|
+
channels=len(config.output_mapping),
|
|
72
|
+
dtype="float32",
|
|
73
|
+
samplerate=config.sample_rate,
|
|
74
|
+
)
|
|
75
|
+
return replace(config, device=device)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate_audio_device_available(config: AudioConfig) -> AudioConfig:
|
|
79
|
+
"""Quickly validate that a matching audio device with enough channels is present."""
|
|
80
|
+
device = resolve_audio_device(config.device)
|
|
81
|
+
if device is None:
|
|
82
|
+
return replace(config, device=device)
|
|
83
|
+
|
|
84
|
+
device_info = sd.query_devices(device)
|
|
85
|
+
input_channels = int(device_info.get("max_input_channels", 0))
|
|
86
|
+
output_channels = int(device_info.get("max_output_channels", 0))
|
|
87
|
+
|
|
88
|
+
if input_channels < max(config.input_mapping):
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"Audio device {device_info['name']!r} has {input_channels} input channels; "
|
|
91
|
+
f"need channel {max(config.input_mapping)}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if output_channels < max(config.output_mapping):
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"Audio device {device_info['name']!r} has {output_channels} output channels; "
|
|
97
|
+
f"need channel {max(config.output_mapping)}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return replace(config, device=device)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def record_processed_audio(
|
|
104
|
+
reference_audio: np.ndarray,
|
|
105
|
+
config: AudioConfig,
|
|
106
|
+
) -> np.ndarray:
|
|
107
|
+
device = resolve_audio_device(config.device)
|
|
108
|
+
pre_roll_frames = round(config.pre_roll_seconds * config.sample_rate)
|
|
109
|
+
post_roll_frames = round(config.post_roll_seconds * config.sample_rate)
|
|
110
|
+
latency_frames = round(config.round_trip_latency_seconds * config.sample_rate)
|
|
111
|
+
|
|
112
|
+
if min(pre_roll_frames, post_roll_frames, latency_frames) < 0:
|
|
113
|
+
raise ValueError("Audio pre-roll, post-roll, and round-trip latency must not be negative")
|
|
114
|
+
|
|
115
|
+
if latency_frames > post_roll_frames:
|
|
116
|
+
raise ValueError("Audio post-roll must be at least as long as round-trip latency")
|
|
117
|
+
|
|
118
|
+
reference = np.asarray(reference_audio)
|
|
119
|
+
|
|
120
|
+
if reference.ndim != 2 or reference.shape[0] == 0:
|
|
121
|
+
raise ValueError("Reference audio must contain frames and one or more channels")
|
|
122
|
+
|
|
123
|
+
silence_shape = (pre_roll_frames + post_roll_frames, reference.shape[1])
|
|
124
|
+
silence = np.zeros(silence_shape, dtype=reference.dtype)
|
|
125
|
+
playback = np.concatenate(
|
|
126
|
+
[silence[:pre_roll_frames], reference, silence[pre_roll_frames:]],
|
|
127
|
+
axis=0,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
recorded = sd.playrec(
|
|
131
|
+
playback,
|
|
132
|
+
samplerate=config.sample_rate,
|
|
133
|
+
channels=len(config.input_mapping),
|
|
134
|
+
dtype="float32",
|
|
135
|
+
input_mapping=list(config.input_mapping),
|
|
136
|
+
output_mapping=list(config.output_mapping),
|
|
137
|
+
blocking=True,
|
|
138
|
+
device=(device, device),
|
|
139
|
+
blocksize=config.blocksize,
|
|
140
|
+
)
|
|
141
|
+
aligned_start = pre_roll_frames + latency_frames
|
|
142
|
+
aligned_end = aligned_start + reference.shape[0]
|
|
143
|
+
return recorded[aligned_start:aligned_end]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def play_audio(audio: np.ndarray, sample_rate: int) -> None:
|
|
147
|
+
"""Play audio through the system default output device."""
|
|
148
|
+
sd.play(np.asarray(audio), samplerate=sample_rate, blocking=True)
|
matchpatch/cli.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Command-line entry point for MatchPatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import platform
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from matchpatch import __version__
|
|
10
|
+
from matchpatch.config import export_default_config
|
|
11
|
+
from matchpatch.devices import list_device_profiles
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def print_environment() -> None:
|
|
15
|
+
print(f"MatchPatch {__version__}")
|
|
16
|
+
print(f"Platform: {platform.platform()}")
|
|
17
|
+
print(f"Python : {sys.executable}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def print_devices() -> None:
|
|
21
|
+
for profile in list_device_profiles():
|
|
22
|
+
print(f"{profile.name}\t{profile.display_name}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv: list[str] | None = None) -> None:
|
|
26
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
27
|
+
if args and args[0] == "normalize":
|
|
28
|
+
from matchpatch.normalize import main as normalize_main
|
|
29
|
+
|
|
30
|
+
normalize_main(args[1:])
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
parser = argparse.ArgumentParser(description="Normalize gain across audio processor presets")
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--version",
|
|
36
|
+
action="version",
|
|
37
|
+
version=f"%(prog)s {__version__}",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--environment",
|
|
41
|
+
action="store_true",
|
|
42
|
+
help="Print the Python environment used for this invocation",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--devices",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="List supported audio processor profiles",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--export-default-config",
|
|
51
|
+
metavar="PATH",
|
|
52
|
+
help="Write a TOML configuration file populated with MatchPatch defaults",
|
|
53
|
+
)
|
|
54
|
+
args = parser.parse_args(args)
|
|
55
|
+
|
|
56
|
+
if args.export_default_config:
|
|
57
|
+
path = export_default_config(args.export_default_config)
|
|
58
|
+
print(f"Wrote default config: {path}")
|
|
59
|
+
elif args.environment:
|
|
60
|
+
print_environment()
|
|
61
|
+
elif args.devices:
|
|
62
|
+
print_devices()
|
|
63
|
+
else:
|
|
64
|
+
parser.print_help()
|
|
65
|
+
print("\nNormalization command:")
|
|
66
|
+
print(" matchpatch normalize --device DEVICE --input PATCH_FILE [options]")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__": # pragma: no cover - console script entry point
|
|
70
|
+
main()
|
matchpatch/config.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""TOML configuration helpers shared by MatchPatch entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from matchpatch.devices import list_device_profiles
|
|
10
|
+
from matchpatch.devices.base import NormalizationPolicy
|
|
11
|
+
|
|
12
|
+
Config = dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def default_config_path() -> Path:
|
|
16
|
+
return Path.home() / ".config" / "matchpatch" / "config.toml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_config(path: str | Path | None) -> Config:
|
|
20
|
+
config_path = Path(path).expanduser() if path is not None else default_config_path()
|
|
21
|
+
|
|
22
|
+
if not config_path.is_file():
|
|
23
|
+
if path is None:
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
raise ValueError(f"MatchPatch config file does not exist: {config_path}")
|
|
27
|
+
|
|
28
|
+
with config_path.open("rb") as config_file:
|
|
29
|
+
return tomllib.load(config_file)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def config_value(config: Config, *keys: str, default: Any = None) -> Any: # noqa: ANN401
|
|
33
|
+
value: Any = config
|
|
34
|
+
|
|
35
|
+
for key in keys:
|
|
36
|
+
if not isinstance(value, dict):
|
|
37
|
+
return default
|
|
38
|
+
|
|
39
|
+
value = value.get(key, default)
|
|
40
|
+
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def prefer(cli_value: object, config: Config, *keys: str, default: object = None) -> object:
|
|
45
|
+
if cli_value is not None:
|
|
46
|
+
return cli_value
|
|
47
|
+
|
|
48
|
+
return config_value(config, *keys, default=default)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_channel_mapping(value: object) -> tuple[int, int]:
|
|
52
|
+
if isinstance(value, str):
|
|
53
|
+
channels = tuple(int(item.strip()) for item in value.split(",") if item.strip())
|
|
54
|
+
elif isinstance(value, (list, tuple)):
|
|
55
|
+
channels = tuple(value)
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError("Channel mapping must contain two positive IDs")
|
|
58
|
+
|
|
59
|
+
if len(channels) != 2 or any(
|
|
60
|
+
not isinstance(channel, int) or channel < 1 for channel in channels
|
|
61
|
+
):
|
|
62
|
+
raise ValueError("Channel mapping must contain two positive IDs")
|
|
63
|
+
|
|
64
|
+
first, second = channels
|
|
65
|
+
assert isinstance(first, int) and isinstance(second, int)
|
|
66
|
+
return first, second
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def default_config() -> Config:
|
|
70
|
+
from matchpatch.normalize import DEFAULT_REFERENCE_DI, DEFAULT_WINDOWS_PYTHON
|
|
71
|
+
|
|
72
|
+
policy = NormalizationPolicy()
|
|
73
|
+
config: Config = {
|
|
74
|
+
"normalize": {
|
|
75
|
+
"backend": "hardware",
|
|
76
|
+
"windows_python": str(DEFAULT_WINDOWS_PYTHON),
|
|
77
|
+
"reference_di": str(DEFAULT_REFERENCE_DI),
|
|
78
|
+
"target_lufs": -16.0,
|
|
79
|
+
},
|
|
80
|
+
"analysis": {
|
|
81
|
+
"window_seconds": 3.0,
|
|
82
|
+
"interval_seconds": 0.1,
|
|
83
|
+
"minimum_valid_lufs": -100.0,
|
|
84
|
+
"pre_roll_seconds": 0.2,
|
|
85
|
+
"post_roll_seconds": 0.1,
|
|
86
|
+
"round_trip_latency_seconds": 0.02,
|
|
87
|
+
},
|
|
88
|
+
"measurement": {
|
|
89
|
+
"stability_runs": 3,
|
|
90
|
+
"termination_tolerance_percent": 10.0,
|
|
91
|
+
"stability_tolerance_percent": 2.0,
|
|
92
|
+
},
|
|
93
|
+
"policy": {
|
|
94
|
+
"measured_snapshots": policy.snapshot_count,
|
|
95
|
+
"solo_regex": policy.solo_regex,
|
|
96
|
+
"ignore_snapshot_regex": policy.ignore_snapshot_regex,
|
|
97
|
+
"solo_gain_bump_db": policy.solo_gain_bump_db,
|
|
98
|
+
"crest_factor_reference_db": policy.crest_factor_reference_db,
|
|
99
|
+
"crest_factor_correction_ratio": policy.crest_factor_correction_ratio,
|
|
100
|
+
"max_crest_factor_correction_db": policy.max_crest_factor_correction_db,
|
|
101
|
+
"gain_deadband_db": policy.gain_deadband_db,
|
|
102
|
+
},
|
|
103
|
+
"devices": {},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
devices = config["devices"]
|
|
107
|
+
assert isinstance(devices, dict)
|
|
108
|
+
for profile in list_device_profiles():
|
|
109
|
+
audio = profile.default_audio_routing()
|
|
110
|
+
steering = profile.default_steering_options()
|
|
111
|
+
devices[profile.name] = {
|
|
112
|
+
"audio": {
|
|
113
|
+
"device": audio.device,
|
|
114
|
+
"sample_rate": audio.sample_rate,
|
|
115
|
+
"input_mapping": list(audio.input_mapping),
|
|
116
|
+
"output_mapping": list(audio.output_mapping),
|
|
117
|
+
"blocksize": 0,
|
|
118
|
+
},
|
|
119
|
+
"steering": {
|
|
120
|
+
"output": steering.output,
|
|
121
|
+
"channel": steering.channel,
|
|
122
|
+
"preset_wait_seconds": steering.preset_wait_seconds,
|
|
123
|
+
"snapshot_wait_seconds": steering.snapshot_wait_seconds,
|
|
124
|
+
"measurement_wait_seconds": steering.measurement_wait_seconds,
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return config
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def export_default_config(path: str | Path) -> Path:
|
|
132
|
+
return export_config(path, default_config())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def export_config(path: str | Path, config: Config) -> Path:
|
|
136
|
+
config_path = Path(path).expanduser()
|
|
137
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
config_path.write_text(_toml_document(config), encoding="utf-8")
|
|
139
|
+
return config_path
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _toml_document(config: Config) -> str:
|
|
143
|
+
lines: list[str] = []
|
|
144
|
+
|
|
145
|
+
for key, value in config.items():
|
|
146
|
+
if not isinstance(value, dict):
|
|
147
|
+
continue
|
|
148
|
+
_append_toml_table(lines, (key,), value)
|
|
149
|
+
|
|
150
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _append_toml_table(lines: list[str], path: tuple[str, ...], table: dict[str, Any]) -> None:
|
|
154
|
+
scalar_items = [
|
|
155
|
+
(key, value)
|
|
156
|
+
for key, value in table.items()
|
|
157
|
+
if value is not None and not isinstance(value, dict)
|
|
158
|
+
]
|
|
159
|
+
nested_items = [(key, value) for key, value in table.items() if isinstance(value, dict)]
|
|
160
|
+
|
|
161
|
+
if scalar_items:
|
|
162
|
+
if lines:
|
|
163
|
+
lines.append("")
|
|
164
|
+
lines.append(f"[{'.'.join(path)}]")
|
|
165
|
+
for key, value in scalar_items:
|
|
166
|
+
lines.append(f"{key} = {_toml_value(value)}")
|
|
167
|
+
|
|
168
|
+
for key, value in nested_items:
|
|
169
|
+
_append_toml_table(lines, (*path, key), value)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _toml_value(value: object) -> str:
|
|
173
|
+
if isinstance(value, str):
|
|
174
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
175
|
+
if isinstance(value, bool):
|
|
176
|
+
return "true" if value else "false"
|
|
177
|
+
if isinstance(value, (int, float)):
|
|
178
|
+
return str(value)
|
|
179
|
+
if isinstance(value, (list, tuple)):
|
|
180
|
+
return "[" + ", ".join(_toml_value(item) for item in value) + "]"
|
|
181
|
+
raise TypeError(f"Unsupported default config value: {value!r}")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Custom per-preset snapshot loudness target bumps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import math
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
CustomAdjustments = dict[str, dict[int, float]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_custom_adjustments_file(path: Path, snapshot_count: int) -> CustomAdjustments:
|
|
13
|
+
"""Load custom dB target bumps from a preset/snapshot CSV."""
|
|
14
|
+
adjustments: CustomAdjustments = {}
|
|
15
|
+
expected_columns = snapshot_count + 1
|
|
16
|
+
|
|
17
|
+
with path.open("r", encoding="utf-8-sig", newline="") as csv_file:
|
|
18
|
+
sample = csv_file.read(4096)
|
|
19
|
+
csv_file.seek(0)
|
|
20
|
+
try:
|
|
21
|
+
dialect = csv.Sniffer().sniff(sample, delimiters=",|")
|
|
22
|
+
except csv.Error:
|
|
23
|
+
dialect = csv.excel
|
|
24
|
+
reader = csv.reader(csv_file, dialect)
|
|
25
|
+
for line_number, row in enumerate(reader, start=1):
|
|
26
|
+
if not row or all(not cell.strip() for cell in row):
|
|
27
|
+
continue
|
|
28
|
+
if len(row) != expected_columns:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
f"Line {line_number}: expected {expected_columns} columns, got {len(row)}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
preset_id = row[0].strip().upper()
|
|
34
|
+
if not preset_id:
|
|
35
|
+
raise ValueError(f"Line {line_number}: preset ID is empty")
|
|
36
|
+
if preset_id in adjustments:
|
|
37
|
+
raise ValueError(f"Line {line_number}: duplicate preset ID {preset_id!r}")
|
|
38
|
+
|
|
39
|
+
preset_adjustments: dict[int, float] = {}
|
|
40
|
+
for snapshot_index, cell in enumerate(row[1:]):
|
|
41
|
+
text = cell.strip()
|
|
42
|
+
if not text:
|
|
43
|
+
continue
|
|
44
|
+
try:
|
|
45
|
+
value = float(text)
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"Line {line_number}: snapshot {snapshot_index + 1} "
|
|
49
|
+
f"custom adjustment is not a floating point number: {text!r}"
|
|
50
|
+
) from exc
|
|
51
|
+
if not math.isfinite(value):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Line {line_number}: snapshot {snapshot_index + 1} "
|
|
54
|
+
f"custom adjustment is not finite: {text!r}"
|
|
55
|
+
)
|
|
56
|
+
preset_adjustments[snapshot_index] = value
|
|
57
|
+
|
|
58
|
+
adjustments[preset_id] = preset_adjustments
|
|
59
|
+
|
|
60
|
+
return adjustments
|