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 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
@@ -0,0 +1,5 @@
1
+ """Audio processor profiles supported by MatchPatch."""
2
+
3
+ from matchpatch.devices.registry import get_device_profile, list_device_profiles
4
+
5
+ __all__ = ["get_device_profile", "list_device_profiles"]