diff --git a/main.py b/main.py index f193dfe..761b151 100644 --- a/main.py +++ b/main.py @@ -115,6 +115,24 @@ def program_change_cli(args): outport.send(msg) +def apply_state_to_synth(state, outport_name): + with mido.open_output(outport_name) as outport: + LOGGER.debug("Ready to use MIDI port %s", outport_name) + for msg in state.to_cc_messages(): + outport.send(msg) + # Sleeping a bit to avoid flooding the MIDI connection + time.sleep(0.001) + + +def send_patch_cli(args): + outport_name = args.midi_out + path = args.path + LOGGER.info("Applying PRM file %s", path) + + state = JU06AState.from_path(path) + apply_state_to_synth(state, outport_name) + + def main(): logging.basicConfig(level=logging.DEBUG, format="%(levelname)s:%(module)s.%(funcName)s: %(message)s") @@ -134,6 +152,11 @@ def main(): help="MIDI channel (default: 1)") pc_parser.set_defaults(func=program_change_cli) + send_patch_parser = subparsers.add_parser("send-patch", + help="Apply the given patch file to the synth through MIDI") + send_patch_parser.add_argument("path", type=str, help="Path to the PRM file") + send_patch_parser.set_defaults(func=send_patch_cli) + cc_parser = subparsers.add_parser("control-change", aliases=["cc"], help="Send a MIDI control change message") cc_parser.add_argument("cc_number", type=int, help="Control change number") diff --git a/src/text2synth/state.py b/src/text2synth/state.py index 9414691..3041764 100644 --- a/src/text2synth/state.py +++ b/src/text2synth/state.py @@ -3,8 +3,35 @@ from enum import IntEnum, verify, UNIQUE from typing import Self +import mido + from pydantic import BaseModel, ConfigDict, Field +from .synths import JU_A6_A + + +# List of fields that support a range of 0..255 at the synth level. This list +# was created automatically from the CLI by analyzing a bunch of real patches +DOUBLE_ATTRIBUTES = { + "amp_level", + "attack", + "cutoff", + "decay", + "env_mod", + "flt_key_follow", + "flt_lfo_mod", + "hpf", + "lfo_delay_time", + "lfo_rate", + "noise_level", + "osc_lfo_mod", + "pwm", + "release", + "resonance", + "sub_level", + "sustain", +} + @verify(UNIQUE) class OscRange(IntEnum): @@ -319,3 +346,28 @@ def attribute_to_patch_key(self, attribute): """ Convert the given attribute name into the key used in .PRN files. """ return attribute.replace("_", " ").upper() + + def to_cc_messages(self): + """ Create a list of MIDI messages that when applied to the synth, will + update the synth to the current state. + """ + messages = [] + for data in JU_A6_A.values(): + cc = data["cc"] + + patch_attribute = data["patch_attribute"] + value = getattr(self, patch_attribute) + if patch_attribute in DOUBLE_ATTRIBUTES: + cc_value = value // 2 + elif isinstance(value, IntEnum): + if isinstance(value, PortamentoSwitch): + # cc value [0, 63[ -> off, [63-128[ -> on + cc_value = 63 * value + else: + cc_value = value + else: + cc_value = value + + messages.append(mido.Message("control_change", control=cc, value=cc_value)) + + return messages