From 90ec616aa218899ba96eba7f01a1a73ef2421805 Mon Sep 17 00:00:00 2001 From: Noseglasses Date: Wed, 17 Jun 2026 21:57:51 +0200 Subject: [PATCH 01/10] feat: Refactor and restructure --- Python/helix_file_ops.py | 233 +++++++++++++++++ Python/preset_handling.py | 153 ++++++----- src/matchpatch/cli.py | 66 ++++- src/matchpatch/config.py | 1 + src/matchpatch/devices/__init__.py | 4 +- src/matchpatch/devices/base.py | 127 ++++++++- src/matchpatch/devices/helix.py | 110 +++++++- src/matchpatch/devices/registry.py | 81 +++++- src/matchpatch/diagnostics.py | 1 + src/matchpatch/file_operations.py | 76 ++++++ src/matchpatch/gui/advanced_settings.py | 4 + src/matchpatch/gui/device_panels.py | 11 + .../gui/file_operations_workflow.py | 219 ++++++++++++++++ src/matchpatch/gui/main_window.py | 46 ++-- src/matchpatch/gui/main_window_callbacks.py | 7 + src/matchpatch/gui/preset_table.py | 90 +++++++ src/matchpatch/gui/table_legend.py | 5 + src/matchpatch/gui/table_roles.py | 4 + src/matchpatch/gui/window_layout.py | 47 ++++ src/matchpatch/gui/window_loading.py | 27 ++ src/matchpatch/measure.py | 26 +- src/matchpatch/normalize.py | 34 ++- src/matchpatch/preflight.py | 14 +- tests/test_cli.py | 79 ++++++ tests/test_devices.py | 247 +++++++++++++++++- tests/test_diagnostics.py | 2 + tests/test_gui.py | 60 ++++- tests/test_gui_advanced_settings.py | 1 + tests/test_gui_preset_table.py | 69 +++++ tests/test_gui_save_workflow.py | 188 ++++++++++++- tests/test_helix.py | 97 +++++++ tests/test_measure.py | 55 ++++ tests/test_normalize.py | 58 ++++ tests/test_preflight.py | 27 +- tests/test_preset_handling.py | 123 +++++++++ 35 files changed, 2280 insertions(+), 112 deletions(-) create mode 100644 Python/helix_file_ops.py create mode 100644 src/matchpatch/file_operations.py create mode 100644 src/matchpatch/gui/file_operations_workflow.py diff --git a/Python/helix_file_ops.py b/Python/helix_file_ops.py new file mode 100644 index 0000000..16e6add --- /dev/null +++ b/Python/helix_file_ops.py @@ -0,0 +1,233 @@ +"""Pure Helix preset/setlist split and join helpers.""" + +import base64 +import binascii +import copy +import json +import os +import re +import zlib + + +def decode_hls_text(hls_text): + wrapper = json.loads(hls_text) + compressed = base64.b64decode(wrapper["encoded_data"]) + raw = zlib.decompress(compressed) + return raw.decode("utf-8") + + +def build_hls_text(original_hls_text, modified_json_text): + raw = modified_json_text.encode("utf-8") + compressed = zlib.compress(raw, level=9) + encoded_data = base64.b64encode(compressed).decode("ascii") + decompressed_size = len(raw) + crc32 = binascii.crc32(raw) & 0xFFFFFFFF + result = original_hls_text + result = re.sub( + r'("encoded_data"\s*:\s*")([^"]*)(")', + rf"\g<1>{encoded_data}\g<3>", + result, + flags=re.DOTALL, + ) + result = re.sub(r'("decompressed_size"\s*:\s*)(\d+)', rf"\g<1>{decompressed_size}", result) + return re.sub(r'("crc32"\s*:\s*)(\d+)', rf"\g<1>{crc32}", result) + + +def build_new_hls_text(json_text): + raw = json_text.encode("utf-8") + compressed = zlib.compress(raw, level=9) + wrapper = { + "compression": { + "crc32": (binascii.crc32(raw) & 0xFFFFFFFF), + "decompressed_size": len(raw), + "type": "zlib", + }, + "encoded_data": base64.b64encode(compressed).decode("ascii"), + } + return json.dumps(wrapper) + + +def _wrap_preset_data(data): + if not isinstance(data, dict): + raise ValueError(".hlx preset content must be a JSON object") + if isinstance(data.get("data"), dict): + preset = data["data"] + if "tone" not in preset: + raise ValueError(".hlx preset data does not contain a tone section") + return {"presets": [preset]} + if "presets" in data: + raise ValueError(".hlx input must contain one preset, not a setlist") + if "tone" not in data: + raise ValueError(".hlx preset content does not contain a tone section") + return {"presets": [data]} + + +def _unwrap_preset_data(data): + presets = data.get("presets") + if not isinstance(presets, list) or len(presets) != 1: + raise ValueError(".hlx output requires exactly one preset") + return presets[0] + + +def load_preset_file(path): + with open(path, "r", encoding="utf-8") as f: + original_hlx_data = json.load(f) + data = _wrap_preset_data(original_hlx_data) + return copy.deepcopy(_unwrap_preset_data(data)), original_hlx_data + + +def load_setlist_file(path): + with open(path, "r", encoding="utf-8") as f: + original_hls_text = f.read() + return json.loads(decode_hls_text(original_hls_text)), original_hls_text + + +def preset_index_to_helix(index): + bank = (index // 4) + 1 + slot = ["A", "B", "C", "D"][index % 4] + return f"{bank:02d}{slot}" + + +def helix_to_preset_index(label): + match = re.fullmatch(r"(?i)(\d{1,2})([A-D])", str(label).strip()) + if match is None: + raise ValueError(f"Invalid Helix preset slot ID: {label!r}") + bank = int(match.group(1)) + slot = "ABCD".index(match.group(2).upper()) + if bank < 1: + raise ValueError(f"Invalid Helix preset slot ID: {label!r}") + return (bank - 1) * 4 + slot + + +def _slot_indices(slot_ids, count): + if slot_ids is None: + return list(range(count)) + if len(slot_ids) != count: + raise ValueError("--slot-ids count must match --join-presets count") + return [helix_to_preset_index(slot_id) for slot_id in slot_ids] + + +def join_preset_files_to_setlist(preset_paths, template_setlist_path=None, slot_ids=None): + sources = {} + slot_indices = _slot_indices(slot_ids, len(preset_paths)) + if template_setlist_path is None: + setlist_data = {"presets": []} + original_hls_text = None + else: + setlist_data, original_hls_text = load_setlist_file(template_setlist_path) + setlist_presets = setlist_data.setdefault("presets", []) + + for slot_index, preset_path in zip(slot_indices, preset_paths): + preset, _ = load_preset_file(preset_path) + while len(setlist_presets) <= slot_index: + setlist_presets.append({"tone": {}}) + setlist_presets[slot_index] = preset + sources[preset_index_to_helix(slot_index)] = os.path.basename(os.fspath(preset_path)) + + json_text = json.dumps(setlist_data, indent=1) + hls_text = ( + build_hls_text(original_hls_text, json_text) + if original_hls_text is not None + else build_new_hls_text(json_text) + ) + return hls_text, {"source_filenames": sources} + + +def _selected_preset_indices(selected_ids, preset_count): + if selected_ids is None: + return list(range(preset_count)) + result = [] + for selected_id in selected_ids: + if isinstance(selected_id, int): + index = selected_id - 1 + else: + text = str(selected_id).strip() + index = int(text) - 1 if text.isdecimal() else helix_to_preset_index(text) + if index < 0 or index >= preset_count: + raise ValueError(f"Selected preset is outside the setlist: {selected_id!r}") + result.append(index) + return result + + +def _original_filename_for_preset(original_filenames, preset_index, label): + if not original_filenames: + return None + keys = [label, preset_index + 1, str(preset_index + 1), preset_index, str(preset_index)] + for key in keys: + if key in original_filenames: + filename = os.path.basename(os.fspath(original_filenames[key])) + root, ext = os.path.splitext(filename) + return filename if ext.lower() == ".hlx" else f"{root}.hlx" + return None + + +def safe_preset_filename(name, fallback_label): + safe_name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", str(name or "")) + safe_name = re.sub(r"\s+", " ", safe_name).strip(" .") + if not safe_name: + safe_name = str(fallback_label) + if not safe_name.lower().endswith(".hlx"): + safe_name = f"{safe_name}.hlx" + return safe_name + + +def _get_preset_name(preset): + meta = preset.get("meta", {}) + return meta.get("name") or preset.get("name") or preset.get("@name") or "" + + +def _preset_has_blocks(preset): + tone = preset.get("tone", {}) + if not isinstance(tone, dict): + return False + for dsp_name in ["dsp0", "dsp1"]: + dsp = tone.get(dsp_name) + if not isinstance(dsp, dict): + continue + for block_name in dsp: + if str(block_name).startswith("block"): + return True + return False + + +def _is_default_preset(preset): + return not _preset_has_blocks(preset) + + +def split_setlist_to_preset_data(input_path, selected_ids=None, original_filenames=None): + data, _ = load_setlist_file(input_path) + presets = data.get("presets", []) + selected_indices = _selected_preset_indices(selected_ids, len(presets)) + split_presets = [] + synthetic_names = [ + safe_preset_filename( + _get_preset_name(presets[preset_index]), preset_index_to_helix(preset_index) + ) + for preset_index in selected_indices + if not _is_default_preset(presets[preset_index]) + and _original_filename_for_preset( + original_filenames, preset_index, preset_index_to_helix(preset_index) + ) + is None + ] + duplicate_synthetic_names = { + filename for filename in synthetic_names if synthetic_names.count(filename) > 1 + } + + for preset_index in selected_indices: + preset = presets[preset_index] + if _is_default_preset(preset): + continue + label = preset_index_to_helix(preset_index) + original_filename = _original_filename_for_preset(original_filenames, preset_index, label) + if original_filename is not None: + split_presets.append((original_filename, copy.deepcopy(preset))) + continue + + filename = safe_preset_filename(_get_preset_name(preset), label) + if filename in duplicate_synthetic_names: + root, ext = os.path.splitext(filename) + filename = f"{root} {label}{ext}" + split_presets.append((filename, copy.deepcopy(preset))) + + return split_presets diff --git a/Python/preset_handling.py b/Python/preset_handling.py index fca7454..dcc46ea 100644 --- a/Python/preset_handling.py +++ b/Python/preset_handling.py @@ -1,8 +1,6 @@ #!/usr/bin/env python3 import argparse -import base64 -import binascii import copy import csv import json @@ -10,9 +8,24 @@ import os import re import sys -import zlib from dataclasses import dataclass +_LEGACY_DIR = os.path.dirname(__file__) +if _LEGACY_DIR not in sys.path: + sys.path.insert(0, _LEGACY_DIR) + +from helix_file_ops import ( # noqa: E402, F401, I001 + build_hls_text, + build_new_hls_text, + decode_hls_text, + helix_to_preset_index as helix_to_preset_index, + join_preset_files_to_setlist, + load_preset_file as load_preset_file, + load_setlist_file as load_setlist_file, + safe_preset_filename as safe_preset_filename, + split_setlist_to_preset_data, +) + # ================================================= # HELIX CONSTANTS # ================================================= @@ -89,22 +102,6 @@ def get_filetype(filename): raise ValueError(f"Unsupported file extension: {ext}") -# ================================================= -# HLS DECODING -# ================================================= - - -def decode_hls_text(hls_text): - - wrapper = json.loads(hls_text) - - compressed = base64.b64decode(wrapper["encoded_data"]) - - raw = zlib.decompress(compressed) - - return raw.decode("utf-8") - - def decode_hls_file(filename): with open(filename, "r", encoding="utf-8") as f: @@ -1577,36 +1574,6 @@ def process_json_structure( return (modified_json_text, snapshot_changes, gain_changes) -# ================================================= -# BUILD HLS -# ================================================= - - -def build_hls_text(original_hls_text, modified_json_text): - - raw = modified_json_text.encode("utf-8") - - compressed = zlib.compress(raw, level=9) - - encoded_data = base64.b64encode(compressed).decode("ascii") - - decompressed_size = len(raw) - - crc32 = binascii.crc32(raw) & 0xFFFFFFFF - - result = original_hls_text - - result = re.sub( - r'("encoded_data"\s*:\s*")([^"]*)(")', rf"\g<1>{encoded_data}\g<3>", result, flags=re.DOTALL - ) - - result = re.sub(r'("decompressed_size"\s*:\s*)(\d+)', rf"\g<1>{decompressed_size}", result) - - result = re.sub(r'("crc32"\s*:\s*)(\d+)', rf"\g<1>{crc32}", result) - - return result - - # ================================================= # LOAD INPUT # ================================================= @@ -1666,21 +1633,8 @@ def save_output(modified_json_text, output_filename, original_hls_text=None): return - raw = modified_json_text.encode("utf-8") - - compressed = zlib.compress(raw, level=9) - - wrapper = { - "compression": { - "crc32": (binascii.crc32(raw) & 0xFFFFFFFF), - "decompressed_size": len(raw), - "type": "zlib", - }, - "encoded_data": base64.b64encode(compressed).decode("ascii"), - } - with open(output_filename, "w", encoding="utf-8") as f: - json.dump(wrapper, f) + f.write(build_new_hls_text(modified_json_text)) # ================================================= @@ -1691,10 +1645,15 @@ def save_output(modified_json_text, output_filename, original_hls_text=None): def _build_parser(): parser = argparse.ArgumentParser(description=("Line 6 Helix HLS/HLX/JSON Utility")) - parser.add_argument("-i", "--input", required=True, help="Input file (.hls, .hlx, or .json)") + parser.add_argument("-i", "--input", help="Input file (.hls, .hlx, or .json)") parser.add_argument("-o", "--output", help="Output file (.hls, .hlx, or .json)") + parser.add_argument( + "--slot-ids", + help="Comma-separated Helix slot IDs for --join-presets, for example 01A,01B", + ) + parser.add_argument("-g", "--lufs-analysis-file", help="LUFS analysis CSV file") parser.add_argument( "--manual-adjustments", help="GUI preset, snapshot, and gain overrides JSON" @@ -1799,10 +1758,25 @@ def _build_parser(): ), ) + mode_group.add_argument( + "--join-presets", + nargs="+", + metavar="PATH", + help="Join .hlx preset files into an .hls setlist", + ) + + mode_group.add_argument( + "--split-setlist", + metavar="DIR", + help="Split an .hls setlist into .hlx preset files in DIR", + ) + return parser def _load_input_text(args): + if not args.input: + raise ValueError("Input file is required") input_filetype = get_filetype(args.input) if args.snapshot_count < 1 or args.snapshot_count > 8: raise ValueError("Snapshot count must be between 1 and 8") @@ -1887,6 +1861,54 @@ def _load_custom_adjustments_for_command(args): return load_custom_adjustments_file(args.custom_adjustments_file, args.snapshot_count) +def _parse_slot_ids(slot_ids): + if not slot_ids: + return None + return [slot_id.strip() for slot_id in slot_ids.split(",") if slot_id.strip()] + + +def _run_join_presets_command(args): + if not args.output: + raise ValueError("Output file is required for --join-presets") + if get_filetype(args.output) != "hls": + raise ValueError(f"Join output must be an .hls file: {args.output}") + + hls_text, metadata = join_preset_files_to_setlist( + args.join_presets, + slot_ids=_parse_slot_ids(args.slot_ids), + ) + with open(args.output, "w", encoding="utf-8") as f: + f.write(hls_text) + return metadata + + +def _run_split_setlist_command(args): + if not args.input: + raise ValueError("Input file is required for --split-setlist") + if get_filetype(args.input) != "hls": + raise ValueError(f"Split input must be an .hls file: {args.input}") + + os.makedirs(args.split_setlist, exist_ok=True) + split_presets = split_setlist_to_preset_data(args.input) + for filename, hlx_data in split_presets: + output_path = os.path.join(args.split_setlist, filename) + with open(output_path, "w", encoding="utf-8") as f: + json.dump(hlx_data, f, indent=1) + return split_presets + + +def _run_split_join_command(args): + if args.join_presets: + metadata = _run_join_presets_command(args) + print(f"[OK] Joined {len(args.join_presets)} presets into {args.output}") + return metadata + if args.split_setlist: + split_presets = _run_split_setlist_command(args) + print(f"[OK] Split {len(split_presets)} presets into {args.split_setlist}") + return split_presets + return None + + def _run_preset_handling_command(args, json_text, input_filetype): mode = _conversion_mode(args) modified_json_text, input_changes, output_changes = _convert_if_needed(args, json_text, mode) @@ -1936,6 +1958,9 @@ def main(): args = _build_parser().parse_args() try: + if args.join_presets or args.split_setlist: + _run_split_join_command(args) + return input_filetype, json_text, original_hls_text = _load_input_text(args) if _run_query_command(args, json_text, original_hls_text): return diff --git a/src/matchpatch/cli.py b/src/matchpatch/cli.py index d79d434..ba8a7c1 100644 --- a/src/matchpatch/cli.py +++ b/src/matchpatch/cli.py @@ -5,10 +5,11 @@ import argparse import platform import sys +from pathlib import Path from matchpatch import __version__ from matchpatch.config import export_default_config -from matchpatch.devices import list_device_profiles +from matchpatch.devices import get_device_profile, list_device_profiles def print_environment() -> None: @@ -22,6 +23,44 @@ def print_devices() -> None: print(f"{profile.name}\t{profile.display_name}") +def _parse_preset_set(device: str, value: str | None) -> list[int] | None: + if value is None: + return None + profile = get_device_profile(device) + handler = profile.create_patch_file_handler(Path(__file__).resolve().parents[2]) + return handler.parse_patch_set(value) + + +def _run_files_command(args: argparse.Namespace) -> None: + from matchpatch import file_operations + + if args.files_command == "join": + slot_ids = _parse_preset_set(args.device, args.preset_set) + result = file_operations.join_preset_files( + args.device, + [Path(path) for path in args.preset_files], + Path(args.output), + slot_ids=slot_ids, + ) + print(f"Joined {len(args.preset_files)} preset files into {result.output_path}") + return + + if args.files_command == "split": + selected_ids = _parse_preset_set(args.device, args.preset_set) + result = file_operations.split_setlist_file( + args.device, + Path(args.input), + Path(args.output_dir), + selected_ids=selected_ids, + ) + print(f"Split {len(result.created_paths)} preset files into {Path(args.output_dir)}") + for path in result.created_paths: + print(path) + return + + raise ValueError(f"Unsupported files command: {args.files_command}") + + def main(argv: list[str] | None = None) -> None: args = list(sys.argv[1:] if argv is None else argv) if args and args[0] == "normalize": @@ -56,9 +95,32 @@ def main(argv: list[str] | None = None) -> None: metavar="PATH", help="Write a TOML configuration file populated with MatchPatch defaults", ) + subparsers = parser.add_subparsers(dest="command") + files_parser = subparsers.add_parser("files", help="Run processor file operations") + files_subparsers = files_parser.add_subparsers(dest="files_command", required=True) + + join_parser = files_subparsers.add_parser("join", help="Join preset files into a setlist") + join_parser.add_argument("--device", required=True, help="Audio processor profile") + join_parser.add_argument("--output", required=True, help="Output setlist path") + join_parser.add_argument( + "--preset-set", + help="Comma-separated destination slots, for example 01A,01B", + ) + join_parser.add_argument("preset_files", nargs="+", help="Input preset files") + + split_parser = files_subparsers.add_parser("split", help="Split a setlist into preset files") + split_parser.add_argument("--device", required=True, help="Audio processor profile") + split_parser.add_argument("--input", required=True, help="Input setlist path") + split_parser.add_argument("--output-dir", required=True, help="Directory for preset files") + split_parser.add_argument( + "--preset-set", + help="Comma-separated setlist slots to export, for example 01A,01B", + ) args = parser.parse_args(args) - if args.export_default_config: + if args.command == "files": + _run_files_command(args) + elif args.export_default_config: path = export_default_config(args.export_default_config) print(f"Wrote default config: {path}") elif args.environment: diff --git a/src/matchpatch/config.py b/src/matchpatch/config.py index 5431b6c..4fb87f8 100644 --- a/src/matchpatch/config.py +++ b/src/matchpatch/config.py @@ -118,6 +118,7 @@ def default_config() -> Config: "measured_snapshots": policy.snapshot_count, "solo_regex": policy.solo_regex, "ignore_snapshot_regex": policy.ignore_snapshot_regex, + "ignore_preset_regex": policy.ignore_preset_regex, "solo_gain_bump_db": policy.solo_gain_bump_db, "crest_factor_reference_db": policy.crest_factor_reference_db, "crest_factor_correction_ratio": policy.crest_factor_correction_ratio, diff --git a/src/matchpatch/devices/__init__.py b/src/matchpatch/devices/__init__.py index 98a7d6d..b90118d 100644 --- a/src/matchpatch/devices/__init__.py +++ b/src/matchpatch/devices/__init__.py @@ -1,5 +1,5 @@ """Audio processor profiles supported by MatchPatch.""" -from matchpatch.devices.registry import get_device_profile, list_device_profiles +from matchpatch.devices.registry import get_device_profile, list_device_profiles, plugin_load_errors -__all__ = ["get_device_profile", "list_device_profiles"] +__all__ = ["get_device_profile", "list_device_profiles", "plugin_load_errors"] diff --git a/src/matchpatch/devices/base.py b/src/matchpatch/devices/base.py index 89f1351..8b09ad1 100644 --- a/src/matchpatch/devices/base.py +++ b/src/matchpatch/devices/base.py @@ -3,11 +3,62 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from pathlib import Path from types import TracebackType -from typing import Self +from typing import Literal, Self + +DeviceFileKind = Literal["preset", "setlist", "unknown"] + + +@dataclass(frozen=True) +class DeviceTerminology: + device: str = "device" + preset: str = "preset" + snapshot: str = "snapshot" + setlist: str = "setlist" + + +@dataclass(frozen=True) +class FileOperationCapabilities: + reads_preset_files: bool = False + writes_preset_files: bool = False + reads_setlist_files: bool = False + writes_setlist_files: bool = False + joins_presets_to_setlist: bool = False + splits_setlist_to_presets: bool = False + replaces_setlist_slots: bool = False + exports_selected_setlist_slots: bool = False + + +@dataclass(frozen=True) +class MeasurementBackendCapabilities: + hardware: bool = True + loopback: bool = True + simulated: bool = True + offline: bool = False + + def names(self) -> tuple[str, ...]: + return tuple( + name for name in ("hardware", "loopback", "simulated", "offline") if getattr(self, name) + ) + + +@dataclass(frozen=True) +class NamingRules: + preset_name_max_length: int | None = None + snapshot_name_max_length: int | None = None + allowed_name_pattern: str | None = None + + +@dataclass(frozen=True) +class PresetFileRecord: + path: Path + slot_id: int | None + device_patch: str | None + name: str + original_filename: str | None = None @dataclass(frozen=True) @@ -18,6 +69,7 @@ class PatchAssignment: snapshot_names: tuple[str, ...] = () snapshot_output_levels: tuple[tuple[float, ...], ...] = () snapshot_output_paths: tuple[str, ...] = () + original_filename: str | None = None @dataclass(frozen=True) @@ -49,6 +101,7 @@ class NormalizationPolicy: snapshot_count: int = 4 solo_regex: str = r"(?i)\bsolo\b" ignore_snapshot_regex: str = r"(?i)^SNAPSHOT [1-9]\d*$" + ignore_preset_regex: str = "" solo_gain_bump_db: float = 3.0 crest_factor_reference_db: float = 12.0 crest_factor_correction_ratio: float = 0.4 @@ -103,6 +156,61 @@ def metadata(self, input_path: Path) -> dict[str, object]: """Extract displayable metadata from a patch file.""" return {} + def file_capabilities(self) -> FileOperationCapabilities: + """Describe device file operations supported by this handler.""" + return FileOperationCapabilities() + + def file_kind(self, path: Path) -> DeviceFileKind: + """Classify a path as a device preset file, setlist file, or unknown.""" + return "unknown" + + def join_preset_files( + self, + preset_paths: list[Path], + output_path: Path, + *, + slot_ids: list[int] | None = None, + ) -> None: + """Join individual preset files into a setlist file when supported.""" + raise NotImplementedError("Joining preset files is not supported for this device") + + def split_setlist_file( + self, + input_path: Path, + output_dir: Path, + *, + selected_ids: list[int] | None = None, + original_filenames: Mapping[int, str] | None = None, + ) -> list[Path]: + """Split a setlist file into individual preset files when supported.""" + raise NotImplementedError("Splitting setlist files is not supported for this device") + + def suggest_preset_filename( + self, + assignment: PatchAssignment, + used_names: set[str], + ) -> str: + """Suggest a unique filename for exporting an individual preset.""" + original = assignment.original_filename + if original: + candidate = Path(original).name + else: + stem = "".join( + char if char.isalnum() or char in "._- " else "_" for char in assignment.name + ) + candidate = (stem.strip(" .") or self.format_patch_id(assignment.id)) + ".preset" + + path = Path(candidate) + stem = path.stem or self.format_patch_id(assignment.id) + suffix = path.suffix or ".preset" + unique = stem + suffix + counter = 2 + while unique.casefold() in used_names: + unique = f"{stem}-{self.format_patch_id(assignment.id)}-{counter}{suffix}" + counter += 1 + used_names.add(unique.casefold()) + return unique + def diff_preset_ids(self, input_path: Path, previous_input_path: Path) -> list[int]: """List presets whose loudness-affecting content differs between two patch files.""" raise NotImplementedError("Preset diff selection is not supported for this device") @@ -171,6 +279,21 @@ class DeviceProfile(ABC): def create_patch_file_handler(self, project_dir: Path) -> PatchFileHandler: """Create the device-specific patch-file adapter.""" + def terminology(self) -> DeviceTerminology: + return DeviceTerminology() + + def file_capabilities(self) -> FileOperationCapabilities: + return FileOperationCapabilities() + + def measurement_backends(self) -> tuple[str, ...]: + return MeasurementBackendCapabilities().names() + + def naming_rules(self) -> NamingRules: + return NamingRules( + preset_name_max_length=getattr(self, "preset_name_max_length", None), + snapshot_name_max_length=getattr(self, "snapshot_name_max_length", None), + ) + def format_patch_id(self, preset_id: int) -> str: """Format a numeric preset ID for device-facing status text.""" return str(preset_id) diff --git a/src/matchpatch/devices/helix.py b/src/matchpatch/devices/helix.py index 4c7e339..d87a709 100644 --- a/src/matchpatch/devices/helix.py +++ b/src/matchpatch/devices/helix.py @@ -4,6 +4,7 @@ import contextlib import csv +import importlib.util import io import json import runpy @@ -11,14 +12,19 @@ import sys import tempfile import time -from collections.abc import Callable +from collections.abc import Callable, Mapping from pathlib import Path from types import TracebackType +from typing import Any from matchpatch.devices.base import ( AudioRouting, DeviceController, + DeviceFileKind, DeviceProfile, + DeviceTerminology, + FileOperationCapabilities, + NamingRules, NormalizationPolicy, PatchAssignment, PatchFileAdjustments, @@ -145,6 +151,7 @@ def list_assignments(self, input_path: Path) -> list[PatchAssignment]: tuple(float(level) for level in levels) for levels in assignment.get("snapshot_output_levels", ()) ), + original_filename=input_path.name if input_path.suffix.lower() == ".hlx" else None, ) for assignment in json.loads(completed.stdout) ] @@ -156,6 +163,67 @@ def metadata(self, input_path: Path) -> dict[str, object]: raise ValueError("Helix metadata output must be a JSON object") return metadata + def file_capabilities(self) -> FileOperationCapabilities: + return FileOperationCapabilities( + reads_preset_files=True, + writes_preset_files=True, + reads_setlist_files=True, + writes_setlist_files=True, + joins_presets_to_setlist=True, + splits_setlist_to_presets=True, + exports_selected_setlist_slots=True, + ) + + def file_kind(self, path: Path) -> DeviceFileKind: + suffix = path.suffix.lower() + if suffix == ".hlx": + return "preset" + if suffix == ".hls": + return "setlist" + return "unknown" + + def join_preset_files( + self, + preset_paths: list[Path], + output_path: Path, + *, + slot_ids: list[int] | None = None, + ) -> None: + if self.file_kind(output_path) != "setlist": + raise ValueError(f"Helix join output must be an .hls file: {output_path}") + args: list[object] = ["--join-presets", *preset_paths, "-o", output_path] + if slot_ids is not None: + args.extend( + ["--slot-ids", ",".join(self.format_patch_id(slot_id) for slot_id in slot_ids)] + ) + self._run(*args) + + def split_setlist_file( + self, + input_path: Path, + output_dir: Path, + *, + selected_ids: list[int] | None = None, + original_filenames: Mapping[int, str] | None = None, + ) -> list[Path]: + if self.file_kind(input_path) != "setlist": + raise ValueError(f"Helix split input must be an .hls file: {input_path}") + + output_dir.mkdir(parents=True, exist_ok=True) + file_ops = _load_helix_file_ops(self.script) + split_presets = file_ops.split_setlist_to_preset_data( + input_path, + selected_ids=selected_ids, + original_filenames=original_filenames, + ) + created_paths = [] + for filename, hlx_data in split_presets: + output_path = output_dir / Path(filename).name + with output_path.open("w", encoding="utf-8") as preset_file: + json.dump(hlx_data, preset_file, indent=1) + created_paths.append(output_path) + return created_paths + def diff_preset_ids(self, input_path: Path, previous_input_path: Path) -> list[int]: if previous_input_path.suffix.lower() != input_path.suffix.lower(): raise ValueError( @@ -467,6 +535,29 @@ class HelixDeviceProfile(DeviceProfile): def create_patch_file_handler(self, project_dir: Path) -> PatchFileHandler: return HelixPatchFileHandler(project_dir) + def terminology(self) -> DeviceTerminology: + return DeviceTerminology( + device="Helix", preset="preset", snapshot="snapshot", setlist="setlist" + ) + + def file_capabilities(self) -> FileOperationCapabilities: + return FileOperationCapabilities( + reads_preset_files=True, + writes_preset_files=True, + reads_setlist_files=True, + writes_setlist_files=True, + joins_presets_to_setlist=True, + splits_setlist_to_presets=True, + exports_selected_setlist_slots=True, + ) + + def naming_rules(self) -> NamingRules: + return NamingRules( + preset_name_max_length=self.preset_name_max_length, + snapshot_name_max_length=self.snapshot_name_max_length, + allowed_name_pattern=r"^[ -~]*$", + ) + def format_patch_id(self, preset_id: int) -> str: zero_based = preset_id - 1 return f"{zero_based // 4 + 1:02d}{'ABCD'[zero_based % 4]}" @@ -500,3 +591,20 @@ def _error_details(exc: subprocess.CalledProcessError) -> str: return "\n".join(errors) return lines[-1].strip() if lines else "" + + +def _load_helix_file_ops(script: Path) -> Any: # noqa: ANN401 + module_path = script.with_name("helix_file_ops.py") + module_name = "_matchpatch_helix_file_ops" + module = sys.modules.get(module_name) + if module is not None: + return module + + spec = importlib.util.spec_from_file_location(module_name, module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load Helix file operations helper: {module_path}") + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module diff --git a/src/matchpatch/devices/registry.py b/src/matchpatch/devices/registry.py index 2440a02..25413d8 100644 --- a/src/matchpatch/devices/registry.py +++ b/src/matchpatch/devices/registry.py @@ -2,21 +2,96 @@ from __future__ import annotations +from collections.abc import Iterable +from importlib import metadata +from typing import Any + from matchpatch.devices.base import DeviceProfile from matchpatch.devices.helix import HelixDeviceProfile +ENTRY_POINT_GROUP = "matchpatch.devices" + _PROFILES: dict[str, DeviceProfile] = { "helix": HelixDeviceProfile(), } +_PLUGIN_LOAD_ERRORS: dict[str, str] = {} + + +def _entry_points() -> Iterable[metadata.EntryPoint]: + entry_points = metadata.entry_points() + if hasattr(entry_points, "select"): + return entry_points.select(group=ENTRY_POINT_GROUP) + return entry_points.get(ENTRY_POINT_GROUP, ()) + + +def _profile_from_loaded(value: Any) -> list[DeviceProfile]: # noqa: ANN401 + if isinstance(value, DeviceProfile): + return [value] + if isinstance(value, type) and issubclass(value, DeviceProfile): + return [value()] + if isinstance(value, Iterable) and not isinstance(value, (str, bytes)): + profiles = [] + for item in value: + if not isinstance(item, DeviceProfile): + raise TypeError("device entry point iterable must contain DeviceProfile instances") + profiles.append(item) + return profiles + raise TypeError("device entry point must return a DeviceProfile, subclass, or iterable") + + +def _validate_profile(profile: DeviceProfile) -> None: + if not isinstance(getattr(profile, "name", None), str) or not profile.name: + raise ValueError("device profile name must be a non-empty string") + if not isinstance(getattr(profile, "display_name", None), str) or not profile.display_name: + raise ValueError(f"device profile {profile.name!r} must define a display name") + if not callable(getattr(profile, "create_patch_file_handler", None)): + raise ValueError(f"device profile {profile.name!r} must create patch file handlers") + + +def _plugin_profiles() -> dict[str, DeviceProfile]: + profiles: dict[str, DeviceProfile] = {} + _PLUGIN_LOAD_ERRORS.clear() + for entry_point in _entry_points(): + try: + loaded = entry_point.load() + for profile in _profile_from_loaded(loaded): + _validate_profile(profile) + if profile.name in _PROFILES or profile.name in profiles: + raise ValueError(f"duplicate device profile name {profile.name!r}") + profiles[profile.name] = profile + except Exception as exc: # noqa: BLE001 + _PLUGIN_LOAD_ERRORS[entry_point.name] = str(exc) + return profiles + + +def _all_profiles() -> dict[str, DeviceProfile]: + profiles = dict(_PROFILES) + profiles.update(_plugin_profiles()) + return profiles def get_device_profile(name: str) -> DeviceProfile: + profiles = _all_profiles() try: - return _PROFILES[name] + return profiles[name] except KeyError as exc: - supported = ", ".join(sorted(_PROFILES)) + supported = ", ".join(sorted(profiles)) + if _PLUGIN_LOAD_ERRORS: + plugin_errors = "; ".join( + f"{plugin}: {error}" for plugin, error in sorted(_PLUGIN_LOAD_ERRORS.items()) + ) + raise ValueError( + f"Unsupported device {name!r}; choose one of: {supported}. " + f"Device plugin load errors: {plugin_errors}" + ) from exc raise ValueError(f"Unsupported device {name!r}; choose one of: {supported}") from exc def list_device_profiles() -> list[DeviceProfile]: - return [_PROFILES[name] for name in sorted(_PROFILES)] + profiles = _all_profiles() + return [profiles[name] for name in sorted(profiles)] + + +def plugin_load_errors() -> dict[str, str]: + _plugin_profiles() + return dict(_PLUGIN_LOAD_ERRORS) diff --git a/src/matchpatch/diagnostics.py b/src/matchpatch/diagnostics.py index 74c29f3..c8af609 100644 --- a/src/matchpatch/diagnostics.py +++ b/src/matchpatch/diagnostics.py @@ -463,6 +463,7 @@ def normalization_policy_to_dict(policy: NormalizationPolicy) -> dict[str, Any]: "snapshot_count": policy.snapshot_count, "solo_regex": policy.solo_regex, "ignore_snapshot_regex": policy.ignore_snapshot_regex, + "ignore_preset_regex": policy.ignore_preset_regex, "solo_gain_bump_db": policy.solo_gain_bump_db, "crest_factor_reference_db": policy.crest_factor_reference_db, "crest_factor_correction_ratio": policy.crest_factor_correction_ratio, diff --git a/src/matchpatch/file_operations.py b/src/matchpatch/file_operations.py new file mode 100644 index 0000000..b560cc7 --- /dev/null +++ b/src/matchpatch/file_operations.py @@ -0,0 +1,76 @@ +"""Device-neutral workflows for processor file split/join operations.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from pathlib import Path + +from matchpatch.devices import get_device_profile +from matchpatch.devices.base import DeviceProfile + +PROJECT_DIR = Path(__file__).resolve().parents[2] + + +@dataclass(frozen=True) +class JoinPresetFilesResult: + output_path: Path + + +@dataclass(frozen=True) +class SplitSetlistFileResult: + created_paths: list[Path] + + +def join_preset_files( + device: str, + preset_paths: list[Path], + output_path: Path, + *, + slot_ids: list[int] | None = None, + get_profile: Callable[[str], DeviceProfile] = get_device_profile, +) -> JoinPresetFilesResult: + profile = get_profile(device) + handler = profile.create_patch_file_handler(PROJECT_DIR) + capabilities = handler.file_capabilities() + + if not capabilities.joins_presets_to_setlist: + raise ValueError(f"{profile.display_name} does not support joining preset files") + if handler.file_kind(output_path) != "setlist": + raise ValueError(f"Join output must be a {profile.terminology().setlist} file") + + for preset_path in preset_paths: + if handler.file_kind(preset_path) != "preset": + raise ValueError(f"Join input must be {profile.terminology().preset} files") + + handler.join_preset_files(preset_paths, output_path, slot_ids=slot_ids) + return JoinPresetFilesResult(output_path=output_path) + + +def split_setlist_file( + device: str, + input_path: Path, + output_dir: Path, + *, + selected_ids: list[int] | None = None, + original_filenames: Mapping[int, str] | None = None, + get_profile: Callable[[str], DeviceProfile] = get_device_profile, +) -> SplitSetlistFileResult: + profile = get_profile(device) + handler = profile.create_patch_file_handler(PROJECT_DIR) + capabilities = handler.file_capabilities() + + if not capabilities.splits_setlist_to_presets: + raise ValueError(f"{profile.display_name} does not support splitting setlist files") + if selected_ids is not None and not capabilities.exports_selected_setlist_slots: + raise ValueError(f"{profile.display_name} does not support selected setlist export") + if handler.file_kind(input_path) != "setlist": + raise ValueError(f"Split input must be a {profile.terminology().setlist} file") + + created_paths = handler.split_setlist_file( + input_path, + output_dir, + selected_ids=selected_ids, + original_filenames=original_filenames, + ) + return SplitSetlistFileResult(created_paths=created_paths) diff --git a/src/matchpatch/gui/advanced_settings.py b/src/matchpatch/gui/advanced_settings.py index a3e5f6c..bc26ce6 100644 --- a/src/matchpatch/gui/advanced_settings.py +++ b/src/matchpatch/gui/advanced_settings.py @@ -33,6 +33,7 @@ class GuiSettingsState: solo_gain_bump_db: str solo_regex: str ignore_snapshot_regex: str + ignore_preset_regex: str snapshot_count: int keep_temp: bool device_arguments: tuple[str, ...] @@ -88,6 +89,7 @@ def append_gui_config_arguments(self, argv: list[str]) -> None: argv.extend( ["--ignore-snapshot-regex", normalize_regex_pattern(self.ignore_snapshot_regex)] ) + argv.extend(["--ignore-preset-regex", normalize_regex_pattern(self.ignore_preset_regex)]) argv.extend(["--snapshot-count", str(self.snapshot_count)]) if self.keep_temp: argv.append("--keep-temp") @@ -132,6 +134,7 @@ def active_config(self) -> Config: "measured_snapshots": args.policy.snapshot_count, "solo_regex": args.policy.solo_regex, "ignore_snapshot_regex": args.policy.ignore_snapshot_regex, + "ignore_preset_regex": args.policy.ignore_preset_regex, "solo_gain_bump_db": args.policy.solo_gain_bump_db, "crest_factor_reference_db": args.policy.crest_factor_reference_db, "crest_factor_correction_ratio": args.policy.crest_factor_correction_ratio, @@ -204,6 +207,7 @@ def from_widgets(window: object) -> GuiSettingsState: solo_gain_bump_db=window.solo_gain_bump_db.text(), solo_regex=window.solo_regex.text(), ignore_snapshot_regex=window.ignore_snapshot_regex.text(), + ignore_preset_regex=window.ignore_preset_regex.text(), snapshot_count=window.snapshot_count_input.value(), keep_temp=window.keep_temp.isChecked(), device_arguments=tuple(device_arguments), diff --git a/src/matchpatch/gui/device_panels.py b/src/matchpatch/gui/device_panels.py index 4dad940..70c9e17 100644 --- a/src/matchpatch/gui/device_panels.py +++ b/src/matchpatch/gui/device_panels.py @@ -14,6 +14,8 @@ QWidget, ) +from matchpatch.devices.base import DeviceProfile + class HelixSettingsPanel(QWidget): def __init__(self, backend_selector: QWidget | None = None) -> None: @@ -113,3 +115,12 @@ def _label(text: str, tooltip: str) -> QLabel: label = QLabel(text) label.setToolTip(tooltip) return label + + +def create_settings_panel( + profile: DeviceProfile, + backend_selector: QWidget, +) -> QWidget | None: + if profile.name == "helix": + return HelixSettingsPanel(backend_selector) + return None diff --git a/src/matchpatch/gui/file_operations_workflow.py b/src/matchpatch/gui/file_operations_workflow.py new file mode 100644 index 0000000..c8ee2fb --- /dev/null +++ b/src/matchpatch/gui/file_operations_workflow.py @@ -0,0 +1,219 @@ +"""GUI helpers for device file split/join operations.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from pathlib import Path +from typing import Any, Protocol, cast + +from PySide6.QtWidgets import QFileDialog, QWidget + +from matchpatch import file_operations +from matchpatch.devices.base import DeviceProfile, FileOperationCapabilities +from matchpatch.gui.window_state import FileActionState + + +class PresetTableControllerLike(Protocol): + def original_filename_map( + self, + parse_patch_set: Callable[[str], list[int]] | None = None, + ) -> dict[int, str]: ... + + def preset_ids_for_rows( + self, + rows: list[int], + parse_patch_set: Callable[[str], list[int]], + ) -> list[int]: ... + + def selected_rows_or_all_measurable_rows(self) -> list[int]: ... + + +class FileOperationWindow(Protocol): + device: Any + input_path: Any + preset_table_controller: PresetTableControllerLike + _loaded_input_path: str + + def show_error(self, message: str) -> None: ... + + def _log(self, message: str, level: str) -> None: ... + + def _open_input_path(self, path: str) -> None: ... + + def _set_optional_widget_enabled(self, name: str, enabled: bool) -> None: ... + + +ProfileProvider = Callable[[str], DeviceProfile] + + +def choose_join_preset_paths(parent: QWidget) -> list[Path] | None: + paths, _ = QFileDialog.getOpenFileNames( + parent, + "Choose preset files", + filter="Preset files (*.hlx)", + ) + return [Path(path) for path in paths] if paths else None + + +def choose_join_output_path(parent: QWidget) -> Path | None: + path, _ = QFileDialog.getSaveFileName( + parent, + "Save joined setlist", + filter="Setlist files (*.hls)", + ) + if not path: + return None + output_path = Path(path) + return output_path if output_path.suffix.lower() == ".hls" else output_path.with_suffix(".hls") + + +def choose_split_output_dir(parent: QWidget) -> Path | None: + path = QFileDialog.getExistingDirectory(parent, "Choose split output directory") + return Path(path) if path else None + + +def selected_split_preset_ids( + controller: PresetTableControllerLike, + parse_patch_set: Callable[[str], list[int]], +) -> list[int] | None: + rows = controller.selected_rows_or_all_measurable_rows() + preset_ids = controller.preset_ids_for_rows(rows, parse_patch_set) + return preset_ids or None + + +def original_filename_map( + controller: PresetTableControllerLike, + parse_patch_set: Callable[[str], list[int]], +) -> dict[int, str]: + return controller.original_filename_map(parse_patch_set) + + +def created_files_log(created_paths: Iterable[Path]) -> list[str]: + return [f"Created preset file: {path.resolve()}" for path in created_paths] + + +def join_preset_files(window: FileOperationWindow) -> bool: + parent = cast(QWidget, window) + preset_paths = choose_join_preset_paths(parent) + if not preset_paths: + return False + output_path = choose_join_output_path(parent) + if output_path is None: + return False + + try: + result = file_operations.join_preset_files( + window.device.currentData(), + preset_paths, + output_path, + ) + except Exception as exc: # noqa: BLE001 + window.show_error(str(exc)) + return False + + window._log(f"Joined preset files: {result.output_path.resolve()}", "success") + window._open_input_path(str(result.output_path)) + return True + + +def split_setlist( + window: FileOperationWindow, + *, + get_profile: ProfileProvider, + project_dir: Path, +) -> bool: + input_path = Path(window.input_path.text().strip()) + if not window._loaded_input_path or not input_path: + window.show_error("Open a setlist file before splitting presets") + return False + output_dir = choose_split_output_dir(cast(QWidget, window)) + if output_dir is None: + return False + + try: + profile = get_profile(window.device.currentData()) + handler = profile.create_patch_file_handler(project_dir) + selected_ids = selected_split_preset_ids( + window.preset_table_controller, + handler.parse_patch_set, + ) + filenames = original_filename_map( + window.preset_table_controller, + handler.parse_patch_set, + ) + result = file_operations.split_setlist_file( + window.device.currentData(), + input_path, + output_dir, + selected_ids=selected_ids, + original_filenames=filenames, + ) + except Exception as exc: # noqa: BLE001 + window.show_error(str(exc)) + return False + + for message in created_files_log(result.created_paths): + window._log(message, "success") + window._log( + f"Split setlist into {len(result.created_paths)} preset file(s)", + "success", + ) + return True + + +def apply_file_operation_action_state( + window: FileOperationWindow, + action_state: FileActionState, + *, + get_profile: ProfileProvider, + project_dir: Path, +) -> None: + capabilities = current_file_operation_capabilities( + window, + get_profile=get_profile, + project_dir=project_dir, + ) + window._set_optional_widget_enabled( + "join_preset_files_action", + capabilities.joins_presets_to_setlist and not action_state.workflow_active, + ) + window._set_optional_widget_enabled( + "split_setlist_action", + capabilities.splits_setlist_to_presets + and active_file_kind(window, get_profile=get_profile, project_dir=project_dir) == "setlist" + and not action_state.workflow_active, + ) + + +def current_file_operation_capabilities( + window: FileOperationWindow, + *, + get_profile: ProfileProvider, + project_dir: Path, +) -> FileOperationCapabilities: + device = window.device.currentData() if hasattr(window, "device") else None + if not device: + return FileOperationCapabilities() + try: + profile = get_profile(device) + handler = profile.create_patch_file_handler(project_dir) + return handler.file_capabilities() + except Exception: # noqa: BLE001 + return FileOperationCapabilities() + + +def active_file_kind( + window: FileOperationWindow, + *, + get_profile: ProfileProvider, + project_dir: Path, +) -> str: + path_text = window.input_path.text().strip() if hasattr(window, "input_path") else "" + if not path_text or not window._loaded_input_path: + return "unknown" + try: + profile = get_profile(window.device.currentData()) + handler = profile.create_patch_file_handler(project_dir) + return handler.file_kind(Path(path_text)) + except Exception: # noqa: BLE001 + return "unknown" diff --git a/src/matchpatch/gui/main_window.py b/src/matchpatch/gui/main_window.py index af9e4a7..b229fd2 100644 --- a/src/matchpatch/gui/main_window.py +++ b/src/matchpatch/gui/main_window.py @@ -9,7 +9,7 @@ from collections import deque from contextlib import contextmanager from pathlib import Path -from typing import Any, Callable, Iterator, Sequence +from typing import Any, Callable, Iterator, Sequence, cast from PySide6.QtCore import ( QAbstractAnimation, @@ -70,15 +70,14 @@ write_diagnostic_bundle, ) from matchpatch.gui import diagnostics_panel as gui_diagnostics +from matchpatch.gui import file_operations_workflow, window_layout, window_state from matchpatch.gui import help as gui_help -from matchpatch.gui import window_layout, window_state from matchpatch.gui.advanced_settings import ( GuiSettingsBinder, PresetTableSelectionContext, - append_optional_argument, diagnostic_request, ) -from matchpatch.gui.device_panels import HelixSettingsPanel +from matchpatch.gui.device_panels import create_settings_panel from matchpatch.gui.diagnostics_panel import ( preset_table_selection_preflight_checks, ) @@ -159,6 +158,7 @@ from matchpatch.gui.table_roles import ( IGNORE_REASON_COMPARISON, IGNORE_REASON_PRESET, + IGNORE_REASON_PRESET_REGEX, IGNORE_REASON_REGEX, PRESET_TABLE_ATTENTION_ROLE, PRESET_TABLE_CSV_DELIMITER, @@ -263,7 +263,7 @@ def __init__(self) -> None: self.completed_request: NormalizationRequest | None = None self.completed_result: NormalizationResult | None = None self._last_hardware_diagnostic_checks: list[DiagnosticCheck] = [] - self.device_panels: dict[str, HelixSettingsPanel] = {} + self.device_panels: dict[str, Any] = {} self.snapshot_count = 4 self.preset_snapshot_positions: dict[str, int] = {} self._adjusted_presets: set[str] = set() @@ -322,7 +322,12 @@ def __init__(self) -> None: self._record_off_icon = _record_icon(recording=False) self._ignore_reason_icons = { reason: _ignore_reason_icon(reason) - for reason in (IGNORE_REASON_PRESET, IGNORE_REASON_COMPARISON, IGNORE_REASON_REGEX) + for reason in ( + IGNORE_REASON_PRESET, + IGNORE_REASON_COMPARISON, + IGNORE_REASON_REGEX, + IGNORE_REASON_PRESET_REGEX, + ) } self._startup_resize_done = False self.settings = QSettings() @@ -330,7 +335,6 @@ def __init__(self) -> None: self.input_path = QLineEdit() self.output_path = QLineEdit() self.backend = QComboBox() - self.backend.addItems(["hardware", "loopback", "simulated"]) self.backend.currentTextChanged.connect(self.backend_changed) self._build_toolbar() content = QWidget() @@ -665,10 +669,11 @@ def _build_lufs(self) -> QWidget: def _populate_devices(self) -> None: for profile in list_device_profiles(): self.device.addItem(profile.display_name, profile.name) - if profile.name == "helix": - panel = HelixSettingsPanel(self.backend) + panel = create_settings_panel(profile, self.backend) + if panel is not None: self.device_panels[profile.name] = panel self.device_stack.addWidget(panel) + self.loading_controller.refresh_backend_choices() def browse_input(self) -> None: path, _ = QFileDialog.getOpenFileName( @@ -1714,18 +1719,14 @@ def _current_file_action_state(self) -> FileActionState: has_file=bool(self.input_path.text().strip()), has_loaded_file=has_loaded_file, preset_table_modified=self._preset_table_has_unsaved_changes(), - has_preset_selection=self._current_optimization_preset_selection_state(), + has_preset_selection=hasattr(self, "determine_parameters_button") + and self._has_optimization_preset_selection(), normalization_active=self.worker is not None, hardware_check_active=self.hardware_check_worker is not None, optimization_active=self.optimization_worker is not None, preflight_active=self.preflight_worker is not None, ) - def _current_optimization_preset_selection_state(self) -> bool: - if not hasattr(self, "determine_parameters_button"): - return False - return self._has_optimization_preset_selection() - def _apply_file_action_state(self, action_state: FileActionState) -> None: self._set_optional_widget_enabled("save_action", action_state.save_enabled) self._set_optional_widget_enabled("save_as_action", action_state.save_as_enabled) @@ -1733,6 +1734,12 @@ def _apply_file_action_state(self, action_state: FileActionState) -> None: "save_measurement_action", action_state.save_measurement_enabled, ) + file_operations_workflow.apply_file_operation_action_state( + cast(file_operations_workflow.FileOperationWindow, self), + action_state, + get_profile=get_device_profile, + project_dir=Path(__file__).resolve().parents[3], + ) self._set_optional_widget_enabled("start_button", action_state.start_enabled) self._refresh_determine_parameters_action(action_state) self._set_optional_widget_enabled( @@ -2333,6 +2340,11 @@ def _refresh_all_snapshot_names(self) -> None: return self.preset_table_controller.refresh_all_snapshot_names() + def _refresh_all_preset_names(self) -> None: + if not hasattr(self, "preset_table"): + return + self.preset_table_controller.refresh_all_preset_names() + def _refresh_snapshot_name_cell_widget(self, item: QTableWidgetItem) -> None: refresh_snapshot_name_cell_widget( item, @@ -2485,7 +2497,3 @@ def _reset_preset_table_modified(self) -> None: def _preset_table_content_signature(self) -> tuple[tuple[str, ...], ...]: return self.preset_table_controller.preset_table_content_signature() - - -def _append_optional_argument(argv: list[str], name: str, value: object) -> None: - append_optional_argument(argv, name, value) diff --git a/src/matchpatch/gui/main_window_callbacks.py b/src/matchpatch/gui/main_window_callbacks.py index 86cd476..b9d138f 100644 --- a/src/matchpatch/gui/main_window_callbacks.py +++ b/src/matchpatch/gui/main_window_callbacks.py @@ -141,6 +141,13 @@ def is_ignored_snapshot_name(self, name: str) -> bool: return False return ignore_pattern.search(name) is not None + def is_ignored_preset_name(self, name: str) -> bool: + try: + pattern = re.compile(normalize_regex_pattern(self._window.ignore_preset_regex.text())) + except re.error: + return False + return bool(pattern.pattern) and pattern.search(name) is not None + def refresh_measurement_time_estimate(self) -> None: self._window._refresh_measurement_time_estimate() diff --git a/src/matchpatch/gui/preset_table.py b/src/matchpatch/gui/preset_table.py index 0140cff..4f45c01 100644 --- a/src/matchpatch/gui/preset_table.py +++ b/src/matchpatch/gui/preset_table.py @@ -52,6 +52,7 @@ IGNORE_REASON_COMPARISON, IGNORE_REASON_LABELS, IGNORE_REASON_PRESET, + IGNORE_REASON_PRESET_REGEX, IGNORE_REASON_REGEX, IGNORED_SNAPSHOT_REASONS_ROLE, IGNORED_SNAPSHOT_ROLE, @@ -60,6 +61,7 @@ NORMALIZATION_FOCUS_ROLE, OUTPUT_LEVEL_MAX_DB, OUTPUT_LEVEL_MIN_DB, + PRESET_ORIGINAL_FILENAME_ROLE, PRESET_TABLE_ATTENTION_ROLE, PROCESSED_SNAPSHOT_ROLE, RECORDED_OUTPUT_PATH_ROLE, @@ -138,6 +140,8 @@ def is_solo_snapshot_name(self, name: str) -> bool: ... def is_ignored_snapshot_name(self, name: str) -> bool: ... + def is_ignored_preset_name(self, name: str) -> bool: ... + def refresh_measurement_time_estimate(self) -> None: ... def refresh_preset_item_background(self, item: QTableWidgetItem) -> None: ... @@ -202,6 +206,72 @@ def has_optimization_preset_selection(self) -> bool: return self.row_has_measured_snapshots(0) return any(self.row_has_measured_snapshots(row) for row in self.checked_preset_rows()) + def set_preset_original_filename(self, row: int, filename: str | None) -> None: + item = self.table.item(row, 2) + if item is None: + return + item.setData(PRESET_ORIGINAL_FILENAME_ROLE, filename or None) + + def preset_original_filename(self, row: int) -> str | None: + item = self.table.item(row, 2) + if item is None: + return None + filename = item.data(PRESET_ORIGINAL_FILENAME_ROLE) + return filename if isinstance(filename, str) and filename else None + + def original_filename_map( + self, + parse_patch_set: Callable[[str], list[int]] | None = None, + ) -> dict[int, str]: + filenames: dict[int, str] = {} + for row in range(self.table.rowCount()): + filename = self.preset_original_filename(row) + if filename is None: + continue + preset_id = self._preset_id_for_row(row, parse_patch_set) + if preset_id is not None: + filenames[preset_id] = filename + return filenames + + def preset_ids_for_rows( + self, + rows: list[int], + parse_patch_set: Callable[[str], list[int]], + ) -> list[int]: + preset_ids = [] + for row in rows: + preset_id = self._preset_id_for_row(row, parse_patch_set) + if preset_id is not None: + preset_ids.append(preset_id) + return preset_ids + + def selected_rows_or_all_measurable_rows(self) -> list[int]: + selected_rows = sorted( + {index.row() for index in self.table.selectionModel().selectedIndexes()} + ) + rows = selected_rows or list(range(self.table.rowCount())) + return [row for row in rows if self.row_has_measured_snapshots(row)] + + def _preset_id_for_row( + self, + row: int, + parse_patch_set: Callable[[str], list[int]] | None, + ) -> int | None: + patch_item = self.table.item(row, 1) + patch = patch_item.text().strip() if patch_item is not None else "" + if not patch: + return None + if parse_patch_set is None: + try: + return int(patch) + except ValueError: + return None + try: + preset_ids = parse_patch_set(patch) + except ValueError: + return None + return preset_ids[0] if len(preset_ids) == 1 else None + def preset_selection_state(self) -> _PresetSelectionState: checked_patches: set[str] = set() for row in range(self.table.rowCount()): @@ -759,6 +829,8 @@ def preset_item_changed(self, item: QTableWidgetItem) -> None: elif self.manual_adjustments_enabled() and is_manual_adjustment_column(item.column()): if self._handle_manual_adjustment_item_change(item): self.mark_preset_table_modified() + if item.column() == 2: + self.refresh_preset_name(item.row()) def _consume_attention_marker(self, item: QTableWidgetItem) -> None: if not item.data(PRESET_TABLE_ATTENTION_ROLE): @@ -1195,6 +1267,15 @@ def set_preset_ignore_reason(self, row: int, active: bool) -> None: for snapshot_index in range(self.callbacks.snapshot_count()): self.set_snapshot_ignore_reason(row, snapshot_index, IGNORE_REASON_PRESET, active) + def set_preset_name_ignore_reason(self, row: int, active: bool) -> None: + for snapshot_index in range(self.callbacks.snapshot_count()): + self.set_snapshot_ignore_reason( + row, + snapshot_index, + IGNORE_REASON_PRESET_REGEX, + active, + ) + def set_comparison_ignore_plan( self, changed_by_patch: Mapping[str, tuple[int, ...]], @@ -1283,6 +1364,15 @@ def refresh_all_snapshot_names(self) -> None: for row in range(self.table.rowCount()): self.refresh_snapshot_names(row) + def refresh_preset_name(self, row: int) -> None: + item = self.table.item(row, 2) + name = item.text() if item is not None else "" + self.set_preset_name_ignore_reason(row, self.callbacks.is_ignored_preset_name(name)) + + def refresh_all_preset_names(self) -> None: + for row in range(self.table.rowCount()): + self.refresh_preset_name(row) + def set_snapshot_output_levels( self, row: int, diff --git a/src/matchpatch/gui/table_legend.py b/src/matchpatch/gui/table_legend.py index fc25121..18021df 100644 --- a/src/matchpatch/gui/table_legend.py +++ b/src/matchpatch/gui/table_legend.py @@ -27,6 +27,7 @@ from matchpatch.gui.table_roles import ( IGNORE_REASON_COMPARISON, IGNORE_REASON_PRESET, + IGNORE_REASON_PRESET_REGEX, IGNORE_REASON_REGEX, ) @@ -66,6 +67,10 @@ def build_preset_table_legend_dialog( _legend_ignore_icon_label(IGNORE_REASON_REGEX, dialog, ignore_reason_icons), "Ignored because the snapshot name matches the ignored-snapshot regex.", ), + ( + _legend_ignore_icon_label(IGNORE_REASON_PRESET_REGEX, dialog, ignore_reason_icons), + "Ignored because the preset name matches the ignored-preset regex.", + ), ) for row, (symbol, text) in enumerate(marker_rows): symbol.setFixedSize(30, 24) diff --git a/src/matchpatch/gui/table_roles.py b/src/matchpatch/gui/table_roles.py index 730bfe7..5060245 100644 --- a/src/matchpatch/gui/table_roles.py +++ b/src/matchpatch/gui/table_roles.py @@ -21,6 +21,8 @@ SNAPSHOT_OUTPUT_PATHS_ROLE = Qt.ItemDataRole.UserRole + 11 PROCESSED_SNAPSHOT_ROLE = Qt.ItemDataRole.UserRole + 12 IGNORED_SNAPSHOT_REASONS_ROLE = Qt.ItemDataRole.UserRole + 13 +PRESET_ORIGINAL_FILENAME_ROLE = Qt.ItemDataRole.UserRole + 14 +PRESET_FILE_PATH_ROLE = Qt.ItemDataRole.UserRole + 15 OUTPUT_LEVEL_MIN_DB = -120.0 OUTPUT_LEVEL_MAX_DB = 20.0 ADJUSTMENT_MIN_DB = OUTPUT_LEVEL_MIN_DB - OUTPUT_LEVEL_MAX_DB @@ -29,10 +31,12 @@ IGNORE_REASON_PRESET = "P" IGNORE_REASON_COMPARISON = "C" IGNORE_REASON_REGEX = "R" +IGNORE_REASON_PRESET_REGEX = "N" IGNORE_REASON_LABELS = { IGNORE_REASON_PRESET: "preset unchecked", IGNORE_REASON_COMPARISON: "unchanged compared with previous file", IGNORE_REASON_REGEX: "ignore regex", + IGNORE_REASON_PRESET_REGEX: "preset name ignore regex", } diff --git a/src/matchpatch/gui/window_layout.py b/src/matchpatch/gui/window_layout.py index 52edd64..b42a428 100644 --- a/src/matchpatch/gui/window_layout.py +++ b/src/matchpatch/gui/window_layout.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +from pathlib import Path from typing import Any, Protocol, cast from PySide6.QtCore import ( @@ -48,7 +49,9 @@ QWidget, ) +from matchpatch.devices import get_device_profile from matchpatch.devices.base import NormalizationPolicy +from matchpatch.gui import file_operations_workflow from matchpatch.gui.diagnostics_panel import DiagnosticsPanel from matchpatch.gui.dialogs import ASSETS_DIR from matchpatch.gui.help import HelpId @@ -208,6 +211,36 @@ def build_toolbar(window: MainWindowLike) -> None: window.save_measurement_action.triggered.connect(window.save_measurement_file) toolbar.addAction(window.save_measurement_action) + window.join_preset_files_action = QAction( + window.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogNewFolder), + "Join Preset Files", + object_parent, + ) + window.join_preset_files_action.setToolTip("Join multiple preset files into a setlist.") + window.join_preset_files_action.setProperty("help_id", HelpId.OPEN_FILES) + window.join_preset_files_action.triggered.connect( + lambda: file_operations_workflow.join_preset_files( + cast(file_operations_workflow.FileOperationWindow, window) + ) + ) + toolbar.addAction(window.join_preset_files_action) + + window.split_setlist_action = QAction( + window.style().standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon), + "Split Setlist", + object_parent, + ) + window.split_setlist_action.setToolTip("Export setlist presets as individual preset files.") + window.split_setlist_action.setProperty("help_id", HelpId.OPEN_FILES) + window.split_setlist_action.triggered.connect( + lambda: file_operations_workflow.split_setlist( + cast(file_operations_workflow.FileOperationWindow, window), + get_profile=get_device_profile, + project_dir=Path(__file__).resolve().parents[3], + ) + ) + toolbar.addAction(window.split_setlist_action) + window.normalization_separator_action = toolbar.addSeparator() window.start_button = QToolButton(parent) window.start_button.setIcon(_normalization_icon()) @@ -311,6 +344,8 @@ def build_toolbar(window: MainWindowLike) -> None: window.save_action, window.save_as_action, window.save_measurement_action, + window.join_preset_files_action, + window.split_setlist_action, window.help_action, window.about_action, ): @@ -943,6 +978,14 @@ def build_lufs(window: MainWindowLike) -> QWidget: ) window.ignore_snapshot_regex.textChanged.connect(window._refresh_all_snapshot_names) window.ignore_snapshot_regex.textChanged.connect(window._refresh_measurement_time_estimate) + window.ignore_preset_regex = QLineEdit(NormalizationPolicy().ignore_preset_regex) + window.ignore_preset_regex.setProperty("help_id", HelpId.SNAPSHOTS_SOLOS_IGNORED) + window.ignore_preset_regex.setMaximumWidth(260) + window.ignore_preset_regex.setToolTip( + "Regular expression used to identify presets skipped during normalization." + ) + window.ignore_preset_regex.textChanged.connect(window._refresh_all_preset_names) + window.ignore_preset_regex.textChanged.connect(window._refresh_measurement_time_estimate) snapshot_regexes = QGroupBox("Snapshot name regex") snapshot_regex_layout = QFormLayout(snapshot_regexes) snapshot_regex_layout.setContentsMargins(8, 8, 8, 8) @@ -952,6 +995,10 @@ def build_lufs(window: MainWindowLike) -> QWidget: _label("Ignored", window.ignore_snapshot_regex.toolTip()), window.ignore_snapshot_regex, ) + snapshot_regex_layout.addRow( + _label("Ignored presets", window.ignore_preset_regex.toolTip()), + window.ignore_preset_regex, + ) form.addRow(snapshot_regexes) return content diff --git a/src/matchpatch/gui/window_loading.py b/src/matchpatch/gui/window_loading.py index 0904bfc..029f19d 100644 --- a/src/matchpatch/gui/window_loading.py +++ b/src/matchpatch/gui/window_loading.py @@ -35,6 +35,7 @@ def device_changed(self) -> None: panel = window.device_panels.get(name) if panel is not None: window.device_stack.setCurrentWidget(panel) + self.refresh_backend_choices() window.load_defaults() def backend_changed(self) -> None: @@ -52,6 +53,24 @@ def refresh_backend_tooltip(self) -> None: else: window.device_settings.setToolTip("") + def refresh_backend_choices(self) -> None: + window = self.window + device = window.device.currentData() + if not device: + return + profile = self.get_profile(device) + current = window.backend.currentText() or "hardware" + backends = profile.measurement_backends() + if not backends: + backends = ("hardware",) + signals_blocked = window.backend.blockSignals(True) + try: + window.backend.clear() + window.backend.addItems(list(backends)) + window.backend.setCurrentText(current if current in backends else backends[0]) + finally: + window.backend.blockSignals(signals_blocked) + def load_defaults(self) -> None: window = self.window if not window.device.currentData(): @@ -86,6 +105,7 @@ def load_defaults(self) -> None: window.solo_gain_bump_db.setText(str(args.policy.solo_gain_bump_db)) window.solo_regex.setText(args.policy.solo_regex) window.ignore_snapshot_regex.setText(args.policy.ignore_snapshot_regex) + window.ignore_preset_regex.setText(args.policy.ignore_preset_regex) window.analysis_window.setText(str(args.analysis_options.window_seconds)) window.analysis_interval.setText(str(args.analysis_options.interval_seconds)) window._optimization_stability_runs = int( @@ -237,7 +257,12 @@ def _load_setlist_assignments(self, path: Path) -> None: window.preset_table.setItem(row, 0, selected) window.preset_table.setItem(row, 1, QTableWidgetItem(assignment.device_patch)) window.preset_table.setItem(row, 2, QTableWidgetItem(assignment.name)) + window.preset_table_controller.set_preset_original_filename( + row, + getattr(assignment, "original_filename", None), + ) window.preset_table_controller.clear_preset_adjustments(row) + window.preset_table_controller.refresh_preset_name(row) window.preset_table_controller.set_snapshot_names( row, assignment.snapshot_names ) @@ -289,7 +314,9 @@ def populate_single_preset_table(self, path: Path, assignment: object | None = N window.preset_table.setItem(0, 0, selected) window.preset_table.setItem(0, 1, QTableWidgetItem()) window.preset_table.setItem(0, 2, QTableWidgetItem(preset_name)) + window.preset_table_controller.set_preset_original_filename(0, path.name) window.preset_table_controller.clear_preset_adjustments(0) + window.preset_table_controller.refresh_preset_name(0) window.preset_table_controller.set_snapshot_names(0, snapshot_names) window.preset_table_controller.set_snapshot_output_levels( 0, snapshot_output_levels, snapshot_output_paths diff --git a/src/matchpatch/measure.py b/src/matchpatch/measure.py index 42657a7..f7785f5 100644 --- a/src/matchpatch/measure.py +++ b/src/matchpatch/measure.py @@ -747,6 +747,7 @@ def resolve_steering_options( def measure(args: argparse.Namespace) -> None: profile = get_device_profile(args.device) + _raise_unimplemented_backend(args.backend) defaults = profile.default_audio_routing() sample_rate = args.sample_rate if args.sample_rate is not None else defaults.sample_rate on_progress = getattr(args, "on_progress", None) @@ -853,6 +854,7 @@ def measure(args: argparse.Namespace) -> None: def optimize_measurement_timing(args: argparse.Namespace) -> None: profile = get_device_profile(args.device) + _raise_unimplemented_backend(args.backend) defaults = profile.default_audio_routing() sample_rate = args.sample_rate if args.sample_rate is not None else defaults.sample_rate reference = load_reference_audio(Path(args.reference_di), sample_rate) @@ -1232,7 +1234,7 @@ def add_hardware_arguments(parser: argparse.ArgumentParser) -> None: def apply_config(args: argparse.Namespace) -> argparse.Namespace: config = load_config(args.config) profile = get_device_profile(args.device) - _apply_backend_config(args, config) + _apply_backend_config(args, config, profile) _apply_audio_config(args, config, profile) _apply_timing_config(args, config, profile) _apply_optimization_config(args, config) @@ -1242,10 +1244,28 @@ def apply_config(args: argparse.Namespace) -> argparse.Namespace: return args -def _apply_backend_config(args: argparse.Namespace, config: Config) -> None: +def _apply_backend_config( + args: argparse.Namespace, + config: Config, + profile: DeviceProfile, +) -> None: args.backend = getattr(args, "backend", None) or config_value( config, "normalize", "backend", default="hardware" ) + if args.backend == "helix": + args.backend = "hardware" + supported_backends = profile.measurement_backends() + if args.backend not in supported_backends: + supported = ", ".join(supported_backends) + raise ValueError( + f"Backend {args.backend!r} is not supported by {profile.display_name}; " + f"choose one of: {supported}" + ) + + +def _raise_unimplemented_backend(backend: str) -> None: + if backend == "offline": + raise NotImplementedError("The offline measurement backend is not implemented yet") def _apply_audio_config( @@ -1415,7 +1435,6 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: measure_parser.add_argument("--reference-di", required=True) measure_parser.add_argument( "--backend", - choices=["hardware", "loopback", "simulated", "helix"], help="Use hardware, empty-patch loopback, or a stateful processor simulation", ) measure_parser.add_argument( @@ -1446,7 +1465,6 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: optimize_parser.add_argument("--reference-di", required=True) optimize_parser.add_argument( "--backend", - choices=["hardware", "loopback", "simulated", "helix"], ) optimize_parser.add_argument("--stability-runs", type=int, default=3) optimize_parser.add_argument("--termination-tolerance", type=float, default=10.0) diff --git a/src/matchpatch/normalize.py b/src/matchpatch/normalize.py index 7941d16..1db58b0 100644 --- a/src/matchpatch/normalize.py +++ b/src/matchpatch/normalize.py @@ -121,6 +121,18 @@ def _normalization_policy(config: Config, args: argparse.Namespace) -> Normaliza ), ) ), + ignore_preset_regex=normalize_regex_pattern( + cast( + str, + prefer( + args.ignore_preset_regex, + config, + "policy", + "ignore_preset_regex", + default=NormalizationPolicy().ignore_preset_regex, + ), + ) + ), solo_gain_bump_db=cast( float, prefer(args.solo_gain_bump_db, config, "policy", "solo_gain_bump_db", default=3.0), @@ -146,6 +158,22 @@ def _normalization_policy(config: Config, args: argparse.Namespace) -> Normaliza re.compile(policy.ignore_snapshot_regex) except re.error as exc: raise ValueError(f"Invalid ignore snapshot regex: {exc}") from exc + try: + re.compile(policy.ignore_preset_regex) + except re.error as exc: + raise ValueError(f"Invalid ignore preset regex: {exc}") from exc + + supported_backends = ( + profile.measurement_backends() + if hasattr(profile, "measurement_backends") + else ("hardware", "loopback", "simulated") + ) + if args.backend not in supported_backends: + supported = ", ".join(supported_backends) + raise ValueError( + f"Backend {args.backend!r} is not supported by {profile.display_name}; " + f"choose one of: {supported}" + ) return policy @@ -927,12 +955,10 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument("--target-lufs", type=float) parser.add_argument("--solo-regex") parser.add_argument("--ignore-snapshot-regex") + parser.add_argument("--ignore-preset-regex") parser.add_argument("--solo-gain-bump-db", type=float) parser.add_argument("--snapshot-count", type=int) - parser.add_argument( - "--backend", - choices=["hardware", "loopback", "simulated"], - ) + parser.add_argument("--backend") parser.add_argument( "--windows-python", ) diff --git a/src/matchpatch/preflight.py b/src/matchpatch/preflight.py index 17bffb9..32a31f6 100644 --- a/src/matchpatch/preflight.py +++ b/src/matchpatch/preflight.py @@ -55,7 +55,7 @@ def run_preflight_checks( checks.append(_output_mode_check(handler, request)) checks.append(_reference_di_check(request.reference_di)) checks.append(_custom_adjustments_check(request)) - checks.append(_backend_check(request.backend)) + checks.append(_backend_check(request.backend, profile)) checks.extend( _backend_specific_checks( request, @@ -183,13 +183,19 @@ def _custom_adjustments_check(request: NormalizationRequest) -> DiagnosticCheck: return DiagnosticCheck("custom_adjustments", "pass", "Custom adjustments file parses") -def _backend_check(backend: str) -> DiagnosticCheck: - if backend in {"hardware", "loopback", "simulated"}: +def _backend_check(backend: str, profile: DeviceProfile | None) -> DiagnosticCheck: + supported_backends = ( + profile.measurement_backends() + if profile is not None and hasattr(profile, "measurement_backends") + else ("hardware", "loopback", "simulated") + ) + if backend in supported_backends: return DiagnosticCheck("backend", "pass", f"Backend is valid: {backend}") + supported = ", ".join(supported_backends) return DiagnosticCheck( "backend", "fail", - f"Backend must be one of hardware, loopback, or simulated: {backend}", + f"Backend must be one of {supported}: {backend}", ) diff --git a/tests/test_cli.py b/tests/test_cli.py index a7223db..74dac19 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,8 +2,10 @@ import sys import tomllib +from pathlib import Path from matchpatch import cli +from matchpatch.file_operations import JoinPresetFilesResult, SplitSetlistFileResult def test_devices_command_lists_helix(monkeypatch, capsys) -> None: @@ -40,6 +42,83 @@ def test_measure_command_is_dispatched(monkeypatch) -> None: assert calls == [["check-hardware", "--device", "helix"]] +def test_files_join_command_dispatches_file_operation(monkeypatch, capsys) -> None: + from matchpatch import file_operations + + calls = [] + + def fake_join_preset_files(device, preset_paths, output_path, *, slot_ids=None): + calls.append((device, preset_paths, output_path, slot_ids)) + return JoinPresetFilesResult(output_path=output_path) + + monkeypatch.setattr(file_operations, "join_preset_files", fake_join_preset_files) + monkeypatch.setattr( + sys, + "argv", + [ + "matchpatch", + "files", + "join", + "--device", + "helix", + "--output", + "out.hls", + "--preset-set", + "01A,02B", + "preset1.hlx", + "preset2.hlx", + ], + ) + + cli.main() + + assert calls == [ + ( + "helix", + [Path("preset1.hlx"), Path("preset2.hlx")], + Path("out.hls"), + [1, 6], + ) + ] + assert "Joined 2 preset files into out.hls" in capsys.readouterr().out + + +def test_files_split_command_dispatches_file_operation(monkeypatch, capsys) -> None: + from matchpatch import file_operations + + calls = [] + + def fake_split_setlist_file(device, input_path, output_dir, *, selected_ids=None): + calls.append((device, input_path, output_dir, selected_ids)) + return SplitSetlistFileResult(created_paths=[output_dir / "Lead.hlx"]) + + monkeypatch.setattr(file_operations, "split_setlist_file", fake_split_setlist_file) + monkeypatch.setattr( + sys, + "argv", + [ + "matchpatch", + "files", + "split", + "--device", + "helix", + "--input", + "setlist.hls", + "--output-dir", + "presets", + "--preset-set", + "01A", + ], + ) + + cli.main() + + assert calls == [("helix", Path("setlist.hls"), Path("presets"), [1])] + output = capsys.readouterr().out + assert "Split 1 preset files into presets" in output + assert "presets/Lead.hlx" in output + + def test_environment_command_prints_runtime(monkeypatch, capsys) -> None: monkeypatch.setattr(sys, "argv", ["matchpatch", "--environment"]) diff --git a/tests/test_devices.py b/tests/test_devices.py index 0f272d9..8c628b4 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -4,8 +4,18 @@ import pytest -from matchpatch.devices import get_device_profile -from matchpatch.devices.base import DeviceController, validate_snapshot_count +from matchpatch import file_operations +from matchpatch.devices import get_device_profile, registry +from matchpatch.devices.base import ( + AudioRouting, + DeviceController, + DeviceProfile, + FileOperationCapabilities, + MeasurementBackendCapabilities, + PatchFileHandler, + SteeringOptions, + validate_snapshot_count, +) def test_helix_profile_defines_processor_boundaries() -> None: @@ -21,6 +31,17 @@ def test_helix_profile_defines_processor_boundaries() -> None: assert steering.measurement_wait_seconds == 0.1 assert profile.snapshot_count == 4 assert profile.max_snapshot_count == 8 + assert profile.terminology().device == "Helix" + assert profile.file_capabilities().reads_setlist_files + assert profile.file_capabilities().reads_preset_files + assert profile.file_capabilities().joins_presets_to_setlist + assert profile.file_capabilities().splits_setlist_to_presets + assert profile.file_capabilities().exports_selected_setlist_slots + assert profile.measurement_backends() == ("hardware", "loopback", "simulated") + assert profile.naming_rules().preset_name_max_length == 16 + assert profile.naming_rules().snapshot_name_max_length == 10 + assert handler.file_kind(Path("tone.hlx")) == "preset" + assert handler.file_kind(Path("setlist.hls")) == "setlist" assert handler.parse_patch_set("01A,02B") == [1, 6] assert handler.format_patch_id(6) == "02B" assert profile.format_patch_id(7) == "02C" @@ -49,3 +70,225 @@ def test_base_controller_context_manager_returns_and_closes_cleanly() -> None: assert controller.__enter__() is controller assert controller.__exit__(None, None, None) is None + + +class MinimalHandler(PatchFileHandler): + def validate_input(self, input_path: Path) -> None: + return None + + def validate_output(self, input_path: Path, output_path: Path) -> None: + return None + + def list_assignments(self, input_path: Path): + return [] + + def parse_patch_set(self, value: str) -> list[int]: + return [] + + def select_preset_ids(self, input_path, assignments, requested_ids): + return [] + + def format_patch_id(self, preset_id: int) -> str: + return str(preset_id) + + def create_measurement_file(self, input_path: Path, output_path: Path) -> None: + return None + + def apply_analysis_csv(self, *args) -> None: + return None + + def automation_output_path(self, input_path: Path, postfix: str) -> Path: + return input_path + + +class FileOperationsHandler(MinimalHandler): + def __init__(self) -> None: + self.join_calls = [] + self.split_calls = [] + + def file_capabilities(self) -> FileOperationCapabilities: + return FileOperationCapabilities( + joins_presets_to_setlist=True, + splits_setlist_to_presets=True, + exports_selected_setlist_slots=True, + ) + + def file_kind(self, path: Path): + if path.suffix == ".preset": + return "preset" + if path.suffix == ".setlist": + return "setlist" + return "unknown" + + def join_preset_files(self, preset_paths, output_path, *, slot_ids=None) -> None: + self.join_calls.append((preset_paths, output_path, slot_ids)) + + def split_setlist_file( + self, + input_path, + output_dir, + *, + selected_ids=None, + original_filenames=None, + ): + self.split_calls.append((input_path, output_dir, selected_ids, original_filenames)) + return [output_dir / "one.preset"] + + +class PluginProfile(DeviceProfile): + name = "plugin-device" + display_name = "Plugin Device" + + def create_patch_file_handler(self, project_dir: Path) -> PatchFileHandler: + return MinimalHandler() + + def default_audio_routing(self) -> AudioRouting: + return AudioRouting(None, 48000, (1, 2), (1, 2)) + + def default_steering_options(self) -> SteeringOptions: + return SteeringOptions(None, 0, 0.0, 0.0, 0.0) + + def create_controller(self, options: SteeringOptions) -> DeviceController: + return EmptyController() + + +class FileOperationsProfile(PluginProfile): + name = "files-device" + display_name = "Files Device" + + def __init__(self, handler: PatchFileHandler) -> None: + self.handler = handler + + def create_patch_file_handler(self, project_dir: Path) -> PatchFileHandler: + return self.handler + + +class OfflineOnlyProfile(PluginProfile): + name = "offline-device" + display_name = "Offline Device" + + def measurement_backends(self) -> tuple[str, ...]: + return MeasurementBackendCapabilities( + hardware=False, + loopback=False, + simulated=False, + offline=True, + ).names() + + +class EntryPoint: + def __init__(self, name: str, value) -> None: + self.name = name + self.value = value + + def load(self): + if isinstance(self.value, BaseException): + raise self.value + return self.value + + +class EntryPoints(list): + def select(self, *, group: str): + assert group == registry.ENTRY_POINT_GROUP + return self + + +def test_default_device_capabilities_are_backward_compatible() -> None: + profile = PluginProfile() + handler = profile.create_patch_file_handler(Path(".")) + + assert profile.terminology().preset == "preset" + assert profile.file_capabilities().reads_preset_files is False + assert profile.measurement_backends() == ("hardware", "loopback", "simulated") + assert profile.naming_rules().preset_name_max_length is None + assert handler.file_capabilities().reads_setlist_files is False + assert handler.file_kind(Path("anything")) == "unknown" + + +def test_file_operations_join_validates_capabilities_and_delegates(tmp_path) -> None: + handler = FileOperationsHandler() + + result = file_operations.join_preset_files( + "files-device", + [tmp_path / "one.preset"], + tmp_path / "joined.setlist", + slot_ids=[1], + get_profile=lambda device: FileOperationsProfile(handler), + ) + + assert result.output_path == tmp_path / "joined.setlist" + assert handler.join_calls == [([tmp_path / "one.preset"], tmp_path / "joined.setlist", [1])] + + with pytest.raises(ValueError, match="does not support joining"): + file_operations.join_preset_files( + "plugin-device", + [tmp_path / "one.preset"], + tmp_path / "joined.setlist", + get_profile=lambda device: PluginProfile(), + ) + + +def test_file_operations_split_validates_capabilities_and_delegates(tmp_path) -> None: + handler = FileOperationsHandler() + + result = file_operations.split_setlist_file( + "files-device", + tmp_path / "joined.setlist", + tmp_path / "presets", + selected_ids=[1], + original_filenames={1: "one.preset"}, + get_profile=lambda device: FileOperationsProfile(handler), + ) + + assert result.created_paths == [tmp_path / "presets" / "one.preset"] + assert handler.split_calls == [ + (tmp_path / "joined.setlist", tmp_path / "presets", [1], {1: "one.preset"}) + ] + + with pytest.raises(ValueError, match="does not support splitting"): + file_operations.split_setlist_file( + "plugin-device", + tmp_path / "joined.setlist", + tmp_path / "presets", + get_profile=lambda device: PluginProfile(), + ) + + +def test_plugin_device_profiles_are_discovered(monkeypatch) -> None: + monkeypatch.setattr( + registry.metadata, + "entry_points", + lambda: EntryPoints([EntryPoint("plugin", PluginProfile)]), + ) + + assert get_device_profile("plugin-device").display_name == "Plugin Device" + assert [profile.name for profile in registry.list_device_profiles()] == [ + "helix", + "plugin-device", + ] + + +def test_plugin_load_errors_are_reported_for_explicit_lookup(monkeypatch) -> None: + monkeypatch.setattr( + registry.metadata, + "entry_points", + lambda: EntryPoints([EntryPoint("broken", RuntimeError("boom"))]), + ) + + assert [profile.name for profile in registry.list_device_profiles()] == ["helix"] + assert registry.plugin_load_errors() == {"broken": "boom"} + with pytest.raises(ValueError, match="Device plugin load errors: broken: boom"): + get_device_profile("missing") + + +def test_duplicate_plugin_device_names_are_reported(monkeypatch) -> None: + class DuplicateProfile(PluginProfile): + name = "helix" + + monkeypatch.setattr( + registry.metadata, + "entry_points", + lambda: EntryPoints([EntryPoint("duplicate", DuplicateProfile())]), + ) + + assert registry.plugin_load_errors() == {"duplicate": "duplicate device profile name 'helix'"} diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 1c844ed..d2ed856 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -59,6 +59,7 @@ def test_effective_config_from_request_is_json_ready(tmp_path: Path) -> None: snapshot_count=3, solo_regex="solo", ignore_snapshot_regex="skip", + ignore_preset_regex="empty", solo_gain_bump_db=2.0, ), analysis_options=AnalysisOptions( @@ -75,6 +76,7 @@ def test_effective_config_from_request_is_json_ready(tmp_path: Path) -> None: assert payload["custom_adjustments_path"] == str(tmp_path / "custom.csv") assert payload["policy"]["snapshot_count"] == 3 assert payload["policy"]["solo_regex"] == "solo" + assert payload["policy"]["ignore_preset_regex"] == "empty" assert payload["analysis_options"]["window_seconds"] == 2.5 assert payload["snapshot_plan"] == [{"patch": "01A", "snapshots": [1, 3]}] json.dumps(payload) diff --git a/tests/test_gui.py b/tests/test_gui.py index 210d3f0..7b706b5 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -41,7 +41,7 @@ from matchpatch.gui.preset_table import ( ContentHeightTableWidget, ) -from matchpatch.gui.table_roles import PRESET_TABLE_ATTENTION_ROLE +from matchpatch.gui.table_roles import PRESET_ORIGINAL_FILENAME_ROLE, PRESET_TABLE_ATTENTION_ROLE from matchpatch.gui.worker import NormalizationWorker from matchpatch.normalize import DEFAULT_REFERENCE_DI, DEFAULT_WINDOWS_PYTHON from matchpatch.progress import ProgressEvent @@ -230,6 +230,7 @@ def test_main_window_starts_with_registry_device_and_hardware(app) -> None: assert not window.advanced_tabs.widget(2).isAncestorOf(window.custom_adjustments_path) assert not window.advanced_tabs.widget(2).isAncestorOf(window.reference_di) assert not window.advanced_tabs.widget(2).isAncestorOf(window.keep_temp) + assert window.advanced_tabs.widget(2).isAncestorOf(window.measurement_parameter_preset) assert window.advanced_tabs.widget(2).isAncestorOf(window.apply_measurement_parameters_button) assert window.advanced_tabs.widget(2).isAncestorOf(window.pre_roll) @@ -354,11 +355,13 @@ def test_main_window_starts_with_registry_device_and_hardware(app) -> None: "Save", "Save As", "Save Measurement File", + "Join Preset Files", + "Split Setlist", "Help", "About", ] assert toolbar.actions().index(window.normalization_separator_action) == ( - toolbar.actions().index(window.save_measurement_action) + 1 + toolbar.actions().index(window.split_setlist_action) + 1 ) assert toolbar.actions().index(window.save_measurement_action) == ( toolbar.actions().index(window.save_as_action) + 1 @@ -514,6 +517,48 @@ def test_main_window_starts_with_registry_device_and_hardware(app) -> None: window.close() +def test_main_window_lists_device_without_settings_panel(monkeypatch, app) -> None: + helix = main_window.get_device_profile("helix") + fake = SimpleNamespace( + name="fake", + display_name="Fake Device", + measurement_backends=lambda: ("offline",), + default_audio_routing=lambda: SimpleNamespace( + device=None, + sample_rate=48000, + input_mapping="1,2", + output_mapping="1,2", + ), + default_steering_options=lambda: SimpleNamespace( + output=None, + channel=0, + preset_wait_seconds=0.0, + snapshot_wait_seconds=0.0, + measurement_wait_seconds=0.0, + ), + ) + monkeypatch.setattr(main_window, "list_device_profiles", lambda: [helix, fake]) + monkeypatch.setattr( + "matchpatch.normalize.get_device_profile", + lambda name: fake if name == "fake" else helix, + ) + + window = MainWindow() + window.loading_controller.get_profile = lambda name: fake if name == "fake" else helix + fake_index = window.device.findData("fake") + + assert fake_index >= 0 + assert "fake" not in window.device_panels + + window.device.setCurrentIndex(fake_index) + app.processEvents() + + assert window.device.currentData() == "fake" + assert window.backend.currentText() == "offline" + + window.close() + + def test_initial_window_size_avoids_scrollbar_for_collapsed_layout(app) -> None: window = MainWindow() window.show() @@ -722,6 +767,7 @@ def test_single_preset_load_displays_presets_panel_with_instruction_label(monkey assert window.preset_table.rowCount() == 1 assert window.preset_table.item(0, 1).text() == "" assert window.preset_table.item(0, 2).text() == "Lead" + assert window.preset_table.item(0, 2).data(PRESET_ORIGINAL_FILENAME_ROLE) == "example.hlx" assert window.preset_table.item(0, 3).text() == "Clean" assert window.preset_table.item(0, 4).text() == "0.0" assert window.preset_table.item(0, 6).text() == "Solo" @@ -785,7 +831,14 @@ def validate_input(path): @staticmethod def list_assignments(path): - return [] + return [ + SimpleNamespace( + device_patch="01A", + name="Lead", + snapshot_names=("Verse",), + original_filename="lead.hlx", + ) + ] @staticmethod def metadata(path): @@ -804,6 +857,7 @@ def create_patch_file_handler(root): assert not window.preset_advanced_splitter.isHidden() assert not window.preset_table.isHidden() assert not window.preset_table.isColumnHidden(0) + assert window.preset_table.item(0, 2).data(PRESET_ORIGINAL_FILENAME_ROLE) == "lead.hlx" assert not window.preset_csv_controls.isHidden() assert window.preset_hint.text() == "Select the presets to normalize." assert '"file_type": "hls"' in window.metadata_text.toPlainText() diff --git a/tests/test_gui_advanced_settings.py b/tests/test_gui_advanced_settings.py index 6d16a5a..fc987f3 100644 --- a/tests/test_gui_advanced_settings.py +++ b/tests/test_gui_advanced_settings.py @@ -197,6 +197,7 @@ def _state(**overrides: object) -> GuiSettingsState: "solo_gain_bump_db": "2.5", "solo_regex": "solo", "ignore_snapshot_regex": "", + "ignore_preset_regex": "", "snapshot_count": 4, "keep_temp": False, "device_arguments": ("--audio-device", "Helix"), diff --git a/tests/test_gui_preset_table.py b/tests/test_gui_preset_table.py index 0780a31..57413ee 100644 --- a/tests/test_gui_preset_table.py +++ b/tests/test_gui_preset_table.py @@ -122,10 +122,12 @@ IGNORED_SNAPSHOT_ROLE, IGNORE_REASON_COMPARISON, IGNORE_REASON_PRESET, + IGNORE_REASON_PRESET_REGEX, IGNORE_REASON_REGEX, MANUAL_NAME_MODIFIED_ROLE, MEASURED_ADJUSTMENT_ROLE, NORMALIZATION_FOCUS_ROLE, + PRESET_ORIGINAL_FILENAME_ROLE, PROCESSED_SNAPSHOT_ROLE, RECORDED_OUTPUT_PATH_ROLE, ) @@ -359,6 +361,31 @@ def test_preset_table_controller_builds_adjustment_payload(app) -> None: table.close() +def test_preset_table_controller_tracks_original_filenames(app) -> None: + table, callbacks = _controller_table() + controller = PresetTableController(table, callbacks, set()) + + controller.set_preset_original_filename(0, "lead.hlx") + controller.set_preset_original_filename(1, "") + + assert controller.preset_original_filename(0) == "lead.hlx" + assert controller.preset_original_filename(1) is None + assert table.item(0, 2).data(PRESET_ORIGINAL_FILENAME_ROLE) == "lead.hlx" + assert controller.original_filename_map(lambda patch: {"01A": [1], "02B": [6]}[patch]) == { + 1: "lead.hlx" + } + + table.item(1, 1).setText("06B") + controller.set_preset_original_filename(1, "rhythm.hlx") + assert controller.preset_ids_for_rows( + [0, 1], lambda patch: {"01A": [1], "06B": [22]}[patch] + ) == [ + 1, + 22, + ] + table.close() + + def test_preset_table_controller_lists_patch_ids_for_save_workflow(app) -> None: table, callbacks = _controller_table() controller = PresetTableController(table, callbacks, set()) @@ -807,6 +834,48 @@ def test_ignore_snapshot_regex_marks_and_skips_default_snapshots(monkeypatch, ap window.close() +def test_ignore_preset_regex_marks_and_skips_matching_preset(monkeypatch, app) -> None: + window = MainWindow() + _mock_single_hlx_handler( + monkeypatch, + name="Init Tone", + snapshot_names=("Verse", "Chorus"), + snapshot_output_levels=((0.0,), (0.0,)), + ) + window.ignore_preset_regex.setText("^Init") + window.input_path.setText("/tmp/example.hlx") + window.load_assignments() + window.preset_table.item(0, 1).setText("01A") + selected = window.preset_table.item(0, 0) + assert selected is not None + selected.setCheckState(Qt.CheckState.Checked) + + for snapshot in range(2): + item = window.preset_table.item(0, snapshot_name_column(snapshot)) + assert item.data(IGNORED_SNAPSHOT_REASONS_ROLE) == (IGNORE_REASON_PRESET_REGEX,) + + assert window._row_measured_snapshot_indexes(0) == () + + selected.setCheckState(Qt.CheckState.Unchecked) + name = window.preset_table.item(0, snapshot_name_column(0)) + assert name.data(IGNORED_SNAPSHOT_REASONS_ROLE) == ( + IGNORE_REASON_PRESET_REGEX, + IGNORE_REASON_PRESET, + ) + + window.ignore_preset_regex.setText("^Other") + + assert name.data(IGNORED_SNAPSHOT_REASONS_ROLE) == (IGNORE_REASON_PRESET,) + assert window._row_measured_snapshot_indexes(0) == () + + selected.setCheckState(Qt.CheckState.Checked) + + assert name.data(IGNORED_SNAPSHOT_ROLE) is None + assert window._row_measured_snapshot_indexes(0) == (1, 2, 3, 4) + + window.close() + + def test_snapshot_ignore_reasons_stack_and_clear_independently(app) -> None: window = MainWindow() window.snapshot_count_input.setValue(1) diff --git a/tests/test_gui_save_workflow.py b/tests/test_gui_save_workflow.py index 0ac1936..ebcb317 100644 --- a/tests/test_gui_save_workflow.py +++ b/tests/test_gui_save_workflow.py @@ -53,10 +53,15 @@ ) from shiboken6 import isValid -from matchpatch.devices.base import NormalizationPolicy, PatchFileAdjustments +from matchpatch.devices.base import ( + FileOperationCapabilities, + NormalizationPolicy, + PatchFileAdjustments, +) from matchpatch.diagnostics import DiagnosticCheck from matchpatch.gui import ( advanced_settings, + file_operations_workflow, icons, loudness_widgets, main_window, @@ -359,6 +364,187 @@ def test_save_measurement_file_honors_cancelled_overwrite(tmp_path) -> None: assert handler.measurements == [] +class _FileOperationHandler: + def __init__( + self, + *, + capabilities: FileOperationCapabilities, + file_kind: str = "setlist", + ) -> None: + self._capabilities = capabilities + self._file_kind = file_kind + + def file_capabilities(self) -> FileOperationCapabilities: + return self._capabilities + + def file_kind(self, path: Path) -> str: + return self._file_kind + + @staticmethod + def parse_patch_set(patch: str) -> list[int]: + return {"01A": [1], "02B": [6], "03C": [11]}[patch] + + +class _FileOperationProfile: + def __init__(self, handler: _FileOperationHandler) -> None: + self.handler = handler + + def create_patch_file_handler(self, project_dir: Path) -> _FileOperationHandler: + return self.handler + + +def test_file_operation_actions_are_gated_by_capabilities_and_active_kind( + monkeypatch, + app, + tmp_path, +) -> None: + window = MainWindow() + input_path = tmp_path / "input.hls" + input_path.write_text("{}", encoding="utf-8") + window.input_path.setText(str(input_path)) + window._loaded_input_path = str(input_path) + capabilities = FileOperationCapabilities( + joins_presets_to_setlist=True, + splits_setlist_to_presets=True, + ) + handler = _FileOperationHandler(capabilities=capabilities, file_kind="setlist") + monkeypatch.setattr( + main_window, "get_device_profile", lambda device: _FileOperationProfile(handler) + ) + + window._refresh_file_actions() + + assert window.join_preset_files_action.isEnabled() + assert window.split_setlist_action.isEnabled() + + handler._file_kind = "preset" + window._refresh_file_actions() + + assert window.join_preset_files_action.isEnabled() + assert not window.split_setlist_action.isEnabled() + + handler._capabilities = FileOperationCapabilities() + window._refresh_file_actions() + + assert not window.join_preset_files_action.isEnabled() + assert not window.split_setlist_action.isEnabled() + window.close() + + +def test_join_preset_files_action_calls_workflow_and_opens_output( + monkeypatch, + app, + tmp_path, +) -> None: + window = MainWindow() + preset_paths = [tmp_path / "lead.hlx", tmp_path / "rhythm.hlx"] + output_path = tmp_path / "joined.hls" + opened_paths: list[str] = [] + calls = [] + monkeypatch.setattr( + file_operations_workflow, "choose_join_preset_paths", lambda parent: preset_paths + ) + monkeypatch.setattr( + file_operations_workflow, "choose_join_output_path", lambda parent: output_path + ) + monkeypatch.setattr(window, "_open_input_path", opened_paths.append) + + def join_preset_files(device, selected_preset_paths, selected_output_path): + calls.append((device, selected_preset_paths, selected_output_path)) + return file_operations_workflow.file_operations.JoinPresetFilesResult( + output_path=selected_output_path + ) + + monkeypatch.setattr( + file_operations_workflow.file_operations, + "join_preset_files", + join_preset_files, + ) + + assert file_operations_workflow.join_preset_files(window) + + assert calls == [("helix", preset_paths, output_path)] + assert opened_paths == [str(output_path)] + window.close() + + +def test_split_setlist_action_passes_selected_ids_and_original_filename_map( + monkeypatch, + app, + tmp_path, +) -> None: + window = MainWindow() + input_path = tmp_path / "input.hls" + output_dir = tmp_path / "split" + created_paths = [output_dir / "rhythm.hlx"] + window.input_path.setText(str(input_path)) + window._loaded_input_path = str(input_path) + window.preset_table.setRowCount(0) + for row, patch in enumerate(("01A", "02B", "03C")): + window.preset_table.insertRow(row) + window.preset_table.setItem(row, 1, QTableWidgetItem(patch)) + window.preset_table.setItem(row, 2, QTableWidgetItem(f"Preset {patch}")) + window.preset_table.setItem(row, 3, QTableWidgetItem("Snap")) + window.preset_table_controller.set_preset_original_filename(0, "lead.hlx") + window.preset_table_controller.set_preset_original_filename(1, "rhythm.hlx") + window.preset_table.selectRow(1) + handler = _FileOperationHandler( + capabilities=FileOperationCapabilities(splits_setlist_to_presets=True), + ) + monkeypatch.setattr( + main_window, "get_device_profile", lambda device: _FileOperationProfile(handler) + ) + monkeypatch.setattr( + file_operations_workflow, "choose_split_output_dir", lambda parent: output_dir + ) + calls = [] + + def split_setlist_file( + device, + selected_input_path, + selected_output_dir, + *, + selected_ids=None, + original_filenames=None, + ): + calls.append( + ( + device, + selected_input_path, + selected_output_dir, + selected_ids, + original_filenames, + ) + ) + return file_operations_workflow.file_operations.SplitSetlistFileResult( + created_paths=created_paths + ) + + monkeypatch.setattr( + file_operations_workflow.file_operations, + "split_setlist_file", + split_setlist_file, + ) + + assert file_operations_workflow.split_setlist( + window, + get_profile=main_window.get_device_profile, + project_dir=Path(main_window.__file__).resolve().parents[3], + ) + + assert calls == [ + ( + "helix", + input_path, + output_dir, + [6], + {1: "lead.hlx", 6: "rhythm.hlx"}, + ) + ] + assert any(str(created_paths[0].resolve()) in entry[2] for entry in window.log_entries) + window.close() + + def test_completion_enables_save_and_shows_success_popup(tmp_path, monkeypatch, app) -> None: window = MainWindow() window.input_path.setText(str(tmp_path / "input.hls")) diff --git a/tests/test_helix.py b/tests/test_helix.py index 45999f7..3287cfe 100644 --- a/tests/test_helix.py +++ b/tests/test_helix.py @@ -12,6 +12,7 @@ from hypothesis import given from hypothesis import strategies as st +import matchpatch.devices.helix as helix_module from matchpatch.devices.base import PatchAssignment, PatchFileAdjustments, SteeringOptions from matchpatch.devices.helix import ( HelixDeviceProfile, @@ -51,6 +52,22 @@ def test_patch_file_validation_and_automation_path(tmp_path) -> None: ) +def test_helix_file_capabilities_and_kinds_are_advertised(tmp_path) -> None: + handler = make_handler(tmp_path) + capabilities = handler.file_capabilities() + + assert capabilities.reads_preset_files + assert capabilities.writes_preset_files + assert capabilities.reads_setlist_files + assert capabilities.writes_setlist_files + assert capabilities.joins_presets_to_setlist + assert capabilities.splits_setlist_to_presets + assert capabilities.exports_selected_setlist_slots + assert handler.file_kind(Path("preset.hlx")) == "preset" + assert handler.file_kind(Path("setlist.hls")) == "setlist" + assert handler.file_kind(Path("notes.txt")) == "unknown" + + def test_parse_and_format_patch_ids(tmp_path) -> None: handler = make_handler(tmp_path) @@ -113,6 +130,20 @@ def fake_run(*args, capture=False, log_output=True): assert calls[1][0] == ("-i", Path("set.hls"), "-o", Path("measurement.hls"), "--measurement") +def test_single_preset_assignment_includes_original_filename(tmp_path, monkeypatch) -> None: + handler = make_handler(tmp_path) + payload = [{"id": 1, "helix_preset": "01A", "name": "Clean"}] + + def fake_run(*args, capture=False, log_output=True): + return subprocess.CompletedProcess([], 0, stdout=json.dumps(payload)) + + monkeypatch.setattr(handler, "_run", fake_run) + + assert handler.list_assignments(Path("Clean.hlx")) == [ + PatchAssignment(1, "01A", "Clean", original_filename="Clean.hlx") + ] + + def test_metadata_delegates_to_legacy_script(tmp_path, monkeypatch) -> None: handler = make_handler(tmp_path) payload = {"file_type": "hls", "metadata": [{"path": "$.meta", "value": {"name": "Set"}}]} @@ -134,6 +165,72 @@ def fake_run(*args, capture=False, log_output=True): ] +def test_join_preset_files_delegates_to_legacy_script(tmp_path, monkeypatch) -> None: + handler = make_handler(tmp_path) + calls = [] + + def fake_run(*args, capture=False, log_output=True): + calls.append((args, capture, log_output)) + return subprocess.CompletedProcess([], 0) + + monkeypatch.setattr(handler, "_run", fake_run) + + handler.join_preset_files( + [Path("first.hlx"), Path("second.hlx")], + Path("joined.hls"), + slot_ids=[1, 6], + ) + + assert calls == [ + ( + ( + "--join-presets", + Path("first.hlx"), + Path("second.hlx"), + "-o", + Path("joined.hls"), + "--slot-ids", + "01A,02B", + ), + False, + True, + ) + ] + + +def test_split_setlist_file_writes_helper_results(tmp_path, monkeypatch) -> None: + handler = make_handler(tmp_path) + seen = {} + + class Helper: + @staticmethod + def split_setlist_to_preset_data(input_path, selected_ids=None, original_filenames=None): + seen["input_path"] = input_path + seen["selected_ids"] = selected_ids + seen["original_filenames"] = original_filenames + return [("../Lead.hlx", {"meta": {"name": "Lead"}, "tone": {}})] + + monkeypatch.setattr(helix_module, "_load_helix_file_ops", lambda script: Helper) + + created = handler.split_setlist_file( + Path("set.hls"), + tmp_path / "presets", + selected_ids=[1], + original_filenames={1: "Lead.hlx"}, + ) + + assert created == [tmp_path / "presets" / "Lead.hlx"] + assert json.loads(created[0].read_text(encoding="utf-8")) == { + "meta": {"name": "Lead"}, + "tone": {}, + } + assert seen == { + "input_path": Path("set.hls"), + "selected_ids": [1], + "original_filenames": {1: "Lead.hlx"}, + } + + def test_diff_preset_ids_delegates_to_legacy_script(tmp_path, monkeypatch) -> None: handler = make_handler(tmp_path) calls = [] diff --git a/tests/test_measure.py b/tests/test_measure.py index 9ee3347..54cdaae 100644 --- a/tests/test_measure.py +++ b/tests/test_measure.py @@ -953,6 +953,61 @@ def test_check_hardware_parse_args_accepts_diagnostic_timing_flags(monkeypatch) assert args.round_trip_latency == 0.001 +def test_worker_parse_args_validates_backend_against_selected_profile(monkeypatch) -> None: + profile = SimpleNamespace( + display_name="Offline Processor", + measurement_backends=lambda: ("offline",), + default_audio_routing=lambda: AudioRouting(None, 48000, (1, 2), (1, 2)), + default_steering_options=lambda: SteeringOptions(None, 0, 0.0, 0.0, 0.0), + ) + monkeypatch.setattr("matchpatch.measure.get_device_profile", lambda device: profile) + + monkeypatch.setattr( + sys, + "argv", + [ + "measure", + "measure", + "--device", + "offline", + "--preset-ids", + "1", + "--csv", + "out.csv", + "--reference-di", + "ref.wav", + "--backend", + "offline", + ], + ) + args = parse_args() + assert args.backend == "offline" + + with pytest.raises(NotImplementedError, match="offline measurement backend"): + measure(args) + + monkeypatch.setattr( + sys, + "argv", + [ + "measure", + "measure", + "--device", + "offline", + "--preset-ids", + "1", + "--csv", + "out.csv", + "--reference-di", + "ref.wav", + "--backend", + "hardware", + ], + ) + with pytest.raises(ValueError, match="Backend 'hardware' is not supported"): + parse_args() + + def test_worker_main_dispatches_devices_and_legacy_helix_backend(monkeypatch) -> None: calls = [] monkeypatch.setattr("matchpatch.measure.list_devices", lambda: calls.append("devices")) diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 71bedf9..16f3038 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -268,6 +268,7 @@ def test_apply_config_layers_cli_environment_and_toml(tmp_path, monkeypatch) -> measured_snapshots = 3 solo_marker = "lead" ignore_snapshot_regex = "^Init$" +ignore_preset_regex = "^Empty" solo_gain_bump_db = 4.0 crest_factor_reference_db = 11.0 crest_factor_correction_ratio = 0.5 @@ -315,6 +316,7 @@ def test_apply_config_layers_cli_environment_and_toml(tmp_path, monkeypatch) -> assert args.policy.snapshot_count == 3 assert args.policy.solo_regex == "lead" assert args.policy.ignore_snapshot_regex == "^Init$" + assert args.policy.ignore_preset_regex == "^Empty" assert args.analysis_options.window_seconds == 2.0 assert args.pre_roll == 1.5 assert args.post_roll == 2.0 @@ -367,6 +369,28 @@ def test_apply_config_uses_device_timing_defaults_when_config_is_silent() -> Non assert args.measurement_wait == 0.1 +def test_apply_config_validates_backend_against_selected_profile(monkeypatch) -> None: + class OfflineProfile(FakeProfile): + display_name = "Offline Processor" + + def measurement_backends(self) -> tuple[str, ...]: + return ("offline",) + + monkeypatch.setattr( + normalize, "get_device_profile", lambda device: OfflineProfile(FakeHandler()) + ) + + args = normalize.apply_config( + normalize.parse_args(["--device", "fake", "-i", "input.hls", "--backend", "offline"]) + ) + assert args.backend == "offline" + + with pytest.raises(ValueError, match="Backend 'hardware' is not supported"): + normalize.apply_config( + normalize.parse_args(["--device", "fake", "-i", "input.hls", "--backend", "hardware"]) + ) + + def test_configured_windows_python_frozen_windows_ignores_stale_worker_config( tmp_path, monkeypatch ) -> None: @@ -439,6 +463,40 @@ def test_apply_config_rejects_invalid_ignore_snapshot_regex(tmp_path) -> None: ) +def test_apply_config_cli_ignore_preset_regex_overrides_toml(tmp_path) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text("[policy]\nignore_preset_regex = '^Empty$'\n", encoding="utf-8") + + args = normalize.apply_config( + normalize.parse_args( + [ + "--config", + str(config_path), + "--device", + "helix", + "-i", + "input.hls", + "--ignore-preset-regex", + "^Init", + ] + ) + ) + + assert args.policy.ignore_preset_regex == "^Init" + + +def test_apply_config_rejects_invalid_ignore_preset_regex(tmp_path) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text("[policy]\nignore_preset_regex = '('\n", encoding="utf-8") + + with pytest.raises(ValueError, match="Invalid ignore preset regex"): + normalize.apply_config( + normalize.parse_args( + ["--config", str(config_path), "--device", "helix", "-i", "input.hls"] + ) + ) + + def test_wsl_path_to_windows_returns_native_windows_path(monkeypatch) -> None: monkeypatch.setattr(normalize, "_is_windows", lambda: True) monkeypatch.setattr( diff --git a/tests/test_preflight.py b/tests/test_preflight.py index cc2bc12..1734e3a 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -27,11 +27,16 @@ def validate_output(self, input_path: Path, output_path: Path) -> None: raise ValueError(self.output_error) -def _profile(handler: FakeHandler | None = None) -> SimpleNamespace: +def _profile( + handler: FakeHandler | None = None, + *, + backends: tuple[str, ...] = ("hardware", "loopback", "simulated"), +) -> SimpleNamespace: handler = handler or FakeHandler() return SimpleNamespace( display_name="Fake Device", create_patch_file_handler=lambda project_dir: handler, + measurement_backends=lambda: backends, ) @@ -65,6 +70,26 @@ def test_loopback_preflight_skips_hardware(tmp_path: Path) -> None: assert "loopback" in hardware.summary +def test_preflight_validates_backend_against_selected_profile(tmp_path: Path) -> None: + checks = run_preflight_checks( + _request(tmp_path, backend="offline"), + get_profile=lambda device: _profile(backends=("offline",)), + ) + + backend = next(check for check in checks if check.name == "backend") + assert backend.status == "pass" + assert backend.summary == "Backend is valid: offline" + + checks = run_preflight_checks( + _request(tmp_path, backend="hardware"), + get_profile=lambda device: _profile(backends=("offline",)), + ) + + backend = next(check for check in checks if check.name == "backend") + assert backend.status == "fail" + assert backend.summary == "Backend must be one of offline: hardware" + + def test_missing_input_returns_failed_check(tmp_path: Path) -> None: request = _request(tmp_path, input_path=tmp_path / "missing.hls") diff --git a/tests/test_preset_handling.py b/tests/test_preset_handling.py index 4482dde..c6e66ff 100644 --- a/tests/test_preset_handling.py +++ b/tests/test_preset_handling.py @@ -21,6 +21,39 @@ def _load_legacy_module() -> ModuleType: return module +def _preset(name: str) -> dict: + return { + "meta": {"name": name}, + "tone": { + "dsp0": { + "inputA": {"@input": 1}, + "block0": {}, + "outputA": {"@output": 6, "gain": 0.0}, + }, + "snapshot0": {"@name": "Snapshot 1"}, + }, + } + + +def _hls_text(data: dict) -> str: + raw = json.dumps(data, indent=1).encode("utf-8") + wrapper = { + "compression": { + "crc32": binascii.crc32(raw) & 0xFFFFFFFF, + "decompressed_size": len(raw), + "type": "zlib", + }, + "encoded_data": base64.b64encode(zlib.compress(raw, level=9)).decode("ascii"), + } + return json.dumps(wrapper) + + +def _decoded_hls_data(hls_text: str) -> dict: + wrapper = json.loads(hls_text) + raw = zlib.decompress(base64.b64decode(wrapper["encoded_data"])) + return json.loads(raw) + + def test_lufs_error_sentinel_is_retained_per_snapshot(tmp_path) -> None: module = _load_legacy_module() csv_path = tmp_path / "analysis.csv" @@ -314,3 +347,93 @@ def test_save_output_packs_crc32_for_encoded_data(tmp_path) -> None: assert wrapper["compression"]["crc32"] == binascii.crc32(raw) & 0xFFFFFFFF assert wrapper["compression"]["decompressed_size"] == len(raw) + + +def test_join_preset_files_to_setlist_contains_joined_presets(tmp_path) -> None: + module = _load_legacy_module() + first_path = tmp_path / "first.hlx" + second_path = tmp_path / "second.hlx" + first_path.write_text(json.dumps(_preset("First")), encoding="utf-8") + second_path.write_text(json.dumps({"data": _preset("Second"), "meta": {"app": "HX Edit"}})) + + hls_text, metadata = module.join_preset_files_to_setlist([first_path, second_path]) + + data = _decoded_hls_data(hls_text) + assert [preset["meta"]["name"] for preset in data["presets"]] == ["First", "Second"] + assert metadata["source_filenames"] == {"01A": "first.hlx", "01B": "second.hlx"} + + +def test_join_preset_files_to_setlist_uses_slot_ids(tmp_path) -> None: + module = _load_legacy_module() + preset_path = tmp_path / "lead.hlx" + preset_path.write_text(json.dumps(_preset("Lead")), encoding="utf-8") + + hls_text, _ = module.join_preset_files_to_setlist([preset_path], slot_ids=["01B"]) + + data = _decoded_hls_data(hls_text) + assert module.is_default_preset(data["presets"][0]) + assert data["presets"][1]["meta"]["name"] == "Lead" + + +def test_split_setlist_to_preset_data_skips_empty_presets(tmp_path) -> None: + module = _load_legacy_module() + setlist_path = tmp_path / "setlist.hls" + setlist_path.write_text( + _hls_text({"presets": [_preset("Lead"), {"meta": {"name": "Empty"}, "tone": {}}]}), + encoding="utf-8", + ) + + split_presets = module.split_setlist_to_preset_data(setlist_path) + + assert split_presets == [("Lead.hlx", _preset("Lead"))] + + +def test_split_setlist_reuses_original_filename_when_supplied(tmp_path) -> None: + module = _load_legacy_module() + setlist_path = tmp_path / "setlist.hls" + setlist_path.write_text(_hls_text({"presets": [_preset("Lead")]}), encoding="utf-8") + + split_presets = module.split_setlist_to_preset_data( + setlist_path, original_filenames={"01A": "Original Lead.hlx"} + ) + + assert split_presets[0][0] == "Original Lead.hlx" + + +def test_split_setlist_synthesizes_safe_filename_from_preset_name(tmp_path) -> None: + module = _load_legacy_module() + setlist_path = tmp_path / "setlist.hls" + setlist_path.write_text(_hls_text({"presets": [_preset('Lead: / "A"')]}), encoding="utf-8") + + split_presets = module.split_setlist_to_preset_data(setlist_path) + + assert split_presets[0][0] == "Lead A.hlx" + + +def test_split_setlist_disambiguates_duplicate_synthesized_names(tmp_path) -> None: + module = _load_legacy_module() + setlist_path = tmp_path / "setlist.hls" + setlist_path.write_text( + _hls_text({"presets": [_preset("Lead"), _preset("Lead")]}), + encoding="utf-8", + ) + + split_presets = module.split_setlist_to_preset_data(setlist_path) + + assert [filename for filename, _ in split_presets] == ["Lead 01A.hlx", "Lead 01B.hlx"] + + +def test_load_and_rebuild_hlx_preserves_wrapper_shape(tmp_path) -> None: + module = _load_legacy_module() + hlx_path = tmp_path / "wrapped.hlx" + hlx_path.write_text( + json.dumps({"data": _preset("Wrapped"), "meta": {"app": "HX Edit"}}), + encoding="utf-8", + ) + + preset, wrapper = module.load_preset_file(hlx_path) + preset["meta"]["name"] = "Renamed" + rebuilt = module.rebuild_hlx_data(wrapper, preset) + + assert rebuilt["data"]["meta"]["name"] == "Renamed" + assert rebuilt["meta"] == {"app": "HX Edit"} From d0a0fe564249845f36079fb04be638edee0cd681 Mon Sep 17 00:00:00 2001 From: Noseglasses Date: Wed, 17 Jun 2026 22:37:57 +0200 Subject: [PATCH 02/10] feat: Add missing device profile toml setting --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index fecdbdf..a619a22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,11 @@ Repository = "https://github.com/noseglasses/MatchPatch.git" matchpatch = "matchpatch.cli:main" matchpatch-gui = "matchpatch.gui.app:main" +[project.entry-points."matchpatch.devices"] +# Third-party packages can register device profiles here, for example: +# my-device = "my_package.matchpatch_plugin:DeviceProfile" +# Built-in profiles are registered directly in matchpatch.devices.registry. + [dependency-groups] docs = [ "furo>=2024.8.6", From c9fc60ec7a8fb3af728f091021f17d1ec392d69e Mon Sep 17 00:00:00 2001 From: Noseglasses Date: Wed, 17 Jun 2026 22:51:06 +0200 Subject: [PATCH 03/10] feat: Remove legacy Python directory and scripts --- AGENTS.md | 3 +- Python/adjust_gain.py | 20 -- Python/decrypt_hls.py | 76 ----- Python/encrypt_hls.py | 85 ------ Python/list_cab_presets.py | 114 ------- Python/remove_inactive_blocks.py | 278 ------------------ Python/replace_amp.py | 127 -------- Python/reset_output_levels.py | 192 ------------ Python/stereofy.py | 254 ---------------- docs/dev/architecture.md | 26 +- docs/dev/commands.md | 12 +- docs/dev/file-formats.md | 20 +- installer/pyinstaller/build_support.py | 2 - installer/pyinstaller/matchpatch-gui.spec | 6 +- pyproject.toml | 3 +- scripts/check_maintainability.py | 4 +- src/matchpatch/devices/helix.py | 33 +-- .../matchpatch/devices}/helix_file_ops.py | 0 .../devices/helix_preset_handling.py | 18 +- tests/README.md | 10 +- tests/test_helix.py | 57 ++-- tests/test_installer_metadata.py | 7 +- tests/test_preset_handling.py | 11 +- 23 files changed, 96 insertions(+), 1262 deletions(-) delete mode 100644 Python/adjust_gain.py delete mode 100644 Python/decrypt_hls.py delete mode 100644 Python/encrypt_hls.py delete mode 100644 Python/list_cab_presets.py delete mode 100644 Python/remove_inactive_blocks.py delete mode 100644 Python/replace_amp.py delete mode 100644 Python/reset_output_levels.py delete mode 100644 Python/stereofy.py rename {Python => src/matchpatch/devices}/helix_file_ops.py (100%) rename Python/preset_handling.py => src/matchpatch/devices/helix_preset_handling.py (99%) diff --git a/AGENTS.md b/AGENTS.md index 5f5de8e..74e5113 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # MatchPatch Agent Notes -Python package in `src/matchpatch`; legacy Helix JSON/HLS utilities live in `Python/`. +Python package in `src/matchpatch`; Helix JSON/HLS utilities live in +`src/matchpatch/devices/`. Use existing WSL env, not bare `pytest` or a stale project `.venv`: ```bash diff --git a/Python/adjust_gain.py b/Python/adjust_gain.py deleted file mode 100644 index c8b6c16..0000000 --- a/Python/adjust_gain.py +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env python3 -"""Compatibility wrapper for the historical Helix adjustment command.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -PROJECT_DIR = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(PROJECT_DIR / "src")) - -from matchpatch.normalize import main # noqa: E402 - -if __name__ == "__main__": - arguments = sys.argv[1:] - - if "--device" not in arguments: - arguments = ["--device", "helix", *arguments] - - main(arguments) diff --git a/Python/decrypt_hls.py b/Python/decrypt_hls.py deleted file mode 100644 index 999ad8a..0000000 --- a/Python/decrypt_hls.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import base64 -import json -import os -import sys -import zlib - - -def require_extension(path, expected_ext, label): - ext = os.path.splitext(path)[1].lower() - - if ext != expected_ext: - raise ValueError(f"{label} must have extension {expected_ext}, got {ext or ''}") - - -def get_extension(path): - return os.path.splitext(path)[1].lower() - - -def parse_args(): - parser = argparse.ArgumentParser( - description=( - "Decrypt/unpack a Helix .hls file to JSON, or validate/copy a .hlx preset to .hlx" - ) - ) - - parser.add_argument("-i", "--input", required=True, help="Input .hls or .hlx file") - - parser.add_argument( - "-o", - "--output", - required=True, - help="Output .json file for .hls input, or .hlx for .hlx input", - ) - - return parser.parse_args() - - -def main(): - args = parse_args() - - input_ext = get_extension(args.input) - - if input_ext == ".hlx": - require_extension(args.output, ".hlx", "Output") - - with open(args.input, "r", encoding="utf-8") as f: - preset = json.load(f) - - with open(args.output, "w", encoding="utf-8") as f: - json.dump(preset, f, indent=1) - - return - - require_extension(args.input, ".hls", "Input") - require_extension(args.output, ".json", "Output") - - with open(args.input, "r", encoding="utf-8") as f: - wrapper = json.load(f) - - compressed = base64.b64decode(wrapper["encoded_data"]) - raw = zlib.decompress(compressed) - text = raw.decode("utf-8") - - with open(args.output, "w", encoding="utf-8") as f: - f.write(text) - - -if __name__ == "__main__": - try: - main() - except Exception as exc: - print(f"ERROR: {exc}", file=sys.stderr) - sys.exit(1) diff --git a/Python/encrypt_hls.py b/Python/encrypt_hls.py deleted file mode 100644 index 54bf1e4..0000000 --- a/Python/encrypt_hls.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import base64 -import binascii -import json -import os -import sys -import zlib - - -def require_extension(path, expected_ext, label): - ext = os.path.splitext(path)[1].lower() - - if ext != expected_ext: - raise ValueError(f"{label} must have extension {expected_ext}, got {ext or ''}") - - -def get_extension(path): - return os.path.splitext(path)[1].lower() - - -def parse_args(): - parser = argparse.ArgumentParser( - description=( - "Encrypt/pack a JSON file to Helix .hls, or validate/copy a .hlx preset to .hlx" - ) - ) - - parser.add_argument("-i", "--input", required=True, help="Input .json or .hlx file") - - parser.add_argument( - "-o", - "--output", - required=True, - help="Output .hls file for .json input, or .hlx for .hlx input", - ) - - return parser.parse_args() - - -def main(): - args = parse_args() - - input_ext = get_extension(args.input) - - if input_ext == ".hlx": - require_extension(args.output, ".hlx", "Output") - - with open(args.input, "r", encoding="utf-8") as f: - preset = json.load(f) - - with open(args.output, "w", encoding="utf-8") as f: - json.dump(preset, f, indent=1) - - return - - require_extension(args.input, ".json", "Input") - require_extension(args.output, ".hls", "Output") - - with open(args.input, "r", encoding="utf-8") as f: - text = f.read() - - raw = text.encode("utf-8") - compressed = zlib.compress(raw) - - wrapper = { - "compression": { - "crc32": binascii.crc32(raw) & 0xFFFFFFFF, - "decompressed_size": len(raw), - "type": "zlib", - }, - "encoded_data": base64.b64encode(compressed).decode("ascii"), - } - - with open(args.output, "w", encoding="utf-8") as f: - json.dump(wrapper, f) - - -if __name__ == "__main__": - try: - main() - except Exception as exc: - print(f"ERROR: {exc}", file=sys.stderr) - sys.exit(1) diff --git a/Python/list_cab_presets.py b/Python/list_cab_presets.py deleted file mode 100644 index d66b091..0000000 --- a/Python/list_cab_presets.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import json -import sys - -from preset_handling import ( - get_preset_name, - is_default_preset, - load_input, - preset_index_to_helix, - require_helix_input_path, -) - -CAB_BLOCK_TYPES = {2, 4} - - -def is_cab_block(block): - if not isinstance(block, dict): - return False - - model = block.get("@model", "") - - return block.get("@type") in CAB_BLOCK_TYPES or ( - isinstance(model, str) and model.startswith("HD2_Cab") - ) - - -def list_cab_presets(data): - preset_count = 0 - block_count = 0 - - for preset_index, preset in enumerate(data.get("presets", [])): - if is_default_preset(preset): - continue - - cab_blocks = [] - tone = preset.get("tone", {}) - - if not isinstance(tone, dict): - continue - - for dsp_name in ["dsp0", "dsp1"]: - dsp = tone.get(dsp_name) - - if not isinstance(dsp, dict): - continue - - for block_name, block in dsp.items(): - if not block_name.startswith("block"): - continue - - if not is_cab_block(block): - continue - - cab_blocks.append( - (dsp_name, block_name, block.get("@model", ""), block.get("@type")) - ) - - if not cab_blocks: - continue - - preset_count += 1 - block_count += len(cab_blocks) - - mappings = ", ".join( - f"{dsp_name}.{block_name}: {model} (type {block_type})" - for (dsp_name, block_name, model, block_type) in cab_blocks - ) - - print( - f'[CAB] {preset_index_to_helix(preset_index)} "{get_preset_name(preset)}": {mappings}' - ) - - return preset_count, block_count - - -def parse_args(): - parser = argparse.ArgumentParser( - description=("List Helix presets that use isolated cab blocks") - ) - - parser.add_argument("-i", "--input", required=True, help="Input .hls or .hlx file") - - return parser.parse_args() - - -def main(): - args = parse_args() - - try: - require_helix_input_path(args.input, "Input") - - json_text, _ = load_input(args.input) - data = json.loads(json_text) - - preset_count, block_count = list_cab_presets(data) - - except Exception as e: - print() - print(f"ERROR: {e}") - print() - sys.exit(1) - - print() - print("[OK] Cab scan complete") - print(f"Input : {args.input}") - print(f"Presets: {preset_count}") - print(f"Blocks : {block_count}") - print() - - -if __name__ == "__main__": - main() diff --git a/Python/remove_inactive_blocks.py b/Python/remove_inactive_blocks.py deleted file mode 100644 index bf2f8f9..0000000 --- a/Python/remove_inactive_blocks.py +++ /dev/null @@ -1,278 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import json -import sys - -from preset_handling import ( - get_preset_name, - is_default_preset, - load_input, - preset_index_to_helix, - require_compatible_output_path, - require_helix_input_path, - save_output, -) - -DSP_NAMES = ["dsp0", "dsp1"] - -SNAPSHOT_COUNT = 4 - -PEDAL_CONTROLLERS = {1, 2, 3} - - -def iter_effect_blocks(tone): - if not isinstance(tone, dict): - return - - for dsp_name in DSP_NAMES: - dsp = tone.get(dsp_name) - - if not isinstance(dsp, dict): - continue - - for block_name, block in list(dsp.items()): - if not block_name.startswith("block"): - continue - - if not isinstance(block, dict): - continue - - yield dsp_name, block_name, block - - -def snapshot_block_state(tone, snapshot_index, dsp_name, block_name): - snapshot = tone.get(f"snapshot{snapshot_index}") - - if not isinstance(snapshot, dict): - return None - - snapshot_blocks = snapshot.get("blocks") - - if not isinstance(snapshot_blocks, dict): - return None - - dsp_blocks = snapshot_blocks.get(dsp_name) - - if not isinstance(dsp_blocks, dict): - return None - - return dsp_blocks.get(block_name) - - -def is_inactive_in_first_snapshots(tone, dsp_name, block_name): - states = [ - snapshot_block_state(tone, snapshot_index, dsp_name, block_name) - for snapshot_index in range(SNAPSHOT_COUNT) - ] - - return all(state is False for state in states) - - -def get_block_controller_assignments(tone, dsp_name, block_name): - controller_root = tone.get("controller") - - if not isinstance(controller_root, dict): - return {} - - dsp_controller = controller_root.get(dsp_name) - - if not isinstance(dsp_controller, dict): - return {} - - block_controller = dsp_controller.get(block_name) - - if not isinstance(block_controller, dict): - return {} - - return block_controller - - -def get_pedal_assignments(tone, dsp_name, block_name): - assignments = [] - block_controller = get_block_controller_assignments(tone, dsp_name, block_name) - - for parameter, assignment in block_controller.items(): - if not isinstance(assignment, dict): - continue - - controller = assignment.get("@controller") - - if controller not in PEDAL_CONTROLLERS: - continue - - assignments.append((parameter, controller)) - - return assignments - - -def remove_block_references(tone, dsp_name, block_name): - removed_references = 0 - - controller_root = tone.get("controller") - - if isinstance(controller_root, dict): - dsp_controller = controller_root.get(dsp_name) - - if isinstance(dsp_controller, dict) and block_name in dsp_controller: - del dsp_controller[block_name] - removed_references += 1 - - for snapshot_index in range(8): - snapshot = tone.get(f"snapshot{snapshot_index}") - - if not isinstance(snapshot, dict): - continue - - snapshot_blocks = snapshot.get("blocks") - - if isinstance(snapshot_blocks, dict): - dsp_blocks = snapshot_blocks.get(dsp_name) - - if isinstance(dsp_blocks, dict) and block_name in dsp_blocks: - del dsp_blocks[block_name] - removed_references += 1 - - snapshot_controllers = snapshot.get("controllers") - - if isinstance(snapshot_controllers, dict): - dsp_controllers = snapshot_controllers.get(dsp_name) - - if isinstance(dsp_controllers, dict) and block_name in dsp_controllers: - del dsp_controllers[block_name] - removed_references += 1 - - return removed_references - - -def format_pedal_assignments(assignments): - return ", ".join(f"{parameter}->EXP{controller}" for parameter, controller in assignments) - - -def remove_inactive_blocks(data): - removed_blocks = 0 - removed_references = 0 - manual_blocks = 0 - affected_presets = 0 - - for preset_index, preset in enumerate(data.get("presets", [])): - if is_default_preset(preset): - continue - - tone = preset.get("tone", {}) - - if not isinstance(tone, dict): - continue - - removed = [] - manual = [] - - for dsp_name, block_name, block in list(iter_effect_blocks(tone)): - if not is_inactive_in_first_snapshots(tone, dsp_name, block_name): - continue - - model = block.get("@model", "") - block_type = block.get("@type") - pedal_assignments = get_pedal_assignments(tone, dsp_name, block_name) - - if pedal_assignments: - manual_blocks += 1 - manual.append((dsp_name, block_name, model, block_type, pedal_assignments)) - continue - - dsp = tone.get(dsp_name) - del dsp[block_name] - removed_blocks += 1 - refs = remove_block_references(tone, dsp_name, block_name) - removed_references += refs - - removed.append((dsp_name, block_name, model, block_type, refs)) - - if removed or manual: - affected_presets += 1 - - if removed: - mappings = ", ".join( - f"{dsp_name}.{block_name}: {model} (type {block_type}, refs {refs})" - for (dsp_name, block_name, model, block_type, refs) in removed - ) - - print( - f"[REMOVED] " - f"{preset_index_to_helix(preset_index)} " - f'"{get_preset_name(preset)}": ' - f"{mappings}" - ) - - if manual: - mappings = ", ".join( - f"{dsp_name}.{block_name}: " - f"{model} (type {block_type}, " - f"{format_pedal_assignments(assignments)})" - for (dsp_name, block_name, model, block_type, assignments) in manual - ) - - print( - f"[MANUAL] " - f"{preset_index_to_helix(preset_index)} " - f'"{get_preset_name(preset)}": ' - f"inactive in snapshots 1-4 but pedal-bound; " - f"please inspect manually: {mappings}" - ) - - return (affected_presets, removed_blocks, removed_references, manual_blocks) - - -def parse_args(): - parser = argparse.ArgumentParser( - description=( - "Remove Helix blocks that are inactive in the first " - "four snapshots, except blocks controlled by expression " - "pedals such as wah/pitch effects" - ) - ) - - parser.add_argument("-i", "--input", required=True, help="Input .hls or .hlx file") - - parser.add_argument("-o", "--output", required=True, help="Output .hls or .hlx file to create") - - return parser.parse_args() - - -def main(): - args = parse_args() - - try: - require_helix_input_path(args.input, "Input") - require_compatible_output_path(args.input, args.output, allow_json=False) - - json_text, original_hls_text = load_input(args.input) - data = json.loads(json_text) - - (affected_presets, removed_blocks, removed_references, manual_blocks) = ( - remove_inactive_blocks(data) - ) - - modified_json_text = json.dumps(data, indent=1) - - save_output(modified_json_text, args.output, original_hls_text) - - except Exception as e: - print() - print(f"ERROR: {e}") - print() - sys.exit(1) - - print() - print("[OK] Inactive block cleanup complete") - print(f"Input : {args.input}") - print(f"Output : {args.output}") - print(f"Affected presets : {affected_presets}") - print(f"Removed blocks : {removed_blocks}") - print(f"Removed references: {removed_references}") - print(f"Manual blocks : {manual_blocks}") - print() - - -if __name__ == "__main__": - main() diff --git a/Python/replace_amp.py b/Python/replace_amp.py deleted file mode 100644 index 9664e1d..0000000 --- a/Python/replace_amp.py +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import json -import sys - -from preset_handling import ( - get_preset_name, - is_default_preset, - load_input, - preset_index_to_helix, - require_compatible_output_path, - require_helix_input_path, - save_output, -) - -AMP_BLOCK_TYPE = 1 -AMP_CAB_BLOCK_TYPE = 3 - - -def is_amp_cab_block(block): - if not isinstance(block, dict): - return False - - model = block.get("@model", "") - - return ( - isinstance(model, str) - and model.startswith("HD2_Amp") - and "@cab" in block - and block.get("@type") == AMP_CAB_BLOCK_TYPE - ) - - -def replace_amp_cab_blocks(data): - changes = 0 - - for preset_index, preset in enumerate(data.get("presets", [])): - if is_default_preset(preset): - continue - - preset_changes = [] - tone = preset.get("tone", {}) - - if not isinstance(tone, dict): - continue - - for dsp_name in ["dsp0", "dsp1"]: - dsp = tone.get(dsp_name) - - if not isinstance(dsp, dict): - continue - - for block_name, block in dsp.items(): - if not block_name.startswith("block"): - continue - - if not is_amp_cab_block(block): - continue - - model = block["@model"] - old_cab = block.pop("@cab") - block["@type"] = AMP_BLOCK_TYPE - changes += 1 - - preset_changes.append((dsp_name, block_name, model, old_cab)) - - if preset_changes: - mappings = ", ".join( - f"{dsp_name}.{block_name}: {model}+cab({old_cab}) -> {model}" - for (dsp_name, block_name, model, old_cab) in preset_changes - ) - - print( - f"[AMP] " - f"{preset_index_to_helix(preset_index)} " - f'"{get_preset_name(preset)}": ' - f"{mappings}" - ) - - return changes - - -def parse_args(): - parser = argparse.ArgumentParser( - description=("Replace Helix amp+cab blocks with the equivalent amp-only blocks") - ) - - parser.add_argument("-i", "--input", required=True, help="Input .hls or .hlx file") - - parser.add_argument("-o", "--output", required=True, help="Output .hls or .hlx file to create") - - return parser.parse_args() - - -def main(): - args = parse_args() - - try: - require_helix_input_path(args.input, "Input") - require_compatible_output_path(args.input, args.output, allow_json=False) - - json_text, original_hls_text = load_input(args.input) - data = json.loads(json_text) - - changes = replace_amp_cab_blocks(data) - - modified_json_text = json.dumps(data, indent=1) - - save_output(modified_json_text, args.output, original_hls_text) - - except Exception as e: - print() - print(f"ERROR: {e}") - print() - sys.exit(1) - - print() - print("[OK] Amp+cab replacement complete") - print(f"Input : {args.input}") - print(f"Output : {args.output}") - print(f"Changes: {changes}") - print() - - -if __name__ == "__main__": - main() diff --git a/Python/reset_output_levels.py b/Python/reset_output_levels.py deleted file mode 100644 index 3366c2e..0000000 --- a/Python/reset_output_levels.py +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import json -import sys - -from preset_handling import ( - get_preset_name, - is_default_preset, - load_input, - output_label, - preset_index_to_helix, - require_compatible_output_path, - require_helix_input_path, - save_output, -) - -INACTIVE_OUTPUTS = {0, 2, 3} - -OUTPUT_BLOCKS = ["outputA", "outputB"] - - -def is_active_output(block): - if not isinstance(block, dict): - return False - - output_id = block.get("@output", 0) - - return output_id not in INACTIVE_OUTPUTS - - -def set_gain_to_zero(container): - if not isinstance(container, dict): - return False - - if "gain" not in container: - return False - - gain = container["gain"] - - if isinstance(gain, dict): - if gain.get("@value") == 0.0: - return False - - gain["@value"] = 0.0 - return True - - if gain == 0.0: - return False - - container["gain"] = 0.0 - return True - - -def reset_snapshot_output_gains(tone, dsp_name, output_name): - changes = 0 - - for snapshot_index in range(8): - snapshot = tone.get(f"snapshot{snapshot_index}") - - if not isinstance(snapshot, dict): - continue - - controllers = snapshot.get("controllers", {}) - - if not isinstance(controllers, dict): - continue - - dsp_snapshot = controllers.get(dsp_name, {}) - - if not isinstance(dsp_snapshot, dict): - continue - - output_snapshot = dsp_snapshot.get(output_name, {}) - - if set_gain_to_zero(output_snapshot): - changes += 1 - - return changes - - -def reset_output_levels(data): - base_changes = 0 - snapshot_changes = 0 - - for preset_index, preset in enumerate(data.get("presets", [])): - tone = _resettable_tone(preset) - if tone is None: - continue - preset_changes = [] - for output in _iter_active_outputs(tone): - changed, snapshot_count, change = _reset_output_level(tone, output) - base_changes += 1 if changed else 0 - snapshot_changes += snapshot_count - if change is not None: - preset_changes.append(change) - - if preset_changes: - _log_output_level_changes(preset_index, preset, preset_changes) - - return base_changes, snapshot_changes - - -def _resettable_tone(preset): - if is_default_preset(preset): - return None - tone = preset.get("tone", {}) - return tone if isinstance(tone, dict) else None - - -def _iter_active_outputs(tone): - for dsp_name in ["dsp0", "dsp1"]: - dsp = tone.get(dsp_name) - if not isinstance(dsp, dict): - continue - for output_name in OUTPUT_BLOCKS: - output_block = dsp.get(output_name) - if is_active_output(output_block): - yield dsp_name, output_name, output_block - - -def _reset_output_level(tone, output): - dsp_name, output_name, output_block = output - output_id = output_block.get("@output") - old_gain = output_block.get("gain") - changed = set_gain_to_zero(output_block) - snapshot_count = reset_snapshot_output_gains(tone, dsp_name, output_name) - change = None - if changed or snapshot_count: - change = (dsp_name, output_name, output_id, old_gain, snapshot_count) - return changed, snapshot_count, change - - -def _log_output_level_changes(preset_index, preset, preset_changes): - mappings = ", ".join(_output_level_change_text(change) for change in preset_changes) - print(f'[OUTPUT] {preset_index_to_helix(preset_index)} "{get_preset_name(preset)}": {mappings}') - - -def _output_level_change_text(change): - dsp_name, output_name, output_id, old_gain, snapshot_count = change - return f"{dsp_name}.{output_name} {output_label(output_id)}: {old_gain} dB -> 0.0 dB" + ( - f", {snapshot_count} snapshot values" if snapshot_count else "" - ) - - -def parse_args(): - parser = argparse.ArgumentParser( - description=( - "Set active Helix output block levels and snapshot-assigned output levels to 0 dB" - ) - ) - - parser.add_argument("-i", "--input", required=True, help="Input .hls or .hlx file") - - parser.add_argument("-o", "--output", required=True, help="Output .hls or .hlx file to create") - - return parser.parse_args() - - -def main(): - args = parse_args() - - try: - require_helix_input_path(args.input, "Input") - require_compatible_output_path(args.input, args.output, allow_json=False) - - json_text, original_hls_text = load_input(args.input) - data = json.loads(json_text) - - base_changes, snapshot_changes = reset_output_levels(data) - - modified_json_text = json.dumps(data, indent=1) - - save_output(modified_json_text, args.output, original_hls_text) - - except Exception as e: - print() - print(f"ERROR: {e}") - print() - sys.exit(1) - - print() - print("[OK] Output levels reset") - print(f"Input : {args.input}") - print(f"Output : {args.output}") - print(f"Base gains changed: {base_changes}") - print(f"Snapshot gains : {snapshot_changes}") - print() - - -if __name__ == "__main__": - main() diff --git a/Python/stereofy.py b/Python/stereofy.py deleted file mode 100644 index 8693921..0000000 --- a/Python/stereofy.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import json -import sys -from collections import defaultdict - -from preset_handling import ( - get_preset_name, - is_default_preset, - load_input, - preset_index_to_helix, - require_compatible_output_path, - require_helix_input_path, - save_output, -) - -CAB_BLOCK_TYPES = {2, 3, 4, 5} - -DSP_HANDOFF_OUTPUT = 2 - -DEDICATED_STEREO_MODELS = { - "GainMono": "GainStereo", - "VolumePedalMono": "VolumePedalStereo", - "PanMono": "PanStereo", - "SendMono": "SendStereo", - "ReturnMono": "ReturnStereo", - "FXLoopMono": "FXLoopStereo", - "1SwitchLooperMono": "1SwitchLooperStereo", - "6SwitchLooperMono": "6SwitchLooperStereo", - "mono_ir": "stereo_ir", - "HD2_VolPanGainMono": "HD2_VolPanGainStereo", - "HD2_VolPanVolMono": "HD2_VolPanVolStereo", - "HD2_VolPanPanMono": "HD2_VolPanPanStereo", - "HD2_SendMono1": "HD2_SendStereo1", - "HD2_SendMono2": "HD2_SendStereo2", - "HD2_SendMono3": "HD2_SendStereo3", - "HD2_SendMono4": "HD2_SendStereo4", - "HD2_ReturnMono1": "HD2_ReturnStereo1", - "HD2_ReturnMono2": "HD2_ReturnStereo2", - "HD2_ReturnMono3": "HD2_ReturnStereo3", - "HD2_ReturnMono4": "HD2_ReturnStereo4", - "HD2_FXLoopMono1": "HD2_FXLoopStereo1", - "HD2_FXLoopMono2": "HD2_FXLoopStereo2", - "HD2_FXLoopMono3": "HD2_FXLoopStereo3", - "HD2_FXLoopMono4": "HD2_FXLoopStereo4", - "HD2_ImpulseResponse1024Mono": "HD2_ImpulseResponse1024Stereo", - "HD2_ImpulseResponse2048Mono": "HD2_ImpulseResponse2048Stereo", -} - - -def is_cab_or_ir_block(block): - if not isinstance(block, dict): - return False - - model = block.get("@model", "") - - return block.get("@type") in CAB_BLOCK_TYPES or ( - isinstance(model, str) - and (model.startswith("HD2_Cab") or model.startswith("HD2_ImpulseResponse")) - ) - - -def is_dsp_chained(tone): - dsp0 = tone.get("dsp0", {}) - - if not isinstance(dsp0, dict): - return False - - for output_name in ["outputA", "outputB"]: - output_block = dsp0.get(output_name) - - if not isinstance(output_block, dict): - continue - - if output_block.get("@output") == DSP_HANDOFF_OUTPUT: - return True - - return False - - -def iter_blocks_by_path(dsp): - blocks_by_path = defaultdict(list) - - if not isinstance(dsp, dict): - return blocks_by_path - - for block_name, block in dsp.items(): - if not block_name.startswith("block"): - continue - - if not isinstance(block, dict): - continue - - path = block.get("@path", 0) - position = block.get("@position", 0) - blocks_by_path[path].append((position, block_name, block)) - - for path in blocks_by_path: - blocks_by_path[path].sort(key=lambda item: item[0]) - - return blocks_by_path - - -def get_post_cab_blocks(tone): - post_blocks = [] - chain_after_cab = False - chained = is_dsp_chained(tone) - - for dsp_name in ["dsp0", "dsp1"]: - dsp = tone.get(dsp_name, {}) - blocks_by_path = iter_blocks_by_path(dsp) - dsp_has_cab = False - - for path, blocks in blocks_by_path.items(): - after_cab = chain_after_cab and dsp_name == "dsp1" - - for _, block_name, block in blocks: - if is_cab_or_ir_block(block): - after_cab = True - dsp_has_cab = True - continue - - if after_cab: - post_blocks.append((dsp_name, path, block_name, block)) - - if dsp_name == "dsp0" and chained and dsp_has_cab: - chain_after_cab = True - - return post_blocks - - -def stereofy_block(block): - model_key = "@model" if "@model" in block else "model" if "model" in block else None - - model = block.get(model_key, "") if model_key is not None else "" - - if "@stereo" in block: - if block["@stereo"] is True: - return "already", None - - block["@stereo"] = True - return "changed", "@stereo False -> True" - - stereo_model = DEDICATED_STEREO_MODELS.get(model) - - if stereo_model is not None: - block[model_key] = stereo_model - return ("changed", f"{model} -> {stereo_model}") - - return ("unknown", "no @stereo parameter and no verified stereo model mapping") - - -def stereofy(data): - changed_count = 0 - already_count = 0 - unknown_count = 0 - - for preset_index, preset in enumerate(data.get("presets", [])): - if is_default_preset(preset): - continue - - tone = preset.get("tone", {}) - - if not isinstance(tone, dict): - continue - - changes = [] - already = [] - unknown = [] - - for dsp_name, path, block_name, block in get_post_cab_blocks(tone): - status, detail = stereofy_block(block) - model = block.get("@model", "") - location = f"{dsp_name}.{block_name}(path {path})" - - if status == "changed": - changed_count += 1 - changes.append(f"{location}: {model} ({detail})") - - elif status == "already": - already_count += 1 - already.append(f"{location}: {model}") - - else: - unknown_count += 1 - unknown.append(f"{location}: {model} ({detail})") - - if changes: - print( - f"[STEREO] " - f"{preset_index_to_helix(preset_index)} " - f'"{get_preset_name(preset)}": ' + ", ".join(changes) - ) - - if unknown: - print( - f"[UNSURE] " - f"{preset_index_to_helix(preset_index)} " - f'"{get_preset_name(preset)}": ' + ", ".join(unknown) - ) - - return changed_count, already_count, unknown_count - - -def parse_args(): - parser = argparse.ArgumentParser( - description=( - "Turn blocks after cab or IR blocks stereo " - "where the conversion can be identified safely" - ) - ) - - parser.add_argument("-i", "--input", required=True, help="Input .hls or .hlx file") - - parser.add_argument("-o", "--output", required=True, help="Output .hls or .hlx file to create") - - return parser.parse_args() - - -def main(): - args = parse_args() - - try: - require_helix_input_path(args.input, "Input") - require_compatible_output_path(args.input, args.output, allow_json=False) - - json_text, original_hls_text = load_input(args.input) - data = json.loads(json_text) - - changed, already, unknown = stereofy(data) - - modified_json_text = json.dumps(data, indent=1) - - save_output(modified_json_text, args.output, original_hls_text) - - except Exception as e: - print() - print(f"ERROR: {e}") - print() - sys.exit(1) - - print() - print("[OK] Stereofy complete") - print(f"Input : {args.input}") - print(f"Output : {args.output}") - print(f"Changed blocks : {changed}") - print(f"Already stereo : {already}") - print(f"Unsure blocks : {unknown}") - print() - - -if __name__ == "__main__": - main() diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index ba99920..0606277 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -10,10 +10,7 @@ measurement, and device-specific adapters so more processors can be added later. - `src/matchpatch/` contains the installable package and console entry points. - `src/matchpatch/gui/` contains the PySide6 GUI. - `src/matchpatch/devices/` contains processor profile interfaces and the Helix - implementation. -- `Python/` contains legacy Helix file-manipulation utilities. The modern Helix - profile still delegates `.hls`/`.hlx` parsing and rewriting to - `Python/preset_handling.py`. + implementation, including `.hls`/`.hlx` parsing and rewriting helpers. - `scripts/` contains WSL/Windows environment, worker, installer build, and installer smoke-test wrappers. - `installer/` contains the Inno Setup script, PyInstaller specs, and @@ -171,8 +168,9 @@ The registry in `matchpatch.devices.registry` currently registers only changes, where internal preset ID `1` maps to program `0`. Snapshots use CC 69 with values `0..7`. -`HelixPatchFileHandler` shells out to `Python/preset_handling.py` with the -current Python interpreter. It delegates: +`HelixPatchFileHandler` runs `matchpatch.devices.helix_preset_handling` with the +current Python interpreter in development, and in-process in frozen builds. It +delegates: - assignment listing via `--list-presets` - metadata extraction via `--metadata` @@ -181,12 +179,12 @@ current Python interpreter. It delegates: - gain application via `--adjust-gain` Modern measurement CSVs use a generic `DevicePatch` column. Before passing them -to the legacy utility, the Helix handler writes a temporary legacy CSV that adds +to the Helix utility module, the Helix handler writes a temporary adapter CSV that adds or replaces `HelixPreset`. ## Helix File Processing -`Python/preset_handling.py` understands `.hls`, `.hlx`, and unpacked `.json`. +`matchpatch.devices.helix_preset_handling` understands `.hls`, `.hlx`, and unpacked `.json`. Setlists are stored as JSON wrappers whose `encoded_data` contains base64 zlib data; the script preserves wrapper fields while replacing encoded data, size, and CRC. Presets are JSON files and may contain either a top-level preset or a @@ -241,14 +239,12 @@ file values. `export_default_config` writes a complete TOML file containing normalization, analysis, measurement policy, and per-device routing/steering defaults. -## Legacy Utility Scripts +## Helix Utility Module -The `Python/` directory predates the package architecture. The main integrated -script is `preset_handling.py`; other checked-in scripts perform Helix-specific -batch transformations such as decrypting/encrypting HLS, listing cab presets, -replacing amp blocks, removing inactive blocks, resetting output levels, and -converting blocks to stereo. They are useful utilities but not the core -normalization API. +The Helix file utility code is packaged under `matchpatch.devices` so the GUI, +CLI, tests, and frozen builds use the same implementation. Older standalone +batch scripts were removed because they were not called by the MatchPatch GUI or +CLI applications. ## CI And Packaging diff --git a/docs/dev/commands.md b/docs/dev/commands.md index e5182e7..4373408 100644 --- a/docs/dev/commands.md +++ b/docs/dev/commands.md @@ -198,22 +198,20 @@ The GUI starts with the configured backend and can run loopback/simulated flows without Helix hardware. Hardware mode requires the native Windows environment and visible audio/MIDI endpoints. -## Legacy Helix Utilities +## Helix Utility Module Run from the repository root: ```bash -python3 Python/preset_handling.py --help -python3 Python/decrypt_hls.py --help -python3 Python/encrypt_hls.py --help +python3 -m matchpatch.devices.helix_preset_handling --help ``` Useful integrated operations: ```bash -python3 Python/preset_handling.py -i setlist.hls -o setlist_measurement.hls --measurement -python3 Python/preset_handling.py -i setlist.hls --list-presets -python3 Python/preset_handling.py -i current.hls --diff-presets previous.hls +python3 -m matchpatch.devices.helix_preset_handling -i setlist.hls -o setlist_measurement.hls --measurement +python3 -m matchpatch.devices.helix_preset_handling -i setlist.hls --list-presets +python3 -m matchpatch.devices.helix_preset_handling -i current.hls --diff-presets previous.hls ``` ## Build diff --git a/docs/dev/file-formats.md b/docs/dev/file-formats.md index aac75f7..01bfd70 100644 --- a/docs/dev/file-formats.md +++ b/docs/dev/file-formats.md @@ -13,9 +13,9 @@ The wrapper includes: - `encoded_data`: base64-encoded zlib-compressed JSON text. - compression metadata such as `decompressed_size` and `crc32`. -`Python/preset_handling.py` decodes `encoded_data`, edits the decompressed JSON, -then rebuilds the wrapper by replacing `encoded_data`, `decompressed_size`, and -`crc32`. Other wrapper fields are preserved. +`matchpatch.devices.helix_preset_handling` decodes `encoded_data`, edits the +decompressed JSON, then rebuilds the wrapper by replacing `encoded_data`, +`decompressed_size`, and `crc32`. Other wrapper fields are preserved. The decompressed setlist JSON is expected to contain a `presets` list. Each non-empty preset is assigned an internal numeric ID starting at `1`, and a Helix @@ -40,7 +40,7 @@ considered non-empty if its `tone` contains at least one `block*` entry under - a top-level preset object containing `tone`, or - a wrapper object containing a preset object in `data`. -Internally, the legacy utility wraps a single preset as: +Internally, the Helix utility wraps a single preset as: ```text {"presets": [preset]} @@ -57,7 +57,7 @@ measurement. ## Unpacked `.json` -The legacy Helix utility can also read and write unpacked JSON for selected +The Helix utility can also read and write unpacked JSON for selected utility modes. The modern `HelixPatchFileHandler` only accepts `.hls` and `.hlx` as normal workflow inputs and requires output files to use the same extension as the input. @@ -128,9 +128,9 @@ CSV files are written with UTF-8 and read with UTF-8-SIG so a BOM is tolerated. ## Measurement CSV: Helix Legacy Adapter -`Python/preset_handling.py` historically expects a `HelixPreset` column instead -of `DevicePatch`. `HelixPatchFileHandler.apply_analysis_csv` therefore writes a -temporary adapter CSV before invoking the legacy script. +`matchpatch.devices.helix_preset_handling` expects a `HelixPreset` column +instead of `DevicePatch`. `HelixPatchFileHandler.apply_analysis_csv` therefore writes a +temporary adapter CSV before invoking the packaged Helix utility module. Its columns are: @@ -203,7 +203,7 @@ preset/snapshot. ## Manual Adjustments JSON -The GUI passes manual table edits to the Helix legacy script as temporary JSON, +The GUI passes manual table edits to the Helix utility module as temporary JSON, not CSV. The payload can contain: ```json @@ -222,5 +222,5 @@ deltas for matching snapshots. Setlist diff selection compares current and previous files of the same type. The comparison removes non-signal content before comparing presets, including names, metadata, and color fields. Presets are selected when loudness-affecting -signal content differs. This feature is implemented by the legacy Helix utility +signal content differs. This feature is implemented by the Helix utility module and surfaced through `--diff-input` and the GUI diff button. diff --git a/installer/pyinstaller/build_support.py b/installer/pyinstaller/build_support.py index 32847c7..22a4d02 100644 --- a/installer/pyinstaller/build_support.py +++ b/installer/pyinstaller/build_support.py @@ -23,7 +23,6 @@ PROJECT_ROOT / "audio" / "reference-di" / "DI_Strandberg_Boden_Fusion_Bridge_Humbucker.wav" ) PAYLOAD_RUNTIME_FILES = [ - (PROJECT_ROOT / "Python" / "preset_handling.py", Path("Python") / "preset_handling.py"), ( REFERENCE_DI_SOURCE, Path("audio") / "reference-di" / "DI_Strandberg_Boden_Fusion_Bridge_Humbucker.wav", @@ -51,7 +50,6 @@ def git_sha() -> str: def asset_datas() -> list[tuple[str, str]]: return [ - (str(PROJECT_ROOT / "Python" / "preset_handling.py"), "Python"), (str(REFERENCE_DI_SOURCE), "audio/reference-di"), (str(PROJECT_ROOT / "docs" / "assets" / "matchmatch-icon.png"), "docs/assets"), (str(PROJECT_ROOT / "docs" / "assets" / "matchmatch-icon-512.png"), "docs/assets"), diff --git a/installer/pyinstaller/matchpatch-gui.spec b/installer/pyinstaller/matchpatch-gui.spec index 89933bb..8509960 100644 --- a/installer/pyinstaller/matchpatch-gui.spec +++ b/installer/pyinstaller/matchpatch-gui.spec @@ -30,7 +30,11 @@ a = Analysis( pathex=[str(PROJECT_ROOT / "src")], binaries=[], datas=asset_datas(), - hiddenimports=["mido.backends.rtmidi", "rtmidi"], + hiddenimports=[ + "matchpatch.devices.helix_preset_handling", + "mido.backends.rtmidi", + "rtmidi", + ], hookspath=[], hooksconfig={}, runtime_hooks=[], diff --git a/pyproject.toml b/pyproject.toml index a619a22..1e3a1ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,7 +99,8 @@ select = ["ANN", "C901", "E4", "E7", "E9", "F", "I"] max-complexity = 10 [tool.ruff.lint.per-file-ignores] -"Python/**" = ["ANN"] +"src/matchpatch/devices/helix_file_ops.py" = ["ANN"] +"src/matchpatch/devices/helix_preset_handling.py" = ["ANN", "C901"] "tests/**" = ["ANN"] [tool.ty.src] diff --git a/scripts/check_maintainability.py b/scripts/check_maintainability.py index 55025e7..6aa4efc 100644 --- a/scripts/check_maintainability.py +++ b/scripts/check_maintainability.py @@ -15,15 +15,15 @@ LINE_LIMITS: tuple[tuple[str, int], ...] = ( ("src/matchpatch/gui/main_window.py", 2500), ("tests/test_gui.py", 1600), + ("src/matchpatch/devices/helix_preset_handling.py", 2000), ("src/matchpatch/gui/*.py", 2000), ("src/matchpatch/**/*.py", 2000), - ("Python/*.py", 2000), ("tests/test_*.py", 1600), ("scripts/*.py", 800), ) MAX_CYCLOMATIC_COMPLEXITY = 10 -COMPLEXITY_PATHS = ("src/matchpatch/**/*.py", "Python/*.py", "scripts/*.py", "tests/**/*.py") +COMPLEXITY_PATHS = ("src/matchpatch/**/*.py", "scripts/*.py", "tests/**/*.py") @dataclass(frozen=True) diff --git a/src/matchpatch/devices/helix.py b/src/matchpatch/devices/helix.py index d87a709..17ab966 100644 --- a/src/matchpatch/devices/helix.py +++ b/src/matchpatch/devices/helix.py @@ -4,7 +4,6 @@ import contextlib import csv -import importlib.util import io import json import runpy @@ -17,6 +16,7 @@ from types import TracebackType from typing import Any +from matchpatch.devices import helix_file_ops from matchpatch.devices.base import ( AudioRouting, DeviceController, @@ -36,7 +36,8 @@ class HelixPatchFileHandler(PatchFileHandler): def __init__(self, project_dir: Path) -> None: - self.script = project_dir / "Python" / "preset_handling.py" + self.project_dir = project_dir + self.module = "matchpatch.devices.helix_preset_handling" self.log_callback: Callable[[str], None] | None = None def set_log_callback(self, callback: Callable[[str], None] | None) -> None: @@ -55,7 +56,7 @@ def _run( try: completed = subprocess.run( - [sys.executable, str(self.script), *(str(arg) for arg in args)], + [sys.executable, "-m", self.module, *(str(arg) for arg in args)], check=True, text=True, stdout=subprocess.PIPE if should_capture else None, @@ -79,11 +80,11 @@ def _run_in_process( log_output: bool = True, ) -> subprocess.CompletedProcess[str]: should_capture = capture or self.log_callback is not None - command = [str(self.script), *(str(arg) for arg in args)] + command = [self.module, *(str(arg) for arg in args)] original_argv = sys.argv stdout = io.StringIO() stderr = io.StringIO() - sys.argv = command + sys.argv = [self.module, *(str(arg) for arg in args)] try: stdout_context = ( contextlib.redirect_stdout(stdout) if should_capture else contextlib.nullcontext() @@ -93,7 +94,7 @@ def _run_in_process( ) with stdout_context, stderr_context: try: - runpy.run_path(str(self.script), run_name="__main__") + runpy.run_module(self.module, run_name="__main__") returncode = 0 except SystemExit as exc: returncode = exc.code if isinstance(exc.code, int) else 1 @@ -210,8 +211,7 @@ def split_setlist_file( raise ValueError(f"Helix split input must be an .hls file: {input_path}") output_dir.mkdir(parents=True, exist_ok=True) - file_ops = _load_helix_file_ops(self.script) - split_presets = file_ops.split_setlist_to_preset_data( + split_presets = _load_helix_file_ops().split_setlist_to_preset_data( input_path, selected_ids=selected_ids, original_filenames=original_filenames, @@ -593,18 +593,5 @@ def _error_details(exc: subprocess.CalledProcessError) -> str: return lines[-1].strip() if lines else "" -def _load_helix_file_ops(script: Path) -> Any: # noqa: ANN401 - module_path = script.with_name("helix_file_ops.py") - module_name = "_matchpatch_helix_file_ops" - module = sys.modules.get(module_name) - if module is not None: - return module - - spec = importlib.util.spec_from_file_location(module_name, module_path) - if spec is None or spec.loader is None: - raise RuntimeError(f"Unable to load Helix file operations helper: {module_path}") - - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) - return module +def _load_helix_file_ops() -> Any: # noqa: ANN401 + return helix_file_ops diff --git a/Python/helix_file_ops.py b/src/matchpatch/devices/helix_file_ops.py similarity index 100% rename from Python/helix_file_ops.py rename to src/matchpatch/devices/helix_file_ops.py diff --git a/Python/preset_handling.py b/src/matchpatch/devices/helix_preset_handling.py similarity index 99% rename from Python/preset_handling.py rename to src/matchpatch/devices/helix_preset_handling.py index dcc46ea..63ef92b 100644 --- a/Python/preset_handling.py +++ b/src/matchpatch/devices/helix_preset_handling.py @@ -10,20 +10,24 @@ import sys from dataclasses import dataclass -_LEGACY_DIR = os.path.dirname(__file__) -if _LEGACY_DIR not in sys.path: - sys.path.insert(0, _LEGACY_DIR) - -from helix_file_ops import ( # noqa: E402, F401, I001 +from matchpatch.devices.helix_file_ops import ( # noqa: F401 build_hls_text, build_new_hls_text, decode_hls_text, - helix_to_preset_index as helix_to_preset_index, join_preset_files_to_setlist, + split_setlist_to_preset_data, +) +from matchpatch.devices.helix_file_ops import ( + helix_to_preset_index as helix_to_preset_index, +) +from matchpatch.devices.helix_file_ops import ( load_preset_file as load_preset_file, +) +from matchpatch.devices.helix_file_ops import ( load_setlist_file as load_setlist_file, +) +from matchpatch.devices.helix_file_ops import ( safe_preset_filename as safe_preset_filename, - split_setlist_to_preset_data, ) # ================================================= diff --git a/tests/README.md b/tests/README.md index 016059f..ecd550f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -36,15 +36,15 @@ Do not rely on bare `pytest`; it may not be on `PATH`. - `test_config.py`: TOML config loading, default export, precedence helpers, and channel mapping parsing. - `test_devices.py`: device registry and base device contracts. -- `test_helix.py`: Helix patch IDs, file-handler delegation to the legacy - utility, MIDI steering, metadata/diff handling, and CSV translation. +- `test_helix.py`: Helix patch IDs, file-handler delegation to the packaged + utility module, MIDI steering, metadata/diff handling, and CSV translation. - `test_measure.py`: native measurement worker behavior, loopback and simulated backends, hardware backend wiring via mocks, CSV output, progress events, and worker CLI parsing. - `test_normalize.py`: WSL orchestration, config merging, Windows command construction, cancellation, retained CSV handling, deferred export, and workflow error handling. -- `test_preset_handling.py`: legacy Helix gain math, custom/manual adjustments, +- `test_preset_handling.py`: Helix gain math, custom/manual adjustments, routing conversion, and name validation. - `test_progress.py`: structured progress JSON serialization. - `test_gui_app.py`: GUI app bootstrap helpers, WSLg runtime, desktop entry, and @@ -69,8 +69,8 @@ fixtures. Important recurring fixtures/helpers: - `capsys`: asserts CLI stdout/stderr and error reporting. - `app` fixture in GUI tests: module-scoped `QApplication`, with `QT_QPA_PLATFORM=offscreen`. -- `load_legacy_preset_handling`: imports `Python/preset_handling.py` directly so - legacy behavior can be tested without installing it as a package module. +- `load_legacy_preset_handling`: imports `matchpatch.devices.helix_preset_handling` + so Helix file behavior can be tested through the packaged module. - `FakePatchFileHandler`, `FakeDeviceProfile`, and GUI-local fake dialogs/workers isolate workflow and UI behavior from real files and devices. diff --git a/tests/test_helix.py b/tests/test_helix.py index 3287cfe..aede701 100644 --- a/tests/test_helix.py +++ b/tests/test_helix.py @@ -1,7 +1,7 @@ from __future__ import annotations import csv -import importlib.util +import importlib import json import subprocess import sys @@ -23,13 +23,7 @@ def load_legacy_preset_handling(): - script_path = Path(__file__).resolve().parents[1] / "Python" / "preset_handling.py" - spec = importlib.util.spec_from_file_location("preset_handling", script_path) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module + return importlib.import_module("matchpatch.devices.helix_preset_handling") def make_handler(tmp_path: Path) -> HelixPatchFileHandler: @@ -210,7 +204,7 @@ def split_setlist_to_preset_data(input_path, selected_ids=None, original_filenam seen["original_filenames"] = original_filenames return [("../Lead.hlx", {"meta": {"name": "Lead"}, "tone": {}})] - monkeypatch.setattr(helix_module, "_load_helix_file_ops", lambda script: Helper) + monkeypatch.setattr(helix_module, "_load_helix_file_ops", lambda: Helper) created = handler.split_setlist_file( Path("set.hls"), @@ -417,7 +411,7 @@ def test_legacy_snapshot_diff_tracks_snapshot_assigned_parameter_values(tmp_path } -def test_legacy_script_runner_builds_subprocess_call(tmp_path, monkeypatch) -> None: +def test_helix_module_runner_builds_subprocess_call(tmp_path, monkeypatch) -> None: handler = make_handler(tmp_path) calls = [] completed = subprocess.CompletedProcess([], 0, stdout="ok") @@ -427,48 +421,53 @@ def test_legacy_script_runner_builds_subprocess_call(tmp_path, monkeypatch) -> N assert handler._run("--list-presets", capture=True) is completed command, options = calls[0] - assert command[0][0] == sys.executable + assert command[0][:3] == [ + sys.executable, + "-m", + "matchpatch.devices.helix_preset_handling", + ] assert command[0][-1] == "--list-presets" assert options["stdout"] is subprocess.PIPE assert options["stderr"] is subprocess.PIPE -def test_frozen_legacy_script_runner_executes_in_process(tmp_path, monkeypatch) -> None: - script = tmp_path / "Python" / "preset_handling.py" - script.parent.mkdir() - script.write_text( - "import sys\n" - "print('args=' + ','.join(sys.argv[1:]))\n" - "print('error stream', file=sys.stderr)\n", - encoding="utf-8", - ) +def test_frozen_helix_module_runner_executes_in_process(tmp_path, monkeypatch) -> None: handler = make_handler(tmp_path) original_argv = sys.argv[:] + + def fake_run_module(module, run_name): + assert module == "matchpatch.devices.helix_preset_handling" + assert run_name == "__main__" + print("args=" + ",".join(sys.argv[1:])) + print("error stream", file=sys.stderr) + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(helix_module.runpy, "run_module", fake_run_module) monkeypatch.setattr( subprocess, "run", - lambda *args, **kwargs: pytest.fail("frozen legacy runner must not spawn a subprocess"), + lambda *args, **kwargs: pytest.fail("frozen Helix runner must not spawn a subprocess"), ) completed = handler._run("--list-presets", capture=True) - assert completed.args == [str(script), "--list-presets"] + assert completed.args == ["matchpatch.devices.helix_preset_handling", "--list-presets"] assert completed.returncode == 0 assert completed.stdout == "args=--list-presets\n" assert completed.stderr == "error stream\n" assert sys.argv == original_argv -def test_frozen_legacy_script_runner_raises_called_process_error(tmp_path, monkeypatch) -> None: - script = tmp_path / "Python" / "preset_handling.py" - script.parent.mkdir() - script.write_text( - "import sys\nprint('before exit')\nprint('failed', file=sys.stderr)\nraise SystemExit(2)\n", - encoding="utf-8", - ) +def test_frozen_helix_module_runner_raises_called_process_error(tmp_path, monkeypatch) -> None: handler = make_handler(tmp_path) + + def fake_run_module(module, run_name): + print("before exit") + print("failed", file=sys.stderr) + raise SystemExit(2) + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(helix_module.runpy, "run_module", fake_run_module) with pytest.raises(subprocess.CalledProcessError) as exc: handler._run("--metadata", capture=True) diff --git a/tests/test_installer_metadata.py b/tests/test_installer_metadata.py index b49c003..7d6ff40 100644 --- a/tests/test_installer_metadata.py +++ b/tests/test_installer_metadata.py @@ -120,7 +120,9 @@ def test_pyinstaller_specs_include_payload_metadata_docs_and_assets() -> None: assert 'name="MatchPatch"' in gui_spec assert "console=False" in gui_spec assert "datas=asset_datas()" in gui_spec - assert 'hiddenimports=["mido.backends.rtmidi", "rtmidi"]' in gui_spec + assert '"matchpatch.devices.helix_preset_handling"' in gui_spec + assert '"mido.backends.rtmidi"' in gui_spec + assert '"rtmidi"' in gui_spec assert '"src" / "matchpatch" / "app.py"' in gui_spec assert "prepare_installer_assets()" in gui_spec assert 'prepare_pyinstaller_paths(Path(CONF["workpath"]), Path(CONF["distpath"]))' in gui_spec @@ -128,9 +130,7 @@ def test_pyinstaller_specs_include_payload_metadata_docs_and_assets() -> None: assert "stage_runtime_files()" in gui_spec assert "stage_docs()" in gui_spec assert "write_build_info()" in gui_spec - assert "PAYLOAD_RUNTIME_FILES" in build_support - assert '"Python" / "preset_handling.py"' in build_support assert '"audio" / "reference-di"' in build_support assert "DI_Strandberg_Boden_Fusion_Bridge_Humbucker.wav" in build_support assert '"docs_html"' in build_support @@ -187,7 +187,6 @@ def test_runtime_files_are_staged_at_payload_root(tmp_path: Path) -> None: build_support.stage_runtime_files(payload_root) - assert (payload_root / "Python" / "preset_handling.py").is_file() assert ( payload_root / "audio" / "reference-di" / "DI_Strandberg_Boden_Fusion_Bridge_Humbucker.wav" ).is_file() diff --git a/tests/test_preset_handling.py b/tests/test_preset_handling.py index c6e66ff..c2598dc 100644 --- a/tests/test_preset_handling.py +++ b/tests/test_preset_handling.py @@ -2,23 +2,16 @@ import base64 import binascii -import importlib.util +import importlib import json import zlib -from pathlib import Path from types import ModuleType import pytest def _load_legacy_module() -> ModuleType: - script = Path(__file__).resolve().parents[1] / "Python" / "preset_handling.py" - spec = importlib.util.spec_from_file_location("preset_handling", script) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module + return importlib.import_module("matchpatch.devices.helix_preset_handling") def _preset(name: str) -> dict: From bdb42957561a054ea2a42fb024511ddacb020fd5 Mon Sep 17 00:00:00 2001 From: Noseglasses Date: Wed, 17 Jun 2026 23:00:09 +0200 Subject: [PATCH 04/10] feat: Refactor large files and resturcture --- README.md | 1 + docs/device_plugins.md | 144 ++++ docs/index.md | 2 + examples/device_plugin/README.md | 22 + examples/device_plugin/pyproject.toml | 15 + .../src/matchpatch_example_device/__init__.py | 142 ++++ pyproject.toml | 3 + src/matchpatch/config.py | 42 +- src/matchpatch/device_settings.py | 209 +++++ src/matchpatch/devices/base.py | 735 +++++++++++++++++- src/matchpatch/devices/helix.py | 190 ++++- src/matchpatch/diagnostics.py | 5 + src/matchpatch/gui/device_panel_registry.py | 66 ++ src/matchpatch/gui/device_panels.py | 8 + .../gui/file_operations_workflow.py | 86 +- src/matchpatch/gui/file_type_filters.py | 64 ++ src/matchpatch/gui/main_window.py | 34 +- src/matchpatch/gui/main_window_callbacks.py | 18 + src/matchpatch/gui/name_rules.py | 85 ++ src/matchpatch/gui/preset_table.py | 35 +- src/matchpatch/gui/settings_renderer.py | 240 ++++++ src/matchpatch/measure.py | 584 +++++++++----- src/matchpatch/normalize.py | 109 +-- src/matchpatch/preflight.py | 36 +- src/matchpatch/workflow.py | 126 ++- tests/test_config.py | 26 + tests/test_devices.py | 389 +++++++++ tests/test_diagnostics.py | 8 + tests/test_gui.py | 5 + tests/test_gui_device_panel_plugins.py | 96 +++ tests/test_gui_preset_table.py | 38 + tests/test_gui_save_workflow.py | 91 ++- tests/test_gui_settings_renderer.py | 317 ++++++++ tests/test_helix.py | 48 +- tests/test_measure.py | 211 ++++- tests/test_normalize.py | 111 ++- tests/test_preflight.py | 63 ++ 37 files changed, 4004 insertions(+), 400 deletions(-) create mode 100644 docs/device_plugins.md create mode 100644 examples/device_plugin/README.md create mode 100644 examples/device_plugin/pyproject.toml create mode 100644 examples/device_plugin/src/matchpatch_example_device/__init__.py create mode 100644 src/matchpatch/device_settings.py create mode 100644 src/matchpatch/gui/device_panel_registry.py create mode 100644 src/matchpatch/gui/file_type_filters.py create mode 100644 src/matchpatch/gui/name_rules.py create mode 100644 src/matchpatch/gui/settings_renderer.py create mode 100644 tests/test_gui_device_panel_plugins.py create mode 100644 tests/test_gui_settings_renderer.py diff --git a/README.md b/README.md index 2400730..ef74aea 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ Technical details live in the developer docs: - [Developer Notes](docs/developer-notes.md) - [Architecture](docs/dev/architecture.md) - [Commands](docs/dev/commands.md) +- [Device Plugins](docs/device_plugins.md) - [File Formats](docs/dev/file-formats.md) ## License diff --git a/docs/device_plugins.md b/docs/device_plugins.md new file mode 100644 index 0000000..09fab6f --- /dev/null +++ b/docs/device_plugins.md @@ -0,0 +1,144 @@ +# Device Plugins + +MatchPatch discovers third-party device profiles with the +`matchpatch.devices` Python entry point group. The built-in Helix profile is +registered directly in `matchpatch.devices.registry`; plugins use packaging +metadata instead. + +```toml +[project.entry-points."matchpatch.devices"] +my-device = "my_package.matchpatch_plugin:MyDeviceProfile" +``` + +The loaded object may be a `DeviceProfile` instance, a `DeviceProfile` subclass, +or an iterable of `DeviceProfile` instances. Every profile must expose a unique +non-empty `name`, a non-empty `display_name`, and +`create_patch_file_handler(project_dir)`. Duplicate names and import errors are +recorded by the registry and reported by `plugin_load_errors()` or when an +unknown device is requested. + +## Profile Contract + +Device plugins implement `matchpatch.devices.base.DeviceProfile`. The required +methods are: + +- `create_patch_file_handler(project_dir)`: returns the file adapter for the + device. +- `default_audio_routing()`: returns an `AudioRouting` default for device name, + sample rate, and one-based stereo input/output channels. +- `default_steering_options()`: returns `SteeringOptions` for MIDI or other + target selection timing defaults. +- `create_controller(options)`: returns a `DeviceController` that can activate + numeric presets and one-based subdivisions for hardware-style measurement. + +Profiles may also override `terminology()`, `file_capabilities()`, +`measurement_backends()`, `audio_transport_factories()`, +`diagnostics_provider()`, `naming_rules()`, `setting_descriptors()`, and +`format_patch_id()`. + +## Settings Descriptors + +`DeviceSettingDescriptor` is the GUI-free settings surface. MatchPatch uses it +to resolve defaults, config paths, CLI flags, validation, diagnostics, and the +generic GUI settings panel. + +The default `DeviceProfile.setting_descriptors()` returns descriptors for: + +- `audio_device`, `sample_rate`, `input_mapping`, `output_mapping`, and + `blocksize` under the `audio` scope. +- `midi_output`, `midi_channel`, `preset_wait`, `snapshot_wait`, and + `measurement_wait` under the `steering` scope. + +Descriptors support `string`, `integer`, `float`, `boolean`, `choice`, `path`, +and `channel_mapping` kinds. Numeric ranges, choices, `required`, labels, help +text, config paths, and CLI flags are enforced or rendered by the existing +settings code. Unknown settings are currently ignored by +`DeviceProfile.validate_settings()`. + +## Diagnostics Providers + +A profile can return a `DiagnosticsProvider` from `diagnostics_provider()`. +During preflight, MatchPatch builds a `DiagnosticsContext` containing the +normalization request, profile, file handler, resolved device settings, and +project directory, then calls `run_checks(context)`. + +The provider returns `DiagnosticCheck` objects from `matchpatch.diagnostics`. +Provider exceptions are caught and turned into a failed `device_diagnostics` +check, so a broken plugin does not stop the rest of preflight. + +## Audio Transports + +Profiles can provide custom audio backends with `audio_transport_factories()`. +Each `AudioTransportFactory` has `capabilities`, `supports(mode, settings)`, +and `create(context)`. MatchPatch checks plugin factories before its built-in +hardware, loopback, and simulated factories. + +`AudioTransportCapabilities` declares the backend mode and behavior such as +sample rates, channel layout, real-time/offline operation, async operation, +alignment guarantees, and debug-output support. The supported backend names +advertised by `measurement_backends()` must include any plugin-only mode the +profile expects to use, such as `offline`. + +The created transport implements either `process(reference_audio)` for +real-time style processing or `process_offline(request)` for offline rendering. + +## Target Model + +Patch handlers expose measurable content as `MeasurementTarget` objects. A +target has an `id`, display label, zero-based `index`, name, optional source +filename, optional `compat_numeric_id`, target-level gain points, and +subdivisions. + +Subdivisions are `MeasurementSubdivision` objects. The built-in Helix profile +uses snapshots as subdivisions, but plugins can use string IDs and labels for +other device concepts. Legacy numeric preset/snapshot helpers are still present: +if a handler only implements `list_assignments()`, MatchPatch adapts +`PatchAssignment` values into measurement targets and subdivisions. + +## File Handlers And Capabilities + +`PatchFileHandler` is responsible for device-owned files. Required methods +validate input/output paths, list assignments, parse numeric preset selectors, +select presets, format numeric IDs, create measurement files, apply analysis +CSVs, and build automation output paths. + +Optional methods describe richer devices: + +- `file_types()` and `file_kind()` advertise user-facing extensions. +- `file_capabilities()` returns `FileOperationCapabilities`, which gates GUI and + command support for reading/writing preset files, reading/writing setlist + files, joining presets into setlists, splitting setlists, replacing setlist + slots, and exporting selected slots. +- `list_targets()`, `parse_target_set()`, `select_targets()`, + `diff_targets()`, and `diff_subdivisions()` support non-Helix target IDs. +- `list_gain_points()` and `apply_gain_adjustments()` support target-level or + subdivision-level gain points. + +The default capabilities are all false, and unsupported optional operations +raise `NotImplementedError`. + +## Optional GUI Panels + +GUI-only plugins can register a settings panel factory with the +`matchpatch.device_gui_panels` entry point group: + +```toml +[project.entry-points."matchpatch.device_gui_panels"] +my-device-panel = "my_package.matchpatch_gui:MyPanelFactory" +``` + +The loaded object may be a factory object or a callable returning one. It must +define `device_name` and `create_panel(profile, backend_selector)`. If +`device_name` matches the active profile name, the returned `QWidget` replaces +the built-in or descriptor-rendered settings panel. GUI panel load errors are +logged and recorded by `matchpatch.gui.device_panel_registry.plugin_load_errors()`. + +Plugins that do not need custom Qt controls should prefer settings descriptors; +the generic renderer handles the current descriptor kinds. + +## Minimal Example + +A small read-only example plugin lives in `examples/device_plugin/`. It shows +the package metadata entry point and the minimum profile/file-handler classes +needed for discovery. It is intentionally not a complete processor integration: +the file handler lists no targets and raises for measurement and save operations. diff --git a/docs/index.md b/docs/index.md index 299b943..9f10bad 100644 --- a/docs/index.md +++ b/docs/index.md @@ -67,6 +67,7 @@ Normal users should not need this section. - [Developer Notes](developer-notes.md) - [Existing technical docs](dev/architecture.md) - [Development Commands](dev/commands.md) +- [Device Plugins](device_plugins.md) - [Release Checklist](dev/release.md) ```{toctree} @@ -107,6 +108,7 @@ glossary developer-notes dev/architecture dev/commands +device_plugins dev/file-formats dev/release ``` diff --git a/examples/device_plugin/README.md b/examples/device_plugin/README.md new file mode 100644 index 0000000..4c9a524 --- /dev/null +++ b/examples/device_plugin/README.md @@ -0,0 +1,22 @@ +# MatchPatch Example Device Plugin + +This package is a minimal, read-only example of the +`matchpatch.devices` entry point. It is useful as a starting point for plugin +discovery and settings experiments, not as a complete device integration. + +Install it into the same environment as MatchPatch while developing: + +```bash +pip install -e examples/device_plugin +``` + +The important metadata is: + +```toml +[project.entry-points."matchpatch.devices"] +example-device = "matchpatch_example_device:ExampleDeviceProfile" +``` + +After installation, `matchpatch --device example-device ...` can discover the +profile, but normalization still needs real file parsing, measurement-file +creation, and adjustment application before it can be useful. diff --git a/examples/device_plugin/pyproject.toml b/examples/device_plugin/pyproject.toml new file mode 100644 index 0000000..7736108 --- /dev/null +++ b/examples/device_plugin/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "matchpatch-example-device-plugin" +version = "0.1.0" +description = "Minimal MatchPatch device plugin example" +requires-python = ">=3.12" +dependencies = [ + "matchpatch", +] + +[project.entry-points."matchpatch.devices"] +example-device = "matchpatch_example_device:ExampleDeviceProfile" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/examples/device_plugin/src/matchpatch_example_device/__init__.py b/examples/device_plugin/src/matchpatch_example_device/__init__.py new file mode 100644 index 0000000..03cb4c0 --- /dev/null +++ b/examples/device_plugin/src/matchpatch_example_device/__init__.py @@ -0,0 +1,142 @@ +"""Minimal read-only MatchPatch device plugin example.""" + +from __future__ import annotations + +from pathlib import Path + +from matchpatch.devices.base import ( + AudioRouting, + DeviceController, + DeviceFileKind, + DeviceFileType, + DeviceProfile, + DeviceSettingDescriptor, + FileOperationCapabilities, + NormalizationPolicy, + PatchAssignment, + PatchFileAdjustments, + PatchFileHandler, + SteeringOptions, +) + + +class ExampleController(DeviceController): + def activate_preset(self, preset_id: int) -> None: + raise NotImplementedError("Example device does not implement hardware steering") + + def reapply_snapshot(self, snapshot: int) -> None: + raise NotImplementedError("Example device does not implement hardware steering") + + +class ExamplePatchFileHandler(PatchFileHandler): + def validate_input(self, input_path: Path) -> None: + if input_path.suffix.lower() != ".examplebank": + raise ValueError("Example device inputs must use the .examplebank extension") + + def validate_output(self, input_path: Path, output_path: Path) -> None: + if output_path.suffix.lower() != ".examplebank": + raise ValueError("Example device outputs must use the .examplebank extension") + + def list_assignments(self, input_path: Path) -> list[PatchAssignment]: + self.validate_input(input_path) + return [] + + def file_capabilities(self) -> FileOperationCapabilities: + return FileOperationCapabilities(reads_setlist_files=True) + + def file_types(self) -> tuple[DeviceFileType, ...]: + return ( + DeviceFileType( + kind="setlist", + extensions=(".examplebank",), + description="Example Device Banks", + can_save=False, + ), + ) + + def file_kind(self, path: Path) -> DeviceFileKind: + if path.suffix.lower() == ".examplebank": + return "setlist" + return "unknown" + + def parse_patch_set(self, value: str) -> list[int]: + return [int(item.strip()) for item in value.split(",") if item.strip()] + + def select_preset_ids( + self, + input_path: Path, + assignments: list[PatchAssignment], + requested_ids: list[int] | None, + ) -> list[int]: + if requested_ids is not None: + return requested_ids + return [assignment.id for assignment in assignments] + + def format_patch_id(self, preset_id: int) -> str: + return f"EX{preset_id:03d}" + + def create_measurement_file(self, input_path: Path, output_path: Path) -> None: + raise NotImplementedError("Example device does not create measurement files") + + def apply_analysis_csv( + self, + input_path: Path, + output_path: Path, + csv_path: Path, + ignore_bad_lufs: bool, + target_lufs: float, + policy: NormalizationPolicy, + custom_adjustments_path: Path | None = None, + adjustments: PatchFileAdjustments | None = None, + ) -> None: + raise NotImplementedError("Example device does not apply analysis CSVs") + + def automation_output_path(self, input_path: Path, postfix: str) -> Path: + return input_path.with_name(f"{input_path.stem}{postfix}{input_path.suffix}") + + +class ExampleDeviceProfile(DeviceProfile): + name = "example-device" + display_name = "Example Device" + + def create_patch_file_handler(self, project_dir: Path) -> PatchFileHandler: + return ExamplePatchFileHandler() + + def default_audio_routing(self) -> AudioRouting: + return AudioRouting(None, 48000, (1, 2), (1, 2)) + + def default_steering_options(self) -> SteeringOptions: + return SteeringOptions(None, 0, 0.0, 0.0, 0.0) + + def create_controller(self, options: SteeringOptions) -> DeviceController: + return ExampleController() + + def setting_descriptors(self) -> tuple[DeviceSettingDescriptor, ...]: + audio = self.default_audio_routing() + return ( + DeviceSettingDescriptor( + name="audio_device", + scope="audio", + kind="string", + default=audio.device, + config_path=("devices", self.name, "audio", "device"), + cli_flags=("--audio-device",), + label="Audio device", + ), + DeviceSettingDescriptor( + name="sample_rate", + scope="audio", + kind="integer", + default=audio.sample_rate, + config_path=("devices", self.name, "audio", "sample_rate"), + cli_flags=("--sample-rate",), + label="Sample rate", + minimum=1, + ), + ) + + def file_capabilities(self) -> FileOperationCapabilities: + return FileOperationCapabilities(reads_setlist_files=True) + + +__all__ = ["ExampleDeviceProfile", "ExamplePatchFileHandler"] diff --git a/pyproject.toml b/pyproject.toml index 1e3a1ce..c8688c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,9 @@ matchpatch-gui = "matchpatch.gui.app:main" # my-device = "my_package.matchpatch_plugin:DeviceProfile" # Built-in profiles are registered directly in matchpatch.devices.registry. +[project.entry-points."matchpatch.device_gui_panels"] +# Optional GUI-only panel factories for device plugins. + [dependency-groups] docs = [ "furo>=2024.8.6", diff --git a/src/matchpatch/config.py b/src/matchpatch/config.py index 4fb87f8..5f9e60a 100644 --- a/src/matchpatch/config.py +++ b/src/matchpatch/config.py @@ -9,7 +9,7 @@ from typing import Any from matchpatch.devices import list_device_profiles -from matchpatch.devices.base import NormalizationPolicy +from matchpatch.devices.base import DeviceSettingDescriptor, NormalizationPolicy Config = dict[str, Any] @@ -131,28 +131,32 @@ def default_config() -> Config: devices = config["devices"] assert isinstance(devices, dict) for profile in list_device_profiles(): - audio = profile.default_audio_routing() - steering = profile.default_steering_options() - devices[profile.name] = { - "audio": { - "device": audio.device, - "sample_rate": audio.sample_rate, - "input_mapping": list(audio.input_mapping), - "output_mapping": list(audio.output_mapping), - "blocksize": 0, - }, - "steering": { - "output": steering.output, - "channel": steering.channel, - "preset_wait_seconds": steering.preset_wait_seconds, - "snapshot_wait_seconds": steering.snapshot_wait_seconds, - "measurement_wait_seconds": steering.measurement_wait_seconds, - }, - } + devices[profile.name] = _default_device_config(profile.setting_descriptors()) return config +def _default_device_config( + descriptors: tuple[DeviceSettingDescriptor, ...], +) -> dict[str, Any]: + device_config: dict[str, Any] = {} + for descriptor in descriptors: + if len(descriptor.config_path) < 4: + continue + section = descriptor.config_path[-2] + key = descriptor.config_path[-1] + section_config = device_config.setdefault(section, {}) + assert isinstance(section_config, dict) + section_config[key] = _config_default_value(descriptor.default) + return device_config + + +def _config_default_value(value: object) -> object: + if isinstance(value, tuple): + return list(value) + return value + + def export_default_config(path: str | Path) -> Path: return export_config(path, default_config()) diff --git a/src/matchpatch/device_settings.py b/src/matchpatch/device_settings.py new file mode 100644 index 0000000..f0d9b2e --- /dev/null +++ b/src/matchpatch/device_settings.py @@ -0,0 +1,209 @@ +"""Descriptor-based device setting resolution.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import Any, cast + +from matchpatch.config import Config, config_value, parse_channel_mapping +from matchpatch.devices.base import ( + AudioRouting, + DeviceProfile, + DeviceSettingDescriptor, + SteeringOptions, +) + +DeviceSettings = dict[str, object] + +_ARG_ATTRS = { + "midi_output": "steering_output", + "midi_channel": "steering_channel", +} + + +def resolve_device_settings( + profile: DeviceProfile, + config: Config, + args: object, +) -> DeviceSettings: + """Resolve descriptor defaults, TOML config values, and CLI args.""" + settings: DeviceSettings = {} + + for descriptor in _setting_descriptors(profile): + value = _arg_value(args, descriptor) + if value is None and descriptor.config_path: + value = config_value(config, *descriptor.config_path, default=None) + if value is None: + value = descriptor.default + settings[descriptor.name] = _coerce_setting_value(descriptor, value) + + if hasattr(profile, "validate_settings"): + profile.validate_settings(settings) + return settings + + +def settings_to_audio_routing( + profile: DeviceProfile, + settings: Mapping[str, object], +) -> AudioRouting: + defaults = profile.default_audio_routing() + return AudioRouting( + device=cast("str | int | None", settings.get("audio_device", defaults.device)), + sample_rate=cast("int", settings.get("sample_rate", defaults.sample_rate)), + input_mapping=cast( + "tuple[int, int]", settings.get("input_mapping", defaults.input_mapping) + ), + output_mapping=cast( + "tuple[int, int]", settings.get("output_mapping", defaults.output_mapping) + ), + ) + + +def settings_to_steering_options( + profile: DeviceProfile, + settings: Mapping[str, object], +) -> SteeringOptions: + defaults = profile.default_steering_options() + return SteeringOptions( + output=cast("str | None", settings.get("midi_output", defaults.output)), + channel=cast("int", settings.get("midi_channel", defaults.channel)), + preset_wait_seconds=cast( + "float", settings.get("preset_wait", defaults.preset_wait_seconds) + ), + snapshot_wait_seconds=cast( + "float", settings.get("snapshot_wait", defaults.snapshot_wait_seconds) + ), + measurement_wait_seconds=cast( + "float", settings.get("measurement_wait", defaults.measurement_wait_seconds) + ), + ) + + +def setting_diagnostics(settings: Mapping[str, object]) -> dict[str, object]: + """Return non-sensitive setting values suitable for diagnostics output.""" + diagnostics = dict(settings) + if "midi_output" in diagnostics: + diagnostics["steering_output"] = diagnostics["midi_output"] + if "midi_channel" in diagnostics: + diagnostics["steering_channel"] = diagnostics["midi_channel"] + return diagnostics + + +def _arg_value(args: object, descriptor: DeviceSettingDescriptor) -> object | None: + attr = _ARG_ATTRS.get(descriptor.name, descriptor.name) + return getattr(args, attr, None) + + +def _setting_descriptors(profile: DeviceProfile) -> tuple[DeviceSettingDescriptor, ...]: + if hasattr(profile, "setting_descriptors"): + return profile.setting_descriptors() + + audio = _default_audio(profile) + steering = _default_steering(profile) + name = getattr(profile, "name", "device") + audio_path = ("devices", name, "audio") + steering_path = ("devices", name, "steering") + return ( + DeviceSettingDescriptor( + name="audio_device", + scope="audio", + kind="string", + default=getattr(audio, "device", None), + config_path=(*audio_path, "device"), + ), + DeviceSettingDescriptor( + name="sample_rate", + scope="audio", + kind="integer", + default=getattr(audio, "sample_rate", None), + config_path=(*audio_path, "sample_rate"), + ), + DeviceSettingDescriptor( + name="input_mapping", + scope="audio", + kind="channel_mapping", + default=getattr(audio, "input_mapping", None), + config_path=(*audio_path, "input_mapping"), + ), + DeviceSettingDescriptor( + name="output_mapping", + scope="audio", + kind="channel_mapping", + default=getattr(audio, "output_mapping", None), + config_path=(*audio_path, "output_mapping"), + ), + DeviceSettingDescriptor( + name="blocksize", + scope="audio", + kind="integer", + default=0, + config_path=(*audio_path, "blocksize"), + ), + DeviceSettingDescriptor( + name="midi_output", + scope="steering", + kind="string", + default=getattr(steering, "output", None), + config_path=(*steering_path, "output"), + ), + DeviceSettingDescriptor( + name="midi_channel", + scope="steering", + kind="integer", + default=getattr(steering, "channel", None), + config_path=(*steering_path, "channel"), + ), + DeviceSettingDescriptor( + name="preset_wait", + scope="steering", + kind="float", + default=getattr(steering, "preset_wait_seconds", None), + config_path=(*steering_path, "preset_wait_seconds"), + ), + DeviceSettingDescriptor( + name="snapshot_wait", + scope="steering", + kind="float", + default=getattr(steering, "snapshot_wait_seconds", None), + config_path=(*steering_path, "snapshot_wait_seconds"), + ), + DeviceSettingDescriptor( + name="measurement_wait", + scope="steering", + kind="float", + default=getattr(steering, "measurement_wait_seconds", None), + config_path=(*steering_path, "measurement_wait_seconds"), + ), + ) + + +def _default_audio(profile: DeviceProfile) -> object: + if hasattr(profile, "default_audio_routing"): + return profile.default_audio_routing() + return object() + + +def _default_steering(profile: DeviceProfile) -> object: + if hasattr(profile, "default_steering_options"): + return profile.default_steering_options() + return object() + + +def _coerce_setting_value( + descriptor: DeviceSettingDescriptor, + value: object | None, +) -> object | None: + if value is None: + return None + if descriptor.kind == "channel_mapping": + return parse_channel_mapping(value) + if descriptor.kind == "integer" and not isinstance(value, bool): + return int(cast(Any, value)) + if descriptor.kind == "float" and not isinstance(value, bool): + return float(cast(Any, value)) + if descriptor.kind == "path" and isinstance(value, Path): + return value + if descriptor.kind == "path": + return str(value) + return value diff --git a/src/matchpatch/devices/base.py b/src/matchpatch/devices/base.py index 8b09ad1..6369b69 100644 --- a/src/matchpatch/devices/base.py +++ b/src/matchpatch/devices/base.py @@ -2,14 +2,52 @@ from __future__ import annotations +import re from abc import ABC, abstractmethod from collections.abc import Callable, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from types import TracebackType -from typing import Literal, Self +from typing import TYPE_CHECKING, Literal, Protocol, Self, cast, runtime_checkable + +if TYPE_CHECKING: + import numpy as np + + from matchpatch.diagnostics import DiagnosticCheck + from matchpatch.workflow import NormalizationRequest DeviceFileKind = Literal["preset", "setlist", "unknown"] +AudioProcessingMode = Literal["hardware", "loopback", "simulated", "offline"] +DeviceTargetId = int | str +DeviceSubdivisionId = int | str +GainPointScope = Literal["target", "subdivision"] +SettingKind = Literal[ + "string", + "integer", + "float", + "boolean", + "choice", + "path", + "channel_mapping", +] +SettingScope = Literal["audio", "steering", "processing", "diagnostics", "device"] +DeviceSettings = dict[str, object] + + +@dataclass(frozen=True) +class DeviceSettingDescriptor: + name: str + scope: SettingScope + kind: SettingKind + default: object | None = None + config_path: tuple[str, ...] = () + cli_flags: tuple[str, ...] = () + label: str = "" + help: str = "" + choices: tuple[str, ...] = () + minimum: int | float | None = None + maximum: int | float | None = None + required: bool = False @dataclass(frozen=True) @@ -20,6 +58,27 @@ class DeviceTerminology: setlist: str = "setlist" +@dataclass(frozen=True) +class DeviceFileType: + kind: DeviceFileKind + extensions: tuple[str, ...] + description: str + can_open: bool = True + can_save: bool = True + + def normalized_extensions(self) -> tuple[str, ...]: + return tuple( + extension.lower() if extension.startswith(".") else f".{extension.lower()}" + for extension in self.extensions + ) + + def patterns(self) -> tuple[str, ...]: + return tuple(f"*{extension}" for extension in self.normalized_extensions()) + + def name_filter(self) -> str: + return f"{self.description} ({' '.join(self.patterns())})" + + @dataclass(frozen=True) class FileOperationCapabilities: reads_preset_files: bool = False @@ -45,11 +104,26 @@ def names(self) -> tuple[str, ...]: ) +@dataclass(frozen=True) +class AudioTransportCapabilities: + mode: AudioProcessingMode + supported_sample_rates: tuple[int, ...] = () + channel_layout: str = "stereo" + real_time: bool = True + offline: bool = False + asynchronous: bool = False + alignment_guarantee: bool = True + can_record_debug_output: bool = False + + @dataclass(frozen=True) class NamingRules: preset_name_max_length: int | None = None snapshot_name_max_length: int | None = None allowed_name_pattern: str | None = None + replacement_character: str = "" + trim_whitespace: bool = False + forbidden_names: frozenset[str] = frozenset() @dataclass(frozen=True) @@ -61,6 +135,25 @@ class PresetFileRecord: original_filename: str | None = None +@dataclass(frozen=True) +class GainPoint: + id: str + label: str + current_db: float + minimum_db: float + maximum_db: float + scope: GainPointScope + path: str | None = None + + +@dataclass(frozen=True) +class GainAdjustment: + target_id: DeviceTargetId + subdivision_id: DeviceSubdivisionId | None + gain_point_id: str + delta_db: float + + @dataclass(frozen=True) class PatchAssignment: id: int @@ -70,6 +163,47 @@ class PatchAssignment: snapshot_output_levels: tuple[tuple[float, ...], ...] = () snapshot_output_paths: tuple[str, ...] = () original_filename: str | None = None + gain_points: tuple[GainPoint, ...] = () + + +@dataclass(frozen=True) +class MeasurementSubdivision: + id: DeviceSubdivisionId + display_label: str + index: int + name: str + gain_points: tuple[GainPoint, ...] = () + + +@dataclass(frozen=True) +class MeasurementTarget: + id: DeviceTargetId + display_label: str + index: int + name: str + source_filename: str | None = None + subdivisions: tuple[MeasurementSubdivision, ...] = () + compat_numeric_id: int | None = None + gain_points: tuple[GainPoint, ...] = () + + +@dataclass(frozen=True) +class TargetSelection: + id: DeviceTargetId + display_label: str + index: int | None = None + name: str = "" + compat_numeric_id: int | None = None + + +@dataclass(frozen=True) +class SubdivisionSelection: + target_id: DeviceTargetId + id: DeviceSubdivisionId + display_label: str + index: int | None = None + name: str = "" + compat_numeric_id: int | None = None @dataclass(frozen=True) @@ -96,6 +230,33 @@ class SteeringOptions: measurement_wait_seconds: float +@dataclass(frozen=True) +class AudioTransportContext: + profile: DeviceProfile + mode: AudioProcessingMode + settings: Mapping[str, object] + audio_routing: AudioRouting + steering_options: SteeringOptions + sample_rate: int + snapshot_count: int + audio_config: object | None = None + controller: DeviceController | None = None + temporary_file_dir: Path | None = None + failing_preset_ids: frozenset[int] = frozenset() + timing_values: Mapping[str, float] = field(default_factory=dict) + + +@dataclass(frozen=True) +class OfflineAudioProcessingRequest: + reference_audio: np.ndarray + sample_rate: int + target_id: int + subdivision_id: int + target_metadata: Mapping[str, object] = field(default_factory=dict) + subdivision_metadata: Mapping[str, object] = field(default_factory=dict) + temporary_file_dir: Path | None = None + + @dataclass(frozen=True) class NormalizationPolicy: snapshot_count: int = 4 @@ -109,11 +270,98 @@ class NormalizationPolicy: gain_deadband_db: float = 0.25 +@dataclass(frozen=True) +class DiagnosticsContext: + request: NormalizationRequest + profile: DeviceProfile + handler: PatchFileHandler + resolved_settings: Mapping[str, object] + project_dir: Path + + +class DiagnosticsProvider(Protocol): + def run_checks(self, context: DiagnosticsContext) -> list[DiagnosticCheck]: + """Return device-specific preflight diagnostics.""" + + def normalize_regex_pattern(pattern: str) -> str: r"""Preserve user-visible ``\b`` word-boundary escapes decoded by config/UI paths.""" return pattern.replace("\b", r"\b") +def _assignment_to_measurement_target( + assignment: PatchAssignment, + index: int, +) -> MeasurementTarget: + return MeasurementTarget( + id=assignment.id, + display_label=assignment.device_patch, + index=index, + name=assignment.name, + source_filename=assignment.original_filename, + subdivisions=_assignment_subdivisions(assignment), + compat_numeric_id=assignment.id, + gain_points=tuple( + gain_point for gain_point in assignment.gain_points if gain_point.scope == "target" + ), + ) + + +def _assignment_subdivisions( + assignment: PatchAssignment, +) -> tuple[MeasurementSubdivision, ...]: + subdivision_count = max( + len(assignment.snapshot_names), + len(assignment.snapshot_output_levels), + len(assignment.snapshot_output_paths), + ) + return tuple(_assignment_subdivision(assignment, index) for index in range(subdivision_count)) + + +def _assignment_subdivision( + assignment: PatchAssignment, + index: int, +) -> MeasurementSubdivision: + name = assignment.snapshot_names[index] if index < len(assignment.snapshot_names) else "" + subdivision_id = index + 1 + display_label = name or str(subdivision_id) + return MeasurementSubdivision( + id=subdivision_id, + display_label=display_label, + index=index, + name=name, + gain_points=_assignment_subdivision_gain_points(assignment, index), + ) + + +def _assignment_subdivision_gain_points( + assignment: PatchAssignment, + index: int, +) -> tuple[GainPoint, ...]: + if index >= len(assignment.snapshot_output_levels): + return tuple( + gain_point for gain_point in assignment.gain_points if gain_point.scope == "subdivision" + ) + + levels = assignment.snapshot_output_levels[index] + subdivision_gain_points = tuple( + gain_point for gain_point in assignment.gain_points if gain_point.scope == "subdivision" + ) + return tuple( + GainPoint( + id=gain_point.id, + label=gain_point.label, + current_db=float(levels[point_index]), + minimum_db=gain_point.minimum_db, + maximum_db=gain_point.maximum_db, + scope=gain_point.scope, + path=gain_point.path, + ) + for point_index, gain_point in enumerate(subdivision_gain_points) + if point_index < len(levels) + ) + + class DeviceController(ABC): def __enter__(self) -> Self: return self @@ -135,6 +383,52 @@ def reapply_snapshot(self, snapshot: int) -> None: """Select a processor snapshot by its one-based numeric ID.""" +class AudioProcessorTransport(Protocol): + def __enter__(self) -> Self: ... + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: ... + + def activate_target(self, target: int) -> None: ... + + def activate_subdivision(self, subdivision: int) -> None: ... + + def process(self, reference_audio: np.ndarray) -> np.ndarray: ... + + +@runtime_checkable +class OfflineAudioTransport(Protocol): + def __enter__(self) -> Self: ... + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: ... + + def activate_target(self, target: int) -> None: ... + + def activate_subdivision(self, subdivision: int) -> None: ... + + def process_offline(self, request: OfflineAudioProcessingRequest) -> np.ndarray: ... + + +AudioTransport = AudioProcessorTransport | OfflineAudioTransport + + +class AudioTransportFactory(Protocol): + capabilities: AudioTransportCapabilities + + def supports(self, mode: AudioProcessingMode, settings: Mapping[str, object]) -> bool: ... + + def create(self, context: AudioTransportContext) -> AudioTransport: ... + + class PatchFileHandler(ABC): def set_log_callback(self, callback: Callable[[str], None] | None) -> None: """Receive device-specific utility output when a front end wants it.""" @@ -152,6 +446,31 @@ def validate_output(self, input_path: Path, output_path: Path) -> None: def list_assignments(self, input_path: Path) -> list[PatchAssignment]: """List measurable presets contained in a patch file.""" + def list_targets(self, input_path: Path) -> list[MeasurementTarget]: + """List measurable targets contained in a patch file.""" + return [ + _assignment_to_measurement_target(assignment, index) + for index, assignment in enumerate(self.list_assignments(input_path)) + ] + + def list_gain_points( + self, + input_path: Path, + target_id: DeviceTargetId | None = None, + subdivision_id: DeviceSubdivisionId | None = None, + ) -> list[GainPoint]: + """List gain points available for a target or subdivision.""" + points: list[GainPoint] = [] + for target in self.list_targets(input_path): + if target_id is not None and target.id != target_id: + continue + if subdivision_id is None: + points.extend(target.gain_points) + for subdivision in target.subdivisions: + if subdivision_id is None or subdivision.id == subdivision_id: + points.extend(subdivision.gain_points) + return points + def metadata(self, input_path: Path) -> dict[str, object]: """Extract displayable metadata from a patch file.""" return {} @@ -160,6 +479,10 @@ def file_capabilities(self) -> FileOperationCapabilities: """Describe device file operations supported by this handler.""" return FileOperationCapabilities() + def file_types(self) -> tuple[DeviceFileType, ...]: + """Describe device-owned file extensions and their user-facing labels.""" + return () + def file_kind(self, path: Path) -> DeviceFileKind: """Classify a path as a device preset file, setlist file, or unknown.""" return "unknown" @@ -215,6 +538,17 @@ def diff_preset_ids(self, input_path: Path, previous_input_path: Path) -> list[i """List presets whose loudness-affecting content differs between two patch files.""" raise NotImplementedError("Preset diff selection is not supported for this device") + def diff_targets( + self, + input_path: Path, + previous_input_path: Path, + ) -> list[TargetSelection]: + """List changed targets between two patch files.""" + return [ + self._target_selection_from_numeric_id(input_path, preset_id) + for preset_id in self.diff_preset_ids(input_path, previous_input_path) + ] + def diff_snapshot_ids( self, input_path: Path, @@ -227,10 +561,38 @@ def diff_snapshot_ids( for preset_id in self.diff_preset_ids(input_path, previous_input_path) } + def diff_subdivisions( + self, + input_path: Path, + previous_input_path: Path, + subdivision_count: int, + ) -> dict[DeviceTargetId, tuple[SubdivisionSelection, ...]]: + """List changed subdivisions per changed target.""" + return { + target.id: tuple( + self._subdivision_selection_from_numeric_id( + input_path, + target, + subdivision_id, + ) + for subdivision_id in subdivision_ids + ) + for preset_id, subdivision_ids in self.diff_snapshot_ids( + input_path, + previous_input_path, + subdivision_count, + ).items() + for target in (self._target_selection_from_numeric_id(input_path, preset_id),) + } + @abstractmethod def parse_patch_set(self, value: str) -> list[int]: """Parse device-facing preset labels into numeric preset IDs.""" + def parse_target_set(self, value: str) -> list[DeviceTargetId]: + """Parse device-facing target labels into device target IDs.""" + return list(self.parse_patch_set(value)) + @abstractmethod def select_preset_ids( self, @@ -240,6 +602,24 @@ def select_preset_ids( ) -> list[int]: """Resolve the presets that should be measured.""" + def select_targets( + self, + input_path: Path, + targets: list[MeasurementTarget], + requested_ids: list[DeviceTargetId] | None, + ) -> list[TargetSelection]: + """Resolve the measurement targets that should be measured.""" + requested_numeric_ids = self._compat_numeric_ids(requested_ids) + assignments = self.list_assignments(input_path) + return [ + self._target_selection_from_numeric_id(input_path, preset_id, targets) + for preset_id in self.select_preset_ids( + input_path, + assignments, + requested_numeric_ids, + ) + ] + @abstractmethod def format_patch_id(self, preset_id: int) -> str: """Format a numeric preset ID for logs and CSV output.""" @@ -262,10 +642,103 @@ def apply_analysis_csv( ) -> None: """Apply measured gain adjustments to a patch file.""" + def apply_gain_adjustments( + self, + input_path: Path, + output_path: Path, + adjustments: list[GainAdjustment], + ) -> None: + """Apply explicit gain adjustments to a patch file.""" + if adjustments: + raise NotImplementedError("Gain adjustments are not supported for this device") + @abstractmethod def automation_output_path(self, input_path: Path, postfix: str) -> Path: """Build a device-compatible output path beside the input file.""" + @staticmethod + def _compat_numeric_ids(ids: list[DeviceTargetId] | None) -> list[int] | None: + if ids is None: + return None + if all(isinstance(item, int) for item in ids): + return list(cast("list[int]", ids)) + raise NotImplementedError("Generic target selection is not supported for this device") + + def _target_selection_from_numeric_id( + self, + input_path: Path, + preset_id: int, + targets: list[MeasurementTarget] | None = None, + ) -> TargetSelection: + target = self._target_from_numeric_id(input_path, preset_id, targets) + if target is None: + return TargetSelection( + id=preset_id, + display_label=self.format_patch_id(preset_id), + compat_numeric_id=preset_id, + ) + return TargetSelection( + id=target.id, + display_label=target.display_label, + index=target.index, + name=target.name, + compat_numeric_id=target.compat_numeric_id, + ) + + def _subdivision_selection_from_numeric_id( + self, + input_path: Path, + target: TargetSelection, + subdivision_id: int, + ) -> SubdivisionSelection: + measurement_target = self._target_from_selection(input_path, target) + if measurement_target is not None: + for subdivision in measurement_target.subdivisions: + if subdivision.id == subdivision_id: + return SubdivisionSelection( + target_id=target.id, + id=subdivision.id, + display_label=subdivision.display_label, + index=subdivision.index, + name=subdivision.name, + compat_numeric_id=subdivision_id, + ) + + return SubdivisionSelection( + target_id=target.id, + id=subdivision_id, + display_label=str(subdivision_id), + index=subdivision_id - 1, + compat_numeric_id=subdivision_id, + ) + + def _target_from_selection( + self, + input_path: Path, + selection: TargetSelection, + ) -> MeasurementTarget | None: + for target in self.list_targets(input_path): + if target.id == selection.id: + return target + if ( + selection.compat_numeric_id is not None + and target.compat_numeric_id == selection.compat_numeric_id + ): + return target + return None + + def _target_from_numeric_id( + self, + input_path: Path, + preset_id: int, + targets: list[MeasurementTarget] | None = None, + ) -> MeasurementTarget | None: + candidate_targets = targets if targets is not None else self.list_targets(input_path) + for target in candidate_targets: + if target.compat_numeric_id == preset_id or target.id == preset_id: + return target + return None + class DeviceProfile(ABC): name: str @@ -288,12 +761,64 @@ def file_capabilities(self) -> FileOperationCapabilities: def measurement_backends(self) -> tuple[str, ...]: return MeasurementBackendCapabilities().names() + def audio_transport_factories(self) -> tuple[AudioTransportFactory, ...]: + """Return plugin-provided audio transport factories for this device.""" + return () + + def diagnostics_provider(self) -> DiagnosticsProvider | None: + """Return optional device-specific preflight diagnostics.""" + return None + def naming_rules(self) -> NamingRules: return NamingRules( preset_name_max_length=getattr(self, "preset_name_max_length", None), snapshot_name_max_length=getattr(self, "snapshot_name_max_length", None), ) + def validate_preset_name(self, name: str) -> str: + return self._validate_name(name, self.naming_rules().preset_name_max_length) + + def validate_subdivision_name(self, name: str) -> str: + return self._validate_name(name, self.naming_rules().snapshot_name_max_length) + + def sanitize_preset_name(self, name: str) -> str: + return self._sanitize_name(name, self.naming_rules().preset_name_max_length) + + def sanitize_subdivision_name(self, name: str) -> str: + return self._sanitize_name(name, self.naming_rules().snapshot_name_max_length) + + def _validate_name(self, name: str, max_length: int | None) -> str: + rules = self.naming_rules() + candidate = name.strip() if rules.trim_whitespace else name + if ( + rules.allowed_name_pattern is not None + and re.fullmatch( + rules.allowed_name_pattern, + candidate, + ) + is None + ): + raise ValueError(f"Invalid {self.display_name} name: {name!r}") + if max_length is not None and len(candidate) > max_length: + raise ValueError(f"{self.display_name} name exceeds {max_length} characters: {name!r}") + if candidate in rules.forbidden_names: + raise ValueError(f"Forbidden {self.display_name} name: {name!r}") + return candidate + + def _sanitize_name(self, name: str, max_length: int | None) -> str: + rules = self.naming_rules() + sanitized = name.strip() if rules.trim_whitespace else name + if rules.allowed_name_pattern is not None: + sanitized = "".join( + character + if re.fullmatch(rules.allowed_name_pattern, character) is not None + else rules.replacement_character + for character in sanitized + ) + if max_length is not None: + sanitized = sanitized[:max_length] + return "" if sanitized in rules.forbidden_names else sanitized + def format_patch_id(self, preset_id: int) -> str: """Format a numeric preset ID for device-facing status text.""" return str(preset_id) @@ -306,11 +831,217 @@ def default_audio_routing(self) -> AudioRouting: def default_steering_options(self) -> SteeringOptions: """Return the processor's steering defaults.""" + def setting_descriptors(self) -> tuple[DeviceSettingDescriptor, ...]: + """Return GUI-free device setting metadata for config and front ends.""" + audio = self.default_audio_routing() + steering = self.default_steering_options() + audio_path = ("devices", self.name, "audio") + steering_path = ("devices", self.name, "steering") + return ( + DeviceSettingDescriptor( + name="audio_device", + scope="audio", + kind="string", + default=audio.device, + config_path=(*audio_path, "device"), + cli_flags=("--audio-device",), + label="Audio device", + help="Audio input/output device used for measurement.", + ), + DeviceSettingDescriptor( + name="sample_rate", + scope="audio", + kind="integer", + default=audio.sample_rate, + config_path=(*audio_path, "sample_rate"), + cli_flags=("--sample-rate",), + label="Sample rate", + help="Audio sample rate in hertz.", + minimum=1, + ), + DeviceSettingDescriptor( + name="input_mapping", + scope="audio", + kind="channel_mapping", + default=audio.input_mapping, + config_path=(*audio_path, "input_mapping"), + cli_flags=("--input-mapping",), + label="Input mapping", + help="One-based stereo input channel mapping.", + ), + DeviceSettingDescriptor( + name="output_mapping", + scope="audio", + kind="channel_mapping", + default=audio.output_mapping, + config_path=(*audio_path, "output_mapping"), + cli_flags=("--output-mapping",), + label="Output mapping", + help="One-based stereo output channel mapping.", + ), + DeviceSettingDescriptor( + name="blocksize", + scope="audio", + kind="integer", + default=0, + config_path=(*audio_path, "blocksize"), + cli_flags=("--blocksize",), + label="Blocksize", + help="Audio block size, or zero for the backend default.", + minimum=0, + ), + DeviceSettingDescriptor( + name="midi_output", + scope="steering", + kind="string", + default=steering.output, + config_path=(*steering_path, "output"), + cli_flags=("--steering-output", "--midi-output"), + label="MIDI output", + help="MIDI output port query used for device steering.", + ), + DeviceSettingDescriptor( + name="midi_channel", + scope="steering", + kind="integer", + default=steering.channel, + config_path=(*steering_path, "channel"), + cli_flags=("--midi-channel",), + label="MIDI channel", + help="Zero-based MIDI channel used for steering.", + minimum=0, + maximum=15, + ), + DeviceSettingDescriptor( + name="preset_wait", + scope="steering", + kind="float", + default=steering.preset_wait_seconds, + config_path=(*steering_path, "preset_wait_seconds"), + cli_flags=("--preset-wait",), + label="Preset wait", + help="Seconds to wait after changing presets.", + minimum=0.0, + ), + DeviceSettingDescriptor( + name="snapshot_wait", + scope="steering", + kind="float", + default=steering.snapshot_wait_seconds, + config_path=(*steering_path, "snapshot_wait_seconds"), + cli_flags=("--snapshot-wait",), + label="Snapshot wait", + help="Seconds to wait after changing snapshots.", + minimum=0.0, + ), + DeviceSettingDescriptor( + name="measurement_wait", + scope="steering", + kind="float", + default=steering.measurement_wait_seconds, + config_path=(*steering_path, "measurement_wait_seconds"), + cli_flags=("--measurement-wait",), + label="Measurement wait", + help="Seconds to wait before recording each measurement.", + minimum=0.0, + ), + ) + + def validate_settings(self, settings: Mapping[str, object]) -> None: + """Validate provided setting values against this profile's descriptors.""" + descriptors = {descriptor.name: descriptor for descriptor in self.setting_descriptors()} + + for descriptor in descriptors.values(): + if descriptor.required and descriptor.name not in settings: + raise ValueError(f"Missing required device setting: {descriptor.name}") + + for name, value in settings.items(): + descriptor = descriptors.get(name) + if descriptor is None: + continue + _validate_setting_value(descriptor, value) + @abstractmethod def create_controller(self, options: SteeringOptions) -> DeviceController: """Open the transport used to select presets and snapshots.""" +def _validate_setting_value(descriptor: DeviceSettingDescriptor, value: object) -> None: + if value is None: + if descriptor.required: + raise ValueError(f"Device setting {descriptor.name} is required") + return + + validators = { + "boolean": _validate_boolean_setting, + "integer": _validate_integer_setting, + "float": _validate_float_setting, + "choice": _validate_choice_setting, + "path": _validate_path_setting, + "channel_mapping": _validate_channel_mapping_setting, + } + validator = validators.get(descriptor.kind, _validate_string_setting) + validator(descriptor, value) + + +def _validate_boolean_setting(descriptor: DeviceSettingDescriptor, value: object) -> None: + if not isinstance(value, bool): + raise ValueError(f"Device setting {descriptor.name} must be a boolean") + + +def _validate_integer_setting(descriptor: DeviceSettingDescriptor, value: object) -> None: + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError(f"Device setting {descriptor.name} must be an integer") + _validate_numeric_range(descriptor, value) + + +def _validate_float_setting(descriptor: DeviceSettingDescriptor, value: object) -> None: + if not isinstance(value, int | float) or isinstance(value, bool): + raise ValueError(f"Device setting {descriptor.name} must be a number") + _validate_numeric_range(descriptor, float(value)) + + +def _validate_choice_setting(descriptor: DeviceSettingDescriptor, value: object) -> None: + if not isinstance(value, str): + raise ValueError(f"Device setting {descriptor.name} must be a string choice") + if descriptor.choices and value not in descriptor.choices: + choices = ", ".join(descriptor.choices) + raise ValueError(f"Device setting {descriptor.name} must be one of: {choices}") + + +def _validate_path_setting(descriptor: DeviceSettingDescriptor, value: object) -> None: + if not isinstance(value, str | Path): + raise ValueError(f"Device setting {descriptor.name} must be a path") + + +def _validate_channel_mapping_setting( + descriptor: DeviceSettingDescriptor, + value: object, +) -> None: + if ( + not isinstance(value, tuple | list) + or len(value) != 2 + or any(not isinstance(channel, int) or isinstance(channel, bool) for channel in value) + ): + raise ValueError(f"Device setting {descriptor.name} must be a two-channel integer mapping") + channels = cast("tuple[int, ...] | list[int]", value) + if any(channel < 1 for channel in channels): + raise ValueError(f"Device setting {descriptor.name} channels must be at least 1") + + +def _validate_string_setting(descriptor: DeviceSettingDescriptor, value: object) -> None: + if not isinstance(value, str): + raise ValueError(f"Device setting {descriptor.name} must be a string") + + +def _validate_numeric_range(descriptor: DeviceSettingDescriptor, value: int | float) -> None: + if descriptor.minimum is not None and value < descriptor.minimum: + raise ValueError(f"Device setting {descriptor.name} must be at least {descriptor.minimum}") + + if descriptor.maximum is not None and value > descriptor.maximum: + raise ValueError(f"Device setting {descriptor.name} must not exceed {descriptor.maximum}") + + def validate_snapshot_count(profile: DeviceProfile, snapshot_count: int) -> None: if not isinstance(snapshot_count, int) or isinstance(snapshot_count, bool): raise ValueError("Configured measured snapshot count must be an integer") diff --git a/src/matchpatch/devices/helix.py b/src/matchpatch/devices/helix.py index 17ab966..4fc9a2b 100644 --- a/src/matchpatch/devices/helix.py +++ b/src/matchpatch/devices/helix.py @@ -6,6 +6,7 @@ import csv import io import json +import re import runpy import subprocess import sys @@ -21,9 +22,12 @@ AudioRouting, DeviceController, DeviceFileKind, + DeviceFileType, DeviceProfile, + DeviceSettingDescriptor, DeviceTerminology, FileOperationCapabilities, + GainPoint, NamingRules, NormalizationPolicy, PatchAssignment, @@ -33,6 +37,9 @@ ) from matchpatch.midi import midi_output_names +HELIX_NAME_PATTERN = re.compile(r"""^[A-Za-z0-9\-_+=!@#$&()?:'",./ ]*$""") +HELIX_NAME_CHAR_PATTERN = re.compile(r"""[A-Za-z0-9\-_+=!@#$&()?:'",./ ]""") + class HelixPatchFileHandler(PatchFileHandler): def __init__(self, project_dir: Path) -> None: @@ -153,6 +160,7 @@ def list_assignments(self, input_path: Path) -> list[PatchAssignment]: for levels in assignment.get("snapshot_output_levels", ()) ), original_filename=input_path.name if input_path.suffix.lower() == ".hlx" else None, + gain_points=_assignment_gain_points(assignment), ) for assignment in json.loads(completed.stdout) ] @@ -175,6 +183,24 @@ def file_capabilities(self) -> FileOperationCapabilities: exports_selected_setlist_slots=True, ) + def file_types(self) -> tuple[DeviceFileType, ...]: + return ( + DeviceFileType( + kind="setlist", + extensions=(".hls",), + description="Helix .hls", + can_open=True, + can_save=True, + ), + DeviceFileType( + kind="preset", + extensions=(".hlx",), + description="Helix .hlx", + can_open=True, + can_save=True, + ), + ) + def file_kind(self, path: Path) -> DeviceFileKind: suffix = path.suffix.lower() if suffix == ".hlx": @@ -531,6 +557,7 @@ class HelixDeviceProfile(DeviceProfile): max_snapshot_count = 8 preset_name_max_length = 16 snapshot_name_max_length = 10 + name_pattern = r"""^[A-Za-z0-9\-_+=!@#$&()?:'",./ ]*$""" def create_patch_file_handler(self, project_dir: Path) -> PatchFileHandler: return HelixPatchFileHandler(project_dir) @@ -555,9 +582,36 @@ def naming_rules(self) -> NamingRules: return NamingRules( preset_name_max_length=self.preset_name_max_length, snapshot_name_max_length=self.snapshot_name_max_length, - allowed_name_pattern=r"^[ -~]*$", + allowed_name_pattern=self.name_pattern, ) + def validate_preset_name(self, name: str) -> str: + return self._validate_helix_name(name, self.preset_name_max_length) + + def validate_subdivision_name(self, name: str) -> str: + return self._validate_helix_name(name, self.snapshot_name_max_length) + + def sanitize_preset_name(self, name: str) -> str: + return self._sanitize_helix_name(name, self.preset_name_max_length) + + def sanitize_subdivision_name(self, name: str) -> str: + return self._sanitize_helix_name(name, self.snapshot_name_max_length) + + @staticmethod + def _validate_helix_name(name: str, max_length: int | None = None) -> str: + if HELIX_NAME_PATTERN.fullmatch(name) is None: + raise ValueError(f"Invalid Helix name: {name!r}") + if max_length is not None and len(name) > max_length: + raise ValueError(f"Helix name exceeds {max_length} characters: {name!r}") + return name + + @staticmethod + def _sanitize_helix_name(name: str, max_length: int | None = None) -> str: + sanitized = "".join( + character for character in name if HELIX_NAME_CHAR_PATTERN.fullmatch(character) + ) + return sanitized[:max_length] if max_length is not None else sanitized + def format_patch_id(self, preset_id: int) -> str: zero_based = preset_id - 1 return f"{zero_based // 4 + 1:02d}{'ABCD'[zero_based % 4]}" @@ -579,6 +633,121 @@ def default_steering_options(self) -> SteeringOptions: measurement_wait_seconds=0.1, ) + def setting_descriptors(self) -> tuple[DeviceSettingDescriptor, ...]: + audio = self.default_audio_routing() + steering = self.default_steering_options() + audio_path = ("devices", self.name, "audio") + steering_path = ("devices", self.name, "steering") + return ( + DeviceSettingDescriptor( + name="audio_device", + scope="audio", + kind="string", + default=audio.device, + config_path=(*audio_path, "device"), + cli_flags=("--audio-device",), + label="Audio device", + help="Helix USB audio device query.", + ), + DeviceSettingDescriptor( + name="sample_rate", + scope="audio", + kind="integer", + default=audio.sample_rate, + config_path=(*audio_path, "sample_rate"), + cli_flags=("--sample-rate",), + label="Sample rate", + help="Helix USB audio sample rate in hertz.", + minimum=1, + ), + DeviceSettingDescriptor( + name="input_mapping", + scope="audio", + kind="channel_mapping", + default=audio.input_mapping, + config_path=(*audio_path, "input_mapping"), + cli_flags=("--input-mapping",), + label="Input mapping", + help="One-based Helix USB input channel mapping.", + ), + DeviceSettingDescriptor( + name="output_mapping", + scope="audio", + kind="channel_mapping", + default=audio.output_mapping, + config_path=(*audio_path, "output_mapping"), + cli_flags=("--output-mapping",), + label="Output mapping", + help="One-based Helix USB output channel mapping.", + ), + DeviceSettingDescriptor( + name="blocksize", + scope="audio", + kind="integer", + default=0, + config_path=(*audio_path, "blocksize"), + cli_flags=("--blocksize",), + label="Blocksize", + help="Audio block size, or zero for the backend default.", + minimum=0, + ), + DeviceSettingDescriptor( + name="midi_output", + scope="steering", + kind="string", + default=steering.output, + config_path=(*steering_path, "output"), + cli_flags=("--steering-output", "--midi-output"), + label="MIDI output", + help="Helix MIDI output port query.", + ), + DeviceSettingDescriptor( + name="midi_channel", + scope="steering", + kind="integer", + default=steering.channel, + config_path=(*steering_path, "channel"), + cli_flags=("--midi-channel",), + label="MIDI channel", + help="Zero-based MIDI channel used for Helix program changes.", + minimum=0, + maximum=15, + ), + DeviceSettingDescriptor( + name="preset_wait", + scope="steering", + kind="float", + default=steering.preset_wait_seconds, + config_path=(*steering_path, "preset_wait_seconds"), + cli_flags=("--preset-wait",), + label="Preset wait", + help="Seconds to wait after sending a Helix preset change.", + minimum=0.0, + ), + DeviceSettingDescriptor( + name="snapshot_wait", + scope="steering", + kind="float", + default=steering.snapshot_wait_seconds, + config_path=(*steering_path, "snapshot_wait_seconds"), + cli_flags=("--snapshot-wait",), + label="Snapshot wait", + help="Seconds to wait after sending a Helix snapshot change.", + minimum=0.0, + ), + DeviceSettingDescriptor( + name="measurement_wait", + scope="steering", + kind="float", + default=steering.measurement_wait_seconds, + config_path=(*steering_path, "measurement_wait_seconds"), + cli_flags=("--measurement-wait",), + label="Measurement wait", + help="Seconds to wait before recording each Helix measurement.", + minimum=0.0, + ), + ) + def create_controller(self, options: SteeringOptions) -> DeviceController: return HelixMidiController(options) @@ -595,3 +764,22 @@ def _error_details(exc: subprocess.CalledProcessError) -> str: def _load_helix_file_ops() -> Any: # noqa: ANN401 return helix_file_ops + + +def _assignment_gain_points(assignment: Mapping[str, object]) -> tuple[GainPoint, ...]: + paths = assignment.get("snapshot_output_paths", ()) + if not isinstance(paths, list | tuple): + return () + + return tuple( + GainPoint( + id=str(path), + label=str(path), + current_db=0.0, + minimum_db=-120.0, + maximum_db=20.0, + scope="subdivision", + path=str(path), + ) + for path in paths + ) diff --git a/src/matchpatch/diagnostics.py b/src/matchpatch/diagnostics.py index c8af609..4cf55ee 100644 --- a/src/matchpatch/diagnostics.py +++ b/src/matchpatch/diagnostics.py @@ -17,6 +17,7 @@ from matchpatch import __version__ from matchpatch.analysis import AnalysisOptions +from matchpatch.device_settings import setting_diagnostics from matchpatch.devices.base import NormalizationPolicy from matchpatch.progress import ProgressEvent from matchpatch.workflow import NormalizationRequest, NormalizationResult @@ -92,6 +93,7 @@ class EffectiveConfig: snapshot_plan: tuple[tuple[str, tuple[int, ...]], ...] policy: dict[str, Any] analysis_options: dict[str, Any] + device_settings: dict[str, Any] @classmethod def from_request(cls, request: NormalizationRequest) -> EffectiveConfig: @@ -134,6 +136,7 @@ def from_request(cls, request: NormalizationRequest) -> EffectiveConfig: ), policy=normalization_policy_to_dict(request.policy), analysis_options=analysis_options_to_dict(request.analysis_options), + device_settings=setting_diagnostics(request.device_settings or {}), ) def to_dict(self) -> dict[str, Any]: @@ -177,6 +180,7 @@ def to_dict(self) -> dict[str, Any]: ], "policy": self.policy, "analysis_options": self.analysis_options, + "device_settings": self.device_settings, } @@ -530,6 +534,7 @@ def request_diagnostics(request: NormalizationRequest) -> dict[str, object]: ], "policy": normalization_policy_to_dict(request.policy), "analysis_options": analysis_options_to_dict(request.analysis_options), + "device_settings": setting_diagnostics(request.device_settings or {}), } diff --git a/src/matchpatch/gui/device_panel_registry.py b/src/matchpatch/gui/device_panel_registry.py new file mode 100644 index 0000000..602ffbe --- /dev/null +++ b/src/matchpatch/gui/device_panel_registry.py @@ -0,0 +1,66 @@ +"""GUI-only registry for optional device settings panel plugins.""" + +from __future__ import annotations + +import logging +from collections.abc import Iterable +from importlib import metadata +from typing import Any, Protocol + +from PySide6.QtWidgets import QWidget + +from matchpatch.devices.base import DeviceProfile + +ENTRY_POINT_GROUP = "matchpatch.device_gui_panels" + +_LOG = logging.getLogger(__name__) +_PLUGIN_LOAD_ERRORS: dict[str, str] = {} + + +class DevicePanelFactory(Protocol): + device_name: str + + def create_panel(self, profile: DeviceProfile, backend_selector: QWidget) -> QWidget | None: ... + + +def _entry_points() -> Iterable[metadata.EntryPoint]: + entry_points = metadata.entry_points() + if hasattr(entry_points, "select"): + return entry_points.select(group=ENTRY_POINT_GROUP) + return entry_points.get(ENTRY_POINT_GROUP, ()) + + +def _panel_factory_from_loaded(value: Any) -> DevicePanelFactory: # noqa: ANN401 + factory = value + if not _has_factory_shape(factory) and callable(value): + factory = value() + if not _has_factory_shape(factory): + raise TypeError("device GUI panel entry point must define device_name and create_panel") + return factory + + +def _has_factory_shape(value: object) -> bool: + return isinstance(getattr(value, "device_name", None), str) and callable( + getattr(value, "create_panel", None) + ) + + +def create_plugin_settings_panel( + profile: DeviceProfile, + backend_selector: QWidget, +) -> QWidget | None: + _PLUGIN_LOAD_ERRORS.clear() + for entry_point in _entry_points(): + try: + factory = _panel_factory_from_loaded(entry_point.load()) + if factory.device_name == profile.name: + return factory.create_panel(profile, backend_selector) + except Exception as exc: # noqa: BLE001 + message = str(exc) + _PLUGIN_LOAD_ERRORS[entry_point.name] = message + _LOG.warning("Device GUI panel plugin %s failed to load: %s", entry_point.name, message) + return None + + +def plugin_load_errors() -> dict[str, str]: + return dict(_PLUGIN_LOAD_ERRORS) diff --git a/src/matchpatch/gui/device_panels.py b/src/matchpatch/gui/device_panels.py index 70c9e17..87ac985 100644 --- a/src/matchpatch/gui/device_panels.py +++ b/src/matchpatch/gui/device_panels.py @@ -15,6 +15,8 @@ ) from matchpatch.devices.base import DeviceProfile +from matchpatch.gui.device_panel_registry import create_plugin_settings_panel +from matchpatch.gui.settings_renderer import DescriptorSettingsPanel class HelixSettingsPanel(QWidget): @@ -121,6 +123,12 @@ def create_settings_panel( profile: DeviceProfile, backend_selector: QWidget, ) -> QWidget | None: + plugin_panel = create_plugin_settings_panel(profile, backend_selector) + if plugin_panel is not None: + return plugin_panel if profile.name == "helix": return HelixSettingsPanel(backend_selector) + descriptors = profile.setting_descriptors() if hasattr(profile, "setting_descriptors") else () + if descriptors: + return DescriptorSettingsPanel(descriptors) return None diff --git a/src/matchpatch/gui/file_operations_workflow.py b/src/matchpatch/gui/file_operations_workflow.py index c8ee2fb..54f8abc 100644 --- a/src/matchpatch/gui/file_operations_workflow.py +++ b/src/matchpatch/gui/file_operations_workflow.py @@ -9,7 +9,13 @@ from PySide6.QtWidgets import QFileDialog, QWidget from matchpatch import file_operations -from matchpatch.devices.base import DeviceProfile, FileOperationCapabilities +from matchpatch.devices import get_device_profile +from matchpatch.devices.base import ( + DeviceFileKind, + DeviceFileType, + DeviceProfile, + FileOperationCapabilities, +) from matchpatch.gui.window_state import FileActionState @@ -46,25 +52,84 @@ def _set_optional_widget_enabled(self, name: str, enabled: bool) -> None: ... ProfileProvider = Callable[[str], DeviceProfile] -def choose_join_preset_paths(parent: QWidget) -> list[Path] | None: +def _file_type_patterns( + file_types: Iterable[DeviceFileType], + kind: DeviceFileKind, +) -> tuple[str, ...]: + return tuple( + pattern + for file_type in file_types + if file_type.kind == kind + for pattern in file_type.patterns() + ) + + +def _kind_file_filter( + file_types: Iterable[DeviceFileType], + kind: DeviceFileKind, + fallback: str, +) -> str: + patterns = _file_type_patterns(file_types, kind) + if not patterns: + return fallback + label = "Preset files" if kind == "preset" else "Setlist files" + return f"{label} ({' '.join(patterns)})" + + +def _kind_extension( + file_types: Iterable[DeviceFileType], + kind: DeviceFileKind, + fallback: str, +) -> str: + for file_type in file_types: + if file_type.kind == kind and file_type.normalized_extensions(): + return file_type.normalized_extensions()[0] + return fallback + + +def current_file_types( + window: FileOperationWindow, + *, + get_profile: ProfileProvider, + project_dir: Path, +) -> tuple[DeviceFileType, ...]: + device = window.device.currentData() if hasattr(window, "device") else None + if not device: + return () + try: + profile = get_profile(device) + handler = profile.create_patch_file_handler(project_dir) + return handler.file_types() + except Exception: # noqa: BLE001 + return () + + +def choose_join_preset_paths( + parent: QWidget, + file_types: Iterable[DeviceFileType] = (), +) -> list[Path] | None: paths, _ = QFileDialog.getOpenFileNames( parent, "Choose preset files", - filter="Preset files (*.hlx)", + filter=_kind_file_filter(file_types, "preset", "Preset files (*.hlx)"), ) return [Path(path) for path in paths] if paths else None -def choose_join_output_path(parent: QWidget) -> Path | None: +def choose_join_output_path( + parent: QWidget, + file_types: Iterable[DeviceFileType] = (), +) -> Path | None: path, _ = QFileDialog.getSaveFileName( parent, "Save joined setlist", - filter="Setlist files (*.hls)", + filter=_kind_file_filter(file_types, "setlist", "Setlist files (*.hls)"), ) if not path: return None output_path = Path(path) - return output_path if output_path.suffix.lower() == ".hls" else output_path.with_suffix(".hls") + suffix = _kind_extension(file_types, "setlist", ".hls") + return output_path if output_path.suffix.lower() == suffix else output_path.with_suffix(suffix) def choose_split_output_dir(parent: QWidget) -> Path | None: @@ -94,10 +159,15 @@ def created_files_log(created_paths: Iterable[Path]) -> list[str]: def join_preset_files(window: FileOperationWindow) -> bool: parent = cast(QWidget, window) - preset_paths = choose_join_preset_paths(parent) + file_types = current_file_types( + window, + get_profile=get_device_profile, + project_dir=Path(__file__).resolve().parents[3], + ) + preset_paths = choose_join_preset_paths(parent, file_types) if not preset_paths: return False - output_path = choose_join_output_path(parent) + output_path = choose_join_output_path(parent, file_types) if output_path is None: return False diff --git a/src/matchpatch/gui/file_type_filters.py b/src/matchpatch/gui/file_type_filters.py new file mode 100644 index 0000000..d06b298 --- /dev/null +++ b/src/matchpatch/gui/file_type_filters.py @@ -0,0 +1,64 @@ +"""Qt file-filter helpers built from device-owned file type metadata.""" + +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path + +from matchpatch.devices import get_device_profile +from matchpatch.devices.base import DeviceFileType + +PROJECT_DIR = Path(__file__).resolve().parents[3] + + +def current_device_file_types(device: str) -> tuple[DeviceFileType, ...]: + try: + profile = get_device_profile(device) + handler = profile.create_patch_file_handler(PROJECT_DIR) + return handler.file_types() + except Exception: # noqa: BLE001 + return () + + +def open_patch_filter(file_types: Sequence[DeviceFileType]) -> str: + patterns = [ + pattern + for file_type in file_types + if file_type.can_open + for pattern in file_type.patterns() + ] + return f"Patches ({' '.join(patterns)})" if patterns else "Patches (*.hls *.hlx)" + + +def open_patch_filter_for_device(device: str) -> str: + return open_patch_filter(current_device_file_types(device)) + + +def save_file_filter( + file_types: Sequence[DeviceFileType], + suffix: str, + fallback: str, +) -> str | None: + file_type = _file_type_for_suffix(file_types, suffix) + if file_type is None: + return None if file_types else fallback + patterns = tuple(f"*{extension}" for extension in file_type.normalized_extensions()) + return f"{file_type.description} ({' '.join(patterns)})" + + +def helix_save_file_filter(device: str, suffix: str) -> str | None: + fallback = f"Helix {suffix} (*{suffix})" if suffix in {".hls", ".hlx"} else "" + return save_file_filter(current_device_file_types(device), suffix, fallback) + + +def _file_type_for_suffix( + file_types: Sequence[DeviceFileType], + suffix: str, +) -> DeviceFileType | None: + normalized_suffix = suffix.lower() + for file_type in file_types: + if not file_type.can_save: + continue + if normalized_suffix in file_type.normalized_extensions(): + return file_type + return None diff --git a/src/matchpatch/gui/main_window.py b/src/matchpatch/gui/main_window.py index b229fd2..2519e51 100644 --- a/src/matchpatch/gui/main_window.py +++ b/src/matchpatch/gui/main_window.py @@ -70,7 +70,7 @@ write_diagnostic_bundle, ) from matchpatch.gui import diagnostics_panel as gui_diagnostics -from matchpatch.gui import file_operations_workflow, window_layout, window_state +from matchpatch.gui import file_operations_workflow, file_type_filters, window_layout, window_state from matchpatch.gui import help as gui_help from matchpatch.gui.advanced_settings import ( GuiSettingsBinder, @@ -115,6 +115,11 @@ from matchpatch.gui.measurement_optimization import ( _optimization_progress_event_total as _optimization_progress_event_total, ) +from matchpatch.gui.name_rules import ( + device_name_max_length, + validate_preset_name_for_device, + validate_subdivision_name_for_device, +) from matchpatch.gui.normalization_workflow import NormalizationWorkflowController from matchpatch.gui.optimization_workflow import MeasurementOptimizationWorkflowController from matchpatch.gui.playback import AudioPlaybackWorker @@ -677,7 +682,9 @@ def _populate_devices(self) -> None: def browse_input(self) -> None: path, _ = QFileDialog.getOpenFileName( - self, "Choose patch file", filter="Patches (*.hls *.hlx)" + self, + "Choose patch file", + filter=file_type_filters.open_patch_filter_for_device(self.device.currentData()), ) self._open_input_path(path) @@ -742,12 +749,10 @@ def _confirm_discard_preset_table_changes(self) -> bool: def _choose_save_as_path(self, *, accept_label: str = "Save as") -> Path | None: suffix = Path(self.input_path.text()).suffix.lower() - if suffix not in {".hls", ".hlx"}: + file_filter = file_type_filters.helix_save_file_filter(self.device.currentData(), suffix) + if file_filter is None: self.show_error("Open a Helix .hls or .hlx file before saving") return None - file_filter = ( - f"Helix {suffix} (*{suffix})" if suffix in {".hls", ".hlx"} else "Patches (*.hls *.hlx)" - ) dialog = QFileDialog(self, "Save Helix file as") dialog.setOption(QFileDialog.Option.DontUseNativeDialog) dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptOpen) @@ -766,10 +771,10 @@ def _choose_save_as_path(self, *, accept_label: str = "Save as") -> Path | None: def _choose_measurement_save_path(self) -> Path | None: input_path = Path(self.input_path.text()) suffix = input_path.suffix.lower() - if suffix not in {".hls", ".hlx"}: + file_filter = file_type_filters.helix_save_file_filter(self.device.currentData(), suffix) + if file_filter is None: self.show_error("Open a Helix .hls or .hlx file before saving a measurement file") return None - file_filter = f"Helix {suffix} (*{suffix})" suggested_path = input_path.with_name(input_path.stem + "_measurement" + suffix) dialog = QFileDialog(self, "Save measurement file") dialog.setOption(QFileDialog.Option.DontUseNativeDialog) @@ -897,10 +902,10 @@ def _preset_table_csv_callbacks(self) -> FunctionPresetTableCsvCallbacks: ) def _validate_preset_table_csv_preset_name(self, name: str) -> None: - self._validate_helix_name(name, self._preset_name_max_length()) + validate_preset_name_for_device(self.device.currentData(), name) def _validate_preset_table_csv_snapshot_name(self, name: str) -> None: - self._validate_helix_name(name, self._snapshot_name_max_length()) + validate_subdivision_name_for_device(self.device.currentData(), name) def _is_solo_snapshot_name(self, name: str) -> bool: try: @@ -2362,14 +2367,7 @@ def _snapshot_name_max_length(self) -> int | None: def _current_profile_name_max_length(self, attribute: str) -> int | None: device = self.device.currentData() if hasattr(self, "device") else None - if not device: - return None - try: - profile = get_device_profile(device) - except ValueError: - return None - value = getattr(profile, attribute, None) - return value if isinstance(value, int) and not isinstance(value, bool) else None + return device_name_max_length(device, attribute) @staticmethod def _sanitize_helix_name(name: str, max_length: int | None = None) -> str: diff --git a/src/matchpatch/gui/main_window_callbacks.py b/src/matchpatch/gui/main_window_callbacks.py index b9d138f..e6e2155 100644 --- a/src/matchpatch/gui/main_window_callbacks.py +++ b/src/matchpatch/gui/main_window_callbacks.py @@ -9,6 +9,12 @@ from PySide6.QtWidgets import QTableWidgetItem from matchpatch.devices.base import PatchFileAdjustments, normalize_regex_pattern +from matchpatch.gui.name_rules import ( + sanitize_preset_name_for_device, + sanitize_subdivision_name_for_device, + validate_preset_name_for_device, + validate_subdivision_name_for_device, +) from matchpatch.gui.preset_table import ( PresetTableCallbacks, refresh_adjustment_cell_widget, @@ -59,6 +65,18 @@ def input_path_text(self) -> str: def validate_helix_name(self, name: str, max_length: int | None = None) -> str: return self._window._validate_helix_name(name, max_length) + def validate_preset_name(self, name: str) -> str: + return validate_preset_name_for_device(self._window.device.currentData(), name) + + def validate_subdivision_name(self, name: str) -> str: + return validate_subdivision_name_for_device(self._window.device.currentData(), name) + + def sanitize_preset_name(self, name: str) -> str: + return sanitize_preset_name_for_device(self._window.device.currentData(), name) + + def sanitize_subdivision_name(self, name: str) -> str: + return sanitize_subdivision_name_for_device(self._window.device.currentData(), name) + def preset_name_max_length(self) -> int | None: return self._window._preset_name_max_length() diff --git a/src/matchpatch/gui/name_rules.py b/src/matchpatch/gui/name_rules.py new file mode 100644 index 0000000..04d4b51 --- /dev/null +++ b/src/matchpatch/gui/name_rules.py @@ -0,0 +1,85 @@ +"""GUI adapters for device-owned preset and subdivision names.""" + +from __future__ import annotations + +from collections.abc import Callable + +from matchpatch.devices import get_device_profile +from matchpatch.gui.table_formatting import sanitize_helix_name, validate_helix_name + + +def profile_name_max_length(profile: object, attribute: str) -> int | None: + naming_rules = getattr(profile, "naming_rules", None) + source = naming_rules() if naming_rules is not None else profile + value = getattr(source, attribute, None) + return value if isinstance(value, int) and not isinstance(value, bool) else None + + +def device_name_max_length(device: object, attribute: str) -> int | None: + profile = _profile_for_device(device) + return None if profile is None else profile_name_max_length(profile, attribute) + + +def validate_preset_name_for_device(device: object, name: str) -> str: + return _apply_name_rule( + device, + name, + method_name="validate_preset_name", + fallback=validate_helix_name, + max_length_attribute="preset_name_max_length", + ) + + +def validate_subdivision_name_for_device(device: object, name: str) -> str: + return _apply_name_rule( + device, + name, + method_name="validate_subdivision_name", + fallback=validate_helix_name, + max_length_attribute="snapshot_name_max_length", + ) + + +def sanitize_preset_name_for_device(device: object, name: str) -> str: + return _apply_name_rule( + device, + name, + method_name="sanitize_preset_name", + fallback=sanitize_helix_name, + max_length_attribute="preset_name_max_length", + ) + + +def sanitize_subdivision_name_for_device(device: object, name: str) -> str: + return _apply_name_rule( + device, + name, + method_name="sanitize_subdivision_name", + fallback=sanitize_helix_name, + max_length_attribute="snapshot_name_max_length", + ) + + +def _apply_name_rule( + device: object, + name: str, + *, + method_name: str, + fallback: Callable[[str, int | None], str], + max_length_attribute: str, +) -> str: + profile = _profile_for_device(device) + method = getattr(profile, method_name, None) if profile is not None else None + if method is not None: + return method(name) + max_length = None if profile is None else profile_name_max_length(profile, max_length_attribute) + return fallback(name, max_length) + + +def _profile_for_device(device: object) -> object | None: + if not device: + return None + try: + return get_device_profile(str(device)) + except ValueError: + return None diff --git a/src/matchpatch/gui/preset_table.py b/src/matchpatch/gui/preset_table.py index 4f45c01..259fd3f 100644 --- a/src/matchpatch/gui/preset_table.py +++ b/src/matchpatch/gui/preset_table.py @@ -42,7 +42,6 @@ _normalize_snapshot_output_paths, _parse_adjustment_display_text, _parse_output_level_display_text, - sanitize_helix_name, ) from matchpatch.gui.table_roles import ( ADJUSTMENT_MAX_DB, @@ -100,6 +99,14 @@ def input_path_text(self) -> str: ... def validate_helix_name(self, name: str, max_length: int | None = None) -> str: ... + def validate_preset_name(self, name: str) -> str: ... + + def validate_subdivision_name(self, name: str) -> str: ... + + def sanitize_preset_name(self, name: str) -> str: ... + + def sanitize_subdivision_name(self, name: str) -> str: ... + def preset_name_max_length(self) -> int | None: ... def snapshot_name_max_length(self) -> int | None: ... @@ -406,16 +413,10 @@ def _iter_snapshot_adjustment_cells( ) def _validated_preset_name(self, item: QTableWidgetItem) -> str: - return self.callbacks.validate_helix_name( - item.text(), - self.callbacks.preset_name_max_length(), - ) + return self.callbacks.validate_preset_name(item.text()) def _validated_snapshot_name(self, item: QTableWidgetItem) -> str: - return self.callbacks.validate_helix_name( - item.text(), - self.callbacks.snapshot_name_max_length(), - ) + return self.callbacks.validate_subdivision_name(item.text()) def _table_adjustment_value(self, item: QTableWidgetItem) -> float | None: if item.data(IGNORED_SNAPSHOT_ROLE) or item.data(BAD_LUFS_HIGHLIGHT_ROLE): @@ -799,9 +800,9 @@ def finish_manual_cell_edit( if column == 1 and Path(self.callbacks.input_path_text()).suffix.lower() == ".hlx": item.setText(value.strip().upper()) elif column == 2: - item.setText(sanitize_helix_name(value, self.callbacks.preset_name_max_length())) + item.setText(self.callbacks.sanitize_preset_name(value)) elif is_snapshot_name_column(column): - item.setText(sanitize_helix_name(value, self.callbacks.snapshot_name_max_length())) + item.setText(self.callbacks.sanitize_subdivision_name(value)) elif is_snapshot_adjustment_column(column): try: delta = float(value) @@ -857,7 +858,7 @@ def _normalize_single_preset_patch_item(self, item: QTableWidgetItem) -> None: def _handle_manual_adjustment_item_change(self, item: QTableWidgetItem) -> bool: if item.column() == 2: - self._sanitize_item_text(item, self.callbacks.preset_name_max_length()) + self._sanitize_item_text(item, self.callbacks.sanitize_preset_name) elif is_snapshot_name_column(item.column()): self._snapshot_name_item_changed(item) elif is_snapshot_adjustment_column(item.column()): @@ -876,7 +877,7 @@ def _handle_snapshot_adjustment_item_change(self, item: QTableWidgetItem) -> boo return True def _snapshot_name_item_changed(self, item: QTableWidgetItem) -> None: - self._sanitize_item_text(item, self.callbacks.snapshot_name_max_length()) + self._sanitize_item_text(item, self.callbacks.sanitize_subdivision_name) name_item = self.table.item(item.row(), 2) snapshot_index = ( item.column() - SNAPSHOT_TABLE_START_COLUMN @@ -901,8 +902,12 @@ def _snapshot_name_item_changed(self, item: QTableWidgetItem) -> None: ) self.callbacks.refresh_measurement_time_estimate() - def _sanitize_item_text(self, item: QTableWidgetItem, max_length: int | None) -> None: - sanitized = sanitize_helix_name(item.text(), max_length) + def _sanitize_item_text( + self, + item: QTableWidgetItem, + sanitize_name: Callable[[str], str], + ) -> None: + sanitized = sanitize_name(item.text()) if sanitized == item.text(): return signals_blocked = self.table.blockSignals(True) diff --git a/src/matchpatch/gui/settings_renderer.py b/src/matchpatch/gui/settings_renderer.py new file mode 100644 index 0000000..c85dc9c --- /dev/null +++ b/src/matchpatch/gui/settings_renderer.py @@ -0,0 +1,240 @@ +"""Declarative device settings renderer for descriptor-only profiles.""" + +from __future__ import annotations + +from collections.abc import Callable + +from PySide6.QtGui import QDoubleValidator +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QLabel, + QLineEdit, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from matchpatch.config import parse_channel_mapping +from matchpatch.devices.base import DeviceSettingDescriptor + +_ARG_ATTRS = { + "midi_output": "steering_output", + "midi_channel": "steering_channel", +} + +_SCOPE_TITLES = { + "audio": "Audio routing", + "steering": "MIDI steering", + "processing": "Processing", + "diagnostics": "Diagnostics", + "device": "Device", +} + + +class DescriptorSettingsPanel(QWidget): + """Settings panel rendered from ``DeviceSettingDescriptor`` metadata.""" + + def __init__(self, descriptors: tuple[DeviceSettingDescriptor, ...]) -> None: + super().__init__() + self.descriptors = descriptors + self.controls: dict[str, QWidget] = {} + + layout = QVBoxLayout(self) + grouped = _group_descriptors(descriptors) + for scope, scope_descriptors in grouped: + group = QGroupBox(_SCOPE_TITLES.get(scope, scope.title())) + form = QFormLayout(group) + for descriptor in scope_descriptors: + control = _create_control(descriptor) + control.setObjectName(descriptor.name) + self.controls[descriptor.name] = control + form.addRow(_label(descriptor), control) + layout.addWidget(group) + layout.addStretch() + + def populate(self, args: object) -> None: + for descriptor in self.descriptors: + value = _argument_value(args, descriptor) + if value is None: + value = descriptor.default + _set_control_value(self.controls[descriptor.name], descriptor, value) + + def append_arguments(self, argv: list[str]) -> None: + for descriptor in self.descriptors: + if not descriptor.cli_flags: + continue + flag = descriptor.cli_flags[0] + value = _control_argument_value(self.controls[descriptor.name], descriptor) + if descriptor.kind == "boolean": + if value: + argv.append(flag) + continue + if str(value).strip(): + argv.extend([flag, str(value)]) + + def collect_settings(self) -> dict[str, object]: + return { + descriptor.name: _collect_control_value(self.controls[descriptor.name], descriptor) + for descriptor in self.descriptors + } + + +def _group_descriptors( + descriptors: tuple[DeviceSettingDescriptor, ...], +) -> list[tuple[str, list[DeviceSettingDescriptor]]]: + groups: dict[str, list[DeviceSettingDescriptor]] = {} + order: list[str] = [] + for descriptor in descriptors: + if descriptor.scope not in groups: + groups[descriptor.scope] = [] + order.append(descriptor.scope) + groups[descriptor.scope].append(descriptor) + return [(scope, groups[scope]) for scope in order] + + +def _create_control(descriptor: DeviceSettingDescriptor) -> QWidget: + builders: dict[str, Callable[[DeviceSettingDescriptor], QWidget]] = { + "integer": _integer_control, + "float": _float_control, + "boolean": _boolean_control, + "choice": _choice_control, + } + return builders.get(descriptor.kind, _line_control)(descriptor) + + +def _line_control(descriptor: DeviceSettingDescriptor) -> QLineEdit: + control = QLineEdit() + if descriptor.kind == "float": + control.setValidator(QDoubleValidator(control)) + return control + + +def _integer_control(descriptor: DeviceSettingDescriptor) -> QSpinBox: + control = QSpinBox() + minimum = int(descriptor.minimum) if descriptor.minimum is not None else -(2**31) + maximum = int(descriptor.maximum) if descriptor.maximum is not None else 2**31 - 1 + control.setRange(minimum, maximum) + return control + + +def _float_control(descriptor: DeviceSettingDescriptor) -> QDoubleSpinBox: + control = QDoubleSpinBox() + minimum = float(descriptor.minimum) if descriptor.minimum is not None else -1_000_000_000.0 + maximum = float(descriptor.maximum) if descriptor.maximum is not None else 1_000_000_000.0 + control.setRange(minimum, maximum) + control.setDecimals(6) + control.setSingleStep(0.1) + return control + + +def _boolean_control(descriptor: DeviceSettingDescriptor) -> QCheckBox: + control = QCheckBox() + control.setText(descriptor.label or descriptor.name.replace("_", " ").title()) + return control + + +def _choice_control(descriptor: DeviceSettingDescriptor) -> QComboBox: + control = QComboBox() + control.addItems(list(descriptor.choices)) + return control + + +def _label(descriptor: DeviceSettingDescriptor) -> QLabel: + label = QLabel(descriptor.label or descriptor.name.replace("_", " ").title()) + if descriptor.help: + label.setToolTip(descriptor.help) + return label + + +def _argument_value(args: object, descriptor: DeviceSettingDescriptor) -> object | None: + for attr in _argument_attrs(descriptor): + value = getattr(args, attr, None) + if value is not None: + return value + return None + + +def _argument_attrs(descriptor: DeviceSettingDescriptor) -> tuple[str, ...]: + attrs = [_ARG_ATTRS.get(descriptor.name, descriptor.name)] + attrs.extend(flag.removeprefix("--").replace("-", "_") for flag in descriptor.cli_flags) + return tuple(dict.fromkeys(attrs)) + + +def _set_control_value( + control: QWidget, + descriptor: DeviceSettingDescriptor, + value: object | None, +) -> None: + if isinstance(control, QLineEdit): + control.setText(_text(_format_value(descriptor, value))) + elif isinstance(control, QSpinBox): + control.setValue(_int_value(value, descriptor.default)) + elif isinstance(control, QDoubleSpinBox): + control.setValue(_float_value(value, descriptor.default)) + elif isinstance(control, QCheckBox): + control.setChecked(bool(value)) + elif isinstance(control, QComboBox): + text = _text(value) + index = control.findText(text) + if index >= 0: + control.setCurrentIndex(index) + + +def _control_argument_value(control: QWidget, descriptor: DeviceSettingDescriptor) -> object: + if isinstance(control, QLineEdit): + return control.text() + value = _collect_control_value(control, descriptor) + if descriptor.kind == "channel_mapping" and isinstance(value, tuple): + return ",".join(str(channel) for channel in value) + return value + + +def _collect_control_value(control: QWidget, descriptor: DeviceSettingDescriptor) -> object: + if isinstance(control, QLineEdit): + text = control.text() + if descriptor.kind == "channel_mapping": + return parse_channel_mapping(text) + if descriptor.kind == "float": + return float(text) + return text + if isinstance(control, QSpinBox): + return control.value() + if isinstance(control, QDoubleSpinBox): + return control.value() + if isinstance(control, QCheckBox): + return control.isChecked() + if isinstance(control, QComboBox): + return control.currentText() + raise TypeError(f"Unsupported control for descriptor {descriptor.name}") + + +def _format_value(descriptor: DeviceSettingDescriptor, value: object | None) -> object | None: + if descriptor.kind == "channel_mapping" and isinstance(value, list | tuple): + return ",".join(str(channel) for channel in value) + return value + + +def _int_value(value: object | None, default: object | None) -> int: + candidate = value if value is not None else default + if isinstance(candidate, bool) or candidate is None: + return 0 + if isinstance(candidate, int | float | str): + return int(candidate) + return 0 + + +def _float_value(value: object | None, default: object | None) -> float: + candidate = value if value is not None else default + if isinstance(candidate, bool) or candidate is None: + return 0.0 + if isinstance(candidate, int | float | str): + return float(candidate) + return 0.0 + + +def _text(value: object | None) -> str: + return "" if value is None else str(value) diff --git a/src/matchpatch/measure.py b/src/matchpatch/measure.py index f7785f5..6f85fb9 100644 --- a/src/matchpatch/measure.py +++ b/src/matchpatch/measure.py @@ -8,9 +8,10 @@ import re import sys import time -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass, replace from pathlib import Path +from types import TracebackType from typing import TYPE_CHECKING, Any, Protocol, cast import numpy as np @@ -22,14 +23,24 @@ config_value, load_config, ) -from matchpatch.config import ( - parse_channel_mapping as parse_config_mapping, +from matchpatch.device_settings import ( + resolve_device_settings, + settings_to_audio_routing, + settings_to_steering_options, ) from matchpatch.devices import get_device_profile, list_device_profiles from matchpatch.devices.base import ( + AudioProcessingMode, + AudioProcessorTransport, AudioRouting, + AudioTransport, + AudioTransportCapabilities, + AudioTransportContext, + AudioTransportFactory, DeviceController, DeviceProfile, + OfflineAudioProcessingRequest, + OfflineAudioTransport, PatchFileHandler, SteeringOptions, validate_snapshot_count, @@ -84,6 +95,73 @@ def record(self, reference_audio: np.ndarray) -> np.ndarray: ... PlaybackEnabled = Callable[[], bool] +class BackendAudioTransport: + def __init__(self, backend: MeasurementBackend) -> None: + self.backend = backend + + def __enter__(self) -> BackendAudioTransport: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + return None + + def activate_target(self, target: int) -> None: + self.backend.activate_preset(target) + + def activate_subdivision(self, subdivision: int) -> None: + self.backend.reapply_snapshot(subdivision) + + def process(self, reference_audio: np.ndarray) -> np.ndarray: + return self.backend.record(reference_audio) + + +class TransportMeasurementBackend: + def __init__( + self, + transport: AudioTransport, + sample_rate: int | None = None, + temporary_file_dir: Path | None = None, + ) -> None: + self.transport = transport + self.sample_rate = sample_rate + self.temporary_file_dir = temporary_file_dir + self.active_preset_id: int | None = None + self.active_snapshot: int | None = None + + def activate_preset(self, preset_id: int) -> None: + self.active_preset_id = preset_id + self.active_snapshot = None + self.transport.activate_target(preset_id) + + def reapply_snapshot(self, snapshot: int) -> None: + self.active_snapshot = snapshot + self.transport.activate_subdivision(snapshot) + + def record(self, reference_audio: np.ndarray) -> np.ndarray: + if isinstance(self.transport, OfflineAudioTransport): + if self.sample_rate is None: + raise ValueError("Offline transport requires a sample rate") + if self.active_preset_id is None or self.active_snapshot is None: + raise RuntimeError("Offline transport target and subdivision must be active") + return self.transport.process_offline( + OfflineAudioProcessingRequest( + reference_audio=reference_audio, + sample_rate=self.sample_rate, + target_id=self.active_preset_id, + subdivision_id=self.active_snapshot, + target_metadata={"preset_id": self.active_preset_id}, + subdivision_metadata={"snapshot": self.active_snapshot}, + temporary_file_dir=self.temporary_file_dir, + ) + ) + return self.transport.process(reference_audio) + + class HardwareBackend: def __init__( self, @@ -193,6 +271,55 @@ def _gain_db(preset_id: int, snapshot: int) -> float: return float(((preset_id - 1) % 5 - 2) * 2 + (snapshot - 1)) +class LoopbackTransportFactory: + capabilities = AudioTransportCapabilities(mode="loopback") + + def supports(self, mode: AudioProcessingMode, settings: Mapping[str, object]) -> bool: # noqa: ARG002 + return mode == self.capabilities.mode + + def create(self, context: AudioTransportContext) -> AudioProcessorTransport: # noqa: ARG002 + return BackendAudioTransport(LoopbackBackend()) + + +class SimulatedTransportFactory: + capabilities = AudioTransportCapabilities(mode="simulated") + + def supports(self, mode: AudioProcessingMode, settings: Mapping[str, object]) -> bool: # noqa: ARG002 + return mode == self.capabilities.mode + + def create(self, context: AudioTransportContext) -> AudioProcessorTransport: + return BackendAudioTransport( + SimulatedHardwareBackend( + context.audio_routing, + context.snapshot_count, + _channel_mapping_setting(context.settings, "input_mapping"), + _channel_mapping_setting(context.settings, "output_mapping"), + context.failing_preset_ids, + ) + ) + + +class HardwareTransportFactory: + capabilities = AudioTransportCapabilities(mode="hardware") + + def supports(self, mode: AudioProcessingMode, settings: Mapping[str, object]) -> bool: # noqa: ARG002 + return mode == self.capabilities.mode + + def create(self, context: AudioTransportContext) -> AudioProcessorTransport: + if context.audio_config is None or context.controller is None: + raise ValueError("Hardware transport requires audio configuration and controller") + return BackendAudioTransport( + HardwareBackend( + cast("AudioConfig", context.audio_config), + context.controller, + context.timing_values.get( + "measurement_wait", + context.steering_options.measurement_wait_seconds, + ), + ) + ) + + def parse_int_list(value: str) -> list[int]: return [int(item.strip()) for item in value.split(",") if item.strip()] @@ -691,17 +818,14 @@ def _emit_progress( def resolve_audio_config(args: argparse.Namespace, profile: DeviceProfile) -> AudioConfig: from matchpatch.audio import AudioConfig - defaults = profile.default_audio_routing() + settings = getattr(args, "device_settings", None) or resolve_device_settings(profile, {}, args) + routing = settings_to_audio_routing(profile, settings) config = AudioConfig( - device=args.audio_device if args.audio_device is not None else defaults.device, - sample_rate=args.sample_rate if args.sample_rate is not None else defaults.sample_rate, - input_mapping=( - args.input_mapping if args.input_mapping is not None else defaults.input_mapping - ), - output_mapping=( - args.output_mapping if args.output_mapping is not None else defaults.output_mapping - ), - blocksize=args.blocksize, + device=routing.device, + sample_rate=routing.sample_rate, + input_mapping=routing.input_mapping, + output_mapping=routing.output_mapping, + blocksize=cast("int", settings.get("blocksize", getattr(args, "blocksize", 0) or 0)), pre_roll_seconds=getattr(args, "pre_roll", 0.2), post_roll_seconds=getattr(args, "post_roll", 0.1), round_trip_latency_seconds=getattr(args, "round_trip_latency", 0.02), @@ -727,29 +851,117 @@ def resolve_steering_options( args: argparse.Namespace, profile: DeviceProfile, ) -> SteeringOptions: - defaults = profile.default_steering_options() - return SteeringOptions( - output=(args.steering_output if args.steering_output is not None else defaults.output), - channel=args.steering_channel if args.steering_channel is not None else defaults.channel, - preset_wait_seconds=( - args.preset_wait if args.preset_wait is not None else defaults.preset_wait_seconds - ), - snapshot_wait_seconds=( - args.snapshot_wait if args.snapshot_wait is not None else defaults.snapshot_wait_seconds - ), - measurement_wait_seconds=( - args.measurement_wait - if args.measurement_wait is not None - else defaults.measurement_wait_seconds - ), + return settings_to_steering_options( + profile, + getattr(args, "device_settings", None) or resolve_device_settings(profile, {}, args), ) +def _builtin_audio_transport_factories() -> tuple[AudioTransportFactory, ...]: + return ( + HardwareTransportFactory(), + LoopbackTransportFactory(), + SimulatedTransportFactory(), + ) + + +def _audio_transport_factories(profile: DeviceProfile) -> tuple[AudioTransportFactory, ...]: + profile_factories = getattr(profile, "audio_transport_factories", lambda: ())() + return (*profile_factories, *_builtin_audio_transport_factories()) + + +def _backend_mode(backend: str) -> AudioProcessingMode: + if backend not in {"hardware", "loopback", "simulated", "offline"}: + raise ValueError(f"Unknown measurement backend: {backend}") + return cast("AudioProcessingMode", backend) + + +def _transport_settings( + args: argparse.Namespace, + profile: DeviceProfile, +) -> Mapping[str, object]: + return getattr(args, "device_settings", None) or resolve_device_settings(profile, {}, args) + + +def _select_audio_transport_factory( + profile: DeviceProfile, + mode: AudioProcessingMode, + settings: Mapping[str, object], +) -> AudioTransportFactory: + _validate_profile_backend_support(profile, mode) + for factory in _audio_transport_factories(profile): + if factory.supports(mode, settings): + return factory + if mode == "offline": + raise NotImplementedError( + "The offline measurement backend is not implemented yet; " + "install or enable a plugin-provided offline audio transport factory" + ) + raise ValueError( + f"Backend {mode!r} is supported by {profile.display_name}, " + "but no audio transport factory is available" + ) + + +def _validate_profile_backend_support(profile: DeviceProfile, mode: AudioProcessingMode) -> None: + supported_backends = profile.measurement_backends() + if mode not in supported_backends: + supported = ", ".join(supported_backends) + raise ValueError( + f"Backend {mode!r} is not supported by {profile.display_name}; " + f"choose one of: {supported}" + ) + + +def _transport_context( + args: argparse.Namespace, + profile: DeviceProfile, + mode: AudioProcessingMode, + settings: Mapping[str, object], + sample_rate: int, + snapshot_count: int, + *, + audio_config: object | None = None, + controller: DeviceController | None = None, + timing_values: Mapping[str, float] | None = None, +) -> AudioTransportContext: + temporary_file_dir = getattr(args, "temporary_file_dir", None) + return AudioTransportContext( + profile=profile, + mode=mode, + settings=settings, + audio_routing=settings_to_audio_routing(profile, settings), + steering_options=resolve_steering_options(args, profile), + sample_rate=sample_rate, + snapshot_count=snapshot_count, + audio_config=audio_config, + controller=controller, + temporary_file_dir=Path(temporary_file_dir) if temporary_file_dir else None, + failing_preset_ids=frozenset(getattr(args, "simulate_fail_presets", ())), + timing_values=timing_values or {}, + ) + + +def _channel_mapping_setting( + settings: Mapping[str, object], + name: str, +) -> tuple[int, int] | None: + value = settings.get(name) + if value is None: + return None + channels = tuple(cast("tuple[int, int] | list[int]", value)) + if len(channels) != 2: + raise ValueError(f"{name} must contain exactly two channels") + return channels[0], channels[1] + + def measure(args: argparse.Namespace) -> None: profile = get_device_profile(args.device) - _raise_unimplemented_backend(args.backend) - defaults = profile.default_audio_routing() - sample_rate = args.sample_rate if args.sample_rate is not None else defaults.sample_rate + mode = _backend_mode(args.backend) + settings = _transport_settings(args, profile) + factory = _select_audio_transport_factory(profile, mode, settings) + routing = settings_to_audio_routing(profile, settings) + sample_rate = routing.sample_rate on_progress = getattr(args, "on_progress", None) _emit_progress( on_progress, @@ -772,47 +984,28 @@ def measure(args: argparse.Namespace) -> None: Path(args.recordings_dir) if getattr(args, "recordings_dir", None) else None ) snapshot_plan = getattr(args, "snapshot_plan", None) + measure_kwargs = { + "snapshot_count": snapshot_count, + "analysis_options": analysis_options, + "on_progress": on_progress, + "log_output": log_output, + "play_recorded_output": play_recorded_output, + "recorded_output_dir": recorded_output_dir, + "snapshot_plan": snapshot_plan, + } - if args.backend == "loopback": - measure_presets( - profile, - args.preset_ids, - Path(args.csv), - reference, - sample_rate, - LoopbackBackend(), - snapshot_count=snapshot_count, - analysis_options=analysis_options, - on_progress=on_progress, - log_output=log_output, - play_recorded_output=play_recorded_output, - recorded_output_dir=recorded_output_dir, - snapshot_plan=snapshot_plan, - ) - return - - if args.backend == "simulated": - measure_presets( - profile, - args.preset_ids, - Path(args.csv), - reference, - sample_rate, - SimulatedHardwareBackend( - defaults, - snapshot_count, - args.input_mapping, - args.output_mapping, - frozenset(args.simulate_fail_presets), - ), - snapshot_count=snapshot_count, - analysis_options=analysis_options, - on_progress=on_progress, - log_output=log_output, - play_recorded_output=play_recorded_output, - recorded_output_dir=recorded_output_dir, - snapshot_plan=snapshot_plan, - ) + if mode != "hardware": + context = _transport_context(args, profile, mode, settings, sample_rate, snapshot_count) + with factory.create(context) as transport: + measure_presets( + profile, + args.preset_ids, + Path(args.csv), + reference, + sample_rate, + TransportMeasurementBackend(transport, sample_rate, context.temporary_file_dir), + **measure_kwargs, + ) return from matchpatch.audio import prepare_audio_config @@ -831,32 +1024,35 @@ def measure(args: argparse.Namespace) -> None: ProgressEvent("measurement_preparation", message="Opening processor MIDI output..."), ) with profile.create_controller(steering_options) as controller: - measure_presets( + context = _transport_context( + args, profile, - args.preset_ids, - Path(args.csv), - reference, + mode, + settings, sample_rate, - HardwareBackend( - audio_config, - controller, - steering_options.measurement_wait_seconds, - ), - snapshot_count=snapshot_count, - analysis_options=analysis_options, - on_progress=on_progress, - log_output=log_output, - play_recorded_output=play_recorded_output, - recorded_output_dir=recorded_output_dir, - snapshot_plan=snapshot_plan, + snapshot_count, + audio_config=audio_config, + controller=controller, ) + with factory.create(context) as transport: + measure_presets( + profile, + args.preset_ids, + Path(args.csv), + reference, + sample_rate, + TransportMeasurementBackend(transport, sample_rate, context.temporary_file_dir), + **measure_kwargs, + ) def optimize_measurement_timing(args: argparse.Namespace) -> None: profile = get_device_profile(args.device) - _raise_unimplemented_backend(args.backend) - defaults = profile.default_audio_routing() - sample_rate = args.sample_rate if args.sample_rate is not None else defaults.sample_rate + mode = _backend_mode(args.backend) + settings = _transport_settings(args, profile) + factory = _select_audio_transport_factory(profile, mode, settings) + routing = settings_to_audio_routing(profile, settings) + sample_rate = routing.sample_rate reference = load_reference_audio(Path(args.reference_di), sample_rate) initial_values = _timing_values(args) valid_parameter_names = {parameter.name for parameter in TIMING_PARAMETERS} @@ -887,49 +1083,7 @@ def optimize_measurement_timing(args: argparse.Namespace) -> None: getattr(args, "play_recorded_output", False), ) - if args.backend == "loopback": - results = optimize_timing_parameters( - profile, - args.preset_id, - alternate_id, - reference, - sample_rate, - lambda values: PlaybackBackend(LoopbackBackend(), sample_rate, play_recorded_output), - initial_values, - analysis_options, - stability_runs=args.stability_runs, - termination_tolerance_percent=args.termination_tolerance, - stability_tolerance_percent=args.stability_tolerance, - on_progress=on_progress, - parameters=optimization_parameters, - ) - elif args.backend == "simulated": - results = optimize_timing_parameters( - profile, - args.preset_id, - alternate_id, - reference, - sample_rate, - lambda values: PlaybackBackend( - SimulatedHardwareBackend( - defaults, - max(2, getattr(profile, "snapshot_count", 4)), - args.input_mapping, - args.output_mapping, - frozenset(args.simulate_fail_presets), - ), - sample_rate, - play_recorded_output, - ), - initial_values, - analysis_options, - stability_runs=args.stability_runs, - termination_tolerance_percent=args.termination_tolerance, - stability_tolerance_percent=args.stability_tolerance, - on_progress=on_progress, - parameters=optimization_parameters, - ) - else: + if mode == "hardware": from matchpatch.audio import prepare_audio_config audio_config = prepare_audio_config(resolve_audio_config(args, profile)) @@ -945,16 +1099,27 @@ def hardware_backend(values: dict[str, float]) -> PlaybackBackend: preset_wait_seconds=values["preset_wait"], snapshot_wait_seconds=values["snapshot_wait"], ) + context = _transport_context( + args, + profile, + mode, + settings, + sample_rate, + max(2, getattr(profile, "snapshot_count", 4)), + audio_config=replace( + audio_config, + pre_roll_seconds=values["pre_roll"], + post_roll_seconds=values["post_roll"], + round_trip_latency_seconds=values["round_trip_latency"], + ), + controller=controller, + timing_values=values, + ) return PlaybackBackend( - HardwareBackend( - replace( - audio_config, - pre_roll_seconds=values["pre_roll"], - post_roll_seconds=values["post_roll"], - round_trip_latency_seconds=values["round_trip_latency"], - ), - controller, - values["measurement_wait"], + TransportMeasurementBackend( + factory.create(context), + sample_rate, + context.temporary_file_dir, ), sample_rate, play_recorded_output, @@ -975,6 +1140,43 @@ def hardware_backend(values: dict[str, float]) -> PlaybackBackend: on_progress=on_progress, parameters=optimization_parameters, ) + else: + + def transport_backend(values: dict[str, float]) -> PlaybackBackend: + context = _transport_context( + args, + profile, + mode, + settings, + sample_rate, + max(2, getattr(profile, "snapshot_count", 4)), + timing_values=values, + ) + return PlaybackBackend( + TransportMeasurementBackend( + factory.create(context), + sample_rate, + context.temporary_file_dir, + ), + sample_rate, + play_recorded_output, + ) + + results = optimize_timing_parameters( + profile, + args.preset_id, + alternate_id, + reference, + sample_rate, + transport_backend, + initial_values, + analysis_options, + stability_runs=args.stability_runs, + termination_tolerance_percent=args.termination_tolerance, + stability_tolerance_percent=args.stability_tolerance, + on_progress=on_progress, + parameters=optimization_parameters, + ) result_by_name = {result.parameter.name: result for result in (*pinned_results, *results)} results = tuple( @@ -1235,8 +1437,10 @@ def apply_config(args: argparse.Namespace) -> argparse.Namespace: config = load_config(args.config) profile = get_device_profile(args.device) _apply_backend_config(args, config, profile) - _apply_audio_config(args, config, profile) - _apply_timing_config(args, config, profile) + args.device_settings = resolve_device_settings(profile, config, args) + _validate_configured_backend_factory(args, profile) + _apply_resolved_device_settings(args) + _apply_timing_config(args, config) _apply_optimization_config(args, config) if args.snapshot_count is not None: validate_snapshot_count(profile, args.snapshot_count) @@ -1254,89 +1458,43 @@ def _apply_backend_config( ) if args.backend == "helix": args.backend = "hardware" - supported_backends = profile.measurement_backends() - if args.backend not in supported_backends: - supported = ", ".join(supported_backends) - raise ValueError( - f"Backend {args.backend!r} is not supported by {profile.display_name}; " - f"choose one of: {supported}" - ) - - -def _raise_unimplemented_backend(backend: str) -> None: - if backend == "offline": - raise NotImplementedError("The offline measurement backend is not implemented yet") + _validate_profile_backend_support(profile, _backend_mode(args.backend)) -def _apply_audio_config( +def _validate_configured_backend_factory( args: argparse.Namespace, - config: Config, profile: DeviceProfile, ) -> None: - default_audio = profile.default_audio_routing() - device_audio = ("devices", args.device, "audio") - args.audio_device = _arg_or_config( - args, - "audio_device", - config, - *device_audio, - "device", - default=default_audio.device, - ) - args.sample_rate = _arg_or_config( - args, - "sample_rate", - config, - *device_audio, - "sample_rate", - default=default_audio.sample_rate, - ) - _apply_mapping_config(args, config, device_audio, default_audio) - args.blocksize = _arg_or_config( - args, "blocksize", config, *device_audio, "blocksize", default=0 + mode = _backend_mode(args.backend) + if mode == "offline": + return + settings = cast("Mapping[str, object]", args.device_settings) + if any(factory.supports(mode, settings) for factory in _audio_transport_factories(profile)): + return + raise ValueError( + f"Backend {mode!r} is supported by {profile.display_name}, " + "but no audio transport factory is available" ) -def _apply_mapping_config( - args: argparse.Namespace, - config: Config, - device_audio: tuple[str, str, str], - default_audio: AudioRouting, -) -> None: - for name in ("input_mapping", "output_mapping"): - value = _arg_or_config( - args, - name, - config, - *device_audio, - name, - default=getattr(default_audio, name), - ) - if value is not None: - setattr(args, name, parse_config_mapping(value)) +def _apply_resolved_device_settings(args: argparse.Namespace) -> None: + settings = args.device_settings + args.audio_device = settings["audio_device"] + args.sample_rate = settings["sample_rate"] + args.input_mapping = settings["input_mapping"] + args.output_mapping = settings["output_mapping"] + args.blocksize = settings["blocksize"] + args.steering_output = settings["midi_output"] + args.steering_channel = settings["midi_channel"] + args.preset_wait = settings["preset_wait"] + args.snapshot_wait = settings["snapshot_wait"] + args.measurement_wait = settings["measurement_wait"] def _apply_timing_config( args: argparse.Namespace, config: Config, - profile: DeviceProfile, ) -> None: - default_steering = profile.default_steering_options() - device_steering = ("devices", args.device, "steering") - for attr, key, default in ( - ("steering_output", "output", default_steering.output), - ("steering_channel", "channel", default_steering.channel), - ("preset_wait", "preset_wait_seconds", default_steering.preset_wait_seconds), - ("snapshot_wait", "snapshot_wait_seconds", default_steering.snapshot_wait_seconds), - ( - "measurement_wait", - "measurement_wait_seconds", - default_steering.measurement_wait_seconds, - ), - ): - setattr( - args, attr, _arg_or_config(args, attr, config, *device_steering, key, default=default) - ) for attr, key, default in ( ("pre_roll", "pre_roll_seconds", 0.2), ("post_roll", "post_roll_seconds", 0.1), diff --git a/src/matchpatch/normalize.py b/src/matchpatch/normalize.py index 1db58b0..d3cf233 100644 --- a/src/matchpatch/normalize.py +++ b/src/matchpatch/normalize.py @@ -19,6 +19,7 @@ from matchpatch.analysis import AnalysisOptions from matchpatch.config import Config, config_value, load_config, parse_channel_mapping, prefer +from matchpatch.device_settings import resolve_device_settings, setting_diagnostics from matchpatch.devices import get_device_profile from matchpatch.devices.base import ( NormalizationPolicy, @@ -210,29 +211,6 @@ def float_prefer(arg_value: object | None, section: str, key: str, default: floa def apply_config(args: argparse.Namespace) -> argparse.Namespace: config = load_config(args.config) profile = get_device_profile(args.device) - default_audio = ( - profile.default_audio_routing() - if hasattr(profile, "default_audio_routing") - else argparse.Namespace( - device=None, - sample_rate=None, - input_mapping=None, - output_mapping=None, - ) - ) - default_steering = ( - profile.default_steering_options() - if hasattr(profile, "default_steering_options") - else argparse.Namespace( - output=None, - channel=None, - preset_wait_seconds=None, - snapshot_wait_seconds=None, - measurement_wait_seconds=None, - ) - ) - device_audio = ("devices", args.device, "audio") - device_steering = ("devices", args.device, "steering") args.backend = ( args.backend or os.getenv("MATCHPATCH_BACKEND") @@ -254,74 +232,8 @@ def apply_config(args: argparse.Namespace) -> argparse.Namespace: args.target_lufs = prefer(args.target_lufs, config, "normalize", "target_lufs", default=-16.0) args.timeout = prefer(args.timeout, config, "normalize", "timeout_seconds") args.ignore_bad_lufs = True - args.audio_device = prefer( - args.audio_device, - config, - *device_audio, - "device", - default=default_audio.device, - ) - args.sample_rate = prefer( - args.sample_rate, - config, - *device_audio, - "sample_rate", - default=default_audio.sample_rate, - ) - args.input_mapping = _mapping_argument( - prefer( - args.input_mapping, - config, - *device_audio, - "input_mapping", - default=default_audio.input_mapping, - ) - ) - args.output_mapping = _mapping_argument( - prefer( - args.output_mapping, - config, - *device_audio, - "output_mapping", - default=default_audio.output_mapping, - ) - ) - args.blocksize = prefer(args.blocksize, config, *device_audio, "blocksize", default=0) - args.steering_output = prefer( - args.steering_output, - config, - *device_steering, - "output", - default=default_steering.output, - ) - args.steering_channel = prefer( - args.steering_channel, - config, - *device_steering, - "channel", - default=default_steering.channel, - ) - args.preset_wait = prefer( - args.preset_wait, - config, - *device_steering, - "preset_wait_seconds", - default=default_steering.preset_wait_seconds, - ) - args.snapshot_wait = prefer( - args.snapshot_wait, - config, - *device_steering, - "snapshot_wait_seconds", - default=default_steering.snapshot_wait_seconds, - ) - args.measurement_wait = prefer( - args.measurement_wait, - config, - *device_steering, - "measurement_wait_seconds", - default=default_steering.measurement_wait_seconds, - ) + args.device_settings = resolve_device_settings(profile, config, args) + _apply_resolved_device_settings(args) args.pre_roll = prefer(args.pre_roll, config, "analysis", "pre_roll_seconds", default=0.2) args.post_roll = prefer(args.post_roll, config, "analysis", "post_roll_seconds", default=0.1) args.round_trip_latency = prefer( @@ -336,6 +248,20 @@ def apply_config(args: argparse.Namespace) -> argparse.Namespace: return args +def _apply_resolved_device_settings(args: argparse.Namespace) -> None: + settings = args.device_settings + args.audio_device = settings["audio_device"] + args.sample_rate = settings["sample_rate"] + args.input_mapping = _mapping_argument(settings["input_mapping"]) + args.output_mapping = _mapping_argument(settings["output_mapping"]) + args.blocksize = settings["blocksize"] + args.steering_output = settings["midi_output"] + args.steering_channel = settings["midi_channel"] + args.preset_wait = settings["preset_wait"] + args.snapshot_wait = settings["snapshot_wait"] + args.measurement_wait = settings["measurement_wait"] + + def run_command(args: list[object], timeout: float | None = None) -> None: subprocess.run( [str(arg) for arg in args], @@ -1034,6 +960,7 @@ def request_from_args(args: argparse.Namespace) -> NormalizationRequest: timeout=args.timeout, policy=args.policy, analysis_options=args.analysis_options, + device_settings=setting_diagnostics(getattr(args, "device_settings", {})), ) diff --git a/src/matchpatch/preflight.py b/src/matchpatch/preflight.py index 32a31f6..e8e9aef 100644 --- a/src/matchpatch/preflight.py +++ b/src/matchpatch/preflight.py @@ -7,7 +7,7 @@ from matchpatch.custom_adjustments import load_custom_adjustments_file from matchpatch.devices import get_device_profile -from matchpatch.devices.base import DeviceProfile, PatchFileHandler +from matchpatch.devices.base import DeviceProfile, DiagnosticsContext, PatchFileHandler from matchpatch.diagnostics import DiagnosticCheck, effective_config_from_request from matchpatch.normalize import collect_windows_hardware_diagnostics from matchpatch.workflow import PROJECT_DIR, NormalizationRequest @@ -56,6 +56,7 @@ def run_preflight_checks( checks.append(_reference_di_check(request.reference_di)) checks.append(_custom_adjustments_check(request)) checks.append(_backend_check(request.backend, profile)) + checks.extend(_device_diagnostic_checks(request, profile, handler)) checks.extend( _backend_specific_checks( request, @@ -199,6 +200,39 @@ def _backend_check(backend: str, profile: DeviceProfile | None) -> DiagnosticChe ) +def _device_diagnostic_checks( + request: NormalizationRequest, + profile: DeviceProfile | None, + handler: PatchFileHandler | None, +) -> list[DiagnosticCheck]: + if profile is None or handler is None: + return [] + try: + provider_factory = getattr(profile, "diagnostics_provider", None) + if provider_factory is None: + return [] + provider = provider_factory() + if provider is None: + return [] + context = DiagnosticsContext( + request=request, + profile=profile, + handler=handler, + resolved_settings=request.device_settings or {}, + project_dir=PROJECT_DIR, + ) + return list(provider.run_checks(context)) + except Exception as exc: # noqa: BLE001 + return [ + DiagnosticCheck( + "device_diagnostics", + "fail", + "Device diagnostics provider failed", + str(exc), + ) + ] + + def _backend_specific_checks( request: NormalizationRequest, *, diff --git a/src/matchpatch/workflow.py b/src/matchpatch/workflow.py index c9d90c9..ea44fdc 100644 --- a/src/matchpatch/workflow.py +++ b/src/matchpatch/workflow.py @@ -9,15 +9,20 @@ from dataclasses import dataclass from dataclasses import replace as dataclass_replace from pathlib import Path +from typing import TypeVar from matchpatch.analysis import AnalysisOptions from matchpatch.custom_adjustments import load_custom_adjustments_file from matchpatch.devices import get_device_profile from matchpatch.devices.base import ( DeviceProfile, + DeviceSettings, + DeviceTargetId, NormalizationPolicy, PatchFileAdjustments, PatchFileHandler, + SubdivisionSelection, + TargetSelection, validate_snapshot_count, ) from matchpatch.progress import ProgressEvent @@ -78,6 +83,7 @@ class NormalizationRequest: snapshot_plan: tuple[tuple[str, tuple[int, ...]], ...] = () policy: NormalizationPolicy = NormalizationPolicy() analysis_options: AnalysisOptions = AnalysisOptions() + device_settings: DeviceSettings | None = None @dataclass(frozen=True) @@ -92,6 +98,7 @@ class NormalizationResult: AnalysisRunner = Callable[[NormalizationRequest, list[int], Path, ProgressCallback | None], None] ProfileProvider = Callable[[str], DeviceProfile] TempDirFactory = Callable[[], Path] +T = TypeVar("T") def normalize_presets( @@ -114,15 +121,16 @@ def normalize_presets( output_path = _resolve_output_paths( request, handler, input_path, profile, confirm_import, on_progress ) - preset_ids = _select_presets(request, handler, input_path) - preset_ids, snapshot_plan = _apply_diff_filter( - request, handler, input_path, preset_ids, request.snapshot_plan + selected_targets = _select_targets(request, handler, input_path) + selected_targets, snapshot_plan = _apply_diff_filter( + request, handler, input_path, selected_targets, request.snapshot_plan ) - preset_ids = _apply_limit(request.limit, preset_ids) + selected_targets = _apply_limit(request.limit, selected_targets) - if not preset_ids: + if not selected_targets: raise ValueError("Patch file contains no measurable presets") + preset_ids = _compat_numeric_target_ids(selected_targets) return _run_normalization_workspace( request, run_analysis, @@ -187,50 +195,90 @@ def _resolve_output_paths( return output_path -def _select_presets( +def _select_targets( request: NormalizationRequest, handler: PatchFileHandler, input_path: Path, -) -> list[int]: - requested_ids = ( +) -> list[TargetSelection]: + if _has_target_selection_api(handler): + requested_ids = ( + handler.parse_target_set(request.preset_set) if request.preset_set is not None else None + ) + return handler.select_targets( + input_path, + handler.list_targets(input_path), + requested_ids, + ) + + requested_preset_ids = ( handler.parse_patch_set(request.preset_set) if request.preset_set is not None else None ) assignments = handler.list_assignments(input_path) - return handler.select_preset_ids(input_path, assignments, requested_ids) + return [ + TargetSelection( + id=preset_id, + display_label=handler.format_patch_id(preset_id), + compat_numeric_id=preset_id, + ) + for preset_id in handler.select_preset_ids( + input_path, + assignments, + requested_preset_ids, + ) + ] def _apply_diff_filter( request: NormalizationRequest, handler: PatchFileHandler, input_path: Path, - preset_ids: list[int], + selected_targets: list[TargetSelection], snapshot_plan: tuple[tuple[str, tuple[int, ...]], ...], -) -> tuple[list[int], tuple[tuple[str, tuple[int, ...]], ...]]: +) -> tuple[list[TargetSelection], tuple[tuple[str, tuple[int, ...]], ...]]: if request.diff_input_path is None: - return preset_ids, snapshot_plan + return selected_targets, snapshot_plan previous_input_path = request.diff_input_path.resolve() if previous_input_path.suffix.lower() != input_path.suffix.lower(): raise ValueError("--diff-input must use the same file type as --input") - diff_snapshots = _diff_snapshots(request, handler, input_path, previous_input_path) + diff_snapshots = _diff_subdivisions(request, handler, input_path, previous_input_path) diff_ids = set(diff_snapshots) - filtered_ids = [preset_id for preset_id in preset_ids if preset_id in diff_ids] - return filtered_ids, _intersect_snapshot_plans( + filtered_targets = [target for target in selected_targets if target.id in diff_ids] + return filtered_targets, _intersect_snapshot_plans( snapshot_plan, - tuple( - (handler.format_patch_id(preset_id), diff_snapshots[preset_id]) - for preset_id in filtered_ids - ), + tuple((target.display_label, diff_snapshots[target.id]) for target in filtered_targets), ) -def _diff_snapshots( +def _diff_subdivisions( + request: NormalizationRequest, + handler: PatchFileHandler, + input_path: Path, + previous_input_path: Path, +) -> dict[DeviceTargetId, tuple[int, ...]]: + diff_subdivisions = getattr(handler, "diff_subdivisions", None) + if diff_subdivisions is None: + return _legacy_diff_subdivisions(request, handler, input_path, previous_input_path) + + return { + target_id: tuple( + _compat_numeric_subdivision_id(subdivision) for subdivision in subdivisions + ) + for target_id, subdivisions in diff_subdivisions( + input_path, + previous_input_path, + request.policy.snapshot_count, + ).items() + } + + +def _legacy_diff_subdivisions( request: NormalizationRequest, handler: PatchFileHandler, input_path: Path, previous_input_path: Path, -) -> dict[int, tuple[int, ...]]: +) -> dict[DeviceTargetId, tuple[int, ...]]: diff_snapshot_ids = getattr(handler, "diff_snapshot_ids", None) if diff_snapshot_ids is not None: return diff_snapshot_ids( @@ -245,12 +293,42 @@ def _diff_snapshots( } -def _apply_limit(limit: int | None, preset_ids: list[int]) -> list[int]: +def _has_target_selection_api(handler: PatchFileHandler) -> bool: + return hasattr(handler, "list_targets") and hasattr(handler, "select_targets") + + +def _apply_limit(limit: int | None, items: list[T]) -> list[T]: if limit is None: - return preset_ids + return items if limit < 1: raise ValueError("--limit must be at least 1") - return preset_ids[:limit] + return items[:limit] + + +def _compat_numeric_target_ids(targets: list[TargetSelection]) -> list[int]: + preset_ids: list[int] = [] + for target in targets: + if target.compat_numeric_id is not None: + preset_ids.append(target.compat_numeric_id) + elif isinstance(target.id, int): + preset_ids.append(target.id) + else: + raise ValueError( + f"Target {target.display_label!r} cannot be measured by the legacy worker" + ) + return preset_ids + + +def _compat_numeric_subdivision_id(subdivision: SubdivisionSelection) -> int: + if subdivision.compat_numeric_id is not None: + return subdivision.compat_numeric_id + if isinstance(subdivision.id, int): + return subdivision.id + if subdivision.index is not None: + return subdivision.index + 1 + raise ValueError( + f"Subdivision {subdivision.display_label!r} cannot be measured by the legacy worker" + ) def _run_normalization_workspace( diff --git a/tests/test_config.py b/tests/test_config.py index 98a9e93..4b7142f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,10 +3,13 @@ import re import tomllib from pathlib import Path +from types import SimpleNamespace import pytest from matchpatch import config +from matchpatch.device_settings import resolve_device_settings, settings_to_audio_routing +from matchpatch.devices import get_device_profile from matchpatch.devices.base import NormalizationPolicy, normalize_regex_pattern @@ -93,6 +96,29 @@ def test_export_default_config_writes_loadable_toml(tmp_path) -> None: assert loaded["devices"]["helix"]["steering"]["snapshot_wait_seconds"] == 0.2 +def test_resolve_device_settings_layers_descriptor_defaults_config_and_cli() -> None: + profile = get_device_profile("helix") + settings = resolve_device_settings( + profile, + { + "devices": { + "helix": { + "audio": {"device": "Configured", "input_mapping": [3, 4]}, + "steering": {"output": "Configured MIDI"}, + } + } + }, + SimpleNamespace(audio_device="CLI", output_mapping="5,6"), + ) + + assert settings["audio_device"] == "CLI" + assert settings["sample_rate"] == 48000 + assert settings["input_mapping"] == (3, 4) + assert settings["output_mapping"] == (5, 6) + assert settings["midi_output"] == "Configured MIDI" + assert settings_to_audio_routing(profile, settings).device == "CLI" + + @pytest.mark.parametrize("snapshot_name", ["solo", "Solo Pitch", "solo 1", "clean SOLO boost"]) def test_default_solo_regex_matches_names_containing_solo(snapshot_name) -> None: assert re.search(NormalizationPolicy().solo_regex, snapshot_name) diff --git a/tests/test_devices.py b/tests/test_devices.py index 8c628b4..29a4ad6 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from pathlib import Path import pytest @@ -10,10 +11,19 @@ AudioRouting, DeviceController, DeviceProfile, + DeviceTargetId, FileOperationCapabilities, + GainAdjustment, + GainPoint, MeasurementBackendCapabilities, + MeasurementSubdivision, + MeasurementTarget, + NamingRules, + PatchAssignment, PatchFileHandler, SteeringOptions, + SubdivisionSelection, + TargetSelection, validate_snapshot_count, ) @@ -47,6 +57,73 @@ def test_helix_profile_defines_processor_boundaries() -> None: assert profile.format_patch_id(7) == "02C" +def test_helix_profile_name_rules_match_legacy_gui_helpers() -> None: + profile = get_device_profile("helix") + + assert profile.validate_preset_name("Clean + Lead") == "Clean + Lead" + assert profile.sanitize_preset_name("Bad*Name🙂") == "BadName" + with pytest.raises(ValueError, match="Invalid Helix name"): + profile.validate_preset_name("Bad*Name") + with pytest.raises(ValueError, match="exceeds 10 characters"): + profile.validate_subdivision_name("Very Long Snapshot") + + +def test_helix_setting_descriptors_match_current_defaults() -> None: + profile = get_device_profile("helix") + descriptors = {descriptor.name: descriptor for descriptor in profile.setting_descriptors()} + + assert set(descriptors) == { + "audio_device", + "sample_rate", + "input_mapping", + "output_mapping", + "blocksize", + "midi_output", + "midi_channel", + "preset_wait", + "snapshot_wait", + "measurement_wait", + } + assert descriptors["audio_device"].default == "Helix" + assert descriptors["audio_device"].config_path == ("devices", "helix", "audio", "device") + assert descriptors["audio_device"].cli_flags == ("--audio-device",) + assert descriptors["sample_rate"].default == 48000 + assert descriptors["sample_rate"].kind == "integer" + assert descriptors["input_mapping"].default == (1, 2) + assert descriptors["input_mapping"].kind == "channel_mapping" + assert descriptors["output_mapping"].default == (3, 4) + assert descriptors["blocksize"].default == 0 + assert descriptors["blocksize"].minimum == 0 + assert descriptors["midi_output"].default == "Helix" + assert descriptors["midi_output"].cli_flags == ("--steering-output", "--midi-output") + assert descriptors["midi_channel"].default == 0 + assert descriptors["midi_channel"].minimum == 0 + assert descriptors["midi_channel"].maximum == 15 + assert descriptors["preset_wait"].default == 0.5 + assert descriptors["snapshot_wait"].default == 0.2 + assert descriptors["measurement_wait"].default == 0.1 + + profile.validate_settings( + {name: descriptor.default for name, descriptor in descriptors.items()} + ) + + +def test_device_setting_validation_rejects_wrong_kind_and_range() -> None: + profile = get_device_profile("helix") + + with pytest.raises(ValueError, match="sample_rate must be an integer"): + profile.validate_settings({"sample_rate": "48000"}) + + with pytest.raises(ValueError, match="midi_channel must not exceed 15"): + profile.validate_settings({"midi_channel": 16}) + + with pytest.raises(ValueError, match="blocksize must be at least 0"): + profile.validate_settings({"blocksize": -1}) + + with pytest.raises(ValueError, match="input_mapping channels must be at least 1"): + profile.validate_settings({"input_mapping": (0, 2)}) + + def test_helix_profile_rejects_more_than_eight_snapshots() -> None: with pytest.raises(ValueError, match="must not exceed 8"): validate_snapshot_count(get_device_profile("helix"), 9) @@ -101,6 +178,88 @@ def automation_output_path(self, input_path: Path, postfix: str) -> Path: return input_path +class AssignmentBackedHandler(MinimalHandler): + def list_assignments(self, input_path: Path): + return [ + PatchAssignment( + 1, + "01A", + "Clean", + snapshot_names=("Rhythm", "Solo"), + original_filename="Clean.hlx", + ), + PatchAssignment(6, "02B", "Lead"), + ] + + def parse_patch_set(self, value: str) -> list[int]: + if value == "01A,02B": + return [1, 6] + return [int(value)] + + def diff_preset_ids(self, input_path: Path, previous_input_path: Path) -> list[int]: + return [6] + + def diff_snapshot_ids( + self, + input_path: Path, + previous_input_path: Path, + snapshot_count: int, + ) -> dict[int, tuple[int, ...]]: + return {1: (2,), 6: (1,)} + + +class StringTargetHandler(MinimalHandler): + def list_targets(self, input_path: Path) -> list[MeasurementTarget]: + return [ + MeasurementTarget( + id="scene:clean", + display_label="Clean Scene", + index=0, + name="Clean", + source_filename="bank.scene", + compat_numeric_id=None, + ) + ] + + def parse_target_set(self, value: str) -> list[DeviceTargetId]: + return [token.strip() for token in value.split(",") if token.strip()] + + +class MultiOutputHandler(MinimalHandler): + def __init__(self) -> None: + self.gain_adjustments: list[GainAdjustment] = [] + + def list_targets(self, input_path: Path) -> list[MeasurementTarget]: + return [ + MeasurementTarget( + id="preset:clean", + display_label="Clean", + index=0, + name="Clean", + subdivisions=( + MeasurementSubdivision( + id="snap:intro", + display_label="Intro", + index=0, + name="Intro", + gain_points=( + GainPoint("main", "Main Out", -3.0, -60.0, 12.0, "subdivision"), + GainPoint("aux", "Aux Out", -6.0, -60.0, 12.0, "subdivision"), + ), + ), + ), + ) + ] + + def apply_gain_adjustments( + self, + input_path: Path, + output_path: Path, + adjustments: list[GainAdjustment], + ) -> None: + self.gain_adjustments.extend(adjustments) + + class FileOperationsHandler(MinimalHandler): def __init__(self) -> None: self.join_calls = [] @@ -176,6 +335,26 @@ def measurement_backends(self) -> tuple[str, ...]: ).names() +class PermissiveNamingProfile(PluginProfile): + name = "permissive-names" + display_name = "Permissive Names" + + +class RestrictiveNamingProfile(PluginProfile): + name = "restrictive-names" + display_name = "Restrictive Names" + + def naming_rules(self) -> NamingRules: + return NamingRules( + preset_name_max_length=8, + snapshot_name_max_length=4, + allowed_name_pattern=r"^[A-Z0-9 ]*$", + replacement_character="-", + trim_whitespace=True, + forbidden_names=frozenset({"BYPASS"}), + ) + + class EntryPoint: def __init__(self, name: str, value) -> None: self.name = name @@ -196,15 +375,203 @@ def select(self, *, group: str): def test_default_device_capabilities_are_backward_compatible() -> None: profile = PluginProfile() handler = profile.create_patch_file_handler(Path(".")) + descriptors = {descriptor.name: descriptor for descriptor in profile.setting_descriptors()} assert profile.terminology().preset == "preset" assert profile.file_capabilities().reads_preset_files is False assert profile.measurement_backends() == ("hardware", "loopback", "simulated") assert profile.naming_rules().preset_name_max_length is None + assert descriptors["audio_device"].default is None + assert descriptors["sample_rate"].default == 48000 + assert descriptors["input_mapping"].default == (1, 2) + assert descriptors["output_mapping"].default == (1, 2) + assert descriptors["midi_output"].default is None + assert descriptors["midi_channel"].default == 0 + profile.validate_settings({}) + profile.validate_settings({"unknown_plugin_setting": object()}) assert handler.file_capabilities().reads_setlist_files is False assert handler.file_kind(Path("anything")) == "unknown" +def test_permissive_device_name_rules_allow_long_names() -> None: + profile = PermissiveNamingProfile() + name = "Wide preset name with * and emoji 🙂" + + assert profile.validate_preset_name(name) == name + assert profile.sanitize_subdivision_name(name) == name + + +def test_restrictive_device_name_rules_validate_and_sanitize() -> None: + profile = RestrictiveNamingProfile() + + assert profile.validate_preset_name(" LEAD 1 ") == "LEAD 1" + assert profile.sanitize_preset_name("lead*123456") == "-----123" + assert profile.sanitize_subdivision_name("A*B123") == "A-B1" + with pytest.raises(ValueError, match="Invalid Restrictive Names name"): + profile.validate_preset_name("lead") + with pytest.raises(ValueError, match="exceeds 8 characters"): + profile.validate_preset_name("TOO LONG NAME") + with pytest.raises(ValueError, match="Forbidden Restrictive Names name"): + profile.validate_preset_name("BYPASS") + + +def test_default_target_api_adapts_legacy_assignments() -> None: + handler = AssignmentBackedHandler() + + targets = handler.list_targets(Path("set.hls")) + + assert handler.parse_target_set("01A,02B") == [1, 6] + assert [target.id for target in targets] == [1, 6] + assert [target.display_label for target in targets] == ["01A", "02B"] + assert [target.compat_numeric_id for target in targets] == [1, 6] + assert targets[0].index == 0 + assert targets[0].source_filename == "Clean.hlx" + assert [ + (subdivision.id, subdivision.display_label, subdivision.index) + for subdivision in targets[0].subdivisions + ] == [ + (1, "Rhythm", 0), + (2, "Solo", 1), + ] + + +def test_generic_gain_points_can_be_listed_and_adjusted_for_multi_output_device() -> None: + handler = MultiOutputHandler() + + points = handler.list_gain_points(Path("bank.fake"), "preset:clean", "snap:intro") + adjustments = [ + GainAdjustment("preset:clean", "snap:intro", points[0].id, 1.5), + GainAdjustment("preset:clean", "snap:intro", points[1].id, -2.0), + ] + + handler.apply_gain_adjustments(Path("bank.fake"), Path("out.fake"), adjustments) + + assert [(point.id, point.current_db) for point in points] == [("main", -3.0), ("aux", -6.0)] + assert handler.gain_adjustments == adjustments + + +def test_default_gain_adjustment_api_reports_unsupported_for_read_only_devices() -> None: + handler = MinimalHandler() + + assert handler.list_gain_points(Path("readonly.fake")) == [] + with pytest.raises(NotImplementedError, match="Gain adjustments are not supported"): + handler.apply_gain_adjustments( + Path("readonly.fake"), + Path("out.fake"), + [GainAdjustment(1, 1, "main", 1.0)], + ) + + +def test_target_api_accepts_string_ids_without_numeric_compatibility() -> None: + handler = StringTargetHandler() + + targets = handler.list_targets(Path("bank.scene")) + + assert handler.parse_target_set("scene:clean, scene:lead") == ["scene:clean", "scene:lead"] + assert targets == [ + MeasurementTarget( + id="scene:clean", + display_label="Clean Scene", + index=0, + name="Clean", + source_filename="bank.scene", + compat_numeric_id=None, + ) + ] + + +def test_diff_target_api_defaults_adapt_legacy_numeric_diff() -> None: + handler = AssignmentBackedHandler() + + assert handler.diff_targets(Path("current.hls"), Path("previous.hls")) == [ + TargetSelection( + id=6, + display_label="02B", + index=1, + name="Lead", + compat_numeric_id=6, + ) + ] + assert handler.diff_subdivisions(Path("current.hls"), Path("previous.hls"), 4) == { + 1: ( + SubdivisionSelection( + target_id=1, + id=2, + display_label="Solo", + index=1, + name="Solo", + compat_numeric_id=2, + ), + ), + 6: ( + SubdivisionSelection( + target_id=6, + id=1, + display_label="1", + index=0, + compat_numeric_id=1, + ), + ), + } + + +def test_string_target_diff_api_can_report_changed_subdivisions() -> None: + class DiffStringTargetHandler(StringTargetHandler): + def list_targets(self, input_path: Path) -> list[MeasurementTarget]: + return [ + MeasurementTarget( + id="scene:clean", + display_label="Clean Scene", + index=0, + name="Clean", + subdivisions=( + MeasurementSubdivision("snapshot:rhythm", "Rhythm", 0, "Rhythm"), + MeasurementSubdivision("snapshot:solo", "Solo", 1, "Solo"), + ), + ) + ] + + def diff_targets( + self, + input_path: Path, + previous_input_path: Path, + ) -> list[TargetSelection]: + return [TargetSelection(id="scene:clean", display_label="Clean Scene", index=0)] + + def diff_subdivisions( + self, + input_path: Path, + previous_input_path: Path, + subdivision_count: int, + ) -> dict[DeviceTargetId, tuple[SubdivisionSelection, ...]]: + return { + "scene:clean": ( + SubdivisionSelection( + target_id="scene:clean", + id="snapshot:solo", + display_label="Solo", + index=1, + ), + ) + } + + handler = DiffStringTargetHandler() + + assert handler.diff_targets(Path("current.scene"), Path("previous.scene")) == [ + TargetSelection(id="scene:clean", display_label="Clean Scene", index=0) + ] + assert handler.diff_subdivisions(Path("current.scene"), Path("previous.scene"), 2) == { + "scene:clean": ( + SubdivisionSelection( + target_id="scene:clean", + id="snapshot:solo", + display_label="Solo", + index=1, + ), + ) + } + + def test_file_operations_join_validates_capabilities_and_delegates(tmp_path) -> None: handler = FileOperationsHandler() @@ -292,3 +659,25 @@ class DuplicateProfile(PluginProfile): ) assert registry.plugin_load_errors() == {"duplicate": "duplicate device profile name 'helix'"} + + +def test_example_device_plugin_imports_and_defines_entry_point_contract() -> None: + example_src = Path(__file__).resolve().parents[1] / "examples" / "device_plugin" / "src" + sys.path.insert(0, str(example_src)) + try: + from matchpatch_example_device import ExampleDeviceProfile + finally: + sys.path.remove(str(example_src)) + + profile = ExampleDeviceProfile() + handler = profile.create_patch_file_handler(Path(".")) + + assert profile.name == "example-device" + assert profile.display_name == "Example Device" + assert profile.file_capabilities().reads_setlist_files + assert handler.file_kind(Path("demo.examplebank")) == "setlist" + assert handler.file_types()[0].name_filter() == "Example Device Banks (*.examplebank)" + assert handler.parse_patch_set("1, 2") == [1, 2] + assert handler.automation_output_path(Path("demo.examplebank"), "_measurement") == Path( + "demo_measurement.examplebank" + ) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index d2ed856..d8305fd 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -67,6 +67,11 @@ def test_effective_config_from_request_is_json_ready(tmp_path: Path) -> None: interval_seconds=0.25, minimum_valid_lufs=-90.0, ), + device_settings={ + "audio_device": "ASIO Helix", + "sample_rate": 48000, + "midi_output": "Helix MIDI", + }, ) config = EffectiveConfig.from_request(request) @@ -78,6 +83,8 @@ def test_effective_config_from_request_is_json_ready(tmp_path: Path) -> None: assert payload["policy"]["solo_regex"] == "solo" assert payload["policy"]["ignore_preset_regex"] == "empty" assert payload["analysis_options"]["window_seconds"] == 2.5 + assert payload["device_settings"]["audio_device"] == "ASIO Helix" + assert payload["device_settings"]["steering_output"] == "Helix MIDI" assert payload["snapshot_plan"] == [{"patch": "01A", "snapshots": [1, 3]}] json.dumps(payload) @@ -252,6 +259,7 @@ def test_write_diagnostic_bundle_appends_zip_and_writes_stable_entries(tmp_path: } ] assert effective_config["input_path"] == str(tmp_path / "input.hls") + assert effective_config["device_settings"] == {} assert progress_events == [{"kind": "phase", "message": "Measuring"}] assert retained_csv["row_count"] == 1 assert gui_log == "[12:00] INFO Started\n" diff --git a/tests/test_gui.py b/tests/test_gui.py index 7b706b5..2a84585 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -538,6 +538,11 @@ def test_main_window_lists_device_without_settings_panel(monkeypatch, app) -> No ), ) monkeypatch.setattr(main_window, "list_device_profiles", lambda: [helix, fake]) + monkeypatch.setattr( + main_window, + "get_device_profile", + lambda name: fake if name == "fake" else helix, + ) monkeypatch.setattr( "matchpatch.normalize.get_device_profile", lambda name: fake if name == "fake" else helix, diff --git a/tests/test_gui_device_panel_plugins.py b/tests/test_gui_device_panel_plugins.py new file mode 100644 index 0000000..7950454 --- /dev/null +++ b/tests/test_gui_device_panel_plugins.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6") + +from PySide6.QtCore import QCoreApplication, QSettings +from PySide6.QtWidgets import QApplication, QLabel, QWidget + +from matchpatch.gui import device_panel_registry, device_panels, main_window +from matchpatch.gui.main_window import MainWindow + + +class _GuiEntryPoint: + def __init__(self, name: str, loaded: object) -> None: + self.name = name + self._loaded = loaded + + def load(self) -> object: + if isinstance(self._loaded, BaseException): + raise self._loaded + return self._loaded + + +class _GuiEntryPoints(list[_GuiEntryPoint]): + def select(self, *, group: str) -> list[_GuiEntryPoint]: + if group == device_panel_registry.ENTRY_POINT_GROUP: + return list(self) + return [] + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + QCoreApplication.setOrganizationName("MatchPatchTests") + QCoreApplication.setApplicationName("MatchPatchTests") + yield instance + + +@pytest.fixture(autouse=True) +def isolated_qsettings(tmp_path): + QSettings.setPath(QSettings.Format.NativeFormat, QSettings.Scope.UserScope, str(tmp_path)) + QSettings.setPath(QSettings.Format.IniFormat, QSettings.Scope.UserScope, str(tmp_path)) + QSettings().clear() + yield + QSettings().clear() + + +def test_plugin_device_settings_panel_is_selected_before_builtin_mapping( + monkeypatch, + app, +) -> None: + class PluginPanelFactory: + device_name = "helix" + + def create_panel(self, profile, backend_selector): + panel = QLabel(f"{profile.name}:{backend_selector.objectName()}") + panel.setObjectName("plugin-helix-panel") + return panel + + monkeypatch.setattr( + device_panel_registry.metadata, + "entry_points", + lambda: _GuiEntryPoints([_GuiEntryPoint("helix-gui", PluginPanelFactory())]), + ) + backend = QWidget() + backend.setObjectName("backend-selector") + + panel = device_panels.create_settings_panel(main_window.get_device_profile("helix"), backend) + + assert isinstance(panel, QLabel) + assert panel.objectName() == "plugin-helix-panel" + assert panel.text() == "helix:backend-selector" + assert device_panel_registry.plugin_load_errors() == {} + + +def test_broken_plugin_device_settings_panel_does_not_break_main_window_startup( + monkeypatch, + app, +) -> None: + monkeypatch.setattr( + device_panel_registry.metadata, + "entry_points", + lambda: _GuiEntryPoints([_GuiEntryPoint("broken-gui", RuntimeError("boom"))]), + ) + + window = MainWindow() + + assert window.device.findData("helix") >= 0 + assert "helix" in window.device_panels + assert device_panel_registry.plugin_load_errors() == {"broken-gui": "boom"} + + window.close() diff --git a/tests/test_gui_preset_table.py b/tests/test_gui_preset_table.py index 57413ee..4947dcc 100644 --- a/tests/test_gui_preset_table.py +++ b/tests/test_gui_preset_table.py @@ -189,6 +189,22 @@ def validate_helix_name(self, name: str, max_length: int | None = None) -> str: return name[:max_length] return name + def validate_preset_name(self, name: str) -> str: + return self.validate_helix_name(name, self.preset_name_max_length()) + + def validate_subdivision_name(self, name: str) -> str: + return self.validate_helix_name(name, self.snapshot_name_max_length()) + + def sanitize_preset_name(self, name: str) -> str: + max_length = self.preset_name_max_length() + sanitized = name.replace("%", "") + return sanitized[:max_length] if max_length is not None else sanitized + + def sanitize_subdivision_name(self, name: str) -> str: + max_length = self.snapshot_name_max_length() + sanitized = name.replace("%", "") + return sanitized[:max_length] if max_length is not None else sanitized + def preset_name_max_length(self) -> int | None: return None @@ -441,6 +457,28 @@ def test_preset_table_controller_owns_manual_edit_rules(app) -> None: table.close() +def test_preset_table_controller_uses_callback_name_sanitizers(app) -> None: + table, callbacks = _controller_table() + controller = PresetTableController(table, callbacks, set()) + callbacks.manual_checked = True + callbacks.snapshot_count_value = 1 + callbacks.sanitize_preset_name = lambda name: name.replace("*", "-") + callbacks.sanitize_subdivision_name = lambda name: name.replace("*", "") + + assert controller.finish_manual_cell_edit(0, 2, "Lead*Wide", commit=True) + assert table.item(0, 2).text() == "Lead-Wide" + + assert controller.finish_manual_cell_edit( + 0, + snapshot_name_column(0), + "Solo*Boost", + commit=True, + ) + assert table.item(0, snapshot_name_column(0)).text() == "SoloBoost" + + table.close() + + def test_preset_table_controller_owns_adjustment_and_ignore_state(app) -> None: table, callbacks = _controller_table() controller = PresetTableController(table, callbacks, set()) diff --git a/tests/test_gui_save_workflow.py b/tests/test_gui_save_workflow.py index ebcb317..d6e4fd3 100644 --- a/tests/test_gui_save_workflow.py +++ b/tests/test_gui_save_workflow.py @@ -54,6 +54,7 @@ from shiboken6 import isValid from matchpatch.devices.base import ( + DeviceFileType, FileOperationCapabilities, NormalizationPolicy, PatchFileAdjustments, @@ -442,10 +443,14 @@ def test_join_preset_files_action_calls_workflow_and_opens_output( opened_paths: list[str] = [] calls = [] monkeypatch.setattr( - file_operations_workflow, "choose_join_preset_paths", lambda parent: preset_paths + file_operations_workflow, + "choose_join_preset_paths", + lambda parent, file_types=(): preset_paths, ) monkeypatch.setattr( - file_operations_workflow, "choose_join_output_path", lambda parent: output_path + file_operations_workflow, + "choose_join_output_path", + lambda parent, file_types=(): output_path, ) monkeypatch.setattr(window, "_open_input_path", opened_paths.append) @@ -468,6 +473,88 @@ def join_preset_files(device, selected_preset_paths, selected_output_path): window.close() +def test_input_browse_uses_device_file_type_filter(monkeypatch, app) -> None: + window = MainWindow() + filters = [] + + class Handler: + @staticmethod + def file_types(): + return ( + DeviceFileType("setlist", (".setlist",), "Fake setlist"), + DeviceFileType("preset", (".preset",), "Fake preset"), + ) + + class Profile: + @staticmethod + def create_patch_file_handler(project_dir): + return Handler() + + monkeypatch.setattr( + main_window.file_type_filters, + "get_device_profile", + lambda device: Profile(), + ) + + def get_open_file_name(*args, **kwargs): + filters.append(kwargs["filter"]) + return "", "" + + monkeypatch.setattr(QFileDialog, "getOpenFileName", get_open_file_name) + + window.browse_input() + + assert filters == ["Patches (*.setlist *.preset)"] + window.close() + + +def test_join_dialogs_use_device_file_type_filters(monkeypatch, app, tmp_path) -> None: + window = MainWindow() + preset_paths = [tmp_path / "lead.preset"] + output_path = tmp_path / "joined" + filters = [] + + class Handler: + @staticmethod + def file_types(): + return ( + DeviceFileType("setlist", (".setlist",), "Fake setlist"), + DeviceFileType("preset", (".preset",), "Fake preset"), + ) + + class Profile: + @staticmethod + def create_patch_file_handler(project_dir): + return Handler() + + monkeypatch.setattr(file_operations_workflow, "get_device_profile", lambda device: Profile()) + monkeypatch.setattr( + QFileDialog, + "getOpenFileNames", + lambda *args, **kwargs: filters.append(kwargs["filter"]) or ([str(preset_paths[0])], ""), + ) + monkeypatch.setattr( + QFileDialog, + "getSaveFileName", + lambda *args, **kwargs: filters.append(kwargs["filter"]) or (str(output_path), ""), + ) + monkeypatch.setattr(window, "_open_input_path", lambda path: None) + monkeypatch.setattr( + file_operations_workflow.file_operations, + "join_preset_files", + lambda device, selected_preset_paths, selected_output_path: ( + file_operations_workflow.file_operations.JoinPresetFilesResult( + output_path=selected_output_path + ) + ), + ) + + assert file_operations_workflow.join_preset_files(window) + + assert filters == ["Preset files (*.preset)", "Setlist files (*.setlist)"] + window.close() + + def test_split_setlist_action_passes_selected_ids_and_original_filename_map( monkeypatch, app, diff --git a/tests/test_gui_settings_renderer.py b/tests/test_gui_settings_renderer.py new file mode 100644 index 0000000..5a5f9ed --- /dev/null +++ b/tests/test_gui_settings_renderer.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import os +from types import SimpleNamespace + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6") + +from PySide6.QtCore import QCoreApplication, QSettings +from PySide6.QtWidgets import QApplication + +from matchpatch.devices.base import DeviceSettingDescriptor +from matchpatch.gui import main_window +from matchpatch.gui.main_window import MainWindow +from matchpatch.gui.settings_renderer import DescriptorSettingsPanel + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + QCoreApplication.setOrganizationName("MatchPatchTests") + QCoreApplication.setApplicationName("MatchPatchTests") + yield instance + + +@pytest.fixture(autouse=True) +def isolated_qsettings(tmp_path): + QSettings.setPath(QSettings.Format.NativeFormat, QSettings.Scope.UserScope, str(tmp_path)) + QSettings.setPath(QSettings.Format.IniFormat, QSettings.Scope.UserScope, str(tmp_path)) + QSettings().clear() + yield + QSettings().clear() + + +def test_main_window_lists_descriptor_only_device_with_rendered_panel( + monkeypatch, + app, + tmp_path, +) -> None: + helix = main_window.get_device_profile("helix") + descriptors = _standard_fake_descriptors() + fake = SimpleNamespace( + name="fake", + display_name="Fake Device", + measurement_backends=lambda: ("offline",), + setting_descriptors=lambda: descriptors, + default_audio_routing=lambda: SimpleNamespace( + device="Fake Audio", + sample_rate=48000, + input_mapping=(1, 2), + output_mapping=(3, 4), + ), + default_steering_options=lambda: SimpleNamespace( + output="Fake MIDI", + channel=1, + preset_wait_seconds=0.2, + snapshot_wait_seconds=0.3, + measurement_wait_seconds=0.4, + ), + validate_settings=lambda settings: None, + ) + config_path = tmp_path / "config.toml" + config_path.write_text( + """ +[devices.fake.audio] +device = "Configured Fake" +sample_rate = 96000 +input_mapping = [5, 6] +output_mapping = [7, 8] + +[devices.fake.steering] +output = "Configured MIDI" +channel = 3 +preset_wait_seconds = 0.7 +snapshot_wait_seconds = 0.8 +measurement_wait_seconds = 0.9 +""", + encoding="utf-8", + ) + monkeypatch.setattr(main_window, "list_device_profiles", lambda: [helix, fake]) + monkeypatch.setattr( + main_window, + "get_device_profile", + lambda name: fake if name == "fake" else helix, + ) + monkeypatch.setattr( + "matchpatch.normalize.get_device_profile", + lambda name: fake if name == "fake" else helix, + ) + + window = MainWindow() + window.loading_controller.get_profile = lambda name: fake if name == "fake" else helix + window.config_path.setText(str(config_path)) + errors: list[str] = [] + window.show_error = errors.append + fake_index = window.device.findData("fake") + window.device.setCurrentIndex(fake_index) + app.processEvents() + + assert errors == [] + panel = window.device_panels["fake"] + assert isinstance(panel, DescriptorSettingsPanel) + assert window.device_stack.currentWidget() is panel + assert panel.controls["audio_device"].text() == "Configured Fake" + assert panel.controls["sample_rate"].value() == 96000 + assert panel.controls["input_mapping"].text() == "5,6" + assert panel.controls["midi_output"].text() == "Configured MIDI" + assert panel.controls["midi_channel"].value() == 3 + + argv: list[str] = [] + panel.append_arguments(argv) + + assert ["--audio-device", "Configured Fake"] == argv[ + argv.index("--audio-device") : argv.index("--audio-device") + 2 + ] + assert ["--input-mapping", "5,6"] == argv[ + argv.index("--input-mapping") : argv.index("--input-mapping") + 2 + ] + assert ["--steering-output", "Configured MIDI"] == argv[ + argv.index("--steering-output") : argv.index("--steering-output") + 2 + ] + + window.close() + + +def test_descriptor_settings_panel_renders_all_descriptor_kinds(app) -> None: + panel = DescriptorSettingsPanel( + ( + DeviceSettingDescriptor( + name="name", + scope="device", + kind="string", + default="Initial", + cli_flags=("--name",), + ), + DeviceSettingDescriptor( + name="count", + scope="device", + kind="integer", + default=2, + cli_flags=("--count",), + minimum=0, + maximum=10, + ), + DeviceSettingDescriptor( + name="gain", + scope="processing", + kind="float", + default=1.5, + cli_flags=("--gain",), + minimum=0.0, + ), + DeviceSettingDescriptor( + name="enabled", + scope="processing", + kind="boolean", + default=False, + cli_flags=("--enabled",), + ), + DeviceSettingDescriptor( + name="mode", + scope="processing", + kind="choice", + default="fast", + choices=("fast", "careful"), + cli_flags=("--mode",), + ), + DeviceSettingDescriptor( + name="cache_path", + scope="diagnostics", + kind="path", + default="/tmp/cache", + cli_flags=("--cache-path",), + ), + DeviceSettingDescriptor( + name="channels", + scope="audio", + kind="channel_mapping", + default=(1, 2), + cli_flags=("--channels",), + ), + ) + ) + + panel.populate( + SimpleNamespace( + name="Configured", + count=4, + gain=2.25, + enabled=True, + mode="careful", + cache_path="/tmp/configured", + channels="3,4", + ) + ) + + assert panel.collect_settings() == { + "name": "Configured", + "count": 4, + "gain": 2.25, + "enabled": True, + "mode": "careful", + "cache_path": "/tmp/configured", + "channels": (3, 4), + } + argv: list[str] = [] + panel.append_arguments(argv) + assert argv == [ + "--name", + "Configured", + "--count", + "4", + "--gain", + "2.25", + "--enabled", + "--mode", + "careful", + "--cache-path", + "/tmp/configured", + "--channels", + "3,4", + ] + + +def _standard_fake_descriptors() -> tuple[DeviceSettingDescriptor, ...]: + return ( + DeviceSettingDescriptor( + name="audio_device", + scope="audio", + kind="string", + default="Fake Audio", + config_path=("devices", "fake", "audio", "device"), + cli_flags=("--audio-device",), + label="Audio device", + ), + DeviceSettingDescriptor( + name="sample_rate", + scope="audio", + kind="integer", + default=48000, + config_path=("devices", "fake", "audio", "sample_rate"), + cli_flags=("--sample-rate",), + minimum=1, + ), + DeviceSettingDescriptor( + name="input_mapping", + scope="audio", + kind="channel_mapping", + default=(1, 2), + config_path=("devices", "fake", "audio", "input_mapping"), + cli_flags=("--input-mapping",), + ), + DeviceSettingDescriptor( + name="output_mapping", + scope="audio", + kind="channel_mapping", + default=(3, 4), + config_path=("devices", "fake", "audio", "output_mapping"), + cli_flags=("--output-mapping",), + ), + DeviceSettingDescriptor( + name="blocksize", + scope="audio", + kind="integer", + default=0, + config_path=("devices", "fake", "audio", "blocksize"), + cli_flags=("--blocksize",), + minimum=0, + ), + DeviceSettingDescriptor( + name="midi_output", + scope="steering", + kind="string", + default="Fake MIDI", + config_path=("devices", "fake", "steering", "output"), + cli_flags=("--steering-output", "--midi-output"), + ), + DeviceSettingDescriptor( + name="midi_channel", + scope="steering", + kind="integer", + default=1, + config_path=("devices", "fake", "steering", "channel"), + cli_flags=("--steering-channel", "--midi-channel"), + minimum=0, + maximum=15, + ), + DeviceSettingDescriptor( + name="preset_wait", + scope="steering", + kind="float", + default=0.2, + config_path=("devices", "fake", "steering", "preset_wait_seconds"), + cli_flags=("--preset-wait",), + minimum=0.0, + ), + DeviceSettingDescriptor( + name="snapshot_wait", + scope="steering", + kind="float", + default=0.3, + config_path=("devices", "fake", "steering", "snapshot_wait_seconds"), + cli_flags=("--snapshot-wait",), + minimum=0.0, + ), + DeviceSettingDescriptor( + name="measurement_wait", + scope="steering", + kind="float", + default=0.4, + config_path=("devices", "fake", "steering", "measurement_wait_seconds"), + cli_flags=("--measurement-wait",), + minimum=0.0, + ), + ) diff --git a/tests/test_helix.py b/tests/test_helix.py index aede701..e8296a9 100644 --- a/tests/test_helix.py +++ b/tests/test_helix.py @@ -120,8 +120,54 @@ def fake_run(*args, capture=False, log_output=True): assert handler.list_assignments(Path("set.hls")) == [ PatchAssignment(1, "01A", "Clean", ("Rhythm", "Solo"), ((0.0,), (-3.5, -4.0))) ] + targets = handler.list_targets(Path("set.hls")) + assert [ + (target.id, target.display_label, target.name, target.compat_numeric_id) + for target in targets + ] == [(1, "01A", "Clean", 1)] + assert [ + (subdivision.id, subdivision.display_label, subdivision.name) + for subdivision in targets[0].subdivisions + ] == [ + (1, "Rhythm", "Rhythm"), + (2, "Solo", "Solo"), + ] handler.create_measurement_file(Path("set.hls"), Path("measurement.hls")) - assert calls[1][0] == ("-i", Path("set.hls"), "-o", Path("measurement.hls"), "--measurement") + assert calls[2][0] == ("-i", Path("set.hls"), "-o", Path("measurement.hls"), "--measurement") + + +def test_helix_snapshot_output_paths_are_exposed_as_gain_points(tmp_path, monkeypatch) -> None: + handler = make_handler(tmp_path) + payload = [ + { + "id": 1, + "helix_preset": "01A", + "name": "Clean", + "snapshot_names": ["Rhythm", "Solo"], + "snapshot_output_paths": ["dsp0.outputA", "dsp0.outputB"], + "snapshot_output_levels": [[0.0, -3.0], [1.5, -1.5]], + } + ] + + def fake_run(*args, capture=False, log_output=True): + return subprocess.CompletedProcess([], 0, stdout=json.dumps(payload)) + + monkeypatch.setattr(handler, "_run", fake_run) + + targets = handler.list_targets(Path("set.hls")) + solo_points = handler.list_gain_points(Path("set.hls"), 1, 2) + + assert [ + (point.id, point.current_db, point.minimum_db, point.maximum_db, point.scope, point.path) + for point in targets[0].subdivisions[0].gain_points + ] == [ + ("dsp0.outputA", 0.0, -120.0, 20.0, "subdivision", "dsp0.outputA"), + ("dsp0.outputB", -3.0, -120.0, 20.0, "subdivision", "dsp0.outputB"), + ] + assert [(point.id, point.current_db) for point in solo_points] == [ + ("dsp0.outputA", 1.5), + ("dsp0.outputB", -1.5), + ] def test_single_preset_assignment_includes_original_filename(tmp_path, monkeypatch) -> None: diff --git a/tests/test_measure.py b/tests/test_measure.py index 54cdaae..4d815ca 100644 --- a/tests/test_measure.py +++ b/tests/test_measure.py @@ -15,8 +15,13 @@ from matchpatch.devices import get_device_profile from matchpatch.devices.base import ( + AudioProcessingMode, AudioRouting, + AudioTransportCapabilities, + AudioTransportContext, DeviceProfile, + MeasurementBackendCapabilities, + OfflineAudioProcessingRequest, PatchFileHandler, SteeringOptions, ) @@ -194,6 +199,128 @@ def create_controller(self, options: SteeringOptions): raise AssertionError("Loopback must not create a controller") +class RecordingTransport: + def __init__(self) -> None: + self.calls: list[tuple[str, int] | tuple[str, tuple[int, ...]] | tuple[str]] = [] + + def __enter__(self): + self.calls.append(("enter",)) + return self + + def __exit__(self, *args) -> None: + self.calls.append(("exit",)) + + def activate_target(self, target: int) -> None: + self.calls.append(("target", target)) + + def activate_subdivision(self, subdivision: int) -> None: + self.calls.append(("subdivision", subdivision)) + + def process(self, reference_audio: np.ndarray) -> np.ndarray: + self.calls.append(("process", reference_audio.shape)) + return reference_audio.copy() + + +class RecordingTransportFactory: + capabilities = AudioTransportCapabilities(mode="loopback") + + def __init__(self) -> None: + self.transport = RecordingTransport() + self.contexts: list[AudioTransportContext] = [] + + def supports(self, mode: AudioProcessingMode, settings) -> bool: + return mode == "loopback" and settings["sample_rate"] == 48000 + + def create(self, context: AudioTransportContext) -> RecordingTransport: + self.contexts.append(context) + return self.transport + + +class OfflineRecordingTransport: + def __init__(self) -> None: + self.calls: list[tuple[str, int] | tuple[str, tuple[int, ...]] | tuple[str]] = [] + self.requests: list[OfflineAudioProcessingRequest] = [] + + def __enter__(self): + self.calls.append(("enter",)) + return self + + def __exit__(self, *args) -> None: + self.calls.append(("exit",)) + + def activate_target(self, target: int) -> None: + self.calls.append(("target", target)) + + def activate_subdivision(self, subdivision: int) -> None: + self.calls.append(("subdivision", subdivision)) + + def process_offline(self, request: OfflineAudioProcessingRequest) -> np.ndarray: + self.calls.append(("offline", request.reference_audio.shape)) + self.requests.append(request) + return request.reference_audio * 0.5 + + +class OfflineRecordingTransportFactory: + capabilities = AudioTransportCapabilities(mode="offline", real_time=False, offline=True) + + def __init__(self) -> None: + self.transport = OfflineRecordingTransport() + self.contexts: list[AudioTransportContext] = [] + + def supports(self, mode: AudioProcessingMode, settings) -> bool: + return mode == "offline" and settings["sample_rate"] == 48000 + + def create(self, context: AudioTransportContext) -> OfflineRecordingTransport: + self.contexts.append(context) + return self.transport + + +class TransportDeviceProfile(DeviceProfile): + name = "transport" + display_name = "Transport Processor" + snapshot_count = 1 + + def __init__(self, factory: RecordingTransportFactory) -> None: + self.factory = factory + + def create_patch_file_handler(self, project_dir: Path) -> PatchFileHandler: + return FakePatchFileHandler() + + def measurement_backends(self) -> tuple[str, ...]: + return MeasurementBackendCapabilities(hardware=False, simulated=False).names() + + def audio_transport_factories(self): + return (self.factory,) + + def default_audio_routing(self) -> AudioRouting: + return AudioRouting(None, 48000, (1, 2), (1, 2)) + + def default_steering_options(self) -> SteeringOptions: + return SteeringOptions(None, 0, 0.0, 0.0, 0.0) + + def create_controller(self, options: SteeringOptions): + raise AssertionError("Custom loopback transport must not create a controller") + + +class OfflineTransportDeviceProfile(TransportDeviceProfile): + name = "offline-transport" + display_name = "Offline Transport Processor" + + def __init__(self, factory: OfflineRecordingTransportFactory) -> None: + self.factory = factory + + def measurement_backends(self) -> tuple[str, ...]: + return MeasurementBackendCapabilities( + hardware=False, + loopback=False, + simulated=False, + offline=True, + ).names() + + def audio_transport_factories(self): + return (self.factory,) + + def test_loopback_is_device_independent(tmp_path) -> None: sample_rate = 48000 times = np.arange(sample_rate * 4) / sample_rate @@ -557,7 +684,7 @@ def test_measure_dispatches_loopback_without_audio_module(monkeypatch) -> None: measure(worker_args()) - assert isinstance(calls[0][-1], LoopbackBackend) + assert isinstance(calls[0][-1].transport.backend, LoopbackBackend) assert calls[0][4] == 48000 @@ -578,8 +705,8 @@ def test_measure_dispatches_stateful_simulator_without_audio_module(monkeypatch) ) backend = calls[0][-1] - assert isinstance(backend, SimulatedHardwareBackend) - assert backend.failing_preset_ids == frozenset({6}) + assert isinstance(backend.transport.backend, SimulatedHardwareBackend) + assert backend.transport.backend.failing_preset_ids == frozenset({6}) def test_measure_configures_hardware_backend(monkeypatch) -> None: @@ -620,7 +747,7 @@ def __exit__(self, *args): ) assert calls[0] == ("prepared", "processor") - assert isinstance(calls[1][-1], HardwareBackend) + assert isinstance(calls[1][-1].transport.backend, HardwareBackend) assert [event.message for event in events] == [ "Loading reference DI audio...", "Resolving and validating audio device...", @@ -628,6 +755,80 @@ def __exit__(self, *args): ] +def test_measure_uses_profile_audio_transport_factory(monkeypatch, tmp_path) -> None: + sample_rate = 48000 + times = np.arange(sample_rate * 4) / sample_rate + reference = np.sin(2 * np.pi * 1000 * times)[:, np.newaxis] + factory = RecordingTransportFactory() + profile = TransportDeviceProfile(factory) + csv_path = tmp_path / "transport.csv" + + monkeypatch.setattr("matchpatch.measure.get_device_profile", lambda device: profile) + monkeypatch.setattr("matchpatch.measure.load_reference_audio", lambda path, rate: reference) + + measure( + worker_args( + device="transport", + backend="loopback", + csv=str(csv_path), + reference_di="reference.wav", + ) + ) + + with csv_path.open(newline="", encoding="utf-8") as csv_file: + row = next(csv.DictReader(csv_file)) + + assert row["DevicePatch"] == "patch-1" + assert factory.contexts[0].mode == "loopback" + assert factory.transport.calls == [ + ("enter",), + ("target", 1), + ("subdivision", 1), + ("process", reference.shape), + ("exit",), + ] + + +def test_measure_uses_profile_offline_audio_transport_factory(monkeypatch, tmp_path) -> None: + sample_rate = 48000 + times = np.arange(sample_rate * 4) / sample_rate + reference = np.sin(2 * np.pi * 1000 * times)[:, np.newaxis] + factory = OfflineRecordingTransportFactory() + profile = OfflineTransportDeviceProfile(factory) + csv_path = tmp_path / "offline.csv" + + monkeypatch.setattr("matchpatch.measure.get_device_profile", lambda device: profile) + monkeypatch.setattr("matchpatch.measure.load_reference_audio", lambda path, rate: reference) + + measure( + worker_args( + device="offline-transport", + backend="offline", + csv=str(csv_path), + reference_di="reference.wav", + ) + ) + + with csv_path.open(newline="", encoding="utf-8") as csv_file: + row = next(csv.DictReader(csv_file)) + + request = factory.transport.requests[0] + assert row["DevicePatch"] == "patch-1" + assert factory.contexts[0].mode == "offline" + assert request.sample_rate == sample_rate + assert request.target_id == 1 + assert request.subdivision_id == 1 + assert request.target_metadata == {"preset_id": 1} + assert request.subdivision_metadata == {"snapshot": 1} + assert factory.transport.calls == [ + ("enter",), + ("target", 1), + ("subdivision", 1), + ("offline", reference.shape), + ("exit",), + ] + + def test_check_hardware_validates_audio_and_midi_presence(monkeypatch) -> None: calls = [] @@ -869,6 +1070,8 @@ def test_worker_parse_args_reads_toml_defaults(tmp_path, monkeypatch) -> None: assert args.preset_wait == 0.5 assert args.snapshot_wait == 0.2 assert args.measurement_wait == 0.1 + assert args.device_settings["input_mapping"] == (3, 4) + assert args.device_settings["midi_output"] == "Helix" assert args.snapshot_count == 2 assert args.analysis_options.window_seconds == 1.5 assert args.pre_roll == 1.5 diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 16f3038..ef4b324 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -11,7 +11,13 @@ import pytest from matchpatch import normalize, workflow -from matchpatch.devices.base import PatchAssignment +from matchpatch.devices.base import ( + DeviceTargetId, + MeasurementTarget, + PatchAssignment, + SubdivisionSelection, + TargetSelection, +) from matchpatch.workflow import NormalizationRequest, export_adjusted_file, normalize_presets @@ -152,6 +158,104 @@ def fake_analysis(request, preset_ids, csv_path, callback): assert requests[0][0].snapshot_plan == (("patch-1", (2,)), ("patch-2", (3,))) +def test_normalize_presets_diff_filter_uses_target_api_with_string_ids(tmp_path) -> None: + class StringTargetDiffHandler(FakeHandler): + def parse_target_set(self, value: str) -> list[DeviceTargetId]: + return [token.strip() for token in value.split(",") if token.strip()] + + def list_targets(self, input_path: Path) -> list[MeasurementTarget]: + return [ + MeasurementTarget( + id="scene:clean", + display_label="Clean Scene", + index=0, + name="Clean", + compat_numeric_id=1, + ), + MeasurementTarget( + id="scene:lead", + display_label="Lead Scene", + index=1, + name="Lead", + compat_numeric_id=2, + ), + ] + + def select_targets( + self, + input_path: Path, + targets: list[MeasurementTarget], + requested_ids: list[DeviceTargetId] | None, + ) -> list[TargetSelection]: + requested = set(requested_ids or [target.id for target in targets]) + return [ + TargetSelection( + id=target.id, + display_label=target.display_label, + index=target.index, + name=target.name, + compat_numeric_id=target.compat_numeric_id, + ) + for target in targets + if target.id in requested + ] + + def diff_subdivisions( + self, + input_path: Path, + previous_input_path: Path, + subdivision_count: int, + ) -> dict[DeviceTargetId, tuple[SubdivisionSelection, ...]]: + return { + "scene:lead": ( + SubdivisionSelection( + target_id="scene:lead", + id="snapshot:solo", + display_label="Solo", + index=1, + ), + ) + } + + handler = StringTargetDiffHandler() + input_path = tmp_path / "input.scene" + previous_path = tmp_path / "previous.scene" + output_path = tmp_path / "output.scene" + reference = tmp_path / "reference.wav" + work_dir = tmp_path / "work" + input_path.touch() + previous_path.touch() + reference.touch() + work_dir.mkdir() + requests = [] + + def fake_analysis(request, preset_ids, csv_path, callback): + requests.append((request, preset_ids)) + write_analysis_csv(request, preset_ids, csv_path) + + normalize_presets( + NormalizationRequest( + device="fake", + input_path=input_path, + output_path=output_path, + diff_input_path=previous_path, + backend="loopback", + windows_python="python.exe", + reference_di=reference, + automation=False, + preset_set="scene:clean,scene:lead", + snapshot_plan=(("Lead Scene", (1, 2)),), + policy=workflow.NormalizationPolicy(snapshot_count=2), + ), + run_analysis=fake_analysis, + get_profile=lambda device: FakeProfile(handler), + make_temp_dir=lambda: work_dir, + ) + + assert requests[0][1] == [2] + assert requests[0][0].snapshot_plan == (("Lead Scene", (2,)),) + + def test_normalize_presets_default_temp_dir_uses_normalization_prefix( tmp_path, monkeypatch ) -> None: @@ -313,6 +417,9 @@ def test_apply_config_layers_cli_environment_and_toml(tmp_path, monkeypatch) -> assert args.output_mapping == "5,6" assert args.blocksize == 128 assert args.steering_output == "Configured MIDI" + assert args.device_settings["audio_device"] == "CLI Audio" + assert args.device_settings["input_mapping"] == (3, 4) + assert args.device_settings["midi_output"] == "Configured MIDI" assert args.policy.snapshot_count == 3 assert args.policy.solo_regex == "lead" assert args.policy.ignore_snapshot_regex == "^Init$" @@ -367,6 +474,8 @@ def test_apply_config_uses_device_timing_defaults_when_config_is_silent() -> Non assert args.preset_wait == 0.5 assert args.snapshot_wait == 0.2 assert args.measurement_wait == 0.1 + assert args.device_settings["sample_rate"] == 48000 + assert args.device_settings["midi_channel"] == 0 def test_apply_config_validates_backend_against_selected_profile(monkeypatch) -> None: diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 1734e3a..facb14d 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -3,6 +3,7 @@ from pathlib import Path from types import SimpleNamespace +from matchpatch.devices.base import DiagnosticsContext from matchpatch.diagnostics import DiagnosticCheck from matchpatch.preflight import run_preflight_checks from matchpatch.workflow import NormalizationRequest @@ -31,12 +32,14 @@ def _profile( handler: FakeHandler | None = None, *, backends: tuple[str, ...] = ("hardware", "loopback", "simulated"), + diagnostics_provider: object | None = None, ) -> SimpleNamespace: handler = handler or FakeHandler() return SimpleNamespace( display_name="Fake Device", create_patch_file_handler=lambda project_dir: handler, measurement_backends=lambda: backends, + diagnostics_provider=lambda: diagnostics_provider, ) @@ -61,6 +64,24 @@ def _statuses(checks: list[DiagnosticCheck]) -> dict[str, str]: return {check.name: check.status for check in checks} +class RecordingDiagnosticsProvider: + def __init__(self) -> None: + self.context: DiagnosticsContext | None = None + + def run_checks(self, context: DiagnosticsContext) -> list[DiagnosticCheck]: + self.context = context + return [ + DiagnosticCheck("device_storage", "pass", "Storage is writable"), + DiagnosticCheck("device_firmware", "warning", "Firmware is unverified"), + DiagnosticCheck("device_license", "fail", "License is missing"), + ] + + +class FailingDiagnosticsProvider: + def run_checks(self, context: DiagnosticsContext) -> list[DiagnosticCheck]: + raise RuntimeError("diagnostic transport unavailable") + + def test_loopback_preflight_skips_hardware(tmp_path: Path) -> None: checks = run_preflight_checks(_request(tmp_path), get_profile=lambda device: _profile()) @@ -144,6 +165,48 @@ def test_hardware_backend_includes_windows_checks(tmp_path: Path) -> None: assert [check for check in checks if check.name.startswith("windows_")] == hardware_checks +def test_device_diagnostics_provider_contributes_checks(tmp_path: Path) -> None: + provider = RecordingDiagnosticsProvider() + request = _request( + tmp_path, + backend="hardware", + device_settings={"sample_rate": 48000}, + ) + hardware_checks = [DiagnosticCheck("windows_audio", "pass", "Audio device resolved")] + + checks = run_preflight_checks( + request, + get_profile=lambda device: _profile(diagnostics_provider=provider), + collect_hardware_diagnostics=lambda request: hardware_checks, + ) + + assert provider.context is not None + assert provider.context.request is request + assert provider.context.handler is not None + assert provider.context.resolved_settings == {"sample_rate": 48000} + assert [check.name for check in checks[8:12]] == [ + "device_storage", + "device_firmware", + "device_license", + "windows_audio", + ] + assert _statuses(checks)["device_storage"] == "pass" + assert _statuses(checks)["device_firmware"] == "warning" + assert _statuses(checks)["device_license"] == "fail" + + +def test_device_diagnostics_provider_exception_returns_failed_check(tmp_path: Path) -> None: + checks = run_preflight_checks( + _request(tmp_path), + get_profile=lambda device: _profile(diagnostics_provider=FailingDiagnosticsProvider()), + ) + + check = next(check for check in checks if check.name == "device_diagnostics") + assert check.status == "fail" + assert check.summary == "Device diagnostics provider failed" + assert check.detail == "diagnostic transport unavailable" + + def test_preflight_check_ordering(tmp_path: Path) -> None: request = _request( tmp_path, From 7f6dad9ca3a560bbb0f79713f5d6d892b157aa14 Mon Sep 17 00:00:00 2001 From: Noseglasses Date: Thu, 18 Jun 2026 15:18:53 +0200 Subject: [PATCH 05/10] feat: Restructure main menu --- .../measurement-and-adjusted-files.md | 7 + docs/dev/file-formats.md | 6 + docs/faq.md | 11 + docs/musician-guide.md | 10 + docs/quick-start.md | 11 +- docs/workflows/normalize-setlist.md | 24 +- docs/workflows/normalize-single-preset.md | 5 + docs/workflows/save-and-import.md | 10 + src/matchpatch/file_operations.py | 4 + .../gui/file_operations_workflow.py | 31 +- src/matchpatch/gui/main_window.py | 91 +++--- src/matchpatch/gui/main_window_callbacks.py | 12 +- src/matchpatch/gui/window_layout.py | 50 ++- tests/test_devices.py | 8 + tests/test_gui.py | 53 +++- tests/test_gui_save_workflow.py | 292 ++++++++++++++++-- 16 files changed, 521 insertions(+), 104 deletions(-) diff --git a/docs/concepts/measurement-and-adjusted-files.md b/docs/concepts/measurement-and-adjusted-files.md index 6ba68ee..2152ede 100644 --- a/docs/concepts/measurement-and-adjusted-files.md +++ b/docs/concepts/measurement-and-adjusted-files.md @@ -59,12 +59,19 @@ In the GUI: Use Save As when you want to keep the original file untouched. +When several `.hlx` presets are opened together, MatchPatch treats them as a +temporary setlist while you work. Save writes the edited presets back to their +original `.hlx` files. Save As writes one new `.hls` setlist containing all open +presets. + ## File Extensions The output file should keep the same extension as the input: - `.hls` setlists save as `.hls`; - `.hlx` presets save as `.hlx`. +- multiple `.hlx` presets opened together save back to multiple `.hlx` files + with Save, but Save As uses `.hls`. MatchPatch will warn you if the extension does not match. diff --git a/docs/dev/file-formats.md b/docs/dev/file-formats.md index 01bfd70..bf79731 100644 --- a/docs/dev/file-formats.md +++ b/docs/dev/file-formats.md @@ -55,6 +55,12 @@ measurement therefore requires exactly one `--preset-set`/`-S` value, such as `12A`, so the worker knows which temporary Helix slot to steer during measurement. +The GUI can open several `.hlx` files in one File > Open selection. That mode is +implemented by joining the selected presets into a temporary `.hls` setlist so +the existing preset-table and measurement workflow can operate on the group. +Saving with Save splits the temporary setlist back into the original `.hlx` +files. Saving with Save As writes the temporary setlist as a normal `.hls` file. + ## Unpacked `.json` The Helix utility can also read and write unpacked JSON for selected diff --git a/docs/faq.md b/docs/faq.md index 73ab8cd..6ae8e7b 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -139,6 +139,17 @@ during measurement. See [Normalize A Single Preset](workflows/normalize-single-preset.md). +## Can I Open Several `.hlx` Presets Together? + +Yes. Use File > Open and select only `.hlx` files. MatchPatch shows those +presets together as a temporary setlist in the preset table. + +Save overwrites the original `.hlx` files. The first overwrite prompt lets you +approve overwriting the rest of the batch. Save As writes one `.hls` setlist +instead. + +Do not mix `.hlx` and `.hls` files in one Open selection. + ## Should I Normalize Before Or After Rehearsal? Both can help. diff --git a/docs/musician-guide.md b/docs/musician-guide.md index aeb9424..b4a3339 100644 --- a/docs/musician-guide.md +++ b/docs/musician-guide.md @@ -113,6 +113,16 @@ A setlist shows multiple preset rows. A single preset shows one row and needs a temporary Helix slot, such as `12A`, so MatchPatch knows where to steer the Helix during measurement. +File > Open can also open several `.hlx` preset files at once. In that case, +MatchPatch joins the selected presets into one temporary setlist view and shows +them together in the preset table. The selection must contain only `.hlx` files: +do not mix `.hlx` presets with an `.hls` setlist, and do not select more than +one `.hls` setlist at a time. + +When several `.hlx` files are open together, Save writes each edited preset back +to its original `.hlx` file. Save As writes one new `.hls` setlist containing all +open presets. + Workflows: - [Normalize A Setlist](workflows/normalize-setlist.md) diff --git a/docs/quick-start.md b/docs/quick-start.md index f6af046..339c0a4 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -12,7 +12,9 @@ Helix results, use hardware mode. - Have a reference DI WAV ready, or use the default one. - Decide which file you are working on: - `.hls` for a setlist; - - `.hlx` for one preset. + - one `.hlx` for one preset; + - several `.hlx` files when you want MatchPatch to show them together as a + temporary setlist. - Decide which backend to use: - loopback for a safe no-hardware test; - hardware for real Helix measurement. @@ -31,7 +33,7 @@ Loopback is the easiest way to learn MatchPatch. It does not measure your Helix, but it lets you practice the full GUI flow. 1. Open MatchPatch. -2. Open a Helix `.hls` setlist or `.hlx` preset. +2. Open a Helix `.hls` setlist, one `.hlx` preset, or several `.hlx` presets. 3. Open Advanced. 4. Go to the Device tab. 5. Set Backend to `loopback`. @@ -55,7 +57,7 @@ Use hardware mode when you are ready to measure the Helix. 1. Connect and power on the Helix. 2. Open MatchPatch. -3. Open your `.hls` setlist or `.hlx` preset. +3. Open your `.hls` setlist, one `.hlx` preset, or several `.hlx` presets. 4. Open Advanced > Device. 5. Set Backend to `hardware`. 6. Check audio routing and MIDI steering. @@ -65,7 +67,8 @@ Use hardware mode when you are ready to measure the Helix. 10. Click Start normalization. 11. Follow any import prompts. 12. Review the result table. -13. Click Save As and save an adjusted file. +13. Click Save As and save an adjusted file, or click Save when you opened + several `.hlx` files and want to overwrite those original preset files. 14. Import the adjusted file into the Helix. 15. Listen through the presets and snapshots. diff --git a/docs/workflows/normalize-setlist.md b/docs/workflows/normalize-setlist.md index 3525464..12d41c2 100644 --- a/docs/workflows/normalize-setlist.md +++ b/docs/workflows/normalize-setlist.md @@ -22,7 +22,9 @@ Useful background: ## Steps 1. Open MatchPatch. -2. Open your `.hls` setlist. +2. Open your `.hls` setlist. You may also select several `.hlx` preset files + together; MatchPatch will show them as one temporary setlist in the preset + table. 3. Wait for the preset table to appear. 4. Review the listed presets. MatchPatch shows only non-empty presets. 5. Choose which presets to measure: @@ -49,6 +51,26 @@ Useful background: ![Loaded setlist with selected presets](../assets/screenshots/loaded-setlist.png) +## Opening Several `.hlx` Presets + +Use File > Open and select more than one `.hlx` file when you want to work on +separate preset files as a group. MatchPatch joins them into a temporary setlist +view for the table and for measurement. + +Selection rules: + +- several selected files must all be `.hlx` presets; +- one `.hls` setlist can be opened by itself; +- `.hls` and `.hlx` files cannot be mixed in one Open selection. + +Save behavior is different for this mode: + +- Save writes each edited preset back to the original `.hlx` file it came from; +- the first overwrite prompt includes an "overwrite them all" checkbox so you + can approve the whole batch; +- Save As writes one `.hls` setlist containing all open presets. + + (help-preset-table-legend)= ## Preset Table Legend diff --git a/docs/workflows/normalize-single-preset.md b/docs/workflows/normalize-single-preset.md index 3259f56..1b471d1 100644 --- a/docs/workflows/normalize-single-preset.md +++ b/docs/workflows/normalize-single-preset.md @@ -4,6 +4,11 @@ Use this workflow when you have one Helix `.hlx` preset instead of a full setlist. +If you have several `.hlx` presets and want to work on them together, select all +of those `.hlx` files in File > Open. MatchPatch will show them as a temporary +setlist in the preset table. In that mode, Save writes the edited presets back +to their original `.hlx` files, while Save As writes one `.hls` setlist. + A single preset file does not know where it will live on the Helix. MatchPatch therefore needs a temporary Helix slot for measurement. diff --git a/docs/workflows/save-and-import.md b/docs/workflows/save-and-import.md index 2fe9320..07bd084 100644 --- a/docs/workflows/save-and-import.md +++ b/docs/workflows/save-and-import.md @@ -23,6 +23,10 @@ Save writes changes to the active Helix file. Use Save when you are comfortable replacing the active file and you already have a backup. +If you opened several `.hlx` presets at once, Save writes each edited preset +back to its original `.hlx` file. The first overwrite prompt includes a checkbox +for approving the rest of the batch without being asked for every file. + (help-save-as)= ## Save As @@ -31,6 +35,10 @@ Save As writes a new Helix file. This is the safer choice for most users because it keeps the original file untouched. +If you opened several `.hlx` presets at once, Save As writes one `.hls` setlist +that contains all of those open presets. It does not write several separate +`.hlx` files. + Example: ```text @@ -56,6 +64,8 @@ Keep the same file type: - `.hls` setlist saves as `.hls`; - `.hlx` preset saves as `.hlx`. +- several open `.hlx` presets save individually with Save, but Save As writes an + `.hls` setlist. MatchPatch warns you if the saved file extension does not match. diff --git a/src/matchpatch/file_operations.py b/src/matchpatch/file_operations.py index b560cc7..e03c043 100644 --- a/src/matchpatch/file_operations.py +++ b/src/matchpatch/file_operations.py @@ -28,10 +28,12 @@ def join_preset_files( output_path: Path, *, slot_ids: list[int] | None = None, + log_callback: Callable[[str], None] | None = None, get_profile: Callable[[str], DeviceProfile] = get_device_profile, ) -> JoinPresetFilesResult: profile = get_profile(device) handler = profile.create_patch_file_handler(PROJECT_DIR) + handler.set_log_callback(log_callback) capabilities = handler.file_capabilities() if not capabilities.joins_presets_to_setlist: @@ -54,10 +56,12 @@ def split_setlist_file( *, selected_ids: list[int] | None = None, original_filenames: Mapping[int, str] | None = None, + log_callback: Callable[[str], None] | None = None, get_profile: Callable[[str], DeviceProfile] = get_device_profile, ) -> SplitSetlistFileResult: profile = get_profile(device) handler = profile.create_patch_file_handler(PROJECT_DIR) + handler.set_log_callback(log_callback) capabilities = handler.file_capabilities() if not capabilities.splits_setlist_to_presets: diff --git a/src/matchpatch/gui/file_operations_workflow.py b/src/matchpatch/gui/file_operations_workflow.py index 54f8abc..8659878 100644 --- a/src/matchpatch/gui/file_operations_workflow.py +++ b/src/matchpatch/gui/file_operations_workflow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import tempfile from collections.abc import Callable, Iterable from pathlib import Path from typing import Any, Protocol, cast @@ -46,6 +47,8 @@ def _log(self, message: str, level: str) -> None: ... def _open_input_path(self, path: str) -> None: ... + def _mark_joined_setlist_staged(self, path: Path) -> None: ... + def _set_optional_widget_enabled(self, name: str, enabled: bool) -> None: ... @@ -132,6 +135,21 @@ def choose_join_output_path( return output_path if output_path.suffix.lower() == suffix else output_path.with_suffix(suffix) +def temporary_join_output_path( + file_types: Iterable[DeviceFileType] = (), +) -> Path: + suffix = _kind_extension(file_types, "setlist", ".hls") + temporary = tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + prefix="matchpatch_joined_", + suffix=suffix, + delete=False, + ) + temporary.close() + return Path(temporary.name) + + def choose_split_output_dir(parent: QWidget) -> Path | None: path = QFileDialog.getExistingDirectory(parent, "Choose split output directory") return Path(path) if path else None @@ -167,22 +185,26 @@ def join_preset_files(window: FileOperationWindow) -> bool: preset_paths = choose_join_preset_paths(parent, file_types) if not preset_paths: return False - output_path = choose_join_output_path(parent, file_types) - if output_path is None: - return False + output_path = temporary_join_output_path(file_types) try: result = file_operations.join_preset_files( window.device.currentData(), preset_paths, output_path, + log_callback=lambda message: window._log(message, "info"), ) except Exception as exc: # noqa: BLE001 + output_path.unlink(missing_ok=True) window.show_error(str(exc)) return False - window._log(f"Joined preset files: {result.output_path.resolve()}", "success") + window._log( + f"Joined {len(preset_paths)} preset file(s) into the preset table", + "success", + ) window._open_input_path(str(result.output_path)) + window._mark_joined_setlist_staged(result.output_path) return True @@ -217,6 +239,7 @@ def split_setlist( output_dir, selected_ids=selected_ids, original_filenames=filenames, + log_callback=lambda message: window._log(message, "info"), ) except Exception as exc: # noqa: BLE001 window.show_error(str(exc)) diff --git a/src/matchpatch/gui/main_window.py b/src/matchpatch/gui/main_window.py index 2519e51..b7f19a7 100644 --- a/src/matchpatch/gui/main_window.py +++ b/src/matchpatch/gui/main_window.py @@ -70,7 +70,14 @@ write_diagnostic_bundle, ) from matchpatch.gui import diagnostics_panel as gui_diagnostics -from matchpatch.gui import file_operations_workflow, file_type_filters, window_layout, window_state +from matchpatch.gui import ( + file_operations_workflow, + file_type_filters, + multi_hlx_workflow, + save_dialogs, + window_layout, + window_state, +) from matchpatch.gui import help as gui_help from matchpatch.gui.advanced_settings import ( GuiSettingsBinder, @@ -224,7 +231,6 @@ __all__ = ["MainWindow"] -RECENT_FILES_SETTINGS_KEY = window_state.RECENT_FILES_SETTINGS_KEY MAX_RECENT_FILES = window_state.MAX_RECENT_FILES TOOLBAR_VERTICAL_PADDING = window_layout.TOOLBAR_VERTICAL_PADDING @@ -275,6 +281,9 @@ def __init__(self) -> None: self._preset_table_modified = False self._preset_table_clean_signature: tuple[tuple[str, ...], ...] = () self._loaded_input_path = "" + self._staged_joined_setlist_path: Path | None = None + self._multi_hlx_output_paths_by_id: dict[int, Path] = {} + self._multi_hlx_input_count = 0 self._preset_load_discard_confirmed = False self._manual_cell_editor: QLineEdit | None = None self._manual_cell_target: tuple[int, int] | None = None @@ -681,12 +690,12 @@ def _populate_devices(self) -> None: self.loading_controller.refresh_backend_choices() def browse_input(self) -> None: - path, _ = QFileDialog.getOpenFileName( + paths, _ = QFileDialog.getOpenFileNames( self, "Choose patch file", filter=file_type_filters.open_patch_filter_for_device(self.device.currentData()), ) - self._open_input_path(path) + multi_hlx_workflow.open_input_paths(self._multi_hlx_window(), paths) def _recent_file_paths(self) -> list[str]: return recent_file_paths(self.settings) @@ -728,6 +737,9 @@ def _open_input_path(self, path: str) -> None: ): return self._preset_load_discard_confirmed = True + self._staged_joined_setlist_path = None + self._multi_hlx_output_paths_by_id = {} + self._multi_hlx_input_count = 0 self.input_path.setText(path) try: self.load_assignments() @@ -748,49 +760,21 @@ def _confirm_discard_preset_table_changes(self) -> bool: return answer in {QMessageBox.StandardButton.Discard, QMessageBox.StandardButton.Yes} def _choose_save_as_path(self, *, accept_label: str = "Save as") -> Path | None: - suffix = Path(self.input_path.text()).suffix.lower() - file_filter = file_type_filters.helix_save_file_filter(self.device.currentData(), suffix) - if file_filter is None: - self.show_error("Open a Helix .hls or .hlx file before saving") - return None - dialog = QFileDialog(self, "Save Helix file as") - dialog.setOption(QFileDialog.Option.DontUseNativeDialog) - dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptOpen) - dialog.setFileMode(QFileDialog.FileMode.AnyFile) - dialog.setNameFilter(file_filter) - dialog.setLabelText(QFileDialog.DialogLabel.Accept, accept_label) - path = dialog.selectedFiles()[0] if dialog.exec() and dialog.selectedFiles() else "" - if not path: - return None - save_path = Path(path) - if save_path.suffix.lower() != suffix: - self.show_error(f"Saved file must use the {suffix} extension") - return None - return save_path + return save_dialogs.choose_save_as_path( + self, + input_path_text=self.input_path.text(), + device_name=self.device.currentData(), + show_error=self.show_error, + accept_label=accept_label, + ) def _choose_measurement_save_path(self) -> Path | None: - input_path = Path(self.input_path.text()) - suffix = input_path.suffix.lower() - file_filter = file_type_filters.helix_save_file_filter(self.device.currentData(), suffix) - if file_filter is None: - self.show_error("Open a Helix .hls or .hlx file before saving a measurement file") - return None - suggested_path = input_path.with_name(input_path.stem + "_measurement" + suffix) - dialog = QFileDialog(self, "Save measurement file") - dialog.setOption(QFileDialog.Option.DontUseNativeDialog) - dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave) - dialog.setFileMode(QFileDialog.FileMode.AnyFile) - dialog.setNameFilter(file_filter) - dialog.selectFile(str(suggested_path)) - dialog.setLabelText(QFileDialog.DialogLabel.Accept, "Save") - path = dialog.selectedFiles()[0] if dialog.exec() and dialog.selectedFiles() else "" - if not path: - return None - save_path = Path(path) - if save_path.suffix.lower() != suffix: - self.show_error(f"Measurement file must use the {suffix} extension") - return None - return save_path + return save_dialogs.choose_measurement_save_path( + self, + input_path=Path(self.input_path.text()), + device_name=self.device.currentData(), + show_error=self.show_error, + ) def browse_output(self) -> None: path = self._choose_save_as_path(accept_label="Save") @@ -1607,6 +1591,10 @@ def save_active_file(self) -> bool: if not self.input_path.text().strip(): self.show_error("Open a Helix .hls or .hlx file before saving") return False + if self._multi_hlx_output_paths_by_id: + return multi_hlx_workflow.save_multi_hlx_files(self._multi_hlx_window()) + if active_path == self._staged_joined_setlist_path: + return self.save_active_file_as() return self._save_to_path(active_path) def save_active_file_as(self) -> bool: @@ -1705,12 +1693,21 @@ def _activate_saved_file( def _activate_saved_file_without_reloading_preset_table(self, path: Path) -> None: self.input_path.setText(str(path)) self._loaded_input_path = str(path) + self._staged_joined_setlist_path = None + self._multi_hlx_output_paths_by_id = {} + self._multi_hlx_input_count = 0 self._set_active_file(path) self._store_recent_file(path) self._load_metadata() self._reset_preset_table_modified() self._refresh_file_actions() + def _mark_joined_setlist_staged(self, path: Path) -> None: + multi_hlx_workflow.mark_joined_setlist_staged(self._multi_hlx_window(), path) + + def _multi_hlx_window(self) -> multi_hlx_workflow.MultiHlxWindow: + return cast(multi_hlx_workflow.MultiHlxWindow, self) + def _set_active_file(self, path: Path) -> None: self.setWindowTitle(active_file_title(path)) @@ -1746,6 +1743,10 @@ def _apply_file_action_state(self, action_state: FileActionState) -> None: project_dir=Path(__file__).resolve().parents[3], ) self._set_optional_widget_enabled("start_button", action_state.start_enabled) + self._set_optional_widget_enabled( + "run_normalization_action", + action_state.start_enabled, + ) self._refresh_determine_parameters_action(action_state) self._set_optional_widget_enabled( "record_output_button", diff --git a/src/matchpatch/gui/main_window_callbacks.py b/src/matchpatch/gui/main_window_callbacks.py index e6e2155..7d9b0f3 100644 --- a/src/matchpatch/gui/main_window_callbacks.py +++ b/src/matchpatch/gui/main_window_callbacks.py @@ -4,7 +4,7 @@ import re from pathlib import Path -from typing import Any +from typing import Any, Callable from PySide6.QtWidgets import QTableWidgetItem @@ -28,10 +28,18 @@ class MainWindowSaveCallbacks(SaveCallbacks): - def __init__(self, window: object) -> None: + def __init__( + self, + window: object, + *, + confirm_overwrite: Callable[[Path], bool] | None = None, + ) -> None: self._window: Any = window + self._confirm_overwrite = confirm_overwrite def confirm_overwrite(self, output_path: Path) -> bool: + if self._confirm_overwrite is not None: + return self._confirm_overwrite(output_path) return self._window._confirm_overwrite(output_path) def create_table_save_csv(self, directory: Path) -> Path: diff --git a/src/matchpatch/gui/window_layout.py b/src/matchpatch/gui/window_layout.py index b42a428..6c31470 100644 --- a/src/matchpatch/gui/window_layout.py +++ b/src/matchpatch/gui/window_layout.py @@ -33,6 +33,7 @@ QHBoxLayout, QLabel, QLineEdit, + QMenu, QProgressBar, QPushButton, QSizePolicy, @@ -204,9 +205,7 @@ def build_toolbar(window: MainWindowLike) -> None: "Save Measurement File", object_parent, ) - window.save_measurement_action.setToolTip( - "Save the measurement Helix file generated by normalization." - ) + window.save_measurement_action.setToolTip("Save the measurement Helix file for normalization.") window.save_measurement_action.setProperty("help_id", HelpId.MEASUREMENT_FILE) window.save_measurement_action.triggered.connect(window.save_measurement_file) toolbar.addAction(window.save_measurement_action) @@ -223,7 +222,6 @@ def build_toolbar(window: MainWindowLike) -> None: cast(file_operations_workflow.FileOperationWindow, window) ) ) - toolbar.addAction(window.join_preset_files_action) window.split_setlist_action = QAction( window.style().standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon), @@ -239,7 +237,6 @@ def build_toolbar(window: MainWindowLike) -> None: project_dir=Path(__file__).resolve().parents[3], ) ) - toolbar.addAction(window.split_setlist_action) window.normalization_separator_action = toolbar.addSeparator() window.start_button = QToolButton(parent) @@ -258,6 +255,10 @@ def build_toolbar(window: MainWindowLike) -> None: window.start_cancel_stack.addWidget(window.start_button) window.start_cancel_stack.addWidget(window.cancel_button) window.normalization_action = toolbar.addWidget(window.start_cancel_stack) + window.run_normalization_action = QAction(_normalization_icon(), "Normalize", object_parent) + window.run_normalization_action.setToolTip("Start the guided preset-normalization workflow.") + window.run_normalization_action.setProperty("help_id", HelpId.NORMALIZE_SETLIST) + window.run_normalization_action.triggered.connect(window.start_normalization) help_spacer = QWidget(parent) help_spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) @@ -327,6 +328,15 @@ def build_toolbar(window: MainWindowLike) -> None: window.about_action.triggered.connect(window.show_about) toolbar.addAction(window.about_action) + window.exit_action = QAction( + window.style().standardIcon(QStyle.StandardPixmap.SP_DialogCloseButton), + "Exit", + object_parent, + ) + window.exit_action.setToolTip("Close MatchPatch.") + window.exit_action.triggered.connect(window.close) + _build_menu_bar(window) + square_button_size = toolbar.iconSize().width() + 14 for button in ( window.start_button, @@ -344,8 +354,6 @@ def build_toolbar(window: MainWindowLike) -> None: window.save_action, window.save_as_action, window.save_measurement_action, - window.join_preset_files_action, - window.split_setlist_action, window.help_action, window.about_action, ): @@ -365,6 +373,34 @@ def build_toolbar(window: MainWindowLike) -> None: widget.installEventFilter(object_parent) +def _build_menu_bar(window: MainWindowLike) -> None: + parent = cast(QWidget, window) + menu_bar = window.menuBar() + + window.file_menu = QMenu("File", parent) + menu_bar.addMenu(window.file_menu) + for action in ( + window.open_action, + window.save_action, + window.save_as_action, + window.save_measurement_action, + ): + window.file_menu.addAction(action) + window.file_menu.addSeparator() + window.file_menu.addAction(window.split_setlist_action) + window.file_menu.addSeparator() + window.file_menu.addAction(window.exit_action) + + window.run_menu = QMenu("Run", parent) + menu_bar.addMenu(window.run_menu) + window.run_menu.addAction(window.run_normalization_action) + + window.help_menu = QMenu("Help", parent) + menu_bar.addMenu(window.help_menu) + window.help_menu.addAction(window.help_action) + window.help_menu.addAction(window.about_action) + + def build_preset_advanced_splitter(window: MainWindowLike) -> QSplitter: splitter = QSplitter(Qt.Orientation.Horizontal) window.preset_advanced_splitter = splitter diff --git a/tests/test_devices.py b/tests/test_devices.py index 29a4ad6..b15d7df 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -264,6 +264,10 @@ class FileOperationsHandler(MinimalHandler): def __init__(self) -> None: self.join_calls = [] self.split_calls = [] + self.log_callback = None + + def set_log_callback(self, callback) -> None: + self.log_callback = callback def file_capabilities(self) -> FileOperationCapabilities: return FileOperationCapabilities( @@ -574,16 +578,20 @@ def diff_subdivisions( def test_file_operations_join_validates_capabilities_and_delegates(tmp_path) -> None: handler = FileOperationsHandler() + messages = [] + log_callback = messages.append result = file_operations.join_preset_files( "files-device", [tmp_path / "one.preset"], tmp_path / "joined.setlist", slot_ids=[1], + log_callback=log_callback, get_profile=lambda device: FileOperationsProfile(handler), ) assert result.output_path == tmp_path / "joined.setlist" + assert handler.log_callback is log_callback assert handler.join_calls == [([tmp_path / "one.preset"], tmp_path / "joined.setlist", [1])] with pytest.raises(ValueError, match="does not support joining"): diff --git a/tests/test_gui.py b/tests/test_gui.py index 2a84585..66cb188 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -345,7 +345,41 @@ def test_main_window_starts_with_registry_device_and_hardware(app) -> None: assert window.preset_advanced_splitter.widget(0) is window.presets assert window.preset_advanced_splitter.widget(1) is window.advanced assert window.content.layout().indexOf(window.preset_advanced_splitter) == 0 - assert not window.findChildren(QMenuBar) + menu_bar = window.menuBar() + assert isinstance(menu_bar, QMenuBar) + assert [action.text() for action in menu_bar.actions()] == ["File", "Run", "Help"] + file_menu = menu_bar.actions()[0].menu() + run_menu = menu_bar.actions()[1].menu() + help_menu = menu_bar.actions()[2].menu() + assert file_menu is not None + assert run_menu is not None + assert help_menu is not None + assert [action.text() for action in file_menu.actions() if not action.isSeparator()] == [ + "Open", + "Save", + "Save As", + "Save Measurement File", + "Split Setlist", + "Exit", + ] + assert file_menu.actions()[-1] is window.exit_action + assert [action.text() for action in run_menu.actions()] == ["Normalize"] + assert run_menu.actions()[0] is window.run_normalization_action + assert [action.text() for action in help_menu.actions()] == ["Help", "About"] + assert help_menu.actions()[0] is window.help_action + assert help_menu.actions()[1] is window.about_action + for action in ( + window.open_action, + window.save_action, + window.save_as_action, + window.save_measurement_action, + window.split_setlist_action, + window.exit_action, + window.run_normalization_action, + window.help_action, + window.about_action, + ): + assert not action.icon().isNull() toolbar = window.findChildren(QToolBar)[0] toolbar_actions = [ action for action in toolbar.actions() if action.text() and not action.isSeparator() @@ -355,13 +389,11 @@ def test_main_window_starts_with_registry_device_and_hardware(app) -> None: "Save", "Save As", "Save Measurement File", - "Join Preset Files", - "Split Setlist", "Help", "About", ] assert toolbar.actions().index(window.normalization_separator_action) == ( - toolbar.actions().index(window.split_setlist_action) + 1 + toolbar.actions().index(window.save_measurement_action) + 1 ) assert toolbar.actions().index(window.save_measurement_action) == ( toolbar.actions().index(window.save_as_action) + 1 @@ -460,6 +492,7 @@ def test_main_window_starts_with_registry_device_and_hardware(app) -> None: assert not window.save_as_action.isEnabled() assert not window.save_measurement_action.isEnabled() assert not window.start_button.isEnabled() + assert not window.run_normalization_action.isEnabled() assert not window.determine_parameters_button.isEnabled() assert not window.determine_parameters_hint.isHidden() assert "Open a Helix file" in window.determine_parameters_hint.text() @@ -964,8 +997,8 @@ def test_input_browse_prompts_before_discarding_preset_adjustments(monkeypatch, answers = iter([QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Discard]) monkeypatch.setattr( QFileDialog, - "getOpenFileName", - lambda *args, **kwargs: ("/tmp/new.hlx", ""), + "getOpenFileNames", + lambda *args, **kwargs: (["/tmp/new.hlx"], ""), ) monkeypatch.setattr(main_window, "QMessageBox", _FakeSaveChangesMessageBox) _FakeSaveChangesMessageBox.instances = [] @@ -1004,8 +1037,8 @@ def test_input_browse_does_not_prompt_for_clean_preset_table(monkeypatch, app) - window.input_path.setText("/tmp/original.hls") monkeypatch.setattr( QFileDialog, - "getOpenFileName", - lambda *args, **kwargs: ("/tmp/new.hlx", ""), + "getOpenFileNames", + lambda *args, **kwargs: (["/tmp/new.hlx"], ""), ) monkeypatch.setattr( QMessageBox, @@ -1028,8 +1061,8 @@ def test_startup_open_button_loads_like_toolbar_open(tmp_path, monkeypatch, app) path = str(input_file) monkeypatch.setattr( QFileDialog, - "getOpenFileName", - lambda *args, **kwargs: (path, ""), + "getOpenFileNames", + lambda *args, **kwargs: ([path], ""), ) window.preset_empty_open_button.click() diff --git a/tests/test_gui_save_workflow.py b/tests/test_gui_save_workflow.py index d6e4fd3..46db610 100644 --- a/tests/test_gui_save_workflow.py +++ b/tests/test_gui_save_workflow.py @@ -67,7 +67,9 @@ loudness_widgets, main_window, measurement_optimization, + multi_hlx_workflow, results, + save_dialogs, ) from matchpatch.gui import worker as gui_worker from matchpatch.gui.advanced_settings import ( @@ -191,7 +193,7 @@ def install_save_measurement_fakes(monkeypatch, output_path, request): SaveMeasurementFileDialog.output_path = output_path RecordingMeasurementHandler.created = [] RecordingMeasurementHandler.validated = [] - monkeypatch.setattr(main_window, "QFileDialog", SaveMeasurementFileDialog) + monkeypatch.setattr(save_dialogs, "QFileDialog", SaveMeasurementFileDialog) monkeypatch.setattr(advanced_settings, "parse_args", lambda argv: argv) monkeypatch.setattr(advanced_settings, "apply_config", lambda args: args) monkeypatch.setattr(advanced_settings, "request_from_args", lambda args: request) @@ -432,15 +434,16 @@ def test_file_operation_actions_are_gated_by_capabilities_and_active_kind( window.close() -def test_join_preset_files_action_calls_workflow_and_opens_output( +def test_join_preset_files_action_calls_workflow_and_opens_staged_setlist( monkeypatch, app, tmp_path, ) -> None: window = MainWindow() preset_paths = [tmp_path / "lead.hlx", tmp_path / "rhythm.hlx"] - output_path = tmp_path / "joined.hls" + staged_path = tmp_path / "joined.hls" opened_paths: list[str] = [] + staged_paths: list[Path] = [] calls = [] monkeypatch.setattr( file_operations_workflow, @@ -449,13 +452,21 @@ def test_join_preset_files_action_calls_workflow_and_opens_output( ) monkeypatch.setattr( file_operations_workflow, - "choose_join_output_path", - lambda parent, file_types=(): output_path, + "temporary_join_output_path", + lambda file_types=(): staged_path, ) monkeypatch.setattr(window, "_open_input_path", opened_paths.append) + monkeypatch.setattr(window, "_mark_joined_setlist_staged", staged_paths.append) - def join_preset_files(device, selected_preset_paths, selected_output_path): - calls.append((device, selected_preset_paths, selected_output_path)) + def join_preset_files( + device, + selected_preset_paths, + selected_output_path, + *, + log_callback=None, + ): + calls.append((device, selected_preset_paths, selected_output_path, log_callback)) + log_callback("[OK] Joined 2 presets into staged.hls") return file_operations_workflow.file_operations.JoinPresetFilesResult( output_path=selected_output_path ) @@ -468,8 +479,17 @@ def join_preset_files(device, selected_preset_paths, selected_output_path): assert file_operations_workflow.join_preset_files(window) - assert calls == [("helix", preset_paths, output_path)] - assert opened_paths == [str(output_path)] + assert len(calls) == 1 + device, selected_preset_paths, selected_output_path, log_callback = calls[0] + assert (device, selected_preset_paths, selected_output_path) == ( + "helix", + preset_paths, + staged_path, + ) + assert log_callback is not None + assert any("[OK] Joined 2 presets into staged.hls" in entry[2] for entry in window.log_entries) + assert opened_paths == [str(staged_path)] + assert staged_paths == [staged_path] window.close() @@ -496,11 +516,11 @@ def create_patch_file_handler(project_dir): lambda device: Profile(), ) - def get_open_file_name(*args, **kwargs): + def get_open_file_names(*args, **kwargs): filters.append(kwargs["filter"]) - return "", "" + return [], "" - monkeypatch.setattr(QFileDialog, "getOpenFileName", get_open_file_name) + monkeypatch.setattr(QFileDialog, "getOpenFileNames", get_open_file_names) window.browse_input() @@ -508,10 +528,74 @@ def get_open_file_name(*args, **kwargs): window.close() +def test_input_browse_rejects_mixed_multi_selection(monkeypatch, app) -> None: + window = MainWindow() + errors = [] + monkeypatch.setattr( + QFileDialog, + "getOpenFileNames", + lambda *args, **kwargs: (["/tmp/one.hlx", "/tmp/set.hls"], ""), + ) + monkeypatch.setattr(window, "show_error", errors.append) + + window.browse_input() + + assert errors == [ + "Select either one .hls setlist, one .hlx preset, or multiple .hlx presets. " + "Do not mix .hls and .hlx files." + ] + window.close() + + +def test_input_browse_multiple_hlx_joins_and_loads_staged_setlist( + monkeypatch, + app, + tmp_path, +) -> None: + window = MainWindow() + preset_paths = [tmp_path / "lead.hlx", tmp_path / "rhythm.hlx"] + staged_path = tmp_path / "joined.hls" + opened_paths = [] + staged_calls = [] + calls = [] + monkeypatch.setattr( + QFileDialog, + "getOpenFileNames", + lambda *args, **kwargs: ([str(path) for path in preset_paths], ""), + ) + monkeypatch.setattr( + file_operations_workflow, + "temporary_join_output_path", + lambda file_types=(): staged_path, + ) + monkeypatch.setattr(window, "_open_input_path", opened_paths.append) + monkeypatch.setattr( + multi_hlx_workflow, + "mark_multi_hlx_setlist_staged", + lambda *args: staged_calls.append(args), + ) + + def join_preset_files(device, selected_preset_paths, output_path, *, log_callback=None): + calls.append((device, selected_preset_paths, output_path, log_callback)) + return file_operations_workflow.file_operations.JoinPresetFilesResult(output_path) + + monkeypatch.setattr(multi_hlx_workflow.file_operations, "join_preset_files", join_preset_files) + + window.browse_input() + + assert len(calls) == 1 + device, selected_preset_paths, output_path, log_callback = calls[0] + assert (device, selected_preset_paths, output_path) == ("helix", preset_paths, staged_path) + assert log_callback is not None + assert opened_paths == [str(staged_path)] + assert staged_calls == [(window, staged_path, preset_paths)] + window.close() + + def test_join_dialogs_use_device_file_type_filters(monkeypatch, app, tmp_path) -> None: window = MainWindow() preset_paths = [tmp_path / "lead.preset"] - output_path = tmp_path / "joined" + staged_path = tmp_path / "joined.setlist" filters = [] class Handler: @@ -534,15 +618,16 @@ def create_patch_file_handler(project_dir): lambda *args, **kwargs: filters.append(kwargs["filter"]) or ([str(preset_paths[0])], ""), ) monkeypatch.setattr( - QFileDialog, - "getSaveFileName", - lambda *args, **kwargs: filters.append(kwargs["filter"]) or (str(output_path), ""), + file_operations_workflow, + "temporary_join_output_path", + lambda file_types=(): staged_path, ) monkeypatch.setattr(window, "_open_input_path", lambda path: None) + monkeypatch.setattr(window, "_mark_joined_setlist_staged", lambda path: None) monkeypatch.setattr( file_operations_workflow.file_operations, "join_preset_files", - lambda device, selected_preset_paths, selected_output_path: ( + lambda device, selected_preset_paths, selected_output_path, **kwargs: ( file_operations_workflow.file_operations.JoinPresetFilesResult( output_path=selected_output_path ) @@ -551,7 +636,7 @@ def create_patch_file_handler(project_dir): assert file_operations_workflow.join_preset_files(window) - assert filters == ["Preset files (*.preset)", "Setlist files (*.setlist)"] + assert filters == ["Preset files (*.preset)"] window.close() @@ -593,6 +678,7 @@ def split_setlist_file( *, selected_ids=None, original_filenames=None, + log_callback=None, ): calls.append( ( @@ -601,6 +687,7 @@ def split_setlist_file( selected_output_dir, selected_ids, original_filenames, + log_callback, ) ) return file_operations_workflow.file_operations.SplitSetlistFileResult( @@ -619,15 +706,29 @@ def split_setlist_file( project_dir=Path(main_window.__file__).resolve().parents[3], ) - assert calls == [ - ( - "helix", - input_path, - output_dir, - [6], - {1: "lead.hlx", 6: "rhythm.hlx"}, - ) - ] + assert len(calls) == 1 + ( + device, + selected_input_path, + selected_output_dir, + selected_ids, + original_filenames, + log_callback, + ) = calls[0] + assert ( + device, + selected_input_path, + selected_output_dir, + selected_ids, + original_filenames, + ) == ( + "helix", + input_path, + output_dir, + [6], + {1: "lead.hlx", 6: "rhythm.hlx"}, + ) + assert log_callback is not None assert any(str(created_paths[0].resolve()) in entry[2] for entry in window.log_entries) window.close() @@ -802,7 +903,7 @@ def exec(): def selectedFiles(): return ["/tmp/output.hls"] - monkeypatch.setattr(main_window, "QFileDialog", FileDialog) + monkeypatch.setattr(save_dialogs, "QFileDialog", FileDialog) monkeypatch.setattr( window, "_save_to_path", @@ -822,6 +923,135 @@ def selectedFiles(): window.close() +def test_save_active_file_routes_staged_join_to_save_as(monkeypatch, app, tmp_path) -> None: + window = MainWindow() + staged_path = tmp_path / "matchpatch_joined.hls" + window.input_path.setText(str(staged_path)) + window._staged_joined_setlist_path = staged_path + calls = [] + monkeypatch.setattr(window, "save_active_file_as", lambda: calls.append("save_as") or True) + + assert window.save_active_file() + + assert calls == ["save_as"] + window.close() + + +def test_save_active_file_with_multiple_hlx_writes_original_presets( + monkeypatch, + app, + tmp_path, +) -> None: + window = MainWindow() + active_setlist = tmp_path / "active.hls" + active_setlist.write_text("old setlist", encoding="utf-8") + lead_path = tmp_path / "lead.hlx" + rhythm_path = tmp_path / "rhythm.hlx" + lead_path.write_text("old lead", encoding="utf-8") + rhythm_path.write_text("old rhythm", encoding="utf-8") + window.input_path.setText(str(active_setlist)) + window._loaded_input_path = str(active_setlist) + window._multi_hlx_output_paths_by_id = {1: lead_path, 6: rhythm_path} + window._multi_hlx_input_count = 2 + window._mark_preset_table_modified() + split_calls = [] + monkeypatch.setattr( + multi_hlx_workflow, + "confirm_multi_hlx_overwrites", + lambda window, paths: True, + ) + monkeypatch.setattr( + multi_hlx_workflow, + "save_table_to_temporary_setlist", + lambda window, output_path: output_path.write_text("new setlist", encoding="utf-8") or True, + ) + + def split_setlist_file( + device, + input_path, + output_dir, + *, + selected_ids=None, + original_filenames=None, + log_callback=None, + ): + split_calls.append((device, input_path, output_dir, selected_ids, original_filenames)) + output_dir.mkdir(parents=True) + created = [] + for preset_id, filename in original_filenames.items(): + output_path = output_dir / filename + output_path.write_text(f"new {preset_id}", encoding="utf-8") + created.append(output_path) + return multi_hlx_workflow.file_operations.SplitSetlistFileResult(created) + + monkeypatch.setattr( + multi_hlx_workflow.file_operations, + "split_setlist_file", + split_setlist_file, + ) + + assert window.save_active_file() + + assert lead_path.read_text(encoding="utf-8") == "new 1" + assert rhythm_path.read_text(encoding="utf-8") == "new 6" + assert active_setlist.read_text(encoding="utf-8") == "new setlist" + assert not window._preset_table_has_unsaved_changes() + assert split_calls[0][3] == [1, 6] + assert split_calls[0][4] == {1: "001_lead.hlx", 6: "006_rhythm.hlx"} + window.close() + + +def test_multi_hlx_overwrite_prompt_can_apply_to_all(monkeypatch, app, tmp_path) -> None: + window = MainWindow() + first = tmp_path / "first.hlx" + second = tmp_path / "second.hlx" + first.touch() + second.touch() + prompts = [] + + class FakeMessageBox: + StandardButton = QMessageBox.StandardButton + + def __init__(self, parent): + self.parent = parent + self.checkbox = None + self.overwrite_button = object() + prompts.append(self) + + def setWindowTitle(self, title): + self.title = title + + def setText(self, text): + self.text = text + + def addButton(self, button): + if button == QMessageBox.StandardButton.Yes: + return self.overwrite_button + return object() + + def setDefaultButton(self, button): + self.default_button = button + + def setCheckBox(self, checkbox): + self.checkbox = checkbox + checkbox.setChecked(True) + + def exec(self): + return None + + def clickedButton(self): + return self.overwrite_button + + monkeypatch.setattr(multi_hlx_workflow, "QMessageBox", FakeMessageBox) + + assert multi_hlx_workflow.confirm_multi_hlx_overwrites(window, [first, second]) + + assert len(prompts) == 1 + assert prompts[0].checkbox is not None + assert prompts[0].checkbox.text() == "I do not want to be asked again, overwrite them all" + window.close() + + def test_save_measurement_dialog_uses_loaded_suffix_and_save_label( tmp_path, monkeypatch, app ) -> None: @@ -865,7 +1095,7 @@ def exec(self): def selectedFiles(self): return [str(output_path)] - monkeypatch.setattr(main_window, "QFileDialog", FileDialog) + monkeypatch.setattr(save_dialogs, "QFileDialog", FileDialog) window.input_path.setText(str(input_path)) assert window._choose_measurement_save_path() == output_path @@ -946,7 +1176,7 @@ def exec(self): def selectedFiles(self): return [str(output_path)] - monkeypatch.setattr(main_window, "QFileDialog", FileDialog) + monkeypatch.setattr(save_dialogs, "QFileDialog", FileDialog) monkeypatch.setattr(window, "show_error", errors.append) window.input_path.setText(str(input_path)) @@ -1134,7 +1364,7 @@ def exec(): def selectedFiles(): return [str(Path("/tmp/output.hls"))] - monkeypatch.setattr(main_window, "QFileDialog", FileDialog) + monkeypatch.setattr(save_dialogs, "QFileDialog", FileDialog) window.browse_output() From e2c7bb94be831316083c8030a1fe51ad1db04cff Mon Sep 17 00:00:00 2001 From: Noseglasses Date: Thu, 18 Jun 2026 16:29:03 +0200 Subject: [PATCH 06/10] feat: Rework dialogs --- src/matchpatch/gui/multi_hlx_workflow.py | 289 +++++++++++++++++++++++ src/matchpatch/gui/save_dialogs.py | 69 ++++++ 2 files changed, 358 insertions(+) create mode 100644 src/matchpatch/gui/multi_hlx_workflow.py create mode 100644 src/matchpatch/gui/save_dialogs.py diff --git a/src/matchpatch/gui/multi_hlx_workflow.py b/src/matchpatch/gui/multi_hlx_workflow.py new file mode 100644 index 0000000..90d962e --- /dev/null +++ b/src/matchpatch/gui/multi_hlx_workflow.py @@ -0,0 +1,289 @@ +"""Open and save workflows for multiple Helix preset files.""" + +from __future__ import annotations + +import shutil +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Protocol, cast + +from PySide6.QtWidgets import QCheckBox, QMessageBox, QTableWidgetItem, QWidget + +from matchpatch import file_operations +from matchpatch.devices import get_device_profile +from matchpatch.gui import file_operations_workflow +from matchpatch.gui.advanced_settings import GuiSettingsBinder +from matchpatch.gui.main_window_callbacks import MainWindowSaveCallbacks +from matchpatch.gui.save_workflow import SaveContext, SaveWorkflow +from matchpatch.gui.window_state import RECENT_FILES_SETTINGS_KEY +from matchpatch.workflow import NormalizationRequest, NormalizationResult + +if TYPE_CHECKING: + from matchpatch.gui.main_window import MainWindow + + +class _CurrentDeviceWidget(Protocol): + def currentData(self) -> str: ... + + +class _InputPathWidget(Protocol): + def text(self) -> str: ... + + +class _PresetTableWidget(Protocol): + def item(self, row: int, column: int) -> QTableWidgetItem | None: ... + + +class _SettingsStore(Protocol): + def setValue(self, key: str, value: object) -> None: ... + + +class MultiHlxWindow(Protocol): + device: _CurrentDeviceWidget + input_path: _InputPathWidget + preset_table: _PresetTableWidget + settings: _SettingsStore + completed_request: NormalizationRequest | None + completed_result: NormalizationResult | None + _staged_joined_setlist_path: Path | None + _multi_hlx_output_paths_by_id: dict[int, Path] + _multi_hlx_input_count: int + + def _open_input_path(self, path: str) -> None: ... + def _preset_table_has_unsaved_changes(self) -> bool: ... + def _prompt_save_or_discard_preset_table_changes(self, action: str) -> str | bool: ... + def _discard_preset_table_changes(self) -> None: ... + def _log(self, message: str, level: str) -> None: ... + def _mark_multi_hlx_setlist_staged(self, path: Path, preset_paths: list[Path]) -> None: ... + def _recent_file_paths(self) -> list[str]: ... + def _refresh_recent_files_selector(self) -> None: ... + def setWindowTitle(self, title: str) -> None: ... + def _mark_preset_table_modified(self) -> None: ... + def _refresh_file_actions(self) -> None: ... + def _set_phase(self, phase: str) -> None: ... + def _reset_preset_table_modified(self) -> None: ... + def _save_workflow(self) -> SaveWorkflow: ... + def show_error(self, message: str) -> None: ... + + +def open_input_paths(window: MultiHlxWindow, paths: list[str]) -> None: + selected_paths = [Path(path) for path in paths if path] + if not selected_paths: + return + if len(selected_paths) == 1: + window._open_input_path(str(selected_paths[0])) + return + + suffixes = {path.suffix.lower() for path in selected_paths} + if suffixes != {".hlx"}: + window.show_error( + "Select either one .hls setlist, one .hlx preset, or multiple .hlx presets. " + "Do not mix .hls and .hlx files." + ) + return + open_multiple_hlx_paths(window, selected_paths) + + +def open_multiple_hlx_paths(window: MultiHlxWindow, paths: list[Path]) -> None: + if window._preset_table_has_unsaved_changes(): + prompt_result = window._prompt_save_or_discard_preset_table_changes( + "opening another preset or setlist file" + ) + if not prompt_result: + return + if prompt_result == "discard": + window._discard_preset_table_changes() + + file_types = file_operations_workflow.current_file_types( + cast(file_operations_workflow.FileOperationWindow, window), + get_profile=get_device_profile, + project_dir=_project_dir(), + ) + output_path = file_operations_workflow.temporary_join_output_path(file_types) + try: + result = file_operations.join_preset_files( + window.device.currentData(), + paths, + output_path, + log_callback=lambda message: window._log(message, "info"), + ) + except Exception as exc: # noqa: BLE001 + output_path.unlink(missing_ok=True) + window.show_error(str(exc)) + return + + window._open_input_path(str(result.output_path)) + mark_multi_hlx_setlist_staged(window, result.output_path, paths) + + +def mark_joined_setlist_staged(window: MultiHlxWindow, path: Path) -> None: + window._staged_joined_setlist_path = path + window._multi_hlx_output_paths_by_id = {} + window._multi_hlx_input_count = 0 + _remove_recent_file(window, path) + window.setWindowTitle("Joined setlist (unsaved)") + window._mark_preset_table_modified() + window._refresh_file_actions() + + +def mark_multi_hlx_setlist_staged( + window: MultiHlxWindow, + path: Path, + preset_paths: list[Path], +) -> None: + window._staged_joined_setlist_path = None + window._multi_hlx_output_paths_by_id = multi_hlx_path_map(window, preset_paths) + window._multi_hlx_input_count = len(preset_paths) + _remove_recent_file(window, path) + window.setWindowTitle(f"{len(preset_paths)} presets (multiple .hlx files)") + window._refresh_file_actions() + + +def multi_hlx_path_map(window: MultiHlxWindow, preset_paths: list[Path]) -> dict[int, Path]: + try: + profile = get_device_profile(window.device.currentData()) + handler = profile.create_patch_file_handler(_project_dir()) + except Exception: # noqa: BLE001 + return {} + + output_paths_by_id: dict[int, Path] = {} + for row, preset_path in enumerate(preset_paths): + patch_item = window.preset_table.item(row, 1) + patch = patch_item.text().strip() if patch_item is not None else "" + try: + preset_ids = handler.parse_patch_set(patch) + except ValueError: + continue + if len(preset_ids) == 1: + output_paths_by_id[preset_ids[0]] = preset_path + return output_paths_by_id + + +def save_multi_hlx_files(window: MultiHlxWindow) -> bool: + if not window._preset_table_has_unsaved_changes(): + return True + if not window._multi_hlx_output_paths_by_id: + window.show_error("Open multiple .hlx preset files before saving them together") + return False + if len(window._multi_hlx_output_paths_by_id) != window._multi_hlx_input_count: + window.show_error( + "Could not map every open .hlx preset back to its original file. " + "Use Save As to write a setlist instead." + ) + return False + output_paths = list(window._multi_hlx_output_paths_by_id.values()) + if not confirm_multi_hlx_overwrites(window, output_paths): + return False + + active_path = Path(window.input_path.text()) + with tempfile.TemporaryDirectory(prefix="matchpatch_multi_hlx_") as temporary_directory: + temporary_dir = Path(temporary_directory) + materialized_setlist = temporary_dir / "materialized.hls" + if not save_table_to_temporary_setlist(window, materialized_setlist): + return False + if not _split_and_copy_presets(window, materialized_setlist, temporary_dir, active_path): + return False + + window._set_phase("completed") + window._reset_preset_table_modified() + window._refresh_file_actions() + return True + + +def save_table_to_temporary_setlist(window: MultiHlxWindow, output_path: Path) -> bool: + request = window.completed_request + result = window.completed_result + if request is None: + try: + request = GuiSettingsBinder.from_widgets(window).normalization_request() + except Exception as exc: # noqa: BLE001 + window.show_error(str(exc)) + return False + + try: + window._save_workflow().save_adjusted_file( + SaveContext( + input_path=Path(window.input_path.text()), + output_path=output_path, + completed_request=request, + completed_result=result, + table_has_unsaved_changes=True, + make_active=False, + ), + MainWindowSaveCallbacks( + cast("MainWindow", window), + confirm_overwrite=lambda output_path: True, + ), + ) + except Exception as exc: # noqa: BLE001 + window.show_error(str(exc)) + return False + return True + + +def confirm_multi_hlx_overwrites(window: MultiHlxWindow, output_paths: list[Path]) -> bool: + overwrite_all = False + existing_paths = [path for path in output_paths if path.exists()] + for index, output_path in enumerate(existing_paths): + if overwrite_all: + continue + dialog = QMessageBox(cast(QWidget, window)) + dialog.setWindowTitle("Overwrite preset file") + dialog.setText(f"The file already exists:\n{output_path}\n\nOverwrite it?") + overwrite_button = dialog.addButton( + QMessageBox.StandardButton.Yes, + ) + dialog.addButton(QMessageBox.StandardButton.Cancel) + dialog.setDefaultButton(overwrite_button) + checkbox: QCheckBox | None = None + if len(existing_paths) > 1 and index == 0: + checkbox = QCheckBox("I do not want to be asked again, overwrite them all") + dialog.setCheckBox(checkbox) + dialog.exec() + if dialog.clickedButton() is not overwrite_button: + return False + overwrite_all = checkbox is not None and checkbox.isChecked() + return True + + +def _split_and_copy_presets( + window: MultiHlxWindow, + materialized_setlist: Path, + temporary_dir: Path, + active_path: Path, +) -> bool: + temporary_names = { + preset_id: f"{preset_id:03d}_{output_path.name}" + for preset_id, output_path in window._multi_hlx_output_paths_by_id.items() + } + try: + result = file_operations.split_setlist_file( + window.device.currentData(), + materialized_setlist, + temporary_dir / "presets", + selected_ids=list(window._multi_hlx_output_paths_by_id), + original_filenames=temporary_names, + log_callback=lambda message: window._log(message, "info"), + ) + created_by_name = {path.name: path for path in result.created_paths} + for preset_id, output_path in window._multi_hlx_output_paths_by_id.items(): + created_path = created_by_name.get(temporary_names[preset_id]) + if created_path is None: + raise ValueError(f"Could not create updated preset file for {output_path}") + shutil.copy2(created_path, output_path) + window._log(f"Saved preset file: {output_path.resolve()}", "success") + shutil.copy2(materialized_setlist, active_path) + except Exception as exc: # noqa: BLE001 + window.show_error(str(exc)) + return False + return True + + +def _remove_recent_file(window: MultiHlxWindow, path: Path) -> None: + recent = [item for item in window._recent_file_paths() if item != str(path)] + window.settings.setValue(RECENT_FILES_SETTINGS_KEY, recent) + window._refresh_recent_files_selector() + + +def _project_dir() -> Path: + return Path(__file__).resolve().parents[3] diff --git a/src/matchpatch/gui/save_dialogs.py b/src/matchpatch/gui/save_dialogs.py new file mode 100644 index 0000000..093b31d --- /dev/null +++ b/src/matchpatch/gui/save_dialogs.py @@ -0,0 +1,69 @@ +"""File save path dialogs used by the main window.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Callable + +from PySide6.QtWidgets import QFileDialog, QWidget + +from matchpatch.gui import file_type_filters + + +def choose_save_as_path( + parent: QWidget, + *, + input_path_text: str, + device_name: str, + show_error: Callable[[str], None], + accept_label: str = "Save as", +) -> Path | None: + suffix = Path(input_path_text).suffix.lower() + file_filter = file_type_filters.helix_save_file_filter(device_name, suffix) + if file_filter is None: + show_error("Open a Helix .hls or .hlx file before saving") + return None + dialog = QFileDialog(parent, "Save Helix file as") + dialog.setOption(QFileDialog.Option.DontUseNativeDialog) + dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptOpen) + dialog.setFileMode(QFileDialog.FileMode.AnyFile) + dialog.setNameFilter(file_filter) + dialog.setLabelText(QFileDialog.DialogLabel.Accept, accept_label) + path = dialog.selectedFiles()[0] if dialog.exec() and dialog.selectedFiles() else "" + if not path: + return None + save_path = Path(path) + if save_path.suffix.lower() != suffix: + show_error(f"Saved file must use the {suffix} extension") + return None + return save_path + + +def choose_measurement_save_path( + parent: QWidget, + *, + input_path: Path, + device_name: str, + show_error: Callable[[str], None], +) -> Path | None: + suffix = input_path.suffix.lower() + file_filter = file_type_filters.helix_save_file_filter(device_name, suffix) + if file_filter is None: + show_error("Open a Helix .hls or .hlx file before saving a measurement file") + return None + suggested_path = input_path.with_name(input_path.stem + "_measurement" + suffix) + dialog = QFileDialog(parent, "Save measurement file") + dialog.setOption(QFileDialog.Option.DontUseNativeDialog) + dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave) + dialog.setFileMode(QFileDialog.FileMode.AnyFile) + dialog.setNameFilter(file_filter) + dialog.selectFile(str(suggested_path)) + dialog.setLabelText(QFileDialog.DialogLabel.Accept, "Save") + path = dialog.selectedFiles()[0] if dialog.exec() and dialog.selectedFiles() else "" + if not path: + return None + save_path = Path(path) + if save_path.suffix.lower() != suffix: + show_error(f"Measurement file must use the {suffix} extension") + return None + return save_path From ea69b8976d8c1c82bee661682d2ee72ea1a089b9 Mon Sep 17 00:00:00 2001 From: Noseglasses Date: Thu, 18 Jun 2026 16:36:46 +0200 Subject: [PATCH 07/10] feat: Make the Helix device implementation respect the max number of snapshot assigned parameters per preset --- docs/troubleshooting.md | 19 +++ .../devices/helix_preset_handling.py | 21 ++- tests/test_preset_handling.py | 144 ++++++++++++++++++ 3 files changed, 178 insertions(+), 6 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 37785e4..01b7cb4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -388,6 +388,25 @@ The measurement was probably invalid, often because silence was recorded. 4. Rerun the preset. 5. If the preset is intentionally strange, use manual editing carefully. +## Too Many Snapshot Assignments + +### What You See + +MatchPatch says the Helix snapshot-assigned property limit would be exceeded. + +### Likely Cause + +The preset already uses close to Helix's limit of 64 snapshot-assigned +properties. MatchPatch needs to assign the output block level to snapshots before +it can balance snapshot loudness. + +### What To Try + +1. Open the preset in HX Edit or on the Helix. +2. Remove unused snapshot assignments from blocks or parameters. +3. Save the preset or setlist. +4. Run MatchPatch again. + ## Fast Timing Feels Unstable ### What You See diff --git a/src/matchpatch/devices/helix_preset_handling.py b/src/matchpatch/devices/helix_preset_handling.py index 63ef92b..ba4b4d0 100644 --- a/src/matchpatch/devices/helix_preset_handling.py +++ b/src/matchpatch/devices/helix_preset_handling.py @@ -350,7 +350,10 @@ def _iter_controller_blocks(tone, controller_root): def _is_snapshot_parameter_assignment(assignment, parameter, block): return ( - isinstance(assignment, dict) and assignment.get("@controller") == 19 and parameter in block + isinstance(assignment, dict) + and assignment.get("@controller") == 19 + and assignment.get("@snapshot_disable") is not True + and parameter in block ) @@ -588,6 +591,13 @@ def count_controller_assignments(preset): return count +def count_snapshot_assigned_properties(preset): + tone = preset.get("tone", {}) + if not isinstance(tone, dict): + return 0 + return sum(1 for _ in iter_snapshot_assigned_parameters(tone)) + + def iter_output_blocks(preset): tone = preset.get("tone", {}) @@ -732,7 +742,7 @@ def validate_controller_assignment_capacity(data, gain_deltas=None): if gain_deltas is not None and helix_preset not in gain_deltas: continue - current_count = count_controller_assignments(preset) + current_count = count_snapshot_assigned_properties(preset) missing = get_missing_output_gain_assignments(preset) @@ -749,15 +759,15 @@ def validate_controller_assignment_capacity(data, gain_deltas=None): raise ValueError( "Cannot assign output gain/level to snapshots: " - "the Helix controller assignment limit would be " + "the Helix snapshot-assigned property limit would be " f"exceeded for preset {preset_index + 1} " f'({helix_preset}, "{preset_name}"). ' - f"Current controller assignments: {current_count}. " + f"Current snapshot-assigned properties: {current_count}. " f"Required additional assignments: {len(missing)} " f"({missing_text}). " f"Limit: {CONTROLLER_ASSIGNMENT_LIMIT}. " "Please edit this preset manually in HX Edit/Helix " - "and remove unused controller/snapshot assignments " + "and remove unused snapshot assignments " "before running this conversion." ) @@ -984,7 +994,6 @@ def normalize_single_preset_gain_deltas(gain_deltas): def preset_index_to_helix(index): - bank = (index // 4) + 1 slot = ["A", "B", "C", "D"][index % 4] diff --git a/tests/test_preset_handling.py b/tests/test_preset_handling.py index c2598dc..3fc89a4 100644 --- a/tests/test_preset_handling.py +++ b/tests/test_preset_handling.py @@ -2,8 +2,10 @@ import base64 import binascii +import copy import importlib import json +import sys import zlib from types import ModuleType @@ -28,6 +30,40 @@ def _preset(name: str) -> dict: } +def _preset_with_snapshot_assigned_properties( + name: str, + count: int, + *, + output_gain_assigned: bool = False, +) -> dict: + preset = _preset(name) + tone = preset["tone"] + block = tone["dsp0"]["block0"] + block_controller = ( + tone.setdefault("controller", {}).setdefault("dsp0", {}).setdefault("block0", {}) + ) + + for index in range(count): + parameter = f"param{index}" + block[parameter] = index + block_controller[parameter] = { + "@controller": 19, + "@snapshot_disable": False, + } + + if output_gain_assigned: + tone["controller"]["dsp0"]["outputA"] = { + "gain": { + "@controller": 19, + "@max": 20.0, + "@min": -120.0, + "@snapshot_disable": False, + } + } + + return preset + + def _hls_text(data: dict) -> str: raw = json.dumps(data, indent=1).encode("utf-8") wrapper = { @@ -171,6 +207,114 @@ def test_snapshot_level_assignment_includes_parallel_outputs() -> None: assert tone["snapshot1"]["controllers"]["dsp0"]["outputB"]["gain"]["@value"] == -3.0 +def test_snapshot_level_assignment_allows_helix_limit_boundary() -> None: + module = _load_legacy_module() + data = {"presets": [_preset_with_snapshot_assigned_properties("Boundary", 63)]} + + modified_json_text, snapshot_changes, gain_changes = module.process_json_structure( + json.dumps(data), + assign_output_gain=True, + ) + modified = json.loads(modified_json_text) + preset = modified["presets"][0] + + assert snapshot_changes == 1 + assert gain_changes == 0 + assert module.count_snapshot_assigned_properties(preset) == 64 + assert preset["tone"]["controller"]["dsp0"]["outputA"]["gain"]["@controller"] == 19 + + +def test_snapshot_level_assignment_rejects_exceeding_helix_limit_without_mutation() -> None: + module = _load_legacy_module() + data = {"presets": [_preset_with_snapshot_assigned_properties("Full", 64)]} + original = copy.deepcopy(data) + + with pytest.raises(ValueError, match="snapshot-assigned property limit"): + module.process_json_structure(json.dumps(data), assign_output_gain=True) + + assert data == original + + +def test_snapshot_level_assignment_does_not_double_count_existing_output_gain() -> None: + module = _load_legacy_module() + data = { + "presets": [ + _preset_with_snapshot_assigned_properties( + "Already Assigned", + 63, + output_gain_assigned=True, + ) + ] + } + + modified_json_text, snapshot_changes, gain_changes = module.process_json_structure( + json.dumps(data), + assign_output_gain=True, + ) + preset = json.loads(modified_json_text)["presets"][0] + + assert snapshot_changes == 0 + assert gain_changes == 0 + assert module.count_snapshot_assigned_properties(preset) == 64 + assert preset["tone"]["snapshot0"]["controllers"]["dsp0"]["outputA"]["gain"]["@value"] == 0.0 + + +def test_snapshot_level_assignment_rejects_setlist_before_partial_mutation() -> None: + module = _load_legacy_module() + data = { + "presets": [ + _preset_with_snapshot_assigned_properties("Would Mutate", 0), + _preset_with_snapshot_assigned_properties("Too Full", 64), + ] + } + original = copy.deepcopy(data) + + with pytest.raises(ValueError, match=r'01B, "Too Full"'): + module.process_json_structure(json.dumps(data), assign_output_gain=True) + + assert data == original + + +def test_snapshot_assignment_limit_error_does_not_write_partial_setlist( + tmp_path, + monkeypatch, + capsys, +) -> None: + module = _load_legacy_module() + input_path = tmp_path / "input.hls" + output_path = tmp_path / "output.hls" + input_path.write_text( + _hls_text( + { + "presets": [ + _preset_with_snapshot_assigned_properties("Would Mutate", 0), + _preset_with_snapshot_assigned_properties("Too Full", 64), + ] + } + ), + encoding="utf-8", + ) + monkeypatch.setattr( + sys, + "argv", + [ + "helix_preset_handling", + "-i", + str(input_path), + "-o", + str(output_path), + "--measurement", + ], + ) + + with pytest.raises(SystemExit) as exc: + module.main() + + assert exc.value.code == 1 + assert "snapshot-assigned property limit" in capsys.readouterr().out + assert not output_path.exists() + + def test_metadata_extraction_keeps_wrapper_and_meta_nodes() -> None: module = _load_legacy_module() data = { From ea16bcd439ee1f5cbc770460fb374268552e0d42 Mon Sep 17 00:00:00 2001 From: Noseglasses Date: Thu, 18 Jun 2026 19:10:42 +0200 Subject: [PATCH 08/10] feat: Added demo device --- docs/adding-devices.md | 299 ++++++++++ docs/dev/architecture.md | 11 +- docs/dev/commands.md | 8 +- docs/dev/file-formats.md | 4 +- docs/device_plugins.md | 144 ----- docs/index.md | 4 +- examples/device_plugin/README.md | 22 - examples/device_plugin/pyproject.toml | 15 - .../src/matchpatch_example_device/__init__.py | 142 ----- installer/pyinstaller/matchpatch-gui.spec | 2 +- pyproject.toml | 12 +- src/matchpatch/devices/__init__.py | 4 +- src/matchpatch/devices/available.py | 21 + src/matchpatch/devices/base.py | 11 +- src/matchpatch/devices/demo/__init__.py | 513 ++++++++++++++++++ .../devices/{helix.py => helix/__init__.py} | 10 +- .../{helix_file_ops.py => helix/file_ops.py} | 0 .../preset_handling.py} | 10 +- src/matchpatch/devices/registry.py | 84 +-- src/matchpatch/gui/device_panel_registry.py | 66 --- src/matchpatch/gui/device_panels.py | 5 +- src/matchpatch/gui/normalization_workflow.py | 15 + src/matchpatch/gui/window_loading.py | 20 +- src/matchpatch/measure.py | 2 +- tests/README.md | 2 +- tests/test_devices.py | 175 +++--- tests/test_gui_device_panel_plugins.py | 96 ---- tests/test_gui_settings_renderer.py | 83 ++- tests/test_helix.py | 10 +- tests/test_installer_metadata.py | 2 +- tests/test_preset_handling.py | 2 +- 31 files changed, 1106 insertions(+), 688 deletions(-) create mode 100644 docs/adding-devices.md delete mode 100644 docs/device_plugins.md delete mode 100644 examples/device_plugin/README.md delete mode 100644 examples/device_plugin/pyproject.toml delete mode 100644 examples/device_plugin/src/matchpatch_example_device/__init__.py create mode 100644 src/matchpatch/devices/available.py create mode 100644 src/matchpatch/devices/demo/__init__.py rename src/matchpatch/devices/{helix.py => helix/__init__.py} (99%) rename src/matchpatch/devices/{helix_file_ops.py => helix/file_ops.py} (100%) rename src/matchpatch/devices/{helix_preset_handling.py => helix/preset_handling.py} (99%) delete mode 100644 src/matchpatch/gui/device_panel_registry.py delete mode 100644 tests/test_gui_device_panel_plugins.py diff --git a/docs/adding-devices.md b/docs/adding-devices.md new file mode 100644 index 0000000..94e736f --- /dev/null +++ b/docs/adding-devices.md @@ -0,0 +1,299 @@ +# Adding Devices + +MatchPatch keeps device support in the source tree. Adding a device should be a +small, explicit change: + +1. Add a new package under `src/matchpatch/devices/`. +2. Implement a `DeviceProfile`, `PatchFileHandler`, and, when needed, a + `DeviceController`. +3. Add one instance of the profile to `DEVICE_PROFILES` in + `src/matchpatch/devices/available.py`. +4. Add focused tests for the profile, file handler, and any GUI behavior. + +The in-tree demo device in `src/matchpatch/devices/demo/` is the reference +implementation for developers. It is deterministic, offline, heavily commented, +and intentionally simple. Its `.demobank` JSON format is fake example data, not +a recommendation for real processor file formats. + +## Directory Layout + +Each device gets its own sibling directory: + +```text +src/matchpatch/devices/ + available.py + base.py + demo/ + __init__.py + helix/ + __init__.py + file_ops.py + preset_handling.py +``` + +For a new device named `my_device`, create: + +```text +src/matchpatch/devices/my_device/ + __init__.py +``` + +Large devices can split helpers into additional files inside that directory. +Keep device-specific parsing, subprocess adapters, SDK wrappers, and steering +code inside the device package. + +## Registration + +Register built-in devices in exactly one place: + +```python +# src/matchpatch/devices/available.py +from matchpatch.devices.my_device import MyDeviceProfile + +DEVICE_PROFILES = ( + HelixDeviceProfile(), + DemoDeviceProfile(), + MyDeviceProfile(), +) +``` + +The registry validates the list and exposes `get_device_profile(name)` and +`list_device_profiles()`. Device names must be unique, non-empty strings. The +GUI, CLI, config loader, workflows, diagnostics, and file-operation helpers all +use that same registry, so adding the profile instance makes the device +selectable everywhere. + +## DeviceProfile + +`DeviceProfile` describes device-wide behavior. The demo profile shows the +minimum shape: + +```python +class DemoDeviceProfile(DeviceProfile): + name = "demo-device" + display_name = "Demo Device" + snapshot_count = 2 + max_snapshot_count = 8 + + def create_patch_file_handler(self, project_dir: Path) -> PatchFileHandler: + return DemoPatchFileHandler() + + def default_audio_routing(self) -> AudioRouting: + return AudioRouting(None, 48000, (1, 2), (1, 2)) + + def default_steering_options(self) -> SteeringOptions: + return SteeringOptions(None, 0, 0.0, 0.0, 0.0) + + def create_controller(self, options: SteeringOptions) -> DeviceController: + return DemoController() +``` + +Required methods: + +- `create_patch_file_handler(project_dir)` returns a fresh file adapter. +- `default_audio_routing()` returns audio defaults. Channel mappings are + one-based stereo pairs. +- `default_steering_options()` returns target-selection defaults. MIDI channels + are zero-based. +- `create_controller(options)` returns a controller for hardware-style target + and subdivision activation. + +Useful optional overrides: + +- `terminology()` changes words such as device, preset, snapshot, and setlist. +- `file_capabilities()` advertises device-level file operations. +- `measurement_backends()` declares supported measurement modes. +- `audio_transport_factories()` adds custom device audio transports. +- `diagnostics_provider()` adds preflight checks. +- `supports_normalization()` and `normalization_unavailable_message()` let + example, inspection-only, or file-operation-only devices appear in the GUI + without starting normalization. +- `naming_rules()` validates and sanitizes device-owned names. +- `setting_descriptors()` defines GUI-free settings. +- `format_patch_id()` formats numeric preset IDs for status text and CSVs. + +The demo device intentionally returns `False` from `supports_normalization()`. +When selected in the GUI, pressing Normalize shows a short message explaining +that Demo Device is only an example and cannot normalize files. + +## PatchFileHandler + +`PatchFileHandler` owns device files. MatchPatch calls it for path validation, +target discovery, selector parsing, measurement-file creation, adjustment +writes, and file-operation workflows. + +Required methods: + +- `validate_input(input_path)` checks source files. +- `validate_output(input_path, output_path)` checks destination files. +- `list_assignments(input_path)` returns legacy numeric `PatchAssignment` + values. +- `parse_patch_set(value)` parses numeric preset selectors. +- `select_preset_ids(input_path, assignments, requested_ids)` resolves legacy + numeric preset selections. +- `format_patch_id(preset_id)` formats numeric IDs. +- `create_measurement_file(input_path, output_path)` writes a measurement + variant. +- `apply_analysis_csv(...)` writes normalized output from MatchPatch analysis. +- `automation_output_path(input_path, postfix)` builds device-compatible + sibling output paths. + +The demo handler implements these methods for a small JSON file. Real devices +should use structured parsing for their actual file format and should preserve +unknown or unrelated fields when writing adjusted files. + +## Targets And Gain Points + +Modern devices should implement `list_targets()` in addition to +`list_assignments()`. This avoids forcing every processor into Helix-style +numeric preset IDs. + +`MeasurementTarget` models a measurable top-level item. IDs may be integers or +strings. Targets include a display label, zero-based index, name, optional +source filename, optional numeric compatibility ID, target-level gain points, +and subdivisions. + +`MeasurementSubdivision` models snapshots, scenes, channels, layers, or any +within-target concept. The demo device maps presets to targets and scenes to +subdivisions: + +```python +MeasurementTarget( + id="preset:clean", + display_label="D001", + index=0, + name="Clean", + subdivisions=( + MeasurementSubdivision( + id="scene:intro", + display_label="Intro", + index=0, + name="Intro", + gain_points=(GainPoint(...),), + ), + ), + compat_numeric_id=1, +) +``` + +Use `GainPoint` for adjustable device controls. Set `scope="target"` for +target-level controls and `scope="subdivision"` for per-scene or per-snapshot +controls. Override `apply_gain_adjustments(input_path, output_path, +adjustments)` when the device can apply explicit gain changes. The demo device +supports one subdivision gain point named `main-output` and changes only a +scene's `output_level_db`, leaving unrelated JSON data intact. + +## File Types And Capabilities + +File metadata drives GUI filters, validation, and file-operation commands. + +`file_types()` returns user-facing extensions: + +```python +DeviceFileType( + kind="setlist", + extensions=(".demobank",), + description="Demo Device Banks", +) +``` + +`file_kind(path)` classifies paths as `"preset"`, `"setlist"`, or `"unknown"`. +`file_capabilities()` returns `FileOperationCapabilities`: + +- `reads_preset_files` and `writes_preset_files`; +- `reads_setlist_files` and `writes_setlist_files`; +- `joins_presets_to_setlist`; +- `splits_setlist_to_presets`; +- `replaces_setlist_slots`; +- `exports_selected_setlist_slots`. + +Only advertise operations the handler really implements. Unsupported optional +operations should keep the base behavior, which raises `NotImplementedError`. + +## Settings And GUI + +`DeviceSettingDescriptor` is the device settings API. MatchPatch uses +descriptors for config defaults, CLI flags, validation, diagnostics, and the +generic GUI settings panel. + +Descriptor fields include: + +- `name`, `scope`, and `kind`; +- `default`; +- `config_path`; +- `cli_flags`; +- `label` and `help`; +- `choices`, `minimum`, `maximum`, and `required`. +- `show_in_gui`, which defaults to `True`. + +Supported kinds are `string`, `integer`, `float`, `boolean`, `choice`, `path`, +and `channel_mapping`. The base `validate_settings()` checks required settings, +types, numeric ranges, choices, and one-based channel mappings. Unknown settings +are ignored so config files can carry values for code that has not loaded them. + +The base `DeviceProfile.setting_descriptors()` already exposes common audio and +steering settings. Override it when a device needs different defaults or +device-specific settings. The demo adds a `demo_mode` choice setting, and the +GUI uses the generic `DescriptorSettingsPanel` for it automatically. The demo +also marks `preset_wait`, `snapshot_wait`, and `measurement_wait` with +`show_in_gui=False`: those settings remain available to config, CLI, and +normalization plumbing, but they are hidden from the device panel because the +GUI already exposes them in the timing tab. + +If a device needs a bespoke GUI, add it directly to +`src/matchpatch/gui/device_panels.py` and keep device-specific widgets small. +Prefer descriptors whenever possible. + +## Validation And Diagnostics + +Validation happens in layers: + +- registry validation checks profile names, display names, and handler creation; +- `validate_settings(settings)` validates descriptors; +- `validate_input()` and `validate_output()` reject unsupported paths before + writing; +- `naming_rules()` can enforce device name limits and allowed characters. + +For preflight checks, return a `DiagnosticsProvider` from +`diagnostics_provider()`. MatchPatch passes a `DiagnosticsContext` containing +the request, profile, handler, resolved settings, and project directory. Provider +exceptions are caught and reported as failed diagnostics. + +## Audio Transports + +Most hardware-style devices can use the built-in hardware, loopback, or +simulated backends. Devices that need custom processing can return factories +from `audio_transport_factories()`. + +An `AudioTransportFactory` exposes: + +- `capabilities`, an `AudioTransportCapabilities` object; +- `supports(mode, settings)`; +- `create(context)`. + +Created transports implement `process(reference_audio)` for real-time style +measurement or `process_offline(request)` for offline rendering. If your device +expects a custom mode such as `offline`, include that mode in +`measurement_backends()`. + +## Testing A Device + +Keep new device tests deterministic and offline unless hardware coverage is +explicitly required. Follow `tests/test_devices.py` and +`tests/test_gui_settings_renderer.py`: + +- assert the profile is listed by the static registry; +- write a small fixture in a temporary directory; +- verify `list_targets()`, `list_gain_points()`, and selectors; +- verify adjustment writes mutate only the intended fields; +- verify unsupported capabilities fail clearly; +- verify GUI selection when the device should appear in the main window; +- add focused parser tests for any real file format helpers. + +Useful checks while developing device-facing changes: + +```bash +UV_CACHE_DIR=/tmp/matchpatch-uv-cache UV_PROJECT_ENVIRONMENT="$HOME/.local/share/matchpatch/.venv-wsl" uv run --frozen --no-default-groups --group wsl pytest tests/test_devices.py tests/test_gui_settings_renderer.py +UV_CACHE_DIR=/tmp/matchpatch-uv-cache UV_PROJECT_ENVIRONMENT="$HOME/.local/share/matchpatch/.venv-wsl" uv run --frozen --no-default-groups --group wsl ruff check src/matchpatch/devices tests/test_devices.py tests/test_gui_settings_renderer.py +UV_PROJECT_ENVIRONMENT="$HOME/.local/share/matchpatch/.venv-wsl" uv run --frozen --no-default-groups --group docs sphinx-build -W --keep-going -b html docs docs_html +``` diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 0606277..b3892d4 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -147,8 +147,11 @@ with configured USB mappings, and trims pre-roll/post-roll using automation output paths. - `DeviceController` activates presets and reapplies snapshots. -The registry in `matchpatch.devices.registry` currently registers only -`helix`. +The registry in `matchpatch.devices.registry` reads the explicit +`DEVICE_PROFILES` list in `matchpatch.devices.available`. To add a device, +create a sibling package under `matchpatch.devices` and add one profile +instance to that list. The demo device in `matchpatch.devices.demo` is the +reference implementation for this flow. ## Helix Profile @@ -168,7 +171,7 @@ The registry in `matchpatch.devices.registry` currently registers only changes, where internal preset ID `1` maps to program `0`. Snapshots use CC 69 with values `0..7`. -`HelixPatchFileHandler` runs `matchpatch.devices.helix_preset_handling` with the +`HelixPatchFileHandler` runs `matchpatch.devices.helix.preset_handling` with the current Python interpreter in development, and in-process in frozen builds. It delegates: @@ -184,7 +187,7 @@ or replaces `HelixPreset`. ## Helix File Processing -`matchpatch.devices.helix_preset_handling` understands `.hls`, `.hlx`, and unpacked `.json`. +`matchpatch.devices.helix.preset_handling` understands `.hls`, `.hlx`, and unpacked `.json`. Setlists are stored as JSON wrappers whose `encoded_data` contains base64 zlib data; the script preserves wrapper fields while replacing encoded data, size, and CRC. Presets are JSON files and may contain either a top-level preset or a diff --git a/docs/dev/commands.md b/docs/dev/commands.md index 4373408..eef9f93 100644 --- a/docs/dev/commands.md +++ b/docs/dev/commands.md @@ -203,15 +203,15 @@ and visible audio/MIDI endpoints. Run from the repository root: ```bash -python3 -m matchpatch.devices.helix_preset_handling --help +python3 -m matchpatch.devices.helix.preset_handling --help ``` Useful integrated operations: ```bash -python3 -m matchpatch.devices.helix_preset_handling -i setlist.hls -o setlist_measurement.hls --measurement -python3 -m matchpatch.devices.helix_preset_handling -i setlist.hls --list-presets -python3 -m matchpatch.devices.helix_preset_handling -i current.hls --diff-presets previous.hls +python3 -m matchpatch.devices.helix.preset_handling -i setlist.hls -o setlist_measurement.hls --measurement +python3 -m matchpatch.devices.helix.preset_handling -i setlist.hls --list-presets +python3 -m matchpatch.devices.helix.preset_handling -i current.hls --diff-presets previous.hls ``` ## Build diff --git a/docs/dev/file-formats.md b/docs/dev/file-formats.md index bf79731..124d241 100644 --- a/docs/dev/file-formats.md +++ b/docs/dev/file-formats.md @@ -13,7 +13,7 @@ The wrapper includes: - `encoded_data`: base64-encoded zlib-compressed JSON text. - compression metadata such as `decompressed_size` and `crc32`. -`matchpatch.devices.helix_preset_handling` decodes `encoded_data`, edits the +`matchpatch.devices.helix.preset_handling` decodes `encoded_data`, edits the decompressed JSON, then rebuilds the wrapper by replacing `encoded_data`, `decompressed_size`, and `crc32`. Other wrapper fields are preserved. @@ -134,7 +134,7 @@ CSV files are written with UTF-8 and read with UTF-8-SIG so a BOM is tolerated. ## Measurement CSV: Helix Legacy Adapter -`matchpatch.devices.helix_preset_handling` expects a `HelixPreset` column +`matchpatch.devices.helix.preset_handling` expects a `HelixPreset` column instead of `DevicePatch`. `HelixPatchFileHandler.apply_analysis_csv` therefore writes a temporary adapter CSV before invoking the packaged Helix utility module. diff --git a/docs/device_plugins.md b/docs/device_plugins.md deleted file mode 100644 index 09fab6f..0000000 --- a/docs/device_plugins.md +++ /dev/null @@ -1,144 +0,0 @@ -# Device Plugins - -MatchPatch discovers third-party device profiles with the -`matchpatch.devices` Python entry point group. The built-in Helix profile is -registered directly in `matchpatch.devices.registry`; plugins use packaging -metadata instead. - -```toml -[project.entry-points."matchpatch.devices"] -my-device = "my_package.matchpatch_plugin:MyDeviceProfile" -``` - -The loaded object may be a `DeviceProfile` instance, a `DeviceProfile` subclass, -or an iterable of `DeviceProfile` instances. Every profile must expose a unique -non-empty `name`, a non-empty `display_name`, and -`create_patch_file_handler(project_dir)`. Duplicate names and import errors are -recorded by the registry and reported by `plugin_load_errors()` or when an -unknown device is requested. - -## Profile Contract - -Device plugins implement `matchpatch.devices.base.DeviceProfile`. The required -methods are: - -- `create_patch_file_handler(project_dir)`: returns the file adapter for the - device. -- `default_audio_routing()`: returns an `AudioRouting` default for device name, - sample rate, and one-based stereo input/output channels. -- `default_steering_options()`: returns `SteeringOptions` for MIDI or other - target selection timing defaults. -- `create_controller(options)`: returns a `DeviceController` that can activate - numeric presets and one-based subdivisions for hardware-style measurement. - -Profiles may also override `terminology()`, `file_capabilities()`, -`measurement_backends()`, `audio_transport_factories()`, -`diagnostics_provider()`, `naming_rules()`, `setting_descriptors()`, and -`format_patch_id()`. - -## Settings Descriptors - -`DeviceSettingDescriptor` is the GUI-free settings surface. MatchPatch uses it -to resolve defaults, config paths, CLI flags, validation, diagnostics, and the -generic GUI settings panel. - -The default `DeviceProfile.setting_descriptors()` returns descriptors for: - -- `audio_device`, `sample_rate`, `input_mapping`, `output_mapping`, and - `blocksize` under the `audio` scope. -- `midi_output`, `midi_channel`, `preset_wait`, `snapshot_wait`, and - `measurement_wait` under the `steering` scope. - -Descriptors support `string`, `integer`, `float`, `boolean`, `choice`, `path`, -and `channel_mapping` kinds. Numeric ranges, choices, `required`, labels, help -text, config paths, and CLI flags are enforced or rendered by the existing -settings code. Unknown settings are currently ignored by -`DeviceProfile.validate_settings()`. - -## Diagnostics Providers - -A profile can return a `DiagnosticsProvider` from `diagnostics_provider()`. -During preflight, MatchPatch builds a `DiagnosticsContext` containing the -normalization request, profile, file handler, resolved device settings, and -project directory, then calls `run_checks(context)`. - -The provider returns `DiagnosticCheck` objects from `matchpatch.diagnostics`. -Provider exceptions are caught and turned into a failed `device_diagnostics` -check, so a broken plugin does not stop the rest of preflight. - -## Audio Transports - -Profiles can provide custom audio backends with `audio_transport_factories()`. -Each `AudioTransportFactory` has `capabilities`, `supports(mode, settings)`, -and `create(context)`. MatchPatch checks plugin factories before its built-in -hardware, loopback, and simulated factories. - -`AudioTransportCapabilities` declares the backend mode and behavior such as -sample rates, channel layout, real-time/offline operation, async operation, -alignment guarantees, and debug-output support. The supported backend names -advertised by `measurement_backends()` must include any plugin-only mode the -profile expects to use, such as `offline`. - -The created transport implements either `process(reference_audio)` for -real-time style processing or `process_offline(request)` for offline rendering. - -## Target Model - -Patch handlers expose measurable content as `MeasurementTarget` objects. A -target has an `id`, display label, zero-based `index`, name, optional source -filename, optional `compat_numeric_id`, target-level gain points, and -subdivisions. - -Subdivisions are `MeasurementSubdivision` objects. The built-in Helix profile -uses snapshots as subdivisions, but plugins can use string IDs and labels for -other device concepts. Legacy numeric preset/snapshot helpers are still present: -if a handler only implements `list_assignments()`, MatchPatch adapts -`PatchAssignment` values into measurement targets and subdivisions. - -## File Handlers And Capabilities - -`PatchFileHandler` is responsible for device-owned files. Required methods -validate input/output paths, list assignments, parse numeric preset selectors, -select presets, format numeric IDs, create measurement files, apply analysis -CSVs, and build automation output paths. - -Optional methods describe richer devices: - -- `file_types()` and `file_kind()` advertise user-facing extensions. -- `file_capabilities()` returns `FileOperationCapabilities`, which gates GUI and - command support for reading/writing preset files, reading/writing setlist - files, joining presets into setlists, splitting setlists, replacing setlist - slots, and exporting selected slots. -- `list_targets()`, `parse_target_set()`, `select_targets()`, - `diff_targets()`, and `diff_subdivisions()` support non-Helix target IDs. -- `list_gain_points()` and `apply_gain_adjustments()` support target-level or - subdivision-level gain points. - -The default capabilities are all false, and unsupported optional operations -raise `NotImplementedError`. - -## Optional GUI Panels - -GUI-only plugins can register a settings panel factory with the -`matchpatch.device_gui_panels` entry point group: - -```toml -[project.entry-points."matchpatch.device_gui_panels"] -my-device-panel = "my_package.matchpatch_gui:MyPanelFactory" -``` - -The loaded object may be a factory object or a callable returning one. It must -define `device_name` and `create_panel(profile, backend_selector)`. If -`device_name` matches the active profile name, the returned `QWidget` replaces -the built-in or descriptor-rendered settings panel. GUI panel load errors are -logged and recorded by `matchpatch.gui.device_panel_registry.plugin_load_errors()`. - -Plugins that do not need custom Qt controls should prefer settings descriptors; -the generic renderer handles the current descriptor kinds. - -## Minimal Example - -A small read-only example plugin lives in `examples/device_plugin/`. It shows -the package metadata entry point and the minimum profile/file-handler classes -needed for discovery. It is intentionally not a complete processor integration: -the file handler lists no targets and raises for measurement and save operations. diff --git a/docs/index.md b/docs/index.md index 9f10bad..75b0b8b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -67,7 +67,7 @@ Normal users should not need this section. - [Developer Notes](developer-notes.md) - [Existing technical docs](dev/architecture.md) - [Development Commands](dev/commands.md) -- [Device Plugins](device_plugins.md) +- [Adding Devices](adding-devices.md) - [Release Checklist](dev/release.md) ```{toctree} @@ -108,7 +108,7 @@ glossary developer-notes dev/architecture dev/commands -device_plugins +adding-devices dev/file-formats dev/release ``` diff --git a/examples/device_plugin/README.md b/examples/device_plugin/README.md deleted file mode 100644 index 4c9a524..0000000 --- a/examples/device_plugin/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# MatchPatch Example Device Plugin - -This package is a minimal, read-only example of the -`matchpatch.devices` entry point. It is useful as a starting point for plugin -discovery and settings experiments, not as a complete device integration. - -Install it into the same environment as MatchPatch while developing: - -```bash -pip install -e examples/device_plugin -``` - -The important metadata is: - -```toml -[project.entry-points."matchpatch.devices"] -example-device = "matchpatch_example_device:ExampleDeviceProfile" -``` - -After installation, `matchpatch --device example-device ...` can discover the -profile, but normalization still needs real file parsing, measurement-file -creation, and adjustment application before it can be useful. diff --git a/examples/device_plugin/pyproject.toml b/examples/device_plugin/pyproject.toml deleted file mode 100644 index 7736108..0000000 --- a/examples/device_plugin/pyproject.toml +++ /dev/null @@ -1,15 +0,0 @@ -[project] -name = "matchpatch-example-device-plugin" -version = "0.1.0" -description = "Minimal MatchPatch device plugin example" -requires-python = ">=3.12" -dependencies = [ - "matchpatch", -] - -[project.entry-points."matchpatch.devices"] -example-device = "matchpatch_example_device:ExampleDeviceProfile" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" diff --git a/examples/device_plugin/src/matchpatch_example_device/__init__.py b/examples/device_plugin/src/matchpatch_example_device/__init__.py deleted file mode 100644 index 03cb4c0..0000000 --- a/examples/device_plugin/src/matchpatch_example_device/__init__.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Minimal read-only MatchPatch device plugin example.""" - -from __future__ import annotations - -from pathlib import Path - -from matchpatch.devices.base import ( - AudioRouting, - DeviceController, - DeviceFileKind, - DeviceFileType, - DeviceProfile, - DeviceSettingDescriptor, - FileOperationCapabilities, - NormalizationPolicy, - PatchAssignment, - PatchFileAdjustments, - PatchFileHandler, - SteeringOptions, -) - - -class ExampleController(DeviceController): - def activate_preset(self, preset_id: int) -> None: - raise NotImplementedError("Example device does not implement hardware steering") - - def reapply_snapshot(self, snapshot: int) -> None: - raise NotImplementedError("Example device does not implement hardware steering") - - -class ExamplePatchFileHandler(PatchFileHandler): - def validate_input(self, input_path: Path) -> None: - if input_path.suffix.lower() != ".examplebank": - raise ValueError("Example device inputs must use the .examplebank extension") - - def validate_output(self, input_path: Path, output_path: Path) -> None: - if output_path.suffix.lower() != ".examplebank": - raise ValueError("Example device outputs must use the .examplebank extension") - - def list_assignments(self, input_path: Path) -> list[PatchAssignment]: - self.validate_input(input_path) - return [] - - def file_capabilities(self) -> FileOperationCapabilities: - return FileOperationCapabilities(reads_setlist_files=True) - - def file_types(self) -> tuple[DeviceFileType, ...]: - return ( - DeviceFileType( - kind="setlist", - extensions=(".examplebank",), - description="Example Device Banks", - can_save=False, - ), - ) - - def file_kind(self, path: Path) -> DeviceFileKind: - if path.suffix.lower() == ".examplebank": - return "setlist" - return "unknown" - - def parse_patch_set(self, value: str) -> list[int]: - return [int(item.strip()) for item in value.split(",") if item.strip()] - - def select_preset_ids( - self, - input_path: Path, - assignments: list[PatchAssignment], - requested_ids: list[int] | None, - ) -> list[int]: - if requested_ids is not None: - return requested_ids - return [assignment.id for assignment in assignments] - - def format_patch_id(self, preset_id: int) -> str: - return f"EX{preset_id:03d}" - - def create_measurement_file(self, input_path: Path, output_path: Path) -> None: - raise NotImplementedError("Example device does not create measurement files") - - def apply_analysis_csv( - self, - input_path: Path, - output_path: Path, - csv_path: Path, - ignore_bad_lufs: bool, - target_lufs: float, - policy: NormalizationPolicy, - custom_adjustments_path: Path | None = None, - adjustments: PatchFileAdjustments | None = None, - ) -> None: - raise NotImplementedError("Example device does not apply analysis CSVs") - - def automation_output_path(self, input_path: Path, postfix: str) -> Path: - return input_path.with_name(f"{input_path.stem}{postfix}{input_path.suffix}") - - -class ExampleDeviceProfile(DeviceProfile): - name = "example-device" - display_name = "Example Device" - - def create_patch_file_handler(self, project_dir: Path) -> PatchFileHandler: - return ExamplePatchFileHandler() - - def default_audio_routing(self) -> AudioRouting: - return AudioRouting(None, 48000, (1, 2), (1, 2)) - - def default_steering_options(self) -> SteeringOptions: - return SteeringOptions(None, 0, 0.0, 0.0, 0.0) - - def create_controller(self, options: SteeringOptions) -> DeviceController: - return ExampleController() - - def setting_descriptors(self) -> tuple[DeviceSettingDescriptor, ...]: - audio = self.default_audio_routing() - return ( - DeviceSettingDescriptor( - name="audio_device", - scope="audio", - kind="string", - default=audio.device, - config_path=("devices", self.name, "audio", "device"), - cli_flags=("--audio-device",), - label="Audio device", - ), - DeviceSettingDescriptor( - name="sample_rate", - scope="audio", - kind="integer", - default=audio.sample_rate, - config_path=("devices", self.name, "audio", "sample_rate"), - cli_flags=("--sample-rate",), - label="Sample rate", - minimum=1, - ), - ) - - def file_capabilities(self) -> FileOperationCapabilities: - return FileOperationCapabilities(reads_setlist_files=True) - - -__all__ = ["ExampleDeviceProfile", "ExamplePatchFileHandler"] diff --git a/installer/pyinstaller/matchpatch-gui.spec b/installer/pyinstaller/matchpatch-gui.spec index 8509960..a78be01 100644 --- a/installer/pyinstaller/matchpatch-gui.spec +++ b/installer/pyinstaller/matchpatch-gui.spec @@ -31,7 +31,7 @@ a = Analysis( binaries=[], datas=asset_datas(), hiddenimports=[ - "matchpatch.devices.helix_preset_handling", + "matchpatch.devices.helix.preset_handling", "mido.backends.rtmidi", "rtmidi", ], diff --git a/pyproject.toml b/pyproject.toml index c8688c9..12bf980 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,14 +36,6 @@ Repository = "https://github.com/noseglasses/MatchPatch.git" matchpatch = "matchpatch.cli:main" matchpatch-gui = "matchpatch.gui.app:main" -[project.entry-points."matchpatch.devices"] -# Third-party packages can register device profiles here, for example: -# my-device = "my_package.matchpatch_plugin:DeviceProfile" -# Built-in profiles are registered directly in matchpatch.devices.registry. - -[project.entry-points."matchpatch.device_gui_panels"] -# Optional GUI-only panel factories for device plugins. - [dependency-groups] docs = [ "furo>=2024.8.6", @@ -102,8 +94,8 @@ select = ["ANN", "C901", "E4", "E7", "E9", "F", "I"] max-complexity = 10 [tool.ruff.lint.per-file-ignores] -"src/matchpatch/devices/helix_file_ops.py" = ["ANN"] -"src/matchpatch/devices/helix_preset_handling.py" = ["ANN", "C901"] +"src/matchpatch/devices/helix/file_ops.py" = ["ANN"] +"src/matchpatch/devices/helix/preset_handling.py" = ["ANN", "C901"] "tests/**" = ["ANN"] [tool.ty.src] diff --git a/src/matchpatch/devices/__init__.py b/src/matchpatch/devices/__init__.py index b90118d..98a7d6d 100644 --- a/src/matchpatch/devices/__init__.py +++ b/src/matchpatch/devices/__init__.py @@ -1,5 +1,5 @@ """Audio processor profiles supported by MatchPatch.""" -from matchpatch.devices.registry import get_device_profile, list_device_profiles, plugin_load_errors +from matchpatch.devices.registry import get_device_profile, list_device_profiles -__all__ = ["get_device_profile", "list_device_profiles", "plugin_load_errors"] +__all__ = ["get_device_profile", "list_device_profiles"] diff --git a/src/matchpatch/devices/available.py b/src/matchpatch/devices/available.py new file mode 100644 index 0000000..3b55f36 --- /dev/null +++ b/src/matchpatch/devices/available.py @@ -0,0 +1,21 @@ +"""Built-in device profile registration. + +Adding a device should stay intentionally boring: + +1. Create a sibling package under ``matchpatch.devices``. +2. Implement a ``DeviceProfile`` there. +3. Instantiate it in ``DEVICE_PROFILES`` below. + +The registry reads only this list. There is no dynamic discovery layer. +""" + +from __future__ import annotations + +from matchpatch.devices.base import DeviceProfile +from matchpatch.devices.demo import DemoDeviceProfile +from matchpatch.devices.helix import HelixDeviceProfile + +DEVICE_PROFILES: tuple[DeviceProfile, ...] = ( + HelixDeviceProfile(), + DemoDeviceProfile(), +) diff --git a/src/matchpatch/devices/base.py b/src/matchpatch/devices/base.py index 6369b69..b389356 100644 --- a/src/matchpatch/devices/base.py +++ b/src/matchpatch/devices/base.py @@ -48,6 +48,7 @@ class DeviceSettingDescriptor: minimum: int | float | None = None maximum: int | float | None = None required: bool = False + show_in_gui: bool = True @dataclass(frozen=True) @@ -762,13 +763,21 @@ def measurement_backends(self) -> tuple[str, ...]: return MeasurementBackendCapabilities().names() def audio_transport_factories(self) -> tuple[AudioTransportFactory, ...]: - """Return plugin-provided audio transport factories for this device.""" + """Return device-provided audio transport factories for this device.""" return () def diagnostics_provider(self) -> DiagnosticsProvider | None: """Return optional device-specific preflight diagnostics.""" return None + def supports_normalization(self) -> bool: + """Return whether this profile can run the normalization workflow.""" + return True + + def normalization_unavailable_message(self) -> str: + """Explain why normalization is unavailable when ``supports_normalization`` is false.""" + return f"{self.display_name} cannot normalize files." + def naming_rules(self) -> NamingRules: return NamingRules( preset_name_max_length=getattr(self, "preset_name_max_length", None), diff --git a/src/matchpatch/devices/demo/__init__.py b/src/matchpatch/devices/demo/__init__.py new file mode 100644 index 0000000..4951106 --- /dev/null +++ b/src/matchpatch/devices/demo/__init__.py @@ -0,0 +1,513 @@ +"""Demo MatchPatch device implementation and copyable device template. + +This module is intentionally small, deterministic, and offline. The classes +demonstrate the MatchPatch device API; the ``.demobank`` JSON format is fake +demo behavior, not a format recommendation for real devices. + +When adding a real device, copying this file is a reasonable starting point: + +* replace the fake ``.demobank`` JSON parsing with the device's real file format; +* replace ``DemoController`` with MIDI, USB, network, or SDK steering code; +* keep the public method shapes the same so MatchPatch can call the device + through the shared ``DeviceProfile`` and ``PatchFileHandler`` APIs. +""" + +from __future__ import annotations + +import json +from copy import deepcopy +from dataclasses import replace +from pathlib import Path +from typing import Any, cast + +from matchpatch.devices.base import ( + AudioRouting, + DeviceController, + DeviceFileKind, + DeviceFileType, + DeviceProfile, + DeviceSettingDescriptor, + FileOperationCapabilities, + GainAdjustment, + GainPoint, + MeasurementSubdivision, + MeasurementTarget, + NormalizationPolicy, + PatchAssignment, + PatchFileAdjustments, + PatchFileHandler, + SteeringOptions, +) + +# Demo-only file extension. Real devices should use their vendor file +# extensions, such as ``.hls``/``.hlx`` for Helix. +DEMO_EXTENSION = ".demobank" + +# Stable gain-point ids let MatchPatch tell the file handler exactly which +# control to adjust after measurement. Real devices usually have ids for +# outputs, blocks, scenes/snapshots, or other gain-bearing parameters. +DEMO_GAIN_POINT_ID = "main-output" + +# These settings still exist for config and CLI compatibility, but the demo +# panel hides them because the GUI has a dedicated timing tab. This is useful +# when a setting is part of the backend API but should not be duplicated in the +# per-device panel. +DEMO_REDUNDANT_PANEL_SETTINGS = frozenset( + { + "preset_wait", + "snapshot_wait", + "measurement_wait", + } +) + + +class DemoController(DeviceController): + """No-op controller for an offline demo device. + + Real hardware-backed devices would open MIDI, USB, network, or vendor SDK + resources here. This demo accepts calls so tests and docs can run without + hardware. + """ + + def activate_preset(self, preset_id: int) -> None: + """Switch the physical or virtual device to a preset before measuring. + + MatchPatch calls this during measurement. A real implementation would + send MIDI program changes, call a vendor SDK, or otherwise steer the + device. The demo has no hardware, so this method is intentionally a + no-op. + """ + return None + + def reapply_snapshot(self, snapshot: int) -> None: + """Select or reapply the current subdivision within the active preset. + + Helix calls these subdivisions snapshots; the demo calls them scenes. + Real devices should translate MatchPatch's one-based subdivision number + to whatever addressing scheme their hardware uses. + """ + return None + + +class DemoPatchFileHandler(PatchFileHandler): + """Patch handler for the fake JSON ``.demobank`` format. + + Required API methods validate paths, expose measurement targets, parse + selectors, create derived files, and apply adjustments. The JSON schema used + below is demo-only: + + { + "presets": [ + { + "id": "preset:clean", + "number": 1, + "name": "Clean", + "scenes": [ + {"id": "scene:intro", "name": "Intro", "output_level_db": -6.0} + ] + } + ] + } + """ + + def validate_input(self, input_path: Path) -> None: + """Reject input files that this handler cannot read. + + MatchPatch calls validation before file operations and normalization. + Keep the error message user-facing: it may appear in CLI or GUI errors. + """ + if input_path.suffix.lower() != DEMO_EXTENSION: + raise ValueError(f"Demo device inputs must use the {DEMO_EXTENSION} extension") + + def validate_output(self, input_path: Path, output_path: Path) -> None: + """Reject output paths that would produce an unsupported device file. + + The input path is provided too because some devices may allow different + output extensions depending on whether the input is a preset, setlist, + library, project, or bank file. + """ + self.validate_input(input_path) + if output_path.suffix.lower() != DEMO_EXTENSION: + raise ValueError(f"Demo device outputs must use the {DEMO_EXTENSION} extension") + + def list_assignments(self, input_path: Path) -> list[PatchAssignment]: + """Return GUI table rows for the patches contained in a file. + + ``PatchAssignment`` is the older compatibility model used by parts of + the GUI. Each row needs a stable numeric id, a display patch number, a + name, and the source filename. New devices should still implement this + until all workflows have moved fully to ``MeasurementTarget``. + """ + return [ + PatchAssignment( + id=_numeric_preset_id(preset, index), + device_patch=self.format_patch_id(_numeric_preset_id(preset, index)), + name=str(preset.get("name", "")), + original_filename=input_path.name, + ) + for index, preset in enumerate(self._read_presets(input_path)) + ] + + def list_targets(self, input_path: Path) -> list[MeasurementTarget]: + """Return measurement targets with subdivisions and adjustable points. + + A target is the thing MatchPatch can measure as a unit: usually a + preset, patch, rig, or bank slot. Subdivisions are optional states below + that target, such as snapshots or scenes. Gain points describe where + MatchPatch may write level corrections. + """ + targets = [] + for preset_index, preset in enumerate(self._read_presets(input_path)): + preset_id = str(preset.get("id", f"preset:{preset_index + 1}")) + numeric_id = _numeric_preset_id(preset, preset_index) + targets.append( + MeasurementTarget( + id=preset_id, + display_label=self.format_patch_id(numeric_id), + index=preset_index, + name=str(preset.get("name", "")), + source_filename=input_path.name, + subdivisions=self._subdivisions(preset_id, preset), + compat_numeric_id=numeric_id, + ) + ) + return targets + + def file_capabilities(self) -> FileOperationCapabilities: + """Advertise only the file operations this handler actually supports. + + The demo can read and write whole fake bank files. Real handlers should + enable flags conservatively; GUI actions and tests rely on this contract + to avoid offering unsupported operations. + """ + return FileOperationCapabilities(reads_setlist_files=True, writes_setlist_files=True) + + def file_types(self) -> tuple[DeviceFileType, ...]: + """Describe file extensions for open/save dialogs and file-kind checks. + + ``kind`` uses MatchPatch's generic vocabulary: ``preset`` for a single + patch-like file, ``setlist`` for a multi-patch container, and + ``unknown`` for paths the handler should ignore. + """ + return ( + DeviceFileType( + kind="setlist", + extensions=(DEMO_EXTENSION,), + description="Demo Device Banks", + ), + ) + + def file_kind(self, path: Path) -> DeviceFileKind: + """Classify a path without opening it. + + Returning ``unknown`` is important. It lets the GUI silently ignore a + file selected for another device instead of showing scary validation + popups while the user is only changing device selection. + """ + if path.suffix.lower() == DEMO_EXTENSION: + return "setlist" + return "unknown" + + def parse_patch_set(self, value: str) -> list[int]: + """Parse legacy numeric patch selectors from CLI text. + + This demo accepts comma-separated numbers such as ``1,2,3``. A real + device can accept vendor-style labels if it also maps them to stable + ids before returning. + """ + return [int(item.strip()) for item in value.split(",") if item.strip()] + + def parse_target_set(self, value: str) -> list[int | str]: + """Parse target selectors from CLI text. + + Targets may use strings because real device formats often have stable + UUIDs or composite ids. This demo keeps the input text unchanged after + trimming whitespace. + """ + return [item.strip() for item in value.split(",") if item.strip()] + + def select_preset_ids( + self, + input_path: Path, + assignments: list[PatchAssignment], + requested_ids: list[int] | None, + ) -> list[int]: + """Choose which assignment ids should be measured by default. + + If the user requested specific ids, preserve that request. Otherwise + measure every assignment found in the file. Real devices can filter out + unsupported factory slots, empty presets, or non-audio patches here. + """ + if requested_ids is not None: + return requested_ids + return [assignment.id for assignment in assignments] + + def format_patch_id(self, preset_id: int) -> str: + """Format a numeric patch id for display in the GUI and logs.""" + return f"D{preset_id:03d}" + + def create_measurement_file(self, input_path: Path, output_path: Path) -> None: + """Create a file variant suitable for measurement. + + Some devices need a temporary measurement file that disables effects, + rewrites routing, or normalizes controller state before recording. The + demo simply copies the JSON and adds a marker so the behavior is visible + in tests. + """ + self.validate_output(input_path, output_path) + data = self._read_data(input_path) + data["measurement_file"] = True + self._write_data(output_path, data) + + def apply_analysis_csv( + self, + input_path: Path, + output_path: Path, + csv_path: Path, + ignore_bad_lufs: bool, + target_lufs: float, + policy: NormalizationPolicy, + custom_adjustments_path: Path | None = None, + adjustments: PatchFileAdjustments | None = None, + ) -> None: + """Apply the classic CSV-driven normalization output. + + Real handlers usually parse ``csv_path`` or the structured + ``adjustments`` object and write device-specific gain values. The demo + does not implement loudness normalization; it only writes a note so this + method remains deterministic and offline. + """ + self.validate_output(input_path, output_path) + data = self._read_data(input_path) + data["normalization_note"] = "Demo device copied file; CSV parsing is not implemented." + self._write_data(output_path, data) + + def apply_gain_adjustments( + self, + input_path: Path, + output_path: Path, + adjustments: list[GainAdjustment], + ) -> None: + """Apply structured gain adjustments to a copied output file. + + This is the clearest example of real patch mutation in the demo. It + deep-copies the JSON so unrelated data from the input survives unchanged, + validates each requested gain point, and changes only the selected + scene's ``output_level_db`` value. + """ + self.validate_output(input_path, output_path) + data = self._read_data(input_path) + output_data = deepcopy(data) + + scenes = { + (str(preset.get("id")), str(scene.get("id"))): scene + for preset in cast("list[dict[str, Any]]", output_data.get("presets", [])) + for scene in cast("list[dict[str, Any]]", preset.get("scenes", [])) + } + for adjustment in adjustments: + if adjustment.gain_point_id != DEMO_GAIN_POINT_ID: + raise ValueError(f"Unknown demo gain point: {adjustment.gain_point_id}") + scene = scenes.get((str(adjustment.target_id), str(adjustment.subdivision_id))) + if scene is None: + raise ValueError( + "Unknown demo target/subdivision: " + f"{adjustment.target_id}/{adjustment.subdivision_id}" + ) + scene["output_level_db"] = float(scene.get("output_level_db", 0.0)) + float( + adjustment.delta_db + ) + + self._write_data(output_path, output_data) + + def automation_output_path(self, input_path: Path, postfix: str) -> Path: + """Return the default output path for generated files. + + MatchPatch passes suffixes such as ``_normalized`` or + ``_measurement``. Most devices can preserve the original extension and + append the postfix to the stem, as shown here. + """ + return input_path.with_name(f"{input_path.stem}{postfix}{input_path.suffix}") + + def _subdivisions( + self, + preset_id: str, + preset: dict[str, Any], + ) -> tuple[MeasurementSubdivision, ...]: + """Convert fake demo scenes into MatchPatch measurement subdivisions. + + Private helpers are not required by the API. They are here to keep the + public methods small and to show where a real handler would translate + vendor file structures into MatchPatch dataclasses. + """ + scenes = cast("list[dict[str, Any]]", preset.get("scenes", [])) + return tuple( + MeasurementSubdivision( + id=str(scene.get("id", f"scene:{index + 1}")), + display_label=str(scene.get("name", index + 1)), + index=index, + name=str(scene.get("name", "")), + gain_points=( + GainPoint( + id=DEMO_GAIN_POINT_ID, + label="Main output", + current_db=float(scene.get("output_level_db", 0.0)), + minimum_db=-60.0, + maximum_db=12.0, + scope="subdivision", + path=f"{preset_id}/{scene.get('id', f'scene:{index + 1}')}", + ), + ), + ) + for index, scene in enumerate(scenes) + ) + + def _read_presets(self, input_path: Path) -> list[dict[str, Any]]: + """Read and type-check the demo's top-level preset list.""" + data = self._read_data(input_path) + presets = data.get("presets", []) + if not isinstance(presets, list): + raise ValueError("Demo device file must contain a list named 'presets'") + return cast("list[dict[str, Any]]", presets) + + def _read_data(self, input_path: Path) -> dict[str, Any]: + """Read a demo bank JSON object after validating the path. + + Real handlers should use structured parsers whenever possible. Avoid + ad-hoc string manipulation for binary or JSON-like vendor formats unless + the format truly leaves no better option. + """ + self.validate_input(input_path) + with input_path.open(encoding="utf-8") as file: + data = json.load(file) + if not isinstance(data, dict): + raise ValueError("Demo device file must contain a JSON object") + return cast("dict[str, Any]", data) + + @staticmethod + def _write_data(output_path: Path, data: dict[str, Any]) -> None: + """Write deterministic JSON so tests can compare output reliably.""" + with output_path.open("w", encoding="utf-8") as file: + json.dump(data, file, indent=2, sort_keys=True) + file.write("\n") + + +class DemoDeviceProfile(DeviceProfile): + """Top-level description of one MatchPatch-supported device. + + MatchPatch discovers devices from ``src/matchpatch/devices/available.py``. + Each entry in that list is a ``DeviceProfile`` instance. The profile owns + device-wide defaults and creates the smaller helpers used by workflows. + """ + + # ``name`` is the stable machine id used by CLI arguments, config files, + # tests, and saved GUI state. Changing it is a breaking change. + name = "demo-device" + + # ``display_name`` is user-facing and can be friendlier than ``name``. + display_name = "Demo Device" + + # Default and maximum subdivision counts used by policies and GUI controls. + # For Helix these correspond to snapshots; for the demo they correspond to + # fake scenes. + snapshot_count = 2 + max_snapshot_count = 8 + + def create_patch_file_handler(self, project_dir: Path) -> PatchFileHandler: + """Create the object that reads, writes, and mutates device files. + + ``project_dir`` is available for handlers that need bundled resources + or conversion tools. The demo does not need it. + """ + return DemoPatchFileHandler() + + def default_audio_routing(self) -> AudioRouting: + """Return device-specific audio defaults for measurement. + + The demo is offline, but it still provides harmless defaults so shared + settings code can run. Real devices should choose values that are useful + for first-run setup. + """ + return AudioRouting(None, 48000, (1, 2), (1, 2)) + + def default_steering_options(self) -> SteeringOptions: + """Return default device steering options. + + Hardware devices normally provide a MIDI or network output, a channel, + and wait times. The demo uses zero waits and no output because it cannot + steer real hardware. + """ + return SteeringOptions(None, 0, 0.0, 0.0, 0.0) + + def create_controller(self, options: SteeringOptions) -> DeviceController: + """Create the runtime controller used during measurement. + + A real implementation should pass ``options`` into its controller so it + can open the configured MIDI port, channel, or transport endpoint. + """ + return DemoController() + + def supports_normalization(self) -> bool: + """Disable the normalize button workflow for this example device. + + The demo can demonstrate file parsing and structured adjustment tests, + but it cannot record audio or compute real loudness corrections. + """ + return False + + def normalization_unavailable_message(self) -> str: + """Return the GUI message shown when normalization is unavailable.""" + return ( + "Demo Device is an example for developers and cannot normalize files. " + "Select a real device, such as Line 6 Helix, to run normalization." + ) + + def setting_descriptors(self) -> tuple[DeviceSettingDescriptor, ...]: + """Describe settings for config, CLI, validation, and generic GUI. + + Reusing ``super().setting_descriptors()`` gives the demo the standard + audio and steering settings. The three wait settings are kept for config + and CLI but hidden from the device panel because they are already shown + in the GUI timing tab. + """ + return ( + *( + replace(descriptor, show_in_gui=False) + if descriptor.name in DEMO_REDUNDANT_PANEL_SETTINGS + else descriptor + for descriptor in super().setting_descriptors() + ), + DeviceSettingDescriptor( + name="demo_mode", + scope="device", + kind="choice", + default="offline", + config_path=("devices", self.name, "mode"), + label="Demo mode", + help="Example device-specific setting rendered by generic front ends.", + choices=("offline", "simulated"), + ), + ) + + def file_capabilities(self) -> FileOperationCapabilities: + """Advertise profile-level file capabilities for menus and dialogs. + + This mirrors ``DemoPatchFileHandler.file_capabilities()`` so callers can + inspect capabilities from either the profile or a concrete handler. + """ + return FileOperationCapabilities(reads_setlist_files=True, writes_setlist_files=True) + + +def _numeric_preset_id(preset: dict[str, Any], index: int) -> int: + """Return a stable numeric compatibility id for one demo preset. + + The modern API can use string target ids, but parts of MatchPatch still use + numeric preset ids. Real devices with non-numeric ids should provide a + deterministic compatibility mapping. + """ + number = preset.get("number", index + 1) + if not isinstance(number, int) or isinstance(number, bool): + raise ValueError("Demo preset 'number' must be an integer") + return number + + +__all__ = ["DemoDeviceProfile", "DemoPatchFileHandler"] diff --git a/src/matchpatch/devices/helix.py b/src/matchpatch/devices/helix/__init__.py similarity index 99% rename from src/matchpatch/devices/helix.py rename to src/matchpatch/devices/helix/__init__.py index 4fc9a2b..0fb6e64 100644 --- a/src/matchpatch/devices/helix.py +++ b/src/matchpatch/devices/helix/__init__.py @@ -17,7 +17,6 @@ from types import TracebackType from typing import Any -from matchpatch.devices import helix_file_ops from matchpatch.devices.base import ( AudioRouting, DeviceController, @@ -35,6 +34,7 @@ PatchFileHandler, SteeringOptions, ) +from matchpatch.devices.helix import file_ops from matchpatch.midi import midi_output_names HELIX_NAME_PATTERN = re.compile(r"""^[A-Za-z0-9\-_+=!@#$&()?:'",./ ]*$""") @@ -44,7 +44,7 @@ class HelixPatchFileHandler(PatchFileHandler): def __init__(self, project_dir: Path) -> None: self.project_dir = project_dir - self.module = "matchpatch.devices.helix_preset_handling" + self.module = "matchpatch.devices.helix.preset_handling" self.log_callback: Callable[[str], None] | None = None def set_log_callback(self, callback: Callable[[str], None] | None) -> None: @@ -237,7 +237,7 @@ def split_setlist_file( raise ValueError(f"Helix split input must be an .hls file: {input_path}") output_dir.mkdir(parents=True, exist_ok=True) - split_presets = _load_helix_file_ops().split_setlist_to_preset_data( + split_presets = _load_helix_file_operations().split_setlist_to_preset_data( input_path, selected_ids=selected_ids, original_filenames=original_filenames, @@ -762,8 +762,8 @@ def _error_details(exc: subprocess.CalledProcessError) -> str: return lines[-1].strip() if lines else "" -def _load_helix_file_ops() -> Any: # noqa: ANN401 - return helix_file_ops +def _load_helix_file_operations() -> Any: # noqa: ANN401 + return file_ops def _assignment_gain_points(assignment: Mapping[str, object]) -> tuple[GainPoint, ...]: diff --git a/src/matchpatch/devices/helix_file_ops.py b/src/matchpatch/devices/helix/file_ops.py similarity index 100% rename from src/matchpatch/devices/helix_file_ops.py rename to src/matchpatch/devices/helix/file_ops.py diff --git a/src/matchpatch/devices/helix_preset_handling.py b/src/matchpatch/devices/helix/preset_handling.py similarity index 99% rename from src/matchpatch/devices/helix_preset_handling.py rename to src/matchpatch/devices/helix/preset_handling.py index ba4b4d0..06e3d93 100644 --- a/src/matchpatch/devices/helix_preset_handling.py +++ b/src/matchpatch/devices/helix/preset_handling.py @@ -10,23 +10,23 @@ import sys from dataclasses import dataclass -from matchpatch.devices.helix_file_ops import ( # noqa: F401 +from matchpatch.devices.helix.file_ops import ( # noqa: F401 build_hls_text, build_new_hls_text, decode_hls_text, join_preset_files_to_setlist, split_setlist_to_preset_data, ) -from matchpatch.devices.helix_file_ops import ( +from matchpatch.devices.helix.file_ops import ( helix_to_preset_index as helix_to_preset_index, ) -from matchpatch.devices.helix_file_ops import ( +from matchpatch.devices.helix.file_ops import ( load_preset_file as load_preset_file, ) -from matchpatch.devices.helix_file_ops import ( +from matchpatch.devices.helix.file_ops import ( load_setlist_file as load_setlist_file, ) -from matchpatch.devices.helix_file_ops import ( +from matchpatch.devices.helix.file_ops import ( safe_preset_filename as safe_preset_filename, ) diff --git a/src/matchpatch/devices/registry.py b/src/matchpatch/devices/registry.py index 25413d8..592b415 100644 --- a/src/matchpatch/devices/registry.py +++ b/src/matchpatch/devices/registry.py @@ -1,42 +1,19 @@ -"""Registry of audio processor profiles.""" +"""Registry of built-in audio processor profiles.""" from __future__ import annotations -from collections.abc import Iterable -from importlib import metadata -from typing import Any - +from matchpatch.devices.available import DEVICE_PROFILES from matchpatch.devices.base import DeviceProfile -from matchpatch.devices.helix import HelixDeviceProfile - -ENTRY_POINT_GROUP = "matchpatch.devices" - -_PROFILES: dict[str, DeviceProfile] = { - "helix": HelixDeviceProfile(), -} -_PLUGIN_LOAD_ERRORS: dict[str, str] = {} - -def _entry_points() -> Iterable[metadata.EntryPoint]: - entry_points = metadata.entry_points() - if hasattr(entry_points, "select"): - return entry_points.select(group=ENTRY_POINT_GROUP) - return entry_points.get(ENTRY_POINT_GROUP, ()) - -def _profile_from_loaded(value: Any) -> list[DeviceProfile]: # noqa: ANN401 - if isinstance(value, DeviceProfile): - return [value] - if isinstance(value, type) and issubclass(value, DeviceProfile): - return [value()] - if isinstance(value, Iterable) and not isinstance(value, (str, bytes)): - profiles = [] - for item in value: - if not isinstance(item, DeviceProfile): - raise TypeError("device entry point iterable must contain DeviceProfile instances") - profiles.append(item) - return profiles - raise TypeError("device entry point must return a DeviceProfile, subclass, or iterable") +def _profiles_by_name() -> dict[str, DeviceProfile]: + profiles: dict[str, DeviceProfile] = {} + for profile in DEVICE_PROFILES: + _validate_profile(profile) + if profile.name in profiles: + raise ValueError(f"duplicate device profile name {profile.name!r}") + profiles[profile.name] = profile + return profiles def _validate_profile(profile: DeviceProfile) -> None: @@ -48,50 +25,15 @@ def _validate_profile(profile: DeviceProfile) -> None: raise ValueError(f"device profile {profile.name!r} must create patch file handlers") -def _plugin_profiles() -> dict[str, DeviceProfile]: - profiles: dict[str, DeviceProfile] = {} - _PLUGIN_LOAD_ERRORS.clear() - for entry_point in _entry_points(): - try: - loaded = entry_point.load() - for profile in _profile_from_loaded(loaded): - _validate_profile(profile) - if profile.name in _PROFILES or profile.name in profiles: - raise ValueError(f"duplicate device profile name {profile.name!r}") - profiles[profile.name] = profile - except Exception as exc: # noqa: BLE001 - _PLUGIN_LOAD_ERRORS[entry_point.name] = str(exc) - return profiles - - -def _all_profiles() -> dict[str, DeviceProfile]: - profiles = dict(_PROFILES) - profiles.update(_plugin_profiles()) - return profiles - - def get_device_profile(name: str) -> DeviceProfile: - profiles = _all_profiles() + profiles = _profiles_by_name() try: return profiles[name] except KeyError as exc: supported = ", ".join(sorted(profiles)) - if _PLUGIN_LOAD_ERRORS: - plugin_errors = "; ".join( - f"{plugin}: {error}" for plugin, error in sorted(_PLUGIN_LOAD_ERRORS.items()) - ) - raise ValueError( - f"Unsupported device {name!r}; choose one of: {supported}. " - f"Device plugin load errors: {plugin_errors}" - ) from exc raise ValueError(f"Unsupported device {name!r}; choose one of: {supported}") from exc def list_device_profiles() -> list[DeviceProfile]: - profiles = _all_profiles() - return [profiles[name] for name in sorted(profiles)] - - -def plugin_load_errors() -> dict[str, str]: - _plugin_profiles() - return dict(_PLUGIN_LOAD_ERRORS) + profiles = _profiles_by_name() + return [profiles[profile.name] for profile in DEVICE_PROFILES] diff --git a/src/matchpatch/gui/device_panel_registry.py b/src/matchpatch/gui/device_panel_registry.py deleted file mode 100644 index 602ffbe..0000000 --- a/src/matchpatch/gui/device_panel_registry.py +++ /dev/null @@ -1,66 +0,0 @@ -"""GUI-only registry for optional device settings panel plugins.""" - -from __future__ import annotations - -import logging -from collections.abc import Iterable -from importlib import metadata -from typing import Any, Protocol - -from PySide6.QtWidgets import QWidget - -from matchpatch.devices.base import DeviceProfile - -ENTRY_POINT_GROUP = "matchpatch.device_gui_panels" - -_LOG = logging.getLogger(__name__) -_PLUGIN_LOAD_ERRORS: dict[str, str] = {} - - -class DevicePanelFactory(Protocol): - device_name: str - - def create_panel(self, profile: DeviceProfile, backend_selector: QWidget) -> QWidget | None: ... - - -def _entry_points() -> Iterable[metadata.EntryPoint]: - entry_points = metadata.entry_points() - if hasattr(entry_points, "select"): - return entry_points.select(group=ENTRY_POINT_GROUP) - return entry_points.get(ENTRY_POINT_GROUP, ()) - - -def _panel_factory_from_loaded(value: Any) -> DevicePanelFactory: # noqa: ANN401 - factory = value - if not _has_factory_shape(factory) and callable(value): - factory = value() - if not _has_factory_shape(factory): - raise TypeError("device GUI panel entry point must define device_name and create_panel") - return factory - - -def _has_factory_shape(value: object) -> bool: - return isinstance(getattr(value, "device_name", None), str) and callable( - getattr(value, "create_panel", None) - ) - - -def create_plugin_settings_panel( - profile: DeviceProfile, - backend_selector: QWidget, -) -> QWidget | None: - _PLUGIN_LOAD_ERRORS.clear() - for entry_point in _entry_points(): - try: - factory = _panel_factory_from_loaded(entry_point.load()) - if factory.device_name == profile.name: - return factory.create_panel(profile, backend_selector) - except Exception as exc: # noqa: BLE001 - message = str(exc) - _PLUGIN_LOAD_ERRORS[entry_point.name] = message - _LOG.warning("Device GUI panel plugin %s failed to load: %s", entry_point.name, message) - return None - - -def plugin_load_errors() -> dict[str, str]: - return dict(_PLUGIN_LOAD_ERRORS) diff --git a/src/matchpatch/gui/device_panels.py b/src/matchpatch/gui/device_panels.py index 87ac985..c572289 100644 --- a/src/matchpatch/gui/device_panels.py +++ b/src/matchpatch/gui/device_panels.py @@ -15,7 +15,6 @@ ) from matchpatch.devices.base import DeviceProfile -from matchpatch.gui.device_panel_registry import create_plugin_settings_panel from matchpatch.gui.settings_renderer import DescriptorSettingsPanel @@ -123,12 +122,10 @@ def create_settings_panel( profile: DeviceProfile, backend_selector: QWidget, ) -> QWidget | None: - plugin_panel = create_plugin_settings_panel(profile, backend_selector) - if plugin_panel is not None: - return plugin_panel if profile.name == "helix": return HelixSettingsPanel(backend_selector) descriptors = profile.setting_descriptors() if hasattr(profile, "setting_descriptors") else () + descriptors = tuple(descriptor for descriptor in descriptors if descriptor.show_in_gui) if descriptors: return DescriptorSettingsPanel(descriptors) return None diff --git a/src/matchpatch/gui/normalization_workflow.py b/src/matchpatch/gui/normalization_workflow.py index 502a7c2..d17e209 100644 --- a/src/matchpatch/gui/normalization_workflow.py +++ b/src/matchpatch/gui/normalization_workflow.py @@ -40,6 +40,9 @@ def __init__( def start_normalization(self) -> None: window = self.window + if not self._device_supports_normalization(): + return + if not window._validate_single_preset_slot_for_run(): return @@ -75,6 +78,18 @@ def start_normalization(self) -> None: window._available_backend = request.backend window._start_normalization_request(request) + def _device_supports_normalization(self) -> bool: + window = self.window + profile = get_device_profile(window.device.currentData()) + if getattr(profile, "supports_normalization", lambda: True)(): + return True + QMessageBox.information( + window, + "Normalization unavailable", + profile.normalization_unavailable_message(), + ) + return False + def start_request(self, request: NormalizationRequest) -> None: if not self._confirm_start_allowed(request): return diff --git a/src/matchpatch/gui/window_loading.py b/src/matchpatch/gui/window_loading.py index 029f19d..36f619a 100644 --- a/src/matchpatch/gui/window_loading.py +++ b/src/matchpatch/gui/window_loading.py @@ -216,12 +216,13 @@ def _load_single_preset_assignments(self, path: Path) -> None: try: profile = self.get_profile(window.device.currentData()) handler = profile.create_patch_file_handler(Path(__file__).resolve().parents[3]) + if handler.file_kind(path) == "unknown": + self._show_no_assignments_loaded() + return handler.validate_input(path) assignments = handler.list_assignments(path) except Exception as exc: # noqa: BLE001 - window._show_preset_empty_state() - window.presets.updateGeometry() - window._schedule_resize_for_content() + self._show_no_assignments_loaded() window.show_error(str(exc)) return @@ -245,6 +246,9 @@ def _load_setlist_assignments(self, path: Path) -> None: try: profile = self.get_profile(window.device.currentData()) handler = profile.create_patch_file_handler(Path(__file__).resolve().parents[3]) + if handler.file_kind(path) == "unknown": + self._show_no_assignments_loaded() + return handler.validate_input(path) with window._sorting_paused(): window._adjusted_presets.clear() @@ -273,9 +277,7 @@ def _load_setlist_assignments(self, path: Path) -> None: ) window._refresh_preset_table_editable_flags() except Exception as exc: # noqa: BLE001 - window._show_preset_empty_state() - window.presets.updateGeometry() - window._schedule_resize_for_content() + self._show_no_assignments_loaded() window.show_error(str(exc)) return @@ -289,6 +291,12 @@ def _load_setlist_assignments(self, path: Path) -> None: QTimer.singleShot(0, window._fit_advanced_splitter_width) window._schedule_resize_for_content() + def _show_no_assignments_loaded(self) -> None: + window = self.window + window._show_preset_empty_state() + window.presets.updateGeometry() + window._schedule_resize_for_content() + @staticmethod def load_custom_adjustments(request: NormalizationRequest) -> CustomAdjustments: if request.custom_adjustments_path is None: diff --git a/src/matchpatch/measure.py b/src/matchpatch/measure.py index 6f85fb9..3c633ed 100644 --- a/src/matchpatch/measure.py +++ b/src/matchpatch/measure.py @@ -895,7 +895,7 @@ def _select_audio_transport_factory( if mode == "offline": raise NotImplementedError( "The offline measurement backend is not implemented yet; " - "install or enable a plugin-provided offline audio transport factory" + "enable a device-provided offline audio transport factory" ) raise ValueError( f"Backend {mode!r} is supported by {profile.display_name}, " diff --git a/tests/README.md b/tests/README.md index ecd550f..5180f3d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -69,7 +69,7 @@ fixtures. Important recurring fixtures/helpers: - `capsys`: asserts CLI stdout/stderr and error reporting. - `app` fixture in GUI tests: module-scoped `QApplication`, with `QT_QPA_PLATFORM=offscreen`. -- `load_legacy_preset_handling`: imports `matchpatch.devices.helix_preset_handling` +- `load_legacy_preset_handling`: imports `matchpatch.devices.helix.preset_handling` so Helix file behavior can be tested through the packaged module. - `FakePatchFileHandler`, `FakeDeviceProfile`, and GUI-local fake dialogs/workers isolate workflow and UI behavior from real files and devices. diff --git a/tests/test_devices.py b/tests/test_devices.py index b15d7df..19aecce 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -1,6 +1,6 @@ from __future__ import annotations -import sys +import json from pathlib import Path import pytest @@ -26,6 +26,7 @@ TargetSelection, validate_snapshot_count, ) +from matchpatch.devices.demo import DemoPatchFileHandler def test_helix_profile_defines_processor_boundaries() -> None: @@ -298,9 +299,9 @@ def split_setlist_file( return [output_dir / "one.preset"] -class PluginProfile(DeviceProfile): - name = "plugin-device" - display_name = "Plugin Device" +class BasicProfile(DeviceProfile): + name = "basic-device" + display_name = "Basic Device" def create_patch_file_handler(self, project_dir: Path) -> PatchFileHandler: return MinimalHandler() @@ -315,7 +316,7 @@ def create_controller(self, options: SteeringOptions) -> DeviceController: return EmptyController() -class FileOperationsProfile(PluginProfile): +class FileOperationsProfile(BasicProfile): name = "files-device" display_name = "Files Device" @@ -326,7 +327,7 @@ def create_patch_file_handler(self, project_dir: Path) -> PatchFileHandler: return self.handler -class OfflineOnlyProfile(PluginProfile): +class OfflineOnlyProfile(BasicProfile): name = "offline-device" display_name = "Offline Device" @@ -339,12 +340,12 @@ def measurement_backends(self) -> tuple[str, ...]: ).names() -class PermissiveNamingProfile(PluginProfile): +class PermissiveNamingProfile(BasicProfile): name = "permissive-names" display_name = "Permissive Names" -class RestrictiveNamingProfile(PluginProfile): +class RestrictiveNamingProfile(BasicProfile): name = "restrictive-names" display_name = "Restrictive Names" @@ -359,25 +360,8 @@ def naming_rules(self) -> NamingRules: ) -class EntryPoint: - def __init__(self, name: str, value) -> None: - self.name = name - self.value = value - - def load(self): - if isinstance(self.value, BaseException): - raise self.value - return self.value - - -class EntryPoints(list): - def select(self, *, group: str): - assert group == registry.ENTRY_POINT_GROUP - return self - - def test_default_device_capabilities_are_backward_compatible() -> None: - profile = PluginProfile() + profile = BasicProfile() handler = profile.create_patch_file_handler(Path(".")) descriptors = {descriptor.name: descriptor for descriptor in profile.setting_descriptors()} @@ -392,7 +376,7 @@ def test_default_device_capabilities_are_backward_compatible() -> None: assert descriptors["midi_output"].default is None assert descriptors["midi_channel"].default == 0 profile.validate_settings({}) - profile.validate_settings({"unknown_plugin_setting": object()}) + profile.validate_settings({"unknown_device_setting": object()}) assert handler.file_capabilities().reads_setlist_files is False assert handler.file_kind(Path("anything")) == "unknown" @@ -596,10 +580,10 @@ def test_file_operations_join_validates_capabilities_and_delegates(tmp_path) -> with pytest.raises(ValueError, match="does not support joining"): file_operations.join_preset_files( - "plugin-device", + "basic-device", [tmp_path / "one.preset"], tmp_path / "joined.setlist", - get_profile=lambda device: PluginProfile(), + get_profile=lambda device: BasicProfile(), ) @@ -622,70 +606,109 @@ def test_file_operations_split_validates_capabilities_and_delegates(tmp_path) -> with pytest.raises(ValueError, match="does not support splitting"): file_operations.split_setlist_file( - "plugin-device", + "basic-device", tmp_path / "joined.setlist", tmp_path / "presets", - get_profile=lambda device: PluginProfile(), + get_profile=lambda device: BasicProfile(), ) -def test_plugin_device_profiles_are_discovered(monkeypatch) -> None: - monkeypatch.setattr( - registry.metadata, - "entry_points", - lambda: EntryPoints([EntryPoint("plugin", PluginProfile)]), - ) - - assert get_device_profile("plugin-device").display_name == "Plugin Device" +def test_builtin_device_profiles_are_listed_from_static_registry() -> None: + assert get_device_profile("demo-device").display_name == "Demo Device" assert [profile.name for profile in registry.list_device_profiles()] == [ "helix", - "plugin-device", + "demo-device", ] -def test_plugin_load_errors_are_reported_for_explicit_lookup(monkeypatch) -> None: - monkeypatch.setattr( - registry.metadata, - "entry_points", - lambda: EntryPoints([EntryPoint("broken", RuntimeError("boom"))]), - ) +def test_duplicate_builtin_device_names_are_reported(monkeypatch) -> None: + class DuplicateProfile(BasicProfile): + name = "helix" - assert [profile.name for profile in registry.list_device_profiles()] == ["helix"] - assert registry.plugin_load_errors() == {"broken": "boom"} - with pytest.raises(ValueError, match="Device plugin load errors: broken: boom"): - get_device_profile("missing") + monkeypatch.setattr(registry, "DEVICE_PROFILES", (DuplicateProfile(), DuplicateProfile())) + with pytest.raises(ValueError, match="duplicate device profile name 'helix'"): + registry.list_device_profiles() -def test_duplicate_plugin_device_names_are_reported(monkeypatch) -> None: - class DuplicateProfile(PluginProfile): - name = "helix" - monkeypatch.setattr( - registry.metadata, - "entry_points", - lambda: EntryPoints([EntryPoint("duplicate", DuplicateProfile())]), +def test_demo_device_is_registered_and_defines_example_contract() -> None: + profile = get_device_profile("demo-device") + handler = profile.create_patch_file_handler(Path(".")) + + assert profile.name == "demo-device" + assert profile.display_name == "Demo Device" + assert not profile.supports_normalization() + assert "cannot normalize files" in profile.normalization_unavailable_message() + assert profile.file_capabilities() == FileOperationCapabilities( + reads_setlist_files=True, + writes_setlist_files=True, + ) + assert handler.file_kind(Path("demo.demobank")) == "setlist" + assert handler.file_types()[0].name_filter() == "Demo Device Banks (*.demobank)" + assert handler.parse_patch_set("1, 2") == [1, 2] + assert handler.parse_target_set("preset:clean, preset:lead") == [ + "preset:clean", + "preset:lead", + ] + assert handler.automation_output_path(Path("demo.demobank"), "_measurement") == Path( + "demo_measurement.demobank" ) - assert registry.plugin_load_errors() == {"duplicate": "duplicate device profile name 'helix'"} +def test_demo_device_handler_reads_targets_and_writes_adjustments(tmp_path) -> None: + input_path = tmp_path / "show.demobank" + output_path = tmp_path / "show-adjusted.demobank" + original_data = { + "device": "demo", + "metadata": {"keep": "unchanged"}, + "presets": [ + { + "id": "preset:clean", + "number": 1, + "name": "Clean", + "unrelated": {"stays": True}, + "scenes": [ + { + "id": "scene:intro", + "name": "Intro", + "output_level_db": -6.0, + "bypass": False, + }, + { + "id": "scene:solo", + "name": "Solo", + "output_level_db": -3.0, + "bypass": True, + }, + ], + } + ], + } + input_path.write_text(json.dumps(original_data), encoding="utf-8") + handler = DemoPatchFileHandler() -def test_example_device_plugin_imports_and_defines_entry_point_contract() -> None: - example_src = Path(__file__).resolve().parents[1] / "examples" / "device_plugin" / "src" - sys.path.insert(0, str(example_src)) - try: - from matchpatch_example_device import ExampleDeviceProfile - finally: - sys.path.remove(str(example_src)) + targets = handler.list_targets(input_path) + gain_points = handler.list_gain_points(input_path, "preset:clean", "scene:intro") + handler.apply_gain_adjustments( + input_path, + output_path, + [GainAdjustment("preset:clean", "scene:intro", "main-output", 1.5)], + ) - profile = ExampleDeviceProfile() - handler = profile.create_patch_file_handler(Path(".")) + adjusted_data = json.loads(output_path.read_text(encoding="utf-8")) + input_data_after_write = json.loads(input_path.read_text(encoding="utf-8")) - assert profile.name == "example-device" - assert profile.display_name == "Example Device" - assert profile.file_capabilities().reads_setlist_files - assert handler.file_kind(Path("demo.examplebank")) == "setlist" - assert handler.file_types()[0].name_filter() == "Example Device Banks (*.examplebank)" - assert handler.parse_patch_set("1, 2") == [1, 2] - assert handler.automation_output_path(Path("demo.examplebank"), "_measurement") == Path( - "demo_measurement.examplebank" - ) + assert [(target.id, target.display_label, target.name) for target in targets] == [ + ("preset:clean", "D001", "Clean") + ] + assert [(scene.id, scene.display_label) for scene in targets[0].subdivisions] == [ + ("scene:intro", "Intro"), + ("scene:solo", "Solo"), + ] + assert [(point.id, point.current_db) for point in gain_points] == [("main-output", -6.0)] + assert adjusted_data["presets"][0]["scenes"][0]["output_level_db"] == -4.5 + assert adjusted_data["presets"][0]["scenes"][0]["bypass"] is False + assert adjusted_data["presets"][0]["scenes"][1] == original_data["presets"][0]["scenes"][1] + assert adjusted_data["presets"][0]["unrelated"] == {"stays": True} + assert adjusted_data["metadata"] == {"keep": "unchanged"} + assert input_data_after_write == original_data diff --git a/tests/test_gui_device_panel_plugins.py b/tests/test_gui_device_panel_plugins.py deleted file mode 100644 index 7950454..0000000 --- a/tests/test_gui_device_panel_plugins.py +++ /dev/null @@ -1,96 +0,0 @@ -from __future__ import annotations - -import os - -import pytest - -os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -pytest.importorskip("PySide6") - -from PySide6.QtCore import QCoreApplication, QSettings -from PySide6.QtWidgets import QApplication, QLabel, QWidget - -from matchpatch.gui import device_panel_registry, device_panels, main_window -from matchpatch.gui.main_window import MainWindow - - -class _GuiEntryPoint: - def __init__(self, name: str, loaded: object) -> None: - self.name = name - self._loaded = loaded - - def load(self) -> object: - if isinstance(self._loaded, BaseException): - raise self._loaded - return self._loaded - - -class _GuiEntryPoints(list[_GuiEntryPoint]): - def select(self, *, group: str) -> list[_GuiEntryPoint]: - if group == device_panel_registry.ENTRY_POINT_GROUP: - return list(self) - return [] - - -@pytest.fixture(scope="module") -def app(): - instance = QApplication.instance() or QApplication([]) - QCoreApplication.setOrganizationName("MatchPatchTests") - QCoreApplication.setApplicationName("MatchPatchTests") - yield instance - - -@pytest.fixture(autouse=True) -def isolated_qsettings(tmp_path): - QSettings.setPath(QSettings.Format.NativeFormat, QSettings.Scope.UserScope, str(tmp_path)) - QSettings.setPath(QSettings.Format.IniFormat, QSettings.Scope.UserScope, str(tmp_path)) - QSettings().clear() - yield - QSettings().clear() - - -def test_plugin_device_settings_panel_is_selected_before_builtin_mapping( - monkeypatch, - app, -) -> None: - class PluginPanelFactory: - device_name = "helix" - - def create_panel(self, profile, backend_selector): - panel = QLabel(f"{profile.name}:{backend_selector.objectName()}") - panel.setObjectName("plugin-helix-panel") - return panel - - monkeypatch.setattr( - device_panel_registry.metadata, - "entry_points", - lambda: _GuiEntryPoints([_GuiEntryPoint("helix-gui", PluginPanelFactory())]), - ) - backend = QWidget() - backend.setObjectName("backend-selector") - - panel = device_panels.create_settings_panel(main_window.get_device_profile("helix"), backend) - - assert isinstance(panel, QLabel) - assert panel.objectName() == "plugin-helix-panel" - assert panel.text() == "helix:backend-selector" - assert device_panel_registry.plugin_load_errors() == {} - - -def test_broken_plugin_device_settings_panel_does_not_break_main_window_startup( - monkeypatch, - app, -) -> None: - monkeypatch.setattr( - device_panel_registry.metadata, - "entry_points", - lambda: _GuiEntryPoints([_GuiEntryPoint("broken-gui", RuntimeError("boom"))]), - ) - - window = MainWindow() - - assert window.device.findData("helix") >= 0 - assert "helix" in window.device_panels - assert device_panel_registry.plugin_load_errors() == {"broken-gui": "boom"} - - window.close() diff --git a/tests/test_gui_settings_renderer.py b/tests/test_gui_settings_renderer.py index 5a5f9ed..10b0a98 100644 --- a/tests/test_gui_settings_renderer.py +++ b/tests/test_gui_settings_renderer.py @@ -9,11 +9,14 @@ pytest.importorskip("PySide6") from PySide6.QtCore import QCoreApplication, QSettings -from PySide6.QtWidgets import QApplication +from PySide6.QtWidgets import QApplication, QMessageBox, QWidget from matchpatch.devices.base import DeviceSettingDescriptor +from matchpatch.devices.registry import get_device_profile from matchpatch.gui import main_window +from matchpatch.gui.device_panels import create_settings_panel from matchpatch.gui.main_window import MainWindow +from matchpatch.gui.normalization_workflow import NormalizationWorkflowController from matchpatch.gui.settings_renderer import DescriptorSettingsPanel @@ -125,6 +128,84 @@ def test_main_window_lists_descriptor_only_device_with_rendered_panel( window.close() +def test_demo_device_uses_descriptor_settings_panel(app) -> None: + profile = get_device_profile("demo-device") + panel = create_settings_panel(profile, QWidget()) + + assert isinstance(panel, DescriptorSettingsPanel) + panel.populate(SimpleNamespace()) + assert panel.controls["sample_rate"].value() == 48000 + assert panel.controls["input_mapping"].text() == "1,2" + assert panel.controls["output_mapping"].text() == "1,2" + assert panel.controls["demo_mode"].currentText() == "offline" + assert "preset_wait" not in panel.controls + assert "snapshot_wait" not in panel.controls + assert "measurement_wait" not in panel.controls + + +def test_main_window_lists_demo_device_without_changing_default(app) -> None: + window = MainWindow() + + assert window.device.currentData() == "helix" + assert window.device.findData("demo-device") >= 0 + assert "demo-device" in window.device_panels + + window.close() + + +def test_selecting_demo_device_does_not_show_error_popup(monkeypatch, app) -> None: + window = MainWindow() + critical_messages = [] + monkeypatch.setattr(QMessageBox, "critical", lambda *args: critical_messages.append(args)) + + window.device.setCurrentIndex(window.device.findData("demo-device")) + app.processEvents() + + assert critical_messages == [] + assert window.device.currentData() == "demo-device" + + window.close() + + +def test_demo_device_ignores_incompatible_opened_setlist_without_popup( + monkeypatch, + app, + tmp_path, +) -> None: + window = MainWindow() + critical_messages = [] + input_path = tmp_path / "helix-setlist.hls" + input_path.write_text("{}", encoding="utf-8") + monkeypatch.setattr(QMessageBox, "critical", lambda *args: critical_messages.append(args)) + window.device.setCurrentIndex(window.device.findData("demo-device")) + app.processEvents() + window.input_path.setText(str(input_path)) + + window.load_assignments() + + assert critical_messages == [] + assert window.preset_table.rowCount() == 0 + + window.close() + + +def test_demo_device_normalization_shows_clear_unavailable_message(monkeypatch, app) -> None: + window = SimpleNamespace( + device=SimpleNamespace(currentData=lambda: "demo-device"), + worker=None, + ) + messages = [] + monkeypatch.setattr(QMessageBox, "information", lambda *args: messages.append(args)) + + NormalizationWorkflowController(window).start_normalization() + + assert len(messages) == 1 + assert messages[0][1] == "Normalization unavailable" + assert "Demo Device" in messages[0][2] + assert "cannot normalize files" in messages[0][2] + assert window.worker is None + + def test_descriptor_settings_panel_renders_all_descriptor_kinds(app) -> None: panel = DescriptorSettingsPanel( ( diff --git a/tests/test_helix.py b/tests/test_helix.py index e8296a9..8d6a6f8 100644 --- a/tests/test_helix.py +++ b/tests/test_helix.py @@ -23,7 +23,7 @@ def load_legacy_preset_handling(): - return importlib.import_module("matchpatch.devices.helix_preset_handling") + return importlib.import_module("matchpatch.devices.helix.preset_handling") def make_handler(tmp_path: Path) -> HelixPatchFileHandler: @@ -250,7 +250,7 @@ def split_setlist_to_preset_data(input_path, selected_ids=None, original_filenam seen["original_filenames"] = original_filenames return [("../Lead.hlx", {"meta": {"name": "Lead"}, "tone": {}})] - monkeypatch.setattr(helix_module, "_load_helix_file_ops", lambda: Helper) + monkeypatch.setattr(helix_module, "_load_helix_file_operations", lambda: Helper) created = handler.split_setlist_file( Path("set.hls"), @@ -470,7 +470,7 @@ def test_helix_module_runner_builds_subprocess_call(tmp_path, monkeypatch) -> No assert command[0][:3] == [ sys.executable, "-m", - "matchpatch.devices.helix_preset_handling", + "matchpatch.devices.helix.preset_handling", ] assert command[0][-1] == "--list-presets" assert options["stdout"] is subprocess.PIPE @@ -482,7 +482,7 @@ def test_frozen_helix_module_runner_executes_in_process(tmp_path, monkeypatch) - original_argv = sys.argv[:] def fake_run_module(module, run_name): - assert module == "matchpatch.devices.helix_preset_handling" + assert module == "matchpatch.devices.helix.preset_handling" assert run_name == "__main__" print("args=" + ",".join(sys.argv[1:])) print("error stream", file=sys.stderr) @@ -497,7 +497,7 @@ def fake_run_module(module, run_name): completed = handler._run("--list-presets", capture=True) - assert completed.args == ["matchpatch.devices.helix_preset_handling", "--list-presets"] + assert completed.args == ["matchpatch.devices.helix.preset_handling", "--list-presets"] assert completed.returncode == 0 assert completed.stdout == "args=--list-presets\n" assert completed.stderr == "error stream\n" diff --git a/tests/test_installer_metadata.py b/tests/test_installer_metadata.py index 7d6ff40..5363888 100644 --- a/tests/test_installer_metadata.py +++ b/tests/test_installer_metadata.py @@ -120,7 +120,7 @@ def test_pyinstaller_specs_include_payload_metadata_docs_and_assets() -> None: assert 'name="MatchPatch"' in gui_spec assert "console=False" in gui_spec assert "datas=asset_datas()" in gui_spec - assert '"matchpatch.devices.helix_preset_handling"' in gui_spec + assert '"matchpatch.devices.helix.preset_handling"' in gui_spec assert '"mido.backends.rtmidi"' in gui_spec assert '"rtmidi"' in gui_spec assert '"src" / "matchpatch" / "app.py"' in gui_spec diff --git a/tests/test_preset_handling.py b/tests/test_preset_handling.py index 3fc89a4..c467db3 100644 --- a/tests/test_preset_handling.py +++ b/tests/test_preset_handling.py @@ -13,7 +13,7 @@ def _load_legacy_module() -> ModuleType: - return importlib.import_module("matchpatch.devices.helix_preset_handling") + return importlib.import_module("matchpatch.devices.helix.preset_handling") def _preset(name: str) -> dict: From 9f2dbe0619b4fb51fd00a2cf42b12fa7f5076cf5 Mon Sep 17 00:00:00 2001 From: Noseglasses Date: Thu, 18 Jun 2026 19:49:25 +0200 Subject: [PATCH 09/10] test: Fix pytest bench --- .pre-commit-config.yaml | 4 +-- tests/gui_test_helpers.py | 4 +++ tests/test_gui.py | 44 ++++++++++++++------------------- tests/test_gui_icons.py | 32 +++++++++--------------- tests/test_gui_save_workflow.py | 12 +++++++++ 5 files changed, 48 insertions(+), 48 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 35b7920..3565be2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -71,8 +71,8 @@ repos: entry: >- bash -c 'export UV_PROJECT_ENVIRONMENT="${XDG_DATA_HOME:-$HOME/.local/share}/matchpatch/.venv-wsl"; python3 scripts/run_hook_with_hint.py - --hint "UV_PROJECT_ENVIRONMENT=\"\${XDG_DATA_HOME:-\$HOME/.local/share}/matchpatch/.venv-wsl\" uv run --frozen --no-default-groups --group wsl pytest" - -- uv run --frozen --no-default-groups --group wsl pytest' + --hint "UV_PROJECT_ENVIRONMENT=\"\${XDG_DATA_HOME:-\$HOME/.local/share}/matchpatch/.venv-wsl\" uv run --frozen --no-default-groups --group wsl pytest --no-cov" + -- uv run --frozen --no-default-groups --group wsl pytest --no-cov' language: system pass_filenames: false stages: diff --git a/tests/gui_test_helpers.py b/tests/gui_test_helpers.py index 51623fc..9478360 100644 --- a/tests/gui_test_helpers.py +++ b/tests/gui_test_helpers.py @@ -54,6 +54,10 @@ def mock_single_hlx_handler( ] class Handler: + @staticmethod + def file_kind(path): + return "preset" + @staticmethod def validate_input(path): return None diff --git a/tests/test_gui.py b/tests/test_gui.py index 66cb188..bd31c92 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -35,7 +35,7 @@ from shiboken6 import isValid from matchpatch.diagnostics import DiagnosticCheck -from matchpatch.gui import main_window, measurement_optimization, progress_widgets +from matchpatch.gui import main_window, measurement_optimization, progress_widgets, window_state from matchpatch.gui import worker as gui_worker from matchpatch.gui.main_window import MainWindow from matchpatch.gui.preset_table import ( @@ -118,12 +118,10 @@ def list_assignments(path): def metadata(path): return {"file_type": "hlx"} - class Profile: - @staticmethod - def create_patch_file_handler(root): - return Handler() + Handler.file_kind = staticmethod(lambda path: "preset") - monkeypatch.setattr(main_window, "get_device_profile", lambda device: Profile()) + profile = SimpleNamespace(create_patch_file_handler=lambda root: Handler()) + monkeypatch.setattr(main_window, "get_device_profile", lambda device: profile) class _SignalStub: @@ -501,7 +499,7 @@ def test_main_window_starts_with_registry_device_and_hardware(app) -> None: assert not window.play_recorded_output_button.isChecked() assert window.log_level.currentText() == "Info" assert window.metadata_text.toPlainText() == "{}" - assert window.device_stack.count() == 1 + assert window.device_stack.count() == 2 assert window.device_panels["helix"].audio_group.isEnabled() assert window.progress_group.sizePolicy().verticalPolicy() == QSizePolicy.Policy.Maximum assert not window.statusBar().isHidden() @@ -636,12 +634,10 @@ def list_assignments(path): def metadata(path): return {"file_type": "hls"} - class Profile: - @staticmethod - def create_patch_file_handler(root): - return Handler() + Handler.file_kind = staticmethod(lambda path: "setlist") - monkeypatch.setattr(main_window, "get_device_profile", lambda device: Profile()) + profile = SimpleNamespace(create_patch_file_handler=lambda root: Handler()) + monkeypatch.setattr(main_window, "get_device_profile", lambda device: profile) window.show() app.processEvents() initial_size = window.size() @@ -882,12 +878,10 @@ def list_assignments(path): def metadata(path): return {"file_type": "hls", "metadata": [{"path": "$.meta", "value": {"name": "Set"}}]} - class Profile: - @staticmethod - def create_patch_file_handler(root): - return Handler() + Handler.file_kind = staticmethod(lambda path: "setlist") - monkeypatch.setattr(main_window, "get_device_profile", lambda device: Profile()) + profile = SimpleNamespace(create_patch_file_handler=lambda root: Handler()) + monkeypatch.setattr(main_window, "get_device_profile", lambda device: profile) window.input_path.setText(str(path)) window.load_assignments() @@ -956,12 +950,12 @@ def list_assignments(path): def metadata(path): return {"file_type": "hls"} - class Profile: - @staticmethod - def create_patch_file_handler(root): - return Handler() + Handler.file_kind = staticmethod( + lambda path: "preset" if Path(path).suffix.lower() == ".hlx" else "setlist" + ) - monkeypatch.setattr(main_window, "get_device_profile", lambda device: Profile()) + profile = SimpleNamespace(create_patch_file_handler=lambda root: Handler()) + monkeypatch.setattr(main_window, "get_device_profile", lambda device: profile) window.input_path.setText(str(path)) window.load_assignments() @@ -1072,7 +1066,7 @@ def test_startup_open_button_loads_like_toolbar_open(tmp_path, monkeypatch, app) assert window.preset_table.item(0, 1).text() == "" assert window.preset_table.item(0, 2).text() == "Embedded" assert window.preset_empty_state.isHidden() - assert QSettings().value(main_window.RECENT_FILES_SETTINGS_KEY) == [path] + assert QSettings().value(window_state.RECENT_FILES_SETTINGS_KEY) == [path] window.close() @@ -1085,7 +1079,7 @@ def test_startup_recent_files_selector_loads_selected_file(tmp_path, monkeypatch older = str(older_file) recent_path = str(recent_file) recent = [older, recent_path] - QSettings().setValue(main_window.RECENT_FILES_SETTINGS_KEY, recent) + QSettings().setValue(window_state.RECENT_FILES_SETTINGS_KEY, recent) window = MainWindow() _mock_single_hlx_handler(monkeypatch, name="Recent") @@ -1099,7 +1093,7 @@ def test_startup_recent_files_selector_loads_selected_file(tmp_path, monkeypatch assert window.input_path.text() == recent_path assert window.preset_table.rowCount() == 1 assert window.preset_table.item(0, 2).text() == "Recent" - assert QSettings().value(main_window.RECENT_FILES_SETTINGS_KEY) == [recent_path, older] + assert QSettings().value(window_state.RECENT_FILES_SETTINGS_KEY) == [recent_path, older] window.close() diff --git a/tests/test_gui_icons.py b/tests/test_gui_icons.py index 15b2a77..172fdd8 100644 --- a/tests/test_gui_icons.py +++ b/tests/test_gui_icons.py @@ -106,6 +106,7 @@ from matchpatch.gui.table_roles import ( IGNORE_REASON_COMPARISON, IGNORE_REASON_PRESET, + IGNORE_REASON_PRESET_REGEX, IGNORE_REASON_REGEX, ) from matchpatch.gui.worker import NormalizationWorker @@ -282,14 +283,13 @@ def test_toolbar_tooltip_position_is_kept_inside_screen() -> None: def test_preset_table_legend_dialog_uses_table_icons(app) -> None: - ignore_reason_icons = { - reason: icons._ignore_reason_icon(reason) - for reason in ( - IGNORE_REASON_PRESET, - IGNORE_REASON_COMPARISON, - IGNORE_REASON_REGEX, - ) - } + ignore_reasons = ( + IGNORE_REASON_PRESET, + IGNORE_REASON_COMPARISON, + IGNORE_REASON_REGEX, + IGNORE_REASON_PRESET_REGEX, + ) + ignore_reason_icons = {reason: icons._ignore_reason_icon(reason) for reason in ignore_reasons} parent = QWidget() dialog = table_legend.build_preset_table_legend_dialog( @@ -325,22 +325,12 @@ def test_preset_table_legend_dialog_uses_table_icons(app) -> None: label_text.index(entry) for entry in color_entries ) pixmap_labels = [ - dialog.findChild(QLabel, f"legendIgnoreIcon{reason}") - for reason in ( - IGNORE_REASON_PRESET, - IGNORE_REASON_COMPARISON, - IGNORE_REASON_REGEX, - ) + dialog.findChild(QLabel, f"legendIgnoreIcon{reason}") for reason in ignore_reasons ] assert all(label is not None for label in pixmap_labels) - assert len(pixmap_labels) == 3 + assert len(pixmap_labels) == len(ignore_reasons) assert {label.pixmap().cacheKey() for label in pixmap_labels if label is not None} == { - ignore_reason_icons[reason].pixmap(18, 18).cacheKey() - for reason in ( - IGNORE_REASON_PRESET, - IGNORE_REASON_COMPARISON, - IGNORE_REASON_REGEX, - ) + ignore_reason_icons[reason].pixmap(18, 18).cacheKey() for reason in ignore_reasons } color_swatches = dialog.findChildren(QLabel, "legendColorSwatch") assert len(color_swatches) == 6 diff --git a/tests/test_gui_save_workflow.py b/tests/test_gui_save_workflow.py index 46db610..bdc9efb 100644 --- a/tests/test_gui_save_workflow.py +++ b/tests/test_gui_save_workflow.py @@ -808,6 +808,10 @@ def test_discarding_before_normalization_preserves_preset_selection( input_path.write_text("{}", encoding="utf-8") class Handler: + @staticmethod + def file_kind(path): + return "setlist" + @staticmethod def validate_input(path): return None @@ -1195,6 +1199,10 @@ def test_single_preset_save_as_preserves_preset_table_state(tmp_path, monkeypatc exports = [] class Handler: + @staticmethod + def file_kind(path): + return "preset" + @staticmethod def validate_input(path): return None @@ -1261,6 +1269,10 @@ def test_saving_table_changes_preserves_preset_selection(tmp_path, monkeypatch, csv_path.touch() class Handler: + @staticmethod + def file_kind(path): + return "setlist" + @staticmethod def validate_input(path): return None From 35902506098a3d3f28081e13e3b113363ef75d48 Mon Sep 17 00:00:00 2001 From: Noseglasses Date: Thu, 18 Jun 2026 23:39:14 +0200 Subject: [PATCH 10/10] ci: Fix failing tests --- tests/test_cli.py | 2 +- tests/test_gui.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 74dac19..6ffe333 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -116,7 +116,7 @@ def fake_split_setlist_file(device, input_path, output_dir, *, selected_ids=None assert calls == [("helix", Path("setlist.hls"), Path("presets"), [1])] output = capsys.readouterr().out assert "Split 1 preset files into presets" in output - assert "presets/Lead.hlx" in output + assert str(Path("presets") / "Lead.hlx") in output def test_environment_command_prints_runtime(monkeypatch, capsys) -> None: diff --git a/tests/test_gui.py b/tests/test_gui.py index bd31c92..85567e0 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1006,7 +1006,7 @@ def test_input_browse_prompts_before_discarding_preset_adjustments(monkeypatch, _FakeSaveChangesMessageBox.next_click = next(answers) window.browse_input() - assert window.input_path.text() == "/tmp/new.hlx" + assert window.input_path.text() == str(Path("/tmp/new.hlx")) assert window.preset_table.rowCount() == 1 assert window.preset_table.item(0, 1).text() == "" assert window.preset_table.item(0, 2).text() == "New" @@ -1042,7 +1042,7 @@ def test_input_browse_does_not_prompt_for_clean_preset_table(monkeypatch, app) - window.browse_input() - assert window.input_path.text() == "/tmp/new.hlx" + assert window.input_path.text() == str(Path("/tmp/new.hlx")) window.close()