diff --git a/.github/workflows/validate-brand-yaml.yml b/.github/workflows/validate-brand-yaml.yml new file mode 100644 index 00000000..02abd512 --- /dev/null +++ b/.github/workflows/validate-brand-yaml.yml @@ -0,0 +1,47 @@ +name: Validate Brand YAML + +on: + pull_request: + branches: + - master + types: + - opened + - synchronize + - reopened + paths: + - 'data/brands/**' + - 'data/mapping.json' + - 'validate_brand_yaml.py' + +jobs: + validate-brand-yaml: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install dependencies + run: pip install pyyaml + + - name: Validate changed brand YAML files + run: | + BASE_SHA="${{ github.event.pull_request.base.sha }}" + mapfile -t files < <( + git diff --name-only --diff-filter=ACMR "$BASE_SHA" HEAD -- 'data/brands/**/*.yaml' + ) + + if ((${#files[@]} == 0)); then + echo "No brand YAML files added or modified in this PR." + exit 0 + fi + + printf 'Validating %s changed file(s):\n' "${#files[@]}" + printf ' %s\n' "${files[@]}" + python validate_brand_yaml.py "${files[@]}" diff --git a/data/brands/arturia/matrixbrute.yaml b/data/brands/arturia/matrixbrute.yaml index d750e663..1075a3d6 100644 --- a/data/brands/arturia/matrixbrute.yaml +++ b/data/brands/arturia/matrixbrute.yaml @@ -39,7 +39,7 @@ cc: max: 127 min: 0 name: 'VCO1: Ultrasaw' - value: 668 + value: 68 - description: '' max: 127 min: 0 diff --git a/data/brands/chasebliss/cxm1978.yaml b/data/brands/chasebliss/cxm1978.yaml index 823398a7..4e0dac83 100644 --- a/data/brands/chasebliss/cxm1978.yaml +++ b/data/brands/chasebliss/cxm1978.yaml @@ -113,7 +113,7 @@ cc: min: 0 max: 127 - name: AUX PERF SWITCH 3 - value: 230 + value: 30 description: "Any value will trigger this event" type: System min: 0 diff --git a/data/brands/disasterarea/dpc8ezgen2.yaml b/data/brands/disasterarea/dpc8ezgen2.yaml index f7ec6858..8d9e24e6 100644 --- a/data/brands/disasterarea/dpc8ezgen2.yaml +++ b/data/brands/disasterarea/dpc8ezgen2.yaml @@ -123,4 +123,4 @@ cc: type: null value: 122 pc: - - description: Loads Presets 0 - 127 + description: Loads Presets 0 - 127 diff --git a/data/brands/disasterarea/dpcmicro.yaml b/data/brands/disasterarea/dpcmicro.yaml index 39c42744..9a931175 100644 --- a/data/brands/disasterarea/dpcmicro.yaml +++ b/data/brands/disasterarea/dpcmicro.yaml @@ -25,3 +25,4 @@ cc: max: 127 pc: + description: '' diff --git a/data/brands/drscientist/dusk.yaml b/data/brands/drscientist/dusk.yaml index 1617ded3..7534f094 100644 --- a/data/brands/drscientist/dusk.yaml +++ b/data/brands/drscientist/dusk.yaml @@ -2,11 +2,10 @@ midi_channel: instructions: |+ pc: - - description: |+ - PC# 0 = Bypass - PC# 1 – 4 = Internal Preset # 1 – 4 - PC# 5 – 99 = Presets saved with MIDI CC #55 - + description: |+ + PC# 0 = Bypass + PC# 1 – 4 = Internal Preset # 1 – 4 + PC# 5 – 99 = Presets saved with MIDI CC #55 cc: - name: Remote Tap diff --git a/data/brands/electroharmonix/pog3.yaml b/data/brands/electroharmonix/pog3.yaml index 596ac47c..e0c7c04e 100644 --- a/data/brands/electroharmonix/pog3.yaml +++ b/data/brands/electroharmonix/pog3.yaml @@ -1,5 +1,7 @@ brand: Electro-Harmonix model: POG3 Polyphonic Octave Generator +midi_channel: + instructions: '' pc: description: |+ PC Messages 1 to 100: Loads the preset associated with the MIDI PC number diff --git a/data/brands/emcustom/amiuniversaldualtrs.yaml b/data/brands/emcustom/amiuniversaldualtrs.yaml index d0252666..11ab34fc 100644 --- a/data/brands/emcustom/amiuniversaldualtrs.yaml +++ b/data/brands/emcustom/amiuniversaldualtrs.yaml @@ -1,6 +1,10 @@ brand: EM Custom model: AMI Universal Dual TRS url: 'https://emcustom.eu/downloads/AMI_MI.pdf' +midi_channel: + instructions: '' +pc: + description: '' data: - name: Tip 1 Off value: '80' diff --git a/data/brands/g-lab/bc1.yaml b/data/brands/g-lab/bc1.yaml index 1ee515c6..f4c33865 100644 --- a/data/brands/g-lab/bc1.yaml +++ b/data/brands/g-lab/bc1.yaml @@ -19,79 +19,79 @@ pc: cc: - name: COMP OFF value: 108 - description: [Description of function] + description: '' type: Parameter min: 0 max: 63 - name: COMP ON value: 108 - description: [Description of function] + description: '' type: Parameter min: 64 max: 127 - name: SELECT COMP A value: 109 - description: [Description of function] + description: '' type: Parameter min: 0 max: 63 - name: SELECT COMP B value: 109 - description: [Description of function] + description: '' type: Parameter min: 64 max: 127 - name: SELECT COMP A value: 110 - description: [Description of function] + description: '' type: Parameter min: 1 max: 1 - name: SELECT COMP B value: 110 - description: [Description of function] + description: '' type: Parameter min: 2 max: 2 - name: SELECT COMP C value: 110 - description: [Description of function] + description: '' type: Parameter min: 3 max: 3 - name: BYPASS ON value: 111 - description: [Description of function] + description: '' type: Parameter min: 1 max: 1 - name: BUFFER ON value: 111 - description: [Description of function] + description: '' type: Parameter min: 2 max: 2 - name: COMP A ON value: 111 - description: [Description of function] + description: '' type: Parameter min: 3 max: 3 - name: COMP B ON value: 111 - description: [Description of function] + description: '' type: Parameter min: 4 max: 4 - name: COMP C ON value: 111 - description: [Description of function] + description: '' type: Parameter min: 5 max: 5 - name: SEL COMP ON value: 111 - description: [Description of function] + description: '' type: Parameter min: 6 max: 6 diff --git a/data/brands/kinotone/ribbons.yaml b/data/brands/kinotone/ribbons.yaml index 5188388f..732f9548 100644 --- a/data/brands/kinotone/ribbons.yaml +++ b/data/brands/kinotone/ribbons.yaml @@ -1,5 +1,9 @@ brand: Kinotone device_name: Ribbons +midi_channel: + instructions: '' +pc: + description: '' cc: - name: High Pass Filter value: 0 diff --git a/data/brands/moog/matriarch.yaml b/data/brands/moog/matriarch.yaml index 3324fd49..c350804e 100644 --- a/data/brands/moog/matriarch.yaml +++ b/data/brands/moog/matriarch.yaml @@ -565,7 +565,7 @@ cc: Default 8192 = 50% / no swing type: System min: 0 - max: 16383 + max: 127 - name: Sequence Keyboard Control value: 24 description: | @@ -845,7 +845,7 @@ cc: Default 0 / Off type: System min: 0 - max: 400 + max: 127 - name: KB CV OUT Range value: 59 description: | @@ -949,7 +949,7 @@ cc: Default 16383 type: System min: 0 - max: 16383 + max: 127 - name: Arp/Seq Random Repeats value: 72 description: > diff --git a/data/brands/novation/ultranova.yaml b/data/brands/novation/ultranova.yaml index b43b5d04..cec9e413 100644 --- a/data/brands/novation/ultranova.yaml +++ b/data/brands/novation/ultranova.yaml @@ -349,7 +349,7 @@ cc: max: 127 min: 0 name: AmpEnv Decay - value: 775 + value: 75 - description: '' max: 127 min: 0 diff --git a/data/brands/onecontrol/midisolostereoloop.yaml b/data/brands/onecontrol/midisolostereoloop.yaml index 6c20ded2..e599136a 100644 --- a/data/brands/onecontrol/midisolostereoloop.yaml +++ b/data/brands/onecontrol/midisolostereoloop.yaml @@ -14,12 +14,12 @@ pc: phantom_power: unknown midi_mapping: not applicable cc: - - name: off + - name: 'off' value: '102' description: 'disengage loop' min: 0 max: 63 - - name: on + - name: 'on' value: '102' description: 'engage loop' min: 64 diff --git a/data/brands/roland/fr-18d.yaml b/data/brands/roland/fr-18d.yaml index 38a09033..0399f355 100644 --- a/data/brands/roland/fr-18d.yaml +++ b/data/brands/roland/fr-18d.yaml @@ -1,5 +1,9 @@ brand: 'Roland' model: FR-18d +midi_channel: + instructions: '' +pc: + description: '' data: - name: Bank Select LSB value: 0 diff --git a/data/brands/singular_sound/aeros_looper.yaml b/data/brands/singular_sound/aeros_looper.yaml index bb765671..033ddf22 100644 --- a/data/brands/singular_sound/aeros_looper.yaml +++ b/data/brands/singular_sound/aeros_looper.yaml @@ -420,6 +420,7 @@ cc: type: system value: 43 - description: Cancel Stop Stops the song at the end of the longest loop in the current song part + max: 4 min: 4 name: Stop End of Loop type: system diff --git a/data/brands/sourceaudio/collider.yaml b/data/brands/sourceaudio/collider.yaml index 8f038f63..945c3108 100644 --- a/data/brands/sourceaudio/collider.yaml +++ b/data/brands/sourceaudio/collider.yaml @@ -326,13 +326,13 @@ cc: max: 5 - name: Preset Decrement value: 80 - description: [Description of function] + description: '' type: System min: 0 max: 127 - name: Preset Increment value: 82 - description: [Description of function] + description: '' type: System min: 0 max: 127 @@ -368,7 +368,7 @@ cc: max: 127 - name: Toggle Delay (A) Bypass/Engage value: 102 - description: [Description of function] + description: '' type: System min: 0 max: 127 @@ -386,7 +386,7 @@ cc: max: 127 - name: Toggle Reverb (B) Bypass/Engage value: 104 - description: [Description of function] + description: '' type: System min: 0 max: 127 diff --git a/data/brands/synergyamps/syn20ir.yaml b/data/brands/synergyamps/syn20ir.yaml index bc027693..60d77885 100644 --- a/data/brands/synergyamps/syn20ir.yaml +++ b/data/brands/synergyamps/syn20ir.yaml @@ -4,11 +4,12 @@ midi_thru: No phantom_power: Yes midi_clock: No -midi_channel: |+ - Out of the box, the SYN-20IR is set up to receive MIDI program changes on MIDI channel OMNI. - You can change the MIDI channel using the free Synergy editor software. +midi_channel: + instructions: |+ + Out of the box, the SYN-20IR is set up to receive MIDI program changes on MIDI channel OMNI. + You can change the MIDI channel using the free Synergy editor software. - Values and descriptions are based on the official Synergy Amps documentation (SYNERGY-SYN-20IR-MANUAL-FINAL-2-13-2025) + Values and descriptions are based on the official Synergy Amps documentation (SYNERGY-SYN-20IR-MANUAL-FINAL-2-13-2025) pc: description: |+ diff --git a/data/brands/tcelectronic/novadrive.yaml b/data/brands/tcelectronic/novadrive.yaml index 2ec2562e..62547123 100644 --- a/data/brands/tcelectronic/novadrive.yaml +++ b/data/brands/tcelectronic/novadrive.yaml @@ -16,91 +16,91 @@ pc: cc: - name: OD Off value: 21 - description: [Description of function] + description: '' type: Parameter min: 0 max: 63 - name: OD On value: 21 - description: [Description of function] + description: '' type: Parameter min: 64 max: 127 - name: Dist Off value: 22 - description: [Description of function] + description: '' type: Parameter min: 0 max: 63 - name: Dist On value: 22 - description: [Description of function] + description: '' type: Parameter min: 64 max: 127 - name: OD Drive value: 23 - description: [Description of function] + description: '' type: Parameter min: 0 max: 127 - name: OD Level value: 24 - description: [Description of function] + description: '' type: Parameter min: 0 max: 127 - name: OD Tone value: 25 - description: [Description of function] + description: '' type: Parameter min: 0 max: 127 - name: OD Mix value: 26 - description: [Description of function] + description: '' type: Parameter min: 0 max: 127 - name: Dist Drive value: 27 - description: [Description of function] + description: '' type: Parameter min: 0 max: 127 - name: Dist Level value: 28 - description: [Description of function] + description: '' type: Parameter min: 0 max: 127 - name: Dist Bass value: 29 - description: [Description of function] + description: '' type: Parameter min: 0 max: 127 - name: Dist Treble value: 30 - description: [Description of function] + description: '' type: Parameter min: 0 max: 127 - name: Routing OD -> Dist value: 31 - description: [Description of function] + description: '' type: Parameter min: 0 max: 42 - name: Routing Dist -> OD value: 31 - description: [Description of function] + description: '' type: Parameter min: 43 max: 84 - name: Routing Parallel value: 31 - description: [Description of function] + description: '' type: Parameter min: 85 max: 127 diff --git a/data/brands/tchelicon/voicelivetouch2.yaml b/data/brands/tchelicon/voicelivetouch2.yaml index 482085df..f2515514 100644 --- a/data/brands/tchelicon/voicelivetouch2.yaml +++ b/data/brands/tchelicon/voicelivetouch2.yaml @@ -54,7 +54,7 @@ cc: max: 0 min: 0 name: 'Rhythm: Off' - value: 1102 + value: 102 - description: '' max: 127 min: 127 diff --git a/data/mapping.json b/data/mapping.json index 89a9266b..76e0c44e 100644 --- a/data/mapping.json +++ b/data/mapping.json @@ -2072,18 +2072,12 @@ }, { "name": "OPFXS", - "value": "OPFXS", + "value": "opfxs", "models": [ { "name": "V Uno", "value": "v-uno" - } - ] - }, - { - "name": "OPFXS", - "value": "opfxs", - "models": [ + }, { "name": "Dig Deep MK3", "value": "digdeepmk3" diff --git a/validate_brand_yaml.py b/validate_brand_yaml.py new file mode 100644 index 00000000..8aab19cf --- /dev/null +++ b/validate_brand_yaml.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +"""Structural validator for data/brands/**/*.yaml used in CI.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import yaml + +BRANDS_DIR = Path("data/brands") +MAPPING_PATH = Path("data/mapping.json") + +REQUIRED_TOP_LEVEL = ("cc", "pc", "midi_channel") +ALLOWED_OPTIONAL_KEYS = { + "brand", + "device_name", + "model", + "url", + "email", + "midi_in", + "midi_thru", + "midi_clock", + "phantom_power", + "midi_mapping", + "instructions", + "device_type", + "midi_inputs", + "midi_outputs", + "brandName", + "modelName", + "data", # legacy alias for cc +} +KNOWN_TOP_LEVEL = set(REQUIRED_TOP_LEVEL) | ALLOWED_OPTIONAL_KEYS + + +class IssueCollector: + def __init__(self) -> None: + self.errors: list[str] = [] + self.warnings: list[str] = [] + + def error(self, path: str, message: str) -> None: + self.errors.append(f"{path}: {message}") + + def warn(self, path: str, message: str) -> None: + self.warnings.append(f"{path}: {message}") + + +def is_nullish(value: object) -> bool: + return value is None + + +def is_string_or_null(value: object) -> bool: + return value is None or isinstance(value, str) + + +def parse_midi_byte(value: object) -> int | None: + """Return int 0-127 if value is a MIDI byte; None if not a number.""" + if isinstance(value, bool): + return None + if isinstance(value, int): + return value if 0 <= value <= 127 else -1 + if isinstance(value, float) and value.is_integer(): + number = int(value) + return number if 0 <= number <= 127 else -1 + if isinstance(value, str): + stripped = value.strip() + if not stripped: + return None + try: + number = int(stripped, 10) + except ValueError: + return None + return number if 0 <= number <= 127 else -1 + return None + + +def validate_midi_number_field( + item_path: str, field: str, value: object, issues: IssueCollector +) -> None: + """Accept 0-127 ints/numeric strings; non-numeric strings warn only.""" + if is_nullish(value): + issues.error(item_path, f"'{field}' must not be null") + return + + parsed = parse_midi_byte(value) + if parsed is not None and parsed >= 0: + return + if parsed == -1: + issues.error( + item_path, + f"'{field}' must be in 0-127 when numeric, got {value!r}", + ) + return + if isinstance(value, str) and value.strip(): + issues.warn( + item_path, + f"'{field}' is non-numeric ({value!r}); expected 0-127 for strict MIDI CC values", + ) + return + issues.error( + item_path, + f"'{field}' must be an integer or numeric string in 0-127, got {value!r}", + ) + + +def validate_cc_item(path: str, index: int, item: object, issues: IssueCollector) -> None: + item_path = f"{path}:cc[{index}]" + if not isinstance(item, dict): + issues.error(item_path, "must be a mapping") + return + + name = item.get("name") + if "name" not in item: + issues.error(item_path, "missing required field 'name'") + elif not is_string_or_null(name): + issues.error(item_path, "'name' must be a string") + elif is_nullish(name) or (isinstance(name, str) and not name.strip()): + issues.error(item_path, "'name' must be a non-empty string") + + for field in ("value", "min", "max"): + if field not in item: + issues.error(item_path, f"missing required field '{field}'") + else: + validate_midi_number_field(item_path, field, item[field], issues) + + if "description" in item and not is_string_or_null(item["description"]): + issues.error(item_path, "'description' must be a string when present") + + if "type" in item and not is_string_or_null(item["type"]): + issues.error(item_path, "'type' must be a string when present") + + +def validate_port_list(path: str, key: str, value: object, issues: IssueCollector) -> None: + if not isinstance(value, list): + issues.error(path, f"'{key}' must be a list when present") + return + + for index, item in enumerate(value): + item_path = f"{path}:{key}[{index}]" + if not isinstance(item, dict): + issues.error(item_path, "must be a mapping") + continue + if "name" not in item or not isinstance(item["name"], str): + issues.error(item_path, "'name' must be a string") + if "type" not in item or not isinstance(item["type"], str): + issues.error(item_path, "'type' must be a string") + + +def validate_document(path: str, data: object, issues: IssueCollector) -> None: + if not isinstance(data, dict): + issues.error(path, "root must be a mapping") + return + + for key in data: + if key not in KNOWN_TOP_LEVEL: + issues.warn(path, f"unknown top-level key '{key}'") + + cc_key = "cc" + if "cc" not in data and "data" in data: + issues.warn(path, "using legacy 'data' key as alias for 'cc'") + cc_key = "data" + elif "cc" not in data: + issues.error(path, "missing required key 'cc'") + cc_key = None + + if cc_key is not None: + cc_list = data[cc_key] + if not isinstance(cc_list, list): + issues.error(path, f"'{cc_key}' must be a list") + elif len(cc_list) == 0: + issues.error(path, f"'{cc_key}' must be a non-empty list") + else: + for index, item in enumerate(cc_list): + validate_cc_item(path, index, item, issues) + + if "pc" not in data: + issues.error(path, "missing required key 'pc'") + else: + pc = data["pc"] + if not isinstance(pc, dict): + issues.error(path, "'pc' must be a mapping") + elif "description" not in pc: + issues.error(path, "'pc' must contain 'description'") + elif not is_string_or_null(pc["description"]): + issues.error(path, "'pc.description' must be a string") + + if "midi_channel" not in data: + issues.error(path, "missing required key 'midi_channel'") + else: + midi_channel = data["midi_channel"] + if not isinstance(midi_channel, dict): + issues.error(path, "'midi_channel' must be a mapping") + elif "instructions" not in midi_channel: + issues.error(path, "'midi_channel' must contain 'instructions'") + elif not is_string_or_null(midi_channel["instructions"]): + issues.error(path, "'midi_channel.instructions' must be a string") + + if "midi_inputs" in data: + validate_port_list(path, "midi_inputs", data["midi_inputs"], issues) + if "midi_outputs" in data: + validate_port_list(path, "midi_outputs", data["midi_outputs"], issues) + + +def load_yaml(path: Path) -> object: + with path.open("r", encoding="utf-8") as handle: + return yaml.safe_load(handle) + + +def mapped_yaml_paths(mapping: dict) -> set[str]: + paths: set[str] = set() + brands = mapping.get("brands") + if not isinstance(brands, list): + return paths + + for brand in brands: + if not isinstance(brand, dict): + continue + brand_value = brand.get("value") + models = brand.get("models") + if not isinstance(brand_value, str) or not isinstance(models, list): + continue + for model in models: + if not isinstance(model, dict): + continue + model_value = model.get("value") + if isinstance(model_value, str): + paths.add(f"data/brands/{brand_value}/{model_value}.yaml") + return paths + + +def discover_yaml_files() -> list[Path]: + if not BRANDS_DIR.is_dir(): + return [] + return sorted(BRANDS_DIR.glob("*/*.yaml")) + + +def is_brand_yaml_path(path: Path) -> bool: + try: + path.relative_to(BRANDS_DIR) + except ValueError: + return False + return path.suffix == ".yaml" and len(path.parts) >= 4 + + +def validate_yaml_file( + path_str: str, mapped: set[str], issues: IssueCollector +) -> None: + path = Path(path_str) + + if not path.is_file(): + issues.error(path_str, "file does not exist") + return + + if path_str not in mapped: + issues.error(path_str, "not listed in mapping.json") + + try: + data = load_yaml(path) + except yaml.YAMLError as exc: + issues.error(path_str, f"invalid YAML: {exc}") + return + except OSError as exc: + issues.error(path_str, f"could not read file: {exc}") + return + + if data is None: + issues.error(path_str, "file is empty") + return + + validate_document(path_str, data, issues) + + +def load_mapping(issues: IssueCollector) -> dict | None: + if not MAPPING_PATH.is_file(): + issues.error(str(MAPPING_PATH), "file not found") + return None + + try: + mapping = json.loads(MAPPING_PATH.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + issues.error(str(MAPPING_PATH), f"invalid JSON: {exc}") + return None + + if not isinstance(mapping, dict): + issues.error(str(MAPPING_PATH), "root must be a mapping") + return None + + return mapping + + +def validate_paths(paths: list[str], issues: IssueCollector) -> None: + """Validate specific brand YAML paths (PR / selective mode).""" + mapping = load_mapping(issues) + if mapping is None: + return + + mapped = mapped_yaml_paths(mapping) + + for raw in paths: + path = Path(raw) + path_str = str(path) + if not is_brand_yaml_path(path): + issues.error(path_str, "not a brand YAML under data/brands/") + continue + validate_yaml_file(path_str, mapped, issues) + + +def validate_all(issues: IssueCollector) -> None: + """Validate every brand YAML and full mapping consistency.""" + mapping = load_mapping(issues) + if mapping is None: + return + + mapped = mapped_yaml_paths(mapping) + on_disk = {str(path) for path in discover_yaml_files()} + + for path in sorted(mapped - on_disk): + issues.error(path, "listed in mapping.json but file does not exist") + + for path in sorted(on_disk - mapped): + issues.error(path, "YAML file exists but is not listed in mapping.json") + + for path_str in sorted(on_disk & mapped): + validate_yaml_file(path_str, mapped, issues) + + +def main(argv: list[str] | None = None) -> int: + args = sys.argv[1:] if argv is None else argv + issues = IssueCollector() + + if args: + print(f"Validating {len(args)} file(s)...") + validate_paths(args, issues) + else: + print("Validating all brand YAML files...") + validate_all(issues) + + print_report(issues) + return 1 if issues.errors else 0 + + +def print_report(issues: IssueCollector) -> None: + for warning in issues.warnings: + print(f"WARNING: {warning}") + for error in issues.errors: + print(f"ERROR: {error}") + + print( + f"\n{len(issues.errors)} error(s), {len(issues.warnings)} warning(s)" + ) + + +if __name__ == "__main__": + sys.exit(main())