Source code for jdxi_editor.midi.sysex.json_composer

"""
JDXiSysExComposer
"""

import json
import os
from pathlib import Path
from typing import Any, Optional, Union

from decologr import Decologr as log

from jdxi_editor.midi.data.address.address import (
    AddressOffsetTemporaryToneUMB,
    RolandSysExAddress,
)
from jdxi_editor.midi.data.parameter.drum.partial import DrumPartialParam
from jdxi_editor.project import __package_name__
from jdxi_editor.ui.editors import SynthEditor
from jdxi_editor.ui.windows.midi.debugger import parse_sysex_byte


[docs] class JDXiJSONComposer: """JSON SysExComposer""" def __init__(self, editor: Optional[SynthEditor] = None):
[docs] self.json_string = None
if editor: self.editor = editor self.address = editor.address
[docs] self.temp_folder = Path.home() / f".{__package_name__}" / "temp"
if not os.path.exists(self.temp_folder): self.temp_folder.mkdir(parents=True, exist_ok=True)
[docs] def compose_message( self, editor: SynthEditor, ) -> Optional[dict[Union[str, Any], Union[str, Any]]]: """ :param editor: SynthEditor Editor instance to process :return: str JSON SysEx message """ if editor: self.editor = editor self.address = editor.address try: editor_data = {"JD_XI_HEADER": "f041100000000e"} if not hasattr(editor, "address"): log.warning(f"Skipping invalid editor: {editor}, has no address") return None if not hasattr(editor, "get_controls_as_dict"): log.warning( f"Skipping invalid editor: {editor}, has no get_controls_as_dict method" ) return None # Convert address to hex string without spaces address_hex = "".join([f"{x:02x}" for x in editor.address.to_bytes()]) synth_tone_byte = address_hex[4:6] editor_data["ADDRESS"] = address_hex editor_data["TEMPORARY_AREA"] = parse_sysex_byte( editor.address.umb, AddressOffsetTemporaryToneUMB ) synth_tone_map = { "20": "PARTIAL_1", "21": "PARTIAL_2", "22": "PARTIAL_3", } editor_data["SYNTH_TONE"] = synth_tone_map.get( synth_tone_byte, "UNKNOWN_SYNTH_TONE" ) # editor_data["SYNTH_TONE"] = synth_tone_map.get(synth_tone_byte, "COMMON") # Get the raw control values instead of the full control data other_data = editor.get_controls_as_dict() for k, v in other_data.items(): # If the value is a list/array, take just the first value (the actual control value) if isinstance(v, (list, tuple)) and len(v) > 0: editor_data[k] = v[0] else: editor_data[k] = v # Add tone name parameters if available if hasattr(editor, "tone_names") and hasattr(editor, "preset_type"): tone_name = editor.tone_names.get(editor.preset_type, "") if tone_name: # Convert tone name string to TONE_NAME_1 through TONE_NAME_12 # Pad to 12 characters and truncate if longer tone_name_padded = tone_name.ljust(12)[:12] for i, char in enumerate(tone_name_padded, start=1): ascii_value = ord(char) editor_data[f"TONE_NAME_{i}"] = ascii_value log.message(f"Including tone name in JSON: '{tone_name}'") # Convert combined_data to JSON string self.json_string = editor_data # json.dumps(editor_data, indent=4) return self.json_string except (ValueError, TypeError, OSError, IOError) as ex: log.error(f"Error sending message: {ex}") return None
[docs] def save_json(self, file_path: str) -> None: """ Save the JSON string to a file :param file_path: str File path to save the JSON :return: None """ try: with open(file_path, "w", encoding="utf-8") as file_handle: json.dump(self.json_string, file_handle, ensure_ascii=False, indent=2) log.message(f"JSON saved successfully to {file_path}") except Exception as ex: log.error(f"Error saving JSON: {ex}")
[docs] def process_editor(self, editor: SynthEditor, temp_folder: Path) -> Path: """ Process the editor and save the JSON :param editor: SynthEditor Editor instance to process :param temp_folder: str Temporary folder to save the JSON :return: None """ if temp_folder: self.temp_folder = temp_folder os.makedirs(temp_folder, exist_ok=True) # Special handling for DigitalSynthEditor: save Common and Modify separately from picomidi.constant import Midi from jdxi_editor.midi.data.address.address import ( AddressOffsetProgramLMB, AddressOffsetSuperNATURALLMB, AddressStartMSB, RolandSysExAddress, ) from jdxi_editor.midi.data.parameter.digital import ( DigitalCommonParam, DigitalModifyParam, ) from jdxi_editor.midi.data.parameter.drum.common import ( DrumCommonParam, ) from jdxi_editor.ui.editors.digital.editor import DigitalSynthEditor from jdxi_editor.ui.editors.drum.editor import DrumCommonEditor if isinstance(editor, DigitalSynthEditor): # Separate Common and Modify controls common_controls = {} modify_controls = {} other_controls = {} controls_dict = editor.get_controls_as_dict() for param, widget in editor.controls.items(): param_name = param.name value = controls_dict.get(param_name) # Check if this is a Common parameter if isinstance(param, DigitalCommonParam): common_controls[param_name] = value # Check if this is a Modify parameter elif isinstance(param, DigitalModifyParam): modify_controls[param_name] = value else: other_controls[param_name] = value # Save Common section with Common address if common_controls: # For Common, we need: MSB=0x19, UMB=editor.address.umb, LMB=0x00 (COMMON), LSB=0x00 # The editor.address.umb is already correct (0x01 for DS1, 0x21 for DS2) # We just need to ensure LMB is set to COMMON (0x00) from picomidi.constant import Midi common_address = RolandSysExAddress( msb=editor.address.msb, # 0x19 (TEMPORARY_TONE) umb=editor.address.umb, # 0x01 for DS1, 0x21 for DS2 (includes SuperNATURAL offset) lmb=AddressOffsetSuperNATURALLMB.COMMON.STATUS, # 0x00 (COMMON) lsb=Midi.VALUE.ZERO, # 0x00 ) self._save_editor_section( editor, common_controls, common_address, temp_folder, "COMMON" ) # Save Modify section with Modify address (current editor address) if modify_controls or other_controls: # Combine modify and other controls all_modify_controls = {**modify_controls, **other_controls} self._save_editor_section( editor, all_modify_controls, editor.address, temp_folder, "MODIFY" ) # Return the Common file path if it exists, otherwise Modify if common_controls: common_address_hex = "".join( [f"{x:02x}" for x in common_address.to_bytes()] ) return temp_folder / f"jdxi_tone_data_{common_address_hex}.json" else: address_hex = "".join([f"{x:02x}" for x in editor.address.to_bytes()]) return temp_folder / f"jdxi_tone_data_{address_hex}.json" elif isinstance(editor, DrumCommonEditor): # Special handling for DrumCommonEditor: save Common and Partial separately # Separate Common and Partial controls common_controls = {} partial_controls = {} controls_dict = editor.get_controls_as_dict() log.message( f"DrumCommonEditor: Found {len(editor.controls)} controls in editor" ) log.message( f"DrumCommonEditor: controls_dict has {len(controls_dict)} entries" ) # First, check if KIT_LEVEL is in controls_dict (from get_controls_as_dict) if "KIT_LEVEL" in controls_dict: log.message( f"DrumCommonEditor: KIT_LEVEL found in controls_dict = {controls_dict['KIT_LEVEL']}" ) else: log.warning("DrumCommonEditor: KIT_LEVEL NOT found in controls_dict") log.message( f"DrumCommonEditor: Available keys in controls_dict: {list(controls_dict.keys())[:10]}..." ) for param, widget in editor.controls.items(): param_name = param.name # Get value directly from widget to ensure we have the current value # This is more reliable than using controls_dict which might have stale values widget_value = None try: if hasattr(widget, "value"): # Custom Slider widget has value() method that returns slider.value() widget_value = widget.value() elif hasattr(widget, "slider"): # Direct QSlider access (fallback) widget_value = widget.slider.STATUS() else: # Fallback to controls_dict widget_value = controls_dict.get(param_name) # Ensure we have a valid integer value if widget_value is None: widget_value = controls_dict.get(param_name) value = int(widget_value) if widget_value is not None else 0 except Exception as ex: log.warning( f"DrumCommonEditor: Error getting value for {param_name}: {ex}" ) value = controls_dict.get(param_name, 0) log.message( f"DrumCommonEditor: Parameter {param_name}: widget.value()={widget_value}, controls_dict={controls_dict.get(param_name)}, final={value}" ) # Check if this is a Common parameter # Use isinstance to check if param is an instance of the enum class if isinstance(param, DrumCommonParam): # Special check for KIT_LEVEL - warn if it's 0 as it might indicate the slider wasn't updated if param_name == "KIT_LEVEL" and value == 0: log.warning( f"DrumCommonEditor: KIT_LEVEL is 0 - this might indicate the slider wasn't updated from the synth" ) log.message( f"DrumCommonEditor: Widget value: {widget_value}, controls_dict value: {controls_dict.get(param_name)}" ) common_controls[param_name] = value log.message( f"DrumCommonEditor: Added Common parameter {param_name} = {value}" ) # Check if this is a Partial parameter elif isinstance(param, DrumPartialParam): partial_controls[param_name] = value log.message( f"DrumCommonEditor: Added Partial parameter {param_name} = {value}" ) else: log.warning( f"DrumCommonEditor: Unknown parameter type for {param_name}: {type(param)}" ) log.message( f"DrumCommonEditor: Separated {len(common_controls)} Common and {len(partial_controls)} Partial controls" ) # If no common controls found, try alternative identification methods if not common_controls: log.warning( "DrumCommonEditor: No Common controls found via isinstance check" ) log.message( "DrumCommonEditor: Trying alternative method to identify Common parameters..." ) # Get all Common parameter names from the enum common_param_names = {param.name for param in DrumCommonParam} log.message( f"DrumCommonEditor: Known Common parameter names: {common_param_names}" ) # Check each control by name for param, widget in editor.controls.items(): param_name = param.name value = controls_dict.get(param_name) # Check if parameter name is in Common enum if param_name in common_param_names: common_controls[param_name] = value log.message( f"DrumCommonEditor: Added Common parameter by name: {param_name} = {value}" ) # Also check if it's a known Common parameter elif param_name == "KIT_LEVEL": common_controls[param_name] = value log.message( f"DrumCommonEditor: Force-added KIT_LEVEL by name = {value}" ) if common_controls: log.message( f"DrumCommonEditor: Found {len(common_controls)} Common controls via name matching" ) else: log.error( "DrumCommonEditor: Still no Common controls found after name matching!" ) # Last resort: if KIT_LEVEL is in controls_dict, add it anyway if "KIT_LEVEL" in controls_dict: common_controls["KIT_LEVEL"] = controls_dict["KIT_LEVEL"] log.message( f"DrumCommonEditor: Force-added KIT_LEVEL from controls_dict = {common_controls['KIT_LEVEL']}" ) # Save Common section with Common address (19700000) # Always try to save Common if we have any Common controls or KIT_LEVEL if common_controls or "KIT_LEVEL" in controls_dict: # For Drum Common: MSB=0x19, UMB=0x70, LMB=0x00 (COMMON), LSB=0x00 common_address = RolandSysExAddress( msb=editor.address.msb, # 0x19 (TEMPORARY_TONE) umb=editor.address.umb, # 0x70 (DRUM_KIT) lmb=AddressOffsetProgramLMB.COMMON.value, # 0x00 (COMMON) lsb=Midi.VALUE.ZERO, # 0x00 ) # Ensure KIT_LEVEL is included if it exists if "KIT_LEVEL" in controls_dict and "KIT_LEVEL" not in common_controls: common_controls["KIT_LEVEL"] = controls_dict["KIT_LEVEL"] log.message( f"DrumCommonEditor: Added KIT_LEVEL to common_controls = {common_controls['KIT_LEVEL']}" ) if common_controls: self._save_editor_section( editor, common_controls, common_address, temp_folder, "COMMON" ) log.message( f"DrumCommonEditor: Saved Common section with {len(common_controls)} parameters to address {common_address.to_bytes()}" ) else: log.warning( "DrumCommonEditor: common_controls is empty, skipping Common section save" ) # Save Partial sections - each partial has its own address # Note: Partial controls are typically saved by individual partial editors, # but if there are any in the main editor, we'll save them with the editor's address if partial_controls: # Use the editor's current address for partials (which may be a partial address) self._save_editor_section( editor, partial_controls, editor.address, temp_folder, "PARTIAL" ) # Return the Common file path if it exists, otherwise the first partial if common_controls: common_address_hex = "".join( [f"{x:02x}" for x in common_address.to_bytes()] ) return temp_folder / f"jdxi_tone_data_{common_address_hex}.json" else: address_hex = "".join([f"{x:02x}" for x in editor.address.to_bytes()]) return temp_folder / f"jdxi_tone_data_{address_hex}.json" else: # Standard processing for other editors self.compose_message(editor) address_hex = "".join([f"{x:02x}" for x in editor.address.to_bytes()]) json_temp_file = self.temp_folder / f"jdxi_tone_data_{address_hex}.json" self.save_json(str(json_temp_file)) log.message(f"JSON saved successfully to {json_temp_file}") return json_temp_file
[docs] def _save_editor_section( self, editor: SynthEditor, controls_dict: dict, address: RolandSysExAddress, temp_folder: Path, section_name: str, ) -> Path: """ Save a specific section (Common or Modify) of an editor with a given address. :param editor: SynthEditor Editor instance :param controls_dict: dict Dictionary of parameter names to values :param address: RolandSysExAddress Address to use for this section :param temp_folder: Path Temporary folder to save the JSON :param section_name: str Name of the section (e.g., "COMMON", "MODIFY") :return: Path Path to the saved JSON file """ try: editor_data = {"JD_XI_HEADER": "f041100000000e"} # Convert address to hex string address_hex = "".join([f"{x:02x}" for x in address.to_bytes()]) editor_data["ADDRESS"] = address_hex # Determine TEMPORARY_AREA and SYNTH_TONE from jdxi_editor.midi.data.address.address import ( AddressOffsetTemporaryToneUMB, ) from jdxi_editor.ui.windows.midi.debugger import parse_sysex_byte editor_data["TEMPORARY_AREA"] = parse_sysex_byte( address.umb, AddressOffsetTemporaryToneUMB ) # Determine SYNTH_TONE based on LMB synth_tone_byte = address_hex[4:6] synth_tone_map = { "00": "COMMON", "20": "PARTIAL_1", "21": "PARTIAL_2", "22": "PARTIAL_3", "50": "MODIFY", } editor_data["SYNTH_TONE"] = synth_tone_map.get( synth_tone_byte, "UNKNOWN_SYNTH_TONE" ) # Add control values for k, v in controls_dict.items(): # If the value is a list/array, take just the first value if isinstance(v, (list, tuple)) and len(v) > 0: editor_data[k] = v[0] else: editor_data[k] = v # Add tone name parameters if available if hasattr(editor, "tone_names") and hasattr(editor, "preset_type"): tone_name = editor.tone_names.get(editor.preset_type, "") if tone_name: # Convert tone name string to TONE_NAME_1 through TONE_NAME_12 tone_name_padded = tone_name.ljust(12)[:12] for i, char in enumerate(tone_name_padded, start=1): ascii_value = ord(char) editor_data[f"TONE_NAME_{i}"] = ascii_value log.message(f"Including tone name in JSON: '{tone_name}'") # Save JSON file json_temp_file = temp_folder / f"jdxi_tone_data_{address_hex}.json" with open(json_temp_file, "w", encoding="utf-8") as file_handle: json.dump(editor_data, file_handle, ensure_ascii=False, indent=2) log.message( f"JSON saved successfully for {section_name} section: {json_temp_file}" ) return json_temp_file except Exception as ex: log.error(f"Error saving {section_name} section: {ex}") raise