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,204 @@
|
|
|
1
|
+
"""Interfaces implemented by each supported audio processor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import Self
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class PatchAssignment:
|
|
15
|
+
id: int
|
|
16
|
+
device_patch: str
|
|
17
|
+
name: str
|
|
18
|
+
snapshot_names: tuple[str, ...] = ()
|
|
19
|
+
snapshot_output_levels: tuple[tuple[float, ...], ...] = ()
|
|
20
|
+
snapshot_output_paths: tuple[str, ...] = ()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class PatchFileAdjustments:
|
|
25
|
+
preset_names: dict[str, str]
|
|
26
|
+
snapshot_names: dict[str, dict[int, str]]
|
|
27
|
+
gain_deltas: dict[str, dict[int, float]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class AudioRouting:
|
|
32
|
+
device: str | int | None
|
|
33
|
+
sample_rate: int
|
|
34
|
+
input_mapping: tuple[int, int]
|
|
35
|
+
output_mapping: tuple[int, int]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class SteeringOptions:
|
|
40
|
+
output: str | None
|
|
41
|
+
channel: int
|
|
42
|
+
preset_wait_seconds: float
|
|
43
|
+
snapshot_wait_seconds: float
|
|
44
|
+
measurement_wait_seconds: float
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class NormalizationPolicy:
|
|
49
|
+
snapshot_count: int = 4
|
|
50
|
+
solo_regex: str = r"(?i)\bsolo\b"
|
|
51
|
+
ignore_snapshot_regex: str = r"(?i)^SNAPSHOT [1-9]\d*$"
|
|
52
|
+
solo_gain_bump_db: float = 3.0
|
|
53
|
+
crest_factor_reference_db: float = 12.0
|
|
54
|
+
crest_factor_correction_ratio: float = 0.4
|
|
55
|
+
max_crest_factor_correction_db: float = 3.0
|
|
56
|
+
gain_deadband_db: float = 0.25
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def normalize_regex_pattern(pattern: str) -> str:
|
|
60
|
+
r"""Preserve user-visible ``\b`` word-boundary escapes decoded by config/UI paths."""
|
|
61
|
+
return pattern.replace("\b", r"\b")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DeviceController(ABC):
|
|
65
|
+
def __enter__(self) -> Self:
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def __exit__(
|
|
69
|
+
self,
|
|
70
|
+
exc_type: type[BaseException] | None,
|
|
71
|
+
exc_value: BaseException | None,
|
|
72
|
+
traceback: TracebackType | None,
|
|
73
|
+
) -> None:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
def activate_preset(self, preset_id: int) -> None:
|
|
78
|
+
"""Select a processor preset by its internal numeric ID."""
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def reapply_snapshot(self, snapshot: int) -> None:
|
|
82
|
+
"""Select a processor snapshot by its one-based numeric ID."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PatchFileHandler(ABC):
|
|
86
|
+
def set_log_callback(self, callback: Callable[[str], None] | None) -> None:
|
|
87
|
+
"""Receive device-specific utility output when a front end wants it."""
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def validate_input(self, input_path: Path) -> None:
|
|
92
|
+
"""Validate an input setlist or preset file."""
|
|
93
|
+
|
|
94
|
+
@abstractmethod
|
|
95
|
+
def validate_output(self, input_path: Path, output_path: Path) -> None:
|
|
96
|
+
"""Validate the requested output filename."""
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def list_assignments(self, input_path: Path) -> list[PatchAssignment]:
|
|
100
|
+
"""List measurable presets contained in a patch file."""
|
|
101
|
+
|
|
102
|
+
def metadata(self, input_path: Path) -> dict[str, object]:
|
|
103
|
+
"""Extract displayable metadata from a patch file."""
|
|
104
|
+
return {}
|
|
105
|
+
|
|
106
|
+
def diff_preset_ids(self, input_path: Path, previous_input_path: Path) -> list[int]:
|
|
107
|
+
"""List presets whose loudness-affecting content differs between two patch files."""
|
|
108
|
+
raise NotImplementedError("Preset diff selection is not supported for this device")
|
|
109
|
+
|
|
110
|
+
def diff_snapshot_ids(
|
|
111
|
+
self,
|
|
112
|
+
input_path: Path,
|
|
113
|
+
previous_input_path: Path,
|
|
114
|
+
snapshot_count: int,
|
|
115
|
+
) -> dict[int, tuple[int, ...]]:
|
|
116
|
+
"""List changed one-based snapshots per changed preset."""
|
|
117
|
+
return {
|
|
118
|
+
preset_id: tuple(range(1, snapshot_count + 1))
|
|
119
|
+
for preset_id in self.diff_preset_ids(input_path, previous_input_path)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@abstractmethod
|
|
123
|
+
def parse_patch_set(self, value: str) -> list[int]:
|
|
124
|
+
"""Parse device-facing preset labels into numeric preset IDs."""
|
|
125
|
+
|
|
126
|
+
@abstractmethod
|
|
127
|
+
def select_preset_ids(
|
|
128
|
+
self,
|
|
129
|
+
input_path: Path,
|
|
130
|
+
assignments: list[PatchAssignment],
|
|
131
|
+
requested_ids: list[int] | None,
|
|
132
|
+
) -> list[int]:
|
|
133
|
+
"""Resolve the presets that should be measured."""
|
|
134
|
+
|
|
135
|
+
@abstractmethod
|
|
136
|
+
def format_patch_id(self, preset_id: int) -> str:
|
|
137
|
+
"""Format a numeric preset ID for logs and CSV output."""
|
|
138
|
+
|
|
139
|
+
@abstractmethod
|
|
140
|
+
def create_measurement_file(self, input_path: Path, output_path: Path) -> None:
|
|
141
|
+
"""Rewrite a patch file for processor USB measurement."""
|
|
142
|
+
|
|
143
|
+
@abstractmethod
|
|
144
|
+
def apply_analysis_csv(
|
|
145
|
+
self,
|
|
146
|
+
input_path: Path,
|
|
147
|
+
output_path: Path,
|
|
148
|
+
csv_path: Path,
|
|
149
|
+
ignore_bad_lufs: bool,
|
|
150
|
+
target_lufs: float,
|
|
151
|
+
policy: NormalizationPolicy,
|
|
152
|
+
custom_adjustments_path: Path | None = None,
|
|
153
|
+
adjustments: PatchFileAdjustments | None = None,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Apply measured gain adjustments to a patch file."""
|
|
156
|
+
|
|
157
|
+
@abstractmethod
|
|
158
|
+
def automation_output_path(self, input_path: Path, postfix: str) -> Path:
|
|
159
|
+
"""Build a device-compatible output path beside the input file."""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class DeviceProfile(ABC):
|
|
163
|
+
name: str
|
|
164
|
+
display_name: str
|
|
165
|
+
snapshot_count: int = 4
|
|
166
|
+
max_snapshot_count: int | None = None
|
|
167
|
+
preset_name_max_length: int | None = None
|
|
168
|
+
snapshot_name_max_length: int | None = None
|
|
169
|
+
|
|
170
|
+
@abstractmethod
|
|
171
|
+
def create_patch_file_handler(self, project_dir: Path) -> PatchFileHandler:
|
|
172
|
+
"""Create the device-specific patch-file adapter."""
|
|
173
|
+
|
|
174
|
+
def format_patch_id(self, preset_id: int) -> str:
|
|
175
|
+
"""Format a numeric preset ID for device-facing status text."""
|
|
176
|
+
return str(preset_id)
|
|
177
|
+
|
|
178
|
+
@abstractmethod
|
|
179
|
+
def default_audio_routing(self) -> AudioRouting:
|
|
180
|
+
"""Return the processor's USB measurement channel defaults."""
|
|
181
|
+
|
|
182
|
+
@abstractmethod
|
|
183
|
+
def default_steering_options(self) -> SteeringOptions:
|
|
184
|
+
"""Return the processor's steering defaults."""
|
|
185
|
+
|
|
186
|
+
@abstractmethod
|
|
187
|
+
def create_controller(self, options: SteeringOptions) -> DeviceController:
|
|
188
|
+
"""Open the transport used to select presets and snapshots."""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def validate_snapshot_count(profile: DeviceProfile, snapshot_count: int) -> None:
|
|
192
|
+
if not isinstance(snapshot_count, int) or isinstance(snapshot_count, bool):
|
|
193
|
+
raise ValueError("Configured measured snapshot count must be an integer")
|
|
194
|
+
|
|
195
|
+
if snapshot_count < 1:
|
|
196
|
+
raise ValueError("Configured measured snapshot count must be at least 1")
|
|
197
|
+
|
|
198
|
+
max_snapshot_count = getattr(profile, "max_snapshot_count", None)
|
|
199
|
+
|
|
200
|
+
if max_snapshot_count is not None and snapshot_count > max_snapshot_count:
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"Configured measured snapshot count for {profile.display_name} "
|
|
203
|
+
f"must not exceed {max_snapshot_count}"
|
|
204
|
+
)
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Line 6 Helix profile: patch files, MIDI steering, and USB routing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
|
|
15
|
+
from matchpatch.devices.base import (
|
|
16
|
+
AudioRouting,
|
|
17
|
+
DeviceController,
|
|
18
|
+
DeviceProfile,
|
|
19
|
+
NormalizationPolicy,
|
|
20
|
+
PatchAssignment,
|
|
21
|
+
PatchFileAdjustments,
|
|
22
|
+
PatchFileHandler,
|
|
23
|
+
SteeringOptions,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HelixPatchFileHandler(PatchFileHandler):
|
|
28
|
+
def __init__(self, project_dir: Path) -> None:
|
|
29
|
+
self.script = project_dir / "Python" / "preset_handling.py"
|
|
30
|
+
self.log_callback: Callable[[str], None] | None = None
|
|
31
|
+
|
|
32
|
+
def set_log_callback(self, callback: Callable[[str], None] | None) -> None:
|
|
33
|
+
self.log_callback = callback
|
|
34
|
+
|
|
35
|
+
def _run(
|
|
36
|
+
self,
|
|
37
|
+
*args: object,
|
|
38
|
+
capture: bool = False,
|
|
39
|
+
log_output: bool = True,
|
|
40
|
+
) -> subprocess.CompletedProcess[str]:
|
|
41
|
+
should_capture = capture or self.log_callback is not None
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
completed = subprocess.run(
|
|
45
|
+
[sys.executable, str(self.script), *(str(arg) for arg in args)],
|
|
46
|
+
check=True,
|
|
47
|
+
text=True,
|
|
48
|
+
stdout=subprocess.PIPE if should_capture else None,
|
|
49
|
+
stderr=subprocess.PIPE if should_capture else None,
|
|
50
|
+
)
|
|
51
|
+
except subprocess.CalledProcessError as exc:
|
|
52
|
+
if log_output:
|
|
53
|
+
self._log_output(exc.stdout)
|
|
54
|
+
self._log_output(exc.stderr)
|
|
55
|
+
raise
|
|
56
|
+
|
|
57
|
+
if log_output:
|
|
58
|
+
self._log_output(completed.stdout)
|
|
59
|
+
self._log_output(completed.stderr)
|
|
60
|
+
return completed
|
|
61
|
+
|
|
62
|
+
def _log_output(self, output: str | None) -> None:
|
|
63
|
+
if self.log_callback is None or not output:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
for line in output.splitlines():
|
|
67
|
+
if line.strip():
|
|
68
|
+
self.log_callback(line)
|
|
69
|
+
|
|
70
|
+
def validate_input(self, input_path: Path) -> None:
|
|
71
|
+
if input_path.suffix.lower() not in {".hls", ".hlx"}:
|
|
72
|
+
raise ValueError("Helix input must be an .hls or .hlx file")
|
|
73
|
+
|
|
74
|
+
def validate_output(self, input_path: Path, output_path: Path) -> None:
|
|
75
|
+
if output_path.suffix.lower() != input_path.suffix.lower():
|
|
76
|
+
raise ValueError(f"Helix output must use the {input_path.suffix.lower()} extension")
|
|
77
|
+
|
|
78
|
+
def list_assignments(self, input_path: Path) -> list[PatchAssignment]:
|
|
79
|
+
completed = self._run("-i", input_path, "--list-presets", capture=True, log_output=False)
|
|
80
|
+
return [
|
|
81
|
+
PatchAssignment(
|
|
82
|
+
id=assignment["id"],
|
|
83
|
+
device_patch=assignment["helix_preset"],
|
|
84
|
+
name=assignment["name"],
|
|
85
|
+
snapshot_names=tuple(assignment.get("snapshot_names", ())),
|
|
86
|
+
snapshot_output_paths=tuple(
|
|
87
|
+
str(path) for path in assignment.get("snapshot_output_paths", ())
|
|
88
|
+
),
|
|
89
|
+
snapshot_output_levels=tuple(
|
|
90
|
+
tuple(float(level) for level in levels)
|
|
91
|
+
for levels in assignment.get("snapshot_output_levels", ())
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
for assignment in json.loads(completed.stdout)
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
def metadata(self, input_path: Path) -> dict[str, object]:
|
|
98
|
+
completed = self._run("-i", input_path, "--metadata", capture=True, log_output=False)
|
|
99
|
+
metadata = json.loads(completed.stdout)
|
|
100
|
+
if not isinstance(metadata, dict):
|
|
101
|
+
raise ValueError("Helix metadata output must be a JSON object")
|
|
102
|
+
return metadata
|
|
103
|
+
|
|
104
|
+
def diff_preset_ids(self, input_path: Path, previous_input_path: Path) -> list[int]:
|
|
105
|
+
if previous_input_path.suffix.lower() != input_path.suffix.lower():
|
|
106
|
+
raise ValueError(
|
|
107
|
+
"Helix diff input must use the same extension as the active input file"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
completed = self._run(
|
|
111
|
+
"-i",
|
|
112
|
+
input_path,
|
|
113
|
+
"--diff-presets",
|
|
114
|
+
previous_input_path,
|
|
115
|
+
capture=True,
|
|
116
|
+
log_output=False,
|
|
117
|
+
)
|
|
118
|
+
diff_ids = json.loads(completed.stdout)
|
|
119
|
+
if not isinstance(diff_ids, list) or not all(isinstance(item, int) for item in diff_ids):
|
|
120
|
+
raise ValueError("Helix diff output must be a JSON array of preset IDs")
|
|
121
|
+
return diff_ids
|
|
122
|
+
|
|
123
|
+
def diff_snapshot_ids(
|
|
124
|
+
self,
|
|
125
|
+
input_path: Path,
|
|
126
|
+
previous_input_path: Path,
|
|
127
|
+
snapshot_count: int,
|
|
128
|
+
) -> dict[int, tuple[int, ...]]:
|
|
129
|
+
if previous_input_path.suffix.lower() != input_path.suffix.lower():
|
|
130
|
+
raise ValueError(
|
|
131
|
+
"Helix diff input must use the same extension as the active input file"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
completed = self._run(
|
|
135
|
+
"-i",
|
|
136
|
+
input_path,
|
|
137
|
+
"--diff-snapshots",
|
|
138
|
+
previous_input_path,
|
|
139
|
+
"--snapshot-count",
|
|
140
|
+
snapshot_count,
|
|
141
|
+
capture=True,
|
|
142
|
+
log_output=False,
|
|
143
|
+
)
|
|
144
|
+
raw_plan = json.loads(completed.stdout)
|
|
145
|
+
if not isinstance(raw_plan, dict):
|
|
146
|
+
raise ValueError("Helix snapshot diff output must be a JSON object")
|
|
147
|
+
result: dict[int, tuple[int, ...]] = {}
|
|
148
|
+
for preset_id, snapshots in raw_plan.items():
|
|
149
|
+
if not str(preset_id).isdigit() or not isinstance(snapshots, list):
|
|
150
|
+
raise ValueError("Helix snapshot diff output has an invalid preset entry")
|
|
151
|
+
if not all(isinstance(snapshot, int) for snapshot in snapshots):
|
|
152
|
+
raise ValueError("Helix snapshot diff output has an invalid snapshot list")
|
|
153
|
+
result[int(preset_id)] = tuple(snapshots)
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
def parse_patch_set(self, value: str) -> list[int]:
|
|
157
|
+
preset_ids = []
|
|
158
|
+
|
|
159
|
+
for token in value.split(","):
|
|
160
|
+
text = token.strip().upper()
|
|
161
|
+
|
|
162
|
+
if len(text) < 2 or not text[:-1].isdigit() or text[-1] not in "ABCD":
|
|
163
|
+
raise ValueError(f"Invalid Helix preset ID: {token}")
|
|
164
|
+
|
|
165
|
+
bank = int(text[:-1])
|
|
166
|
+
|
|
167
|
+
if bank < 1:
|
|
168
|
+
raise ValueError(f"Invalid Helix preset ID: {token}")
|
|
169
|
+
|
|
170
|
+
preset_id = (bank - 1) * 4 + "ABCD".index(text[-1]) + 1
|
|
171
|
+
|
|
172
|
+
if preset_id not in preset_ids:
|
|
173
|
+
preset_ids.append(preset_id)
|
|
174
|
+
|
|
175
|
+
if not preset_ids: # pragma: no cover - guarded by token validation
|
|
176
|
+
raise ValueError("Preset set did not contain Helix presets")
|
|
177
|
+
|
|
178
|
+
return preset_ids
|
|
179
|
+
|
|
180
|
+
def select_preset_ids(
|
|
181
|
+
self,
|
|
182
|
+
input_path: Path,
|
|
183
|
+
assignments: list[PatchAssignment],
|
|
184
|
+
requested_ids: list[int] | None,
|
|
185
|
+
) -> list[int]:
|
|
186
|
+
if input_path.suffix.lower() == ".hlx":
|
|
187
|
+
if requested_ids is None or len(requested_ids) != 1:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
"Helix .hlx input requires exactly one --preset-set value, "
|
|
190
|
+
"for example --preset-set 12A"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return requested_ids
|
|
194
|
+
|
|
195
|
+
available_ids = {assignment.id for assignment in assignments}
|
|
196
|
+
|
|
197
|
+
if requested_ids is None:
|
|
198
|
+
return [assignment.id for assignment in assignments]
|
|
199
|
+
|
|
200
|
+
missing_ids = [preset_id for preset_id in requested_ids if preset_id not in available_ids]
|
|
201
|
+
|
|
202
|
+
if missing_ids:
|
|
203
|
+
missing = ",".join(self.format_patch_id(preset_id) for preset_id in missing_ids)
|
|
204
|
+
raise ValueError(f"Requested Helix presets are missing or empty: {missing}")
|
|
205
|
+
|
|
206
|
+
requested = set(requested_ids)
|
|
207
|
+
return [assignment.id for assignment in assignments if assignment.id in requested]
|
|
208
|
+
|
|
209
|
+
def format_patch_id(self, preset_id: int) -> str:
|
|
210
|
+
zero_based = preset_id - 1
|
|
211
|
+
return f"{zero_based // 4 + 1:02d}{'ABCD'[zero_based % 4]}"
|
|
212
|
+
|
|
213
|
+
def create_measurement_file(self, input_path: Path, output_path: Path) -> None:
|
|
214
|
+
self._run("-i", input_path, "-o", output_path, "--measurement")
|
|
215
|
+
|
|
216
|
+
def apply_analysis_csv(
|
|
217
|
+
self,
|
|
218
|
+
input_path: Path,
|
|
219
|
+
output_path: Path,
|
|
220
|
+
csv_path: Path,
|
|
221
|
+
ignore_bad_lufs: bool,
|
|
222
|
+
target_lufs: float,
|
|
223
|
+
policy: NormalizationPolicy = NormalizationPolicy(),
|
|
224
|
+
custom_adjustments_path: Path | None = None,
|
|
225
|
+
adjustments: PatchFileAdjustments | None = None,
|
|
226
|
+
) -> None:
|
|
227
|
+
legacy_csv_path = self._create_legacy_analysis_csv(csv_path)
|
|
228
|
+
adjustments_path = None
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
adjustments_path = self._create_adjustments_json(csv_path, adjustments)
|
|
232
|
+
args: list[object] = [
|
|
233
|
+
"-i",
|
|
234
|
+
input_path,
|
|
235
|
+
"-o",
|
|
236
|
+
output_path,
|
|
237
|
+
"--adjust-gain",
|
|
238
|
+
"-g",
|
|
239
|
+
legacy_csv_path,
|
|
240
|
+
"--target-lufs",
|
|
241
|
+
target_lufs,
|
|
242
|
+
"--snapshot-count",
|
|
243
|
+
policy.snapshot_count,
|
|
244
|
+
"--solo-regex",
|
|
245
|
+
policy.solo_regex,
|
|
246
|
+
"--ignore-snapshot-regex",
|
|
247
|
+
policy.ignore_snapshot_regex,
|
|
248
|
+
"--solo-gain-bump-db",
|
|
249
|
+
policy.solo_gain_bump_db,
|
|
250
|
+
"--crest-factor-reference-db",
|
|
251
|
+
policy.crest_factor_reference_db,
|
|
252
|
+
"--crest-factor-correction-ratio",
|
|
253
|
+
policy.crest_factor_correction_ratio,
|
|
254
|
+
"--max-crest-factor-correction-db",
|
|
255
|
+
policy.max_crest_factor_correction_db,
|
|
256
|
+
"--gain-deadband-db",
|
|
257
|
+
policy.gain_deadband_db,
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
args.append("--ignore-bad-lufs")
|
|
261
|
+
if custom_adjustments_path is not None:
|
|
262
|
+
args.extend(["--custom-adjustments-file", custom_adjustments_path])
|
|
263
|
+
if adjustments_path is not None:
|
|
264
|
+
args.extend(["--manual-adjustments", adjustments_path])
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
self._run(*args, capture=True)
|
|
268
|
+
except subprocess.CalledProcessError as exc:
|
|
269
|
+
details = _error_details(exc)
|
|
270
|
+
message = "Helix gain adjustment failed"
|
|
271
|
+
if details:
|
|
272
|
+
message += f":\n{details}"
|
|
273
|
+
raise RuntimeError(message) from exc
|
|
274
|
+
finally:
|
|
275
|
+
legacy_csv_path.unlink(missing_ok=True)
|
|
276
|
+
if adjustments_path is not None:
|
|
277
|
+
adjustments_path.unlink(missing_ok=True)
|
|
278
|
+
|
|
279
|
+
def _create_legacy_analysis_csv(self, csv_path: Path) -> Path:
|
|
280
|
+
with csv_path.open("r", encoding="utf-8-sig", newline="") as source:
|
|
281
|
+
reader = csv.DictReader(source)
|
|
282
|
+
fieldnames = [
|
|
283
|
+
"Preset",
|
|
284
|
+
"HelixPreset",
|
|
285
|
+
*(
|
|
286
|
+
field
|
|
287
|
+
for field in reader.fieldnames or []
|
|
288
|
+
if field not in {"Preset", "DevicePatch", "HelixPreset"}
|
|
289
|
+
),
|
|
290
|
+
]
|
|
291
|
+
temporary = tempfile.NamedTemporaryFile(
|
|
292
|
+
"w",
|
|
293
|
+
encoding="utf-8",
|
|
294
|
+
newline="",
|
|
295
|
+
suffix=".helix.csv",
|
|
296
|
+
dir=csv_path.parent,
|
|
297
|
+
delete=False,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
with temporary:
|
|
301
|
+
writer = csv.DictWriter(temporary, fieldnames=fieldnames)
|
|
302
|
+
writer.writeheader()
|
|
303
|
+
|
|
304
|
+
for row in reader:
|
|
305
|
+
row["HelixPreset"] = row["DevicePatch"]
|
|
306
|
+
writer.writerow({field: row.get(field, "") for field in fieldnames})
|
|
307
|
+
|
|
308
|
+
return Path(temporary.name)
|
|
309
|
+
|
|
310
|
+
def _create_adjustments_json(
|
|
311
|
+
self,
|
|
312
|
+
csv_path: Path,
|
|
313
|
+
adjustments: PatchFileAdjustments | None,
|
|
314
|
+
) -> Path | None:
|
|
315
|
+
if adjustments is None:
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
temporary = tempfile.NamedTemporaryFile(
|
|
319
|
+
"w",
|
|
320
|
+
encoding="utf-8",
|
|
321
|
+
suffix=".adjustments.json",
|
|
322
|
+
dir=csv_path.parent,
|
|
323
|
+
delete=False,
|
|
324
|
+
)
|
|
325
|
+
with temporary:
|
|
326
|
+
json.dump(
|
|
327
|
+
{
|
|
328
|
+
"preset_names": adjustments.preset_names,
|
|
329
|
+
"snapshot_names": adjustments.snapshot_names,
|
|
330
|
+
"gain_deltas": adjustments.gain_deltas,
|
|
331
|
+
},
|
|
332
|
+
temporary,
|
|
333
|
+
)
|
|
334
|
+
return Path(temporary.name)
|
|
335
|
+
|
|
336
|
+
def automation_output_path(self, input_path: Path, postfix: str) -> Path:
|
|
337
|
+
self.validate_input(input_path)
|
|
338
|
+
return input_path.with_name(input_path.stem + postfix + input_path.suffix)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class HelixMidiController(DeviceController):
|
|
342
|
+
def __init__(self, options: SteeringOptions) -> None:
|
|
343
|
+
self.options = options
|
|
344
|
+
self.port = None
|
|
345
|
+
|
|
346
|
+
def __enter__(self) -> "HelixMidiController":
|
|
347
|
+
import mido
|
|
348
|
+
|
|
349
|
+
names = mido.get_output_names()
|
|
350
|
+
query = self.options.output
|
|
351
|
+
matches = (
|
|
352
|
+
names
|
|
353
|
+
if query is None
|
|
354
|
+
else [name for name in names if query.casefold() in name.casefold()]
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
if len(matches) != 1:
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"Helix MIDI output query {query!r} matched {len(matches)} ports; "
|
|
360
|
+
"use --steering-output with a unique substring"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
self.port = mido.open_output(matches[0])
|
|
364
|
+
return self
|
|
365
|
+
|
|
366
|
+
def __exit__(
|
|
367
|
+
self,
|
|
368
|
+
exc_type: type[BaseException] | None,
|
|
369
|
+
exc_value: BaseException | None,
|
|
370
|
+
traceback: TracebackType | None,
|
|
371
|
+
) -> None:
|
|
372
|
+
if self.port is not None:
|
|
373
|
+
self.port.close()
|
|
374
|
+
|
|
375
|
+
def _send(self, message_type: str, **kwargs: int) -> None:
|
|
376
|
+
import mido
|
|
377
|
+
|
|
378
|
+
if self.port is None:
|
|
379
|
+
raise RuntimeError("Helix MIDI output is not open")
|
|
380
|
+
|
|
381
|
+
self.port.send(mido.Message(message_type, channel=self.options.channel, **kwargs))
|
|
382
|
+
|
|
383
|
+
def activate_preset(self, preset_id: int) -> None:
|
|
384
|
+
value = preset_id - 1
|
|
385
|
+
|
|
386
|
+
if value < 0 or value > 127:
|
|
387
|
+
raise ValueError(f"Invalid Helix preset ID: {preset_id}")
|
|
388
|
+
|
|
389
|
+
self._send("program_change", program=value)
|
|
390
|
+
time.sleep(self.options.preset_wait_seconds)
|
|
391
|
+
|
|
392
|
+
def activate_snapshot(self, snapshot: int) -> None:
|
|
393
|
+
value = snapshot - 1
|
|
394
|
+
|
|
395
|
+
if value < 0 or value > 7:
|
|
396
|
+
raise ValueError(f"Invalid Helix snapshot: {snapshot}")
|
|
397
|
+
|
|
398
|
+
self._send("control_change", control=69, value=value)
|
|
399
|
+
time.sleep(self.options.snapshot_wait_seconds)
|
|
400
|
+
|
|
401
|
+
def reapply_snapshot(self, snapshot: int) -> None:
|
|
402
|
+
self.activate_snapshot(snapshot)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class HelixDeviceProfile(DeviceProfile):
|
|
406
|
+
name = "helix"
|
|
407
|
+
display_name = "Line 6 Helix"
|
|
408
|
+
max_snapshot_count = 8
|
|
409
|
+
preset_name_max_length = 16
|
|
410
|
+
snapshot_name_max_length = 10
|
|
411
|
+
|
|
412
|
+
def create_patch_file_handler(self, project_dir: Path) -> PatchFileHandler:
|
|
413
|
+
return HelixPatchFileHandler(project_dir)
|
|
414
|
+
|
|
415
|
+
def format_patch_id(self, preset_id: int) -> str:
|
|
416
|
+
zero_based = preset_id - 1
|
|
417
|
+
return f"{zero_based // 4 + 1:02d}{'ABCD'[zero_based % 4]}"
|
|
418
|
+
|
|
419
|
+
def default_audio_routing(self) -> AudioRouting:
|
|
420
|
+
return AudioRouting(
|
|
421
|
+
device="Helix",
|
|
422
|
+
sample_rate=48000,
|
|
423
|
+
input_mapping=(1, 2),
|
|
424
|
+
output_mapping=(3, 4),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def default_steering_options(self) -> SteeringOptions:
|
|
428
|
+
return SteeringOptions(
|
|
429
|
+
output="Helix",
|
|
430
|
+
channel=0,
|
|
431
|
+
preset_wait_seconds=0.5,
|
|
432
|
+
snapshot_wait_seconds=0.2,
|
|
433
|
+
measurement_wait_seconds=0.1,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
def create_controller(self, options: SteeringOptions) -> DeviceController:
|
|
437
|
+
return HelixMidiController(options)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _error_details(exc: subprocess.CalledProcessError) -> str:
|
|
441
|
+
lines = (exc.stderr or "").splitlines() + (exc.stdout or "").splitlines()
|
|
442
|
+
errors = [line for line in lines if line.strip().startswith("ERROR:")]
|
|
443
|
+
|
|
444
|
+
if errors:
|
|
445
|
+
return "\n".join(errors)
|
|
446
|
+
|
|
447
|
+
return lines[-1].strip() if lines else ""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Registry of audio processor profiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from matchpatch.devices.base import DeviceProfile
|
|
6
|
+
from matchpatch.devices.helix import HelixDeviceProfile
|
|
7
|
+
|
|
8
|
+
_PROFILES: dict[str, DeviceProfile] = {
|
|
9
|
+
"helix": HelixDeviceProfile(),
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_device_profile(name: str) -> DeviceProfile:
|
|
14
|
+
try:
|
|
15
|
+
return _PROFILES[name]
|
|
16
|
+
except KeyError as exc:
|
|
17
|
+
supported = ", ".join(sorted(_PROFILES))
|
|
18
|
+
raise ValueError(f"Unsupported device {name!r}; choose one of: {supported}") from exc
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def list_device_profiles() -> list[DeviceProfile]:
|
|
22
|
+
return [_PROFILES[name] for name in sorted(_PROFILES)]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""PySide6 desktop interface for MatchPatch."""
|