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/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/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/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/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/architecture.md b/docs/dev/architecture.md index ba99920..b3892d4 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 @@ -150,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 @@ -171,8 +171,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 +182,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 +242,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..eef9f93 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..124d241 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]} @@ -55,9 +55,15 @@ 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 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 +134,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 +209,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 +228,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/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/index.md b/docs/index.md index 299b943..75b0b8b 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) +- [Adding Devices](adding-devices.md) - [Release Checklist](dev/release.md) ```{toctree} @@ -107,6 +108,7 @@ glossary developer-notes dev/architecture dev/commands +adding-devices dev/file-formats dev/release ``` 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/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/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/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..a78be01 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 fecdbdf..12bf980 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,7 +94,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/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..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] @@ -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, @@ -130,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/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 89f1351..b389356 100644 --- a/src/matchpatch/devices/base.py +++ b/src/matchpatch/devices/base.py @@ -2,12 +2,157 @@ from __future__ import annotations +import re from abc import ABC, abstractmethod -from collections.abc import Callable -from dataclasses import dataclass +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field from pathlib import Path from types import TracebackType -from typing import 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 + show_in_gui: bool = True + + +@dataclass(frozen=True) +class DeviceTerminology: + device: str = "device" + preset: str = "preset" + snapshot: str = "snapshot" + 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 + 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 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) +class PresetFileRecord: + path: Path + slot_id: int | None + device_patch: str | None + name: str + 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) @@ -18,6 +163,48 @@ class PatchAssignment: snapshot_names: tuple[str, ...] = () 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) @@ -44,11 +231,39 @@ 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 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 @@ -56,11 +271,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 @@ -82,6 +384,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.""" @@ -99,14 +447,109 @@ 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 {} + 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" + + 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") + 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, @@ -119,10 +562,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, @@ -132,6 +603,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.""" @@ -154,10 +643,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 @@ -171,6 +753,81 @@ 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 audio_transport_factories(self) -> tuple[AudioTransportFactory, ...]: + """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), + 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) @@ -183,11 +840,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/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 61% rename from src/matchpatch/devices/helix.py rename to src/matchpatch/devices/helix/__init__.py index 4c7e339..0fb6e64 100644 --- a/src/matchpatch/devices/helix.py +++ b/src/matchpatch/devices/helix/__init__.py @@ -6,31 +6,45 @@ import csv import io import json +import re import runpy import subprocess 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, + DeviceFileType, DeviceProfile, + DeviceSettingDescriptor, + DeviceTerminology, + FileOperationCapabilities, + GainPoint, + NamingRules, NormalizationPolicy, PatchAssignment, PatchFileAdjustments, 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\-_+=!@#$&()?:'",./ ]*$""") +HELIX_NAME_CHAR_PATTERN = re.compile(r"""[A-Za-z0-9\-_+=!@#$&()?:'",./ ]""") + 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: @@ -49,7 +63,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, @@ -73,11 +87,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() @@ -87,7 +101,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 @@ -145,6 +159,8 @@ 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, + gain_points=_assignment_gain_points(assignment), ) for assignment in json.loads(completed.stdout) ] @@ -156,6 +172,84 @@ 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_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": + 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) + split_presets = _load_helix_file_operations().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( @@ -463,10 +557,61 @@ 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) + 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=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]}" @@ -488,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) @@ -500,3 +760,26 @@ def _error_details(exc: subprocess.CalledProcessError) -> str: return "\n".join(errors) return lines[-1].strip() if lines else "" + + +def _load_helix_file_operations() -> Any: # noqa: ANN401 + return 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/devices/helix/file_ops.py b/src/matchpatch/devices/helix/file_ops.py new file mode 100644 index 0000000..16e6add --- /dev/null +++ b/src/matchpatch/devices/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/src/matchpatch/devices/helix/preset_handling.py similarity index 94% rename from Python/preset_handling.py rename to src/matchpatch/devices/helix/preset_handling.py index fca7454..06e3d93 100644 --- a/Python/preset_handling.py +++ b/src/matchpatch/devices/helix/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,28 @@ import os import re import sys -import zlib from dataclasses import dataclass +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 ( + 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, +) + # ================================================= # HELIX CONSTANTS # ================================================= @@ -89,22 +106,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: @@ -349,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 ) @@ -587,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", {}) @@ -731,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) @@ -748,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." ) @@ -983,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] @@ -1577,36 +1587,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 +1646,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 +1658,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 +1771,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 +1874,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 +1971,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/devices/registry.py b/src/matchpatch/devices/registry.py index 2440a02..592b415 100644 --- a/src/matchpatch/devices/registry.py +++ b/src/matchpatch/devices/registry.py @@ -1,22 +1,39 @@ -"""Registry of audio processor profiles.""" +"""Registry of built-in audio processor profiles.""" from __future__ import annotations +from matchpatch.devices.available import DEVICE_PROFILES from matchpatch.devices.base import DeviceProfile -from matchpatch.devices.helix import HelixDeviceProfile -_PROFILES: dict[str, DeviceProfile] = { - "helix": HelixDeviceProfile(), -} + +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: + 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 get_device_profile(name: str) -> DeviceProfile: + profiles = _profiles_by_name() try: - return _PROFILES[name] + return profiles[name] except KeyError as exc: - supported = ", ".join(sorted(_PROFILES)) + supported = ", ".join(sorted(profiles)) 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 = _profiles_by_name() + return [profiles[profile.name] for profile in DEVICE_PROFILES] diff --git a/src/matchpatch/diagnostics.py b/src/matchpatch/diagnostics.py index 74c29f3..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, } @@ -463,6 +467,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, @@ -529,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/file_operations.py b/src/matchpatch/file_operations.py new file mode 100644 index 0000000..e03c043 --- /dev/null +++ b/src/matchpatch/file_operations.py @@ -0,0 +1,80 @@ +"""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, + 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: + 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, + 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: + 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..c572289 100644 --- a/src/matchpatch/gui/device_panels.py +++ b/src/matchpatch/gui/device_panels.py @@ -14,6 +14,9 @@ QWidget, ) +from matchpatch.devices.base import DeviceProfile +from matchpatch.gui.settings_renderer import DescriptorSettingsPanel + class HelixSettingsPanel(QWidget): def __init__(self, backend_selector: QWidget | None = None) -> None: @@ -113,3 +116,16 @@ 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) + 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/file_operations_workflow.py b/src/matchpatch/gui/file_operations_workflow.py new file mode 100644 index 0000000..8659878 --- /dev/null +++ b/src/matchpatch/gui/file_operations_workflow.py @@ -0,0 +1,312 @@ +"""GUI helpers for device file split/join operations.""" + +from __future__ import annotations + +import tempfile +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 import get_device_profile +from matchpatch.devices.base import ( + DeviceFileKind, + DeviceFileType, + 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 _mark_joined_setlist_staged(self, path: Path) -> None: ... + + def _set_optional_widget_enabled(self, name: str, enabled: bool) -> None: ... + + +ProfileProvider = Callable[[str], DeviceProfile] + + +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=_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, + file_types: Iterable[DeviceFileType] = (), +) -> Path | None: + path, _ = QFileDialog.getSaveFileName( + parent, + "Save joined setlist", + filter=_kind_file_filter(file_types, "setlist", "Setlist files (*.hls)"), + ) + if not path: + return None + output_path = Path(path) + suffix = _kind_extension(file_types, "setlist", ".hls") + 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 + + +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) + 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 = 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 {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 + + +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, + log_callback=lambda message: window._log(message, "info"), + ) + 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/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 af9e4a7..b7f19a7 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,21 @@ write_diagnostic_bundle, ) from matchpatch.gui import diagnostics_panel as gui_diagnostics +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 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, ) @@ -116,6 +122,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 @@ -159,6 +170,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, @@ -219,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 @@ -263,13 +274,16 @@ 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() 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 @@ -322,7 +336,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 +349,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,16 +683,19 @@ 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( - self, "Choose patch file", filter="Patches (*.hls *.hlx)" + 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) @@ -716,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() @@ -736,51 +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() - if suffix not in {".hls", ".hlx"}: - 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)" + 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, ) - 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 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"}: - 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) - 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") @@ -892,10 +886,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: @@ -1597,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: @@ -1695,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)) @@ -1714,18 +1721,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,7 +1736,17 @@ 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._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", @@ -2333,6 +2346,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, @@ -2350,14 +2368,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: @@ -2485,7 +2496,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..7d9b0f3 100644 --- a/src/matchpatch/gui/main_window_callbacks.py +++ b/src/matchpatch/gui/main_window_callbacks.py @@ -4,11 +4,17 @@ import re from pathlib import Path -from typing import Any +from typing import Any, Callable 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, @@ -22,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: @@ -59,6 +73,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() @@ -141,6 +167,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/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/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/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/preset_table.py b/src/matchpatch/gui/preset_table.py index 0140cff..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, @@ -52,6 +51,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 +60,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, @@ -98,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: ... @@ -138,6 +147,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 +213,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()): @@ -336,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): @@ -729,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) @@ -759,6 +830,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): @@ -785,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()): @@ -804,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 @@ -829,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) @@ -1195,6 +1272,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 +1369,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/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 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/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..6c31470 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 ( @@ -32,6 +33,7 @@ QHBoxLayout, QLabel, QLineEdit, + QMenu, QProgressBar, QPushButton, QSizePolicy, @@ -48,7 +50,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 @@ -201,13 +205,39 @@ 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) + 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) + ) + ) + + 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], + ) + ) + window.normalization_separator_action = toolbar.addSeparator() window.start_button = QToolButton(parent) window.start_button.setIcon(_normalization_icon()) @@ -225,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) @@ -294,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, @@ -330,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 @@ -943,6 +1014,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 +1031,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..36f619a 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( @@ -196,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 @@ -225,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() @@ -237,7 +261,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 ) @@ -248,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 @@ -264,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: @@ -289,7 +322,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..3c633ed 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,28 +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; " + "enable a device-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) - 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, @@ -771,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 @@ -830,31 +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) - 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} @@ -885,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)) @@ -943,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, @@ -973,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( @@ -1232,9 +1436,11 @@ 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_audio_config(args, config, profile) - _apply_timing_config(args, config, profile) + _apply_backend_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) @@ -1242,81 +1448,53 @@ 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" + _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), @@ -1415,7 +1593,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 +1623,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..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, @@ -121,6 +122,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 +159,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 @@ -182,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") @@ -226,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( @@ -308,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], @@ -927,12 +881,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", ) @@ -1008,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 17bffb9..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 @@ -55,7 +55,8 @@ 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(_device_diagnostic_checks(request, profile, handler)) checks.extend( _backend_specific_checks( request, @@ -183,16 +184,55 @@ 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}", ) +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/README.md b/tests/README.md index 016059f..5180f3d 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/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_cli.py b/tests/test_cli.py index a7223db..6ffe333 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 str(Path("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_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 0f272d9..19aecce 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -1,11 +1,32 @@ from __future__ import annotations +import json from pathlib import Path 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, + DeviceTargetId, + FileOperationCapabilities, + GainAdjustment, + GainPoint, + MeasurementBackendCapabilities, + MeasurementSubdivision, + MeasurementTarget, + NamingRules, + PatchAssignment, + PatchFileHandler, + SteeringOptions, + SubdivisionSelection, + TargetSelection, + validate_snapshot_count, +) +from matchpatch.devices.demo import DemoPatchFileHandler def test_helix_profile_defines_processor_boundaries() -> None: @@ -21,11 +42,89 @@ 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" +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) @@ -49,3 +148,567 @@ 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 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 = [] + 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( + 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 BasicProfile(DeviceProfile): + name = "basic-device" + display_name = "Basic 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(BasicProfile): + 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(BasicProfile): + 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 PermissiveNamingProfile(BasicProfile): + name = "permissive-names" + display_name = "Permissive Names" + + +class RestrictiveNamingProfile(BasicProfile): + 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"}), + ) + + +def test_default_device_capabilities_are_backward_compatible() -> None: + profile = BasicProfile() + 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_device_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() + 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"): + file_operations.join_preset_files( + "basic-device", + [tmp_path / "one.preset"], + tmp_path / "joined.setlist", + get_profile=lambda device: BasicProfile(), + ) + + +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( + "basic-device", + tmp_path / "joined.setlist", + tmp_path / "presets", + get_profile=lambda device: BasicProfile(), + ) + + +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", + "demo-device", + ] + + +def test_duplicate_builtin_device_names_are_reported(monkeypatch) -> None: + class DuplicateProfile(BasicProfile): + name = "helix" + + monkeypatch.setattr(registry, "DEVICE_PROFILES", (DuplicateProfile(), DuplicateProfile())) + + with pytest.raises(ValueError, match="duplicate device profile name 'helix'"): + registry.list_device_profiles() + + +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" + ) + + +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() + + 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)], + ) + + 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 [(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_diagnostics.py b/tests/test_diagnostics.py index 1c844ed..d8305fd 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( @@ -66,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) @@ -75,7 +81,10 @@ 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["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) @@ -250,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 210d3f0..85567e0 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -35,13 +35,13 @@ 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 ( 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 @@ -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: @@ -230,6 +228,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) @@ -344,7 +343,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() @@ -457,6 +490,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() @@ -465,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() @@ -514,6 +548,53 @@ 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( + 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 + 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() @@ -553,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() @@ -722,6 +801,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,18 +865,23 @@ 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): 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() @@ -804,6 +889,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() @@ -864,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() @@ -905,8 +991,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 = [] @@ -920,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" @@ -945,8 +1031,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, @@ -956,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() @@ -969,8 +1055,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() @@ -980,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() @@ -993,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") @@ -1007,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_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_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_preset_table.py b/tests/test_gui_preset_table.py index 0780a31..4947dcc 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, ) @@ -187,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 @@ -359,6 +377,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()) @@ -414,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()) @@ -807,6 +872,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..bdc9efb 100644 --- a/tests/test_gui_save_workflow.py +++ b/tests/test_gui_save_workflow.py @@ -53,15 +53,23 @@ ) from shiboken6 import isValid -from matchpatch.devices.base import NormalizationPolicy, PatchFileAdjustments +from matchpatch.devices.base import ( + DeviceFileType, + FileOperationCapabilities, + NormalizationPolicy, + PatchFileAdjustments, +) from matchpatch.diagnostics import DiagnosticCheck from matchpatch.gui import ( advanced_settings, + file_operations_workflow, icons, 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 ( @@ -185,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) @@ -359,6 +367,372 @@ 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_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: list[str] = [] + staged_paths: list[Path] = [] + calls = [] + monkeypatch.setattr( + file_operations_workflow, + "choose_join_preset_paths", + lambda parent, file_types=(): 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(window, "_mark_joined_setlist_staged", staged_paths.append) + + 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 + ) + + monkeypatch.setattr( + file_operations_workflow.file_operations, + "join_preset_files", + join_preset_files, + ) + + assert file_operations_workflow.join_preset_files(window) + + 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() + + +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_names(*args, **kwargs): + filters.append(kwargs["filter"]) + return [], "" + + monkeypatch.setattr(QFileDialog, "getOpenFileNames", get_open_file_names) + + window.browse_input() + + assert filters == ["Patches (*.setlist *.preset)"] + 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"] + staged_path = tmp_path / "joined.setlist" + 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( + 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, **kwargs: ( + file_operations_workflow.file_operations.JoinPresetFilesResult( + output_path=selected_output_path + ) + ), + ) + + assert file_operations_workflow.join_preset_files(window) + + assert filters == ["Preset files (*.preset)"] + 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, + log_callback=None, + ): + calls.append( + ( + device, + selected_input_path, + selected_output_dir, + selected_ids, + original_filenames, + log_callback, + ) + ) + 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 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() + + 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")) @@ -434,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 @@ -529,7 +907,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", @@ -549,6 +927,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: @@ -592,7 +1099,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 @@ -673,7 +1180,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)) @@ -692,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 @@ -758,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 @@ -861,7 +1376,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() diff --git a/tests/test_gui_settings_renderer.py b/tests/test_gui_settings_renderer.py new file mode 100644 index 0000000..10b0a98 --- /dev/null +++ b/tests/test_gui_settings_renderer.py @@ -0,0 +1,398 @@ +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, 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 + + +@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_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( + ( + 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 45999f7..8d6a6f8 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 @@ -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, @@ -22,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: @@ -51,6 +46,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) @@ -109,8 +120,68 @@ 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: + 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: @@ -134,6 +205,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_operations", lambda: 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 = [] @@ -320,7 +457,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") @@ -330,48 +467,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..5363888 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_measure.py b/tests/test_measure.py index 9ee3347..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 @@ -953,6 +1156,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..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: @@ -268,6 +372,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 @@ -312,9 +417,13 @@ 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$" + 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 @@ -365,6 +474,30 @@ 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: + 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( @@ -439,6 +572,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..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 @@ -27,11 +28,18 @@ 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"), + 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, ) @@ -56,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()) @@ -65,6 +91,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") @@ -119,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, diff --git a/tests/test_preset_handling.py b/tests/test_preset_handling.py index 4482dde..c467db3 100644 --- a/tests/test_preset_handling.py +++ b/tests/test_preset_handling.py @@ -2,23 +2,85 @@ import base64 import binascii -import importlib.util +import copy +import importlib import json +import sys 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: + return { + "meta": {"name": name}, + "tone": { + "dsp0": { + "inputA": {"@input": 1}, + "block0": {}, + "outputA": {"@output": 6, "gain": 0.0}, + }, + "snapshot0": {"@name": "Snapshot 1"}, + }, + } + + +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 = { + "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: @@ -145,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 = { @@ -314,3 +484,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"}